From 70ee7848187ca5b2dc63eb286a744719222852ee Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 20 Jun 2023 17:06:20 -0500
Subject: [PATCH 001/542] Include user "active" flag in profile view context

whoops, missed that one..
---
 tailbone/views/people.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/tailbone/views/people.py b/tailbone/views/people.py
index 29b93b9a..8dc96037 100644
--- a/tailbone/views/people.py
+++ b/tailbone/views/people.py
@@ -811,6 +811,7 @@ class PersonView(MasterView):
             'username': user.username,
             'display_name': user.display_name,
             'email_address': app.get_contact_email_address(user),
+            'active': user.active,
             'view_url': self.request.route_url('users.view', uuid=user.uuid),
         }
 

From 8cc6def93ea2b990a7df0361ac73da71b998231c Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 20 Jun 2023 17:06:54 -0500
Subject: [PATCH 002/542] Update changelog

---
 CHANGES.rst          | 6 ++++++
 tailbone/_version.py | 2 +-
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 1f64e4ea..6225b749 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,12 @@
 CHANGELOG
 =========
 
+0.9.36 (2023-06-20)
+-------------------
+
+* Include user "active" flag in profile view context.
+
+
 0.9.35 (2023-06-20)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 89a33ad0..d43dbe86 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.35'
+__version__ = '0.9.36'

From 08a75f6e9f38d3103d9e1ffbf01989953f93bab0 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 27 Jun 2023 12:37:00 -0500
Subject: [PATCH 003/542] Avoid deprecated product key field getter

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

diff --git a/tailbone/views/products.py b/tailbone/views/products.py
index 8988538b..1cfa528a 100644
--- a/tailbone/views/products.py
+++ b/tailbone/views/products.py
@@ -238,8 +238,7 @@ class ProductView(MasterView):
                                ProductCostCodeAny.product_uuid == model.Product.uuid)
 
         # product key
-        key = self.rattail_config.product_key()
-        field = self.product_key_fields.get(key, key)
+        field = self.get_product_key_field()
         g.filters[field].default_active = True
         g.filters[field].default_verb = 'equal'
         g.set_sort_defaults(field)
@@ -1253,8 +1252,7 @@ class ProductView(MasterView):
         }
 
     def get_panel_fields_main(self, product):
-        key = self.rattail_config.product_key()
-        product_key_field = self.product_key_fields.get(key, key)
+        product_key_field = self.get_product_key_field()
         fields = [
             product_key_field,
             'brand',

From 1be26b7f33bc11d1446771044bd3a1f07005bbdf Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 27 Jun 2023 12:37:16 -0500
Subject: [PATCH 004/542] Allow "arbitrary" PO attachment to purchase batch

for sake of other POS integration etc.
---
 tailbone/views/purchasing/batch.py     | 142 +++++++++----------------
 tailbone/views/purchasing/costing.py   |   6 +-
 tailbone/views/purchasing/ordering.py  |   1 -
 tailbone/views/purchasing/receiving.py |  36 ++++---
 4 files changed, 74 insertions(+), 111 deletions(-)

diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py
index 16153f64..8960a522 100644
--- a/tailbone/views/purchasing/batch.py
+++ b/tailbone/views/purchasing/batch.py
@@ -24,7 +24,7 @@
 Base class for purchasing batch views
 """
 
-from rattail.db import model, api
+from rattail.db.model import PurchaseBatch, PurchaseBatchRow
 
 import colander
 from deform import widget as dfwidget
@@ -40,8 +40,8 @@ class PurchasingBatchView(BatchMasterView):
     Master view base class, for purchase batches.  The views for both
     "ordering" and "receiving" batches will inherit from this.
     """
-    model_class = model.PurchaseBatch
-    model_row_class = model.PurchaseBatchRow
+    model_class = PurchaseBatch
+    model_row_class = PurchaseBatchRow
     default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
     supports_new_product = False
     cloneable = True
@@ -160,11 +160,13 @@ class PurchasingBatchView(BatchMasterView):
         raise NotImplementedError("Please define `batch_mode` for your purchasing batch view")
 
     def query(self, session):
+        model = self.model
         return session.query(model.PurchaseBatch)\
                       .filter(model.PurchaseBatch.mode == self.batch_mode)
 
     def configure_grid(self, g):
-        super(PurchasingBatchView, self).configure_grid(g)
+        super().configure_grid(g)
+        model = self.model
 
         g.joiners['vendor'] = lambda q: q.join(model.Vendor)
         g.filters['vendor'] = g.make_filter('vendor', model.Vendor.name,
@@ -309,7 +311,7 @@ class PurchasingBatchView(BatchMasterView):
                         if buyer:
                             buyer_display = str(buyer)
                 elif self.creating:
-                    buyer = self.request.user.employee
+                    buyer = app.get_employee(self.request.user)
                     if buyer:
                         buyer_display = str(buyer)
                         f.set_default('buyer_uuid', buyer.uuid)
@@ -405,12 +407,30 @@ class PurchasingBatchView(BatchMasterView):
         return tags.link_to(text, url)
 
     def render_purchase(self, batch, field):
-        purchase = batch.purchase
+        model = self.model
+
+        # default logic can only render the "normal" (built-in)
+        # purchase field; anything else must be handled by view
+        # supplement if possible
+        if field != 'purchase':
+            for supp in self.iter_view_supplements():
+                renderer = getattr(supp, f'render_purchase_{field}', None)
+                if renderer:
+                    return renderer(batch)
+
+        # nothing to render if no purchase found
+        purchase = getattr(batch, field)
         if not purchase:
-            return ""
+            return
+
+        # render link to native purchase, if possible
         text = str(purchase)
-        url = self.request.route_url('purchases.view', uuid=purchase.uuid)
-        return tags.link_to(text, url)
+        if isinstance(purchase, model.Purchase):
+            url = self.request.route_url('purchases.view', uuid=purchase.uuid)
+            return tags.link_to(text, url)
+
+        # otherwise just render purchase as-is
+        return text
 
     def render_vendor_email(self, batch, field):
         if batch.vendor.email:
@@ -448,12 +468,14 @@ class PurchasingBatchView(BatchMasterView):
         return text
 
     def get_store_values(self):
+        model = self.model
         stores = self.Session.query(model.Store)\
                              .order_by(model.Store.id)
         return [(s.uuid, "({}) {}".format(s.id, s.name))
                 for s in stores]
 
     def get_vendors(self):
+        model = self.model
         return self.Session.query(model.Vendor)\
                            .order_by(model.Vendor.name)
 
@@ -463,6 +485,7 @@ class PurchasingBatchView(BatchMasterView):
                 for v in vendors]
 
     def get_buyers(self):
+        model = self.model
         return self.Session.query(model.Employee)\
                            .join(model.Person)\
                            .filter(model.Employee.status == self.enum.EMPLOYEE_STATUS_CURRENT)\
@@ -474,6 +497,7 @@ class PurchasingBatchView(BatchMasterView):
                 for b in buyers]
 
     def get_department_options(self):
+        model = self.model
         departments = self.Session.query(model.Department).order_by(model.Department.number)
         return [('{} {}'.format(d.number, d.name), d.uuid) for d in departments]
 
@@ -487,39 +511,10 @@ class PurchasingBatchView(BatchMasterView):
             if phone.type == 'Fax':
                 return phone.number
 
-    def eligible_purchases(self, vendor_uuid=None, mode=None):
-        if not vendor_uuid:
-            vendor_uuid = self.request.GET.get('vendor_uuid')
-        vendor = self.Session.get(model.Vendor, vendor_uuid) if vendor_uuid else None
-        if not vendor:
-            return {'error': "Must specify a vendor."}
-
-        if mode is None:
-            mode = self.request.GET.get('mode')
-            mode = int(mode) if mode and mode.isdigit() else None
-        if not mode or mode not in self.enum.PURCHASE_BATCH_MODE:
-            return {'error': "Unknown mode: {}".format(mode)}
-
-        purchases = self.handler.get_eligible_purchases(vendor, mode)
-        return self.get_eligible_purchases_data(purchases)
-
-    def get_eligible_purchases_data(self, purchases):
-        return {'purchases': [{'key': p.uuid,
-                               'department_uuid': p.department_uuid or '',
-                               'display': self.render_eligible_purchase(p)}
-                              for p in purchases]}
-
-    def render_eligible_purchase(self, purchase):
-        if purchase.status == self.enum.PURCHASE_STATUS_ORDERED:
-            date = purchase.date_ordered
-            total = purchase.po_total
-        elif purchase.status == self.enum.PURCHASE_STATUS_RECEIVED:
-            date = purchase.date_received
-            total = purchase.invoice_total
-        return '{} for ${:0,.2f} ({})'.format(date, total or 0, purchase.department or purchase.buyer)
-
     def get_batch_kwargs(self, batch, **kwargs):
-        kwargs = super(PurchasingBatchView, self).get_batch_kwargs(batch, **kwargs)
+        kwargs = super().get_batch_kwargs(batch, **kwargs)
+        model = self.model
+
         kwargs['mode'] = self.batch_mode
         kwargs['truck_dump'] = batch.truck_dump
         kwargs['invoice_parser_key'] = batch.invoice_parser_key
@@ -565,16 +560,20 @@ class PurchasingBatchView(BatchMasterView):
 
         if self.batch_mode in (self.enum.PURCHASE_BATCH_MODE_RECEIVING,
                                self.enum.PURCHASE_BATCH_MODE_COSTING):
-            purchase = batch.purchase
-            if not purchase and batch.purchase_uuid:
-                purchase = self.Session.get(model.Purchase, batch.purchase_uuid)
-                assert purchase
-            if purchase:
-                kwargs['purchase'] = purchase
-                kwargs['buyer'] = purchase.buyer
-                kwargs['buyer_uuid'] = purchase.buyer_uuid
-                kwargs['date_ordered'] = purchase.date_ordered
-                kwargs['po_total'] = purchase.po_total
+            field = self.batch_handler.get_purchase_order_fieldname()
+            if field == 'purchase':
+                purchase = batch.purchase
+                if not purchase and batch.purchase_uuid:
+                    purchase = self.Session.get(model.Purchase, batch.purchase_uuid)
+                    assert purchase
+                if purchase:
+                    kwargs['purchase'] = purchase
+                    kwargs['buyer'] = purchase.buyer
+                    kwargs['buyer_uuid'] = purchase.buyer_uuid
+                    kwargs['date_ordered'] = purchase.date_ordered
+                    kwargs['po_total'] = purchase.po_total
+            elif hasattr(batch, field):
+                kwargs[field] = getattr(batch, field)
 
         return kwargs
 
@@ -826,25 +825,6 @@ class PurchasingBatchView(BatchMasterView):
         return HTML.literal(
             g.render_buefy_table_element(data_prop='rowData.credits'))
 
-#     def item_lookup(self, value, field=None):
-#         """
-#         Try to locate a single product using ``value`` as a lookup code.
-#         """
-#         batch = self.get_instance()
-#         product = api.get_product_by_vendor_code(Session(), value, vendor=batch.vendor)
-#         if product:
-#             return product.uuid
-#         if value.isdigit():
-#             product = api.get_product_by_upc(Session(), GPC(value))
-#             if not product:
-#                 product = api.get_product_by_upc(Session(), GPC(value, calc_check_digit='upc'))
-#             if product:
-#                 if not product.cost_for_vendor(batch.vendor):
-#                     raise fa.ValidationError("Product {} exists but has no cost for vendor {}".format(
-#                         product.upc.pretty(), batch.vendor))
-#                 return product.uuid
-#         raise fa.ValidationError("Product not found")
-
 #     def before_create_row(self, form):
 #         row = form.fieldset.model
 #         batch = self.get_instance()
@@ -937,28 +917,6 @@ class PurchasingBatchView(BatchMasterView):
 #         return self.get_action_url('view', batch)
 
 
-    @classmethod
-    def _purchasing_defaults(cls, config):
-        rattail_config = config.registry.settings.get('rattail_config')
-        route_prefix = cls.get_route_prefix()
-        url_prefix = cls.get_url_prefix()
-        permission_prefix = cls.get_permission_prefix()
-        model_key = cls.get_model_key()
-        model_title = cls.get_model_title()
-
-        # eligible purchases (AJAX)
-        config.add_route('{}.eligible_purchases'.format(route_prefix), '{}/eligible-purchases'.format(url_prefix))
-        config.add_view(cls, attr='eligible_purchases', route_name='{}.eligible_purchases'.format(route_prefix),
-                        renderer='json', permission='{}.view'.format(permission_prefix))
-
-
-    @classmethod
-    def defaults(cls, config):
-        cls._purchasing_defaults(config)
-        cls._batch_defaults(config)
-        cls._defaults(config)
-
-
 class NewProduct(colander.Schema):
 
     item_id = colander.SchemaNode(colander.String())
diff --git a/tailbone/views/purchasing/costing.py b/tailbone/views/purchasing/costing.py
index 294b29ef..ec4e3ee3 100644
--- a/tailbone/views/purchasing/costing.py
+++ b/tailbone/views/purchasing/costing.py
@@ -43,8 +43,6 @@ class CostingBatchView(PurchasingBatchView):
     downloadable = True
     bulk_deletable = True
 
-    purchase_order_fieldname = 'purchase'
-
     labels = {
         'invoice_parser_key': "Invoice Parser",
     }
@@ -290,8 +288,9 @@ class CostingBatchView(PurchasingBatchView):
             f.remove_field('batch_type')
 
         # purchase
+        field = self.batch_handler.get_purchase_order_fieldname()
         if (self.creating and workflow == 'invoice_with_po'
-            and self.purchase_order_fieldname == 'purchase'):
+            and field == 'purchase'):
             f.replace('purchase', 'purchase_uuid')
             purchases = self.handler.get_eligible_purchases(
                 vendor, self.enum.PURCHASE_BATCH_MODE_COSTING)
@@ -317,7 +316,6 @@ class CostingBatchView(PurchasingBatchView):
     @classmethod
     def defaults(cls, config):
         cls._costing_defaults(config)
-        cls._purchasing_defaults(config)
         cls._batch_defaults(config)
         cls._defaults(config)
 
diff --git a/tailbone/views/purchasing/ordering.py b/tailbone/views/purchasing/ordering.py
index b0b00402..03308d07 100644
--- a/tailbone/views/purchasing/ordering.py
+++ b/tailbone/views/purchasing/ordering.py
@@ -486,7 +486,6 @@ class OrderingBatchView(PurchasingBatchView):
     @classmethod
     def defaults(cls, config):
         cls._ordering_defaults(config)
-        cls._purchasing_defaults(config)
         cls._batch_defaults(config)
         cls._defaults(config)
 
diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py
index cdc69fe5..e659123a 100644
--- a/tailbone/views/purchasing/receiving.py
+++ b/tailbone/views/purchasing/receiving.py
@@ -87,8 +87,6 @@ class ReceivingBatchView(PurchasingBatchView):
 
     default_uom_is_case = True
 
-    purchase_order_fieldname = 'purchase'
-
     labels = {
         'truck_dump_batch': "Truck Dump Parent",
         'invoice_parser_key': "Invoice Parser",
@@ -390,7 +388,7 @@ class ReceivingBatchView(PurchasingBatchView):
         return title
 
     def configure_form(self, f):
-        super(ReceivingBatchView, self).configure_form(f)
+        super().configure_form(f)
         model = self.model
         batch = f.model_instance
         allow_truck_dump = self.batch_handler.allow_truck_dump_receiving()
@@ -498,18 +496,28 @@ class ReceivingBatchView(PurchasingBatchView):
             f.set_widget('store_uuid', dfwidget.HiddenWidget())
 
         # purchase
-        if (self.creating and workflow in ('from_po', 'from_po_with_invoice')
-            and self.purchase_order_fieldname == 'purchase'):
-            f.replace('purchase', 'purchase_uuid')
+        field = self.batch_handler.get_purchase_order_fieldname()
+        if field == 'purchase':
+            field = 'purchase_uuid'
+        # TODO: workflow "invoice_with_po" is for costing mode, should rename?
+        if self.creating and workflow in (
+                'from_po', 'from_po_with_invoice', 'invoice_with_po'):
+            f.replace('purchase', field)
             purchases = self.batch_handler.get_eligible_purchases(
-                vendor, self.enum.PURCHASE_BATCH_MODE_RECEIVING)
-            values = [(p.uuid, self.batch_handler.render_eligible_purchase(p))
+                vendor, self.batch_mode)
+            values = [(self.batch_handler.get_eligible_purchase_key(p),
+                       self.batch_handler.render_eligible_purchase(p))
                       for p in purchases]
-            f.set_widget('purchase_uuid', dfwidget.SelectWidget(values=values))
-            f.set_label('purchase_uuid', "Purchase Order")
-            f.set_required('purchase_uuid')
-        elif self.creating or not batch.purchase:
+            f.set_widget(field, dfwidget.SelectWidget(values=values))
+            if field == 'purchase_uuid':
+                f.set_label(field, "Purchase Order")
+            f.set_required(field)
+        elif self.creating:
             f.remove_field('purchase')
+        else: # not creating
+            if field != 'purchase_uuid':
+                f.replace('purchase', field)
+            f.set_renderer(field, self.render_purchase)
 
         # department
         if self.creating:
@@ -939,8 +947,9 @@ class ReceivingBatchView(PurchasingBatchView):
         Assign the original purchase order to the given batch.  Default
         behavior assumes a Rattail Purchase object is what we're after.
         """
+        field = self.batch_handler.get_purchase_order_fieldname()
         purchase = self.handler.assign_purchase_order(
-            batch, po_form.validated[self.purchase_order_fieldname],
+            batch, po_form.validated[field],
             session=self.Session())
 
         department = self.department_for_purchase(purchase)
@@ -1992,7 +2001,6 @@ class ReceivingBatchView(PurchasingBatchView):
     @classmethod
     def defaults(cls, config):
         cls._receiving_defaults(config)
-        cls._purchasing_defaults(config)
         cls._batch_defaults(config)
         cls._defaults(config)
 

From 8742a03e18c80463b489b3f1f8f8bd22a0333493 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 3 Jul 2023 09:52:42 -0500
Subject: [PATCH 005/542] Update changelog

---
 CHANGES.rst          | 8 ++++++++
 tailbone/_version.py | 2 +-
 2 files changed, 9 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 6225b749..d71eac97 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,14 @@
 CHANGELOG
 =========
 
+0.9.37 (2023-07-03)
+-------------------
+
+* Avoid deprecated product key field getter.
+
+* Allow "arbitrary" PO attachment to purchase batch.
+
+
 0.9.36 (2023-06-20)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index d43dbe86..d57f5f68 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.36'
+__version__ = '0.9.37'

From 58f9b3ce2a5f560ca567d0860c2417751c77f8cf Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 6 Jul 2023 21:23:44 -0500
Subject: [PATCH 006/542] Optimize "auto-receive" batch process

disable versioning when doing "auto-receive" for a receiving batch
---
 tailbone/views/batch/core.py           |  8 ++-
 tailbone/views/purchasing/receiving.py | 85 +++++++-------------------
 2 files changed, 29 insertions(+), 64 deletions(-)

diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py
index e2eeeda4..1f5e2be9 100644
--- a/tailbone/views/batch/core.py
+++ b/tailbone/views/batch/core.py
@@ -931,7 +931,7 @@ class BatchMasterView(MasterView):
         prefix = self.rattail_config.get('rattail', 'command_prefix',
                                          default=sys.prefix)
         cmd = [os.path.join(prefix, 'bin/{}'.format(command))]
-        for path in self.rattail_config.files_read:
+        for path in reversed(self.rattail_config.files_read):
             cmd.extend(['--config', path])
         if username:
             cmd.extend(['--runas', username])
@@ -969,6 +969,10 @@ class BatchMasterView(MasterView):
         batch_uuid = key[0]
 
         # figure out the (sub)command args we'll be passing
+        if handler_action == 'auto_receive':
+            subcommand = 'auto-receive'
+        else:
+            subcommand = f'{handler_action}-batch'
         subargs = [
             '--batch-type',
             self.handler.batch_key,
@@ -987,7 +991,7 @@ class BatchMasterView(MasterView):
                                    command_args=[
                                        '--no-versioning',
                                    ],
-                                   subcommand='{}-batch'.format(handler_action),
+                                   subcommand=subcommand,
                                    subcommand_args=subargs)
         except Exception as error:
             log.warning("%s of '%s' batch failed: %s", handler_action, self.handler.batch_key, batch_uuid, exc_info=True)
diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py
index e659123a..1d1479d6 100644
--- a/tailbone/views/purchasing/receiving.py
+++ b/tailbone/views/purchasing/receiving.py
@@ -34,10 +34,8 @@ import humanize
 import sqlalchemy as sa
 
 from rattail import pod
-from rattail.db import model, Session as RattailSession
 from rattail.time import localtime, make_utc
 from rattail.util import pretty_quantity, prettify, simple_error
-from rattail.threads import Thread
 
 import colander
 from deform import widget as dfwidget
@@ -252,6 +250,7 @@ class ReceivingBatchView(PurchasingBatchView):
         :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']
@@ -642,7 +641,8 @@ class ReceivingBatchView(PurchasingBatchView):
         return params
 
     def template_kwargs_create(self, **kwargs):
-        kwargs = super(ReceivingBatchView, self).template_kwargs_create(**kwargs)
+        kwargs = super().template_kwargs_create(**kwargs)
+        model = self.model
         if self.handler.allow_truck_dump_receiving():
             vmap = {}
             batches = self.Session.query(model.PurchaseBatch)\
@@ -931,16 +931,17 @@ class ReceivingBatchView(PurchasingBatchView):
         url = self.request.route_url('receiving.view', uuid=truck_dump.uuid)
         return tags.link_to(text, url)
 
-    @staticmethod
-    @colander.deferred
-    def validate_purchase(node, kw):
-        session = kw['session']
-        def validate(node, value):
-            purchase = session.get(model.Purchase, value)
-            if not purchase:
-                raise colander.Invalid(node, "Purchase not found")
-            return purchase.uuid
-        return validate
+    # TODO: is this actually used?  wait to see if something breaks..
+    # @staticmethod
+    # @colander.deferred
+    # def validate_purchase(node, kw):
+    #     session = kw['session']
+    #     def validate(node, value):
+    #         purchase = session.get(model.Purchase, value)
+    #         if not purchase:
+    #             raise colander.Invalid(node, "Purchase not found")
+    #         return purchase.uuid
+    #     return validate
 
     def assign_purchase_order(self, batch, po_form):
         """
@@ -957,7 +958,8 @@ class ReceivingBatchView(PurchasingBatchView):
             batch.department_uuid = department.uuid
 
     def configure_row_grid(self, g):
-        super(ReceivingBatchView, self).configure_row_grid(g)
+        super().configure_row_grid(g)
+        model = self.model
         batch = self.get_instance()
 
         # vendor_code
@@ -1469,6 +1471,7 @@ class ReceivingBatchView(PurchasingBatchView):
         a "pack" item, such that it instead associates with the "unit" item,
         with quantities adjusted accordingly.
         """
+        model = self.model
         batch = self.get_instance()
 
         row_uuid = self.request.params.get('row_uuid')
@@ -1513,7 +1516,8 @@ class ReceivingBatchView(PurchasingBatchView):
         })
 
     def configure_row_form(self, f):
-        super(ReceivingBatchView, self).configure_row_form(f)
+        super().configure_row_form(f)
+        model = self.model
         batch = self.get_instance()
 
         # when viewing a row which has no product reference, enable
@@ -1690,6 +1694,7 @@ class ReceivingBatchView(PurchasingBatchView):
         return True
 
     def save_edit_row_form(self, form):
+        model = self.model
         batch = self.get_instance()
         row = self.objectify(form)
 
@@ -1829,6 +1834,7 @@ class ReceivingBatchView(PurchasingBatchView):
         """
         AJAX view for updating various cost fields in a data row.
         """
+        model = self.model
         batch = self.get_instance()
         data = dict(get_form_data(self.request))
 
@@ -1882,55 +1888,10 @@ class ReceivingBatchView(PurchasingBatchView):
 
     def auto_receive(self):
         """
-        View which can "auto-receive" all items in the batch.  Meant only as a
-        convenience for developers.
+        View which can "auto-receive" all items in the batch.
         """
         batch = self.get_instance()
-        key = '{}.receive_all'.format(self.get_grid_key())
-        progress = self.make_progress(key)
-        kwargs = {'progress': progress}
-        thread = Thread(target=self.auto_receive_thread, args=(batch.uuid, self.request.user.uuid), kwargs=kwargs)
-        thread.start()
-
-        return self.render_progress(progress, {
-            'instance': batch,
-            'cancel_url': self.get_action_url('view', batch),
-            'cancel_msg': "Auto-receive was canceled",
-        })
-
-    def auto_receive_thread(self, uuid, user_uuid, progress=None):
-        """
-        Thread target for receiving all items on the given batch.
-        """
-        session = RattailSession()
-        batch = session.get(model.PurchaseBatch, uuid)
-        # user = session.query(model.User).get(user_uuid)
-        try:
-            self.handler.auto_receive_all_items(batch, progress=progress)
-
-        # if anything goes wrong, rollback and log the error etc.
-        except Exception as error:
-            session.rollback()
-            log.exception("auto-receive failed for: %s".format(batch))
-            session.close()
-            if progress:
-                progress.session.load()
-                progress.session['error'] = True
-                progress.session['error_msg'] = "Auto-receive failed: {}".format(
-                    simple_error(error))
-                progress.session.save()
-
-        # if no error, check result flag (false means user canceled)
-        else:
-            session.commit()
-            session.refresh(batch)
-            success_url = self.get_action_url('view', batch)
-            session.close()
-            if progress:
-                progress.session.load()
-                progress.session['complete'] = True
-                progress.session['success_url'] = success_url
-                progress.session.save()
+        return self.handler_action(batch, 'auto_receive')
 
     def configure_get_simple_settings(self):
         config = self.rattail_config

From 6b6e358dbe8e497622a1cdb2aa4b5b35997a147f Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 7 Jul 2023 15:38:08 -0500
Subject: [PATCH 007/542] Update changelog

---
 CHANGES.rst          | 6 ++++++
 tailbone/_version.py | 2 +-
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index d71eac97..727bd250 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,12 @@
 CHANGELOG
 =========
 
+0.9.38 (2023-07-07)
+-------------------
+
+* Optimize "auto-receive" batch process.
+
+
 0.9.37 (2023-07-03)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index d57f5f68..aa6a7d76 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.37'
+__version__ = '0.9.38'

From 4729785b0506a2aafb5e77ce79999cafaae02f74 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 7 Jul 2023 17:19:08 -0500
Subject: [PATCH 008/542] Show invoice number for each row in receiving

---
 tailbone/templates/receiving/view_row.mako | 1 +
 tailbone/views/purchasing/receiving.py     | 1 +
 2 files changed, 2 insertions(+)

diff --git a/tailbone/templates/receiving/view_row.mako b/tailbone/templates/receiving/view_row.mako
index 4d596391..2341cd3e 100644
--- a/tailbone/templates/receiving/view_row.mako
+++ b/tailbone/templates/receiving/view_row.mako
@@ -444,6 +444,7 @@
           <p class="panel-heading">Invoice</p>
           <div class="panel-block">
             <div>
+              ${form.render_field_readonly('invoice_number')}
               ${form.render_field_readonly('invoice_line_number')}
               ${form.render_field_readonly('invoice_unit_cost')}
               ${form.render_field_readonly('invoice_case_size')}
diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py
index 1d1479d6..d4bed60a 100644
--- a/tailbone/views/purchasing/receiving.py
+++ b/tailbone/views/purchasing/receiving.py
@@ -206,6 +206,7 @@ class ReceivingBatchView(PurchasingBatchView):
         'po_unit_cost',
         'po_case_size',
         'po_total',
+        'invoice_number',
         'invoice_line_number',
         'invoice_unit_cost',
         'invoice_cost_confirmed',

From a84bcf688bb90591e4bc7820c6c7fc67eb3375d4 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 7 Jul 2023 17:56:45 -0500
Subject: [PATCH 009/542] Tweak display options for tempmon probe readings
 graph

---
 tailbone/templates/tempmon/probes/graph.mako | 12 ++++++++++--
 tailbone/views/tempmon/probes.py             | 20 ++++----------------
 2 files changed, 14 insertions(+), 18 deletions(-)

diff --git a/tailbone/templates/tempmon/probes/graph.mako b/tailbone/templates/tempmon/probes/graph.mako
index 795af145..412f25dd 100644
--- a/tailbone/templates/tempmon/probes/graph.mako
+++ b/tailbone/templates/tempmon/probes/graph.mako
@@ -39,7 +39,13 @@
         </b-field>
 
         <b-field horizontal label="Showing">
-          ${time_range}
+          <b-select v-model="currentTimeRange"
+                    @input="timeRangeChanged">
+            <option value="last hour">Last Hour</option>
+            <option value="last 6 hours">Last 6 Hours</option>
+            <option value="last day">Last Day</option>
+            <option value="last week">Last Week</option>
+          </b-select>
         </b-field>
 
       </div>
@@ -86,7 +92,9 @@
             this.chart.destroy()
         }
 
-        this.$http.get('${url('{}.graph_readings'.format(route_prefix), uuid=probe.uuid)}', {params: {'time-range': timeRange}}).then(({ data }) => {
+        let url = '${url(f'{route_prefix}.graph_readings', uuid=probe.uuid)}'
+        let params = {'time-range': timeRange}
+        this.$http.get(url, {params: params}).then(({ data }) => {
 
             this.chart = new Chart(this.$refs.tempchart, {
                 type: 'scatter',
diff --git a/tailbone/views/tempmon/probes.py b/tailbone/views/tempmon/probes.py
index 6d12a3d2..381a9f4a 100644
--- a/tailbone/views/tempmon/probes.py
+++ b/tailbone/views/tempmon/probes.py
@@ -26,12 +26,11 @@ Views for tempmon probes
 
 import datetime
 
-from rattail.time import make_utc, localtime
 from rattail_tempmon.db import model as tempmon
 
 import colander
 from deform import widget as dfwidget
-from webhelpers2.html import tags, HTML
+from webhelpers2.html import tags
 
 from tailbone import forms, grids
 from tailbone.views.tempmon import MasterView
@@ -258,27 +257,16 @@ class TempmonProbeView(MasterView):
             selected = self.request.session.get(key, 'last hour')
         self.request.session[key] = selected
 
-        range_options = tags.Options([
-            tags.Option("Last Hour", 'last hour'),
-            tags.Option("Last 6 Hours", 'last 6 hours'),
-            tags.Option("Last Day", 'last day'),
-            tags.Option("Last Week", 'last week'),
-        ])
-
-        time_range = HTML.tag('b-select', c=[range_options.render()],
-                              **{'v-model': 'currentTimeRange',
-                                 '@input': 'timeRangeChanged'})
-
         context = {
             'probe': probe,
             'parent_title': str(probe),
             'parent_url': self.get_action_url('view', probe),
-            'time_range': time_range,
             'current_time_range': selected,
         }
         return self.render_to_response('graph', context)
 
     def graph_readings(self):
+        app = self.get_rattail_app()
         probe = self.get_instance()
 
         key = 'tempmon.probe.{}.graph_time_range'.format(probe.uuid)
@@ -299,7 +287,7 @@ class TempmonProbeView(MasterView):
             raise NotImplementedError("Unknown time range: {}".format(selected))
 
         # figure out which readings we need to graph
-        cutoff = make_utc() - datetime.timedelta(seconds=cutoff)
+        cutoff = app.make_utc() - datetime.timedelta(seconds=cutoff)
         readings = self.Session.query(tempmon.Reading)\
                                .filter(tempmon.Reading.probe == probe)\
                                .filter(tempmon.Reading.taken >= cutoff)\
@@ -308,7 +296,7 @@ class TempmonProbeView(MasterView):
 
         # convert readings to data for scatter plot
         data = [{
-            'x': localtime(self.rattail_config, reading.taken, from_utc=True).isoformat(),
+            'x': app.localtime(reading.taken, from_utc=True).isoformat(),
             'y': float(reading.degrees_f),
         } for reading in readings]
         return data

From 1f3b5a49c4b6789198c6f06fe86cacba943d7c0b Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sat, 15 Jul 2023 19:32:04 -0500
Subject: [PATCH 010/542] Update changelog

---
 CHANGES.rst          | 8 ++++++++
 tailbone/_version.py | 2 +-
 2 files changed, 9 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 727bd250..eeec3c57 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,14 @@
 CHANGELOG
 =========
 
+0.9.39 (2023-07-15)
+-------------------
+
+* Show invoice number for each row in receiving.
+
+* Tweak display options for tempmon probe readings graph.
+
+
 0.9.38 (2023-07-07)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index aa6a7d76..48ba66dd 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.38'
+__version__ = '0.9.39'

From 9f0cfc68c1f886de4c877dadc9982a0057a363a4 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 2 Aug 2023 21:59:52 -0500
Subject: [PATCH 011/542] Make system key searchable for problem report grid

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

diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py
index a1c737b6..5a945f0c 100644
--- a/tailbone/views/reports.py
+++ b/tailbone/views/reports.py
@@ -629,7 +629,9 @@ class ProblemReportView(MasterView):
         return data
 
     def configure_grid(self, g):
-        super(ProblemReportView, self).configure_grid(g)
+        super().configure_grid(g)
+
+        g.set_searchable('system_key')
 
         g.set_renderer('email_recipients', self.render_email_recipients)
 

From ec7b0cdda178a68b656838d146a86824d3ac1a24 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 3 Aug 2023 22:42:34 -0500
Subject: [PATCH 012/542] Update changelog

---
 CHANGES.rst          | 6 ++++++
 tailbone/_version.py | 2 +-
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index eeec3c57..08bff3b8 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,12 @@
 CHANGELOG
 =========
 
+0.9.40 (2023-08-03)
+-------------------
+
+* Make system key searchable for problem report grid.
+
+
 0.9.39 (2023-07-15)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 48ba66dd..6d32d447 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.39'
+__version__ = '0.9.40'

From d504da19c5197ee0d6d2c37c09bbc1e94470f264 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 7 Aug 2023 12:36:07 -0500
Subject: [PATCH 013/542] Add common logic to validate employee reference field

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

diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index e0c42e6e..eeae4dae 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -856,6 +856,13 @@ class MasterView(View):
             url = self.request.route_url('stores.view', uuid=store.uuid)
             return tags.link_to(text, url)
 
+    def valid_employee_uuid(self, node, value):
+        if value:
+            model = self.model
+            employee = self.Session.get(model.Employee, value)
+            if not employee:
+                node.raise_invalid("Employee not found")
+
     def render_product(self, obj, field):
         product = getattr(obj, field)
         if not product:

From f2915afda4dd94ad95facd4c23e2f100e9e94909 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 8 Aug 2023 14:11:54 -0500
Subject: [PATCH 014/542] Fix HTML rendering for UOM choice options

also avoid deprecated config methods
---
 tailbone/templates/deform/select_dynamic.pt | 2 +-
 tailbone/views/custorders/orders.py         | 4 ++--
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/tailbone/templates/deform/select_dynamic.pt b/tailbone/templates/deform/select_dynamic.pt
index a0ee1daf..712830d1 100644
--- a/tailbone/templates/deform/select_dynamic.pt
+++ b/tailbone/templates/deform/select_dynamic.pt
@@ -26,7 +26,7 @@
     <option v-for="item in ${name}_options"
             tal:attributes=":key 'item.value';
                             :value 'item.value';">
-      {{ item.label }}
+      <span v-html="item.label"></span>
     </option>
 
   </b-select>
diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py
index 563739ea..cdf765a6 100644
--- a/tailbone/views/custorders/orders.py
+++ b/tailbone/views/custorders/orders.py
@@ -341,7 +341,7 @@ class CustomerOrderView(MasterView):
             'allow_contact_info_choice': self.batch_handler.allow_contact_info_choice(),
             'allow_contact_info_create': self.batch_handler.allow_contact_info_creation(),
             'order_items': items,
-            'product_key_label': self.rattail_config.product_key_title(),
+            'product_key_label': app.get_product_key_label(),
             'allow_unknown_product': self.batch_handler.allow_unknown_product(),
             'department_options': self.get_department_options(),
             'default_uom_choices': self.batch_handler.uom_choices_for_product(None),
@@ -767,7 +767,7 @@ class CustomerOrderView(MasterView):
         if self.batch_handler.product_price_may_be_questionable():
             data['price_needs_confirmation'] = row.price_needs_confirmation
 
-        key = self.rattail_config.product_key()
+        key = app.get_product_key_field()
         if key == 'upc':
             data['product_key'] = data['product_upc_pretty']
         elif key == 'item_id':

From 845b5cda1a6730fe27028d180b246e2c221b3f40 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 8 Aug 2023 18:06:22 -0500
Subject: [PATCH 015/542] Fix custom cell click handlers in main buefy grid
 tables

just used for editing catalog/invoice cost in receiving thus far..
---
 tailbone/templates/grids/buefy.mako    | 20 +++++++++++++++++---
 tailbone/templates/receiving/view.mako |  3 ++-
 tailbone/views/purchasing/receiving.py |  9 +++------
 3 files changed, 22 insertions(+), 10 deletions(-)

diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako
index d96358d5..42451597 100644
--- a/tailbone/templates/grids/buefy.mako
+++ b/tailbone/templates/grids/buefy.mako
@@ -180,18 +180,21 @@
        % endif
 
        :checkable="checkable"
+
        % if grid.checkboxes:
        :checked-rows.sync="checkedRows"
        % if grid.clicking_row_checks_box:
        @click="rowClick"
        % endif
        % endif
+
        % if grid.check_handler:
        @check="${grid.check_handler}"
        % endif
        % if grid.check_all_handler:
        @check-all="${grid.check_all_handler}"
        % endif
+
        % if isinstance(grid.checkable, str):
        :is-row-checkable="${grid.row_checkable}"
        % elif grid.checkable:
@@ -204,6 +207,10 @@
        @sort="onSort"
        % endif
 
+       % if grid.click_handlers:
+       @cellclick="cellClick"
+       % endif
+
        :paginated="paginated"
        :per-page="perPage"
        :current-page="currentPage"
@@ -227,9 +234,6 @@
                           searchable
                           % endif
                           cell-class="c_${column['field']}"
-                          % if grid.has_click_handler(column['field']):
-                          @click.native="${grid.click_handlers[column['field']]}"
-                          % endif
                           :visible="${json.dumps(column['visible'])}">
             % if column['field'] in grid.raw_renderers:
                 ${grid.raw_renderers[column['field']]()}
@@ -392,6 +396,16 @@
 
       methods: {
 
+          % if grid.click_handlers:
+              cellClick(row, column, rowIndex, columnIndex) {
+                  % for key in grid.click_handlers:
+                      if (column._props.field == '${key}') {
+                          ${grid.click_handlers[key]}(row)
+                      }
+                  % endfor
+              },
+          % endif
+
           copyDirectLink() {
 
               if (navigator.clipboard) {
diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako
index 463fdf6c..b4de37f1 100644
--- a/tailbone/templates/receiving/view.mako
+++ b/tailbone/templates/receiving/view.mako
@@ -103,7 +103,8 @@
                    ref="input"
                    v-show="editing"
                    @keydown.native="inputKeyDown"
-                   @blur="inputBlur">
+                   @blur="inputBlur"
+                   style="width: 6rem;">
           </b-input>
         </div>
       </script>
diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py
index d4bed60a..35e1d6b4 100644
--- a/tailbone/views/purchasing/receiving.py
+++ b/tailbone/views/purchasing/receiving.py
@@ -968,19 +968,16 @@ class ReceivingBatchView(PurchasingBatchView):
         g.filters['vendor_code'].default_verb = 'contains'
 
         # catalog_unit_cost
-        if (self.handler.has_purchase_order(batch)
-            or self.handler.has_invoice_file(batch)):
-            g.remove('catalog_unit_cost')
-        elif self.allow_edit_catalog_unit_cost(batch):
+        if self.allow_edit_catalog_unit_cost(batch):
             g.set_raw_renderer('catalog_unit_cost', self.render_catalog_unit_cost)
             g.set_click_handler('catalog_unit_cost',
-                                'catalogUnitCostClicked(props.row)')
+                                'this.catalogUnitCostClicked')
 
         # invoice_unit_cost
         if self.allow_edit_invoice_unit_cost(batch):
             g.set_raw_renderer('invoice_unit_cost', self.render_invoice_unit_cost)
             g.set_click_handler('invoice_unit_cost',
-                                'invoiceUnitCostClicked(props.row)')
+                                'this.invoiceUnitCostClicked')
 
         # nb. only show PO *or* invoice cost; prefer the latter unless
         # we have a PO and no invoice

From 4ecea891b3347a878b710569e3a07c120a5a922a Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 8 Aug 2023 18:42:50 -0500
Subject: [PATCH 016/542] Update changelog

---
 CHANGES.rst          | 10 ++++++++++
 tailbone/_version.py |  2 +-
 2 files changed, 11 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 08bff3b8..f43e669b 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,16 @@
 CHANGELOG
 =========
 
+0.9.41 (2023-08-08)
+-------------------
+
+* Add common logic to validate employee reference field.
+
+* Fix HTML rendering for UOM choice options.
+
+* Fix custom cell click handlers in main buefy grid tables.
+
+
 0.9.40 (2023-08-03)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 6d32d447..07ccc0e9 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.40'
+__version__ = '0.9.41'

From 90075b3b6539d554ccca6915fe6fcab14b7df7fe Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 9 Aug 2023 18:04:51 -0500
Subject: [PATCH 017/542] When bulk-deleting, skip objects which are not
 "deletable"

whatever that means in context
---
 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 eeae4dae..107870cd 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -1728,7 +1728,8 @@ class MasterView(View):
     def bulk_delete_objects(self, session, objects, progress=None):
 
         def delete(obj, i):
-            self.delete_instance(obj)
+            if self.deletable_instance(obj):
+                self.delete_instance(obj)
             if i % 1000 == 0:
                 session.flush()
 

From a007606863ab386578018c765a388f50a9bf8d0f Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 17 Aug 2023 18:12:42 -0500
Subject: [PATCH 018/542] Declare "from PO" receiving workflow if applicable,
 in API

---
 tailbone/api/batch/receiving.py | 10 ++++++++--
 1 file changed, 8 insertions(+), 2 deletions(-)

diff --git a/tailbone/api/batch/receiving.py b/tailbone/api/batch/receiving.py
index 9a6864db..b02215d2 100644
--- a/tailbone/api/batch/receiving.py
+++ b/tailbone/api/batch/receiving.py
@@ -77,9 +77,15 @@ class ReceivingBatchViews(APIBatchView):
 
     def create_object(self, data):
         data = dict(data)
+
+        # all about receiving mode here
         data['mode'] = self.enum.PURCHASE_BATCH_MODE_RECEIVING
-        batch = super(ReceivingBatchViews, self).create_object(data)
-        return batch
+
+        # assume "receive from PO" if given a PO key
+        if data['purchase_key']:
+            data['receiving_workflow'] = 'from_po'
+
+        return super().create_object(data)
 
     def auto_receive(self):
         """

From b2aea57da6933d84b79d049f10c07dff20d56579 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 18 Aug 2023 15:04:52 -0500
Subject: [PATCH 019/542] Auto-select text when editing costs for receiving

---
 tailbone/templates/receiving/view.mako | 7 +++++++
 1 file changed, 7 insertions(+)

diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako
index b4de37f1..77560ac1 100644
--- a/tailbone/templates/receiving/view.mako
+++ b/tailbone/templates/receiving/view.mako
@@ -103,6 +103,7 @@
                    ref="input"
                    v-show="editing"
                    @keydown.native="inputKeyDown"
+                   @focus="selectAll"
                    @blur="inputBlur"
                    style="width: 6rem;">
           </b-input>
@@ -189,6 +190,12 @@
             },
             methods: {
 
+                selectAll() {
+                    // nb. must traverse into the <b-input> element
+                    let trueInput = this.$refs.input.$el.firstChild
+                    trueInput.select()
+                },
+
                 startEdit() {
                     this.inputValue = this.value
                     this.editing = true

From 8be7dac33b7020b3ae59db15ace1c74a9b9524cb Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 24 Aug 2023 22:00:11 -0500
Subject: [PATCH 020/542] Include shopper history from parent customer account
 perspective

..right?  or should this be hidden? configurable etc.?
---
 tailbone/views/people.py | 16 ++++++++++++++++
 1 file changed, 16 insertions(+)

diff --git a/tailbone/views/people.py b/tailbone/views/people.py
index 8dc96037..54d00ca7 100644
--- a/tailbone/views/people.py
+++ b/tailbone/views/people.py
@@ -1283,6 +1283,22 @@ class PersonView(MasterView):
                             .filter(cls.account_holder_uuid == person.uuid)
         versions.extend(query.all())
 
+        # CustomerShopper (from Customer perspective)
+        cls = continuum.version_class(model.CustomerShopper)
+        query = self.Session.query(cls)\
+                            .join(model.Customer, model.Customer.uuid == cls.customer_uuid)\
+                            .filter(model.Customer.account_holder_uuid == person.uuid)
+        versions.extend(query.all())
+
+        # CustomerShopperHistory (from Customer perspective)
+        cls = continuum.version_class(model.CustomerShopperHistory)
+        query = self.Session.query(cls)\
+                            .join(model.CustomerShopper,
+                                  model.CustomerShopper.uuid == cls.shopper_uuid)\
+                            .join(model.Customer)\
+                            .filter(model.Customer.account_holder_uuid == person.uuid)
+        versions.extend(query.all())
+
         # CustomerShopper (from Shopper perspective)
         cls = continuum.version_class(model.CustomerShopper)
         query = self.Session.query(cls)\

From bc8b5a8d324b3d30410ef8222e068714cdb7b84a Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 25 Aug 2023 09:08:33 -0500
Subject: [PATCH 021/542] Link to product record, for New Product batch row

also fix a typo
---
 tailbone/templates/products/configure.mako | 2 +-
 tailbone/views/batch/newproduct.py         | 7 +++----
 2 files changed, 4 insertions(+), 5 deletions(-)

diff --git a/tailbone/templates/products/configure.mako b/tailbone/templates/products/configure.mako
index a8caeac7..10f3c0e5 100644
--- a/tailbone/templates/products/configure.mako
+++ b/tailbone/templates/products/configure.mako
@@ -50,7 +50,7 @@
   <h3 class="block is-size-3">Handling</h3>
   <div class="block" style="padding-left: 2rem;">
 
-    <b-field message="If set, GPC values like 002XXXXXYYYYY-Z will be converted to 002XXXXX00000-Z for lokkup">
+    <b-field message="If set, GPC values like 002XXXXXYYYYY-Z will be converted to 002XXXXX00000-Z for lookup">
       <b-checkbox name="rattail.products.convert_type2_for_gpc_lookup"
                   v-model="simpleSettings['rattail.products.convert_type2_for_gpc_lookup']"
                   native-value="true"
diff --git a/tailbone/views/batch/newproduct.py b/tailbone/views/batch/newproduct.py
index 03ca638b..d58357d0 100644
--- a/tailbone/views/batch/newproduct.py
+++ b/tailbone/views/batch/newproduct.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,8 +24,6 @@
 Views for new product batches
 """
 
-from __future__ import unicode_literals, absolute_import
-
 from rattail.db import model
 
 from tailbone.views.batch import BatchMasterView
@@ -165,7 +163,7 @@ class NewProductBatchView(BatchMasterView):
             return 'notice'
 
     def configure_row_form(self, f):
-        super(NewProductBatchView, self).configure_row_form(f)
+        super().configure_row_form(f)
 
         f.set_readonly('product')
         f.set_readonly('vendor')
@@ -177,6 +175,7 @@ class NewProductBatchView(BatchMasterView):
 
         f.set_type('upc', 'gpc')
 
+        f.set_renderer('product', self.render_product)
         f.set_renderer('vendor', self.render_vendor)
         f.set_renderer('department', self.render_department)
         f.set_renderer('subdepartment', self.render_subdepartment)

From a40b44b6e33aa68f557cd14d9e125d484c68cc9e Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 25 Aug 2023 10:41:20 -0500
Subject: [PATCH 022/542] Fix profile history to show when a
 CustomerShopperHistory is deleted

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

diff --git a/tailbone/views/people.py b/tailbone/views/people.py
index 54d00ca7..48391f63 100644
--- a/tailbone/views/people.py
+++ b/tailbone/views/people.py
@@ -1307,10 +1307,10 @@ class PersonView(MasterView):
 
         # CustomerShopperHistory (from Shopper perspective)
         cls = continuum.version_class(model.CustomerShopperHistory)
+        standin = continuum.version_class(model.CustomerShopper)
         query = self.Session.query(cls)\
-                            .join(model.CustomerShopper,
-                                  model.CustomerShopper.uuid == cls.shopper_uuid)\
-                            .filter(model.CustomerShopper.person_uuid == person.uuid)
+                            .join(standin, standin.uuid == cls.shopper_uuid)\
+                            .filter(standin.person_uuid == person.uuid)
         versions.extend(query.all())
 
         # PersonNote

From 844c629a6a013ce57ff01f896fa9cce442cd6426 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 25 Aug 2023 13:59:58 -0500
Subject: [PATCH 023/542] Fix profile history to show when a
 CustomerShopperHistory is deleted

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

diff --git a/tailbone/views/people.py b/tailbone/views/people.py
index 48391f63..d7f84849 100644
--- a/tailbone/views/people.py
+++ b/tailbone/views/people.py
@@ -1292,10 +1292,10 @@ class PersonView(MasterView):
 
         # CustomerShopperHistory (from Customer perspective)
         cls = continuum.version_class(model.CustomerShopperHistory)
+        standin = continuum.version_class(model.CustomerShopper)
         query = self.Session.query(cls)\
-                            .join(model.CustomerShopper,
-                                  model.CustomerShopper.uuid == cls.shopper_uuid)\
-                            .join(model.Customer)\
+                            .join(standin, standin.uuid == cls.shopper_uuid)\
+                            .join(model.Customer, model.Customer.uuid == standin.customer_uuid)\
                             .filter(model.Customer.account_holder_uuid == person.uuid)
         versions.extend(query.all())
 

From 12e477909305a1f2ed4b7e4ba2b421ab727c782e Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 28 Aug 2023 20:43:31 -0500
Subject: [PATCH 024/542] Fairly massive overhaul of the Profile view;
 standardize tabs etc.

much cleaner and more consistent interface now, between the main
ProfileInfo component, and various *Tab components

also cleaner interface between client-side JS and server view methods

to my knowledge this is complete and breaks nothing..we'll see!
---
 tailbone/templates/members/configure.mako     |   14 +
 tailbone/templates/page.mako                  |    7 +-
 .../templates/people/view_profile_buefy.mako  | 1830 +++++++++--------
 tailbone/views/members.py                     |    5 +
 tailbone/views/people.py                      |  397 ++--
 5 files changed, 1234 insertions(+), 1019 deletions(-)

diff --git a/tailbone/templates/members/configure.mako b/tailbone/templates/members/configure.mako
index c0e0355d..465bf611 100644
--- a/tailbone/templates/members/configure.mako
+++ b/tailbone/templates/members/configure.mako
@@ -36,6 +36,20 @@
     </b-field>
 
   </div>
+
+  <h3 class="block is-size-3">Relationships</h3>
+  <div class="block" style="padding-left: 2rem;">
+
+    <b-field message="By default a Person may have multiple Member accounts.">
+      <b-checkbox name="rattail.members.max_one_per_person"
+                  v-model="simpleSettings['rattail.members.max_one_per_person']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Limit one (1) Member account per Person
+      </b-checkbox>
+    </b-field>
+
+  </div>
 </%def>
 
 <%def name="modify_this_page_vars()">
diff --git a/tailbone/templates/page.mako b/tailbone/templates/page.mako
index b5ac8773..bf799440 100644
--- a/tailbone/templates/page.mako
+++ b/tailbone/templates/page.mako
@@ -38,7 +38,12 @@
         },
         computed: {},
         watch: {},
-        methods: {},
+        methods: {
+
+            changeContentTitle(newTitle) {
+                this.$emit('change-content-title', newTitle)
+            },
+        },
     }
 
     let ThisPageData = {
diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako
index e1da8661..5574088e 100644
--- a/tailbone/templates/people/view_profile_buefy.mako
+++ b/tailbone/templates/people/view_profile_buefy.mako
@@ -119,17 +119,17 @@
 
                   <section class="modal-card-body">
                     <b-field label="First Name">
-                      <b-input v-model.trim="personFirstName"
+                      <b-input v-model.trim="editNameFirst"
                                :maxlength="maxLengths.person_first_name || null">
                       </b-input>
                     </b-field>
                     <b-field label="Middle Name">
-                      <b-input v-model.trim="personMiddleName"
+                      <b-input v-model.trim="editNameMiddle"
                                :maxlength="maxLengths.person_middle_name || null">
                       </b-input>
                     </b-field>
                     <b-field label="Last Name">
-                      <b-input v-model.trim="personLastName"
+                      <b-input v-model.trim="editNameLast"
                                :maxlength="maxLengths.person_last_name || null">
                       </b-input>
                     </b-field>
@@ -210,38 +210,38 @@
                   <section class="modal-card-body">
 
                     <b-field label="Street 1" expanded>
-                      <b-input v-model.trim="personStreet1"
+                      <b-input v-model.trim="editAddressStreet1"
                                :maxlength="maxLengths.address_street || null">
                       </b-input>
                     </b-field>
 
                     <b-field label="Street 2" expanded>
-                      <b-input v-model.trim="personStreet2"
+                      <b-input v-model.trim="editAddressStreet2"
                                :maxlength="maxLengths.address_street2 || null">
                       </b-input>
                     </b-field>
 
                     <b-field label="Zipcode">
-                      <b-input v-model.trim="personZipcode"
+                      <b-input v-model.trim="editAddressZipcode"
                                :maxlength="maxLengths.address_zipcode || null">
                       </b-input>
                     </b-field>
 
                     <b-field grouped>
                       <b-field label="City">
-                        <b-input v-model.trim="personCity"
+                        <b-input v-model.trim="editAddressCity"
                                  :maxlength="maxLengths.address_city || null">
                         </b-input>
                       </b-field>
                       <b-field label="State">
-                        <b-input v-model.trim="personState"
+                        <b-input v-model.trim="editAddressState"
                                  :maxlength="maxLengths.address_state || null">
                         </b-input>
                       </b-field>
                     </b-field>
 
                     <b-field label="Invalid">
-                      <b-checkbox v-model="personInvalidAddress"
+                      <b-checkbox v-model="editAddressInvalid"
                                   type="is-danger">
                       </b-checkbox>
                     </b-field>
@@ -298,7 +298,7 @@
 
                 <header class="modal-card-head">
                   <p class="modal-card-title">
-                    {{ phoneUUID ? "Edit Phone" : "Add Phone" }}
+                    {{ editPhoneUUID ? "Edit" : "Add" }} Phone
                   </p>
                 </header>
 
@@ -306,7 +306,7 @@
                   <b-field grouped>
 
                     <b-field label="Type" expanded>
-                      <b-select v-model="phoneType" expanded>
+                      <b-select v-model="editPhoneType" expanded>
                         <option v-for="option in phoneTypeOptions"
                                 :key="option.value"
                                 :value="option.value">
@@ -316,14 +316,14 @@
                     </b-field>
 
                     <b-field label="Number" expanded>
-                      <b-input v-model.trim="phoneNumber"
+                      <b-input v-model.trim="editPhoneNumber"
                                ref="editPhoneInput">
                       </b-input>
                     </b-field>
                   </b-field>
 
                   <b-field label="Preferred?">
-                    <b-checkbox v-model="phonePreferred">
+                    <b-checkbox v-model="editPhonePreferred">
                     </b-checkbox>
                   </b-field>
 
@@ -335,7 +335,7 @@
                             :disabled="editPhoneSaveDisabled"
                             icon-pack="fas"
                             icon-left="save">
-                    {{ editPhoneSaveText }}
+                    {{ editPhoneSaving ? "Working..." : "Save" }}
                   </b-button>
                   <b-button @click="editPhoneShowDialog = false">
                     Cancel
@@ -372,12 +372,12 @@
               <i class="fas fa-edit"></i>
               Edit
             </a>
-            <a href="#" @click.prevent="deletePhone(props.row)"
+            <a href="#" @click.prevent="deletePhoneInit(props.row)"
                class="has-text-danger">
               <i class="fas fa-trash"></i>
               Delete
             </a>
-            <a href="#" @click.prevent="setPreferredPhone(props.row)"
+            <a href="#" @click.prevent="preferPhoneInit(props.row)"
                v-if="!props.row.preferred">
               <i class="fas fa-star"></i>
               Set Preferred
@@ -415,7 +415,7 @@
 
                 <header class="modal-card-head">
                   <p class="modal-card-title">
-                    {{ emailUUID ? "Edit Email" : "Add Email" }}
+                    {{ editEmailUUID ? "Edit" : "Add" }} Email
                   </p>
                 </header>
 
@@ -423,7 +423,7 @@
                   <b-field grouped>
 
                     <b-field label="Type" expanded>
-                      <b-select v-model="emailType" expanded>
+                      <b-select v-model="editEmailType" expanded>
                         <option v-for="option in emailTypeOptions"
                                 :key="option.value"
                                 :value="option.value">
@@ -433,23 +433,23 @@
                     </b-field>
 
                     <b-field label="Address" expanded>
-                      <b-input v-model.trim="emailAddress"
+                      <b-input v-model.trim="editEmailAddress"
                                ref="editEmailInput">
                       </b-input>
                     </b-field>
 
                   </b-field>
 
-                  <b-field v-if="!emailUUID"
+                  <b-field v-if="!editEmailUUID"
                            label="Preferred?">
-                    <b-checkbox v-model="emailPreferred">
+                    <b-checkbox v-model="editEmailPreferred">
                     </b-checkbox>
                   </b-field>
 
-                  <b-field v-if="emailUUID"
+                  <b-field v-if="editEmailUUID"
                            label="Invalid?">
-                    <b-checkbox v-model="emailInvalid"
-                                :type="emailInvalid ? 'is-danger': null">
+                    <b-checkbox v-model="editEmailInvalid"
+                                :type="editEmailInvalid ? 'is-danger': null">
                     </b-checkbox>
                   </b-field>
 
@@ -461,7 +461,7 @@
                             :disabled="editEmailSaveDisabled"
                             icon-pack="fas"
                             icon-left="save">
-                    {{ editEmailSaveText }}
+                    {{ editEmailSaving ? "Working, please wait..." : "Save" }}
                   </b-button>
                   <b-button @click="editEmailShowDialog = false">
                     Cancel
@@ -504,12 +504,12 @@
                   <i class="fas fa-edit"></i>
                   Edit
                 </a>
-                <a href="#" @click.prevent="deleteEmail(props.row)"
+                <a href="#" @click.prevent="deleteEmailInit(props.row)"
                    class="has-text-danger">
                   <i class="fas fa-trash"></i>
                   Delete
                 </a>
-                <a href="#" @click.prevent="setPreferredEmail(props.row)"
+                <a href="#" @click.prevent="preferEmailInit(props.row)"
                    v-if="!props.row.preferred">
                   <i class="fas fa-star"></i>
                   Set Preferred
@@ -541,10 +541,12 @@
 
       <div>
         % if request.has_perm('people.view'):
-            ${h.link_to("View Person", url('people.view', uuid=person.uuid), class_='button')}
+            <b-button tag="a" :href="person.view_url">
+              View Person
+            </b-button>
         % endif
       </div>
-
+      <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading>
     </div>
   </script>
 </%def>
@@ -553,294 +555,336 @@
   <b-tab-item label="Personal"
               value="personal"
               icon-pack="fas"
-              icon="check">
-    <personal-tab :person="person"
-                  :member="member"
-                  :max-lengths="maxLengths"
+              :icon="tabchecks.personal ? 'check' : null">
+    <personal-tab ref="tab_personal"
+                  :person="person"
+                  @profile-changed="profileChanged"
                   :phone-type-options="phoneTypeOptions"
                   :email-type-options="emailTypeOptions"
-                  @person-updated="personUpdated"
-                  @change-content-title="changeContentTitle">
+                  :max-lengths="maxLengths">
     </personal-tab>
   </b-tab-item>
 </%def>
 
+<%def name="render_member_tab_template()">
+  <script type="text/x-template" id="member-tab-template">
+    <div>
+      % if max_one_member:
+          <p class="block">
+            TODO: UI not yet implemented for "max one member per person"
+          </p
+
+      % else:
+          ## nb. multiple members allowed per person
+          <div v-if="members.length">
+
+            <div style="display: flex; justify-content: space-between;">
+              <p>{{ person.display_name }} has <strong>{{ members.length }}</strong> member account{{ members.length == 1 ? '' : 's' }}</p>
+            </div>
+
+            <br />
+            <b-collapse v-for="member in members"
+                        :key="member.uuid"
+                        class="panel"
+                        :open="members.length == 1">
+
+              <div slot="trigger"
+                   slot-scope="props"
+                   class="panel-heading"
+                   role="button">
+                <b-icon pack="fas"
+                        icon="caret-right">
+                </b-icon>
+                <strong>{{ member._key }} - {{ member.display }}</strong>
+              </div>
+
+              <div class="panel-block">
+                <div style="display: flex; justify-content: space-between; width: 100%;">
+                  <div style="flex-grow: 1;">
+
+                    <b-field horizontal label="${member_key_label}">
+                      {{ member._key }}
+                    </b-field>
+
+                    <b-field horizontal label="Account Holder">
+                      <a v-if="member.person_uuid != person.uuid"
+                         :href="member.view_profile_url">
+                        {{ member.person_display_name }}
+                      </a>
+                      <span v-if="member.person_uuid == person.uuid">
+                        {{ member.person_display_name }}
+                      </span>
+                    </b-field>
+
+                    <b-field horizontal label="Membership Type">
+                      <a v-if="member.view_membership_type_url"
+                         :href="member.view_membership_type_url">
+                        {{ member.membership_type_name }}
+                      </a>
+                      <span v-if="!member.view_membership_type_url">
+                        {{ member.membership_type_name }}
+                      </span>
+                    </b-field>
+
+                    <b-field horizontal label="Active">
+                      {{ member.active ? "Yes" : "No" }}
+                    </b-field>
+
+                    <b-field horizontal label="Joined">
+                      {{ member.joined }}
+                    </b-field>
+
+                    <b-field horizontal label="Withdrew"
+                             v-if="member.withdrew">
+                      {{ member.withdrew }}
+                    </b-field>
+
+                    <b-field horizontal label="Equity Total">
+                      {{ member.equity_total_display }}
+                    </b-field>
+
+                  </div>
+                  <div class="buttons" style="align-items: start;">
+
+                    <b-button v-for="link in member.external_links"
+                              :key="link.url"
+                              type="is-primary"
+                              tag="a" :href="link.url" target="_blank"
+                              icon-pack="fas"
+                              icon-left="external-link-alt">
+                      {{ link.label }}
+                    </b-button>
+
+                    % if request.has_perm('members.view'):
+                        <b-button tag="a" :href="member.view_url">
+                          View Member
+                        </b-button>
+                    % endif
+
+                  </div>
+                </div>
+              </div>
+            </b-collapse>
+          </div>
+
+          <div v-if="!members.length">
+            <p>{{ person.display_name }} does not have a member account.</p>
+          </div>
+      % endif
+
+    <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading>
+    </div>
+  </script>
+</%def>
+
 <%def name="render_member_tab()">
   <b-tab-item label="Member"
               value="member"
               icon-pack="fas"
-              :icon="members.length ? 'check' : null">
-
-    <div v-if="members.length">
-
-      <div style="display: flex; justify-content: space-between;">
-        <p>{{ person.display_name }} has <strong>{{ members.length }}</strong> member account{{ members.length == 1 ? '' : 's' }}</p>
-      </div>
-
-      <br />
-      <b-collapse v-for="member in members"
-                  :key="member.uuid"
-                  class="panel"
-                  :open="members.length == 1">
-
-        <div slot="trigger"
-             slot-scope="props"
-             class="panel-heading"
-             role="button">
-          <b-icon pack="fas"
-                  icon="caret-right">
-          </b-icon>
-          <strong>{{ member._key }} - {{ member.display }}</strong>
-        </div>
-
-        <div class="panel-block">
-          <div style="display: flex; justify-content: space-between; width: 100%;">
-            <div style="flex-grow: 1;">
-
-              <b-field horizontal label="${member_key_label}">
-                {{ member._key }}
-              </b-field>
-
-              <b-field horizontal label="Account Holder">
-                <a v-if="member.person_uuid != person.uuid"
-                   :href="member.view_profile_url">
-                  {{ member.person_display_name }}
-                </a>
-                <span v-if="member.person_uuid == person.uuid">
-                  {{ member.person_display_name }}
-                </span>
-              </b-field>
-
-              <b-field horizontal label="Membership Type">
-                <a v-if="member.view_membership_type_url"
-                   :href="member.view_membership_type_url">
-                  {{ member.membership_type_name }}
-                </a>
-                <span v-if="!member.view_membership_type_url">
-                  {{ member.membership_type_name }}
-                </span>
-              </b-field>
-
-              <b-field horizontal label="Active">
-                {{ member.active ? "Yes" : "No" }}
-              </b-field>
-
-              <b-field horizontal label="Joined">
-                {{ member.joined }}
-              </b-field>
-
-              <b-field horizontal label="Withdrew"
-                       v-if="member.withdrew">
-                {{ member.withdrew }}
-              </b-field>
-
-              <b-field horizontal label="Equity Total">
-                {{ member.equity_total_display }}
-              </b-field>
-
-            </div>
-            <div class="buttons" style="align-items: start;">
-              ${self.render_member_panel_buttons(member)}
-            </div>
-          </div>
-        </div>
-      </b-collapse>
-    </div>
-
-    <div v-if="!members.length">
-      <p>{{ person.display_name }} does not have a member account.</p>
-    </div>
-
+              :icon="tabchecks.member ? 'check' : null">
+    <member-tab ref="tab_member"
+                :person="person"
+                @profile-changed="profileChanged"
+                :phone-type-options="phoneTypeOptions">
+    </member-tab>
   </b-tab-item>
 </%def>
 
-<%def name="render_member_panel_buttons(member)">
-  % for button in member_xref_buttons:
-      ${button}
-  % endfor
-  % if request.has_perm('members.view'):
-      <b-button tag="a" :href="member.view_url">
-        View Member
-      </b-button>
-  % endif
+<%def name="render_customer_tab_template()">
+  <script type="text/x-template" id="customer-tab-template">
+    <div>
+      <div v-if="customers.length">
+
+        <div style="display: flex; justify-content: space-between;">
+          <p>{{ person.display_name }} has <strong>{{ customers.length }}</strong> customer account{{ customers.length == 1 ? '' : 's' }}</p>
+        </div>
+
+        <br />
+        <b-collapse v-for="customer in customers"
+                    :key="customer.uuid"
+                    class="panel"
+                    :open="customers.length == 1">
+
+          <div slot="trigger"
+               slot-scope="props"
+               class="panel-heading"
+               role="button">
+            <b-icon pack="fas"
+                    icon="caret-right">
+            </b-icon>
+            <strong>{{ customer._key }} - {{ customer.name }}</strong>
+          </div>
+
+          <div class="panel-block">
+            <div style="display: flex; justify-content: space-between; width: 100%;">
+              <div style="flex-grow: 1;">
+
+                <b-field horizontal label="${customer_key_label}">
+                  {{ customer._key }}
+                </b-field>
+
+                <b-field horizontal label="Account Name">
+                  {{ customer.name }}
+                </b-field>
+
+                % if expose_customer_shoppers:
+                    <b-field horizontal label="Shoppers">
+                      <ul>
+                        <li v-for="shopper in customer.shoppers"
+                            :key="shopper.uuid">
+                          <a v-if="shopper.person_uuid != person.uuid"
+                             :href="shopper.view_profile_url">
+                            {{ shopper.display_name }}
+                          </a>
+                          <span v-if="shopper.person_uuid == person.uuid">
+                            {{ shopper.display_name }}
+                          </span>
+                        </li>
+                      </ul>
+                    </b-field>
+                % endif
+
+                % if expose_customer_people:
+                    <b-field horizontal label="People">
+                      <ul>
+                        <li v-for="p in customer.people"
+                            :key="p.uuid">
+                          <a v-if="p.uuid != person.uuid"
+                             :href="p.view_profile_url">
+                            {{ p.display_name }}
+                          </a>
+                          <span v-if="p.uuid == person.uuid">
+                            {{ p.display_name }}
+                          </span>
+                        </li>
+                      </ul>
+                    </b-field>
+                % endif
+
+                <b-field horizontal label="Address"
+                         v-for="address in customer.addresses"
+                         :key="address.uuid">
+                  {{ address.display }}
+                </b-field>
+
+              </div>
+              <div class="buttons" style="align-items: start;">
+
+                <b-button v-for="link in customer.external_links"
+                          :key="link.url"
+                          type="is-primary"
+                          tag="a" :href="link.url" target="_blank"
+                          icon-pack="fas"
+                          icon-left="external-link-alt">
+                  {{ link.label }}
+                </b-button>
+
+                % if request.has_perm('customers.view'):
+                    <b-button tag="a" :href="customer.view_url">
+                      View Customer
+                    </b-button>
+                % endif
+
+              </div>
+            </div>
+          </div>
+        </b-collapse>
+      </div>
+
+      <div v-if="!customers.length">
+        <p>{{ person.display_name }} does not have a customer account.</p>
+      </div>
+      <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading>
+    </div>
+  </script>
 </%def>
 
 <%def name="render_customer_tab()">
   <b-tab-item label="Customer"
               value="customer"
               icon-pack="fas"
-              :icon="customers.length ? 'check' : null">
-
-    <div v-if="customers.length">
-
-      <div style="display: flex; justify-content: space-between;">
-        <p>{{ person.display_name }} has <strong>{{ customers.length }}</strong> customer account{{ customers.length == 1 ? '' : 's' }}</p>
-      </div>
-
-      <br />
-      <b-collapse v-for="customer in customers"
-                  :key="customer.uuid"
-                  class="panel"
-                  :open="customers.length == 1">
-
-        <div slot="trigger"
-             slot-scope="props"
-             class="panel-heading"
-             role="button">
-          <b-icon pack="fas"
-                  icon="caret-right">
-          </b-icon>
-          <strong>{{ customer._key }} - {{ customer.name }}</strong>
-        </div>
-
-        <div class="panel-block">
-          <div style="display: flex; justify-content: space-between; width: 100%;">
-            <div style="flex-grow: 1;">
-
-              <b-field horizontal label="${customer_key_label}">
-                {{ customer._key }}
-              </b-field>
-
-              <b-field horizontal label="Account Name">
-                {{ customer.name }}
-              </b-field>
-
-              % if expose_customer_shoppers:
-                  <b-field horizontal label="Shoppers">
-                    <ul>
-                      <li v-for="shopper in customer.shoppers"
-                          :key="shopper.uuid">
-                        <a v-if="shopper.person_uuid != person.uuid"
-                           :href="shopper.view_profile_url">
-                          {{ shopper.display_name }}
-                        </a>
-                        <span v-if="shopper.person_uuid == person.uuid">
-                          {{ shopper.display_name }}
-                        </span>
-                      </li>
-                    </ul>
-                  </b-field>
-              % endif
-
-              % if expose_customer_people:
-                  <b-field horizontal label="People">
-                    <ul>
-                      <li v-for="p in customer.people"
-                          :key="p.uuid">
-                        <a v-if="p.uuid != person.uuid"
-                           :href="p.view_profile_url">
-                          {{ p.display_name }}
-                        </a>
-                        <span v-if="p.uuid == person.uuid">
-                          {{ p.display_name }}
-                        </span>
-                      </li>
-                    </ul>
-                  </b-field>
-              % endif
-
-              <b-field horizontal label="Address"
-                       v-for="address in customer.addresses"
-                       :key="address.uuid">
-                {{ address.display }}
-              </b-field>
-
-            </div>
-            <div class="buttons" style="align-items: start;">
-              ${self.render_customer_panel_buttons(customer)}
-            </div>
-          </div>
-        </div>
-      </b-collapse>
-    </div>
-
-    <div v-if="!customers.length">
-      <p>{{ person.display_name }} does not have a customer account.</p>
-    </div>
-
-  </b-tab-item> <!-- Customer -->
+              :icon="tabchecks.customer ? 'check' : null">
+    <customer-tab ref="tab_customer"
+                  :person="person"
+                  @profile-changed="profileChanged">
+    </customer-tab>
+  </b-tab-item>
 </%def>
 
-<%def name="render_customer_panel_buttons(customer)">
-  <b-button v-for="link in customer.external_links"
-            :key="link.url"
-            type="is-primary"
-            tag="a" :href="link.url" target="_blank"
-            icon-pack="fas"
-            icon-left="external-link-alt">
-    {{ link.label }}
-  </b-button>
-  % if request.has_perm('customers.view'):
-      <b-button tag="a" :href="customer.view_url">
-        View Customer
-      </b-button>
-  % endif
+<%def name="render_shopper_tab_template()">
+  <script type="text/x-template" id="shopper-tab-template">
+    <div>
+      <div v-if="shoppers.length">
+
+        <div style="display: flex; justify-content: space-between;">
+          <p>{{ person.display_name }} is shopper for <strong>{{ shoppers.length }}</strong> customer account{{ shoppers.length == 1 ? '' : 's' }}</p>
+        </div>
+
+        <br />
+        <b-collapse v-for="shopper in shoppers"
+                    :key="shopper.uuid"
+                    class="panel"
+                    :open="shoppers.length == 1">
+
+          <div slot="trigger"
+               slot-scope="props"
+               class="panel-heading"
+               role="button">
+            <b-icon pack="fas"
+                    icon="caret-right">
+            </b-icon>
+            <strong>{{ shopper.customer_key }} - {{ shopper.customer_name }}</strong>
+          </div>
+
+          <div class="panel-block">
+            <div style="display: flex; justify-content: space-between; width: 100%;">
+              <div style="flex-grow: 1;">
+
+                <b-field horizontal label="${customer_key_label}">
+                  {{ shopper.customer_key }}
+                </b-field>
+
+                <b-field horizontal label="Account Name">
+                  {{ shopper.customer_name }}
+                </b-field>
+
+                <b-field horizontal label="Account Holder">
+                  <span v-if="!shopper.account_holder_view_profile_url">
+                    {{ shopper.account_holder_name }}
+                  </span>
+                  <a v-if="shopper.account_holder_view_profile_url"
+                     :href="shopper.account_holder_view_profile_url">
+                    {{ shopper.account_holder_name }}
+                  </a>
+                </b-field>
+
+              </div>
+  ##             <div class="buttons" style="align-items: start;">
+  ##               ${self.render_shopper_panel_buttons(shopper)}
+  ##             </div>
+            </div>
+          </div>
+        </b-collapse>
+      </div>
+
+      <div v-if="!shoppers.length">
+        <p>{{ person.display_name }} is not a shopper.</p>
+      </div>
+      <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading>
+    </div>
+  </script>
 </%def>
 
 <%def name="render_shopper_tab()">
   <b-tab-item label="Shopper"
               value="shopper"
               icon-pack="fas"
-              :icon="shoppers.length ? 'check' : null">
-
-    <div v-if="shoppers.length">
-
-      <div style="display: flex; justify-content: space-between;">
-        <p>{{ person.display_name }} is shopper for <strong>{{ shoppers.length }}</strong> customer account{{ shoppers.length == 1 ? '' : 's' }}</p>
-      </div>
-
-      <br />
-      <b-collapse v-for="shopper in shoppers"
-                  :key="shopper.uuid"
-                  class="panel"
-                  :open="shoppers.length == 1">
-
-        <div slot="trigger"
-             slot-scope="props"
-             class="panel-heading"
-             role="button">
-          <b-icon pack="fas"
-                  icon="caret-right">
-          </b-icon>
-          <strong>{{ shopper.customer_key }} - {{ shopper.customer_name }}</strong>
-        </div>
-
-        <div class="panel-block">
-          <div style="display: flex; justify-content: space-between; width: 100%;">
-            <div style="flex-grow: 1;">
-
-              <b-field horizontal label="${customer_key_label}">
-                {{ shopper.customer_key }}
-              </b-field>
-
-              <b-field horizontal label="Account Name">
-                {{ shopper.customer_name }}
-              </b-field>
-
-              <b-field horizontal label="Account Holder">
-                <span v-if="!shopper.account_holder_view_profile_url">
-                  {{ shopper.account_holder_name }}
-                </span>
-                <a v-if="shopper.account_holder_view_profile_url"
-                   :href="shopper.account_holder_view_profile_url">
-                  {{ shopper.account_holder_name }}
-                </a>
-              </b-field>
-
-            </div>
-##             <div class="buttons" style="align-items: start;">
-##               ${self.render_shopper_panel_buttons(shopper)}
-##             </div>
-          </div>
-        </div>
-      </b-collapse>
-    </div>
-
-    <div v-if="!shoppers.length">
-      <p>{{ person.display_name }} is not a shopper.</p>
-    </div>
-
-  </b-tab-item> <!-- Shopper -->
+              :icon="tabchecks.shopper ? 'check' : null">
+    <shopper-tab ref="tab_shopper"
+                 :person="person"
+                 @profile-changed="profileChanged">
+    </shopper-tab>
+  </b-tab-item>
 </%def>
 
 <%def name="render_employee_tab_template()">
@@ -863,11 +907,11 @@
                         <b-button type="is-primary"
                                   icon-pack="fas"
                                   icon-left="edit"
-                                  @click="initEditEmployeeID()">
+                                  @click="editEmployeeIdInit()">
                           Edit ID
                         </b-button>
                         <b-modal has-modal-card
-                                 :active.sync="showEditEmployeeIDDialog">
+                                 :active.sync="editEmployeeIdShowDialog">
                           <div class="modal-card">
 
                             <header class="modal-card-head">
@@ -876,20 +920,20 @@
 
                             <section class="modal-card-body">
                               <b-field label="Employee ID">
-                                <b-input v-model="newEmployeeID"></b-input>
+                                <b-input v-model="editEmployeeIdValue"></b-input>
                               </b-field>
                             </section>
 
                             <footer class="modal-card-foot">
-                              <b-button @click="showEditEmployeeIDDialog = false">
+                              <b-button @click="editEmployeeIdShowDialog = false">
                                 Cancel
                               </b-button>
                               <b-button type="is-primary"
                                         icon-pack="fas"
                                         icon-left="save"
-                                        :disabled="updatingEmployeeID"
-                                        @click="updateEmployeeID()">
-                                {{ editEmployeeIDSaveButtonText }}
+                                        :disabled="editEmployeeIdSaving"
+                                        @click="editEmployeeIdSave()">
+                                {{ editEmployeeIdSaving ? "Working, please wait..." : "Save" }}
                               </b-button>
                             </footer>
                           </div>
@@ -934,7 +978,7 @@
                   <b-table-column field="actions"
                                   label="Actions"
                                   v-slot="props">
-                    <a href="#" @click.prevent="editEmployeeHistory(props.row)">
+                    <a href="#" @click.prevent="editEmployeeHistoryInit(props.row)">
                       <i class="fas fa-edit"></i>
                       Edit
                     </a>
@@ -964,7 +1008,7 @@
 
                 <b-button v-if="employee.current"
                           type="is-primary"
-                          @click="showStopEmployeeDialog = true">
+                          @click="stopEmployeeInit()">
                   ${person} is no longer an Employee
                 </b-button>
 
@@ -978,10 +1022,10 @@
 
                     <section class="modal-card-body">
                       <b-field label="Employee Number">
-                        <b-input v-model="employeeID"></b-input>
+                        <b-input v-model="startEmployeeID"></b-input>
                       </b-field>
                       <b-field label="Start Date">
-                        <tailbone-datepicker v-model="employeeStartDate"></tailbone-datepicker>
+                        <tailbone-datepicker v-model="startEmployeeStartDate"></tailbone-datepicker>
                       </b-field>
                     </section>
 
@@ -990,8 +1034,8 @@
                         Cancel
                       </b-button>
                       <once-button type="is-primary"
-                                   @click="startEmployee()"
-                                   :disabled="!employeeStartDate"
+                                   @click="startEmployeeSave()"
+                                   :disabled="!startEmployeeStartDate"
                                    text="Save">
                       </once-button>
                     </footer>
@@ -999,7 +1043,7 @@
                 </b-modal>
 
                 <b-modal has-modal-card
-                         :active.sync="showStopEmployeeDialog">
+                         :active.sync="stopEmployeeShowDialog">
                   <div class="modal-card">
 
                     <header class="modal-card-head">
@@ -1008,22 +1052,22 @@
 
                     <section class="modal-card-body">
                       <b-field label="End Date"
-                               :type="employeeEndDate ? null : 'is-danger'">
-                        <tailbone-datepicker v-model="employeeEndDate"></tailbone-datepicker>
+                               :type="stopEmployeeEndDate ? null : 'is-danger'">
+                        <tailbone-datepicker v-model="stopEmployeeEndDate"></tailbone-datepicker>
                       </b-field>
                       <b-field label="Revoke Internal App Access">
-                        <b-checkbox v-model="employeeRevokeAccess">
+                        <b-checkbox v-model="stopEmployeeRevokeAccess">
                         </b-checkbox>
                       </b-field>
                     </section>
 
                     <footer class="modal-card-foot">
-                      <b-button @click="showStopEmployeeDialog = false">
+                      <b-button @click="stopEmployeeShowDialog = false">
                         Cancel
                       </b-button>
                       <once-button type="is-primary"
-                                   @click="endEmployee()"
-                                   :disabled="!employeeEndDate"
+                                   @click="stopEmployeeSave()"
+                                   :disabled="!stopEmployeeEndDate"
                                    text="Save">
                       </once-button>
                     </footer>
@@ -1033,7 +1077,7 @@
 
             % if request.has_perm('people_profile.edit_employee_history'):
                 <b-modal has-modal-card
-                         :active.sync="showEditEmployeeHistoryDialog">
+                         :active.sync="editEmployeeHistoryShowDialog">
                   <div class="modal-card">
 
                     <header class="modal-card-head">
@@ -1042,22 +1086,22 @@
 
                     <section class="modal-card-body">
                       <b-field label="Start Date">
-                        <tailbone-datepicker v-model="employeeHistoryStartDate"></tailbone-datepicker>
+                        <tailbone-datepicker v-model="editEmployeeHistoryStartDate"></tailbone-datepicker>
                       </b-field>
                       <b-field label="End Date">
-                        <tailbone-datepicker v-model="employeeHistoryEndDate"
-                                             :disabled="!employeeHistoryEndDateRequired">
+                        <tailbone-datepicker v-model="editEmployeeHistoryEndDate"
+                                             :disabled="!editEmployeeHistoryEndDateRequired">
                         </tailbone-datepicker>
                       </b-field>
                     </section>
 
                     <footer class="modal-card-foot">
-                      <b-button @click="showEditEmployeeHistoryDialog = false">
+                      <b-button @click="editEmployeeHistoryShowDialog = false">
                         Cancel
                       </b-button>
                       <once-button type="is-primary"
-                                   @click="saveEmployeeHistory()"
-                                   :disabled="!employeeHistoryStartDate || (employeeHistoryEndDateRequired && !employeeHistoryEndDate)"
+                                   @click="editEmployeeHistorySave()"
+                                   :disabled="!editEmployeeHistoryStartDate || (editEmployeeHistoryEndDateRequired && !editEmployeeHistoryEndDate)"
                                    text="Save">
                       </once-button>
                     </footer>
@@ -1076,6 +1120,7 @@
         </div>
 
       </div>
+      <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading>
     </div>
   </script>
 </%def>
@@ -1084,12 +1129,10 @@
   <b-tab-item label="Employee"
               value="employee"
               icon-pack="fas"
-              :icon="employee.current ? 'check' : null">
-    <employee-tab :employee="employee"
-                  :employee-history="employeeHistory"
-                  @employee-updated="employeeUpdated"
-                  @employee-history-updated="employeeHistoryUpdated"
-                  @change-content-title="changeContentTitle">
+              :icon="tabchecks.employee ? 'check' : null">
+    <employee-tab ref="tab_employee"
+                  :person="person"
+                  @profile-changed="profileChanged">
     </employee-tab>
   </b-tab-item>
 </%def>
@@ -1101,7 +1144,7 @@
       % if request.has_perm('people_profile.add_note'):
           <b-button type="is-primary"
                     class="control"
-                    @click="noteNew()"
+                    @click="addNoteInit()"
                     icon-pack="fas"
                     icon-left="plus">
             Add Note
@@ -1144,13 +1187,13 @@
             <b-table-column label="Actions"
                             v-slot="props">
               % if request.has_perm('people_profile.edit_note'):
-                  <a href="#" @click.prevent="noteEdit(props.row)">
+                  <a href="#" @click.prevent="editNoteInit(props.row)">
                     <i class="fas fa-edit"></i>
                     Edit
                   </a>
               % endif
               % if request.has_perm('people_profile.delete_note'):
-                  <a href="#" @click.prevent="noteDelete(props.row)"
+                  <a href="#" @click.prevent="deleteNoteInit(props.row)"
                      class="has-text-danger">
                     <i class="fas fa-trash"></i>
                     Delete
@@ -1161,68 +1204,71 @@
 
       </b-table>
 
-      <b-modal :active.sync="noteShowDialog"
-               has-modal-card>
+      % if request.has_any_perm('people_profile.add_note', 'people_profile.edit_note', 'people_profile.delete_note'):
+          <b-modal :active.sync="editNoteShowDialog"
+                   has-modal-card>
 
-        <div class="modal-card">
+            <div class="modal-card">
 
-          <header class="modal-card-head">
-            <p class="modal-card-title">
-              {{ noteDialogTitle }}
-            </p>
-          </header>
+              <header class="modal-card-head">
+                <p class="modal-card-title">
+                  {{ editNoteUUID ? (editNoteDelete ? "Delete" : "Edit") : "New" }} Note
+                </p>
+              </header>
 
-          <section class="modal-card-body">
+              <section class="modal-card-body">
 
-            <b-field label="Type"
-                     :type="!noteDeleting && !noteType ? 'is-danger' : null">
-              <b-select v-model="noteType"
-                        :disabled="noteUUID">
-                <option v-for="option in noteTypeOptions"
-                        :key="option.value"
-                        :value="option.value">
-                  {{ option.label }}
-                </option>
-              </b-select>
-            </b-field>
+                <b-field label="Type"
+                         :type="!editNoteDelete && !editNoteType ? 'is-danger' : null">
+                  <b-select v-model="editNoteType"
+                            :disabled="editNoteUUID">
+                    <option v-for="option in noteTypeOptions"
+                            :key="option.value"
+                            :value="option.value">
+                      {{ option.label }}
+                    </option>
+                  </b-select>
+                </b-field>
 
-            <b-field label="Subject">
-              <b-input v-model.trim="noteSubject"
-                       :disabled="noteDeleting">
-              </b-input>
-            </b-field>
+                <b-field label="Subject">
+                  <b-input v-model.trim="editNoteSubject"
+                           :disabled="editNoteDelete">
+                  </b-input>
+                </b-field>
 
-            <b-field label="Text">
-              <b-input v-model.trim="noteText"
-                       type="textarea"
-                       :disabled="noteDeleting">
-              </b-input>
-            </b-field>
+                <b-field label="Text">
+                  <b-input v-model.trim="editNoteText"
+                           type="textarea"
+                           :disabled="editNoteDelete">
+                  </b-input>
+                </b-field>
 
-            <b-notification v-if="noteDeleting"
-                            type="is-danger"
-                            :closable="false">
-              Are you sure you wish to delete this note?
-            </b-notification>
+                <b-notification v-if="editNoteDelete"
+                                type="is-danger"
+                                :closable="false">
+                  Are you sure you wish to delete this note?
+                </b-notification>
 
-          </section>
+              </section>
 
-          <footer class="modal-card-foot">
-            <b-button :type="noteDeleting ? 'is-danger' : 'is-primary'"
-                      @click="noteSave()"
-                      :disabled="noteSaving || (!noteDeleting && !noteType)"
-                      icon-pack="fas"
-                      icon-left="save">
-              {{ noteSaving ? "Working, please wait..." : noteSaveText }}
-            </b-button>
-            <b-button @click="noteShowDialog = false">
-              Cancel
-            </b-button>
-          </footer>
+              <footer class="modal-card-foot">
+                <b-button :type="editNoteDelete ? 'is-danger' : 'is-primary'"
+                          @click="editNoteSave()"
+                          :disabled="editNoteSaving || (!editNoteDelete && !editNoteType)"
+                          icon-pack="fas"
+                          icon-left="save">
+                  {{ editNoteSaving ? "Working..." : (editNoteDelete ? "Delete" : "Save") }}
+                </b-button>
+                <b-button @click="editNoteShowDialog = false">
+                  Cancel
+                </b-button>
+              </footer>
 
-        </div>
-      </b-modal>
+            </div>
+          </b-modal>
+      % endif
 
+      <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading>
     </div>
   </script>
 </%def>
@@ -1231,70 +1277,79 @@
   <b-tab-item label="Notes"
               value="notes"
               icon-pack="fas"
-              :icon="notes.length ? 'check' : null">
-
-    <notes-tab :notes="notes"
-               :note-type-options="noteTypeOptions"
-               @new-notes-data="newNotesData">
+              :icon="tabchecks.notes ? 'check' : null">
+    <notes-tab ref="tab_notes"
+               :person="person"
+               @profile-changed="profileChanged">
     </notes-tab>
-
   </b-tab-item>
 </%def>
 
+<%def name="render_user_tab_template()">
+  <script type="text/x-template" id="user-tab-template">
+    <div>
+      <div v-if="users.length">
+
+        <p>{{ person.display_name }} has <strong>{{ users.length }}</strong> user account{{ users.length == 1 ? '' : 's' }}</p>
+        <br />
+        <div id="users-accordion">
+
+          <b-collapse class="panel"
+                      v-for="user in users"
+                      :key="user.uuid">
+
+            <div slot="trigger"
+                 class="panel-heading"
+                 role="button">
+              <strong>{{ user.username }}</strong>
+            </div>
+
+            <div class="panel-block">
+              <div style="display: flex; justify-content: space-between; width: 100%;">
+
+                <div>
+                  <div class="field-wrapper id">
+                    <div class="field-row">
+                      <label>Username</label>
+                      <div class="field">
+                        {{ user.username }}
+                      </div>
+                    </div>
+                  </div>
+                </div>
+
+                <div>
+                  % if request.has_perm('users.view'):
+                      <b-button tag="a" :href="user.view_url">
+                        View User
+                      </b-button>
+                  % endif
+                </div>
+
+              </div>
+            </div>
+          </b-collapse>
+        </div>
+      </div>
+
+      <div v-if="!users.length">
+        <p>{{ person.display_name }} does not have a user account.</p>
+      </div>
+      <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading>
+    </div>
+  </script>
+</%def>
+
 <%def name="render_user_tab()">
   <b-tab-item label="User"
               value="user"
               icon-pack="fas"
-              :icon="users.length ? 'check' : null">
-
-    <div v-if="users.length">
-
-      <p>{{ person.display_name }} has <strong>{{ users.length }}</strong> user account{{ users.length == 1 ? '' : 's' }}</p>
-      <br />
-      <div id="users-accordion">
-
-        <b-collapse class="panel"
-                    v-for="user in users"
-                    :key="user.uuid">
-
-          <div slot="trigger"
-               class="panel-heading"
-               role="button">
-            <strong>{{ user.username }}</strong>
-          </div>
-
-          <div class="panel-block">
-            <div style="display: flex; justify-content: space-between; width: 100%;">
-
-              <div>
-                <div class="field-wrapper id">
-                  <div class="field-row">
-                    <label>Username</label>
-                    <div class="field">
-                      {{ user.username }}
-                    </div>
-                  </div>
-                </div>
-              </div>
-
-              <div>
-                % if request.has_perm('users.view'):
-                    <b-button tag="a" :href="user.view_url">
-                      View User
-                    </b-button>
-                % endif
-              </div>
-
-            </div>
-          </div>
-        </b-collapse>
-      </div>
-    </div>
-
-    <div v-if="!users.length">
-      <p>{{ person.display_name }} does not have a user account.</p>
-    </div>
-  </b-tab-item><!-- User -->
+              :icon="tabchecks.user ? 'check' : null">
+    <user-tab ref="tab_user"
+              :person="person"
+              @profile-changed="profileChanged">
+    </user-tab>
+  </b-tab-item>
 </%def>
 
 <%def name="render_profile_tabs()">
@@ -1302,7 +1357,7 @@
   ${self.render_member_tab()}
   ${self.render_customer_tab()}
   % if expose_customer_shoppers:
-  ${self.render_shopper_tab()}
+      ${self.render_shopper_tab()}
   % endif
   ${self.render_employee_tab()}
   ${self.render_notes_tab()}
@@ -1422,8 +1477,14 @@
 <%def name="render_this_page_template()">
   ${parent.render_this_page_template()}
   ${self.render_personal_tab_template()}
+  ${self.render_member_tab_template()}
+  ${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()}
+  ${self.render_user_tab_template()}
   ${self.render_profile_info_template()}
 </%def>
 
@@ -1431,127 +1492,95 @@
   <script type="text/javascript">
 
     let PersonalTabData = {
+        refreshTabURL: '${url('people.profile_tab_personal', uuid=person.uuid)}',
 
-        editNameShowDialog: false,
-        personFirstName: null,
-        personMiddleName: null,
-        personLastName: null,
+        % if request.has_perm('people_profile.edit_person'):
+            editNameShowDialog: false,
+            editNameFirst: null,
+            editNameMiddle: null,
+            editNameLast: null,
 
-        editAddressShowDialog: false,
-        personStreet1: null,
-        personStreet2: null,
-        personCity: null,
-        personState: null,
-        personZipcode: null,
-        personInvalidAddress: false,
+            editAddressShowDialog: false,
+            editAddressStreet1: null,
+            editAddressStreet2: null,
+            editAddressCity: null,
+            editAddressState: null,
+            editAddressZipcode: null,
+            editAddressInvalid: false,
 
-        editPhoneShowDialog: false,
-        phoneUUID: null,
-        phoneType: null,
-        phoneNumber: null,
-        phonePreferred: false,
-        savingPhone: false,
+            editPhoneShowDialog: false,
+            editPhoneUUID: null,
+            editPhoneType: null,
+            editPhoneNumber: null,
+            editPhonePreferred: false,
+            editPhoneSaving: false,
 
-        editEmailShowDialog: false,
-        emailUUID: null,
-        emailType: null,
-        emailAddress: null,
-        emailPreferred: null,
-        emailInvalid: false,
-        editEmailSaving: false,
+            editEmailShowDialog: false,
+            editEmailUUID: null,
+            editEmailType: null,
+            editEmailAddress: null,
+            editEmailPreferred: null,
+            editEmailInvalid: false,
+            editEmailSaving: false,
+        % endif
     }
 
     let PersonalTab = {
         template: '#personal-tab-template',
-        mixins: [SubmitMixin],
+        mixins: [TabMixin, SimpleRequestMixin],
         props: {
             person: Object,
-            member: Object,
             phoneTypeOptions: Array,
             emailTypeOptions: Array,
             maxLengths: Object,
         },
         computed: {
-            % if request.has_perm('people_profile.edit_person'):
-                editNameSaveDisabled: function() {
 
-                    // first and last name are required
-                    if (!this.personFirstName || !this.personLastName) {
+            % if request.has_perm('people_profile.edit_person'):
+
+                editNameSaveDisabled: function() {
+                    if (!this.editNameFirst || !this.editNameLast) {
                         return true
                     }
-
-                    // otherwise don't disable; let user save
                     return false
                 },
 
                 editAddressSaveDisabled: function() {
-
                     // TODO: should require anything here?
-
-                    // otherwise don't disable; let user save
                     return false
                 },
 
-                editPhoneSaveText() {
-                    if (this.savingPhone) {
-                        return "Working..."
-                    }
-                    return "Save"
-                },
-
                 editPhoneSaveDisabled: function() {
-                    if (this.savingPhone) {
+                    if (this.editPhoneSaving) {
                         return true
                     }
-
-                    // phone type is required
-                    if (!this.phoneType) {
+                    if (!this.editPhoneType) {
                         return true
                     }
-
-                    // phone number is required
-                    if (!this.phoneNumber) {
+                    if (!this.editPhoneNumber) {
                         return true
                     }
-
-                    // otherwise don't disable; let user save
                     return false
                 },
 
-                editEmailSaveText() {
-                    if (this.editEmailSaving) {
-                        return "Working, please wait..."
-                    }
-                    return "Save"
-                },
-
                 editEmailSaveDisabled: function() {
-
-                    // disable if currently submitting form
                     if (this.editEmailSaving) {
                         return true
                     }
-
-                    // email type is required
-                    if (!this.emailType) {
+                    if (!this.editEmailType) {
                         return true
                     }
-
-                    // email address is required
-                    if (!this.emailAddress) {
+                    if (!this.editEmailAddress) {
                         return true
                     }
-
-                    // otherwise don't disable; let user save
                     return false
                 },
+
             % endif
         },
         methods: {
 
-            changeContentTitle(newTitle) {
-                this.$emit('change-content-title', newTitle)
-            },
+            // refreshTabSuccess(response) {},
 
             % if request.has_perm('people_profile.edit_person'):
 
@@ -1560,59 +1589,53 @@
                 },
 
                 editNameInit() {
-                    this.personFirstName = this.person.first_name
-                    this.personMiddleName = this.person.middle_name
-                    this.personLastName = this.person.last_name
+                    this.editNameFirst = this.person.first_name
+                    this.editNameMiddle = this.person.middle_name
+                    this.editNameLast = this.person.last_name
                     this.editNameShowDialog = true
                 },
 
                 editNameSave() {
                     let url = '${url('people.profile_edit_name', uuid=person.uuid)}'
-
                     let params = {
-                        first_name: this.personFirstName,
-                        middle_name: this.personMiddleName,
-                        last_name: this.personLastName,
+                        first_name: this.editNameFirst,
+                        middle_name: this.editNameMiddle,
+                        last_name: this.editNameLast,
                     }
 
-                    let that = this
-                    this.submitData(url, params, function(response) {
-                        that.$emit('person-updated', response.data.person)
-                        that.editNameShowDialog = false
-                        // TODO: not sure this is standard upstream, or just in bespoke?
-                        if (response.data.dynamic_content_title) {
-                            that.$emit('change-content-title', response.data.dynamic_content_title)
-                        }
+                    this.simplePOST(url, params, response => {
+                        this.$emit('profile-changed', response.data)
+                        this.editNameShowDialog = false
+                        this.refreshTab()
                     })
                 },
 
                 editAddressInit() {
                     let address = this.person.address
-                    this.personStreet1 = address ? address.street : null
-                    this.personStreet2 = address ? address.street2 : null
-                    this.personCity = address ? address.city : null
-                    this.personState = address ? address.state : null
-                    this.personZipcode = address ? address.zipcode : null
-                    this.personInvalidAddress = address ? address.invalid : false
+                    this.editAddressStreet1 = address ? address.street : null
+                    this.editAddressStreet2 = address ? address.street2 : null
+                    this.editAddressCity = address ? address.city : null
+                    this.editAddressState = address ? address.state : null
+                    this.editAddressZipcode = address ? address.zipcode : null
+                    this.editAddressInvalid = address ? address.invalid : false
                     this.editAddressShowDialog = true
                 },
 
                 editAddressSave() {
                     let url = '${url('people.profile_edit_address', uuid=person.uuid)}'
-
                     let params = {
-                        street: this.personStreet1,
-                        street2: this.personStreet2,
-                        city: this.personCity,
-                        state: this.personState,
-                        zipcode: this.personZipcode,
-                        invalid: this.personInvalidAddress,
+                        street: this.editAddressStreet1,
+                        street2: this.editAddressStreet2,
+                        city: this.editAddressCity,
+                        state: this.editAddressState,
+                        zipcode: this.editAddressZipcode,
+                        invalid: this.editAddressInvalid,
                     }
 
-                    let that = this
-                    this.submitData(url, params, function(response) {
-                        that.$emit('person-updated', response.data.person)
-                        that.editAddressShowDialog = false
+                    this.simplePOST(url, params, response => {
+                        this.$emit('profile-changed', response.data)
+                        this.editAddressShowDialog = false
+                        this.refreshTab()
                     })
                 },
 
@@ -1626,10 +1649,10 @@
                 },
 
                 editPhoneInit(phone) {
-                    this.phoneUUID = phone.uuid
-                    this.phoneType = phone.type
-                    this.phoneNumber = phone.number
-                    this.phonePreferred = phone.preferred
+                    this.editPhoneUUID = phone.uuid
+                    this.editPhoneType = phone.type
+                    this.editPhoneNumber = phone.number
+                    this.editPhonePreferred = phone.preferred
                     this.editPhoneShowDialog = true
                     this.$nextTick(function() {
                         this.$refs.editPhoneInput.focus()
@@ -1637,63 +1660,54 @@
                 },
 
                 editPhoneSave() {
-                    this.savingPhone = true
+                    this.editPhoneSaving = true
 
                     let url
                     let params = {
-                        phone_number: this.phoneNumber,
-                        phone_type: this.phoneType,
-                        phone_preferred: this.phonePreferred,
+                        phone_number: this.editPhoneNumber,
+                        phone_type: this.editPhoneType,
+                        phone_preferred: this.editPhonePreferred,
                     }
 
-                    if (this.phoneUUID) {
+                    // nb. create or update
+                    if (this.editPhoneUUID) {
                         url = '${url('people.profile_update_phone', uuid=person.uuid)}'
-                        params.phone_uuid = this.phoneUUID
+                        params.phone_uuid = this.editPhoneUUID
                     } else {
                         url = '${url('people.profile_add_phone', uuid=person.uuid)}'
                     }
 
-                    let that = this
-                    this.submitData(url, params, function(response) {
-                        that.$emit('person-updated', response.data.person)
-                        that.editPhoneShowDialog = false
-                        that.savingPhone = false
+                    this.simplePOST(url, params, response => {
+                        this.$emit('profile-changed', response.data)
+                        this.editPhoneShowDialog = false
+                        this.editPhoneSaving = false
+                        this.refreshTab()
+                    }, response => {
+                        this.editPhoneSaving = false
                     })
                 },
 
-                deletePhone(phone) {
+                deletePhoneInit(phone) {
                     let url = '${url('people.profile_delete_phone', uuid=person.uuid)}'
-
                     let params = {
                         phone_uuid: phone.uuid,
                     }
 
-                    let that = this
-                    this.submitData(url, params, function(response) {
-                        that.$emit('person-updated', response.data.person)
-                        that.$buefy.toast.open({
-                            message: "Phone number was deleted.",
-                            type: 'is-info',
-                            duration: 3000, // 3 seconds
-                        })
+                    this.simplePOST(url, params, response => {
+                        this.$emit('profile-changed', response.data)
+                        this.refreshTab()
                     })
                 },
 
-                setPreferredPhone(phone) {
+                preferPhoneInit(phone) {
                     let url = '${url('people.profile_set_preferred_phone', uuid=person.uuid)}'
-
                     let params = {
                         phone_uuid: phone.uuid,
                     }
 
-                    let that = this
-                    this.submitData(url, params, function(response) {
-                        that.$emit('person-updated', response.data.person)
-                        that.$buefy.toast.open({
-                            message: "Phone preference updated!",
-                            type: 'is-info',
-                            duration: 3000, // 3 seconds
-                        })
+                    this.simplePOST(url, params, response => {
+                        this.$emit('profile-changed', response.data)
+                        this.refreshTab()
                     })
                 },
 
@@ -1708,11 +1722,11 @@
                 },
 
                 editEmailInit(email) {
-                    this.emailUUID = email.uuid
-                    this.emailType = email.type
-                    this.emailAddress = email.address
-                    this.emailInvalid = email.invalid
-                    this.emailPreferred = email.preferred
+                    this.editEmailUUID = email.uuid
+                    this.editEmailType = email.type
+                    this.editEmailAddress = email.address
+                    this.editEmailInvalid = email.invalid
+                    this.editEmailPreferred = email.preferred
                     this.editEmailShowDialog = true
                     this.$nextTick(function() {
                         this.$refs.editEmailInput.focus()
@@ -1724,62 +1738,50 @@
 
                     let url = null
                     let params = {
-                        email_address: this.emailAddress,
-                        email_type: this.emailType,
+                        email_address: this.editEmailAddress,
+                        email_type: this.editEmailType,
                     }
 
-                    if (this.emailUUID) {
+                    if (this.editEmailUUID) {
                         url = '${url('people.profile_update_email', uuid=person.uuid)}'
-                        params.email_uuid = this.emailUUID
-                        params.email_invalid = this.emailInvalid
+                        params.email_uuid = this.editEmailUUID
+                        params.email_invalid = this.editEmailInvalid
                     } else {
                         url = '${url('people.profile_add_email', uuid=person.uuid)}'
-                        params.email_preferred = this.emailPreferred
+                        params.email_preferred = this.editEmailPreferred
                     }
 
-                    let that = this
-                    this.submitData(url, params, function(response) {
-                        that.$emit('person-updated', response.data.person)
-                        that.editEmailShowDialog = false
-                        that.editEmailSaving = false
-                    }, function(error) {
-                        that.editEmailSaving = false
+                    this.simplePOST(url, params, response => {
+                        this.$emit('profile-changed', response.data)
+                        this.editEmailShowDialog = false
+                        this.editEmailSaving = false
+                        this.refreshTab()
+                    }, response => {
+                        this.editEmailSaving = false
                     })
                 },
 
-                deleteEmail(email) {
+                deleteEmailInit(email) {
                     let url = '${url('people.profile_delete_email', uuid=person.uuid)}'
-
                     let params = {
                         email_uuid: email.uuid,
                     }
 
-                    let that = this
-                    this.submitData(url, params, function(response) {
-                        that.$emit('person-updated', response.data.person)
-                        that.$buefy.toast.open({
-                            message: "Email address was deleted.",
-                            type: 'is-info',
-                            duration: 3000, // 3 seconds
-                        })
+                    this.simplePOST(url, params, response => {
+                        this.$emit('profile-changed', response.data)
+                        this.refreshTab()
                     })
                 },
 
-                setPreferredEmail(email) {
+                preferEmailInit(email) {
                     let url = '${url('people.profile_set_preferred_email', uuid=person.uuid)}'
-
                     let params = {
                         email_uuid: email.uuid,
                     }
 
-                    let that = this
-                    this.submitData(url, params, function(response) {
-                        that.$emit('person-updated', response.data.person)
-                        that.$buefy.toast.open({
-                            message: "Email preference updated!",
-                            type: 'is-info',
-                            duration: 3000, // 3 seconds
-                        })
+                    this.simplePOST(url, params, response => {
+                        this.$emit('profile-changed', response.data)
+                        this.refreshTab()
                     })
                 },
 
@@ -1800,80 +1802,190 @@
   </script>
 </%def>
 
+<%def name="declare_member_tab_vars()">
+  <script type="text/javascript">
+
+    let MemberTabData = {
+        refreshTabURL: '${url('people.profile_tab_member', uuid=person.uuid)}',
+        % if max_one_member:
+            member: {},
+        % else:
+            members: [],
+        % endif
+    }
+
+    let MemberTab = {
+        template: '#member-tab-template',
+        mixins: [TabMixin, SimpleRequestMixin],
+        props: {
+            person: Object,
+            phoneTypeOptions: Array,
+        },
+        computed: {},
+        methods: {
+
+            refreshTabSuccess(response) {
+                % if max_one_member:
+                    this.member = response.data.member
+                % else:
+                    this.members = response.data.members
+                % endif
+            },
+        },
+    }
+
+  </script>
+</%def>
+
+<%def name="make_member_tab_component()">
+  ${self.declare_member_tab_vars()}
+  <script type="text/javascript">
+
+    MemberTab.data = function() { return MemberTabData }
+    Vue.component('member-tab', MemberTab)
+
+  </script>
+</%def>
+
+<%def name="declare_customer_tab_vars()">
+  <script type="text/javascript">
+
+    let CustomerTabData = {
+        refreshTabURL: '${url('people.profile_tab_customer', uuid=person.uuid)}',
+        customers: [],
+    }
+
+    let CustomerTab = {
+        template: '#customer-tab-template',
+        mixins: [TabMixin, SimpleRequestMixin],
+        props: {
+            person: Object,
+        },
+        computed: {},
+        methods: {
+
+            refreshTabSuccess(response) {
+                this.customers = response.data.customers
+            },
+        },
+    }
+
+  </script>
+</%def>
+
+<%def name="make_customer_tab_component()">
+  ${self.declare_customer_tab_vars()}
+  <script type="text/javascript">
+
+    CustomerTab.data = function() { return CustomerTabData }
+    Vue.component('customer-tab', CustomerTab)
+
+  </script>
+</%def>
+
+<%def name="declare_shopper_tab_vars()">
+  <script type="text/javascript">
+
+    let ShopperTabData = {
+        refreshTabURL: '${url('people.profile_tab_shopper', uuid=person.uuid)}',
+        shoppers: [],
+    }
+
+    let ShopperTab = {
+        template: '#shopper-tab-template',
+        mixins: [TabMixin, SimpleRequestMixin],
+        props: {
+            person: Object,
+        },
+        computed: {},
+        methods: {
+
+            refreshTabSuccess(response) {
+                this.shoppers = response.data.shoppers
+            },
+        },
+    }
+
+  </script>
+</%def>
+
+<%def name="make_shopper_tab_component()">
+  ${self.declare_shopper_tab_vars()}
+  <script type="text/javascript">
+
+    ShopperTab.data = function() { return ShopperTabData }
+    Vue.component('shopper-tab', ShopperTab)
+
+  </script>
+</%def>
+
 <%def name="declare_employee_tab_vars()">
   <script type="text/javascript">
 
     let EmployeeTabData = {
-
-        startEmployeeShowDialog: false,
-        employeeID: null,
-        employeeStartDate: null,
-        showStopEmployeeDialog: false,
-        employeeEndDate: null,
-        employeeRevokeAccess: false,
-        showEditEmployeeHistoryDialog: false,
-        employeeHistoryUUID: null,
-        employeeHistoryStartDate: null,
-        employeeHistoryEndDate: null,
-        employeeHistoryEndDateRequired: false,
+        refreshTabURL: '${url('people.profile_tab_employee', uuid=person.uuid)}',
+        employee: {},
+        employeeHistory: [],
 
         % if request.has_perm('employees.edit'):
-        showEditEmployeeIDDialog: false,
-        newEmployeeID: null,
-        updatingEmployeeID: false,
+            editEmployeeIdShowDialog: false,
+            editEmployeeIdValue: null,
+            editEmployeeIdSaving: false,
+        % endif
+
+        % if request.has_perm('people_profile.toggle_employee'):
+            startEmployeeShowDialog: false,
+            startEmployeeID: null,
+            startEmployeeStartDate: null,
+
+            stopEmployeeShowDialog: false,
+            stopEmployeeEndDate: null,
+            stopEmployeeRevokeAccess: false,
+        % endif
+
+        % if request.has_perm('people_profile.edit_employee_history'):
+            editEmployeeHistoryShowDialog: false,
+            editEmployeeHistoryUUID: null,
+            editEmployeeHistoryStartDate: null,
+            editEmployeeHistoryEndDate: null,
+            editEmployeeHistoryEndDateRequired: false,
         % endif
     }
 
     let EmployeeTab = {
         template: '#employee-tab-template',
-        mixins: [SubmitMixin],
+        mixins: [TabMixin, SimpleRequestMixin],
         props: {
-            employee: Object,
-            employeeHistory: Array,
+            person: Object,
         },
-
-        computed: {
-
-            % if request.has_perm('employees.edit'):
-
-                editEmployeeIDSaveButtonText() {
-                    if (this.updatingEmployeeID) {
-                        return "Working, please wait..."
-                    }
-                    return "Save"
-                },
-
-            % endif
-        },
-
+        computed: {},
         methods: {
 
-            changeContentTitle(newTitle) {
-                this.$emit('change-content-title', newTitle)
+            refreshTabSuccess(response) {
+                this.employee = response.data.employee
+                this.employeeHistory = response.data.employee_history
             },
 
             % if request.has_perm('employees.edit'):
 
-                initEditEmployeeID() {
-                    this.newEmployeeID = this.employee.id
-                    this.updatingEmployeeID = false
-                    this.showEditEmployeeIDDialog = true
+                editEmployeeIdInit() {
+                    this.editEmployeeIdValue = this.employee.id
+                    this.editEmployeeIdShowDialog = true
                 },
 
-                updateEmployeeID() {
-                    this.updatingEmployeeID = true
-
+                editEmployeeIdSave() {
+                    this.editEmployeeIdSaving = true
                     let url = '${url('people.profile_update_employee_id', uuid=instance.uuid)}'
-
                     let params = {
-                        'employee_id': this.newEmployeeID,
+                        'employee_id': this.editEmployeeIdValue,
                     }
-
-                    let that = this
-                    this.submitData(url, params, function(response) {
-                        that.$emit('employee-updated', response.data.employee)
-                        that.showEditEmployeeIDDialog = false
-                        that.updatingEmployeeID = false
+                    this.simplePOST(url, params, response => {
+                        this.$emit('profile-changed', response.data)
+                        this.editEmployeeIdShowDialog = false
+                        this.editEmployeeIdSaving = false
+                        this.refreshTab()
+                    }, response => {
+                        this.editEmployeeIdSaving = false
                     })
                 },
 
@@ -1882,90 +1994,67 @@
             % if request.has_perm('people_profile.toggle_employee'):
 
                 startEmployeeInit() {
-                    this.employeeID = this.employee.id || null
+                    this.startEmployeeID = this.employee.id || null
+                    this.startEmployeeStartDate = null
                     this.startEmployeeShowDialog = true
                 },
 
-                startEmployee() {
+                startEmployeeSave() {
                     let url = '${url('people.profile_start_employee', uuid=person.uuid)}'
-
                     let params = {
-                        id: this.employeeID,
-                        start_date: this.employeeStartDate,
+                        id: this.startEmployeeID,
+                        start_date: this.startEmployeeStartDate,
                     }
 
-                    let that = this
-                    this.submitData(url, params, function(response) {
-                        that.startEmployeeSuccess(response.data)
+                    this.simplePOST(url, params, response => {
+                        this.$emit('profile-changed', response.data)
+                        this.startEmployeeShowDialog = false
+                        this.refreshTab()
                     })
                 },
 
-                startEmployeeSuccess(data) {
-                    this.$emit('employee-updated', data.employee)
-                    this.$emit('employee-history-updated', data.employee_history_data)
-                    this.$emit('change-content-title', data.dynamic_content_title)
-
-                    // let derived component do more here if needed
-                    this.startEmployeeSuccessExtra(data)
-
-                    this.startEmployeeShowDialog = false
+                stopEmployeeInit() {
+                    this.stopEmployeeShowDialog = true
                 },
 
-                startEmployeeSuccessExtra(data) {},
-
-                endEmployee() {
+                stopEmployeeSave() {
                     let url = '${url('people.profile_end_employee', uuid=person.uuid)}'
-
                     let params = {
-                        end_date: this.employeeEndDate,
-                        revoke_access: this.employeeRevokeAccess,
+                        end_date: this.stopEmployeeEndDate,
+                        revoke_access: this.stopEmployeeRevokeAccess,
                     }
 
-                    let that = this
-                    this.submitData(url, params, function(response) {
-                        that.endEmployeeSuccess(response.data)
+                    this.simplePOST(url, params, response => {
+                        this.$emit('profile-changed', response.data)
+                        this.stopEmployeeShowDialog = false
+                        this.refreshTab()
                     })
                 },
 
-                endEmployeeSuccess(data) {
-                    this.$emit('employee-updated', data.employee)
-                    this.$emit('employee-history-updated', data.employee_history_data)
-                    this.$emit('change-content-title', data.dynamic_content_title)
-
-                    // let derived component do more here if needed
-                    this.startEmployeeSuccessExtra(data)
-
-                    this.showStopEmployeeDialog = false
-                },
-
-                endEmployeeSuccessExtra(data) {},
-
             % endif
 
             % if request.has_perm('people_profile.edit_employee_history'):
 
-                editEmployeeHistory(row) {
-                    this.employeeHistoryUUID = row.uuid
-                    this.employeeHistoryStartDate = row.start_date
-                    this.employeeHistoryEndDate = row.end_date
-                    this.employeeHistoryEndDateRequired = !!row.end_date
-                    this.showEditEmployeeHistoryDialog = true
+                editEmployeeHistoryInit(row) {
+                    this.editEmployeeHistoryUUID = row.uuid
+                    this.editEmployeeHistoryStartDate = row.start_date
+                    this.editEmployeeHistoryEndDate = row.end_date
+                    this.editEmployeeHistoryEndDateRequired = !!row.end_date
+                    this.editEmployeeHistoryShowDialog = true
                 },
 
-                saveEmployeeHistory() {
+                editEmployeeHistorySave() {
                     let url = '${url('people.profile_edit_employee_history', uuid=person.uuid)}'
-
                     let params = {
-                        uuid: this.employeeHistoryUUID,
-                        start_date: this.employeeHistoryStartDate,
-                        end_date: this.employeeHistoryEndDate,
+                        uuid: this.editEmployeeHistoryUUID,
+                        start_date: this.editEmployeeHistoryStartDate,
+                        end_date: this.editEmployeeHistoryEndDate,
                     }
 
-                    let that = this
-                    this.submitData(url, params, function(response) {
-                        that.$emit('employee-updated', response.data.employee)
-                        that.$emit('employee-history-updated', response.data.employee_history_data)
-                        that.showEditEmployeeHistoryDialog = false
+                    this.simplePOST(url, params, response => {
+                        this.$emit('profile-changed', response.data)
+                        this.editEmployeeHistoryShowDialog = false
+                        this.refreshTab()
                     })
                 },
 
@@ -1990,112 +2079,102 @@
   <script type="text/javascript">
 
     let NotesTabData = {
-        noteShowDialog: false,
-        noteUUID: null,
-        noteType: null,
-        noteSubject: null,
-        noteText: null,
-        noteDeleting: false,
-        noteSaving: false,
+        refreshTabURL: '${url('people.profile_tab_notes', uuid=person.uuid)}',
+        notes: [],
+        noteTypeOptions: [],
+
+        % if request.has_any_perm('people_profile.add_note', 'people_profile.edit_note', 'people_profile.delete_note'):
+            editNoteShowDialog: false,
+            editNoteUUID: null,
+            editNoteDelete: false,
+            editNoteType: null,
+            editNoteSubject: null,
+            editNoteText: null,
+            editNoteSaving: false,
+        % endif
     }
 
     let NotesTab = {
         template: '#notes-tab-template',
-        mixins: [SimpleRequestMixin],
+        mixins: [TabMixin, SimpleRequestMixin],
         props: {
-            notes: Array,
-            noteTypeOptions: Array,
+            person: Object,
         },
-
-        computed: {
-
-            noteDialogTitle() {
-                if (this.noteUUID) {
-                    if (this.noteDeleting) {
-                        return "Delete Note"
-                    }
-                    return "Edit Note"
-                }
-                return "New Note"
-            },
-
-            noteSaveText() {
-                if (this.noteDeleting) {
-                    return "Delete Note"
-                }
-                return "Save Note"
-            },
-
-        },
-
+        computed: {},
         methods: {
 
+            refreshTabSuccess(response) {
+                this.notes = response.data.notes
+                this.noteTypeOptions = response.data.note_types
+            },
+
             % if request.has_perm('people_profile.add_note'):
 
-                noteNew() {
-                    this.noteUUID = null
-                    this.noteType = null
-                    this.noteSubject = null
-                    this.noteText = null
-                    this.noteDeleting = false
-                    this.noteShowDialog = true
+                addNoteInit() {
+                    this.editNoteUUID = null
+                    this.editNoteType = null
+                    this.editNoteSubject = null
+                    this.editNoteText = null
+                    this.editNoteDelete = false
+                    this.editNoteShowDialog = true
                 },
 
             % endif
 
             % if request.has_perm('people_profile.edit_note'):
 
-                noteEdit(note) {
-                    this.noteUUID = note.uuid
-                    this.noteType = note.note_type
-                    this.noteSubject = note.subject
-                    this.noteText = note.text
-                    this.noteDeleting = false
-                    this.noteShowDialog = true
+                editNoteInit(note) {
+                    this.editNoteUUID = note.uuid
+                    this.editNoteType = note.note_type
+                    this.editNoteSubject = note.subject
+                    this.editNoteText = note.text
+                    this.editNoteDelete = false
+                    this.editNoteShowDialog = true
                 },
 
             % endif
 
             % if request.has_perm('people_profile.delete_note'):
 
-                noteDelete(note) {
-                    this.noteUUID = note.uuid
-                    this.noteType = note.note_type
-                    this.noteSubject = note.subject
-                    this.noteText = note.text
-                    this.noteDeleting = true
-                    this.noteShowDialog = true
+                deleteNoteInit(note) {
+                    this.editNoteUUID = note.uuid
+                    this.editNoteType = note.note_type
+                    this.editNoteSubject = note.subject
+                    this.editNoteText = note.text
+                    this.editNoteDelete = true
+                    this.editNoteShowDialog = true
                 },
 
             % endif
 
             % if request.has_any_perm('people_profile.add_note', 'people_profile.edit_note', 'people_profile.delete_note'):
 
-                noteSave() {
-                    this.noteSaving = true
+                editNoteSave() {
+                    this.editNoteSaving = true
 
                     let url = null
-                    if (!this.noteUUID) {
+                    if (!this.editNoteUUID) {
                         url = '${master.get_action_url('profile_add_note', instance)}'
-                    } else if (this.noteDeleting) {
+                    } else if (this.editNoteDelete) {
                         url = '${master.get_action_url('profile_delete_note', instance)}'
                     } else {
                         url = '${master.get_action_url('profile_edit_note', instance)}'
                     }
 
                     let params = {
-                        uuid: this.noteUUID,
-                        note_type: this.noteType,
-                        note_subject: this.noteSubject,
-                        note_text: this.noteText,
+                        uuid: this.editNoteUUID,
+                        note_type: this.editNoteType,
+                        note_subject: this.editNoteSubject,
+                        note_text: this.editNoteText,
                     }
 
                     this.simplePOST(url, params, response => {
-                        this.$emit('new-notes-data', response.data.notes)
-                        this.noteSaving = false
-                        this.noteShowDialog = false
+                        this.$emit('profile-changed', response.data)
+                        this.editNoteSaving = false
+                        this.editNoteShowDialog = false
+                        this.refreshTab()
                     }, response => {
-                        this.notesSaving = false
+                        this.editNoteSaving = false
                     })
                 },
 
@@ -2116,76 +2195,106 @@
   </script>
 </%def>
 
+<%def name="declare_user_tab_vars()">
+  <script type="text/javascript">
+
+    let UserTabData = {
+        refreshTabURL: '${url('people.profile_tab_user', uuid=person.uuid)}',
+        users: [],
+    }
+
+    let UserTab = {
+        template: '#user-tab-template',
+        mixins: [TabMixin, SimpleRequestMixin],
+        props: {
+            person: Object,
+        },
+        computed: {},
+        methods: {
+
+            refreshTabSuccess(response) {
+                this.users = response.data.users
+            },
+        },
+    }
+
+  </script>
+</%def>
+
+<%def name="make_user_tab_component()">
+  ${self.declare_user_tab_vars()}
+  <script type="text/javascript">
+
+    UserTab.data = function() { return UserTabData }
+    Vue.component('user-tab', UserTab)
+
+  </script>
+</%def>
+
 <%def name="declare_profile_info_vars()">
   <script type="text/javascript">
 
     let ProfileInfoData = {
         activeTab: location.hash ? location.hash.substring(1) : undefined,
+        tabchecks: ${json.dumps(tabchecks)|n},
+        today: '${rattail_app.today()}',
+        profileLastChanged: Date.now(),
         person: ${json.dumps(person_data)|n},
-        customers: ${json.dumps(customers_data)|n},
-        % if expose_customer_shoppers:
-        shoppers: ${json.dumps(shoppers_data)|n},
-        % endif
-        member: null,           // TODO
-        members: ${json.dumps(members_data)|n},
-        employee: ${json.dumps(employee_data)|n},
-        employeeHistory: ${json.dumps(employee_history_data)|n},
-        notes: ${json.dumps(notes_data)|n},
-        noteTypeOptions: ${json.dumps(note_type_options)|n},
-        users: ${json.dumps(users_data)|n},
         phoneTypeOptions: ${json.dumps(phone_type_options)|n},
         emailTypeOptions: ${json.dumps(email_type_options)|n},
         maxLengths: ${json.dumps(max_lengths)|n},
 
         % if request.has_perm('people_profile.view_versions'):
-        loadingRevisions: false,
-        showingRevisionDialog: false,
-        revision: {},
-        revisionShowAllFields: false,
+            loadingRevisions: false,
+            showingRevisionDialog: false,
+            revision: {},
+            revisionShowAllFields: false,
         % endif
     }
 
     let ProfileInfo = {
         template: '#profile-info-template',
-        mixins: [FormPosterMixin],
-
-        % if request.has_perm('people_profile.view_versions'):
         props: {
-            viewingHistory: Boolean,
-            gettingRevisions: Boolean,
-            revisions: Array,
-            revisionVersionMap: null,
+            % if request.has_perm('people_profile.view_versions'):
+                viewingHistory: Boolean,
+                gettingRevisions: Boolean,
+                revisions: Array,
+                revisionVersionMap: null,
+            % endif
         },
-        % 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: {
 
-            newNotesData(notes) {
-                this.notes = notes
-            },
-
-            personUpdated(person) {
-                this.person = person
-            },
-
-            employeeUpdated(employee) {
-                this.employee = employee
-            },
-
-            employeeHistoryUpdated(employeeHistory) {
-                this.employeeHistory = employeeHistory
-            },
-
-            changeContentTitle(newTitle) {
-                this.$emit('change-content-title', newTitle)
+            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'):
@@ -2221,7 +2330,6 @@
   <script type="text/javascript">
 
     ProfileInfo.data = function() { return ProfileInfoData }
-
     Vue.component('profile-info', ProfileInfo)
 
   </script>
@@ -2232,54 +2340,48 @@
   <script type="text/javascript">
 
     % if request.has_perm('people_profile.view_versions'):
-    ThisPage.props.viewingHistory = Boolean
-    ThisPage.props.gettingRevisions = Boolean
-    ThisPage.props.revisions = Array
-    ThisPage.props.revisionVersionMap = null
+        ThisPage.props.viewingHistory = Boolean
+        ThisPage.props.gettingRevisions = Boolean
+        ThisPage.props.revisions = Array
+        ThisPage.props.revisionVersionMap = null
     % endif
 
-    ThisPage.methods.changeContentTitle = function(newTitle) {
-        this.$emit('change-content-title', newTitle)
-    }
+    let TabMixin = {
 
-    var SubmitMixin = {
         data() {
             return {
-                csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n},
+                refreshed: null,
+                refreshTabURL: null,
+                refreshingTab: false,
             }
         },
-
         methods: {
-            submitData(url, params, success, failure) {
-                let headers = {
-                    'X-CSRF-TOKEN': this.csrftoken,
+
+            refreshIfNeeded(time) {
+                if (this.refreshed && time && this.refreshed > time) {
+                    return
                 }
-                this.$http.post(url, params, {headers: headers}).then((response) => {
-                    if (response.data.success) {
-                        if (success) {
-                            success(response)
-                        }
-                    } else {
-                        this.$buefy.toast.open({
-                            message: "Save failed:  " + (response.data.error || "(unknown error)"),
-                            type: 'is-danger',
-                            duration: 4000, // 4 seconds
-                        })
-                        if (failure) {
-                            failure()
-                        }
-                    }
-                }).catch((error) => {
-                    this.$buefy.toast.open({
-                        message: "Save failed:  (unknown error)",
-                        type: 'is-danger',
-                        duration: 4000, // 4 seconds
-                    })
-                    if (failure) {
-                        failure()
-                    }
-                })
+                this.refreshTab()
             },
+
+            refreshTab() {
+
+                if (this.refreshTabURL) {
+                    this.refreshingTab = true
+                    this.simpleGET(this.refreshTabURL, {}, response => {
+                        this.refreshTabSuccess(response)
+                        this.refreshTabSuccessExtra(response)
+                        this.refreshed = Date.now()
+                        this.refreshingTab = false
+                    })
+                }
+            },
+
+            // nb. subclass must define this as needed
+            refreshTabSuccess(response) {},
+
+            // nb. subclass may define this if needed
+            refreshTabSuccessExtra(response) {},
         },
     }
 
@@ -2289,8 +2391,14 @@
 <%def name="make_this_page_component()">
   ${parent.make_this_page_component()}
   ${self.make_personal_tab_component()}
+  ${self.make_member_tab_component()}
+  ${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()}
+  ${self.make_user_tab_component()}
   ${self.make_profile_info_component()}
 </%def>
 
diff --git a/tailbone/views/members.py b/tailbone/views/members.py
index 197efa41..a004b5a3 100644
--- a/tailbone/views/members.py
+++ b/tailbone/views/members.py
@@ -390,6 +390,11 @@ class MemberView(MasterView):
             {'section': 'rattail',
              'option': 'members.straight_to_profile',
              'type': bool},
+
+            # Relationships
+            {'section': 'rattail',
+             'option': 'members.max_one_per_person',
+             'type': bool},
         ]
 
 
diff --git a/tailbone/views/people.py b/tailbone/views/people.py
index d7f84849..0aaf4c26 100644
--- a/tailbone/views/people.py
+++ b/tailbone/views/people.py
@@ -455,63 +455,81 @@ class PersonView(MasterView):
         self.viewing = True
         app = self.get_rattail_app()
         person = self.get_instance()
-        employee = app.get_employee(person)
+
         context = {
             'person': person,
             'instance': person,
             'instance_title': self.get_instance_title(person),
-            'today': localtime(self.rattail_config).date(),
+            'dynamic_content_title': self.get_context_content_title(person),
+            'tabchecks': self.get_context_tabchecks(person),
             'person_data': self.get_context_person(person),
             'phone_type_options': self.get_phone_type_options(),
             'email_type_options': self.get_email_type_options(),
             'max_lengths': self.get_max_lengths(),
-            'customers_data': self.get_context_customers(person),
-            # TODO: deprecate / remove this
-            'customer_xref_buttons': self.get_customer_xref_buttons(person),
             'expose_customer_people': self.customers_should_expose_people(),
             'expose_customer_shoppers': self.customers_should_expose_shoppers(),
-            'members_data': self.get_context_members(person),
-            'member_xref_buttons': self.get_member_xref_buttons(person),
-            'employee': employee,
-            'employee_data': self.get_context_employee(employee) if employee else {},
-            'employee_view_url': self.request.route_url('employees.view', uuid=employee.uuid) if employee else None,
-            'employee_history': employee.get_current_history() if employee else None,
-            'employee_history_data': self.get_context_employee_history(employee),
-            'notes_data': self.get_context_notes(person),
-            'note_type_options': self.get_note_type_options(),
-            'users_data': self.get_context_users(person),
-            'dynamic_content_title': self.get_context_content_title(person),
+            'max_one_member': app.get_membership_handler().max_one_per_person(),
         }
 
-        if context['expose_customer_shoppers']:
-            shoppers = person.customer_shoppers
-            # TODO: what a hack! surely this belongs in handler at least..?
-            shoppers = [shopper for shopper in shoppers
-                        if shopper.shopper_number != 1]
-            context['shoppers_data'] = self.get_context_shoppers(shoppers)
-
         if self.request.has_perm('people_profile.view_versions'):
             context['revisions_grid'] = self.profile_revisions_grid(person)
 
-        template = 'view_profile_buefy'
-        return self.render_to_response(template, context)
+        return self.render_to_response('view_profile_buefy', context)
 
-    # TODO: deprecate / remove this
-    def get_customer_xref_buttons(self, person):
-        buttons = []
-        for supp in self.iter_view_supplements():
-            if hasattr(supp, 'get_customer_xref_buttons'):
-                buttons.extend(supp.get_customer_xref_buttons(person) or [])
-        buttons = self.normalize_xref_buttons(buttons)
-        return buttons
+    def get_context_tabchecks(self, person):
+        app = self.get_rattail_app()
+        membership = app.get_membership_handler()
+        clientele = app.get_clientele_handler()
+        tabchecks = {}
 
-    def get_member_xref_buttons(self, person):
-        buttons = []
-        for supp in self.iter_view_supplements():
-            if hasattr(supp, 'get_member_xref_buttons'):
-                buttons.extend(supp.get_member_xref_buttons(person) or [])
-        buttons = self.normalize_xref_buttons(buttons)
-        return buttons
+        # TODO: for efficiency, should only calculate checks for tabs
+        # actually in use by app..(how) should that be configurable?
+
+        # personal
+        tabchecks['personal'] = True
+
+        # member
+        if membership.max_one_per_person():
+            member = app.get_member(person)
+            tabchecks['member'] = bool(member and member.active)
+        else:
+            members = membership.get_members_for_account_holder(person)
+            tabchecks['member'] = any([m.active for m in members])
+
+        # customer
+        customers = clientele.get_customers_for_account_holder(person)
+        tabchecks['customer'] = bool(customers)
+
+        # shopper
+        # TODO: what a hack! surely some of this belongs in handler
+        shoppers = person.customer_shoppers
+        shoppers = [shopper for shopper in shoppers
+                    if shopper.shopper_number != 1]
+        tabchecks['shopper'] = bool(shoppers)
+
+        # employee
+        employee = app.get_employee(person)
+        tabchecks['employee'] = bool(employee and employee.status == self.enum.EMPLOYEE_STATUS_CURRENT)
+
+        # notes
+        tabchecks['notes'] = bool(person.notes)
+
+        # user
+        tabchecks['user'] = bool(person.users)
+
+        return tabchecks
+
+    def profile_changed_response(self, person):
+        """
+        Return common context result for all AJAX views which may
+        change the profile details.  This is enough to update the
+        page-wide things, and let other tabs know they should be
+        refreshed when next displayed.
+        """
+        return {
+            'person': self.get_context_person(person),
+            'tabchecks': self.get_context_tabchecks(person),
+        }
 
     def template_kwargs_view_profile(self, **kwargs):
         """
@@ -716,10 +734,12 @@ class PersonView(MasterView):
 
     def get_context_member(self, member):
         app = self.get_rattail_app()
+        person = app.get_person(member)
+
         profile_url = None
-        if member.person:
+        if person:
             profile_url = self.request.route_url('people.view_profile',
-                                                 uuid=member.person_uuid)
+                                                 uuid=person.uuid)
 
         key = self.get_member_key_field()
         equity_total = sum([payment.amount for payment in member.equity_payments])
@@ -739,6 +759,7 @@ class PersonView(MasterView):
             'view_url': self.request.route_url('members.view', uuid=member.uuid),
             'view_profile_url': profile_url,
             'equity_total_display': app.render_currency(equity_total),
+            'external_links': [],
         }
 
         membership_type = member.membership_type
@@ -825,6 +846,19 @@ class PersonView(MasterView):
         customer = handler.ensure_customer(person)
         return customer
 
+    def profile_tab_personal(self):
+        """
+        Fetch personal tab data for profile view.
+        """
+        # TODO: no need to return primary person data, since that
+        # always comes back via normal profile_changed_response()
+        # ..so for now this is a no-op..
+
+        # person = self.get_instance()
+        return {
+            # 'person': self.get_context_person(person),
+        }
+
     def profile_edit_name(self):
         """
         View which allows a person's name to be updated.
@@ -838,11 +872,7 @@ class PersonView(MasterView):
                                   last=data['last_name'])
 
         self.Session.flush()
-        return {
-            'success': True,
-            'person': self.get_context_person(person),
-            'dynamic_content_title': self.get_context_content_title(person),
-        }
+        return self.profile_changed_response(person)
 
     def get_context_phones(self, person):
         data = []
@@ -872,10 +902,7 @@ class PersonView(MasterView):
             return {'error': simple_error(error)}
 
         self.Session.flush()
-        return {
-            'success': True,
-            'person': self.get_context_person(person),
-        }
+        return self.profile_changed_response(person)
 
     def profile_update_phone(self):
         """
@@ -902,10 +929,7 @@ class PersonView(MasterView):
             return {'error': simple_error(error)}
 
         self.Session.flush()
-        return {
-            'success': True,
-            'person': self.get_context_person(person),
-        }
+        return self.profile_changed_response(person)
 
     def profile_delete_phone(self):
         """
@@ -925,10 +949,7 @@ class PersonView(MasterView):
         person.remove_phone(phone)
 
         self.Session.flush()
-        return {
-            'success': True,
-            'person': self.get_context_person(person),
-        }
+        return self.profile_changed_response(person)
 
     def profile_set_preferred_phone(self):
         """
@@ -948,10 +969,7 @@ class PersonView(MasterView):
         person.set_primary_phone(phone)
 
         self.Session.flush()
-        return {
-            'success': True,
-            'person': self.get_context_person(person),
-        }
+        return self.profile_changed_response(person)
 
     def get_context_emails(self, person):
         data = []
@@ -987,10 +1005,7 @@ class PersonView(MasterView):
             return {'error': simple_error(error)}
 
         self.Session.flush()
-        return {
-            'success': True,
-            'person': self.get_context_person(person),
-        }
+        return self.profile_changed_response(person)
 
     def profile_update_email(self):
         """
@@ -1013,10 +1028,7 @@ class PersonView(MasterView):
             return {'error': simple_error(error)}
 
         self.Session.flush()
-        return {
-            'success': True,
-            'person': self.get_context_person(person),
-        }
+        return self.profile_changed_response(person)
 
     def profile_delete_email(self):
         """
@@ -1036,11 +1048,7 @@ class PersonView(MasterView):
         person.remove_email(email)
 
         self.Session.flush()
-
-        return {
-            'success': True,
-            'person': self.get_context_person(person),
-        }
+        return self.profile_changed_response(person)
 
     def profile_set_preferred_email(self):
         """
@@ -1060,10 +1068,7 @@ class PersonView(MasterView):
         person.set_primary_email(email)
 
         self.Session.flush()
-        return {
-            'success': True,
-            'person': self.get_context_person(person),
-        }
+        return self.profile_changed_response(person)
 
     def profile_edit_address(self):
         """
@@ -1077,9 +1082,66 @@ class PersonView(MasterView):
         self.people_handler.update_address(person, address, **data)
 
         self.Session.flush()
+        return self.profile_changed_response(person)
+
+    def profile_tab_member(self):
+        """
+        Fetch member tab data for profile view.
+        """
+        app = self.get_rattail_app()
+        membership = app.get_membership_handler()
+        person = self.get_instance()
+
+        max_one_member = membership.max_one_per_person()
+
+        context = {
+            'max_one_member': max_one_member,
+        }
+
+        if max_one_member:
+            member = app.get_member(person)
+            context['member'] = {'exists': bool(member)}
+            if member:
+                context['member'].update(self.get_context_member(member))
+        else:
+            context['members'] = self.get_context_members(person)
+
+        return context
+
+    def profile_tab_customer(self):
+        """
+        Fetch customer tab data for profile view.
+        """
+        person = self.get_instance()
         return {
-            'success': True,
-            'person': self.get_context_person(person),
+            'customers': self.get_context_customers(person),
+        }
+
+    def profile_tab_shopper(self):
+        """
+        Fetch shopper tab data for profile view.
+        """
+        person = self.get_instance()
+
+        # TODO: what a hack! surely some of this belongs in handler
+        shoppers = person.customer_shoppers
+        shoppers = [shopper for shopper in shoppers
+                    if shopper.shopper_number != 1]
+
+        return {
+            'shoppers': self.get_context_shoppers(shoppers),
+        }
+
+    def profile_tab_employee(self):
+        """
+        Fetch employee tab data for profile view.
+        """
+        app = self.get_rattail_app()
+        person = self.get_instance()
+        employee = app.get_employee(person)
+        return {
+            'employee': self.get_context_employee(employee) if employee else {},
+            'employee_history': self.get_context_employee_history(employee),
         }
 
     def profile_start_employee(self):
@@ -1099,18 +1161,7 @@ class PersonView(MasterView):
         employee = handler.begin_employment(person, start_date,
                                             employee_id=data['id'])
         self.Session.flush()
-        return self.profile_start_employee_result(employee, start_date)
-
-    def profile_start_employee_result(self, employee, start_date):
-        person = employee.person
-        return {
-            'success': True,
-            'employee': self.get_context_employee(employee),
-            'employee_view_url': self.request.route_url('employees.view', uuid=employee.uuid),
-            'start_date': str(start_date),
-            'employee_history_data': self.get_context_employee_history(employee),
-            'dynamic_content_title': self.get_context_content_title(person),
-        }
+        return self.profile_changed_response(person)
 
     def profile_end_employee(self):
         """
@@ -1130,18 +1181,7 @@ class PersonView(MasterView):
         handler.end_employment(employee, end_date,
                                revoke_access=data.get('revoke_access'))
         self.Session.flush()
-        return self.profile_end_employee_result(employee, end_date)
-
-    def profile_end_employee_result(self, employee, end_date):
-        person = employee.person
-        return {
-            'success': True,
-            'employee': self.get_context_employee(employee),
-            'employee_view_url': self.request.route_url('employees.view', uuid=employee.uuid),
-            'end_date': str(end_date),
-            'employee_history_data': self.get_context_employee_history(employee),
-            'dynamic_content_title': self.get_context_content_title(person),
-        }
+        return self.profile_changed_response(person)
 
     def profile_edit_employee_history(self):
         """
@@ -1167,14 +1207,7 @@ class PersonView(MasterView):
             history.end_date = end_date
 
         self.Session.flush()
-        current_history = employee.get_current_history()
-        return {
-            'success': True,
-            'employee': self.get_context_employee(employee),
-            'start_date': str(current_history.start_date),
-            'end_date': str(current_history.end_date or ''),
-            'employee_history_data': self.get_context_employee_history(employee),
-        }
+        return self.profile_changed_response(person)
 
     def profile_update_employee_id(self):
         """
@@ -1188,11 +1221,27 @@ class PersonView(MasterView):
 
         data = self.request.json_body
         employee.id = data['employee_id']
-        self.Session.flush()
 
+        self.Session.flush()
+        return self.profile_changed_response(person)
+
+    def profile_tab_notes(self):
+        """
+        Fetch notes tab data for profile view.
+        """
+        person = self.get_instance()
         return {
-            'success': True,
-            'employee': self.get_context_employee(employee),
+            'notes': self.get_context_notes(person),
+            'note_types': self.get_note_type_options(),
+        }
+
+    def profile_tab_user(self):
+        """
+        Fetch user tab data for profile view.
+        """
+        person = self.get_instance()
+        return {
+            'users': self.get_context_users(person),
         }
 
     def profile_revisions_grid(self, person):
@@ -1413,12 +1462,12 @@ class PersonView(MasterView):
     def profile_add_note(self):
         person = self.get_instance()
         form = self.make_note_form('create', person)
-        if form.validate():
-            note = self.create_note(person, form)
-            self.Session.flush()
-            return self.profile_add_note_success(note)
-        else:
-            return self.profile_add_note_failure(person, form)
+        if not form.validate():
+            return {'error': str(form.make_deform_form().error)}
+
+        note = self.create_note(person, form)
+        self.Session.flush()
+        return self.profile_changed_response(person)
 
     def create_note(self, person, form):
         note = model.PersonNote()
@@ -1429,25 +1478,15 @@ class PersonView(MasterView):
         person.notes.append(note)
         return note
 
-    def profile_add_note_success(self, note, person=None):
-        return {
-            'notes': self.get_context_notes(person or note.person),
-        }
-
-    def profile_add_note_failure(self, person, form):
-        return {
-            'error': str(form.make_deform_form().error),
-        }
-
     def profile_edit_note(self):
         person = self.get_instance()
         form = self.make_note_form('edit', person)
-        if form.validate():
-            note = self.update_note(person, form)
-            self.Session.flush()
-            return self.profile_edit_note_success(note)
-        else:
-            return self.profile_edit_note_failure(person, form)
+        if not form.validate():
+            return {'error': str(form.make_deform_form().error)}
+
+        note = self.update_note(person, form)
+        self.Session.flush()
+        return self.profile_changed_response(person)
 
     def update_note(self, person, form):
         note = self.Session.get(model.PersonNote, form.validated['uuid'])
@@ -1455,32 +1494,20 @@ class PersonView(MasterView):
         note.text = form.validated['note_text']
         return note
 
-    def profile_edit_note_success(self, note):
-        return self.profile_add_note_success(note)
-
-    def profile_edit_note_failure(self, person, form):
-        return self.profile_add_note_failure(person, form)
-
     def profile_delete_note(self):
         person = self.get_instance()
         form = self.make_note_form('delete', person)
-        if form.validate():
-            self.delete_note(person, form)
-            self.Session.flush()
-            return self.profile_delete_note_success(person)
-        else:
-            return self.profile_delete_note_failure(person, form)
+        if not form.validate():
+            return {'error': str(form.make_deform_form().error)}
+
+        self.delete_note(person, form)
+        self.Session.flush()
+        return self.profile_changed_response(person)
 
     def delete_note(self, person, form):
         note = self.Session.get(model.PersonNote, form.validated['uuid'])
         self.Session.delete(note)
 
-    def profile_delete_note_success(self, person):
-        return self.profile_add_note_success(None, person=person)
-
-    def profile_delete_note_failure(self, person, form):
-        return self.profile_add_note_failure(person, form)
-
     def make_user(self):
         uuid = self.request.POST['person_uuid']
         person = self.Session.get(model.Person, uuid)
@@ -1553,6 +1580,14 @@ class PersonView(MasterView):
         config.add_view(cls, attr='view_profile', route_name='{}.view_profile'.format(route_prefix),
                         permission='{}.view_profile'.format(permission_prefix))
 
+        # profile - refresh personal tab
+        config.add_route(f'{route_prefix}.profile_tab_personal',
+                         f'{instance_url_prefix}/profile/tab-personal',
+                         request_method='GET')
+        config.add_view(cls, attr='profile_tab_personal',
+                        route_name=f'{route_prefix}.profile_tab_personal',
+                        renderer='json')
+
         # profile - edit personal details
         config.add_tailbone_permission('people_profile',
                                        'people_profile.edit_person',
@@ -1648,6 +1683,38 @@ class PersonView(MasterView):
                         renderer='json',
                         permission='people_profile.edit_person')
 
+        # profile - refresh member tab
+        config.add_route(f'{route_prefix}.profile_tab_member',
+                         f'{instance_url_prefix}/profile/tab-member',
+                         request_method='GET')
+        config.add_view(cls, attr='profile_tab_member',
+                        route_name=f'{route_prefix}.profile_tab_member',
+                        renderer='json')
+
+        # profile - refresh customer tab
+        config.add_route(f'{route_prefix}.profile_tab_customer',
+                         f'{instance_url_prefix}/profile/tab-customer',
+                         request_method='GET')
+        config.add_view(cls, attr='profile_tab_customer',
+                        route_name=f'{route_prefix}.profile_tab_customer',
+                        renderer='json')
+
+        # profile - refresh shopper tab
+        config.add_route(f'{route_prefix}.profile_tab_shopper',
+                         f'{instance_url_prefix}/profile/tab-shopper',
+                         request_method='GET')
+        config.add_view(cls, attr='profile_tab_shopper',
+                        route_name=f'{route_prefix}.profile_tab_shopper',
+                        renderer='json')
+
+        # profile - refresh employee tab
+        config.add_route(f'{route_prefix}.profile_tab_employee',
+                         f'{instance_url_prefix}/profile/tab-employee',
+                         request_method='GET')
+        config.add_view(cls, attr='profile_tab_employee',
+                        route_name=f'{route_prefix}.profile_tab_employee',
+                        renderer='json')
+
         # profile - start employee
         config.add_route('{}.profile_start_employee'.format(route_prefix), '{}/profile/start-employee'.format(instance_url_prefix),
                          request_method='POST')
@@ -1675,6 +1742,22 @@ class PersonView(MasterView):
                         renderer='json',
                         permission='employees.edit')
 
+        # profile - refresh notes tab
+        config.add_route(f'{route_prefix}.profile_tab_notes',
+                         f'{instance_url_prefix}/profile/tab-notes',
+                         request_method='GET')
+        config.add_view(cls, attr='profile_tab_notes',
+                        route_name=f'{route_prefix}.profile_tab_notes',
+                        renderer='json')
+
+        # profile - refresh user tab
+        config.add_route(f'{route_prefix}.profile_tab_user',
+                         f'{instance_url_prefix}/profile/tab-user',
+                         request_method='GET')
+        config.add_view(cls, attr='profile_tab_user',
+                        route_name=f'{route_prefix}.profile_tab_user',
+                        renderer='json')
+
         # profile - revisions data
         config.add_tailbone_permission('people_profile',
                                        'people_profile.view_versions',

From 4e2125d613df71169100d13abed55fd6373e30e7 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 29 Aug 2023 16:10:14 -0500
Subject: [PATCH 025/542] Add support for "missing" credit in mobile receiving

---
 tailbone/api/batch/receiving.py | 4 ++--
 tailbone/forms/receiving.py     | 1 +
 2 files changed, 3 insertions(+), 2 deletions(-)

diff --git a/tailbone/api/batch/receiving.py b/tailbone/api/batch/receiving.py
index b02215d2..a0b61f38 100644
--- a/tailbone/api/batch/receiving.py
+++ b/tailbone/api/batch/receiving.py
@@ -414,8 +414,8 @@ class ReceivingBatchRowViews(APIBatchRowView):
         # TODO: this seems hacky, but avoids "complex" date value parsing
         form.set_widget('expiration_date', dfwidget.TextInputWidget())
         if not form.validate():
-            log.debug("form did not validate: %s",
-                      form.make_deform_form().error)
+            log.warning("form did not validate: %s",
+                        form.make_deform_form().error)
             return {'error': "Form did not validate"}
 
         # fetch / validate row object
diff --git a/tailbone/forms/receiving.py b/tailbone/forms/receiving.py
index 20c4774f..9f5706c7 100644
--- a/tailbone/forms/receiving.py
+++ b/tailbone/forms/receiving.py
@@ -52,6 +52,7 @@ class ReceiveRow(colander.MappingSchema):
                                    'received',
                                    'damaged',
                                    'expired',
+                                   'missing',
                                    # 'mispick',
                                ]))
 

From 74678882eea520db2d4bc60c08b5301216017552 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 29 Aug 2023 22:21:20 -0500
Subject: [PATCH 026/542] Update changelog

---
 CHANGES.rst          | 20 ++++++++++++++++++++
 tailbone/_version.py |  2 +-
 2 files changed, 21 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index f43e669b..5cf50519 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,26 @@
 CHANGELOG
 =========
 
+0.9.42 (2023-08-29)
+-------------------
+
+* When bulk-deleting, skip objects which are not "deletable".
+
+* Declare "from PO" receiving workflow if applicable, in API.
+
+* Auto-select text when editing costs for receiving.
+
+* Include shopper history from parent customer account perspective.
+
+* Link to product record, for New Product batch row.
+
+* Fix profile history to show when a CustomerShopperHistory is deleted.
+
+* Fairly massive overhaul of the Profile view; standardize tabs etc..
+
+* Add support for "missing" credit in mobile receiving.
+
+
 0.9.41 (2023-08-08)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 07ccc0e9..fdf05e34 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.41'
+__version__ = '0.9.42'

From f4267737c37e8539db17b73812232f12ba49bc4f Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 30 Aug 2023 20:10:10 -0500
Subject: [PATCH 027/542] Let "new product" batch override type-2 UPC lookup
 behavior

---
 tailbone/views/batch/newproduct.py | 38 +++++++++++++++++++++++++++++-
 1 file changed, 37 insertions(+), 1 deletion(-)

diff --git a/tailbone/views/batch/newproduct.py b/tailbone/views/batch/newproduct.py
index d58357d0..bd46ad52 100644
--- a/tailbone/views/batch/newproduct.py
+++ b/tailbone/views/batch/newproduct.py
@@ -26,6 +26,8 @@ Views for new product batches
 
 from rattail.db import model
 
+from deform import widget as dfwidget
+
 from tailbone.views.batch import BatchMasterView
 
 
@@ -47,11 +49,17 @@ class NewProductBatchView(BatchMasterView):
     configurable = True
     has_input_file_templates = True
 
+    labels = {
+        'type2_lookup': "Type-2 UPC Lookups",
+    }
+
     form_fields = [
         'id',
         'input_filename',
         'description',
         'notes',
+        'type2_lookup',
+        'params',
         'created',
         'created_by',
         'rowcount',
@@ -127,7 +135,7 @@ class NewProductBatchView(BatchMasterView):
         ]
 
     def configure_form(self, f):
-        super(NewProductBatchView, self).configure_form(f)
+        super().configure_form(f)
 
         # input_filename
         if self.creating:
@@ -136,6 +144,34 @@ class NewProductBatchView(BatchMasterView):
             f.set_readonly('input_filename')
             f.set_renderer('input_filename', self.render_downloadable_file)
 
+        # type2_lookup
+        if self.creating:
+            values = [
+                ('', "(use default behavior)"),
+                ('always', "Always try Type-2 lookup, when applicable"),
+                ('never', "Never try Type-2 lookup"),
+            ]
+            f.set_widget('type2_lookup', dfwidget.SelectWidget(values=values))
+            f.set_default('type2_lookup', '')
+        else:
+            f.remove('type2_lookup')
+
+    def save_create_form(self, form):
+        batch = super().save_create_form(form)
+
+        if 'type2_lookup' in form:
+            type2_lookup = form.validated['type2_lookup']
+            if type2_lookup == 'always':
+                type2_lookup = True
+            elif type2_lookup == 'never':
+                type2_lookup = False
+            else:
+                type2_lookup = None
+            if type2_lookup is not None:
+                batch.set_param('type2_lookup', type2_lookup)
+
+        return batch
+
     def configure_row_grid(self, g):
         super(NewProductBatchView, self).configure_row_grid(g)
 

From 9f65de2ba605128e54767ddbdc2a6562d5264a4d Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 30 Aug 2023 22:08:50 -0500
Subject: [PATCH 028/542] Update changelog

---
 CHANGES.rst          | 6 ++++++
 tailbone/_version.py | 2 +-
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 5cf50519..83d6a298 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,12 @@
 CHANGELOG
 =========
 
+0.9.43 (2023-08-30)
+-------------------
+
+* Let "new product" batch override type-2 UPC lookup behavior.
+
+
 0.9.42 (2023-08-29)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index fdf05e34..8e29a48e 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.42'
+__version__ = '0.9.43'

From 625982d63968c2528f007a295e18bb8d4f0e2785 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 30 Aug 2023 23:32:09 -0500
Subject: [PATCH 029/542] Avoid deprecated `User.email_address` property

---
 tailbone/templates/settings/email/configure.mako | 2 +-
 tailbone/templates/settings/email/view.mako      | 2 +-
 tailbone/views/email.py                          | 9 +++++++++
 3 files changed, 11 insertions(+), 2 deletions(-)

diff --git a/tailbone/templates/settings/email/configure.mako b/tailbone/templates/settings/email/configure.mako
index 50a3d483..ef487809 100644
--- a/tailbone/templates/settings/email/configure.mako
+++ b/tailbone/templates/settings/email/configure.mako
@@ -90,7 +90,7 @@
   ${parent.modify_this_page_vars()}
   <script type="text/javascript">
 
-    ThisPageData.testRecipient = ${json.dumps(request.user.email_address)|n}
+    ThisPageData.testRecipient = ${json.dumps(user_email_address)|n}
     ThisPageData.sendingTest = false
 
     ThisPage.methods.sendTest = function() {
diff --git a/tailbone/templates/settings/email/view.mako b/tailbone/templates/settings/email/view.mako
index 59842498..1d292c69 100644
--- a/tailbone/templates/settings/email/view.mako
+++ b/tailbone/templates/settings/email/view.mako
@@ -84,7 +84,7 @@
             return {
                 previewFormButtonText: "Send Preview Email",
                 previewFormSubmitting: false,
-                userEmailAddress: ${json.dumps(request.user.email_address)|n},
+                userEmailAddress: ${json.dumps(user_email_address)|n},
             }
         },
         methods: {
diff --git a/tailbone/views/email.py b/tailbone/views/email.py
index 9c3d2268..8d227a1e 100644
--- a/tailbone/views/email.py
+++ b/tailbone/views/email.py
@@ -268,8 +268,14 @@ class EmailSettingView(MasterView):
         return data
 
     def template_kwargs_view(self, **kwargs):
+        kwargs = super().template_kwargs_view(**kwargs)
+        app = self.get_rattail_app()
+
         key = self.request.matchdict['key']
         kwargs['email'] = self.email_handler.get_email(key)
+
+        kwargs['user_email_address'] = app.get_contact_email_address(self.request.user)
+
         return kwargs
 
     def configure_get_simple_settings(self):
@@ -293,12 +299,15 @@ class EmailSettingView(MasterView):
 
     def configure_get_context(self, *args, **kwargs):
         context = super().configure_get_context(*args, **kwargs)
+        app = self.get_rattail_app()
 
         # prettify list of template paths
         templates = self.rattail_config.parse_list(
             context['simple_settings']['rattail.mail.templates'])
         context['simple_settings']['rattail.mail.templates'] = ', '.join(templates)
 
+        context['user_email_address'] = app.get_contact_email_address(self.request.user)
+
         return context
 
     def toggle_hidden(self):

From 62aa0c59657ca6a4305a272369bbcd718ae165ba Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 30 Aug 2023 23:51:18 -0500
Subject: [PATCH 030/542] Preserve URL hash when redirecting in grid "reset to
 defaults"

---
 tailbone/templates/grids/buefy.mako | 11 ++++++++++-
 tailbone/views/master.py            | 12 ++++++++++--
 2 files changed, 20 insertions(+), 3 deletions(-)

diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako
index 42451597..25b8abca 100644
--- a/tailbone/templates/grids/buefy.mako
+++ b/tailbone/templates/grids/buefy.mako
@@ -542,7 +542,16 @@
 
           resetView() {
               this.loading = true
-              location.href = '?reset-to-default-filters=true'
+
+              // use current url proper, plus reset param
+              let url = '?reset-to-default-filters=true'
+
+              // add current hash, to preserve that in redirect
+              if (location.hash) {
+                  url += '&hash=' + location.hash.slice(1)
+              }
+
+              location.href = url
           },
 
           addFilter(filter_key) {
diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index 107870cd..98408420 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -329,7 +329,11 @@ 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':
-            return self.redirect(self.request.current_route_url(_query=None))
+            kw = {'_query': None}
+            hash_ = self.request.GET.get('hash')
+            if hash_:
+                kw['_anchor'] = hash_
+            return self.redirect(self.request.current_route_url(**kw))
 
         # Stash some grid stats, for possible use when generating URLs.
         if grid.pageable and hasattr(grid, 'pager'):
@@ -1126,7 +1130,11 @@ 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':
-                return self.redirect(self.request.current_route_url(_query=None))
+                kw = {'_query': None}
+                hash_ = self.request.GET.get('hash')
+                if hash_:
+                    kw['_anchor'] = hash_
+                return self.redirect(self.request.current_route_url(**kw))
 
             # return grid only, if partial page was requested
             if self.request.params.get('partial'):

From 5ab47aeead13fc65f5cbe6dfd14c80d458488924 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 31 Aug 2023 10:08:20 -0500
Subject: [PATCH 031/542] Update changelog

---
 CHANGES.rst          | 8 ++++++++
 tailbone/_version.py | 2 +-
 2 files changed, 9 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 83d6a298..ca3c5342 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,14 @@
 CHANGELOG
 =========
 
+0.9.44 (2023-08-31)
+-------------------
+
+* Avoid deprecated ``User.email_address`` property.
+
+* Preserve URL hash when redirecting in grid "reset to defaults".
+
+
 0.9.43 (2023-08-30)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 8e29a48e..da259c44 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.43'
+__version__ = '0.9.44'

From de373a683bdb2e0917d763671ea11867b5f7d56e Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 1 Sep 2023 11:20:30 -0500
Subject: [PATCH 032/542] Add grid filter type for BigInteger columns

so we can filter by larger values
---
 tailbone/grids/core.py    |  2 ++
 tailbone/grids/filters.py | 15 ++++++++++++---
 2 files changed, 14 insertions(+), 3 deletions(-)

diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py
index abbac793..a6ba34d1 100644
--- a/tailbone/grids/core.py
+++ b/tailbone/grids/core.py
@@ -689,6 +689,8 @@ class Grid(object):
                 factory = gridfilters.AlchemyStringFilter
             elif isinstance(column.type, sa.Numeric):
                 factory = gridfilters.AlchemyNumericFilter
+            elif isinstance(column.type, sa.BigInteger):
+                factory = gridfilters.AlchemyBigIntegerFilter
             elif isinstance(column.type, sa.Integer):
                 factory = gridfilters.AlchemyIntegerFilter
             elif isinstance(column.type, sa.Boolean):
diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py
index 59e20d78..c8815f9f 100644
--- a/tailbone/grids/filters.py
+++ b/tailbone/grids/filters.py
@@ -659,6 +659,7 @@ class AlchemyIntegerFilter(AlchemyNumericFilter):
     """
     Integer filter for SQLAlchemy.
     """
+    bigint = False
 
     def value_invalid(self, value):
         if value:
@@ -666,9 +667,10 @@ class AlchemyIntegerFilter(AlchemyNumericFilter):
                 return True
             if not value.isdigit():
                 return True
-            # TODO: this one is to avoid DataError from PG, but perhaps that
-            # isn't a good enough reason to make this global logic?
-            if int(value) > 2147483647:
+            # normal Integer columns have a max value, beyond which PG
+            # will throw an error if we try to query for larger values
+            # TODO: this seems hacky, how to better handle it?
+            if not self.bigint and int(value) > 2147483647:
                 return True
         return False
 
@@ -678,6 +680,13 @@ class AlchemyIntegerFilter(AlchemyNumericFilter):
         return int(value)
 
 
+class AlchemyBigIntegerFilter(AlchemyIntegerFilter):
+    """
+    BigInteger filter for SQLAlchemy.
+    """
+    bigint = True
+
+
 class AlchemyBooleanFilter(AlchemyGridFilter):
     """
     Boolean filter for SQLAlchemy.

From 75caface6b8b184411583cfa6a65cca907488ec8 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sat, 2 Sep 2023 10:56:06 -0500
Subject: [PATCH 033/542] Add products API route to fetch label profiles for
 use w/ printing

---
 tailbone/api/products.py   | 25 +++++++++++++++++++++++++
 tailbone/views/products.py | 11 ++++-------
 2 files changed, 29 insertions(+), 7 deletions(-)

diff --git a/tailbone/api/products.py b/tailbone/api/products.py
index a2e2db73..3f29ff54 100644
--- a/tailbone/api/products.py
+++ b/tailbone/api/products.py
@@ -125,6 +125,24 @@ class ProductView(APIMasterView):
         return {'ok': True,
                 'product': self.normalize(product)}
 
+    def label_profiles(self):
+        """
+        Returns the set of label profiles available for use with
+        printing label for product.
+        """
+        app = self.get_rattail_app()
+        label_handler = app.get_label_handler()
+        model = self.model
+
+        profiles = []
+        for profile in label_handler.get_label_profiles(self.Session()):
+            profiles.append({
+                'uuid': profile.uuid,
+                'description': profile.description,
+            })
+
+        return {'label_profiles': profiles}
+
     def print_labels(self):
         app = self.get_rattail_app()
         label_handler = app.get_label_handler()
@@ -176,6 +194,13 @@ class ProductView(APIMasterView):
                               permission='{}.list'.format(permission_prefix))
         config.add_cornice_service(quick_lookup)
 
+        # label profiles
+        label_profiles = Service(name=f'{route_prefix}.label_profiles',
+                                 path=f'{collection_url_prefix}/label-profiles')
+        label_profiles.add_view('GET', 'label_profiles', klass=cls,
+                                permission=f'{permission_prefix}.print_labels')
+        config.add_cornice_service(label_profiles)
+
         # print labels
         print_labels = Service(name='{}.print_labels'.format(route_prefix),
                                path='{}/print-labels'.format(collection_url_prefix))
diff --git a/tailbone/views/products.py b/tailbone/views/products.py
index 1cfa528a..92c99c34 100644
--- a/tailbone/views/products.py
+++ b/tailbone/views/products.py
@@ -658,16 +658,13 @@ class ProductView(MasterView):
         return pretty_quantity(inventory.on_order)
 
     def template_kwargs_index(self, **kwargs):
-        kwargs = super(ProductView, self).template_kwargs_index(**kwargs)
+        kwargs = super().template_kwargs_index(**kwargs)
+        app = self.get_rattail_app()
+        label_handler = app.get_label_handler()
         model = self.model
 
         if self.expose_label_printing:
-
-            kwargs['label_profiles'] = self.Session.query(model.LabelProfile)\
-                                                   .filter(model.LabelProfile.visible == True)\
-                                                   .order_by(model.LabelProfile.ordinal)\
-                                                   .all()
-
+            kwargs['label_profiles'] = label_handler.get_label_profiles(self.Session())
             kwargs['quick_label_speedbump_threshold'] = self.rattail_config.getint(
                 'tailbone', 'products.quick_labels.speedbump_threshold')
 

From bd7e6f9f8a2bc932e47f0d74c8884ceaaeccf48c Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sat, 2 Sep 2023 11:39:49 -0500
Subject: [PATCH 034/542] Tweaks for cost editing within a receiving batch

never show PO Cost column in row grid, since Invoice Cost is what
receiving is most concerned with

add "zig-zag" entry behavior when both catalog and invoice costs are editable
---
 tailbone/templates/receiving/view.mako | 82 ++++++++++++++------------
 tailbone/views/purchasing/receiving.py |  9 ---
 2 files changed, 44 insertions(+), 47 deletions(-)

diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako
index 77560ac1..b01436ba 100644
--- a/tailbone/templates/receiving/view.mako
+++ b/tailbone/templates/receiving/view.mako
@@ -177,6 +177,7 @@
 
         let ReceivingCostEditor = {
             template: '#receiving-cost-editor-template',
+            mixins: [SimpleRequestMixin],
             props: {
                 row: Object,
                 'field': String,
@@ -232,41 +233,21 @@
                 submitEdit() {
                     let url = '${url('{}.update_row_cost'.format(route_prefix), uuid=batch.uuid)}'
 
-                    // TODO: should get csrf token from parent component?
-                    let csrftoken = ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}
-                    let headers = {'${csrf_header_name}': csrftoken}
-
                     let params = {
                         row_uuid: this.$props.row.uuid,
                     }
                     params[this.$props.field] = this.inputValue
 
-                    this.$http.post(url, params, {headers: headers}).then(response => {
-                        if (!response.data.error) {
+                    this.simplePOST(url, params, response => {
 
-                            // let parent know cost value has changed
-                            // (this in turn will update data in *this*
-                            // component, and display will refresh)
-                            this.$emit('input', response.data.row[this.$props.field],
-                                       this.$props.row._index)
+                        // let parent know cost value has changed
+                        // (this in turn will update data in *this*
+                        // component, and display will refresh)
+                        this.$emit('input', response.data.row[this.$props.field],
+                                   this.$props.row._index)
 
-                            // and hide the input box
-                            this.editing = false
-
-                        } else {
-                            this.$buefy.toast.open({
-                                message: "Submit failed:  " + response.data.error,
-                                type: 'is-warning',
-                                duration: 4000, // 4 seconds
-                            })
-                        }
-
-                    }, response => {
-                        this.$buefy.toast.open({
-                            message: "Submit failed:  (unknown error)",
-                            type: 'is-warning',
-                            duration: 4000, // 4 seconds
-                        })
+                        // and hide the input box
+                        this.editing = false
                     })
                 },
             },
@@ -289,11 +270,23 @@
             // update display to indicate cost was confirmed
             this.addRowClass(index, 'catalog_cost_confirmed')
 
-            // start editing next row, unless there are no more
-            let nextRow = index + 1
-            if (this.data.length > nextRow) {
-                nextRow = this.data[nextRow]
-                this.$refs['catalogUnitCost_' + nextRow.uuid].startEdit()
+            // advance to next editable cost input...
+
+            // first try invoice cost within same row
+            let thisRow = this.data[index]
+            let cost = this.$refs['invoiceUnitCost_' + thisRow.uuid]
+            if (!cost) {
+
+                // or, try catalog cost from next row
+                let nextRow = this.data[index + 1]
+                if (nextRow) {
+                    cost = this.$refs['catalogUnitCost_' + nextRow.uuid]
+                }
+            }
+
+            // start editing next cost if found
+            if (cost) {
+                cost.startEdit()
             }
         }
 
@@ -312,11 +305,24 @@
             // update display to indicate cost was confirmed
             this.addRowClass(index, 'invoice_cost_confirmed')
 
-            // start editing next row, unless there are no more
-            let nextRow = index + 1
-            if (this.data.length > nextRow) {
-                nextRow = this.data[nextRow]
-                this.$refs['invoiceUnitCost_' + nextRow.uuid].startEdit()
+            // advance to next editable cost input...
+
+            // nb. always advance to next row, regardless of field
+            let nextRow = this.data[index + 1]
+            if (nextRow) {
+
+                // first try catalog cost from next row
+                let cost = this.$refs['catalogUnitCost_' + nextRow.uuid]
+                if (!cost) {
+
+                    // or, try invoice cost from next row
+                    cost = this.$refs['invoiceUnitCost_' + nextRow.uuid]
+                }
+
+                // start editing next cost if found
+                if (cost) {
+                    cost.startEdit()
+                }
             }
         }
 
diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py
index 35e1d6b4..909ded3f 100644
--- a/tailbone/views/purchasing/receiving.py
+++ b/tailbone/views/purchasing/receiving.py
@@ -162,7 +162,6 @@ class ReceivingBatchView(PurchasingBatchView):
         'cases_received',
         'units_received',
         'catalog_unit_cost',
-        'po_unit_cost',
         'invoice_unit_cost',
         'invoice_total_calculated',
         'credits',
@@ -979,14 +978,6 @@ class ReceivingBatchView(PurchasingBatchView):
             g.set_click_handler('invoice_unit_cost',
                                 'this.invoiceUnitCostClicked')
 
-        # nb. only show PO *or* invoice cost; prefer the latter unless
-        # we have a PO and no invoice
-        if (self.batch_handler.has_purchase_order(batch)
-            and not self.batch_handler.has_invoice_file(batch)):
-            g.remove('invoice_unit_cost')
-        else:
-            g.remove('po_unit_cost')
-
         # credits
         # note that sorting by credits involves a subquery with group by clause.
         # seems likely there may be a better way? but this seems to work fine

From b1ec1b881706b4ef460122871f3223fe4cfc9965 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sat, 2 Sep 2023 13:56:10 -0500
Subject: [PATCH 035/542] Update changelog

---
 CHANGES.rst          | 10 ++++++++++
 tailbone/_version.py |  2 +-
 2 files changed, 11 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index ca3c5342..2c3388b4 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,16 @@
 CHANGELOG
 =========
 
+0.9.45 (2023-09-02)
+-------------------
+
+* Add grid filter type for BigInteger columns.
+
+* Add products API route to fetch label profiles for use w/ printing.
+
+* Tweaks for cost editing within a receiving batch.
+
+
 0.9.44 (2023-08-31)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index da259c44..f9f23ea3 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.44'
+__version__ = '0.9.45'

From ecf46fa6fed1d817e9c8329395d7cb4c16143a11 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 7 Sep 2023 17:41:47 -0500
Subject: [PATCH 036/542] Improve display for member equity payments

---
 tailbone/views/members.py | 26 ++++++++++++++++++++++++--
 1 file changed, 24 insertions(+), 2 deletions(-)

diff --git a/tailbone/views/members.py b/tailbone/views/members.py
index a004b5a3..0cacaf04 100644
--- a/tailbone/views/members.py
+++ b/tailbone/views/members.py
@@ -144,9 +144,10 @@ class MemberView(MasterView):
     rows_title = "Equity Payments"
 
     row_grid_columns = [
-        'amount',
         'received',
+        'amount',
         'description',
+        'source',
         'transaction_identifier',
     ]
 
@@ -408,18 +409,22 @@ class MemberEquityPaymentView(MasterView):
     has_versions = True
 
     grid_columns = [
+        'received',
+        '_member_key_',
         'member',
         'amount',
-        'received',
         'description',
+        'source',
         'transaction_identifier',
     ]
 
     form_fields = [
+        '_member_key_',
         'member',
         'amount',
         'received',
         'description',
+        'source',
         'transaction_identifier',
     ]
 
@@ -435,14 +440,31 @@ class MemberEquityPaymentView(MasterView):
         super().configure_grid(g)
         model = self.model
 
+        # member_key
+        field = self.get_member_key_field()
+        attr = getattr(model.Member, field)
+        g.set_renderer(field, self.render_member_key)
+        g.set_filter(field, attr,
+                     label=self.get_member_key_label(),
+                     default_active=True)
+        g.set_sorter(field, attr)
+
+        # member (name)
         g.set_joiner('member', lambda q: q.outerjoin(model.Person))
         g.set_sorter('member', model.Person.display_name)
         g.set_link('member')
+        g.set_filter('member', model.Person.display_name,
+                     label="Member Name")
 
         g.set_type('amount', 'currency')
 
         g.set_sort_defaults('received', 'desc')
         g.set_link('received')
+        g.set_link('transaction_identifier')
+
+    def render_member_key(self, payment, field):
+        key = getattr(payment.member, field)
+        return key
 
     def configure_form(self, f):
         super().configure_form(f)

From f732e04f49b0d802f568c93256ba5b7cdfc3a386 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 7 Sep 2023 18:36:02 -0500
Subject: [PATCH 037/542] Update changelog

---
 CHANGES.rst          | 6 ++++++
 tailbone/_version.py | 2 +-
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 2c3388b4..16b70517 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,12 @@
 CHANGELOG
 =========
 
+0.9.46 (2023-09-07)
+-------------------
+
+* Improve display for member equity payments.
+
+
 0.9.45 (2023-09-02)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index f9f23ea3..f089641a 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.45'
+__version__ = '0.9.46'

From f717bc47e56d48b210079a4ce7dce389ff4cff04 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 7 Sep 2023 20:57:33 -0500
Subject: [PATCH 038/542] Fallback to None when getting values for merge
 preview

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

diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index 98408420..5027f230 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -2141,7 +2141,7 @@ class MasterView(View):
         if self.merge_handler:
             return self.merge_handler.get_merge_preview_data(obj)
 
-        return dict([(f, getattr(obj, f))
+        return dict([(f, getattr(obj, f, None))
                      for f in self.get_merge_fields()])
 
     def get_merge_resulting_data(self, remove, keep):

From 84de5e09a2f81c878007dd00e22f2583e9caea7e Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 7 Sep 2023 21:00:40 -0500
Subject: [PATCH 039/542] Update changelog

---
 CHANGES.rst          | 6 ++++++
 tailbone/_version.py | 2 +-
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 16b70517..2668119c 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,12 @@
 CHANGELOG
 =========
 
+0.9.47 (2023-09-07)
+-------------------
+
+* Fallback to None when getting values for merge preview.
+
+
 0.9.46 (2023-09-07)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index f089641a..bedab6b6 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.46'
+__version__ = '0.9.47'

From 6e50288bd4f55921b682e5f29a0173611b7f43ad Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 8 Sep 2023 08:49:43 -0500
Subject: [PATCH 040/542] Add grid link for equity payment description

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

diff --git a/tailbone/views/members.py b/tailbone/views/members.py
index 0cacaf04..85ffa99c 100644
--- a/tailbone/views/members.py
+++ b/tailbone/views/members.py
@@ -460,6 +460,10 @@ class MemberEquityPaymentView(MasterView):
 
         g.set_sort_defaults('received', 'desc')
         g.set_link('received')
+
+        # description
+        g.set_link('description')
+
         g.set_link('transaction_identifier')
 
     def render_member_key(self, payment, field):

From 7221400b8850710cc80a9220a545101a48986908 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 8 Sep 2023 10:56:25 -0500
Subject: [PATCH 041/542] Fix msg body display, download link for email bounces

---
 tailbone/templates/email-bounces/view.mako |  4 +-
 tailbone/views/bouncer.py                  | 45 ++++++++--------------
 tailbone/views/master.py                   |  4 +-
 3 files changed, 18 insertions(+), 35 deletions(-)

diff --git a/tailbone/templates/email-bounces/view.mako b/tailbone/templates/email-bounces/view.mako
index 610118ed..f8372c88 100644
--- a/tailbone/templates/email-bounces/view.mako
+++ b/tailbone/templates/email-bounces/view.mako
@@ -48,9 +48,7 @@
 
 <%def name="render_this_page()">
   ${parent.render_this_page()}
-  <pre class="email-message-body">
-    ${message}
-  </pre>
+  <pre class="email-message-body">${message}</pre>
 </%def>
 
 
diff --git a/tailbone/views/bouncer.py b/tailbone/views/bouncer.py
index 628ed07c..3416bbed 100644
--- a/tailbone/views/bouncer.py
+++ b/tailbone/views/bouncer.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,18 +24,12 @@
 Views for Email Bounces
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import os
 import datetime
 
-import six
-
 from rattail.db import model
-from rattail.bouncer import get_handler
 from rattail.bouncer.config import get_profile_keys
 
-from pyramid.response import FileResponse
 from webhelpers2.html import HTML, tags
 
 from tailbone.views import MasterView
@@ -50,6 +44,7 @@ class EmailBounceView(MasterView):
     url_prefix = '/email-bounces'
     creatable = False
     editable = False
+    downloadable = True
 
     labels = {
         'config_key': "Source",
@@ -70,7 +65,8 @@ class EmailBounceView(MasterView):
         self.handler_options = sorted(get_profile_keys(self.rattail_config))
 
     def get_handler(self, bounce):
-        return get_handler(self.rattail_config, bounce.config_key)
+        app = self.get_rattail_app()
+        return app.get_bounce_handler(bounce.config_key)
 
     def configure_grid(self, g):
         super(EmailBounceView, self).configure_grid(g)
@@ -142,11 +138,16 @@ class EmailBounceView(MasterView):
         path = handler.msgpath(bounce)
         if os.path.exists(path):
             with open(path, 'rb') as f:
-                kwargs['message'] = f.read()
+                # TODO: how to determine encoding? (is utf_8 guaranteed?)
+                kwargs['message'] = f.read().decode('utf_8')
         else:
             kwargs['message'] = "(file not found)"
         return kwargs
 
+    def download_path(self, bounce, filename):
+        handler = self.get_handler(bounce)
+        return handler.msgpath(bounce)
+
     # TODO: should require POST here
     def process(self):
         """
@@ -169,20 +170,13 @@ class EmailBounceView(MasterView):
         self.request.session.flash("Email bounce has been marked UN-processed.")
         return self.redirect(self.get_action_url('view', bounce))
 
-    def download(self):
-        """
-        View for downloading the message file associated with a bounce.
-        """
-        bounce = self.get_instance()
-        handler = self.get_handler(bounce)
-        path = handler.msgpath(bounce)
-        response = FileResponse(path, request=self.request)
-        response.headers[b'Content-Length'] = six.binary_type(os.path.getsize(path))
-        response.headers[b'Content-Disposition'] = b'attachment; filename="bounce.eml"'
-        return response
-
     @classmethod
     def defaults(cls, config):
+        cls._bounce_defaults(config)
+        cls._defaults(config)
+
+    @classmethod
+    def _bounce_defaults(cls, config):
 
         config.add_tailbone_permission_group('emailbounces', "Email Bounces", overwrite=False)
 
@@ -200,15 +194,6 @@ class EmailBounceView(MasterView):
         config.add_tailbone_permission('emailbounces', 'emailbounces.unprocess',
                                        "Mark Email Bounce as UN-processed")
 
-        # download raw email
-        config.add_route('emailbounces.download', '/email-bounces/{uuid}/download')
-        config.add_view(cls, attr='download', route_name='emailbounces.download',
-                        permission='emailbounces.download')
-        config.add_tailbone_permission('emailbounces', 'emailbounces.download',
-                                       "Download raw message of Email Bounce")
-
-        cls._defaults(config)
-
 
 def defaults(config, **kwargs):
     base = globals()
diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index 5027f230..4aacc9f1 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -1593,9 +1593,9 @@ class MasterView(View):
         """
         obj = self.get_instance()
         filename = self.request.GET.get('filename', None)
-        if not filename:
-            raise self.notfound()
         path = self.download_path(obj, filename)
+        if not path or not os.path.exists(path):
+            raise self.notfound()
         response = FileResponse(path, request=self.request)
         response.content_length = os.path.getsize(path)
         content_type = self.download_content_type(path, filename)

From 669e50e40658caca5dd66913739aa13a50fa3b8c Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 8 Sep 2023 19:53:10 -0500
Subject: [PATCH 042/542] Fix member key display for equity payment form

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

diff --git a/tailbone/views/members.py b/tailbone/views/members.py
index 85ffa99c..d2a0e455 100644
--- a/tailbone/views/members.py
+++ b/tailbone/views/members.py
@@ -475,6 +475,10 @@ class MemberEquityPaymentView(MasterView):
         model = self.model
         payment = f.model_instance
 
+        # member_key
+        field = self.get_member_key_field()
+        f.set_renderer(field, self.render_member_key)
+
         # member
         if self.creating:
             f.replace('member', 'member_uuid')

From c5344d2df62284bb5b12fbf99dd0f1331bcc122f Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 8 Sep 2023 19:55:14 -0500
Subject: [PATCH 043/542] Update changelog

---
 CHANGES.rst          | 10 ++++++++++
 tailbone/_version.py |  2 +-
 2 files changed, 11 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 2668119c..fc858f6b 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,16 @@
 CHANGELOG
 =========
 
+0.9.48 (2023-09-08)
+-------------------
+
+* Add grid link for equity payment description.
+
+* Fix msg body display, download link for email bounces.
+
+* Fix member key display for equity payment form.
+
+
 0.9.47 (2023-09-07)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index bedab6b6..014d9357 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.47'
+__version__ = '0.9.48'

From ccb4661b39641f52881f4fc6d6e4ae921be36212 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sat, 9 Sep 2023 14:14:23 -0500
Subject: [PATCH 044/542] Add custom hook for grid "apply filters"

so a page can know when the data set changes..

this seems a bit hacky, may need a better solution some day
---
 tailbone/templates/grids/buefy.mako | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako
index 25b8abca..519c16d8 100644
--- a/tailbone/templates/grids/buefy.mako
+++ b/tailbone/templates/grids/buefy.mako
@@ -604,8 +604,11 @@
 
               params = new URLSearchParams(params)
               this.loadAsyncData(params)
+              this.appliedFiltersHook()
           },
 
+          appliedFiltersHook() {},
+
           clearFilters() {
 
               // explicitly deactivate all filters

From a9fbf480531ebea8ee2e0ea9283c568b9dfd457f Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sat, 9 Sep 2023 16:18:39 -0500
Subject: [PATCH 045/542] Use common POST logic for submitting new customer
 order

---
 tailbone/templates/custorders/create.mako | 28 +++--------------------
 1 file changed, 3 insertions(+), 25 deletions(-)

diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako
index 77129fb8..055957bb 100644
--- a/tailbone/templates/custorders/create.mako
+++ b/tailbone/templates/custorders/create.mako
@@ -1091,6 +1091,7 @@
 
     const CustomerOrderCreator = {
         template: '#customer-order-creator-template',
+        mixins: [SimpleRequestMixin],
         data() {
 
             let defaultUnitChoices = ${json.dumps(default_uom_choices)|n}
@@ -1198,9 +1199,6 @@
                 pendingProduct: {},
                 departmentOptions: ${json.dumps(department_options)|n},
 
-                ## 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},
-
                 submittingOrder: false,
             }
         },
@@ -1500,31 +1498,11 @@
             submitBatchData(params, success, failure) {
                 let url = ${json.dumps(request.current_route_url())|n}
                 
-                let headers = {
-                    ## TODO: should find a better way to handle CSRF token
-                    'X-CSRF-TOKEN': this.csrftoken,
-                }
-
-                ## TODO: should find a better way to handle CSRF token
-                this.$http.post(url, params, {headers: headers}).then((response) => {
-                    if (response.data.error) {
-                        this.$buefy.toast.open({
-                            message: response.data.error,
-                            type: 'is-danger',
-                            duration: 2000, // 2 seconds
-                        })
-                        if (failure) {
-                            failure(response)
-                        }
-                    } else if (success) {
+                this.simplePOST(url, params, response => {
+                    if (success) {
                         success(response)
                     }
                 }, response => {
-                    this.$buefy.toast.open({
-                        message: "Unexpected error occurred",
-                        type: 'is-danger',
-                        duration: 2000, // 2 seconds
-                    })
                     if (failure) {
                         failure(response)
                     }

From 64c58a3cf8357624567eb6084c421e618fabcb40 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 10 Sep 2023 07:44:13 -0500
Subject: [PATCH 046/542] Optionally configure SQLAlchemy Session with
 `future=True`

this avoids the need for setting `cascade_backrefs=False` everywhere

https://docs.sqlalchemy.org/en/14/errors.html#error-s9r1

https://docs.sqlalchemy.org/en/14/orm/session_api.html#sqlalchemy.orm.Session.params.future
---
 tailbone/app.py | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/tailbone/app.py b/tailbone/app.py
index 4d4f435c..6f41a8de 100644
--- a/tailbone/app.py
+++ b/tailbone/app.py
@@ -70,6 +70,10 @@ def make_rattail_config(settings):
     if hasattr(rattail_config, 'tempmon_engine'):
         tailbone.db.TempmonSession.configure(bind=rattail_config.tempmon_engine)
 
+    # maybe set "future" behavior for SQLAlchemy
+    if rattail_config.getbool('rattail.db', 'sqlalchemy_future_mode', usedb=False):
+        tailbone.db.Session.configure(future=True)
+
     # create session wrappers for each "extra" Trainwreck engine
     for key, engine in rattail_config.trainwreck_engines.items():
         if key != 'default':

From 48daa042d17827e133f7107b139251ce460e85b0 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 10 Sep 2023 07:45:25 -0500
Subject: [PATCH 047/542] Show related customer orders for Pending Product view

and similar tweaks
---
 tailbone/templates/products/pending/view.mako | 22 +++----
 tailbone/views/products.py                    | 64 ++++++++++++++++---
 2 files changed, 65 insertions(+), 21 deletions(-)

diff --git a/tailbone/templates/products/pending/view.mako b/tailbone/templates/products/pending/view.mako
index 90d9c687..be61a44f 100644
--- a/tailbone/templates/products/pending/view.mako
+++ b/tailbone/templates/products/pending/view.mako
@@ -53,24 +53,22 @@
 
           <section class="modal-card-body">
             <p class="block">
-              If this Product already exists, you can declare that by
+              If this product already exists, you can declare that by
               identifying the record below.
             </p>
             <p class="block">
               The app will take care of updating any Customer Orders
               etc.  as needed once you declare the match.
             </p>
-            <b-field grouped>
-              <b-field label="Pending">
-                <span>${instance.full_description}</span>
-              </b-field>
-              <b-field label="Actual Product" expanded>
-                <tailbone-autocomplete name="product_uuid"
-                                       v-model="resolveProductUUID"
-                                       ref="resolveProductAutocomplete"
-                                       service-url="${url('products.autocomplete')}">
-                </tailbone-autocomplete>
-              </b-field>
+            <b-field label="Pending Product">
+              <span>${instance.full_description}</span>
+            </b-field>
+            <b-field label="Actual Product" expanded>
+              <tailbone-autocomplete name="product_uuid"
+                                     v-model="resolveProductUUID"
+                                     ref="resolveProductAutocomplete"
+                                     service-url="${url('products.autocomplete')}">
+              </tailbone-autocomplete>
             </b-field>
           </section>
 
diff --git a/tailbone/views/products.py b/tailbone/views/products.py
index 92c99c34..e0183d14 100644
--- a/tailbone/views/products.py
+++ b/tailbone/views/products.py
@@ -2229,6 +2229,7 @@ class PendingProductView(MasterView):
 
     form_fields = [
         '_product_key_',
+        'product',
         'brand_name',
         'brand',
         'description',
@@ -2237,14 +2238,34 @@ class PendingProductView(MasterView):
         'department',
         'vendor_name',
         'vendor',
+        'vendor_item_code',
         'unit_cost',
         'case_size',
         'regular_price_amount',
         'special_order',
         'notes',
+        'status_code',
         'created',
         'user',
-        'status_code',
+        'resolved',
+        'resolved_by',
+    ]
+
+    has_rows = True
+    model_row_class = model.CustomerOrderItem
+    rows_title = "Customer Orders"
+    # TODO: add support for this someday
+    rows_viewable = False
+
+    # TODO: this clearly needs help
+    row_grid_columns = [
+        # 'upc',
+        'brand_name',
+        'description',
+        'size',
+        'vendor_name',
+        # 'regular_price',
+        # 'current_price',
     ]
 
     def configure_grid(self, g):
@@ -2264,6 +2285,10 @@ class PendingProductView(MasterView):
         model = self.model
         pending = f.model_instance
 
+        # product
+        f.set_readonly('product') # TODO
+        f.set_renderer('product', self.render_product)
+
         # department
         if self.creating or self.editing:
             if 'department' in f:
@@ -2342,13 +2367,6 @@ class PendingProductView(MasterView):
         else:
             f.set_readonly('created')
 
-        # user
-        if self.creating:
-            f.remove('user')
-        else:
-            f.set_readonly('user')
-            f.set_renderer('user', self.render_user)
-
         # status_code
         if self.creating:
             f.remove('status_code')
@@ -2356,7 +2374,23 @@ class PendingProductView(MasterView):
             # f.set_readonly('status_code')
             f.set_enum('status_code', self.enum.PENDING_PRODUCT_STATUS)
 
+        # user
+        if self.creating:
+            f.remove('user')
+        else:
+            f.set_readonly('user')
+            f.set_renderer('user', self.render_user)
+
+        # resolved*
+        if self.creating:
+            f.remove('resolved', 'resolved_by')
+        else:
+            if not pending.resolved:
+                f.remove('resolved', 'resolved_by')
+
     def editable_instance(self, pending):
+        if self.request.is_root:
+            return True
         if pending.status_code == self.enum.PENDING_PRODUCT_STATUS_RESOLVED:
             return False
         return True
@@ -2419,9 +2453,21 @@ class PendingProductView(MasterView):
 
         app = self.get_rattail_app()
         products_handler = app.get_products_handler()
-        products_handler.resolve_product(pending, product, self.request.user)
+        kwargs = self.get_resolve_product_kwargs()
+        products_handler.resolve_product(pending, product, self.request.user, **kwargs)
         return redirect
 
+    def get_resolve_product_kwargs(self, **kwargs):
+        return kwargs
+
+    def get_row_data(self, pending):
+        model = self.model
+        return self.Session.query(model.CustomerOrderItem)\
+                           .filter(model.CustomerOrderItem.pending_product == pending)
+
+    def get_parent(self, item):
+        return item.pending_product
+
     @classmethod
     def defaults(cls, config):
         cls._defaults(config)

From e255c35e8663f78f12f3c231c2d3a625fdf63a10 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 10 Sep 2023 13:51:11 -0500
Subject: [PATCH 048/542] Set stacklevel for all deprecation warnings

---
 tailbone/grids/core.py             | 2 +-
 tailbone/views/handheld.py         | 6 ++----
 tailbone/views/labels/batch.py     | 6 ++----
 tailbone/views/vendors/catalogs.py | 6 ++----
 tailbone/views/vendors/invoices.py | 6 ++----
 5 files changed, 9 insertions(+), 17 deletions(-)

diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py
index a6ba34d1..4a748536 100644
--- a/tailbone/grids/core.py
+++ b/tailbone/grids/core.py
@@ -309,7 +309,7 @@ class Grid(object):
         """
         warnings.warn("Grid.hide_column() is deprecated; please use "
                       "Grid.remove() instead.",
-                      DeprecationWarning)
+                      DeprecationWarning, stacklevel=2)
         self.remove(key)
 
     def hide_columns(self, *keys):
diff --git a/tailbone/views/handheld.py b/tailbone/views/handheld.py
index 4d702c92..34211c30 100644
--- a/tailbone/views/handheld.py
+++ b/tailbone/views/handheld.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,8 +24,6 @@
 (DEPRECATED) Views for handheld batches
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import warnings
 
 # nb. this is imported only for sake of legacy callers
@@ -35,5 +33,5 @@ from tailbone.views.batch.handheld import HandheldBatchView
 def includeme(config):
     warnings.warn("tailbone.views.handheld is a deprecated module; "
                   "please use tailbone.views.batch.handheld instead",
-                  DeprecationWarning)
+                  DeprecationWarning, stacklevel=2)
     config.include('tailbone.views.batch.handheld')
diff --git a/tailbone/views/labels/batch.py b/tailbone/views/labels/batch.py
index b4910466..e9d2971b 100644
--- a/tailbone/views/labels/batch.py
+++ b/tailbone/views/labels/batch.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2019 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -26,8 +26,6 @@
 Please use `tailbone.views.batch.labels` instead.
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import warnings
 
 
@@ -35,6 +33,6 @@ def includeme(config):
 
     warnings.warn("The `tailbone.views.labels.batch` module is deprecated, "
                   "please use `tailbone.views.batch.labels` instead.",
-                  DeprecationWarning)
+                  DeprecationWarning, stacklevel=2)
 
     config.include('tailbone.views.batch.labels')
diff --git a/tailbone/views/vendors/catalogs.py b/tailbone/views/vendors/catalogs.py
index e021a88a..2471ad47 100644
--- a/tailbone/views/vendors/catalogs.py
+++ b/tailbone/views/vendors/catalogs.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2019 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -26,13 +26,11 @@
 Please use `tailbone.views.batch.vendorcatalog` instead.
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import warnings
 
 
 def includeme(config):
     warnings.warn("The `tailbone.views.vendors.catalogs` module is deprecated, "
                   "please use `tailbone.views.batch.vendorcatalog` instead.",
-                  DeprecationWarning)
+                  DeprecationWarning, stacklevel=2)
     config.include('tailbone.views.batch.vendorcatalog')
diff --git a/tailbone/views/vendors/invoices.py b/tailbone/views/vendors/invoices.py
index e61329f6..40fe0365 100644
--- a/tailbone/views/vendors/invoices.py
+++ b/tailbone/views/vendors/invoices.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2019 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -26,13 +26,11 @@
 Please use `tailbone.views.batch.vendorinvoice` instead.
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import warnings
 
 
 def includeme(config):
     warnings.warn("The `tailbone.views.vendors.invoices` module is deprecated, "
                   "please use `tailbone.views.batch.vendorinvoice` instead.",
-                  DeprecationWarning)
+                  DeprecationWarning, stacklevel=2)
     config.include('tailbone.views.batch.vendorinvoice')

From e49e0edc5719386cf688c5e10b957ee52937d27b Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 10 Sep 2023 17:06:14 -0500
Subject: [PATCH 049/542] Misc. improvements for Customer Orders view

---
 tailbone/templates/custorders/view.mako |  3 +
 tailbone/views/custorders/items.py      | 32 ++++++---
 tailbone/views/custorders/orders.py     | 93 +++++++++++++++++++++++--
 3 files changed, 113 insertions(+), 15 deletions(-)
 create mode 100644 tailbone/templates/custorders/view.mako

diff --git a/tailbone/templates/custorders/view.mako b/tailbone/templates/custorders/view.mako
new file mode 100644
index 00000000..e2af7bf4
--- /dev/null
+++ b/tailbone/templates/custorders/view.mako
@@ -0,0 +1,3 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/master/view.mako" />
+${parent.body()}
diff --git a/tailbone/views/custorders/items.py b/tailbone/views/custorders/items.py
index 5dc61e4d..5d4f6049 100644
--- a/tailbone/views/custorders/items.py
+++ b/tailbone/views/custorders/items.py
@@ -85,8 +85,10 @@ class CustomerOrderItemView(MasterView):
 
     form_fields = [
         'order',
-        'sequence',
+        'customer',
         'person',
+        'sequence',
+        '_product_key_',
         'product',
         'pending_product',
         'product_brand',
@@ -97,9 +99,11 @@ class CustomerOrderItemView(MasterView):
         'case_quantity',
         'unit_price',
         'total_price',
+        'special_order',
         'price_needs_confirmation',
         'paid_amount',
         'status_code',
+        'flagged',
         'notes',
     ]
 
@@ -167,13 +171,30 @@ class CustomerOrderItemView(MasterView):
             return HTML.tag('span', title=item.status_text, c=[text])
         return text
 
+    def get_batch_handler(self):
+        app = self.get_rattail_app()
+        return app.get_batch_handler(
+            'custorder',
+            default='rattail.batch.custorder:CustomerOrderBatchHandler')
+
     def configure_form(self, f):
-        super(CustomerOrderItemView, self).configure_form(f)
+        super().configure_form(f)
         item = f.model_instance
 
         # order
         f.set_renderer('order', self.render_order)
 
+        # contact
+        batch_handler = self.get_batch_handler()
+        if batch_handler.new_order_requires_customer():
+            f.remove('person')
+        else:
+            f.remove('customer')
+
+        # product key
+        key = self.get_product_key_field()
+        f.set_renderer(key, lambda item, field: getattr(item, f'product_{key}'))
+
         # (pending) product
         f.set_renderer('product', self.render_product)
         f.set_renderer('pending_product', self.render_pending_product)
@@ -192,13 +213,6 @@ class CustomerOrderItemView(MasterView):
         f.set_renderer('product_size', self.highlight_pending_field)
         f.set_renderer('case_quantity', self.highlight_pending_field_quantity)
 
-        'unit_price',
-        'total_price',
-        'price_needs_confirmation',
-        'paid_amount',
-        'status_code',
-        'notes',
-
         # quantity fields
         f.set_type('cases_ordered', 'quantity')
         f.set_type('units_ordered', 'quantity')
diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py
index cdf765a6..abbcf87c 100644
--- a/tailbone/views/custorders/orders.py
+++ b/tailbone/views/custorders/orders.py
@@ -52,7 +52,7 @@ class CustomerOrderView(MasterView):
     configurable = True
 
     labels = {
-        'id': "ID",
+        'id': "Order ID",
         'status_code': "Status",
     }
 
@@ -60,8 +60,9 @@ class CustomerOrderView(MasterView):
         'id',
         'customer',
         'person',
-        'created',
         'status_code',
+        'created',
+        'created_by',
     ]
 
     form_fields = [
@@ -88,14 +89,17 @@ class CustomerOrderView(MasterView):
 
     row_grid_columns = [
         'sequence',
+        '_product_key_',
         'product_brand',
         'product_description',
         'product_size',
         'order_quantity',
         'order_uom',
         'case_quantity',
+        'department_name',
         'total_price',
         'status_code',
+        'flagged',
     ]
 
     def __init__(self, request):
@@ -107,11 +111,19 @@ class CustomerOrderView(MasterView):
                       .options(orm.joinedload(model.CustomerOrder.customer))
 
     def configure_grid(self, g):
-        super(CustomerOrderView, self).configure_grid(g)
+        super().configure_grid(g)
+
+        # id
+        g.set_link('id')
+        g.filters['id'].default_active = True
+        g.filters['id'].default_verb = 'equal'
+
+        # import ipdb; ipdb.set_trace()
 
         # customer or person
         if self.batch_handler.new_order_requires_customer():
             g.remove('person')
+            g.set_link('customer')
             g.set_joiner('customer', lambda q: q.outerjoin(model.Customer))
             g.set_sorter('customer', model.Customer.name)
             g.filters['customer'] = g.make_filter('customer', model.Customer.name,
@@ -120,6 +132,7 @@ class CustomerOrderView(MasterView):
                                                   default_verb='contains')
         else:
             g.remove('customer')
+            g.set_link('person')
             g.set_joiner('person', lambda q: q.outerjoin(model.Person))
             g.set_sorter('person', model.Person.display_name)
             g.filters['person'] = g.make_filter('person', model.Person.display_name,
@@ -127,13 +140,14 @@ class CustomerOrderView(MasterView):
                                                 default_active=True,
                                                 default_verb='contains')
 
+        # status_code
         g.set_enum('status_code', self.enum.CUSTORDER_STATUS)
 
+        # created
         g.set_sort_defaults('created', 'desc')
 
-        g.set_link('id')
-        g.set_link('customer')
-        g.set_link('person')
+    def get_instance_title(self, order):
+        return f"#{order.id} for {order.customer or order.person}"
 
     def configure_form(self, f):
         super(CustomerOrderView, self).configure_form(f)
@@ -232,6 +246,10 @@ class CustomerOrderView(MasterView):
             'custorder',
             default='rattail.batch.custorder:CustomerOrderBatchHandler')
 
+        # product key
+        key = self.get_product_key_field()
+        g.set_renderer(key, lambda item, field: getattr(item, f'product_{key}'))
+
         g.set_type('case_quantity', 'quantity')
         g.set_type('order_quantity', 'quantity')
         g.set_type('cases_ordered', 'quantity')
@@ -962,6 +980,61 @@ class CustomerOrderView(MasterView):
     def execute_new_order_batch(self, batch, data):
         return self.batch_handler.do_execute(batch, self.request.user)
 
+    def fetch_order_data(self):
+        app = self.get_rattail_app()
+        model = self.model
+
+        order = None
+        uuid = self.request.GET.get('uuid')
+        if uuid:
+            order = self.Session.get(model.CustomerOrder, uuid)
+        if not order:
+            # raise self.notfound()
+            return {'error': "Customer order not found"}
+
+        address = None
+        if self.batch_handler.new_order_requires_customer():
+            contact = order.customer
+        else:
+            contact = order.person
+        if contact and contact.address:
+            a = contact.address
+            address = {
+                'street_1': a.street,
+                'street_2': a.street2,
+                'city': a.city,
+                'state': a.state,
+                'zip': a.zipcode,
+            }
+
+        # gather all the order items
+        items = []
+        grand_total = 0
+        for item in order.items:
+            item_data = {
+                'uuid': item.uuid,
+                'special_order': False, # TODO
+                'product_description': item.product_description,
+                'order_quantity': app.render_quantity(item.order_quantity),
+                'department': item.department_name,
+                'price': app.render_currency(item.unit_price),
+                'total': app.render_currency(item.total_price),
+            }
+            items.append(item_data)
+            grand_total += item.total_price
+
+        return {
+            'uuid': order.uuid,
+            'id': order.id,
+            'created_display': app.render_datetime(app.localtime(order.created, from_utc=True)),
+            'contact_display': str(contact or ''),
+            'address': address,
+            'phone_display': str(contact.phone) if contact and contact.phone else "",
+            'email_display': str(contact.email) if contact and contact.email else "",
+            'items': items,
+            'grand_total_display': app.render_currency(grand_total),
+        }
+
     def configure_get_simple_settings(self):
         return [
 
@@ -1048,6 +1121,14 @@ class CustomerOrderView(MasterView):
                         renderer='json',
                         permission='products.list')
 
+        # fetch order data
+        config.add_route(f'{route_prefix}.fetch_order_data',
+                         f'{url_prefix}/fetch-order-data')
+        config.add_view(cls, attr='fetch_order_data',
+                        route_name=f'{route_prefix}.fetch_order_data',
+                        renderer='json',
+                        permission=f'{permission_prefix}.view')
+
 
 # TODO: deprecate / remove this
 CustomerOrdersView = CustomerOrderView

From ddb8e3656fe10c4b1f7d83dd8113029b3f035c1f Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 10 Sep 2023 17:49:29 -0500
Subject: [PATCH 050/542] Add support for toggling custorder item "flagged"

---
 tailbone/templates/custorders/items/view.mako |  6 ++
 tailbone/views/custorders/items.py            | 62 +++++++++++++++++--
 2 files changed, 64 insertions(+), 4 deletions(-)

diff --git a/tailbone/templates/custorders/items/view.mako b/tailbone/templates/custorders/items/view.mako
index 9eb239ed..e82b567f 100644
--- a/tailbone/templates/custorders/items/view.mako
+++ b/tailbone/templates/custorders/items/view.mako
@@ -324,6 +324,12 @@
             this.$refs.changeStatusForm.submit()
         }
 
+        ${form.component_studly}Data.changeFlaggedSubmitting = false
+
+        ${form.component_studly}.methods.changeFlaggedSubmit = function() {
+            this.changeFlaggedSubmitting = true
+        }
+
     % endif
 
     % if master.has_perm('add_note'):
diff --git a/tailbone/views/custorders/items.py b/tailbone/views/custorders/items.py
index 5d4f6049..baec4151 100644
--- a/tailbone/views/custorders/items.py
+++ b/tailbone/views/custorders/items.py
@@ -34,7 +34,7 @@ from rattail.time import localtime
 from webhelpers2.html import HTML, tags
 
 from tailbone.views import MasterView
-from tailbone.util import raw_datetime
+from tailbone.util import raw_datetime, csrf_token
 
 
 class CustomerOrderItemView(MasterView):
@@ -231,9 +231,53 @@ class CustomerOrderItemView(MasterView):
         # status_code
         f.set_renderer('status_code', self.render_status_code)
 
+        # flagged
+        f.set_renderer('flagged', self.render_flagged)
+
         # notes
         f.set_renderer('notes', self.render_notes)
 
+    def render_flagged(self, item, field):
+        text = "Yes" if item.flagged else "No"
+        items = [HTML.tag('span', c=text)]
+
+        if self.has_perm('change_status'):
+            button_text = "Un-Flag This" if item.flagged else "Flag This"
+            form = [
+                tags.form(self.get_action_url('change_flagged', item),
+                          **{'@submit': 'changeFlaggedSubmit'}),
+                csrf_token(self.request),
+                tags.hidden('new_flagged',
+                            value='false' if item.flagged else 'true'),
+                HTML.tag('b-button',
+                         type='is-warning' if item.flagged else 'is-primary',
+                         c=f"{{{{ changeFlaggedSubmitting ? 'Working, please wait...' : '{button_text}' }}}}",
+                         native_type='submit',
+                         style='margin-left: 1rem;',
+                         icon_pack='fas', icon_left='flag',
+                         **{':disabled': 'changeFlaggedSubmitting'}),
+                tags.end_form(),
+            ]
+            items.append(HTML.literal('').join(form))
+
+        left = HTML.tag('div', class_='level-left', c=items)
+        outer = HTML.tag('div', class_='level', c=[left])
+        return outer
+
+    def change_flagged(self):
+        """
+        View for changing "flagged" status of one or more order products.
+        """
+        item = self.get_instance()
+        redirect = self.redirect(self.get_action_url('view', item))
+
+        new_flagged = self.request.POST['new_flagged'] == 'true'
+        item.flagged = new_flagged
+
+        flagged = "FLAGGED" if new_flagged else "UN-FLAGGED"
+        self.request.session.flash(f"Order item has been {flagged}")
+        return redirect
+
     def highlight_pending_field(self, item, field, value=None):
         if value is None:
             value = getattr(item, field)
@@ -299,14 +343,16 @@ class CustomerOrderItemView(MasterView):
 
         factory = self.get_grid_factory()
         g = factory(
-            key='{}.notes'.format(route_prefix),
+            key=f'{route_prefix}.notes',
             data=[],
             columns=[
-                'text',
-                'created_by',
                 'created',
+                'created_by',
+                'text',
             ],
             labels={
+                'created': "Date/Time",
+                'created_by': "Added by",
                 'text': "Note",
             },
         )
@@ -555,6 +601,14 @@ class CustomerOrderItemView(MasterView):
                         route_name='{}.change_status'.format(route_prefix),
                         permission='{}.change_status'.format(permission_prefix))
 
+        # change flagged
+        config.add_route(f'{route_prefix}.change_flagged',
+                         f'{instance_url_prefix}/change-flagged',
+                         request_method='POST')
+        config.add_view(cls, attr='change_flagged',
+                        route_name=f'{route_prefix}.change_flagged',
+                        permission=f'{permission_prefix}.change_status')
+
         # add note
         config.add_tailbone_permission(permission_prefix,
                                        '{}.add_note'.format(permission_prefix),

From 67ec6f7773147fbe2be0cd5c8b9315c0875f71b4 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 10 Sep 2023 19:55:48 -0500
Subject: [PATCH 051/542] Add support for "mark received" when viewing
 custorder item

---
 tailbone/templates/custorders/items/view.mako | 118 ++++++++++++++----
 tailbone/views/custorders/items.py            |  57 ++++++++-
 2 files changed, 149 insertions(+), 26 deletions(-)

diff --git a/tailbone/templates/custorders/items/view.mako b/tailbone/templates/custorders/items/view.mako
index e82b567f..c1aaf970 100644
--- a/tailbone/templates/custorders/items/view.mako
+++ b/tailbone/templates/custorders/items/view.mako
@@ -9,6 +9,7 @@
                        % endif
                        % if master.has_perm('change_status'):
                        @change-status="showChangeStatus"
+                       @mark-received="markReceivedInit"
                        % endif
                        % if master.has_perm('add_note'):
                        @add-note="showAddNote"
@@ -61,6 +62,67 @@
   % endif
 
   % if master.has_perm('change_status'):
+
+      ## TODO ##
+      <% contact = instance.order.person %>
+      <% email_address = rattail_app.get_contact_email_address(contact) %>
+
+      <b-modal has-modal-card
+               :active.sync="markReceivedShowDialog">
+        <div class="modal-card">
+
+          <header class="modal-card-head">
+            <p class="modal-card-title">Mark Received</p>
+          </header>
+
+          <section class="modal-card-body">
+            <p class="block">
+              new status will be:&nbsp;
+              <span class="has-text-weight-bold">
+                % if email_address:
+                    ${enum.CUSTORDER_ITEM_STATUS[enum.CUSTORDER_ITEM_STATUS_CONTACTED]}
+                % else:
+                    ${enum.CUSTORDER_ITEM_STATUS[enum.CUSTORDER_ITEM_STATUS_RECEIVED]}
+                % endif
+              </span>
+            </p>
+            % if email_address:
+                <p class="block">
+                  This customer has an email address on file, which
+                  means that we will automatically send them an email
+                  notification, and advance the Order Product status to
+                  "${enum.CUSTORDER_ITEM_STATUS[enum.CUSTORDER_ITEM_STATUS_CONTACTED]}".
+                </p>
+            % else:
+                <p class="block">
+                  This customer does *not* have an email address on
+                  file, which means that we will *not* automatically
+                  send them an email notification, so the Order
+                  Product status will become
+                  "${enum.CUSTORDER_ITEM_STATUS[enum.CUSTORDER_ITEM_STATUS_RECEIVED]}".
+                </p>
+            % endif
+          </section>
+
+          <footer class="modal-card-foot">
+            <b-button type="is-primary"
+                      @click="markReceivedSubmit()"
+                      :disabled="markReceivedSubmitting"
+                      icon-pack="fas"
+                      icon-left="check">
+              {{ markReceivedSubmitting ? "Working, please wait..." : "Mark Received" }}
+            </b-button>
+            <b-button @click="markReceivedShowDialog = false">
+              Cancel
+            </b-button>
+          </footer>
+        </div>
+      </b-modal>
+      ${h.form(url(f'{route_prefix}.mark_received'), ref='markReceivedForm')}
+      ${h.csrf_token(request)}
+      ${h.hidden('order_item_uuids', value=instance.uuid)}
+      ${h.end_form()}
+
       <b-modal :active.sync="showChangeStatusDialog">
         <div class="card">
           <div class="card-content">
@@ -106,21 +168,30 @@
                        :checked-rows.sync="changeStatusCheckedRows"
                        narrowed 
                        class="is-size-7">
-                <b-table-column field="product_brand" label="Brand"
+                <b-table-column field="product_key" label="${rattail_app.get_product_key_label()}"
                                 v-slot="props">
-                  <span v-html="props.row.product_brand"></span>
+                  {{ props.row.product_key }}
                 </b-table-column>
-                <b-table-column field="product_description" label="Product"
+                <b-table-column field="brand_name" label="Brand"
+                                v-slot="props">
+                  <span v-html="props.row.brand_name"></span>
+                </b-table-column>
+                <b-table-column field="product_description" label="Description"
                                 v-slot="props">
                   <span v-html="props.row.product_description"></span>
                 </b-table-column>
-                <!-- <b-table-column field="quantity" label="Quantity"> -->
-                <!--   <span v-html="props.row.quantity"></span> -->
-                <!-- </b-table-column> -->
+                <b-table-column field="product_size" label="Size"
+                                v-slot="props">
+                  <span v-html="props.row.product_size"></span>
+                </b-table-column>
                 <b-table-column field="product_case_quantity" label="cPack"
                                 v-slot="props">
                   <span v-html="props.row.product_case_quantity"></span>
                 </b-table-column>
+                <b-table-column field="department_name" label="Department"
+                                v-slot="props">
+                  <span v-html="props.row.department_name"></span>
+                </b-table-column>
                 <b-table-column field="order_quantity" label="oQty"
                                 v-slot="props">
                   <span v-html="props.row.order_quantity"></span>
@@ -129,33 +200,18 @@
                                 v-slot="props">
                   <span v-html="props.row.order_uom"></span>
                 </b-table-column>
-                <b-table-column field="department_name" label="Department"
-                                v-slot="props">
-                  <span v-html="props.row.department_name"></span>
-                </b-table-column>
-                <b-table-column field="product_barcode" label="Product Barcode"
-                                v-slot="props">
-                  <span v-html="props.row.product_barcode"></span>
-                </b-table-column>
-                <b-table-column field="unit_price" label="Unit $"
-                                v-slot="props">
-                  <span v-html="props.row.unit_price"></span>
-                </b-table-column>
                 <b-table-column field="total_price" label="Total $"
                                 v-slot="props">
                   <span v-html="props.row.total_price"></span>
                 </b-table-column>
-                <b-table-column field="order_date" label="Order Date"
-                                v-slot="props">
-                  <span v-html="props.row.order_date"></span>
-                </b-table-column>
                 <b-table-column field="status_code" label="Status"
                                 v-slot="props">
                   <span v-html="props.row.status_code"></span>
                 </b-table-column>
-                <!-- <b-table-column field="flagged" label="Flagged"> -->
-                <!--   <span v-html="props.row.flagged"></span> -->
-                <!-- </b-table-column> -->
+                <b-table-column field="flagged" label="Flagged"
+                                v-slot="props">
+                  {{ props.row.flagged ? "FLAG" : "" }}
+                </b-table-column>
               </b-table>
 
               <br />
@@ -278,6 +334,18 @@
 
     % if master.has_perm('change_status'):
 
+        ThisPageData.markReceivedShowDialog = false
+        ThisPageData.markReceivedSubmitting = false
+
+        ThisPage.methods.markReceivedInit = function() {
+            this.markReceivedShowDialog = true
+        }
+
+        ThisPage.methods.markReceivedSubmit = function() {
+            this.markReceivedSubmitting = true
+            this.$refs.markReceivedForm.submit()
+        }
+
         ThisPageData.orderItemStatuses = ${json.dumps(enum.CUSTORDER_ITEM_STATUS)|n}
         ThisPageData.orderItemStatusOptions = ${json.dumps([dict(key=k, label=v) for k, v in six.iteritems(enum.CUSTORDER_ITEM_STATUS)])|n}
 
diff --git a/tailbone/views/custorders/items.py b/tailbone/views/custorders/items.py
index baec4151..84fe615a 100644
--- a/tailbone/views/custorders/items.py
+++ b/tailbone/views/custorders/items.py
@@ -328,6 +328,16 @@ class CustomerOrderItemView(MasterView):
         items = [HTML.tag('span', c=[text])]
 
         if self.has_perm('change_status'):
+
+            # Mark Received
+            if self.can_be_received(item):
+                button = HTML.tag('b-button', type='is-primary', c="Mark Received",
+                                  style='margin-left: 1rem;',
+                                  icon_pack='fas', icon_left='check',
+                                  **{'@click': "$emit('mark-received')"})
+                items.append(button)
+
+            # Change Status
             button = HTML.tag('b-button', type='is-primary', c="Change Status",
                               style='margin-left: 1rem;',
                               icon_pack='fas', icon_left='edit',
@@ -338,6 +348,16 @@ class CustomerOrderItemView(MasterView):
         outer = HTML.tag('div', class_='level', c=[left])
         return outer
 
+    def can_be_received(self, item):
+
+        # TODO: is this generic enough?  probably belongs in handler anyway..
+        if item.status_code in (self.enum.CUSTORDER_ITEM_STATUS_INITIATED,
+                                self.enum.CUSTORDER_ITEM_STATUS_READY,
+                                self.enum.CUSTORDER_ITEM_STATUS_PLACED):
+            return True
+
+        return False
+
     def render_notes(self, item, field):
         route_prefix = self.get_route_prefix()
 
@@ -389,6 +409,7 @@ class CustomerOrderItemView(MasterView):
                                   .filter(model.CustomerOrderItem.uuid != item.uuid)\
                                   .all()
         other_data = []
+        product_key_field = self.get_product_key_field()
         for other in other_items:
 
             order_date = None
@@ -397,8 +418,10 @@ class CustomerOrderItemView(MasterView):
 
             other_data.append({
                 'uuid': other.uuid,
+                'product_key': getattr(other, f'product_{product_key_field}'),
                 'brand_name': other.product_brand,
                 'product_description': other.product_description,
+                'product_size': other.product_size,
                 'product_case_quantity': app.render_quantity(other.case_quantity),
                 'order_quantity': app.render_quantity(other.order_quantity),
                 'order_uom': self.enum.UNIT_OF_MEASURE[other.order_uom],
@@ -408,6 +431,7 @@ class CustomerOrderItemView(MasterView):
                 'total_price': app.render_currency(other.total_price),
                 'order_date': app.render_date(order_date),
                 'status_code': self.enum.CUSTORDER_ITEM_STATUS[other.status_code],
+                'flagged': other.flagged,
             })
         kwargs['other_order_items_data'] = other_data
 
@@ -450,6 +474,28 @@ class CustomerOrderItemView(MasterView):
         self.request.session.flash("Price has been confirmed.")
         return redirect
 
+    def mark_received(self):
+        """
+        View to mark some order item(s) as having been received.
+        """
+        app = self.get_rattail_app()
+        model = self.model
+        uuids = self.request.POST['order_item_uuids'].split(',')
+
+        order_items = self.Session.query(model.CustomerOrderItem)\
+                                  .filter(model.CustomerOrderItem.uuid.in_(uuids))\
+                                  .all()
+
+        handler = app.get_custorder_handler()
+        handler.mark_received(order_items, self.request.user)
+
+        msg = self.mark_received_get_flash(order_items)
+        self.request.session.flash(msg)
+        return self.redirect(self.request.get_referrer(default=self.get_index_url()))
+
+    def mark_received_get_flash(self, order_items):
+        return "Order item statuses have been updated."
+
     def change_status(self):
         """
         View for changing status of one or more order items.
@@ -550,7 +596,7 @@ class CustomerOrderItemView(MasterView):
     def get_row_data(self, item):
         return self.Session.query(model.CustomerOrderItemEvent)\
                            .filter(model.CustomerOrderItemEvent.item == item)\
-                           .order_by(model.CustomerOrderItemEvent.occurred.desc(),
+                           .order_by(model.CustomerOrderItemEvent.occurred,
                                      model.CustomerOrderItemEvent.type_code)
 
     def configure_row_grid(self, g):
@@ -571,6 +617,7 @@ class CustomerOrderItemView(MasterView):
     @classmethod
     def _order_item_defaults(cls, config):
         route_prefix = cls.get_route_prefix()
+        url_prefix = cls.get_url_prefix()
         instance_url_prefix = cls.get_instance_url_prefix()
         permission_prefix = cls.get_permission_prefix()
         model_title = cls.get_model_title()
@@ -590,6 +637,14 @@ class CustomerOrderItemView(MasterView):
                         route_name='{}.confirm_price'.format(route_prefix),
                         permission='{}.confirm_price'.format(permission_prefix))
 
+        # mark received
+        config.add_route(f'{route_prefix}.mark_received',
+                         f'{url_prefix}/mark-received',
+                         request_method='POST')
+        config.add_view(cls, attr='mark_received',
+                        route_name=f'{route_prefix}.mark_received',
+                        permission=f'{permission_prefix}.change_status')
+
         # change status
         config.add_tailbone_permission(permission_prefix,
                                        '{}.change_status'.format(permission_prefix),

From e793ba66308cbc8120dcd1a947815dc54aee6798 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 11 Sep 2023 15:24:00 -0500
Subject: [PATCH 052/542] Improve grids for custorder items

main grid as well as rows grid for Pending Product
---
 tailbone/templates/products/pending/view.mako | 21 ------
 tailbone/views/custorders/items.py            | 75 ++++++++++++-------
 tailbone/views/products.py                    | 65 ++++++++++++++--
 3 files changed, 103 insertions(+), 58 deletions(-)

diff --git a/tailbone/templates/products/pending/view.mako b/tailbone/templates/products/pending/view.mako
index be61a44f..2b9852d9 100644
--- a/tailbone/templates/products/pending/view.mako
+++ b/tailbone/templates/products/pending/view.mako
@@ -3,27 +3,6 @@
 
 <%def name="object_helpers()">
   ${parent.object_helpers()}
-  % if instance.custorder_item_records:
-      <nav class="panel">
-        <p class="panel-heading">Cross-Reference</p>
-        <div class="panel-block">
-          <div style="display: flex; flex-direction: column;">
-            <p class="block">
-              This ${model_title} is referenced by the following<br />
-              Customer Order Items:
-            </p>
-            <ul class="list">
-              % for item in instance.custorder_item_records:
-                  <li class="list-item">
-                    ${h.link_to('#{}-{}'.format(item.order.id, item.sequence), url('custorders.items.view', uuid=item.uuid))}
-                  </li>
-              % endfor
-            </ul>
-          </div>
-        </div>
-      </nav>
-  % endif
-
   % if instance.status_code == enum.PENDING_PRODUCT_STATUS_PENDING and master.has_perm('resolve_product'):
       <nav class="panel">
         <p class="panel-heading">Tools</p>
diff --git a/tailbone/views/custorders/items.py b/tailbone/views/custorders/items.py
index 84fe615a..a6853399 100644
--- a/tailbone/views/custorders/items.py
+++ b/tailbone/views/custorders/items.py
@@ -58,14 +58,18 @@ class CustomerOrderItemView(MasterView):
     grid_columns = [
         'order_id',
         'person',
+        '_product_key_',
         'product_brand',
         'product_description',
         'product_size',
+        'department_name',
+        'case_quantity',
         'order_quantity',
         'order_uom',
-        'case_quantity',
+        'total_price',
         'order_created',
         'status_code',
+        'flagged',
     ]
 
     has_rows = True
@@ -114,42 +118,55 @@ class CustomerOrderItemView(MasterView):
                                .joinedload(model.CustomerOrder.person))
 
     def configure_grid(self, g):
-        super(CustomerOrderItemView, self).configure_grid(g)
+        super().configure_grid(g)
+        batch_handler = self.get_batch_handler()
 
+        # order_id
         g.set_renderer('order_id', self.render_order_id)
+        g.set_link('order_id')
 
-        g.set_joiner('person', lambda q: q.outerjoin(model.Person))
-
-        g.filters['person'] = g.make_filter('person', model.Person.display_name,
-                                            default_active=True, default_verb='contains')
-
-        g.set_sorter('person', model.Person.display_name)
-        g.set_sorter('order_created', model.CustomerOrder.created)
-
-        g.set_sort_defaults('order_created', 'desc')
-
-        g.set_type('case_quantity', 'quantity')
-        g.set_type('cases_ordered', 'quantity')
-        g.set_type('units_ordered', 'quantity')
-        g.set_type('total_price', 'currency')
-        g.set_type('order_quantity', 'quantity')
-
-        g.set_enum('order_uom', self.enum.UNIT_OF_MEASURE)
-
-        g.set_renderer('person', self.render_person_text)
-        g.set_renderer('order_created', self.render_order_created)
-
-        g.set_renderer('status_code', self.render_status_code_column)
-
+        # person
         g.set_label('person', "Person Name")
+        g.set_renderer('person', self.render_person_text)
+        g.set_link('person')
+        g.set_joiner('person', lambda q: q.outerjoin(model.Person))
+        g.set_sorter('person', model.Person.display_name)
+        g.set_filter('person', model.Person.display_name,
+                     default_active=True, default_verb='contains')
+
+        # product_key
+        field = self.get_product_key_field()
+        g.set_renderer(field, lambda item, field: getattr(item, f'product_{field}'))
+
+        # product_*
         g.set_label('product_brand', "Brand")
+        g.set_link('product_brand')
         g.set_label('product_description', "Description")
+        g.set_link('product_description')
         g.set_label('product_size', "Size")
 
-        g.set_link('order_id')
-        g.set_link('person')
-        g.set_link('product_brand')
-        g.set_link('product_description')
+        # "numbers"
+        g.set_type('case_quantity', 'quantity')
+        g.set_type('order_quantity', 'quantity')
+        g.set_type('total_price', 'currency')
+        # TODO: deprecate / remove these
+        g.set_type('cases_ordered', 'quantity')
+        g.set_type('units_ordered', 'quantity')
+
+        # order_uom
+        # nb. this is not relevant if "case orders only"
+        if not batch_handler.allow_unit_orders():
+            g.remove('order_uom')
+        else:
+            g.set_enum('order_uom', self.enum.UNIT_OF_MEASURE)
+
+        # order_created
+        g.set_renderer('order_created', self.render_order_created)
+        g.set_sorter('order_created', model.CustomerOrder.created)
+        g.set_sort_defaults('order_created', 'desc')
+
+        # status_code
+        g.set_renderer('status_code', self.render_status_code_column)
 
     def render_order_id(self, item, field):
         return item.order.id
diff --git a/tailbone/views/products.py b/tailbone/views/products.py
index e0183d14..2b03871b 100644
--- a/tailbone/views/products.py
+++ b/tailbone/views/products.py
@@ -2257,15 +2257,28 @@ class PendingProductView(MasterView):
     # TODO: add support for this someday
     rows_viewable = False
 
-    # TODO: this clearly needs help
+    row_labels = {
+        'order_id': "Order ID",
+        'product_brand': "Brand",
+        'product_description': "Description",
+        'product_size': "Size",
+        'order_created': "Ordered",
+        'status_code': "Status",
+    }
+
     row_grid_columns = [
-        # 'upc',
-        'brand_name',
-        'description',
-        'size',
-        'vendor_name',
-        # 'regular_price',
-        # 'current_price',
+        'order_id',
+        'customer',
+        'person',
+        '_product_key_',
+        'product_brand',
+        'product_description',
+        'product_size',
+        'order_quantity',
+        'total_price',
+        'order_created',
+        'status_code',
+        'flagged',
     ]
 
     def configure_grid(self, g):
@@ -2468,6 +2481,42 @@ class PendingProductView(MasterView):
     def get_parent(self, item):
         return item.pending_product
 
+    def configure_row_grid(self, g):
+        super().configure_row_grid(g)
+        app = self.get_rattail_app()
+
+        # order_id
+        g.set_renderer('order_id', lambda item, field: item.order.id)
+
+        # contact
+        handler = app.get_batch_handler('custorder')
+        if handler.new_order_requires_customer():
+            g.remove('person')
+            g.set_renderer('customer', lambda item, field: item.order.customer)
+        else:
+            g.remove('customer')
+            g.set_renderer('person', lambda item, field: item.order.person)
+
+        # product_key
+        field = self.get_product_key_field()
+        if not self.rows_viewable:
+            g.set_link(field, False)
+        g.set_renderer(field, lambda item, field: getattr(item, f'product_{field}'))
+
+        # "numbers"
+        g.set_type('order_quantity', 'quantity')
+        g.set_type('total_price', 'currency')
+
+        # order_created
+        g.set_renderer('order_created',
+                       lambda item, field: raw_datetime(self.rattail_config,
+                                                        app.localtime(item.order.created,
+                                                                      from_utc=True),
+                                                        as_date=True))
+
+        # status_code
+        g.set_enum('status_code', self.enum.CUSTORDER_ITEM_STATUS)
+
     @classmethod
     def defaults(cls, config):
         cls._defaults(config)

From 60044d5cdf741b578ebef7b041048296feba887d Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 11 Sep 2023 15:58:35 -0500
Subject: [PATCH 053/542] Update changelog

---
 CHANGES.rst          | 20 ++++++++++++++++++++
 tailbone/_version.py |  2 +-
 2 files changed, 21 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index fc858f6b..c01f7418 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,26 @@
 CHANGELOG
 =========
 
+0.9.49 (2023-09-11)
+-------------------
+
+* Add custom hook for grid "apply filters".
+
+* Use common POST logic for submitting new customer order.
+
+* Optionally configure SQLAlchemy Session with ``future=True``.
+
+* Show related customer orders for Pending Product view.
+
+* Set stacklevel for all deprecation warnings.
+
+* Add support for toggling custorder item "flagged".
+
+* Add support for "mark received" when viewing custorder item.
+
+* Misc. improvements for custorder views.
+
+
 0.9.48 (2023-09-08)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 014d9357..75375549 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.48'
+__version__ = '0.9.49'

From e930199f83183ef3e82ca64962f5e53b9b3ce687 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 11 Sep 2023 17:13:07 -0500
Subject: [PATCH 054/542] Avoid legacy logic for `Customer.people` schema

---
 tailbone/views/customers.py | 25 +++----------------------
 1 file changed, 3 insertions(+), 22 deletions(-)

diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py
index 078cda58..0860fc31 100644
--- a/tailbone/views/customers.py
+++ b/tailbone/views/customers.py
@@ -146,16 +146,8 @@ class CustomerView(MasterView):
         query = super().query(session)
         app = self.get_rattail_app()
         model = self.model
-        if app.get_clientele_handler().should_use_legacy_people():
-            query = query.outerjoin(model.CustomerPerson,
-                                    sa.and_(
-                                        model.CustomerPerson.customer_uuid == model.Customer.uuid,
-                                        model.CustomerPerson.ordinal == 1))\
-                         .outerjoin(model.Person,
-                                    model.Person.uuid == model.CustomerPerson.person_uuid)
-        else:
-            query = query.outerjoin(model.Person,
-                                    model.Person.uuid == model.Customer.account_holder_uuid)
+        query = query.outerjoin(model.Person,
+                                model.Person.uuid == model.Customer.account_holder_uuid)
         return query
 
     def configure_grid(self, g):
@@ -163,7 +155,6 @@ class CustomerView(MasterView):
         app = self.get_rattail_app()
         model = self.model
         route_prefix = self.get_route_prefix()
-        legacy = app.get_clientele_handler().should_use_legacy_people()
 
         # customer key
         field = self.get_customer_key_field()
@@ -203,17 +194,7 @@ class CustomerView(MasterView):
 
         # person
         g.set_renderer('person', self.grid_render_person)
-        if legacy:
-            LegacyPerson = orm.aliased(model.Person)
-            g.set_joiner('person', lambda q:
-                         q.outerjoin(model.CustomerPerson,
-                                     sa.and_(
-                                         model.CustomerPerson.customer_uuid == model.Customer.uuid,
-                                         model.CustomerPerson.ordinal == 1))\
-                         .outerjoin(LegacyPerson))
-            g.set_sorter('person', LegacyPerson.display_name)
-        else:
-            g.set_sorter('person', model.Person.display_name)
+        g.set_sorter('person', model.Person.display_name)
 
         # active_in_pos
         if self.get_expose_active_in_pos():

From 1cad8b24810443467ea79e999ab8d6f9b2208dc1 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 12 Sep 2023 12:35:53 -0500
Subject: [PATCH 055/542] Show events instead of notes, in field subgrid for
 custorder item

---
 tailbone/templates/custorders/items/view.mako |  37 +---
 tailbone/views/custorders/items.py            | 163 ++++++++----------
 2 files changed, 77 insertions(+), 123 deletions(-)

diff --git a/tailbone/templates/custorders/items/view.mako b/tailbone/templates/custorders/items/view.mako
index c1aaf970..592095ff 100644
--- a/tailbone/templates/custorders/items/view.mako
+++ b/tailbone/templates/custorders/items/view.mako
@@ -280,7 +280,7 @@
                       :disabled="addNoteSaveDisabled"
                       icon-pack="fas"
                       icon-left="save">
-              {{ addNoteSubmitText }}
+              {{ addNoteSubmitting ? "Working, please wait..." : "Save Note" }}
             </b-button>
             <b-button @click="showAddNoteDialog = false">
               Cancel
@@ -295,7 +295,7 @@
   ${parent.modify_this_page_vars()}
   <script type="text/javascript">
 
-    ${form.component_studly}Data.notesData = ${json.dumps(notes_data)|n}
+    ${form.component_studly}Data.eventsData = ${json.dumps(events_data)|n}
 
     % if master.has_perm('confirm_price'):
 
@@ -406,7 +406,6 @@
         ThisPageData.newNoteText = null
         ThisPageData.newNoteApplyAll = false
         ThisPageData.addNoteSubmitting = false
-        ThisPageData.addNoteSubmitText = "Save Note"
 
         ThisPage.computed.addNoteSaveDisabled = function() {
             if (!this.newNoteText) {
@@ -429,43 +428,19 @@
 
         ThisPage.methods.addNoteSave = function() {
             this.addNoteSubmitting = true
-            this.addNoteSubmitText = "Working, please wait..."
 
             let url = '${url('{}.add_note'.format(route_prefix), uuid=instance.uuid)}'
-
             let params = {
                 note: this.newNoteText,
                 apply_all: this.newNoteApplyAll,
             }
 
-            let headers = {
-                ## TODO: should find a better way to handle CSRF token
-                'X-CSRF-TOKEN': this.csrftoken,
-            }
-
-            ## TODO: should find a better way to handle CSRF token
-            this.$http.post(url, params, {headers: headers}).then(({ data }) => {
-                if (data.success) {
-                    this.$refs.mainForm.notesData = data.notes
-                    this.showAddNoteDialog = false
-                } else {
-                    this.$buefy.toast.open({
-                        message: "Save failed:  " + (data.error || "(unknown error)"),
-                        type: 'is-danger',
-                        duration: 4000, // 4 seconds
-                    })
-                }
+            this.simplePOST(url, params, response => {
+                this.$refs.mainForm.eventsData = response.data.events
+                this.showAddNoteDialog = false
                 this.addNoteSubmitting = false
-                this.addNoteSubmitText = "Save Note"
-            }).catch((error) => {
-                // TODO: should handle this better somehow..?
-                this.$buefy.toast.open({
-                    message: "Save failed:  (unknown error)",
-                    type: 'is-danger',
-                    duration: 4000, // 4 seconds
-                })
+            }, response => {
                 this.addNoteSubmitting = false
-                this.addNoteSubmitText = "Save Note"
             })
         }
 
diff --git a/tailbone/views/custorders/items.py b/tailbone/views/custorders/items.py
index a6853399..91976196 100644
--- a/tailbone/views/custorders/items.py
+++ b/tailbone/views/custorders/items.py
@@ -28,8 +28,7 @@ import datetime
 
 from sqlalchemy import orm
 
-from rattail.db import model
-from rattail.time import localtime
+from rattail.db.model import CustomerOrderItem
 
 from webhelpers2.html import HTML, tags
 
@@ -41,7 +40,7 @@ class CustomerOrderItemView(MasterView):
     """
     Master view for customer order items
     """
-    model_class = model.CustomerOrderItem
+    model_class = CustomerOrderItem
     route_prefix = 'custorders.items'
     url_prefix = '/custorders/items'
     creatable = False
@@ -72,21 +71,6 @@ class CustomerOrderItemView(MasterView):
         'flagged',
     ]
 
-    has_rows = True
-    model_row_class = model.CustomerOrderItemEvent
-    rows_title = "Event History"
-    rows_filterable = False
-    rows_sortable = False
-    rows_pageable = False
-    rows_viewable = False
-
-    row_grid_columns = [
-        'occurred',
-        'type_code',
-        'user',
-        'note',
-    ]
-
     form_fields = [
         'order',
         'customer',
@@ -98,20 +82,32 @@ class CustomerOrderItemView(MasterView):
         'product_brand',
         'product_description',
         'product_size',
+        'case_quantity',
         'order_quantity',
         'order_uom',
-        'case_quantity',
         'unit_price',
         'total_price',
         'special_order',
         'price_needs_confirmation',
         'paid_amount',
+        'payment_transaction_number',
         'status_code',
         'flagged',
-        'notes',
+        'contact_attempts',
+        'last_contacted',
+        'events',
     ]
 
+    def __init__(self, request):
+        super().__init__(request)
+        app = self.get_rattail_app()
+        self.custorder_handler = app.get_custorder_handler()
+        self.batch_handler = app.get_batch_handler(
+            'custorder',
+            default='rattail.batch.custorder:CustomerOrderBatchHandler')
+
     def query(self, session):
+        model = self.model
         return session.query(model.CustomerOrderItem)\
                       .join(model.CustomerOrder)\
                       .options(orm.joinedload(model.CustomerOrderItem.order)\
@@ -119,7 +115,7 @@ class CustomerOrderItemView(MasterView):
 
     def configure_grid(self, g):
         super().configure_grid(g)
-        batch_handler = self.get_batch_handler()
+        model = self.model
 
         # order_id
         g.set_renderer('order_id', self.render_order_id)
@@ -155,7 +151,7 @@ class CustomerOrderItemView(MasterView):
 
         # order_uom
         # nb. this is not relevant if "case orders only"
-        if not batch_handler.allow_unit_orders():
+        if not self.batch_handler.allow_unit_orders():
             g.remove('order_uom')
         else:
             g.set_enum('order_uom', self.enum.UNIT_OF_MEASURE)
@@ -168,6 +164,19 @@ class CustomerOrderItemView(MasterView):
         # status_code
         g.set_renderer('status_code', self.render_status_code_column)
 
+        # abbreviate some labels, only in grid header
+        g.set_label('case_quantity', "Case Qty")
+        g.filters['case_quantity'].label = "Case Quantity"
+        g.set_label('order_quantity', "Order Qty")
+        g.filters['order_quantity'].label = "Order Quantity"
+        g.set_label('department_name', "Department")
+        g.filters['department_name'].label = "Department Name"
+        g.set_label('total_price', "Total")
+        g.filters['total_price'].label = "Total Price"
+        g.set_label('order_created', "Ordered")
+        if 'order_created' in g.filters:
+            g.filters['order_created'].label = "Order Created"
+
     def render_order_id(self, item, field):
         return item.order.id
 
@@ -178,7 +187,8 @@ class CustomerOrderItemView(MasterView):
             return text
 
     def render_order_created(self, item, column):
-        value = localtime(self.rattail_config, item.order.created, from_utc=True)
+        app = self.get_rattail_app()
+        value = app.localtime(item.order.created, from_utc=True)
         return raw_datetime(self.rattail_config, value)
 
     def render_status_code_column(self, item, field):
@@ -188,12 +198,6 @@ class CustomerOrderItemView(MasterView):
             return HTML.tag('span', title=item.status_text, c=[text])
         return text
 
-    def get_batch_handler(self):
-        app = self.get_rattail_app()
-        return app.get_batch_handler(
-            'custorder',
-            default='rattail.batch.custorder:CustomerOrderBatchHandler')
-
     def configure_form(self, f):
         super().configure_form(f)
         item = f.model_instance
@@ -202,8 +206,7 @@ class CustomerOrderItemView(MasterView):
         f.set_renderer('order', self.render_order)
 
         # contact
-        batch_handler = self.get_batch_handler()
-        if batch_handler.new_order_requires_customer():
+        if self.batch_handler.new_order_requires_customer():
             f.remove('person')
         else:
             f.remove('customer')
@@ -221,7 +224,9 @@ class CustomerOrderItemView(MasterView):
             elif item.pending_product and not item.product:
                 f.remove('product')
 
-        # product uom
+        # product*
+        if not self.creating and item.product:
+            f.remove('product_brand', 'product_description')
         f.set_enum('product_unit_of_measure', self.enum.UNIT_OF_MEASURE)
 
         # highlight pending fields
@@ -251,8 +256,8 @@ class CustomerOrderItemView(MasterView):
         # flagged
         f.set_renderer('flagged', self.render_flagged)
 
-        # notes
-        f.set_renderer('notes', self.render_notes)
+        # events
+        f.set_renderer('events', self.render_events)
 
     def render_flagged(self, item, field):
         text = "Yes" if item.flagged else "No"
@@ -375,27 +380,28 @@ class CustomerOrderItemView(MasterView):
 
         return False
 
-    def render_notes(self, item, field):
+    def render_events(self, item, field):
         route_prefix = self.get_route_prefix()
 
         factory = self.get_grid_factory()
         g = factory(
-            key=f'{route_prefix}.notes',
+            key=f'{route_prefix}.events',
             data=[],
             columns=[
-                'created',
-                'created_by',
-                'text',
+                'occurred',
+                'type_code',
+                'user',
+                'note',
             ],
             labels={
-                'created': "Date/Time",
-                'created_by': "Added by",
-                'text': "Note",
+                'occurred': "When",
+                'type_code': "What",
+                'user': "Who",
             },
         )
 
         table = HTML.literal(
-            g.render_buefy_table_element(data_prop='notesData'))
+            g.render_buefy_table_element(data_prop='eventsData'))
         elements = [table]
 
         if self.has_perm('add_note'):
@@ -412,12 +418,13 @@ class CustomerOrderItemView(MasterView):
                         c=elements)
 
     def template_kwargs_view(self, **kwargs):
-        kwargs = super(CustomerOrderItemView, self).template_kwargs_view(**kwargs)
+        kwargs = super().template_kwargs_view(**kwargs)
+        model = self.model
         app = self.get_rattail_app()
         item = kwargs['instance']
 
-        # fetch notes for current item
-        kwargs['notes_data'] = self.get_context_notes(item)
+        # fetch events for current item
+        kwargs['events_data'] = self.get_context_events(item)
 
         # fetch "other" order items, siblings of current one
         order = item.order
@@ -431,7 +438,7 @@ class CustomerOrderItemView(MasterView):
 
             order_date = None
             if order.created:
-                order_date = localtime(self.rattail_config, order.created, from_utc=True).date()
+                order_date = app.localtime(order.created, from_utc=True).date()
 
             other_data.append({
                 'uuid': other.uuid,
@@ -454,16 +461,18 @@ class CustomerOrderItemView(MasterView):
 
         return kwargs
 
-    def get_context_notes(self, item):
-        notes = []
-        for note in reversed(item.notes):
-            created = localtime(self.rattail_config, note.created, from_utc=True)
-            notes.append({
-                'created': raw_datetime(self.rattail_config, created),
-                'created_by': note.created_by.display_name,
-                'text': note.text,
+    def get_context_events(self, item):
+        app = self.get_rattail_app()
+        events = []
+        for event in item.events:
+            occurred = app.localtime(event.occurred, from_utc=True)
+            events.append({
+                'occurred': raw_datetime(self.rattail_config, occurred),
+                'type_code': self.enum.CUSTORDER_ITEM_EVENT.get(event.type_code, event.type_code),
+                'user': str(event.user),
+                'note': event.note,
             })
-        return notes
+        return events
 
     def confirm_price(self):
         """
@@ -517,6 +526,7 @@ class CustomerOrderItemView(MasterView):
         """
         View for changing status of one or more order items.
         """
+        model = self.model
         order_item = self.get_instance()
         redirect = self.redirect(self.get_action_url('view', order_item))
 
@@ -570,30 +580,15 @@ class CustomerOrderItemView(MasterView):
         View for adding a new note to current order item, optinally
         also adding it to all other items under the parent order.
         """
-        order_item = self.get_instance()
+        item = self.get_instance()
         data = self.request.json_body
-        new_note = data['note']
-        apply_all = data['apply_all'] == True
-        user = self.request.user
 
-        if apply_all:
-            order_items = order_item.order.items
-        else:
-            order_items = [order_item]
-
-        for item in order_items:
-            item.notes.append(model.CustomerOrderItemNote(
-                created_by=user, text=new_note))
-
-            # # attach event
-            # item.events.append(model.CustomerOrderItemEvent(
-            #     type_code=self.enum.CUSTORDER_ITEM_EVENT_ADDED_NOTE,
-            #     user=user, note=new_note))
+        self.custorder_handler.add_note(item, data['note'], self.request.user,
+                                        apply_all=data['apply_all'] == True)
 
         self.Session.flush()
-        self.Session.refresh(order_item)
-        return {'success': True,
-                'notes': self.get_context_notes(order_item)}
+        self.Session.refresh(item)
+        return {'events': self.get_context_events(item)}
 
     def render_order(self, item, field):
         order = item.order
@@ -610,22 +605,6 @@ class CustomerOrderItemView(MasterView):
             url = self.request.route_url('people.view', uuid=person.uuid)
             return tags.link_to(text, url)
 
-    def get_row_data(self, item):
-        return self.Session.query(model.CustomerOrderItemEvent)\
-                           .filter(model.CustomerOrderItemEvent.item == item)\
-                           .order_by(model.CustomerOrderItemEvent.occurred,
-                                     model.CustomerOrderItemEvent.type_code)
-
-    def configure_row_grid(self, g):
-        super(CustomerOrderItemView, self).configure_row_grid(g)
-
-        g.set_enum('type_code', self.enum.CUSTORDER_ITEM_EVENT)
-
-        g.set_label('occurred', "When")
-        g.set_label('type_code', "What") # TODO: enum renderer
-        g.set_label('user', "Who")
-        g.set_label('note', "Notes")
-
     @classmethod
     def defaults(cls, config):
         cls._order_item_defaults(config)

From 03fc301dec61336e540480facd809187f8db6adb Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 12 Sep 2023 18:31:18 -0500
Subject: [PATCH 056/542] Update changelog

---
 CHANGES.rst          | 8 ++++++++
 tailbone/_version.py | 2 +-
 2 files changed, 9 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index c01f7418..9a7316ef 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,14 @@
 CHANGELOG
 =========
 
+0.9.50 (2023-09-12)
+-------------------
+
+* Avoid legacy logic for ``Customer.people`` schema.
+
+* Show events instead of notes, in field subgrid for custorder item.
+
+
 0.9.49 (2023-09-11)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 75375549..7725363d 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.49'
+__version__ = '0.9.50'

From 608da824d95379327282786ea5882aba3735e557 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 13 Sep 2023 13:14:00 -0500
Subject: [PATCH 057/542] Tweak default field list for batch views

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

diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py
index 1f5e2be9..79d3f581 100644
--- a/tailbone/views/batch/core.py
+++ b/tailbone/views/batch/core.py
@@ -100,10 +100,10 @@ class BatchMasterView(MasterView):
         'description',
         'notes',
         'params',
-        'created',
-        'created_by',
         'rowcount',
         'status_code',
+        'created',
+        'created_by',
         'executed',
         'executed_by',
     ]

From eed73eca81c6483939724ac69f684f7f10bcafbb Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 14 Sep 2023 12:56:15 -0500
Subject: [PATCH 058/542] Add `get_rattail_app()` method for view supplements

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

diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index 4aacc9f1..c515da7b 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -5585,6 +5585,9 @@ class ViewSupplement(object):
         self.rattail_config = master.rattail_config
         self.Session = master.Session
 
+    def get_rattail_app(self):
+        return self.master.get_rattail_app()
+
     def get_grid_query(self, query):
         """
         Return the "base" query for the grid.  This is invoked from

From ac6106ca697b3070f97a1fa5b7cecd5e3d5c6e84 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 15 Sep 2023 10:34:25 -0500
Subject: [PATCH 059/542] Update changelog

---
 CHANGES.rst          | 8 ++++++++
 tailbone/_version.py | 2 +-
 2 files changed, 9 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 9a7316ef..9389bb0e 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,14 @@
 CHANGELOG
 =========
 
+0.9.51 (2023-09-15)
+-------------------
+
+* Tweak default field list for batch views.
+
+* Add ``get_rattail_app()`` method for view supplements.
+
+
 0.9.50 (2023-09-12)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 7725363d..f51a34fd 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.50'
+__version__ = '0.9.51'

From 3968e40a0b9fb416ad7eedf0f6e07844f9debd1b Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 15 Sep 2023 19:19:20 -0500
Subject: [PATCH 060/542] Add basic feature for "grid totals"

---
 tailbone/templates/master/index.mako | 35 ++++++++++++++++++++++++++++
 tailbone/views/master.py             | 13 +++++++++++
 tailbone/views/members.py            |  7 ++++++
 3 files changed, 55 insertions(+)

diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako
index d2215abe..b0ee17d6 100644
--- a/tailbone/templates/master/index.mako
+++ b/tailbone/templates/master/index.mako
@@ -28,6 +28,19 @@
 
 <%def name="grid_tools()">
 
+  ## grid totals
+  % if master.supports_grid_totals:
+      <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>
+  % endif
+
   ## download search results
   % if master.results_downloadable and master.has_perm('download_results'):
       <b-button type="is-primary"
@@ -321,6 +334,28 @@
   ${parent.modify_this_page_vars()}
   <script type="text/javascript">
 
+    % if master.supports_grid_totals:
+        ${grid.component_studly}Data.gridTotalsDisplay = null
+        ${grid.component_studly}Data.gridTotalsFetching = false
+
+        ${grid.component_studly}.methods.gridTotalsFetch = function() {
+            this.gridTotalsFetching = true
+
+            let url = '${url(f'{route_prefix}.fetch_grid_totals')}'
+            this.simpleGET(url, {}, response => {
+                this.gridTotalsDisplay = response.data.totals_display
+                this.gridTotalsFetching = false
+            }, response => {
+                this.gridTotalsFetching = false
+            })
+        }
+
+        ${grid.component_studly}.methods.appliedFiltersHook = function() {
+            this.gridTotalsDisplay = null
+            this.gridTotalsFetching = false
+        }
+    % endif
+
     ## maybe auto-redirect to download latest results file
     % if download_results_path:
         ThisPage.methods.downloadResultsRedirect = function() {
diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index c515da7b..04262124 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -107,6 +107,7 @@ class MasterView(View):
     set_deletable = False
     supports_autocomplete = False
     supports_set_enabled_toggle = False
+    supports_grid_totals = False
     populatable = False
     mergeable = False
     merge_handler = None
@@ -1837,6 +1838,9 @@ class MasterView(View):
             self.request.session.flash("Deleted {} {}".format(len(objects), model_title_plural))
         return self.redirect(self.get_index_url())
 
+    def fetch_grid_totals(self):
+        return {'totals_display': "TODO: totals go here"}
+
     def oneoff_import(self, importer, host_object=None):
         """
         Basic helper method, to do a one-off import (or export, depending on
@@ -5198,6 +5202,15 @@ class MasterView(View):
                 config.add_view(cls, attr='download_results_rows', route_name='{}.download_results_rows'.format(route_prefix),
                                 permission='{}.download_results_rows'.format(permission_prefix))
 
+            # fetch total hours
+            if cls.supports_grid_totals:
+                config.add_route(f'{route_prefix}.fetch_grid_totals',
+                                 f'{url_prefix}/fetch-grid-totals')
+                config.add_view(cls, attr='fetch_grid_totals',
+                                route_name=f'{route_prefix}.fetch_grid_totals',
+                                permission=f'{permission_prefix}.list',
+                                renderer='json')
+
         # configure
         if cls.configurable:
             config.add_tailbone_permission(permission_prefix,
diff --git a/tailbone/views/members.py b/tailbone/views/members.py
index d2a0e455..61b190c2 100644
--- a/tailbone/views/members.py
+++ b/tailbone/views/members.py
@@ -406,6 +406,7 @@ class MemberEquityPaymentView(MasterView):
     model_class = model.MemberEquityPayment
     route_prefix = 'member_equity_payments'
     url_prefix = '/member-equity-payments'
+    supports_grid_totals = True
     has_versions = True
 
     grid_columns = [
@@ -470,6 +471,12 @@ class MemberEquityPaymentView(MasterView):
         key = getattr(payment.member, field)
         return key
 
+    def fetch_grid_totals(self):
+        app = self.get_rattail_app()
+        results = self.get_effective_data()
+        total = sum([payment.amount for payment in results])
+        return {'totals_display': app.render_currency(total)}
+
     def configure_form(self, f):
         super().configure_form(f)
         model = self.model

From 1cfc275eae3262ce9fb727a74de6c6328c86c003 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 15 Sep 2023 19:30:27 -0500
Subject: [PATCH 061/542] Update changelog

---
 CHANGES.rst          | 6 ++++++
 tailbone/_version.py | 2 +-
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 9389bb0e..4a84df53 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,12 @@
 CHANGELOG
 =========
 
+0.9.52 (2023-09-15)
+-------------------
+
+* Add basic feature for "grid totals".
+
+
 0.9.51 (2023-09-15)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index f51a34fd..66e8068d 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.51'
+__version__ = '0.9.52'

From df897aef13add3501923d7522af2561a785bd9b2 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sat, 16 Sep 2023 13:06:26 -0500
Subject: [PATCH 062/542] Make member key field readonly when viewing equity
 payment

---
 tailbone/views/members.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/tailbone/views/members.py b/tailbone/views/members.py
index 61b190c2..0de8fa67 100644
--- a/tailbone/views/members.py
+++ b/tailbone/views/members.py
@@ -485,6 +485,7 @@ class MemberEquityPaymentView(MasterView):
         # member_key
         field = self.get_member_key_field()
         f.set_renderer(field, self.render_member_key)
+        f.set_readonly(field)
 
         # member
         if self.creating:

From 99065548fff42d262c40f59a0a1dda94ff3e5648 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sat, 16 Sep 2023 13:06:54 -0500
Subject: [PATCH 063/542] Update changelog

---
 CHANGES.rst          | 6 ++++++
 tailbone/_version.py | 2 +-
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 4a84df53..38d8afea 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,12 @@
 CHANGELOG
 =========
 
+0.9.53 (2023-09-16)
+-------------------
+
+* Make member key field readonly when viewing equity payment.
+
+
 0.9.52 (2023-09-15)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 66e8068d..118085ee 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.52'
+__version__ = '0.9.53'

From a807a0f50c1e332b2a066b10f2a01a4b8bb6a134 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sat, 16 Sep 2023 20:01:32 -0500
Subject: [PATCH 064/542] Add "falafel" custom date/time field type and widget

finally able to edit datetime fields, but feels like a lot of
assumptions to make, just to determine time zone..so keeping naive UTC
on the backend still, and naive local on the frontend

in general this needs more polish, but is a start..
---
 tailbone/forms/__init__.py                    |  7 ++-
 tailbone/forms/core.py                        | 13 ++++++
 tailbone/forms/types.py                       | 46 +++++++++++++++++++
 tailbone/forms/widgets.py                     | 10 +++-
 .../static/js/tailbone.buefy.timepicker.js    | 44 +++++++++++++++++-
 tailbone/templates/deform/datetime_falafel.pt | 23 ++++++++++
 6 files changed, 136 insertions(+), 7 deletions(-)
 create mode 100644 tailbone/templates/deform/datetime_falafel.pt

diff --git a/tailbone/forms/__init__.py b/tailbone/forms/__init__.py
index a368f2d1..34b34a6c 100644
--- a/tailbone/forms/__init__.py
+++ b/tailbone/forms/__init__.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2018 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,8 +24,7 @@
 Forms Library
 """
 
-from __future__ import unicode_literals, absolute_import
-
-from . import types
+# nb. import widgets before types, b/c types may refer to widgets
 from . import widgets
+from . import types
 from .core import Form, SimpleFileImport
diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py
index c4a7b0ea..245ee1e4 100644
--- a/tailbone/forms/core.py
+++ b/tailbone/forms/core.py
@@ -590,8 +590,18 @@ class Form(object):
             self.schema[key] = node
 
     def set_type(self, key, type_, **kwargs):
+
         if type_ == 'datetime':
             self.set_renderer(key, self.render_datetime)
+
+        elif type_ == 'datetime_falafel':
+            self.set_renderer(key, self.render_datetime)
+            self.set_node(key, types.FalafelDateTime(request=self.request))
+            if kwargs.get('helptext'):
+                app = self.request.rattail_config.get_app()
+                timezone = app.get_timezone()
+                self.set_helptext(key, f"NOTE: all times are local to {timezone}")
+
         elif type_ == 'datetime_local':
             self.set_renderer(key, self.render_datetime_local)
         elif type_ == 'date_plain':
@@ -871,6 +881,9 @@ class Form(object):
             if field.cstruct is colander.null:
                 return '[]'
 
+        if isinstance(field.schema.typ, types.FalafelDateTime):
+            return field.cstruct
+
         try:
             return self.jsonify_value(field.cstruct)
         except Exception as error:
diff --git a/tailbone/forms/types.py b/tailbone/forms/types.py
index 0d87ae3f..173a83a2 100644
--- a/tailbone/forms/types.py
+++ b/tailbone/forms/types.py
@@ -26,6 +26,7 @@ Form Schema Types
 
 import re
 import datetime
+import json
 
 from rattail.db import model
 from rattail.gpc import GPC
@@ -33,6 +34,7 @@ from rattail.gpc import GPC
 import colander
 
 from tailbone.db import Session
+from tailbone.forms import widgets
 
 
 class JQueryTime(colander.Time):
@@ -72,6 +74,50 @@ class DateTimeBoolean(colander.Boolean):
             return datetime.datetime.utcnow()
 
 
+class FalafelDateTime(colander.DateTime):
+    """
+    Custom schema node type for rattail UTC datetimes
+    """
+    widget_maker = widgets.FalafelDateTimeWidget
+
+    def __init__(self, *args, **kwargs):
+        request = kwargs.pop('request')
+        super().__init__(*args, **kwargs)
+        self.request = request
+
+    def serialize(self, node, appstruct):
+        if not appstruct:
+            return colander.null
+
+        # cant use isinstance; dt subs date
+        if type(appstruct) is datetime.date:
+            appstruct = datetime.datetime.combine(appstruct, datetime.time())
+
+        if not isinstance(appstruct, datetime.datetime):
+            raise colander.Invalid(node, f'"{appstruct}" is not a datetime object')
+
+        if appstruct.tzinfo is None:
+            appstruct = appstruct.replace(tzinfo=self.default_tzinfo)
+
+        app = self.request.rattail_config.get_app()
+        dt = app.localtime(appstruct, from_utc=True)
+
+        return json.dumps({
+            'date': str(dt.date()),
+            'time': str(dt.time()),
+        })
+
+    def deserialize(self, node, cstruct):
+        if not cstruct:
+            return colander.null
+
+        app = self.request.rattail_config.get_app()
+        result = datetime.datetime.strptime(cstruct, '%Y-%m-%dT%H:%M:%S')
+        result = app.localtime(result)
+        result = app.make_utc(result)
+        return result
+
+
 class GPCType(colander.SchemaType):
     """
     Schema type for product GPC data.
diff --git a/tailbone/forms/widgets.py b/tailbone/forms/widgets.py
index f672ab47..69f57520 100644
--- a/tailbone/forms/widgets.py
+++ b/tailbone/forms/widgets.py
@@ -33,7 +33,6 @@ from deform import widget as dfwidget
 from webhelpers2.html import tags, HTML
 
 from tailbone.db import Session
-from tailbone.forms.types import ProductQuantity
 
 
 class ReadonlyWidget(dfwidget.HiddenWidget):
@@ -119,6 +118,8 @@ class CasesUnitsWidget(dfwidget.Widget):
         return field.renderer(template, **values)
 
     def deserialize(self, field, pstruct):
+        from tailbone.forms.types import ProductQuantity
+
         if pstruct is colander.null:
             return colander.null
 
@@ -235,6 +236,13 @@ class JQueryTimeWidget(dfwidget.TimeInputWidget):
     )
 
 
+class FalafelDateTimeWidget(dfwidget.DateTimeInputWidget):
+    """
+    Custom widget for rattail UTC datetimes
+    """
+    template = 'datetime_falafel'
+
+
 class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget):
     """ 
     Uses the jQuery autocomplete plugin, instead of whatever it is deform uses
diff --git a/tailbone/static/js/tailbone.buefy.timepicker.js b/tailbone/static/js/tailbone.buefy.timepicker.js
index 6cca75f3..207a7940 100644
--- a/tailbone/static/js/tailbone.buefy.timepicker.js
+++ b/tailbone/static/js/tailbone.buefy.timepicker.js
@@ -9,15 +9,55 @@ const TailboneTimepicker = {
         'placeholder="Click to select ..."',
         'icon-pack="fas"',
         'icon="clock"',
+        ':value="value ? parseTime(value) : null"',
         'hour-format="12"',
+        '@input="timeChanged"',
+        ':time-formatter="formatTime"',
         '>',
         '</b-timepicker>'
     ].join(' '),
 
     props: {
         name: String,
-        id: String
-    }
+        id: String,
+        value: String,
+    },
+
+    methods: {
+
+        formatTime(time) {
+            if (time === null) {
+                return null
+            }
+
+            let h = time.getHours()
+            let m = time.getMinutes()
+            let s = time.getSeconds()
+
+            h = h < 10 ? '0' + h : h
+            m = m < 10 ? '0' + m : m
+            s = s < 10 ? '0' + s : s
+
+            return h + ':' + m + ':' + s
+        },
+
+        parseTime(time) {
+
+            if (time.getHours) {
+                return time
+            }
+
+            let found = time.match(/^(\d\d):(\d\d):\d\d$/)
+            if (found) {
+                return new Date(null, null, null,
+                                parseInt(found[1]), parseInt(found[2]))
+            }
+        },
+
+        timeChanged(time) {
+            this.$emit('input', time)
+        },
+    },
 }
 
 Vue.component('tailbone-timepicker', TailboneTimepicker)
diff --git a/tailbone/templates/deform/datetime_falafel.pt b/tailbone/templates/deform/datetime_falafel.pt
new file mode 100644
index 00000000..17cfe6c3
--- /dev/null
+++ b/tailbone/templates/deform/datetime_falafel.pt
@@ -0,0 +1,23 @@
+<div tal:omit-tag=""
+     tal:define="name name|field.name;
+                 vmodel vmodel|'field_model_' + name;">
+
+  <b-field grouped>
+    ${field.start_mapping()}
+
+    <b-field label="Date">
+      <tailbone-datepicker name="date"
+                           v-model="${vmodel}.date">
+      </tailbone-datepicker>
+    </b-field>
+
+    <b-field label="Time">
+      <tailbone-timepicker name="time"
+                           v-model="${vmodel}.time">
+      </tailbone-timepicker>
+    </b-field>
+
+    ${field.end_mapping()}
+  </b-field>
+
+</div>

From cc7b9ccb86739fbb0d50628883479a9a0c2da20d Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 17 Sep 2023 17:23:59 -0500
Subject: [PATCH 065/542] Avoid error when history has blanks for ordering
 worksheet

---
 tailbone/api/batch/ordering.py | 22 ++++++++++------------
 1 file changed, 10 insertions(+), 12 deletions(-)

diff --git a/tailbone/api/batch/ordering.py b/tailbone/api/batch/ordering.py
index 3c489fcd..1661d06f 100644
--- a/tailbone/api/batch/ordering.py
+++ b/tailbone/api/batch/ordering.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -27,12 +27,8 @@ These views expose the basic CRUD interface to "ordering" batches, for the web
 API.
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import datetime
 
-import six
-
 from rattail.db import model
 from rattail.util import pretty_quantity
 
@@ -67,10 +63,10 @@ class OrderingBatchViews(APIBatchView):
         data = super(OrderingBatchViews, self).normalize(batch)
 
         data['vendor_uuid'] = batch.vendor.uuid
-        data['vendor_display'] = six.text_type(batch.vendor)
+        data['vendor_display'] = str(batch.vendor)
 
         data['department_uuid'] = batch.department_uuid
-        data['department_display'] = six.text_type(batch.department) if batch.department else None
+        data['department_display'] = str(batch.department) if batch.department else None
 
         data['po_total_calculated_display'] = "${:0.2f}".format(batch.po_total_calculated or 0)
         data['ship_method'] = batch.ship_method
@@ -152,7 +148,7 @@ class OrderingBatchViews(APIBatchView):
             product = cost.product
             subdept_costs.append({
                 'uuid': cost.uuid,
-                'upc': six.text_type(product.upc),
+                'upc': str(product.upc),
                 'upc_pretty': product.upc.pretty() if product.upc else None,
                 'brand_name': product.brand.name if product.brand else None,
                 'description': product.description,
@@ -173,8 +169,8 @@ class OrderingBatchViews(APIBatchView):
 
         # sort the (sub)department groupings
         sorted_departments = []
-        for dept in sorted(six.itervalues(departments), key=lambda d: d['name']):
-            dept['subdepartments'] = sorted(six.itervalues(dept['subdepartments']),
+        for dept in sorted(departments.values(), key=lambda d: d['name']):
+            dept['subdepartments'] = sorted(dept['subdepartments'].values(),
                                             key=lambda s: s['name'])
             sorted_departments.append(dept)
 
@@ -185,6 +181,8 @@ class OrderingBatchViews(APIBatchView):
         history = list(reversed(history))
         # must convert some date objects to string, for JSON sake
         for h in history:
+            if not h:
+                continue
             purchase = h.get('purchase')
             if purchase:
                 dt = purchase.get('date_ordered')
@@ -237,7 +235,7 @@ class OrderingBatchRowViews(APIBatchRowView):
         data = super(OrderingBatchRowViews, self).normalize(row)
 
         data['item_id'] = row.item_id
-        data['upc'] = six.text_type(row.upc)
+        data['upc'] = str(row.upc)
         data['upc_pretty'] = row.upc.pretty() if row.upc else None
         data['brand_name'] = row.brand_name
         data['description'] = row.description
@@ -262,7 +260,7 @@ class OrderingBatchRowViews(APIBatchRowView):
         data['po_total_calculated'] = row.po_total_calculated
         data['po_total_calculated_display'] = "${:0.2f}".format(row.po_total_calculated) if row.po_total_calculated is not None else None
         data['status_code'] = row.status_code
-        data['status_display'] = row.STATUS.get(row.status_code, six.text_type(row.status_code))
+        data['status_display'] = row.STATUS.get(row.status_code, str(row.status_code))
 
         return data
 

From e894d1d1f4e4841e87d957b4a3de424f7a26d9ab Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 17 Sep 2023 18:03:30 -0500
Subject: [PATCH 066/542] Include PO number for receiving batch details via API

---
 tailbone/api/batch/receiving.py | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/tailbone/api/batch/receiving.py b/tailbone/api/batch/receiving.py
index a0b61f38..6c4302d2 100644
--- a/tailbone/api/batch/receiving.py
+++ b/tailbone/api/batch/receiving.py
@@ -59,7 +59,7 @@ class ReceivingBatchViews(APIBatchView):
         return query
 
     def normalize(self, batch):
-        data = super(ReceivingBatchViews, self).normalize(batch)
+        data = super().normalize(batch)
 
         data['vendor_uuid'] = batch.vendor.uuid
         data['vendor_display'] = str(batch.vendor)
@@ -67,6 +67,7 @@ class ReceivingBatchViews(APIBatchView):
         data['department_uuid'] = batch.department_uuid
         data['department_display'] = str(batch.department) if batch.department else None
 
+        data['po_number'] = batch.po_number
         data['po_total'] = batch.po_total
         data['invoice_total'] = batch.invoice_total
         data['invoice_total_calculated'] = batch.invoice_total_calculated

From 70956a2c476820494e2f781d207a4c592e241c77 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 17 Sep 2023 18:30:38 -0500
Subject: [PATCH 067/542] Tweaks to improve handling of "missing" items for
 receiving

---
 tailbone/api/batch/receiving.py             | 12 ++++++++++++
 tailbone/templates/receiving/configure.mako |  9 +++++++++
 tailbone/views/purchases/credits.py         |  4 +++-
 tailbone/views/purchasing/receiving.py      |  3 +++
 4 files changed, 27 insertions(+), 1 deletion(-)

diff --git a/tailbone/api/batch/receiving.py b/tailbone/api/batch/receiving.py
index 6c4302d2..284d8fdb 100644
--- a/tailbone/api/batch/receiving.py
+++ b/tailbone/api/batch/receiving.py
@@ -280,6 +280,15 @@ class ReceivingBatchRowViews(APIBatchRowView):
                         ]},
                     ])
 
+                # is_missing
+                elif filtr['field'] == 'is_missing' and filtr['op'] == 'eq' and filtr['value'] is True:
+                    filters.extend([
+                        {'or': [
+                            {'field': 'cases_missing', 'op': '!=', 'value': 0},
+                            {'field': 'units_missing', 'op': '!=', 'value': 0},
+                        ]},
+                    ])
+
                 else: # just some filter, use as-is
                     filters.append(filtr)
 
@@ -326,6 +335,9 @@ class ReceivingBatchRowViews(APIBatchRowView):
         data['cases_expired'] = row.cases_expired
         data['units_expired'] = row.units_expired
 
+        data['cases_missing'] = row.cases_missing
+        data['units_missing'] = row.units_missing
+
         cases, units = self.batch_handler.get_unconfirmed_counts(row)
         data['cases_unconfirmed'] = cases
         data['units_unconfirmed'] = units
diff --git a/tailbone/templates/receiving/configure.mako b/tailbone/templates/receiving/configure.mako
index faa13a24..92003fee 100644
--- a/tailbone/templates/receiving/configure.mako
+++ b/tailbone/templates/receiving/configure.mako
@@ -151,6 +151,15 @@
       </b-checkbox>
     </b-field>
 
+    <b-field>
+      <b-checkbox name="rattail.batch.purchase.receiving.auto_missing_credits"
+                  v-model="simpleSettings['rattail.batch.purchase.receiving.auto_missing_credits']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Auto-generate "missing" (DNR) credits for items not accounted for
+      </b-checkbox>
+    </b-field>
+
   </div>
 
   <h3 class="block is-size-3">Mobile Interface</h3>
diff --git a/tailbone/views/purchases/credits.py b/tailbone/views/purchases/credits.py
index ad1079a6..7da096eb 100644
--- a/tailbone/views/purchases/credits.py
+++ b/tailbone/views/purchases/credits.py
@@ -96,10 +96,12 @@ class PurchaseCreditView(MasterView):
     ]
 
     def configure_grid(self, g):
-        super(PurchaseCreditView, self).configure_grid(g)
+        super().configure_grid(g)
 
+        # vendor
         g.set_joiner('vendor', lambda q: q.outerjoin(model.Vendor))
         g.set_sorter('vendor', model.Vendor.name)
+        g.set_filter('vendor', model.Vendor.name, label="Vendor Name")
 
         g.set_sort_defaults('date_received', 'desc')
 
diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py
index 909ded3f..e4c67af0 100644
--- a/tailbone/views/purchasing/receiving.py
+++ b/tailbone/views/purchasing/receiving.py
@@ -1935,6 +1935,9 @@ class ReceivingBatchView(PurchasingBatchView):
             {'section': 'rattail.batch',
              'option': 'purchase.receiving.allow_edit_invoice_unit_cost',
              'type': bool},
+            {'section': 'rattail.batch',
+             'option': 'purchase.receiving.auto_missing_credits',
+             'type': bool},
 
             # mobile interface
             {'section': 'rattail.batch',

From a01fd628991c72847c2a03c00d2071623bf2cc33 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 17 Sep 2023 21:21:10 -0500
Subject: [PATCH 068/542] Update changelog

---
 CHANGES.rst          | 12 ++++++++++++
 tailbone/_version.py |  2 +-
 2 files changed, 13 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 38d8afea..3fba5f2a 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,18 @@
 CHANGELOG
 =========
 
+0.9.54 (2023-09-17)
+-------------------
+
+* Add "falafel" custom date/time field type and widget.
+
+* Avoid error when history has blanks for ordering worksheet.
+
+* Include PO number for receiving batch details via API.
+
+* Tweaks to improve handling of "missing" items for receiving.
+
+
 0.9.53 (2023-09-16)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 118085ee..b67bad70 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.53'
+__version__ = '0.9.54'

From d1d69e94885531f7fd8413c000b477d68f141cb3 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 18 Sep 2023 18:28:11 -0500
Subject: [PATCH 069/542] Show user warning if receive quick lookup fails

just b/c a UPC doesn't exist yet doesn't prevent the batch from (in
some cases) adding a row for "unknown product" - but if the UPC is
sufficiently invalid, that can't happen
---
 tailbone/api/batch/core.py | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/tailbone/api/batch/core.py b/tailbone/api/batch/core.py
index f239aaaf..c98e01f1 100644
--- a/tailbone/api/batch/core.py
+++ b/tailbone/api/batch/core.py
@@ -333,6 +333,9 @@ class APIBatchRowView(APIBatchMixin, APIMasterView):
                 msg = "Feature is not implemented"
             return {'error': msg}
 
+        if not row:
+            return {'error': "Could not identify product"}
+
         self.Session.flush()
         result = self._get(obj=row)
         result['ok'] = True

From 4d8c8b199c31a55752b6c9ac2d9a33ae98f1b0f0 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 18 Sep 2023 18:37:41 -0500
Subject: [PATCH 070/542] Fix bug for new receiving from scratch via API

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

diff --git a/tailbone/api/batch/receiving.py b/tailbone/api/batch/receiving.py
index 284d8fdb..57501a7d 100644
--- a/tailbone/api/batch/receiving.py
+++ b/tailbone/api/batch/receiving.py
@@ -83,7 +83,7 @@ class ReceivingBatchViews(APIBatchView):
         data['mode'] = self.enum.PURCHASE_BATCH_MODE_RECEIVING
 
         # assume "receive from PO" if given a PO key
-        if data['purchase_key']:
+        if data.get('purchase_key'):
             data['receiving_workflow'] = 'from_po'
 
         return super().create_object(data)

From b566549d153ef9bdef08c336930cbb9b0a4d7217 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 18 Sep 2023 18:40:51 -0500
Subject: [PATCH 071/542] Update changelog

---
 CHANGES.rst          | 8 ++++++++
 tailbone/_version.py | 2 +-
 2 files changed, 9 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 3fba5f2a..e2d29b12 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,14 @@
 CHANGELOG
 =========
 
+0.9.55 (2023-09-18)
+-------------------
+
+* Show user warning if receive quick lookup fails.
+
+* Fix bug for new receiving from scratch via API.
+
+
 0.9.54 (2023-09-17)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index b67bad70..028c7595 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.54'
+__version__ = '0.9.55'

From 1f97d4f5e5905218610849e0a0a8832b2e2fe874 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 19 Sep 2023 14:40:58 -0500
Subject: [PATCH 072/542] Add link to vendor name for receiving batches grid

---
 tailbone/views/purchasing/batch.py | 10 ++++++----
 1 file changed, 6 insertions(+), 4 deletions(-)

diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py
index 8960a522..96557d55 100644
--- a/tailbone/views/purchasing/batch.py
+++ b/tailbone/views/purchasing/batch.py
@@ -168,10 +168,12 @@ class PurchasingBatchView(BatchMasterView):
         super().configure_grid(g)
         model = self.model
 
-        g.joiners['vendor'] = lambda q: q.join(model.Vendor)
-        g.filters['vendor'] = g.make_filter('vendor', model.Vendor.name,
-                                            default_active=True, default_verb='contains')
-        g.sorters['vendor'] = g.make_sorter(model.Vendor.name)
+        # vendor
+        g.set_link('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,
+                     default_active=True, default_verb='contains')
 
         g.joiners['department'] = lambda q: q.join(model.Department)
         g.filters['department'] = g.make_filter('department', model.Department.name)

From 6274e33a8c8f47958a05d251dd3554e3c4e40b6b Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 19 Sep 2023 14:41:15 -0500
Subject: [PATCH 073/542] Prevent catalog/invoice cost edits if receiving batch
 is complete

---
 tailbone/views/purchasing/receiving.py | 34 +++++++++++++++++++++-----
 1 file changed, 28 insertions(+), 6 deletions(-)

diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py
index e4c67af0..23bb27fe 100644
--- a/tailbone/views/purchasing/receiving.py
+++ b/tailbone/views/purchasing/receiving.py
@@ -737,14 +737,36 @@ class ReceivingBatchView(PurchasingBatchView):
         return breakdown
 
     def allow_edit_catalog_unit_cost(self, batch):
-        return (not batch.executed
-                and self.has_perm('edit_row')
-                and self.batch_handler.allow_receiving_edit_catalog_unit_cost())
+
+        # batch must not yet be frozen
+        if batch.executed or batch.complete:
+            return False
+
+        # user must have edit_row perm
+        if not self.has_perm('edit_row'):
+            return False
+
+        # config must allow this generally
+        if not self.batch_handler.allow_receiving_edit_catalog_unit_cost():
+            return False
+
+        return True
 
     def allow_edit_invoice_unit_cost(self, batch):
-        return (not batch.executed
-                and self.has_perm('edit_row')
-                and self.batch_handler.allow_receiving_edit_invoice_unit_cost())
+
+        # batch must not yet be frozen
+        if batch.executed or batch.complete:
+            return False
+
+        # user must have edit_row perm
+        if not self.has_perm('edit_row'):
+            return False
+
+        # config must allow this generally
+        if not self.batch_handler.allow_receiving_edit_invoice_unit_cost():
+            return False
+
+        return True
 
     def template_kwargs_view(self, **kwargs):
         kwargs = super(ReceivingBatchView, self).template_kwargs_view(**kwargs)

From 8b15f1304f808f6e4436b5af9fd2f1da0cdabd61 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 19 Sep 2023 14:45:48 -0500
Subject: [PATCH 074/542] Use small text input for receiving cost editor fields

---
 tailbone/templates/receiving/view.mako | 1 +
 1 file changed, 1 insertion(+)

diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako
index b01436ba..f6b3205a 100644
--- a/tailbone/templates/receiving/view.mako
+++ b/tailbone/templates/receiving/view.mako
@@ -102,6 +102,7 @@
           <b-input v-model="inputValue"
                    ref="input"
                    v-show="editing"
+                   size="is-small"
                    @keydown.native="inputKeyDown"
                    @focus="selectAll"
                    @blur="inputBlur"

From 510b8383a480426481e6f48a9e89f92542f2438d Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 19 Sep 2023 15:03:16 -0500
Subject: [PATCH 075/542] Show catalog/invoice costs as 2-decimal currency in
 receiving

---
 tailbone/templates/receiving/view.mako |  4 +++-
 tailbone/views/purchasing/receiving.py | 21 +++++++++++++++++++--
 2 files changed, 22 insertions(+), 3 deletions(-)

diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako
index f6b3205a..30bfd3a9 100644
--- a/tailbone/templates/receiving/view.mako
+++ b/tailbone/templates/receiving/view.mako
@@ -199,7 +199,9 @@
                 },
 
                 startEdit() {
-                    this.inputValue = this.value
+                    // nb. must strip $ sign etc. to get the real value
+                    let value = this.value.replace(/[^\-\d\.]/g, '')
+                    this.inputValue = parseFloat(value) || null
                     this.editing = true
                     this.$nextTick(() => {
                         this.$refs.input.focus()
diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py
index 23bb27fe..0cef3a37 100644
--- a/tailbone/views/purchasing/receiving.py
+++ b/tailbone/views/purchasing/receiving.py
@@ -989,12 +989,14 @@ class ReceivingBatchView(PurchasingBatchView):
         g.filters['vendor_code'].default_verb = 'contains'
 
         # catalog_unit_cost
+        g.set_renderer('catalog_unit_cost', self.render_simple_unit_cost)
         if self.allow_edit_catalog_unit_cost(batch):
             g.set_raw_renderer('catalog_unit_cost', self.render_catalog_unit_cost)
             g.set_click_handler('catalog_unit_cost',
                                 'this.catalogUnitCostClicked')
 
         # invoice_unit_cost
+        g.set_renderer('invoice_unit_cost', self.render_simple_unit_cost)
         if self.allow_edit_invoice_unit_cost(batch):
             g.set_raw_renderer('invoice_unit_cost', self.render_invoice_unit_cost)
             g.set_click_handler('invoice_unit_cost',
@@ -1049,6 +1051,19 @@ class ReceivingBatchView(PurchasingBatchView):
         else:
             g.set_enum('truck_dump_status', model.PurchaseBatchRow.STATUS)
 
+    def render_simple_unit_cost(self, row, field):
+        value = getattr(row, field)
+        if value is None:
+            return
+
+        # TODO: if anyone ever wants to see "raw" costs displayed,
+        # should make this configurable, b/c some folks already wanted
+        # the shorter 2-decimal display
+        #return str(value)
+
+        app = self.get_rattail_app()
+        return app.render_currency(value)
+
     def render_catalog_unit_cost(self):
         return HTML.tag('receiving-cost-editor', **{
             'field': 'catalog_unit_cost',
@@ -1871,11 +1886,13 @@ class ReceivingBatchView(PurchasingBatchView):
         # okay, update our row
         self.handler.update_row_cost(row, **data)
 
+        self.Session.flush()
+        self.Session.refresh(row)
         return {
             'row': {
-                'catalog_unit_cost': '{:0.3f}'.format(row.catalog_unit_cost),
+                'catalog_unit_cost': self.render_simple_unit_cost(row, 'catalog_unit_cost'),
                 'catalog_cost_confirmed': row.catalog_cost_confirmed,
-                'invoice_unit_cost': '{:0.3f}'.format(row.invoice_unit_cost),
+                'invoice_unit_cost': self.render_simple_unit_cost(row, 'invoice_unit_cost'),
                 'invoice_cost_confirmed': row.invoice_cost_confirmed,
                 'invoice_total_calculated': '{:0.2f}'.format(row.invoice_total_calculated),
             },

From 836fc0bf5b7de7db69336999a63c0f5cc90946a5 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 19 Sep 2023 16:37:05 -0500
Subject: [PATCH 076/542] Update changelog

---
 CHANGES.rst          | 12 ++++++++++++
 tailbone/_version.py |  2 +-
 2 files changed, 13 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index e2d29b12..a3fb5114 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,18 @@
 CHANGELOG
 =========
 
+0.9.56 (2023-09-19)
+-------------------
+
+* Add link to vendor name for receiving batches grid.
+
+* Prevent catalog/invoice cost edits if receiving batch is complete.
+
+* Use small text input for receiving cost editor fields.
+
+* Show catalog/invoice costs as 2-decimal currency in receiving.
+
+
 0.9.55 (2023-09-18)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 028c7595..78a773b6 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.55'
+__version__ = '0.9.56'

From 3d6cc8a490a787fff68ade03c059e931a26b62fa Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 20 Sep 2023 18:13:52 -0500
Subject: [PATCH 077/542] Show yesterday by default for Trainwreck if so
 configured

---
 tailbone/views/trainwreck/base.py | 12 +++++++++---
 1 file changed, 9 insertions(+), 3 deletions(-)

diff --git a/tailbone/views/trainwreck/base.py b/tailbone/views/trainwreck/base.py
index 8ac243a0..82c5c163 100644
--- a/tailbone/views/trainwreck/base.py
+++ b/tailbone/views/trainwreck/base.py
@@ -165,16 +165,22 @@ class TransactionView(MasterView):
         return TrainwreckSession()
 
     def configure_grid(self, g):
-        super(TransactionView, self).configure_grid(g)
+        super().configure_grid(g)
         app = self.get_rattail_app()
 
         g.filters['receipt_number'].default_active = True
         g.filters['receipt_number'].default_verb = 'equal'
 
+        # end_time
+        g.set_sort_defaults('end_time', 'desc')
         g.filters['end_time'].default_active = True
         g.filters['end_time'].default_verb = 'equal'
-        g.filters['end_time'].default_value = str(app.today())
-        g.set_sort_defaults('end_time', 'desc')
+        # TODO: should expose this setting somewhere
+        if self.rattail_config.getbool('trainwreck', 'show_yesterday_first'):
+            date = app.yesterday()
+        else:
+            date = app.today()
+        g.filters['end_time'].default_value = str(date)
 
         g.set_enum('system', self.enum.TRAINWRECK_SYSTEM)
         g.set_type('total', 'currency')

From abca0115a62efa0d272e12d58c4f10604571010d Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 21 Sep 2023 14:37:33 -0500
Subject: [PATCH 078/542] Add `remove_sorter()` method for grids

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

diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py
index 4a748536..639eabd1 100644
--- a/tailbone/grids/core.py
+++ b/tailbone/grids/core.py
@@ -353,7 +353,13 @@ class Grid(object):
             self.joiners[key] = joiner
 
     def set_sorter(self, key, *args, **kwargs):
-        self.sorters[key] = self.make_sorter(*args, **kwargs)
+        if len(args) == 1 and args[0] is None:
+            self.remove_sorter(key)
+        else:
+            self.sorters[key] = self.make_sorter(*args, **kwargs)
+
+    def remove_sorter(self, key):
+        self.sorters.pop(key, None)
 
     def set_sort_defaults(self, sortkey, sortdir='asc'):
         self.default_sortkey = sortkey

From d329b2945ca5369ed233c2fc6cfbd7b3914439e5 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 21 Sep 2023 14:39:18 -0500
Subject: [PATCH 079/542] Show "true" (calculated) equity total in members grid

pretty sure will need to tweak this but wanted something in place at least
---
 tailbone/views/members.py | 20 +++++++++++++++++---
 1 file changed, 17 insertions(+), 3 deletions(-)

diff --git a/tailbone/views/members.py b/tailbone/views/members.py
index 0de8fa67..1b3735bd 100644
--- a/tailbone/views/members.py
+++ b/tailbone/views/members.py
@@ -122,6 +122,7 @@ class MemberView(MasterView):
         'equity_current',
         'joined',
         'withdrew',
+        'equity_total',
     ]
 
     form_fields = [
@@ -168,7 +169,7 @@ class MemberView(MasterView):
         return app.get_people_handler().get_quickie_search_placeholder()
 
     def configure_grid(self, g):
-        super(MemberView, self).configure_grid(g)
+        super().configure_grid(g)
         route_prefix = self.get_route_prefix()
         model = self.model
 
@@ -179,10 +180,14 @@ class MemberView(MasterView):
         g.set_sort_defaults(field)
         g.set_link(field)
 
+        # person
+        g.set_link('person')
         g.set_joiner('person', lambda q: q.outerjoin(model.Person))
         g.set_sorter('person', model.Person.display_name)
         g.set_filter('person', model.Person.display_name)
 
+        # customer
+        g.set_link('customer')
         g.set_joiner('customer', lambda q: q.outerjoin(model.Customer))
         g.set_sorter('customer', model.Customer.name)
         g.set_filter('customer', model.Customer.name)
@@ -223,8 +228,17 @@ class MemberView(MasterView):
             g.main_actions.insert(1, self.make_action(
                 'view_raw', url=url, icon='eye'))
 
-        g.set_link('person')
-        g.set_link('customer')
+        # equity_total
+        # TODO: should make this configurable
+        # g.set_type('equity_total', 'currency')
+        g.set_renderer('equity_total', self.render_equity_total)
+        g.remove_sorter('equity_total')
+        g.remove_filter('equity_total')
+
+    def render_equity_total(self, member, field):
+        app = self.get_rattail_app()
+        equity = app.get_membership_handler().get_equity_total(member, cached=False)
+        return app.render_currency(equity)
 
     def default_view_url(self):
         if (self.request.has_perm('people.view_profile')

From 53e8c15267cb275b36f7532db67d2a252df8e1f4 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sat, 23 Sep 2023 11:14:43 -0500
Subject: [PATCH 080/542] Add basic views for POS batches

---
 tailbone/views/batch/pos.py | 148 ++++++++++++++++++++++++++++++++++++
 1 file changed, 148 insertions(+)
 create mode 100644 tailbone/views/batch/pos.py

diff --git a/tailbone/views/batch/pos.py b/tailbone/views/batch/pos.py
new file mode 100644
index 00000000..402a70b4
--- /dev/null
+++ b/tailbone/views/batch/pos.py
@@ -0,0 +1,148 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  Rattail -- Retail Software Framework
+#  Copyright © 2010-2023 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/>.
+#
+################################################################################
+"""
+Views for POS batches
+"""
+
+from rattail.db.model import POSBatch, POSBatchRow
+
+from tailbone.views.batch import BatchMasterView
+
+
+class POSBatchView(BatchMasterView):
+    """
+    Master view for POS batches
+    """
+    model_class = POSBatch
+    model_row_class = POSBatchRow
+    default_handler_spec = 'rattail.batch.pos:POSBatchHandler'
+    route_prefix = 'batch.pos'
+    url_prefix = '/batch/pos'
+    creatable = False
+
+    grid_columns = [
+        'id',
+        'created',
+        'created_by',
+        'rowcount',
+        'sales_total',
+        'void',
+        'status_code',
+        'executed',
+        'executed_by',
+    ]
+
+    form_fields = [
+        'id',
+        'description',
+        'notes',
+        'params',
+        'rowcount',
+        'sales_total',
+        'tax1_total',
+        'tax2_total',
+        'status_code',
+        'created',
+        'created_by',
+        'executed',
+        'executed_by',
+    ]
+
+    row_grid_columns = [
+        'sequence',
+        'row_type',
+        'product',
+        'description',
+        'reg_price',
+        'txn_price',
+        'quantity',
+        'sales_total',
+        'status_code',
+    ]
+
+    row_form_fields = [
+        'sequence',
+        'row_type',
+        'item_entry',
+        'product',
+        'description',
+        'reg_price',
+        'txn_price',
+        'quantity',
+        'sales_total',
+        'tax1_total',
+        'tax2_total',
+        'status_code',
+    ]
+
+    def configure_grid(self, g):
+        super().configure_grid(g)
+
+        g.set_type('sales_total', 'currency')
+        g.set_type('tax1_total', 'currency')
+        g.set_type('tax2_total', 'currency')
+
+        g.set_link('created')
+        g.set_link('created_by')
+
+    def grid_extra_class(self, batch, i):
+        if batch.void:
+            return 'warning'
+
+    def configure_form(self, f):
+        super().configure_form(f)
+
+        f.set_type('sales_total', 'currency')
+        f.set_type('tax1_total', 'currency')
+        f.set_type('tax2_total', 'currency')
+
+    def configure_row_grid(self, g):
+        super().configure_row_grid(g)
+
+        g.set_type('quantity', 'quantity')
+        g.set_type('reg_price', 'currency')
+        g.set_type('txn_price', 'currency')
+        g.set_type('sales_total', 'currency')
+
+        g.set_link('product')
+        g.set_link('description')
+
+    def configure_row_form(self, f):
+        super().configure_row_form(f)
+
+        f.set_type('quantity', 'quantity')
+        f.set_type('reg_price', 'currency')
+        f.set_type('txn_price', 'currency')
+        f.set_type('sales_total', 'currency')
+        f.set_renderer('product', self.render_product)
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    POSBatchView = kwargs.get('POSBatchView', base['POSBatchView'])
+    POSBatchView.defaults(config)
+
+
+def includeme(config):
+    defaults(config)

From 91ac1a9031c794d628f92fe64ff68c2ea33acceb Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sat, 23 Sep 2023 20:01:29 -0500
Subject: [PATCH 081/542] Show customer for POS batches

---
 tailbone/views/batch/pos.py | 14 +++++++++-----
 1 file changed, 9 insertions(+), 5 deletions(-)

diff --git a/tailbone/views/batch/pos.py b/tailbone/views/batch/pos.py
index 402a70b4..7d71a88a 100644
--- a/tailbone/views/batch/pos.py
+++ b/tailbone/views/batch/pos.py
@@ -42,6 +42,7 @@ class POSBatchView(BatchMasterView):
 
     grid_columns = [
         'id',
+        'customer',
         'created',
         'created_by',
         'rowcount',
@@ -54,8 +55,7 @@ class POSBatchView(BatchMasterView):
 
     form_fields = [
         'id',
-        'description',
-        'notes',
+        'customer',
         'params',
         'rowcount',
         'sales_total',
@@ -98,13 +98,15 @@ class POSBatchView(BatchMasterView):
     def configure_grid(self, g):
         super().configure_grid(g)
 
-        g.set_type('sales_total', 'currency')
-        g.set_type('tax1_total', 'currency')
-        g.set_type('tax2_total', 'currency')
+        g.set_link('customer')
 
         g.set_link('created')
         g.set_link('created_by')
 
+        g.set_type('sales_total', 'currency')
+        g.set_type('tax1_total', 'currency')
+        g.set_type('tax2_total', 'currency')
+
     def grid_extra_class(self, batch, i):
         if batch.void:
             return 'warning'
@@ -112,6 +114,8 @@ class POSBatchView(BatchMasterView):
     def configure_form(self, f):
         super().configure_form(f)
 
+        f.set_renderer('customer', self.render_customer)
+
         f.set_type('sales_total', 'currency')
         f.set_type('tax1_total', 'currency')
         f.set_type('tax2_total', 'currency')

From bda05aed86e13ed4d46f29e44a052646fa9a6c4b Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 24 Sep 2023 08:37:50 -0500
Subject: [PATCH 082/542] Use header button instead of link for "touch"
 instance

---
 tailbone/templates/master/view.mako | 31 ++++++++++++++++++++++++++---
 1 file changed, 28 insertions(+), 3 deletions(-)

diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako
index 69485dd1..e6d0c8de 100644
--- a/tailbone/templates/master/view.mako
+++ b/tailbone/templates/master/view.mako
@@ -7,6 +7,18 @@
   ${instance_title}
 </%def>
 
+<%def name="render_instance_header_title_extras()">
+  <span style="width: 2rem;"></span>
+  % if master.touchable and master.has_perm('touch'):
+      <b-button title="&quot;Touch&quot; this record to trigger sync"
+                icon-pack="fas"
+                icon-left="hand-pointer"
+                @click="touchRecord()"
+                :disabled="touchSubmitting">
+      </b-button>
+  % endif
+</%def>
+
 <%def name="object_helpers()">
   ${parent.object_helpers()}
   ${self.render_xref_helper()}
@@ -37,9 +49,6 @@
   % if master.has_versions and request.rattail_config.versioning_enabled() and request.has_perm('{}.versions'.format(permission_prefix)):
       <li>${h.link_to("Version History", action_url('versions', instance))}</li>
   % endif
-  % if master.touchable and request.has_perm('{}.touch'.format(permission_prefix)):
-      <li>${h.link_to("\"Touch\" this {}".format(model_title), master.get_action_url('touch', instance))}</li>
-  % endif
 </%def>
 
 <%def name="render_row_grid_tools()">
@@ -83,6 +92,22 @@
   ${parent.render_this_page_template()}
 </%def>
 
+<%def name="modify_whole_page_vars()">
+  ${parent.modify_whole_page_vars()}
+  % if master.touchable and master.has_perm('touch'):
+      <script type="text/javascript">
+
+        WholePageData.touchSubmitting = false
+
+        WholePage.methods.touchRecord = function() {
+            this.touchSubmitting = true
+            location.href = '${master.get_action_url('touch', instance)}'
+        }
+
+      </script>
+  % endif
+</%def>
+
 <%def name="finalize_this_page_vars()">
   ${parent.finalize_this_page_vars()}
   % if master.has_rows:

From 5a2612acab2dc94271dfa5f1c315a5206c35588f Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 24 Sep 2023 14:47:54 -0500
Subject: [PATCH 083/542] Update changelog

---
 CHANGES.rst          | 16 ++++++++++++++++
 tailbone/_version.py |  2 +-
 2 files changed, 17 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index a3fb5114..6b58e0e4 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,22 @@
 CHANGELOG
 =========
 
+0.9.57 (2023-09-24)
+-------------------
+
+* Show yesterday by default for Trainwreck if so configured.
+
+* Add ``remove_sorter()`` method for grids.
+
+* Show "true" (calculated) equity total in members grid.
+
+* Add basic views for POS batches.
+
+* Show customer for POS batches.
+
+* Use header button instead of link for "touch" instance.
+
+
 0.9.56 (2023-09-19)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 78a773b6..6b1da83b 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.56'
+__version__ = '0.9.57'

From 3e56950872a125b7c5ac91cfc57af78ad26d82c5 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 24 Sep 2023 19:30:59 -0500
Subject: [PATCH 084/542] Expose POS batch views as "typical"

---
 tailbone/menus.py         | 5 +++++
 tailbone/views/typical.py | 1 +
 2 files changed, 6 insertions(+)

diff --git a/tailbone/menus.py b/tailbone/menus.py
index c26484f0..b50233f8 100644
--- a/tailbone/menus.py
+++ b/tailbone/menus.py
@@ -513,6 +513,11 @@ class MenuHandler(GenericHandler):
                     'route': 'batch.importer',
                     'perm': 'batch.importer.list',
                 },
+                {
+                    'title': "POS",
+                    'route': 'batch.pos',
+                    'perm': 'batch.pos.list',
+                },
             ],
         }
 
diff --git a/tailbone/views/typical.py b/tailbone/views/typical.py
index 8b5c9a07..d3450fbd 100644
--- a/tailbone/views/typical.py
+++ b/tailbone/views/typical.py
@@ -50,6 +50,7 @@ def defaults(config, **kwargs):
     config.include(mod('tailbone.views.batch.handheld'))
     config.include(mod('tailbone.views.batch.importer'))
     config.include(mod('tailbone.views.batch.inventory'))
+    config.include(mod('tailbone.views.batch.pos'))
     config.include(mod('tailbone.views.batch.vendorcatalog'))
     config.include(mod('tailbone.views.purchasing'))
 

From 032d37194fcfe408b1470ebf1537678872504776 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 25 Sep 2023 18:06:16 -0500
Subject: [PATCH 085/542] Update changelog

---
 CHANGES.rst          | 6 ++++++
 tailbone/_version.py | 2 +-
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 6b58e0e4..2ee4ef21 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,12 @@
 CHANGELOG
 =========
 
+0.9.58 (2023-09-25)
+-------------------
+
+* Expose POS batch views as "typical".
+
+
 0.9.57 (2023-09-24)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 6b1da83b..fdbfb1a9 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.57'
+__version__ = '0.9.58'

From e23b2f8711390f35a688a8a357c8b7ccf32c93c4 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 25 Sep 2023 19:22:02 -0500
Subject: [PATCH 086/542] Add custom form type/widget for time fields

ugh this still isn't that great, but making progress overall
---
 tailbone/forms/core.py                    |  5 +++++
 tailbone/forms/types.py                   | 12 ++++++++++++
 tailbone/forms/widgets.py                 | 12 ++++++++++++
 tailbone/templates/deform/time_falafel.pt |  7 +++++++
 4 files changed, 36 insertions(+)
 create mode 100644 tailbone/templates/deform/time_falafel.pt

diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py
index 245ee1e4..53c234db 100644
--- a/tailbone/forms/core.py
+++ b/tailbone/forms/core.py
@@ -610,9 +610,14 @@ class Form(object):
             # TODO: is this safe / a good idea?
             # self.set_node(key, colander.Date())
             self.set_widget(key, JQueryDateWidget())
+
         elif type_ == 'time_jquery':
             self.set_node(key, types.JQueryTime())
             self.set_widget(key, JQueryTimeWidget())
+
+        elif type_ == 'time_falafel':
+            self.set_node(key, types.FalafelTime(request=self.request))
+
         elif type_ == 'duration':
             self.set_renderer(key, self.render_duration)
         elif type_ == 'boolean':
diff --git a/tailbone/forms/types.py b/tailbone/forms/types.py
index 173a83a2..026bc598 100644
--- a/tailbone/forms/types.py
+++ b/tailbone/forms/types.py
@@ -118,6 +118,18 @@ class FalafelDateTime(colander.DateTime):
         return result
 
 
+class FalafelTime(colander.Time):
+    """
+    Custom schema node type for simple time fields
+    """
+    widget_maker = widgets.FalafelTimeWidget
+
+    def __init__(self, *args, **kwargs):
+        request = kwargs.pop('request')
+        super().__init__(*args, **kwargs)
+        self.request = request
+
+
 class GPCType(colander.SchemaType):
     """
     Schema type for product GPC data.
diff --git a/tailbone/forms/widgets.py b/tailbone/forms/widgets.py
index 69f57520..a8810e69 100644
--- a/tailbone/forms/widgets.py
+++ b/tailbone/forms/widgets.py
@@ -243,6 +243,18 @@ class FalafelDateTimeWidget(dfwidget.DateTimeInputWidget):
     template = 'datetime_falafel'
 
 
+class FalafelTimeWidget(dfwidget.TimeInputWidget):
+    """
+    Custom widget for simple time fields
+    """
+    template = 'time_falafel'
+
+    def deserialize(self, field, pstruct):
+        if pstruct  == '':
+            return colander.null
+        return pstruct
+
+
 class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget):
     """ 
     Uses the jQuery autocomplete plugin, instead of whatever it is deform uses
diff --git a/tailbone/templates/deform/time_falafel.pt b/tailbone/templates/deform/time_falafel.pt
new file mode 100644
index 00000000..00ebc2f0
--- /dev/null
+++ b/tailbone/templates/deform/time_falafel.pt
@@ -0,0 +1,7 @@
+<div tal:omit-tag=""
+     tal:define="name name|field.name;
+                 vmodel vmodel|'field_model_' + name;">
+  <tailbone-timepicker name="${name}"
+                       v-model="${vmodel}">
+  </tailbone-timepicker>
+</div>

From a11be5a1e10df98145491705e8aac3f30a6f41ce Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 25 Sep 2023 19:41:59 -0500
Subject: [PATCH 087/542] Update changelog

---
 CHANGES.rst          | 6 ++++++
 tailbone/_version.py | 2 +-
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 2ee4ef21..2e17dc24 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,12 @@
 CHANGELOG
 =========
 
+0.9.59 (2023-09-25)
+-------------------
+
+* Add custom form type/widget for time fields.
+
+
 0.9.58 (2023-09-25)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index fdbfb1a9..7b773591 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.58'
+__version__ = '0.9.59'

From a9e9474f5cfa577356bec123358b0a91de7e6035 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 26 Sep 2023 09:32:57 -0500
Subject: [PATCH 088/542] Do not allow executing custorder if no customer is
 set

or really any reason, as defined by handler
---
 tailbone/views/custorders/orders.py | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py
index abbcf87c..f88886bb 100644
--- a/tailbone/views/custorders/orders.py
+++ b/tailbone/views/custorders/orders.py
@@ -956,6 +956,11 @@ class CustomerOrderView(MasterView):
                 'batch': self.normalize_batch(batch)}
 
     def submit_new_order(self, batch, data):
+
+        reason = self.batch_handler.why_not_execute(batch, user=self.request.user)
+        if reason:
+            return {'error': reason}
+
         try:
             result = self.execute_new_order_batch(batch, data)
         except Exception as error:

From abcf1e1895097d75ece12010b267c6026523191c Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 26 Sep 2023 17:52:17 -0500
Subject: [PATCH 089/542] Add clone support for POS batches

just for testing of course..
---
 tailbone/views/batch/pos.py | 14 +++++++++++++-
 1 file changed, 13 insertions(+), 1 deletion(-)

diff --git a/tailbone/views/batch/pos.py b/tailbone/views/batch/pos.py
index 7d71a88a..7c9d5586 100644
--- a/tailbone/views/batch/pos.py
+++ b/tailbone/views/batch/pos.py
@@ -39,9 +39,15 @@ class POSBatchView(BatchMasterView):
     route_prefix = 'batch.pos'
     url_prefix = '/batch/pos'
     creatable = False
+    cloneable = True
+
+    labels = {
+        'terminal_id': "Terminal ID",
+    }
 
     grid_columns = [
         'id',
+        'terminal_id',
         'customer',
         'created',
         'created_by',
@@ -55,6 +61,7 @@ class POSBatchView(BatchMasterView):
 
     form_fields = [
         'id',
+        'terminal_id',
         'customer',
         'params',
         'rowcount',
@@ -71,7 +78,7 @@ class POSBatchView(BatchMasterView):
     row_grid_columns = [
         'sequence',
         'row_type',
-        'product',
+        'item_entry',
         'description',
         'reg_price',
         'txn_price',
@@ -98,6 +105,11 @@ class POSBatchView(BatchMasterView):
     def configure_grid(self, g):
         super().configure_grid(g)
 
+        # terminal_id
+        g.set_label('terminal_id', "Terminal")
+        if 'terminal_id' in g.filters:
+            g.filters['terminal_id'].label = self.labels.get('terminal_id', "Terminal ID")
+
         g.set_link('customer')
 
         g.set_link('created')

From f572757f0091fe09ecd5409e06eb44c94016d434 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 27 Sep 2023 17:13:49 -0500
Subject: [PATCH 090/542] Expose views for tenders, more columns for POS
 batch/rows

---
 tailbone/menus.py           | 33 +++++++++++++-----
 tailbone/views/batch/pos.py | 29 +++++++++++++++-
 tailbone/views/tenders.py   | 67 +++++++++++++++++++++++++++++++++++++
 tailbone/views/typical.py   |  1 +
 4 files changed, 120 insertions(+), 10 deletions(-)
 create mode 100644 tailbone/views/tenders.py

diff --git a/tailbone/menus.py b/tailbone/menus.py
index b50233f8..36189b88 100644
--- a/tailbone/menus.py
+++ b/tailbone/menus.py
@@ -625,15 +625,30 @@ class MenuHandler(GenericHandler):
         """
         items = []
 
-        if kwargs.get('include_stores', True):
-            items.extend([
-                {
-                    'title': "Stores",
-                    'route': 'stores',
-                    'perm': 'stores.list',
-                },
-                {'type': 'sep'},
-            ])
+        include_stores = kwargs.get('include_stores', True)
+        include_tenders = kwargs.get('include_tenders', True)
+
+        if include_stores or include_tenders:
+
+            if include_stores:
+                items.extend([
+                    {
+                        'title': "Stores",
+                        'route': 'stores',
+                        'perm': 'stores.list',
+                    },
+                ])
+
+            if include_tenders:
+                items.extend([
+                    {
+                        'title': "Tenders",
+                        'route': 'tenders',
+                        'perm': 'tenders.list',
+                    },
+                ])
+
+            items.append({'type': 'sep'})
 
         items.extend([
             {
diff --git a/tailbone/views/batch/pos.py b/tailbone/views/batch/pos.py
index 7c9d5586..d2a38314 100644
--- a/tailbone/views/batch/pos.py
+++ b/tailbone/views/batch/pos.py
@@ -68,6 +68,10 @@ class POSBatchView(BatchMasterView):
         'sales_total',
         'tax1_total',
         'tax2_total',
+        'tender_total',
+        'balance',
+        'void',
+        'training_mode',
         'status_code',
         'created',
         'created_by',
@@ -84,6 +88,7 @@ class POSBatchView(BatchMasterView):
         'txn_price',
         'quantity',
         'sales_total',
+        'tender_total',
         'status_code',
     ]
 
@@ -99,7 +104,10 @@ class POSBatchView(BatchMasterView):
         'sales_total',
         'tax1_total',
         'tax2_total',
+        'tender_total',
         'status_code',
+        'timestamp',
+        'user',
     ]
 
     def configure_grid(self, g):
@@ -118,19 +126,33 @@ class POSBatchView(BatchMasterView):
         g.set_type('sales_total', 'currency')
         g.set_type('tax1_total', 'currency')
         g.set_type('tax2_total', 'currency')
+        g.set_type('tender_total', 'currency')
+
+        # executed
+        # nb. default view should show "all recent" batches regardless
+        # of execution (i think..)
+        if 'executed' in g.filters:
+            g.filters['executed'].default_active = False
 
     def grid_extra_class(self, batch, i):
         if batch.void:
             return 'warning'
+        if batch.training_mode:
+            return 'notice'
 
     def configure_form(self, f):
         super().configure_form(f)
+        app = self.get_rattail_app()
 
         f.set_renderer('customer', self.render_customer)
 
         f.set_type('sales_total', 'currency')
         f.set_type('tax1_total', 'currency')
         f.set_type('tax2_total', 'currency')
+        f.set_type('tender_total', 'currency')
+        f.set_type('tender_total', 'currency')
+
+        f.set_renderer('balance', lambda batch, field: app.render_currency(batch.get_balance()))
 
     def configure_row_grid(self, g):
         super().configure_row_grid(g)
@@ -139,6 +161,7 @@ class POSBatchView(BatchMasterView):
         g.set_type('reg_price', 'currency')
         g.set_type('txn_price', 'currency')
         g.set_type('sales_total', 'currency')
+        g.set_type('tender_total', 'currency')
 
         g.set_link('product')
         g.set_link('description')
@@ -146,11 +169,15 @@ class POSBatchView(BatchMasterView):
     def configure_row_form(self, f):
         super().configure_row_form(f)
 
+        f.set_renderer('product', self.render_product)
+
         f.set_type('quantity', 'quantity')
         f.set_type('reg_price', 'currency')
         f.set_type('txn_price', 'currency')
         f.set_type('sales_total', 'currency')
-        f.set_renderer('product', self.render_product)
+        f.set_type('tender_total', 'currency')
+
+        f.set_renderer('user', self.render_user)
 
 
 def defaults(config, **kwargs):
diff --git a/tailbone/views/tenders.py b/tailbone/views/tenders.py
new file mode 100644
index 00000000..a95773e3
--- /dev/null
+++ b/tailbone/views/tenders.py
@@ -0,0 +1,67 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  Rattail -- Retail Software Framework
+#  Copyright © 2010-2023 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/>.
+#
+################################################################################
+"""
+Views for tenders
+"""
+
+from rattail.db.model import Tender
+
+from tailbone.views import MasterView
+
+
+class TenderView(MasterView):
+    """
+    Master view for the Tender class.
+    """
+    model_class = Tender
+    has_versions = True
+
+    grid_columns = [
+        'code',
+        'name',
+    ]
+
+    form_fields = [
+        'code',
+        'name',
+        'notes',
+    ]
+
+    def configure_grid(self, g):
+        super().configure_grid(g)
+
+        g.set_link('code')
+
+        g.set_link('name')
+        g.set_sort_defaults('name')
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    TenderView = kwargs.get('TenderView', base['TenderView'])
+    TenderView.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/views/typical.py b/tailbone/views/typical.py
index d3450fbd..ed94d552 100644
--- a/tailbone/views/typical.py
+++ b/tailbone/views/typical.py
@@ -43,6 +43,7 @@ def defaults(config, **kwargs):
     config.include(mod('tailbone.views.reportcodes'))
     config.include(mod('tailbone.views.stores'))
     config.include(mod('tailbone.views.subdepartments'))
+    config.include(mod('tailbone.views.tenders'))
     config.include(mod('tailbone.views.uoms'))
     config.include(mod('tailbone.views.vendors'))
 

From 0ee67251889e84435fb0348179ecddfa922c1957 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 28 Sep 2023 10:56:15 -0500
Subject: [PATCH 091/542] Tidy up logic for vendor filtering in products grid

was hoping to "fix" count issue but alas..

refs #23
---
 tailbone/views/products.py | 74 ++++++++++++++++++--------------------
 1 file changed, 35 insertions(+), 39 deletions(-)

diff --git a/tailbone/views/products.py b/tailbone/views/products.py
index 2b03871b..0ee53093 100644
--- a/tailbone/views/products.py
+++ b/tailbone/views/products.py
@@ -86,6 +86,8 @@ class ProductView(MasterView):
     labels = {
         'item_id': "Item ID",
         'upc': "UPC",
+        'vendor': "Vendor (preferred)",
+        'vendor_any': "Vendor (any)",
         'status_code': "Status",
         'tax1': "Tax 1",
         'tax2': "Tax 2",
@@ -158,13 +160,6 @@ class ProductView(MasterView):
         'inventory_on_order',
     ]
 
-    # These aliases enable the grid queries to filter products which may be
-    # purchased from *any* vendor, and yet sort by only the "preferred" vendor
-    # (since that's what shows up in the grid column).
-    ProductVendorCost = orm.aliased(model.ProductCost)
-    ProductVendorCostAny = orm.aliased(model.ProductCost)
-    VendorAny = orm.aliased(model.Vendor)
-
     # same, but for prices
     RegularPrice = orm.aliased(model.ProductPrice)
     CurrentPrice = orm.aliased(model.ProductPrice)
@@ -184,14 +179,11 @@ class ProductView(MasterView):
         self.handler = self.products_handler
 
     def query(self, session):
-        query = super(ProductView, self).query(session)
+        query = super().query(session)
 
         if not self.has_perm('view_deleted'):
             query = query.filter(model.Product.deleted == False)
 
-        # TODO: surely this is not always needed
-        query = query.outerjoin(model.ProductInventory)
-
         return query
 
     def get_departments(self):
@@ -207,23 +199,10 @@ class ProductView(MasterView):
                            .all()
 
     def configure_grid(self, g):
-        super(ProductView, self).configure_grid(g)
+        super().configure_grid(g)
         app = self.get_rattail_app()
         model = self.model
 
-        def join_vendor(q):
-            return q.outerjoin(self.ProductVendorCost,
-                               sa.and_(
-                                   self.ProductVendorCost.product_uuid == model.Product.uuid,
-                                   self.ProductVendorCost.preference == 1))\
-                    .outerjoin(model.Vendor)
-
-        def join_vendor_any(q):
-            return q.outerjoin(self.ProductVendorCostAny,
-                               self.ProductVendorCostAny.product_uuid == model.Product.uuid)\
-                    .outerjoin(self.VendorAny,
-                               self.VendorAny.uuid == self.ProductVendorCostAny.vendor_uuid)
-
         ProductCostCode = orm.aliased(model.ProductCost)
         ProductCostCodeAny = orm.aliased(model.ProductCost)
 
@@ -261,12 +240,33 @@ class ProductView(MasterView):
         g.joiners['subdepartment'] = lambda q: q.outerjoin(model.Subdepartment,
                                                            model.Subdepartment.uuid == model.Product.subdepartment_uuid)
         g.joiners['code'] = lambda q: q.outerjoin(model.ProductCode)
-        g.joiners['vendor'] = join_vendor
-        g.joiners['vendor_any'] = join_vendor_any
 
         g.sorters['brand'] = g.make_sorter(model.Brand.name)
         g.sorters['subdepartment'] = g.make_sorter(model.Subdepartment.name)
-        g.sorters['vendor'] = g.make_sorter(model.Vendor.name)
+
+        # vendor
+        ProductVendorCost = orm.aliased(model.ProductCost)
+        def join_vendor(q):
+            return q.outerjoin(ProductVendorCost,
+                               sa.and_(
+                                   ProductVendorCost.product_uuid == model.Product.uuid,
+                                   ProductVendorCost.preference == 1))\
+                    .outerjoin(model.Vendor)
+        g.set_joiner('vendor', join_vendor)
+        g.set_sorter('vendor', model.Vendor.name)
+        g.set_filter('vendor', model.Vendor.name)
+
+        # vendor_any
+        ProductVendorCostAny = orm.aliased(model.ProductCost)
+        VendorAny = orm.aliased(model.Vendor)
+        def join_vendor_any(q):
+            return q.outerjoin(ProductVendorCostAny,
+                               ProductVendorCostAny.product_uuid == model.Product.uuid)\
+                    .outerjoin(VendorAny,
+                               VendorAny.uuid == ProductVendorCostAny.vendor_uuid)
+        g.set_joiner('vendor_any', join_vendor_any)
+        g.set_filter('vendor_any', VendorAny.name)
+                     # factory=VendorAnyFilter, joiner=join_vendor_any)
 
         ProductTrueCost = orm.aliased(model.ProductVolatile)
         ProductTrueMargin = orm.aliased(model.ProductVolatile)
@@ -284,12 +284,15 @@ class ProductView(MasterView):
         g.set_renderer('true_margin', self.render_true_margin)
 
         # on_hand
-        g.set_sorter('on_hand', model.ProductInventory.on_hand)
-        g.set_filter('on_hand', model.ProductInventory.on_hand)
+        InventoryOnHand = orm.aliased(model.ProductInventory)
+        g.set_joiner('on_hand', lambda q: q.outerjoin(InventoryOnHand))
+        g.set_sorter('on_hand', InventoryOnHand.on_hand)
+        g.set_filter('on_hand', InventoryOnHand.on_hand)
 
         # on_order
-        g.set_sorter('on_order', model.ProductInventory.on_order)
-        g.set_filter('on_order', model.ProductInventory.on_order)
+        InventoryOnOrder = orm.aliased(model.ProductInventory)
+        g.set_sorter('on_order', InventoryOnOrder.on_order)
+        g.set_filter('on_order', InventoryOnOrder.on_order)
 
         g.filters['description'].default_active = True
         g.filters['description'].default_verb = 'contains'
@@ -297,9 +300,6 @@ class ProductView(MasterView):
                                            default_active=True, default_verb='contains')
         g.filters['subdepartment'] = g.make_filter('subdepartment', model.Subdepartment.name)
         g.filters['code'] = g.make_filter('code', model.ProductCode.code)
-        g.filters['vendor'] = g.make_filter('vendor', model.Vendor.name)
-        g.filters['vendor_any'] = g.make_filter('vendor_any', self.VendorAny.name)
-                                                # factory=VendorAnyFilter, joiner=join_vendor_any)
 
         # g.joiners['vendor_code_any'] = join_vendor_code_any
         # g.filters['vendor_code_any'] = g.make_filter('vendor_code_any', ProductCostCodeAny.code)
@@ -382,10 +382,6 @@ class ProductView(MasterView):
         g.set_link('item_id')
         g.set_link('description')
 
-        g.set_label('vendor', "Vendor (preferred)")
-        g.set_label('vendor_any', "Vendor (any)")
-        g.set_label('vendor', "Vendor (preferred)")
-
     def configure_common_form(self, f):
         super(ProductView, self).configure_common_form(f)
         product = f.model_instance

From 9f7e70f240f27138bb05109f52131586d223d2a9 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sat, 30 Sep 2023 21:08:01 -0500
Subject: [PATCH 092/542] Add support for void rows in POS batch

---
 tailbone/views/batch/pos.py | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/tailbone/views/batch/pos.py b/tailbone/views/batch/pos.py
index d2a38314..e4c787f9 100644
--- a/tailbone/views/batch/pos.py
+++ b/tailbone/views/batch/pos.py
@@ -105,6 +105,7 @@ class POSBatchView(BatchMasterView):
         'tax1_total',
         'tax2_total',
         'tender_total',
+        'void',
         'status_code',
         'timestamp',
         'user',
@@ -166,6 +167,10 @@ class POSBatchView(BatchMasterView):
         g.set_link('product')
         g.set_link('description')
 
+    def row_grid_extra_class(self, row, i):
+        if row.void:
+            return 'warning'
+
     def configure_row_form(self, f):
         super().configure_row_form(f)
 

From a6bc3fb793ca9ac3926e5dc7604b686bc7c62942 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 1 Oct 2023 12:09:32 -0500
Subject: [PATCH 093/542] Update changelog

---
 CHANGES.rst          | 14 ++++++++++++++
 tailbone/_version.py |  2 +-
 2 files changed, 15 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 2e17dc24..8cce23d1 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,20 @@
 CHANGELOG
 =========
 
+0.9.60 (2023-10-01)
+-------------------
+
+* Do not allow executing custorder if no customer is set.
+
+* Add clone support for POS batches.
+
+* Expose views for tenders, more columns for POS batch/rows.
+
+* Tidy up logic for vendor filtering in products grid.
+
+* Add support for void rows in POS batch.
+
+
 0.9.59 (2023-09-25)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 7b773591..27e2acc7 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.59'
+__version__ = '0.9.60'

From b7ccc6ea0705ac863081c758fb93930f7ad7b8ad Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 1 Oct 2023 17:31:33 -0500
Subject: [PATCH 094/542] Use enum to display `POS_ROW_TYPE`

---
 tailbone/views/batch/pos.py | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/tailbone/views/batch/pos.py b/tailbone/views/batch/pos.py
index e4c787f9..c8ceede5 100644
--- a/tailbone/views/batch/pos.py
+++ b/tailbone/views/batch/pos.py
@@ -158,6 +158,8 @@ class POSBatchView(BatchMasterView):
     def configure_row_grid(self, g):
         super().configure_row_grid(g)
 
+        g.set_enum('row_type', self.enum.POS_ROW_TYPE)
+
         g.set_type('quantity', 'quantity')
         g.set_type('reg_price', 'currency')
         g.set_type('txn_price', 'currency')
@@ -174,6 +176,8 @@ class POSBatchView(BatchMasterView):
     def configure_row_form(self, f):
         super().configure_row_form(f)
 
+        g.set_enum('row_type', self.enum.POS_ROW_TYPE)
+
         f.set_renderer('product', self.render_product)
 
         f.set_type('quantity', 'quantity')

From 746e13d134d96747b0969baec883d9048e117bb5 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 1 Oct 2023 18:54:56 -0500
Subject: [PATCH 095/542] Expose cash-back flags for tenders

---
 tailbone/views/tenders.py | 9 +++++++++
 1 file changed, 9 insertions(+)

diff --git a/tailbone/views/tenders.py b/tailbone/views/tenders.py
index a95773e3..54a0cdba 100644
--- a/tailbone/views/tenders.py
+++ b/tailbone/views/tenders.py
@@ -39,11 +39,15 @@ class TenderView(MasterView):
     grid_columns = [
         'code',
         'name',
+        'is_cash',
+        'allow_cash_back',
     ]
 
     form_fields = [
         'code',
         'name',
+        'is_cash',
+        'allow_cash_back',
         'notes',
     ]
 
@@ -55,6 +59,11 @@ class TenderView(MasterView):
         g.set_link('name')
         g.set_sort_defaults('name')
 
+    def configure_form(self, f):
+        super().configure_form(f)
+
+        f.set_type('notes', 'text')
+
 
 def defaults(config, **kwargs):
     base = globals()

From 4125be7e8d919fca2e8e1c1ce1f9fd509c5b1b11 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 2 Oct 2023 09:54:34 -0500
Subject: [PATCH 096/542] Re-work FalafelDateTime logic a bit

need to be more "standard" in how (de)serialize works etc.

also be sure to show error messages if present, not just field helptext
---
 tailbone/forms/core.py    | 42 ++++++++++++++++++---------------------
 tailbone/forms/types.py   | 17 +++++++++++++---
 tailbone/forms/widgets.py | 11 ++++++++++
 3 files changed, 44 insertions(+), 26 deletions(-)

diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py
index 53c234db..97e23a25 100644
--- a/tailbone/forms/core.py
+++ b/tailbone/forms/core.py
@@ -886,9 +886,6 @@ class Form(object):
             if field.cstruct is colander.null:
                 return '[]'
 
-        if isinstance(field.schema.typ, types.FalafelDateTime):
-            return field.cstruct
-
         try:
             return self.jsonify_value(field.cstruct)
         except Exception as error:
@@ -980,32 +977,31 @@ class Form(object):
             if field and isinstance(field.schema.typ, deform.FileData):
                 attrs['class_'] = 'file'
 
-            # show helptext if present
-            # TODO: older logic did this only if field was *not*
-            # readonly, perhaps should add that back..
-            if self.has_helptext(fieldname):
-                msgkey = 'message'
-                if self.dynamic_helptext.get(fieldname):
-                    msgkey = ':message'
-                attrs[msgkey] = self.render_helptext(fieldname)
+            # next we will build array of messages to display..some
+            # fields always show a "helptext" msg, and some may have
+            # validation errors..
+            field_type = None
+            messages = []
 
             # show errors if present
             error_messages = self.get_error_messages(field) if field else None
             if error_messages:
+                field_type = 'is-danger'
+                messages.extend(error_messages)
 
-                # TODO: this surely can't be what we ought to do
-                # here..?  seems like we must pass JS but not JSON,
-                # sort of, so we custom-write the JS code to ensure
-                # single instead of double quotes delimit strings
-                # within the code.
-                message = '[{}]'.format(', '.join([
+            # show helptext if present
+            # TODO: older logic did this only if field was *not*
+            # readonly, perhaps should add that back..
+            if self.has_helptext(fieldname):
+                messages.append(self.render_helptext(fieldname))
+
+            # ..okay now we can declare the field messages and type
+            if field_type:
+                attrs['type'] = field_type
+            if messages:
+                attrs[':message'] = '[{}]'.format(', '.join([
                     "'{}'".format(msg.replace("'", r"\'"))
-                    for msg in error_messages]))
-
-                attrs.update({
-                    'type': 'is-danger',
-                    ':message': message,
-                })
+                    for msg in messages]))
 
             # merge anything caller provided
             attrs.update(bfield_attrs)
diff --git a/tailbone/forms/types.py b/tailbone/forms/types.py
index 026bc598..3e4952e4 100644
--- a/tailbone/forms/types.py
+++ b/tailbone/forms/types.py
@@ -102,17 +102,28 @@ class FalafelDateTime(colander.DateTime):
         app = self.request.rattail_config.get_app()
         dt = app.localtime(appstruct, from_utc=True)
 
-        return json.dumps({
+        return {
             'date': str(dt.date()),
             'time': str(dt.time()),
-        })
+        }
 
     def deserialize(self, node, cstruct):
         if not cstruct:
             return colander.null
 
+        try:
+            date = datetime.datetime.strptime(cstruct['date'], '%Y-%m-%d').date()
+        except:
+            node.raise_invalid("Missing or invalid date")
+
+        try:
+            time = datetime.datetime.strptime(cstruct['time'], '%H:%M:%S').time()
+        except:
+            node.raise_invalid("Missing or invalid time")
+
+        result = datetime.datetime.combine(date, time)
+
         app = self.request.rattail_config.get_app()
-        result = datetime.datetime.strptime(cstruct, '%Y-%m-%dT%H:%M:%S')
         result = app.localtime(result)
         result = app.make_utc(result)
         return result
diff --git a/tailbone/forms/widgets.py b/tailbone/forms/widgets.py
index a8810e69..23bbac00 100644
--- a/tailbone/forms/widgets.py
+++ b/tailbone/forms/widgets.py
@@ -242,6 +242,17 @@ class FalafelDateTimeWidget(dfwidget.DateTimeInputWidget):
     """
     template = 'datetime_falafel'
 
+    def serialize(self, field, cstruct, **kw):
+        readonly = kw.get('readonly', self.readonly)
+        values = self.get_template_values(field, cstruct, kw)
+        template = self.readonly_template if readonly else self.template
+        return field.renderer(template, **values)
+
+    def deserialize(self, field, pstruct):
+        if pstruct  == '':
+            return colander.null
+        return pstruct
+
 
 class FalafelTimeWidget(dfwidget.TimeInputWidget):
     """

From 0b7791070fb014c38c5259fd39c985035bf2a6bf Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 4 Oct 2023 10:59:54 -0500
Subject: [PATCH 097/542] Update changelog

---
 CHANGES.rst          | 10 ++++++++++
 tailbone/_version.py |  2 +-
 2 files changed, 11 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 8cce23d1..ca67318d 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,16 @@
 CHANGELOG
 =========
 
+0.9.61 (2023-10-04)
+-------------------
+
+* Use enum to display ``POS_ROW_TYPE``.
+
+* Expose cash-back flags for tenders.
+
+* Re-work FalafelDateTime logic a bit.
+
+
 0.9.60 (2023-10-01)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 27e2acc7..58d905cb 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.60'
+__version__ = '0.9.61'

From f3dddf0e401316421ad5aa6ff0025408e94086f6 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 4 Oct 2023 11:56:50 -0500
Subject: [PATCH 098/542] Avoid deprecated `pretty_hours()` function

---
 tailbone/grids/core.py        |  5 +++--
 tailbone/views/shifts/core.py | 41 ++++++++++++++++++-----------------
 tailbone/views/shifts/lib.py  |  8 ++++---
 3 files changed, 29 insertions(+), 25 deletions(-)

diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py
index 639eabd1..6373add6 100644
--- a/tailbone/grids/core.py
+++ b/tailbone/grids/core.py
@@ -32,7 +32,7 @@ import sqlalchemy as sa
 from sqlalchemy import orm
 
 from rattail.db.types import GPCType
-from rattail.util import prettify, pretty_boolean, pretty_quantity, pretty_hours
+from rattail.util import prettify, pretty_boolean, pretty_quantity
 from rattail.time import localtime
 
 import webhelpers2_grid
@@ -541,7 +541,8 @@ class Grid(object):
         value = self.obtain_value(obj, field)
         if value is None:
             return ""
-        return pretty_hours(hours=value)
+        app = self.request.rattail_config.get_app()
+        return app.render_duration(hours=value)
 
     def set_url(self, url):
         self.url = url
diff --git a/tailbone/views/shifts/core.py b/tailbone/views/shifts/core.py
index b6d9aadf..8fa934ea 100644
--- a/tailbone/views/shifts/core.py
+++ b/tailbone/views/shifts/core.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,31 +24,32 @@
 Views for employee shifts
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import datetime
 
-import six
-
 from rattail.db import model
 from rattail.time import localtime
-from rattail.util import pretty_hours, hours_as_decimal
+from rattail.util import hours_as_decimal
 
 from webhelpers2.html import tags, HTML
 
 from tailbone.views import MasterView
 
 
-def render_shift_length(shift, field):
-    if not shift.start_time or not shift.end_time:
-        return ""
-    if shift.end_time < shift.start_time:
-        return "??"
-    length = shift.end_time - shift.start_time
-    return HTML.tag('span', title="{} hrs".format(hours_as_decimal(length)), c=[pretty_hours(length)])
+class ShiftViewMixin:
+
+    def render_shift_length(self, shift, field):
+        if not shift.start_time or not shift.end_time:
+            return ""
+        if shift.end_time < shift.start_time:
+            return "??"
+        app = self.get_rattail_app()
+        length = shift.end_time - shift.start_time
+        return HTML.tag('span',
+                        title="{} hrs".format(hours_as_decimal(length)),
+                        c=[app.render_duration(delta=length)])
 
 
-class ScheduledShiftView(MasterView):
+class ScheduledShiftView(MasterView, ShiftViewMixin):
     """
     Master view for employee scheduled shifts.
     """
@@ -78,20 +79,20 @@ class ScheduledShiftView(MasterView):
 
         g.set_sort_defaults('start_time', 'desc')
 
-        g.set_renderer('length', render_shift_length)
+        g.set_renderer('length', self.render_shift_length)
 
         g.set_label('employee', "Employee Name")
 
     def configure_form(self, f):
         super(ScheduledShiftView, self).configure_form(f)
 
-        f.set_renderer('length', render_shift_length)
+        f.set_renderer('length', self.render_shift_length)
 
 # TODO: deprecate / remove this
 ScheduledShiftsView = ScheduledShiftView
 
 
-class WorkedShiftView(MasterView):
+class WorkedShiftView(MasterView, ShiftViewMixin):
     """
     Master view for employee worked shifts.
     """
@@ -136,7 +137,7 @@ class WorkedShiftView(MasterView):
         # (but we'll still have to set this)
         g.set_sort_defaults('start_time', 'desc')
 
-        g.set_renderer('length', render_shift_length)
+        g.set_renderer('length', self.render_shift_length)
 
         g.set_label('employee', "Employee Name")
         g.set_label('store', "Store Name")
@@ -154,7 +155,7 @@ class WorkedShiftView(MasterView):
         f.set_readonly('employee')
         f.set_renderer('employee', self.render_employee)
 
-        f.set_renderer('length', render_shift_length)
+        f.set_renderer('length', self.render_shift_length)
         if self.editing:
             f.remove('length')
 
@@ -162,7 +163,7 @@ class WorkedShiftView(MasterView):
         employee = shift.employee
         if not employee:
             return ""
-        text = six.text_type(employee)
+        text = str(employee)
         url = self.request.route_url('employees.view', uuid=employee.uuid)
         return tags.link_to(text, url)
 
diff --git a/tailbone/views/shifts/lib.py b/tailbone/views/shifts/lib.py
index d32a1309..8fc58264 100644
--- a/tailbone/views/shifts/lib.py
+++ b/tailbone/views/shifts/lib.py
@@ -31,7 +31,7 @@ import sqlalchemy as sa
 from rattail import enum
 from rattail.db import model, api
 from rattail.time import localtime, make_utc, get_sunday
-from rattail.util import pretty_hours, hours_as_decimal
+from rattail.util import hours_as_decimal
 
 import colander
 from deform import widget as dfwidget
@@ -401,6 +401,8 @@ class TimeSheetView(View):
         Fetch all shift data of the given model class (``cls``), according to
         the given params.  The cached shift data is attached to each employee.
         """
+        app = self.get_rattail_app()
+
         # TODO: a bit hacky, this?  display hours as HH:MM by default, but
         # check config in order to display as HH.HH for certain users
         hours_style = 'pretty'
@@ -465,7 +467,7 @@ class TimeSheetView(View):
                 hours = empday['{}_hours'.format(shift_type)]
                 if hours:
                     if hours_style == 'pretty':
-                        display = pretty_hours(hours)
+                        display = app.render_duration(hours=hours)
                     else: # decimal
                         display = str(hours_as_decimal(hours))
                     if empday['hours_incomplete']:
@@ -476,7 +478,7 @@ class TimeSheetView(View):
             hours = getattr(employee, '{}_hours'.format(shift_type))
             if hours:
                 if hours_style == 'pretty':
-                    display = pretty_hours(hours)
+                    display = app.render_duration(hours=hours)
                 else: # decimal
                     display = str(hours_as_decimal(hours))
                 if hours_incomplete:

From 7bae01f03cb33e1402baf41b915fde4386197eef Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 4 Oct 2023 13:07:26 -0500
Subject: [PATCH 099/542] Improve master view `oneoff_import()` method

be more flexible about what caller must provide
---
 tailbone/views/master.py | 25 ++++++++++++++++++-------
 1 file changed, 18 insertions(+), 7 deletions(-)

diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index 04262124..f9e2d150 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -1841,21 +1841,32 @@ class MasterView(View):
     def fetch_grid_totals(self):
         return {'totals_display': "TODO: totals go here"}
 
-    def oneoff_import(self, importer, host_object=None):
+    def oneoff_import(self, importer, host_object=None, local_object=None):
         """
         Basic helper method, to do a one-off import (or export, depending on
         perspective) of the "current instance" object.  Where the data "goes"
         depends on the importer you provide.
         """
-        if not host_object:
+        if host_object is None and local_object is None:
             host_object = self.get_instance()
 
-        host_data = importer.normalize_host_object(host_object)
-        if not host_data:
-            return
+        if host_object is None:
+            local_data = importer.normalize_local_object(local_object)
+            key = importer.get_key(local_data)
+            host_object = importer.get_single_host_object(key)
+            if not host_object:
+                return
+            host_data = importer.normalize_host_object(host_object)
+            if not host_data:
+                return
+
+        else:
+            host_data = importer.normalize_host_object(host_object)
+            if not host_data:
+                return
+            key = importer.get_key(host_data)
+            local_object = importer.get_local_object(key)
 
-        key = importer.get_key(host_data)
-        local_object = importer.get_local_object(key)
         if local_object:
             if importer.allow_update:
                 local_data = importer.normalize_local_object(local_object)

From 3dfab8e42d88510eb9dc9d7d1b48896f7596625e Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 4 Oct 2023 13:56:22 -0500
Subject: [PATCH 100/542] Update changelog

---
 CHANGES.rst          | 8 ++++++++
 tailbone/_version.py | 2 +-
 2 files changed, 9 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index ca67318d..755b9e7d 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,14 @@
 CHANGELOG
 =========
 
+0.9.62 (2023-10-04)
+-------------------
+
+* Avoid deprecated ``pretty_hours()`` function.
+
+* Improve master view ``oneoff_import()`` method.
+
+
 0.9.61 (2023-10-04)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 58d905cb..9b2f1e6a 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.61'
+__version__ = '0.9.62'

From b30f6cdf3ac2de8914aac0c0f6f7fa9b3fc1cb41 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 5 Oct 2023 13:11:05 -0500
Subject: [PATCH 101/542] Fix CRUD pages for tempmon clients, probes

for some reason if helptext had embedded newlines, it would now fail
to render the form altogether.  guess that is a result of recent
change to e.g. `<b-field :message="['foo', 'bar']">` logic,
somehow.. anyway hopefully this fixes and no more surprises
---
 tailbone/forms/core.py                      |  5 +-
 tailbone/templates/tempmon/probes/view.mako | 51 +++++++--------------
 tailbone/views/tempmon/clients.py           | 13 +++---
 tailbone/views/tempmon/probes.py            |  9 ++--
 4 files changed, 31 insertions(+), 47 deletions(-)

diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py
index 97e23a25..06bf96e4 100644
--- a/tailbone/forms/core.py
+++ b/tailbone/forms/core.py
@@ -755,7 +755,8 @@ class Form(object):
         """
         Set the help text for a given field.
         """
-        self.helptext[key] = value
+        # nb. must avoid newlines, they cause some weird "blank page" error?!
+        self.helptext[key] = value.replace('\n', ' ')
         if value and dynamic:
             self.dynamic_helptext[key] = True
         else:
@@ -1009,6 +1010,8 @@ class Form(object):
             # render the field widget or whatever
             if self.readonly or fieldname in self.readonly_fields:
                 html = self.render_field_value(fieldname) or HTML.tag('span')
+                if type(html) is str:
+                    html = HTML.tag('span', c=[html])
             elif field:
                 html = field.serialize(**self.get_renderer_kwargs(fieldname))
                 html = HTML.literal(html)
diff --git a/tailbone/templates/tempmon/probes/view.mako b/tailbone/templates/tempmon/probes/view.mako
index 207c48d4..7afd2427 100644
--- a/tailbone/templates/tempmon/probes/view.mako
+++ b/tailbone/templates/tempmon/probes/view.mako
@@ -1,48 +1,29 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/master/view.mako" />
 
-<%def name="render_form_complete()">
+<%def name="page_content()">
+  <div class="form-wrapper">
+    <div style="display: flex; flex-direction: column;">
 
-  ## ${self.render_form()}
-
-  <script type="text/x-template" id="form-page-template">
-
-    <div style="display: flex; justify-content: space-between;">
-
-      <div class="form-wrapper">
-
-        <div style="display: flex; flex-direction: column;">
-
-          <nav class="panel" id="probe-main">
-            <p class="panel-heading">General</p>
-            <div class="panel-block">
-              <div>
-                ${self.render_main_fields(form)}
-              </div>
-            </div>
-          </nav>
-
-          <div style="display: flex;">
-            <div class="panel-wrapper">
-              ${self.left_column()}
-            </div>
-            <div class="panel-wrapper" style="margin-left: 1em;"> <!-- right column -->
-              ${self.right_column()}
-            </div>
+      <nav class="panel" id="probe-main">
+        <p class="panel-heading">General</p>
+        <div class="panel-block">
+          <div>
+            ${self.render_main_fields(form)}
           </div>
+        </div>
+      </nav>
 
+      <div style="display: flex;">
+        <div class="panel-wrapper">
+          ${self.left_column()}
+        </div>
+        <div class="panel-wrapper" style="margin-left: 1em;"> <!-- right column -->
+          ${self.right_column()}
         </div>
       </div>
 
-      <ul id="context-menu">
-        ${self.context_menu_items()}
-      </ul>
-
     </div>
-  </script>
-
-  <div id="form-page-app">
-    <form-page></form-page>
   </div>
 </%def>
 
diff --git a/tailbone/views/tempmon/clients.py b/tailbone/views/tempmon/clients.py
index 9edbd2ba..1b2d49d8 100644
--- a/tailbone/views/tempmon/clients.py
+++ b/tailbone/views/tempmon/clients.py
@@ -24,8 +24,6 @@
 Views for tempmon clients
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import subprocess
 
 from rattail.config import parse_list
@@ -51,6 +49,7 @@ class TempmonClientView(MasterView):
 
     has_rows = True
     model_row_class = tempmon.Reading
+    rows_title = "Readings"
 
     grid_columns = [
         'config_key',
@@ -83,7 +82,7 @@ class TempmonClientView(MasterView):
     ]
 
     def configure_grid(self, g):
-        super(TempmonClientView, self).configure_grid(g)
+        super().configure_grid(g)
 
         # config_key
         g.set_label('config_key', "Key")
@@ -116,7 +115,7 @@ class TempmonClientView(MasterView):
         return "No"
 
     def configure_form(self, f):
-        super(TempmonClientView, self).configure_form(f)
+        super().configure_form(f)
 
         # config_key
         f.set_validator('config_key', self.unique_config_key)
@@ -160,7 +159,7 @@ class TempmonClientView(MasterView):
         f.set_helptext('archived', tempmon.Client.archived.__doc__)
 
     def template_kwargs_view(self, **kwargs):
-        kwargs = super(TempmonClientView, self).template_kwargs_view(**kwargs)
+        kwargs = super().template_kwargs_view(**kwargs)
         client = kwargs['instance']
 
         kwargs['probes_data'] = self.normalize_probes(client.probes)
@@ -177,7 +176,7 @@ class TempmonClientView(MasterView):
             if data['enabled'] and form.model_instance.enabled:
                 data['enabled'] = form.model_instance.enabled
 
-        return super(TempmonClientView, self).objectify(form, data=data)
+        return super().objectify(form, data=data)
 
     def unique_config_key(self, node, value):
         query = self.Session.query(tempmon.Client)\
@@ -230,7 +229,7 @@ class TempmonClientView(MasterView):
         return reading.client
 
     def configure_row_grid(self, g):
-        super(TempmonClientView, self).configure_row_grid(g)
+        super().configure_row_grid(g)
 
         # probe
         g.set_filter('probe', tempmon.Probe.description)
diff --git a/tailbone/views/tempmon/probes.py b/tailbone/views/tempmon/probes.py
index 381a9f4a..dbf15dd1 100644
--- a/tailbone/views/tempmon/probes.py
+++ b/tailbone/views/tempmon/probes.py
@@ -49,6 +49,7 @@ class TempmonProbeView(MasterView):
 
     has_rows = True
     model_row_class = tempmon.Reading
+    rows_title = "Readings"
 
     labels = {
         'critical_max_timeout': "Critical High Timeout",
@@ -98,7 +99,7 @@ class TempmonProbeView(MasterView):
     ]
 
     def configure_grid(self, g):
-        super(TempmonProbeView, self).configure_grid(g)
+        super().configure_grid(g)
 
         g.joiners['client'] = lambda q: q.join(tempmon.Client)
         g.sorters['client'] = g.make_sorter(tempmon.Client.config_key)
@@ -121,7 +122,7 @@ class TempmonProbeView(MasterView):
         return "No"
 
     def configure_form(self, f):
-        super(TempmonProbeView, self).configure_form(f)
+        super().configure_form(f)
 
         # config_key
         f.set_validator('config_key', self.unique_config_key)
@@ -186,7 +187,7 @@ class TempmonProbeView(MasterView):
             if data['enabled'] and form.model_instance.enabled:
                 data['enabled'] = form.model_instance.enabled
 
-        return super(TempmonProbeView, self).objectify(form, data=data)
+        return super().objectify(form, data=data)
 
     def unique_config_key(self, node, value):
         query = self.Session.query(tempmon.Probe)\
@@ -240,7 +241,7 @@ class TempmonProbeView(MasterView):
         return reading.client
 
     def configure_row_grid(self, g):
-        super(TempmonProbeView, self).configure_row_grid(g)
+        super().configure_row_grid(g)
 
         # # probe
         # g.set_filter('probe', tempmon.Probe.description)

From e1a64de205c82b398f453265d08f0a8696f33742 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 5 Oct 2023 19:59:57 -0500
Subject: [PATCH 102/542] Fix bug in POS batch view

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

diff --git a/tailbone/views/batch/pos.py b/tailbone/views/batch/pos.py
index c8ceede5..42ea3a67 100644
--- a/tailbone/views/batch/pos.py
+++ b/tailbone/views/batch/pos.py
@@ -176,7 +176,7 @@ class POSBatchView(BatchMasterView):
     def configure_row_form(self, f):
         super().configure_row_form(f)
 
-        g.set_enum('row_type', self.enum.POS_ROW_TYPE)
+        f.set_enum('row_type', self.enum.POS_ROW_TYPE)
 
         f.set_renderer('product', self.render_product)
 

From d45ee34b0cbb334b06770941e8fda1dcbc4da4e9 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 6 Oct 2023 08:56:22 -0500
Subject: [PATCH 103/542] Expose permissions for POS, if so configured

---
 tailbone/views/batch/pos.py | 28 +++++++++++++++++++++++++++-
 1 file changed, 27 insertions(+), 1 deletion(-)

diff --git a/tailbone/views/batch/pos.py b/tailbone/views/batch/pos.py
index 42ea3a67..71479391 100644
--- a/tailbone/views/batch/pos.py
+++ b/tailbone/views/batch/pos.py
@@ -89,7 +89,7 @@ class POSBatchView(BatchMasterView):
         'quantity',
         'sales_total',
         'tender_total',
-        'status_code',
+        'user',
     ]
 
     row_form_fields = [
@@ -188,6 +188,32 @@ class POSBatchView(BatchMasterView):
 
         f.set_renderer('user', self.render_user)
 
+    @classmethod
+    def defaults(cls, config):
+        cls._batch_defaults(config)
+        cls._defaults(config)
+        cls._pos_batch_defaults(config)
+
+    @classmethod
+    def _pos_batch_defaults(cls, config):
+        rattail_config = config.registry.settings.get('rattail_config')
+
+        if rattail_config.getbool('tailbone', 'expose_pos_permissions',
+                                  default=False):
+
+            config.add_tailbone_permission_group('pos', "POS", overwrite=False)
+
+            config.add_tailbone_permission('pos', 'pos.ring_sales',
+                                           "Make transactions (ring up sales)")
+            # config.add_tailbone_permission('pos', 'pos.resume',
+            #                                "Resume previously-suspended transaction")
+            # config.add_tailbone_permission('pos', 'pos.suspend',
+            #                                "Suspend current transaction")
+            config.add_tailbone_permission('pos', 'pos.swap_customer',
+                                           "Swap customer for current transaction")
+            config.add_tailbone_permission('pos', 'pos.void_txn',
+                                           "Void current transaction")
+
 
 def defaults(config, **kwargs):
     base = globals()

From 53cf771c81a4c37c011116def272947a6a22fbc6 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 6 Oct 2023 10:00:37 -0500
Subject: [PATCH 104/542] Update changelog

---
 CHANGES.rst          | 10 ++++++++++
 tailbone/_version.py |  2 +-
 2 files changed, 11 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 755b9e7d..ef40368c 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,16 @@
 CHANGELOG
 =========
 
+0.9.63 (2023-10-06)
+-------------------
+
+* Fix CRUD pages for tempmon clients, probes.
+
+* Fix bug in POS batch view.
+
+* Expose permissions for POS, if so configured.
+
+
 0.9.62 (2023-10-04)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 9b2f1e6a..f2d08dcc 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.62'
+__version__ = '0.9.63'

From d1d781966fc3c676813088b19d44ef2c6acabaa7 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 6 Oct 2023 10:12:38 -0500
Subject: [PATCH 105/542] Fix bug for param helptext in New Report page

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

diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py
index 5a945f0c..9bf30a88 100644
--- a/tailbone/views/reports.py
+++ b/tailbone/views/reports.py
@@ -431,7 +431,8 @@ class ReportOutputView(ExportMasterView):
                 node.default = param.default
 
             # set docstring
-            helptext[param.name] = param.helptext
+            # nb. must avoid newlines, they cause some weird "blank page" error?!
+            helptext[param.name] = param.helptext.replace('\n', ' ')
 
             schema.add(node)
 

From 2ae2cdc4bd25d7fd72487cefefd6486d04449b32 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 6 Oct 2023 10:13:18 -0500
Subject: [PATCH 106/542] Update changelog

---
 CHANGES.rst          | 6 ++++++
 tailbone/_version.py | 2 +-
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index ef40368c..aa1d68b9 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,12 @@
 CHANGELOG
 =========
 
+0.9.64 (2023-10-06)
+-------------------
+
+* Fix bug for param helptext in New Report page.
+
+
 0.9.63 (2023-10-06)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index f2d08dcc..83562798 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.63'
+__version__ = '0.9.64'

From d84b98041f5a1717d8b6bc351872a56034111ee0 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 6 Oct 2023 15:03:17 -0500
Subject: [PATCH 107/542] Avoid deprecated logic for fetching vendor contact
 email/phone

---
 tailbone/views/vendors/core.py | 10 ++++++----
 1 file changed, 6 insertions(+), 4 deletions(-)

diff --git a/tailbone/views/vendors/core.py b/tailbone/views/vendors/core.py
index 176afab2..743e1632 100644
--- a/tailbone/views/vendors/core.py
+++ b/tailbone/views/vendors/core.py
@@ -92,7 +92,8 @@ class VendorView(MasterView):
         g.set_link('abbreviation')
 
     def configure_form(self, f):
-        super(VendorView, self).configure_form(f)
+        super().configure_form(f)
+        app = self.get_rattail_app()
         vendor = f.model_instance
 
         f.set_type('lead_time_days', 'quantity')
@@ -111,7 +112,7 @@ class VendorView(MasterView):
         # orders_email
         f.set_renderer('orders_email', self.render_orders_email)
         if not self.creating and vendor.emails:
-            f.set_default('orders_email', vendor.get_email_address(type_='Orders') or '')
+            f.set_default('orders_email', app.get_contact_email_address(vendor, type_='Orders') or '')
 
         # contact
         if self.creating:
@@ -128,7 +129,7 @@ class VendorView(MasterView):
 
         if 'orders_email' in data:
             address = data['orders_email']
-            email = vendor.get_email(type_='Orders')
+            email = app.get_contact_email(vendor, type_='Orders')
             if address:
                 if email:
                     if email.address != address:
@@ -145,7 +146,8 @@ class VendorView(MasterView):
             return vendor.emails[0].address
 
     def render_orders_email(self, vendor, field):
-        return vendor.get_email_address(type_='Orders')
+        app = self.get_rattail_app()
+        return app.get_contact_email_address(vendor, type_='Orders')
 
     def render_default_phone(self, vendor, field):
         if vendor.phones:

From 2f4877a264b4ee2ea9746fb16235cc0284b7a4d3 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 6 Oct 2023 15:53:17 -0500
Subject: [PATCH 108/542] Add "mark complete" button for inventory batch row
 entry page

---
 .../batch/inventory/desktop_form.mako         | 65 +++++++++++++++----
 tailbone/views/batch/inventory.py             | 16 +++--
 2 files changed, 65 insertions(+), 16 deletions(-)

diff --git a/tailbone/templates/batch/inventory/desktop_form.mako b/tailbone/templates/batch/inventory/desktop_form.mako
index 2a853f4f..9f13cbf9 100644
--- a/tailbone/templates/batch/inventory/desktop_form.mako
+++ b/tailbone/templates/batch/inventory/desktop_form.mako
@@ -3,9 +3,35 @@
 
 <%def name="title()">Inventory Form</%def>
 
-<%def name="context_menu_items()">
-  ${parent.context_menu_items()}
-  <li>${h.link_to("Back to Inventory Batch", url('batch.inventory.view', uuid=batch.uuid))}</li>
+<%def name="object_helpers()">
+  <nav class="panel">
+    <p class="panel-heading">Batch</p>
+    <div class="panel-block buttons">
+      <div style="display: flex; flex-direction: column;">
+
+        <once-button type="is-primary"
+                     icon-left="eye"
+                     tag="a" href="${url('batch.inventory.view', uuid=batch.uuid)}"
+                     text="View Batch">
+        </once-button>
+
+        % if not batch.executed and master.has_perm('edit'):
+            ${h.form(master.get_action_url('toggle_complete', batch), **{'@submit': 'toggleCompleteSubmitting = true'})}
+            ${h.csrf_token(request)}
+            ${h.hidden('complete', value='true')}
+            <b-button type="is-primary"
+                      native-type="submit"
+                      icon-pack="fas"
+                      icon-left="check"
+                      :disabled="toggleCompleteSubmitting">
+              {{ toggleCompleteSubmitting ? "Working, please wait..." : "Mark Complete" }}
+            </b-button>
+            ${h.end_form()}
+        % endif
+
+      </div>
+    </div>
+  </nav>
 </%def>
 
 <%def name="render_form()">
@@ -123,6 +149,7 @@
 
     let ${form.component_studly} = {
         template: '#${form.component}-template',
+        mixins: [SimpleRequestMixin],
 
         mounted() {
             this.$refs.productUPC.focus()
@@ -195,15 +222,9 @@
                 let params = {
                     upc: this.productUPC,
                 }
-                this.$http.get(url, {params: params}).then(response => {
+                this.simpleGET(url, params, response => {
 
-                    if (response.data.error) {
-                        alert(response.data.error)
-                        if (response.data.redirect) {
-                            location.href = response.data.redirect
-                        }
-
-                    } else if (response.data.product.uuid) {
+                    if (response.data.product.uuid) {
 
                         this.productUPC = response.data.product.upc_pretty
                         this.productInfo = response.data.product
@@ -238,6 +259,19 @@
                     } else {
                         ## this.productNotFound = true
                         alert("Product not found!")
+
+                        // focus/select UPC entry
+                        this.$refs.productUPC.focus()
+                        // nb. must traverse into the <b-input> element
+                        this.$refs.productUPC.$el.firstChild.select()
+                    }
+
+                }, response => {
+                    if (response.data.error) {
+                        alert(response.data.error)
+                        if (response.data.redirect) {
+                            location.href = response.data.redirect
+                        }
                     }
                 })
             },
@@ -263,5 +297,14 @@
   </script>
 </%def>
 
+<%def name="modify_this_page_vars()">
+  ${parent.modify_this_page_vars()}
+  <script type="text/javascript">
+
+    ThisPageData.toggleCompleteSubmitting = false
+
+  </script>
+</%def>
+
 
 ${parent.body()}
diff --git a/tailbone/views/batch/inventory.py b/tailbone/views/batch/inventory.py
index 92f0b2d4..e9f72ceb 100644
--- a/tailbone/views/batch/inventory.py
+++ b/tailbone/views/batch/inventory.py
@@ -228,7 +228,7 @@ class InventoryBatchView(BatchMasterView):
         Desktop workflow view for adding items to inventory batch.
         """
         batch = self.get_instance()
-        if batch.executed:
+        if batch.executed or batch.complete:
             return self.redirect(self.get_action_url('view', batch))
 
         schema = DesktopForm().bind(session=self.Session())
@@ -360,11 +360,17 @@ class InventoryBatchView(BatchMasterView):
 
     # TODO: deprecate / remove (?)
     def find_product(self, entry):
-        lookup_by_code = self.rattail_config.getbool(
-            'tailbone', 'inventory.lookup_by_code', default=False)
+        lookup_fields = [
+            'uuid',
+            '_product_key_',
+        ]
 
-        return self.handler.locate_product_for_entry(
-            self.Session(), entry, lookup_by_code=lookup_by_code)
+        if self.rattail_config.getbool('tailbone', 'inventory.lookup_by_code',
+                                       default=False):
+            lookup_fields.append('alt_code')
+
+        return self.handler.locate_product_for_entry(self.Session(), entry,
+                                                     lookup_fields=lookup_fields)
 
     def product_info(self, product):
         data = {}

From eccb855d09fbd1bc8f2f7b766b33f0b5172740bf Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 6 Oct 2023 20:34:14 -0500
Subject: [PATCH 109/542] Expose tender ref in POS batch rows; new tender flags

---
 tailbone/views/batch/pos.py | 2 ++
 tailbone/views/master.py    | 8 ++++++++
 tailbone/views/tenders.py   | 7 +++++++
 3 files changed, 17 insertions(+)

diff --git a/tailbone/views/batch/pos.py b/tailbone/views/batch/pos.py
index 71479391..8bc70b02 100644
--- a/tailbone/views/batch/pos.py
+++ b/tailbone/views/batch/pos.py
@@ -105,6 +105,7 @@ class POSBatchView(BatchMasterView):
         'tax1_total',
         'tax2_total',
         'tender_total',
+        'tender',
         'void',
         'status_code',
         'timestamp',
@@ -179,6 +180,7 @@ class POSBatchView(BatchMasterView):
         f.set_enum('row_type', self.enum.POS_ROW_TYPE)
 
         f.set_renderer('product', self.render_product)
+        f.set_renderer('tender', self.render_tender)
 
         f.set_type('quantity', 'quantity')
         f.set_type('reg_price', 'currency')
diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index f9e2d150..e3a60eca 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -861,6 +861,14 @@ class MasterView(View):
             url = self.request.route_url('stores.view', uuid=store.uuid)
             return tags.link_to(text, url)
 
+    def render_tender(self, obj, field):
+        tender = getattr(obj, field)
+        if not tender:
+            return
+        text = str(tender)
+        url = self.request.route_url('tenders.view', uuid=tender.uuid)
+        return tags.link_to(text, url)
+
     def valid_employee_uuid(self, node, value):
         if value:
             model = self.model
diff --git a/tailbone/views/tenders.py b/tailbone/views/tenders.py
index 54a0cdba..d5524e74 100644
--- a/tailbone/views/tenders.py
+++ b/tailbone/views/tenders.py
@@ -41,6 +41,7 @@ class TenderView(MasterView):
         'name',
         'is_cash',
         'allow_cash_back',
+        'kick_drawer',
     ]
 
     form_fields = [
@@ -48,7 +49,9 @@ class TenderView(MasterView):
         'name',
         'is_cash',
         'allow_cash_back',
+        'kick_drawer',
         'notes',
+        'disabled',
     ]
 
     def configure_grid(self, g):
@@ -59,6 +62,10 @@ class TenderView(MasterView):
         g.set_link('name')
         g.set_sort_defaults('name')
 
+    def grid_extra_class(self, tender, i):
+        if tender.disabled:
+            return 'warning'
+
     def configure_form(self, f):
         super().configure_form(f)
 

From 07b1d0841efce1234052fc89e043388e5c8018d4 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sat, 7 Oct 2023 16:26:33 -0500
Subject: [PATCH 110/542] Improve views for taxes, esp. in POS batches

---
 tailbone/grids/filters.py              | 11 ++++-
 tailbone/templates/batch/pos/view.mako | 13 ++++++
 tailbone/views/batch/pos.py            | 60 ++++++++++++++++++++++----
 tailbone/views/master.py               |  8 ++++
 tailbone/views/products.py             | 13 +++++-
 tailbone/views/taxes.py                | 24 ++++++++---
 tailbone/views/typical.py              |  1 +
 7 files changed, 113 insertions(+), 17 deletions(-)
 create mode 100644 tailbone/templates/batch/pos/view.mako

diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py
index c8815f9f..61d29554 100644
--- a/tailbone/grids/filters.py
+++ b/tailbone/grids/filters.py
@@ -177,13 +177,18 @@ class GridFilter(object):
         self.key = key
         self.config = config
         self.label = label or prettify(key)
-        self.verbs = verbs or self.get_default_verbs()
+
         if value_renderer:
             self.set_value_renderer(value_renderer)
         elif value_enum:
             self.set_choices(value_enum)
         else:
             self.set_value_renderer(self.value_renderer_factory)
+
+        # nb. do this after setting choices, if applicable, since that
+        # could change default verbs
+        self.verbs = verbs or self.get_default_verbs()
+
         self.default_active = default_active
         self.default_verb = default_verb
         self.default_value = default_value
@@ -461,6 +466,10 @@ class AlchemyStringFilter(AlchemyGridFilter):
         """
         Expose contains / does-not-contain verbs in addition to core.
         """
+
+        if self.choices:
+            return ['equal', 'not_equal', 'is_null', 'is_not_null', 'is_any']
+
         return ['contains', 'does_not_contain',
                 'contains_any_of',
                 'equal', 'not_equal', 'equal_any_of',
diff --git a/tailbone/templates/batch/pos/view.mako b/tailbone/templates/batch/pos/view.mako
new file mode 100644
index 00000000..0da755aa
--- /dev/null
+++ b/tailbone/templates/batch/pos/view.mako
@@ -0,0 +1,13 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/batch/view.mako" />
+
+<%def name="modify_this_page_vars()">
+  ${parent.modify_this_page_vars()}
+  <script type="text/javascript">
+
+    ${form.component_studly}Data.taxesData = ${json.dumps(taxes_data)|n}
+
+  </script>
+</%def>
+
+${parent.body()}
diff --git a/tailbone/views/batch/pos.py b/tailbone/views/batch/pos.py
index 8bc70b02..00f1603f 100644
--- a/tailbone/views/batch/pos.py
+++ b/tailbone/views/batch/pos.py
@@ -26,6 +26,8 @@ Views for POS batches
 
 from rattail.db.model import POSBatch, POSBatchRow
 
+from webhelpers2.html import HTML
+
 from tailbone.views.batch import BatchMasterView
 
 
@@ -39,7 +41,11 @@ class POSBatchView(BatchMasterView):
     route_prefix = 'batch.pos'
     url_prefix = '/batch/pos'
     creatable = False
+    editable = False
     cloneable = True
+    refreshable = False
+    rows_deletable = False
+    rows_bulk_deletable = False
 
     labels = {
         'terminal_id': "Terminal ID",
@@ -66,8 +72,7 @@ class POSBatchView(BatchMasterView):
         'params',
         'rowcount',
         'sales_total',
-        'tax1_total',
-        'tax2_total',
+        'taxes',
         'tender_total',
         'balance',
         'void',
@@ -89,6 +94,7 @@ class POSBatchView(BatchMasterView):
         'quantity',
         'sales_total',
         'tender_total',
+        'tax_code',
         'user',
     ]
 
@@ -102,8 +108,7 @@ class POSBatchView(BatchMasterView):
         'txn_price',
         'quantity',
         'sales_total',
-        'tax1_total',
-        'tax2_total',
+        'tax_code',
         'tender_total',
         'tender',
         'void',
@@ -126,8 +131,6 @@ class POSBatchView(BatchMasterView):
         g.set_link('created_by')
 
         g.set_type('sales_total', 'currency')
-        g.set_type('tax1_total', 'currency')
-        g.set_type('tax2_total', 'currency')
         g.set_type('tender_total', 'currency')
 
         # executed
@@ -149,13 +152,54 @@ class POSBatchView(BatchMasterView):
         f.set_renderer('customer', self.render_customer)
 
         f.set_type('sales_total', 'currency')
-        f.set_type('tax1_total', 'currency')
-        f.set_type('tax2_total', 'currency')
         f.set_type('tender_total', 'currency')
         f.set_type('tender_total', 'currency')
 
+        f.set_renderer('taxes', self.render_taxes)
+
         f.set_renderer('balance', lambda batch, field: app.render_currency(batch.get_balance()))
 
+    def render_taxes(self, batch, field):
+        route_prefix = self.get_route_prefix()
+
+        factory = self.get_grid_factory()
+        g = factory(
+            key=f'{route_prefix}.taxes',
+            data=[],
+            columns=[
+                'code',
+                'description',
+                'rate',
+                'total',
+            ],
+        )
+
+        return HTML.literal(
+            g.render_buefy_table_element(data_prop='taxesData'))
+
+    def template_kwargs_view(self, **kwargs):
+        kwargs = super().template_kwargs_view(**kwargs)
+        app = self.get_rattail_app()
+        batch = kwargs['instance']
+
+        taxes = []
+        for btax in batch.taxes.values():
+            data = {
+                'uuid': btax.uuid,
+                'code': btax.tax_code,
+                'description': btax.tax.description,
+                'rate': app.render_percent(btax.tax_rate),
+                'total': app.render_currency(btax.tax_total),
+            }
+            taxes.append(data)
+        taxes.sort(key=lambda t: t['code'])
+        kwargs['taxes_data'] = taxes
+
+        kwargs['execute_enabled'] = False
+        kwargs['why_not_execute'] = "POS batch must be executed at POS"
+
+        return kwargs
+
     def configure_row_grid(self, g):
         super().configure_row_grid(g)
 
diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index e3a60eca..26936a71 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -861,6 +861,14 @@ class MasterView(View):
             url = self.request.route_url('stores.view', uuid=store.uuid)
             return tags.link_to(text, url)
 
+    def render_tax(self, obj, field):
+        tax = getattr(obj, field)
+        if not tax:
+            return
+        text = str(tax)
+        url = self.request.route_url('taxes.view', uuid=tax.uuid)
+        return tags.link_to(text, url)
+
     def render_tender(self, obj, field):
         tender = getattr(obj, field)
         if not tender:
diff --git a/tailbone/views/products.py b/tailbone/views/products.py
index 0ee53093..327b6366 100644
--- a/tailbone/views/products.py
+++ b/tailbone/views/products.py
@@ -366,6 +366,15 @@ class ProductView(MasterView):
         g.set_renderer('cost', self.render_cost)
         g.set_label('cost', "Unit Cost")
 
+        # tax
+        g.set_joiner('tax', lambda q: q.outerjoin(model.Tax))
+        taxes = self.Session.query(model.Tax)\
+                            .order_by(model.Tax.code)\
+                            .all()
+        taxes = OrderedDict([(tax.uuid, tax.description)
+                             for tax in taxes])
+        g.set_filter('tax', model.Tax.uuid, value_enum=taxes)
+
         # report_code_name
         g.set_joiner('report_code_name', lambda q: q.outerjoin(model.ReportCode))
         g.set_filter('report_code_name', model.ReportCode.name)
@@ -810,7 +819,7 @@ class ProductView(MasterView):
         raise self.notfound()
 
     def configure_form(self, f):
-        super(ProductView, self).configure_form(f)
+        super().configure_form(f)
         product = f.model_instance
 
         # department
@@ -934,7 +943,7 @@ class ProductView(MasterView):
                 f.set_label('tax_uuid', "Tax")
         else:
             f.set_readonly('tax')
-            # f.set_renderer('tax', self.render_tax)
+            f.set_renderer('tax', self.render_tax)
 
         # tax1/2/3
         f.set_readonly('tax1')
diff --git a/tailbone/views/taxes.py b/tailbone/views/taxes.py
index 19a385ba..b2afaeb9 100644
--- a/tailbone/views/taxes.py
+++ b/tailbone/views/taxes.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,8 +24,6 @@
 Tax Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
 from rattail.db import model
 
 from tailbone.views import MasterView
@@ -53,12 +51,26 @@ class TaxView(MasterView):
     ]
 
     def configure_grid(self, g):
-        super(TaxView, self).configure_grid(g)
-        g.filters['description'].default_active = True
-        g.filters['description'].default_verb = 'contains'
+        super().configure_grid(g)
+
+        # code
         g.set_sort_defaults('code')
         g.set_link('code')
+
+        # description
         g.set_link('description')
+        g.filters['description'].default_active = True
+        g.filters['description'].default_verb = 'contains'
+
+        # rate
+        g.set_type('rate', 'percent')
+
+    def configure_form(self, f):
+        super().configure_form(f)
+
+        # rate
+        f.set_type('rate', 'percent')
+
 
 # TODO: deprecate / remove this
 TaxesView = TaxView
diff --git a/tailbone/views/typical.py b/tailbone/views/typical.py
index ed94d552..35259a14 100644
--- a/tailbone/views/typical.py
+++ b/tailbone/views/typical.py
@@ -43,6 +43,7 @@ def defaults(config, **kwargs):
     config.include(mod('tailbone.views.reportcodes'))
     config.include(mod('tailbone.views.stores'))
     config.include(mod('tailbone.views.subdepartments'))
+    config.include(mod('tailbone.views.taxes'))
     config.include(mod('tailbone.views.tenders'))
     config.include(mod('tailbone.views.uoms'))
     config.include(mod('tailbone.views.vendors'))

From a201072a9d131e504324bc185ac24f1d1cf4f099 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sat, 7 Oct 2023 18:57:03 -0500
Subject: [PATCH 111/542] Update changelog

---
 CHANGES.rst          | 12 ++++++++++++
 tailbone/_version.py |  2 +-
 2 files changed, 13 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index aa1d68b9..07addfcc 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,18 @@
 CHANGELOG
 =========
 
+0.9.65 (2023-10-07)
+-------------------
+
+* Avoid deprecated logic for fetching vendor contact email/phone.
+
+* Add "mark complete" button for inventory batch row entry page.
+
+* Expose tender ref in POS batch rows; new tender flags.
+
+* Improve views for taxes, esp. in POS batches.
+
+
 0.9.64 (2023-10-06)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 83562798..466968d6 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.64'
+__version__ = '0.9.65'

From 4beca7af20f8b098684aca1a47ef6861d22697dd Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sat, 7 Oct 2023 20:13:41 -0500
Subject: [PATCH 112/542] Make grid JS `loadAsyncData()` method truly async

not sure what this does but it seems to work, we'll see
---
 tailbone/templates/grids/buefy.mako | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako
index 519c16d8..f0dd2c59 100644
--- a/tailbone/templates/grids/buefy.mako
+++ b/tailbone/templates/grids/buefy.mako
@@ -484,7 +484,10 @@
                       ...this.getFilterParams()}
           },
 
-          loadAsyncData(params, callback) {
+          ## 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
+          async loadAsyncData(params, callback) {
 
               if (params === undefined || params === null) {
                   params = new URLSearchParams(this.getBasicParams())

From 6d7754cf2ac7325d63158c621686ef5e158d699f Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 8 Oct 2023 14:29:01 -0500
Subject: [PATCH 113/542] Add back-end support for multi-column grid sorting

or very nearly, anyway.  front-end still just supports 1 column yet
---
 tailbone/api/master.py                 |   8 +-
 tailbone/grids/core.py                 | 285 +++++++++++++++++--------
 tailbone/templates/grids/buefy.mako    |  16 +-
 tailbone/templates/grids/complete.mako |  38 ----
 tailbone/templates/grids/grid.mako     |  21 --
 tailbone/util.py                       |  11 +
 tailbone/views/customers.py            |  30 ---
 tailbone/views/master.py               |  12 +-
 tailbone/views/members.py              |   3 +-
 9 files changed, 222 insertions(+), 202 deletions(-)
 delete mode 100644 tailbone/templates/grids/complete.mako
 delete mode 100644 tailbone/templates/grids/grid.mako

diff --git a/tailbone/api/master.py b/tailbone/api/master.py
index dabc31ff..70616484 100644
--- a/tailbone/api/master.py
+++ b/tailbone/api/master.py
@@ -33,13 +33,7 @@ from cornice import resource, Service
 
 from tailbone.api import APIView, api
 from tailbone.db import Session
-
-
-class SortColumn(object):
-
-    def __init__(self, field_name, model_name=None):
-        self.field_name = field_name
-        self.model_name = model_name
+from tailbone.util import SortColumn
 
 
 class APIMasterView(APIView):
diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py
index 6373add6..984307b3 100644
--- a/tailbone/grids/core.py
+++ b/tailbone/grids/core.py
@@ -24,12 +24,13 @@
 Core Grid Classes
 """
 
+from urllib.parse import urlencode
 import warnings
 import logging
 
-from six.moves import urllib
 import sqlalchemy as sa
 from sqlalchemy import orm
+from sa_filters import apply_sort
 
 from rattail.db.types import GPCType
 from rattail.util import prettify, pretty_boolean, pretty_quantity
@@ -552,48 +553,6 @@ class Grid(object):
             return self.url(obj)
         return self.url
 
-    def make_webhelpers_grid(self):
-        kwargs = dict(self._whgrid_kwargs)
-        kwargs['request'] = self.request
-        kwargs['url'] = self.make_url
-
-        columns = list(self.columns)
-        column_labels = kwargs.setdefault('column_labels', {})
-        column_formats = kwargs.setdefault('column_formats', {})
-
-        for key, value in self.labels.items():
-            column_labels.setdefault(key, value)
-
-        if self.checkboxes:
-            columns.insert(0, 'checkbox')
-            column_labels['checkbox'] = tags.checkbox('check-all')
-            column_formats['checkbox'] = self.checkbox_column_format
-
-        if self.renderers:
-            kwargs['renderers'] = self.renderers
-        if self.extra_row_class:
-            kwargs['extra_record_class'] = self.extra_row_class
-        if self.linked_columns:
-            kwargs['linked_columns'] = list(self.linked_columns)
-
-        if self.main_actions or self.more_actions:
-            columns.append('actions')
-            column_formats['actions'] = self.actions_column_format
-
-        # TODO: pretty sure this factory doesn't serve all use cases yet?
-        factory = CustomWebhelpersGrid
-        # factory = webhelpers2_grid.Grid
-        if self.sortable:
-            # factory = CustomWebhelpersGrid
-            kwargs['order_column'] = self.sortkey
-            kwargs['order_direction'] = 'dsc' if self.sortdir == 'desc' else 'asc'
-
-        grid = factory(self.make_visible_data(), columns, **kwargs)
-        if self.sortable:
-            grid.exclude_ordering = list([key for key in grid.exclude_ordering
-                                          if key not in self.sorters])
-        return grid
-
     def make_default_renderers(self, renderers):
         """
         Make the default set of column renderers for the grid.
@@ -638,19 +597,6 @@ class Grid(object):
     def actions_column_format(self, column_number, row_number, item):
         return HTML.td(self.render_actions(item, row_number), class_='actions')
 
-    def render_grid(self, template='/grids/grid.mako', **kwargs):
-        context = kwargs
-        context['grid'] = self
-        context['request'] = self.request
-        grid_class = ''
-        if self.width == 'full':
-            grid_class = 'full'
-        elif self.width == 'half':
-            grid_class = 'half'
-        context['grid_class'] = '{} {}'.format(grid_class, context.get('grid_class', ''))
-        context.setdefault('grid_attrs', {})
-        return render(template, context)
-
     def get_default_filters(self):
         """
         Returns the default set of filters provided by the grid.
@@ -761,6 +707,9 @@ class Grid(object):
                 return query
             return query.order_by(getattr(column, direction)())
 
+        sorter._class = class_
+        sorter._column = column
+
         return sorter
 
     def make_simple_sorter(self, key, foldcase=False):
@@ -801,8 +750,12 @@ class Grid(object):
         # initial default settings
         settings = {}
         if self.sortable:
-            settings['sortkey'] = self.default_sortkey
-            settings['sortdir'] = self.default_sortdir
+            if self.default_sortkey:
+                settings['sorters.length'] = 1
+                settings['sorters.1.key'] = self.default_sortkey
+                settings['sorters.1.dir'] = self.default_sortdir
+            else:
+                settings['sorters.length'] = 0
         if self.pageable:
             settings['pagesize'] = self.get_default_pagesize()
             settings['page'] = self.default_page
@@ -875,8 +828,12 @@ class Grid(object):
                 filtr.verb = settings['filter.{}.verb'.format(filtr.key)]
                 filtr.value = settings['filter.{}.value'.format(filtr.key)]
         if self.sortable:
-            self.sortkey = settings['sortkey']
-            self.sortdir = settings['sortdir']
+            self.active_sorters = []
+            for i in range(1, settings['sorters.length'] + 1):
+                self.active_sorters.append((
+                    settings[f'sorters.{i}.key'],
+                    settings[f'sorters.{i}.dir'],
+                ))
         if self.pageable:
             self.pagesize = settings['pagesize']
             self.page = settings['page']
@@ -895,21 +852,36 @@ class Grid(object):
         # anything...
         session = Session()
         if user not in session:
-            user = session.merge(user)
+            # TODO: pretty sure there is no need to *merge* here..
+            # but we shall see if any breakage happens maybe
+            #user = session.merge(user)
+            user = session.get(user.__class__, user.uuid)
 
-        # User defaults should have all or nothing, so just check one key.
-        key = 'tailbone.{}.grid.{}.sortkey'.format(user.uuid, self.key)
         app = self.request.rattail_config.get_app()
-        return app.get_setting(Session(), key) is not None
+
+        # user defaults should be all or nothing, so just check one key
+        key = f'tailbone.{user.uuid}.grid.{self.key}.sorters.length'
+        if app.get_setting(session, key) is not None:
+            return True
+
+        # TODO: this is deprecated but should work its way out of the
+        # system in a little while (?)..then can remove this entirely
+        key = f'tailbone.{user.uuid}.grid.{self.key}.sortkey'
+        if app.get_setting(session, key) is not None:
+            return True
+
+        return False
 
     def apply_user_defaults(self, settings):
         """
         Update the given settings dict with user defaults, if any exist.
         """
+        app = self.request.rattail_config.get_app()
+        session = Session()
+        prefix = f'tailbone.{self.request.user.uuid}.grid.{self.key}'
+
         def merge(key, normalize=lambda v: v):
-            skey = 'tailbone.{}.grid.{}.{}'.format(self.request.user.uuid, self.key, key)
-            app = self.request.rattail_config.get_app()
-            value = app.get_setting(Session(), skey)
+            value = app.get_setting(session, f'{prefix}.{key}')
             settings[key] = normalize(value)
 
         if self.filterable:
@@ -919,8 +891,52 @@ class Grid(object):
                 merge('filter.{}.value'.format(filtr.key))
 
         if self.sortable:
-            merge('sortkey')
-            merge('sortdir')
+
+            # first clear existing settings for *sorting* only
+            # nb. this is because number of sort settings will vary
+            for key in list(settings):
+                if key.startswith('sorters.'):
+                    del settings[key]
+
+            # check for *deprecated* settings, and use those if present
+            # TODO: obviously should stop this, but must wait until
+            # all old settings have been flushed out.  which in the
+            # case of user-persisted settings, could be a while...
+            sortkey = app.get_setting(session, f'{prefix}.sortkey')
+            if sortkey:
+                settings['sorters.length'] = 1
+                settings['sorters.1.key'] = sortkey
+                settings['sorters.1.dir'] = app.get_setting(session, f'{prefix}.sortdir')
+
+                # nb. re-persist these user settings per new
+                # convention, so deprecated settings go away and we
+                # can remove this logic after a while..
+                app = self.request.rattail_config.get_app()
+                model = app.model
+                prefix = f'tailbone.{self.request.user.uuid}.grid.{self.key}'
+                query = Session.query(model.Setting)\
+                               .filter(sa.or_(
+                                   model.Setting.name.like(f'{prefix}.sorters.%'),
+                                   model.Setting.name == f'{prefix}.sortkey',
+                                   model.Setting.name == f'{prefix}.sortdir'))
+                for setting in query.all():
+                    Session.delete(setting)
+                Session.flush()
+
+                def persist(key):
+                    app.save_setting(Session(),
+                                     f'tailbone.{self.request.user.uuid}.grid.{self.key}.{key}',
+                                     settings[key])
+
+                persist('sorters.length')
+                persist('sorters.1.key')
+                persist('sorters.1.dir')
+
+            else: # the future
+                merge('sorters.length', int)
+                for i in range(1, settings['sorters.length'] + 1):
+                    merge(f'sorters.{i}.key')
+                    merge(f'sorters.{i}.dir')
 
         if self.pageable:
             merge('pagesize', int)
@@ -939,10 +955,16 @@ class Grid(object):
                 return True
 
         elif type_ == 'sort':
+
+            # TODO: remove this eventually, but some links in the wild
+            # may still include these params, so leave it for now
             for key in ['sortkey', 'sortdir']:
                 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:
@@ -956,10 +978,12 @@ class Grid(object):
         """
         # session should have all or nothing, so just check a few keys which
         # should be guaranteed present if anything has been stashed
-        for key in ['page', 'sortkey']:
-            if 'grid.{}.{}'.format(self.key, key) in self.request.session:
+        prefix = f'grid.{self.key}'
+        for key in ['page', 'sorters.length']:
+            if f'{prefix}.{key}' in self.request.session:
                 return True
-        return any([key.startswith('grid.{}.filter'.format(self.key)) for key in self.request.session])
+        return any([key.startswith(f'{prefix}.filter')
+                    for key in self.request.session])
 
     def get_setting(self, source, settings, key, normalize=lambda v: v, default=None):
         """
@@ -1044,8 +1068,46 @@ class Grid(object):
         """
         if not self.sortable:
             return
-        settings['sortkey'] = self.get_setting(source, settings, 'sortkey')
-        settings['sortdir'] = self.get_setting(source, settings, 'sortdir')
+
+        if source == 'request':
+
+            # TODO: remove this eventually, but some links in the wild
+            # may still include these params, so leave it for now
+            if 'sortkey' in self.request.GET:
+                settings['sorters.length'] = 1
+                settings['sorters.1.key'] = self.get_setting(source, settings, 'sortkey')
+                settings['sorters.1.dir'] = self.get_setting(source, settings, 'sortdir')
+
+            else: # the future
+                i = 1
+                while True:
+                    skey = f'sort{i}key'
+                    if skey in self.request.GET:
+                        settings[f'sorters.{i}.key'] = self.get_setting(source, settings, skey)
+                        settings[f'sorters.{i}.dir'] = self.get_setting(source, settings, f'sort{i}dir')
+                    else:
+                        break
+                    i += 1
+                settings['sorters.length'] = i - 1
+
+        else: # session
+
+            # TODO: definitely will remove this, but leave it for now
+            # so it doesn't monkey with current user sessions when
+            # next upgrade happens.  so, remove after all are upgraded
+            sortkey = self.get_setting(source, settings, 'sortkey')
+            if sortkey:
+                settings['sorters.length'] = 1
+                settings['sorters.1.key'] = sortkey
+                settings['sorters.1.dir'] = self.get_setting(source, settings, 'sortdir')
+
+            else: # the future
+                settings['sorters.length'] = self.get_setting(source, settings,
+                                                              'sorters.length', int)
+                for i in range(1, settings['sorters.length'] + 1):
+                    for key in ('key', 'dir'):
+                        skey = f'sorters.{i}.{key}'
+                        settings[skey] = self.get_setting(source, settings, skey)
 
     def update_page_settings(self, settings):
         """
@@ -1100,8 +1162,40 @@ class Grid(object):
                 persist('filter.{}.value'.format(filtr.key))
 
         if self.sortable:
-            persist('sortkey')
-            persist('sortdir')
+
+            # first clear existing settings for *sorting* only
+            # nb. this is because number of sort settings will vary
+            if to == 'defaults':
+                model = self.request.rattail_config.get_model()
+                prefix = f'tailbone.{self.request.user.uuid}.grid.{self.key}'
+                query = Session.query(model.Setting)\
+                               .filter(sa.or_(
+                                   model.Setting.name.like(f'{prefix}.sorters.%'),
+                                   # TODO: remove these eventually,
+                                   # but probably should wait until
+                                   # all nodes have been upgraded for
+                                   # (quite) a while?
+                                   model.Setting.name == f'{prefix}.sortkey',
+                                   model.Setting.name == f'{prefix}.sortdir'))
+                for setting in query.all():
+                    Session.delete(setting)
+                Session.flush()
+            else: # session
+                prefix = f'grid.{self.key}'
+                for key in list(self.request.session):
+                    if key.startswith(f'{prefix}.sorters.'):
+                        del self.request.session[key]
+                # TODO: definitely will remove these, but leave for
+                # now so they don't monkey with current user sessions
+                # when next upgrade happens.  so, remove after all are
+                # upgraded
+                self.request.session.pop(f'{prefix}.sortkey', None)
+                self.request.session.pop(f'{prefix}.sortdir', None)
+
+            persist('sorters.length')
+            for i in range(1, settings['sorters.length'] + 1):
+                persist(f'sorters.{i}.key')
+                persist(f'sorters.{i}.dir')
 
         if self.pageable:
             persist('pagesize')
@@ -1131,21 +1225,32 @@ class Grid(object):
         """
         Sort the given query according to current settings, and return the result.
         """
-        # Cannot sort unless we know which column to sort by.
-        if not self.sortkey:
+        # bail if no sort settings
+        if not self.active_sorters:
             return data
 
-        # Cannot sort unless we have a sort function.
-        sortfunc = self.sorters.get(self.sortkey)
-        if not sortfunc:
-            return data
+        # convert sort settings into a 'sortspec' for use with sa-filters
+        full_spec = []
+        for sortkey, sortdir in self.active_sorters:
+            sortfunc = self.sorters.get(sortkey)
+            if sortfunc:
+                spec = {
+                    'sortkey': sortkey,
+                    'model': sortfunc._class.__name__,
+                    'field': sortfunc._column.name,
+                    'direction': sortdir or 'asc',
+                }
+                # spec.sortkey = sortkey
+                full_spec.append(spec)
 
-        # We can provide a default sort direction though.
-        sortdir = getattr(self, 'sortdir', 'asc')
-        if self.sortkey in self.joiners and self.sortkey not in self.joined:
-            data = self.joiners[self.sortkey](data)
-            self.joined.add(self.sortkey)
-        return sortfunc(data, sortdir)
+        # apply joins needed for this sort spec
+        for spec in full_spec:
+            sortkey = spec['sortkey']
+            if sortkey in self.joiners and sortkey not in self.joined:
+                data = self.joiners[sortkey](data)
+                self.joined.add(sortkey)
+
+        return apply_sort(data, full_spec)
 
     def paginate_data(self, data):
         """
@@ -1197,7 +1302,7 @@ class Grid(object):
             data = self.pager
         return data
 
-    def render_complete(self, template='/grids/complete.mako', **kwargs):
+    def render_complete(self, template='/grids/buefy.mako', **kwargs):
         """
         Render the complete grid, including filters.
         """
@@ -1717,5 +1822,5 @@ class URLMaker(object):
         params = self.request.GET.copy()
         params["page"] = page
         params["partial"] = "1"
-        qs = urllib.parse.urlencode(params, True)
+        qs = urlencode(params, True)
         return '{}?{}'.format(self.request.path, qs)
diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako
index f0dd2c59..1203b9de 100644
--- a/tailbone/templates/grids/buefy.mako
+++ b/tailbone/templates/grids/buefy.mako
@@ -202,7 +202,7 @@
        % endif
 
        % if grid.sortable:
-       :default-sort="[sortField, sortOrder]"
+       :default-sort="sortingPriority[0]"
        backend-sorting
        @sort="onSort"
        % endif
@@ -352,8 +352,9 @@
       firstItem: ${json.dumps(grid_data['first_item'] if grid.pageable else None)|n},
       lastItem: ${json.dumps(grid_data['last_item'] if grid.pageable else None)|n},
 
-      sortField: ${json.dumps(grid.sortkey if grid.sortable else None)|n},
-      sortOrder: ${json.dumps(grid.sortdir if grid.sortable else None)|n},
+      % if grid.sortable:
+          sortingPriority: ${json.dumps(grid.active_sorters)|n},
+      % endif
 
       ## filterable: ${json.dumps(grid.filterable)|n},
       filters: ${json.dumps(filters_data if grid.filterable else None)|n},
@@ -454,8 +455,10 @@
           getBasicParams() {
               let params = {}
               % if grid.sortable:
-                  params.sortkey = this.sortField
-                  params.sortdir = this.sortOrder
+                  for (let i = 1; i <= this.sortingPriority.length; i++) {
+                      params['sort'+i+'key'] = this.sortingPriority[i-1][0]
+                      params['sort'+i+'dir'] = this.sortingPriority[i-1][1]
+                  }
               % endif
               % if grid.pageable:
                   params.pagesize = this.perPage
@@ -535,8 +538,7 @@
           },
 
           onSort(field, order) {
-              this.sortField = field
-              this.sortOrder = order
+              this.sortingPriority = [[field, order]]
               // always reset to first page when changing sort options
               // TODO: i mean..right? would we ever not want that?
               this.currentPage = 1
diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako
deleted file mode 100644
index 169264c4..00000000
--- a/tailbone/templates/grids/complete.mako
+++ /dev/null
@@ -1,38 +0,0 @@
-## -*- coding: utf-8 -*-
-<div class="grid-wrapper">
-
-  <table class="grid-header">
-    <tbody>
-      <tr>
-
-        <td class="filters" rowspan="2">
-          % if grid.filterable:
-              ${grid.render_filters(allow_save_defaults=allow_save_defaults)|n}
-          % endif
-        </td>
-
-        <td class="menu">
-          % if context_menu:
-              <ul id="context-menu">
-                ${context_menu|n}
-              </ul>
-          % endif
-        </td>
-      </tr>
-
-      <tr>
-        <td class="tools">
-          % if tools:
-              <div class="grid-tools">
-                ${tools|n}
-              </div><!-- grid-tools -->
-          % endif
-        </td>
-      </tr>
-
-    </tbody>
-  </table><!-- grid-header -->
-
-  ${grid.render_grid()|n}
-
-</div><!-- grid-wrapper -->
diff --git a/tailbone/templates/grids/grid.mako b/tailbone/templates/grids/grid.mako
deleted file mode 100644
index 146fcab6..00000000
--- a/tailbone/templates/grids/grid.mako
+++ /dev/null
@@ -1,21 +0,0 @@
-## -*- coding: utf-8; -*-
-<div class="grid ${grid_class}" data-delete-speedbump="${'true' if grid.delete_speedbump else 'false'}" ${h.HTML.render_attrs(grid_attrs)}>
-  <table>
-    ${grid.make_webhelpers_grid()}
-  </table>
-  % if grid.pageable and grid.pager:
-      <div class="pager">
-        <p class="showing">
-          ${"showing {} thru {} of {:,d}".format(grid.pager.first_item, grid.pager.last_item, grid.pager.item_count)}
-          % if grid.pager.page_count > 1:
-              ${"(page {} of {:,d})".format(grid.pager.page, grid.pager.page_count)}
-          % endif
-        </p>
-        <p class="page-links">
-          ${h.select('pagesize', grid.pager.items_per_page, grid.get_pagesize_options())}
-          per page&nbsp;
-          ${grid.pager.pager('$link_first $link_previous ~1~ $link_next $link_last', symbol_next='next', symbol_previous='prev')|n}
-        </p>
-      </div>
-  % endif
-</div>
diff --git a/tailbone/util.py b/tailbone/util.py
index 7015ad49..4c9c680e 100644
--- a/tailbone/util.py
+++ b/tailbone/util.py
@@ -44,6 +44,17 @@ from webhelpers2.html import HTML, tags
 log = logging.getLogger(__name__)
 
 
+class SortColumn(object):
+    """
+    Generic representation of a sort column, for use with sorting grid
+    data as well as with API.
+    """
+
+    def __init__(self, field_name, model_name=None):
+        self.field_name = field_name
+        self.model_name = model_name
+
+
 def get_csrf_token(request):
     """
     Convenience function to retrieve the effective CSRF token for the given
diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py
index 0860fc31..74f66458 100644
--- a/tailbone/views/customers.py
+++ b/tailbone/views/customers.py
@@ -476,36 +476,6 @@ class CustomerView(MasterView):
             items.append(HTML.tag('li', c=[link]))
         return HTML.tag('ul', c=items)
 
-    # TODO: remove if no longer used
-    def render_people_removable(self, customer, field):
-        people = customer.people
-        if not people:
-            return ""
-
-        route_prefix = self.get_route_prefix()
-        permission_prefix = self.get_permission_prefix()
-
-        view_url = lambda p, i: self.request.route_url('people.view', uuid=p.uuid)
-        actions = [
-            grids.GridAction('view', icon='zoomin', url=view_url),
-        ]
-        if self.people_detachable and self.request.has_perm('{}.detach_person'.format(permission_prefix)):
-            url = lambda p, i: self.request.route_url('{}.detach_person'.format(route_prefix),
-                                                      uuid=customer.uuid, person_uuid=p.uuid)
-            actions.append(
-                grids.GridAction('detach', icon='trash', url=url))
-
-        columns = ['first_name', 'last_name', 'display_name']
-        g = grids.Grid(
-            key='{}.people'.format(route_prefix),
-            data=customer.people,
-            columns=columns,
-            labels={'display_name': "Full Name"},
-            url=lambda p: self.request.route_url('people.view', uuid=p.uuid),
-            linked_columns=columns,
-            main_actions=actions)
-        return HTML.literal(g.render_grid())
-
     def render_shoppers(self, customer, field):
         route_prefix = self.get_route_prefix()
         permission_prefix = self.get_permission_prefix()
diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index 26936a71..ac68a02f 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -340,11 +340,9 @@ class MasterView(View):
         if grid.pageable and hasattr(grid, 'pager'):
             self.first_visible_grid_index = grid.pager.first_item
 
-        # return grid only, if partial page was requested
+        # return grid data only, if partial page was requested
         if self.request.params.get('partial'):
-            # render grid data only, as JSON
-            return render_to_response('json', grid.get_buefy_data(),
-                                      request=self.request)
+            return self.json_response(grid.get_buefy_data())
 
         context = {
             'grid': grid,
@@ -1156,8 +1154,7 @@ class MasterView(View):
             # return grid only, if partial page was requested
             if self.request.params.get('partial'):
                 # render grid data only, as JSON
-                return render_to_response('json', grid.get_buefy_data(),
-                                          request=self.request)
+                return self.json_response(grid.get_buefy_data())
 
         context = {
             'instance': instance,
@@ -1284,8 +1281,7 @@ class MasterView(View):
         # return grid only, if partial page was requested
         if self.request.params.get('partial'):
             # render grid data only, as JSON
-            return render_to_response('json', grid.get_buefy_data(),
-                                      request=self.request)
+            return self.json_response(grid.get_buefy_data())
 
         return self.render_to_response('versions', {
             'instance': instance,
diff --git a/tailbone/views/members.py b/tailbone/views/members.py
index 1b3735bd..74b15512 100644
--- a/tailbone/views/members.py
+++ b/tailbone/views/members.py
@@ -461,7 +461,8 @@ class MemberEquityPaymentView(MasterView):
         g.set_renderer(field, self.render_member_key)
         g.set_filter(field, attr,
                      label=self.get_member_key_label(),
-                     default_active=True)
+                     default_active=True,
+                     default_verb='equal')
         g.set_sorter(field, attr)
 
         # member (name)

From edb5393cdc4f64b830548cd180d59b69ea408c27 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 8 Oct 2023 16:38:13 -0500
Subject: [PATCH 114/542] Add front-end support for multi-column grid sorting

user must ctrl-click column header to engage multi-sort
---
 tailbone/grids/core.py              | 66 ++++++++++++++-------
 tailbone/templates/grids/buefy.mako | 92 ++++++++++++++++++++++++++---
 2 files changed, 128 insertions(+), 30 deletions(-)

diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py
index 984307b3..e42f8714 100644
--- a/tailbone/grids/core.py
+++ b/tailbone/grids/core.py
@@ -830,10 +830,10 @@ class Grid(object):
         if self.sortable:
             self.active_sorters = []
             for i in range(1, settings['sorters.length'] + 1):
-                self.active_sorters.append((
-                    settings[f'sorters.{i}.key'],
-                    settings[f'sorters.{i}.dir'],
-                ))
+                self.active_sorters.append({
+                    'field': settings[f'sorters.{i}.key'],
+                    'order': settings[f'sorters.{i}.dir'],
+                })
         if self.pageable:
             self.pagesize = settings['pagesize']
             self.page = settings['page']
@@ -1229,28 +1229,52 @@ class Grid(object):
         if not self.active_sorters:
             return data
 
-        # convert sort settings into a 'sortspec' for use with sa-filters
-        full_spec = []
-        for sortkey, sortdir in self.active_sorters:
-            sortfunc = self.sorters.get(sortkey)
-            if sortfunc:
-                spec = {
-                    'sortkey': sortkey,
-                    'model': sortfunc._class.__name__,
-                    'field': sortfunc._column.name,
-                    'direction': sortdir or 'asc',
-                }
-                # spec.sortkey = sortkey
-                full_spec.append(spec)
+        # TODO: is there a better way to check for SA sorting?
+        if self.model_class:
 
-        # apply joins needed for this sort spec
-        for spec in full_spec:
-            sortkey = spec['sortkey']
+            # convert sort settings into a 'sortspec' for use with sa-filters
+            full_spec = []
+            for sorter in self.active_sorters:
+                sortkey = sorter['field']
+                sortdir = sorter['order']
+                sortfunc = self.sorters.get(sortkey)
+                if sortfunc:
+                    spec = {
+                        'sortkey': sortkey,
+                        'model': sortfunc._class.__name__,
+                        'field': sortfunc._column.name,
+                        'direction': sortdir or 'asc',
+                    }
+                    full_spec.append(spec)
+
+            # apply joins needed for this sort spec
+            for spec in full_spec:
+                sortkey = spec['sortkey']
+                if sortkey in self.joiners and sortkey not in self.joined:
+                    data = self.joiners[sortkey](data)
+                    self.joined.add(sortkey)
+
+            return apply_sort(data, full_spec)
+
+        else:
+            # not a SQLAlchemy grid, custom sorter
+
+            assert len(self.active_sorters) < 2
+
+            sortkey = self.active_sorters[0]['field']
+            sortdir = self.active_sorters[0]['order'] or 'asc'
+
+            # Cannot sort unless we have a sort function.
+            sortfunc = self.sorters.get(sortkey)
+            if not sortfunc:
+                return data
+
+            # apply joins needed for this sorter
             if sortkey in self.joiners and sortkey not in self.joined:
                 data = self.joiners[sortkey](data)
                 self.joined.add(sortkey)
 
-        return apply_sort(data, full_spec)
+            return sortfunc(data, sortdir)
 
     def paginate_data(self, data):
         """
diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako
index 1203b9de..5b21b42a 100644
--- a/tailbone/templates/grids/buefy.mako
+++ b/tailbone/templates/grids/buefy.mako
@@ -202,9 +202,25 @@
        % endif
 
        % if grid.sortable:
-       :default-sort="sortingPriority[0]"
-       backend-sorting
-       @sort="onSort"
+           backend-sorting
+           @sort="onSort"
+           @sorting-priority-removed="sortingPriorityRemoved"
+
+           ## TODO: there is a bug (?) which prevents the arrow from
+           ## displaying for simple default single-column sort.  so to
+           ## work around that, we *disable* multi-sort until the
+           ## component is mounted.  seems to work for now..see also
+           ## https://github.com/buefy/buefy/issues/2584
+           :sort-multiple="allowMultiSort"
+
+           ## nb. specify default sort only if single-column
+           :default-sort="backendSorters.length == 1 ? [backendSorters[0].field, backendSorters[0].order] : null"
+
+           ## nb. otherwise there may be default multi-column sort
+           :sort-multiple-data="sortingPriority"
+
+           ## user must ctrl-click column header to do multi-sort
+           sort-multiple-key="ctrlKey"
        % endif
 
        % if grid.click_handlers:
@@ -353,7 +369,25 @@
       lastItem: ${json.dumps(grid_data['last_item'] if grid.pageable else None)|n},
 
       % if grid.sortable:
-          sortingPriority: ${json.dumps(grid.active_sorters)|n},
+
+          ## TODO: there is a bug (?) which prevents the arrow from
+          ## displaying for simple default single-column sort.  so to
+          ## work around that, we *disable* multi-sort until the
+          ## component is mounted.  seems to work for now..see also
+          ## https://github.com/buefy/buefy/issues/2584
+          allowMultiSort: false,
+
+          ## nb. this contains all truly active sorters
+          backendSorters: ${json.dumps(grid.active_sorters)|n},
+
+          ## nb. whereas this will only contain multi-column sorters,
+          ## but will be *empty* for single-column sorting
+          % if len(grid.active_sorters) > 1:
+              sortingPriority: ${json.dumps(grid.active_sorters)|n},
+          % else:
+              sortingPriority: [],
+          % endif
+
       % endif
 
       ## filterable: ${json.dumps(grid.filterable)|n},
@@ -395,6 +429,15 @@
           },
       },
 
+      mounted() {
+          ## TODO: there is a bug (?) which prevents the arrow from
+          ## displaying for simple default single-column sort.  so to
+          ## work around that, we *disable* multi-sort until the
+          ## component is mounted.  seems to work for now..see also
+          ## https://github.com/buefy/buefy/issues/2584
+          this.allowMultiSort = true
+      },
+
       methods: {
 
           % if grid.click_handlers:
@@ -455,9 +498,9 @@
           getBasicParams() {
               let params = {}
               % if grid.sortable:
-                  for (let i = 1; i <= this.sortingPriority.length; i++) {
-                      params['sort'+i+'key'] = this.sortingPriority[i-1][0]
-                      params['sort'+i+'dir'] = this.sortingPriority[i-1][1]
+                  for (let i = 1; i <= this.backendSorters.length; i++) {
+                      params['sort'+i+'key'] = this.backendSorters[i-1].field
+                      params['sort'+i+'dir'] = this.backendSorters[i-1].order
                   }
               % endif
               % if grid.pageable:
@@ -537,14 +580,45 @@
               this.loadAsyncData()
           },
 
-          onSort(field, order) {
-              this.sortingPriority = [[field, order]]
+          onSort(field, order, event) {
+
+              if (event.ctrlKey) {
+
+                  // engage or enhance multi-column sorting
+                  let sorter = this.backendSorters.filter(i => i.field === field)[0]
+                  if (sorter) {
+                      sorter.order = sorter.order === 'desc' ? 'asc' : 'desc'
+                  } else {
+                      this.backendSorters.push({field, order})
+                  }
+                  this.sortingPriority = this.backendSorters
+
+              } else {
+
+                  // sort by single column only
+                  this.backendSorters = [{field, order}]
+                  this.sortingPriority = []
+              }
+
               // always reset to first page when changing sort options
               // TODO: i mean..right? would we ever not want that?
               this.currentPage = 1
               this.loadAsyncData()
           },
 
+          sortingPriorityRemoved(field) {
+
+              // prune field from active sorters
+              this.backendSorters = this.backendSorters.filter(
+                  (sorter) => sorter.field !== field)
+
+              // nb. must keep active sorter list "as-is" even if
+              // there is only one sorter; buefy seems to expect it
+              this.sortingPriority = this.backendSorters
+
+              this.loadAsyncData()
+          },
+
           resetView() {
               this.loading = true
 

From 9efe767654db3bffb03d9391c5e2a826e021b208 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 9 Oct 2023 00:19:29 -0500
Subject: [PATCH 115/542] Add smarts to show display text for some version diff
 fields

e.g. show `str(customer)` along with `customer_uuid` since almost
nobody will "care" about the uuid so much, they just want the name
---
 tailbone/diffs.py                             | 85 ++++++++++++++++++-
 tailbone/templates/diff.mako                  |  2 +-
 tailbone/templates/master/view_version.mako   | 69 ++-------------
 .../templates/people/view_profile_buefy.mako  |  4 +-
 tailbone/views/master.py                      | 21 ++++-
 tailbone/views/people.py                      | 32 ++-----
 6 files changed, 118 insertions(+), 95 deletions(-)

diff --git a/tailbone/diffs.py b/tailbone/diffs.py
index d57aa9ac..431c2efe 100644
--- a/tailbone/diffs.py
+++ b/tailbone/diffs.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2019 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,7 +24,8 @@
 Tools for displaying data diffs
 """
 
-from __future__ import unicode_literals, absolute_import
+import sqlalchemy as sa
+import sqlalchemy_continuum as continuum
 
 from pyramid.renderers import render
 from webhelpers2.html import HTML
@@ -36,7 +37,7 @@ class Diff(object):
     """
 
     def __init__(self, old_data, new_data, columns=None, fields=None,
-                 render_field=None, render_value=None,
+                 render_field=None, render_value=None, nature='dirty',
                  monospace=False, extra_row_attrs=None):
         """
         Constructor.  You must provide the old and new data sets, and
@@ -64,6 +65,7 @@ class Diff(object):
         self.fields = fields or self.make_fields()
         self._render_field = render_field or self.render_field_default
         self.render_value = render_value or self.render_value_default
+        self.nature = nature
         self.monospace = monospace
         self.extra_row_attrs = extra_row_attrs
 
@@ -126,3 +128,80 @@ class Diff(object):
     def render_new_value(self, field):
         value = self.new_value(field)
         return self.render_value(field, value)
+
+
+class VersionDiff(Diff):
+    """
+    Special diff class, for use with version history views
+    """
+
+    def __init__(self, version, *args, **kwargs):
+        self.title = kwargs.pop('title', None)
+
+        if 'nature' not in kwargs:
+            if version.previous and version.operation_type == continuum.Operation.DELETE:
+                kwargs['nature'] = 'deleted'
+            elif version.previous:
+                kwargs['nature'] = 'dirty'
+            else:
+                kwargs['nature'] = 'new'
+
+        super().__init__(*args, **kwargs)
+
+        self.version = version
+        self.mapper = sa.inspect(continuum.parent_class(type(self.version)))
+
+    def render_version_value(self, field, value, version):
+        text = HTML.tag('span', c=[repr(value)],
+                        style='font-family: monospace;')
+
+        for prop in self.mapper.relationships:
+            if prop.uselist:
+                continue
+
+            for col in prop.local_columns:
+                if col.name != field:
+                    continue
+
+                if not hasattr(version, prop.key):
+                    continue
+
+                if col in self.mapper.primary_key:
+                    continue
+
+                ref = getattr(version, prop.key)
+                if ref:
+                    ref = ref.version_parent
+                    if ref:
+                        return HTML.tag('span', c=[
+                            text,
+                            HTML.tag('span', c=[str(ref)],
+                                     style='margin-left: 2rem; font-style: italic; font-weight: bold;'),
+                        ])
+
+        return text
+
+    def render_old_value(self, field):
+        if self.nature == 'new':
+            return ''
+        value = self.old_value(field)
+        return self.render_version_value(field, value, self.version.previous)
+
+    def render_new_value(self, field):
+        if self.nature == 'deleted':
+            return ''
+        value = self.new_value(field)
+        return self.render_version_value(field, value, self.version)
+
+    def as_struct(self):
+        values = {}
+        for field in self.fields:
+            values[field] = {'before': self.render_old_value(field),
+                             'after': self.render_new_value(field)}
+        return {
+            'key': id(self.version),
+            'model_title': self.title,
+            'diff_class': self.nature,
+            'fields': self.fields,
+            'values': values,
+        }
diff --git a/tailbone/templates/diff.mako b/tailbone/templates/diff.mako
index 3e5ec99e..a78bd770 100644
--- a/tailbone/templates/diff.mako
+++ b/tailbone/templates/diff.mako
@@ -1,5 +1,5 @@
 ## -*- coding: utf-8; -*-
-<table class="diff dirty${' monospace' if diff.monospace else ''}">
+<table class="diff ${diff.nature} ${' monospace' if diff.monospace else ''}">
   <thead>
     <tr>
       % for column in diff.columns:
diff --git a/tailbone/templates/master/view_version.mako b/tailbone/templates/master/view_version.mako
index 5dbcd15d..d29a3496 100644
--- a/tailbone/templates/master/view_version.mako
+++ b/tailbone/templates/master/view_version.mako
@@ -50,71 +50,12 @@
 </div><!-- form-wrapper -->
 
 <div class="versions-wrapper">
-% for version in versions:
-
-    <h2>${title_for_version(version)}</h2>
-
-    % if version.previous and version.operation_type == continuum.Operation.DELETE:
-        <table class="diff monospace deleted">
-          <thead>
-            <tr>
-              <th>field name</th>
-              <th>old value</th>
-              <th>new value</th>
-            </tr>
-          </thead>
-          <tbody>
-            % for field in fields_for_version(version):
-               <tr>
-                 <td class="field">${field}</td>
-                 <td class="value old-value">${render_old_value(version, field)}</td>
-                 <td class="value new-value">&nbsp;</td>
-               </tr>
-            % endfor
-          </tbody>
-        </table>
-    % elif version.previous:
-        <table class="diff monospace dirty">
-          <thead>
-            <tr>
-              <th>field name</th>
-              <th>old value</th>
-              <th>new value</th>
-            </tr>
-          </thead>
-          <tbody>
-            % for field in fields_for_version(version):
-               <tr${' class="diff"' if getattr(version, field) != getattr(version.previous, field) else ''|n}>
-                 <td class="field">${field}</td>
-                 <td class="value old-value">${render_old_value(version, field)}</td>
-                 <td class="value new-value">${render_new_value(version, field, 'dirty')}</td>
-               </tr>
-            % endfor
-          </tbody>
-        </table>
-    % else:
-        <table class="diff monospace new">
-          <thead>
-            <tr>
-              <th>field name</th>
-              <th>old value</th>
-              <th>new value</th>
-            </tr>
-          </thead>
-          <tbody>
-            % for field in fields_for_version(version):
-               <tr>
-                 <td class="field">${field}</td>
-                 <td class="value old-value">&nbsp;</td>
-                 <td class="value new-value">${render_new_value(version, field, 'new')}</td>
-               </tr>
-            % endfor
-          </tbody>
-        </table>
-    % endif
-
-% endfor
+  % for diff in version_diffs:
+      <h2>${diff.title}</h2>
+      ${diff.render_html()}
+  % endfor
 </div>
+
 </%def>
 
 
diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako
index 5574088e..4b1e089c 100644
--- a/tailbone/templates/people/view_profile_buefy.mako
+++ b/tailbone/templates/people/view_profile_buefy.mako
@@ -1456,8 +1456,8 @@
                           :class="{diff: version.values[field].after != version.values[field].before}"
                           v-show="revisionShowAllFields || version.values[field].after != version.values[field].before">
                         <td class="field">{{ field }}</td>
-                        <td class="old-value">{{ version.values[field].before }}</td>
-                        <td class="new-value">{{ version.values[field].after }}</td>
+                        <td class="old-value" v-html="version.values[field].before"></td>
+                        <td class="new-value" v-html="version.values[field].after"></td>
                       </tr>
                     </tbody>
                   </table>
diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index ac68a02f..167bdace 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -1361,6 +1361,20 @@ class MasterView(View):
         if newer:
             next_url = self.request.route_url('{}.version'.format(route_prefix), uuid=instance.uuid, txnid=newer.id)
 
+        version_diffs = []
+        versions = self.get_relevant_versions(transaction, instance)
+        for version in versions:
+
+            old_data = {}
+            new_data = {}
+            fields = self.fields_for_version(version)
+            for field in fields:
+                if version.previous:
+                    old_data[field] = getattr(version.previous, field)
+                new_data[field] = getattr(version, field)
+            diff = self.make_version_diff(version, old_data, new_data, fields=fields)
+            version_diffs.append(diff)
+
         return self.render_to_response('view_version', {
             'instance': instance,
             'instance_title': "{} (history)".format(instance_title),
@@ -1368,7 +1382,7 @@ class MasterView(View):
             'instance_url': self.get_action_url('versions', instance),
             'transaction': transaction,
             'changed': localtime(self.rattail_config, transaction.issued_at, from_utc=True),
-            'versions': self.get_relevant_versions(transaction, instance),
+            'version_diffs': version_diffs,
             'show_prev_next': True,
             'prev_url': prev_url,
             'next_url': next_url,
@@ -4815,6 +4829,11 @@ class MasterView(View):
     def make_diff(self, old_data, new_data, **kwargs):
         return diffs.Diff(old_data, new_data, **kwargs)
 
+    def make_version_diff(self, version, old_data, new_data, **kwargs):
+        if 'title' not in kwargs:
+            kwargs['title'] = self.title_for_version(version)
+        return diffs.VersionDiff(version, old_data, new_data, **kwargs)
+
     ##############################
     # Configuration Views
     ##############################
diff --git a/tailbone/views/people.py b/tailbone/views/people.py
index 0aaf4c26..31760d2a 100644
--- a/tailbone/views/people.py
+++ b/tailbone/views/people.py
@@ -1398,25 +1398,15 @@ class PersonView(MasterView):
         # also organize final transaction/versions (diff) map
         vmap = {}
         for version in versions:
-
-            if version.previous and version.operation_type == continuum.Operation.DELETE:
-                diff_class = 'deleted'
-            elif version.previous:
-                diff_class = 'dirty'
-            else:
-                diff_class = 'new'
-
-            # collect before/after field values for version
             fields = self.fields_for_version(version)
-            values = {}
+
+            old_data = {}
+            new_data = {}
             for field in fields:
-                before = ''
-                after = ''
-                if diff_class != 'new':
-                    before = repr(getattr(version.previous, field))
-                if diff_class != 'deleted':
-                    after = repr(getattr(version, field))
-                values[field] = {'before': before, 'after': after}
+                if version.previous:
+                    old_data[field] = getattr(version.previous, field)
+                new_data[field] = getattr(version, field)
+            diff = self.make_version_diff(version, old_data, new_data, fields=fields)
 
             if version.transaction_id not in vmap:
                 txn = version.transaction
@@ -1439,13 +1429,7 @@ class PersonView(MasterView):
                     'versions': [],
                 }
 
-            vmap[version.transaction_id]['versions'].append({
-                'key': id(version),
-                'model_title': self.title_for_version(version),
-                'diff_class': diff_class,
-                'fields': fields,
-                'values': values,
-            })
+            vmap[version.transaction_id]['versions'].append(diff.as_struct())
 
         return {'data': data, 'vmap': vmap}
 

From 44112a3a4b5d2a13c559752fb7dd71d9be836713 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 9 Oct 2023 15:50:41 -0500
Subject: [PATCH 116/542] Allow null for FalafelDateTime form fields

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

diff --git a/tailbone/forms/types.py b/tailbone/forms/types.py
index 3e4952e4..ac7f2d43 100644
--- a/tailbone/forms/types.py
+++ b/tailbone/forms/types.py
@@ -87,7 +87,7 @@ class FalafelDateTime(colander.DateTime):
 
     def serialize(self, node, appstruct):
         if not appstruct:
-            return colander.null
+            return {}
 
         # cant use isinstance; dt subs date
         if type(appstruct) is datetime.date:
@@ -111,6 +111,9 @@ class FalafelDateTime(colander.DateTime):
         if not cstruct:
             return colander.null
 
+        if not cstruct['date'] and not cstruct['time']:
+            return colander.null
+
         try:
             date = datetime.datetime.strptime(cstruct['date'], '%Y-%m-%d').date()
         except:

From 4328b9e38510655a8d14f85ed82e4c28e8d9e804 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 10 Oct 2023 10:54:16 -0500
Subject: [PATCH 117/542] Show full version history within the "view" page

avoid full page loads when navigating version history
---
 tailbone/diffs.py                           |  28 ++-
 tailbone/grids/core.py                      |  12 +-
 tailbone/static/css/layout.css              |  13 +-
 tailbone/templates/base.mako                | 157 ++++++------
 tailbone/templates/grids/buefy.mako         |  19 +-
 tailbone/templates/master/edit.mako         |   3 +-
 tailbone/templates/master/view.mako         | 255 ++++++++++++++++++--
 tailbone/templates/master/view_version.mako |   7 +-
 tailbone/views/master.py                    | 134 +++++++++-
 9 files changed, 498 insertions(+), 130 deletions(-)

diff --git a/tailbone/diffs.py b/tailbone/diffs.py
index 431c2efe..1c73635a 100644
--- a/tailbone/diffs.py
+++ b/tailbone/diffs.py
@@ -136,6 +136,9 @@ class VersionDiff(Diff):
     """
 
     def __init__(self, version, *args, **kwargs):
+        self.version = version
+        self.mapper = sa.inspect(continuum.parent_class(type(self.version)))
+        self.version_mapper = sa.inspect(type(self.version))
         self.title = kwargs.pop('title', None)
 
         if 'nature' not in kwargs:
@@ -146,10 +149,31 @@ class VersionDiff(Diff):
             else:
                 kwargs['nature'] = 'new'
 
+        if 'fields' not in kwargs:
+            kwargs['fields'] = self.get_default_fields()
+
+        if not args:
+            old_data = {}
+            new_data = {}
+            for field in kwargs['fields']:
+                if version.previous:
+                    old_data[field] = getattr(version.previous, field)
+                new_data[field] = getattr(version, field)
+            args = (old_data, new_data)
+
         super().__init__(*args, **kwargs)
 
-        self.version = version
-        self.mapper = sa.inspect(continuum.parent_class(type(self.version)))
+    def get_default_fields(self):
+        fields = sorted(self.version_mapper.columns.keys())
+
+        unwanted = [
+            'transaction_id',
+            'end_transaction_id',
+            'operation_type',
+        ]
+
+        return [field for field in fields
+                if field not in unwanted]
 
     def render_version_value(self, field, value, version):
         text = HTML.tag('span', c=[repr(value)],
diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py
index e42f8714..dc1a5af0 100644
--- a/tailbone/grids/core.py
+++ b/tailbone/grids/core.py
@@ -1334,6 +1334,7 @@ class Grid(object):
         context['grid'] = self
         context['request'] = self.request
         context.setdefault('allow_save_defaults', True)
+        context.setdefault('view_click_handler', self.get_view_click_handler())
         return render(template, context)
 
     def render_buefy(self, template='/grids/buefy.mako', **kwargs):
@@ -1374,6 +1375,10 @@ class Grid(object):
         context.setdefault('paginated', False)
         if context['paginated']:
             context.setdefault('per_page', 20)
+        context['view_click_handler'] = self.get_view_click_handler()
+        return render(template, context)
+
+    def get_view_click_handler(self):
 
         # locate the 'view' action
         # TODO: this should be easier, and/or moved elsewhere?
@@ -1388,11 +1393,8 @@ class Grid(object):
                     view = action
                     break
 
-        context['view_click_handler'] = None
-        if view and view.click_handler:
-            context['view_click_handler'] = view.click_handler
-
-        return render(template, context)
+        if view:
+            return view.click_handler
 
     def set_filters_sequence(self, filters, only=False):
         """
diff --git a/tailbone/static/css/layout.css b/tailbone/static/css/layout.css
index cc4d0015..bdf35410 100644
--- a/tailbone/static/css/layout.css
+++ b/tailbone/static/css/layout.css
@@ -61,13 +61,14 @@ header .level .theme-picker {
     display: inline-flex;
 }
 
-#content-title {
-    padding: 0.3rem;
-}
-
 #content-title h1 {
-    font-size: 2rem;
-    margin-left: 1rem;
+    margin-bottom: 0;
+    margin-right: 1rem;
+    max-width: 50%;
+    overflow: hidden;
+    padding: 0 0.3rem;
+    text-overflow: ellipsis;
+    white-space: nowrap;
 }
 
 /******************************
diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako
index 0e767353..8558eeb7 100644
--- a/tailbone/templates/base.mako
+++ b/tailbone/templates/base.mako
@@ -426,17 +426,22 @@
 
       ## Page Title
       % if capture(self.content_title):
-          <section id="content-title" class="hero is-primary">
-            <div class="level">
-              <div class="level-left">
-                <div class="level-item">
-                  <h1 class="title" v-html="contentTitleHTML"></h1>
-                </div>
+          <section id="content-title"
+                   class="has-background-primary">
+            <div style="display: flex; align-items: center; padding: 0.5rem;">
+
+              <h1 class="title has-text-white"
+                  v-html="contentTitleHTML">
+              </h1>
+
+              <div style="flex-grow: 1; display: flex; gap: 0.5rem;">
                 ${self.render_instance_header_title_extras()}
               </div>
-              <div class="level-right">
+
+              <div style="display: flex; gap: 0.5rem;">
                 ${self.render_instance_header_buttons()}
               </div>
+
             </div>
           </section>
       % endif
@@ -634,76 +639,60 @@
       ## TODO: is there a better way to check if viewing parent?
       % if parent_instance is Undefined:
           % if master.editable and instance_editable and master.has_perm('edit'):
-              <div class="level-item">
-                <once-button tag="a" href="${action_url('edit', instance)}"
-                             icon-left="edit"
-                             text="Edit This">
-                </once-button>
-              </div>
+              <once-button tag="a" href="${action_url('edit', instance)}"
+                           icon-left="edit"
+                           text="Edit This">
+              </once-button>
           % endif
           % if master.cloneable and master.has_perm('clone'):
-              <div class="level-item">
-                <once-button tag="a" href="${action_url('clone', instance)}"
-                             icon-left="object-ungroup"
-                             text="Clone This">
-                </once-button>
-              </div>
+              <once-button tag="a" href="${action_url('clone', instance)}"
+                           icon-left="object-ungroup"
+                           text="Clone This">
+              </once-button>
           % endif
           % if master.deletable and instance_deletable and master.has_perm('delete'):
-              <div class="level-item">
-                <once-button tag="a" href="${action_url('delete', instance)}"
-                             type="is-danger"
-                             icon-left="trash"
-                             text="Delete This">
-                </once-button>
-              </div>
+              <once-button tag="a" href="${action_url('delete', instance)}"
+                           type="is-danger"
+                           icon-left="trash"
+                           text="Delete This">
+              </once-button>
           % endif
       % else:
           ## viewing row
           % if instance_deletable and master.has_perm('delete_row'):
-              <div class="level-item">
-                <once-button tag="a" href="${action_url('delete', instance)}"
-                             type="is-danger"
-                             icon-left="trash"
-                             text="Delete This">
-                </once-button>
-              </div>
+              <once-button tag="a" href="${action_url('delete', instance)}"
+                           type="is-danger"
+                           icon-left="trash"
+                           text="Delete This">
+              </once-button>
           % endif
       % endif
   % elif master and master.editing:
       % if master.viewable and master.has_perm('view'):
-          <div class="level-item">
-            <once-button tag="a" href="${action_url('view', instance)}"
-                         icon-left="eye"
-                         text="View This">
-            </once-button>
-          </div>
+          <once-button tag="a" href="${action_url('view', instance)}"
+                       icon-left="eye"
+                       text="View This">
+          </once-button>
       % endif
       % if master.deletable and instance_deletable and master.has_perm('delete'):
-          <div class="level-item">
-            <once-button tag="a" href="${action_url('delete', instance)}"
-                         type="is-danger"
-                         icon-left="trash"
-                         text="Delete This">
-            </once-button>
-          </div>
+          <once-button tag="a" href="${action_url('delete', instance)}"
+                       type="is-danger"
+                       icon-left="trash"
+                       text="Delete This">
+          </once-button>
       % endif
   % elif master and master.deleting:
       % if master.viewable and master.has_perm('view'):
-          <div class="level-item">
-            <once-button tag="a" href="${action_url('view', instance)}"
-                         icon-left="eye"
-                         text="View This">
-            </once-button>
-          </div>
+          <once-button tag="a" href="${action_url('view', instance)}"
+                       icon-left="eye"
+                       text="View This">
+          </once-button>
       % endif
       % if master.editable and instance_editable and master.has_perm('edit'):
-          <div class="level-item">
-            <once-button tag="a" href="${action_url('edit', instance)}"
-                         icon-left="edit"
-                         text="Edit This">
-            </once-button>
-          </div>
+          <once-button tag="a" href="${action_url('edit', instance)}"
+                       icon-left="edit"
+                       text="Edit This">
+          </once-button>
       % endif
   % endif
 </%def>
@@ -711,40 +700,32 @@
 <%def name="render_prevnext_header_buttons()">
   % if show_prev_next is not Undefined and show_prev_next:
       % if prev_url:
-          <div class="level-item">
-            <b-button tag="a" href="${prev_url}"
-                      icon-pack="fas"
-                      icon-left="arrow-left">
-              Older
-            </b-button>
-          </div>
+          <b-button tag="a" href="${prev_url}"
+                    icon-pack="fas"
+                    icon-left="arrow-left">
+            Older
+          </b-button>
       % else:
-          <div class="level-item">
-            <b-button tag="a" href="#"
-                      disabled
-                      icon-pack="fas"
-                      icon-left="arrow-left">
-              Older
-            </b-button>
-          </div>
+          <b-button tag="a" href="#"
+                    disabled
+                    icon-pack="fas"
+                    icon-left="arrow-left">
+            Older
+          </b-button>
       % endif
       % if next_url:
-          <div class="level-item">
-            <b-button tag="a" href="${next_url}"
-                      icon-pack="fas"
-                      icon-left="arrow-right">
-              Newer
-            </b-button>
-          </div>
+          <b-button tag="a" href="${next_url}"
+                    icon-pack="fas"
+                    icon-left="arrow-right">
+            Newer
+          </b-button>
       % else:
-          <div class="level-item">
-            <b-button tag="a" href="#"
-                      disabled
-                      icon-pack="fas"
-                      icon-left="arrow-right">
-              Newer
-            </b-button>
-          </div>
+          <b-button tag="a" href="#"
+                    disabled
+                    icon-pack="fas"
+                    icon-left="arrow-right">
+            Newer
+          </b-button>
       % endif
   % endif
 </%def>
diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako
index 5b21b42a..6fdcf77d 100644
--- a/tailbone/templates/grids/buefy.mako
+++ b/tailbone/templates/grids/buefy.mako
@@ -254,7 +254,12 @@
             % if column['field'] in grid.raw_renderers:
                 ${grid.raw_renderers[column['field']]()}
             % elif grid.is_linked(column['field']):
-                <a :href="props.row._action_url_view" v-html="props.row.${column['field']}"></a>
+                <a :href="props.row._action_url_view"
+                   % if view_click_handler:
+                   @click.prevent="${view_click_handler}"
+                   % endif
+                   v-html="props.row.${column['field']}">
+                </a>
             % else:
                 <span v-html="props.row.${column['field']}"></span>
             % endif
@@ -274,6 +279,9 @@
                    % if action.click_handler:
                    @click.prevent="${action.click_handler}"
                    % endif
+                   % if action.target:
+                   target="${action.target}"
+                   % endif
                    >
                   ${action.render_icon()|n}
                   ${action.render_label()|n}
@@ -533,7 +541,7 @@
           ## 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
-          async loadAsyncData(params, callback) {
+          async loadAsyncData(params, success, failure) {
 
               if (params === undefined || params === null) {
                   params = new URLSearchParams(this.getBasicParams())
@@ -551,14 +559,17 @@
                   this.lastItem = data.last_item
                   this.loading = false
                   this.checkedRows = this.locateCheckedRows(data.checked_rows)
-                  if (callback) {
-                      callback()
+                  if (success) {
+                      success()
                   }
               })
               .catch((error) => {
                   this.data = []
                   this.total = 0
                   this.loading = false
+                  if (failure) {
+                      failure()
+                  }
                   throw error
               })
           },
diff --git a/tailbone/templates/master/edit.mako b/tailbone/templates/master/edit.mako
index f1bc7318..a03912e6 100644
--- a/tailbone/templates/master/edit.mako
+++ b/tailbone/templates/master/edit.mako
@@ -1,7 +1,8 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/master/form.mako" />
 
-<%def name="title()">Edit: ${instance_title}</%def>
+<%def name="title()">${index_title} &raquo; ${instance_title} &raquo; Edit</%def>
 
+<%def name="content_title()">Edit: ${instance_title}</%def>
 
 ${parent.body()}
diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako
index e6d0c8de..b5930664 100644
--- a/tailbone/templates/master/view.mako
+++ b/tailbone/templates/master/view.mako
@@ -8,7 +8,6 @@
 </%def>
 
 <%def name="render_instance_header_title_extras()">
-  <span style="width: 2rem;"></span>
   % if master.touchable and master.has_perm('touch'):
       <b-button title="&quot;Touch&quot; this record to trigger sync"
                 icon-pack="fas"
@@ -17,6 +16,13 @@
                 :disabled="touchSubmitting">
       </b-button>
   % endif
+  % if expose_versions:
+      <b-button icon-pack="fas"
+                icon-left="history"
+                @click="viewingHistory = !viewingHistory">
+        {{ viewingHistory ? "View Current" : "View History" }}
+      </b-button>
+  % endif
 </%def>
 
 <%def name="object_helpers()">
@@ -46,9 +52,6 @@
   ## TODO: either make this configurable, or just lose it.
   ## nobody seems to ever find it useful in practice.
   ## <li>${h.link_to("Permalink for this {}".format(model_title), action_url('view', instance))}</li>
-  % if master.has_versions and request.rattail_config.versioning_enabled() and request.has_perm('{}.versions'.format(permission_prefix)):
-      <li>${h.link_to("Version History", action_url('versions', instance))}</li>
-  % endif
 </%def>
 
 <%def name="render_row_grid_tools()">
@@ -69,14 +72,152 @@
   % endif
 </%def>
 
+<%def name="render_this_page_component()">
+  ## TODO: should override this in a cleaner way!  too much duplicate code w/ parent template
+  <this-page @change-content-title="changeContentTitle"
+             % if can_edit_help:
+             :configure-fields-help="configureFieldsHelp"
+             % endif
+             % if expose_versions:
+             :viewing-history="viewingHistory"
+             % endif
+             >
+  </this-page>
+</%def>
+
 <%def name="render_this_page()">
-  ${parent.render_this_page()}
-  % if master.has_rows:
-      <br />
-      % if rows_title:
-          <h4 class="block is-size-4">${rows_title}</h4>
-      % endif
-      ${self.render_row_grid_component()}
+  <div
+    % if expose_versions:
+    v-show="!viewingHistory"
+    % endif
+    >
+
+    ## render main form
+    ${parent.render_this_page()}
+
+    ## render row grid
+    % if master.has_rows:
+        <br />
+        % if rows_title:
+            <h4 class="block is-size-4">${rows_title}</h4>
+        % endif
+        ${self.render_row_grid_component()}
+    % endif
+  </div>
+
+  % if expose_versions:
+      <div v-show="viewingHistory">
+
+        <div style="display: flex; align-items: center; gap: 2rem;">
+          <h3 class="is-size-3">Version History</h3>
+          <p class="block">
+            <a href="${master.get_action_url('versions', instance)}"
+               target="_blank">
+              <i class="fas fa-external-link-alt"></i>
+              View as separate page
+            </a>
+          </p>
+        </div>
+
+        <versions-grid ref="versionsGrid"
+                       @view-revision="viewRevision">
+        </versions-grid>
+
+        <b-modal :active.sync="viewVersionShowDialog" :width="1200">
+          <div class="card">
+            <div class="card-content">
+              <div style="display: flex; flex-direction: column; gap: 1.5rem;">
+
+                <div style="display: flex; gap: 1rem;">
+
+                  <div style="flex-grow: 1;">
+                    <b-field horizontal label="Changed">
+                      <div v-html="viewVersionData.changed"></div>
+                    </b-field>
+                    <b-field horizontal label="Changed by">
+                      <div v-html="viewVersionData.changed_by"></div>
+                    </b-field>
+                    <b-field horizontal label="IP Address">
+                      <div v-html="viewVersionData.remote_addr"></div>
+                    </b-field>
+                    <b-field horizontal label="Comment">
+                      <div v-html="viewVersionData.comment"></div>
+                    </b-field>
+                    <b-field horizontal label="TXN ID">
+                      <div v-html="viewVersionData.txnid"></div>
+                    </b-field>
+                  </div>
+
+                  <div style="display: flex; flex-direction: column; justify-content: space-between;">
+
+                    <div class="buttons">
+                      <b-button @click="viewPrevRevision()"
+                                type="is-primary"
+                                icon-pack="fas"
+                                icon-left="arrow-left"
+                                :disabled="!viewVersionData.prev_txnid">
+                        Older
+                      </b-button>
+                      <b-button @click="viewNextRevision()"
+                                type="is-primary"
+                                icon-pack="fas"
+                                icon-right="arrow-right"
+                                :disabled="!viewVersionData.next_txnid">
+                        Newer
+                      </b-button>
+                    </div>
+
+                    <div>
+                      <a :href="viewVersionData.url"
+                         target="_blank">
+                        <i class="fas fa-external-link-alt"></i>
+                        View as separate page
+                      </a>
+                    </div>
+
+                    <b-button @click="toggleVersionFields()">
+                      {{ viewVersionShowAllFields ? "Show Diffs Only" : "Show All Fields" }}
+                    </b-button>
+                  </div>
+
+                </div>
+
+                <div v-for="version in viewVersionData.versions"
+                     :key="version.key">
+
+                  <p class="block has-text-weight-bold">
+                    {{ version.model_title }}
+                  </p>
+
+                  <table class="diff monospace is-size-7"
+                         :class="version.diff_class">
+                    <thead>
+                      <tr>
+                        <th>field name</th>
+                        <th>old value</th>
+                        <th>new value</th>
+                      </tr>
+                    </thead>
+                    <tbody>
+                      <tr v-for="field in version.fields"
+                          :key="field"
+                          :class="{diff: version.values[field].after != version.values[field].before}"
+                          v-show="viewVersionShowAllFields || version.values[field].after != version.values[field].before">
+                        <td class="field has-text-weight-bold">{{ field }}</td>
+                        <td class="old-value" v-html="version.values[field].before"></td>
+                        <td class="new-value" v-html="version.values[field].after"></td>
+                      </tr>
+                    </tbody>
+                  </table>
+
+                </div>
+
+              </div>
+              <b-loading :active.sync="viewVersionLoading" :is-full-page="false"></b-loading>
+            </div>
+          </div>
+        </b-modal>
+      </div>
   % endif
 </%def>
 
@@ -90,12 +231,79 @@
       ${rows_grid.render_buefy(allow_save_defaults=False, tools=capture(self.render_row_grid_tools))|n}
   % endif
   ${parent.render_this_page_template()}
+  % if expose_versions:
+      ${versions_grid.render_buefy()|n}
+  % endif
+</%def>
+
+<%def name="modify_this_page_vars()">
+  ${parent.modify_this_page_vars()}
+  % if expose_versions:
+      <script type="text/javascript">
+
+        ThisPage.props.viewingHistory = Boolean
+
+        ThisPageData.gettingRevisions = false
+        ThisPageData.gotRevisions = false
+
+        ThisPageData.viewVersionShowDialog = false
+        ThisPageData.viewVersionData = {}
+        ThisPageData.viewVersionShowAllFields = false
+        ThisPageData.viewVersionLoading = false
+
+        // auto-fetch grid results when first viewing history
+        ThisPage.watch.viewingHistory = function(newval, oldval) {
+            if (!this.gotRevisions && !this.gettingRevisions) {
+                this.gettingRevisions = true
+                this.$refs.versionsGrid.loadAsyncData(null, () => {
+                    this.gettingRevisions = false
+                    this.gotRevisions = true
+                }, () => {
+                    this.gettingRevisions = false
+                })
+            }
+        }
+
+        VersionsGrid.methods.viewRevision = function(row) {
+            this.$emit('view-revision', row)
+        }
+
+        ThisPage.methods.viewRevision = function(row) {
+            this.viewVersionLoading = true
+
+            let url = '${master.get_action_url('revisions_data', instance)}'
+            let params = {txnid: row.id}
+            this.simpleGET(url, params, response => {
+                this.viewVersionData = response.data
+                this.viewVersionLoading = false
+            }, response => {
+                this.viewVersionLoading = false
+            })
+
+            this.viewVersionShowDialog = true
+        }
+
+        ThisPage.methods.viewPrevRevision = function() {
+            this.viewRevision({id: this.viewVersionData.prev_txnid})
+        }
+
+        ThisPage.methods.viewNextRevision = function() {
+            this.viewRevision({id: this.viewVersionData.next_txnid})
+        }
+
+        ThisPage.methods.toggleVersionFields = function() {
+            this.viewVersionShowAllFields = !this.viewVersionShowAllFields
+        }
+
+      </script>
+  % endif
 </%def>
 
 <%def name="modify_whole_page_vars()">
   ${parent.modify_whole_page_vars()}
-  % if master.touchable and master.has_perm('touch'):
-      <script type="text/javascript">
+  <script type="text/javascript">
+
+    % if master.touchable and master.has_perm('touch'):
 
         WholePageData.touchSubmitting = false
 
@@ -104,21 +312,30 @@
             location.href = '${master.get_action_url('touch', instance)}'
         }
 
-      </script>
-  % endif
+    % endif
+
+    % if expose_versions:
+        WholePageData.viewingHistory = false
+    % endif
+
+  </script>
 </%def>
 
 <%def name="finalize_this_page_vars()">
   ${parent.finalize_this_page_vars()}
-  % if master.has_rows:
   <script type="text/javascript">
 
-    TailboneGrid.data = function() { return TailboneGridData }
+    % if master.has_rows:
+        TailboneGrid.data = function() { return TailboneGridData }
+        Vue.component('tailbone-grid', TailboneGrid)
+    % endif
 
-    Vue.component('tailbone-grid', TailboneGrid)
+    % if expose_versions:
+        VersionsGrid.data = function() { return VersionsGridData }
+        Vue.component('versions-grid', VersionsGrid)
+    % endif
 
   </script>
-  % endif
 </%def>
 
 
diff --git a/tailbone/templates/master/view_version.mako b/tailbone/templates/master/view_version.mako
index d29a3496..6417dfb7 100644
--- a/tailbone/templates/master/view_version.mako
+++ b/tailbone/templates/master/view_version.mako
@@ -45,13 +45,18 @@
       <div class="field">${transaction.meta.get('comment') or ''}</div>
     </div>
 
+    <div class="field-wrapper">
+      <label>TXN ID</label>
+      <div class="field">${transaction.id}</div>
+    </div>
+
   </div>
 
 </div><!-- form-wrapper -->
 
 <div class="versions-wrapper">
   % for diff in version_diffs:
-      <h2>${diff.title}</h2>
+      <h4 class="is-size-4 block">${diff.title}</h4>
       ${diff.render_html()}
   % endfor
 </div>
diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index 167bdace..21418521 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -1172,6 +1172,12 @@ class MasterView(View):
             context['rows_grid'] = grid
             context['rows_grid_tools'] = HTML(self.make_row_grid_tools(instance) or '').strip()
 
+        context['expose_versions'] = (self.has_versions
+                                      and self.request.rattail_config.versioning_enabled()
+                                      and self.has_perm('versions'))
+        if context['expose_versions']:
+            context['versions_grid'] = self.make_revisions_grid(instance, empty_data=True)
+
         return self.render_to_response('view', context)
 
     def image(self):
@@ -1300,7 +1306,7 @@ class MasterView(View):
             return cls.version_grid_key
         return '{}.history'.format(cls.get_route_prefix())
 
-    def get_version_data(self, instance):
+    def get_version_data(self, instance, order_by=True):
         """
         Generate the base data set for the version grid.
         """
@@ -1308,7 +1314,9 @@ class MasterView(View):
         transaction_class = continuum.transaction_class(model_class)
         query = model_transaction_query(self.Session(), instance, model_class,
                                         child_classes=self.normalize_version_child_classes())
-        return query.order_by(transaction_class.issued_at.desc())
+        if order_by:
+            query = query.order_by(transaction_class.issued_at.desc())
+        return query
 
     def get_version_child_classes(self):
         """
@@ -1330,6 +1338,114 @@ class MasterView(View):
             classes.append(cls)
         return classes
 
+    def make_revisions_grid(self, obj, empty_data=False):
+        route_prefix = self.get_route_prefix()
+        row_url = lambda txn, i: self.request.route_url(f'{route_prefix}.version',
+                                                        uuid=obj.uuid,
+                                                        txnid=txn.id)
+
+        kwargs = {
+            'component': 'versions-grid',
+            'ajax_data_url': self.get_action_url('revisions_data', obj),
+            'sortable': True,
+            'default_sortkey': 'changed',
+            'default_sortdir': 'desc',
+            'main_actions': [
+                self.make_action('view', icon='eye', url='#',
+                                 click_handler='viewRevision(props.row)'),
+                self.make_action('view_separate', url=row_url, target='_blank',
+                                 icon='external-link-alt', ),
+            ],
+        }
+
+        if empty_data:
+
+            # TODO: surely there is a better way to have empty initial
+            # data..?  but so much logic depends on a query, can't
+            # just pass empty list here
+            txn_class = continuum.transaction_class(self.get_model_class())
+            meta_class = continuum.versioning_manager.transaction_meta_cls
+            kwargs['data'] = self.Session.query(txn_class)\
+                                         .outerjoin(meta_class,
+                                                    meta_class.transaction_id == txn_class.id)\
+                                         .filter(txn_class.id == -1)
+
+        else:
+            kwargs['data'] = self.get_version_data(obj, order_by=False)
+
+        grid = self.make_version_grid(**kwargs)
+
+        grid.set_joiner('user', lambda q: q.outerjoin(self.model.User))
+        grid.set_sorter('user', self.model.User.username)
+
+        grid.set_link('remote_addr')
+
+        grid.append('id')
+        grid.set_label('id', "TXN ID")
+        grid.set_link('id')
+
+        return grid
+
+    def revisions_data(self):
+        """
+        AJAX view to fetch revision data for current instance.
+        """
+        txnid = self.request.GET.get('txnid')
+        if txnid:
+            # return single txn data
+
+            app = self.get_rattail_app()
+            obj = self.get_instance()
+            cls = self.get_model_class()
+            txn_cls = continuum.transaction_class(cls)
+            route_prefix = self.get_route_prefix()
+
+            transactions = model_transaction_query(
+                self.Session(), obj, cls,
+                child_classes=self.normalize_version_child_classes())
+
+            txn = transactions.filter(txn_cls.id == txnid).first()
+            if not txn:
+                return self.notfound()
+
+            older = transactions.filter(txn_cls.issued_at <= txn.issued_at)\
+                                .filter(txn_cls.id != txnid)\
+                                .order_by(txn_cls.issued_at.desc())\
+                                .first()
+            newer = transactions.filter(txn_cls.issued_at >= txn.issued_at)\
+                                .filter(txn_cls.id != txnid)\
+                                .order_by(txn_cls.issued_at)\
+                                .first()
+
+            version_diffs = []
+            for version in self.get_relevant_versions(txn, obj):
+                diff = self.make_version_diff(version)
+                version_diffs.append(diff.as_struct())
+
+            changed_raw = app.render_datetime(app.localtime(txn.issued_at, from_utc=True))
+            changed_ago = app.render_time_ago(app.make_utc() - txn.issued_at)
+
+            changed_by = str(txn.user)
+            if self.request.has_perm('users.view'):
+                changed_by = tags.link_to(changed_by, self.request.route_url('users.view', uuid=txn.user.uuid))
+
+            return {
+                'txnid': txn.id,
+                'changed': f"{changed_raw} ({changed_ago})",
+                'changed_by': changed_by,
+                'remote_addr': txn.remote_addr,
+                'comment': txn.meta.get('comment'),
+                'versions': version_diffs,
+                'url': self.request.route_url(f'{route_prefix}.version', uuid=obj.uuid, txnid=txnid),
+                'prev_txnid': older.id if older else None,
+                'next_txnid': newer.id if newer else None,
+            }
+
+        else: # no txnid, return grid data
+            obj = self.get_instance()
+            grid = self.make_revisions_grid(obj)
+            return grid.get_buefy_data()
+
     def view_version(self):
         """
         View showing diff details of a particular object version.
@@ -4829,10 +4945,10 @@ class MasterView(View):
     def make_diff(self, old_data, new_data, **kwargs):
         return diffs.Diff(old_data, new_data, **kwargs)
 
-    def make_version_diff(self, version, old_data, new_data, **kwargs):
+    def make_version_diff(self, version, *args, **kwargs):
         if 'title' not in kwargs:
             kwargs['title'] = self.title_for_version(version)
-        return diffs.VersionDiff(version, old_data, new_data, **kwargs)
+        return diffs.VersionDiff(version, *args, **kwargs)
 
     ##############################
     # Configuration Views
@@ -5576,6 +5692,16 @@ class MasterView(View):
                             route_name='{}.version'.format(route_prefix),
                             permission='{}.versions'.format(permission_prefix))
 
+            # revisions data (AJAX)
+            config.add_route(f'{route_prefix}.revisions_data',
+                             f'{instance_url_prefix}/revisions-data',
+                             request_method='GET')
+            config.add_view(cls, attr='revisions_data',
+                            route_name=f'{route_prefix}.revisions_data',
+                            permission=f'{permission_prefix}.versions',
+                            renderer='json')
+
+
     @classmethod
     def _defaults_edit_help(cls, config, **kwargs):
         route_prefix = cls.get_route_prefix()

From 78deb5d09a9395ef02287a14c64c44bc359b1208 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 10 Oct 2023 22:01:46 -0500
Subject: [PATCH 118/542] Use autocomplete instead of dropdown for grid "add
 filter"

---
 tailbone/templates/grids/buefy.mako         | 64 ++++++++++++++++++---
 tailbone/templates/grids/filters_buefy.mako | 35 +++++++----
 2 files changed, 80 insertions(+), 19 deletions(-)

diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako
index 6fdcf77d..a3e6e229 100644
--- a/tailbone/templates/grids/buefy.mako
+++ b/tailbone/templates/grids/buefy.mako
@@ -358,7 +358,6 @@
 
   let ${grid.component_studly}Data = {
       loading: false,
-      selectedFilter: null,
       ajaxDataUrl: ${json.dumps(grid.ajax_data_url)|n},
 
       data: ${grid.component_studly}CurrentData,
@@ -401,7 +400,8 @@
       ## filterable: ${json.dumps(grid.filterable)|n},
       filters: ${json.dumps(filters_data if grid.filterable else None)|n},
       filtersSequence: ${json.dumps(filters_sequence if grid.filterable else None)|n},
-      selectedFilter: null,
+      addFilterTerm: '',
+      addFilterShow: false,
 
       ## dummy input value needed for sharing links on *insecure* sites
       % if request.scheme == 'http':
@@ -420,6 +420,39 @@
 
       computed: {
 
+          addFilterChoices() {
+
+              // collect all filters, which are *not* already shown
+              let choices = []
+              for (let field of this.filtersSequence) {
+                  let filtr = this.filters[field]
+                  if (!filtr.visible) {
+                      choices.push(filtr)
+                  }
+              }
+
+              // parse list of search terms
+              let terms = []
+              for (let term of this.addFilterTerm.toLowerCase().split(' ')) {
+                  term = term.trim()
+                  if (term) {
+                      terms.push(term)
+                  }
+              }
+
+              // only filters matching all search terms are presented
+              // as choices to the user
+              return choices.filter(option => {
+                  let label = option.label.toLowerCase()
+                  for (let term of terms) {
+                      if (label.indexOf(term) < 0) {
+                          return false
+                      }
+                  }
+                  return true
+              })
+          },
+
           // note, can use this with v-model for hidden 'uuids' fields
           selected_uuids: function() {
               return this.checkedRowUUIDs().join(',')
@@ -644,12 +677,29 @@
               location.href = url
           },
 
-          addFilter(filter_key) {
-
-              // reset dropdown so user again sees "Add Filter" placeholder
-              this.$nextTick(function() {
-                  this.selectedFilter = null
+          addFilterButton(event) {
+              this.addFilterShow = true
+              this.$nextTick(() => {
+                  this.$refs.addFilterAutocomplete.focus()
               })
+          },
+
+          addFilterKeydown(event) {
+
+              // ESC will clear searchbox
+              if (event.which == 27) {
+                  this.addFilterTerm = ''
+                  this.addFilterShow = false
+              }
+          },
+
+          addFilterSelect(filtr) {
+              this.addFilter(filtr.key)
+              this.addFilterTerm = ''
+              this.addFilterShow = false
+          },
+
+          addFilter(filter_key) {
 
               // show corresponding grid filter
               this.filters[filter_key].visible = true
diff --git a/tailbone/templates/grids/filters_buefy.mako b/tailbone/templates/grids/filters_buefy.mako
index 3136a15f..5e1fef9b 100644
--- a/tailbone/templates/grids/filters_buefy.mako
+++ b/tailbone/templates/grids/filters_buefy.mako
@@ -18,18 +18,29 @@
       Apply Filters
     </b-button>
 
-    <b-select @input="addFilter"
-              placeholder="Add Filter"
-              v-model="selectedFilter">
-      <option v-for="key in filtersSequence"
-              :key="key"
-              :value="key"
-              ## TODO: previous code here was simpler; trying to track down
-              ## why disabled options don't appear so on Windows Chrome (?)
-              :disabled="filters[key].visible ? 'disabled' : null">
-        {{ filters[key].label }}
-      </option>
-    </b-select>
+    <b-button v-if="!addFilterShow"
+              icon-pack="fas"
+              icon-left="plus"
+              class="control"
+              @click="addFilterButton">
+      Add Filter
+    </b-button>
+
+    <b-autocomplete v-if="addFilterShow"
+                    ref="addFilterAutocomplete"
+                    :data="addFilterChoices"
+                    v-model="addFilterTerm"
+                    placeholder="Add Filter"
+                    field="key"
+                    :custom-formatter="filtr => filtr.label"
+                    open-on-focus
+                    keep-first
+                    icon-pack="fas"
+                    clearable
+                    clear-on-select
+                    @select="addFilterSelect"
+                    @keydown.native="addFilterKeydown">
+    </b-autocomplete>
 
     <b-button @click="resetView()"
               icon-pack="fas"

From cddec5158251a9e66227132ce8adb1bf2fbc9f58 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 11 Oct 2023 15:56:16 -0500
Subject: [PATCH 119/542] Update changelog

---
 CHANGES.rst          | 16 ++++++++++++++++
 tailbone/_version.py |  2 +-
 2 files changed, 17 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 07addfcc..dd1bbd70 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,22 @@
 CHANGELOG
 =========
 
+0.9.66 (2023-10-11)
+-------------------
+
+* Make grid JS ``loadAsyncData()`` method truly async.
+
+* Add support for multi-column grid sorting.
+
+* Add smarts to show display text for some version diff fields.
+
+* Allow null for FalafelDateTime form fields.
+
+* Show full version history within the "view" page.
+
+* Use autocomplete instead of dropdown for grid "add filter".
+
+
 0.9.65 (2023-10-07)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 466968d6..7a7c683c 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.65'
+__version__ = '0.9.66'

From cd82f8927b69c65b7f9f76db0171017050a80036 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 11 Oct 2023 16:13:20 -0500
Subject: [PATCH 120/542] Fix grid sorting when column key/name differ

---
 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 dc1a5af0..a3d85006 100644
--- a/tailbone/grids/core.py
+++ b/tailbone/grids/core.py
@@ -1242,7 +1242,7 @@ class Grid(object):
                     spec = {
                         'sortkey': sortkey,
                         'model': sortfunc._class.__name__,
-                        'field': sortfunc._column.name,
+                        'field': sortfunc._column.key,
                         'direction': sortdir or 'asc',
                     }
                     full_spec.append(spec)

From 507a9ffc710b23eef3ec9c4bf891d3039de05f77 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 11 Oct 2023 18:35:35 -0500
Subject: [PATCH 121/542] Expose department tax, FS flag

---
 tailbone/views/batch/pos.py   |  2 ++
 tailbone/views/departments.py | 15 +++++++++++----
 tailbone/views/master.py      |  8 ++++++++
 3 files changed, 21 insertions(+), 4 deletions(-)

diff --git a/tailbone/views/batch/pos.py b/tailbone/views/batch/pos.py
index 00f1603f..09df6ddb 100644
--- a/tailbone/views/batch/pos.py
+++ b/tailbone/views/batch/pos.py
@@ -104,6 +104,8 @@ class POSBatchView(BatchMasterView):
         'item_entry',
         'product',
         'description',
+        'department_number',
+        'department_name',
         'reg_price',
         'txn_price',
         'quantity',
diff --git a/tailbone/views/departments.py b/tailbone/views/departments.py
index e71203ba..8115c5c3 100644
--- a/tailbone/views/departments.py
+++ b/tailbone/views/departments.py
@@ -46,6 +46,8 @@ class DepartmentView(MasterView):
         'name',
         'product',
         'personnel',
+        'tax',
+        'food_stampable',
         'exempt_from_gross_sales',
     ]
 
@@ -54,6 +56,8 @@ class DepartmentView(MasterView):
         'name',
         'product',
         'personnel',
+        'tax',
+        'food_stampable',
         'exempt_from_gross_sales',
         'allow_product_deletions',
         'employees',
@@ -78,7 +82,7 @@ class DepartmentView(MasterView):
     ]
 
     def configure_grid(self, g):
-        super(DepartmentView, self).configure_grid(g)
+        super().configure_grid(g)
 
         # number
         g.set_sort_defaults('number')
@@ -93,7 +97,7 @@ class DepartmentView(MasterView):
         g.set_type('personnel', 'boolean')
 
     def configure_form(self, f):
-        super(DepartmentView, self).configure_form(f)
+        super().configure_form(f)
 
         f.remove_field('subdepartments')
 
@@ -105,6 +109,9 @@ class DepartmentView(MasterView):
         f.set_type('product', 'boolean')
         f.set_type('personnel', 'boolean')
 
+        # tax
+        f.set_renderer('tax', self.render_tax)
+
     def render_employees(self, department, field):
         route_prefix = self.get_route_prefix()
         permission_prefix = self.get_permission_prefix()
@@ -130,7 +137,7 @@ class DepartmentView(MasterView):
             g.render_buefy_table_element(data_prop='employeesData'))
 
     def template_kwargs_view(self, **kwargs):
-        kwargs = super(DepartmentView, self).template_kwargs_view(**kwargs)
+        kwargs = super().template_kwargs_view(**kwargs)
         department = kwargs['instance']
         department_employees = sorted(department.employees, key=str)
 
@@ -169,7 +176,7 @@ class DepartmentView(MasterView):
         return product.department
 
     def configure_row_grid(self, g):
-        super(DepartmentView, self).configure_row_grid(g)
+        super().configure_row_grid(g)
 
         app = self.get_rattail_app()
         self.handler = app.get_products_handler()
diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index 21418521..9c814799 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -918,6 +918,14 @@ class MasterView(View):
             if not vendor:
                 node.raise_invalid("Vendor not found")
 
+    def render_tax(self, obj, field):
+        tax = getattr(obj, field)
+        if not tax:
+            return
+        text = str(tax)
+        url = self.request.route_url('taxes.view', uuid=tax.uuid)
+        return tags.link_to(text, url)
+
     def render_department(self, obj, field):
         department = getattr(obj, field)
         if not department:

From d66dd5f199965c9f577c7a41762ebf99203bec2a Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 11 Oct 2023 19:55:43 -0500
Subject: [PATCH 122/542] Add permission for testing error handling at POS

---
 tailbone/views/batch/pos.py | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/tailbone/views/batch/pos.py b/tailbone/views/batch/pos.py
index 09df6ddb..72d2e7ee 100644
--- a/tailbone/views/batch/pos.py
+++ b/tailbone/views/batch/pos.py
@@ -251,6 +251,8 @@ class POSBatchView(BatchMasterView):
 
             config.add_tailbone_permission_group('pos', "POS", overwrite=False)
 
+            config.add_tailbone_permission('pos', 'pos.test_error',
+                                           "Force error to test error handling")
             config.add_tailbone_permission('pos', 'pos.ring_sales',
                                            "Make transactions (ring up sales)")
             # config.add_tailbone_permission('pos', 'pos.resume',

From 1a15d7056800f27dac137247adbb9ee3c37bfcf9 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 11 Oct 2023 23:11:23 -0500
Subject: [PATCH 123/542] Add some awareness of suspend/resume for POS batch

---
 tailbone/views/batch/pos.py | 35 +++++++++++++++++++++++++++--------
 tailbone/views/master.py    |  8 ++++++++
 2 files changed, 35 insertions(+), 8 deletions(-)

diff --git a/tailbone/views/batch/pos.py b/tailbone/views/batch/pos.py
index 72d2e7ee..b536521b 100644
--- a/tailbone/views/batch/pos.py
+++ b/tailbone/views/batch/pos.py
@@ -53,21 +53,21 @@ class POSBatchView(BatchMasterView):
 
     grid_columns = [
         'id',
-        'terminal_id',
-        'customer',
         'created',
-        'created_by',
+        'terminal_id',
+        'cashier',
+        'customer',
         'rowcount',
         'sales_total',
         'void',
         'status_code',
         'executed',
-        'executed_by',
     ]
 
     form_fields = [
         'id',
         'terminal_id',
+        'cashier',
         'customer',
         'params',
         'rowcount',
@@ -121,13 +121,26 @@ class POSBatchView(BatchMasterView):
 
     def configure_grid(self, g):
         super().configure_grid(g)
+        model = self.model
 
         # terminal_id
         g.set_label('terminal_id', "Terminal")
         if 'terminal_id' in g.filters:
             g.filters['terminal_id'].label = self.labels.get('terminal_id', "Terminal ID")
 
+        # cashier
+        def join_cashier(q):
+            return q.outerjoin(model.Employee,
+                               model.Employee.uuid == model.POSBatch.cashier_uuid)\
+                    .outerjoin(model.Person,
+                               model.Person.uuid == model.Employee.person_uuid)
+        g.set_joiner('cashier', join_cashier)
+        g.set_sorter('cashier', model.Person.display_name)
+
+        # customer
         g.set_link('customer')
+        g.set_joiner('customer', lambda q: q.outerjoin(model.Customer))
+        g.set_sorter('customer', model.Customer.name)
 
         g.set_link('created')
         g.set_link('created_by')
@@ -144,20 +157,26 @@ class POSBatchView(BatchMasterView):
     def grid_extra_class(self, batch, i):
         if batch.void:
             return 'warning'
-        if batch.training_mode:
+        if (batch.training_mode
+            or batch.status_code == batch.STATUS_SUSPENDED):
             return 'notice'
 
     def configure_form(self, f):
         super().configure_form(f)
         app = self.get_rattail_app()
 
+        # cashier
+        f.set_renderer('cashier', self.render_employee)
+
+        # customer
         f.set_renderer('customer', self.render_customer)
 
         f.set_type('sales_total', 'currency')
         f.set_type('tender_total', 'currency')
         f.set_type('tender_total', 'currency')
 
-        f.set_renderer('taxes', self.render_taxes)
+        if self.viewing:
+            f.set_renderer('taxes', self.render_taxes)
 
         f.set_renderer('balance', lambda batch, field: app.render_currency(batch.get_balance()))
 
@@ -257,8 +276,8 @@ class POSBatchView(BatchMasterView):
                                            "Make transactions (ring up sales)")
             # config.add_tailbone_permission('pos', 'pos.resume',
             #                                "Resume previously-suspended transaction")
-            # config.add_tailbone_permission('pos', 'pos.suspend',
-            #                                "Suspend current transaction")
+            config.add_tailbone_permission('pos', 'pos.suspend',
+                                           "Suspend current transaction")
             config.add_tailbone_permission('pos', 'pos.swap_customer',
                                            "Swap customer for current transaction")
             config.add_tailbone_permission('pos', 'pos.void_txn',
diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index 9c814799..176ff672 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -1010,6 +1010,14 @@ class MasterView(View):
             items.append(HTML.tag('li', c=[tags.link_to(text, url)]))
         return HTML.tag('ul', c=items)
 
+    def render_employee(self, obj, field):
+        employee = getattr(obj, field)
+        if not employee:
+            return ""
+        text = str(employee)
+        url = self.request.route_url('employees.view', uuid=employee.uuid)
+        return tags.link_to(text, url)
+
     def render_customer(self, obj, field):
         customer = getattr(obj, field)
         if not customer:

From 5940778189979be1d18bc031252628df85e91ff7 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 12 Oct 2023 10:33:44 -0500
Subject: [PATCH 124/542] Fix version child classes for Customers view

must be sure to include any supplements
---
 tailbone/views/customers.py | 6 ++++--
 1 file changed, 4 insertions(+), 2 deletions(-)

diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py
index 74f66458..dd8923e6 100644
--- a/tailbone/views/customers.py
+++ b/tailbone/views/customers.py
@@ -557,14 +557,16 @@ class CustomerView(MasterView):
         return HTML.tag('ul', HTML.literal('').join(items))
 
     def get_version_child_classes(self):
-        return [
+        classes = super().get_version_child_classes()
+        classes.extend([
             (model.CustomerGroupAssignment, 'customer_uuid'),
             (model.CustomerPhoneNumber, 'parent_uuid'),
             (model.CustomerEmailAddress, 'parent_uuid'),
             (model.CustomerMailingAddress, 'parent_uuid'),
             (model.CustomerPerson, 'customer_uuid'),
             (model.CustomerNote, 'parent_uuid'),
-        ]
+        ])
+        return classes
 
     def detach_person(self):
         customer = self.get_instance()

From 115e95b9a82ba2c8a802f90014a227c99b4dd24c Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 12 Oct 2023 10:37:12 -0500
Subject: [PATCH 125/542] Update changelog

---
 CHANGES.rst          | 14 ++++++++++++++
 tailbone/_version.py |  2 +-
 2 files changed, 15 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index dd1bbd70..8be310e7 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,20 @@
 CHANGELOG
 =========
 
+0.9.67 (2023-10-12)
+-------------------
+
+* Fix grid sorting when column key/name differ.
+
+* Expose department tax, FS flag.
+
+* Add permission for testing error handling at POS.
+
+* Add some awareness of suspend/resume for POS batch.
+
+* Fix version child classes for Customers view.
+
+
 0.9.66 (2023-10-11)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 7a7c683c..8e69986c 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.66'
+__version__ = '0.9.67'

From 7525aaaa87ab547b5763834e92c8d9ebaeec23f3 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 12 Oct 2023 11:57:18 -0500
Subject: [PATCH 126/542] Expose more permissions for POS

---
 tailbone/views/batch/pos.py | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/tailbone/views/batch/pos.py b/tailbone/views/batch/pos.py
index b536521b..f1e2b0d9 100644
--- a/tailbone/views/batch/pos.py
+++ b/tailbone/views/batch/pos.py
@@ -274,6 +274,10 @@ class POSBatchView(BatchMasterView):
                                            "Force error to test error handling")
             config.add_tailbone_permission('pos', 'pos.ring_sales',
                                            "Make transactions (ring up sales)")
+            config.add_tailbone_permission('pos', 'pos.override_price',
+                                           "Override price for any item")
+            config.add_tailbone_permission('pos', 'pos.del_customer',
+                                           "Remove customer from current transaction")
             # config.add_tailbone_permission('pos', 'pos.resume',
             #                                "Resume previously-suspended transaction")
             config.add_tailbone_permission('pos', 'pos.suspend',

From f86cc839965f94aaaebbe472795ee7edff3e042b Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 17 Oct 2023 15:26:22 -0500
Subject: [PATCH 127/542] Fix order xlsx download if missing order date

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

diff --git a/tailbone/views/purchasing/ordering.py b/tailbone/views/purchasing/ordering.py
index 03308d07..63c13517 100644
--- a/tailbone/views/purchasing/ordering.py
+++ b/tailbone/views/purchasing/ordering.py
@@ -460,7 +460,8 @@ class OrderingBatchView(PurchasingBatchView):
         worksheet = workbook.active
         worksheet.title = "Purchase Order"
         worksheet.append(["Store", "Vendor", "Date ordered"])
-        worksheet.append([batch.store.name, batch.vendor.name, batch.date_ordered.strftime('%m/%d/%Y')])
+        date_ordered = batch.date_ordered.strftime('%m/%d/%Y') if batch.date_ordered else None
+        worksheet.append([batch.store.name, batch.vendor.name, date_ordered])
         worksheet.append([])
         worksheet.append(['vendor_code', 'upc', 'brand_name', 'description', 'cases_ordered', 'units_ordered'])
         for row in batch.active_rows():

From 659f5a8fe18d75ba4d5f2e9658c090812c397d94 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 18 Oct 2023 17:35:14 -0500
Subject: [PATCH 128/542] Replace dropdowns with autocomplete, for "find
 principals by perm"

---
 .../templates/principal/find_by_perm.mako     | 201 ++++++++++++++----
 tailbone/templates/principal/index.mako       |   4 +-
 tailbone/views/principal.py                   |  15 +-
 3 files changed, 173 insertions(+), 47 deletions(-)

diff --git a/tailbone/templates/principal/find_by_perm.mako b/tailbone/templates/principal/find_by_perm.mako
index 9cc5aa05..e0536324 100644
--- a/tailbone/templates/principal/find_by_perm.mako
+++ b/tailbone/templates/principal/find_by_perm.mako
@@ -16,44 +16,67 @@
     <div>
 
       ${h.form(request.current_route_url(), method='GET', **{'@submit': 'formSubmitting = true'})}
+        <div style="margin-left: 10rem; max-width: 50%;">
 
-      <b-field label="Permission Group" horizontal>
-        <b-select name="permission_group"
-                  v-model="selectedGroup"
-                  @input="selectGroup">
-          <option v-for="groupkey in sortedGroups"
-                  :key="groupkey"
-                  :value="groupkey">
-            {{ permissionGroups[groupkey].label }}
-          </option>
-        </b-select>
-      </b-field>
+          ${h.hidden('permission_group', **{':value': 'selectedGroup'})}
+          <b-field label="Permission Group" horizontal>
+            <b-autocomplete v-if="!selectedGroup"
+                            ref="permissionGroupAutocomplete"
+                            v-model="permissionGroupTerm"
+                            :data="permissionGroupChoices"
+                            field="groupkey"
+                            :custom-formatter="filtr => filtr.label"
+                            open-on-focus
+                            keep-first
+                            icon-pack="fas"
+                            clearable
+                            clear-on-select
+                            @select="permissionGroupSelect">
+            </b-autocomplete>
+            <b-button v-if="selectedGroup"
+                      @click="permissionGroupReset()">
+              {{ permissionGroups[selectedGroup].label }}
+            </b-button>
+          </b-field>
 
-      <b-field label="Permission" horizontal>
-        <b-select name="permission"
-                  v-model="selectedPermission">
-          <option v-for="perm in groupPermissions"
-                  :key="perm.permkey"
-                  :value="perm.permkey">
-            {{ perm.label }}
-          </option>
-        </b-select>
-      </b-field>
+          ${h.hidden('permission', **{':value': 'selectedPermission'})}
+          <b-field label="Permission" horizontal>
+            <b-autocomplete v-if="!selectedPermission"
+                            ref="permissionAutocomplete"
+                            v-model="permissionTerm"
+                            :data="permissionChoices"
+                            field="permkey"
+                            :custom-formatter="filtr => filtr.label"
+                            open-on-focus
+                            keep-first
+                            icon-pack="fas"
+                            clearable
+                            clear-on-select
+                            @select="permissionSelect">
+            </b-autocomplete>
+            <b-button v-if="selectedPermission"
+                      @click="permissionReset()">
+              {{ selectedPermissionLabel }}
+            </b-button>
+          </b-field>
 
-      <div class="buttons">
-        <once-button tag="a"
-                     href="${request.current_route_url(_query=None)}"
-                     text="Reset Form">
-        </once-button>
-        <b-button type="is-primary"
-                  native-type="submit"
-                  icon-pack="fas"
-                  icon-left="search"
-                  :disabled="formSubmitting">
-          {{ formSubmitting ? "Working, please wait..." : "Find ${model_title_plural}" }}
-        </b-button>
-      </div>
+          <b-field horizontal>
+            <div class="buttons" style="margin-top: 1rem;">
+              <once-button tag="a"
+                           href="${request.current_route_url(_query=None)}"
+                           text="Reset Form">
+              </once-button>
+              <b-button type="is-primary"
+                        native-type="submit"
+                        icon-pack="fas"
+                        icon-left="search"
+                        :disabled="formSubmitting">
+                {{ formSubmitting ? "Working, please wait..." : "Find ${model_title_plural}" }}
+              </b-button>
+            </div>
+          </b-field>
 
+        </div>
       ${h.end_form()}
 
       % if principals is not None:
@@ -91,24 +114,114 @@
         data() {
             return {
                 groupPermissions: ${json.dumps(buefy_perms.get(selected_group, {}).get('permissions', []))|n},
+                permissionGroupTerm: '',
+                permissionTerm: '',
                 selectedGroup: ${json.dumps(selected_group)|n},
-                % if selected_permission:
                 selectedPermission: ${json.dumps(selected_permission)|n},
-                % elif selected_group in buefy_perms:
-                selectedPermission: ${json.dumps(buefy_perms[selected_group]['permissions'][0]['permkey'])|n},
-                % else:
-                selectedPermission: null,
-                % endif
+                selectedPermissionLabel: ${json.dumps(selected_permission_label or '')|n},
                 formSubmitting: false,
             }
         },
+
+        computed: {
+
+            permissionGroupChoices() {
+
+                // collect all groups
+                let choices = []
+                for (let groupkey of this.sortedGroups) {
+                    choices.push(this.permissionGroups[groupkey])
+                }
+
+                // parse list of search terms
+                let terms = []
+                for (let term of this.permissionGroupTerm.toLowerCase().split(' ')) {
+                    term = term.trim()
+                    if (term) {
+                        terms.push(term)
+                    }
+                }
+
+                // filter groups by search terms
+                choices = choices.filter(option => {
+                    let label = option.label.toLowerCase()
+                    for (let term of terms) {
+                        if (label.indexOf(term) < 0) {
+                            return false
+                        }
+                    }
+                    return true
+                })
+
+                return choices
+            },
+
+            permissionChoices() {
+
+                // collect all permissions for current group
+                let choices = this.groupPermissions
+
+                // parse list of search terms
+                let terms = []
+                for (let term of this.permissionTerm.toLowerCase().split(' ')) {
+                    term = term.trim()
+                    if (term) {
+                        terms.push(term)
+                    }
+                }
+
+                // filter permissions by search terms
+                choices = choices.filter(option => {
+                    let label = option.label.toLowerCase()
+                    for (let term of terms) {
+                        if (label.indexOf(term) < 0) {
+                            return false
+                        }
+                    }
+                    return true
+                })
+
+                return choices
+            },
+        },
+
         methods: {
 
-            selectGroup(groupkey) {
+            permissionGroupSelect(option) {
+                this.selectedPermission = null
+                this.selectedPermissionLabel = null
+                if (option) {
+                    this.selectedGroup = option.groupkey
+                    this.groupPermissions = this.permissionGroups[option.groupkey].permissions
+                    this.$nextTick(() => {
+                        this.$refs.permissionAutocomplete.focus()
+                    })
+                }
+            },
 
-                // re-populate Permission dropdown, auto-select first option
-                this.groupPermissions = this.permissionGroups[groupkey].permissions
-                this.selectedPermission = this.groupPermissions[0].permkey
+            permissionGroupReset() {
+                this.selectedGroup = null
+                this.selectedPermission = null
+                this.selectedPermissionLabel = ''
+                this.$nextTick(() => {
+                    this.$refs.permissionGroupAutocomplete.focus()
+                })
+            },
+
+            permissionSelect(option) {
+                if (option) {
+                    this.selectedPermission = option.permkey
+                    this.selectedPermissionLabel = option.label
+                }
+            },
+
+            permissionReset() {
+                this.selectedPermission = null
+                this.selectedPermissionLabel = null
+                this.permissionTerm = ''
+                this.$nextTick(() => {
+                    this.$refs.permissionAutocomplete.focus()
+                })
             },
         }
     })
diff --git a/tailbone/templates/principal/index.mako b/tailbone/templates/principal/index.mako
index 4ed3ba5b..fa806455 100644
--- a/tailbone/templates/principal/index.mako
+++ b/tailbone/templates/principal/index.mako
@@ -3,8 +3,8 @@
 
 <%def name="context_menu_items()">
   ${parent.context_menu_items()}
-  % if request.has_perm('{}.find_by_perm'.format(permission_prefix)):
-      <li>${h.link_to("Find {} with Permission X".format(model_title_plural), url('{}.find_by_perm'.format(route_prefix)))}</li>
+  % if master.has_perm('find_by_perm'):
+      <li>${h.link_to(f"Find {model_title_plural} by Permission", url(f'{route_prefix}.find_by_perm'))}</li>
   % endif
 </%def>
 
diff --git a/tailbone/views/principal.py b/tailbone/views/principal.py
index 5d477677..20f6b866 100644
--- a/tailbone/views/principal.py
+++ b/tailbone/views/principal.py
@@ -77,7 +77,20 @@ class PrincipalMasterView(MasterView):
         perms = self.get_buefy_perms_data(sorted_perms)
         context['buefy_perms'] = perms
         context['buefy_sorted_groups'] = list(perms)
-        context['selected_group'] = permission_group or 'common'
+
+        if permission_group and permission_group not in perms:
+            permission_group = None
+        if permission:
+            if permission_group:
+                group = dict([(p['permkey'], p) for p in perms[permission_group]['permissions']])
+                if permission in group:
+                    context['selected_permission_label'] = group[permission]['label']
+                else:
+                    permission = None
+            else:
+                permission = None
+
+        context['selected_group'] = permission_group
         context['selected_permission'] = permission
 
         return self.render_to_response('find_by_perm', context)

From 919d8d109fa9c30a3686f1af2786cafa205755f9 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 18 Oct 2023 18:18:55 -0500
Subject: [PATCH 129/542] Use `Grid.make_sorter()` instead of legacy code

b/c multi-column sorting relies on this
---
 tailbone/views/bouncer.py              | 16 +++++---
 tailbone/views/customers.py            | 16 ++++----
 tailbone/views/employees.py            | 41 ++++++++++-----------
 tailbone/views/members.py              |  8 ++--
 tailbone/views/messages.py             | 31 ++++++++++------
 tailbone/views/people.py               | 51 ++++++++++++++------------
 tailbone/views/products.py             | 40 ++++++++++----------
 tailbone/views/purchases/core.py       | 51 ++++++++++++++------------
 tailbone/views/purchasing/batch.py     | 17 +++++----
 tailbone/views/purchasing/receiving.py | 32 ++++++++--------
 tailbone/views/shifts/core.py          | 29 ++++++++-------
 tailbone/views/tempmon/probes.py       |  5 ++-
 tailbone/views/tempmon/readings.py     | 31 ++++++++--------
 tailbone/views/views.py                |  6 +--
 14 files changed, 198 insertions(+), 176 deletions(-)

diff --git a/tailbone/views/bouncer.py b/tailbone/views/bouncer.py
index 3416bbed..7afcc567 100644
--- a/tailbone/views/bouncer.py
+++ b/tailbone/views/bouncer.py
@@ -61,7 +61,7 @@ class EmailBounceView(MasterView):
     ]
 
     def __init__(self, request):
-        super(EmailBounceView, self).__init__(request)
+        super().__init__(request)
         self.handler_options = sorted(get_profile_keys(self.rattail_config))
 
     def get_handler(self, bounce):
@@ -69,17 +69,21 @@ class EmailBounceView(MasterView):
         return app.get_bounce_handler(bounce.config_key)
 
     def configure_grid(self, g):
-        super(EmailBounceView, self).configure_grid(g)
+        super().configure_grid(g)
+        model = self.model
 
         g.filters['config_key'].set_choices(self.handler_options)
         g.filters['config_key'].default_active = True
         g.filters['config_key'].default_verb = 'equal'
 
-        g.joiners['processed_by'] = lambda q: q.outerjoin(model.User)
         g.filters['processed'].default_active = True
         g.filters['processed'].default_verb = 'is_null'
-        g.filters['processed_by'] = g.make_filter('processed_by', model.User.username)
-        g.sorters['processed_by'] = g.make_sorter(model.User.username)
+
+        # processed_by
+        g.set_joiner('processed_by', lambda q: q.outerjoin(model.User))
+        g.set_sorter('processed_by', model.User.username)
+        g.set_filter('processed_by', model.User.username)
+
         g.set_sort_defaults('bounced', 'desc')
 
         g.set_label('bounce_recipient_address', "Bounced To")
@@ -89,7 +93,7 @@ class EmailBounceView(MasterView):
         g.set_link('intended_recipient_address')
 
     def configure_form(self, f):
-        super(EmailBounceView, self).configure_form(f)
+        super().configure_form(f)
         bounce = f.model_instance
         f.set_renderer('message', self.render_message_file)
         f.set_renderer('links', self.render_links)
diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py
index dd8923e6..668f4a2b 100644
--- a/tailbone/views/customers.py
+++ b/tailbone/views/customers.py
@@ -168,22 +168,22 @@ class CustomerView(MasterView):
         g.filters['name'].default_verb = 'contains'
 
         # phone
+        g.set_label('phone', "Phone Number")
         g.set_joiner('phone', lambda q: q.outerjoin(model.CustomerPhoneNumber, sa.and_(
             model.CustomerPhoneNumber.parent_uuid == model.Customer.uuid,
             model.CustomerPhoneNumber.preference == 1)))
-        g.sorters['phone'] = lambda q, d: q.order_by(getattr(model.CustomerPhoneNumber.number, d)())
+        g.set_sorter('phone', model.CustomerPhoneNumber.number)
         g.set_filter('phone', model.CustomerPhoneNumber.number,
                      # label="Phone Number",
                      factory=grids.filters.AlchemyPhoneNumberFilter)
-        g.set_label('phone', "Phone Number")
 
         # email
+        g.set_label('email', "Email Address")
         g.set_joiner('email', lambda q: q.outerjoin(model.CustomerEmailAddress, sa.and_(
             model.CustomerEmailAddress.parent_uuid == model.Customer.uuid,
             model.CustomerEmailAddress.preference == 1)))
-        g.sorters['email'] = lambda q, d: q.order_by(getattr(model.CustomerEmailAddress.address, d)())
+        g.set_sorter('email', model.CustomerEmailAddress.address)
         g.set_filter('email', model.CustomerEmailAddress.address)#, label="Email Address")
-        g.set_label('email', "Email Address")
 
         # email_preference
         g.set_enum('email_preference', self.enum.EMAIL_PREFERENCE)
@@ -244,7 +244,7 @@ class CustomerView(MasterView):
 
     def get_instance(self):
         try:
-            instance = super(CustomerView, self).get_instance()
+            instance = super().get_instance()
         except HTTPNotFound:
             pass
         else:
@@ -273,7 +273,7 @@ class CustomerView(MasterView):
         raise HTTPNotFound
 
     def configure_form(self, f):
-        super(CustomerView, self).configure_form(f)
+        super().configure_form(f)
         customer = f.model_instance
         permission_prefix = self.get_permission_prefix()
 
@@ -802,7 +802,7 @@ class PendingCustomerView(MasterView):
     ]
 
     def configure_grid(self, g):
-        super(PendingCustomerView, self).configure_grid(g)
+        super().configure_grid(g)
 
         g.set_enum('status_code', self.enum.PENDING_CUSTOMER_STATUS)
         g.filters['status_code'].default_active = True
@@ -814,7 +814,7 @@ class PendingCustomerView(MasterView):
         g.set_link('display_name')
 
     def configure_form(self, f):
-        super(PendingCustomerView, self).configure_form(f)
+        super().configure_form(f)
 
         f.set_enum('status_code', self.enum.PENDING_CUSTOMER_STATUS)
 
diff --git a/tailbone/views/employees.py b/tailbone/views/employees.py
index 973075b6..f4f99058 100644
--- a/tailbone/views/employees.py
+++ b/tailbone/views/employees.py
@@ -96,7 +96,7 @@ class EmployeeView(MasterView):
         return app.get_people_handler().get_quickie_search_placeholder()
 
     def configure_grid(self, g):
-        super(EmployeeView, self).configure_grid(g)
+        super().configure_grid(g)
         route_prefix = self.get_route_prefix()
 
         # phone
@@ -115,9 +115,20 @@ class EmployeeView(MasterView):
         g.filters['email'] = g.make_filter('email', model.EmployeeEmailAddress.address,
                                            label="Email Address")
 
-        # first/last name
-        g.filters['first_name'] = g.make_filter('first_name', model.Person.first_name)
-        g.filters['last_name'] = g.make_filter('last_name', model.Person.last_name)
+        # first_name
+        g.set_link('first_name')
+        g.set_sorter('first_name', model.Person.first_name)
+        g.set_sort_defaults('first_name')
+        g.set_filter('first_name', model.Person.first_name,
+                     default_active=True,
+                     default_verb='contains')
+
+        # last_name
+        g.set_link('last_name')
+        g.set_sorter('last_name', model.Person.last_name)
+        g.set_filter('last_name', model.Person.last_name,
+                     default_active=True,
+                     default_verb='contains')
 
         # username
         if self.request.has_perm('users.view'):
@@ -145,18 +156,7 @@ class EmployeeView(MasterView):
             g.remove('status')
             del g.filters['status']
 
-        g.filters['first_name'].default_active = True
-        g.filters['first_name'].default_verb = 'contains'
-
-        g.filters['last_name'].default_active = True
-        g.filters['last_name'].default_verb = 'contains'
-
-        g.sorters['first_name'] = lambda q, d: q.order_by(getattr(model.Person.first_name, d)())
-        g.sorters['last_name'] = lambda q, d: q.order_by(getattr(model.Person.last_name, d)())
-
-        g.sorters['email'] = lambda q, d: q.order_by(getattr(model.EmployeeEmailAddress.address, d)())
-
-        g.set_sort_defaults('first_name')
+        g.set_sorter('email', model.EmployeeEmailAddress.address)
 
         g.set_label('email', "Email Address")
 
@@ -170,9 +170,6 @@ class EmployeeView(MasterView):
             g.main_actions.insert(1, self.make_action(
                 'view_raw', url=url, icon='eye'))
 
-        g.set_link('first_name')
-        g.set_link('last_name')
-
     def default_view_url(self):
         if (self.request.has_perm('people.view_profile')
             and self.should_link_straight_to_profile()):
@@ -196,7 +193,7 @@ class EmployeeView(MasterView):
                                            default=False)
 
     def query(self, session):
-        query = super(EmployeeView, self).query(session)
+        query = super().query(session)
         query = query.join(model.Person)
         if not self.has_perm('view_all'):
             query = query.filter(model.Employee.status == self.enum.EMPLOYEE_STATUS_CURRENT)
@@ -229,7 +226,7 @@ class EmployeeView(MasterView):
         return not self.is_employee_protected(employee)
 
     def configure_form(self, f):
-        super(EmployeeView, self).configure_form(f)
+        super().configure_form(f)
         employee = f.model_instance
 
         f.set_renderer('person', self.render_person)
@@ -283,7 +280,7 @@ class EmployeeView(MasterView):
     def objectify(self, form, data=None):
         if data is None:
             data = form.validated
-        employee = super(EmployeeView, self).objectify(form, data)
+        employee = super().objectify(form, data)
         self.update_stores(employee, data)
         self.update_departments(employee, data)
         return employee
diff --git a/tailbone/views/members.py b/tailbone/views/members.py
index 74b15512..3a4ff0a1 100644
--- a/tailbone/views/members.py
+++ b/tailbone/views/members.py
@@ -196,21 +196,21 @@ class MemberView(MasterView):
         g.filters['active'].default_verb = 'is_true'
 
         # phone
+        g.set_label('phone', "Phone Number")
         g.set_joiner('phone', lambda q: q.outerjoin(model.MemberPhoneNumber, sa.and_(
             model.MemberPhoneNumber.parent_uuid == model.Member.uuid,
             model.MemberPhoneNumber.preference == 1)))
-        g.sorters['phone'] = lambda q, d: q.order_by(getattr(model.MemberPhoneNumber.number, d)())
+        g.set_sorter('phone', model.MemberPhoneNumber.number)
         g.set_filter('phone', model.MemberPhoneNumber.number,
                      factory=grids.filters.AlchemyPhoneNumberFilter)
-        g.set_label('phone', "Phone Number")
 
         # email
+        g.set_label('email', "Email Address")
         g.set_joiner('email', lambda q: q.outerjoin(model.MemberEmailAddress, sa.and_(
             model.MemberEmailAddress.parent_uuid == model.Member.uuid,
             model.MemberEmailAddress.preference == 1)))
-        g.sorters['email'] = lambda q, d: q.order_by(getattr(model.MemberEmailAddress.address, d)())
+        g.set_sorter('email', model.MemberEmailAddress.address)
         g.set_filter('email', model.MemberEmailAddress.address)
-        g.set_label('email', "Email Address")
 
         # membership_type
         g.set_joiner('membership_type', lambda q: q.outerjoin(model.MembershipType))
diff --git a/tailbone/views/messages.py b/tailbone/views/messages.py
index 4c83da34..d1509163 100644
--- a/tailbone/views/messages.py
+++ b/tailbone/views/messages.py
@@ -84,12 +84,12 @@ class MessageView(MasterView):
     def index(self):
         if not self.request.user:
             raise httpexceptions.HTTPForbidden
-        return super(MessageView, self).index()
+        return super().index()
 
     def get_instance(self):
         if not self.request.user:
             raise httpexceptions.HTTPForbidden
-        message = super(MessageView, self).get_instance()
+        message = super().get_instance()
         if not self.associated_with(message):
             raise httpexceptions.HTTPForbidden
         return message
@@ -108,11 +108,18 @@ class MessageView(MasterView):
                       .filter(model.MessageRecipient.recipient == self.request.user)
 
     def configure_grid(self, g):
-        
-        g.joiners['sender'] = lambda q: q.join(model.User, model.User.uuid == model.Message.sender_uuid).outerjoin(model.Person)
-        g.filters['sender'] = g.make_filter('sender', model.Person.display_name,
-                                            default_active=True, default_verb='contains')
-        g.sorters['sender'] = g.make_sorter(model.Person.display_name)
+        super().configure_grid(g)
+        model = self.model
+
+        # sender
+        g.set_joiner('sender',
+                     lambda q: q.join(model.User,
+                                      model.User.uuid == model.Message.sender_uuid)\
+                     .outerjoin(model.Person))
+        g.set_sorter('sender', model.Person.display_name)
+        g.set_filter('sender', model.Person.display_name,
+                     default_active=True,
+                     default_verb='contains')
 
         g.filters['subject'].default_active = True
         g.filters['subject'].default_verb = 'contains'
@@ -201,7 +208,7 @@ class MessageView(MasterView):
     #     return form
 
     def configure_form(self, f):
-        super(MessageView, self).configure_form(f)
+        super().configure_form(f)
 
         f.submit_label = "Send Message"
 
@@ -274,7 +281,7 @@ class MessageView(MasterView):
     def objectify(self, form, data=None):
         if data is None:
             data = form.validated
-        message = super(MessageView, self).objectify(form, data)
+        message = super().objectify(form, data)
 
         if self.creating:
             if self.request.user:
@@ -463,7 +470,7 @@ class InboxView(MessageView):
         return self.request.route_url('messages.inbox')
 
     def query(self, session):
-        q = super(InboxView, self).query(session)
+        q = super().query(session)
         return q.filter(model.MessageRecipient.status == self.enum.MESSAGE_STATUS_INBOX)
 
 
@@ -479,7 +486,7 @@ class ArchiveView(MessageView):
         return self.request.route_url('messages.archive')
 
     def query(self, session):
-        q = super(ArchiveView, self).query(session)
+        q = super().query(session)
         return q.filter(model.MessageRecipient.status == self.enum.MESSAGE_STATUS_ARCHIVE)
 
 
@@ -500,7 +507,7 @@ class SentView(MessageView):
                       .filter(model.Message.sender == self.request.user)
 
     def configure_grid(self, g):
-        super(SentView, self).configure_grid(g)
+        super().configure_grid(g)
         g.filters['sender'].default_active = False
         g.joiners['recipients'] = lambda q: q.join(model.MessageRecipient)\
                                              .join(model.User, model.User.uuid == model.MessageRecipient.recipient_uuid)\
diff --git a/tailbone/views/people.py b/tailbone/views/people.py
index 31760d2a..7f786ace 100644
--- a/tailbone/views/people.py
+++ b/tailbone/views/people.py
@@ -95,7 +95,7 @@ class PersonView(MasterView):
     mergeable = True
 
     def __init__(self, request):
-        super(PersonView, self).__init__(request)
+        super().__init__(request)
         app = self.get_rattail_app()
 
         # always get a reference to the People Handler
@@ -105,7 +105,7 @@ class PersonView(MasterView):
         self.handler = self.people_handler
 
     def make_grid_kwargs(self, **kwargs):
-        kwargs = super(PersonView, self).make_grid_kwargs(**kwargs)
+        kwargs = super().make_grid_kwargs(**kwargs)
 
         # turn on checkboxes if user can create a merge reqeust
         if self.mergeable and self.has_perm('request_merge'):
@@ -114,18 +114,28 @@ class PersonView(MasterView):
         return kwargs
 
     def configure_grid(self, g):
-        super(PersonView, self).configure_grid(g)
+        super().configure_grid(g)
         route_prefix = self.get_route_prefix()
         model = self.model
 
-        g.joiners['email'] = lambda q: q.outerjoin(model.PersonEmailAddress, sa.and_(
-            model.PersonEmailAddress.parent_uuid == model.Person.uuid,
-            model.PersonEmailAddress.preference == 1))
-        g.joiners['phone'] = lambda q: q.outerjoin(model.PersonPhoneNumber, sa.and_(
-            model.PersonPhoneNumber.parent_uuid == model.Person.uuid,
-            model.PersonPhoneNumber.preference == 1))
+        # email
+        g.set_label('email', "Email Address")
+        g.set_joiner('email', lambda q: q.outerjoin(
+            model.PersonEmailAddress,
+            sa.and_(
+                model.PersonEmailAddress.parent_uuid == model.Person.uuid,
+                model.PersonEmailAddress.preference == 1)))
+        g.set_sorter('email', model.PersonEmailAddress.address)
+        g.set_filter('email', model.PersonEmailAddress.address)
 
-        g.filters['email'] = g.make_filter('email', model.PersonEmailAddress.address)
+        # phone
+        g.set_label('phone', "Phone Number")
+        g.set_joiner('phone', lambda q: q.outerjoin(
+            model.PersonPhoneNumber,
+            sa.and_(
+                model.PersonPhoneNumber.parent_uuid == model.Person.uuid,
+                model.PersonPhoneNumber.preference == 1)))
+        g.set_sorter('phone', model.PersonPhoneNumber.number)
         g.set_filter('phone', model.PersonPhoneNumber.number,
                      factory=grids.filters.AlchemyPhoneNumberFilter)
 
@@ -151,17 +161,12 @@ class PersonView(MasterView):
         g.set_filter('employee_status', model.Employee.status,
                      value_enum=self.enum.EMPLOYEE_STATUS)
 
-        g.sorters['email'] = lambda q, d: q.order_by(getattr(model.PersonEmailAddress.address, d)())
-        g.sorters['phone'] = lambda q, d: q.order_by(getattr(model.PersonPhoneNumber.number, d)())
-
         g.set_label('merge_requested', "MR")
         g.set_renderer('merge_requested', self.render_merge_requested)
 
         g.set_sort_defaults('display_name')
 
         g.set_label('display_name', "Full Name")
-        g.set_label('phone', "Phone Number")
-        g.set_label('email', "Email Address")
         g.set_label('customer_id', "Customer ID")
 
         if (self.has_perm('view_profile')
@@ -237,7 +242,7 @@ class PersonView(MasterView):
             data = form.validated
 
         # do normal create/update
-        person = super(PersonView, self).objectify(form, data)
+        person = super().objectify(form, data)
 
         # collect data from all name fields
         names = {}
@@ -278,7 +283,7 @@ class PersonView(MasterView):
             customer._people.reorder()
 
         # continue with normal logic
-        super(PersonView, self).delete_instance(person)
+        super().delete_instance(person)
 
     def touch_instance(self, person):
         """
@@ -288,7 +293,7 @@ class PersonView(MasterView):
         contact info record associated with them.
         """
         # touch person, as per usual
-        super(PersonView, self).touch_instance(person)
+        super().touch_instance(person)
 
         def touch(obj):
             change = model.Change()
@@ -310,7 +315,7 @@ class PersonView(MasterView):
             touch(address)
 
     def configure_common_form(self, f):
-        super(PersonView, self).configure_common_form(f)
+        super().configure_common_form(f)
         person = f.model_instance
 
         f.set_label('display_name', "Full Name")
@@ -1836,7 +1841,7 @@ class PersonNoteView(MasterView):
         return note.subject or "(no subject)"
 
     def configure_grid(self, g):
-        super(PersonNoteView, self).configure_grid(g)
+        super().configure_grid(g)
 
         # person
         g.set_joiner('person', lambda q: q.join(model.Person,
@@ -1857,7 +1862,7 @@ class PersonNoteView(MasterView):
         g.set_link('created')
 
     def configure_form(self, f):
-        super(PersonNoteView, self).configure_form(f)
+        super().configure_form(f)
 
         # person
         f.set_readonly('person')
@@ -1931,7 +1936,7 @@ class MergePeopleRequestView(MasterView):
     ]
 
     def configure_grid(self, g):
-        super(MergePeopleRequestView, self).configure_grid(g)
+        super().configure_grid(g)
 
         g.set_renderer('removing_uuid', self.render_referenced_person_name)
         g.set_renderer('keeping_uuid', self.render_referenced_person_name)
@@ -1960,7 +1965,7 @@ class MergePeopleRequestView(MasterView):
             keeping or "(not found)")
 
     def configure_form(self, f):
-        super(MergePeopleRequestView, self).configure_form(f)
+        super().configure_form(f)
 
         f.set_renderer('removing_uuid', self.render_referenced_person)
         f.set_renderer('keeping_uuid', self.render_referenced_person)
diff --git a/tailbone/views/products.py b/tailbone/views/products.py
index 327b6366..1ddf6ae0 100644
--- a/tailbone/views/products.py
+++ b/tailbone/views/products.py
@@ -167,7 +167,7 @@ class ProductView(MasterView):
     TPRPrice = orm.aliased(model.ProductPrice)
 
     def __init__(self, request):
-        super(ProductView, self).__init__(request)
+        super().__init__(request)
         self.expose_label_printing = self.rattail_config.getbool(
             'tailbone', 'products.print_labels', default=False)
 
@@ -224,7 +224,10 @@ class ProductView(MasterView):
         g.set_link(field)
 
         # brand
-        g.joiners['brand'] = lambda q: q.outerjoin(model.Brand)
+        g.set_joiner('brand', lambda q: q.outerjoin(model.Brand))
+        g.set_sorter('brand', model.Brand.name)
+        g.set_filter('brand', model.Brand.name,
+                     default_active=True, default_verb='contains')
 
         # department
         g.set_joiner('department', lambda q: q.outerjoin(model.Department))
@@ -237,12 +240,14 @@ class ProductView(MasterView):
                      verbs=['equal', 'not_equal', 'is_null', 'is_not_null', 'is_any'],
                      default_active=True, default_verb='equal')
 
-        g.joiners['subdepartment'] = lambda q: q.outerjoin(model.Subdepartment,
-                                                           model.Subdepartment.uuid == model.Product.subdepartment_uuid)
-        g.joiners['code'] = lambda q: q.outerjoin(model.ProductCode)
+        # subdepartment
+        g.set_joiner('subdepartment', lambda q: q.outerjoin(
+            model.Subdepartment,
+            model.Subdepartment.uuid == model.Product.subdepartment_uuid))
+        g.set_sorter('subdepartment', model.Subdepartment.name)
+        g.set_filter('subdepartment', model.Subdepartment.name)
 
-        g.sorters['brand'] = g.make_sorter(model.Brand.name)
-        g.sorters['subdepartment'] = g.make_sorter(model.Subdepartment.name)
+        g.joiners['code'] = lambda q: q.outerjoin(model.ProductCode)
 
         # vendor
         ProductVendorCost = orm.aliased(model.ProductCost)
@@ -296,9 +301,6 @@ class ProductView(MasterView):
 
         g.filters['description'].default_active = True
         g.filters['description'].default_verb = 'contains'
-        g.filters['brand'] = g.make_filter('brand', model.Brand.name,
-                                           default_active=True, default_verb='contains')
-        g.filters['subdepartment'] = g.make_filter('subdepartment', model.Subdepartment.name)
         g.filters['code'] = g.make_filter('code', model.ProductCode.code)
 
         # g.joiners['vendor_code_any'] = join_vendor_code_any
@@ -392,7 +394,7 @@ class ProductView(MasterView):
         g.set_link('description')
 
     def configure_common_form(self, f):
-        super(ProductView, self).configure_common_form(f)
+        super().configure_common_form(f)
         product = f.model_instance
 
         # unit_size
@@ -687,7 +689,7 @@ class ProductView(MasterView):
             return ' '.join(classes)
 
     def get_xlsx_fields(self):
-        fields = super(ProductView, self).get_xlsx_fields()
+        fields = super().get_xlsx_fields()
 
         i = fields.index('department_uuid')
         fields.insert(i + 1, 'department_number')
@@ -734,7 +736,7 @@ class ProductView(MasterView):
         return fields
 
     def get_xlsx_row(self, product, fields):
-        row = super(ProductView, self).get_xlsx_row(product, fields)
+        row = super().get_xlsx_row(product, fields)
 
         if 'upc' in fields and isinstance(row['upc'], GPC):
             row['upc'] = row['upc'].pretty()
@@ -799,7 +801,7 @@ class ProductView(MasterView):
         return row
 
     def download_results_normalize(self, product, fields, **kwargs):
-        data = super(ProductView, self).download_results_normalize(
+        data = super().download_results_normalize(
             product, fields, **kwargs)
 
         if 'upc' in data:
@@ -988,7 +990,7 @@ class ProductView(MasterView):
     def objectify(self, form, data=None):
         if data is None:
             data = form.validated
-        product = super(ProductView, self).objectify(form, data=data)
+        product = super().objectify(form, data=data)
 
         # regular_price_amount
         if (self.creating or self.editing) and 'regular_price_amount' in form.fields:
@@ -1163,7 +1165,7 @@ class ProductView(MasterView):
         return jsdata
 
     def template_kwargs_view(self, **kwargs):
-        kwargs = super(ProductView, self).template_kwargs_view(**kwargs)
+        kwargs = super().template_kwargs_view(**kwargs)
         product = kwargs['instance']
 
         kwargs['image_url'] = self.products_handler.get_image_url(product)
@@ -2287,7 +2289,7 @@ class PendingProductView(MasterView):
     ]
 
     def configure_grid(self, g):
-        super(PendingProductView, self).configure_grid(g)
+        super().configure_grid(g)
 
         g.set_enum('status_code', self.enum.PENDING_PRODUCT_STATUS)
         g.filters['status_code'].default_active = True
@@ -2299,7 +2301,7 @@ class PendingProductView(MasterView):
         g.set_link('description')
 
     def configure_form(self, f):
-        super(PendingProductView, self).configure_form(f)
+        super().configure_form(f)
         model = self.model
         pending = f.model_instance
 
@@ -2417,7 +2419,7 @@ class PendingProductView(MasterView):
         if data is None:
             data = form.validated
 
-        pending = super(PendingProductView, self).objectify(form, data)
+        pending = super().objectify(form, data)
 
         if not pending.user:
             pending.user = self.request.user
diff --git a/tailbone/views/purchases/core.py b/tailbone/views/purchases/core.py
index 77b02501..e7bebdff 100644
--- a/tailbone/views/purchases/core.py
+++ b/tailbone/views/purchases/core.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,10 +24,6 @@
 Views for "true" purchase orders
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
 from rattail.db import model
 
 from webhelpers2.html import HTML, tags
@@ -143,28 +139,35 @@ class PurchaseView(MasterView):
             if purchase.date_ordered:
                 return "{} (ordered {})".format(purchase.vendor, purchase.date_ordered.strftime('%Y-%m-%d'))
             return "{} (ordered)".format(purchase.vendor)
-        return six.text_type(purchase)
+        return str(purchase)
 
     def configure_grid(self, g):
-        super(PurchaseView, self).configure_grid(g)
+        super().configure_grid(g)
+        model = self.model
 
-        g.joiners['store'] = lambda q: q.join(model.Store)
-        g.filters['store'] = g.make_filter('store', model.Store.name)
-        g.sorters['store'] = g.make_sorter(model.Store.name)
+        # store
+        g.set_joiner('store', lambda q: q.join(model.Store))
+        g.set_sorter('store', model.Store.name)
+        g.set_filter('store', model.Store.name)
 
-        g.joiners['vendor'] = lambda q: q.join(model.Vendor)
-        g.filters['vendor'] = g.make_filter('vendor', model.Vendor.name,
-                                            default_active=True, default_verb='contains')
-        g.sorters['vendor'] = g.make_sorter(model.Vendor.name)
+        # 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,
+                     default_active=True,
+                     default_verb='contains')
 
-        g.joiners['department'] = lambda q: q.join(model.Department)
-        g.filters['department'] = g.make_filter('department', model.Department.name)
-        g.sorters['department'] = g.make_sorter(model.Department.name)
+        # department
+        g.set_joiner('department', lambda q: q.join(model.Department))
+        g.set_sorter('department', model.Department.name)
+        g.set_filter('department', model.Department.name)
 
-        g.joiners['buyer'] = lambda q: q.join(model.Employee).join(model.Person)
-        g.filters['buyer'] = g.make_filter('buyer', model.Person.display_name,
-                                           default_active=True, default_verb='contains')
-        g.sorters['buyer'] = g.make_sorter(model.Person.display_name)
+        # buyer
+        g.set_joiner('buyer', lambda q: q.join(model.Employee).join(model.Person))
+        g.set_sorter('buyer', model.Person.display_name)
+        g.set_filter('buyer', model.Person.display_name,
+                     default_active=True,
+                     default_verb='contains')
 
         # id
         g.set_renderer('id', self.render_id_str)
@@ -198,7 +201,7 @@ class PurchaseView(MasterView):
         g.set_link('invoice_total')
 
     def configure_form(self, f):
-        super(PurchaseView, self).configure_form(f)
+        super().configure_form(f)
 
         # id
         f.set_renderer('id', self.render_id_str)
@@ -322,7 +325,7 @@ class PurchaseView(MasterView):
                       .filter(model.PurchaseItem.purchase == purchase)
 
     def configure_row_grid(self, g):
-        super(PurchaseView, self).configure_row_grid(g)
+        super().configure_row_grid(g)
 
         g.set_sort_defaults('sequence')
 
@@ -353,7 +356,7 @@ class PurchaseView(MasterView):
             g.remove('po_total')
 
     def configure_row_form(self, f):
-        super(PurchaseView, self).configure_row_form(f)
+        super().configure_row_form(f)
 
         # quantity fields
         f.set_type('case_quantity', 'quantity')
diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py
index 96557d55..e49a5dea 100644
--- a/tailbone/views/purchasing/batch.py
+++ b/tailbone/views/purchasing/batch.py
@@ -175,9 +175,10 @@ class PurchasingBatchView(BatchMasterView):
         g.set_filter('vendor', model.Vendor.name,
                      default_active=True, default_verb='contains')
 
-        g.joiners['department'] = lambda q: q.join(model.Department)
-        g.filters['department'] = g.make_filter('department', model.Department.name)
-        g.sorters['department'] = g.make_sorter(model.Department.name)
+        # department
+        g.set_joiner('department', lambda q: q.join(model.Department))
+        g.set_filter('department', model.Department.name)
+        g.set_sorter('department', model.Department.name)
 
         g.set_joiner('buyer', lambda q: q.join(model.Employee).join(model.Person))
         g.set_filter('buyer', model.Person.display_name)
@@ -212,7 +213,7 @@ class PurchasingBatchView(BatchMasterView):
 #         return form
 
     def configure_common_form(self, f):
-        super(PurchasingBatchView, self).configure_common_form(f)
+        super().configure_common_form(f)
 
         # po_total
         if self.creating:
@@ -225,7 +226,7 @@ class PurchasingBatchView(BatchMasterView):
             f.set_type('po_total_calculated', 'currency')
 
     def configure_form(self, f):
-        super(PurchasingBatchView, self).configure_form(f)
+        super().configure_form(f)
         model = self.model
         batch = f.model_instance
         app = self.get_rattail_app()
@@ -598,7 +599,7 @@ class PurchasingBatchView(BatchMasterView):
 #         return query.options(orm.joinedload(model.PurchaseBatchRow.credits))
 
     def configure_row_grid(self, g):
-        super(PurchasingBatchView, self).configure_row_grid(g)
+        super().configure_row_grid(g)
 
         g.set_type('upc', 'gpc')
         g.set_type('cases_ordered', 'quantity')
@@ -685,7 +686,7 @@ class PurchasingBatchView(BatchMasterView):
             return 'notice'
 
     def configure_row_form(self, f):
-        super(PurchasingBatchView, self).configure_row_form(f)
+        super().configure_row_form(f)
         row = f.model_instance
         if self.creating:
             batch = self.get_instance()
@@ -894,7 +895,7 @@ class PurchasingBatchView(BatchMasterView):
                     batch.invoice_total -= row.invoice_total
 
             # do the "normal" save logic...
-            row = super(PurchasingBatchView, self).save_edit_row_form(form)
+            row = super().save_edit_row_form(form)
 
             # TODO: is this needed?
             # self.handler.refresh_row(row)
diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py
index 0cef3a37..3e78dfea 100644
--- a/tailbone/views/purchasing/receiving.py
+++ b/tailbone/views/purchasing/receiving.py
@@ -233,7 +233,7 @@ class ReceivingBatchView(PurchasingBatchView):
         return self.enum.PURCHASE_BATCH_MODE_RECEIVING
 
     def configure_grid(self, g):
-        super(ReceivingBatchView, self).configure_grid(g)
+        super().configure_grid(g)
 
         if not self.handler.allow_truck_dump_receiving():
             g.remove('truck_dump')
@@ -285,14 +285,14 @@ class ReceivingBatchView(PurchasingBatchView):
                 raise redirect
 
             # okay now do the normal thing, per workflow
-            return super(ReceivingBatchView, self).create(**kwargs)
+            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(ReceivingBatchView, self).create(form=form, **kwargs)
+            return super().create(form=form, **kwargs)
 
         # okay, at this point we need the user to select a vendor and workflow
         self.creating = True
@@ -372,14 +372,14 @@ class ReceivingBatchView(PurchasingBatchView):
 
         # first run it through the normal logic, if that doesn't like
         # it then we won't either
-        if not super(ReceivingBatchView, self).row_deletable(row):
+        if not super().row_deletable(row):
             return False
 
         # otherwise let handler decide
         return self.batch_handler.is_row_deletable(row)
 
     def get_instance_title(self, batch):
-        title = super(ReceivingBatchView, self).get_instance_title(batch)
+        title = super().get_instance_title(batch)
         if batch.is_truck_dump_parent():
             title = "{} (TRUCK DUMP PARENT)".format(title)
         elif batch.is_truck_dump_child():
@@ -633,7 +633,7 @@ class ReceivingBatchView(PurchasingBatchView):
             return info['display']
 
     def get_visible_params(self, batch):
-        params = super(ReceivingBatchView, self).get_visible_params(batch)
+        params = super().get_visible_params(batch)
 
         # remove this since we show it separately
         params.pop('invoice_files', None)
@@ -655,7 +655,7 @@ class ReceivingBatchView(PurchasingBatchView):
         return kwargs
 
     def get_batch_kwargs(self, batch, **kwargs):
-        kwargs = super(ReceivingBatchView, self).get_batch_kwargs(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
@@ -769,7 +769,7 @@ class ReceivingBatchView(PurchasingBatchView):
         return True
 
     def template_kwargs_view(self, **kwargs):
-        kwargs = super(ReceivingBatchView, self).template_kwargs_view(**kwargs)
+        kwargs = super().template_kwargs_view(**kwargs)
         batch = kwargs['instance']
 
         if self.handler.has_purchase_order(batch) and self.handler.has_invoice_file(batch):
@@ -810,7 +810,7 @@ class ReceivingBatchView(PurchasingBatchView):
         return credits_data
 
     def template_kwargs_view_row(self, **kwargs):
-        kwargs = super(ReceivingBatchView, self).template_kwargs_view_row(**kwargs)
+        kwargs = super().template_kwargs_view_row(**kwargs)
         app = self.get_rattail_app()
         products_handler = app.get_products_handler()
         row = kwargs['instance']
@@ -847,7 +847,7 @@ class ReceivingBatchView(PurchasingBatchView):
         if batch.is_truck_dump_parent():
             for child in batch.truck_dump_children:
                 self.delete_instance(child)
-        super(ReceivingBatchView, self).delete_instance(batch)
+        super().delete_instance(batch)
         if truck_dump:
             self.handler.refresh(truck_dump)
 
@@ -1010,7 +1010,7 @@ class ReceivingBatchView(PurchasingBatchView):
                               .group_by(model.PurchaseBatchCredit.row_uuid)\
                               .subquery()
         g.set_joiner('credits', lambda q: q.outerjoin(Credits))
-        g.sorters['credits'] = lambda q, d: q.order_by(getattr(Credits.c.credit_count, d)())
+        g.set_sorter('credits', Credits.c.credit_count)
 
         show_ordered = self.rattail_config.getbool(
             'rattail.batch', 'purchase.receiving.show_ordered_column_in_grid',
@@ -1083,7 +1083,7 @@ class ReceivingBatchView(PurchasingBatchView):
         })
 
     def row_grid_extra_class(self, row, i):
-        css_class = super(ReceivingBatchView, self).row_grid_extra_class(row, i)
+        css_class = super().row_grid_extra_class(row, i)
 
         if row.catalog_cost_confirmed:
             css_class = '{} catalog_cost_confirmed'.format(css_class or '')
@@ -1098,7 +1098,7 @@ class ReceivingBatchView(PurchasingBatchView):
             return str(row.product)
         if row.upc:
             return row.upc.pretty()
-        return super(ReceivingBatchView, self).get_row_instance_title(row)
+        return super().get_row_instance_title(row)
 
     def transform_unit_url(self, row, i):
         # grid action is shown only when we return a URL here
@@ -1110,7 +1110,7 @@ class ReceivingBatchView(PurchasingBatchView):
     def make_row_credits_grid(self, row):
 
         # first make grid like normal
-        g = super(ReceivingBatchView, self).make_row_credits_grid(row)
+        g = super().make_row_credits_grid(row)
 
         if (self.has_perm('edit_row')
             and self.row_editable(row)):
@@ -1616,7 +1616,7 @@ class ReceivingBatchView(PurchasingBatchView):
     def validate_row_form(self, form):
 
         # if normal validation fails, stop there
-        if not super(ReceivingBatchView, self).validate_row_form(form):
+        if not super().validate_row_form(form):
             return False
 
         # if user is editing row from truck dump child, then we must further
@@ -2097,7 +2097,7 @@ class ReceiveRowForm(colander.MappingSchema):
     quick_receive = colander.SchemaNode(colander.Boolean())
 
     def deserialize(self, *args):
-        result = super(ReceiveRowForm, self).deserialize(*args)
+        result = super().deserialize(*args)
 
         if result['mode'] == 'expired' and not result['expiration_date']:
             msg = "Expiration date is required for items with 'expired' mode."
diff --git a/tailbone/views/shifts/core.py b/tailbone/views/shifts/core.py
index 8fa934ea..53bfc446 100644
--- a/tailbone/views/shifts/core.py
+++ b/tailbone/views/shifts/core.py
@@ -84,7 +84,7 @@ class ScheduledShiftView(MasterView, ShiftViewMixin):
         g.set_label('employee', "Employee Name")
 
     def configure_form(self, f):
-        super(ScheduledShiftView, self).configure_form(f)
+        super().configure_form(f)
 
         f.set_renderer('length', self.render_shift_length)
 
@@ -118,19 +118,22 @@ class WorkedShiftView(MasterView, ShiftViewMixin):
     ]
 
     def configure_grid(self, g):
-        super(WorkedShiftView, self).configure_grid(g)
+        super().configure_grid(g)
+        model = self.model
 
-        g.joiners['employee'] = lambda q: q.join(model.Employee).join(model.Person)
-        g.filters['employee'] = g.make_filter('employee', model.Person.display_name)
-        g.sorters['employee'] = g.make_sorter(model.Person.display_name)
+        # employee
+        g.set_joiner('employee', lambda q: q.join(model.Employee).join(model.Person))
+        g.set_sorter('employee', model.Person.display_name)
+        g.set_filter('employee', model.Person.display_name)
 
-        g.joiners['store'] = lambda q: q.join(model.Store)
-        g.filters['store'] = g.make_filter('store', model.Store.name)
-        g.sorters['store'] = g.make_sorter(model.Store.name)
+        # store
+        g.set_joiner('store', lambda q: q.join(model.Store))
+        g.set_sorter('store', model.Store.name)
+        g.set_filter('store', model.Store.name)
 
         # TODO: these sorters should be automatic once we fix the schema
-        g.sorters['start_time'] = g.make_sorter(model.WorkedShift.punch_in)
-        g.sorters['end_time'] = g.make_sorter(model.WorkedShift.punch_out)
+        g.set_sorter('start_time', model.WorkedShift.punch_in)
+        g.set_sorter('end_time', model.WorkedShift.punch_out)
         # TODO: same goes for these renderers
         g.set_type('start_time', 'datetime')
         g.set_type('end_time', 'datetime')
@@ -150,7 +153,7 @@ class WorkedShiftView(MasterView, ShiftViewMixin):
         return "WorkedShift: {}, {}".format(shift.employee, date)
 
     def configure_form(self, f):
-        super(WorkedShiftView, self).configure_form(f)
+        super().configure_form(f)
 
         f.set_readonly('employee')
         f.set_renderer('employee', self.render_employee)
@@ -168,7 +171,7 @@ class WorkedShiftView(MasterView, ShiftViewMixin):
         return tags.link_to(text, url)
 
     def get_xlsx_fields(self):
-        fields = super(WorkedShiftView, self).get_xlsx_fields()
+        fields = super().get_xlsx_fields()
 
         # add employee name
         i = fields.index('employee_uuid')
@@ -180,7 +183,7 @@ class WorkedShiftView(MasterView, ShiftViewMixin):
         return fields
 
     def get_xlsx_row(self, shift, fields):
-        row = super(WorkedShiftView, self).get_xlsx_row(shift, fields)
+        row = super().get_xlsx_row(shift, fields)
 
         # localize start and end times (Excel requires time with no zone)
         if shift.punch_in:
diff --git a/tailbone/views/tempmon/probes.py b/tailbone/views/tempmon/probes.py
index dbf15dd1..573f9a2d 100644
--- a/tailbone/views/tempmon/probes.py
+++ b/tailbone/views/tempmon/probes.py
@@ -101,8 +101,9 @@ class TempmonProbeView(MasterView):
     def configure_grid(self, g):
         super().configure_grid(g)
 
-        g.joiners['client'] = lambda q: q.join(tempmon.Client)
-        g.sorters['client'] = g.make_sorter(tempmon.Client.config_key)
+        # client
+        g.set_joiner('client', lambda q: q.join(tempmon.Client))
+        g.set_sorter('client', tempmon.Client.config_key)
         g.set_sort_defaults('client')
 
         g.set_enum('appliance_type', self.enum.TEMPMON_APPLIANCE_TYPE)
diff --git a/tailbone/views/tempmon/readings.py b/tailbone/views/tempmon/readings.py
index a8223dd2..02e3fc51 100644
--- a/tailbone/views/tempmon/readings.py
+++ b/tailbone/views/tempmon/readings.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,9 +24,6 @@
 Views for tempmon readings
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
 from sqlalchemy import orm
 
 from rattail_tempmon.db import model as tempmon
@@ -70,17 +67,21 @@ class TempmonReadingView(MasterView):
                       .options(orm.joinedload(tempmon.Reading.client))
 
     def configure_grid(self, g):
-        super(TempmonReadingView, self).configure_grid(g)
+        super().configure_grid(g)
 
-        g.sorters['client_key'] = g.make_sorter(tempmon.Client.config_key)
-        g.filters['client_key'] = g.make_filter('client_key', tempmon.Client.config_key)
+        # client_key
+        g.set_sorter('client_key', tempmon.Client.config_key)
+        g.set_filter('client_key', tempmon.Client.config_key)
 
-        g.sorters['client_host'] = g.make_sorter(tempmon.Client.hostname)
-        g.filters['client_host'] = g.make_filter('client_host', tempmon.Client.hostname)
+        # client_host
+        g.set_sorter('client_host', tempmon.Client.hostname)
+        g.set_filter('client_host', tempmon.Client.hostname)
 
-        g.joiners['probe'] = lambda q: q.join(tempmon.Probe, tempmon.Probe.uuid == tempmon.Reading.probe_uuid)
-        g.sorters['probe'] = g.make_sorter(tempmon.Probe.description)
-        g.filters['probe'] = g.make_filter('probe', tempmon.Probe.description)
+        # probe
+        g.set_joiner('probe', lambda q: q.join(tempmon.Probe,
+                                               tempmon.Probe.uuid == tempmon.Reading.probe_uuid))
+        g.set_sorter('probe', tempmon.Probe.description)
+        g.set_filter('probe', tempmon.Probe.description)
 
         g.set_sort_defaults('taken', 'desc')
         g.set_type('taken', 'datetime')
@@ -98,7 +99,7 @@ class TempmonReadingView(MasterView):
         return reading.client.hostname
 
     def configure_form(self, f):
-        super(TempmonReadingView, self).configure_form(f)
+        super().configure_form(f)
 
         # client
         f.set_renderer('client', self.render_client)
@@ -112,7 +113,7 @@ class TempmonReadingView(MasterView):
         client = reading.client
         if not client:
             return ""
-        text = six.text_type(client)
+        text = str(client)
         url = self.request.route_url('tempmon.clients.view', uuid=client.uuid)
         return tags.link_to(text, url)
 
@@ -120,7 +121,7 @@ class TempmonReadingView(MasterView):
         probe = reading.probe
         if not probe:
             return ""
-        text = six.text_type(probe)
+        text = str(probe)
         url = self.request.route_url('tempmon.probes.view', uuid=probe.uuid)
         return tags.link_to(text, url)
 
diff --git a/tailbone/views/views.py b/tailbone/views/views.py
index 25828cde..67cba2e2 100644
--- a/tailbone/views/views.py
+++ b/tailbone/views/views.py
@@ -24,8 +24,6 @@
 Views for views
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import os
 import sys
 
@@ -80,7 +78,7 @@ class ModelViewView(MasterView):
         return data
 
     def configure_grid(self, g):
-        super(ModelViewView, self).configure_grid(g)
+        super().configure_grid(g)
 
         # label
         g.sorters['label'] = g.make_simple_sorter('label')
@@ -107,7 +105,7 @@ class ModelViewView(MasterView):
         return ModelViewSchema()
 
     def template_kwargs_create(self, **kwargs):
-        kwargs = super(ModelViewView, self).template_kwargs_create(**kwargs)
+        kwargs = super().template_kwargs_create(**kwargs)
         app = self.get_rattail_app()
         db_handler = app.get_db_handler()
 

From 13565d1c455818897b9ad4ecc2439620abec9f31 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 18 Oct 2023 21:24:37 -0500
Subject: [PATCH 130/542] Avoid "None" when rendering product UOM field

---
 tailbone/views/products.py | 269 +++++++++++++++++++------------------
 1 file changed, 137 insertions(+), 132 deletions(-)

diff --git a/tailbone/views/products.py b/tailbone/views/products.py
index 1ddf6ae0..449e7473 100644
--- a/tailbone/views/products.py
+++ b/tailbone/views/products.py
@@ -393,138 +393,6 @@ class ProductView(MasterView):
         g.set_link('item_id')
         g.set_link('description')
 
-    def configure_common_form(self, f):
-        super().configure_common_form(f)
-        product = f.model_instance
-
-        # unit_size
-        f.set_type('unit_size', 'quantity')
-
-        # unit_of_measure
-        f.set_enum('unit_of_measure', self.enum.UNIT_OF_MEASURE)
-        f.set_label('unit_of_measure', "Unit of Measure")
-
-        # packs
-        if self.creating:
-            f.remove_field('packs')
-        elif self.viewing and product.packs:
-            f.set_renderer('packs', self.render_packs)
-            f.set_label('packs', "Pack Items")
-        else:
-            f.remove_field('packs')
-
-        # pack_size
-        if self.viewing and not product.is_pack_item():
-            f.remove_field('pack_size')
-        else:
-            f.set_type('pack_size', 'quantity')
-
-        # default_pack
-        if self.viewing and not product.is_pack_item():
-            f.remove_field('default_pack')
-
-        # unit
-        if self.creating:
-            f.remove_field('unit')
-        elif self.viewing and product.is_pack_item():
-            f.set_renderer('unit', self.render_unit)
-            f.set_label('unit', "Unit Item")
-        else:
-            f.remove_field('unit')
-
-        # suggested_price
-        if self.creating:
-            f.remove_field('suggested_price')
-        else:
-            f.set_readonly('suggested_price')
-            f.set_renderer('suggested_price', self.render_suggested_price)
-
-        # regular_price
-        if self.creating:
-            f.remove_field('regular_price')
-        else:
-            f.set_readonly('regular_price')
-            f.set_renderer('regular_price', self.render_regular_price)
-
-        # current_price
-        if self.creating:
-            f.remove_field('current_price')
-        else:
-            f.set_readonly('current_price')
-            f.set_renderer('current_price', self.render_current_price)
-
-        # current_price_ends
-        if self.creating:
-            f.remove_field('current_price_ends')
-        else:
-            f.set_readonly('current_price_ends')
-            f.set_renderer('current_price_ends', self.render_current_price_ends)
-
-        # sale_price
-        if self.creating:
-            f.remove_field('sale_price')
-        else:
-            f.set_readonly('sale_price')
-            f.set_renderer('sale_price', self.render_price)
-
-        # sale_price_ends
-        if self.creating:
-            f.remove_field('sale_price_ends')
-        else:
-            f.set_readonly('sale_price_ends')
-            f.set_renderer('sale_price_ends', self.render_sale_price_ends)
-
-        # tpr_price
-        if self.creating:
-            f.remove_field('tpr_price')
-        else:
-            f.set_readonly('tpr_price')
-            f.set_renderer('tpr_price', self.render_price)
-
-        # tpr_price_ends
-        if self.creating:
-            f.remove_field('tpr_price_ends')
-        else:
-            f.set_readonly('tpr_price_ends')
-            f.set_renderer('tpr_price_ends', self.render_tpr_price_ends)
-
-        # vendor
-        if self.creating:
-            f.remove_field('vendor')
-        else:
-            f.set_readonly('vendor')
-            f.set_label('vendor', "Preferred Vendor")
-
-        # cost
-        if self.creating:
-            f.remove_field('cost')
-        else:
-            f.set_readonly('cost')
-            f.set_label('cost', "Preferred Unit Cost")
-            f.set_renderer('cost', self.render_cost)
-
-        # last_sold
-        if self.creating:
-            f.remove_field('last_sold')
-        else:
-            f.set_readonly('last_sold')
-
-        # inventory_on_hand
-        if self.creating:
-            f.remove_field('inventory_on_hand')
-        else:
-            f.set_readonly('inventory_on_hand')
-            f.set_renderer('inventory_on_hand', self.render_inventory_on_hand)
-            f.set_label('inventory_on_hand', "On Hand")
-
-        # inventory_on_order
-        if self.creating:
-            f.remove_field('inventory_on_order')
-        else:
-            f.set_readonly('inventory_on_order')
-            f.set_renderer('inventory_on_order', self.render_inventory_on_order)
-            f.set_label('inventory_on_order', "On Order")
-
     def render_cost(self, product, field):
         cost = getattr(product, field)
         if not cost:
@@ -824,6 +692,135 @@ class ProductView(MasterView):
         super().configure_form(f)
         product = f.model_instance
 
+        # unit_size
+        f.set_type('unit_size', 'quantity')
+
+        # unit_of_measure
+        f.set_enum('unit_of_measure', self.enum.UNIT_OF_MEASURE)
+        f.set_renderer('unit_of_measure', self.render_unit_of_measure)
+        f.set_label('unit_of_measure', "Unit of Measure")
+
+        # packs
+        if self.creating:
+            f.remove_field('packs')
+        elif self.viewing and product.packs:
+            f.set_renderer('packs', self.render_packs)
+            f.set_label('packs', "Pack Items")
+        else:
+            f.remove_field('packs')
+
+        # pack_size
+        if self.viewing and not product.is_pack_item():
+            f.remove_field('pack_size')
+        else:
+            f.set_type('pack_size', 'quantity')
+
+        # default_pack
+        if self.viewing and not product.is_pack_item():
+            f.remove_field('default_pack')
+
+        # unit
+        if self.creating:
+            f.remove_field('unit')
+        elif self.viewing and product.is_pack_item():
+            f.set_renderer('unit', self.render_unit)
+            f.set_label('unit', "Unit Item")
+        else:
+            f.remove_field('unit')
+
+        # suggested_price
+        if self.creating:
+            f.remove_field('suggested_price')
+        else:
+            f.set_readonly('suggested_price')
+            f.set_renderer('suggested_price', self.render_suggested_price)
+
+        # regular_price
+        if self.creating:
+            f.remove_field('regular_price')
+        else:
+            f.set_readonly('regular_price')
+            f.set_renderer('regular_price', self.render_regular_price)
+
+        # current_price
+        if self.creating:
+            f.remove_field('current_price')
+        else:
+            f.set_readonly('current_price')
+            f.set_renderer('current_price', self.render_current_price)
+
+        # current_price_ends
+        if self.creating:
+            f.remove_field('current_price_ends')
+        else:
+            f.set_readonly('current_price_ends')
+            f.set_renderer('current_price_ends', self.render_current_price_ends)
+
+        # sale_price
+        if self.creating:
+            f.remove_field('sale_price')
+        else:
+            f.set_readonly('sale_price')
+            f.set_renderer('sale_price', self.render_price)
+
+        # sale_price_ends
+        if self.creating:
+            f.remove_field('sale_price_ends')
+        else:
+            f.set_readonly('sale_price_ends')
+            f.set_renderer('sale_price_ends', self.render_sale_price_ends)
+
+        # tpr_price
+        if self.creating:
+            f.remove_field('tpr_price')
+        else:
+            f.set_readonly('tpr_price')
+            f.set_renderer('tpr_price', self.render_price)
+
+        # tpr_price_ends
+        if self.creating:
+            f.remove_field('tpr_price_ends')
+        else:
+            f.set_readonly('tpr_price_ends')
+            f.set_renderer('tpr_price_ends', self.render_tpr_price_ends)
+
+        # vendor
+        if self.creating:
+            f.remove_field('vendor')
+        else:
+            f.set_readonly('vendor')
+            f.set_label('vendor', "Preferred Vendor")
+
+        # cost
+        if self.creating:
+            f.remove_field('cost')
+        else:
+            f.set_readonly('cost')
+            f.set_label('cost', "Preferred Unit Cost")
+            f.set_renderer('cost', self.render_cost)
+
+        # last_sold
+        if self.creating:
+            f.remove_field('last_sold')
+        else:
+            f.set_readonly('last_sold')
+
+        # inventory_on_hand
+        if self.creating:
+            f.remove_field('inventory_on_hand')
+        else:
+            f.set_readonly('inventory_on_hand')
+            f.set_renderer('inventory_on_hand', self.render_inventory_on_hand)
+            f.set_label('inventory_on_hand', "On Hand")
+
+        # inventory_on_order
+        if self.creating:
+            f.remove_field('inventory_on_order')
+        else:
+            f.set_readonly('inventory_on_order')
+            f.set_renderer('inventory_on_order', self.render_inventory_on_order)
+            f.set_label('inventory_on_order', "On Order")
+
         # department
         if self.creating or self.editing:
             if 'department' in f.fields:
@@ -998,6 +995,14 @@ class ProductView(MasterView):
 
         return product
 
+    def render_unit_of_measure(self, product, field):
+        uom = getattr(product, field)
+        if uom is None:
+            return
+        if uom == self.enum.UNIT_OF_MEASURE_NONE:
+            return
+        return self.enum.UNIT_OF_MEASURE.get(uom, uom)
+
     def render_department(self, product, field):
         department = product.department
         if not department:

From 230a54cb99746009ac46701ad3242b4c0bd2b50c Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 18 Oct 2023 21:25:13 -0500
Subject: [PATCH 131/542] Fix default grid filter when "local" date times are
 involved

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

diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py
index a3d85006..6177d3d0 100644
--- a/tailbone/grids/core.py
+++ b/tailbone/grids/core.py
@@ -652,7 +652,10 @@ class Grid(object):
             elif isinstance(column.type, sa.Date):
                 factory = gridfilters.AlchemyDateFilter
             elif isinstance(column.type, sa.DateTime):
-                factory = gridfilters.AlchemyDateTimeFilter
+                if self.assume_local_times:
+                    factory = gridfilters.AlchemyLocalDateTimeFilter
+                else:
+                    factory = gridfilters.AlchemyDateTimeFilter
             elif isinstance(column.type, GPCType):
                 factory = gridfilters.AlchemyGPCFilter
         kwargs['column'] = column

From 954a2b78beff44dfcfd954c855deeea4e5905580 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 18 Oct 2023 21:25:32 -0500
Subject: [PATCH 132/542] Expose new price fields for POS batch row

---
 tailbone/views/batch/pos.py | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/tailbone/views/batch/pos.py b/tailbone/views/batch/pos.py
index f1e2b0d9..939879d2 100644
--- a/tailbone/views/batch/pos.py
+++ b/tailbone/views/batch/pos.py
@@ -107,7 +107,12 @@ class POSBatchView(BatchMasterView):
         'department_number',
         'department_name',
         'reg_price',
+        'cur_price',
+        'cur_price_type',
+        'cur_price_start',
+        'cur_price_end',
         'txn_price',
+        'txn_price_adjusted',
         'quantity',
         'sales_total',
         'tax_code',

From aaf6f05820fc771c856da5d454f10bfbd91a6714 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 19 Oct 2023 13:02:17 -0500
Subject: [PATCH 133/542] Remove sorter for "Credits?" column in purchasing
 batch row grid

too convoluted, and broken per recent sort overhaul
---
 tailbone/views/purchasing/receiving.py | 11 -----------
 1 file changed, 11 deletions(-)

diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py
index 3e78dfea..5ccf6081 100644
--- a/tailbone/views/purchasing/receiving.py
+++ b/tailbone/views/purchasing/receiving.py
@@ -31,7 +31,6 @@ import logging
 from collections import OrderedDict
 
 import humanize
-import sqlalchemy as sa
 
 from rattail import pod
 from rattail.time import localtime, make_utc
@@ -1002,16 +1001,6 @@ class ReceivingBatchView(PurchasingBatchView):
             g.set_click_handler('invoice_unit_cost',
                                 'this.invoiceUnitCostClicked')
 
-        # credits
-        # note that sorting by credits involves a subquery with group by clause.
-        # seems likely there may be a better way? but this seems to work fine
-        Credits = self.Session.query(model.PurchaseBatchCredit.row_uuid,
-                                     sa.func.count().label('credit_count'))\
-                              .group_by(model.PurchaseBatchCredit.row_uuid)\
-                              .subquery()
-        g.set_joiner('credits', lambda q: q.outerjoin(Credits))
-        g.set_sorter('credits', Credits.c.credit_count)
-
         show_ordered = self.rattail_config.getbool(
             'rattail.batch', 'purchase.receiving.show_ordered_column_in_grid',
             default=False)

From 0d302473538ca9a9536e38f9b3f621b2b30db3a4 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 19 Oct 2023 14:03:25 -0500
Subject: [PATCH 134/542] Add validtion to prevent duplicate files for
 multi-invoice receiving

by comparing sha256 hash values for each file
---
 tailbone/forms/core.py                 | 20 ++++++++++++++++++++
 tailbone/forms/widgets.py              | 15 +++++++++++++++
 tailbone/views/purchasing/receiving.py |  2 +-
 3 files changed, 36 insertions(+), 1 deletion(-)

diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py
index 06bf96e4..2c23b126 100644
--- a/tailbone/forms/core.py
+++ b/tailbone/forms/core.py
@@ -24,6 +24,7 @@
 Forms Core
 """
 
+import hashlib
 import json
 import logging
 import warnings
@@ -659,11 +660,25 @@ class Form(object):
                   'widget': MultiFileUploadWidget(tmpstore)}
             # if 'required' in kwargs and not kwargs['required']:
             #     kw['missing'] = colander.null
+            if kwargs.get('validate_unique'):
+                kw['validator'] = self.validate_multiple_files_unique
             files_node = colander.SequenceSchema(file_node, **kw)
             self.set_node(key, files_node)
         else:
             raise ValueError("unknown type for '{}' field: {}".format(key, type_))
 
+    def validate_multiple_files_unique(self, node, value):
+
+        # get SHA256 hash for each file; error if duplicates encountered
+        hashes = {}
+        for fileinfo in value:
+            fp = fileinfo['fp']
+            fp.seek(0)
+            filehash = hashlib.sha256(fp.read()).hexdigest()
+            if filehash in hashes:
+                node.raise_invalid(f"Duplicate file detected: {fileinfo['filename']}")
+            hashes[filehash] = fileinfo
+
     def set_enum(self, key, enum, empty=None):
         if enum:
             self.enums[key] = enum
@@ -906,6 +921,11 @@ class Form(object):
                 return json.dumps({'name': value['filename']})
             return 'null'
 
+        elif isinstance(value, list) and all([isinstance(f, dfwidget.filedict)
+                                              for f in value]):
+            return json.dumps([{'name': f['filename']}
+                               for f in value])
+
         app = self.request.rattail_config.get_app()
         value = app.json_friendly(value)
         return json.dumps(value)
diff --git a/tailbone/forms/widgets.py b/tailbone/forms/widgets.py
index 23bbac00..0b8d3dc9 100644
--- a/tailbone/forms/widgets.py
+++ b/tailbone/forms/widgets.py
@@ -323,6 +323,21 @@ class MultiFileUploadWidget(dfwidget.FileUploadWidget):
     template = 'multi_file_upload'
     requirements = ()
 
+    def serialize(self, field, cstruct, **kw):
+        if cstruct in (colander.null, None):
+            cstruct = []
+
+        if cstruct:
+            for fileinfo in cstruct:
+                uid = fileinfo['uid']
+                if uid not in self.tmpstore:
+                    self.tmpstore[uid] = fileinfo
+
+        readonly = kw.get("readonly", self.readonly)
+        template = readonly and self.readonly_template or self.template
+        values = self.get_template_values(field, cstruct, kw)
+        return field.renderer(template, **values)
+
     def deserialize(self, field, pstruct):
         if pstruct is colander.null:
             return colander.null
diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py
index 5ccf6081..9de4baa3 100644
--- a/tailbone/views/purchasing/receiving.py
+++ b/tailbone/views/purchasing/receiving.py
@@ -570,7 +570,7 @@ class ReceivingBatchView(PurchasingBatchView):
             elif workflow == 'from_multi_invoice':
                 if 'invoice_files' not in f:
                     f.insert_before('invoice_file', 'invoice_files')
-                f.set_type('invoice_files', 'multi_file')
+                f.set_type('invoice_files', 'multi_file', validate_unique=True)
                 f.set_required('invoice_parser_key')
                 f.remove('truck_dump_batch_uuid',
                          'po_number',

From 5e8ea6777393cd91760e8941cc331fd9622e2bf7 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 19 Oct 2023 14:57:06 -0500
Subject: [PATCH 135/542] Include invoice number for receiving batch row API

---
 tailbone/api/batch/receiving.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/tailbone/api/batch/receiving.py b/tailbone/api/batch/receiving.py
index 57501a7d..f8ce4a33 100644
--- a/tailbone/api/batch/receiving.py
+++ b/tailbone/api/batch/receiving.py
@@ -345,6 +345,7 @@ class ReceivingBatchRowViews(APIBatchRowView):
         data['po_unit_cost'] = row.po_unit_cost
         data['po_total'] = row.po_total
 
+        data['invoice_number'] = row.invoice_number
         data['invoice_unit_cost'] = row.invoice_unit_cost
         data['invoice_total'] = row.invoice_total
         data['invoice_total_calculated'] = row.invoice_total_calculated

From dc99828b66cecde71a56777445c17ddc5ec739fb Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 19 Oct 2023 19:12:28 -0500
Subject: [PATCH 136/542] Show food stamp tender info for POS batch

---
 tailbone/views/batch/pos.py | 5 ++++-
 tailbone/views/tenders.py   | 2 ++
 2 files changed, 6 insertions(+), 1 deletion(-)

diff --git a/tailbone/views/batch/pos.py b/tailbone/views/batch/pos.py
index 939879d2..bb7fbb39 100644
--- a/tailbone/views/batch/pos.py
+++ b/tailbone/views/batch/pos.py
@@ -49,6 +49,7 @@ class POSBatchView(BatchMasterView):
 
     labels = {
         'terminal_id': "Terminal ID",
+        'fs_tender_total': "FS Tender Total",
     }
 
     grid_columns = [
@@ -74,6 +75,7 @@ class POSBatchView(BatchMasterView):
         'sales_total',
         'taxes',
         'tender_total',
+        'fs_tender_total',
         'balance',
         'void',
         'training_mode',
@@ -152,6 +154,7 @@ class POSBatchView(BatchMasterView):
 
         g.set_type('sales_total', 'currency')
         g.set_type('tender_total', 'currency')
+        g.set_type('fs_tender_total', 'currency')
 
         # executed
         # nb. default view should show "all recent" batches regardless
@@ -178,7 +181,7 @@ class POSBatchView(BatchMasterView):
 
         f.set_type('sales_total', 'currency')
         f.set_type('tender_total', 'currency')
-        f.set_type('tender_total', 'currency')
+        f.set_type('fs_tender_total', 'currency')
 
         if self.viewing:
             f.set_renderer('taxes', self.render_taxes)
diff --git a/tailbone/views/tenders.py b/tailbone/views/tenders.py
index d5524e74..46f51c83 100644
--- a/tailbone/views/tenders.py
+++ b/tailbone/views/tenders.py
@@ -40,6 +40,7 @@ class TenderView(MasterView):
         'code',
         'name',
         'is_cash',
+        'is_foodstamp',
         'allow_cash_back',
         'kick_drawer',
     ]
@@ -48,6 +49,7 @@ class TenderView(MasterView):
         'code',
         'name',
         'is_cash',
+        'is_foodstamp',
         'allow_cash_back',
         'kick_drawer',
         'notes',

From d87de1dc4f44520657ca304216d32d0d8a586749 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 19 Oct 2023 20:48:52 -0500
Subject: [PATCH 137/542] Expose permission for POS batch, toggle training mode

---
 tailbone/views/batch/pos.py | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/tailbone/views/batch/pos.py b/tailbone/views/batch/pos.py
index bb7fbb39..9062ec12 100644
--- a/tailbone/views/batch/pos.py
+++ b/tailbone/views/batch/pos.py
@@ -288,6 +288,8 @@ class POSBatchView(BatchMasterView):
                                            "Remove customer from current transaction")
             # config.add_tailbone_permission('pos', 'pos.resume',
             #                                "Resume previously-suspended transaction")
+            config.add_tailbone_permission('pos', 'pos.toggle_training',
+                                           "Start/end training mode")
             config.add_tailbone_permission('pos', 'pos.suspend',
                                            "Suspend current transaction")
             config.add_tailbone_permission('pos', 'pos.swap_customer',

From 421266e70c53eb14f5e72d5ac99986ec683ea4fe Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 20 Oct 2023 14:29:45 -0500
Subject: [PATCH 138/542] Show more customer attrs for POS batch

---
 tailbone/views/batch/pos.py | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/tailbone/views/batch/pos.py b/tailbone/views/batch/pos.py
index 9062ec12..afda919e 100644
--- a/tailbone/views/batch/pos.py
+++ b/tailbone/views/batch/pos.py
@@ -70,6 +70,8 @@ class POSBatchView(BatchMasterView):
         'terminal_id',
         'cashier',
         'customer',
+        'customer_is_member',
+        'customer_is_employee',
         'params',
         'rowcount',
         'sales_total',

From 6d79766b24e8873f568bcdac62793e5c9fc1abfa Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sat, 21 Oct 2023 16:10:36 -0500
Subject: [PATCH 139/542] Stop using sa-filters for basic grid sorting

this just breaks if we need to use "aliased" models e.g. when sorting
and/or filtering by Product "regular price" column and similar.  so
now sorting more like we always used to, except for multi-column.

nb. this still assumes callers use `Grid.make_sorter()` when declaring
the sorters.  if caller must specify more custom/explicit sort logic
then it likely will not work and we'll have to add a workaround to
allow avoiding the common logic..but that's another day
---
 tailbone/grids/core.py     | 31 ++++++++++++++--------------
 tailbone/views/products.py | 42 ++++++++++++++++++++------------------
 2 files changed, 37 insertions(+), 36 deletions(-)

diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py
index 6177d3d0..5f28fca0 100644
--- a/tailbone/grids/core.py
+++ b/tailbone/grids/core.py
@@ -30,7 +30,6 @@ import logging
 
 import sqlalchemy as sa
 from sqlalchemy import orm
-from sa_filters import apply_sort
 
 from rattail.db.types import GPCType
 from rattail.util import prettify, pretty_boolean, pretty_quantity
@@ -1235,29 +1234,29 @@ class Grid(object):
         # TODO: is there a better way to check for SA sorting?
         if self.model_class:
 
-            # convert sort settings into a 'sortspec' for use with sa-filters
-            full_spec = []
+            # collect actual column sorters for order_by clause
+            sorters = []
             for sorter in self.active_sorters:
                 sortkey = sorter['field']
-                sortdir = sorter['order']
                 sortfunc = self.sorters.get(sortkey)
-                if sortfunc:
-                    spec = {
-                        'sortkey': sortkey,
-                        'model': sortfunc._class.__name__,
-                        'field': sortfunc._column.key,
-                        'direction': sortdir or 'asc',
-                    }
-                    full_spec.append(spec)
+                if not sortfunc:
+                    log.warning("unknown sorter: %s", sorter)
+                    continue
 
-            # apply joins needed for this sort spec
-            for spec in full_spec:
-                sortkey = spec['sortkey']
+                # 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)
 
-            return apply_sort(data, full_spec)
+                # add column/dir to collection
+                sortdir = sorter['order']
+                sorters.append(getattr(sortfunc._column, sortdir)())
+
+            # apply sorting to query
+            if sorters:
+                data = data.order_by(*sorters)
+
+            return data
 
         else:
             # not a SQLAlchemy grid, custom sorter
diff --git a/tailbone/views/products.py b/tailbone/views/products.py
index 449e7473..e9e32a21 100644
--- a/tailbone/views/products.py
+++ b/tailbone/views/products.py
@@ -160,12 +160,6 @@ class ProductView(MasterView):
         'inventory_on_order',
     ]
 
-    # same, but for prices
-    RegularPrice = orm.aliased(model.ProductPrice)
-    CurrentPrice = orm.aliased(model.ProductPrice)
-    SalePrice = orm.aliased(model.ProductPrice)
-    TPRPrice = orm.aliased(model.ProductPrice)
-
     def __init__(self, request):
         super().__init__(request)
         self.expose_label_printing = self.rattail_config.getbool(
@@ -332,28 +326,34 @@ class ProductView(MasterView):
         g.set_joiner('family', lambda q: q.outerjoin(model.Family))
         g.set_filter('family', model.Family.name)
 
+        # regular_price
         g.set_label('regular_price', "Reg. Price")
+        RegularPrice = orm.aliased(model.ProductPrice)
         g.set_joiner('regular_price', lambda q: q.outerjoin(
-            self.RegularPrice, self.RegularPrice.uuid == model.Product.regular_price_uuid))
-        g.set_sorter('regular_price', self.RegularPrice.price)
-        g.set_filter('regular_price', self.RegularPrice.price, label="Regular Price")
+            RegularPrice, RegularPrice.uuid == model.Product.regular_price_uuid))
+        g.set_sorter('regular_price', RegularPrice.price)
+        g.set_filter('regular_price', RegularPrice.price, label="Regular Price")
 
+        # current_price
         g.set_label('current_price', "Cur. Price")
         g.set_renderer('current_price', self.render_current_price_for_grid)
+        CurrentPrice = orm.aliased(model.ProductPrice)
         g.set_joiner('current_price', lambda q: q.outerjoin(
-            self.CurrentPrice, self.CurrentPrice.uuid == model.Product.current_price_uuid))
-        g.set_sorter('current_price', self.CurrentPrice.price)
-        g.set_filter('current_price', self.CurrentPrice.price, label="Current Price")
+            CurrentPrice, CurrentPrice.uuid == model.Product.current_price_uuid))
+        g.set_sorter('current_price', CurrentPrice.price)
+        g.set_filter('current_price', CurrentPrice.price, label="Current Price")
 
         # tpr_price
+        TPRPrice = orm.aliased(model.ProductPrice)
         g.set_joiner('tpr_price', lambda q: q.outerjoin(
-            self.TPRPrice, self.TPRPrice.uuid == model.Product.tpr_price_uuid))
-        g.set_filter('tpr_price', self.TPRPrice.price)
+            TPRPrice, TPRPrice.uuid == model.Product.tpr_price_uuid))
+        g.set_filter('tpr_price', TPRPrice.price)
 
         # sale_price
+        SalePrice = orm.aliased(model.ProductPrice)
         g.set_joiner('sale_price', lambda q: q.outerjoin(
-            self.SalePrice, self.SalePrice.uuid == model.Product.sale_price_uuid))
-        g.set_filter('sale_price', self.SalePrice.price)
+            SalePrice, SalePrice.uuid == model.Product.sale_price_uuid))
+        g.set_filter('sale_price', SalePrice.price)
 
         # suggested_price
         g.set_renderer('suggested_price', self.render_grid_suggested_price)
@@ -402,10 +402,12 @@ class ProductView(MasterView):
         return "${:0.2f}".format(cost.unit_cost)
 
     def render_price(self, product, field):
-        if not product.not_for_sale:
-            price = product[field]
-            if price:
-                return self.products_handler.render_price(price)
+        # TODO: previously this rendered null (empty string) if
+        # product was marked "not for sale" - but why? important?
+        #if not product.not_for_sale:
+        price = product[field]
+        if price:
+            return self.products_handler.render_price(price)
         
     def render_current_price_for_grid(self, product, field):
         text = self.render_price(product, field) or ""

From ec8a8d5ddc21b88fbc8037f76b41ecb4258b264c Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 23 Oct 2023 13:06:38 -0500
Subject: [PATCH 140/542] Update changelog

---
 CHANGES.rst          | 28 ++++++++++++++++++++++++++++
 tailbone/_version.py |  2 +-
 2 files changed, 29 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 8be310e7..fa562cde 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,34 @@
 CHANGELOG
 =========
 
+0.9.68 (2023-10-23)
+-------------------
+
+* Expose more permissions for POS.
+
+* Fix order xlsx download if missing order date.
+
+* Replace dropdowns with autocomplete, for "find principals by perm".
+
+* Use ``Grid.make_sorter()`` instead of legacy code.
+
+* Avoid "None" when rendering product UOM field.
+
+* Fix default grid filter when "local" date times are involved.
+
+* Expose new fields for POS batch/row.
+
+* Remove sorter for "Credits?" column in purchasing batch row grid.
+
+* Add validation to prevent duplicate files for multi-invoice receiving.
+
+* Include invoice number for receiving batch row API.
+
+* Show food stamp tender info for POS batch.
+
+* Stop using sa-filters for basic grid sorting.
+
+
 0.9.67 (2023-10-12)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 8e69986c..fcf12c27 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.67'
+__version__ = '0.9.68'

From f70772fabc5bf9646690583ca19bfec728724158 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 23 Oct 2023 15:48:48 -0500
Subject: [PATCH 141/542] Allow override of version diff for master views

and misc. other tweaks
---
 tailbone/templates/custorders/create.mako |  6 +++---
 tailbone/templates/master/view.mako       |  2 +-
 tailbone/views/master.py                  | 12 +++++++++---
 3 files changed, 13 insertions(+), 7 deletions(-)

diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako
index 055957bb..663c4300 100644
--- a/tailbone/templates/custorders/create.mako
+++ b/tailbone/templates/custorders/create.mako
@@ -574,7 +574,7 @@
                             </b-field>
 
                             <b-field label="Unit Size">
-                              <span>{{ productSize }}</span>
+                              <span>{{ productSize || '' }}</span>
                             </b-field>
 
                             <b-field label="Case Size">
@@ -734,7 +734,7 @@
                       <b-field grouped>
                         <b-field label="Product" horizontal>
                           <span :class="productIsKnown ? null : 'has-text-success'">
-                            {{ productIsKnown ? productDisplay : pendingProduct.brand_name + ' ' + pendingProduct.description + ' ' + pendingProduct.size }}
+                            {{ productIsKnown ? productDisplay : (pendingProduct.brand_name || '') + ' ' + (pendingProduct.description || '') + ' ' + (pendingProduct.size || '') }}
                           </span>
                         </b-field>
                       </b-field>
@@ -761,7 +761,7 @@
                                     :class="productPriceNeedsConfirmation ? 'has-background-warning' : ''"
                                 % endif
                                 >
-                            {{ productIsKnown ? productUnitPriceDisplay : '$' + pendingProduct.regular_price_amount }}
+                            {{ productIsKnown ? productUnitPriceDisplay : (pendingProduct.regular_price_amount ? '$' + pendingProduct.regular_price_amount : '') }}
                           </span>
                         </b-field>
 
diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako
index b5930664..9a37b2bb 100644
--- a/tailbone/templates/master/view.mako
+++ b/tailbone/templates/master/view.mako
@@ -35,7 +35,7 @@
       <nav class="panel">
         <p class="panel-heading">Cross-Reference</p>
         <div class="panel-block buttons">
-          <div style="display: flex; flex-direction: column;">
+          <div style="display: flex; flex-direction: column; gap: 0.5rem;">
             % for button in xref_buttons:
                 ${button}
             % endfor
diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index 176ff672..7a1eff98 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -1441,8 +1441,8 @@ class MasterView(View):
             changed_raw = app.render_datetime(app.localtime(txn.issued_at, from_utc=True))
             changed_ago = app.render_time_ago(app.make_utc() - txn.issued_at)
 
-            changed_by = str(txn.user)
-            if self.request.has_perm('users.view'):
+            changed_by = str(txn.user or '')
+            if self.request.has_perm('users.view') and txn.user:
                 changed_by = tags.link_to(changed_by, self.request.route_url('users.view', uuid=txn.user.uuid))
 
             return {
@@ -4961,10 +4961,16 @@ class MasterView(View):
     def make_diff(self, old_data, new_data, **kwargs):
         return diffs.Diff(old_data, new_data, **kwargs)
 
+    def get_version_diff_factory(self, **kwargs):
+        if hasattr(self, 'version_diff_factory'):
+            return self.version_diff_factory
+        return diffs.VersionDiff
+
     def make_version_diff(self, version, *args, **kwargs):
         if 'title' not in kwargs:
             kwargs['title'] = self.title_for_version(version)
-        return diffs.VersionDiff(version, *args, **kwargs)
+        factory = self.get_version_diff_factory()
+        return factory(version, *args, **kwargs)
 
     ##############################
     # Configuration Views

From 756b4b9d18036fcdb491e4f0a7ee051704afeab5 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 23 Oct 2023 20:35:43 -0500
Subject: [PATCH 142/542] No need to configure logging

since the rattail config object will do that when first made
---
 tailbone/app.py | 1 -
 1 file changed, 1 deletion(-)

diff --git a/tailbone/app.py b/tailbone/app.py
index 6f41a8de..ae10c9bc 100644
--- a/tailbone/app.py
+++ b/tailbone/app.py
@@ -60,7 +60,6 @@ def make_rattail_config(settings):
                                      "to the path of your config file.  Lame, but necessary.")
         rattail_config = make_config(path)
         settings['rattail_config'] = rattail_config
-    rattail_config.configure_logging()
 
     # configure database sessions
     if hasattr(rattail_config, 'rattail_engine'):

From 549976dcfbb88dbf0d6b02c8d6fcf66f486c685a Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 24 Oct 2023 09:27:12 -0500
Subject: [PATCH 143/542] Update changelog

---
 CHANGES.rst          | 8 ++++++++
 tailbone/_version.py | 2 +-
 2 files changed, 9 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index fa562cde..bf395f94 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,14 @@
 CHANGELOG
 =========
 
+0.9.69 (2023-10-24)
+-------------------
+
+* Allow override of version diff for master views.
+
+* No need to configure logging.
+
+
 0.9.68 (2023-10-23)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index fcf12c27..fa0bae73 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.68'
+__version__ = '0.9.69'

From 72e4daafc1d3d742de5c336572bcd9c88dd4eb97 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 24 Oct 2023 09:53:40 -0500
Subject: [PATCH 144/542] Fix config file priority for display, and batch
 subprocess commands

---
 tailbone/templates/appinfo/index.mako | 4 ++--
 tailbone/views/batch/core.py          | 2 +-
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/tailbone/templates/appinfo/index.mako b/tailbone/templates/appinfo/index.mako
index 40bf31ce..62a911ee 100644
--- a/tailbone/templates/appinfo/index.mako
+++ b/tailbone/templates/appinfo/index.mako
@@ -51,7 +51,7 @@
           </b-icon>
         </span>
 
-        <strong>Configuration Files</strong>
+        <span>Configuration Files (style: ${request.rattail_config._style})</span>
       </div>
     </template>
 
@@ -116,7 +116,7 @@
   ${parent.modify_this_page_vars()}
   <script type="text/javascript">
 
-    ThisPageData.configFiles = ${json.dumps([dict(path=p, priority=i) for i, p in enumerate(reversed(request.rattail_config.files_read), 1)])|n}
+    ThisPageData.configFiles = ${json.dumps([dict(path=p, priority=i) for i, p in enumerate(request.rattail_config.prioritized_files, 1)])|n}
 
   </script>
 </%def>
diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py
index 79d3f581..b9c28be7 100644
--- a/tailbone/views/batch/core.py
+++ b/tailbone/views/batch/core.py
@@ -931,7 +931,7 @@ class BatchMasterView(MasterView):
         prefix = self.rattail_config.get('rattail', 'command_prefix',
                                          default=sys.prefix)
         cmd = [os.path.join(prefix, 'bin/{}'.format(command))]
-        for path in reversed(self.rattail_config.files_read):
+        for path in self.rattail_config.prioritized_files:
             cmd.extend(['--config', path])
         if username:
             cmd.extend(['--runas', username])

From 1f3877b7cb15712feb5700eb1b132fb885e27bc6 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 24 Oct 2023 09:54:31 -0500
Subject: [PATCH 145/542] Update changelog

---
 CHANGES.rst          | 6 ++++++
 tailbone/_version.py | 2 +-
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index bf395f94..06db3d61 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,12 @@
 CHANGELOG
 =========
 
+0.9.70 (2023-10-24)
+-------------------
+
+* Fix config file priority for display, and batch subprocess commands.
+
+
 0.9.69 (2023-10-24)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index fa0bae73..deda170c 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.69'
+__version__ = '0.9.70'

From f708cb0b253b1b6fe4f23889bfba2cd2bba99be3 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 24 Oct 2023 17:44:02 -0500
Subject: [PATCH 146/542] Fix bug when editing vendor

---
 tailbone/views/vendors/core.py | 13 +++++++------
 1 file changed, 7 insertions(+), 6 deletions(-)

diff --git a/tailbone/views/vendors/core.py b/tailbone/views/vendors/core.py
index 743e1632..8b9361b7 100644
--- a/tailbone/views/vendors/core.py
+++ b/tailbone/views/vendors/core.py
@@ -78,7 +78,7 @@ class VendorView(MasterView):
     ]
 
     def configure_grid(self, g):
-        super(VendorView, self).configure_grid(g)
+        super().configure_grid(g)
 
         g.filters['name'].default_active = True
         g.filters['name'].default_verb = 'contains'
@@ -124,8 +124,9 @@ class VendorView(MasterView):
     def objectify(self, form, data=None):
         if data is None:
             data = form.validated
-        vendor = super(VendorView, self).objectify(form, data)
+        vendor = super().objectify(form, data)
         vendor = self.objectify_contact(vendor, data)
+        app = self.get_rattail_app()
 
         if 'orders_email' in data:
             address = data['orders_email']
@@ -169,7 +170,7 @@ class VendorView(MasterView):
             self.Session.delete(cost)
 
     def get_version_child_classes(self):
-        return super(VendorView, self).get_version_child_classes() + [
+        return super().get_version_child_classes() + [
             (model.VendorPhoneNumber, 'parent_uuid'),
             (model.VendorEmailAddress, 'parent_uuid'),
             (model.VendorContact, 'vendor_uuid'),
@@ -186,14 +187,14 @@ class VendorView(MasterView):
         ]
 
     def configure_get_context(self, **kwargs):
-        context = super(VendorView, self).configure_get_context(**kwargs)
+        context = super().configure_get_context(**kwargs)
 
         context['supported_vendor_settings'] = self.configure_get_supported_vendor_settings()
 
         return context
 
     def configure_gather_settings(self, data, **kwargs):
-        settings = super(VendorView, self).configure_gather_settings(
+        settings = super().configure_gather_settings(
             data, **kwargs)
 
         supported_vendor_settings = self.configure_get_supported_vendor_settings()
@@ -205,7 +206,7 @@ class VendorView(MasterView):
         return settings
 
     def configure_remove_settings(self, **kwargs):
-        super(VendorView, self).configure_remove_settings(**kwargs)
+        super().configure_remove_settings(**kwargs)
         app = self.get_rattail_app()
         names = []
 

From e308108bf761ab446c552255e5e175a640ae4f42 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 24 Oct 2023 17:48:08 -0500
Subject: [PATCH 147/542] Show user warning if "add item to custorder" fails

specifically, if user enters alpha chars for cost/price fields
---
 tailbone/templates/custorders/create.mako | 2 ++
 tailbone/views/custorders/orders.py       | 5 ++++-
 2 files changed, 6 insertions(+), 1 deletion(-)

diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako
index 663c4300..7d3b367f 100644
--- a/tailbone/templates/custorders/create.mako
+++ b/tailbone/templates/custorders/create.mako
@@ -2124,6 +2124,8 @@
 
                     this.itemDialogSaving = false
                     this.showingItemDialog = false
+                }, response => {
+                    this.itemDialogSaving = false
                 })
             },
         },
diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py
index f88886bb..60949e8f 100644
--- a/tailbone/views/custorders/orders.py
+++ b/tailbone/views/custorders/orders.py
@@ -864,7 +864,10 @@ class CustomerOrderView(MasterView):
 
             for field in ('unit_cost', 'regular_price_amount', 'case_size'):
                 if field in pending_info:
-                    pending_info[field] = decimal.Decimal(pending_info[field])
+                    try:
+                        pending_info[field] = decimal.Decimal(pending_info[field])
+                    except decimal.InvalidOperation:
+                        return {'error': f"Invalid entry for field: {field}"}
 
             pending_info['user'] = self.request.user
 

From 4247804707b69e0fe6f2291e027307060c42696e Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 24 Oct 2023 19:17:36 -0500
Subject: [PATCH 148/542] Allow pending product fields to be required, for new
 custorder

---
 tailbone/templates/custorders/configure.mako |  44 +++++--
 tailbone/templates/custorders/create.mako    | 128 ++++++++++++-------
 tailbone/views/custorders/orders.py          |  52 +++++++-
 3 files changed, 167 insertions(+), 57 deletions(-)

diff --git a/tailbone/templates/custorders/configure.mako b/tailbone/templates/custorders/configure.mako
index ee1f06c5..3f7041d3 100644
--- a/tailbone/templates/custorders/configure.mako
+++ b/tailbone/templates/custorders/configure.mako
@@ -79,15 +79,6 @@
       </b-checkbox>
     </b-field>
 
-    <b-field message="If set, user can enter details of an arbitrary new &quot;pending&quot; product.">
-      <b-checkbox name="rattail.custorders.allow_unknown_product"
-                  v-model="simpleSettings['rattail.custorders.allow_unknown_product']"
-                  native-value="true"
-                  @input="settingsNeedSaved = true">
-        Allow creating orders for "unknown" products
-      </b-checkbox>
-    </b-field>
-
     <b-field>
       <b-checkbox name="rattail.custorders.allow_item_discounts"
                   v-model="simpleSettings['rattail.custorders.allow_item_discounts']"
@@ -130,6 +121,41 @@
     </b-field>
 
   </div>
+
+  <h3 class="block is-size-3">Unknown Products</h3>
+  <div class="block" style="padding-left: 2rem;">
+
+    <b-field message="If set, user can enter details of an arbitrary new &quot;pending&quot; product.">
+      <b-checkbox name="rattail.custorders.allow_unknown_product"
+                  v-model="simpleSettings['rattail.custorders.allow_unknown_product']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Allow creating orders for "unknown" products
+      </b-checkbox>
+    </b-field>
+
+    <div v-if="simpleSettings['rattail.custorders.allow_unknown_product']">
+
+      <p class="block">
+        Require these fields for new product:
+      </p>
+
+      <div style="margin-left: 2rem;">
+        % for field in pending_product_fields:
+            <b-field>
+              <b-checkbox name="rattail.custorders.unknown_product.fields.${field}.required"
+                          v-model="simpleSettings['rattail.custorders.unknown_product.fields.${field}.required']"
+                          native-value="true"
+                          @input="settingsNeedSaved = true">
+                ${field}
+              </b-checkbox>
+            </b-field>
+        % endfor
+      </div>
+
+    </div>
+
+  </div>
 </%def>
 
 
diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako
index 7d3b367f..dbcd81b3 100644
--- a/tailbone/templates/custorders/create.mako
+++ b/tailbone/templates/custorders/create.mako
@@ -28,7 +28,7 @@
                     :disabled="submittingOrder"
                     icon-pack="fas"
                     icon-left="fas fa-upload">
-            {{ submitOrderButtonText }}
+            {{ submittingOrder ? "Working, please wait..." : "Submit this Order" }}
           </b-button>
           <b-button @click="startOverEntirely()"
                     icon-pack="fas"
@@ -644,18 +644,29 @@
 
                         <b-field grouped>
 
-                          <b-field label="Brand">
+                          <b-field label="Brand"
+                                   % if 'brand_name' in pending_product_required_fields:
+                                   :type="pendingProduct.brand_name ? null : 'is-danger'"
+                                   % endif
+                                   >
                             <b-input v-model="pendingProduct.brand_name">
                             </b-input>
                           </b-field>
 
                           <b-field label="Description"
-                                   :type="pendingProduct.description ? null : 'is-danger'">
+                                   % if 'description' in pending_product_required_fields:
+                                   :type="pendingProduct.description ? null : 'is-danger'"
+                                   % endif
+                                   >
                             <b-input v-model="pendingProduct.description">
                             </b-input>
                           </b-field>
 
-                          <b-field label="Unit Size">
+                          <b-field label="Unit Size"
+                                   % if 'size' in pending_product_required_fields:
+                                   :type="pendingProduct.size ? null : 'is-danger'"
+                                   % endif
+                                   >
                             <b-input v-model="pendingProduct.size">
                             </b-input>
                           </b-field>
@@ -664,12 +675,20 @@
 
                         <b-field grouped>
 
-                          <b-field :label="productKeyLabel">
+                          <b-field :label="productKeyLabel"
+                                   % if 'key' in pending_product_required_fields:
+                                   :type="pendingProduct[productKeyField] ? null : 'is-danger'"
+                                   % endif
+                                   >
                             <b-input v-model="pendingProduct[productKeyField]">
                             </b-input>
                           </b-field>
 
-                          <b-field label="Department">
+                          <b-field label="Department"
+                                   % if 'department_uuid' in pending_product_required_fields:
+                                   :type="pendingProduct.department_uuid ? null : 'is-danger'"
+                                   % endif
+                                   >
                             <b-select v-model="pendingProduct.department_uuid">
                               <option :value="null">(not known)</option>
                               <option v-for="option in departmentOptions"
@@ -680,9 +699,36 @@
                             </b-select>
                           </b-field>
 
-                          <b-field label="Unit Reg. Price">
-                            <b-input v-model="pendingProduct.regular_price_amount"
-                                     type="number" step="0.01">
+                        </b-field>
+
+                        <b-field grouped>
+
+                          <b-field label="Vendor"
+                                   % if 'vendor_name' in pending_product_required_fields:
+                                   :type="pendingProduct.vendor_name ? null : 'is-danger'"
+                                   % endif
+                                   >
+                            <b-input v-model="pendingProduct.vendor_name">
+                            </b-input>
+                          </b-field>
+
+                          <b-field label="Vendor Item Code"
+                                   % if 'vendor_item_code' in pending_product_required_fields:
+                                   :type="pendingProduct.vendor_item_code ? null : 'is-danger'"
+                                   % endif
+                                   >
+                            <b-input v-model="pendingProduct.vendor_item_code">
+                            </b-input>
+                          </b-field>
+
+                          <b-field label="Case Size"
+                                   % if 'case_size' in pending_product_required_fields:
+                                   :type="pendingProduct.case_size ? null : 'is-danger'"
+                                   % endif
+                                   >
+                            <b-input v-model="pendingProduct.case_size"
+                                     type="number" step="0.01"
+                                     style="width: 7rem;">
                             </b-input>
                           </b-field>
 
@@ -690,27 +736,24 @@
 
                         <b-field grouped>
 
-                          <b-field label="Vendor">
-                            <b-input v-model="pendingProduct.vendor_name">
-                            </b-input>
-                          </b-field>
-
-                          <b-field label="Vendor Item Code">
-                            <b-input v-model="pendingProduct.vendor_item_code">
-                            </b-input>
-                          </b-field>
-
-                          <b-field label="Unit Cost">
+                          <b-field label="Unit Cost"
+                                   % if 'unit_cost' in pending_product_required_fields:
+                                   :type="pendingProduct.unit_cost ? null : 'is-danger'"
+                                   % endif
+                                   >
                             <b-input v-model="pendingProduct.unit_cost"
                                      type="number" step="0.01"
                                      style="width: 10rem;">
                             </b-input>
                           </b-field>
 
-                          <b-field label="Case Size">
-                            <b-input v-model="pendingProduct.case_size"
-                                     type="number" step="0.01"
-                                     style="width: 7rem;">
+                          <b-field label="Unit Reg. Price"
+                                   % if 'regular_price_amount' in pending_product_required_fields:
+                                   :type="pendingProduct.regular_price_amount ? null : 'is-danger'"
+                                   % endif
+                                   >
+                            <b-input v-model="pendingProduct.regular_price_amount"
+                                     type="number" step="0.01">
                             </b-input>
                           </b-field>
 
@@ -854,7 +897,7 @@
                               :disabled="itemDialogSaveDisabled"
                               icon-pack="fas"
                               icon-left="save">
-                      {{ itemDialogSaveButtonText }}
+                      {{ itemDialogSaving ? "Working, please wait..." : (this.editingItem ? "Update Item" : "Add Item") }}
                     </b-button>
                   </div>
 
@@ -1197,6 +1240,7 @@
                 % endif
 
                 pendingProduct: {},
+                pendingProductRequiredFields: ${json.dumps(pending_product_required_fields)|n},
                 departmentOptions: ${json.dumps(department_options)|n},
 
                 submittingOrder: false,
@@ -1385,37 +1429,30 @@
             % endif
 
             itemDialogSaveDisabled() {
+
                 if (this.itemDialogSaving) {
                     return true
                 }
+
                 if (this.productIsKnown) {
                     if (!this.productUUID) {
                         return true
                     }
+
                 } else {
-                    if (!this.pendingProduct.description) {
-                        return true
+                    for (let field of this.pendingProductRequiredFields) {
+                        if (!this.pendingProduct[field]) {
+                            return true
+                        }
                     }
                 }
+
                 if (!this.productUOM) {
                     return true
                 }
+
                 return false
             },
-
-            itemDialogSaveButtonText() {
-                if (this.itemDialogSaving) {
-                    return "Working, please wait..."
-                }
-                return this.editingItem ? "Update Item" : "Add Item"
-            },
-
-            submitOrderButtonText() {
-                if (this.submittingOrder) {
-                    return "Working, please wait..."
-                }
-                return "Submit this Order"
-            },
         },
         mounted() {
             if (this.customerStatusType) {
@@ -1925,11 +1962,14 @@
 
                 this.productIsKnown = !!row.product_uuid
                 this.productUUID = row.product_uuid
-                this.pendingProduct = {}
+
+                // nb. must construct new object before updating data
+                // (otherwise vue does not notice the changes?)
+                let pending = {}
                 if (row.pending_product) {
-                    this.copyPendingProductAttrs(row.pending_product,
-                                                 this.pendingProduct)
+                    this.copyPendingProductAttrs(row.pending_product, pending)
                 }
+                this.pendingProduct = pending
 
                 this.productDisplay = row.product_full_description
                 this.productKey = row.product_key
diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py
index 60949e8f..cc02f682 100644
--- a/tailbone/views/custorders/orders.py
+++ b/tailbone/views/custorders/orders.py
@@ -102,6 +102,19 @@ class CustomerOrderView(MasterView):
         'flagged',
     ]
 
+    PENDING_PRODUCT_ENTRY_FIELDS = [
+        'key',
+        'department_uuid',
+        'brand_name',
+        'description',
+        'size',
+        'vendor_name',
+        'vendor_item_code',
+        'unit_cost',
+        'case_size',
+        'regular_price_amount',
+    ]
+
     def __init__(self, request):
         super(CustomerOrderView, self).__init__(request)
         self.batch_handler = self.get_batch_handler()
@@ -361,6 +374,7 @@ class CustomerOrderView(MasterView):
             'order_items': items,
             'product_key_label': app.get_product_key_label(),
             'allow_unknown_product': self.batch_handler.allow_unknown_product(),
+            'pending_product_required_fields': self.get_pending_product_required_fields(),
             'department_options': self.get_department_options(),
             'default_uom_choices': self.batch_handler.uom_choices_for_product(None),
             'default_uom': None,
@@ -390,6 +404,17 @@ class CustomerOrderView(MasterView):
                             'value': department.uuid})
         return options
 
+    def get_pending_product_required_fields(self):
+        required = []
+        for field in self.PENDING_PRODUCT_ENTRY_FIELDS:
+            require = self.rattail_config.getbool('rattail.custorders',
+                                                  f'unknown_product.fields.{field}.required')
+            if require is None and field == 'description':
+                require = True
+            if require:
+                required.append(field)
+        return required
+
     def get_current_batch(self):
         user = self.request.user
         if not user:
@@ -1044,7 +1069,7 @@ class CustomerOrderView(MasterView):
         }
 
     def configure_get_simple_settings(self):
-        return [
+        settings = [
 
             # customer handling
             {'section': 'rattail.custorders',
@@ -1067,9 +1092,6 @@ class CustomerOrderView(MasterView):
             {'section': 'rattail.custorders',
              'option': 'product_price_may_be_questionable',
              'type': bool},
-            {'section': 'rattail.custorders',
-             'option': 'allow_unknown_product',
-             'type': bool},
             {'section': 'rattail.custorders',
              'option': 'allow_item_discounts',
              'type': bool},
@@ -1082,8 +1104,30 @@ class CustomerOrderView(MasterView):
             {'section': 'rattail.custorders',
              'option': 'allow_past_item_reorder',
              'type': bool},
+
+            # unknown products
+            {'section': 'rattail.custorders',
+             'option': 'allow_unknown_product',
+             'type': bool},
         ]
 
+        for field in self.PENDING_PRODUCT_ENTRY_FIELDS:
+            setting = {'section': 'rattail.custorders',
+                       'option': f'unknown_product.fields.{field}.required',
+                       'type': bool}
+            if field == 'description':
+                setting['default'] = True
+            settings.append(setting)
+
+        return settings
+
+    def configure_get_context(self, **kwargs):
+        context = super().configure_get_context(**kwargs)
+
+        context['pending_product_fields'] = self.PENDING_PRODUCT_ENTRY_FIELDS
+
+        return context
+
     @classmethod
     def defaults(cls, config):
         cls._order_defaults(config)

From 72dda3771ede6c4ac0822a821f9ced57deca814a Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 24 Oct 2023 19:51:27 -0500
Subject: [PATCH 149/542] Add price confirm prompt when adding unknown item to
 custorder

optional, per config
---
 tailbone/templates/custorders/configure.mako | 12 ++-
 tailbone/templates/custorders/create.mako    | 90 +++++++++++++++++++-
 tailbone/views/custorders/orders.py          |  5 ++
 3 files changed, 105 insertions(+), 2 deletions(-)

diff --git a/tailbone/templates/custorders/configure.mako b/tailbone/templates/custorders/configure.mako
index 3f7041d3..d2f6610d 100644
--- a/tailbone/templates/custorders/configure.mako
+++ b/tailbone/templates/custorders/configure.mako
@@ -140,7 +140,8 @@
         Require these fields for new product:
       </p>
 
-      <div style="margin-left: 2rem;">
+      <div class="block"
+           style="margin-left: 2rem;">
         % for field in pending_product_fields:
             <b-field>
               <b-checkbox name="rattail.custorders.unknown_product.fields.${field}.required"
@@ -153,6 +154,15 @@
         % endfor
       </div>
 
+      <b-field message="If set, user is always prompted to confirm price when adding new product.">
+        <b-checkbox name="rattail.custorders.unknown_product.always_confirm_price"
+                    v-model="simpleSettings['rattail.custorders.unknown_product.always_confirm_price']"
+                    native-value="true"
+                    @input="settingsNeedSaved = true">
+          Require price confirmation
+        </b-checkbox>
+      </b-field>
+
     </div>
 
   </div>
diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako
index dbcd81b3..f666790e 100644
--- a/tailbone/templates/custorders/create.mako
+++ b/tailbone/templates/custorders/create.mako
@@ -757,6 +757,12 @@
                             </b-input>
                           </b-field>
 
+                          <b-field label="Gross Margin">
+                            <span class="control">
+                              {{ pendingProductGrossMargin }}
+                            </span>
+                          </b-field>
+
                         </b-field>
 
                         <b-field label="Notes">
@@ -905,6 +911,52 @@
               </div>
             </b-modal>
 
+            % if unknown_product_confirm_price:
+                <b-modal has-modal-card
+                         :active.sync="confirmPriceShowDialog">
+                  <div class="modal-card">
+
+                    <header class="modal-card-head">
+                      <p class="modal-card-title">Confirm Price</p>
+                    </header>
+
+                    <section class="modal-card-body">
+                      <p class="block">
+                        Please confirm the price info before proceeding.
+                      </p>
+
+                      <div style="white-space: nowrap;">
+
+                        <b-field label="Unit Cost" horizontal>
+                          <span>{{ pendingProduct.unit_cost }}</span>
+                        </b-field>
+
+                        <b-field label="Unit Reg. Price" horizontal>
+                          <span>{{ pendingProduct.regular_price_amount }}</span>
+                        </b-field>
+
+                        <b-field label="Gross Margin" horizontal>
+                          <span>{{ pendingProductGrossMargin }}</span>
+                        </b-field>
+
+                      </div>
+                    </section>
+
+                    <footer class="modal-card-foot">
+                      <b-button type="is-primary"
+                                icon-pack="fas"
+                                icon-left="check"
+                                @click="confirmPriceSave()">
+                        Confirm
+                      </b-button>
+                      <b-button @click="confirmPriceCancel()">
+                        Cancel
+                      </b-button>
+                    </footer>
+                  </div>
+                </b-modal>
+            % endif
+
             <tailbone-product-lookup ref="productLookup"
                                      @canceled="productLookupCanceled"
                                      @selected="productLookupSelected">
@@ -1242,6 +1294,9 @@
                 pendingProduct: {},
                 pendingProductRequiredFields: ${json.dumps(pending_product_required_fields)|n},
                 departmentOptions: ${json.dumps(department_options)|n},
+                % if unknown_product_confirm_price:
+                    confirmPriceShowDialog: false,
+                % endif
 
                 submittingOrder: false,
             }
@@ -1428,6 +1483,15 @@
 
             % endif
 
+            pendingProductGrossMargin() {
+                let cost = this.pendingProduct.unit_cost
+                let price = this.pendingProduct.regular_price_amount
+                if (cost && price) {
+                    let margin = (price - cost) / price
+                    return (100 * margin).toFixed(2).toString() + " %"
+                }
+            },
+
             itemDialogSaveDisabled() {
 
                 if (this.itemDialogSaving) {
@@ -2116,7 +2180,7 @@
                 }
             },
 
-            itemDialogSave() {
+            itemDialogAttemptSave() {
                 this.itemDialogSaving = true
 
                 let params = {
@@ -2168,6 +2232,30 @@
                     this.itemDialogSaving = false
                 })
             },
+
+            itemDialogSave() {
+
+                % if unknown_product_confirm_price:
+                    if (!this.productIsKnown && !this.editingItem) {
+                        this.showingItemDialog = false
+                        this.confirmPriceShowDialog = true
+                        return
+                    }
+                % endif
+
+                this.itemDialogAttemptSave()
+            },
+
+            confirmPriceCancel() {
+                this.confirmPriceShowDialog = false
+                this.showingItemDialog = true
+            },
+
+            confirmPriceSave() {
+                this.confirmPriceShowDialog = false
+                this.showingItemDialog = true
+                this.itemDialogAttemptSave()
+            },
         },
     }
 
diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py
index cc02f682..c91ff4d2 100644
--- a/tailbone/views/custorders/orders.py
+++ b/tailbone/views/custorders/orders.py
@@ -375,6 +375,8 @@ class CustomerOrderView(MasterView):
             'product_key_label': app.get_product_key_label(),
             'allow_unknown_product': self.batch_handler.allow_unknown_product(),
             'pending_product_required_fields': self.get_pending_product_required_fields(),
+            'unknown_product_confirm_price': self.rattail_config.getbool(
+                'rattail.custorders', 'unknown_product.always_confirm_price'),
             'department_options': self.get_department_options(),
             'default_uom_choices': self.batch_handler.uom_choices_for_product(None),
             'default_uom': None,
@@ -1109,6 +1111,9 @@ class CustomerOrderView(MasterView):
             {'section': 'rattail.custorders',
              'option': 'allow_unknown_product',
              'type': bool},
+            {'section': 'rattail.custorders',
+             'option': 'unknown_product.always_confirm_price',
+             'type': bool},
         ]
 
         for field in self.PENDING_PRODUCT_ENTRY_FIELDS:

From 70cc754f3e871d0fff64f8cfaea8cb90fb4c266b Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 25 Oct 2023 10:45:33 -0500
Subject: [PATCH 150/542] Use `<b-select>` for theme picker

instead of webhelpers2.html.tags.select() which seems to break for me
in dev now with python 3.10
---
 tailbone/static/css/layout.css |  4 ----
 tailbone/subscribers.py        |  2 +-
 tailbone/templates/base.mako   | 22 ++++++++++++++++------
 3 files changed, 17 insertions(+), 11 deletions(-)

diff --git a/tailbone/static/css/layout.css b/tailbone/static/css/layout.css
index bdf35410..20dbf6b7 100644
--- a/tailbone/static/css/layout.css
+++ b/tailbone/static/css/layout.css
@@ -57,10 +57,6 @@ header span.header-text {
     margin-right: 10px;
 }
 
-header .level .theme-picker {
-    display: inline-flex;
-}
-
 #content-title h1 {
     margin-bottom: 0;
     margin-right: 1rem;
diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py
index b724a4c5..1143b510 100644
--- a/tailbone/subscribers.py
+++ b/tailbone/subscribers.py
@@ -158,7 +158,7 @@ def before_render(event):
                                                        default=['falafel'])
             if 'default' not in available:
                 available.insert(0, 'default')
-            options = [tags.Option(theme) for theme in available]
+            options = [tags.Option(theme, value=theme) for theme in available]
             renderer_globals['theme_picker_options'] = options
 
         # heck while we're assuming the classic web app here...
diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako
index 8558eeb7..53dc3423 100644
--- a/tailbone/templates/base.mako
+++ b/tailbone/templates/base.mako
@@ -392,13 +392,19 @@
             % 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)}
-                  Theme:
-                  <div class="theme-picker">
-                    <div class="select">
-                      ${h.select('theme', theme, theme_picker_options, **{'@change': 'changeTheme()'})}
+                    ${h.csrf_token(request)}
+                    <div style="display: flex; align-items: center; gap: 0.5rem;">
+                      <span>Theme:</span>
+                      <b-select name="theme"
+                                v-model="globalTheme"
+                                @change="changeTheme()">
+                        % for option in theme_picker_options:
+                            <option value="${option.value}">
+                              ${option.label}
+                            </option>
+                        % endfor
+                      </b-select>
                     </div>
-                  </div>
                   ${h.end_form()}
                 </div>
             % endif
@@ -840,6 +846,10 @@
         contentTitleHTML: ${json.dumps(capture(self.content_title))|n},
         feedbackMessage: "",
 
+        % if expose_theme_picker and request.has_perm('common.change_app_theme'):
+            globalTheme: ${json.dumps(theme)|n},
+        % endif
+
         % if can_edit_help:
             configureFieldsHelp: false,
         % endif

From cf1ef2399626a46bf44efd6229a8427e4865304a Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 25 Oct 2023 11:40:52 -0500
Subject: [PATCH 151/542] Add `column_only` kwarg for `Grid.set_label()` method

pass True to affect only the column label and not the filter
---
 tailbone/grids/core.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py
index 5f28fca0..7a0d00e3 100644
--- a/tailbone/grids/core.py
+++ b/tailbone/grids/core.py
@@ -385,9 +385,9 @@ class Grid(object):
     def remove_filter(self, key):
         self.filters.pop(key, None)
 
-    def set_label(self, key, label):
+    def set_label(self, key, label, column_only=False):
         self.labels[key] = label
-        if key in self.filters:
+        if not column_only and key in self.filters:
             self.filters[key].label = label
 
     def get_label(self, key):

From b5c68831b55d299f0d613626da2fed5fda791d09 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 25 Oct 2023 12:20:04 -0500
Subject: [PATCH 152/542] Do not show profile buttons for inactive customer
 shoppers

---
 tailbone/views/customers.py | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py
index 668f4a2b..0d4e3d7c 100644
--- a/tailbone/views/customers.py
+++ b/tailbone/views/customers.py
@@ -424,8 +424,9 @@ class CustomerView(MasterView):
                 people.setdefault(person.uuid, person)
 
             for shopper in customer.shoppers:
-                person = shopper.person
-                people.setdefault(person.uuid, person)
+                if shopper.active:
+                    person = shopper.person
+                    people.setdefault(person.uuid, person)
 
             for person in customer.people:
                 people.setdefault(person.uuid, person)

From 441a6e5e0c00e3cbdc846648253a9442e3fa9483 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 25 Oct 2023 14:06:40 -0500
Subject: [PATCH 153/542] Add separate perm for making new custorder for
 unknown product

---
 tailbone/views/custorders/orders.py | 10 +++++++++-
 1 file changed, 9 insertions(+), 1 deletion(-)

diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py
index c91ff4d2..f76d4d93 100644
--- a/tailbone/views/custorders/orders.py
+++ b/tailbone/views/custorders/orders.py
@@ -373,7 +373,8 @@ class CustomerOrderView(MasterView):
             'allow_contact_info_create': self.batch_handler.allow_contact_info_creation(),
             'order_items': items,
             'product_key_label': app.get_product_key_label(),
-            'allow_unknown_product': self.batch_handler.allow_unknown_product(),
+            'allow_unknown_product': (self.batch_handler.allow_unknown_product()
+                                      and self.has_perm('create_unknown_product')),
             'pending_product_required_fields': self.get_pending_product_required_fields(),
             'unknown_product_confirm_price': self.rattail_config.getbool(
                 'rattail.custorders', 'unknown_product.always_confirm_price'),
@@ -1143,8 +1144,15 @@ class CustomerOrderView(MasterView):
         route_prefix = cls.get_route_prefix()
         url_prefix = cls.get_url_prefix()
         model_title = cls.get_model_title()
+        model_title_plural = cls.get_model_title_plural()
         permission_prefix = cls.get_permission_prefix()
 
+        config.add_tailbone_permission_group(permission_prefix, model_title_plural, overwrite=False)
+
+        config.add_tailbone_permission(permission_prefix,
+                                       f'{permission_prefix}.create_unknown_product',
+                                       f"Create new {model_title} for unknown product")
+
         # add pseudo-index page for creating new custorder
         # (makes it available when building menus etc.)
         config.add_tailbone_index_page('{}.create'.format(route_prefix),

From a8121814660665011404e970e19560139e24edda Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 25 Oct 2023 20:10:21 -0500
Subject: [PATCH 154/542] Expand the "product lookup" component to include
 autocomplete

---
 tailbone/templates/custorders/create.mako |  87 ++++++-------
 tailbone/templates/products/lookup.mako   | 141 ++++++++++++++++++----
 2 files changed, 155 insertions(+), 73 deletions(-)

diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako
index f666790e..86a5e804 100644
--- a/tailbone/templates/custorders/create.mako
+++ b/tailbone/templates/custorders/create.mako
@@ -531,33 +531,10 @@
                           <p class="label control">
                             Product
                           </p>
-                          <b-field :expanded="!productUUID">
-                            <tailbone-autocomplete ref="productAutocomplete"
-                                                   v-model="productUUID"
-                                                   placeholder="Enter UPC or brand, description etc."
-                                                   :assigned-label="productDisplay"
-                                                   serviceUrl="${url('{}.product_autocomplete'.format(route_prefix))}"
-                                                   @input="productChanged">
-                            </tailbone-autocomplete>
-                          </b-field>
-
-                          <b-button type="is-primary"
-                                    v-if="!productUUID"
-                                    @click="productFullLookup()"
-                                    icon-pack="fas"
-                                    icon-left="search">
-                            Full Lookup
-                          </b-button>
-
-                          <b-button v-if="productUUID"
-                                    type="is-primary"
-                                    tag="a" target="_blank"
-                                    :href="productURL"
-                                    :disabled="!productURL"
-                                    icon-pack="fas"
-                                    icon-left="external-link-alt">
-                            View Product
-                          </b-button>
+                          <tailbone-product-lookup ref="productLookup"
+                                                   :selected-product="selectedProduct"
+                                                   @selected="productLookupSelected">
+                          </tailbone-product-lookup>
                         </b-field>
 
                         <div v-if="productUUID">
@@ -565,7 +542,6 @@
                           <div class="is-pulled-right has-text-centered">
                             <img :src="productImageURL"
                                  style="max-height: 150px; max-width: 150px; "/>
-                            ## <p>{{ productKey }}</p>
                           </div>
 
                           <b-field grouped>
@@ -957,11 +933,6 @@
                 </b-modal>
             % endif
 
-            <tailbone-product-lookup ref="productLookup"
-                                     @canceled="productLookupCanceled"
-                                     @selected="productLookupSelected">
-            </tailbone-product-lookup>
-
             % if allow_past_item_reorder:
             <b-modal :active.sync="pastItemsShowDialog">
               <div class="card">
@@ -1258,6 +1229,7 @@
                 pastItemsSelected: null,
                 % endif
                 productIsKnown: true,
+                selectedProduct: null,
                 productUUID: null,
                 productDisplay: null,
                 productKey: null,
@@ -1544,6 +1516,18 @@
                     this.$refs.contactAutocomplete.clearSelection()
                 }
             },
+
+            productIsKnown(newval, oldval) {
+                // TODO: seems like this should be better somehow?
+                // e.g. maybe we should not be clearing *everything*
+                // in case user accidentally clicks, and then clicks
+                // "is known" again?  and if we *should* clear all,
+                // why does that require 2 steps?
+                if (!newval) {
+                    this.selectedProduct = null
+                    this.clearProduct()
+                }
+            },
         },
         methods: {
 
@@ -1894,20 +1878,12 @@
                 }
             },
 
-            productFullLookup() {
-                this.showingItemDialog = false
-                let term = this.$refs.productAutocomplete.getUserInput()
-                this.$refs.productLookup.showDialog(term)
-            },
-
-            productLookupCanceled() {
-                this.showingItemDialog = true
-            },
-
             productLookupSelected(selected) {
+                // TODO: this still is a hack somehow, am sure of it.
+                // need to clean this up at some point
+                this.selectedProduct = selected
                 this.clearProduct()
-                this.productChanged(selected.uuid)
-                this.showingItemDialog = true
+                this.productChanged(selected)
             },
 
             copyPendingProductAttrs(from, to) {
@@ -1930,6 +1906,7 @@
                 this.customerPanelOpen = false
                 this.editingItem = null
                 this.productIsKnown = true
+                this.selectedProduct = null
                 this.productUUID = null
                 this.productDisplay = null
                 this.productKey = null
@@ -1962,7 +1939,7 @@
                 this.itemDialogTabIndex = 0
                 this.showingItemDialog = true
                 this.$nextTick(() => {
-                    this.$refs.productAutocomplete.focus()
+                    this.$refs.productLookup.focus()
                 })
             },
 
@@ -2027,6 +2004,16 @@
                 this.productIsKnown = !!row.product_uuid
                 this.productUUID = row.product_uuid
 
+                if (row.product_uuid) {
+                    this.selectedProduct = {
+                        uuid: row.product_uuid,
+                        full_description: row.product_full_description,
+                        url: row.product_url,
+                    }
+                } else {
+                    this.selectedProduct = null
+                }
+
                 // nb. must construct new object before updating data
                 // (otherwise vue does not notice the changes?)
                 let pending = {}
@@ -2131,11 +2118,11 @@
                 }
             },
 
-            productChanged(uuid) {
-                if (uuid) {
+            productChanged(product) {
+                if (product) {
                     let params = {
                         action: 'get_product_info',
-                        uuid: uuid,
+                        uuid: product.uuid,
                     }
                     // nb. it is possible for the handler to "swap"
                     // the product selection, i.e. user chooses a "per
@@ -2144,6 +2131,8 @@
                     // received above is the correct one, but just use
                     // whatever came back from handler
                     this.submitBatchData(params, response => {
+                        this.selectedProduct = response.data
+
                         this.productUUID = response.data.uuid
                         this.productKey = response.data.key
                         this.productDisplay = response.data.full_description
diff --git a/tailbone/templates/products/lookup.mako b/tailbone/templates/products/lookup.mako
index cdc4c565..42ee0742 100644
--- a/tailbone/templates/products/lookup.mako
+++ b/tailbone/templates/products/lookup.mako
@@ -2,8 +2,49 @@
 
 <%def name="tailbone_product_lookup_template()">
   <script type="text/x-template" id="tailbone-product-lookup-template">
-    <div>
-      <b-modal :active.sync="showingDialog">
+    <div style="width: 100%;">
+
+      <b-field grouped>
+
+        <b-field :expanded="!selectedProduct">
+          <b-autocomplete ref="productAutocomplete"
+                          v-if="!selectedProduct"
+                          v-model="autocompleteValue"
+                          placeholder="Enter UPC or brand, description etc."
+                          :data="autocompleteOptions"
+                          field="value"
+                          :custom-formatter="option => option.label"
+                          @typing="getAutocompleteOptions"
+                          @select="autocompleteSelected"
+                          style="width: 100%;">
+          </b-autocomplete>
+          <b-button v-if="selectedProduct"
+                    @click="clearSelection(true)">
+            {{ selectedProduct.full_description }}
+          </b-button>
+        </b-field>
+
+        <b-button type="is-primary"
+                  v-if="!selectedProduct"
+                  @click="lookupInit()"
+                  icon-pack="fas"
+                  icon-left="search">
+          Full Lookup
+        </b-button>
+
+        <b-button v-if="selectedProduct"
+                  type="is-primary"
+                  tag="a" target="_blank"
+                  :href="selectedProduct.url"
+                  :disabled="!selectedProduct.url"
+                  icon-pack="fas"
+                  icon-left="external-link-alt">
+          View Product
+        </b-button>
+
+      </b-field>
+
+      <b-modal :active.sync="lookupShowDialog">
         <div class="card">
           <div class="card-content">
 
@@ -157,6 +198,7 @@
           </div>
         </div>
       </b-modal>
+
     </div>
   </script>
 </%def>
@@ -166,9 +208,17 @@
 
     const TailboneProductLookup = {
         template: '#tailbone-product-lookup-template',
+        props: {
+            selectedProduct: {
+                type: Object,
+            },
+        },
         data() {
             return {
-                showingDialog: false,
+                autocompleteValue: '',
+                autocompleteOptions: [],
+
+                lookupShowDialog: false,
 
                 searchTerm: null,
                 searchTermLastUsed: null,
@@ -187,23 +237,67 @@
         },
         methods: {
 
-            showDialog(term) {
+            focus() {
+                if (!this.selectedProduct) {
+                    this.$refs.productAutocomplete.focus()
+                }
+            },
 
+            clearSelection(focus) {
+
+                // clear data
+                this.autocompleteValue = ''
+                this.$emit('selected', null)
+
+                // maybe set focus to our (autocomplete) component
+                if (focus) {
+                    this.$nextTick(() => {
+                        this.focus()
+                    })
+                }
+            },
+
+            getAutocompleteOptions: debounce(function (entry) {
+
+                // since the `@typing` event from buefy component does not
+                // "self-regulate" in any way, we a) use `debounce` above,
+                // but also b) skip the search unless we have at least 3
+                // characters of input from user
+                if (entry.length < 3) {
+                    this.data = []
+                    return
+                }
+
+                // and perform the search
+                let url = '${url(f'{route_prefix}.product_autocomplete')}'
+                this.$http.get(url + '?term=' + encodeURIComponent(entry))
+                    .then(({ data }) => {
+                        this.autocompleteOptions = data
+                    }).catch((error) => {
+                        this.autocompleteOptions = []
+                        throw error
+                    })
+            }),
+
+            autocompleteSelected(option) {
+                this.$emit('selected', {
+                    uuid: option.value,
+                    full_description: option.label,
+                })
+            },
+
+            lookupInit() {
                 this.searchResultSelected = null
+                this.lookupShowDialog = true
 
-                if (term !== undefined) {
-                    this.searchTerm = term
-                    // perform search if invoked with new term
-                    if (term != this.searchTermLastUsed) {
+                this.$nextTick(() => {
+
+                    this.searchTerm = this.autocompleteValue
+                    if (this.searchTerm != this.searchTermLastUsed) {
                         this.searchTermLastUsed = null
                         this.performSearch()
                     }
-                } else {
-                    this.searchTerm = this.searchTermLastUsed
-                }
 
-                this.showingDialog = true
-                this.$nextTick(() => {
                     this.$refs.searchTermInput.focus()
                 })
             },
@@ -214,17 +308,6 @@
                 }
             },
 
-            cancelDialog() {
-                this.searchResultSelected = null
-                this.showingDialog = false
-                this.$emit('canceled')
-            },
-
-            selectResult() {
-                this.showingDialog = false
-                this.$emit('selected', this.searchResultSelected)
-            },
-
             performSearch() {
                 if (this.searchResultsLoading) {
                     return
@@ -255,6 +338,16 @@
                     this.searchResultsLoading = false
                 })
             },
+
+            selectResult() {
+                this.lookupShowDialog = false
+                this.$emit('selected', this.searchResultSelected)
+            },
+
+            cancelDialog() {
+                this.searchResultSelected = null
+                this.lookupShowDialog = false
+            },
         },
     }
 

From 4809cf039e9925d64f19b75e6467cb8de1e74f72 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 25 Oct 2023 20:22:48 -0500
Subject: [PATCH 155/542] Update changelog

---
 CHANGES.rst          | 22 ++++++++++++++++++++++
 tailbone/_version.py |  2 +-
 2 files changed, 23 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 06db3d61..03c89807 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,28 @@
 CHANGELOG
 =========
 
+0.9.71 (2023-10-25)
+-------------------
+
+* Fix bug when editing vendor.
+
+* Show user warning if "add item to custorder" fails.
+
+* Allow pending product fields to be required, for new custorder.
+
+* Add price confirm prompt when adding unknown item to custorder.
+
+* Use ``<b-select>`` for theme picker.
+
+* Add ``column_only`` kwarg for ``Grid.set_label()`` method.
+
+* Do not show profile buttons for inactive customer shoppers.
+
+* Add separate perm for making new custorder for unknown product.
+
+* Expand the "product lookup" component to include autocomplete.
+
+
 0.9.70 (2023-10-24)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index deda170c..4477c9fb 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.70'
+__version__ = '0.9.71'

From a5c1cba81bb68394f3b54d42a29da84d1fb25715 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 26 Oct 2023 10:06:00 -0500
Subject: [PATCH 156/542] Use product lookup component for "resolve pending
 product" tool

---
 tailbone/templates/custorders/create.mako     |  5 +--
 tailbone/templates/products/lookup.mako       | 29 ++++++++++-------
 tailbone/templates/products/pending/view.mako | 31 +++++++++++++++----
 3 files changed, 45 insertions(+), 20 deletions(-)

diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako
index 86a5e804..399c1a6b 100644
--- a/tailbone/templates/custorders/create.mako
+++ b/tailbone/templates/custorders/create.mako
@@ -532,8 +532,9 @@
                             Product
                           </p>
                           <tailbone-product-lookup ref="productLookup"
-                                                   :selected-product="selectedProduct"
-                                                   @selected="productLookupSelected">
+                                                   :product="selectedProduct"
+                                                   @selected="productLookupSelected"
+                                                   autocomplete-url="${url(f'{route_prefix}.product_autocomplete')}">
                           </tailbone-product-lookup>
                         </b-field>
 
diff --git a/tailbone/templates/products/lookup.mako b/tailbone/templates/products/lookup.mako
index 42ee0742..4e8c3a8b 100644
--- a/tailbone/templates/products/lookup.mako
+++ b/tailbone/templates/products/lookup.mako
@@ -6,9 +6,9 @@
 
       <b-field grouped>
 
-        <b-field :expanded="!selectedProduct">
+        <b-field :expanded="!product">
           <b-autocomplete ref="productAutocomplete"
-                          v-if="!selectedProduct"
+                          v-if="!product"
                           v-model="autocompleteValue"
                           placeholder="Enter UPC or brand, description etc."
                           :data="autocompleteOptions"
@@ -18,25 +18,25 @@
                           @select="autocompleteSelected"
                           style="width: 100%;">
           </b-autocomplete>
-          <b-button v-if="selectedProduct"
+          <b-button v-if="product"
                     @click="clearSelection(true)">
-            {{ selectedProduct.full_description }}
+            {{ product.full_description }}
           </b-button>
         </b-field>
 
         <b-button type="is-primary"
-                  v-if="!selectedProduct"
+                  v-if="!product"
                   @click="lookupInit()"
                   icon-pack="fas"
                   icon-left="search">
           Full Lookup
         </b-button>
 
-        <b-button v-if="selectedProduct"
+        <b-button v-if="product"
                   type="is-primary"
                   tag="a" target="_blank"
-                  :href="selectedProduct.url"
-                  :disabled="!selectedProduct.url"
+                  :href="product.url"
+                  :disabled="!product.url"
                   icon-pack="fas"
                   icon-left="external-link-alt">
           View Product
@@ -209,9 +209,13 @@
     const TailboneProductLookup = {
         template: '#tailbone-product-lookup-template',
         props: {
-            selectedProduct: {
+            product: {
                 type: Object,
             },
+            autocompleteUrl: {
+                type: String,
+                default: '${url('products.autocomplete')}',
+            },
         },
         data() {
             return {
@@ -238,7 +242,7 @@
         methods: {
 
             focus() {
-                if (!this.selectedProduct) {
+                if (!this.product) {
                     this.$refs.productAutocomplete.focus()
                 }
             },
@@ -269,8 +273,7 @@
                 }
 
                 // and perform the search
-                let url = '${url(f'{route_prefix}.product_autocomplete')}'
-                this.$http.get(url + '?term=' + encodeURIComponent(entry))
+                this.$http.get(this.autocompleteUrl + '?term=' + encodeURIComponent(entry))
                     .then(({ data }) => {
                         this.autocompleteOptions = data
                     }).catch((error) => {
@@ -283,6 +286,8 @@
                 this.$emit('selected', {
                     uuid: option.value,
                     full_description: option.label,
+                    url: option.url,
+                    image_url: option.image_url,
                 })
             },
 
diff --git a/tailbone/templates/products/pending/view.mako b/tailbone/templates/products/pending/view.mako
index 2b9852d9..e3740c71 100644
--- a/tailbone/templates/products/pending/view.mako
+++ b/tailbone/templates/products/pending/view.mako
@@ -1,5 +1,11 @@
 ## -*- coding: utf-8; -*-
 <%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="object_helpers()">
   ${parent.object_helpers()}
@@ -43,12 +49,13 @@
               <span>${instance.full_description}</span>
             </b-field>
             <b-field label="Actual Product" expanded>
-              <tailbone-autocomplete name="product_uuid"
-                                     v-model="resolveProductUUID"
-                                     ref="resolveProductAutocomplete"
-                                     service-url="${url('products.autocomplete')}">
-              </tailbone-autocomplete>
+              <tailbone-product-lookup ref="productLookup"
+                                       autocomplete-url="${url('products.autocomplete_special', key='with_key')}"
+                                       :product="actualProduct"
+                                       @selected="productSelected">
+              </tailbone-product-lookup>
             </b-field>
+            ${h.hidden('product_uuid', **{':value': 'resolveProductUUID'})}
           </section>
 
           <footer class="modal-card-foot">
@@ -91,7 +98,7 @@
         this.resolveProductUUID = null
         this.resolveProductShowDialog = true
         this.$nextTick(() => {
-            this.$refs.resolveProductAutocomplete.focus()
+            this.$refs.productLookup.focus()
         })
     }
 
@@ -100,8 +107,20 @@
         this.$refs.resolveProductForm.submit()
     }
 
+    ThisPageData.actualProduct = null
+
+    ThisPage.methods.productSelected = function(product) {
+       this.actualProduct = product
+       this.resolveProductUUID = product ? product.uuid : null
+    }
+
   </script>
 </%def>
 
+<%def name="make_this_page_component()">
+  ${parent.make_this_page_component()}
+  ${product_lookup.tailbone_product_lookup_component()}
+</%def>
+
 
 ${parent.body()}

From 1fc17658ff74e4071f7f9ed0b302342ba490245e Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 26 Oct 2023 18:44:38 -0500
Subject: [PATCH 157/542] Update changelog

---
 CHANGES.rst          | 6 ++++++
 tailbone/_version.py | 2 +-
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 03c89807..da77da7c 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,12 @@
 CHANGELOG
 =========
 
+0.9.72 (2023-10-26)
+-------------------
+
+* Use product lookup component for "resolve pending product" tool.
+
+
 0.9.71 (2023-10-25)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 4477c9fb..e1fc06fd 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.71'
+__version__ = '0.9.72'

From fe4a178d43e6a112d6ca8a2fa20cf1930d79d28c Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 26 Oct 2023 20:43:12 -0500
Subject: [PATCH 158/542] Add way to "ignore" a pending product

and some related tweaks for sake of grid
---
 tailbone/grids/filters.py                     | 17 +++-
 tailbone/templates/products/pending/view.mako | 95 ++++++++++---------
 tailbone/views/products.py                    | 87 +++++++++++++++--
 3 files changed, 143 insertions(+), 56 deletions(-)

diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py
index 61d29554..41a3c1fa 100644
--- a/tailbone/grids/filters.py
+++ b/tailbone/grids/filters.py
@@ -612,10 +612,11 @@ class AlchemyNumericFilter(AlchemyGridFilter):
     """
     value_renderer_factory = NumericValueRenderer
 
-    # expose greater-than / less-than verbs in addition to core
-    default_verbs = ['equal', 'not_equal', 'greater_than', 'greater_equal',
-                     'less_than', 'less_equal', 'between',
-                     'is_null', 'is_not_null', 'is_any']
+    def default_verbs(self):
+        # expose greater-than / less-than verbs in addition to core
+        return ['equal', 'not_equal', 'greater_than', 'greater_equal',
+                'less_than', 'less_equal', 'between',
+                'is_null', 'is_not_null', 'is_any']
 
     # TODO: what follows "works" in that it prevents an error...but from the
     # user's perspective it still fails silently...need to improve on front-end
@@ -670,6 +671,14 @@ class AlchemyIntegerFilter(AlchemyNumericFilter):
     """
     bigint = False
 
+    def default_verbs(self):
+
+        # limited verbs if choices are defined
+        if self.choices:
+            return ['equal', 'not_equal', 'is_null', 'is_not_null', 'is_any']
+
+        return super().default_verbs()
+
     def value_invalid(self, value):
         if value:
             if isinstance(value, int):
diff --git a/tailbone/templates/products/pending/view.mako b/tailbone/templates/products/pending/view.mako
index e3740c71..765c8838 100644
--- a/tailbone/templates/products/pending/view.mako
+++ b/tailbone/templates/products/pending/view.mako
@@ -7,25 +7,16 @@
   ${product_lookup.tailbone_product_lookup_template()}
 </%def>
 
-<%def name="object_helpers()">
-  ${parent.object_helpers()}
-  % if instance.status_code == enum.PENDING_PRODUCT_STATUS_PENDING and master.has_perm('resolve_product'):
-      <nav class="panel">
-        <p class="panel-heading">Tools</p>
-        <div class="panel-block">
-          <div style="display: flex; flex-direction: column;">
-            <div class="buttons">
-              <b-button type="is-primary"
-                        @click="resolveProductInit()"
-                        icon-pack="fas"
-                        icon-left="object-ungroup">
-                Resolve Product
-              </b-button>
-            </div>
-          </div>
-        </div>
-      </nav>
+<%def name="page_content()">
+  ${parent.page_content()}
 
+  % if master.has_perm('ignore_product') and instance.status_code in (enum.PENDING_PRODUCT_STATUS_PENDING, enum.PENDING_PRODUCT_STATUS_READY):
+      ${h.form(master.get_action_url('ignore_product', instance), ref='ignoreProductForm')}
+      ${h.csrf_token(request)}
+      ${h.end_form()}
+  % endif
+
+  % if master.has_perm('resolve_product') and instance.status_code in (enum.PENDING_PRODUCT_STATUS_PENDING, enum.PENDING_PRODUCT_STATUS_READY, enum.PENDING_PRODUCT_STATUS_IGNORED):
       <b-modal has-modal-card
                :active.sync="resolveProductShowDialog">
         <div class="modal-card">
@@ -80,39 +71,55 @@
   ${parent.modify_this_page_vars()}
   <script type="text/javascript">
 
-    ThisPageData.resolveProductShowDialog = false
-    ThisPageData.resolveProductUUID = null
-    ThisPageData.resolveProductSubmitting = false
+    % if master.has_perm('ignore_product') and instance.status_code in (enum.PENDING_PRODUCT_STATUS_PENDING, enum.PENDING_PRODUCT_STATUS_READY):
 
-    ThisPage.computed.resolveProductSubmitDisabled = function() {
-        if (this.resolveProductSubmitting) {
-            return true
+        ThisPage.methods.ignoreProductInit = function() {
+            if (!confirm("Really ignore this product?\n\n"
+                         + "This will leave it unresolved, but hidden via default filters.")) {
+                return
+            }
+            this.$refs.ignoreProductForm.submit()
         }
-        if (!this.resolveProductUUID) {
-            return true
+
+    % endif
+
+    % if master.has_perm('resolve_product') and instance.status_code in (enum.PENDING_PRODUCT_STATUS_PENDING, enum.PENDING_PRODUCT_STATUS_READY, enum.PENDING_PRODUCT_STATUS_IGNORED):
+
+        ThisPageData.resolveProductShowDialog = false
+        ThisPageData.resolveProductUUID = null
+        ThisPageData.resolveProductSubmitting = false
+
+        ThisPage.computed.resolveProductSubmitDisabled = function() {
+            if (this.resolveProductSubmitting) {
+                return true
+            }
+            if (!this.resolveProductUUID) {
+                return true
+            }
+            return false
         }
-        return false
-    }
 
-    ThisPage.methods.resolveProductInit = function() {
-        this.resolveProductUUID = null
-        this.resolveProductShowDialog = true
-        this.$nextTick(() => {
-            this.$refs.productLookup.focus()
-        })
-    }
+        ThisPage.methods.resolveProductInit = function() {
+            this.resolveProductUUID = null
+            this.resolveProductShowDialog = true
+            this.$nextTick(() => {
+                this.$refs.productLookup.focus()
+            })
+        }
 
-    ThisPage.methods.resolveProductSubmit = function() {
-        this.resolveProductSubmitting = true
-        this.$refs.resolveProductForm.submit()
-    }
+        ThisPage.methods.resolveProductSubmit = function() {
+            this.resolveProductSubmitting = true
+            this.$refs.resolveProductForm.submit()
+        }
 
-    ThisPageData.actualProduct = null
+        ThisPageData.actualProduct = null
 
-    ThisPage.methods.productSelected = function(product) {
-       this.actualProduct = product
-       this.resolveProductUUID = product ? product.uuid : null
-    }
+        ThisPage.methods.productSelected = function(product) {
+           this.actualProduct = product
+           this.resolveProductUUID = product ? product.uuid : null
+        }
+
+    % endif
 
   </script>
 </%def>
diff --git a/tailbone/views/products.py b/tailbone/views/products.py
index e9e32a21..16c65fdb 100644
--- a/tailbone/views/products.py
+++ b/tailbone/views/products.py
@@ -2297,16 +2297,22 @@ class PendingProductView(MasterView):
 
     def configure_grid(self, g):
         super().configure_grid(g)
+        model = self.model
 
-        g.set_enum('status_code', self.enum.PENDING_PRODUCT_STATUS)
-        g.filters['status_code'].default_active = True
-        g.filters['status_code'].default_verb = 'not_equal'
-        g.filters['status_code'].default_value = str(self.enum.PENDING_PRODUCT_STATUS_RESOLVED)
-
-        g.set_sort_defaults('created', 'desc')
-
+        # description
         g.set_link('description')
 
+        # status_code
+        g.set_enum('status_code', self.enum.PENDING_PRODUCT_STATUS)
+        g.set_filter('status_code', model.PendingProduct.status_code,
+                     value_enum=self.enum.PENDING_PRODUCT_STATUS,
+                     default_active=True,
+                     default_verb='equal',
+                     default_value=str(self.enum.PENDING_PRODUCT_STATUS_PENDING))
+
+        # created
+        g.set_sort_defaults('created', 'desc')
+
     def configure_form(self, f):
         super().configure_form(f)
         model = self.model
@@ -2398,8 +2404,20 @@ class PendingProductView(MasterView):
         if self.creating:
             f.remove('status_code')
         else:
-            # f.set_readonly('status_code')
             f.set_enum('status_code', self.enum.PENDING_PRODUCT_STATUS)
+            if self.viewing:
+                f.set_renderer('status_code', self.render_status_code)
+
+                if (self.has_perm('ignore_product')
+                    and pending.status_code in (self.enum.PENDING_PRODUCT_STATUS_PENDING,
+                                                self.enum.PENDING_PRODUCT_STATUS_READY)):
+                    f.set_vuejs_component_kwargs(**{'@ignore-product': 'ignoreProductInit'})
+
+                if (self.has_perm('resolve_product')
+                    and pending.status_code in (self.enum.PENDING_PRODUCT_STATUS_PENDING,
+                                                self.enum.PENDING_PRODUCT_STATUS_READY,
+                                                self.enum.PENDING_PRODUCT_STATUS_IGNORED)):
+                    f.set_vuejs_component_kwargs(**{'@resolve-product': 'resolveProductInit'})
 
         # user
         if self.creating:
@@ -2415,6 +2433,42 @@ class PendingProductView(MasterView):
             if not pending.resolved:
                 f.remove('resolved', 'resolved_by')
 
+    def render_status_code(self, pending, field):
+        status = pending.status_code
+        if not status:
+            return
+
+        # will just show status text by default
+        text = self.enum.PENDING_PRODUCT_STATUS.get(status, str(status))
+        html = text
+
+        # but maybe also show buttons to change status
+        buttons = []
+
+        if (self.has_perm('ignore_product')
+            and status in (self.enum.PENDING_PRODUCT_STATUS_PENDING,
+                           self.enum.PENDING_PRODUCT_STATUS_READY)):
+            buttons.append(self.make_buefy_button("Ignore Product",
+                                                  type='is-warning',
+                                                  icon_left='ban',
+                                                  **{'@click': "$emit('ignore-product')"}))
+
+        if (self.has_perm('resolve_product')
+            and status in (self.enum.PENDING_PRODUCT_STATUS_PENDING,
+                           self.enum.PENDING_PRODUCT_STATUS_READY,
+                           self.enum.PENDING_PRODUCT_STATUS_IGNORED)):
+            buttons.append(self.make_buefy_button("Resolve Product",
+                                                  is_primary=True,
+                                                  icon_left='object-ungroup',
+                                                  **{'@click': "$emit('resolve-product')"}))
+
+        if buttons:
+            text = HTML.tag('span', class_='control', c=[text])
+            buttons = HTML.tag('div', class_='buttons', c=buttons)
+            html = HTML.tag('b-field', grouped='grouped', c=[text, buttons])
+
+        return html
+
     def editable_instance(self, pending):
         if self.request.is_root:
             return True
@@ -2487,6 +2541,12 @@ class PendingProductView(MasterView):
     def get_resolve_product_kwargs(self, **kwargs):
         return kwargs
 
+    def ignore_product(self):
+        model = self.model
+        pending = self.get_instance()
+        pending.status_code = self.enum.PENDING_PRODUCT_STATUS_IGNORED
+        return self.redirect(self.get_action_url('view', pending))
+
     def get_row_data(self, pending):
         model = self.model
         return self.Session.query(model.CustomerOrderItem)\
@@ -2554,6 +2614,17 @@ class PendingProductView(MasterView):
                         route_name='{}.resolve_product'.format(route_prefix),
                         permission='{}.resolve_product'.format(permission_prefix))
 
+        # ignore product
+        config.add_tailbone_permission(permission_prefix,
+                                       f'{permission_prefix}.ignore_product',
+                                       f"Mark {model_title} as ignored")
+        config.add_route(f'{route_prefix}.ignore_product',
+                         f'{instance_url_prefix}/ignore-product',
+                         request_method='POST')
+        config.add_view(cls, attr='ignore_product',
+                        route_name=f'{route_prefix}.ignore_product',
+                        permission=f'{permission_prefix}.ignore_product')
+
 
 def defaults(config, **kwargs):
     base = globals()

From da13254caa1a181fb66fd5d1b21f4a414951203a Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 29 Oct 2023 15:10:56 -0500
Subject: [PATCH 159/542] Tweak param docs for `Form.set_validator()`

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

diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py
index 2c23b126..e04126a3 100644
--- a/tailbone/forms/core.py
+++ b/tailbone/forms/core.py
@@ -742,9 +742,8 @@ class Form(object):
            case the validator pertains to the form at large instead of
            one of the fields.
 
-           TODO: what should the validator look like?
-
-        :param validator: Callable validator for the node.
+        :param validator: Callable which accepts ``(node, value)``
+           args.
         """
         self.validators[key] = validator
 

From c1f2f84c7fe8f41a1983a8c9dc8dd55337c6ffcc Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 29 Oct 2023 15:46:18 -0500
Subject: [PATCH 160/542] Remove unused "simple menus" module approach

now we always use a handler instead
---
 tailbone/menus.py | 16 ++--------------
 1 file changed, 2 insertions(+), 14 deletions(-)

diff --git a/tailbone/menus.py b/tailbone/menus.py
index 36189b88..50dd3f4a 100644
--- a/tailbone/menus.py
+++ b/tailbone/menus.py
@@ -29,7 +29,7 @@ import logging
 import warnings
 
 from rattail.app import GenericHandler
-from rattail.util import import_module_path, prettify, simple_error
+from rattail.util import prettify, simple_error
 
 from webhelpers2.html import tags, HTML
 
@@ -70,19 +70,7 @@ class MenuHandler(GenericHandler):
                 tags.link_to("Menu Config", request.route_url('configure_menus'))))
             request.session.flash(msg, 'warning')
 
-        # okay, no config, so menus must be built from code..
-
-        # first check for a "simple menus" module; use that if defined
-        menumod = self.config.get('tailbone', 'menus')
-        if menumod:
-            menumod = import_module_path(menumod)
-            if (not hasattr(menumod, 'simple_menus')
-                or not callable(menumod.simple_menus)):
-                raise RuntimeError("module does not have a simple_menus() "
-                                   "callable: {}".format(menumod))
-            return menumod.simple_menus(request)
-
-        # now we fallback to menu handler method
+        # okay, no config, so menus will be built from code
         return self.make_menus(request)
 
     def make_menus_from_config(self, request, **kwargs):

From 8b072894528231876931e8d9e2d0c5cd8035d6a2 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 29 Oct 2023 15:59:17 -0500
Subject: [PATCH 161/542] Update changelog

---
 CHANGES.rst          | 10 ++++++++++
 tailbone/_version.py |  2 +-
 2 files changed, 11 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index da77da7c..58539385 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,16 @@
 CHANGELOG
 =========
 
+0.9.73 (2023-10-29)
+-------------------
+
+* Add way to "ignore" a pending product.
+
+* Tweak param docs for ``Form.set_validator()``.
+
+* Remove unused "simple menus" module approach.
+
+
 0.9.72 (2023-10-26)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index e1fc06fd..85ce4a36 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.72'
+__version__ = '0.9.73'

From a0075f6f78274dbd42226868a706b03efb242978 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 29 Oct 2023 22:22:16 -0500
Subject: [PATCH 162/542] Log warning / avoid error if email profile can't be
 normalized

e.g. if some import error happens
---
 tailbone/views/email.py | 12 +++++++++++-
 1 file changed, 11 insertions(+), 1 deletion(-)

diff --git a/tailbone/views/email.py b/tailbone/views/email.py
index 8d227a1e..22954782 100644
--- a/tailbone/views/email.py
+++ b/tailbone/views/email.py
@@ -24,6 +24,7 @@
 Email Views
 """
 
+import logging
 import re
 import warnings
 
@@ -41,6 +42,9 @@ from tailbone.db import Session
 from tailbone.views import View, MasterView
 
 
+log = logging.getLogger(__name__)
+
+
 class EmailSettingView(MasterView):
     """
     Master view for email admin (settings/preview).
@@ -103,7 +107,13 @@ class EmailSettingView(MasterView):
             emails = self.email_handler.get_available_emails()
         for key, Email in emails.items():
             email = Email(self.rattail_config, key)
-            data.append(self.normalize(email))
+            try:
+                normalized = self.normalize(email)
+            except:
+                log.warning("cannot normalize email: %s", email,
+                            exc_info=True)
+            else:
+                data.append(normalized)
         return data
 
     def configure_grid(self, g):

From a9ab59eb9202cd9ed2e47e5a589bb914d971bfdf Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 30 Oct 2023 01:06:41 -0500
Subject: [PATCH 163/542] Update changelog

---
 CHANGES.rst          | 6 ++++++
 tailbone/_version.py | 2 +-
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 58539385..27908253 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,12 @@
 CHANGELOG
 =========
 
+0.9.74 (2023-10-30)
+-------------------
+
+* Log warning / avoid error if email profile can't be normalized.
+
+
 0.9.73 (2023-10-29)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 85ce4a36..23ed7e0c 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.73'
+__version__ = '0.9.74'

From f47e45a928a7b4eb3a5a77fc3867bbab85519d78 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 1 Nov 2023 08:13:36 -0500
Subject: [PATCH 164/542] Add deprecation warnings for ambgiguous config keys

---
 tailbone/subscribers.py | 13 ++++++-------
 tailbone/util.py        | 24 +++++++++++++++++++++---
 2 files changed, 27 insertions(+), 10 deletions(-)

diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py
index 1143b510..d05b8bd5 100644
--- a/tailbone/subscribers.py
+++ b/tailbone/subscribers.py
@@ -40,7 +40,7 @@ from tailbone import helpers
 from tailbone.db import Session
 from tailbone.config import csrf_header_name, should_expose_websockets
 from tailbone.menus import make_simple_menus
-from tailbone.util import get_global_search_options
+from tailbone.util import get_available_themes, get_global_search_options
 
 
 def new_request(event):
@@ -152,12 +152,11 @@ def before_render(event):
                                                        default=False)
         renderer_globals['expose_theme_picker'] = expose_picker
         if expose_picker:
-            # tailbone's config extension provides a default theme selection,
-            # so the default we specify here *probably* should not matter
-            available = request.rattail_config.getlist('tailbone', 'themes',
-                                                       default=['falafel'])
-            if 'default' not in available:
-                available.insert(0, 'default')
+
+            # TODO: should remove 'falafel' option altogether
+            available = get_available_themes(request.rattail_config,
+                                             include=['falafel'])
+
             options = [tags.Option(theme, value=theme) for theme in available]
             renderer_globals['theme_picker_options'] = options
 
diff --git a/tailbone/util.py b/tailbone/util.py
index 4c9c680e..01efdce4 100644
--- a/tailbone/util.py
+++ b/tailbone/util.py
@@ -333,6 +333,26 @@ def get_theme_template_path(rattail_config, theme=None, session=None):
     return resource_path(theme_path)
 
 
+def get_available_themes(rattail_config, include=None):
+    available = rattail_config.getlist('tailbone', 'themes.keys')
+    if not available:
+        available = rattail_config.getlist('tailbone', 'themes',
+                                           ignore_ambiguous=True)
+        if available:
+            warnings.warn(f"URGENT: instead of 'tailbone.themes', "
+                          f"you should set 'tailbone.themes.keys'",
+                          DeprecationWarning, stacklevel=2)
+        else:
+            available = []
+    if 'default' not in available:
+        available.insert(0, 'default')
+    if include is not None:
+        for theme in include:
+            if theme not in available:
+                available.append(theme)
+    return available
+
+
 def get_effective_theme(rattail_config, theme=None, session=None):
     """
     Validates and returns the "effective" theme.  If you provide a theme, that
@@ -350,9 +370,7 @@ def get_effective_theme(rattail_config, theme=None, session=None):
             session.close()
 
     # confirm requested theme is available
-    available = rattail_config.getlist('tailbone', 'themes',
-                                       default=['bobcat'])
-    available.append('default')
+    available = get_available_themes(rattail_config)
     if theme not in available:
         raise ValueError("theme not available: {}".format(theme))
 

From 7ac505f1f4627da301ff419a81f8c6b6de836f65 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 1 Nov 2023 08:14:09 -0500
Subject: [PATCH 165/542] Update changelog

---
 CHANGES.rst          | 6 ++++++
 tailbone/_version.py | 2 +-
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 27908253..cec422e1 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,12 @@
 CHANGELOG
 =========
 
+0.9.75 (2023-11-01)
+-------------------
+
+* Add deprecation warnings for ambgiguous config keys.
+
+
 0.9.74 (2023-10-30)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 23ed7e0c..f03fa36c 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.74'
+__version__ = '0.9.75'

From 2f70ce2d5c2dff0f31b332540b4a689408d7657e Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 1 Nov 2023 09:20:03 -0500
Subject: [PATCH 166/542] Fix missing import

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

diff --git a/tailbone/util.py b/tailbone/util.py
index 01efdce4..fdd36572 100644
--- a/tailbone/util.py
+++ b/tailbone/util.py
@@ -25,12 +25,12 @@ Utilities
 """
 
 import datetime
-
-import pytz
-import humanize
 import logging
+import warnings
 
+import humanize
 import markdown
+import pytz
 
 from rattail.time import timezone, make_utc
 from rattail.files import resource_path

From bae6bc213343181f40dcc9fc61e16c58da8c5214 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 1 Nov 2023 09:20:26 -0500
Subject: [PATCH 167/542] Update changelog

---
 CHANGES.rst          | 6 ++++++
 tailbone/_version.py | 2 +-
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index cec422e1..ed7e17a4 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,12 @@
 CHANGELOG
 =========
 
+0.9.76 (2023-11-01)
+-------------------
+
+* Fix missing import.
+
+
 0.9.75 (2023-11-01)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index f03fa36c..00bb5697 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.75'
+__version__ = '0.9.76'

From 8522123cd3de9b6268364309e1b133313b7cdf8f Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 1 Nov 2023 14:54:30 -0500
Subject: [PATCH 168/542] Encode values for "between" query filter

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

diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py
index 41a3c1fa..2585433e 100644
--- a/tailbone/grids/filters.py
+++ b/tailbone/grids/filters.py
@@ -447,12 +447,12 @@ class AlchemyGridFilter(GridFilter):
         if start_value:
             if self.value_invalid(start_value):
                 return query
-            query = query.filter(self.column >= start_value)
+            query = query.filter(self.column >= self.encode_value(start_value))
 
         if end_value:
             if self.value_invalid(end_value):
                 return query
-            query = query.filter(self.column <= end_value)
+            query = query.filter(self.column <= self.encode_value(end_value))
 
         return query
 

From b5da5a46de71ae5f5d5a6ebb6f156eaca7af2b03 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 1 Nov 2023 17:47:07 -0500
Subject: [PATCH 169/542] Avoid error when rendering version diff

can't always assume relationship entities are versioned
---
 tailbone/diffs.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/tailbone/diffs.py b/tailbone/diffs.py
index 1c73635a..cdf35830 100644
--- a/tailbone/diffs.py
+++ b/tailbone/diffs.py
@@ -195,7 +195,7 @@ class VersionDiff(Diff):
 
                 ref = getattr(version, prop.key)
                 if ref:
-                    ref = ref.version_parent
+                    ref = getattr(ref, 'version_parent', None)
                     if ref:
                         return HTML.tag('span', c=[
                             text,

From b231c194a4f5d23b556d79e0bafc17077aa94f1c Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 1 Nov 2023 17:48:28 -0500
Subject: [PATCH 170/542] Update changelog

---
 CHANGES.rst          | 8 ++++++++
 tailbone/_version.py | 2 +-
 2 files changed, 9 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index ed7e17a4..85ec1448 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,14 @@
 CHANGELOG
 =========
 
+0.9.77 (2023-11-01)
+-------------------
+
+* Encode values for "between" query filter.
+
+* Avoid error when rendering version diff.
+
+
 0.9.76 (2023-11-01)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 00bb5697..5c19859d 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.76'
+__version__ = '0.9.77'

From b13fc99e9583e2a541497264d8047cb4ddd26dd5 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 1 Nov 2023 19:43:46 -0500
Subject: [PATCH 171/542] Use shared logic to get batch handler

---
 tailbone/api/batch/core.py | 4 +---
 1 file changed, 1 insertion(+), 3 deletions(-)

diff --git a/tailbone/api/batch/core.py b/tailbone/api/batch/core.py
index c98e01f1..f7bc9333 100644
--- a/tailbone/api/batch/core.py
+++ b/tailbone/api/batch/core.py
@@ -66,9 +66,7 @@ class APIBatchMixin(object):
         """
         app = self.get_rattail_app()
         key = self.get_batch_class().batch_key
-        spec = self.rattail_config.get('rattail.batch', '{}.handler'.format(key),
-                                       default=self.default_handler_spec)
-        return app.load_object(spec)(self.rattail_config)
+        return app.get_batch_handler(key, default=self.default_handler_spec)
 
 
 class APIBatchView(APIBatchMixin, APIMasterView):

From 51d7c10bc5ddc3100403899bd448018407b58227 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 1 Nov 2023 19:44:44 -0500
Subject: [PATCH 172/542] Fix config key for default themes list

---
 tailbone/config.py | 4 +---
 1 file changed, 1 insertion(+), 3 deletions(-)

diff --git a/tailbone/config.py b/tailbone/config.py
index be8f2dc2..6106e87e 100644
--- a/tailbone/config.py
+++ b/tailbone/config.py
@@ -24,8 +24,6 @@
 Rattail config extension for Tailbone
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import warnings
 
 from rattail.config import ConfigExtension as BaseExtension
@@ -51,7 +49,7 @@ class ConfigExtension(BaseExtension):
         configure_session(config, Session)
 
         # provide default theme selection
-        config.setdefault('tailbone', 'themes', 'default, falafel')
+        config.setdefault('tailbone', 'themes.keys', 'default, falafel')
         config.setdefault('tailbone', 'themes.expose_picker', 'true')
 
 

From 7ab3d2b635a94f925f979e0578c639baf3d846ee Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 1 Nov 2023 19:45:35 -0500
Subject: [PATCH 173/542] Update changelog

---
 CHANGES.rst          | 8 ++++++++
 tailbone/_version.py | 2 +-
 2 files changed, 9 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 85ec1448..25f12640 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,14 @@
 CHANGELOG
 =========
 
+0.9.78 (2023-11-01)
+-------------------
+
+* Use shared logic to get batch handler.
+
+* Fix config key for default themes list.
+
+
 0.9.77 (2023-11-01)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 5c19859d..956a3695 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.77'
+__version__ = '0.9.78'

From 55a115e57aa162b3f98a0c91011fdb2c8cc09f2d Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 1 Nov 2023 20:53:11 -0500
Subject: [PATCH 174/542] Add button to confirm all costs for receiving

---
 tailbone/templates/receiving/view.mako | 149 +++++++++++++++++--------
 tailbone/views/purchasing/receiving.py |  54 +++++++++
 2 files changed, 156 insertions(+), 47 deletions(-)

diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako
index 30bfd3a9..d639ff24 100644
--- a/tailbone/templates/receiving/view.mako
+++ b/tailbone/templates/receiving/view.mako
@@ -36,57 +36,105 @@
   % endif
 </%def>
 
-<%def name="render_auto_receive_helper()">
-  % if master.has_perm('auto_receive') and master.can_auto_receive(batch):
+<%def name="render_tools_helper()">
+  % if allow_confirm_all_costs or (master.has_perm('auto_receive') and master.can_auto_receive(batch)):
 
       <div class="object-helper">
         <h3>Tools</h3>
-        <div class="object-helper-content">
-          <b-button type="is-primary"
-                    @click="autoReceiveShowDialog = true"
-                    icon-pack="fas"
-                    icon-left="check">
-            Auto-Receive All Items
-          </b-button>
+        <div class="object-helper-content"
+             style="display: flex; flex-direction: column; gap: 1rem;">
+
+          % if allow_confirm_all_costs:
+              <b-button type="is-primary"
+                        icon-pack="fas"
+                        icon-left="check"
+                        @click="confirmAllCostsShowDialog = true">
+                Confirm All Costs
+              </b-button>
+              <b-modal has-modal-card
+                       :active.sync="confirmAllCostsShowDialog">
+                <div class="modal-card">
+
+                  <header class="modal-card-head">
+                    <p class="modal-card-title">Confirm All Costs</p>
+                  </header>
+
+                  <section class="modal-card-body">
+                    <p class="block">
+                      You can automatically mark all catalog and invoice
+                      cost amounts as "confirmed" if you wish.
+                    </p>
+                    <p class="block">
+                      Would you like to do this?
+                    </p>
+                  </section>
+
+                  <footer class="modal-card-foot">
+                    <b-button @click="confirmAllCostsShowDialog = false">
+                      Cancel
+                    </b-button>
+                    ${h.form(url(f'{route_prefix}.confirm_all_costs', uuid=batch.uuid), **{'@submit': 'confirmAllCostsSubmitting = true'})}
+                    ${h.csrf_token(request)}
+                    <b-button type="is-primary"
+                              native-type="submit"
+                              :disabled="confirmAllCostsSubmitting"
+                              icon-pack="fas"
+                              icon-left="check">
+                      {{ confirmAllCostsSubmitting ? "Working, please wait..." : "Confirm All" }}
+                    </b-button>
+                    ${h.end_form()}
+                  </footer>
+                </div>
+              </b-modal>
+          % endif
+
+          % if master.has_perm('auto_receive') and master.can_auto_receive(batch):
+              <b-button type="is-primary"
+                        @click="autoReceiveShowDialog = true"
+                        icon-pack="fas"
+                        icon-left="check">
+                Auto-Receive All Items
+              </b-button>
+              <b-modal has-modal-card
+                       :active.sync="autoReceiveShowDialog">
+                <div class="modal-card">
+
+                  <header class="modal-card-head">
+                    <p class="modal-card-title">Auto-Receive All Items</p>
+                  </header>
+
+                  <section class="modal-card-body">
+                    <p class="block">
+                      You can automatically set the "received" quantity to
+                      match the "shipped" quantity for all items, based on
+                      the invoice.
+                    </p>
+                    <p class="block">
+                      Would you like to do so?
+                    </p>
+                  </section>
+
+                  <footer class="modal-card-foot">
+                    <b-button @click="autoReceiveShowDialog = false">
+                      Cancel
+                    </b-button>
+                    ${h.form(url('{}.auto_receive'.format(route_prefix), uuid=batch.uuid), **{'@submit': 'autoReceiveSubmitting = true'})}
+                    ${h.csrf_token(request)}
+                    <b-button type="is-primary"
+                              native-type="submit"
+                              :disabled="autoReceiveSubmitting"
+                              icon-pack="fas"
+                              icon-left="check">
+                      {{ autoReceiveSubmitting ? "Working, please wait..." : "Auto-Receive All Items" }}
+                    </b-button>
+                    ${h.end_form()}
+                  </footer>
+                </div>
+              </b-modal>
+          % endif
+
         </div>
       </div>
-
-      <b-modal has-modal-card
-               :active.sync="autoReceiveShowDialog">
-        <div class="modal-card">
-
-          <header class="modal-card-head">
-            <p class="modal-card-title">Auto-Receive All Items</p>
-          </header>
-
-          <section class="modal-card-body">
-            <p class="block">
-              You can automatically set the "received" quantity to
-              match the "shipped" quantity for all items, based on
-              the invoice.
-            </p>
-            <p class="block">
-              Would you like to do so?
-            </p>
-          </section>
-
-          <footer class="modal-card-foot">
-            <b-button @click="autoReceiveShowDialog = false">
-              Cancel
-            </b-button>
-            ${h.form(url('{}.auto_receive'.format(route_prefix), uuid=batch.uuid), **{'@submit': 'autoReceiveSubmitting = true'})}
-            ${h.csrf_token(request)}
-            <b-button type="is-primary"
-                      native-type="submit"
-                      :disabled="autoReceiveSubmitting"
-                      icon-pack="fas"
-                      icon-left="check">
-              {{ autoReceiveSubmitting ? "Working, please wait..." : "Auto-Receive All Items" }}
-            </b-button>
-            ${h.end_form()}
-          </footer>
-        </div>
-      </b-modal>
   % endif
 </%def>
 
@@ -117,13 +165,20 @@
   ${self.render_status_breakdown()}
   ${self.render_po_vs_invoice_helper()}
   ${self.render_execute_helper()}
-  ${self.render_auto_receive_helper()}
+  ${self.render_tools_helper()}
 </%def>
 
 <%def name="modify_this_page_vars()">
   ${parent.modify_this_page_vars()}
   <script type="text/javascript">
 
+    % if allow_confirm_all_costs:
+
+        ThisPageData.confirmAllCostsShowDialog = false
+        ThisPageData.confirmAllCostsSubmitting = false
+
+    % endif
+
     ThisPageData.autoReceiveShowDialog = false
     ThisPageData.autoReceiveSubmitting = false
 
diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py
index 9de4baa3..33f3cc53 100644
--- a/tailbone/views/purchasing/receiving.py
+++ b/tailbone/views/purchasing/receiving.py
@@ -786,6 +786,13 @@ class ReceivingBatchView(PurchasingBatchView):
         kwargs['allow_edit_catalog_unit_cost'] = self.allow_edit_catalog_unit_cost(batch)
         kwargs['allow_edit_invoice_unit_cost'] = self.allow_edit_invoice_unit_cost(batch)
 
+        if (kwargs['allow_edit_catalog_unit_cost']
+            and kwargs['allow_edit_invoice_unit_cost']
+            and not batch.get_param('confirmed_all_costs')):
+            kwargs['allow_confirm_all_costs'] = True
+        else:
+            kwargs['allow_confirm_all_costs'] = False
+
         return kwargs
 
     def get_context_credits(self, row):
@@ -1910,6 +1917,45 @@ class ReceivingBatchView(PurchasingBatchView):
         batch = self.get_instance()
         return self.handler_action(batch, 'auto_receive')
 
+    def confirm_all_costs(self):
+        """
+        View which can "confirm all costs" for the batch.
+        """
+        batch = self.get_instance()
+        return self.handler_action(batch, 'confirm_all_receiving_costs')
+
+    def confirm_all_receiving_costs_thread(self, uuid, user_uuid, progress=None):
+        app = self.get_rattail_app()
+        model = self.model
+        session = app.make_session()
+
+        batch = session.get(model.PurchaseBatch, uuid)
+        # user = session.query(model.User).get(user_uuid)
+        try:
+            self.handler.confirm_all_receiving_costs(batch, progress=progress)
+
+        # if anything goes wrong, rollback and log the error etc.
+        except Exception as error:
+            session.rollback()
+            log.exception("failed to confirm all costs for batch: %s", batch)
+            session.close()
+            if progress:
+                progress.session.load()
+                progress.session['error'] = True
+                progress.session['error_msg'] = f"Failed to confirm costs: {simple_error(error)}"
+                progress.session.save()
+
+        else:
+            session.commit()
+            session.refresh(batch)
+            success_url = self.get_action_url('view', batch)
+            session.close()
+            if progress:
+                progress.session.load()
+                progress.session['complete'] = True
+                progress.session['success_url'] = success_url
+                progress.session.save()
+
     def configure_get_simple_settings(self):
         config = self.rattail_config
         return [
@@ -2034,6 +2080,14 @@ class ReceivingBatchView(PurchasingBatchView):
         config.add_view(cls, attr='transform_unit_row', route_name='{}.transform_unit_row'.format(route_prefix),
                         permission='{}.edit_row'.format(permission_prefix), renderer='json')
 
+        # confirm all costs
+        config.add_route(f'{route_prefix}.confirm_all_costs',
+                         f'{instance_url_prefix}/confirm-all-costs',
+                         request_method='POST')
+        config.add_view(cls, attr='confirm_all_costs',
+                        route_name=f'{route_prefix}.confirm_all_costs',
+                        permission=f'{permission_prefix}.edit_row')
+
         # auto-receive all items
         config.add_tailbone_permission(permission_prefix,
                                        '{}.auto_receive'.format(permission_prefix),

From bbffe1dc822cce4dd798717cf285dc786a58b76c Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 1 Nov 2023 20:54:39 -0500
Subject: [PATCH 175/542] Update changelog

---
 CHANGES.rst          | 6 ++++++
 tailbone/_version.py | 2 +-
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 25f12640..646f0d20 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,12 @@
 CHANGELOG
 =========
 
+0.9.79 (2023-11-01)
+-------------------
+
+* Add button to confirm all costs for receiving.
+
+
 0.9.78 (2023-11-01)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 956a3695..9befc488 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.78'
+__version__ = '0.9.79'

From 9fa592c5d6e7f0d4ad0b247c41a2abd8b4dc6580 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 5 Nov 2023 16:57:14 -0600
Subject: [PATCH 176/542] Expose status code for equity payments

---
 tailbone/views/members.py | 12 ++++++++++++
 1 file changed, 12 insertions(+)

diff --git a/tailbone/views/members.py b/tailbone/views/members.py
index 3a4ff0a1..b1bb2a0d 100644
--- a/tailbone/views/members.py
+++ b/tailbone/views/members.py
@@ -423,6 +423,10 @@ class MemberEquityPaymentView(MasterView):
     supports_grid_totals = True
     has_versions = True
 
+    labels = {
+        'status_code': "Status",
+    }
+
     grid_columns = [
         'received',
         '_member_key_',
@@ -431,6 +435,7 @@ class MemberEquityPaymentView(MasterView):
         'description',
         'source',
         'transaction_identifier',
+        'status_code',
     ]
 
     form_fields = [
@@ -441,6 +446,7 @@ class MemberEquityPaymentView(MasterView):
         'description',
         'source',
         'transaction_identifier',
+        'status_code',
     ]
 
     def query(self, session):
@@ -482,6 +488,9 @@ class MemberEquityPaymentView(MasterView):
 
         g.set_link('transaction_identifier')
 
+        # status_code
+        g.set_enum('status_code', model.MemberEquityPayment.STATUS)
+
     def render_member_key(self, payment, field):
         key = getattr(payment.member, field)
         return key
@@ -531,6 +540,9 @@ class MemberEquityPaymentView(MasterView):
         else:
             f.set_readonly('received')
 
+        # status_code
+        f.set_enum('status_code', model.MemberEquityPayment.STATUS)
+
 
 def defaults(config, **kwargs):
     base = globals()

From 172fe6c49ca07be70a693ec1037929699c8c7dc5 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 5 Nov 2023 17:10:32 -0600
Subject: [PATCH 177/542] Update changelog

---
 CHANGES.rst          | 6 ++++++
 tailbone/_version.py | 2 +-
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 646f0d20..713a6fae 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,12 @@
 CHANGELOG
 =========
 
+0.9.80 (2023-11-05)
+-------------------
+
+* Expose status code for equity payments.
+
+
 0.9.79 (2023-11-01)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 9befc488..6a8d8228 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.79'
+__version__ = '0.9.80'

From fc96fb40fbd283ac14c4929db6a643d544756eee Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 5 Nov 2023 18:31:43 -0600
Subject: [PATCH 178/542] Log warning instead of error for batch population
 error

this is most typically caused by bad user input; a warning is shown on
screen so they hopefully can guess what the problem is.  no need to
loop in the admins via email
---
 tailbone/views/batch/core.py | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py
index b9c28be7..f8b53d13 100644
--- a/tailbone/views/batch/core.py
+++ b/tailbone/views/batch/core.py
@@ -1057,7 +1057,8 @@ class BatchMasterView(MasterView):
             session.flush()
         except Exception as error:
             session.rollback()
-            log.exception("population failed for batch %s: %s", batch.uuid, batch)
+            log.warning("population failed for batch %s: %s", batch.uuid, batch,
+                        exc_info=True)
             session.close()
             if progress:
                 progress.session.load()

From 853cc871f7f1c8e6e484cf7c3fea286d310b3fa8 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sat, 11 Nov 2023 21:26:11 -0600
Subject: [PATCH 179/542] Remove reference to `pytz` library

---
 tailbone/util.py | 7 ++++---
 1 file changed, 4 insertions(+), 3 deletions(-)

diff --git a/tailbone/util.py b/tailbone/util.py
index fdd36572..db6ce4a3 100644
--- a/tailbone/util.py
+++ b/tailbone/util.py
@@ -30,7 +30,6 @@ import warnings
 
 import humanize
 import markdown
-import pytz
 
 from rattail.time import timezone, make_utc
 from rattail.files import resource_path
@@ -207,10 +206,12 @@ def pretty_datetime(config, value):
     if not value:
         return ''
 
+    app = config.get_app()
+
     # Make sure we're dealing with a tz-aware value.  If we're given a naive
     # value, we assume it to be local to the UTC timezone.
     if not value.tzinfo:
-        value = pytz.utc.localize(value)
+        value = app.make_utc(value, tzinfo=True)
 
     # Calculate time diff using UTC.
     time_ago = datetime.datetime.utcnow() - make_utc(value)
@@ -242,7 +243,7 @@ def raw_datetime(config, value, verbose=False, as_date=False):
     # Make sure we're dealing with a tz-aware value.  If we're given a naive
     # value, we assume it to be local to the UTC timezone.
     if not value.tzinfo:
-        value = pytz.utc.localize(value)
+        value = app.make_utc(value, tzinfo=True)
 
     # Calculate time diff using UTC.
     time_ago = datetime.datetime.utcnow() - make_utc(value)

From 97e7026cc95f0f73d0fa979e345abd7d50d61c15 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 15 Nov 2023 09:46:23 -0600
Subject: [PATCH 180/542] Avoid outright error if user scans barcode for
 inventory count

---
 tailbone/api/batch/inventory.py | 38 ++++++++++++++++++++-------------
 1 file changed, 23 insertions(+), 15 deletions(-)

diff --git a/tailbone/api/batch/inventory.py b/tailbone/api/batch/inventory.py
index 5e56fe46..22b67e54 100644
--- a/tailbone/api/batch/inventory.py
+++ b/tailbone/api/batch/inventory.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,15 +24,12 @@
 Tailbone Web API - Inventory Batches
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import decimal
 
-import six
+import sqlalchemy as sa
 
 from rattail import pod
-from rattail.db import model
-from rattail.util import pretty_quantity
+from rattail.db.model import InventoryBatch, InventoryBatchRow
 
 from cornice import Service
 
@@ -41,7 +38,7 @@ from tailbone.api.batch import APIBatchView, APIBatchRowView
 
 class InventoryBatchViews(APIBatchView):
 
-    model_class = model.InventoryBatch
+    model_class = InventoryBatch
     default_handler_spec = 'rattail.batch.inventory:InventoryBatchHandler'
     route_prefix = 'inventory'
     permission_prefix = 'batch.inventory'
@@ -50,12 +47,12 @@ class InventoryBatchViews(APIBatchView):
     supports_toggle_complete = True
 
     def normalize(self, batch):
-        data = super(InventoryBatchViews, self).normalize(batch)
+        data = super().normalize(batch)
 
         data['mode'] = batch.mode
         data['mode_display'] = self.enum.INVENTORY_MODE.get(batch.mode)
         if data['mode_display'] is None and batch.mode is not None:
-            data['mode_display'] = six.text_type(batch.mode)
+            data['mode_display'] = str(batch.mode)
 
         data['reason_code'] = batch.reason_code
 
@@ -119,7 +116,7 @@ class InventoryBatchViews(APIBatchView):
 
 class InventoryBatchRowViews(APIBatchRowView):
 
-    model_class = model.InventoryBatchRow
+    model_class = InventoryBatchRow
     default_handler_spec = 'rattail.batch.inventory:InventoryBatchHandler'
     route_prefix = 'inventory.rows'
     permission_prefix = 'batch.inventory'
@@ -130,23 +127,24 @@ class InventoryBatchRowViews(APIBatchRowView):
 
     def normalize(self, row):
         batch = row.batch
-        data = super(InventoryBatchRowViews, self).normalize(row)
+        data = super().normalize(row)
+        app = self.get_rattail_app()
 
         data['item_id'] = row.item_id
-        data['upc'] = six.text_type(row.upc)
+        data['upc'] = str(row.upc)
         data['upc_pretty'] = row.upc.pretty() if row.upc else None
         data['brand_name'] = row.brand_name
         data['description'] = row.description
         data['size'] = row.size
         data['full_description'] = row.product.full_description if row.product else row.description
         data['image_url'] = pod.get_image_url(self.rattail_config, row.upc) if row.upc else None
-        data['case_quantity'] = pretty_quantity(row.case_quantity or 1)
+        data['case_quantity'] = app.render_quantity(row.case_quantity or 1)
 
         data['cases'] = row.cases
         data['units'] = row.units
         data['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA'
         data['quantity_display'] = "{} {}".format(
-            pretty_quantity(row.cases or row.units),
+            app.render_quantity(row.cases or row.units),
             'CS' if row.cases else data['unit_uom'])
 
         data['allow_cases'] = self.batch_handler.allow_cases(batch)
@@ -174,7 +172,17 @@ class InventoryBatchRowViews(APIBatchRowView):
                 data['units'] = decimal.Decimal(data['units'])
 
         # update row per usual
-        row = super(InventoryBatchRowViews, self).update_object(row, data)
+        try:
+            row = super().update_object(row, data)
+        except sa.exc.DataError as error:
+            # detect when user scans barcode for cases/units field
+            if hasattr(error, 'orig'):
+                orig = type(error.orig)
+                if hasattr(orig, '__name__'):
+                    # nb. this particular error is from psycopg2
+                    if orig.__name__ == 'NumericValueOutOfRange':
+                        return {'error': "Numeric value out of range"}
+            raise
         return row
 
 

From dd9e41f6512b1e522f0afd98fb38c8791d856baf Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 15 Nov 2023 11:42:07 -0600
Subject: [PATCH 181/542] Update changelog

---
 CHANGES.rst          | 10 ++++++++++
 tailbone/_version.py |  2 +-
 2 files changed, 11 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 713a6fae..0ef20867 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,16 @@
 CHANGELOG
 =========
 
+0.9.81 (2023-11-15)
+-------------------
+
+* Log warning instead of error for batch population error.
+
+* Remove reference to ``pytz`` library.
+
+* Avoid outright error if user scans barcode for inventory count.
+
+
 0.9.80 (2023-11-05)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 6a8d8228..a08bcc20 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.80'
+__version__ = '0.9.81'

From e39581695f058d84cc5c082a5796d646f96de1fa Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 17 Nov 2023 17:00:50 -0600
Subject: [PATCH 182/542] Fix DB picker, theme picker per Buefy conventions

---
 tailbone/templates/base.mako | 14 ++++++++++----
 tailbone/views/master.py     |  2 +-
 2 files changed, 11 insertions(+), 5 deletions(-)

diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako
index 53dc3423..2a42af0b 100644
--- a/tailbone/templates/base.mako
+++ b/tailbone/templates/base.mako
@@ -339,9 +339,15 @@
                   ${h.form(url('change_db_engine'), ref='dbPickerForm')}
                   ${h.csrf_token(request)}
                   ${h.hidden('engine_type', value=master.engine_type_key)}
-                  <div class="select">
-                    ${h.select('dbkey', db_picker_selected, db_picker_options, **{'@change': 'changeDB()'})}
-                  </div>
+                  <b-select name="dbkey"
+                            value="${db_picker_selected}"
+                            @input="changeDB()">
+                    % for option in db_picker_options:
+                        <option value="${option.value}">
+                          ${option.label}
+                        </option>
+                    % endfor
+                  </b-select>
                   ${h.end_form()}
                 </div>
             % endif
@@ -397,7 +403,7 @@
                       <span>Theme:</span>
                       <b-select name="theme"
                                 v-model="globalTheme"
-                                @change="changeTheme()">
+                                @input="changeTheme()">
                         % for option in theme_picker_options:
                             <option value="${option.value}">
                               ${option.label}
diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index 7a1eff98..cf001c36 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -2783,7 +2783,7 @@ class MasterView(View):
                     # would therefore share the "current" engine)
                     selected = self.get_current_engine_dbkey()
                     kwargs['expose_db_picker'] = True
-                    kwargs['db_picker_options'] = [tags.Option(k) for k in engines]
+                    kwargs['db_picker_options'] = [tags.Option(k, value=k) for k in engines]
                     kwargs['db_picker_selected'] = selected
 
         # add info for downloadable input file templates, if any

From e23998a88b14f917786e15a4688a00def033422e Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 19 Nov 2023 22:24:15 -0600
Subject: [PATCH 183/542] Update changelog

---
 CHANGES.rst          | 6 ++++++
 tailbone/_version.py | 2 +-
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 0ef20867..16a0ed5f 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,12 @@
 CHANGELOG
 =========
 
+0.9.82 (2023-11-19)
+-------------------
+
+* Fix DB picker, theme picker per Buefy conventions.
+
+
 0.9.81 (2023-11-15)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index a08bcc20..ac5e3bac 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.81'
+__version__ = '0.9.82'

From f4cb1cb0976dbc6093f74ecb57f319ccf1e137c6 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 29 Nov 2023 15:03:08 -0600
Subject: [PATCH 184/542] Avoid error when editing a department

just a temp hack, need to fix proper yet
---
 tailbone/views/departments.py | 11 ++++++++---
 1 file changed, 8 insertions(+), 3 deletions(-)

diff --git a/tailbone/views/departments.py b/tailbone/views/departments.py
index 8115c5c3..3d462b16 100644
--- a/tailbone/views/departments.py
+++ b/tailbone/views/departments.py
@@ -24,7 +24,7 @@
 Department Views
 """
 
-from rattail.db import model
+from rattail.db.model import Department, Product
 
 from webhelpers2.html import HTML
 
@@ -35,7 +35,7 @@ class DepartmentView(MasterView):
     """
     Master view for the Department class.
     """
-    model_class = model.Department
+    model_class = Department
     touchable = True
     has_versions = True
     results_downloadable = True
@@ -64,7 +64,7 @@ class DepartmentView(MasterView):
     ]
 
     has_rows = True
-    model_row_class = model.Product
+    model_row_class = Product
     rows_title = "Products"
 
     row_labels = {
@@ -111,6 +111,8 @@ class DepartmentView(MasterView):
 
         # tax
         f.set_renderer('tax', self.render_tax)
+        # TODO: make this editable
+        f.set_readonly('tax')
 
     def render_employees(self, department, field):
         route_prefix = self.get_route_prefix()
@@ -160,6 +162,7 @@ class DepartmentView(MasterView):
         Check to see if there are any products which belong to the department;
         if there are then we do not allow delete and redirect the user.
         """
+        model = self.model
         count = self.Session.query(model.Product)\
                             .filter(model.Product.department == department)\
                             .count()
@@ -169,6 +172,7 @@ class DepartmentView(MasterView):
             raise self.redirect(self.get_action_url('view', department))
 
     def get_row_data(self, department):
+        model = self.model
         return self.Session.query(model.Product)\
                            .filter(model.Product.department == department)
 
@@ -198,6 +202,7 @@ class DepartmentView(MasterView):
         """
         View list of departments by vendor
         """
+        model = self.model
         data = self.Session.query(model.Department)\
                            .outerjoin(model.Product)\
                            .join(model.ProductCost)\

From 2a9d5f74ce73afafa2b3afb72999f21cba7c7244 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 30 Nov 2023 15:17:01 -0600
Subject: [PATCH 185/542] Update changelog

---
 CHANGES.rst          | 6 ++++++
 tailbone/_version.py | 2 +-
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 16a0ed5f..cd356554 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,12 @@
 CHANGELOG
 =========
 
+0.9.83 (2023-11-30)
+-------------------
+
+* Avoid error when editing a department.
+
+
 0.9.82 (2023-11-19)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index ac5e3bac..31167701 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.82'
+__version__ = '0.9.83'

From 35131c87326edc09d84772d04caeb12d90a52dd1 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 30 Nov 2023 18:23:47 -0600
Subject: [PATCH 186/542] Provide a way to show enum display text for some
 version diff fields

master view must explicitly declare which enums for which fields
---
 docs/api/diffs.rst         |  6 +++
 docs/api/views/master.rst  | 14 ++++++
 docs/api/views/members.rst |  6 +++
 docs/index.rst             |  2 +
 tailbone/diffs.py          | 94 ++++++++++++++++++++++++++++----------
 tailbone/views/master.py   | 49 +++++++++++++++++++-
 tailbone/views/members.py  | 27 ++++++++++-
 7 files changed, 172 insertions(+), 26 deletions(-)
 create mode 100644 docs/api/diffs.rst
 create mode 100644 docs/api/views/members.rst

diff --git a/docs/api/diffs.rst b/docs/api/diffs.rst
new file mode 100644
index 00000000..fb1bba71
--- /dev/null
+++ b/docs/api/diffs.rst
@@ -0,0 +1,6 @@
+
+``tailbone.diffs``
+==================
+
+.. automodule:: tailbone.diffs
+  :members:
diff --git a/docs/api/views/master.rst b/docs/api/views/master.rst
index 44278e0a..e7de7170 100644
--- a/docs/api/views/master.rst
+++ b/docs/api/views/master.rst
@@ -81,6 +81,12 @@ override when defining your subclass.
       override this for certain views, if so that should be done within
       :meth:`get_help_url()`.
 
+   .. attribute:: MasterView.version_diff_factory
+
+      Optional factory to use for version diff objects.  By default
+      this is *not set* but a subclass is free to set it.  See also
+      :meth:`get_version_diff_factory()`.
+
 
 Methods to Override
 -------------------
@@ -100,6 +106,14 @@ subclass.
 
    .. automethod:: MasterView.get_model_key
 
+   .. automethod:: MasterView.get_version_diff_enums
+
+   .. automethod:: MasterView.get_version_diff_factory
+
+   .. automethod:: MasterView.make_version_diff
+
+   .. automethod:: MasterView.title_for_version
+
 
 Support Methods
 ---------------
diff --git a/docs/api/views/members.rst b/docs/api/views/members.rst
new file mode 100644
index 00000000..6a9e9168
--- /dev/null
+++ b/docs/api/views/members.rst
@@ -0,0 +1,6 @@
+
+``tailbone.views.members``
+==========================
+
+.. automodule:: tailbone.views.members
+   :members:
diff --git a/docs/index.rst b/docs/index.rst
index b19d859f..4aa22f3e 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -44,6 +44,7 @@ Package API:
 
    api/api/batch/core
    api/api/batch/ordering
+   api/diffs
    api/forms
    api/grids
    api/grids.core
@@ -53,6 +54,7 @@ Package API:
    api/views/batch.vendorcatalog
    api/views/core
    api/views/master
+   api/views/members
    api/views/purchasing.batch
    api/views/purchasing.ordering
 
diff --git a/tailbone/diffs.py b/tailbone/diffs.py
index cdf35830..98253c57 100644
--- a/tailbone/diffs.py
+++ b/tailbone/diffs.py
@@ -34,35 +34,38 @@ from webhelpers2.html import HTML
 class Diff(object):
     """
     Core diff class.  In sore need of documentation.
+
+    You must provide the old and new data sets, and the set of
+    relevant fields as well, if they cannot be easily introspected.
+
+    :param old_data: Dict of "old" data values.
+
+    :param new_data: Dict of "old" data values.
+
+    :param fields: Sequence of relevant field names.  Note that
+       both data dicts are expected to have keys which match these
+       field names.  If you do not specify the fields then they
+       will (hopefully) be introspected from the old or new data
+       sets; however this will not work if they are both empty.
+
+    :param monospace: If true, this flag will cause the value
+       columns to be rendered in monospace font.  This is assumed
+       to be helpful when comparing "raw" data values which are
+       shown as e.g. ``repr(val)``.
+
+    :param enums: Optional dict of enums for use when displaying field
+       values.  If specified, keys should be field names and values
+       should be enum dicts.
     """
 
-    def __init__(self, old_data, new_data, columns=None, fields=None,
+    def __init__(self, old_data, new_data, columns=None, fields=None, enums=None,
                  render_field=None, render_value=None, nature='dirty',
                  monospace=False, extra_row_attrs=None):
-        """
-        Constructor.  You must provide the old and new data sets, and
-        the set of relevant fields as well, if they cannot be easily
-        introspected.
-
-        :param old_data: Dict of "old" data values.
-
-        :param new_data: Dict of "old" data values.
-
-        :param fields: Sequence of relevant field names.  Note that
-           both data dicts are expected to have keys which match these
-           field names.  If you do not specify the fields then they
-           will (hopefully) be introspected from the old or new data
-           sets; however this will not work if they are both empty.
-
-        :param monospace: If true, this flag will cause the value
-           columns to be rendered in monospace font.  This is assumed
-           to be helpful when comparing "raw" data values which are
-           shown as e.g. ``repr(val)``.
-        """
         self.old_data = old_data
         self.new_data = new_data
         self.columns = columns or ["field name", "old value", "new value"]
         self.fields = fields or self.make_fields()
+        self.enums = enums or {}
         self._render_field = render_field or self.render_field_default
         self.render_value = render_value or self.render_value_default
         self.nature = nature
@@ -92,7 +95,7 @@ class Diff(object):
         for the given field.  May be an empty string, or a snippet of HTML
         attribute syntax, e.g.:
 
-        .. code-highlight:: none
+        .. code-block:: none
 
            class="diff" foo="bar"
 
@@ -132,7 +135,21 @@ class Diff(object):
 
 class VersionDiff(Diff):
     """
-    Special diff class, for use with version history views
+    Special diff class, for use with version history views.  Note that
+    while based on :class:`Diff`, this class uses a different
+    signature for the constructor.
+
+    :param version: Reference to a Continuum version record (object).
+
+    :param \*args: Typical usage will not require positional args
+       beyond the ``version`` param, in which case ``old_data`` and
+       ``new_data`` params will be auto-determined based on the
+       ``version``.  But if you specify positional args then nothing
+       automatic is done, they are passed as-is to the parent
+       :class:`Diff` constructor.
+
+    :param \*\*kwargs: Remaining kwargs are passed as-is to the
+       :class:`Diff` constructor.
     """
 
     def __init__(self, version, *args, **kwargs):
@@ -176,9 +193,40 @@ class VersionDiff(Diff):
                 if field not in unwanted]
 
     def render_version_value(self, field, value, version):
+        """
+        Render the cell value text for the given version/field info.
+
+        Note that this method is used to render both sides of the diff
+        (before and after values).
+
+        :param field: Name of the field, as string.
+
+        :param value: Raw value for the field, as obtained from ``version``.
+
+        :param version: Reference to the Continuum version object.
+
+        :returns: Rendered text as string, or ``None``.
+        """
         text = HTML.tag('span', c=[repr(value)],
                         style='font-family: monospace;')
 
+        # assume the enum display is all we need, if enum exists for the field
+        if field in self.enums:
+
+            # but skip the enum display if None
+            display = self.enums[field].get(value)
+            if display is None and value is None:
+                return text
+
+            # otherwise show enum display to the right of raw value
+            display = self.enums[field].get(value, str(value))
+            return HTML.tag('span', c=[
+                text,
+                HTML.tag('span', c=[display],
+                         style='margin-left: 2rem; font-style: italic; font-weight: bold;'),
+            ])
+
+        # next we look for a relationship and may render the foreign object
         for prop in self.mapper.relationships:
             if prop.uselist:
                 continue
diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index cf001c36..cc2adcaf 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -597,7 +597,6 @@ class MasterView(View):
         return defaults
 
     def configure_row_grid(self, grid):
-        # super(MasterView, self).configure_row_grid(grid)
         self.set_row_labels(grid)
 
         self.configure_column_customer_key(grid)
@@ -1528,6 +1527,15 @@ class MasterView(View):
         })
 
     def title_for_version(self, version):
+        """
+        Must return the title text for the given version.  By default
+        this will be the :term:`rattail:model title` for the version's
+        data class.
+
+        :param version: Reference to a Continuum version object.
+
+        :returns: Title text for the version, as string.
+        """
         cls = continuum.parent_class(version.__class__)
         return cls.get_model_title()
 
@@ -4962,13 +4970,52 @@ class MasterView(View):
         return diffs.Diff(old_data, new_data, **kwargs)
 
     def get_version_diff_factory(self, **kwargs):
+        """
+        Must return the factory to be used when creating version diff
+        objects.
+
+        By default this returns the
+        :class:`tailbone.diffs.VersionDiff` class, unless
+        :attr:`version_diff_factory` is set, in which case that is
+        returned as-is.
+
+        :returns: A factory which can produce
+           :class:`~tailbone.diffs.VersionDiff` objects.
+        """
         if hasattr(self, 'version_diff_factory'):
             return self.version_diff_factory
         return diffs.VersionDiff
 
+    def get_version_diff_enums(self, version):
+        """
+        This can optionally return a dict of field enums, to be passed
+        to the version diff factory.  This method is called as part of
+        :meth:`make_version_diff()`.
+        """
+
     def make_version_diff(self, version, *args, **kwargs):
+        """
+        Make a version diff object, using the factory returned by
+        :meth:`get_version_diff_factory()`.
+
+        :param version: Reference to a Continuum version object.
+
+        :param title: If specified, must be as a kwarg.  Optional
+           override for the version title text.  If not specified,
+           :meth:`title_for_version()` is called for the title.
+
+        :param \*args: Additional args to pass to the factory.
+
+        :param \*\*kwargs: Additional kwargs to pass to the factory.
+
+        :returns: A :class:`~tailbone.diffs.VersionDiff` object.
+        """
         if 'title' not in kwargs:
             kwargs['title'] = self.title_for_version(version)
+
+        if 'enums' not in kwargs:
+            kwargs['enums'] = self.get_version_diff_enums(version)
+
         factory = self.get_version_diff_factory()
         return factory(version, *args, **kwargs)
 
diff --git a/tailbone/views/members.py b/tailbone/views/members.py
index b1bb2a0d..de844eb7 100644
--- a/tailbone/views/members.py
+++ b/tailbone/views/members.py
@@ -27,6 +27,7 @@ Member Views
 from collections import OrderedDict
 
 import sqlalchemy as sa
+import sqlalchemy_continuum as continuum
 
 from rattail.db import model
 from rattail.db.model import MembershipType, Member, MemberEquityPayment
@@ -71,6 +72,7 @@ class MembershipTypeView(MasterView):
     ]
 
     def configure_grid(self, g):
+        """ """
         super().configure_grid(g)
 
         g.set_sort_defaults('number')
@@ -79,6 +81,7 @@ class MembershipTypeView(MasterView):
         g.set_link('name')
 
     def get_row_data(self, memtype):
+        """ """
         model = self.model
         return self.Session.query(model.Member)\
                            .filter(model.Member.membership_type == memtype)
@@ -102,7 +105,7 @@ class MemberView(MasterView):
     """
     Master view for the Member class.
     """
-    model_class = model.Member
+    model_class = Member
     is_contact = True
     touchable = True
     has_versions = True
@@ -169,6 +172,7 @@ class MemberView(MasterView):
         return app.get_people_handler().get_quickie_search_placeholder()
 
     def configure_grid(self, g):
+        """ """
         super().configure_grid(g)
         route_prefix = self.get_route_prefix()
         model = self.model
@@ -263,13 +267,16 @@ class MemberView(MasterView):
                                            default=False)
 
     def grid_extra_class(self, member, i):
+        """ """
         if not member.active:
             return 'warning'
         if member.equity_current is False:
             return 'notice'
 
     def configure_form(self, f):
+        """ """
         super().configure_form(f)
+        model = self.model
         member = f.model_instance
 
         # date fields
@@ -342,6 +349,7 @@ class MemberView(MasterView):
         return app.render_currency(total)
 
     def template_kwargs_view(self, **kwargs):
+        """ """
         kwargs = super().template_kwargs_view(**kwargs)
         app = self.get_rattail_app()
         member = kwargs['instance']
@@ -360,10 +368,12 @@ class MemberView(MasterView):
         return kwargs
 
     def render_default_email(self, member, field):
+        """ """
         if member.emails:
             return member.emails[0].address
 
     def render_default_phone(self, member, field):
+        """ """
         if member.phones:
             return member.phones[0].number
 
@@ -376,6 +386,7 @@ class MemberView(MasterView):
         return tags.link_to(text, url)
 
     def get_row_data(self, member):
+        """ """
         model = self.model
         return self.Session.query(model.MemberEquityPayment)\
                                .filter(model.MemberEquityPayment.member == member)
@@ -395,6 +406,7 @@ class MemberView(MasterView):
                                       uuid=payment.uuid)
 
     def configure_get_simple_settings(self):
+        """ """
         return [
 
             # General
@@ -417,7 +429,7 @@ class MemberEquityPaymentView(MasterView):
     """
     Master view for the MemberEquityPayment class.
     """
-    model_class = model.MemberEquityPayment
+    model_class = MemberEquityPayment
     route_prefix = 'member_equity_payments'
     url_prefix = '/member-equity-payments'
     supports_grid_totals = True
@@ -450,6 +462,7 @@ class MemberEquityPaymentView(MasterView):
     ]
 
     def query(self, session):
+        """ """
         query = super().query(session)
         model = self.model
 
@@ -458,6 +471,7 @@ class MemberEquityPaymentView(MasterView):
         return query
 
     def configure_grid(self, g):
+        """ """
         super().configure_grid(g)
         model = self.model
 
@@ -502,6 +516,7 @@ class MemberEquityPaymentView(MasterView):
         return {'totals_display': app.render_currency(total)}
 
     def configure_form(self, f):
+        """ """
         super().configure_form(f)
         model = self.model
         payment = f.model_instance
@@ -543,6 +558,14 @@ class MemberEquityPaymentView(MasterView):
         # status_code
         f.set_enum('status_code', model.MemberEquityPayment.STATUS)
 
+    def get_version_diff_enums(self, version):
+        """ """
+        model = self.model
+        cls = continuum.parent_class(version.__class__)
+
+        if cls is model.MemberEquityPayment:
+            return {'status_code': model.MemberEquityPayment.STATUS}
+
 
 def defaults(config, **kwargs):
     base = globals()

From faeb2cb7e29a9b70b5a2b28009a3aa75d14a7d01 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 30 Nov 2023 18:25:01 -0600
Subject: [PATCH 187/542] Update changelog

---
 CHANGES.rst          | 6 ++++++
 tailbone/_version.py | 2 +-
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index cd356554..45e5cc1b 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,12 @@
 CHANGELOG
 =========
 
+0.9.84 (2023-11-30)
+-------------------
+
+* Provide a way to show enum display text for some version diff fields.
+
+
 0.9.83 (2023-11-30)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 31167701..a44d3ed3 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.83'
+__version__ = '0.9.84'

From 3e4bbf7092fa0937d32ba5e8659a99a0a6d45242 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 1 Dec 2023 19:50:07 -0600
Subject: [PATCH 188/542] Use clientele handler to populate customer dropdown
 widget

---
 docs/api/forms.widgets.rst |  6 +++++
 docs/index.rst             |  1 +
 tailbone/forms/widgets.py  | 52 +++++++++++++++++++++++++-------------
 3 files changed, 41 insertions(+), 18 deletions(-)
 create mode 100644 docs/api/forms.widgets.rst

diff --git a/docs/api/forms.widgets.rst b/docs/api/forms.widgets.rst
new file mode 100644
index 00000000..33316903
--- /dev/null
+++ b/docs/api/forms.widgets.rst
@@ -0,0 +1,6 @@
+
+``tailbone.forms.widgets``
+==========================
+
+.. automodule:: tailbone.forms.widgets
+  :members:
diff --git a/docs/index.rst b/docs/index.rst
index 4aa22f3e..351e910d 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -46,6 +46,7 @@ Package API:
    api/api/batch/ordering
    api/diffs
    api/forms
+   api/forms.widgets
    api/grids
    api/grids.core
    api/progress
diff --git a/tailbone/forms/widgets.py b/tailbone/forms/widgets.py
index 0b8d3dc9..db57f4f0 100644
--- a/tailbone/forms/widgets.py
+++ b/tailbone/forms/widgets.py
@@ -40,6 +40,7 @@ class ReadonlyWidget(dfwidget.HiddenWidget):
     readonly = True
 
     def serialize(self, field, cstruct, **kw):
+        """ """
         if cstruct in (colander.null, None):
             cstruct = ''
         # TODO: is this hacky?
@@ -77,15 +78,17 @@ class PercentInputWidget(dfwidget.TextInputWidget):
     autocomplete = 'off'
 
     def serialize(self, field, cstruct, **kw):
+        """ """
         if cstruct not in (colander.null, None):
             # convert "traditional" value to "human-friendly"
             value = decimal.Decimal(cstruct) * 100
             value = value.quantize(decimal.Decimal('0.001'))
             cstruct = str(value)
-        return super(PercentInputWidget, self).serialize(field, cstruct, **kw)
+        return super().serialize(field, cstruct, **kw)
 
     def deserialize(self, field, pstruct):
-        pstruct = super(PercentInputWidget, self).deserialize(field, pstruct)
+        """ """
+        pstruct = super().deserialize(field, pstruct)
         if pstruct is colander.null:
             return colander.null
         # convert "human-friendly" value to "traditional"
@@ -108,6 +111,7 @@ class CasesUnitsWidget(dfwidget.Widget):
     one_amount_only = False
 
     def serialize(self, field, cstruct, **kw):
+        """ """
         if cstruct in (colander.null, None):
             cstruct = ''
         readonly = kw.get('readonly', self.readonly)
@@ -118,6 +122,7 @@ class CasesUnitsWidget(dfwidget.Widget):
         return field.renderer(template, **values)
 
     def deserialize(self, field, pstruct):
+        """ """
         from tailbone.forms.types import ProductQuantity
 
         if pstruct is colander.null:
@@ -166,7 +171,7 @@ class CustomSelectWidget(dfwidget.SelectWidget):
         self.extra_template_values.update(kw)
 
     def get_template_values(self, field, cstruct, kw):
-        values = super(CustomSelectWidget, self).get_template_values(field, cstruct, kw)
+        values = super().get_template_values(field, cstruct, kw)
         if hasattr(self, 'extra_template_values'):
             values.update(self.extra_template_values)
         return values
@@ -209,6 +214,7 @@ class JQueryDateWidget(dfwidget.DateInputWidget):
     )
 
     def serialize(self, field, cstruct, **kw):
+        """ """
         if cstruct in (colander.null, None):
             cstruct = ''
         readonly = kw.get('readonly', self.readonly)
@@ -243,12 +249,14 @@ class FalafelDateTimeWidget(dfwidget.DateTimeInputWidget):
     template = 'datetime_falafel'
 
     def serialize(self, field, cstruct, **kw):
+        """ """
         readonly = kw.get('readonly', self.readonly)
         values = self.get_template_values(field, cstruct, kw)
         template = self.readonly_template if readonly else self.template
         return field.renderer(template, **values)
 
     def deserialize(self, field, pstruct):
+        """ """
         if pstruct  == '':
             return colander.null
         return pstruct
@@ -261,6 +269,7 @@ class FalafelTimeWidget(dfwidget.TimeInputWidget):
     template = 'time_falafel'
 
     def deserialize(self, field, pstruct):
+        """ """
         if pstruct  == '':
             return colander.null
         return pstruct
@@ -288,6 +297,7 @@ class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget):
     options = None
 
     def serialize(self, field, cstruct, **kw):
+        """ """
         if 'delay' in kw or getattr(self, 'delay', None):
             raise ValueError(
                 'AutocompleteWidget does not support *delay* parameter '
@@ -324,6 +334,7 @@ class MultiFileUploadWidget(dfwidget.FileUploadWidget):
     requirements = ()
 
     def serialize(self, field, cstruct, **kw):
+        """ """
         if cstruct in (colander.null, None):
             cstruct = []
 
@@ -339,6 +350,7 @@ class MultiFileUploadWidget(dfwidget.FileUploadWidget):
         return field.renderer(template, **values)
 
     def deserialize(self, field, pstruct):
+        """ """
         if pstruct is colander.null:
             return colander.null
 
@@ -359,6 +371,7 @@ class MultiFileUploadWidget(dfwidget.FileUploadWidget):
         return files_data
 
     def deserialize_upload(self, upload):
+        """ """
         # nb. this logic was copied from parent class and adapted
         # to allow for multiple files.  needs some more love.
 
@@ -428,11 +441,13 @@ def make_customer_widget(request, **kwargs):
 
 class CustomerAutocompleteWidget(JQueryAutocompleteWidget):
     """
-    Autocomplete widget for a Customer reference field.
+    Autocomplete widget for a
+    :class:`~rattail:rattail.db.model.customers.Customer` reference
+    field.
     """
 
     def __init__(self, request, *args, **kwargs):
-        super(CustomerAutocompleteWidget, self).__init__(*args, **kwargs)
+        super().__init__(*args, **kwargs)
         self.request = request
         model = self.request.rattail_config.get_model()
 
@@ -452,7 +467,7 @@ class CustomerAutocompleteWidget(JQueryAutocompleteWidget):
                 self.input_callback = input_handler
 
     def serialize(self, field, cstruct, **kw):
-
+        """ """
         # fetch customer to provide button label, if we have a value
         if cstruct:
             model = self.request.rattail_config.get_model()
@@ -460,18 +475,21 @@ class CustomerAutocompleteWidget(JQueryAutocompleteWidget):
             if customer:
                 self.field_display = str(customer)
 
-        return super(CustomerAutocompleteWidget, self).serialize(
+        return super().serialize(
             field, cstruct, **kw)
 
 
 class CustomerDropdownWidget(dfwidget.SelectWidget):
     """
-    Dropdown widget for a Customer reference field.
+    Dropdown widget for a
+    :class:`~rattail:rattail.db.model.customers.Customer` reference
+    field.
     """
 
     def __init__(self, request, *args, **kwargs):
-        super(CustomerDropdownWidget, self).__init__(*args, **kwargs)
+        super().__init__(*args, **kwargs)
         self.request = request
+        app = self.request.rattail_config.get_app()
 
         # must figure out dropdown values, if they weren't given
         if 'values' not in kwargs:
@@ -483,10 +501,8 @@ class CustomerDropdownWidget(dfwidget.SelectWidget):
                     customers = customers()
 
             else: # default customer list
-                model = self.request.rattail_config.get_model()
-                customers = Session.query(model.Customer)\
-                                   .order_by(model.Customer.name)\
-                                   .all()
+                customers = app.get_clientele_handler()\
+                               .get_all_customers(Session())
 
             # convert customer list to option values
             self.values = [(c.uuid, c.name)
@@ -517,7 +533,7 @@ class DepartmentWidget(dfwidget.SelectWidget):
                 values.insert(0, ('', "(none)"))
             kwargs['values'] = values
 
-        super(DepartmentWidget, self).__init__(**kwargs)
+        super().__init__(**kwargs)
 
 
 def make_vendor_widget(request, **kwargs):
@@ -548,7 +564,7 @@ class VendorAutocompleteWidget(JQueryAutocompleteWidget):
     """
 
     def __init__(self, request, *args, **kwargs):
-        super(VendorAutocompleteWidget, self).__init__(*args, **kwargs)
+        super().__init__(*args, **kwargs)
         self.request = request
         model = self.request.rattail_config.get_model()
 
@@ -568,7 +584,7 @@ class VendorAutocompleteWidget(JQueryAutocompleteWidget):
         #         self.input_callback = input_handler
 
     def serialize(self, field, cstruct, **kw):
-
+        """ """
         # fetch vendor to provide button label, if we have a value
         if cstruct:
             model = self.request.rattail_config.get_model()
@@ -576,7 +592,7 @@ class VendorAutocompleteWidget(JQueryAutocompleteWidget):
             if vendor:
                 self.field_display = str(vendor)
 
-        return super(VendorAutocompleteWidget, self).serialize(
+        return super().serialize(
             field, cstruct, **kw)
 
 
@@ -586,7 +602,7 @@ class VendorDropdownWidget(dfwidget.SelectWidget):
     """
 
     def __init__(self, request, *args, **kwargs):
-        super(VendorDropdownWidget, self).__init__(*args, **kwargs)
+        super().__init__(*args, **kwargs)
         self.request = request
 
         # must figure out dropdown values, if they weren't given

From d154986128a353e03bec4a0d18c55128bee43020 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 1 Dec 2023 21:57:20 -0600
Subject: [PATCH 189/542] Update changelog

---
 CHANGES.rst          | 6 ++++++
 tailbone/_version.py | 2 +-
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 45e5cc1b..7cffe70a 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,12 @@
 CHANGELOG
 =========
 
+0.9.85 (2023-12-01)
+-------------------
+
+* Use clientele handler to populate customer dropdown widget.
+
+
 0.9.84 (2023-11-30)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index a44d3ed3..66aab6b4 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.84'
+__version__ = '0.9.85'

From 91e7001963148766997a7d349e7d9c40cfca90ce Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 4 Dec 2023 10:15:12 -0600
Subject: [PATCH 190/542] Overhaul tox config for more python versions

---
 setup.cfg |  6 ++++++
 tox.ini   | 31 +++++++++++++++++--------------
 2 files changed, 23 insertions(+), 14 deletions(-)

diff --git a/setup.cfg b/setup.cfg
index 85501357..67541d96 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -26,6 +26,12 @@ classifiers =
         Operating System :: OS Independent
         Programming Language :: Python
         Programming Language :: Python :: 3
+        Programming Language :: Python :: 3.6
+        Programming Language :: Python :: 3.7
+        Programming Language :: Python :: 3.8
+        Programming Language :: Python :: 3.9
+        Programming Language :: Python :: 3.10
+        Programming Language :: Python :: 3.11
         Topic :: Internet :: WWW/HTTP
         Topic :: Office/Business
         Topic :: Software Development :: Libraries :: Python Modules
diff --git a/tox.ini b/tox.ini
index 8681465d..ea833b39 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,25 +1,28 @@
 
 [tox]
-envlist = py36, py37, py39
+envlist = py36, py37, py38, py39, py310, py311
+
+# TODO: can remove this when we drop py36 support
+# nb. need this for testing older python versions
+# https://tox.wiki/en/latest/faq.html#testing-end-of-life-python-versions
+requires = virtualenv<20.22.0
 
 [testenv]
-commands =
-        pip install --upgrade pip
-        pip install --upgrade setuptools wheel
-        pip install --upgrade --upgrade-strategy eager Tailbone[tests] rattail[bouncer,db] rattail-tempmon
-        pytest {posargs}
+deps = rattail-tempmon
+extras = tests
+commands = pytest {posargs}
+
+[testenv:py37]
+# nb. Chameleon 4.3 requires Python 3.9+
+deps = Chameleon<4.3
 
 [testenv:coverage]
 basepython = python3
-commands =
-        pip install --upgrade pip
-        pip install --upgrade --upgrade-strategy eager Tailbone[tests] rattail[bouncer,db] rattail-tempmon
-        pytest --cov=tailbone --cov-report=html
+extras = tests
+commands = pytest --cov=tailbone --cov-report=html
 
 [testenv:docs]
 basepython = python3
 changedir = docs
-commands =
-        pip install --upgrade pip
-        pip install --upgrade --upgrade-strategy eager Tailbone[docs] rattail[bouncer,db] rattail-tempmon
-        sphinx-build -b html -d {envtmpdir}/doctrees -W -T . {envtmpdir}/docs
+extras = docs
+commands = sphinx-build -b html -d {envtmpdir}/doctrees -W -T . {envtmpdir}/docs

From 98fc82acfd0fdad8e0c6f30bc90993f85007931a Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 11 Dec 2023 13:50:02 -0600
Subject: [PATCH 191/542] Use `ltrim(rtrim())` instead of just `trim()` in grid
  filters

apparently this is needed for older SQL Server compatibility, per
https://stackoverflow.com/questions/54340470/trim-is-not-a-recognized-built-in-function-name
---
 tailbone/grids/filters.py | 33 +++++++++++++++++----------------
 1 file changed, 17 insertions(+), 16 deletions(-)

diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py
index 2585433e..f70670b6 100644
--- a/tailbone/grids/filters.py
+++ b/tailbone/grids/filters.py
@@ -313,7 +313,7 @@ class AlchemyGridFilter(GridFilter):
 
     def __init__(self, *args, **kwargs):
         self.column = kwargs.pop('column')
-        super(AlchemyGridFilter, self).__init__(*args, **kwargs)
+        super().__init__(*args, **kwargs)
 
     def filter_equal(self, query, value):
         """
@@ -538,17 +538,18 @@ class AlchemyStringFilter(AlchemyGridFilter):
         return query.filter(sa.or_(*conditions))
 
     def filter_is_empty(self, query, value):
-        return query.filter(sa.func.trim(self.column) == self.encode_value(''))
+        return query.filter(sa.func.ltrim(sa.func.rtrim(self.column)) == self.encode_value(''))
 
     def filter_is_not_empty(self, query, value):
-        return query.filter(sa.func.trim(self.column) != self.encode_value(''))
+        return query.filter(sa.func.ltrim(sa.func.rtrim(self.column)) != self.encode_value(''))
 
     def filter_is_empty_or_null(self, query, value):
         return query.filter(
             sa.or_(
-                sa.func.trim(self.column) == self.encode_value(''),
+                sa.func.ltrim(sa.func.rtrim(self.column)) == self.encode_value(''),
                 self.column == None))
 
+
 class AlchemyEmptyStringFilter(AlchemyStringFilter):
     """
     String filter with special logic to treat empty string values as NULL
@@ -558,13 +559,13 @@ class AlchemyEmptyStringFilter(AlchemyStringFilter):
         return query.filter(
             sa.or_(
                 self.column == None,
-                sa.func.trim(self.column) == self.encode_value('')))
+                sa.func.ltrim(sa.func.rtrim(self.column)) == self.encode_value('')))
 
     def filter_is_not_null(self, query, value):
         return query.filter(
             sa.and_(
                 self.column != None,
-                sa.func.trim(self.column) != self.encode_value('')))
+                sa.func.ltrim(sa.func.rtrim(self.column)) != self.encode_value('')))
 
 
 class AlchemyByteStringFilter(AlchemyStringFilter):
@@ -576,7 +577,7 @@ class AlchemyByteStringFilter(AlchemyStringFilter):
     value_encoding = 'utf-8'
 
     def get_value(self, value=UNSPECIFIED):
-        value = super(AlchemyByteStringFilter, self).get_value(value)
+        value = super().get_value(value)
         if isinstance(value, str):
             value = value.encode(self.value_encoding)
         return value
@@ -637,32 +638,32 @@ class AlchemyNumericFilter(AlchemyGridFilter):
     def filter_equal(self, query, value):
         if self.value_invalid(value):
             return query
-        return super(AlchemyNumericFilter, self).filter_equal(query, value)
+        return super().filter_equal(query, value)
 
     def filter_not_equal(self, query, value):
         if self.value_invalid(value):
             return query
-        return super(AlchemyNumericFilter, self).filter_not_equal(query, value)
+        return super().filter_not_equal(query, value)
 
     def filter_greater_than(self, query, value):
         if self.value_invalid(value):
             return query
-        return super(AlchemyNumericFilter, self).filter_greater_than(query, value)
+        return super().filter_greater_than(query, value)
 
     def filter_greater_equal(self, query, value):
         if self.value_invalid(value):
             return query
-        return super(AlchemyNumericFilter, self).filter_greater_equal(query, value)
+        return super().filter_greater_equal(query, value)
 
     def filter_less_than(self, query, value):
         if self.value_invalid(value):
             return query
-        return super(AlchemyNumericFilter, self).filter_less_than(query, value)
+        return super().filter_less_than(query, value)
 
     def filter_less_equal(self, query, value):
         if self.value_invalid(value):
             return query
-        return super(AlchemyNumericFilter, self).filter_less_equal(query, value)
+        return super().filter_less_equal(query, value)
 
 
 class AlchemyIntegerFilter(AlchemyNumericFilter):
@@ -1193,7 +1194,7 @@ class AlchemyPhoneNumberFilter(AlchemyStringFilter):
         'ILIKE' query with those parts.
         """
         value = self.parse_value(value)
-        return super(AlchemyPhoneNumberFilter, self).filter_contains(query, value)
+        return super().filter_contains(query, value)
 
     def filter_does_not_contain(self, query, value):
         """
@@ -1201,7 +1202,7 @@ class AlchemyPhoneNumberFilter(AlchemyStringFilter):
         'NOT ILIKE' query with those parts.
         """
         value = self.parse_value(value)
-        return super(AlchemyPhoneNumberFilter, self).filter_does_not_contain(query, value)
+        return super().filter_does_not_contain(query, value)
 
 
 class GridFilterSet(OrderedDict):
@@ -1245,7 +1246,7 @@ class GridFiltersForm(forms.Form):
                 node = colander.SchemaNode(colander.String(), name=key)
                 schema.add(node)
             kwargs['schema'] = schema
-        super(GridFiltersForm, self).__init__(**kwargs)
+        super().__init__(**kwargs)
 
     def iter_filters(self):
         return self.filters.values()

From b6618c8ee5e48ada18e429b4ae85a674e91e18cb Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 12 Dec 2023 11:46:28 -0600
Subject: [PATCH 192/542] Update changelog

---
 CHANGES.rst          | 6 ++++++
 tailbone/_version.py | 2 +-
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 7cffe70a..40e3a0d1 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,12 @@
 CHANGELOG
 =========
 
+0.9.86 (2023-12-12)
+-------------------
+
+* Use ``ltrim(rtrim())`` instead of just ``trim()`` in grid filters.
+
+
 0.9.85 (2023-12-01)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 66aab6b4..689b5c2b 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.85'
+__version__ = '0.9.86'

From 90630fe8523a9de739de4ec73076b69a24914d4b Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 13 Dec 2023 12:05:42 -0600
Subject: [PATCH 193/542] Auto-disable submit button for login form

not sure why i had explicitly disabled that before..?
---
 tailbone/views/auth.py | 2 --
 1 file changed, 2 deletions(-)

diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py
index f8d71d34..7c4d26f0 100644
--- a/tailbone/views/auth.py
+++ b/tailbone/views/auth.py
@@ -101,8 +101,6 @@ class AuthenticationView(View):
 
         form = forms.Form(schema=UserLogin(), request=self.request)
         form.save_label = "Login"
-        form.auto_disable_save = False
-        form.auto_disable = False # TODO: deprecate / remove this
         form.show_reset = True
         form.show_cancel = False
         if form.validate():

From 90e35ee3dbcb35335437ae530c8a731305fec366 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 19 Dec 2023 12:49:33 -0600
Subject: [PATCH 194/542] Hide single invoice file field for multi-invoice
 receiving batch

---
 tailbone/views/purchasing/receiving.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py
index 33f3cc53..8cf38aaf 100644
--- a/tailbone/views/purchasing/receiving.py
+++ b/tailbone/views/purchasing/receiving.py
@@ -533,6 +533,7 @@ class ReceivingBatchView(PurchasingBatchView):
                 f.insert_before('invoice_file', 'invoice_files')
             f.set_renderer('invoice_files', self.render_invoice_files)
             f.set_readonly('invoice_files', True)
+            f.remove('invoice_file')
 
         # invoice totals
         f.set_label('invoice_total', "Invoice Total (Orig.)")

From 3bdc7175a3fb52bf3c28f1d88c81cd647ee634b2 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 20 Dec 2023 11:56:24 -0600
Subject: [PATCH 195/542] Use common logic to render invoice total for
 receiving

and avoid error if total is none
---
 tailbone/views/purchasing/receiving.py | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py
index 8cf38aaf..22fbc133 100644
--- a/tailbone/views/purchasing/receiving.py
+++ b/tailbone/views/purchasing/receiving.py
@@ -1857,6 +1857,7 @@ class ReceivingBatchView(PurchasingBatchView):
         """
         AJAX view for updating various cost fields in a data row.
         """
+        app = self.get_rattail_app()
         model = self.model
         batch = self.get_instance()
         data = dict(get_form_data(self.request))
@@ -1891,10 +1892,10 @@ class ReceivingBatchView(PurchasingBatchView):
                 'catalog_cost_confirmed': row.catalog_cost_confirmed,
                 'invoice_unit_cost': self.render_simple_unit_cost(row, 'invoice_unit_cost'),
                 'invoice_cost_confirmed': row.invoice_cost_confirmed,
-                'invoice_total_calculated': '{:0.2f}'.format(row.invoice_total_calculated),
+                'invoice_total_calculated': app.render_currency(row.invoice_total_calculated),
             },
             'batch': {
-                'invoice_total_calculated': '{:0.2f}'.format(batch.invoice_total_calculated),
+                'invoice_total_calculated': app.render_currency(batch.invoice_total_calculated),
             },
         }
 

From a40add8f413d44e3d34ca67204af441269dbda8f Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 22 Dec 2023 11:50:05 -0600
Subject: [PATCH 196/542] Expose default custorder discount for Departments

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

diff --git a/tailbone/views/departments.py b/tailbone/views/departments.py
index 3d462b16..a062b183 100644
--- a/tailbone/views/departments.py
+++ b/tailbone/views/departments.py
@@ -59,6 +59,7 @@ class DepartmentView(MasterView):
         'tax',
         'food_stampable',
         'exempt_from_gross_sales',
+        'default_custorder_discount',
         'allow_product_deletions',
         'employees',
     ]
@@ -114,6 +115,9 @@ class DepartmentView(MasterView):
         # TODO: make this editable
         f.set_readonly('tax')
 
+        # default_custorder_discount
+        f.set_type('default_custorder_discount', 'percent')
+
     def render_employees(self, department, field):
         route_prefix = self.get_route_prefix()
         permission_prefix = self.get_permission_prefix()

From 25c48a97c55a0e8ea5909fe2b34dc0fb8407d1e4 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 26 Dec 2023 20:17:05 -0600
Subject: [PATCH 197/542] Update changelog

---
 CHANGES.rst          | 12 ++++++++++++
 tailbone/_version.py |  2 +-
 2 files changed, 13 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 40e3a0d1..174c06ae 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,18 @@
 CHANGELOG
 =========
 
+0.9.87 (2023-12-26)
+-------------------
+
+* Auto-disable submit button for login form.
+
+* Hide single invoice file field for multi-invoice receiving batch.
+
+* Use common logic to render invoice total for receiving.
+
+* Expose default custorder discount for Departments.
+
+
 0.9.86 (2023-12-12)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 689b5c2b..5c813a10 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.86'
+__version__ = '0.9.87'

From 0b7d2f5aede8f5f6123326f87b78b04247632b70 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 26 Mar 2024 11:47:37 -0500
Subject: [PATCH 198/542] Fix how metadata/bind is used for importer batch
 table

per changes coming in SQLAlchemy 2.0
---
 tailbone/views/batch/importer.py | 26 +++++++++++++-------------
 1 file changed, 13 insertions(+), 13 deletions(-)

diff --git a/tailbone/views/batch/importer.py b/tailbone/views/batch/importer.py
index f0b76bf6..a5916448 100644
--- a/tailbone/views/batch/importer.py
+++ b/tailbone/views/batch/importer.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.
 #
@@ -26,7 +26,7 @@ Views for importer batches
 
 import sqlalchemy as sa
 
-from rattail.db import model
+from rattail.db.model import ImporterBatch
 
 import colander
 
@@ -37,7 +37,7 @@ class ImporterBatchView(BatchMasterView):
     """
     Master view for importer batches.
     """
-    model_class = model.ImporterBatch
+    model_class = ImporterBatch
     default_handler_spec = 'rattail.batch.importer:ImporterBatchHandler'
     route_prefix = 'batch.importer'
     url_prefix = '/batches/importer'
@@ -91,7 +91,7 @@ class ImporterBatchView(BatchMasterView):
     ]
 
     def configure_form(self, f):
-        super(ImporterBatchView, self).configure_form(f)
+        super().configure_form(f)
 
         # readonly fields
         f.set_readonly('import_handler_spec')
@@ -110,21 +110,21 @@ class ImporterBatchView(BatchMasterView):
             self.make_row_table(batch.row_table)
             kwargs['rows'] = self.Session.query(self.current_row_table).all()
         kwargs.setdefault('status_enum', self.enum.IMPORTER_BATCH_ROW_STATUS)
-        breakdown = super(ImporterBatchView, self).make_status_breakdown(
-            batch, **kwargs)
+        breakdown = super().make_status_breakdown(batch, **kwargs)
         return breakdown
 
     def delete_instance(self, batch):
         self.make_row_table(batch.row_table)
         if self.current_row_table is not None:
             self.current_row_table.drop()
-        super(ImporterBatchView, self).delete_instance(batch)
+        super().delete_instance(batch)
 
     def make_row_table(self, name):
         if not hasattr(self, 'current_row_table'):
-            metadata = sa.MetaData(schema='batch', bind=self.Session.bind)
+            metadata = sa.MetaData(schema='batch')
             try:
-                self.current_row_table = sa.Table(name, metadata, autoload=True)
+                self.current_row_table = sa.Table(name, metadata,
+                                                  autoload_with=self.Session.bind)
             except sa.exc.NoSuchTableError:
                 self.current_row_table = None
 
@@ -136,7 +136,7 @@ class ImporterBatchView(BatchMasterView):
         return self.enum.IMPORTER_BATCH_ROW_STATUS
 
     def configure_row_grid(self, g):
-        super(ImporterBatchView, self).configure_row_grid(g)
+        super().configure_row_grid(g)
 
         def make_filter(field, **kwargs):
             column = getattr(self.current_row_table.c, field)
@@ -190,7 +190,7 @@ class ImporterBatchView(BatchMasterView):
 
     def get_parent(self, row):
         uuid = self.current_row_table.name
-        return self.Session.get(model.ImporterBatch, uuid)
+        return self.Session.get(ImporterBatch, uuid)
 
     def get_row_instance_title(self, row):
         if row.object_str:
@@ -242,7 +242,7 @@ class ImporterBatchView(BatchMasterView):
 
         kwargs.setdefault('schema', colander.Schema())
         kwargs.setdefault('cancel_url', None)
-        return super(ImporterBatchView, self).make_row_form(instance=row, **kwargs)
+        return super().make_row_form(instance=row, **kwargs)
 
     def configure_row_form(self, f):
         """
@@ -291,7 +291,7 @@ class ImporterBatchView(BatchMasterView):
         ]
 
     def get_row_xlsx_row(self, row, fields):
-        xlrow = super(ImporterBatchView, self).get_row_xlsx_row(row, fields)
+        xlrow = super().get_row_xlsx_row(row, fields)
 
         xlrow['status'] = self.enum.IMPORTER_BATCH_ROW_STATUS[row.status_code]
 

From 27fce173cefef545e19bd834171d1c685d853fb8 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 26 Mar 2024 11:48:52 -0500
Subject: [PATCH 199/542] Fix how row grid values are fetched, for row proxy
 objects

per changes coming in SQLAlchemy 2.0
---
 tailbone/grids/core.py | 13 +++++++++----
 1 file changed, 9 insertions(+), 4 deletions(-)

diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py
index 7a0d00e3..41964648 100644
--- a/tailbone/grids/core.py
+++ b/tailbone/grids/core.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.
 #
@@ -33,7 +33,6 @@ from sqlalchemy import orm
 
 from rattail.db.types import GPCType
 from rattail.util import prettify, pretty_boolean, pretty_quantity
-from rattail.time import localtime
 
 import webhelpers2_grid
 from pyramid.renderers import render
@@ -478,6 +477,11 @@ class Grid(object):
 
         :returns: The value, or ``None`` if no value was found.
         """
+        # TODO: this seems a little hacky, is there a better way?
+        # nb. this may only be relevant for import/export batch view?
+        if isinstance(obj, sa.engine.Row):
+            return obj._mapping[column_name]
+
         try:
             return obj[column_name]
         except KeyError:
@@ -503,7 +507,8 @@ class Grid(object):
         value = self.obtain_value(obj, column_name)
         if value is None:
             return ""
-        value = localtime(self.request.rattail_config, value)
+        app = self.request.rattail_config.get_app()
+        value = app.localtime(value)
         return raw_datetime(self.request.rattail_config, value)
 
     def render_enum(self, obj, column_name):
@@ -1724,7 +1729,7 @@ class CustomWebhelpersGrid(webhelpers2_grid.Grid):
         self.renderers = kwargs.pop('renderers', {})
         self.linked_columns = kwargs.pop('linked_columns', [])
         self.extra_record_class = kwargs.pop('extra_record_class', None)
-        super(CustomWebhelpersGrid, self).__init__(itemlist, columns, **kwargs)
+        super().__init__(itemlist, columns, **kwargs)
 
     def generate_header_link(self, column_number, column, label_text):
 

From 4363b7c5d738d951483a42947a1c38557dd50646 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 26 Mar 2024 12:53:20 -0500
Subject: [PATCH 200/542] Update changelog

---
 CHANGES.rst          | 7 +++++++
 tailbone/_version.py | 2 +-
 2 files changed, 8 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 174c06ae..4e96d2e1 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,13 @@
 CHANGELOG
 =========
 
+0.9.88 (2024-03-26)
+-------------------
+
+* Update some SQLAlchemy logic per upcoming 2.0 changes.
+
+
+
 0.9.87 (2023-12-26)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 5c813a10..86e8f57c 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.87'
+__version__ = '0.9.88'

From dfdb7a9b59e8c10a551e7003cebca50a50438846 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 27 Mar 2024 13:11:03 -0500
Subject: [PATCH 201/542] Fix bulk-delete rows for import/export batch

per changes in SQLAlchemy 1.4
---
 CHANGES.rst                      | 7 ++++++-
 tailbone/views/batch/importer.py | 2 +-
 2 files changed, 7 insertions(+), 2 deletions(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 4e96d2e1..1717910f 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,13 +2,18 @@
 CHANGELOG
 =========
 
+Unreleased
+----------
+
+* Fix bulk-delete rows for import/export batch.
+
+
 0.9.88 (2024-03-26)
 -------------------
 
 * Update some SQLAlchemy logic per upcoming 2.0 changes.
 
 
-
 0.9.87 (2023-12-26)
 -------------------
 
diff --git a/tailbone/views/batch/importer.py b/tailbone/views/batch/importer.py
index a5916448..962093da 100644
--- a/tailbone/views/batch/importer.py
+++ b/tailbone/views/batch/importer.py
@@ -277,7 +277,7 @@ class ImporterBatchView(BatchMasterView):
         query = self.get_effective_row_data(sort=False)
         batch.rowcount -= query.count()
         delete_query = self.current_row_table.delete().where(self.current_row_table.c.uuid.in_([row.uuid for row in query]))
-        delete_query.execute()
+        self.Session.bind.execute(delete_query)
         return self.redirect(self.get_action_url('view', batch))
 
     def get_row_xlsx_fields(self):

From cdc857065b42fd82eb0b29409e276819231f4b09 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 27 Mar 2024 13:14:23 -0500
Subject: [PATCH 202/542] Update changelog

---
 CHANGES.rst          | 3 +++
 tailbone/_version.py | 2 +-
 2 files changed, 4 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 1717910f..38c3b959 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -5,6 +5,9 @@ CHANGELOG
 Unreleased
 ----------
 
+0.9.89 (2024-03-27)
+-------------------
+
 * Fix bulk-delete rows for import/export batch.
 
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 86e8f57c..a8c7fe3a 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.88'
+__version__ = '0.9.89'

From 1889f7d2697c2741c90594ce55d8fb7c96daa2fa Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 1 Apr 2024 18:05:27 -0500
Subject: [PATCH 203/542] Add basic CRUD for Person "preferred first name"

only shown if config flag says so
---
 CHANGES.rst                                   |  3 +
 .../templates/people/view_profile_buefy.mako  | 37 ++++++++--
 tailbone/views/people.py                      | 68 +++++++++++++++----
 3 files changed, 91 insertions(+), 17 deletions(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 38c3b959..1d8d63d3 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -5,6 +5,9 @@ CHANGELOG
 Unreleased
 ----------
 
+* Add basic CRUD for Person "preferred first name".
+
+
 0.9.89 (2024-03-27)
 -------------------
 
diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako
index 4b1e089c..81243464 100644
--- a/tailbone/templates/people/view_profile_buefy.mako
+++ b/tailbone/templates/people/view_profile_buefy.mako
@@ -91,6 +91,12 @@
               <span>{{ person.first_name }}</span>
             </b-field>
 
+            % if use_preferred_first_name:
+                <b-field horizontal label="Preferred First Name">
+                  <span>{{ person.preferred_first_name }}</span>
+                </b-field>
+            % endif
+
             <b-field horizontal label="Middle Name">
               <span>{{ person.middle_name }}</span>
             </b-field>
@@ -118,11 +124,25 @@
                   </header>
 
                   <section class="modal-card-body">
-                    <b-field label="First Name">
-                      <b-input v-model.trim="editNameFirst"
-                               :maxlength="maxLengths.person_first_name || null">
-                      </b-input>
+
+                    <b-field grouped>
+
+                      <b-field label="First Name" expanded>
+                        <b-input v-model.trim="editNameFirst"
+                                 :maxlength="maxLengths.person_first_name || null">
+                        </b-input>
+                      </b-field>
+
+                      % if use_preferred_first_name:
+                          <b-field label="Preferred First Name" expanded>
+                            <b-input v-model.trim="editNameFirstPreferred"
+                                     :maxlength="maxLengths.person_preferred_first_name || null">
+                            </b-input>
+                          </b-field>
+                      % endif
+
                     </b-field>
+
                     <b-field label="Middle Name">
                       <b-input v-model.trim="editNameMiddle"
                                :maxlength="maxLengths.person_middle_name || null">
@@ -1497,6 +1517,9 @@
         % if request.has_perm('people_profile.edit_person'):
             editNameShowDialog: false,
             editNameFirst: null,
+            % if use_preferred_first_name:
+                editNameFirstPreferred: null,
+            % endif
             editNameMiddle: null,
             editNameLast: null,
 
@@ -1590,6 +1613,9 @@
 
                 editNameInit() {
                     this.editNameFirst = this.person.first_name
+                    % if use_preferred_first_name:
+                        this.editNameFirstPreferred = this.person.preferred_first_name
+                    % endif
                     this.editNameMiddle = this.person.middle_name
                     this.editNameLast = this.person.last_name
                     this.editNameShowDialog = true
@@ -1599,6 +1625,9 @@
                     let url = '${url('people.profile_edit_name', uuid=person.uuid)}'
                     let params = {
                         first_name: this.editNameFirst,
+                        % if use_preferred_first_name:
+                            preferred_first_name: this.editNameFirstPreferred,
+                        % endif
                         middle_name: this.editNameMiddle,
                         last_name: this.editNameLast,
                     }
diff --git a/tailbone/views/people.py b/tailbone/views/people.py
index 7f786ace..071e58b5 100644
--- a/tailbone/views/people.py
+++ b/tailbone/views/people.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.
 #
@@ -32,7 +32,8 @@ import sqlalchemy as sa
 from sqlalchemy import orm
 import sqlalchemy_continuum as continuum
 
-from rattail.db import model, api
+from rattail.db import api
+from rattail.db.model import Person, PersonNote, MergePeopleRequest
 from rattail.db.util import maxlen
 from rattail.time import localtime
 from rattail.util import simple_error
@@ -53,7 +54,7 @@ class PersonView(MasterView):
     """
     Master view for the Person class.
     """
-    model_class = model.Person
+    model_class = Person
     model_title_plural = "People"
     route_prefix = 'people'
     touchable = True
@@ -210,6 +211,7 @@ class PersonView(MasterView):
                             c="MR")
 
     def get_instance(self):
+        model = self.model
         # TODO: I don't recall why this fallback check for a vendor contact
         # exists here, but leaving it intact for now.
         key = self.request.matchdict['uuid']
@@ -237,6 +239,13 @@ class PersonView(MasterView):
             return True
         return not self.is_person_protected(person)
 
+    def configure_form(self, f):
+        super().configure_form(f)
+
+        # preferred_first_name
+        if self.people_handler.should_use_preferred_first_name():
+            f.insert_after('first_name', 'preferred_first_name')
+
     def objectify(self, form, data=None):
         if data is None:
             data = form.validated
@@ -248,6 +257,9 @@ class PersonView(MasterView):
         names = {}
         if 'first_name' in form:
             names['first'] = data['first_name']
+        if self.people_handler.should_use_preferred_first_name():
+            if 'preferred_first_name' in form:
+                names['preferred_first'] = data['preferred_first_name']
         if 'middle_name' in form:
             names['middle'] = data['middle_name']
         if 'last_name' in form:
@@ -292,6 +304,8 @@ class PersonView(MasterView):
         In addition to "touching" the person proper, we also "touch" each
         contact info record associated with them.
         """
+        model = self.model
+
         # touch person, as per usual
         super().touch_instance(person)
 
@@ -426,6 +440,7 @@ class PersonView(MasterView):
             return ""
 
     def get_version_child_classes(self):
+        model = self.model
         return [
             (model.PersonPhoneNumber, 'parent_uuid'),
             (model.PersonEmailAddress, 'parent_uuid'),
@@ -474,6 +489,7 @@ class PersonView(MasterView):
             'expose_customer_people': self.customers_should_expose_people(),
             'expose_customer_shoppers': self.customers_should_expose_shoppers(),
             'max_one_member': app.get_membership_handler().max_one_per_person(),
+            'use_preferred_first_name': self.people_handler.should_use_preferred_first_name(),
         }
 
         if self.request.has_perm('people_profile.view_versions'):
@@ -552,7 +568,7 @@ class PersonView(MasterView):
 
     def get_max_lengths(self):
         model = self.model
-        return {
+        lengths = {
             'person_first_name': maxlen(model.Person.first_name),
             'person_middle_name': maxlen(model.Person.middle_name),
             'person_last_name': maxlen(model.Person.last_name),
@@ -562,6 +578,9 @@ class PersonView(MasterView):
             'address_state': maxlen(model.PersonMailingAddress.state),
             'address_zipcode': maxlen(model.PersonMailingAddress.zipcode),
         }
+        if self.people_handler.should_use_preferred_first_name():
+            lengths['person_preferred_first_name'] = maxlen(model.Person.preferred_first_name)
+        return lengths
 
     def get_phone_type_options(self):
         """
@@ -606,6 +625,9 @@ class PersonView(MasterView):
             'dynamic_content_title': self.get_context_content_title(person),
         }
 
+        if self.people_handler.should_use_preferred_first_name():
+            context['preferred_first_name'] = person.preferred_first_name
+
         if person.address:
             context['address'] = self.get_context_address(person.address)
 
@@ -871,10 +893,16 @@ class PersonView(MasterView):
         person = self.get_instance()
         data = dict(self.request.json_body)
 
-        self.handler.update_names(person,
-                                  first=data['first_name'],
-                                  middle=data['middle_name'],
-                                  last=data['last_name'])
+        kw = {
+            'first': data['first_name'],
+            'middle': data['middle_name'],
+            'last': data['last_name'],
+        }
+
+        if self.people_handler.should_use_preferred_first_name():
+            kw['preferred_first'] = data['preferred_first_name']
+
+        self.handler.update_names(person, **kw)
 
         self.Session.flush()
         return self.profile_changed_response(person)
@@ -913,6 +941,7 @@ class PersonView(MasterView):
         """
         View which updates a phone number for the person.
         """
+        model = self.model
         person = self.get_instance()
         data = dict(self.request.json_body)
 
@@ -940,6 +969,7 @@ class PersonView(MasterView):
         """
         View which allows a person's phone number to be deleted.
         """
+        model = self.model
         person = self.get_instance()
         data = dict(self.request.json_body)
 
@@ -960,6 +990,7 @@ class PersonView(MasterView):
         """
         View which allows a person's "preferred" phone to be set.
         """
+        model = self.model
         person = self.get_instance()
         data = dict(self.request.json_body)
 
@@ -1016,6 +1047,7 @@ class PersonView(MasterView):
         """
         View which updates an email address for the person.
         """
+        model = self.model
         person = self.get_instance()
         data = dict(self.request.json_body)
 
@@ -1039,6 +1071,7 @@ class PersonView(MasterView):
         """
         View which allows a person's email address to be deleted.
         """
+        model = self.model
         person = self.get_instance()
         data = dict(self.request.json_body)
 
@@ -1059,6 +1092,7 @@ class PersonView(MasterView):
         """
         View which allows a person's "preferred" email to be set.
         """
+        model = self.model
         person = self.get_instance()
         data = dict(self.request.json_body)
 
@@ -1192,6 +1226,7 @@ class PersonView(MasterView):
         """
         AJAX view for updating an employee history record.
         """
+        model = self.model
         person = self.get_instance()
         employee = person.employee
 
@@ -1459,6 +1494,7 @@ class PersonView(MasterView):
         return self.profile_changed_response(person)
 
     def create_note(self, person, form):
+        model = self.model
         note = model.PersonNote()
         note.type = form.validated['note_type']
         note.subject = form.validated['note_subject']
@@ -1478,6 +1514,7 @@ class PersonView(MasterView):
         return self.profile_changed_response(person)
 
     def update_note(self, person, form):
+        model = self.model
         note = self.Session.get(model.PersonNote, form.validated['uuid'])
         note.subject = form.validated['note_subject']
         note.text = form.validated['note_text']
@@ -1494,10 +1531,12 @@ class PersonView(MasterView):
         return self.profile_changed_response(person)
 
     def delete_note(self, person, form):
+        model = self.model
         note = self.Session.get(model.PersonNote, form.validated['uuid'])
         self.Session.delete(note)
 
     def make_user(self):
+        model = self.model
         uuid = self.request.POST['person_uuid']
         person = self.Session.get(model.Person, uuid)
         if not person:
@@ -1815,7 +1854,7 @@ class PersonNoteView(MasterView):
     """
     Master view for the PersonNote class.
     """
-    model_class = model.PersonNote
+    model_class = PersonNote
     route_prefix = 'person_notes'
     url_prefix = '/people/notes'
     has_versions = True
@@ -1842,6 +1881,7 @@ class PersonNoteView(MasterView):
 
     def configure_grid(self, g):
         super().configure_grid(g)
+        model = self.model
 
         # person
         g.set_joiner('person', lambda q: q.join(model.Person,
@@ -1881,7 +1921,7 @@ def valid_note_uuid(node, kw):
     session = kw['session']
     person_uuid = kw['person_uuid']
     def validate(node, value):
-        note = session.get(model.PersonNote, value)
+        note = session.get(PersonNote, value)
         if not note:
             raise colander.Invalid(node, "Note not found")
         if note.person.uuid != person_uuid:
@@ -1906,7 +1946,7 @@ class MergePeopleRequestView(MasterView):
     """
     Master view for the MergePeopleRequest class.
     """
-    model_class = model.MergePeopleRequest
+    model_class = MergePeopleRequest
     route_prefix = 'people_merge_requests'
     url_prefix = '/people/merge-requests'
     creatable = False
@@ -1950,8 +1990,9 @@ class MergePeopleRequestView(MasterView):
         g.set_link('keeping_uuid')
 
     def render_referenced_person_name(self, merge_request, field):
+        model = self.model
         uuid = getattr(merge_request, field)
-        person = self.Session.get(self.model.Person, uuid)
+        person = self.Session.get(model.Person, uuid)
         if person:
             return str(person)
         return "(person not found)"
@@ -1971,8 +2012,9 @@ class MergePeopleRequestView(MasterView):
         f.set_renderer('keeping_uuid', self.render_referenced_person)
 
     def render_referenced_person(self, merge_request, field):
+        model = self.model
         uuid = getattr(merge_request, field)
-        person = self.Session.get(self.model.Person, uuid)
+        person = self.Session.get(model.Person, uuid)
         if person:
             text = str(person)
             url = self.request.route_url('people.view', uuid=person.uuid)

From e0dc858451646a6fef744f2e6c07a34632fe2191 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 1 Apr 2024 18:28:39 -0500
Subject: [PATCH 204/542] Update changelog

---
 CHANGES.rst          | 3 +++
 tailbone/_version.py | 2 +-
 2 files changed, 4 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 1d8d63d3..400557d3 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -5,6 +5,9 @@ CHANGELOG
 Unreleased
 ----------
 
+0.9.90 (2024-04-01)
+-------------------
+
 * Add basic CRUD for Person "preferred first name".
 
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index a8c7fe3a..cff6f04f 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.89'
+__version__ = '0.9.90'

From a1b05540bed9e301de9936d67ff4722ce684bc9e Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 10 Apr 2024 12:24:13 -0500
Subject: [PATCH 205/542] Avoid uncaught error when updating order batch row
 quantities

---
 tailbone/api/batch/ordering.py | 41 ++++++++++++++++++++++++----------
 1 file changed, 29 insertions(+), 12 deletions(-)

diff --git a/tailbone/api/batch/ordering.py b/tailbone/api/batch/ordering.py
index 1661d06f..1b11194e 100644
--- a/tailbone/api/batch/ordering.py
+++ b/tailbone/api/batch/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,18 +28,23 @@ API.
 """
 
 import datetime
+import logging
 
-from rattail.db import model
-from rattail.util import pretty_quantity
+import sqlalchemy as sa
+
+from rattail.db.model import PurchaseBatch, PurchaseBatchRow
 
 from cornice import Service
 
 from tailbone.api.batch import APIBatchView, APIBatchRowView
 
 
+log = logging.getLogger(__name__)
+
+
 class OrderingBatchViews(APIBatchView):
 
-    model_class = model.PurchaseBatch
+    model_class = PurchaseBatch
     default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
     route_prefix = 'orderingbatchviews'
     permission_prefix = 'ordering'
@@ -55,12 +60,13 @@ class OrderingBatchViews(APIBatchView):
         Adds a condition to the query, to ensure only purchase batches with
         "ordering" mode are returned.
         """
-        query = super(OrderingBatchViews, self).base_query()
+        model = self.model
+        query = super().base_query()
         query = query.filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING)
         return query
 
     def normalize(self, batch):
-        data = super(OrderingBatchViews, self).normalize(batch)
+        data = super().normalize(batch)
 
         data['vendor_uuid'] = batch.vendor.uuid
         data['vendor_display'] = str(batch.vendor)
@@ -81,7 +87,7 @@ class OrderingBatchViews(APIBatchView):
         """
         data = dict(data)
         data['mode'] = self.enum.PURCHASE_BATCH_MODE_ORDERING
-        batch = super(OrderingBatchViews, self).create_object(data)
+        batch = super().create_object(data)
         return batch
 
     def worksheet(self):
@@ -221,7 +227,7 @@ class OrderingBatchViews(APIBatchView):
 
 class OrderingBatchRowViews(APIBatchRowView):
 
-    model_class = model.PurchaseBatchRow
+    model_class = PurchaseBatchRow
     default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
     route_prefix = 'ordering.rows'
     permission_prefix = 'ordering'
@@ -231,8 +237,9 @@ class OrderingBatchRowViews(APIBatchRowView):
     editable = True
 
     def normalize(self, row):
+        data = super().normalize(row)
+        app = self.get_rattail_app()
         batch = row.batch
-        data = super(OrderingBatchRowViews, self).normalize(row)
 
         data['item_id'] = row.item_id
         data['upc'] = str(row.upc)
@@ -252,8 +259,8 @@ class OrderingBatchRowViews(APIBatchRowView):
         data['case_quantity'] = row.case_quantity
         data['cases_ordered'] = row.cases_ordered
         data['units_ordered'] = row.units_ordered
-        data['cases_ordered_display'] = pretty_quantity(row.cases_ordered or 0, empty_zero=False)
-        data['units_ordered_display'] = pretty_quantity(row.units_ordered or 0, empty_zero=False)
+        data['cases_ordered_display'] = app.render_quantity(row.cases_ordered or 0, empty_zero=False)
+        data['units_ordered_display'] = app.render_quantity(row.units_ordered or 0, empty_zero=False)
 
         data['po_unit_cost'] = row.po_unit_cost
         data['po_unit_cost_display'] = "${:0.2f}".format(row.po_unit_cost) if row.po_unit_cost is not None else None
@@ -281,7 +288,17 @@ class OrderingBatchRowViews(APIBatchRowView):
         if not self.batch_handler.is_mutable(row.batch):
             return {'error': "Batch is not mutable"}
 
-        self.batch_handler.update_row_quantity(row, **data)
+        try:
+            self.batch_handler.update_row_quantity(row, **data)
+            self.Session.flush()
+        except Exception as error:
+            log.warning("update_row_quantity failed", exc_info=True)
+            if isinstance(error, sa.exc.DataError) and hasattr(error, 'orig'):
+                error = str(error.orig)
+            else:
+                error = str(error)
+            return {'error': error}
+
         return row
 
 

From de8751b86c6dffb5c32ca9e01d01139ef8de18b6 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 11 Apr 2024 14:14:27 -0500
Subject: [PATCH 206/542] Try to return JSON error when receiving API call
 fails

although in my testing, the error still got raised somehow in the
tweens or something?  client then sees it as a 500 response and gets
no JSON
---
 tailbone/api/batch/receiving.py | 15 ++++++++++++---
 1 file changed, 12 insertions(+), 3 deletions(-)

diff --git a/tailbone/api/batch/receiving.py b/tailbone/api/batch/receiving.py
index f8ce4a33..daa4290f 100644
--- a/tailbone/api/batch/receiving.py
+++ b/tailbone/api/batch/receiving.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,7 @@ Tailbone Web API - Receiving Batches
 import logging
 
 import humanize
+import sqlalchemy as sa
 
 from rattail.db import model
 from rattail.util import pretty_quantity
@@ -440,9 +441,17 @@ class ReceivingBatchRowViews(APIBatchRowView):
         # handler takes care of the row receiving logic for us
         kwargs = dict(form.validated)
         del kwargs['row']
-        self.batch_handler.receive_row(row, **kwargs)
+        try:
+            self.batch_handler.receive_row(row, **kwargs)
+            self.Session.flush()
+        except Exception as error:
+            log.warning("receive() failed", exc_info=True)
+            if isinstance(error, sa.exc.DataError) and hasattr(error, 'orig'):
+                error = str(error.orig)
+            else:
+                error = str(error)
+            return {'error': error}
 
-        self.Session.flush()
         return self._get(obj=row)
 
     @classmethod

From aa500351edd6fa610c3395cdb08900bb0876a7ab Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 11 Apr 2024 16:37:55 -0500
Subject: [PATCH 207/542] Avoid error for tax field when creating new
 department

someday should fix that for real..
---
 tailbone/views/departments.py | 12 ++++++++----
 1 file changed, 8 insertions(+), 4 deletions(-)

diff --git a/tailbone/views/departments.py b/tailbone/views/departments.py
index a062b183..01d6f520 100644
--- a/tailbone/views/departments.py
+++ b/tailbone/views/departments.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.
 #
@@ -111,9 +111,13 @@ class DepartmentView(MasterView):
         f.set_type('personnel', 'boolean')
 
         # tax
-        f.set_renderer('tax', self.render_tax)
-        # TODO: make this editable
-        f.set_readonly('tax')
+        if self.creating:
+            # TODO: make this editable instead
+            f.remove('tax')
+        else:
+            f.set_renderer('tax', self.render_tax)
+            # TODO: make this editable
+            f.set_readonly('tax')
 
         # default_custorder_discount
         f.set_type('default_custorder_discount', 'percent')

From cbbd77c49c403e827c540ca21b1302ca0a18db08 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 11 Apr 2024 16:58:12 -0500
Subject: [PATCH 208/542] Show toast msg instead of silent error, when grid
 fetch fails

specifically, if a user clicks "Save defaults" for the grid filters,
but they aren't currently logged in, error will ensue.

this is a bit of an edge case which IIUC would require multiple tabs
etc. but still is worth avoiding an error email from it.
---
 tailbone/templates/grids/buefy.mako | 32 +++++++++++++++--------
 tailbone/views/master.py            | 40 +++++++++++++++++++----------
 2 files changed, 48 insertions(+), 24 deletions(-)

diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako
index a3e6e229..cbe33062 100644
--- a/tailbone/templates/grids/buefy.mako
+++ b/tailbone/templates/grids/buefy.mako
@@ -584,16 +584,28 @@
 
               this.loading = true
               this.$http.get(`${'$'}{this.ajaxDataUrl}?${'$'}{params}`).then(({ data }) => {
-                  ${grid.component_studly}CurrentData = data.data
-                  this.data = ${grid.component_studly}CurrentData
-                  this.rowStatusMap = data.row_status_map
-                  this.total = data.total_items
-                  this.firstItem = data.first_item
-                  this.lastItem = data.last_item
-                  this.loading = false
-                  this.checkedRows = this.locateCheckedRows(data.checked_rows)
-                  if (success) {
-                      success()
+                  if (!data.error) {
+                      ${grid.component_studly}CurrentData = data.data
+                      this.data = ${grid.component_studly}CurrentData
+                      this.rowStatusMap = data.row_status_map
+                      this.total = data.total_items
+                      this.firstItem = data.first_item
+                      this.lastItem = data.last_item
+                      this.loading = false
+                      this.checkedRows = this.locateCheckedRows(data.checked_rows)
+                      if (success) {
+                          success()
+                      }
+                  } else {
+                      this.$buefy.toast.open({
+                          message: data.error,
+                          type: 'is-danger',
+                          duration: 2000, // 4 seconds
+                      })
+                      this.loading = false
+                      if (failure) {
+                          failure()
+                      }
                   }
               })
               .catch((error) => {
diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index cc2adcaf..c9d6cd7c 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.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.
 #
@@ -42,8 +42,7 @@ from sqlalchemy_utils.functions import get_primary_keys, get_columns
 
 from rattail.db import model, Session as RattailSession
 from rattail.db.continuum import model_transaction_query
-from rattail.util import prettify, simple_error, get_class_hierarchy
-from rattail.time import localtime
+from rattail.util import simple_error, get_class_hierarchy
 from rattail.threads import Thread
 from rattail.csvutil import UnicodeDictWriter
 from rattail.files import temp_path
@@ -324,6 +323,13 @@ class MasterView(View):
         string, then the view will return the rendered grid only.  Otherwise
         returns the full page.
         """
+        # nb. normally this "save defaults" flag is checked within make_grid()
+        # but it returns JSON data so we can't just do a redirect when there
+        # is no user; must return JSON error message instead
+        if (self.request.GET.get('save-current-filters-as-defaults') == 'true'
+            and not self.request.user):
+            return self.json_response({'error': "User is not currently logged in"})
+
         self.listing = True
         grid = self.make_grid()
 
@@ -1465,6 +1471,7 @@ class MasterView(View):
         """
         View showing diff details of a particular object version.
         """
+        app = self.get_rattail_app()
         instance = self.get_instance()
         model_class = self.get_model_class()
         route_prefix = self.get_route_prefix()
@@ -1512,7 +1519,7 @@ class MasterView(View):
             'instance_title_normal': instance_title,
             'instance_url': self.get_action_url('versions', instance),
             'transaction': transaction,
-            'changed': localtime(self.rattail_config, transaction.issued_at, from_utc=True),
+            'changed': app.localtime(transaction.issued_at, from_utc=True),
             'version_diffs': version_diffs,
             'show_prev_next': True,
             'prev_url': prev_url,
@@ -3502,14 +3509,14 @@ class MasterView(View):
         Normalize the given object into a data dict, for use when writing to
         the results file for download.
         """
+        app = self.get_rattail_app()
         data = {}
         for field in fields:
             value = getattr(obj, field, None)
 
             # make timestamps zone-aware
             if isinstance(value, datetime.datetime):
-                value = localtime(self.rattail_config, value,
-                                  from_utc=not self.has_local_times)
+                value = app.localtime(value, from_utc=not self.has_local_times)
 
             data[field] = value
 
@@ -3539,13 +3546,14 @@ class MasterView(View):
         Coerce the given data dict record, to a "row" dict suitable for use
         when writing directly to XLSX file.
         """
+        app = self.get_rattail_app()
         data = dict(data)
         for key in data:
             value = data[key]
 
             # make timestamps local, "zone-naive"
             if isinstance(value, datetime.datetime):
-                value = localtime(self.rattail_config, value, tzinfo=False)
+                value = app.localtime(value, tzinfo=False)
 
             data[key] = value
 
@@ -4001,14 +4009,14 @@ class MasterView(View):
         Normalize the given row object into a data dict, for use when writing
         to the results file for download.
         """
+        app = self.get_rattail_app()
         data = {}
         for field in fields:
             value = getattr(row, field, None)
 
             # make timestamps zone-aware
             if isinstance(value, datetime.datetime):
-                value = localtime(self.rattail_config, value,
-                                  from_utc=not self.has_local_times)
+                value = app.localtime(value, from_utc=not self.has_local_times)
 
             data[field] = value
 
@@ -4038,6 +4046,7 @@ class MasterView(View):
         Coerce the given data dict record, to a "row" dict suitable for use
         when writing directly to XLSX file.
         """
+        app = self.get_rattail_app()
         data = dict(data)
         for key in data:
             value = data[key]
@@ -4048,7 +4057,7 @@ class MasterView(View):
 
             # make timestamps local, "zone-naive"
             elif isinstance(value, datetime.datetime):
-                value = localtime(self.rattail_config, value, tzinfo=False)
+                value = app.localtime(value, tzinfo=False)
 
             data[key] = value
 
@@ -4099,6 +4108,7 @@ class MasterView(View):
         """
         Return a dict for use when writing the row's data to XLSX download.
         """
+        app = self.get_rattail_app()
         xlrow = {}
         for field in fields:
             value = getattr(row, field, None)
@@ -4111,9 +4121,9 @@ class MasterView(View):
                 # but we should make sure they're in "local" time zone effectively.
                 # note however, this assumes a "naive" time value is in UTC zone!
                 if value.tzinfo:
-                    value = localtime(self.rattail_config, value, tzinfo=False)
+                    value = app.localtime(value, tzinfo=False)
                 else:
-                    value = localtime(self.rattail_config, value, from_utc=True, tzinfo=False)
+                    value = app.localtime(value, from_utc=True, tzinfo=False)
 
             xlrow[field] = value
         return xlrow
@@ -4177,12 +4187,13 @@ class MasterView(View):
         """
         Return a dict for use when writing the row's data to CSV download.
         """
+        app = self.get_rattail_app()
         csvrow = {}
         for field in fields:
             value = getattr(obj, field, None)
             if isinstance(value, datetime.datetime):
                 # TODO: this assumes value is *always* naive UTC
-                value = localtime(self.rattail_config, value, from_utc=True)
+                value = app.localtime(value, from_utc=True)
             csvrow[field] = '' if value is None else str(value)
         return csvrow
 
@@ -4190,12 +4201,13 @@ class MasterView(View):
         """
         Return a dict for use when writing the row's data to CSV download.
         """
+        app = self.get_rattail_app()
         csvrow = {}
         for field in fields:
             value = getattr(row, field, None)
             if isinstance(value, datetime.datetime):
                 # TODO: this assumes value is *always* naive UTC
-                value = localtime(self.rattail_config, value, from_utc=True)
+                value = app.localtime(value, from_utc=True)
             csvrow[field] = '' if value is None else str(value)
         return csvrow
 

From 1973614840f30b906b81e019e7f409695f6e37cf Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sat, 13 Apr 2024 09:09:23 -0500
Subject: [PATCH 209/542] Rename people "view_profile" template (drop buefy
 suffix)

---
 ...w_profile_buefy.mako => view_profile.mako} |  0
 tailbone/views/people.py                      | 34 +++++++------------
 2 files changed, 12 insertions(+), 22 deletions(-)
 rename tailbone/templates/people/{view_profile_buefy.mako => view_profile.mako} (100%)

diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile.mako
similarity index 100%
rename from tailbone/templates/people/view_profile_buefy.mako
rename to tailbone/templates/people/view_profile.mako
diff --git a/tailbone/views/people.py b/tailbone/views/people.py
index 071e58b5..7b175e25 100644
--- a/tailbone/views/people.py
+++ b/tailbone/views/people.py
@@ -34,12 +34,9 @@ import sqlalchemy_continuum as continuum
 
 from rattail.db import api
 from rattail.db.model import Person, PersonNote, MergePeopleRequest
-from rattail.db.util import maxlen
-from rattail.time import localtime
 from rattail.util import simple_error
 
 import colander
-from pyramid.httpexceptions import HTTPFound, HTTPNotFound
 from webhelpers2.html import HTML, tags
 
 from tailbone import forms, grids
@@ -221,7 +218,7 @@ class PersonView(MasterView):
         instance = self.Session.get(model.VendorContact, key)
         if instance:
             return instance.person
-        raise HTTPNotFound
+        raise self.notfound()
 
     def is_person_protected(self, person):
         for user in person.users:
@@ -495,7 +492,7 @@ class PersonView(MasterView):
         if self.request.has_perm('people_profile.view_versions'):
             context['revisions_grid'] = self.profile_revisions_grid(person)
 
-        return self.render_to_response('view_profile_buefy', context)
+        return self.render_to_response('view_profile', context)
 
     def get_context_tabchecks(self, person):
         app = self.get_rattail_app()
@@ -558,28 +555,21 @@ class PersonView(MasterView):
         """
         return kwargs
 
-    def template_kwargs_view_profile_buefy(self, **kwargs):
-        """
-        Note that any subclass should not need to define this method.
-        It by default invokes :meth:`template_kwargs_view_profile()`
-        and returns that result.
-        """
-        return self.template_kwargs_view_profile(**kwargs)
-
     def get_max_lengths(self):
+        app = self.get_rattail_app()
         model = self.model
         lengths = {
-            'person_first_name': maxlen(model.Person.first_name),
-            'person_middle_name': maxlen(model.Person.middle_name),
-            'person_last_name': maxlen(model.Person.last_name),
-            'address_street': maxlen(model.PersonMailingAddress.street),
-            'address_street2': maxlen(model.PersonMailingAddress.street2),
-            'address_city': maxlen(model.PersonMailingAddress.city),
-            'address_state': maxlen(model.PersonMailingAddress.state),
-            'address_zipcode': maxlen(model.PersonMailingAddress.zipcode),
+            'person_first_name': app.maxlen(model.Person.first_name),
+            'person_middle_name': app.maxlen(model.Person.middle_name),
+            'person_last_name': app.maxlen(model.Person.last_name),
+            'address_street': app.maxlen(model.PersonMailingAddress.street),
+            'address_street2': app.maxlen(model.PersonMailingAddress.street2),
+            'address_city': app.maxlen(model.PersonMailingAddress.city),
+            'address_state': app.maxlen(model.PersonMailingAddress.state),
+            'address_zipcode': app.maxlen(model.PersonMailingAddress.zipcode),
         }
         if self.people_handler.should_use_preferred_first_name():
-            lengths['person_preferred_first_name'] = maxlen(model.Person.preferred_first_name)
+            lengths['person_preferred_first_name'] = app.maxlen(model.Person.preferred_first_name)
         return lengths
 
     def get_phone_type_options(self):

From cd7c1bba21292bc9d255e03d65694586bfbbd698 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sat, 13 Apr 2024 09:21:48 -0500
Subject: [PATCH 210/542] Rename template for grid filters (drop buefy suffix)

also remove some deprecated functions
---
 tailbone/config.py                          | 21 +----
 tailbone/templates/grids/buefy.mako         |  2 +-
 tailbone/templates/grids/filters.mako       | 98 ++++++++++++++-------
 tailbone/templates/grids/filters_buefy.mako | 70 ---------------
 4 files changed, 67 insertions(+), 124 deletions(-)
 delete mode 100644 tailbone/templates/grids/filters_buefy.mako

diff --git a/tailbone/config.py b/tailbone/config.py
index 6106e87e..9326a3cb 100644
--- a/tailbone/config.py
+++ b/tailbone/config.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.
 #
@@ -61,25 +61,6 @@ def csrf_header_name(config):
     return config.get('tailbone', 'csrf_header_name', default='X-CSRF-TOKEN')
 
 
-def get_buefy_version(config):
-    warnings.warn("get_buefy_version() is deprecated; please use "
-                  "tailbone.util.get_libver() instead",
-                  DeprecationWarning, stacklevel=2)
-
-    version = config.get('tailbone', 'libver.buefy')
-    if version:
-        return version
-
-    return config.get('tailbone', 'buefy_version',
-                      default='latest')
-
-
-def get_buefy_0_8(config, version=None):
-    warnings.warn("get_buefy_0_8() is no longer supported",
-                  DeprecationWarning, stacklevel=2)
-    return False
-
-
 def global_help_url(config):
     return config.get('tailbone', 'global_help_url')
 
diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako
index cbe33062..73c7e415 100644
--- a/tailbone/templates/grids/buefy.mako
+++ b/tailbone/templates/grids/buefy.mako
@@ -136,7 +136,7 @@
         <div class="filters">
           % if grid.filterable:
               ## TODO: stop using |n filter
-              ${grid.render_filters(template='/grids/filters_buefy.mako', allow_save_defaults=allow_save_defaults)|n}
+              ${grid.render_filters(allow_save_defaults=allow_save_defaults)|n}
           % endif
         </div>
       </div>
diff --git a/tailbone/templates/grids/filters.mako b/tailbone/templates/grids/filters.mako
index 857f53b1..5e1fef9b 100644
--- a/tailbone/templates/grids/filters.mako
+++ b/tailbone/templates/grids/filters.mako
@@ -1,38 +1,70 @@
 ## -*- coding: utf-8; -*-
-<div class="newfilters">
 
-  ${h.form(form.action_url, method='get')}
-    ${h.hidden('reset-to-default-filters', value='false')}
-    ${h.hidden('save-current-filters-as-defaults', value='false')}
+<form action="${form.action_url}" method="GET" @submit.prevent="applyFilters()">
 
-    <fieldset>
-      <legend>Filters</legend>
-      % for filtr in form.iter_filters():
-          <div class="filter" id="filter-${filtr.key}" data-key="${filtr.key}"${' style="display: none;"' if not filtr.active else ''|n}>
-            ${h.checkbox('{}-active'.format(filtr.key), class_='active', id='filter-active-{}'.format(filtr.key), checked=filtr.active)}
-            <label for="filter-active-${filtr.key}">${filtr.label}</label>
-            <div class="inputs" style="display: inline-block;">
-              ${form.filter_verb(filtr)}
-              ${form.filter_value(filtr)}
-            </div>
-          </div>
-      % endfor
-    </fieldset>
+  <grid-filter v-for="key in filtersSequence"
+               :key="key"
+               :filter="filters[key]"
+               ref="gridFilters">
+  </grid-filter>
 
-    <div class="buttons">
-      <button type="submit" id="apply-filters">Apply Filters</button>
-      <select id="add-filter">
-        <option value="">Add a Filter</option>
-        % for filtr in form.iter_filters():
-            <option value="${filtr.key}"${' disabled="disabled"' if filtr.active else ''|n}>${filtr.label}</option>
-        % endfor
-      </select>
-      <button type="button" id="default-filters">Default View</button>
-      <button type="button" id="clear-filters">No Filters</button>
-      % if allow_save_defaults and request.user:
-          <button type="button" id="save-defaults">Save Defaults</button>
-      % endif
-    </div>
+  <b-field grouped>
 
-  ${h.end_form()}
-</div><!-- newfilters -->
+    <b-button type="is-primary"
+              native-type="submit"
+              icon-pack="fas"
+              icon-left="check"
+              class="control">
+      Apply Filters
+    </b-button>
+
+    <b-button v-if="!addFilterShow"
+              icon-pack="fas"
+              icon-left="plus"
+              class="control"
+              @click="addFilterButton">
+      Add Filter
+    </b-button>
+
+    <b-autocomplete v-if="addFilterShow"
+                    ref="addFilterAutocomplete"
+                    :data="addFilterChoices"
+                    v-model="addFilterTerm"
+                    placeholder="Add Filter"
+                    field="key"
+                    :custom-formatter="filtr => filtr.label"
+                    open-on-focus
+                    keep-first
+                    icon-pack="fas"
+                    clearable
+                    clear-on-select
+                    @select="addFilterSelect"
+                    @keydown.native="addFilterKeydown">
+    </b-autocomplete>
+
+    <b-button @click="resetView()"
+              icon-pack="fas"
+              icon-left="home"
+              class="control">
+      Default View
+    </b-button>
+
+    <b-button @click="clearFilters()"
+              icon-pack="fas"
+              icon-left="trash"
+              class="control">
+      No Filters
+    </b-button>
+
+    % if allow_save_defaults and request.user:
+        <b-button @click="saveDefaults()"
+                  icon-pack="fas"
+                  icon-left="save"
+                  class="control">
+          Save Defaults
+        </b-button>
+    % endif
+
+  </b-field>
+
+</form>
diff --git a/tailbone/templates/grids/filters_buefy.mako b/tailbone/templates/grids/filters_buefy.mako
deleted file mode 100644
index 5e1fef9b..00000000
--- a/tailbone/templates/grids/filters_buefy.mako
+++ /dev/null
@@ -1,70 +0,0 @@
-## -*- coding: utf-8; -*-
-
-<form action="${form.action_url}" method="GET" @submit.prevent="applyFilters()">
-
-  <grid-filter v-for="key in filtersSequence"
-               :key="key"
-               :filter="filters[key]"
-               ref="gridFilters">
-  </grid-filter>
-
-  <b-field grouped>
-
-    <b-button type="is-primary"
-              native-type="submit"
-              icon-pack="fas"
-              icon-left="check"
-              class="control">
-      Apply Filters
-    </b-button>
-
-    <b-button v-if="!addFilterShow"
-              icon-pack="fas"
-              icon-left="plus"
-              class="control"
-              @click="addFilterButton">
-      Add Filter
-    </b-button>
-
-    <b-autocomplete v-if="addFilterShow"
-                    ref="addFilterAutocomplete"
-                    :data="addFilterChoices"
-                    v-model="addFilterTerm"
-                    placeholder="Add Filter"
-                    field="key"
-                    :custom-formatter="filtr => filtr.label"
-                    open-on-focus
-                    keep-first
-                    icon-pack="fas"
-                    clearable
-                    clear-on-select
-                    @select="addFilterSelect"
-                    @keydown.native="addFilterKeydown">
-    </b-autocomplete>
-
-    <b-button @click="resetView()"
-              icon-pack="fas"
-              icon-left="home"
-              class="control">
-      Default View
-    </b-button>
-
-    <b-button @click="clearFilters()"
-              icon-pack="fas"
-              icon-left="trash"
-              class="control">
-      No Filters
-    </b-button>
-
-    % if allow_save_defaults and request.user:
-        <b-button @click="saveDefaults()"
-                  icon-pack="fas"
-                  icon-left="save"
-                  class="control">
-          Save Defaults
-        </b-button>
-    % endif
-
-  </b-field>
-
-</form>

From 1103b09a767936f69c02c5714717cf5d1d28bee9 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sat, 13 Apr 2024 09:45:10 -0500
Subject: [PATCH 211/542] Rename forms/deform template (drop buefy suffix)

for now, deprecate `form.render()` method and just use
`render_deform()` - but probably should change that to something
else eventually..?
---
 tailbone/forms/core.py                          | 17 ++++++++---------
 tailbone/templates/batch/view.mako              |  4 ++--
 tailbone/templates/form.mako                    |  2 +-
 .../forms/{deform_buefy.mako => deform.mako}    |  0
 tailbone/templates/forms/form.mako              |  2 --
 tailbone/templates/products/batch.mako          |  2 +-
 6 files changed, 12 insertions(+), 15 deletions(-)
 rename tailbone/templates/forms/{deform_buefy.mako => deform.mako} (100%)
 delete mode 100644 tailbone/templates/forms/form.mako

diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py
index e04126a3..aee85330 100644
--- a/tailbone/forms/core.py
+++ b/tailbone/forms/core.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.
 #
@@ -794,12 +794,11 @@ class Form(object):
     def set_vuejs_field_converter(self, field, converter):
         self.vuejs_field_converters[field] = converter
 
-    def render(self, template=None, **kwargs):
-        if not template:
-            template = '/forms/form.mako'
-        context = kwargs
-        context['form'] = self
-        return render(template, context)
+    def render(self, **kwargs):
+        warnings.warn("Form.render() is deprecated (for now?); "
+                      "please use Form.render_deform() instead",
+                      DeprecationWarning, stacklevel=2)
+        return self.render_deform(**kwargs)
 
     def make_deform_form(self):
         if not hasattr(self, 'deform_form'):
@@ -841,14 +840,14 @@ class Form(object):
 
     def render_deform(self, dform=None, template=None, **kwargs):
         if not template:
-            template = '/forms/deform_buefy.mako'
+            template = '/forms/deform.mako'
 
         if dform is None:
             dform = self.make_deform_form()
 
         # TODO: would perhaps be nice to leverage deform's default rendering
         # someday..? i.e. using Chameleon *.pt templates
-        # return form.render()
+        # return dform.render()
 
         context = kwargs
         context['form'] = self
diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako
index fa8fa19f..aa9677b7 100644
--- a/tailbone/templates/batch/view.mako
+++ b/tailbone/templates/batch/view.mako
@@ -150,8 +150,8 @@
 
 <%def name="render_form()">
   ## TODO: should use self.render_form_buttons()
-  ## ${form.render(form_id='batch-form', buttons=capture(self.render_form_buttons))|n}
-  ${form.render(form_id='batch-form', buttons=capture(buttons))|n}
+  ## ${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()">
diff --git a/tailbone/templates/form.mako b/tailbone/templates/form.mako
index 5878e030..c225bd3a 100644
--- a/tailbone/templates/form.mako
+++ b/tailbone/templates/form.mako
@@ -6,7 +6,7 @@
 <%def name="render_form_buttons()"></%def>
 
 <%def name="render_form()">
-  ${form.render(buttons=capture(self.render_form_buttons))|n}
+  ${form.render_deform(buttons=capture(self.render_form_buttons))|n}
 </%def>
 
 <%def name="render_buefy_form()">
diff --git a/tailbone/templates/forms/deform_buefy.mako b/tailbone/templates/forms/deform.mako
similarity index 100%
rename from tailbone/templates/forms/deform_buefy.mako
rename to tailbone/templates/forms/deform.mako
diff --git a/tailbone/templates/forms/form.mako b/tailbone/templates/forms/form.mako
deleted file mode 100644
index cd8fecc8..00000000
--- a/tailbone/templates/forms/form.mako
+++ /dev/null
@@ -1,2 +0,0 @@
-## -*- coding: utf-8; -*-
-${form.render_deform(buttons=buttons)|n}
diff --git a/tailbone/templates/products/batch.mako b/tailbone/templates/products/batch.mako
index 81af729b..868ad9b1 100644
--- a/tailbone/templates/products/batch.mako
+++ b/tailbone/templates/products/batch.mako
@@ -64,7 +64,7 @@
   ${parent.modify_this_page_vars()}
   <script type="text/javascript">
 
-    ## TODO: ugh, an awful lot of duplicated code here (from /forms/deform_buefy.mako)
+    ## TODO: ugh, an awful lot of duplicated code here (from /forms/deform.mako)
 
     let ${form.component_studly} = {
         template: '#${form.component}-template',

From 96ba039299e7a2efd51bfc33a4ae9853c6b7fef3 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sat, 13 Apr 2024 10:02:57 -0500
Subject: [PATCH 212/542] Rename grids/complete template (avoid buefy name)

and rename grid methods accordingly
---
 CHANGES.rst                                   | 11 ++++++++
 tailbone/grids/core.py                        | 25 +++++++++----------
 .../grids/{buefy.mako => complete.mako}       |  0
 tailbone/templates/master/index.mako          |  2 +-
 tailbone/templates/master/versions.mako       |  2 +-
 tailbone/templates/master/view.mako           |  4 +--
 6 files changed, 27 insertions(+), 17 deletions(-)
 rename tailbone/templates/grids/{buefy.mako => complete.mako} (100%)

diff --git a/CHANGES.rst b/CHANGES.rst
index 400557d3..1edf8a2a 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -5,6 +5,17 @@ CHANGELOG
 Unreleased
 ----------
 
+* Avoid uncaught error when updating order batch row quantities.
+
+* Try to return JSON error when receiving API call fails.
+
+* Avoid error for tax field when creating new department.
+
+* Show toast msg instead of silent error, when grid fetch fails.
+
+* Rename template files to avoid "buefy" names.
+
+
 0.9.90 (2024-04-01)
 -------------------
 
diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py
index 41964648..c03ac2c0 100644
--- a/tailbone/grids/core.py
+++ b/tailbone/grids/core.py
@@ -1333,18 +1333,7 @@ class Grid(object):
             data = self.pager
         return data
 
-    def render_complete(self, template='/grids/buefy.mako', **kwargs):
-        """
-        Render the complete grid, including filters.
-        """
-        context = kwargs
-        context['grid'] = self
-        context['request'] = self.request
-        context.setdefault('allow_save_defaults', True)
-        context.setdefault('view_click_handler', self.get_view_click_handler())
-        return render(template, context)
-
-    def render_buefy(self, template='/grids/buefy.mako', **kwargs):
+    def render_complete(self, template='/grids/complete.mako', **kwargs):
         """
         Render the Buefy grid, complete with filters.  Note that this also
         includes the context menu items and grid tools.
@@ -1364,7 +1353,17 @@ class Grid(object):
         if self.filterable and 'filters_sequence' not in kwargs:
             kwargs['filters_sequence'] = self.get_filters_sequence()
 
-        return self.render_complete(template=template, **kwargs)
+        context = kwargs
+        context['grid'] = self
+        context['request'] = self.request
+        context.setdefault('allow_save_defaults', True)
+        context.setdefault('view_click_handler', self.get_view_click_handler())
+        return render(template, context)
+
+    def render_buefy(self, **kwargs):
+        warnings.warn("Grid.render_buefy() is deprecated; "
+                      "please use Grid.render_complete() instead",
+                      DeprecationWarning, stacklevel=2)
 
     def render_buefy_table_element(self, template='/grids/b-table.mako',
                                    data_prop='gridData', empty_labels=False,
diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/complete.mako
similarity index 100%
rename from tailbone/templates/grids/buefy.mako
rename to tailbone/templates/grids/complete.mako
diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako
index b0ee17d6..051a9ab6 100644
--- a/tailbone/templates/master/index.mako
+++ b/tailbone/templates/master/index.mako
@@ -327,7 +327,7 @@
   ${parent.render_this_page_template()}
 
   ## TODO: stop using |n filter
-  ${grid.render_buefy(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())|n}
+  ${grid.render_complete(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())|n}
 </%def>
 
 <%def name="modify_this_page_vars()">
diff --git a/tailbone/templates/master/versions.mako b/tailbone/templates/master/versions.mako
index bfec39b7..307674b8 100644
--- a/tailbone/templates/master/versions.mako
+++ b/tailbone/templates/master/versions.mako
@@ -31,7 +31,7 @@
   ${parent.render_this_page_template()}
 
   ## TODO: stop using |n filter
-  ${grid.render_buefy()|n}
+  ${grid.render_complete()|n}
 </%def>
 
 <%def name="page_content()">
diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako
index 9a37b2bb..dcf1f8ee 100644
--- a/tailbone/templates/master/view.mako
+++ b/tailbone/templates/master/view.mako
@@ -228,11 +228,11 @@
 <%def name="render_this_page_template()">
   % if master.has_rows:
       ## TODO: stop using |n filter
-      ${rows_grid.render_buefy(allow_save_defaults=False, tools=capture(self.render_row_grid_tools))|n}
+      ${rows_grid.render_complete(allow_save_defaults=False, tools=capture(self.render_row_grid_tools))|n}
   % endif
   ${parent.render_this_page_template()}
   % if expose_versions:
-      ${versions_grid.render_buefy()|n}
+      ${versions_grid.render_complete()|n}
   % endif
 </%def>
 

From c036932ce482479c218b12c443a4e37bfba57fd3 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 14 Apr 2024 19:54:29 -0500
Subject: [PATCH 213/542] Remove several references to "buefy" name

class methods, template filenames, etc.

also made various edits per newer conventions
---
 tailbone/forms/core.py                        | 25 +++---
 tailbone/forms/widgets.py                     |  3 +-
 tailbone/grids/core.py                        | 28 +++----
 tailbone/templates/appsettings.mako           |  2 +-
 .../templates/batch/importer/view_row.mako    |  2 +-
 .../batch/inventory/desktop_form.mako         |  2 +-
 .../batch/vendorcatalog/view_row.mako         |  2 +-
 tailbone/templates/batch/view.mako            |  4 +-
 tailbone/templates/customers/view.mako        |  2 +-
 tailbone/templates/custorders/items/view.mako |  2 +-
 ...ipients_buefy.pt => message_recipients.pt} |  0
 tailbone/templates/form.mako                  |  8 +-
 tailbone/templates/forms/deform.mako          |  4 +-
 tailbone/templates/forms/util.mako            |  7 --
 tailbone/templates/master/clone.mako          |  4 +-
 tailbone/templates/master/delete.mako         |  4 +-
 tailbone/templates/people/view.mako           |  2 +-
 tailbone/templates/people/view_profile.mako   |  8 +-
 .../templates/principal/find_by_perm.mako     |  6 +-
 tailbone/templates/products/batch.mako        |  2 +-
 tailbone/templates/products/view.mako         | 20 ++---
 .../templates/receiving/declare_credit.mako   | 17 ++--
 tailbone/templates/receiving/receive_row.mako | 17 ++--
 .../templates/reports/generated/choose.mako   |  2 +-
 .../templates/reports/generated/generate.mako |  2 +-
 tailbone/templates/settings/email/view.mako   |  4 +-
 tailbone/templates/upgrades/view.mako         |  2 +-
 tailbone/templates/users/preferences.mako     |  4 +-
 tailbone/views/batch/core.py                  | 78 +++++++++++--------
 tailbone/views/batch/handheld.py              | 24 +++---
 tailbone/views/batch/pos.py                   |  4 +-
 tailbone/views/batch/product.py               | 27 +++----
 tailbone/views/customers.py                   | 22 +-----
 tailbone/views/custorders/items.py            |  4 +-
 tailbone/views/departments.py                 |  2 +-
 tailbone/views/master.py                      | 58 +++++++-------
 tailbone/views/messages.py                    | 14 ++--
 tailbone/views/principal.py                   | 12 +--
 tailbone/views/products.py                    | 66 +++++++++-------
 tailbone/views/purchasing/batch.py            |  7 +-
 tailbone/views/purchasing/ordering.py         |  6 +-
 tailbone/views/purchasing/receiving.py        | 15 ++--
 tailbone/views/reports.py                     | 51 ++++++------
 tailbone/views/roles.py                       | 18 +++--
 tailbone/views/settings.py                    | 27 +++----
 tailbone/views/shifts/lib.py                  | 36 +++++----
 tailbone/views/tables.py                      | 24 +++---
 tailbone/views/tempmon/core.py                |  4 +-
 tailbone/views/trainwreck/base.py             | 20 ++---
 tailbone/views/users.py                       | 30 +++----
 50 files changed, 373 insertions(+), 361 deletions(-)
 rename tailbone/templates/deform/{message_recipients_buefy.pt => message_recipients.pt} (100%)
 delete mode 100644 tailbone/templates/forms/util.mako

diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py
index aee85330..9ef8cb2b 100644
--- a/tailbone/forms/core.py
+++ b/tailbone/forms/core.py
@@ -33,10 +33,9 @@ from collections import OrderedDict
 import sqlalchemy as sa
 from sqlalchemy import orm
 from sqlalchemy.ext.associationproxy import AssociationProxy, ASSOCIATION_PROXY
+from wuttjamaican.util import UNSPECIFIED
 
-from rattail.time import localtime
-from rattail.util import prettify, pretty_boolean, pretty_quantity
-from rattail.core import UNSPECIFIED
+from rattail.util import prettify, pretty_boolean
 from rattail.db.util import get_fieldnames
 
 import colander
@@ -50,10 +49,10 @@ from webhelpers2.html import tags, HTML
 
 from tailbone.db import Session
 from tailbone.util import raw_datetime, get_form_data, render_markdown
-from . import types
-from .widgets import (ReadonlyWidget, PlainDateWidget,
-                      JQueryDateWidget, JQueryTimeWidget,
-                      MultiFileUploadWidget)
+from tailbone.forms import types
+from tailbone.forms.widgets import (ReadonlyWidget, PlainDateWidget,
+                                    JQueryDateWidget, JQueryTimeWidget,
+                                    MultiFileUploadWidget)
 from tailbone.exceptions import TailboneJSONFieldError
 
 
@@ -225,7 +224,7 @@ class CustomSchemaNode(SQLAlchemySchemaNode):
         if excludes:
             overrides['excludes'] = excludes
 
-        return super(CustomSchemaNode, self).get_schema_from_relationship(prop, overrides)
+        return super().get_schema_from_relationship(prop, overrides)
 
     def dictify(self, obj):
         """ Return a dictified version of `obj` using schema information.
@@ -234,7 +233,7 @@ class CustomSchemaNode(SQLAlchemySchemaNode):
            This method was copied from upstream and modified to add automatic
            handling of "association proxy" fields.
         """
-        dict_ = super(CustomSchemaNode, self).dictify(obj)
+        dict_ = super().dictify(obj)
         for node in self:
 
             name = node.name
@@ -967,7 +966,7 @@ class Form(object):
             kwargs.setdefault(':configure-fields-help', 'configureFieldsHelp')
         return HTML.tag(self.component, **kwargs)
 
-    def render_buefy_field(self, fieldname, bfield_attrs={}):
+    def render_field_complete(self, fieldname, bfield_attrs={}):
         """
         Render the given field in a Buefy-compatible way.  Note that
         this is meant to render *editable* fields, i.e. showing a
@@ -1131,7 +1130,8 @@ class Form(object):
         value = self.obtain_value(record, field_name)
         if value is None:
             return ""
-        value = localtime(self.request.rattail_config, value)
+        app = self.get_rattail_app()
+        value = app.localtime(value)
         return raw_datetime(self.request.rattail_config, value)
 
     def render_duration(self, record, field_name):
@@ -1160,7 +1160,8 @@ class Form(object):
         value = self.obtain_value(obj, field)
         if value is None:
             return ""
-        return pretty_quantity(value)
+        app = self.get_rattail_app()
+        return app.render_quantity(value)
 
     def render_percent(self, obj, field):
         app = self.request.rattail_config.get_app()
diff --git a/tailbone/forms/widgets.py b/tailbone/forms/widgets.py
index db57f4f0..63813452 100644
--- a/tailbone/forms/widgets.py
+++ b/tailbone/forms/widgets.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.
 #
@@ -153,6 +153,7 @@ class DynamicCheckboxWidget(dfwidget.CheckboxWidget):
     template = 'checkbox_dynamic'
 
 
+# TODO: deprecate / remove this
 class PlainSelectWidget(dfwidget.SelectWidget):
     template = 'select_plain'
 
diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py
index c03ac2c0..b2f90204 100644
--- a/tailbone/grids/core.py
+++ b/tailbone/grids/core.py
@@ -1339,10 +1339,10 @@ class Grid(object):
         includes the context menu items and grid tools.
         """
         if 'grid_columns' not in kwargs:
-            kwargs['grid_columns'] = self.get_buefy_columns()
+            kwargs['grid_columns'] = self.get_table_columns()
 
         if 'grid_data' not in kwargs:
-            kwargs['grid_data'] = self.get_buefy_data()
+            kwargs['grid_data'] = self.get_table_data()
 
         if 'static_data' not in kwargs:
             kwargs['static_data'] = self.has_static_data()
@@ -1364,10 +1364,11 @@ class Grid(object):
         warnings.warn("Grid.render_buefy() is deprecated; "
                       "please use Grid.render_complete() instead",
                       DeprecationWarning, stacklevel=2)
+        return self.render_complete(**kwargs)
 
-    def render_buefy_table_element(self, template='/grids/b-table.mako',
-                                   data_prop='gridData', empty_labels=False,
-                                   **kwargs):
+    def render_table_element(self, template='/grids/b-table.mako',
+                             data_prop='gridData', empty_labels=False,
+                             **kwargs):
         """
         This is intended for ad-hoc "small" grids with static data.  Renders
         just a ``<b-table>`` element instead of the typical "full" grid.
@@ -1377,7 +1378,7 @@ class Grid(object):
         context['data_prop'] = data_prop
         context['empty_labels'] = empty_labels
         if 'grid_columns' not in context:
-            context['grid_columns'] = self.get_buefy_columns()
+            context['grid_columns'] = self.get_table_columns()
         context.setdefault('paginated', False)
         if context['paginated']:
             context.setdefault('per_page', 20)
@@ -1572,10 +1573,10 @@ class Grid(object):
             return True
         return False
 
-    def get_buefy_columns(self):
+    def get_table_columns(self):
         """
-        Return a list of dicts representing all grid columns.  Meant for use
-        with Buefy table.
+        Return a list of dicts representing all grid columns.  Meant
+        for use with the client-side JS table.
         """
         columns = []
         for name in self.columns:
@@ -1597,9 +1598,10 @@ class Grid(object):
         if hasattr(rowobj, 'uuid'):
             return rowobj.uuid
 
-    def get_buefy_data(self):
+    def get_table_data(self):
         """
-        Returns a list of data rows for the grid, for use with Buefy table.
+        Returns a list of data rows for the grid, for use with
+        client-side JS table.
         """
         # filter / sort / paginate to get "visible" data
         raw_data = self.make_visible_data()
@@ -1635,8 +1637,8 @@ class Grid(object):
             # instance, when the "display" version is different than raw data.
             # here is the hack we use for that.
             columns = list(self.columns)
-            if hasattr(self, 'buefy_data_columns'):
-                columns.extend(self.buefy_data_columns)
+            if hasattr(self, 'raw_data_columns'):
+                columns.extend(self.raw_data_columns)
 
             # iterate over data fields
             for name in columns:
diff --git a/tailbone/templates/appsettings.mako b/tailbone/templates/appsettings.mako
index 46f4a7e3..4f935956 100644
--- a/tailbone/templates/appsettings.mako
+++ b/tailbone/templates/appsettings.mako
@@ -154,7 +154,7 @@
   ${parent.modify_this_page_vars()}
   <script type="text/javascript">
 
-    ThisPageData.groups = ${json.dumps(buefy_data)|n}
+    ThisPageData.groups = ${json.dumps(settings_data)|n}
     ThisPageData.showingGroup = ${json.dumps(current_group or '')|n}
 
   </script>
diff --git a/tailbone/templates/batch/importer/view_row.mako b/tailbone/templates/batch/importer/view_row.mako
index 9e08cf43..7d6f121f 100644
--- a/tailbone/templates/batch/importer/view_row.mako
+++ b/tailbone/templates/batch/importer/view_row.mako
@@ -68,7 +68,7 @@
 % endif
 </%def>
 
-<%def name="render_buefy_form()">
+<%def name="render_form()">
   <div class="form">
     <tailbone-form></tailbone-form>
     <br />
diff --git a/tailbone/templates/batch/inventory/desktop_form.mako b/tailbone/templates/batch/inventory/desktop_form.mako
index 9f13cbf9..7e4795a8 100644
--- a/tailbone/templates/batch/inventory/desktop_form.mako
+++ b/tailbone/templates/batch/inventory/desktop_form.mako
@@ -34,7 +34,7 @@
   </nav>
 </%def>
 
-<%def name="render_form()">
+<%def name="render_form_template()">
   <script type="text/x-template" id="${form.component}-template">
     <div class="product-info">
 
diff --git a/tailbone/templates/batch/vendorcatalog/view_row.mako b/tailbone/templates/batch/vendorcatalog/view_row.mako
index 6aaf9bf4..0128e3b3 100644
--- a/tailbone/templates/batch/vendorcatalog/view_row.mako
+++ b/tailbone/templates/batch/vendorcatalog/view_row.mako
@@ -1,7 +1,7 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/master/view_row.mako" />
 
-<%def name="render_buefy_form()">
+<%def name="render_form()">
   <div class="form">
     <tailbone-form></tailbone-form>
     <br />
diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako
index aa9677b7..a87b31a6 100644
--- a/tailbone/templates/batch/view.mako
+++ b/tailbone/templates/batch/view.mako
@@ -148,7 +148,7 @@
   </div>
 </%def>
 
-<%def name="render_form()">
+<%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}
@@ -206,7 +206,7 @@
   % endif
 </%def>
 
-<%def name="render_buefy_form()">
+<%def name="render_form()">
   <div class="form">
     <${form.component} @show-upload="showUploadDialog = true">
     </${form.component}>
diff --git a/tailbone/templates/customers/view.mako b/tailbone/templates/customers/view.mako
index 85ec0055..2fa7c417 100644
--- a/tailbone/templates/customers/view.mako
+++ b/tailbone/templates/customers/view.mako
@@ -9,7 +9,7 @@
   % endif
 </%def>
 
-<%def name="render_buefy_form()">
+<%def name="render_form()">
   <div class="form">
     <tailbone-form @detach-person="detachPerson">
     </tailbone-form>
diff --git a/tailbone/templates/custorders/items/view.mako b/tailbone/templates/custorders/items/view.mako
index 592095ff..41567d41 100644
--- a/tailbone/templates/custorders/items/view.mako
+++ b/tailbone/templates/custorders/items/view.mako
@@ -1,7 +1,7 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/master/view.mako" />
 
-<%def name="render_buefy_form()">
+<%def name="render_form()">
   <div class="form">
     <${form.component} ref="mainForm"
                        % if master.has_perm('confirm_price'):
diff --git a/tailbone/templates/deform/message_recipients_buefy.pt b/tailbone/templates/deform/message_recipients.pt
similarity index 100%
rename from tailbone/templates/deform/message_recipients_buefy.pt
rename to tailbone/templates/deform/message_recipients.pt
diff --git a/tailbone/templates/form.mako b/tailbone/templates/form.mako
index c225bd3a..0352b04c 100644
--- a/tailbone/templates/form.mako
+++ b/tailbone/templates/form.mako
@@ -5,11 +5,11 @@
 
 <%def name="render_form_buttons()"></%def>
 
-<%def name="render_form()">
+<%def name="render_form_template()">
   ${form.render_deform(buttons=capture(self.render_form_buttons))|n}
 </%def>
 
-<%def name="render_buefy_form()">
+<%def name="render_form()">
   <div class="form">
     ${form.render_vuejs_component()}
   </div>
@@ -18,7 +18,7 @@
 <%def name="page_content()">
   <div class="form-wrapper">
     <br />
-    ${self.render_buefy_form()}
+    ${self.render_form()}
   </div>
 </%def>
 
@@ -49,7 +49,7 @@
 
 <%def name="render_this_page_template()">
   % if form is not Undefined:
-      ${self.render_form()}
+      ${self.render_form_template()}
   % endif
   ${parent.render_this_page_template()}
 </%def>
diff --git a/tailbone/templates/forms/deform.mako b/tailbone/templates/forms/deform.mako
index 39633117..db63a424 100644
--- a/tailbone/templates/forms/deform.mako
+++ b/tailbone/templates/forms/deform.mako
@@ -18,7 +18,7 @@
               <div class="panel-block">
                 <div>
                   % for field in form.grouping[group]:
-                      ${form.render_buefy_field(field)}
+                      ${form.render_field_complete(field)}
                   % endfor
                 </div>
               </div>
@@ -26,7 +26,7 @@
         % endfor
     % else:
         % for field in form.fields:
-            ${form.render_buefy_field(field)}
+            ${form.render_field_complete(field)}
         % endfor
     % endif
   </section>
diff --git a/tailbone/templates/forms/util.mako b/tailbone/templates/forms/util.mako
deleted file mode 100644
index 22e7f918..00000000
--- a/tailbone/templates/forms/util.mako
+++ /dev/null
@@ -1,7 +0,0 @@
-## -*- coding: utf-8; -*-
-
-## TODO: deprecate / remove this
-## (tried to add deprecation warning here but it didn't seem to work)
-<%def name="render_buefy_field(field, bfield_kwargs={})">
-  ${form.render_buefy_field(field.name, bfield_attrs=bfield_kwargs)}
-</%def>
diff --git a/tailbone/templates/master/clone.mako b/tailbone/templates/master/clone.mako
index 07784f74..59d6aea2 100644
--- a/tailbone/templates/master/clone.mako
+++ b/tailbone/templates/master/clone.mako
@@ -3,12 +3,12 @@
 
 <%def name="title()">Clone ${model_title}: ${instance_title}</%def>
 
-<%def name="render_buefy_form()">
+<%def name="render_form()">
   <br />
   <b-notification :closable="false">
     You are about to clone the following ${model_title} as a new record:
   </b-notification>
-  ${parent.render_buefy_form()}
+  ${parent.render_form()}
 </%def>
 
 <%def name="render_form_buttons()">
diff --git a/tailbone/templates/master/delete.mako b/tailbone/templates/master/delete.mako
index 0cb5b6c2..30bb50ab 100644
--- a/tailbone/templates/master/delete.mako
+++ b/tailbone/templates/master/delete.mako
@@ -3,12 +3,12 @@
 
 <%def name="title()">Delete ${model_title}: ${instance_title}</%def>
 
-<%def name="render_buefy_form()">
+<%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_buefy_form()}
+  ${parent.render_form()}
 </%def>
 
 <%def name="render_form_buttons()">
diff --git a/tailbone/templates/people/view.mako b/tailbone/templates/people/view.mako
index 973a1da8..184f2b91 100644
--- a/tailbone/templates/people/view.mako
+++ b/tailbone/templates/people/view.mako
@@ -7,7 +7,7 @@
   ${view_profiles_helper([instance])}
 </%def>
 
-<%def name="render_buefy_form()">
+<%def name="render_form()">
   <div class="form">
     <tailbone-form v-on:make-user="makeUser"></tailbone-form>
   </div>
diff --git a/tailbone/templates/people/view_profile.mako b/tailbone/templates/people/view_profile.mako
index 81243464..65c96fd6 100644
--- a/tailbone/templates/people/view_profile.mako
+++ b/tailbone/templates/people/view_profile.mako
@@ -1403,10 +1403,10 @@
 
       % if request.has_perm('people_profile.view_versions'):
 
-          ${revisions_grid.render_buefy_table_element(data_prop='revisions',
-                                                      show_footer=True,
-                                                      vshow='viewingHistory',
-                                                      loading='gettingRevisions')|n}
+          ${revisions_grid.render_table_element(data_prop='revisions',
+                                                show_footer=True,
+                                                vshow='viewingHistory',
+                                                loading='gettingRevisions')|n}
 
           <b-modal :active.sync="showingRevisionDialog">
 
diff --git a/tailbone/templates/principal/find_by_perm.mako b/tailbone/templates/principal/find_by_perm.mako
index e0536324..3bf47dc1 100644
--- a/tailbone/templates/principal/find_by_perm.mako
+++ b/tailbone/templates/principal/find_by_perm.mako
@@ -95,8 +95,8 @@
   ${parent.modify_this_page_vars()}
   <script type="text/javascript">
 
-    ThisPageData.permissionGroups = ${json.dumps(buefy_perms)|n}
-    ThisPageData.sortedGroups = ${json.dumps(buefy_sorted_groups)|n}
+    ThisPageData.permissionGroups = ${json.dumps(perms_data)|n}
+    ThisPageData.sortedGroups = ${json.dumps(sorted_groups_data)|n}
 
   </script>
 </%def>
@@ -113,7 +113,7 @@
         },
         data() {
             return {
-                groupPermissions: ${json.dumps(buefy_perms.get(selected_group, {}).get('permissions', []))|n},
+                groupPermissions: ${json.dumps(perms_data.get(selected_group, {}).get('permissions', []))|n},
                 permissionGroupTerm: '',
                 permissionTerm: '',
                 selectedGroup: ${json.dumps(selected_group)|n},
diff --git a/tailbone/templates/products/batch.mako b/tailbone/templates/products/batch.mako
index 868ad9b1..e0b93bd6 100644
--- a/tailbone/templates/products/batch.mako
+++ b/tailbone/templates/products/batch.mako
@@ -54,7 +54,7 @@
   ${h.end_form()}
 </%def>
 
-<%def name="render_form()">
+<%def name="render_form_template()">
   <script type="text/x-template" id="${form.component}-template">
     ${self.render_form_innards()}
   </script>
diff --git a/tailbone/templates/products/view.mako b/tailbone/templates/products/view.mako
index 5de6d099..c4da08ba 100644
--- a/tailbone/templates/products/view.mako
+++ b/tailbone/templates/products/view.mako
@@ -108,7 +108,7 @@
 </%def>
 
 <%def name="lookup_codes_grid()">
-  ${lookup_codes['grid'].render_buefy_table_element(data_prop='lookupCodesData')|n}
+  ${lookup_codes['grid'].render_table_element(data_prop='lookupCodesData')|n}
 </%def>
 
 <%def name="lookup_codes_panel()">
@@ -121,7 +121,7 @@
 </%def>
 
 <%def name="sources_grid()">
-  ${vendor_sources['grid'].render_buefy_table_element(data_prop='vendorSourcesData')|n}
+  ${vendor_sources['grid'].render_table_element(data_prop='vendorSourcesData')|n}
 </%def>
 
 <%def name="sources_panel()">
@@ -175,7 +175,7 @@
             </p>
           </header>
           <section class="modal-card-body">
-            ${regular_price_history_grid.render_buefy_table_element(data_prop='regularPriceHistoryData', loading='regularPriceHistoryLoading', paginated=True, per_page=10)|n}
+            ${regular_price_history_grid.render_table_element(data_prop='regularPriceHistoryData', loading='regularPriceHistoryLoading', paginated=True, per_page=10)|n}
           </section>
           <footer class="modal-card-foot">
             <b-button @click="showingPriceHistory_regular = false">
@@ -194,7 +194,7 @@
             </p>
           </header>
           <section class="modal-card-body">
-            ${current_price_history_grid.render_buefy_table_element(data_prop='currentPriceHistoryData', loading='currentPriceHistoryLoading', paginated=True, per_page=10)|n}
+            ${current_price_history_grid.render_table_element(data_prop='currentPriceHistoryData', loading='currentPriceHistoryLoading', paginated=True, per_page=10)|n}
           </section>
           <footer class="modal-card-foot">
             <b-button @click="showingPriceHistory_current = false">
@@ -213,7 +213,7 @@
             </p>
           </header>
           <section class="modal-card-body">
-            ${suggested_price_history_grid.render_buefy_table_element(data_prop='suggestedPriceHistoryData', loading='suggestedPriceHistoryLoading', paginated=True, per_page=10)|n}
+            ${suggested_price_history_grid.render_table_element(data_prop='suggestedPriceHistoryData', loading='suggestedPriceHistoryLoading', paginated=True, per_page=10)|n}
           </section>
           <footer class="modal-card-foot">
             <b-button @click="showingPriceHistory_suggested = false">
@@ -232,7 +232,7 @@
             </p>
           </header>
           <section class="modal-card-body">
-            ${cost_history_grid.render_buefy_table_element(data_prop='costHistoryData', loading='costHistoryLoading', paginated=True, per_page=10)|n}
+            ${cost_history_grid.render_table_element(data_prop='costHistoryData', loading='costHistoryLoading', paginated=True, per_page=10)|n}
           </section>
           <footer class="modal-card-foot">
             <b-button @click="showingCostHistory = false">
@@ -289,7 +289,7 @@
     % if request.rattail_config.versioning_enabled() and master.has_perm('versions'):
 
         ThisPageData.showingPriceHistory_regular = false
-        ThisPageData.regularPriceHistoryDataRaw = ${json.dumps(regular_price_history_grid.get_buefy_data()['data'])|n}
+        ThisPageData.regularPriceHistoryDataRaw = ${json.dumps(regular_price_history_grid.get_table_data()['data'])|n}
         ThisPageData.regularPriceHistoryLoading = false
 
         ThisPage.computed.regularPriceHistoryData = function() {
@@ -318,7 +318,7 @@
         }
 
         ThisPageData.showingPriceHistory_current = false
-        ThisPageData.currentPriceHistoryDataRaw = ${json.dumps(current_price_history_grid.get_buefy_data()['data'])|n}
+        ThisPageData.currentPriceHistoryDataRaw = ${json.dumps(current_price_history_grid.get_table_data()['data'])|n}
         ThisPageData.currentPriceHistoryLoading = false
 
         ThisPage.computed.currentPriceHistoryData = function() {
@@ -348,7 +348,7 @@
         }
 
         ThisPageData.showingPriceHistory_suggested = false
-        ThisPageData.suggestedPriceHistoryDataRaw = ${json.dumps(suggested_price_history_grid.get_buefy_data()['data'])|n}
+        ThisPageData.suggestedPriceHistoryDataRaw = ${json.dumps(suggested_price_history_grid.get_table_data()['data'])|n}
         ThisPageData.suggestedPriceHistoryLoading = false
 
         ThisPage.computed.suggestedPriceHistoryData = function() {
@@ -377,7 +377,7 @@
         }
 
         ThisPageData.showingCostHistory = false
-        ThisPageData.costHistoryDataRaw = ${json.dumps(cost_history_grid.get_buefy_data()['data'])|n}
+        ThisPageData.costHistoryDataRaw = ${json.dumps(cost_history_grid.get_table_data()['data'])|n}
         ThisPageData.costHistoryLoading = false
 
         ThisPage.computed.costHistoryData = function() {
diff --git a/tailbone/templates/receiving/declare_credit.mako b/tailbone/templates/receiving/declare_credit.mako
index 6224a539..a377e270 100644
--- a/tailbone/templates/receiving/declare_credit.mako
+++ b/tailbone/templates/receiving/declare_credit.mako
@@ -1,6 +1,5 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/form.mako" />
-<%namespace file="/forms/util.mako" import="render_buefy_field" />
 
 <%def name="title()">Declare Credit for Row #${row.sequence}</%def>
 
@@ -11,7 +10,7 @@
   % endif
 </%def>
 
-<%def name="render_buefy_form()">
+<%def name="render_form()">
 
   <p class="block">
     Please select the "state" of the product, and enter the
@@ -31,22 +30,22 @@
     if you need to "receive" instead of "convert" the product.
   </p>
 
-  ${parent.render_buefy_form()}
+  ${parent.render_form()}
 
 </%def>
 
-<%def name="buefy_form_body()">
+<%def name="form_body()">
 
-  ${render_buefy_field(dform['credit_type'])}
+  ${form.render_field_complete('credit_type')}
 
-  ${render_buefy_field(dform['quantity'])}
+  ${form.render_field_complete('quantity')}
 
-  ${render_buefy_field(dform['expiration_date'], bfield_kwargs={'v-show': "field_model_credit_type == 'expired'"})}
+  ${form.render_field_complete('expiration_date', bfield_attrs={'v-show': "field_model_credit_type == 'expired'"})}
 
 </%def>
 
-<%def name="render_form()">
-  ${form.render_deform(buttons=capture(self.render_form_buttons), form_body=capture(self.buefy_form_body))|n}
+<%def name="render_form_template()">
+  ${form.render_deform(buttons=capture(self.render_form_buttons), form_body=capture(self.form_body))|n}
 </%def>
 
 
diff --git a/tailbone/templates/receiving/receive_row.mako b/tailbone/templates/receiving/receive_row.mako
index 7ef95ac4..48dc6755 100644
--- a/tailbone/templates/receiving/receive_row.mako
+++ b/tailbone/templates/receiving/receive_row.mako
@@ -1,6 +1,5 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/form.mako" />
-<%namespace file="/forms/util.mako" import="render_buefy_field" />
 
 <%def name="title()">Receive for Row #${row.sequence}</%def>
 
@@ -11,7 +10,7 @@
   % endif
 </%def>
 
-<%def name="render_buefy_form()">
+<%def name="render_form()">
 
   <p class="block">
     Please select the "state" of the product, and enter the appropriate
@@ -28,22 +27,22 @@
     if you need to "convert" some already-received amount, into a credit.
   </p>
 
-  ${parent.render_buefy_form()}
+  ${parent.render_form()}
 
 </%def>
 
-<%def name="buefy_form_body()">
+<%def name="form_body()">
 
-  ${render_buefy_field(dform['mode'])}
+  ${form.render_field_complete('mode')}
 
-  ${render_buefy_field(dform['quantity'])}
+  ${form.render_field_complete('quantity')}
 
-  ${render_buefy_field(dform['expiration_date'], bfield_kwargs={'v-show': "field_model_mode == 'expired'"})}
+  ${form.render_field_complete('expiration_date', bfield_attrs={'v-show': "field_model_mode == 'expired'"})}
 
 </%def>
 
-<%def name="render_form()">
-  ${form.render_deform(buttons=capture(self.render_form_buttons), form_body=capture(self.buefy_form_body))|n}
+<%def name="render_form_template()">
+  ${form.render_deform(buttons=capture(self.render_form_buttons), form_body=capture(self.form_body))|n}
 </%def>
 
 
diff --git a/tailbone/templates/reports/generated/choose.mako b/tailbone/templates/reports/generated/choose.mako
index 55cf71dd..a952fb6a 100644
--- a/tailbone/templates/reports/generated/choose.mako
+++ b/tailbone/templates/reports/generated/choose.mako
@@ -23,7 +23,7 @@
   </style>
 </%def>
 
-<%def name="render_buefy_form()">
+<%def name="render_form()">
   <div class="form">
     <p>Please select the type of report you wish to generate.</p>
     <br />
diff --git a/tailbone/templates/reports/generated/generate.mako b/tailbone/templates/reports/generated/generate.mako
index 9feb9f83..2b8fa66c 100644
--- a/tailbone/templates/reports/generated/generate.mako
+++ b/tailbone/templates/reports/generated/generate.mako
@@ -5,7 +5,7 @@
 
 <%def name="content_title()">New Report:&nbsp; ${report.name}</%def>
 
-<%def name="render_buefy_form()">
+<%def name="render_form()">
   <div class="form">
     <p class="block">
       ${report.__doc__}
diff --git a/tailbone/templates/settings/email/view.mako b/tailbone/templates/settings/email/view.mako
index 1d292c69..2a29ce0e 100644
--- a/tailbone/templates/settings/email/view.mako
+++ b/tailbone/templates/settings/email/view.mako
@@ -1,8 +1,8 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/master/view.mako" />
 
-<%def name="render_buefy_form()">
-  ${parent.render_buefy_form()}
+<%def name="render_form()">
+  ${parent.render_form()}
   <email-preview-tools></email-preview-tools>
 </%def>
 
diff --git a/tailbone/templates/upgrades/view.mako b/tailbone/templates/upgrades/view.mako
index c5419574..fe20c1e1 100644
--- a/tailbone/templates/upgrades/view.mako
+++ b/tailbone/templates/upgrades/view.mako
@@ -75,7 +75,7 @@
   % endif
 </%def>
 
-<%def name="render_buefy_form()">
+<%def name="render_form()">
   <div class="form">
     <${form.component}
       % if master.has_perm('execute'):
diff --git a/tailbone/templates/users/preferences.mako b/tailbone/templates/users/preferences.mako
index a44534dc..f1432676 100644
--- a/tailbone/templates/users/preferences.mako
+++ b/tailbone/templates/users/preferences.mako
@@ -30,7 +30,7 @@
         <b-select name="tailbone.${user.uuid}.buefy_css"
                   v-model="simpleSettings['tailbone.${user.uuid}.buefy_css']"
                   @input="settingsNeedSaved = true">
-          <option v-for="option in buefyCSSOptions"
+          <option v-for="option in themeStyleOptions"
                   :key="option.value"
                   :value="option.value">
             {{ option.label }}
@@ -46,7 +46,7 @@
   ${parent.modify_this_page_vars()}
   <script type="text/javascript">
 
-    ThisPageData.buefyCSSOptions = ${json.dumps(buefy_css_options)|n}
+    ThisPageData.themeStyleOptions = ${json.dumps(theme_style_options)|n}
 
   </script>
 </%def>
diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py
index f8b53d13..46bdbb17 100644
--- a/tailbone/views/batch/core.py
+++ b/tailbone/views/batch/core.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.
 #
@@ -32,23 +32,18 @@ import logging
 import socket
 import subprocess
 import tempfile
+import warnings
 
 import json
 import markdown
 import sqlalchemy as sa
 from sqlalchemy import orm
 
-from rattail.db import model, Session as RattailSession
-from rattail.db.util import short_session
 from rattail.threads import Thread
-from rattail.util import prettify, simple_error
-from rattail.progress import SocketProgress
+from rattail.util import simple_error
 
 import colander
-import deform
 from deform import widget as dfwidget
-from pyramid.renderers import render_to_response
-from pyramid.response import FileResponse
 from webhelpers2.html import HTML, tags
 
 from tailbone import forms, grids
@@ -115,7 +110,7 @@ class BatchMasterView(MasterView):
     }
 
     def __init__(self, request):
-        super(BatchMasterView, self).__init__(request)
+        super().__init__(request)
         self.batch_handler = self.get_handler()
         # TODO: deprecate / remove this (?)
         self.handler = self.batch_handler
@@ -167,7 +162,7 @@ class BatchMasterView(MasterView):
         return self.rattail_config.batch_filepath(batch.batch_key, batch.uuid, filename)
 
     def template_kwargs_view(self, **kwargs):
-        kwargs = super(BatchMasterView, self).template_kwargs_view(**kwargs)
+        kwargs = super().template_kwargs_view(**kwargs)
         batch = kwargs['instance']
         kwargs['batch'] = batch
         kwargs['handler'] = self.handler
@@ -195,8 +190,8 @@ class BatchMasterView(MasterView):
         g.set_click_handler('title', "autoFilterStatus(props.row)")
         kwargs['status_breakdown_data'] = breakdown
         kwargs['status_breakdown_grid'] = HTML.literal(
-            g.render_buefy_table_element(data_prop='statusBreakdownData',
-                                         empty_labels=True))
+            g.render_table_element(data_prop='statusBreakdownData',
+                                   empty_labels=True))
 
         return kwargs
 
@@ -288,7 +283,8 @@ class BatchMasterView(MasterView):
         return not batch.executed and not batch.complete
 
     def configure_grid(self, g):
-        super(BatchMasterView, self).configure_grid(g)
+        super().configure_grid(g)
+        model = self.model
 
         # created_by
         CreatedBy = orm.aliased(model.User)
@@ -337,7 +333,7 @@ class BatchMasterView(MasterView):
         return batch.id_str
 
     def configure_form(self, f):
-        super(BatchMasterView, self).configure_form(f)
+        super().configure_form(f)
 
         # id
         f.set_readonly('id')
@@ -436,9 +432,9 @@ class BatchMasterView(MasterView):
 
         label = HTML.literal(
             '{{{{ togglingBatchComplete ? "Working, please wait..." : "{}" }}}}'.format(label))
-        submit = self.make_buefy_button(label, is_primary=True,
-                                        native_type='submit',
-                                        **{':disabled': 'togglingBatchComplete'})
+        submit = self.make_button(label, is_primary=True,
+                                  native_type='submit',
+                                  **{':disabled': 'togglingBatchComplete'})
 
         form = [
             begin_form,
@@ -603,7 +599,7 @@ class BatchMasterView(MasterView):
         return True
 
     def configure_row_grid(self, g):
-        super(BatchMasterView, self).configure_row_grid(g)
+        super().configure_row_grid(g)
 
         g.set_sort_defaults('sequence')
         g.set_link('sequence')
@@ -644,7 +640,7 @@ class BatchMasterView(MasterView):
         if batch.executed:
             self.request.session.flash("You cannot add new rows to a batch which has been executed")
             return self.redirect(self.get_action_url('view', batch))
-        return super(BatchMasterView, self).create_row()
+        return super().create_row()
 
     def save_create_row_form(self, form):
         batch = self.get_instance()
@@ -657,7 +653,7 @@ class BatchMasterView(MasterView):
         self.handler.refresh_row(row)
 
     def configure_row_form(self, f):
-        super(BatchMasterView, self).configure_row_form(f)
+        super().configure_row_form(f)
 
         # sequence
         f.set_readonly('sequence')
@@ -681,9 +677,9 @@ class BatchMasterView(MasterView):
             permission_prefix = self.get_permission_prefix()
             if self.request.has_perm('{}.create_row'.format(permission_prefix)):
                 url = self.get_action_url('create_row', batch)
-                return self.make_buefy_button("New Row", url=url,
-                                              is_primary=True,
-                                              icon_left='plus')
+                return self.make_button("New Row", url=url,
+                                        is_primary=True,
+                                        icon_left='plus')
 
     def make_batch_row_grid_tools(self, batch):
         pass
@@ -719,7 +715,7 @@ class BatchMasterView(MasterView):
 
             kwargs['main_actions'] = actions
 
-        return super(BatchMasterView, self).make_row_grid_kwargs(**kwargs)
+        return super().make_row_grid_kwargs(**kwargs)
 
     def make_row_grid_tools(self, batch):
         return (self.make_default_row_grid_tools(batch) or '') + (self.make_batch_row_grid_tools(batch) or '')
@@ -852,8 +848,11 @@ class BatchMasterView(MasterView):
                         labels = kwargs.setdefault('labels', {})
                         labels[field.name] = field.title
 
-                    # auto-convert select widgets for buefy theme
+                    # auto-convert select widgets for theme
                     if isinstance(field.widget, forms.widgets.PlainSelectWidget):
+                        warnings.warn("PlainSelectWidget is deprecated; "
+                                      "please use deform.widget.SelectWidget instead",
+                                      DeprecationWarning)
                         field.widget = dfwidget.SelectWidget(values=field.widget.values)
 
         if not schema:
@@ -1022,7 +1021,8 @@ class BatchMasterView(MasterView):
         cxn.close()
 
     def catchup_versions(self, port, batch_uuid, username, *models):
-        with short_session() as s:
+        app = self.get_rattail_app()
+        with app.short_session() as s:
             batch = s.get(self.model_class, batch_uuid)
             batch_id = batch.id_str
             description = str(batch)
@@ -1048,8 +1048,10 @@ class BatchMasterView(MasterView):
         """
         Thread target for populating batch data with progress indicator.
         """
+        app = self.get_rattail_app()
+        model = self.model
         # mustn't use tailbone web session here
-        session = RattailSession()
+        session = app.make_session()
         batch = session.get(self.model_class, batch_uuid)
         user = session.get(model.User, user_uuid)
         try:
@@ -1107,7 +1109,9 @@ class BatchMasterView(MasterView):
         # Refresh data for the batch, with progress.  Note that we must use the
         # rattail session here; can't use tailbone because it has web request
         # transaction binding etc.
-        session = RattailSession()
+        app = self.get_rattail_app()
+        model = self.model
+        session = app.make_session()
         batch = session.get(self.model_class, batch_uuid)
         cognizer = session.get(model.User, user_uuid) if user_uuid else None
         try:
@@ -1160,7 +1164,9 @@ class BatchMasterView(MasterView):
         """
         Thread target for refreshing multiple batches with progress indicator.
         """
-        session = RattailSession()
+        app = self.get_rattail_app()
+        model = self.model
+        session = app.make_session()
         batches = batches.with_session(session).all()
         user = session.get(model.User, user_uuid)
         try:
@@ -1257,7 +1263,7 @@ class BatchMasterView(MasterView):
         self.handler.do_remove_row(row)
 
     def delete_row_objects(self, rows):
-        deleted = super(BatchMasterView, self).delete_row_objects(rows)
+        deleted = super().delete_row_objects(rows)
         batch = self.get_instance()
 
         # decrement rowcount for batch
@@ -1300,7 +1306,9 @@ class BatchMasterView(MasterView):
         # Execute the batch, with progress.  Note that we must use the rattail
         # session here; can't use tailbone because it has web request
         # transaction binding etc.
-        session = RattailSession()
+        app = self.get_rattail_app()
+        model = self.model
+        session = app.make_session()
         batch = self.get_instance_for_key(key, session)
         user = session.get(model.User, user_uuid)
         try:
@@ -1375,7 +1383,9 @@ class BatchMasterView(MasterView):
         """
         Thread target for executing multiple batches with progress indicator.
         """
-        session = RattailSession()
+        app = self.get_rattail_app()
+        model = self.model
+        session = app.make_session()
         batches = batches.with_session(session).all()
         user = session.get(model.User, user_uuid)
         try:
@@ -1415,7 +1425,7 @@ class BatchMasterView(MasterView):
         return self.get_index_url()
 
     def get_row_csv_fields(self):
-        fields = super(BatchMasterView, self).get_row_csv_fields()
+        fields = super().get_row_csv_fields()
         fields = [field for field in fields
                   if field not in ('uuid', 'batch_uuid', 'removed')]
         return fields
@@ -1538,7 +1548,7 @@ class FileBatchMasterView(BatchMasterView):
         return uploads
 
     def configure_form(self, f):
-        super(FileBatchMasterView, self).configure_form(f)
+        super().configure_form(f)
         batch = f.model_instance
 
         # filename
diff --git a/tailbone/views/batch/handheld.py b/tailbone/views/batch/handheld.py
index 03b9a441..eb22f367 100644
--- a/tailbone/views/batch/handheld.py
+++ b/tailbone/views/batch/handheld.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.
 #
@@ -26,12 +26,12 @@ Views for handheld batches
 
 from collections import OrderedDict
 
-from rattail.db import model
+from rattail.db.model import HandheldBatch, HandheldBatchRow
 
 import colander
+from deform import widget as dfwidget
 from webhelpers2.html import tags
 
-from tailbone import forms
 from tailbone.views.batch import FileBatchMasterView
 
 
@@ -46,14 +46,14 @@ class ExecutionOptions(colander.Schema):
     action = colander.SchemaNode(
         colander.String(),
         validator=colander.OneOf(ACTION_OPTIONS),
-        widget=forms.widgets.PlainSelectWidget(values=ACTION_OPTIONS.items()))
+        widget=dfwidget.SelectWidget(values=ACTION_OPTIONS.items()))
 
 
 class HandheldBatchView(FileBatchMasterView):
     """
     Master view for handheld batches.
     """
-    model_class = model.HandheldBatch
+    model_class = HandheldBatch
     default_handler_spec = 'rattail.batch.handheld:HandheldBatchHandler'
     model_title_plural = "Handheld Batches"
     route_prefix = 'batch.handheld'
@@ -61,7 +61,7 @@ class HandheldBatchView(FileBatchMasterView):
     execution_options_schema = ExecutionOptions
     editable = False
 
-    model_row_class = model.HandheldBatchRow
+    model_row_class = HandheldBatchRow
     rows_creatable = False
     rows_editable = True
 
@@ -116,7 +116,7 @@ class HandheldBatchView(FileBatchMasterView):
     ]
 
     def configure_grid(self, g):
-        super(HandheldBatchView, self).configure_grid(g)
+        super().configure_grid(g)
         device_types = OrderedDict(sorted(self.enum.HANDHELD_DEVICE_TYPE.items(),
                                           key=lambda item: item[1]))
         g.set_enum('device_type', device_types)
@@ -126,7 +126,7 @@ class HandheldBatchView(FileBatchMasterView):
             return 'notice'
 
     def configure_form(self, f):
-        super(HandheldBatchView, self).configure_form(f)
+        super().configure_form(f)
         batch = f.model_instance
 
         # device_type
@@ -156,13 +156,13 @@ class HandheldBatchView(FileBatchMasterView):
         return tags.link_to(text, url)
 
     def get_batch_kwargs(self, batch):
-        kwargs = super(HandheldBatchView, self).get_batch_kwargs(batch)
+        kwargs = super().get_batch_kwargs(batch)
         kwargs['device_type'] = batch.device_type
         kwargs['device_name'] = batch.device_name
         return kwargs
 
     def configure_row_grid(self, g):
-        super(HandheldBatchView, self).configure_row_grid(g)
+        super().configure_row_grid(g)
         g.set_type('cases', 'quantity')
         g.set_type('units', 'quantity')
         g.set_label('brand_name', "Brand")
@@ -172,7 +172,7 @@ class HandheldBatchView(FileBatchMasterView):
             return 'warning'
 
     def configure_row_form(self, f):
-        super(HandheldBatchView, self).configure_row_form(f)
+        super().configure_row_form(f)
 
         # readonly fields
         f.set_readonly('upc')
@@ -188,7 +188,7 @@ class HandheldBatchView(FileBatchMasterView):
             return self.request.route_url('batch.inventory.view', uuid=result.uuid)
         elif kwargs['action'] == 'make_label_batch':
             return self.request.route_url('labels.batch.view', uuid=result.uuid)
-        return super(HandheldBatchView, self).get_execute_success_url(batch)
+        return super().get_execute_success_url(batch)
 
     def get_execute_results_success_url(self, result, **kwargs):
         if result is True:
diff --git a/tailbone/views/batch/pos.py b/tailbone/views/batch/pos.py
index afda919e..11031353 100644
--- a/tailbone/views/batch/pos.py
+++ b/tailbone/views/batch/pos.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.
 #
@@ -206,7 +206,7 @@ class POSBatchView(BatchMasterView):
         )
 
         return HTML.literal(
-            g.render_buefy_table_element(data_prop='taxesData'))
+            g.render_table_element(data_prop='taxesData'))
 
     def template_kwargs_view(self, **kwargs):
         kwargs = super().template_kwargs_view(**kwargs)
diff --git a/tailbone/views/batch/product.py b/tailbone/views/batch/product.py
index dfe8d890..af8374ac 100644
--- a/tailbone/views/batch/product.py
+++ b/tailbone/views/batch/product.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.
 #
@@ -26,12 +26,12 @@ Views for generic product batches
 
 from collections import OrderedDict
 
-from rattail.db import model
+from rattail.db.model import ProductBatch, ProductBatchRow
 
 import colander
+from deform import widget as dfwidget
 from webhelpers2.html import HTML
 
-from tailbone import forms
 from tailbone.views.batch import BatchMasterView
 
 
@@ -46,15 +46,15 @@ class ExecutionOptions(colander.Schema):
     action = colander.SchemaNode(
         colander.String(),
         validator=colander.OneOf(ACTION_OPTIONS),
-        widget=forms.widgets.PlainSelectWidget(values=ACTION_OPTIONS.items()))
+        widget=dfwidget.SelectWidget(values=ACTION_OPTIONS.items()))
 
 
 class ProductBatchView(BatchMasterView):
     """
     Master view for product batches.
     """
-    model_class = model.ProductBatch
-    model_row_class = model.ProductBatchRow
+    model_class = ProductBatch
+    model_row_class = ProductBatchRow
     default_handler_spec = 'rattail.batch.product:ProductBatchHandler'
     route_prefix = 'batch.product'
     url_prefix = '/batches/product'
@@ -129,7 +129,7 @@ class ProductBatchView(BatchMasterView):
     ]
 
     def configure_form(self, f):
-        super(ProductBatchView, self).configure_form(f)
+        super().configure_form(f)
 
         # input_filename
         if self.creating:
@@ -139,7 +139,8 @@ class ProductBatchView(BatchMasterView):
             f.set_renderer('input_filename', self.render_downloadable_file)
 
     def configure_row_grid(self, g):
-        super(ProductBatchView, self).configure_row_grid(g)
+        super().configure_row_grid(g)
+        model = self.model
 
         g.set_joiner('vendor', lambda q: q.outerjoin(model.Vendor))
         g.set_sorter('vendor', model.Vendor.name)
@@ -165,7 +166,7 @@ class ProductBatchView(BatchMasterView):
             return 'warning'
 
     def configure_row_form(self, f):
-        super(ProductBatchView, self).configure_row_form(f)
+        super().configure_row_form(f)
 
         f.set_type('upc', 'gpc')
 
@@ -204,10 +205,10 @@ class ProductBatchView(BatchMasterView):
             return self.request.route_url('labels.batch.view', uuid=result.uuid)
         elif kwargs['action'] == 'make_pricing_batch':
             return self.request.route_url('batch.pricing.view', uuid=result.uuid)
-        return super(ProductBatchView, self).get_execute_success_url(batch)
+        return super().get_execute_success_url(batch)
 
     def get_row_csv_fields(self):
-        fields = super(ProductBatchView, self).get_row_csv_fields()
+        fields = super().get_row_csv_fields()
 
         if 'vendor_uuid' in fields:
             i = fields.index('vendor_uuid')
@@ -273,12 +274,12 @@ class ProductBatchView(BatchMasterView):
             data['report_name'] = (report.name or '') if report else ''
 
     def get_row_csv_row(self, row, fields):
-        csvrow = super(ProductBatchView, self).get_row_csv_row(row, fields)
+        csvrow = super().get_row_csv_row(row, fields)
         self.supplement_row_data(row, fields, csvrow)
         return csvrow
 
     def get_row_xlsx_row(self, row, fields):
-        xlrow = super(ProductBatchView, self).get_row_xlsx_row(row, fields)
+        xlrow = super().get_row_xlsx_row(row, fields)
         self.supplement_row_data(row, fields, xlrow)
         return xlrow
 
diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py
index 0d4e3d7c..dcd0e943 100644
--- a/tailbone/views/customers.py
+++ b/tailbone/views/customers.py
@@ -341,7 +341,7 @@ class CustomerView(MasterView):
         # people
         if self.should_expose_people():
             if self.viewing:
-                f.set_renderer('people', self.render_people_buefy)
+                f.set_renderer('people', self.render_people)
             else:
                 f.remove('people')
         else:
@@ -463,20 +463,6 @@ class CustomerView(MasterView):
         url = self.request.route_url('people.view', uuid=person.uuid)
         return tags.link_to(text, url)
 
-    # TODO: remove if no longer used
-    def render_people(self, customer, field):
-        people = customer.people
-        if not people:
-            return ""
-
-        items = []
-        for person in people:
-            text = str(person)
-            url = self.request.route_url('people.view', uuid=person.uuid)
-            link = tags.link_to(text, url)
-            items.append(HTML.tag('li', c=[link]))
-        return HTML.tag('ul', c=items)
-
     def render_shoppers(self, customer, field):
         route_prefix = self.get_route_prefix()
         permission_prefix = self.get_permission_prefix()
@@ -504,9 +490,9 @@ class CustomerView(MasterView):
         )
 
         return HTML.literal(
-            g.render_buefy_table_element(data_prop='shoppers'))
+            g.render_table_element(data_prop='shoppers'))
 
-    def render_people_buefy(self, customer, field):
+    def render_people(self, customer, field):
         route_prefix = self.get_route_prefix()
         permission_prefix = self.get_permission_prefix()
 
@@ -533,7 +519,7 @@ class CustomerView(MasterView):
                                                    click_handler="$emit('detach-person', props.row._action_url_detach)"))
 
         return HTML.literal(
-            g.render_buefy_table_element(data_prop='peopleData'))
+            g.render_table_element(data_prop='peopleData'))
 
     def render_groups(self, customer, field):
         groups = customer.groups
diff --git a/tailbone/views/custorders/items.py b/tailbone/views/custorders/items.py
index 91976196..d8e39f55 100644
--- a/tailbone/views/custorders/items.py
+++ b/tailbone/views/custorders/items.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.
 #
@@ -401,7 +401,7 @@ class CustomerOrderItemView(MasterView):
         )
 
         table = HTML.literal(
-            g.render_buefy_table_element(data_prop='eventsData'))
+            g.render_table_element(data_prop='eventsData'))
         elements = [table]
 
         if self.has_perm('add_note'):
diff --git a/tailbone/views/departments.py b/tailbone/views/departments.py
index 01d6f520..c6998105 100644
--- a/tailbone/views/departments.py
+++ b/tailbone/views/departments.py
@@ -144,7 +144,7 @@ class DepartmentView(MasterView):
             g.main_actions.append(self.make_action('edit', icon='edit'))
 
         return HTML.literal(
-            g.render_buefy_table_element(data_prop='employeesData'))
+            g.render_table_element(data_prop='employeesData'))
 
     def template_kwargs_view(self, **kwargs):
         kwargs = super().template_kwargs_view(**kwargs)
diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index c9d6cd7c..c6ce44e0 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -30,7 +30,6 @@ import csv
 import datetime
 import getpass
 import shutil
-import tempfile
 import logging
 from collections import OrderedDict
 
@@ -40,12 +39,10 @@ from sqlalchemy import orm
 import sqlalchemy_continuum as continuum
 from sqlalchemy_utils.functions import get_primary_keys, get_columns
 
-from rattail.db import model, Session as RattailSession
 from rattail.db.continuum import model_transaction_query
 from rattail.util import simple_error, get_class_hierarchy
 from rattail.threads import Thread
 from rattail.csvutil import UnicodeDictWriter
-from rattail.files import temp_path
 from rattail.excel import ExcelWriter
 from rattail.gpc import GPC
 
@@ -54,7 +51,6 @@ import deform
 from deform import widget as dfwidget
 from pyramid import httpexceptions
 from pyramid.renderers import get_renderer, render_to_response, render
-from pyramid.response import FileResponse
 from webhelpers2.html import HTML, tags
 from webob.compat import cgi_FieldStorage
 
@@ -220,7 +216,8 @@ class MasterView(View):
         to the current thread (one per request), this method should instead
         return e.g. a new independent ``rattail.db.Session`` instance.
         """
-        return RattailSession()
+        app = self.get_rattail_app()
+        return app.make_session()
 
     @classmethod
     def get_grid_factory(cls):
@@ -348,7 +345,7 @@ class MasterView(View):
 
         # return grid data only, if partial page was requested
         if self.request.params.get('partial'):
-            return self.json_response(grid.get_buefy_data())
+            return self.json_response(grid.get_table_data())
 
         context = {
             'grid': grid,
@@ -719,10 +716,11 @@ class MasterView(View):
         return obj
 
     def normalize_uploads(self, form, skip=None):
+        app = self.get_rattail_app()
         uploads = {}
 
         def normalize(filedict):
-            tempdir = tempfile.mkdtemp()
+            tempdir = app.make_temp_dir()
             filepath = os.path.join(tempdir, filedict['filename'])
             tmpinfo = form.deform_form[node.name].widget.tmpstore.get(filedict['uid'])
             tmpdata = tmpinfo['fp'].read()
@@ -1114,7 +1112,8 @@ class MasterView(View):
         Thread target for populating new object with progress indicator.
         """
         # mustn't use tailbone web session here
-        session = RattailSession()
+        app = self.get_rattail_app()
+        session = app.make_session()
         obj = session.get(self.model_class, uuid)
         try:
             self.populate_object(session, obj, progress=progress)
@@ -1175,7 +1174,7 @@ class MasterView(View):
             # return grid only, if partial page was requested
             if self.request.params.get('partial'):
                 # render grid data only, as JSON
-                return self.json_response(grid.get_buefy_data())
+                return self.json_response(grid.get_table_data())
 
         context = {
             'instance': instance,
@@ -1308,7 +1307,7 @@ class MasterView(View):
         # return grid only, if partial page was requested
         if self.request.params.get('partial'):
             # render grid data only, as JSON
-            return self.json_response(grid.get_buefy_data())
+            return self.json_response(grid.get_table_data())
 
         return self.render_to_response('versions', {
             'instance': instance,
@@ -1360,6 +1359,7 @@ class MasterView(View):
         return classes
 
     def make_revisions_grid(self, obj, empty_data=False):
+        model = self.model
         route_prefix = self.get_route_prefix()
         row_url = lambda txn, i: self.request.route_url(f'{route_prefix}.version',
                                                         uuid=obj.uuid,
@@ -1396,8 +1396,8 @@ class MasterView(View):
 
         grid = self.make_version_grid(**kwargs)
 
-        grid.set_joiner('user', lambda q: q.outerjoin(self.model.User))
-        grid.set_sorter('user', self.model.User.username)
+        grid.set_joiner('user', lambda q: q.outerjoin(model.User))
+        grid.set_sorter('user', model.User.username)
 
         grid.set_link('remote_addr')
 
@@ -1465,7 +1465,7 @@ class MasterView(View):
         else: # no txnid, return grid data
             obj = self.get_instance()
             grid = self.make_revisions_grid(obj)
-            return grid.get_buefy_data()
+            return grid.get_table_data()
 
     def view_version(self):
         """
@@ -1770,16 +1770,10 @@ class MasterView(View):
         path = self.download_path(obj, filename)
         if not path or not os.path.exists(path):
             raise self.notfound()
-        response = FileResponse(path, request=self.request)
-        response.content_length = os.path.getsize(path)
+        response = self.file_response(path)
         content_type = self.download_content_type(path, filename)
         if content_type:
             response.content_type = content_type
-
-        # content-disposition
-        filename = os.path.basename(path)
-        response.content_disposition = str('attachment; filename="{}"'.format(filename))
-
         return response
 
     def download_content_type(self, path, filename):
@@ -1856,7 +1850,7 @@ class MasterView(View):
         View for deleting an existing model record.
         """
         if not self.deletable:
-            raise httpexceptions.HTTPForbidden()
+            raise self.forbidden()
 
         self.deleting = True
         instance = self.get_instance()
@@ -2111,7 +2105,9 @@ class MasterView(View):
         """
         Thread target for executing an object.
         """
-        session = RattailSession()
+        app = self.get_rattail_app()
+        model = self.model
+        session = app.make_session()
         obj = self.get_instance_for_key(key, session)
         user = session.get(model.User, user_uuid)
         try:
@@ -2926,11 +2922,11 @@ class MasterView(View):
             normal.append(button)
         return normal
 
-    def make_buefy_button(self, label,
-                          type=None, is_primary=False,
-                          url=None, target=None, is_external=False,
-                          icon_left=None,
-                          **kwargs):
+    def make_button(self, label,
+                    type=None, is_primary=False,
+                    url=None, target=None, is_external=False,
+                    icon_left=None,
+                    **kwargs):
         """
         Make and return a HTML ``<b-button>`` literal.
         """
@@ -2983,7 +2979,7 @@ class MasterView(View):
            assumed to be external, which affects the icon and causes
            button click to open link in a new tab.
         """
-        # TODO: this should call make_buefy_button()
+        # TODO: this should call make_button()
 
         # nb. unfortunately HTML.tag() calls its first arg 'tag' and
         # so we can't pass a kwarg with that name...so instead we
@@ -4067,10 +4063,11 @@ class MasterView(View):
         """
         Download current *row* results as XLSX.
         """
+        app = self.get_rattail_app()
         obj = self.get_instance()
         results = self.get_effective_row_data(sort=True)
         fields = self.get_row_xlsx_fields()
-        path = temp_path(suffix='.xlsx')
+        path = app.make_temp_file(suffix='.xlsx')
         writer = ExcelWriter(path, fields, sheet_title=self.get_row_model_title_plural())
         writer.write_header()
 
@@ -5039,6 +5036,7 @@ class MasterView(View):
         """
         Generic view for configuring some aspect of the software.
         """
+        app = self.get_rattail_app()
         if self.request.method == 'POST':
             if self.request.POST.get('remove_settings'):
                 self.configure_remove_settings()
@@ -5053,7 +5051,7 @@ class MasterView(View):
                 uploads = {}
                 for key, value in data.items():
                     if isinstance(value, cgi_FieldStorage):
-                        tempdir = tempfile.mkdtemp()
+                        tempdir = app.make_temp_dir()
                         filename = os.path.basename(value.filename)
                         filepath = os.path.join(tempdir, filename)
                         with open(filepath, 'wb') as f:
diff --git a/tailbone/views/messages.py b/tailbone/views/messages.py
index d1509163..bf460436 100644
--- a/tailbone/views/messages.py
+++ b/tailbone/views/messages.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.
 #
@@ -29,10 +29,8 @@ from rattail.time import localtime
 
 import colander
 from deform import widget as dfwidget
-from pyramid import httpexceptions
 from webhelpers2.html import tags, HTML
 
-# from tailbone import forms
 from tailbone.db import Session
 from tailbone.views import MasterView
 from tailbone.util import raw_datetime
@@ -83,15 +81,15 @@ class MessageView(MasterView):
 
     def index(self):
         if not self.request.user:
-            raise httpexceptions.HTTPForbidden
+            raise self.forbidden()
         return super().index()
 
     def get_instance(self):
         if not self.request.user:
-            raise httpexceptions.HTTPForbidden
+            raise self.forbidden()
         message = super().get_instance()
         if not self.associated_with(message):
-            raise httpexceptions.HTTPForbidden
+            raise self.forbidden()
         return message
 
     def associated_with(self, message):
@@ -395,7 +393,7 @@ class MessageView(MasterView):
         message = self.get_instance()
         recipient = self.get_recipient(message)
         if not recipient:
-            raise httpexceptions.HTTPForbidden
+            raise self.forbidden()
 
         dest = self.request.GET.get('dest')
         if dest not in ('inbox', 'archive'):
@@ -520,7 +518,7 @@ class RecipientsWidgetBuefy(dfwidget.Widget):
     """
     Custom "message recipients" widget, for use with Buefy / Vue.js themes.
     """
-    template = 'message_recipients_buefy'
+    template = 'message_recipients'
 
     def deserialize(self, field, pstruct):
         if pstruct is colander.null:
diff --git a/tailbone/views/principal.py b/tailbone/views/principal.py
index 20f6b866..6bb623d1 100644
--- a/tailbone/views/principal.py
+++ b/tailbone/views/principal.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.
 #
@@ -43,7 +43,7 @@ class PrincipalMasterView(MasterView):
     def get_fallback_templates(self, template, **kwargs):
         return [
             '/principal/{}.mako'.format(template),
-        ] + super(PrincipalMasterView, self).get_fallback_templates(template, **kwargs)
+        ] + super().get_fallback_templates(template, **kwargs)
 
     def perm_sortkey(self, item):
         key, value = item
@@ -74,9 +74,9 @@ class PrincipalMasterView(MasterView):
 
         context = {'permissions': sorted_perms, 'principals': principals}
 
-        perms = self.get_buefy_perms_data(sorted_perms)
-        context['buefy_perms'] = perms
-        context['buefy_sorted_groups'] = list(perms)
+        perms = self.get_perms_data(sorted_perms)
+        context['perms_data'] = perms
+        context['sorted_groups_data'] = list(perms)
 
         if permission_group and permission_group not in perms:
             permission_group = None
@@ -95,7 +95,7 @@ class PrincipalMasterView(MasterView):
 
         return self.render_to_response('find_by_perm', context)
 
-    def get_buefy_perms_data(self, sorted_perms):
+    def get_perms_data(self, sorted_perms):
         data = OrderedDict()
         for gkey, group in sorted_perms:
 
diff --git a/tailbone/views/products.py b/tailbone/views/products.py
index 16c65fdb..1a928d67 100644
--- a/tailbone/views/products.py
+++ b/tailbone/views/products.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.
 #
@@ -37,8 +37,7 @@ from rattail.db import model, api, auth, Session as RattailSession
 from rattail.gpc import GPC
 from rattail.threads import Thread
 from rattail.exceptions import LabelPrintingError
-from rattail.util import load_object, pretty_quantity, simple_error
-from rattail.time import localtime, make_utc
+from rattail.util import simple_error
 
 import colander
 from deform import widget as dfwidget
@@ -417,13 +416,13 @@ class ProductView(MasterView):
             app = self.get_rattail_app()
 
             if price.starts:
-                starts = localtime(self.rattail_config, price.starts, from_utc=True)
+                starts = app.localtime(price.starts, from_utc=True)
                 starts = app.render_date(starts.date())
             else:
                 starts = "??"
 
             if price.ends:
-                ends = localtime(self.rattail_config, price.ends, from_utc=True)
+                ends = app.localtime(price.ends, from_utc=True)
                 ends = app.render_date(ends.date())
             else:
                 ends = "??"
@@ -456,23 +455,25 @@ class ProductView(MasterView):
             default=True)
 
     def render_regular_price(self, product, field):
+        app = self.get_rattail_app()
         text = self.render_price(product, field)
 
         if text and self.show_price_effective_dates():
             history = self.get_regular_price_history(product)
             if history:
-                date = localtime(self.rattail_config, history[0]['changed'], from_utc=True).date()
+                date = app.localtime(history[0]['changed'], from_utc=True).date()
                 text = "{} (as of {})".format(text, date)
 
         return self.add_price_history_link(text, 'regular')
 
     def render_current_price(self, product, field):
+        app = self.get_rattail_app()
         text = self.render_price(product, field)
 
         if text and self.show_price_effective_dates():
             history = self.get_current_price_history(product)
             if history:
-                date = localtime(self.rattail_config, history[0]['changed'], from_utc=True).date()
+                date = app.localtime(history[0]['changed'], from_utc=True).date()
                 text = "{} (as of {})".format(text, date)
 
         return self.add_price_history_link(text, 'current')
@@ -489,10 +490,11 @@ class ProductView(MasterView):
         if not text:
             return
 
+        app = self.get_rattail_app()
         if self.show_price_effective_dates():
             history = self.get_suggested_price_history(product)
             if history:
-                date = localtime(self.rattail_config, history[0]['changed'], from_utc=True).date()
+                date = app.localtime(history[0]['changed'], from_utc=True).date()
                 text = "{} (as of {})".format(text, date)
 
         text = self.warn_if_regprice_more_than_srp(product, text)
@@ -526,13 +528,15 @@ class ProductView(MasterView):
         inventory = product.inventory
         if not inventory:
             return ""
-        return pretty_quantity(inventory.on_hand)
+        app = self.get_rattail_app()
+        return app.render_quantity(inventory.on_hand)
 
     def render_on_order(self, product, column):
         inventory = product.inventory
         if not inventory:
             return ""
-        return pretty_quantity(inventory.on_order)
+        app = self.get_rattail_app()
+        return app.render_quantity(inventory.on_order)
 
     def template_kwargs_index(self, **kwargs):
         kwargs = super().template_kwargs_index(**kwargs)
@@ -1105,7 +1109,8 @@ class ProductView(MasterView):
         value = product.inventory.on_hand
         if not value:
             return ""
-        return pretty_quantity(value)
+        app = self.get_rattail_app()
+        return app.render_quantity(value)
 
     def render_inventory_on_order(self, product, field):
         if not product.inventory:
@@ -1113,7 +1118,8 @@ class ProductView(MasterView):
         value = product.inventory.on_order
         if not value:
             return ""
-        return pretty_quantity(value)
+        app = self.get_rattail_app()
+        return app.render_quantity(value)
 
     def price_history(self):
         """
@@ -1136,7 +1142,7 @@ class ProductView(MasterView):
             if price is not None:
                 history['price'] = float(price)
                 history['price_display'] = app.render_currency(price)
-            changed = localtime(self.rattail_config, history['changed'], from_utc=True)
+            changed = app.localtime(history['changed'], from_utc=True)
             history['changed'] = str(changed)
             history['changed_display_html'] = raw_datetime(self.rattail_config, changed)
             user = history.pop('changed_by')
@@ -1149,6 +1155,7 @@ class ProductView(MasterView):
         """
         AJAX view for fetching cost history for a product.
         """
+        app = self.get_rattail_app()
         product = self.get_instance()
         data = self.get_cost_history(product)
 
@@ -1162,7 +1169,7 @@ class ProductView(MasterView):
                 history['cost_display'] = "${:0.2f}".format(cost)
             else:
                 history['cost_display'] = None
-            changed = localtime(self.rattail_config, history['changed'], from_utc=True)
+            changed = app.localtime(history['changed'], from_utc=True)
             history['changed'] = str(changed)
             history['changed_display_html'] = raw_datetime(self.rattail_config, changed)
             user = history.pop('changed_by')
@@ -1388,10 +1395,11 @@ class ProductView(MasterView):
         Returns a sequence of "records" which corresponds to the given
         product's regular price history.
         """
+        app = self.get_rattail_app()
         Transaction = continuum.transaction_class(model.Product)
         ProductVersion = continuum.version_class(model.Product)
         ProductPriceVersion = continuum.version_class(model.ProductPrice)
-        now = make_utc()
+        now = app.make_utc()
         history = []
 
         # first we find all relevant ProductVersion records
@@ -1457,10 +1465,11 @@ class ProductView(MasterView):
         Returns a sequence of "records" which corresponds to the given
         product's current price history.
         """
+        app = self.get_rattail_app()
         Transaction = continuum.transaction_class(model.Product)
         ProductVersion = continuum.version_class(model.Product)
         ProductPriceVersion = continuum.version_class(model.ProductPrice)
-        now = make_utc()
+        now = app.make_utc()
         history = []
 
         # first we find all relevant ProductVersion records
@@ -1599,10 +1608,11 @@ class ProductView(MasterView):
         Returns a sequence of "records" which corresponds to the given
         product's SRP history.
         """
+        app = self.get_rattail_app()
         Transaction = continuum.transaction_class(model.Product)
         ProductVersion = continuum.version_class(model.Product)
         ProductPriceVersion = continuum.version_class(model.ProductPrice)
-        now = make_utc()
+        now = app.make_utc()
         history = []
 
         # first we find all relevant ProductVersion records
@@ -1668,10 +1678,11 @@ class ProductView(MasterView):
         Returns a sequence of "records" which corresponds to the given
         product's cost history.
         """
+        app = self.get_rattail_app()
         Transaction = continuum.transaction_class(model.Product)
         ProductVersion = continuum.version_class(model.Product)
         ProductCostVersion = continuum.version_class(model.ProductCost)
-        now = make_utc()
+        now = app.make_utc()
         history = []
 
         # we just find all relevant (preferred!) ProductCostVersion records
@@ -1948,10 +1959,11 @@ class ProductView(MasterView):
         """
         View for making a new batch from current product grid query.
         """
+        app = self.get_rattail_app()
         supported = self.get_supported_batches()
         batch_options = []
         for key, info in list(supported.items()):
-            handler = load_object(info['spec'])(self.rattail_config)
+            handler = app.load_object(info['spec'])(self.rattail_config)
             handler.spec = info['spec']
             handler.option_key = key
             handler.option_title = info.get('title', handler.get_model_title())
@@ -2448,19 +2460,19 @@ class PendingProductView(MasterView):
         if (self.has_perm('ignore_product')
             and status in (self.enum.PENDING_PRODUCT_STATUS_PENDING,
                            self.enum.PENDING_PRODUCT_STATUS_READY)):
-            buttons.append(self.make_buefy_button("Ignore Product",
-                                                  type='is-warning',
-                                                  icon_left='ban',
-                                                  **{'@click': "$emit('ignore-product')"}))
+            buttons.append(self.make_button("Ignore Product",
+                                            type='is-warning',
+                                            icon_left='ban',
+                                            **{'@click': "$emit('ignore-product')"}))
 
         if (self.has_perm('resolve_product')
             and status in (self.enum.PENDING_PRODUCT_STATUS_PENDING,
                            self.enum.PENDING_PRODUCT_STATUS_READY,
                            self.enum.PENDING_PRODUCT_STATUS_IGNORED)):
-            buttons.append(self.make_buefy_button("Resolve Product",
-                                                  is_primary=True,
-                                                  icon_left='object-ungroup',
-                                                  **{'@click': "$emit('resolve-product')"}))
+            buttons.append(self.make_button("Resolve Product",
+                                            is_primary=True,
+                                            icon_left='object-ungroup',
+                                            **{'@click': "$emit('resolve-product')"}))
 
         if buttons:
             text = HTML.tag('span', class_='control', c=[text])
diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py
index e49a5dea..cd369f0a 100644
--- a/tailbone/views/purchasing/batch.py
+++ b/tailbone/views/purchasing/batch.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,10 +28,9 @@ from rattail.db.model import PurchaseBatch, PurchaseBatchRow
 
 import colander
 from deform import widget as dfwidget
-from pyramid import httpexceptions
 from webhelpers2.html import tags, HTML
 
-from tailbone import forms, grids
+from tailbone import forms
 from tailbone.views.batch import BatchMasterView
 
 
@@ -826,7 +825,7 @@ class PurchasingBatchView(BatchMasterView):
     def render_row_credits(self, row, field):
         g = self.make_row_credits_grid(row)
         return HTML.literal(
-            g.render_buefy_table_element(data_prop='rowData.credits'))
+            g.render_table_element(data_prop='rowData.credits'))
 
 #     def before_create_row(self, form):
 #         row = form.fieldset.model
diff --git a/tailbone/views/purchasing/ordering.py b/tailbone/views/purchasing/ordering.py
index 63c13517..2e24eebb 100644
--- a/tailbone/views/purchasing/ordering.py
+++ b/tailbone/views/purchasing/ordering.py
@@ -310,8 +310,6 @@ class OrderingBatchView(PurchasingBatchView):
         if not order_date:
             order_date = localtime(self.rattail_config).date()
 
-        buefy_data = self.get_worksheet_buefy_data(departments)
-
         return self.render_to_response('worksheet', {
             'batch': batch,
             'order_date': order_date,
@@ -324,10 +322,10 @@ class OrderingBatchView(PurchasingBatchView):
             'get_upc': lambda p: p.upc.pretty() if p.upc else '',
             'header_columns': self.order_form_header_columns,
             'ignore_cases': not self.handler.allow_cases(),
-            'worksheet_data': buefy_data,
+            'worksheet_data': self.get_worksheet_data(departments),
         })
 
-    def get_worksheet_buefy_data(self, departments):
+    def get_worksheet_data(self, departments):
         data = {}
         for department in departments.values():
             for subdepartment in department._order_subdepartments.values():
diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py
index 22fbc133..739fe0bd 100644
--- a/tailbone/views/purchasing/receiving.py
+++ b/tailbone/views/purchasing/receiving.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.
 #
@@ -33,12 +33,10 @@ from collections import OrderedDict
 import humanize
 
 from rattail import pod
-from rattail.time import localtime, make_utc
-from rattail.util import pretty_quantity, prettify, simple_error
+from rattail.util import prettify, simple_error
 
 import colander
 from deform import widget as dfwidget
-from pyramid import httpexceptions
 from webhelpers2.html import tags, HTML
 
 from tailbone import forms, grids
@@ -781,8 +779,8 @@ class ReceivingBatchView(PurchasingBatchView):
             g.set_click_handler('title', "autoFilterPoVsInvoice(props.row)")
             kwargs['po_vs_invoice_breakdown_data'] = breakdown
             kwargs['po_vs_invoice_breakdown_grid'] = HTML.literal(
-                g.render_buefy_table_element(data_prop='poVsInvoiceBreakdownData',
-                                             empty_labels=True))
+                g.render_table_element(data_prop='poVsInvoiceBreakdownData',
+                                       empty_labels=True))
 
         kwargs['allow_edit_catalog_unit_cost'] = self.allow_edit_catalog_unit_cost(batch)
         kwargs['allow_edit_invoice_unit_cost'] = self.allow_edit_invoice_unit_cost(batch)
@@ -1137,6 +1135,7 @@ class ReceivingBatchView(PurchasingBatchView):
         """
         Primary desktop view for row-level receiving.
         """
+        app = self.get_rattail_app()
         # TODO: this code was largely copied from mobile_receive_row() but it
         # tries to pave the way for shared logic, i.e. where the latter would
         # simply invoke this method and return the result.  however we're not
@@ -1270,7 +1269,7 @@ class ReceivingBatchView(PurchasingBatchView):
                 if accounted_for:
                     # some product accounted for; button should receive "remainder" only
                     if remainder:
-                        remainder = pretty_quantity(remainder)
+                        remainder = app.render_quantity(remainder)
                         context['quick_receive_quantity'] = remainder
                         context['quick_receive_text'] = "Receive Remainder ({} {})".format(remainder, context['unit_uom'])
                     else:
@@ -1280,7 +1279,7 @@ class ReceivingBatchView(PurchasingBatchView):
                 else: # nothing yet accounted for, button should receive "all"
                     if not remainder:
                         raise ValueError("why is remainder empty?")
-                    remainder = pretty_quantity(remainder)
+                    remainder = app.render_quantity(remainder)
                     context['quick_receive_quantity'] = remainder
                     context['quick_receive_text'] = "Receive ALL ({} {})".format(remainder, context['unit_uom'])
 
diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py
index 9bf30a88..aedda61c 100644
--- a/tailbone/views/reports.py
+++ b/tailbone/views/reports.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.
 #
@@ -32,9 +32,8 @@ import logging
 from collections import OrderedDict
 
 import rattail
-from rattail.db import model, Session as RattailSession
+from rattail.db.model import ReportOutput
 from rattail.files import resource_path
-from rattail.time import localtime
 from rattail.threads import Thread
 from rattail.util import simple_error
 
@@ -81,6 +80,7 @@ class OrderingWorksheet(View):
     upc_getter = staticmethod(get_upc)
 
     def __call__(self):
+        model = self.model
         if self.request.params.get('vendor'):
             vendor = Session.get(model.Vendor, self.request.params['vendor'])
             if vendor:
@@ -104,7 +104,8 @@ class OrderingWorksheet(View):
         """
         Rendering engine for the ordering worksheet report.
         """
-
+        app = self.get_rattail_app()
+        model = self.model
         q = Session.query(model.ProductCost)
         q = q.join(model.Product)
         q = q.filter(model.Product.deleted == False)
@@ -127,7 +128,7 @@ class OrderingWorksheet(View):
             key = '{0} {1}'.format(brand, product.description)
             return key
 
-        now = localtime(self.request.rattail_config)
+        now = app.localtime()
         data = dict(
             vendor=vendor,
             costs=costs,
@@ -157,7 +158,7 @@ class InventoryWorksheet(View):
         """
         This is the "Inventory Worksheet" report.
         """
-
+        model = self.model
         departments = Session.query(model.Department)
 
         if self.request.params.get('department'):
@@ -178,6 +179,8 @@ class InventoryWorksheet(View):
         """
         Generates the Inventory Worksheet report.
         """
+        app = self.get_rattail_app()
+        model = self.model
 
         def get_products(subdepartment):
             q = Session.query(model.Product)
@@ -191,7 +194,7 @@ class InventoryWorksheet(View):
             q = q.order_by(model.Brand.name, model.Product.description)
             return q.all()
 
-        now = localtime(self.request.rattail_config)
+        now = app.localtime()
         data = dict(
             date=now.strftime('%a %d %b %Y'),
             time=now.strftime('%I:%M %p'),
@@ -209,7 +212,7 @@ class ReportOutputView(ExportMasterView):
     """
     Master view for report output
     """
-    model_class = model.ReportOutput
+    model_class = ReportOutput
     route_prefix = 'report_output'
     url_prefix = '/reports/generated'
     creatable = True
@@ -238,7 +241,7 @@ class ReportOutputView(ExportMasterView):
     ]
 
     def __init__(self, request):
-        super(ReportOutputView, self).__init__(request)
+        super().__init__(request)
         self.report_handler = self.get_report_handler()
 
     def get_report_handler(self):
@@ -246,7 +249,7 @@ class ReportOutputView(ExportMasterView):
         return app.get_report_handler()
 
     def configure_grid(self, g):
-        super(ReportOutputView, self).configure_grid(g)
+        super().configure_grid(g)
 
         g.filters['report_name'].default_active = True
         g.filters['report_name'].default_verb = 'contains'
@@ -254,7 +257,7 @@ class ReportOutputView(ExportMasterView):
         g.set_link('filename')
 
     def configure_form(self, f):
-        super(ReportOutputView, self).configure_form(f)
+        super().configure_form(f)
 
         # report_type
         f.set_renderer('report_type', self.render_report_type)
@@ -282,10 +285,10 @@ class ReportOutputView(ExportMasterView):
         # add help button if report has a link
         report = self.report_handler.get_report(type_key)
         if report and report.help_url:
-            button = self.make_buefy_button("Help for this report",
-                                            url=report.help_url,
-                                            is_external=True,
-                                            icon_left='question-circle')
+            button = self.make_button("Help for this report",
+                                      url=report.help_url,
+                                      is_external=True,
+                                      icon_left='question-circle')
             button = HTML.tag('div', class_='level-item', c=[button])
             rendered = HTML.tag('div', class_='level-item', c=[rendered])
             rendered = HTML.tag('div', class_='level-left', c=[rendered, button])
@@ -311,7 +314,7 @@ class ReportOutputView(ExportMasterView):
             labels={'key': "Name"},
         )
         return HTML.literal(
-            g.render_buefy_table_element(data_prop='paramsData'))
+            g.render_table_element(data_prop='paramsData'))
 
     def get_params_context(self, report):
         params_data = []
@@ -323,7 +326,7 @@ class ReportOutputView(ExportMasterView):
         return params_data
 
     def template_kwargs_view(self, **kwargs):
-        kwargs = super(ReportOutputView, self).template_kwargs_view(**kwargs)
+        kwargs = super().template_kwargs_view(**kwargs)
         output = kwargs['instance']
 
         kwargs['params_data'] = self.get_params_context(output)
@@ -339,7 +342,7 @@ class ReportOutputView(ExportMasterView):
         return kwargs
 
     def template_kwargs_delete(self, **kwargs):
-        kwargs = super(ReportOutputView, self).template_kwargs_delete(**kwargs)
+        kwargs = super().template_kwargs_delete(**kwargs)
 
         report = kwargs['instance']
         kwargs['params_data'] = self.get_params_context(report)
@@ -496,7 +499,9 @@ class ReportOutputView(ExportMasterView):
         resulting :class:`rattail:~rattail.db.model.reports.ReportOutput`
         object.
         """
-        session = RattailSession()
+        app = self.get_rattail_app()
+        model = self.model
+        session = app.make_session()
         user = session.get(model.User, user_uuid)
         try:
             output = self.report_handler.generate_output(session, report, params, user, progress=progress)
@@ -603,7 +608,7 @@ class ProblemReportView(MasterView):
     ]
 
     def __init__(self, request):
-        super(ProblemReportView, self).__init__(request)
+        super().__init__(request)
 
         app = self.get_rattail_app()
         self.problem_handler = app.get_problem_report_handler()
@@ -660,7 +665,7 @@ class ProblemReportView(MasterView):
         return ProblemReportSchema()
 
     def configure_form(self, f):
-        super(ProblemReportView, self).configure_form(f)
+        super().configure_form(f)
 
         # email_*
         if self.editing:
@@ -703,10 +708,10 @@ class ProblemReportView(MasterView):
         g = self.get_grid_factory()('days', [],
                                     columns=['weekday_name', 'enabled'],
                                     labels={'weekday_name': "Weekday"})
-        return HTML.literal(g.render_buefy_table_element(data_prop='weekdaysData'))
+        return HTML.literal(g.render_table_element(data_prop='weekdaysData'))
 
     def template_kwargs_view(self, **kwargs):
-        kwargs = super(ProblemReportView, self).template_kwargs_view(**kwargs)
+        kwargs = super().template_kwargs_view(**kwargs)
         report_info = kwargs['instance']
 
         data = []
diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py
index 2be47415..19faabd8 100644
--- a/tailbone/views/roles.py
+++ b/tailbone/views/roles.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.
 #
@@ -29,7 +29,7 @@ import os
 from sqlalchemy import orm
 from openpyxl.styles import Font, PatternFill
 
-from rattail.db import model
+from rattail.db.model import Role
 from rattail.db.auth import administrator_role, guest_role, authenticated_role
 from rattail.excel import ExcelWriter
 
@@ -46,7 +46,7 @@ class RoleView(PrincipalMasterView):
     """
     Master view for the Role model.
     """
-    model_class = model.Role
+    model_class = Role
     has_versions = True
     touchable = True
 
@@ -77,7 +77,7 @@ class RoleView(PrincipalMasterView):
     ]
 
     def configure_grid(self, g):
-        super(RoleView, self).configure_grid(g)
+        super().configure_grid(g)
 
         # name
         g.filters['name'].default_active = True
@@ -158,6 +158,7 @@ class RoleView(PrincipalMasterView):
         return True
 
     def unique_name(self, node, value):
+        model = self.model
         query = self.Session.query(model.Role)\
                             .filter(model.Role.name == value)
         if self.editing:
@@ -167,7 +168,7 @@ class RoleView(PrincipalMasterView):
             raise colander.Invalid(node, "Name must be unique")
 
     def configure_form(self, f):
-        super(RoleView, self).configure_form(f)
+        super().configure_form(f)
         role = f.model_instance
         app = self.get_rattail_app()
         auth = app.get_auth_handler()
@@ -265,7 +266,7 @@ class RoleView(PrincipalMasterView):
             g.main_actions.append(self.make_action('edit', icon='edit'))
 
         return HTML.literal(
-            g.render_buefy_table_element(data_prop='usersData'))
+            g.render_table_element(data_prop='usersData'))
 
     def get_available_permissions(self):
         """
@@ -322,7 +323,7 @@ class RoleView(PrincipalMasterView):
         """
         if data is None:
             data = form.validated
-        role = super(RoleView, self).objectify(form, data)
+        role = super().objectify(form, data)
         self.update_permissions(role, data['permissions'])
         return role
 
@@ -345,6 +346,7 @@ class RoleView(PrincipalMasterView):
                     auth.revoke_permission(role, pkey)
 
     def template_kwargs_view(self, **kwargs):
+        model = self.model
         role = kwargs['instance']
         if role.users:
             users = sorted(role.users, key=lambda u: u.username)
@@ -390,6 +392,7 @@ class RoleView(PrincipalMasterView):
 
     def find_principals_with_permission(self, session, permission):
         app = self.get_rattail_app()
+        model = self.model
         auth = app.get_auth_handler()
 
         # TODO: this should search Permission table instead, and work backward to Role?
@@ -408,6 +411,7 @@ class RoleView(PrincipalMasterView):
         Excel spreadsheet, and returns that file.
         """
         app = self.get_rattail_app()
+        model = self.model
         auth = app.get_auth_handler()
 
         roles = self.Session.query(model.Role)\
diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py
index 47cca0c5..46e4c02b 100644
--- a/tailbone/views/settings.py
+++ b/tailbone/views/settings.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.
 #
@@ -32,8 +32,8 @@ from collections import OrderedDict
 
 import json
 
-from rattail.db import model
-from rattail.settings import Setting
+from rattail.db.model import Setting
+from rattail.settings import Setting as AppSetting
 from rattail.util import import_module_path
 
 import colander
@@ -81,7 +81,7 @@ class AppInfoView(MasterView):
         return data
 
     def configure_grid(self, g):
-        super(AppInfoView, self).configure_grid(g)
+        super().configure_grid(g)
 
         g.sorters['name'] = g.make_simple_sorter('name', foldcase=True)
         g.set_sort_defaults('name')
@@ -94,12 +94,12 @@ class AppInfoView(MasterView):
         g.set_searchable('editable_project_location')
 
     def template_kwargs_index(self, **kwargs):
-        kwargs = super(AppInfoView, self).template_kwargs_index(**kwargs)
+        kwargs = super().template_kwargs_index(**kwargs)
         kwargs['configure_button_title'] = "Configure App"
         return kwargs
 
     def configure_get_context(self, **kwargs):
-        context = super(AppInfoView, self).configure_get_context(**kwargs)
+        context = super().configure_get_context(**kwargs)
 
         weblibs = OrderedDict([
             ('vue', "Vue"),
@@ -195,7 +195,7 @@ class SettingView(MasterView):
     """
     Master view for the settings model.
     """
-    model_class = model.Setting
+    model_class = Setting
     model_title = "Raw Setting"
     model_title_plural = "Raw Settings"
     bulk_deletable = True
@@ -207,18 +207,19 @@ class SettingView(MasterView):
     ]
 
     def configure_grid(self, g):
-        super(SettingView, self).configure_grid(g)
+        super().configure_grid(g)
         g.filters['name'].default_active = True
         g.filters['name'].default_verb = 'contains'
         g.set_sort_defaults('name')
         g.set_link('name')
 
     def configure_form(self, f):
-        super(SettingView, self).configure_form(f)
+        super().configure_form(f)
         if self.creating:
             f.set_validator('name', self.unique_name)
 
     def unique_name(self, node, value):
+        model = self.model
         setting = self.Session.get(model.Setting, value)
         if setting:
             raise colander.Invalid(node, "Setting name must be unique")
@@ -245,7 +246,7 @@ class SettingView(MasterView):
         self.rattail_config.beaker_invalidate_setting(setting.name)
 
         # otherwise delete like normal
-        super(SettingView, self).delete_instance(setting)
+        super().delete_instance(setting)
 
 
 # TODO: deprecate / remove this
@@ -307,14 +308,14 @@ class AppSettingsView(View):
             'settings': settings,
             'config_options': config_options,
         }
-        context['buefy_data'] = self.get_buefy_data(form, groups, settings)
+        context['settings_data'] = self.get_settings_data(form, groups, settings)
         # TODO: this seems hacky, and probably only needed if theme changes?
         if current_group == '(All)':
             current_group = ''
         context['current_group'] = current_group
         return context
 
-    def get_buefy_data(self, form, groups, settings):
+    def get_settings_data(self, form, groups, settings):
         dform = form.make_deform_form()
         grouped = dict([(label, [])
                         for label in groups])
@@ -407,7 +408,7 @@ class AppSettingsView(View):
             module = import_module_path(module)
             for name in dir(module):
                 obj = getattr(module, name)
-                if isinstance(obj, type) and issubclass(obj, Setting) and obj is not Setting:
+                if isinstance(obj, type) and issubclass(obj, AppSetting) and obj is not AppSetting:
                     if core_only and not obj.core:
                         continue
                     # NOTE: we set this here, and reference it elsewhere
diff --git a/tailbone/views/shifts/lib.py b/tailbone/views/shifts/lib.py
index 8fc58264..1827bee0 100644
--- a/tailbone/views/shifts/lib.py
+++ b/tailbone/views/shifts/lib.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,9 +28,8 @@ import datetime
 
 import sqlalchemy as sa
 
-from rattail import enum
-from rattail.db import model, api
-from rattail.time import localtime, make_utc, get_sunday
+from rattail.db import api
+from rattail.time import get_sunday
 from rattail.util import hours_as_decimal
 
 import colander
@@ -83,6 +82,8 @@ class TimeSheetView(View):
         """
         Determine date/store/dept context from user's session and/or defaults.
         """
+        app = self.get_rattail_app()
+        model = self.model
         date = None
         date_key = 'timesheet.{}.date'.format(self.key)
         if date_key in self.request.session:
@@ -93,7 +94,7 @@ class TimeSheetView(View):
                 except ValueError:
                     pass
         if not date:
-            date = localtime(self.rattail_config).date()
+            date = app.today()
 
         store = None
         department = None
@@ -113,7 +114,7 @@ class TimeSheetView(View):
                     store = api.get_store(Session(), store)
 
         employees = Session.query(model.Employee)\
-                           .filter(model.Employee.status == enum.EMPLOYEE_STATUS_CURRENT)
+                           .filter(model.Employee.status == self.enum.EMPLOYEE_STATUS_CURRENT)
         if store:
             employees = employees.join(model.EmployeeStore)\
                                  .filter(model.EmployeeStore.store == store)
@@ -132,6 +133,8 @@ class TimeSheetView(View):
         """
         Determine employee/date context from user's session and/or defaults
         """
+        app = self.get_rattail_app()
+        model = self.model
         date = None
         date_key = 'timesheet.{}.employee.date'.format(self.key)
         if date_key in self.request.session:
@@ -142,7 +145,7 @@ class TimeSheetView(View):
                 except ValueError:
                     pass
         if not date:
-            date = localtime(self.rattail_config).date()
+            date = app.today()
 
         employee = None
         employee_key = 'timesheet.{}.employee'.format(self.key)
@@ -191,7 +194,7 @@ class TimeSheetView(View):
         stores = self.get_stores()
         store_values = [(s.uuid, "{} - {}".format(s.id, s.name)) for s in stores]
         store_values.insert(0, ('', "(all)"))
-        form.set_widget('store', forms.widgets.PlainSelectWidget(values=store_values))
+        form.set_widget('store', dfwidget.SelectWidget(values=store_values))
         if context['store']:
             form.set_default('store', context['store'].uuid)
         else:
@@ -203,7 +206,7 @@ class TimeSheetView(View):
         departments = self.get_departments()
         department_values = [(d.uuid, d.name) for d in departments]
         department_values.insert(0, ('', "(all)"))
-        form.set_widget('department', forms.widgets.PlainSelectWidget(values=department_values))
+        form.set_widget('department', dfwidget.SelectWidget(values=department_values))
         if context['department']:
             form.set_default('department', context['department'].uuid)
         else:
@@ -292,6 +295,7 @@ class TimeSheetView(View):
         self.request.session['timesheet.{}.{}'.format(mainkey, key)] = value
 
     def get_stores(self):
+        model = self.model
         return Session.query(model.Store).order_by(model.Store.id).all()
 
     def get_store_options(self, stores):
@@ -299,6 +303,7 @@ class TimeSheetView(View):
         return tags.Options(options, prompt="(all)")
 
     def get_departments(self):
+        model = self.model
         return Session.query(model.Department).order_by(model.Department.name).all()
 
     def get_department_options(self, departments):
@@ -402,6 +407,7 @@ class TimeSheetView(View):
         the given params.  The cached shift data is attached to each employee.
         """
         app = self.get_rattail_app()
+        model = self.model
 
         # TODO: a bit hacky, this?  display hours as HH:MM by default, but
         # check config in order to display as HH.HH for certain users
@@ -413,19 +419,19 @@ class TimeSheetView(View):
             hours_style = 'pretty'
 
         shift_type = 'scheduled' if cls is model.ScheduledShift else 'worked'
-        min_time = localtime(self.rattail_config, datetime.datetime.combine(weekdays[0], datetime.time(0)))
-        max_time = localtime(self.rattail_config, datetime.datetime.combine(weekdays[-1] + datetime.timedelta(days=1), datetime.time(0)))
+        min_time = app.localtime(datetime.datetime.combine(weekdays[0], datetime.time(0)))
+        max_time = app.localtime(datetime.datetime.combine(weekdays[-1] + datetime.timedelta(days=1), datetime.time(0)))
         shifts = Session.query(cls)\
                         .filter(cls.employee_uuid.in_([e.uuid for e in employees]))\
                         .filter(sa.or_(
                             sa.and_(
-                                cls.start_time >= make_utc(min_time),
-                                cls.start_time < make_utc(max_time),
+                                cls.start_time >= app.make_utc(min_time),
+                                cls.start_time < app.make_utc(max_time),
                             ),
                             sa.and_(
                                 cls.start_time == None,
-                                cls.end_time >= make_utc(min_time),
-                                cls.end_time < make_utc(max_time),
+                                cls.end_time >= app.make_utc(min_time),
+                                cls.end_time < app.make_utc(max_time),
                             )))\
                         .all()
 
diff --git a/tailbone/views/tables.py b/tailbone/views/tables.py
index 962dbf50..bfd52f2b 100644
--- a/tailbone/views/tables.py
+++ b/tailbone/views/tables.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.
 #
@@ -80,7 +80,7 @@ class TableView(MasterView):
     ]
 
     def __init__(self, request):
-        super(TableView, self).__init__(request)
+        super().__init__(request)
         app = self.get_rattail_app()
         self.db_handler = app.get_db_handler()
 
@@ -102,7 +102,7 @@ class TableView(MasterView):
                 for row in result]
 
     def configure_grid(self, g):
-        super(TableView, self).configure_grid(g)
+        super().configure_grid(g)
 
         # table_name
         g.sorters['table_name'] = g.make_simple_sorter('table_name', foldcase=True)
@@ -114,7 +114,7 @@ class TableView(MasterView):
         g.sorters['row_count'] = g.make_simple_sorter('row_count')
 
     def configure_form(self, f):
-        super(TableView, self).configure_form(f)
+        super().configure_form(f)
 
         # TODO: should render this instead, by inspecting table
         if not self.creating:
@@ -169,7 +169,7 @@ class TableView(MasterView):
         return TableSchema()
 
     def get_xref_buttons(self, table):
-        buttons = super(TableView, self).get_xref_buttons(table)
+        buttons = super().get_xref_buttons(table)
 
         if table.get('model_name'):
             all_views = self.request.registry.settings['tailbone_model_views']
@@ -182,15 +182,15 @@ class TableView(MasterView):
             if self.request.has_perm('model_views.create'):
                 url = self.request.route_url('model_views.create',
                                              _query={'model_name': table['model_name']})
-                buttons.append(self.make_buefy_button("New View",
-                                                      is_primary=True,
-                                                      url=url,
-                                                      icon_left='plus'))
+                buttons.append(self.make_button("New View",
+                                                is_primary=True,
+                                                url=url,
+                                                icon_left='plus'))
 
         return buttons
 
     def template_kwargs_create(self, **kwargs):
-        kwargs = super(TableView, self).template_kwargs_create(**kwargs)
+        kwargs = super().template_kwargs_create(**kwargs)
         app = self.get_rattail_app()
         model = self.model
 
@@ -301,7 +301,7 @@ class TableView(MasterView):
         return data
 
     def configure_row_grid(self, g):
-        super(TableView, self).configure_row_grid(g)
+        super().configure_row_grid(g)
 
         g.sorters['sequence'] = g.make_simple_sorter('sequence')
         g.set_sort_defaults('sequence')
@@ -419,7 +419,7 @@ class TablesView(TableView):
     def __init__(self, request):
         warnings.warn("TablesView is deprecated; please use TableView instead",
                       DeprecationWarning, stacklevel=2)
-        super(TablesView, self).__init__(request)
+        super().__init__(request)
 
 
 class TableSchema(colander.Schema):
diff --git a/tailbone/views/tempmon/core.py b/tailbone/views/tempmon/core.py
index 62ace028..98fe9199 100644
--- a/tailbone/views/tempmon/core.py
+++ b/tailbone/views/tempmon/core.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.
 #
@@ -98,4 +98,4 @@ class MasterView(views.MasterView):
             main_actions=actions,
         )
         return HTML.literal(
-            g.render_buefy_table_element(data_prop='probesData'))
+            g.render_table_element(data_prop='probesData'))
diff --git a/tailbone/views/trainwreck/base.py b/tailbone/views/trainwreck/base.py
index 82c5c163..1e273c87 100644
--- a/tailbone/views/trainwreck/base.py
+++ b/tailbone/views/trainwreck/base.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.
 #
@@ -202,7 +202,7 @@ class TransactionView(MasterView):
             return 'warning'
 
     def configure_form(self, f):
-        super(TransactionView, self).configure_form(f)
+        super().configure_form(f)
 
         # system
         f.set_enum('system', self.enum.TRAINWRECK_SYSTEM)
@@ -240,10 +240,10 @@ class TransactionView(MasterView):
             request=self.request)
 
         return HTML.literal(
-            g.render_buefy_table_element(data_prop='custorderXrefMarkersData'))
+            g.render_table_element(data_prop='custorderXrefMarkersData'))
 
     def template_kwargs_view(self, **kwargs):
-        kwargs = super(TransactionView, self).template_kwargs_view(**kwargs)
+        kwargs = super().template_kwargs_view(**kwargs)
 
         form = kwargs['form']
         if 'custorder_xref_markers' in form:
@@ -266,7 +266,7 @@ class TransactionView(MasterView):
         return item.transaction
 
     def configure_row_grid(self, g):
-        super(TransactionView, self).configure_row_grid(g)
+        super().configure_row_grid(g)
         g.set_sort_defaults('sequence')
 
         g.set_type('unit_quantity', 'quantity')
@@ -286,7 +286,7 @@ class TransactionView(MasterView):
         return "Trainwreck Line Item"
 
     def configure_row_form(self, f):
-        super(TransactionView, self).configure_row_form(f)
+        super().configure_row_form(f)
 
         # transaction
         f.set_renderer('transaction', self.render_transaction)
@@ -325,7 +325,7 @@ class TransactionView(MasterView):
             request=self.request)
 
         return HTML.literal(
-            g.render_buefy_table_element(data_prop='discountsData'))
+            g.render_table_element(data_prop='discountsData'))
 
     def template_kwargs_view_row(self, **kwargs):
         form = kwargs['form']
@@ -401,7 +401,7 @@ class TransactionView(MasterView):
         ]
 
     def configure_get_context(self):
-        context = super(TransactionView, self).configure_get_context()
+        context = super().configure_get_context()
 
         app = self.get_rattail_app()
         trainwreck_handler = app.get_trainwreck_handler()
@@ -415,7 +415,7 @@ class TransactionView(MasterView):
         return context
 
     def configure_gather_settings(self, data):
-        settings = super(TransactionView, self).configure_gather_settings(data)
+        settings = super().configure_gather_settings(data)
 
         app = self.get_rattail_app()
         trainwreck_handler = app.get_trainwreck_handler()
@@ -432,7 +432,7 @@ class TransactionView(MasterView):
         return settings
 
     def configure_remove_settings(self):
-        super(TransactionView, self).configure_remove_settings()
+        super().configure_remove_settings()
         app = self.get_rattail_app()
 
         names = [
diff --git a/tailbone/views/users.py b/tailbone/views/users.py
index 833c6cf5..1501795f 100644
--- a/tailbone/views/users.py
+++ b/tailbone/views/users.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.
 #
@@ -84,7 +84,7 @@ class UserView(PrincipalMasterView):
     ]
 
     def __init__(self, request):
-        super(UserView, self).__init__(request)
+        super().__init__(request)
         app = self.get_rattail_app()
 
         # always get a reference to the auth/merge handler
@@ -92,7 +92,7 @@ class UserView(PrincipalMasterView):
         self.merge_handler = self.auth_handler
 
     def query(self, session):
-        query = super(UserView, self).query(session)
+        query = super().query(session)
         model = self.model
 
         # bring in the related Person(s)
@@ -102,7 +102,7 @@ class UserView(PrincipalMasterView):
         return query
 
     def configure_grid(self, g):
-        super(UserView, self).configure_grid(g)
+        super().configure_grid(g)
         model = self.model
 
         del g.filters['salt']
@@ -177,7 +177,7 @@ class UserView(PrincipalMasterView):
                 raise colander.Invalid(node, "Person not found (you must *select* a record)")
 
     def configure_form(self, f):
-        super(UserView, self).configure_form(f)
+        super().configure_form(f)
         model = self.model
         user = f.model_instance
 
@@ -290,12 +290,12 @@ class UserView(PrincipalMasterView):
                 self.make_action('delete', icon='trash',
                                  click_handler="$emit('api-token-delete', props.row)")])
 
-        button = self.make_buefy_button("New", is_primary=True,
-                                        icon_left='plus',
-                                        **{'@click': "$emit('api-new-token')"})
+        button = self.make_button("New", is_primary=True,
+                                  icon_left='plus',
+                                  **{'@click': "$emit('api-new-token')"})
 
         table = HTML.literal(
-            g.render_buefy_table_element(data_prop='apiTokens'))
+            g.render_table_element(data_prop='apiTokens'))
 
         return HTML.tag('div', c=[button, table])
 
@@ -329,7 +329,7 @@ class UserView(PrincipalMasterView):
                 'tokens': self.get_api_tokens(user)}
 
     def template_kwargs_view(self, **kwargs):
-        kwargs = super(UserView, self).template_kwargs_view(**kwargs)
+        kwargs = super().template_kwargs_view(**kwargs)
         user = kwargs['instance']
 
         kwargs['api_tokens_data'] = self.get_api_tokens(user)
@@ -377,7 +377,7 @@ class UserView(PrincipalMasterView):
         # create/update user as per normal
         if data is None:
             data = form.validated
-        user = super(UserView, self).objectify(form, data)
+        user = super().objectify(form, data)
 
         # create/update person as needed
         names = {}
@@ -487,7 +487,7 @@ class UserView(PrincipalMasterView):
                            .filter(model.UserEvent.user == user)
 
     def configure_row_grid(self, g):
-        super(UserView, self).configure_row_grid(g)
+        super().configure_row_grid(g)
         g.width = 'half'
         g.filterable = False
         g.set_sort_defaults('occurred', 'desc')
@@ -588,7 +588,7 @@ class UserView(PrincipalMasterView):
                                           'themes.style.{}'.format(name))
             if css:
                 options.append({'value': css, 'label': name})
-        context['buefy_css_options'] = options
+        context['theme_style_options'] = options
 
         return context
 
@@ -699,12 +699,12 @@ class UserEventView(MasterView):
     ]
 
     def get_data(self, session=None):
-        query = super(UserEventView, self).get_data(session=session)
+        query = super().get_data(session=session)
         model = self.model
         return query.join(model.User)
 
     def configure_grid(self, g):
-        super(UserEventView, self).configure_grid(g)
+        super().configure_grid(g)
         model = self.model
         g.set_joiner('person', lambda q: q.outerjoin(model.Person))
         g.set_sorter('user', model.User.username)

From ba521abf4f5d123b5b82f17a730e0d8d886655b6 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 14 Apr 2024 20:30:52 -0500
Subject: [PATCH 214/542] Remove some references to "buefy" name within
 docstrings, comments

---
 tailbone/forms/core.py                  |  8 ++++----
 tailbone/forms/widgets.py               | 10 +++++-----
 tailbone/static/css/grids.rowstatus.css |  2 +-
 tailbone/templates/customers/view.mako  |  4 +---
 tailbone/templates/roles/view.mako      |  4 +---
 tailbone/views/settings.py              |  3 +--
 6 files changed, 13 insertions(+), 18 deletions(-)

diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py
index 9ef8cb2b..a5ab3355 100644
--- a/tailbone/forms/core.py
+++ b/tailbone/forms/core.py
@@ -968,10 +968,10 @@ class Form(object):
 
     def render_field_complete(self, fieldname, bfield_attrs={}):
         """
-        Render the given field in a Buefy-compatible way.  Note that
-        this is meant to render *editable* fields, i.e. showing a
-        widget, unless the field input is hidden.  In other words it's
-        not for "readonly" fields.
+        Render the given field completely, i.e. with ``<b-field>``
+        wrapper.  Note that this is meant to render *editable* fields,
+        i.e. showing a widget, unless the field input is hidden.  In
+        other words it's not for "readonly" fields.
         """
         dform = self.make_deform_form()
         field = dform[fieldname] if fieldname in dform else None
diff --git a/tailbone/forms/widgets.py b/tailbone/forms/widgets.py
index 63813452..6b74798c 100644
--- a/tailbone/forms/widgets.py
+++ b/tailbone/forms/widgets.py
@@ -57,11 +57,11 @@ class NumberInputWidget(dfwidget.TextInputWidget):
 
 class NumericInputWidget(NumberInputWidget):
     """
-    This widget only supports Buefy themes for now.  It uses a
-    ``<numeric-input>`` component, which will leverage the ``numeric.js``
-    functions to ensure user doesn't enter any non-numeric values.  Note that
-    this still uses a normal "text" input on the HTML side, as opposed to a
-    "number" input, since the latter is a bit ugly IMHO.
+    This widget uses a ``<numeric-input>`` component, which will
+    leverage the ``numeric.js`` functions to ensure user doesn't enter
+    any non-numeric values.  Note that this still uses a normal "text"
+    input on the HTML side, as opposed to a "number" input, since the
+    latter is a bit ugly IMHO.
     """
     template = 'numericinput'
     allow_enter = True
diff --git a/tailbone/static/css/grids.rowstatus.css b/tailbone/static/css/grids.rowstatus.css
index 9335b827..bfd73404 100644
--- a/tailbone/static/css/grids.rowstatus.css
+++ b/tailbone/static/css/grids.rowstatus.css
@@ -2,7 +2,7 @@
 /********************************************************************************
  * grids.rowstatus.css
  *
- * Add "row status" styles for Buefy grid tables.
+ * Add "row status" styles for grid tables.
  ********************************************************************************/
 
 /**************************************************
diff --git a/tailbone/templates/customers/view.mako b/tailbone/templates/customers/view.mako
index 2fa7c417..8b07bdb3 100644
--- a/tailbone/templates/customers/view.mako
+++ b/tailbone/templates/customers/view.mako
@@ -28,9 +28,7 @@
     % endif
 
     ThisPage.methods.detachPerson = function(url) {
-        ## TODO: this should require POST, but we will add that once
-        ## we can assume a Buefy theme is present, to avoid having to
-        ## implement the logic in old jquery...
+        ## TODO: this should require POST! but for now we just redirect..
         if (confirm("Are you sure you want to detach this person from this customer account?")) {
             location.href = url
         }
diff --git a/tailbone/templates/roles/view.mako b/tailbone/templates/roles/view.mako
index 5dcd9408..0f4ce472 100644
--- a/tailbone/templates/roles/view.mako
+++ b/tailbone/templates/roles/view.mako
@@ -15,9 +15,7 @@
     % endif
 
     ThisPage.methods.detachPerson = function(url) {
-        ## TODO: this should require POST, but we will add that once
-        ## we can assume a Buefy theme is present, to avoid having to
-        ## implement the logic in old jquery...
+        ## TODO: this should require POST! but for now we just redirect..
         if (confirm("Are you sure you want to detach this person from this customer account?")) {
             location.href = url
         }
diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py
index 46e4c02b..679f170c 100644
--- a/tailbone/views/settings.py
+++ b/tailbone/views/settings.py
@@ -341,8 +341,7 @@ class AppSettingsView(View):
 
             # specify error / message if applicable
             # TODO: not entirely clear to me why some field errors are
-            # represented differently?  presumably it depends on
-            # whether Buefy is used by the theme.
+            # represented differently?
             if field.error:
                 s['error'] = True
                 if isinstance(field.error, colander.Invalid):

From d4089fbc6eac3d42dff09e07635df7a86c988def Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 14 Apr 2024 20:56:11 -0500
Subject: [PATCH 215/542] Some more tweaks to remove "buefy" references

mostly just docstring / comments but there were some code changes too
---
 tailbone/grids/core.py                 |  6 ++---
 tailbone/subscribers.py                |  5 +---
 tailbone/templates/grids/complete.mako |  3 ---
 tailbone/views/batch/core.py           |  2 +-
 tailbone/views/batch/importer.py       |  4 +--
 tailbone/views/customers.py            | 24 ++++++++++--------
 tailbone/views/messages.py             |  2 +-
 tailbone/views/people.py               |  2 +-
 tailbone/views/products.py             |  4 +--
 tailbone/views/upgrades.py             | 35 +++++++++++++-------------
 10 files changed, 42 insertions(+), 45 deletions(-)

diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py
index b2f90204..f905659e 100644
--- a/tailbone/grids/core.py
+++ b/tailbone/grids/core.py
@@ -1335,7 +1335,7 @@ class Grid(object):
 
     def render_complete(self, template='/grids/complete.mako', **kwargs):
         """
-        Render the Buefy grid, complete with filters.  Note that this also
+        Render the grid, complete with filters.  Note that this also
         includes the context menu items and grid tools.
         """
         if 'grid_columns' not in kwargs:
@@ -1437,7 +1437,7 @@ class Grid(object):
 
     def get_filters_data(self):
         """
-        Returns a dict of current filters data, for use with Buefy grid view.
+        Returns a dict of current filters data, for use with index view.
         """
         data = {}
         for filtr in self.filters.values():
@@ -1703,7 +1703,7 @@ class Grid(object):
     def set_action_urls(self, row, rowobj, i):
         """
         Pre-generate all action URLs for the given data row.  Meant for use
-        with Buefy table, since we can't generate URLs from JS.
+        with client-side table, since we can't generate URLs from JS.
         """
         for action in (self.main_actions + self.more_actions):
             url = action.get_url(rowobj, i)
diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py
index d05b8bd5..dce8b3ba 100644
--- a/tailbone/subscribers.py
+++ b/tailbone/subscribers.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.
 #
@@ -178,9 +178,6 @@ def before_render(event):
             renderer_globals['background_color'] = request.rattail_config.get(
                 'tailbone', 'background_color')
 
-        # TODO: remove this hack once nothing references it
-        renderer_globals['buefy_0_8'] = False
-
         # maybe set custom stylesheet
         css = None
         if request.user:
diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako
index 73c7e415..f9e665dc 100644
--- a/tailbone/templates/grids/complete.mako
+++ b/tailbone/templates/grids/complete.mako
@@ -171,9 +171,6 @@
        :loading="loading"
        :row-class="getRowClass"
 
-       ## TODO: this should be more configurable, maybe auto-detect based
-       ## on buefy version??  probably cannot do that, but this feature
-       ## is only supported with buefy 0.8.13 and newer
        % if request.rattail_config.getbool('tailbone', 'sticky_headers'):
        sticky-header
        height="600px"
diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py
index 46bdbb17..4df3d911 100644
--- a/tailbone/views/batch/core.py
+++ b/tailbone/views/batch/core.py
@@ -202,7 +202,7 @@ class BatchMasterView(MasterView):
                           action_url=action_url,
                           component='upload-worksheet-form')
         form.set_type('worksheet_file', 'file')
-        # TODO: must set these to avoid some default Buefy code
+        # TODO: must set these to avoid some default code
         form.auto_disable = False
         form.auto_disable_save = False
         return form
diff --git a/tailbone/views/batch/importer.py b/tailbone/views/batch/importer.py
index 962093da..ea4e1c74 100644
--- a/tailbone/views/batch/importer.py
+++ b/tailbone/views/batch/importer.py
@@ -145,9 +145,7 @@ class ImporterBatchView(BatchMasterView):
         make_filter('object_key')
         make_filter('object_str')
 
-        # for some reason we have to do this differently for Buefy?
-        kwargs = {}
-        make_filter('status_code', label="Status", **kwargs)
+        make_filter('status_code', label="Status")
         g.filters['status_code'].set_choices(self.enum.IMPORTER_BATCH_ROW_STATUS)
 
         def make_sorter(field):
diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py
index dcd0e943..2958a98a 100644
--- a/tailbone/views/customers.py
+++ b/tailbone/views/customers.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.
 #
@@ -37,14 +37,14 @@ from tailbone import grids
 from tailbone.db import Session
 from tailbone.views import MasterView
 
-from rattail.db import model
+from rattail.db.model import Customer, CustomerShopper, PendingCustomer
 
 
 class CustomerView(MasterView):
     """
     Master view for the Customer class.
     """
-    model_class = model.Customer
+    model_class = Customer
     is_contact = True
     has_versions = True
     results_downloadable = True
@@ -251,6 +251,7 @@ class CustomerView(MasterView):
             if instance:
                 return instance
 
+        model = self.model
         key = self.request.matchdict['uuid']
 
         # search by Customer.id
@@ -270,7 +271,7 @@ class CustomerView(MasterView):
         if instance:
             return instance.customer
 
-        raise HTTPNotFound
+        raise self.notfound()
 
     def configure_form(self, f):
         super().configure_form(f)
@@ -436,6 +437,7 @@ class CustomerView(MasterView):
         return kwargs
 
     def unique_id(self, node, value):
+        model = self.model
         query = self.Session.query(model.Customer)\
                             .filter(model.Customer.id == value)
         if self.editing:
@@ -545,6 +547,7 @@ class CustomerView(MasterView):
 
     def get_version_child_classes(self):
         classes = super().get_version_child_classes()
+        model = self.model
         classes.extend([
             (model.CustomerGroupAssignment, 'customer_uuid'),
             (model.CustomerPhoneNumber, 'parent_uuid'),
@@ -556,6 +559,7 @@ class CustomerView(MasterView):
         return classes
 
     def detach_person(self):
+        model = self.model
         customer = self.get_instance()
         person = self.Session.get(model.Person, self.request.matchdict['person_uuid'])
         if not person:
@@ -651,9 +655,7 @@ class CustomerView(MasterView):
             config.add_tailbone_permission(permission_prefix,
                                            '{}.detach_person'.format(permission_prefix),
                                            "Detach a Person from a {}".format(model_title))
-            # TODO: this should require POST, but we'll add that once
-            # we can assume a Buefy theme is present, to avoid having
-            # to implement the logic in old jquery...
+            # TODO: this should require POST!
             config.add_route('{}.detach_person'.format(route_prefix),
                              '{}/detach-person/{{person_uuid}}'.format(instance_url_prefix),
                              # request_method='POST',
@@ -667,7 +669,7 @@ class CustomerShopperView(MasterView):
     """
     Master view for the CustomerShopper class.
     """
-    model_class = model.CustomerShopper
+    model_class = CustomerShopper
     route_prefix = 'customer_shoppers'
     url_prefix = '/customer-shoppers'
 
@@ -748,7 +750,7 @@ class PendingCustomerView(MasterView):
     """
     Master view for the Pending Customer class.
     """
-    model_class = model.PendingCustomer
+    model_class = PendingCustomer
     route_prefix = 'pending_customers'
     url_prefix = '/customers/pending'
 
@@ -877,7 +879,7 @@ class PendingCustomerView(MasterView):
 # TODO: this only works when creating, need to add edit support?
 # TODO: can this just go away? since we have unique_id() view method above
 def unique_id(node, value):
-    customers = Session.query(model.Customer).filter(model.Customer.id == value)
+    customers = Session.query(Customer).filter(Customer.id == value)
     if customers.count():
         raise colander.Invalid(node, "Customer ID must be unique")
 
@@ -886,6 +888,8 @@ def customer_info(request):
     """
     View which returns simple dictionary of info for a particular customer.
     """
+    app = request.rattail_config.get_app()
+    model = app.model
     uuid = request.params.get('uuid')
     customer = Session.get(model.Customer, uuid) if uuid else None
     if not customer:
diff --git a/tailbone/views/messages.py b/tailbone/views/messages.py
index bf460436..ae050784 100644
--- a/tailbone/views/messages.py
+++ b/tailbone/views/messages.py
@@ -516,7 +516,7 @@ class SentView(MessageView):
 
 class RecipientsWidgetBuefy(dfwidget.Widget):
     """
-    Custom "message recipients" widget, for use with Buefy / Vue.js themes.
+    Custom "message recipients" widget, for use with Vue.js themes.
     """
     template = 'message_recipients'
 
diff --git a/tailbone/views/people.py b/tailbone/views/people.py
index 7b175e25..d8e36ec9 100644
--- a/tailbone/views/people.py
+++ b/tailbone/views/people.py
@@ -1404,7 +1404,7 @@ class PersonView(MasterView):
         """
         View which locates and organizes all relevant "transaction"
         (version) history data for a given Person.  Returns JSON, for
-        use with the Buefy table element on the full profile view.
+        use with the table element on the full profile view.
         """
         person = self.get_instance()
         versions = self.profile_revisions_collect(person)
diff --git a/tailbone/views/products.py b/tailbone/views/products.py
index 1a928d67..788cc24d 100644
--- a/tailbone/views/products.py
+++ b/tailbone/views/products.py
@@ -1800,8 +1800,8 @@ class ProductView(MasterView):
     def search(self):
         """
         Perform a product search across multiple fields, and return
-        the results as JSON suitable for row data for a Buefy
-        ``<b-table>`` component.
+        the results as JSON suitable for row data for a table
+        component.
         """
         if 'term' not in self.request.GET:
             # TODO: deprecate / remove this?  not sure if/where it is used
diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py
index f7c83eec..a281062e 100644
--- a/tailbone/views/upgrades.py
+++ b/tailbone/views/upgrades.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.
 #
@@ -33,9 +33,7 @@ from collections import OrderedDict
 
 import sqlalchemy as sa
 
-from rattail.core import Object
-from rattail.db import model, Session as RattailSession
-from rattail.time import make_utc
+from rattail.db.model import Upgrade
 from rattail.threads import Thread
 
 from deform import widget as dfwidget
@@ -53,7 +51,7 @@ class UpgradeView(MasterView):
     """
     Master view for all user events
     """
-    model_class = model.Upgrade
+    model_class = Upgrade
     downloadable = True
     cloneable = True
     configurable = True
@@ -100,7 +98,7 @@ class UpgradeView(MasterView):
     ]
 
     def __init__(self, request):
-        super(UpgradeView, self).__init__(request)
+        super().__init__(request)
 
         if hasattr(self, 'get_handler'):
             warnings.warn("defining get_handler() is deprecated.  please "
@@ -120,7 +118,8 @@ class UpgradeView(MasterView):
         return self.upgrade_handler
 
     def configure_grid(self, g):
-        super(UpgradeView, self).configure_grid(g)
+        super().configure_grid(g)
+        model = self.model
 
         # system
         systems = self.upgrade_handler.get_all_systems()
@@ -147,7 +146,8 @@ class UpgradeView(MasterView):
             return 'notice'
 
     def template_kwargs_view(self, **kwargs):
-        kwargs = super(UpgradeView, self).template_kwargs_view(**kwargs)
+        kwargs = super().template_kwargs_view(**kwargs)
+        model = self.model
         upgrade = kwargs['instance']
 
         kwargs['system_title'] = self.rattail_config.app_title()
@@ -177,7 +177,7 @@ class UpgradeView(MasterView):
         return kwargs
 
     def configure_form(self, f):
-        super(UpgradeView, self).configure_form(f)
+        super().configure_form(f)
         upgrade = f.model_instance
 
         # system
@@ -275,9 +275,10 @@ class UpgradeView(MasterView):
         f.fields = ['system', 'description', 'notes', 'enabled']
 
     def clone_instance(self, original):
+        app = self.get_rattail_app()
         cloned = self.model_class()
         cloned.system = original.system
-        cloned.created = make_utc()
+        cloned.created = app.make_utc()
         cloned.created_by = self.request.user
         cloned.description = original.description
         cloned.notes = original.notes
@@ -335,7 +336,6 @@ class UpgradeView(MasterView):
             return HTML.tag('div', c="(not available for this upgrade)")
 
     def get_extra_diff_row_attrs(self, field, attrs):
-        # note, this is only needed/used with Buefy
         extra = {}
         if attrs.get('class') != 'diff':
             extra['v-show'] = "showingPackages == 'all'"
@@ -449,13 +449,14 @@ class UpgradeView(MasterView):
         return packages
 
     def parse_requirement(self, line):
+        app = self.get_rattail_app()
         match = re.match(r'^.*@(.*)#egg=(.*)$', line)
         if match:
-            return Object(name=match.group(2), version=match.group(1))
+            return app.make_object(name=match.group(2), version=match.group(1))
 
         match = re.match(r'^(.*)==(.*)$', line)
         if match:
-            return Object(name=match.group(1), version=match.group(2))
+            return app.make_object(name=match.group(1), version=match.group(2))
 
     def download_path(self, upgrade, filename):
         return self.rattail_config.upgrade_filepath(upgrade.uuid, filename=filename)
@@ -537,17 +538,17 @@ class UpgradeView(MasterView):
 
     def delete_instance(self, upgrade):
         self.handler.delete_files(upgrade)
-        super(UpgradeView, self).delete_instance(upgrade)
+        super().delete_instance(upgrade)
 
     def configure_get_context(self, **kwargs):
-        context = super(UpgradeView, self).configure_get_context(**kwargs)
+        context = super().configure_get_context(**kwargs)
 
         context['upgrade_systems'] = self.upgrade_handler.get_all_systems()
 
         return context
 
     def configure_gather_settings(self, data):
-        settings = super(UpgradeView, self).configure_gather_settings(data)
+        settings = super().configure_gather_settings(data)
 
         keys = []
         for system in json.loads(data['upgrade_systems']):
@@ -568,7 +569,7 @@ class UpgradeView(MasterView):
         return settings
 
     def configure_remove_settings(self):
-        super(UpgradeView, self).configure_remove_settings()
+        super().configure_remove_settings()
         app = self.get_rattail_app()
         model = self.model
 

From 2f115c07170cfc995097f4d92026a354b9e75ecd Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 15 Apr 2024 10:56:49 -0500
Subject: [PATCH 216/542] Update changelog

---
 CHANGES.rst          | 7 ++++++-
 tailbone/_version.py | 2 +-
 2 files changed, 7 insertions(+), 2 deletions(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 1edf8a2a..aa4e3d8f 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -5,6 +5,10 @@ CHANGELOG
 Unreleased
 ----------
 
+
+0.9.91 (2024-04-15)
+-------------------
+
 * Avoid uncaught error when updating order batch row quantities.
 
 * Try to return JSON error when receiving API call fails.
@@ -13,7 +17,8 @@ Unreleased
 
 * Show toast msg instead of silent error, when grid fetch fails.
 
-* Rename template files to avoid "buefy" names.
+* Remove most references to "buefy" name in class methods, template
+  filenames etc.
 
 
 0.9.90 (2024-04-01)
diff --git a/tailbone/_version.py b/tailbone/_version.py
index cff6f04f..b78f76b7 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.90'
+__version__ = '0.9.91'

From 666c16b74eb8d091bf37668a42cdea2d2af19801 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 15 Apr 2024 10:58:16 -0500
Subject: [PATCH 217/542] Fix default dist filename for release task

not sure why this fix was needed, did setuptools behavior change?
---
 tasks.py | 6 ++----
 1 file changed, 2 insertions(+), 4 deletions(-)

diff --git a/tasks.py b/tasks.py
index 48b51b39..fba0b699 100644
--- a/tasks.py
+++ b/tasks.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,8 +24,6 @@
 Tasks for Tailbone
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import os
 import shutil
 
@@ -47,4 +45,4 @@ def release(c, tests=False):
     if os.path.exists('Tailbone.egg-info'):
         shutil.rmtree('Tailbone.egg-info')
     c.run('python -m build --sdist')
-    c.run('twine upload dist/Tailbone-{}.tar.gz'.format(__version__))
+    c.run(f'twine upload dist/tailbone-{__version__}.tar.gz')

From d0d568b3a55f8e3ec8e699c4b2239b4b99871f97 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 15 Apr 2024 12:44:46 -0500
Subject: [PATCH 218/542] Escape underscore char for "contains" query filter

since underscore has special meaning for LIKE clause
---
 tailbone/grids/filters.py | 50 ++++++++++++++++++++++++++-------------
 1 file changed, 34 insertions(+), 16 deletions(-)

diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py
index f70670b6..3b198614 100644
--- a/tailbone/grids/filters.py
+++ b/tailbone/grids/filters.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.
 #
@@ -484,9 +484,13 @@ class AlchemyStringFilter(AlchemyGridFilter):
         """
         if value is None or value == '':
             return query
-        return query.filter(sa.and_(
-            *[self.column.ilike(self.encode_value('%{}%'.format(v)))
-              for v in value.split()]))
+
+        criteria = []
+        for val in value.split():
+            val = val.replace('_', r'\_')
+            val = self.encode_value(f'%{val}%')
+            criteria.append(self.column.ilike(val))
+        return query.filter(sa.and_(*criteria))
 
     def filter_does_not_contain(self, query, value):
         """
@@ -495,14 +499,17 @@ class AlchemyStringFilter(AlchemyGridFilter):
         if value is None or value == '':
             return query
 
+        criteria = []
+        for val in value.split():
+            val = val.replace('_', r'\_')
+            val = self.encode_value(f'%{val}%')
+            criteria.append(~self.column.ilike(val))
+
         # When saying something is 'not like' something else, we must also
         # include things which are nothing at all, in our result set.
         return query.filter(sa.or_(
             self.column == None,
-            sa.and_(
-                *[~self.column.ilike(self.encode_value('%{}%'.format(v)))
-                  for v in value.split()]),
-        ))
+            sa.and_(*criteria)))
 
     def filter_contains_any_of(self, query, value):
         """
@@ -531,9 +538,12 @@ class AlchemyStringFilter(AlchemyGridFilter):
 
         conditions = []
         for value in values:
-            conditions.append(sa.and_(
-                *[self.column.ilike(self.encode_value('%{}%'.format(v)))
-                  for v in value.split()]))
+            criteria = []
+            for val in value.split():
+                val = val.replace('_', r'\_')
+                val = self.encode_value(f'%{val}%')
+                criteria.append(self.column.ilike(val))
+            conditions.append(sa.and_(*criteria))
 
         return query.filter(sa.or_(*conditions))
 
@@ -588,8 +598,13 @@ class AlchemyByteStringFilter(AlchemyStringFilter):
         """
         if value is None or value == '':
             return query
-        return query.filter(sa.and_(
-            *[self.column.ilike(b'%{}%'.format(v)) for v in value.split()]))
+
+        criteria = []
+        for val in value.split():
+            val = val.replace('_', r'\_')
+            val = b'%{}%'.format(val)
+            criteria.append(self.column.ilike(val))
+        return query.filters(sa.and_(*criteria))
 
     def filter_does_not_contain(self, query, value):
         """
@@ -598,13 +613,16 @@ class AlchemyByteStringFilter(AlchemyStringFilter):
         if value is None or value == '':
             return query
 
+        for val in value.split():
+            val = val.replace('_', '\_')
+            val = b'%{}%'.format(val)
+            criteria.append(~self.column.ilike(val))
+
         # When saying something is 'not like' something else, we must also
         # include things which are nothing at all, in our result set.
         return query.filter(sa.or_(
             self.column == None,
-            sa.and_(
-                *[~self.column.ilike(b'%{}%'.format(v)) for v in value.split()]),
-        ))
+            sa.and_(*criteria)))
 
 
 class AlchemyNumericFilter(AlchemyGridFilter):

From 52c8f3e12c68fc981d4bdab2bb3c961bb0534961 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 15 Apr 2024 13:02:13 -0500
Subject: [PATCH 219/542] Rename custom `user_css` context

and stop checking an older deprecated setting
---
 tailbone/subscribers.py      | 4 +---
 tailbone/templates/base.mako | 5 ++---
 tailbone/views/messages.py   | 4 ++--
 3 files changed, 5 insertions(+), 8 deletions(-)

diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py
index dce8b3ba..33a9d749 100644
--- a/tailbone/subscribers.py
+++ b/tailbone/subscribers.py
@@ -183,9 +183,7 @@ def before_render(event):
         if request.user:
             css = request.rattail_config.get('tailbone.{}'.format(request.user.uuid),
                                              'buefy_css')
-        if not css:
-            css = request.rattail_config.get('tailbone', 'theme.falafel.buefy_css')
-        renderer_globals['buefy_css'] = css
+        renderer_globals['user_css'] = css
 
         # add global search data for quick access
         renderer_globals['global_search_data'] = get_global_search_options(request)
diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako
index 2a42af0b..e1020b28 100644
--- a/tailbone/templates/base.mako
+++ b/tailbone/templates/base.mako
@@ -162,9 +162,8 @@
 </%def>
 
 <%def name="buefy_styles()">
-  % if buefy_css:
-      ## custom Buefy CSS
-      ${h.stylesheet_link(buefy_css)}
+  % if user_css:
+      ${h.stylesheet_link(user_css)}
   % else:
       ## upstream Buefy CSS
       ${h.stylesheet_link(h.get_liburl(request, 'buefy.css'))}
diff --git a/tailbone/views/messages.py b/tailbone/views/messages.py
index ae050784..9199c025 100644
--- a/tailbone/views/messages.py
+++ b/tailbone/views/messages.py
@@ -241,7 +241,7 @@ class MessageView(MasterView):
             f.insert_after('recipients', 'set_recipients')
             f.remove('recipients')
             f.set_node('set_recipients', colander.SchemaNode(colander.Set()))
-            f.set_widget('set_recipients', RecipientsWidgetBuefy())
+            f.set_widget('set_recipients', RecipientsWidget())
             f.set_label('set_recipients', "To")
 
             if self.replying:
@@ -514,7 +514,7 @@ class SentView(MessageView):
                                                 default_active=True, default_verb='contains')
 
 
-class RecipientsWidgetBuefy(dfwidget.Widget):
+class RecipientsWidget(dfwidget.Widget):
     """
     Custom "message recipients" widget, for use with Vue.js themes.
     """

From 85d62a8e3898fa906b9663e34bcd2deaae24f10e Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 15 Apr 2024 13:21:37 -0500
Subject: [PATCH 220/542] Reminder to improve css hack for datepicker in modal

---
 tailbone/static/css/layout.css | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/tailbone/static/css/layout.css b/tailbone/static/css/layout.css
index 20dbf6b7..0761d001 100644
--- a/tailbone/static/css/layout.css
+++ b/tailbone/static/css/layout.css
@@ -136,6 +136,12 @@ header span.header-text {
     overflow: visible !important;
 }
 
+/* TODO: a simpler option we might try sometime instead?  */
+/* cf. https://github.com/buefy/buefy/issues/292#issuecomment-1073851313 */
+
+/* .dropdown-content{ */
+/*     position: fixed; */
+/* } */
 
 /******************************
  * feedback

From 8b4b3de33683cd1ece94fc03d2646d903687dc31 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 16 Apr 2024 09:48:29 -0500
Subject: [PATCH 221/542] Add support for Pyramid 2.x; new security policy

custom apps are still free to use pyramid 1.x

new security policy is only used if config file says so
---
 setup.cfg          |  4 +--
 tailbone/app.py    | 12 ++++++--
 tailbone/auth.py   | 77 +++++++++++++++++++++++++++++++++++++++++++---
 tailbone/webapi.py | 12 ++++++--
 4 files changed, 91 insertions(+), 14 deletions(-)

diff --git a/setup.cfg b/setup.cfg
index 67541d96..2195aee9 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -49,9 +49,6 @@ install_requires =
         # TODO: remove once their bug is fixed?  idk what this is about yet...
         deform<2.0.15
 
-        # TODO: remove this cap and address warnings that follow
-        pyramid<2
-
         asgiref
         colander
         ColanderAlchemy
@@ -65,6 +62,7 @@ install_requires =
         paginate_sqlalchemy
         passlib
         Pillow
+        pyramid
         pyramid_beaker>=0.6
         pyramid_deform
         pyramid_exclog
diff --git a/tailbone/app.py b/tailbone/app.py
index ae10c9bc..abf2fa09 100644
--- a/tailbone/app.py
+++ b/tailbone/app.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.
 #
@@ -133,8 +133,14 @@ def make_pyramid_config(settings, configure_csrf=True):
     config.registry['rattail_config'] = rattail_config
 
     # configure user authorization / authentication
-    config.set_authorization_policy(TailboneAuthorizationPolicy())
-    config.set_authentication_policy(SessionAuthenticationPolicy())
+    # TODO: security policy should become the default, for pyramid 2.x
+    if rattail_config.getbool('tailbone', 'pyramid.use_security_policy',
+                              usedb=False, default=False):
+        from tailbone.auth import TailboneSecurityPolicy
+        config.set_security_policy(TailboneSecurityPolicy())
+    else:
+        config.set_authorization_policy(TailboneAuthorizationPolicy())
+        config.set_authentication_policy(SessionAuthenticationPolicy())
 
     # maybe require CSRF token protection
     if configure_csrf:
diff --git a/tailbone/auth.py b/tailbone/auth.py
index 1f057404..66deeff0 100644
--- a/tailbone/auth.py
+++ b/tailbone/auth.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,7 +27,6 @@ Authentication & Authorization
 import logging
 import re
 
-from rattail import enum
 from rattail.util import prettify, NOTSET
 
 from zope.interface import implementer
@@ -46,7 +45,8 @@ def login_user(request, user, timeout=NOTSET):
     Perform the steps necessary to login the given user.  Note that this
     returns a ``headers`` dict which you should pass to the redirect.
     """
-    user.record_event(enum.USER_EVENT_LOGIN)
+    app = request.rattail_config.get_app()
+    user.record_event(app.enum.USER_EVENT_LOGIN)
     headers = remember(request, user.uuid)
     if timeout is NOTSET:
         timeout = session_timeout_for_user(user)
@@ -60,9 +60,10 @@ def logout_user(request):
     Perform the logout action for the given request.  Note that this returns a
     ``headers`` dict which you should pass to the redirect.
     """
+    app = request.rattail_config.get_app()
     user = request.user
     if user:
-        user.record_event(enum.USER_EVENT_LOGOUT)
+        user.record_event(app.enum.USER_EVENT_LOGOUT)
     request.session.delete()
     request.session.invalidate()
     headers = forget(request)
@@ -117,7 +118,7 @@ class TailboneAuthenticationPolicy(SessionAuthenticationPolicy):
                     return user.uuid
 
         # otherwise do normal session-based logic
-        return super(TailboneAuthenticationPolicy, self).unauthenticated_userid(request)
+        return super().unauthenticated_userid(request)
 
 
 @implementer(IAuthorizationPolicy)
@@ -150,6 +151,72 @@ class TailboneAuthorizationPolicy(object):
         raise NotImplementedError
 
 
+class TailboneSecurityPolicy:
+
+    def __init__(self, api_mode=False):
+        from pyramid.authentication import SessionAuthenticationHelper
+        from pyramid.request import RequestLocalCache
+
+        self.api_mode = api_mode
+        self.session_helper = SessionAuthenticationHelper()
+        self.identity_cache = RequestLocalCache(self.load_identity)
+
+    def load_identity(self, request):
+        config = request.registry.settings.get('rattail_config')
+        app = config.get_app()
+        user = None
+
+        if self.api_mode:
+
+            # determine/load user from header token if present
+            credentials = request.headers.get('Authorization')
+            if credentials:
+                match = re.match(r'^Bearer (\S+)$', credentials)
+                if match:
+                    token = match.group(1)
+                    auth = app.get_auth_handler()
+                    user = auth.authenticate_user_token(Session(), token)
+
+        if not user:
+
+            # fetch user uuid from current session
+            uuid = self.session_helper.authenticated_userid(request)
+            if not uuid:
+                return
+
+            # fetch user object from db
+            model = app.model
+            user = Session.get(model.User, uuid)
+            if not user:
+                return
+
+        # this user is responsible for data changes in current request
+        Session().set_continuum_user(user)
+        return user
+
+    def identity(self, request):
+        return self.identity_cache.get_or_create(request)
+
+    def authenticated_userid(self, request):
+        user = self.identity(request)
+        if user is not None:
+            return user.uuid
+
+    def remember(self, request, userid, **kw):
+        return self.session_helper.remember(request, userid, **kw)
+
+    def forget(self, request, **kw):
+        return self.session_helper.forget(request, **kw)
+
+    def permits(self, request, context, permission):
+        config = request.registry.settings.get('rattail_config')
+        app = config.get_app()
+        auth = app.get_auth_handler()
+
+        user = self.identity(request)
+        return auth.has_permission(Session(), user, permission)
+
+
 def add_permission_group(config, key, label=None, overwrite=True):
     """
     Add a permission group to the app configuration.
diff --git a/tailbone/webapi.py b/tailbone/webapi.py
index 7a2c81b4..70600e79 100644
--- a/tailbone/webapi.py
+++ b/tailbone/webapi.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.
 #
@@ -50,8 +50,14 @@ def make_pyramid_config(settings):
     pyramid_config = Configurator(settings=settings, root_factory=app.Root)
 
     # configure user authorization / authentication
-    pyramid_config.set_authentication_policy(TailboneAuthenticationPolicy())
-    pyramid_config.set_authorization_policy(TailboneAuthorizationPolicy())
+    # TODO: security policy should become the default, for pyramid 2.x
+    if rattail_config.getbool('tailbone', 'pyramid.use_security_policy',
+                              usedb=False, default=False):
+        from tailbone.auth import TailboneSecurityPolicy
+        pyramid_config.set_security_policy(TailboneSecurityPolicy(api_mode=True))
+    else:
+        pyramid_config.set_authentication_policy(TailboneAuthenticationPolicy())
+        pyramid_config.set_authorization_policy(TailboneAuthorizationPolicy())
 
     # always require CSRF token protection
     pyramid_config.set_default_csrf_options(require_csrf=True,

From c35c0f8b6167c54b939b5fefcf4dc912a3f39062 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 16 Apr 2024 10:44:33 -0500
Subject: [PATCH 222/542] Update changelog

---
 CHANGES.rst          | 9 +++++++++
 tailbone/_version.py | 2 +-
 2 files changed, 10 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index aa4e3d8f..99ae8ed9 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -5,6 +5,15 @@ CHANGELOG
 Unreleased
 ----------
 
+0.9.92 (2024-04-16)
+-------------------
+
+* Escape underscore char for "contains" query filter.
+
+* Rename custom ``user_css`` context.
+
+* Add support for Pyramid 2.x; new security policy.
+
 
 0.9.91 (2024-04-15)
 -------------------
diff --git a/tailbone/_version.py b/tailbone/_version.py
index b78f76b7..701a9305 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.91'
+__version__ = '0.9.92'

From 0d9c5a078be54558796c79e79c290a0969f33314 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 16 Apr 2024 18:21:59 -0500
Subject: [PATCH 223/542] Improve form support for view supplements

this seems a bit hacky yet but works for now..

cf. field logic for Vendor -> Quickbooks Bank Accounts, which requires this
---
 tailbone/forms/core.py              | 28 +++++++++++++++++++++++++++-
 tailbone/templates/master/form.mako | 17 +++++++++++++----
 tailbone/views/master.py            | 17 +++++++++++++++--
 3 files changed, 55 insertions(+), 7 deletions(-)

diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py
index a5ab3355..9624f6fb 100644
--- a/tailbone/forms/core.py
+++ b/tailbone/forms/core.py
@@ -338,7 +338,7 @@ class Form(object):
                  assume_local_times=False, renderers=None, renderer_kwargs={},
                  hidden={}, widgets={}, defaults={}, validators={}, required={}, helptext={}, focus_spec=None,
                  action_url=None, cancel_url=None, component='tailbone-form',
-                 vuejs_component_kwargs=None, vuejs_field_converters={},
+                 vuejs_component_kwargs=None, vuejs_field_converters={}, json_data={}, included_templates={},
                  # TODO: ugh this is getting out hand!
                  can_edit_help=False, edit_help_url=None, route_prefix=None,
     ):
@@ -381,6 +381,8 @@ class Form(object):
         self.component = component
         self.vuejs_component_kwargs = vuejs_component_kwargs or {}
         self.vuejs_field_converters = vuejs_field_converters or {}
+        self.json_data = json_data or {}
+        self.included_templates = included_templates or {}
         self.can_edit_help = can_edit_help
         self.edit_help_url = edit_help_url
         self.route_prefix = route_prefix
@@ -966,6 +968,30 @@ class Form(object):
             kwargs.setdefault(':configure-fields-help', 'configureFieldsHelp')
         return HTML.tag(self.component, **kwargs)
 
+    def set_json_data(self, key, value):
+        """
+        Establish a data value for use in client-side JS.  This value
+        will be JSON-encoded and made available to the
+        `<tailbone-form>` component within the client page.
+        """
+        self.json_data[key] = value
+
+    def include_template(self, template, context):
+        """
+        Declare a JS template as required by the current form.  This
+        template will then be included in the final page, so all
+        widgets behave correctly.
+        """
+        self.included_templates[template] = context
+
+    def render_included_templates(self):
+        templates = []
+        for template, context in self.included_templates.items():
+            context = dict(context)
+            context['form'] = self
+            templates.append(HTML.literal(render(template, context)))
+        return HTML.literal('\n').join(templates)
+
     def render_field_complete(self, fieldname, bfield_attrs={}):
         """
         Render the given field completely, i.e. with ``<b-field>``
diff --git a/tailbone/templates/master/form.mako b/tailbone/templates/master/form.mako
index c142d8ef..1339bd91 100644
--- a/tailbone/templates/master/form.mako
+++ b/tailbone/templates/master/form.mako
@@ -3,8 +3,14 @@
 
 <%def name="modify_this_page_vars()">
   ${parent.modify_this_page_vars()}
-  % if master.deletable and instance_deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple':
-      <script type="text/javascript">
+  <script type="text/javascript">
+
+    ## declare extra data needed by form
+    % for key, value in form.json_data.items():
+        ${form.component_studly}Data.${key} = ${json.dumps(value)|n}
+    % endfor
+
+    % if master.deletable and instance_deletable and master.has_perm('delete') and master.delete_confirm == 'simple':
 
         ThisPage.methods.deleteObject = function() {
             if (confirm("Are you sure you wish to delete this ${model_title}?")) {
@@ -12,8 +18,11 @@
             }
         }
 
-      </script>
-  % endif
+    % endif
+  </script>
+
+  ${form.render_included_templates()}
+
 </%def>
 
 
diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index c6ce44e0..20dc0dcf 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -2695,8 +2695,15 @@ class MasterView(View):
 
         context.update(data)
         context.update(self.template_kwargs(**context))
-        if hasattr(self, 'template_kwargs_{}'.format(template)):
-            context.update(getattr(self, 'template_kwargs_{}'.format(template))(**context))
+
+        method_name = f'template_kwargs_{template}'
+        if hasattr(self, method_name):
+            context.update(getattr(self, method_name)(**context))
+        for supp in self.iter_view_supplements():
+            if hasattr(supp, 'template_kwargs'):
+                context.update(getattr(supp, 'template_kwargs')(**context))
+            if hasattr(supp, method_name):
+                context.update(getattr(supp, method_name)(**context))
 
         # First try the template path most specific to the view.
         mako_path = '{}/{}.mako'.format(self.get_template_prefix(), template)
@@ -4441,6 +4448,9 @@ class MasterView(View):
             if not self.has_perm('view_global'):
                 obj.local_only = True
 
+        for supp in self.iter_view_supplements():
+            obj = supp.objectify(obj, form, data)
+
         return obj
 
     def objectify_contact(self, contact, data):
@@ -5892,6 +5902,9 @@ class ViewSupplement(object):
         renderers, default values etc. for them.
         """
 
+    def objectify(self, obj, form, data):
+        return obj
+
     def get_xref_buttons(self, obj):
         return []
 

From b37981e83f1e39499729da468a2e00a129fc60de Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 16 Apr 2024 20:09:39 -0500
Subject: [PATCH 224/542] Prevent multi-click for grid filters "Save Defaults"
 button

---
 tailbone/templates/grids/complete.mako | 6 ++++++
 tailbone/templates/grids/filters.mako  | 5 +++--
 2 files changed, 9 insertions(+), 2 deletions(-)

diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako
index f9e665dc..205012be 100644
--- a/tailbone/templates/grids/complete.mako
+++ b/tailbone/templates/grids/complete.mako
@@ -357,6 +357,8 @@
       loading: false,
       ajaxDataUrl: ${json.dumps(grid.ajax_data_url)|n},
 
+      savingDefaults: false,
+
       data: ${grid.component_studly}CurrentData,
       rowStatusMap: ${json.dumps(grid_data['row_status_map'])|n},
 
@@ -589,6 +591,7 @@
                       this.firstItem = data.first_item
                       this.lastItem = data.last_item
                       this.loading = false
+                      this.savingDefaults = false
                       this.checkedRows = this.locateCheckedRows(data.checked_rows)
                       if (success) {
                           success()
@@ -600,6 +603,7 @@
                           duration: 2000, // 4 seconds
                       })
                       this.loading = false
+                      this.savingDefaults = false
                       if (failure) {
                           failure()
                       }
@@ -609,6 +613,7 @@
                   this.data = []
                   this.total = 0
                   this.loading = false
+                  this.savingDefaults = false
                   if (failure) {
                       failure()
                   }
@@ -805,6 +810,7 @@
           },
 
           saveDefaults() {
+              this.savingDefaults = true
 
               // apply current filters as normal, but add special directive
               this.applyFilters({'save-current-filters-as-defaults': true})
diff --git a/tailbone/templates/grids/filters.mako b/tailbone/templates/grids/filters.mako
index 5e1fef9b..4c584883 100644
--- a/tailbone/templates/grids/filters.mako
+++ b/tailbone/templates/grids/filters.mako
@@ -60,8 +60,9 @@
         <b-button @click="saveDefaults()"
                   icon-pack="fas"
                   icon-left="save"
-                  class="control">
-          Save Defaults
+                  class="control"
+                  :disabled="savingDefaults">
+          {{ savingDefaults ? "Working, please wait..." : "Save Defaults" }}
         </b-button>
     % endif
 

From 9065f42195f1d64bb02452727fb6acf44b280623 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 16 Apr 2024 20:10:10 -0500
Subject: [PATCH 225/542] Fix typo when getting app instance

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

diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py
index 9624f6fb..496d59ee 100644
--- a/tailbone/forms/core.py
+++ b/tailbone/forms/core.py
@@ -1156,7 +1156,7 @@ class Form(object):
         value = self.obtain_value(record, field_name)
         if value is None:
             return ""
-        app = self.get_rattail_app()
+        app = self.request.rattail_config.get_app()
         value = app.localtime(value)
         return raw_datetime(self.request.rattail_config, value)
 
@@ -1186,7 +1186,7 @@ class Form(object):
         value = self.obtain_value(obj, field)
         if value is None:
             return ""
-        app = self.get_rattail_app()
+        app = self.request.rattail_config.get_app()
         return app.render_quantity(value)
 
     def render_percent(self, obj, field):

From 5a7deadba221ad45b764d9eedba14a6e3b07cb69 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 16 Apr 2024 20:11:15 -0500
Subject: [PATCH 226/542] Update changelog

---
 CHANGES.rst          | 11 +++++++++++
 tailbone/_version.py |  2 +-
 2 files changed, 12 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 99ae8ed9..e028452c 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -5,6 +5,17 @@ CHANGELOG
 Unreleased
 ----------
 
+
+0.9.93 (2024-04-16)
+-------------------
+
+* Improve form support for view supplements.
+
+* Prevent multi-click for grid filters "Save Defaults" button.
+
+* Fix typo when getting app instance.
+
+
 0.9.92 (2024-04-16)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 701a9305..109cbcbd 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.92'
+__version__ = '0.9.93'

From e7b8b6e818015ecff1604e71c558d36d5095f34e Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 16 Apr 2024 21:13:53 -0500
Subject: [PATCH 227/542] Fix master template bug when no form in context

---
 tailbone/templates/master/form.mako | 12 ++++++++----
 1 file changed, 8 insertions(+), 4 deletions(-)

diff --git a/tailbone/templates/master/form.mako b/tailbone/templates/master/form.mako
index 1339bd91..dfe56fa8 100644
--- a/tailbone/templates/master/form.mako
+++ b/tailbone/templates/master/form.mako
@@ -6,9 +6,11 @@
   <script type="text/javascript">
 
     ## declare extra data needed by form
-    % for key, value in form.json_data.items():
-        ${form.component_studly}Data.${key} = ${json.dumps(value)|n}
-    % endfor
+    % if form is not Undefined:
+        % for key, value in form.json_data.items():
+            ${form.component_studly}Data.${key} = ${json.dumps(value)|n}
+        % endfor
+    % endif
 
     % if master.deletable and instance_deletable and master.has_perm('delete') and master.delete_confirm == 'simple':
 
@@ -21,7 +23,9 @@
     % endif
   </script>
 
-  ${form.render_included_templates()}
+  % if form is not Undefined:
+      ${form.render_included_templates()}
+  % endif
 
 </%def>
 

From a95cc2b9e80bc5f3eab39352dbe1674d521ad8d2 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 16 Apr 2024 21:14:23 -0500
Subject: [PATCH 228/542] Update changelog

---
 CHANGES.rst          | 5 +++++
 tailbone/_version.py | 2 +-
 2 files changed, 6 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index e028452c..ba3f2b97 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -5,6 +5,11 @@ CHANGELOG
 Unreleased
 ----------
 
+0.9.94 (2024-04-16)
+-------------------
+
+* Fix master template bug when no form in context.
+
 
 0.9.93 (2024-04-16)
 -------------------
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 109cbcbd..665e5baa 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.93'
+__version__ = '0.9.94'

From 7fa39d42e2caf156022dc55187338936e9145db8 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 16 Apr 2024 23:26:46 -0500
Subject: [PATCH 229/542] Fix ASGI websockets when serving on sub-path under
 site root

---
 CHANGES.rst      |  3 +++
 tailbone/asgi.py | 16 +++++++++-------
 2 files changed, 12 insertions(+), 7 deletions(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index ba3f2b97..7321ee2c 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -5,6 +5,9 @@ CHANGELOG
 Unreleased
 ----------
 
+* Fix ASGI websockets when serving on sub-path under site root.
+
+
 0.9.94 (2024-04-16)
 -------------------
 
diff --git a/tailbone/asgi.py b/tailbone/asgi.py
index f2146577..1afbe12a 100644
--- a/tailbone/asgi.py
+++ b/tailbone/asgi.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,14 +24,10 @@
 ASGI App Utilities
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import os
+import configparser
 import logging
 
-import six
-from six.moves import configparser
-
 from rattail.util import load_object
 
 from asgiref.wsgi import WsgiToAsgi
@@ -49,6 +45,12 @@ class TailboneWsgiToAsgi(WsgiToAsgi):
         protocol = scope['type']
         path = scope['path']
 
+        # strip off the root path, if non-empty.  needed for serving
+        # under /poser or anything other than true site root
+        root_path = scope['root_path']
+        if root_path and path.startswith(root_path):
+            path = path[len(root_path):]
+
         if protocol == 'websocket':
             websockets = self.wsgi_application.registry.get(
                 'tailbone_websockets', {})
@@ -85,7 +87,7 @@ def make_asgi_app(main_app=None):
     # parse the settings needed for pyramid app
     settings = dict(parser.items('app:main'))
 
-    if isinstance(main_app, six.string_types):
+    if isinstance(main_app, str):
         make_wsgi_app = load_object(main_app)
     elif callable(main_app):
         make_wsgi_app = main_app

From e82f0f37d860c1c63e924f4f042f6b95c29cac3a Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 16 Apr 2024 23:29:56 -0500
Subject: [PATCH 230/542] Fix raw query to avoid SQLAlchemy 2.x warnings

---
 CHANGES.rst                |  2 ++
 tailbone/views/datasync.py | 14 +++++++-------
 2 files changed, 9 insertions(+), 7 deletions(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 7321ee2c..0ae23410 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -7,6 +7,8 @@ Unreleased
 
 * Fix ASGI websockets when serving on sub-path under site root.
 
+* Fix raw query to avoid SQLAlchemy 2.x warnings.
+
 
 0.9.94 (2024-04-16)
 -------------------
diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py
index ac0fec52..b734325f 100644
--- a/tailbone/views/datasync.py
+++ b/tailbone/views/datasync.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.
 #
@@ -30,7 +30,7 @@ import logging
 
 import sqlalchemy as sa
 
-from rattail.db import model
+from rattail.db.model import DataSyncChange
 from rattail.datasync.util import purge_datasync_settings
 from rattail.util import simple_error
 
@@ -71,7 +71,7 @@ class DataSyncThreadView(MasterView):
     ]
 
     def __init__(self, request, context=None):
-        super(DataSyncThreadView, self).__init__(request, context=context)
+        super().__init__(request, context=context)
         app = self.get_rattail_app()
         self.datasync_handler = app.get_datasync_handler()
 
@@ -106,7 +106,7 @@ class DataSyncThreadView(MasterView):
         from datasync_change
         group by source, consumer
         """
-        result = self.Session.execute(sql)
+        result = self.Session.execute(sa.text(sql))
         all_changes = {}
         for row in result:
             all_changes[(row.source, row.consumer)] = row.changes
@@ -368,7 +368,7 @@ class DataSyncChangeView(MasterView):
     """
     Master view for the DataSyncChange model.
     """
-    model_class = model.DataSyncChange
+    model_class = DataSyncChange
     url_prefix = '/datasync/changes'
     permission_prefix = 'datasync_changes'
     creatable = False
@@ -390,7 +390,7 @@ class DataSyncChangeView(MasterView):
     ]
 
     def configure_grid(self, g):
-        super(DataSyncChangeView, self).configure_grid(g)
+        super().configure_grid(g)
 
         # batch_sequence
         g.set_label('batch_sequence', "Batch Seq.")
@@ -404,7 +404,7 @@ class DataSyncChangeView(MasterView):
         return kwargs
 
     def configure_form(self, f):
-        super(DataSyncChangeView, self).configure_form(f)
+        super().configure_form(f)
 
         f.set_readonly('obtained')
 

From 1fa6e35663b6a144d64d3fe4799564475719d1b4 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 19 Apr 2024 17:45:58 -0500
Subject: [PATCH 231/542] Remove config "style" from appinfo page

there is only one style now (finally)
---
 tailbone/templates/appinfo/index.mako | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/tailbone/templates/appinfo/index.mako b/tailbone/templates/appinfo/index.mako
index 62a911ee..ac67e582 100644
--- a/tailbone/templates/appinfo/index.mako
+++ b/tailbone/templates/appinfo/index.mako
@@ -51,7 +51,7 @@
           </b-icon>
         </span>
 
-        <span>Configuration Files (style: ${request.rattail_config._style})</span>
+        <span>Configuration Files</span>
       </div>
     </template>
 

From 5cb643a32ad1b7196eddfe2e254dfe1b6d37850e Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 19 Apr 2024 19:47:41 -0500
Subject: [PATCH 232/542] Update changelog

---
 CHANGES.rst          | 5 +++++
 tailbone/_version.py | 2 +-
 2 files changed, 6 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 0ae23410..53bc179f 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -5,10 +5,15 @@ CHANGELOG
 Unreleased
 ----------
 
+0.9.95 (2024-04-19)
+-------------------
+
 * Fix ASGI websockets when serving on sub-path under site root.
 
 * Fix raw query to avoid SQLAlchemy 2.x warnings.
 
+* Remove config "style" from appinfo page.
+
 
 0.9.94 (2024-04-16)
 -------------------
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 665e5baa..016440ba 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.94'
+__version__ = '0.9.95'

From 36b9e00dc9ba21bef9b0ab699d088635dfd720d8 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 19 Apr 2024 20:15:44 -0500
Subject: [PATCH 233/542] Remove unused code for `webhelpers2_grid`

---
 setup.cfg              |  6 ----
 tailbone/grids/core.py | 64 ------------------------------------------
 2 files changed, 70 deletions(-)

diff --git a/setup.cfg b/setup.cfg
index 2195aee9..514b77ab 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -40,12 +40,6 @@ classifiers =
 [options]
 install_requires =
 
-        # TODO: apparently they jumped from 0.1 to 0.9 and that broke us...
-        # (0.1 was released on 2014-09-14 and then 0.9 came out on 2018-09-27)
-        # (i've cached 0.1 at pypi.rattailproject.org just in case it disappears)
-        # (still, probably a better idea is to refactor so we can use 0.9)
-        webhelpers2_grid==0.1
-
         # TODO: remove once their bug is fixed?  idk what this is about yet...
         deform<2.0.15
 
diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py
index f905659e..41d75fc2 100644
--- a/tailbone/grids/core.py
+++ b/tailbone/grids/core.py
@@ -34,7 +34,6 @@ from sqlalchemy import orm
 from rattail.db.types import GPCType
 from rattail.util import prettify, pretty_boolean, pretty_quantity
 
-import webhelpers2_grid
 from pyramid.renderers import render
 from webhelpers2.html import HTML, tags
 from paginate_sqlalchemy import SqlalchemyOrmPage
@@ -1721,69 +1720,6 @@ class Grid(object):
         return False
 
 
-class CustomWebhelpersGrid(webhelpers2_grid.Grid):
-    """
-    Implement column sorting links etc. for webhelpers2_grid
-    """
-
-    def __init__(self, itemlist, columns, **kwargs):
-        self.renderers = kwargs.pop('renderers', {})
-        self.linked_columns = kwargs.pop('linked_columns', [])
-        self.extra_record_class = kwargs.pop('extra_record_class', None)
-        super().__init__(itemlist, columns, **kwargs)
-
-    def generate_header_link(self, column_number, column, label_text):
-
-        # display column header as simple no-op link; client-side JS takes care
-        # of the rest for us
-        label_text = tags.link_to(label_text, '#', data_sortkey=column)
-
-        # Is the current column the one we're ordering on?
-        if (column == self.order_column):
-            return self.default_header_ordered_column_format(column_number,
-                                                             column,
-                                                             label_text)
-        else:
-            return self.default_header_column_format(column_number, column,
-                                                     label_text)            
-
-    def default_record_format(self, i, record, columns):
-        kwargs = {
-            'class_': self.get_record_class(i, record, columns),
-        }
-        if hasattr(record, 'uuid'):
-            kwargs['data_uuid'] = record.uuid
-        return HTML.tag('tr', columns, **kwargs)
-
-    def get_record_class(self, i, record, columns):
-        if i % 2 == 0:
-            cls = 'even r{}'.format(i)
-        else:
-            cls = 'odd r{}'.format(i)
-        if self.extra_record_class:
-            extra = self.extra_record_class(record, i)
-            if extra:
-                cls = '{} {}'.format(cls, extra)
-        return cls
-
-    def get_column_value(self, column_number, i, record, column_name):
-        if self.renderers and column_name in self.renderers:
-            return self.renderers[column_name](record, column_name)
-        try:
-            return record[column_name]
-        except TypeError:
-            return getattr(record, column_name)
-
-    def default_column_format(self, column_number, i, record, column_name):
-        value = self.get_column_value(column_number, i, record, column_name)
-        if self.linked_columns and column_name in self.linked_columns and (
-                value is not None and value != ''):
-            url = self.url_generator(record, i)
-            value = tags.link_to(value, url)
-        class_name = 'c{} {}'.format(column_number, column_name)
-        return HTML.tag('td', value, class_=class_name)
-
-
 class GridAction(object):
     """
     Represents an action available to a grid.  This is used to construct the

From 49da9776e72f68483e67f6af2769169f92284cca Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 19 Apr 2024 20:25:07 -0500
Subject: [PATCH 234/542] Remove unused test fixtures

---
 setup.cfg         |  4 ++--
 tests/fixtures.py | 28 ----------------------------
 2 files changed, 2 insertions(+), 30 deletions(-)
 delete mode 100644 tests/fixtures.py

diff --git a/setup.cfg b/setup.cfg
index 514b77ab..7fcce722 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -73,7 +73,7 @@ install_requires =
         zope.sqlalchemy
 
 tests_require = Tailbone[tests]
-test_suite = nose.collector
+test_suite = tests
 packages = find:
 include_package_data = True
 zip_safe = False
@@ -87,7 +87,7 @@ exclude =
 
 [options.extras_require]
 docs = Sphinx; sphinx-rtd-theme
-tests = coverage; fixture; mock; nose; pytest; pytest-cov
+tests = coverage; mock; pytest; pytest-cov
 
 
 [options.entry_points]
diff --git a/tests/fixtures.py b/tests/fixtures.py
deleted file mode 100644
index a07825fd..00000000
--- a/tests/fixtures.py
+++ /dev/null
@@ -1,28 +0,0 @@
-
-import fixture
-
-from rattail.db import model
-
-
-class DepartmentData(fixture.DataSet):
-
-    class grocery:
-        number = 1
-        name = 'Grocery'
-
-    class supplements:
-        number = 2
-        name = 'Supplements'
-
-
-def load_fixtures(engine):
-
-    dbfixture = fixture.SQLAlchemyFixture(
-        env={
-            'DepartmentData': model.Department,
-            },
-        engine=engine)
-
-    data = dbfixture.data(DepartmentData)
-
-    data.setup()

From 8781e34c98dd5215f346af8efe1bcb98ac412640 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 19 Apr 2024 21:18:57 -0500
Subject: [PATCH 235/542] Rename setting for custom user css (remove "buefy")

but have to keep support for older setting name for now
---
 tailbone/subscribers.py                   | 10 ++++++++--
 tailbone/templates/users/preferences.mako |  4 ++--
 tailbone/views/users.py                   | 13 +++++++++++--
 3 files changed, 21 insertions(+), 6 deletions(-)

diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py
index 33a9d749..1dc0592a 100644
--- a/tailbone/subscribers.py
+++ b/tailbone/subscribers.py
@@ -27,6 +27,7 @@ Event Subscribers
 import six
 import json
 import datetime
+import warnings
 
 import rattail
 
@@ -181,8 +182,13 @@ def before_render(event):
         # maybe set custom stylesheet
         css = None
         if request.user:
-            css = request.rattail_config.get('tailbone.{}'.format(request.user.uuid),
-                                             'buefy_css')
+            css = rattail_config.get(f'tailbone.{request.user.uuid}', 'user_css')
+            if not css:
+                css = rattail_config.get(f'tailbone.{request.user.uuid}', 'buefy_css')
+                if css:
+                    warnings.warn(f"setting 'tailbone.{request.user.uuid}.buefy_css' should be"
+                                  f"changed to 'tailbone.{request.user.uuid}.user_css'",
+                                  DeprecationWarning)
         renderer_globals['user_css'] = css
 
         # add global search data for quick access
diff --git a/tailbone/templates/users/preferences.mako b/tailbone/templates/users/preferences.mako
index f1432676..c2e17396 100644
--- a/tailbone/templates/users/preferences.mako
+++ b/tailbone/templates/users/preferences.mako
@@ -27,8 +27,8 @@
   <div class="block" style="padding-left: 2rem;">
 
     <b-field label="Theme Style">
-        <b-select name="tailbone.${user.uuid}.buefy_css"
-                  v-model="simpleSettings['tailbone.${user.uuid}.buefy_css']"
+        <b-select name="tailbone.${user.uuid}.user_css"
+                  v-model="simpleSettings['tailbone.${user.uuid}.user_css']"
                   @input="settingsNeedSaved = true">
           <option v-for="option in themeStyleOptions"
                   :key="option.value"
diff --git a/tailbone/views/users.py b/tailbone/views/users.py
index 1501795f..fb81060a 100644
--- a/tailbone/views/users.py
+++ b/tailbone/views/users.py
@@ -601,11 +601,18 @@ class UserView(PrincipalMasterView):
         The only difference here is that we are given a user account,
         so the settings involved should only pertain to that user.
         """
+        # TODO: can stop pre-fetching this value only once we are
+        # confident all settings have been updated in the wild
+        user_css = self.rattail_config.get(f'tailbone.{user.uuid}', 'user_css')
+        if not user_css:
+            user_css = self.rattail_config.get(f'tailbone.{user.uuid}', 'buefy_css')
+
         return [
 
             # display
-            {'section': 'tailbone.{}'.format(user.uuid),
-             'option': 'buefy_css'},
+            {'section': f'tailbone.{user.uuid}',
+             'option': 'user_css',
+             'value': user_css},
         ]
 
     def preferences_gather_settings(self, data, user):
@@ -614,9 +621,11 @@ class UserView(PrincipalMasterView):
             data, simple_settings=simple_settings, input_file_templates=False)
 
     def preferences_remove_settings(self, user):
+        app = self.get_rattail_app()
         simple_settings = self.preferences_get_simple_settings(user)
         self.configure_remove_settings(simple_settings=simple_settings,
                                        input_file_templates=False)
+        app.delete_setting(self.Session(), f'tailbone.{user.uuid}.buefy_css')
 
     @classmethod
     def defaults(cls, config):

From d6fa83cd87052befbd47a4170118b12a9099f39b Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 19 Apr 2024 22:27:30 -0500
Subject: [PATCH 236/542] Fix permission checks for root user with pyramid 2.x

---
 tailbone/auth.py | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/tailbone/auth.py b/tailbone/auth.py
index 66deeff0..0a5bd903 100644
--- a/tailbone/auth.py
+++ b/tailbone/auth.py
@@ -209,6 +209,10 @@ class TailboneSecurityPolicy:
         return self.session_helper.forget(request, **kw)
 
     def permits(self, request, context, permission):
+        # nb. root user can do anything
+        if request.is_root:
+            return True
+
         config = request.registry.settings.get('rattail_config')
         app = config.get_app()
         auth = app.get_auth_handler()

From 9f984241c4792a365482218ecc863c0ade1d4c90 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 24 Apr 2024 17:31:53 -0500
Subject: [PATCH 237/542] Cleanup grid/filters logic a bit

get rid of grids.js file, remove filter templates from complete.mako

move all that instead to filter-components.mako

for now, base template does import + setup for the latter, "just in
case" a given view has any grids.  each grid should (still) be
isolated but no code should be duplicated now.  whereas before the
grid filter templates were in comlete.mako and hence could be declared
more than once if multiple grids are on a page
---
 tailbone/static/js/tailbone.buefy.grid.js     | 167 ----------
 tailbone/templates/base.mako                  |   5 +-
 tailbone/templates/grids/complete.mako        | 126 -------
 .../templates/grids/filter-components.mako    | 313 ++++++++++++++++++
 tailbone/templates/master/index.mako          |  21 +-
 5 files changed, 329 insertions(+), 303 deletions(-)
 delete mode 100644 tailbone/static/js/tailbone.buefy.grid.js
 create mode 100644 tailbone/templates/grids/filter-components.mako

diff --git a/tailbone/static/js/tailbone.buefy.grid.js b/tailbone/static/js/tailbone.buefy.grid.js
deleted file mode 100644
index 6be28f41..00000000
--- a/tailbone/static/js/tailbone.buefy.grid.js
+++ /dev/null
@@ -1,167 +0,0 @@
-
-const GridFilterNumericValue = {
-    template: '#grid-filter-numeric-value-template',
-    props: {
-        value: String,
-        wantsRange: Boolean,
-    },
-    data() {
-        return {
-            startValue: null,
-            endValue: null,
-        }
-    },
-    mounted() {
-        if (this.wantsRange) {
-            if (this.value.includes('|')) {
-                let values = this.value.split('|')
-                if (values.length == 2) {
-                    this.startValue = values[0]
-                    this.endValue = values[1]
-                } else {
-                    this.startValue = this.value
-                }
-            } else {
-                this.startValue = this.value
-            }
-        } else {
-            this.startValue = this.value
-        }
-    },
-    watch: {
-        // when changing from e.g. 'equal' to 'between' filter verbs,
-        // must proclaim new filter value, to reflect (lack of) range
-        wantsRange(val) {
-            if (val) {
-                this.$emit('input', this.startValue + '|' + this.endValue)
-            } else {
-                this.$emit('input', this.startValue)
-            }
-        },
-    },
-    methods: {
-        focus() {
-            this.$refs.startValue.focus()
-        },
-        startValueChanged(value) {
-            if (this.wantsRange) {
-                value += '|' + this.endValue
-            }
-            this.$emit('input', value)
-        },
-        endValueChanged(value) {
-            value = this.startValue + '|' + value
-            this.$emit('input', value)
-        },
-    },
-}
-
-Vue.component('grid-filter-numeric-value', GridFilterNumericValue)
-
-
-const GridFilterDateValue = {
-    template: '#grid-filter-date-value-template',
-    props: {
-        value: String,
-        dateRange: Boolean,
-    },
-    data() {
-        return {
-            startDate: null,
-            endDate: null,
-        }
-    },
-    mounted() {
-        if (this.dateRange) {
-            if (this.value.includes('|')) {
-                let values = this.value.split('|')
-                if (values.length == 2) {
-                    this.startDate = values[0]
-                    this.endDate = values[1]
-                } else {
-                    this.startDate = this.value
-                }
-            } else {
-                this.startDate = this.value
-            }
-        } else {
-            this.startDate = this.value
-        }
-    },
-    methods: {
-        focus() {
-            this.$refs.startDate.focus()
-        },
-        startDateChanged(value) {
-            if (this.dateRange) {
-                value += '|' + this.endDate
-            }
-            this.$emit('input', value)
-        },
-        endDateChanged(value) {
-            value = this.startDate + '|' + value
-            this.$emit('input', value)
-        },
-    },
-}
-
-Vue.component('grid-filter-date-value', GridFilterDateValue)
-
-
-const GridFilter = {
-    template: '#grid-filter-template',
-    props: {
-        filter: Object
-    },
-
-    methods: {
-
-        changeVerb() {
-            // set focus to value input, "as quickly as we can"
-            this.$nextTick(function() {
-                this.focusValue()
-            })
-        },
-
-        valuedVerb() {
-            /* this returns true if the filter's current verb should expose value input(s) */
-
-            // if filter has no "valueless" verbs, then all verbs should expose value inputs
-            if (!this.filter.valueless_verbs) {
-                return true
-            }
-
-            // if filter *does* have valueless verbs, check if "current" verb is valueless
-            if (this.filter.valueless_verbs.includes(this.filter.verb)) {
-                return false
-            }
-
-            // current verb is *not* valueless
-            return true
-        },
-
-        multiValuedVerb() {
-            /* this returns true if the filter's current verb should expose a multi-value input */
-
-            // if filter has no "multi-value" verbs then we safely assume false
-            if (!this.filter.multiple_value_verbs) {
-                return false
-            }
-
-            // if filter *does* have multi-value verbs, see if "current" is one
-            if (this.filter.multiple_value_verbs.includes(this.filter.verb)) {
-                return true
-            }
-
-            // current verb is not multi-value
-            return false
-        },
-
-        focusValue: function() {
-            this.$refs.valueInput.focus()
-            // this.$refs.valueInput.select()
-        }
-    }
-}
-
-Vue.component('grid-filter', GridFilter)
diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako
index e1020b28..d8e86547 100644
--- a/tailbone/templates/base.mako
+++ b/tailbone/templates/base.mako
@@ -3,6 +3,7 @@
 <%namespace file="/autocomplete.mako" import="tailbone_autocomplete_template" />
 <%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" />
 <%namespace name="multi_file_upload" file="/multi_file_upload.mako" />
 <!DOCTYPE html>
@@ -90,7 +91,6 @@
   ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.numericinput.js') + '?ver={}'.format(tailbone.__version__))}
   ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.oncebutton.js') + '?ver={}'.format(tailbone.__version__))}
   ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.timepicker.js') + '?ver={}'.format(tailbone.__version__))}
-  ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.grid.js') + '?ver={}'.format(tailbone.__version__))}
 
   <script type="text/javascript">
 
@@ -896,6 +896,9 @@
 </%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()}
diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako
index 205012be..1476fbae 100644
--- a/tailbone/templates/grids/complete.mako
+++ b/tailbone/templates/grids/complete.mako
@@ -1,131 +1,5 @@
 ## -*- coding: utf-8; -*-
 
-<script type="text/x-template" id="grid-filter-numeric-value-template">
-  <div class="level">
-    <div class="level-left">
-      <div class="level-item">
-        <b-input v-model="startValue"
-                 ref="startValue"
-                 @input="startValueChanged">
-        </b-input>
-      </div>
-      <div v-show="wantsRange"
-           class="level-item">
-        and
-      </div>
-      <div v-show="wantsRange"
-           class="level-item">
-        <b-input v-model="endValue"
-                 ref="endValue"
-                 @input="endValueChanged">
-        </b-input>
-      </div>
-    </div>
-  </div>
-</script>
-
-<script type="text/x-template" id="grid-filter-date-value-template">
-  <div class="level">
-    <div class="level-left">
-      <div class="level-item">
-        <tailbone-datepicker v-model="startDate"
-                             ref="startDate"
-                             @input="startDateChanged">
-        </tailbone-datepicker>
-      </div>
-      <div v-show="dateRange"
-           class="level-item">
-        and
-      </div>
-      <div v-show="dateRange"
-           class="level-item">
-        <tailbone-datepicker v-model="endDate"
-                             ref="endDate"
-                             @input="endDateChanged">
-        </tailbone-datepicker>
-      </div>
-    </div>
-  </div>
-</script>
-
-<script type="text/x-template" id="grid-filter-template">
-
-  <div class="level filter" v-show="filter.visible">
-    <div class="level-left"
-         style="align-items: start;">
-
-      <div class="level-item filter-fieldname">
-
-        <b-field>
-          <b-checkbox-button v-model="filter.active" native-value="IGNORED">
-            <b-icon pack="fas" icon="check" v-show="filter.active"></b-icon>
-            <span>{{ filter.label }}</span>
-          </b-checkbox-button>
-        </b-field>
-
-      </div>
-
-      <b-field grouped v-show="filter.active"
-               class="level-item"
-               style="align-items: start;">
-
-        <b-select v-model="filter.verb"
-                  @input="focusValue()"
-                  class="filter-verb">
-          <option v-for="verb in filter.verbs"
-                  :key="verb"
-                  :value="verb">
-            {{ filter.verb_labels[verb] }}
-          </option>
-        </b-select>
-
-        ## only one of the following "value input" elements will be rendered
-
-        <grid-filter-date-value v-if="filter.data_type == 'date'"
-                                v-model="filter.value"
-                                v-show="valuedVerb()"
-                                :date-range="filter.verb == 'between'"
-                                ref="valueInput">
-        </grid-filter-date-value>
-
-        <b-select v-if="filter.data_type == 'choice'"
-                  v-model="filter.value"
-                  v-show="valuedVerb()"
-                  ref="valueInput">
-          <option v-for="choice in filter.choices"
-                  :key="choice"
-                  :value="choice">
-            {{ filter.choice_labels[choice] || choice }}
-          </option>
-        </b-select>
-
-        <grid-filter-numeric-value v-if="filter.data_type == 'number'"
-                                  v-model="filter.value"
-                                  v-show="valuedVerb()"
-                                  :wants-range="filter.verb == 'between'"
-                                  ref="valueInput">
-        </grid-filter-numeric-value>
-
-        <b-input v-if="filter.data_type == 'string' && !multiValuedVerb()"
-                 v-model="filter.value"
-                 v-show="valuedVerb()"
-                 ref="valueInput">
-        </b-input>
-
-        <b-input v-if="filter.data_type == 'string' && multiValuedVerb()"
-                 type="textarea"
-                 v-model="filter.value"
-                 v-show="valuedVerb()"
-                 ref="valueInput">
-        </b-input>
-
-      </b-field>
-
-    </div><!-- level-left -->
-  </div><!-- level -->
-
-</script>
-
 <script type="text/x-template" id="${grid.component}-template">
   <div>
 
diff --git a/tailbone/templates/grids/filter-components.mako b/tailbone/templates/grids/filter-components.mako
new file mode 100644
index 00000000..815e6028
--- /dev/null
+++ b/tailbone/templates/grids/filter-components.mako
@@ -0,0 +1,313 @@
+## -*- coding: utf-8; -*-
+
+<%def name="make_grid_filter_components()">
+  ${self.make_grid_filter_numeric_value_component()}
+  ${self.make_grid_filter_date_value_component()}
+  ${self.make_grid_filter_component()}
+</%def>
+
+<%def name="make_grid_filter_numeric_value_component()">
+  <script type="text/x-template" id="grid-filter-numeric-value-template">
+    <div class="level">
+      <div class="level-left">
+        <div class="level-item">
+          <b-input v-model="startValue"
+                   ref="startValue"
+                   @input="startValueChanged">
+          </b-input>
+        </div>
+        <div v-show="wantsRange"
+             class="level-item">
+          and
+        </div>
+        <div v-show="wantsRange"
+             class="level-item">
+          <b-input v-model="endValue"
+                   ref="endValue"
+                   @input="endValueChanged">
+          </b-input>
+        </div>
+      </div>
+    </div>
+  </script>
+  <script>
+
+    const GridFilterNumericValue = {
+        template: '#grid-filter-numeric-value-template',
+        props: {
+            value: String,
+            wantsRange: Boolean,
+        },
+        data() {
+            return {
+                startValue: null,
+                endValue: null,
+            }
+        },
+        mounted() {
+            if (this.wantsRange) {
+                if (this.value.includes('|')) {
+                    let values = this.value.split('|')
+                    if (values.length == 2) {
+                        this.startValue = values[0]
+                        this.endValue = values[1]
+                    } else {
+                        this.startValue = this.value
+                    }
+                } else {
+                    this.startValue = this.value
+                }
+            } else {
+                this.startValue = this.value
+            }
+        },
+        watch: {
+            // when changing from e.g. 'equal' to 'between' filter verbs,
+            // must proclaim new filter value, to reflect (lack of) range
+            wantsRange(val) {
+                if (val) {
+                    this.$emit('input', this.startValue + '|' + this.endValue)
+                } else {
+                    this.$emit('input', this.startValue)
+                }
+            },
+        },
+        methods: {
+            focus() {
+                this.$refs.startValue.focus()
+            },
+            startValueChanged(value) {
+                if (this.wantsRange) {
+                    value += '|' + this.endValue
+                }
+                this.$emit('input', value)
+            },
+            endValueChanged(value) {
+                value = this.startValue + '|' + value
+                this.$emit('input', value)
+            },
+        },
+    }
+
+    Vue.component('grid-filter-numeric-value', GridFilterNumericValue)
+
+  </script>
+</%def>
+
+<%def name="make_grid_filter_date_value_component()">
+  <script type="text/x-template" id="grid-filter-date-value-template">
+    <div class="level">
+      <div class="level-left">
+        <div class="level-item">
+          <tailbone-datepicker v-model="startDate"
+                               ref="startDate"
+                               @input="startDateChanged">
+          </tailbone-datepicker>
+        </div>
+        <div v-show="dateRange"
+             class="level-item">
+          and
+        </div>
+        <div v-show="dateRange"
+             class="level-item">
+          <tailbone-datepicker v-model="endDate"
+                               ref="endDate"
+                               @input="endDateChanged">
+          </tailbone-datepicker>
+        </div>
+      </div>
+    </div>
+  </script>
+  <script>
+
+    const GridFilterDateValue = {
+        template: '#grid-filter-date-value-template',
+        props: {
+            value: String,
+            dateRange: Boolean,
+        },
+        data() {
+            return {
+                startDate: null,
+                endDate: null,
+            }
+        },
+        mounted() {
+            if (this.dateRange) {
+                if (this.value.includes('|')) {
+                    let values = this.value.split('|')
+                    if (values.length == 2) {
+                        this.startDate = values[0]
+                        this.endDate = values[1]
+                    } else {
+                        this.startDate = this.value
+                    }
+                } else {
+                    this.startDate = this.value
+                }
+            } else {
+                this.startDate = this.value
+            }
+        },
+        methods: {
+            focus() {
+                this.$refs.startDate.focus()
+            },
+            startDateChanged(value) {
+                if (this.dateRange) {
+                    value += '|' + this.endDate
+                }
+                this.$emit('input', value)
+            },
+            endDateChanged(value) {
+                value = this.startDate + '|' + value
+                this.$emit('input', value)
+            },
+        },
+    }
+
+    Vue.component('grid-filter-date-value', GridFilterDateValue)
+
+  </script>
+</%def>
+
+<%def name="make_grid_filter_component()">
+  <script type="text/x-template" id="grid-filter-template">
+
+    <div class="level filter" v-show="filter.visible">
+      <div class="level-left"
+           style="align-items: start;">
+
+        <div class="level-item filter-fieldname">
+
+          <b-field>
+            <b-checkbox-button v-model="filter.active" native-value="IGNORED">
+              <b-icon pack="fas" icon="check" v-show="filter.active"></b-icon>
+              <span>{{ filter.label }}</span>
+            </b-checkbox-button>
+          </b-field>
+
+        </div>
+
+        <b-field grouped v-show="filter.active"
+                 class="level-item"
+                 style="align-items: start;">
+
+          <b-select v-model="filter.verb"
+                    @input="focusValue()"
+                    class="filter-verb">
+            <option v-for="verb in filter.verbs"
+                    :key="verb"
+                    :value="verb">
+              {{ filter.verb_labels[verb] }}
+            </option>
+          </b-select>
+
+          ## only one of the following "value input" elements will be rendered
+
+          <grid-filter-date-value v-if="filter.data_type == 'date'"
+                                  v-model="filter.value"
+                                  v-show="valuedVerb()"
+                                  :date-range="filter.verb == 'between'"
+                                  ref="valueInput">
+          </grid-filter-date-value>
+
+          <b-select v-if="filter.data_type == 'choice'"
+                    v-model="filter.value"
+                    v-show="valuedVerb()"
+                    ref="valueInput">
+            <option v-for="choice in filter.choices"
+                    :key="choice"
+                    :value="choice">
+              {{ filter.choice_labels[choice] || choice }}
+            </option>
+          </b-select>
+
+          <grid-filter-numeric-value v-if="filter.data_type == 'number'"
+                                    v-model="filter.value"
+                                    v-show="valuedVerb()"
+                                    :wants-range="filter.verb == 'between'"
+                                    ref="valueInput">
+          </grid-filter-numeric-value>
+
+          <b-input v-if="filter.data_type == 'string' && !multiValuedVerb()"
+                   v-model="filter.value"
+                   v-show="valuedVerb()"
+                   ref="valueInput">
+          </b-input>
+
+          <b-input v-if="filter.data_type == 'string' && multiValuedVerb()"
+                   type="textarea"
+                   v-model="filter.value"
+                   v-show="valuedVerb()"
+                   ref="valueInput">
+          </b-input>
+
+        </b-field>
+
+      </div><!-- level-left -->
+    </div><!-- level -->
+
+  </script>
+  <script>
+
+    const GridFilter = {
+        template: '#grid-filter-template',
+        props: {
+            filter: Object
+        },
+
+        methods: {
+
+            changeVerb() {
+                // set focus to value input, "as quickly as we can"
+                this.$nextTick(function() {
+                    this.focusValue()
+                })
+            },
+
+            valuedVerb() {
+                /* this returns true if the filter's current verb should expose value input(s) */
+
+                // if filter has no "valueless" verbs, then all verbs should expose value inputs
+                if (!this.filter.valueless_verbs) {
+                    return true
+                }
+
+                // if filter *does* have valueless verbs, check if "current" verb is valueless
+                if (this.filter.valueless_verbs.includes(this.filter.verb)) {
+                    return false
+                }
+
+                // current verb is *not* valueless
+                return true
+            },
+
+            multiValuedVerb() {
+                /* this returns true if the filter's current verb should expose a multi-value input */
+
+                // if filter has no "multi-value" verbs then we safely assume false
+                if (!this.filter.multiple_value_verbs) {
+                    return false
+                }
+
+                // if filter *does* have multi-value verbs, see if "current" is one
+                if (this.filter.multiple_value_verbs.includes(this.filter.verb)) {
+                    return true
+                }
+
+                // current verb is not multi-value
+                return false
+            },
+
+            focusValue: function() {
+                this.$refs.valueInput.focus()
+                // this.$refs.valueInput.select()
+            }
+        }
+    }
+
+    Vue.component('grid-filter', GridFilter)
+
+  </script>
+</%def>
diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako
index 051a9ab6..d9dabc7b 100644
--- a/tailbone/templates/master/index.mako
+++ b/tailbone/templates/master/index.mako
@@ -299,6 +299,11 @@
   % endif
 </%def>
 
+<%def name="make_grid_component()">
+  ## TODO: stop using |n filter?
+  ${grid.render_complete(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())|n}
+</%def>
+
 <%def name="render_grid_component()">
   <${grid.component} ref="grid" :csrftoken="csrftoken"
      % if master.deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple':
@@ -309,11 +314,16 @@
 </%def>
 
 <%def name="make_this_page_component()">
+
+  ## define grid
+  ${self.make_grid_component()}
+
   ${parent.make_this_page_component()}
-  <script type="text/javascript">
 
-    ${grid.component_studly}.data = function() { return ${grid.component_studly}Data }
+  ## finalize grid
+  <script>
 
+    ${grid.component_studly}.data = () => { return ${grid.component_studly}Data }
     Vue.component('${grid.component}', ${grid.component_studly})
 
   </script>
@@ -323,13 +333,6 @@
   ${self.page_content()}
 </%def>
 
-<%def name="render_this_page_template()">
-  ${parent.render_this_page_template()}
-
-  ## TODO: stop using |n filter
-  ${grid.render_complete(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())|n}
-</%def>
-
 <%def name="modify_this_page_vars()">
   ${parent.modify_this_page_vars()}
   <script type="text/javascript">

From 0ca3b31b2eb50cee024c48cc128a33c1dac296cf Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 24 Apr 2024 18:20:16 -0500
Subject: [PATCH 238/542] Use normal button for grid filters

since that's more portable (for oruga) than "checkbox button"
---
 tailbone/templates/base.mako                    | 3 ++-
 tailbone/templates/grids/filter-components.mako | 9 +++++----
 2 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako
index d8e86547..53fac116 100644
--- a/tailbone/templates/base.mako
+++ b/tailbone/templates/base.mako
@@ -151,7 +151,8 @@
   ${h.stylesheet_link(request.static_url('tailbone:static/css/codehilite.css') + '?ver={}'.format(tailbone.__version__))}
 
   <style type="text/css">
-    .filters .filter-fieldname {
+    .filters .filter-fieldname,
+    .filters .filter-fieldname .button {
         min-width: ${filter_fieldname_width};
         justify-content: left;
     }
diff --git a/tailbone/templates/grids/filter-components.mako b/tailbone/templates/grids/filter-components.mako
index 815e6028..9bc02fed 100644
--- a/tailbone/templates/grids/filter-components.mako
+++ b/tailbone/templates/grids/filter-components.mako
@@ -181,10 +181,11 @@
         <div class="level-item filter-fieldname">
 
           <b-field>
-            <b-checkbox-button v-model="filter.active" native-value="IGNORED">
-              <b-icon pack="fas" icon="check" v-show="filter.active"></b-icon>
-              <span>{{ filter.label }}</span>
-            </b-checkbox-button>
+            <b-button @click="filter.active = !filter.active"
+                      icon-pack="fas"
+                      :icon-left="filter.active ? 'check' : null">
+              {{ filter.label }}
+            </b-button>
           </b-field>
 
         </div>

From ddafa9ed972f80520085149a237b5540932beb12 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 24 Apr 2024 20:19:15 -0500
Subject: [PATCH 239/542] Tweak icon for Download Results button

make it more portable for oruga
---
 tailbone/templates/master/index.mako | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako
index d9dabc7b..0ae4c8ab 100644
--- a/tailbone/templates/master/index.mako
+++ b/tailbone/templates/master/index.mako
@@ -20,7 +20,7 @@
       <li>${h.link_to("Download results as XLSX", url('{}.results_xlsx'.format(route_prefix)))}</li>
   % endif
   % if master.has_input_file_templates and master.has_perm('create'):
-      % for template in six.itervalues(input_file_templates):
+      % for template in input_file_templates.values():
           <li>${h.link_to("Download {} Template".format(template['label']), template['effective_url'])}</li>
       % endfor
   % endif
@@ -45,7 +45,7 @@
   % if master.results_downloadable and master.has_perm('download_results'):
       <b-button type="is-primary"
                 icon-pack="fas"
-                icon-left="fas fa-download"
+                icon-left="download"
                 @click="showDownloadResultsDialog = true"
                 :disabled="!total">
         Download Results
@@ -86,7 +86,7 @@
               <div>
                 <b-field horizontal label="Format">
                   <b-select v-model="downloadResultsFormat">
-                    % for key, label in six.iteritems(master.download_results_supported_formats()):
+                    % for key, label in master.download_results_supported_formats().items():
                     <option value="${key}">${label}</option>
                     % endfor
                   </b-select>

From 4f6ee1fb22256bd9d8d1f81ae0926372b4d758f1 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 24 Apr 2024 22:10:56 -0500
Subject: [PATCH 240/542] Use v-model to track selection etc. for download
 results fields

---
 tailbone/templates/master/index.mako | 53 ++++++++++++++++------------
 1 file changed, 30 insertions(+), 23 deletions(-)

diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako
index 0ae4c8ab..7cb9ffa2 100644
--- a/tailbone/templates/master/index.mako
+++ b/tailbone/templates/master/index.mako
@@ -84,7 +84,7 @@
             <div style="display: flex; justify-content: space-between">
 
               <div>
-                <b-field horizontal label="Format">
+                <b-field label="Format">
                   <b-select v-model="downloadResultsFormat">
                     % for key, label in master.download_results_supported_formats().items():
                     <option value="${key}">${label}</option>
@@ -130,9 +130,9 @@
                       <b-field label="Excluded Fields">
                         <b-select multiple native-size="8"
                                   expanded
+                                  v-model="downloadResultsExcludedFieldsSelected"
                                   ref="downloadResultsExcludedFields">
-                          <option v-for="field in downloadResultsFieldsAvailable"
-                                  v-if="!downloadResultsFieldsIncluded.includes(field)"
+                          <option v-for="field in downloadResultsFieldsExcluded"
                                   :key="field"
                                   :value="field">
                             {{ field }}
@@ -156,6 +156,7 @@
                       <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"
@@ -417,6 +418,9 @@
         ${grid.component_studly}Data.downloadResultsFieldsDefault = ${json.dumps(download_results_fields_default)|n}
         ${grid.component_studly}Data.downloadResultsFieldsIncluded = ${json.dumps(download_results_fields_default)|n}
 
+        ${grid.component_studly}Data.downloadResultsExcludedFieldsSelected = []
+        ${grid.component_studly}Data.downloadResultsIncludedFieldsSelected = []
+
         ${grid.component_studly}.computed.downloadResultsFieldsExcluded = function() {
             let excluded = []
             this.downloadResultsFieldsAvailable.forEach(field => {
@@ -428,45 +432,48 @@
         }
 
         ${grid.component_studly}.methods.downloadResultsExcludeFields = function() {
-            let selected = this.$refs.downloadResultsIncludedFields.selected
+            const selected = Array.from(this.downloadResultsIncludedFieldsSelected)
             if (!selected) {
                 return
             }
-            selected = Array.from(selected)
-            selected.forEach(field => {
 
-                // de-select the entry within "included" field input
-                let index = this.$refs.downloadResultsIncludedFields.selected.indexOf(field)
-                if (index > -1) {
-                    this.$refs.downloadResultsIncludedFields.selected.splice(index, 1)
+            selected.forEach(field => {
+                let index
+
+                // remove field from selected
+                index = this.downloadResultsIncludedFieldsSelected.indexOf(field)
+                if (index >= 0) {
+                    this.downloadResultsIncludedFieldsSelected.splice(index, 1)
                 }
 
-                // remove field from official "included" list
+                // remove field from included
+                // nb. excluded list will reflect this change too
                 index = this.downloadResultsFieldsIncluded.indexOf(field)
-                if (index > -1) {
+                if (index >= 0) {
                     this.downloadResultsFieldsIncluded.splice(index, 1)
                 }
-            }, this)
+            })
         }
 
         ${grid.component_studly}.methods.downloadResultsIncludeFields = function() {
-            let selected = this.$refs.downloadResultsExcludedFields.selected
+            const selected = Array.from(this.downloadResultsExcludedFieldsSelected)
             if (!selected) {
                 return
             }
-            selected = Array.from(selected)
-            selected.forEach(field => {
 
-                // de-select the entry within "excluded" field input
-                let index = this.$refs.downloadResultsExcludedFields.selected.indexOf(field)
-                if (index > -1) {
-                    this.$refs.downloadResultsExcludedFields.selected.splice(index, 1)
+            selected.forEach(field => {
+                let index
+
+                // remove field from selected
+                index = this.downloadResultsExcludedFieldsSelected.indexOf(field)
+                if (index >= 0) {
+                    this.downloadResultsExcludedFieldsSelected.splice(index, 1)
                 }
 
-                // add field to official "included" list
+                // add field to included
+                // nb. excluded list will reflect this change too
                 this.downloadResultsFieldsIncluded.push(field)
-
-            }, this)
+            })
         }
 
         ${grid.component_studly}.methods.downloadResultsUseDefaultFields = function() {

From d2aa91502a5c3f943c3398179fb57f51493ed07d Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 25 Apr 2024 14:02:45 -0500
Subject: [PATCH 241/542] Allow deleting rows from executed batches

requires a view to explicitly opt-in.  and a separate permission is
required for the user
---
 tailbone/views/batch/core.py | 22 ++++++++++++++++++----
 1 file changed, 18 insertions(+), 4 deletions(-)

diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py
index 4df3d911..84ef451f 100644
--- a/tailbone/views/batch/core.py
+++ b/tailbone/views/batch/core.py
@@ -63,6 +63,7 @@ class BatchMasterView(MasterView):
     batch_handler_class = None
     has_rows = True
     rows_deletable = True
+    rows_deletable_if_executed = False
     rows_bulk_deletable = True
     rows_downloadable_csv = True
     rows_downloadable_xlsx = True
@@ -700,11 +701,11 @@ class BatchMasterView(MasterView):
                 view = lambda r, i: self.get_row_action_url('view', r)
                 actions.append(self.make_action('view', icon='eye', url=view))
 
-            # edit and delete are NOT allowed after execution, or if batch is "complete"
-            if not batch.executed and not batch.complete:
+            # edit and delete are NOT allowed if batch is "complete"
+            if not batch.complete:
 
                 # edit action
-                if self.rows_editable and self.has_perm('edit_row'):
+                if self.rows_editable and not batch.executed and self.has_perm('edit_row'):
                     actions.append(self.make_action('edit', icon='edit',
                                                     url=self.row_edit_action_url))
 
@@ -1241,9 +1242,16 @@ class BatchMasterView(MasterView):
             return False
 
         batch = self.get_parent(row)
-        if batch.complete or batch.executed:
+
+        if batch.complete:
             return False
 
+        if batch.executed:
+            if not self.rows_deletable_if_executed:
+                return False
+            if not self.has_perm('delete_row_if_executed'):
+                return False
+
         return True
 
     def template_kwargs_view_row(self, **kwargs):
@@ -1504,6 +1512,12 @@ class BatchMasterView(MasterView):
             config.add_tailbone_permission(permission_prefix, '{}.refresh'.format(permission_prefix),
                                            "Refresh data for {}".format(model_title))
 
+        # delete row if executed
+        if cls.rows_deletable_if_executed:
+            config.add_tailbone_permission(permission_prefix,
+                                           f'{permission_prefix}.delete_row_if_executed',
+                                           "Delete rows after batch is executed")
+
         # toggle complete
         config.add_route('{}.toggle_complete'.format(route_prefix), '{}/{{{}}}/toggle-complete'.format(url_prefix, model_key))
         config.add_view(cls, attr='toggle_complete', route_name='{}.toggle_complete'.format(route_prefix),

From 23e6eef60430b427e997f4e1303a9a2eedf7d1ea Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 25 Apr 2024 14:05:10 -0500
Subject: [PATCH 242/542] Update changelog

---
 CHANGES.rst          | 20 ++++++++++++++++++++
 tailbone/_version.py |  2 +-
 2 files changed, 21 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 53bc179f..7bdb466d 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -5,6 +5,26 @@ CHANGELOG
 Unreleased
 ----------
 
+0.9.96 (2024-04-25)
+-------------------
+
+* Remove unused code for ``webhelpers2_grid``.
+
+* Rename setting for custom user css (remove "buefy").
+
+* Fix permission checks for root user with pyramid 2.x.
+
+* Cleanup grid/filters logic a bit.
+
+* Use normal (not checkbox) button for grid filters.
+
+* Tweak icon for Download Results button.
+
+* Use v-model to track selection etc. for download results fields.
+
+* Allow deleting rows from executed batches.
+
+
 0.9.95 (2024-04-19)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 016440ba..fb15d91c 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.95'
+__version__ = '0.9.96'

From bfe6b5bc251969d5580b6de8e2e0c296005a1a81 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 25 Apr 2024 15:41:06 -0500
Subject: [PATCH 243/542] Use explicit flex styles for grid-tools element

and so, must ensure children of grid-tools are atomic elements
---
 tailbone/static/css/grids.css          |   5 +
 tailbone/templates/grids/complete.mako |   2 +-
 tailbone/templates/master/index.mako   | 262 +++++++++++++------------
 3 files changed, 138 insertions(+), 131 deletions(-)

diff --git a/tailbone/static/css/grids.css b/tailbone/static/css/grids.css
index da5814c4..42da832c 100644
--- a/tailbone/static/css/grids.css
+++ b/tailbone/static/css/grids.css
@@ -25,6 +25,11 @@
     margin: 0;
 }
 
+.grid-tools {
+    display: flex;
+    gap: 0.5rem;
+}
+
 .grid-wrapper .grid-header td.tools {
     margin: 0;
     padding: 0;
diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako
index 1476fbae..db46764e 100644
--- a/tailbone/templates/grids/complete.mako
+++ b/tailbone/templates/grids/complete.mako
@@ -28,7 +28,7 @@
 
         <div class="grid-tools-wrapper">
           % if tools:
-              <div class="grid-tools field buttons is-grouped is-pulled-right">
+              <div class="grid-tools">
                 ## TODO: stop using |n filter
                 ${tools|n}
               </div>
diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako
index 7cb9ffa2..2ad9a21b 100644
--- a/tailbone/templates/master/index.mako
+++ b/tailbone/templates/master/index.mako
@@ -43,150 +43,152 @@
 
   ## download search results
   % if master.results_downloadable and master.has_perm('download_results'):
-      <b-button type="is-primary"
-                icon-pack="fas"
-                icon-left="download"
-                @click="showDownloadResultsDialog = true"
-                :disabled="!total">
-        Download Results
-      </b-button>
+      <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()}
+        ${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">
+        <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 />
+            <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>
+              <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 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>
+                  <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 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>
 
-                <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 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>
-          </div> <!-- card-content -->
+            </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="fas fa-download"
-                         :disabled="!downloadResultsFieldsIncluded.length"
-                         text="Download Results">
-            </once-button>
-          </footer>
-        </div>
-      </b-modal>
+            <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="fas fa-download"
+                           :disabled="!downloadResultsFieldsIncluded.length"
+                           text="Download Results">
+              </once-button>
+            </footer>
+          </div>
+        </b-modal>
+      </div>
   % endif
 
   ## download rows for search results

From f43259fbc1a60e9e3b972a600d902816ce5c1b5a Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 25 Apr 2024 16:04:30 -0500
Subject: [PATCH 244/542] Use proper flex styles for grid pagination footer

---
 tailbone/templates/grids/complete.mako | 14 ++++++++------
 1 file changed, 8 insertions(+), 6 deletions(-)

diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako
index db46764e..710ea3e6 100644
--- a/tailbone/templates/grids/complete.mako
+++ b/tailbone/templates/grids/complete.mako
@@ -192,10 +192,12 @@
           % endif
 
           % if grid.pageable:
-              <b-field grouped
-                       v-if="firstItem">
-                <span class="control">
-                  showing {{ firstItem.toLocaleString('en') }} - {{ lastItem.toLocaleString('en') }} of {{ total.toLocaleString('en') }} results;
+              <div v-if="firstItem"
+                   style="display: flex; gap: 0.5rem; align-items: center;">
+                <span>
+                  showing
+                  {{ firstItem.toLocaleString('en') }} - {{ lastItem.toLocaleString('en') }}
+                  of {{ total.toLocaleString('en') }} results;
                 </span>
                 <b-select v-model="perPage"
                           size="is-small"
@@ -204,10 +206,10 @@
                       <option value="${value}">${value}</option>
                   % endfor
                 </b-select>
-                <span class="control">
+                <span>
                   per page
                 </span>
-              </b-field>
+              </div>
           % endif
 
         </div>

From ab57fb3f0f47eddc4ffcaf844abe7fb5c641d2a3 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 25 Apr 2024 18:16:39 -0500
Subject: [PATCH 245/542] Tweak flex styles for grid filters

---
 tailbone/templates/grids/complete.mako | 4 +---
 1 file changed, 1 insertion(+), 3 deletions(-)

diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako
index 710ea3e6..940174dc 100644
--- a/tailbone/templates/grids/complete.mako
+++ b/tailbone/templates/grids/complete.mako
@@ -5,8 +5,7 @@
 
     <div style="display: flex; justify-content: space-between; margin-bottom: 0.5em;">
 
-      <div style="display: flex; flex-direction: column; justify-content: space-between;">
-        <div></div>
+      <div style="display: flex; flex-direction: column; justify-content: end;">
         <div class="filters">
           % if grid.filterable:
               ## TODO: stop using |n filter
@@ -41,7 +40,6 @@
 
     <b-table
        :data="visibleData"
-       ## :columns="columns"
        :loading="loading"
        :row-class="getRowClass"
 

From daf68cad0185368231e8f555aaff9ea5e1ac9e41 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 25 Apr 2024 18:52:34 -0500
Subject: [PATCH 246/542] Fix data type handling for datepicker and grid filter
 components

here is what's up now:

- <b-datepicker> expects v-model to be a Date
- <tailbone-datepicker> also expects a Date
- <grid-filter-date-value> uses String for its v-model

latter is so the value can represent a date range, e.g. 'YYYY-MM-DD|YYYY-MM-DD'

anyway there was previously confusion about data type among these
components, and hopefully they are straight now per the above outline
---
 .../static/js/tailbone.buefy.datepicker.js    | 22 ++++++-
 .../templates/grids/filter-components.mako    | 58 +++++++++++++------
 2 files changed, 60 insertions(+), 20 deletions(-)

diff --git a/tailbone/static/js/tailbone.buefy.datepicker.js b/tailbone/static/js/tailbone.buefy.datepicker.js
index fe649380..c516b97f 100644
--- a/tailbone/static/js/tailbone.buefy.datepicker.js
+++ b/tailbone/static/js/tailbone.buefy.datepicker.js
@@ -11,7 +11,7 @@ const TailboneDatepicker = {
         'icon="calendar-alt"',
         ':date-formatter="formatDate"',
         ':date-parser="parseDate"',
-        ':value="value ? parseDate(value) : null"',
+        ':value="buefyValue"',
         '@input="dateChanged"',
         ':disabled="disabled"',
         'ref="trueDatePicker"',
@@ -26,6 +26,24 @@ const TailboneDatepicker = {
         disabled: Boolean,
     },
 
+    data() {
+        let buefyValue = this.value
+        if (buefyValue && !buefyValue.getDate) {
+            buefyValue = this.parseDate(this.value)
+        }
+        return {
+            buefyValue,
+        }
+    },
+
+    watch: {
+        value(to, from) {
+            if (this.buefyValue != to) {
+                this.buefyValue = to
+            }
+        },
+    },
+
     methods: {
 
         formatDate(date) {
@@ -49,7 +67,7 @@ const TailboneDatepicker = {
         },
 
         dateChanged(date) {
-            this.$emit('input', this.formatDate(date))
+            this.$emit('input', date)
         },
 
         focus() {
diff --git a/tailbone/templates/grids/filter-components.mako b/tailbone/templates/grids/filter-components.mako
index 9bc02fed..869455cc 100644
--- a/tailbone/templates/grids/filter-components.mako
+++ b/tailbone/templates/grids/filter-components.mako
@@ -127,40 +127,62 @@
             dateRange: Boolean,
         },
         data() {
-            return {
-                startDate: null,
-                endDate: null,
-            }
-        },
-        mounted() {
-            if (this.dateRange) {
-                if (this.value.includes('|')) {
+            let startDate = null
+            let endDate = null
+            if (this.value) {
+
+                if (this.dateRange) {
                     let values = this.value.split('|')
                     if (values.length == 2) {
-                        this.startDate = values[0]
-                        this.endDate = values[1]
-                    } else {
-                        this.startDate = this.value
+                        startDate = this.parseDate(values[0])
+                        endDate = this.parseDate(values[1])
+                    } else {    // no end date specified?
+                        startDate = this.parseDate(this.value)
                     }
-                } else {
-                    this.startDate = this.value
+
+                } else {        // not a range, so start date only
+                    startDate = this.parseDate(this.value)
                 }
-            } else {
-                this.startDate = this.value
+            }
+
+            return {
+                startDate,
+                endDate,
             }
         },
         methods: {
             focus() {
                 this.$refs.startDate.focus()
             },
+            formatDate(date) {
+                if (date === null) {
+                    return null
+                }
+                // just need to convert to simple ISO date format here, seems
+                // like there should be a more obvious way to do that?
+                var year = date.getFullYear()
+                var month = date.getMonth() + 1
+                var day = date.getDate()
+                month = month < 10 ? '0' + month : month
+                day = day < 10 ? '0' + day : day
+                return year + '-' + month + '-' + day
+            },
+            parseDate(value) {
+                if (value) {
+                    // note, this assumes classic YYYY-MM-DD (i.e. ISO?) format
+                    const parts = value.split('-')
+                    return new Date(parts[0], parseInt(parts[1]) - 1, parts[2])
+                }
+            },
             startDateChanged(value) {
+                value = this.formatDate(value)
                 if (this.dateRange) {
-                    value += '|' + this.endDate
+                    value += '|' + this.formatDate(this.endDate)
                 }
                 this.$emit('input', value)
             },
             endDateChanged(value) {
-                value = this.startDate + '|' + value
+                value = this.formatDate(this.startDate) + '|' + this.formatDate(value)
                 this.$emit('input', value)
             },
         },

From 25a27af29c4bc6d4a665accefde561e6bd76a63d Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 25 Apr 2024 20:45:03 -0500
Subject: [PATCH 247/542] Use explicit flex styles instead of "level" for grid
 filters etc.

just to be more precise, and consistent
---
 tailbone/static/css/filters.css               |  4 ---
 .../templates/grids/filter-components.mako    | 35 +++++++------------
 tailbone/templates/grids/filters.mako         | 27 +++++++-------
 3 files changed, 25 insertions(+), 41 deletions(-)

diff --git a/tailbone/static/css/filters.css b/tailbone/static/css/filters.css
index 6deff7b0..72506a06 100644
--- a/tailbone/static/css/filters.css
+++ b/tailbone/static/css/filters.css
@@ -3,10 +3,6 @@
  * Grid Filters
  ******************************/
 
-.filters .filter {
-    margin-bottom: 0.5rem;
-}
-
 .filters .filter-fieldname .field,
 .filters .filter-fieldname .field label {
     width: 100%;
diff --git a/tailbone/templates/grids/filter-components.mako b/tailbone/templates/grids/filter-components.mako
index 869455cc..7897b3cf 100644
--- a/tailbone/templates/grids/filter-components.mako
+++ b/tailbone/templates/grids/filter-components.mako
@@ -195,26 +195,20 @@
 
 <%def name="make_grid_filter_component()">
   <script type="text/x-template" id="grid-filter-template">
+    <div class="filter"
+         v-show="filter.visible"
+         style="display: flex; gap: 0.5rem;">
 
-    <div class="level filter" v-show="filter.visible">
-      <div class="level-left"
-           style="align-items: start;">
-
-        <div class="level-item filter-fieldname">
-
-          <b-field>
-            <b-button @click="filter.active = !filter.active"
-                      icon-pack="fas"
-                      :icon-left="filter.active ? 'check' : null">
-              {{ filter.label }}
-            </b-button>
-          </b-field>
-
+        <div class="filter-fieldname">
+          <b-button @click="filter.active = !filter.active"
+                    icon-pack="fas"
+                    :icon-left="filter.active ? 'check' : null">
+            {{ filter.label }}
+          </b-button>
         </div>
 
-        <b-field grouped v-show="filter.active"
-                 class="level-item"
-                 style="align-items: start;">
+        <div v-show="filter.active"
+             style="display: flex; gap: 0.5rem;">
 
           <b-select v-model="filter.verb"
                     @input="focusValue()"
@@ -266,11 +260,8 @@
                    ref="valueInput">
           </b-input>
 
-        </b-field>
-
-      </div><!-- level-left -->
-    </div><!-- level -->
-
+        </div>
+    </div>
   </script>
   <script>
 
diff --git a/tailbone/templates/grids/filters.mako b/tailbone/templates/grids/filters.mako
index 4c584883..eb245934 100644
--- a/tailbone/templates/grids/filters.mako
+++ b/tailbone/templates/grids/filters.mako
@@ -2,26 +2,26 @@
 
 <form action="${form.action_url}" method="GET" @submit.prevent="applyFilters()">
 
-  <grid-filter v-for="key in filtersSequence"
-               :key="key"
-               :filter="filters[key]"
-               ref="gridFilters">
-  </grid-filter>
+  <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>
 
-  <b-field grouped>
+  <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"
-              class="control">
+              icon-left="check">
       Apply Filters
     </b-button>
 
     <b-button v-if="!addFilterShow"
               icon-pack="fas"
               icon-left="plus"
-              class="control"
               @click="addFilterButton">
       Add Filter
     </b-button>
@@ -44,15 +44,13 @@
 
     <b-button @click="resetView()"
               icon-pack="fas"
-              icon-left="home"
-              class="control">
+              icon-left="home">
       Default View
     </b-button>
 
     <b-button @click="clearFilters()"
               icon-pack="fas"
-              icon-left="trash"
-              class="control">
+              icon-left="trash">
       No Filters
     </b-button>
 
@@ -60,12 +58,11 @@
         <b-button @click="saveDefaults()"
                   icon-pack="fas"
                   icon-left="save"
-                  class="control"
                   :disabled="savingDefaults">
           {{ savingDefaults ? "Working, please wait..." : "Save Defaults" }}
         </b-button>
     % endif
 
-  </b-field>
+  </div>
 
 </form>

From e030dc841dc41e9726b3ae318c8a213f15290f60 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 25 Apr 2024 21:31:26 -0500
Subject: [PATCH 248/542] Expand some modal fields, per oruga styles

---
 tailbone/templates/page_help.mako | 6 ++++--
 1 file changed, 4 insertions(+), 2 deletions(-)

diff --git a/tailbone/templates/page_help.mako b/tailbone/templates/page_help.mako
index 4da6ac37..19e8e121 100644
--- a/tailbone/templates/page_help.mako
+++ b/tailbone/templates/page_help.mako
@@ -128,13 +128,15 @@
 
                 <b-field label="Help Link (URL)">
                   <b-input v-model="helpURL"
-                           ref="helpURL">
+                           ref="helpURL"
+                           expanded>
                   </b-input>
                 </b-field>
 
                 <b-field label="Help Text (Markdown)">
                   <b-input v-model="markdownText"
-                           type="textarea" rows="8">
+                           type="textarea" rows="8"
+                           expanded>
                   </b-input>
                 </b-field>
 

From 6bee65780ca73ceb2f44215685d97362f734858b Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 25 Apr 2024 22:00:01 -0500
Subject: [PATCH 249/542] Improve logic for Add Filter grid button/autocomplete

this should work for oruga as well as buefy
---
 tailbone/templates/grids/complete.mako | 8 ++++++++
 tailbone/templates/grids/filters.mako  | 2 +-
 2 files changed, 9 insertions(+), 1 deletion(-)

diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako
index 940174dc..cb040cc4 100644
--- a/tailbone/templates/grids/complete.mako
+++ b/tailbone/templates/grids/complete.mako
@@ -294,6 +294,7 @@
       computed: {
 
           addFilterChoices() {
+              // nb. this returns all choices available for "Add Filter" operation
 
               // collect all filters, which are *not* already shown
               let choices = []
@@ -354,6 +355,13 @@
 
       methods: {
 
+          formatAddFilterItem(filtr) {
+              if (!filtr.key) {
+                  filtr = this.filters[filtr]
+              }
+              return filtr.label || filtr.key
+          },
+
           % if grid.click_handlers:
               cellClick(row, column, rowIndex, columnIndex) {
                   % for key in grid.click_handlers:
diff --git a/tailbone/templates/grids/filters.mako b/tailbone/templates/grids/filters.mako
index eb245934..cb6ec9e2 100644
--- a/tailbone/templates/grids/filters.mako
+++ b/tailbone/templates/grids/filters.mako
@@ -32,7 +32,7 @@
                     v-model="addFilterTerm"
                     placeholder="Add Filter"
                     field="key"
-                    :custom-formatter="filtr => filtr.label"
+                    :custom-formatter="formatAddFilterItem"
                     open-on-focus
                     keep-first
                     icon-pack="fas"

From 2a22e8939c806f959714f0c6c15484ff2520581b Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 25 Apr 2024 22:17:59 -0500
Subject: [PATCH 250/542] Add index title to Change Password page

---
 tailbone/views/auth.py | 7 ++++---
 1 file changed, 4 insertions(+), 3 deletions(-)

diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py
index 7c4d26f0..f559a5c4 100644
--- a/tailbone/views/auth.py
+++ b/tailbone/views/auth.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,7 +24,7 @@
 Auth Views
 """
 
-from rattail.db.auth import authenticate_user, set_user_password
+from rattail.db.auth import set_user_password
 
 import colander
 from deform import widget as dfwidget
@@ -188,7 +188,8 @@ class AuthenticationView(View):
             self.request.session.flash("Your password has been changed.")
             return self.redirect(self.request.get_referrer())
 
-        return {'form': form}
+        return {'index_title': str(self.request.user),
+                'form': form}
 
     def become_root(self):
         """

From 8b3a9c9dad7bf0c9638b721402b2beaedfe9117b Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 25 Apr 2024 22:49:37 -0500
Subject: [PATCH 251/542] Use simple field labels when possible

only use template if it must include icons etc.
---
 tailbone/forms/core.py | 22 +++++++++++++++-------
 1 file changed, 15 insertions(+), 7 deletions(-)

diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py
index 496d59ee..f4fa79e4 100644
--- a/tailbone/forms/core.py
+++ b/tailbone/forms/core.py
@@ -1093,15 +1093,23 @@ class Form(object):
                 label_contents.append(HTML.literal('&nbsp; &nbsp;'))
                 label_contents.append(icon)
 
-            # nb. must apply hack to get <template #label> as final result
-            label_template = HTML.tag('template', c=label_contents,
-                                      **{'#label': 1})
-            label_template = label_template.replace(
-                HTML.literal('<template #label="1"'),
-                HTML.literal('<template #label'))
+            # only declare label template if it's complex
+            html = [html]
+            if len(label_contents) > 1:
+
+                # nb. must apply hack to get <template #label> as final result
+                label_template = HTML.tag('template', c=label_contents,
+                                          **{'#label': 1})
+                label_template = label_template.replace(
+                    HTML.literal('<template #label="1"'),
+                    HTML.literal('<template #label'))
+                html.insert(0, label_template)
+
+            else: # simple label
+                attrs['label'] = label
 
             # and finally wrap it all in a <b-field>
-            return HTML.tag('b-field', c=[label_template, html], **attrs)
+            return HTML.tag('b-field', c=html, **attrs)
 
         elif field: # hidden field
 

From ba3242205916e730b25c46e16f14247ccb6d9b2e Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 25 Apr 2024 23:56:21 -0500
Subject: [PATCH 252/542] Fix bug when saving user preferences theme

it was being saved even when it should have been empty value
---
 tailbone/views/users.py | 15 +++++++++++++--
 1 file changed, 13 insertions(+), 2 deletions(-)

diff --git a/tailbone/views/users.py b/tailbone/views/users.py
index fb81060a..e4182da9 100644
--- a/tailbone/views/users.py
+++ b/tailbone/views/users.py
@@ -612,14 +612,25 @@ class UserView(PrincipalMasterView):
             # display
             {'section': f'tailbone.{user.uuid}',
              'option': 'user_css',
-             'value': user_css},
+             'value': user_css,
+             'save_if_empty': False},
         ]
 
     def preferences_gather_settings(self, data, user):
         simple_settings = self.preferences_get_simple_settings(user)
-        return self.configure_gather_settings(
+        settings = self.configure_gather_settings(
             data, simple_settings=simple_settings, input_file_templates=False)
 
+        # TODO: ugh why does user_css come back as 'default' instead of None?
+        final_settings = []
+        for setting in settings:
+            if setting['name'].endswith('.user_css'):
+                if setting['value'] == 'default':
+                    continue
+            final_settings.append(setting)
+
+        return final_settings
+
     def preferences_remove_settings(self, user):
         app = self.get_rattail_app()
         simple_settings = self.preferences_get_simple_settings(user)

From 890ec64f3cb8ad64bbc45c4373631318670b9759 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 26 Apr 2024 11:02:22 -0500
Subject: [PATCH 253/542] Misc. template cleanup per oruga effort

---
 tailbone/forms/core.py                        | 14 ++++-
 tailbone/grids/core.py                        |  1 +
 tailbone/static/css/layout.css                |  5 ++
 tailbone/templates/forms/deform.mako          |  8 ++-
 tailbone/templates/generate_feature.mako      | 13 ++--
 .../templates/generated-projects/create.mako  |  3 +-
 tailbone/templates/grids/b-table.mako         |  4 +-
 tailbone/templates/grids/complete.mako        | 14 ++++-
 tailbone/templates/luigi/configure.mako       | 49 ++++++++-------
 tailbone/templates/master/view.mako           |  5 +-
 tailbone/templates/master/view_version.mako   | 63 ++++++++-----------
 tailbone/templates/settings/email/index.mako  | 10 ++-
 tailbone/templates/upgrades/configure.mako    | 12 ++--
 tailbone/templates/users/view.mako            |  1 +
 tailbone/templates/views/model/create.mako    |  2 +-
 tailbone/util.py                              |  2 +-
 tailbone/views/common.py                      |  3 +-
 tailbone/views/master.py                      |  2 +-
 tailbone/views/roles.py                       |  1 +
 tailbone/views/users.py                       |  4 +-
 20 files changed, 125 insertions(+), 91 deletions(-)

diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py
index f4fa79e4..beae42a4 100644
--- a/tailbone/forms/core.py
+++ b/tailbone/forms/core.py
@@ -1043,9 +1043,17 @@ class Form(object):
             if field_type:
                 attrs['type'] = field_type
             if messages:
-                attrs[':message'] = '[{}]'.format(', '.join([
-                    "'{}'".format(msg.replace("'", r"\'"))
-                    for msg in messages]))
+                if len(messages) == 1:
+                    msg = messages[0]
+                    if msg.startswith('`') and msg.endswith('`'):
+                        attrs[':message'] = msg
+                    else:
+                        attrs['message'] = msg
+                else:
+                    # nb. must pass an array as JSON string
+                    attrs[':message'] = '[{}]'.format(', '.join([
+                        "'{}'".format(msg.replace("'", r"\'"))
+                        for msg in messages]))
 
             # merge anything caller provided
             attrs.update(bfield_attrs)
diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py
index 41d75fc2..b428aaa6 100644
--- a/tailbone/grids/core.py
+++ b/tailbone/grids/core.py
@@ -1374,6 +1374,7 @@ class Grid(object):
         """
         context = dict(kwargs)
         context['grid'] = self
+        context['request'] = self.request
         context['data_prop'] = data_prop
         context['empty_labels'] = empty_labels
         if 'grid_columns' not in context:
diff --git a/tailbone/static/css/layout.css b/tailbone/static/css/layout.css
index 0761d001..ef5c5352 100644
--- a/tailbone/static/css/layout.css
+++ b/tailbone/static/css/layout.css
@@ -90,6 +90,11 @@ header span.header-text {
  * "object helper" panel
  ******************************/
 
+.object-helpers .panel {
+    margin: 1rem;
+    margin-bottom: 1.5rem;
+}
+
 .object-helpers .panel-heading {
     white-space: nowrap;
 }
diff --git a/tailbone/templates/forms/deform.mako b/tailbone/templates/forms/deform.mako
index db63a424..8a940347 100644
--- a/tailbone/templates/forms/deform.mako
+++ b/tailbone/templates/forms/deform.mako
@@ -55,12 +55,16 @@
         % if form.auto_disable_save or form.auto_disable:
             <b-button type="is-primary"
                       native-type="submit"
-                      :disabled="${form.component_studly}Submitting">
+                      :disabled="${form.component_studly}Submitting"
+                      icon-pack="fas"
+                      icon-left="save">
               {{ ${form.component_studly}ButtonText }}
             </b-button>
         % else:
             <b-button type="is-primary"
-                      native-type="submit">
+                      native-type="submit"
+                      icon-pack="fas"
+                      icon-left="save">
               ${getattr(form, 'submit_label', getattr(form, 'save_label', "Submit"))}
             </b-button>
         % endif
diff --git a/tailbone/templates/generate_feature.mako b/tailbone/templates/generate_feature.mako
index 18c9a7a2..6b0d781f 100644
--- a/tailbone/templates/generate_feature.mako
+++ b/tailbone/templates/generate_feature.mako
@@ -87,7 +87,7 @@
                   <div class="level-item">
                     <b-button type="is-primary"
                               icon-pack="fas"
-                              icon-left="fas fa-plus"
+                              icon-left="plus"
                               @click="addColumn()">
                       New Column
                     </b-button>
@@ -97,7 +97,7 @@
                   <div class="level-item">
                     <b-button type="is-danger"
                               icon-pack="fas"
-                              icon-left="fas fa-trash"
+                              icon-left="trash"
                               @click="new_table.columns = []"
                               :disabled="!new_table.columns.length">
                       Delete All
@@ -164,11 +164,13 @@
                   <section class="modal-card-body">
 
                     <b-field label="Name">
-                      <b-input v-model="editingColumnName"></b-input>
+                      <b-input v-model="editingColumnName"
+                               expanded />
                     </b-field>
 
                     <b-field label="Data Type">
-                      <b-input v-model="editingColumnDataType"></b-input>
+                      <b-input v-model="editingColumnDataType"
+                               expanded />
                     </b-field>
 
                     <b-field label="Nullable">
@@ -179,7 +181,8 @@
                     </b-field>
 
                     <b-field label="Description">
-                      <b-input v-model="editingColumnDescription"></b-input>
+                      <b-input v-model="editingColumnDescription"
+                               expanded />
                     </b-field>
 
                   </section>
diff --git a/tailbone/templates/generated-projects/create.mako b/tailbone/templates/generated-projects/create.mako
index 32d205a0..6c3af299 100644
--- a/tailbone/templates/generated-projects/create.mako
+++ b/tailbone/templates/generated-projects/create.mako
@@ -8,7 +8,8 @@
 <%def name="page_content()">
   % if project_type:
       <b-field grouped>
-        <b-field horizontal expanded label="Project Type">
+        <b-field horizontal expanded label="Project Type"
+                 class="is-expanded">
           ${project_type}
         </b-field>
         <once-button type="is-primary"
diff --git a/tailbone/templates/grids/b-table.mako b/tailbone/templates/grids/b-table.mako
index fbd36cbb..1ef3ba7b 100644
--- a/tailbone/templates/grids/b-table.mako
+++ b/tailbone/templates/grids/b-table.mako
@@ -76,12 +76,12 @@
       </b-table-column>
   % endif
 
-  <template slot="empty">
+  <template #empty>
     <div class="content has-text-grey has-text-centered">
       <p>
         <b-icon
            pack="fas"
-           icon="fas fa-sad-tear"
+           icon="sad-tear"
            size="is-large">
         </b-icon>
       </p>
diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako
index cb040cc4..af1a4f4d 100644
--- a/tailbone/templates/grids/complete.mako
+++ b/tailbone/templates/grids/complete.mako
@@ -199,7 +199,7 @@
                 </span>
                 <b-select v-model="perPage"
                           size="is-small"
-                          @input="loadAsyncData()">
+                          @input="perPageUpdated">
                   % for value in grid.get_pagesize_options():
                       <option value="${value}">${value}</option>
                   % endfor
@@ -459,9 +459,11 @@
 
               if (params === undefined || params === null) {
                   params = new URLSearchParams(this.getBasicParams())
-                  params.append('partial', true)
-                  params = params.toString()
+              } else {
+                  params = new URLSearchParams(params)
               }
+              params.append('partial', true)
+              params = params.toString()
 
               this.loading = true
               this.$http.get(`${'$'}{this.ajaxDataUrl}?${'$'}{params}`).then(({ data }) => {
@@ -520,6 +522,12 @@
               this.loadAsyncData()
           },
 
+          perPageUpdated(value) {
+              this.loadAsyncData({
+                  pagesize: value,
+              })
+          },
+
           onSort(field, order, event) {
 
               if (event.ctrlKey) {
diff --git a/tailbone/templates/luigi/configure.mako b/tailbone/templates/luigi/configure.mako
index c35e3216..548701a9 100644
--- a/tailbone/templates/luigi/configure.mako
+++ b/tailbone/templates/luigi/configure.mako
@@ -77,31 +77,31 @@
           <b-field label="Key"
                    :type="overnightTaskKey ? null : 'is-danger'">
             <b-input v-model.trim="overnightTaskKey"
-                     ref="overnightTaskKey">
-            </b-input>
+                     ref="overnightTaskKey"
+                     expanded />
           </b-field>
           <b-field label="Description"
                    :type="overnightTaskDescription ? null : 'is-danger'">
             <b-input v-model.trim="overnightTaskDescription"
-                     ref="overnightTaskDescription">
-            </b-input>
+                     ref="overnightTaskDescription"
+                     expanded />
           </b-field>
           <b-field label="Module">
-            <b-input v-model.trim="overnightTaskModule">
-            </b-input>
+            <b-input v-model.trim="overnightTaskModule"
+                     expanded />
           </b-field>
           <b-field label="Class Name">
-            <b-input v-model.trim="overnightTaskClass">
-            </b-input>
+            <b-input v-model.trim="overnightTaskClass"
+                     expanded />
           </b-field>
           <b-field label="Script">
-            <b-input v-model.trim="overnightTaskScript">
-            </b-input>
+            <b-input v-model.trim="overnightTaskScript"
+                     expanded />
           </b-field>
           <b-field label="Notes">
             <b-input v-model.trim="overnightTaskNotes"
-                     type="textarea">
-            </b-input>
+                     type="textarea"
+                     expanded />
           </b-field>
         </section>
 
@@ -194,19 +194,19 @@
           <b-field label="Key"
                    :type="backfillTaskKey ? null : 'is-danger'">
             <b-input v-model.trim="backfillTaskKey"
-                     ref="backfillTaskKey">
-            </b-input>
+                     ref="backfillTaskKey"
+                     expanded />
           </b-field>
           <b-field label="Description"
                    :type="backfillTaskDescription ? null : 'is-danger'">
             <b-input v-model.trim="backfillTaskDescription"
-                     ref="backfillTaskDescription">
-            </b-input>
+                     ref="backfillTaskDescription"
+                     expanded />
           </b-field>
           <b-field label="Script"
                    :type="backfillTaskScript ? null : 'is-danger'">
-            <b-input v-model.trim="backfillTaskScript">
-            </b-input>
+            <b-input v-model.trim="backfillTaskScript"
+                     expanded />
           </b-field>
           <b-field grouped>
             <b-field label="Orientation">
@@ -222,8 +222,8 @@
           </b-field>
           <b-field label="Notes">
             <b-input v-model.trim="backfillTaskNotes"
-                     type="textarea">
-            </b-input>
+                     type="textarea"
+                     expanded />
           </b-field>
         </section>
 
@@ -252,7 +252,8 @@
              expanded>
       <b-input name="rattail.luigi.url"
                v-model="simpleSettings['rattail.luigi.url']"
-               @input="settingsNeedSaved = true">
+               @input="settingsNeedSaved = true"
+               expanded>
       </b-input>
     </b-field>
 
@@ -261,7 +262,8 @@
              expanded>
       <b-input name="rattail.luigi.scheduler.supervisor_process_name"
                v-model="simpleSettings['rattail.luigi.scheduler.supervisor_process_name']"
-               @input="settingsNeedSaved = true">
+               @input="settingsNeedSaved = true"
+               expanded>
       </b-input>
     </b-field>
 
@@ -270,7 +272,8 @@
              expanded>
       <b-input name="rattail.luigi.scheduler.restart_command"
                v-model="simpleSettings['rattail.luigi.scheduler.restart_command']"
-               @input="settingsNeedSaved = true">
+               @input="settingsNeedSaved = true"
+               expanded>
       </b-input>
     </b-field>
 
diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako
index dcf1f8ee..5973da43 100644
--- a/tailbone/templates/master/view.mako
+++ b/tailbone/templates/master/view.mako
@@ -10,10 +10,9 @@
 <%def name="render_instance_header_title_extras()">
   % if master.touchable and master.has_perm('touch'):
       <b-button title="&quot;Touch&quot; this record to trigger sync"
-                icon-pack="fas"
-                icon-left="hand-pointer"
                 @click="touchRecord()"
                 :disabled="touchSubmitting">
+        <span><i class="fa fa-hand-pointer"></i></span>
       </b-button>
   % endif
   % if expose_versions:
@@ -34,7 +33,7 @@
   % if xref_buttons or xref_links:
       <nav class="panel">
         <p class="panel-heading">Cross-Reference</p>
-        <div class="panel-block buttons">
+        <div class="panel-block">
           <div style="display: flex; flex-direction: column; gap: 0.5rem;">
             % for button in xref_buttons:
                 ${button}
diff --git a/tailbone/templates/master/view_version.mako b/tailbone/templates/master/view_version.mako
index 6417dfb7..dfe03a64 100644
--- a/tailbone/templates/master/view_version.mako
+++ b/tailbone/templates/master/view_version.mako
@@ -19,48 +19,39 @@
 </%def>
 
 <%def name="page_content()">
-## TODO: this was basically copied from Revel diff template..need to abstract
 
-<div class="form-wrapper">
+  <div class="form-wrapper" style="margin: 1rem; 0;">
+    <div class="form">
 
-  <div class="form">
+      <b-field label="Changed" horizontal>
+        <span>${h.pretty_datetime(request.rattail_config, changed)}</span>
+      </b-field>
+
+      <b-field label="Changed by" horizontal>
+        <span>${transaction.user or ''}</span>
+      </b-field>
+
+      <b-field label="IP Address" horizontal>
+        <span>${transaction.remote_addr}</span>
+      </b-field>
+
+      <b-field label="Comment" horizontal>
+        <span>${transaction.meta.get('comment') or ''}</span>
+      </b-field>
+
+      <b-field label="TXN ID" horizontal>
+        <span>${transaction.id}</span>
+      </b-field>
 
-    <div class="field-wrapper">
-      <label>Changed</label>
-      <div class="field">${h.pretty_datetime(request.rattail_config, changed)}</div>
     </div>
-
-    <div class="field-wrapper">
-      <label>Changed by</label>
-      <div class="field">${transaction.user or ''}</div>
-    </div>
-
-    <div class="field-wrapper">
-      <label>IP Address</label>
-      <div class="field">${transaction.remote_addr}</div>
-    </div>
-
-    <div class="field-wrapper">
-      <label>Comment</label>
-      <div class="field">${transaction.meta.get('comment') or ''}</div>
-    </div>
-
-    <div class="field-wrapper">
-      <label>TXN ID</label>
-      <div class="field">${transaction.id}</div>
-    </div>
-
   </div>
 
-</div><!-- form-wrapper -->
-
-<div class="versions-wrapper">
-  % for diff in version_diffs:
-      <h4 class="is-size-4 block">${diff.title}</h4>
-      ${diff.render_html()}
-  % endfor
-</div>
-
+  <div class="versions-wrapper">
+    % for diff in version_diffs:
+        <h4 class="is-size-4 block">${diff.title}</h4>
+        ${diff.render_html()}
+    % endfor
+  </div>
 </%def>
 
 
diff --git a/tailbone/templates/settings/email/index.mako b/tailbone/templates/settings/email/index.mako
index 11881285..dbc963b9 100644
--- a/tailbone/templates/settings/email/index.mako
+++ b/tailbone/templates/settings/email/index.mako
@@ -49,10 +49,16 @@
             let url = '${url('{}.toggle_hidden'.format(route_prefix))}'
             let params = {
                 key: row.key,
-                hidden: row.hidden == 'No'? true : false,
+                hidden: row.hidden == 'No' ? true : false,
             }
             this.submitForm(url, params, response => {
-                row.hidden = params.hidden ? 'Yes' : 'No'
+                // must update "original" data row, since our row arg
+                // may just be a proxy and not trigger view refresh
+                for (let email of this.data) {
+                    if (email.key == row.key) {
+                        email.hidden = params.hidden ? 'Yes' : 'No'
+                    }
+                }
             })
         }
 
diff --git a/tailbone/templates/upgrades/configure.mako b/tailbone/templates/upgrades/configure.mako
index 4172c2b1..fd2c60ad 100644
--- a/tailbone/templates/upgrades/configure.mako
+++ b/tailbone/templates/upgrades/configure.mako
@@ -66,21 +66,21 @@
                      :type="upgradeSystemKey ? null : 'is-danger'">
               <b-input v-model.trim="upgradeSystemKey"
                        ref="upgradeSystemKey"
-                       :disabled="upgradeSystemKey == 'rattail'">
-              </b-input>
+                       :disabled="upgradeSystemKey == 'rattail'"
+                       expanded />
             </b-field>
             <b-field label="Label"
                      :type="upgradeSystemLabel ? null : 'is-danger'">
               <b-input v-model.trim="upgradeSystemLabel"
                        ref="upgradeSystemLabel"
-                       :disabled="upgradeSystemKey == 'rattail'">
-              </b-input>
+                       :disabled="upgradeSystemKey == 'rattail'"
+                       expanded />
             </b-field>
             <b-field label="Command"
                      :type="upgradeSystemCommand ? null : 'is-danger'">
               <b-input v-model.trim="upgradeSystemCommand"
-                       ref="upgradeSystemCommand">
-              </b-input>
+                       ref="upgradeSystemCommand"
+                       expanded />
             </b-field>
           </section>
 
diff --git a/tailbone/templates/users/view.mako b/tailbone/templates/users/view.mako
index f65b6d1c..94931e52 100644
--- a/tailbone/templates/users/view.mako
+++ b/tailbone/templates/users/view.mako
@@ -40,6 +40,7 @@
               <b-field label="Description"
                        :type="{'is-danger': !apiNewTokenDescription}">
                 <b-input v-model.trim="apiNewTokenDescription"
+                         expanded
                          ref="apiNewTokenDescription">
                 </b-input>
               </b-field>
diff --git a/tailbone/templates/views/model/create.mako b/tailbone/templates/views/model/create.mako
index 6a542c52..c5e22cfb 100644
--- a/tailbone/templates/views/model/create.mako
+++ b/tailbone/templates/views/model/create.mako
@@ -263,7 +263,7 @@ def includeme(config):
   ${parent.modify_this_page_vars()}
   <script type="text/javascript">
 
-    ThisPageData.activeStep = null
+    ThisPageData.activeStep = 'enter-details'
 
     ThisPageData.modelNames = ${json.dumps(model_names)|n}
     ThisPageData.modelName = null
diff --git a/tailbone/util.py b/tailbone/util.py
index db6ce4a3..f6678316 100644
--- a/tailbone/util.py
+++ b/tailbone/util.py
@@ -83,7 +83,7 @@ def get_form_data(request):
     # TODO: this seems to work for our use case at least, but perhaps
     # there is a better way?  see also
     # https://docs.pylonsproject.org/projects/pyramid/en/latest/api/request.html#pyramid.request.Request.is_xhr
-    if request.is_xhr and not request.POST:
+    if (request.is_xhr or request.content_type == 'application/json') and not request.POST:
         return request.json_body
     return request.POST
 
diff --git a/tailbone/views/common.py b/tailbone/views/common.py
index 4632a285..35332b6b 100644
--- a/tailbone/views/common.py
+++ b/tailbone/views/common.py
@@ -187,7 +187,8 @@ class CommonView(View):
             data['client_ip'] = self.request.client_addr
             app.send_email('user_feedback', data=data)
             return {'ok': True}
-        return {'error': "Form did not validate!"}
+        dform = form.make_deform_form()
+        return {'error': str(dform.error)}
 
     def consume_batch_id(self):
         """
diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index 20dc0dcf..87c592ee 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -3003,7 +3003,7 @@ class MasterView(View):
         button = HTML.tag('b-button', **btn_kw)
         button = str(button)
         button = button.replace('<b-button ',
-                                '<b-button tag="a"')
+                                '<b-button tag="a" ')
         button = HTML.literal(button)
         return button
 
diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py
index 19faabd8..ddf08dd4 100644
--- a/tailbone/views/roles.py
+++ b/tailbone/views/roles.py
@@ -249,6 +249,7 @@ class RoleView(PrincipalMasterView):
         factory = self.get_grid_factory()
         g = factory(
             key='{}.users'.format(route_prefix),
+            request=self.request,
             data=[],
             columns=[
                 'full_name',
diff --git a/tailbone/views/users.py b/tailbone/views/users.py
index e4182da9..893bf7c4 100644
--- a/tailbone/views/users.py
+++ b/tailbone/views/users.py
@@ -47,6 +47,7 @@ class UserView(PrincipalMasterView):
     """
     model_class = User
     has_rows = True
+    rows_title = "User Events"
     model_row_class = UserEvent
     has_versions = True
     touchable = True
@@ -225,7 +226,7 @@ class UserView(PrincipalMasterView):
         #     f.set_required('password')
 
         # api_tokens
-        if self.creating or self.editing:
+        if self.creating or self.editing or self.deleting:
             f.remove('api_tokens')
         elif self.has_perm('manage_api_tokens'):
             f.set_renderer('api_tokens', self.render_api_tokens)
@@ -283,6 +284,7 @@ class UserView(PrincipalMasterView):
 
         factory = self.get_grid_factory()
         g = factory(
+            request=self.request,
             key='{}.api_tokens'.format(route_prefix),
             data=[],
             columns=['description', 'created'],

From 098ed5b1cfc5f256b21fe91482a56755a734a2e3 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 26 Apr 2024 11:02:48 -0500
Subject: [PATCH 254/542] Improve keydown handling for grid Add Filter
 autocomplete

should work the same, but this way also works with oruga
---
 tailbone/templates/grids/complete.mako | 18 +++++++++++++-----
 tailbone/templates/grids/filters.mako  |  5 ++---
 2 files changed, 15 insertions(+), 8 deletions(-)

diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako
index af1a4f4d..0a5c3780 100644
--- a/tailbone/templates/grids/complete.mako
+++ b/tailbone/templates/grids/complete.mako
@@ -581,26 +581,34 @@
               location.href = url
           },
 
-          addFilterButton(event) {
+          addFilterInit() {
               this.addFilterShow = true
+
               this.$nextTick(() => {
+                  const input = this.$refs.addFilterAutocomplete.$el.querySelector('input')
+                  input.addEventListener('keydown', this.addFilterKeydown)
                   this.$refs.addFilterAutocomplete.focus()
               })
           },
 
+          addFilterHide() {
+              const input = this.$refs.addFilterAutocomplete.$el.querySelector('input')
+              input.removeEventListener('keydown', this.addFilterKeydown)
+              this.addFilterTerm = ''
+              this.addFilterShow = false
+          },
+
           addFilterKeydown(event) {
 
               // ESC will clear searchbox
               if (event.which == 27) {
-                  this.addFilterTerm = ''
-                  this.addFilterShow = false
+                  this.addFilterHide()
               }
           },
 
           addFilterSelect(filtr) {
               this.addFilter(filtr.key)
-              this.addFilterTerm = ''
-              this.addFilterShow = false
+              this.addFilterHide()
           },
 
           addFilter(filter_key) {
diff --git a/tailbone/templates/grids/filters.mako b/tailbone/templates/grids/filters.mako
index cb6ec9e2..9a80b911 100644
--- a/tailbone/templates/grids/filters.mako
+++ b/tailbone/templates/grids/filters.mako
@@ -22,7 +22,7 @@
     <b-button v-if="!addFilterShow"
               icon-pack="fas"
               icon-left="plus"
-              @click="addFilterButton">
+              @click="addFilterInit()">
       Add Filter
     </b-button>
 
@@ -38,8 +38,7 @@
                     icon-pack="fas"
                     clearable
                     clear-on-select
-                    @select="addFilterSelect"
-                    @keydown.native="addFilterKeydown">
+                    @select="addFilterSelect">
     </b-autocomplete>
 
     <b-button @click="resetView()"

From 5aa8d1f9a340ea44b1461958ee279769e8ef64b7 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 26 Apr 2024 20:04:38 -0500
Subject: [PATCH 255/542] Use buefy table for "find principal by perm" results

this should work for oruga as well
---
 .../templates/principal/find_by_perm.mako     | 34 ++++++++++++-----
 tailbone/templates/roles/find_by_perm.mako    | 21 ----------
 tailbone/templates/users/find_by_perm.mako    | 23 -----------
 tailbone/views/principal.py                   | 38 ++++++++++++++++++-
 tailbone/views/roles.py                       | 11 ++++++
 tailbone/views/users.py                       | 15 ++++++++
 6 files changed, 88 insertions(+), 54 deletions(-)
 delete mode 100644 tailbone/templates/roles/find_by_perm.mako
 delete mode 100644 tailbone/templates/users/find_by_perm.mako

diff --git a/tailbone/templates/principal/find_by_perm.mako b/tailbone/templates/principal/find_by_perm.mako
index 3bf47dc1..e2672985 100644
--- a/tailbone/templates/principal/find_by_perm.mako
+++ b/tailbone/templates/principal/find_by_perm.mako
@@ -24,13 +24,13 @@
                             ref="permissionGroupAutocomplete"
                             v-model="permissionGroupTerm"
                             :data="permissionGroupChoices"
-                            field="groupkey"
                             :custom-formatter="filtr => filtr.label"
                             open-on-focus
                             keep-first
                             icon-pack="fas"
                             clearable
                             clear-on-select
+                            expanded
                             @select="permissionGroupSelect">
             </b-autocomplete>
             <b-button v-if="selectedGroup"
@@ -45,13 +45,13 @@
                             ref="permissionAutocomplete"
                             v-model="permissionTerm"
                             :data="permissionChoices"
-                            field="permkey"
                             :custom-formatter="filtr => filtr.label"
                             open-on-focus
                             keep-first
                             icon-pack="fas"
                             clearable
                             clear-on-select
+                            expanded
                             @select="permissionSelect">
             </b-autocomplete>
             <b-button v-if="selectedPermission"
@@ -80,17 +80,26 @@
       ${h.end_form()}
 
       % if principals is not None:
-      <div class="grid half">
-        <br />
-        <h2>Found ${len(principals)} ${model_title_plural} with permission: ${selected_permission}</h2>
-        ${self.principal_table()}
-      </div>
+          <br />
+          <p class="block">
+            Found ${len(principals)} ${model_title_plural} with permission:
+            <span class="has-text-weight-bold">${selected_permission}</span>
+          </p>
+          ${self.principal_table()}
       % endif
 
     </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">
@@ -105,7 +114,7 @@
   ${parent.make_this_page_component()}
   <script type="text/javascript">
 
-    Vue.component('find-principals', {
+    const FindPrincipals = {
         template: '#find-principals-template',
         props: {
             permissionGroups: Object,
@@ -120,6 +129,7 @@
                 selectedPermission: ${json.dumps(selected_permission)|n},
                 selectedPermissionLabel: ${json.dumps(selected_permission_label or '')|n},
                 formSubmitting: false,
+                principalsData: ${json.dumps(principals_data)|n},
             }
         },
 
@@ -187,6 +197,10 @@
 
         methods: {
 
+            navigateTo(url) {
+                location.href = url
+            },
+
             permissionGroupSelect(option) {
                 this.selectedPermission = null
                 this.selectedPermissionLabel = null
@@ -224,7 +238,9 @@
                 })
             },
         }
-    })
+    }
+
+    Vue.component('find-principals', FindPrincipals)
 
   </script>
 </%def>
diff --git a/tailbone/templates/roles/find_by_perm.mako b/tailbone/templates/roles/find_by_perm.mako
deleted file mode 100644
index 8908d12e..00000000
--- a/tailbone/templates/roles/find_by_perm.mako
+++ /dev/null
@@ -1,21 +0,0 @@
-## -*- coding: utf-8 -*-
-<%inherit file="/principal/find_by_perm.mako" />
-
-<%def name="principal_table()">
-  <table>
-    <thead>
-      <tr>
-        <th>Name</th>
-      </tr>
-    </thead>
-    <tbody>
-      % for role in principals:
-          <tr>
-            <td>${h.link_to(role.name, url('roles.view', uuid=role.uuid))}</td>
-          </tr>
-      % endfor
-    </tbody>
-  </table>
-</%def>
-
-${parent.body()}
diff --git a/tailbone/templates/users/find_by_perm.mako b/tailbone/templates/users/find_by_perm.mako
deleted file mode 100644
index 59fcf643..00000000
--- a/tailbone/templates/users/find_by_perm.mako
+++ /dev/null
@@ -1,23 +0,0 @@
-## -*- coding: utf-8 -*-
-<%inherit file="/principal/find_by_perm.mako" />
-
-<%def name="principal_table()">
-  <table>
-    <thead>
-      <tr>
-        <th>Username</th>
-        <th>Person</th>
-      </tr>
-    </thead>
-    <tbody>
-      % for user in principals:
-          <tr>
-            <td>${h.link_to(user.username, url('users.view', uuid=user.uuid))}</td>
-            <td>${user.person or ''}</td>
-          </tr>
-      % endfor
-    </tbody>
-  </table>
-</%def>
-
-${parent.body()}
diff --git a/tailbone/views/principal.py b/tailbone/views/principal.py
index 6bb623d1..fb09306b 100644
--- a/tailbone/views/principal.py
+++ b/tailbone/views/principal.py
@@ -65,14 +65,21 @@ class PrincipalMasterView(MasterView):
         principals = None
         permission_group = self.request.GET.get('permission_group')
         permission = self.request.GET.get('permission')
+        grid = None
         if permission_group and permission:
             principals = self.find_principals_with_permission(self.Session(),
                                                               permission)
+            grid = self.find_by_perm_make_results_grid(principals)
         else: # otherwise clear both values
             permission_group = None
             permission = None
 
-        context = {'permissions': sorted_perms, 'principals': principals}
+        context = {
+            'permissions': sorted_perms,
+            'principals': principals,
+            'principals_data': self.find_by_perm_results_data(principals),
+            'grid': grid,
+        }
 
         perms = self.get_perms_data(sorted_perms)
         context['perms_data'] = perms
@@ -114,6 +121,35 @@ class PrincipalMasterView(MasterView):
 
         return data
 
+    def find_by_perm_make_results_grid(self, principals):
+        route_prefix = self.get_route_prefix()
+        factory = self.get_grid_factory()
+        g = factory(key=f'{route_prefix}.results',
+                    request=self.request,
+                    data=[],
+                    columns=[],
+                    main_actions=[
+                        self.make_action('view', icon='eye',
+                                         click_handler='navigateTo(props.row._url)'),
+                    ])
+        self.find_by_perm_configure_results_grid(g)
+        return g
+
+    def find_by_perm_configure_results_grid(self, g):
+        pass
+
+    def find_by_perm_results_data(self, principals):
+        data = []
+        for principal in principals or []:
+            data.append(self.find_by_perm_normalize(principal))
+        return data
+
+    def find_by_perm_normalize(self, principal):
+        return {
+            'uuid': principal.uuid,
+            '_url': self.get_action_url('view', principal),
+        }
+
     @classmethod
     def defaults(cls, config):
         cls._principal_defaults(config)
diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py
index ddf08dd4..0316ea87 100644
--- a/tailbone/views/roles.py
+++ b/tailbone/views/roles.py
@@ -406,6 +406,17 @@ class RoleView(PrincipalMasterView):
                 roles.append(role)
         return roles
 
+    def find_by_perm_configure_results_grid(self, g):
+        g.append('name')
+        g.set_link('name')
+
+    def find_by_perm_normalize(self, role):
+        data = super().find_by_perm_normalize(role)
+
+        data['name'] = role.name
+
+        return data
+
     def download_permissions_matrix(self):
         """
         View which renders the complete role / permissions matrix data into an
diff --git a/tailbone/views/users.py b/tailbone/views/users.py
index 893bf7c4..9cc1b5b5 100644
--- a/tailbone/views/users.py
+++ b/tailbone/views/users.py
@@ -521,6 +521,21 @@ class UserView(PrincipalMasterView):
                 users.append(user)
         return users
 
+    def find_by_perm_configure_results_grid(self, g):
+        g.append('username')
+        g.set_link('username')
+
+        g.append('person')
+        g.set_link('person')
+
+    def find_by_perm_normalize(self, user):
+        data = super().find_by_perm_normalize(user)
+
+        data['username'] = user.username
+        data['person'] = str(user.person or '')
+
+        return data
+
     def preferences(self, user=None):
         """
         View to modify preferences for a particular user.

From 2eaeb1891df9ba61584d72c6e9bec2ec1bd569d0 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 24 Apr 2024 16:13:14 -0500
Subject: [PATCH 256/542] Add initial support for Vue 3 + Oruga, via
 "butterball" theme

just a savepoint, still have lots to do and test before this really works
---
 tailbone/forms/core.py                        |   26 +-
 tailbone/subscribers.py                       |   33 +-
 tailbone/templates/appinfo/configure.mako     |   38 +-
 tailbone/templates/appinfo/index.mako         |   22 +-
 tailbone/templates/datasync/configure.mako    |  105 +-
 tailbone/templates/datasync/status.mako       |   64 +-
 tailbone/templates/forms/deform.mako          |    2 +
 tailbone/templates/generate_feature.mako      |   57 +-
 tailbone/templates/grids/b-table.mako         |   18 +-
 tailbone/templates/grids/complete.mako        |   46 +-
 .../templates/grids/filter-components.mako    |   22 +-
 tailbone/templates/importing/configure.mako   |   36 +-
 tailbone/templates/luigi/configure.mako       |   80 +-
 tailbone/templates/luigi/index.mako           |   48 +-
 tailbone/templates/master/merge.mako          |    2 +
 tailbone/templates/master/view.mako           |   34 +-
 tailbone/templates/page.mako                  |    1 +
 tailbone/templates/page_help.mako             |  174 ++-
 .../templates/principal/find_by_perm.mako     |    2 +
 tailbone/templates/settings/email/view.mako   |    2 +
 .../templates/themes/butterball/base.mako     | 1141 +++++++++++++++++
 .../themes/butterball/buefy-components.mako   |  679 ++++++++++
 .../themes/butterball/buefy-plugin.mako       |   32 +
 .../themes/butterball/field-components.mako   |  382 ++++++
 .../themes/butterball/http-plugin.mako        |  100 ++
 .../templates/themes/butterball/progress.mako |  244 ++++
 tailbone/templates/upgrades/configure.mako    |   32 +-
 tailbone/util.py                              |   63 +-
 tailbone/views/settings.py                    |   42 +
 29 files changed, 3212 insertions(+), 315 deletions(-)
 create mode 100644 tailbone/templates/themes/butterball/base.mako
 create mode 100644 tailbone/templates/themes/butterball/buefy-components.mako
 create mode 100644 tailbone/templates/themes/butterball/buefy-plugin.mako
 create mode 100644 tailbone/templates/themes/butterball/field-components.mako
 create mode 100644 tailbone/templates/themes/butterball/http-plugin.mako
 create mode 100644 tailbone/templates/themes/butterball/progress.mako

diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py
index beae42a4..857bfccf 100644
--- a/tailbone/forms/core.py
+++ b/tailbone/forms/core.py
@@ -1103,18 +1103,22 @@ class Form(object):
 
             # only declare label template if it's complex
             html = [html]
-            if len(label_contents) > 1:
-
-                # nb. must apply hack to get <template #label> as final result
-                label_template = HTML.tag('template', c=label_contents,
-                                          **{'#label': 1})
-                label_template = label_template.replace(
-                    HTML.literal('<template #label="1"'),
-                    HTML.literal('<template #label'))
-                html.insert(0, label_template)
-
-            else: # simple label
+            # TODO: figure out why complex label does not work for oruga
+            if self.request.use_oruga:
                 attrs['label'] = label
+            else:
+                if len(label_contents) > 1:
+
+                    # nb. must apply hack to get <template #label> as final result
+                    label_template = HTML.tag('template', c=label_contents,
+                                              **{'#label': 1})
+                    label_template = label_template.replace(
+                        HTML.literal('<template #label="1"'),
+                        HTML.literal('<template #label'))
+                    html.insert(0, label_template)
+
+                else: # simple label
+                    attrs['label'] = label
 
             # and finally wrap it all in a <b-field>
             return HTML.tag('b-field', c=html, **attrs)
diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py
index 1dc0592a..9b56335a 100644
--- a/tailbone/subscribers.py
+++ b/tailbone/subscribers.py
@@ -27,7 +27,9 @@ Event Subscribers
 import six
 import json
 import datetime
+import logging
 import warnings
+from collections import OrderedDict
 
 import rattail
 
@@ -41,7 +43,11 @@ from tailbone import helpers
 from tailbone.db import Session
 from tailbone.config import csrf_header_name, should_expose_websockets
 from tailbone.menus import make_simple_menus
-from tailbone.util import get_available_themes, get_global_search_options
+from tailbone.util import (get_available_themes, get_global_search_options,
+                           should_use_oruga)
+
+
+log = logging.getLogger(__name__)
 
 
 def new_request(event):
@@ -92,6 +98,11 @@ def new_request(event):
 
     request.set_property(user, reify=True)
 
+    def use_oruga(request):
+        return should_use_oruga(request)
+
+    request.set_property(use_oruga, reify=True)
+
     # assign client IP address to the session, for sake of versioning
     Session().continuum_remote_addr = request.client_addr
 
@@ -119,6 +130,25 @@ def new_request(event):
             return False
         request.has_any_perm = has_any_perm
 
+        def register_component(tagname, classname):
+            """
+            Register a Vue 3 component, so the base template knows to
+            declare it for use within the app (page).
+            """
+            if not hasattr(request, '_tailbone_registered_components'):
+                request._tailbone_registered_components = OrderedDict()
+
+            if tagname in request._tailbone_registered_components:
+                log.warning("component with tagname '%s' already registered "
+                            "with class '%s' but we are replacing that with "
+                            "class '%s'",
+                            tagname,
+                            request._tailbone_registered_components[tagname],
+                            classname)
+
+            request._tailbone_registered_components[tagname] = classname
+        request.register_component = register_component
+
 
 def before_render(event):
     """
@@ -143,6 +173,7 @@ def before_render(event):
     renderer_globals['colander'] = colander
     renderer_globals['deform'] = deform
     renderer_globals['csrf_header_name'] = csrf_header_name(request.rattail_config)
+    renderer_globals['b'] = 'o' if request.use_oruga else 'b' # for buefy
 
     # theme  - we only want do this for classic web app, *not* API
     # TODO: so, clearly we need a better way to distinguish the two
diff --git a/tailbone/templates/appinfo/configure.mako b/tailbone/templates/appinfo/configure.mako
index 8483a7a2..657e98cf 100644
--- a/tailbone/templates/appinfo/configure.mako
+++ b/tailbone/templates/appinfo/configure.mako
@@ -100,27 +100,27 @@
   <h3 class="block is-size-3">Web Libraries</h3>
   <div class="block" style="padding-left: 2rem;">
 
-    <b-table :data="weblibs">
+    <${b}-table :data="weblibs">
 
-      <b-table-column field="title"
+      <${b}-table-column field="title"
                       label="Name"
                       v-slot="props">
         {{ props.row.title }}
-      </b-table-column>
+      </${b}-table-column>
 
-      <b-table-column field="configured_version"
+      <${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>
 
-      <b-table-column field="configured_url"
+      <${b}-table-column field="configured_url"
                       label="URL Override"
                       v-slot="props">
         {{ props.row.configured_url }}
-      </b-table-column>
+      </${b}-table-column>
 
-      <b-table-column field="live_url"
+      <${b}-table-column field="live_url"
                       label="Effective (Live) URL"
                       v-slot="props">
         <span v-if="props.row.modified"
@@ -130,19 +130,23 @@
         <span v-if="!props.row.modified">
           {{ props.row.live_url }}
         </span>
-      </b-table-column>
+      </${b}-table-column>
 
-      <b-table-column field="actions"
+      <${b}-table-column field="actions"
                       label="Actions"
                       v-slot="props">
         <a href="#"
            @click.prevent="editWebLibraryInit(props.row)">
-          <i class="fas fa-edit"></i>
+          % if request.use_oruga:
+              <o-icon icon="edit" />
+          % else:
+              <i class="fas fa-edit"></i>
+          % endif
           Edit
         </a>
-      </b-table-column>
+      </${b}-table-column>
 
-    </b-table>
+    </${b}-table>
 
     % for weblib in weblibs:
         ${h.hidden('tailbone.libver.{}'.format(weblib['key']), **{':value': "simpleSettings['tailbone.libver.{}']".format(weblib['key'])})}
@@ -175,14 +179,14 @@
           </b-field>
 
           <b-field label="Override URL">
-            <b-input v-model="editWebLibraryURL">
-            </b-input>
+            <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>
-            </b-input>
+                     disabled
+                     expanded />
           </b-field>
 
         </section>
diff --git a/tailbone/templates/appinfo/index.mako b/tailbone/templates/appinfo/index.mako
index ac67e582..73f53920 100644
--- a/tailbone/templates/appinfo/index.mako
+++ b/tailbone/templates/appinfo/index.mako
@@ -28,10 +28,11 @@
 
   </div>
 
-  <b-collapse class="panel" open>
+  <${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
@@ -57,30 +58,31 @@
 
     <div class="panel-block">
       <div style="width: 100%;">
-        <b-table :data="configFiles">
+        <${b}-table :data="configFiles">
           
-          <b-table-column field="priority"
+          <${b}-table-column field="priority"
                           label="Priority"
                           v-slot="props">
             {{ props.row.priority }}
-          </b-table-column>
+          </${b}-table-column>
 
-          <b-table-column field="path"
+          <${b}-table-column field="path"
                           label="File Path"
                           v-slot="props">
             {{ props.row.path }}
-          </b-table-column>
+          </${b}-table-column>
 
-        </b-table>
+        </${b}-table>
       </div>
     </div>
-  </b-collapse>
+  </${b}-collapse>
 
-  <b-collapse class="panel"
+  <${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
@@ -109,7 +111,7 @@
         ${parent.render_grid_component()}
       </div>
     </div>
-  </b-collapse>
+  </${b}-collapse>
 </%def>
 
 <%def name="modify_this_page_vars()">
diff --git a/tailbone/templates/datasync/configure.mako b/tailbone/templates/datasync/configure.mako
index 6dc13e14..8b0f5e51 100644
--- a/tailbone/templates/datasync/configure.mako
+++ b/tailbone/templates/datasync/configure.mako
@@ -48,7 +48,12 @@
   ${h.hidden('profiles', **{':value': 'JSON.stringify(profilesData)'})}
 
   <b-notification type="is-warning"
-                  :active.sync="showConfigFilesNote">
+                  % if request.use_oruga:
+                  v-model:active="showConfigFilesNote"
+                  % else:
+                  :active.sync="showConfigFilesNote"
+                  % endif
+                  >
     ## TODO: should link to some ratman page here, yes?
     <p class="block">
       This tool works by modifying settings in the DB.&nbsp; It
@@ -101,52 +106,52 @@
     </div>
   </div>
 
-  <b-table :data="filteredProfilesData"
+  <${b}-table :data="filteredProfilesData"
            :row-class="(row, i) => row.enabled ? null : 'has-background-warning'">
-      <b-table-column field="key"
+      <${b}-table-column field="key"
                       label="Watcher Key"
                       v-slot="props">
         {{ props.row.key }}
-      </b-table-column>
-      <b-table-column field="watcher_spec"
+      </${b}-table-column>
+      <${b}-table-column field="watcher_spec"
                       label="Watcher Spec"
                       v-slot="props">
         {{ props.row.watcher_spec }}
-      </b-table-column>
-      <b-table-column field="watcher_dbkey"
+      </${b}-table-column>
+      <${b}-table-column field="watcher_dbkey"
                       label="DB Key"
                       v-slot="props">
         {{ props.row.watcher_dbkey }}
-      </b-table-column>
-      <b-table-column field="watcher_delay"
+      </${b}-table-column>
+      <${b}-table-column field="watcher_delay"
                       label="Loop Delay"
                       v-slot="props">
         {{ props.row.watcher_delay }} sec
-      </b-table-column>
-      <b-table-column field="watcher_retry_attempts"
+      </${b}-table-column>
+      <${b}-table-column field="watcher_retry_attempts"
                       label="Attempts / Delay"
                       v-slot="props">
         {{ props.row.watcher_retry_attempts }} / {{ props.row.watcher_retry_delay }} sec
-      </b-table-column>
-      <b-table-column field="watcher_default_runas"
+      </${b}-table-column>
+      <${b}-table-column field="watcher_default_runas"
                       label="Default Runas"
                       v-slot="props">
         {{ props.row.watcher_default_runas }}
-      </b-table-column>
-      <b-table-column label="Consumers"
+      </${b}-table-column>
+      <${b}-table-column label="Consumers"
                       v-slot="props">
         {{ consumerShortList(props.row) }}
-      </b-table-column>
-##         <b-table-column field="notes" label="Notes">
+      </${b}-table-column>
+##         <${b}-table-column field="notes" label="Notes">
 ##           TODO
 ##           ## {{ props.row.notes }}
-##         </b-table-column>
-      <b-table-column field="enabled"
+##         </${b}-table-column>
+      <${b}-table-column field="enabled"
                       label="Enabled"
                       v-slot="props">
         {{ props.row.enabled ? "Yes" : "No" }}
-      </b-table-column>
-      <b-table-column label="Actions"
+      </${b}-table-column>
+      <${b}-table-column label="Actions"
                       v-slot="props"
                       v-if="useProfileSettings">
         <a href="#"
@@ -162,14 +167,14 @@
           <i class="fas fa-trash"></i>
           Delete
         </a>
-      </b-table-column>
-      <template slot="empty">
+      </${b}-table-column>
+      <template #empty>
         <section class="section">
           <div class="content has-text-grey has-text-centered">
             <p>
               <b-icon
                  pack="fas"
-                 icon="fas fa-sad-tear"
+                 icon="sad-tear"
                  size="is-large">
               </b-icon>
             </p>
@@ -177,7 +182,7 @@
           </div>
         </section>
       </template>
-  </b-table>
+  </${b}-table>
 
   <b-modal :active.sync="editProfileShowDialog">
     <div class="card">
@@ -199,12 +204,12 @@
 
         </b-field>
 
-        <b-field grouped>
+        <b-field grouped expanded>
 
           <b-field label="Watcher Spec" 
                    :type="editingProfileWatcherSpec ? null : 'is-danger'"
                    expanded>
-            <b-input v-model="editingProfileWatcherSpec">
+            <b-input v-model="editingProfileWatcherSpec" expanded>
             </b-input>
           </b-field>
 
@@ -293,19 +298,19 @@
           </div>
 
 
-          <b-table :data="editingProfilePendingWatcherKwargs"
+          <${b}-table :data="editingProfilePendingWatcherKwargs"
                    style="margin-left: 1rem;">
-            <b-table-column field="key"
+            <${b}-table-column field="key"
                             label="Key"
                             v-slot="props">
               {{ props.row.key }}
-            </b-table-column>
-            <b-table-column field="value"
+            </${b}-table-column>
+            <${b}-table-column field="value"
                             label="Value"
                             v-slot="props">
               {{ props.row.value }}
-            </b-table-column>
-            <b-table-column label="Actions"
+            </${b}-table-column>
+            <${b}-table-column label="Actions"
                             v-slot="props">
               <a href="#"
                  @click.prevent="editProfileWatcherKwarg(props.row)">
@@ -319,14 +324,14 @@
                 <i class="fas fa-trash"></i>
                 Delete
               </a>
-            </b-table-column>
-            <template slot="empty">
+            </${b}-table-column>
+            <template #empty>
               <section class="section">
                 <div class="content has-text-grey has-text-centered">
                   <p>
                     <b-icon
                       pack="fas"
-                      icon="fas fa-sad-tear"
+                      icon="sad-tear"
                       size="is-large">
                     </b-icon>
                   </p>
@@ -334,7 +339,7 @@
                 </div>
               </section>
             </template>
-          </b-table>
+          </${b}-table>
 
         </div>
 
@@ -350,19 +355,19 @@
               </b-checkbox>
             </b-field>
 
-            <b-table :data="editingProfilePendingConsumers"
+            <${b}-table :data="editingProfilePendingConsumers"
                      v-if="!editingProfileWatcherConsumesSelf"
                      :row-class="(row, i) => row.enabled ? null : 'has-background-warning'">
-              <b-table-column field="key"
+              <${b}-table-column field="key"
                               label="Consumer"
                               v-slot="props">
                 {{ props.row.key }}
-              </b-table-column>
-              <b-table-column style="white-space: nowrap;"
+              </${b}-table-column>
+              <${b}-table-column style="white-space: nowrap;"
                               v-slot="props">
                 {{ props.row.consumer_delay }} / {{ props.row.consumer_retry_attempts }} / {{ props.row.consumer_retry_delay }}
-              </b-table-column>
-              <b-table-column label="Actions"
+              </${b}-table-column>
+              <${b}-table-column label="Actions"
                               v-slot="props">
                 <a href="#"
                    class="grid-action"
@@ -377,14 +382,14 @@
                   <i class="fas fa-trash"></i>
                   Delete
                 </a>
-              </b-table-column>
-              <template slot="empty">
+              </${b}-table-column>
+              <template #empty>
                 <section class="section">
                   <div class="content has-text-grey has-text-centered">
                     <p>
                       <b-icon
                         pack="fas"
-                        icon="fas fa-sad-tear"
+                        icon="sad-tear"
                         size="is-large">
                       </b-icon>
                     </p>
@@ -392,7 +397,7 @@
                   </div>
                 </section>
               </template>
-            </b-table>
+            </${b}-table>
 
           </div>
 
@@ -526,7 +531,8 @@
            expanded>
     <b-input name="supervisor_process_name"
              v-model="supervisorProcessName"
-             @input="settingsNeedSaved = true">
+             @input="settingsNeedSaved = true"
+             expanded>
     </b-input>
   </b-field>
 
@@ -535,7 +541,8 @@
            expanded>
     <b-input name="restart_command"
              v-model="restartCommand"
-             @input="settingsNeedSaved = true">
+             @input="settingsNeedSaved = true"
+             expanded>
     </b-input>
   </b-field>
 
diff --git a/tailbone/templates/datasync/status.mako b/tailbone/templates/datasync/status.mako
index 6df35bbb..43d05f51 100644
--- a/tailbone/templates/datasync/status.mako
+++ b/tailbone/templates/datasync/status.mako
@@ -47,79 +47,79 @@
     </div>
   </b-field>
 
-  <b-field label="Watcher Status">
-    <b-table :data="watchers">
-      <b-table-column field="key"
+  <h3 class="is-size-3">Watcher Status</h3>
+
+    <${b}-table :data="watchers">
+      <${b}-table-column field="key"
                       label="Watcher"
                       v-slot="props">
          {{ props.row.key }}
-      </b-table-column>
-      <b-table-column field="spec"
+      </${b}-table-column>
+      <${b}-table-column field="spec"
                       label="Spec"
                       v-slot="props">
          {{ props.row.spec }}
-      </b-table-column>
-      <b-table-column field="dbkey"
+      </${b}-table-column>
+      <${b}-table-column field="dbkey"
                       label="DB Key"
                       v-slot="props">
          {{ props.row.dbkey }}
-      </b-table-column>
-      <b-table-column field="delay"
+      </${b}-table-column>
+      <${b}-table-column field="delay"
                       label="Delay"
                       v-slot="props">
          {{ props.row.delay }} second(s)
-      </b-table-column>
-      <b-table-column field="lastrun"
+      </${b}-table-column>
+      <${b}-table-column field="lastrun"
                       label="Last Watched"
                       v-slot="props">
          <span v-html="props.row.lastrun"></span>
-      </b-table-column>
-      <b-table-column field="status"
+      </${b}-table-column>
+      <${b}-table-column field="status"
                       label="Status"
                       v-slot="props">
         <span :class="props.row.status == 'okay' ? 'has-background-success' : 'has-background-warning'">
           {{ props.row.status }}
         </span>
-      </b-table-column>
-    </b-table>
-  </b-field>
+      </${b}-table-column>
+    </${b}-table>
 
-  <b-field label="Consumer Status">
-    <b-table :data="consumers">
-      <b-table-column field="key"
+  <h3 class="is-size-3">Consumer Status</h3>
+
+    <${b}-table :data="consumers">
+      <${b}-table-column field="key"
                       label="Consumer"
                       v-slot="props">
          {{ props.row.key }}
-      </b-table-column>
-      <b-table-column field="spec"
+      </${b}-table-column>
+      <${b}-table-column field="spec"
                       label="Spec"
                       v-slot="props">
          {{ props.row.spec }}
-      </b-table-column>
-      <b-table-column field="dbkey"
+      </${b}-table-column>
+      <${b}-table-column field="dbkey"
                       label="DB Key"
                       v-slot="props">
          {{ props.row.dbkey }}
-      </b-table-column>
-      <b-table-column field="delay"
+      </${b}-table-column>
+      <${b}-table-column field="delay"
                       label="Delay"
                       v-slot="props">
          {{ props.row.delay }} second(s)
-      </b-table-column>
-      <b-table-column field="changes"
+      </${b}-table-column>
+      <${b}-table-column field="changes"
                       label="Pending Changes"
                       v-slot="props">
          {{ props.row.changes }}
-      </b-table-column>
-      <b-table-column field="status"
+      </${b}-table-column>
+      <${b}-table-column field="status"
                       label="Status"
                       v-slot="props">
         <span :class="props.row.status == 'okay' ? 'has-background-success' : 'has-background-warning'">
           {{ props.row.status }}
         </span>
-      </b-table-column>
-    </b-table>
-  </b-field>
+      </${b}-table-column>
+    </${b}-table>
 </%def>
 
 <%def name="modify_this_page_vars()">
diff --git a/tailbone/templates/forms/deform.mako b/tailbone/templates/forms/deform.mako
index 8a940347..00cf2c50 100644
--- a/tailbone/templates/forms/deform.mako
+++ b/tailbone/templates/forms/deform.mako
@@ -1,5 +1,7 @@
 ## -*- coding: utf-8; -*-
 
+<% request.register_component(form.component, form.component_studly) %>
+
 <script type="text/x-template" id="${form.component}-template">
 
   <div>
diff --git a/tailbone/templates/generate_feature.mako b/tailbone/templates/generate_feature.mako
index 6b0d781f..a7064331 100644
--- a/tailbone/templates/generate_feature.mako
+++ b/tailbone/templates/generate_feature.mako
@@ -106,55 +106,68 @@
                 </div>
               </div>
 
-              <b-table
+              <${b}-table
                  :data="new_table.columns">
 
-                <b-table-column field="name"
+                <${b}-table-column field="name"
                                 label="Name"
                                 v-slot="props">
                   {{ props.row.name }}
-                </b-table-column>
+                </${b}-table-column>
 
-                <b-table-column field="data_type"
+                <${b}-table-column field="data_type"
                                 label="Data Type"
                                 v-slot="props">
                   {{ props.row.data_type }}
-                </b-table-column>
+                </${b}-table-column>
 
-                <b-table-column field="nullable"
+                <${b}-table-column field="nullable"
                                 label="Nullable"
                                 v-slot="props">
                   {{ props.row.nullable }}
-                </b-table-column>
+                </${b}-table-column>
 
-                <b-table-column field="description"
+                <${b}-table-column field="description"
                                 label="Description"
                                 v-slot="props">
                   {{ props.row.description }}
-                </b-table-column>
+                </${b}-table-column>
 
-                <b-table-column field="actions"
+                <${b}-table-column field="actions"
                                 label="Actions"
                                 v-slot="props">
                   <a href="#" class="grid-action"
-                     @click.prevent="editColumnRow(props.row)">
-                    <i class="fas fa-edit"></i>
+                     @click.prevent="editColumnRow(props)">
+                    % if request.use_oruga:
+                        <o-icon icon="edit" />
+                    % else:
+                        <i class="fas fa-edit"></i>
+                    % endif
                     Edit
                   </a>
                   &nbsp;
 
                   <a href="#" class="grid-action has-text-danger"
                      @click.prevent="deleteColumn(props.index)">
-                    <i class="fas fa-trash"></i>
+                    % if request.use_oruga:
+                        <o-icon icon="trash" />
+                    % else:
+                        <i class="fas fa-trash"></i>
+                    % endif
                     Delete
                   </a>
                   &nbsp;
-                </b-table-column>
+                </${b}-table-column>
 
-              </b-table>
+              </${b}-table>
 
-              <b-modal has-modal-card
-                       :active.sync="showingEditColumn">
+              <${b}-modal has-modal-card
+                       % if request.use_oruga:
+                       v-model:active="showingEditColumn"
+                       % else:
+                       :active.sync="showingEditColumn"
+                       % endif
+                       >
                 <div class="modal-card">
 
                   <header class="modal-card-head">
@@ -197,7 +210,7 @@
                     </b-button>
                   </footer>
                 </div>
-              </b-modal>
+              </${b}-modal>
 
             </div>
           </b-field>
@@ -318,6 +331,7 @@
 
     ThisPageData.showingEditColumn = false
     ThisPageData.editingColumn = null
+    ThisPageData.editingColumnIndex = null
     ThisPageData.editingColumnName = null
     ThisPageData.editingColumnDataType = null
     ThisPageData.editingColumnNullable = null
@@ -325,6 +339,7 @@
 
     ThisPage.methods.addColumn = function(column) {
         this.editingColumn = null
+        this.editingColumnIndex = null
         this.editingColumnName = null
         this.editingColumnDataType = null
         this.editingColumnNullable = true
@@ -332,8 +347,10 @@
         this.showingEditColumn = true
     }
 
-    ThisPage.methods.editColumnRow = function(column) {
+    ThisPage.methods.editColumnRow = function(props) {
+        const column = props.row
         this.editingColumn = column
+        this.editingColumnIndex = props.index
         this.editingColumnName = column.name
         this.editingColumnDataType = column.data_type
         this.editingColumnNullable = column.nullable
@@ -343,7 +360,7 @@
 
     ThisPage.methods.saveColumn = function() {
         if (this.editingColumn) {
-            column = this.editingColumn
+            column = this.new_table.columns[this.editingColumnIndex]
         } else {
             column = {}
             this.new_table.columns.push(column)
diff --git a/tailbone/templates/grids/b-table.mako b/tailbone/templates/grids/b-table.mako
index 1ef3ba7b..632193b5 100644
--- a/tailbone/templates/grids/b-table.mako
+++ b/tailbone/templates/grids/b-table.mako
@@ -1,5 +1,5 @@
 ## -*- coding: utf-8; -*-
-<b-table
+<${b}-table
    :data="${data_prop}"
    icon-pack="fas"
    striped
@@ -21,7 +21,7 @@
    >
 
   % for i, column in enumerate(grid_columns):
-      <b-table-column field="${column['field']}"
+      <${b}-table-column field="${column['field']}"
                       % if not empty_labels:
                       label="${column['label']}"
                       % elif i > 0:
@@ -50,11 +50,11 @@
         % else:
             <span v-html="props.row.${column['field']}"></span>
         % endif
-      </b-table-column>
+      </${b}-table-column>
   % endfor
 
   % if grid.main_actions or grid.more_actions:
-      <b-table-column field="actions"
+      <${b}-table-column field="actions"
                       label="Actions"
                       v-slot="props">
         % for action in grid.main_actions:
@@ -68,12 +68,16 @@
                @click.prevent="${action.click_handler}"
                % endif
                >
-              <i class="fas fa-${action.icon}"></i>
+              % if request.use_oruga:
+                  <o-icon icon="${action.icon}" />
+              % else:
+                  <i class="fas fa-${action.icon}"></i>
+              % endif
               ${action.label}
             </a>
             &nbsp;
         % endfor
-      </b-table-column>
+      </${b}-table-column>
   % endif
 
   <template #empty>
@@ -99,4 +103,4 @@
   </template>
   % endif
 
-</b-table>
+</${b}-table>
diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako
index 0a5c3780..fe9392d3 100644
--- a/tailbone/templates/grids/complete.mako
+++ b/tailbone/templates/grids/complete.mako
@@ -1,5 +1,7 @@
 ## -*- coding: utf-8; -*-
 
+<% request.register_component(grid.component, grid.component_studly) %>
+
 <script type="text/x-template" id="${grid.component}-template">
   <div>
 
@@ -38,7 +40,7 @@
 
     </div>
 
-    <b-table
+    <${b}-table
        :data="visibleData"
        :loading="loading"
        :row-class="getRowClass"
@@ -51,7 +53,11 @@
        :checkable="checkable"
 
        % if grid.checkboxes:
-       :checked-rows.sync="checkedRows"
+           % if request.use_oruga:
+               v-model:checked-rows="checkedRows"
+           % else:
+               :checked-rows.sync="checkedRows"
+           % endif
        % if grid.clicking_row_checks_box:
        @click="rowClick"
        % endif
@@ -111,7 +117,7 @@
        :narrowed="true">
 
       % for column in grid_columns:
-          <b-table-column field="${column['field']}"
+          <${b}-table-column field="${column['field']}"
                           label="${column['label']}"
                           v-slot="props"
                           :sortable="${json.dumps(column['sortable'])}"
@@ -132,11 +138,11 @@
             % else:
                 <span v-html="props.row.${column['field']}"></span>
             % endif
-          </b-table-column>
+          </${b}-table-column>
       % endfor
 
       % if grid.main_actions or grid.more_actions:
-          <b-table-column field="actions"
+          <${b}-table-column field="actions"
                           label="Actions"
                           v-slot="props">
             ## TODO: we do not currently differentiate for "main vs. more"
@@ -152,12 +158,17 @@
                    target="${action.target}"
                    % endif
                    >
-                  ${action.render_icon()|n}
-                  ${action.render_label()|n}
+                  % if request.use_oruga:
+                      <o-icon icon="${action.icon}" />
+                      <span>${action.render_label()|n}</span>
+                  % else:
+                      ${action.render_icon()|n}
+                      ${action.render_label()|n}
+                  % endif
                 </a>
                 &nbsp;
             % endfor
-          </b-table-column>
+          </${b}-table-column>
       % endif
 
       <template #empty>
@@ -183,7 +194,11 @@
                         size="is-small"
                         @click="copyDirectLink()"
                         title="Copy link to clipboard">
-                <span><i class="fa fa-share-alt"></i></span>
+                % if request.use_oruga:
+                    <o-icon icon="share-alt" />
+                % else:
+                    <span><i class="fa fa-share-alt"></i></span>
+                % endif
               </b-button>
           % else:
               <div></div>
@@ -213,7 +228,7 @@
         </div>
       </template>
 
-    </b-table>
+    </${b}-table>
 
     ## dummy input field needed for sharing links on *insecure* sites
     % if request.scheme == 'http':
@@ -523,6 +538,12 @@
           },
 
           perPageUpdated(value) {
+
+              // nb. buefy passes value, oruga passes event
+              if (value.target) {
+                  value = event.target.value
+              }
+
               this.loadAsyncData({
                   pagesize: value,
               })
@@ -530,6 +551,11 @@
 
           onSort(field, order, event) {
 
+              // nb. buefy passes field name, oruga passes object
+              if (field.field) {
+                  field = field.field
+              }
+
               if (event.ctrlKey) {
 
                   // engage or enhance multi-column sorting
diff --git a/tailbone/templates/grids/filter-components.mako b/tailbone/templates/grids/filter-components.mako
index 7897b3cf..9ec1c049 100644
--- a/tailbone/templates/grids/filter-components.mako
+++ b/tailbone/templates/grids/filter-components.mako
@@ -7,6 +7,7 @@
 </%def>
 
 <%def name="make_grid_filter_numeric_value_component()">
+  <% request.register_component('grid-filter-numeric-value', 'GridFilterNumericValue') %>
   <script type="text/x-template" id="grid-filter-numeric-value-template">
     <div class="level">
       <div class="level-left">
@@ -95,13 +96,14 @@
 </%def>
 
 <%def name="make_grid_filter_date_value_component()">
+  <% request.register_component('grid-filter-date-value', 'GridFilterDateValue') %>
   <script type="text/x-template" id="grid-filter-date-value-template">
     <div class="level">
       <div class="level-left">
         <div class="level-item">
           <tailbone-datepicker v-model="startDate"
                                ref="startDate"
-                               @input="startDateChanged">
+                               @${'update:model-value' if request.use_oruga else 'input'}="startDateChanged">
           </tailbone-datepicker>
         </div>
         <div v-show="dateRange"
@@ -112,7 +114,7 @@
              class="level-item">
           <tailbone-datepicker v-model="endDate"
                                ref="endDate"
-                               @input="endDateChanged">
+                               @${'update:model-value' if request.use_oruga else 'input'}="endDateChanged">
           </tailbone-datepicker>
         </div>
       </div>
@@ -123,25 +125,26 @@
     const GridFilterDateValue = {
         template: '#grid-filter-date-value-template',
         props: {
-            value: String,
+            ${'modelValue' if request.use_oruga else 'value'}: String,
             dateRange: Boolean,
         },
         data() {
             let startDate = null
             let endDate = null
-            if (this.value) {
+            let value = this.${'modelValue' if request.use_oruga else 'value'}
+            if (value) {
 
                 if (this.dateRange) {
-                    let values = this.value.split('|')
+                    let values = value.split('|')
                     if (values.length == 2) {
                         startDate = this.parseDate(values[0])
                         endDate = this.parseDate(values[1])
                     } else {    // no end date specified?
-                        startDate = this.parseDate(this.value)
+                        startDate = this.parseDate(value)
                     }
 
                 } else {        // not a range, so start date only
-                    startDate = this.parseDate(this.value)
+                    startDate = this.parseDate(value)
                 }
             }
 
@@ -179,11 +182,11 @@
                 if (this.dateRange) {
                     value += '|' + this.formatDate(this.endDate)
                 }
-                this.$emit('input', value)
+                this.$emit("${'update:modelValue' if request.use_oruga else 'input'}", value)
             },
             endDateChanged(value) {
                 value = this.formatDate(this.startDate) + '|' + this.formatDate(value)
-                this.$emit('input', value)
+                this.$emit("${'update:modelValue' if request.use_oruga else 'input'}", value)
             },
         },
     }
@@ -194,6 +197,7 @@
 </%def>
 
 <%def name="make_grid_filter_component()">
+  <% request.register_component('grid-filter', 'GridFilter') %>
   <script type="text/x-template" id="grid-filter-template">
     <div class="filter"
          v-show="filter.visible"
diff --git a/tailbone/templates/importing/configure.mako b/tailbone/templates/importing/configure.mako
index 90f7cabd..fd8bc35b 100644
--- a/tailbone/templates/importing/configure.mako
+++ b/tailbone/templates/importing/configure.mako
@@ -6,54 +6,58 @@
 
   <h3 class="is-size-3">Designated Handlers</h3>
 
-  <b-table :data="handlersData"
+  <${b}-table :data="handlersData"
            narrowed
            icon-pack="fas"
            :default-sort="['host_title', 'asc']">
-    <b-table-column field="host_title"
+    <${b}-table-column field="host_title"
                     label="Data Source"
                     v-slot="props"
                     sortable>
       {{ props.row.host_title }}
-    </b-table-column>
-    <b-table-column field="local_title"
+    </${b}-table-column>
+    <${b}-table-column field="local_title"
                     label="Data Target"
                     v-slot="props"
                     sortable>
       {{ props.row.local_title }}
-    </b-table-column>
-    <b-table-column field="direction"
+    </${b}-table-column>
+    <${b}-table-column field="direction"
                     label="Direction"
                     v-slot="props"
                     sortable>
       {{ props.row.direction_display }}
-    </b-table-column>
-    <b-table-column field="handler_spec"
+    </${b}-table-column>
+    <${b}-table-column field="handler_spec"
                     label="Handler Spec"
                     v-slot="props"
                     sortable>
       {{ props.row.handler_spec }}
-    </b-table-column>
-    <b-table-column field="cmd"
+    </${b}-table-column>
+    <${b}-table-column field="cmd"
                     label="Command"
                     v-slot="props"
                     sortable>
       {{ props.row.command }} {{ props.row.subcommand }}
-    </b-table-column>
-    <b-table-column field="runas"
+    </${b}-table-column>
+    <${b}-table-column field="runas"
                     label="Default Runas"
                     v-slot="props"
                     sortable>
       {{ props.row.default_runas }}
-    </b-table-column>
-    <b-table-column label="Actions"
+    </${b}-table-column>
+    <${b}-table-column label="Actions"
                     v-slot="props">
       <a href="#" class="grid-action"
          @click.prevent="editHandler(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-column>
     <template slot="empty">
       <section class="section">
         <div class="content has-text-grey has-text-centered">
@@ -68,7 +72,7 @@
         </div>
       </section>
     </template>
-  </b-table>
+  </${b}-table>
   
   <b-modal :active.sync="editHandlerShowDialog">
     <div class="card">
diff --git a/tailbone/templates/luigi/configure.mako b/tailbone/templates/luigi/configure.mako
index 548701a9..49060ceb 100644
--- a/tailbone/templates/luigi/configure.mako
+++ b/tailbone/templates/luigi/configure.mako
@@ -22,48 +22,56 @@
   </div>
   <div class="block" style="padding-left: 2rem; display: flex;">
 
-    <b-table :data="overnightTasks">
-      <!-- <b-table-column field="key" -->
+    <${b}-table :data="overnightTasks">
+      <!-- <${b}-table-column field="key" -->
       <!--                 label="Key" -->
       <!--                 sortable> -->
       <!--   {{ props.row.key }} -->
-      <!-- </b-table-column> -->
-      <b-table-column field="key"
+      <!-- </${b}-table-column> -->
+      <${b}-table-column field="key"
                       label="Key"
                       v-slot="props">
         {{ props.row.key }}
-      </b-table-column>
-      <b-table-column field="description"
+      </${b}-table-column>
+      <${b}-table-column field="description"
                       label="Description"
                       v-slot="props">
         {{ props.row.description }}
-      </b-table-column>
-      <b-table-column field="class_name"
+      </${b}-table-column>
+      <${b}-table-column field="class_name"
                       label="Class Name"
                       v-slot="props">
         {{ props.row.class_name }}
-      </b-table-column>
-      <b-table-column field="script"
+      </${b}-table-column>
+      <${b}-table-column field="script"
                       label="Script"
                       v-slot="props">
         {{ props.row.script }}
-      </b-table-column>
-      <b-table-column label="Actions"
+      </${b}-table-column>
+      <${b}-table-column label="Actions"
                       v-slot="props">
         <a href="#"
            @click.prevent="overnightTaskEdit(props.row)">
-          <i class="fas fa-edit"></i>
+          % if request.use_oruga:
+              <o-icon icon="edit" />
+          % else:
+              <i class="fas fa-edit"></i>
+          % endif
           Edit
         </a>
         &nbsp;
         <a href="#"
            class="has-text-danger"
            @click.prevent="overnightTaskDelete(props.row)">
-          <i class="fas fa-trash"></i>
+          % if request.use_oruga:
+              <o-icon icon="trash" />
+          % else:
+              <i class="fas fa-trash"></i>
+          % endif
           Delete
         </a>
-      </b-table-column>
-    </b-table>
+      </${b}-table-column>
+    </${b}-table>
 
     <b-modal has-modal-card
              :active.sync="overnightTaskShowDialog">
@@ -139,48 +147,56 @@
   </div>
   <div class="block" style="padding-left: 2rem; display: flex;">
 
-    <b-table :data="backfillTasks">
-      <b-table-column field="key"
+    <${b}-table :data="backfillTasks">
+      <${b}-table-column field="key"
                       label="Key"
                       v-slot="props">
         {{ props.row.key }}
-      </b-table-column>
-      <b-table-column field="description"
+      </${b}-table-column>
+      <${b}-table-column field="description"
                       label="Description"
                       v-slot="props">
         {{ props.row.description }}
-      </b-table-column>
-      <b-table-column field="script"
+      </${b}-table-column>
+      <${b}-table-column field="script"
                       label="Script"
                       v-slot="props">
         {{ props.row.script }}
-      </b-table-column>
-      <b-table-column field="forward"
+      </${b}-table-column>
+      <${b}-table-column field="forward"
                       label="Orientation"
                       v-slot="props">
         {{ props.row.forward ? "Forward" : "Backward" }}
-      </b-table-column>
-      <b-table-column field="target_date"
+      </${b}-table-column>
+      <${b}-table-column field="target_date"
                       label="Target Date"
                       v-slot="props">
         {{ props.row.target_date }}
-      </b-table-column>
-      <b-table-column label="Actions"
+      </${b}-table-column>
+      <${b}-table-column label="Actions"
                       v-slot="props">
         <a href="#"
            @click.prevent="backfillTaskEdit(props.row)">
-          <i class="fas fa-edit"></i>
+          % if request.use_oruga:
+              <o-icon icon="edit" />
+          % else:
+              <i class="fas fa-edit"></i>
+          % endif
           Edit
         </a>
         &nbsp;
         <a href="#"
            class="has-text-danger"
            @click.prevent="backfillTaskDelete(props.row)">
-          <i class="fas fa-trash"></i>
+          % if request.use_oruga:
+              <o-icon icon="trash" />
+          % else:
+              <i class="fas fa-trash"></i>
+          % endif
           Delete
         </a>
-      </b-table-column>
-    </b-table>
+      </${b}-table-column>
+    </${b}-table>
 
     <b-modal has-modal-card
              :active.sync="backfillTaskShowDialog">
diff --git a/tailbone/templates/luigi/index.mako b/tailbone/templates/luigi/index.mako
index a64866df..bb8d1465 100644
--- a/tailbone/templates/luigi/index.mako
+++ b/tailbone/templates/luigi/index.mako
@@ -53,25 +53,25 @@
 
         <h3 class="block is-size-3">Overnight Tasks</h3>
 
-        <b-table :data="overnightTasks" hoverable>
-          <b-table-column field="description"
+        <${b}-table :data="overnightTasks" hoverable>
+          <${b}-table-column field="description"
                           label="Description"
                           v-slot="props">
             {{ props.row.description }}
-          </b-table-column>
-          <b-table-column field="script"
+          </${b}-table-column>
+          <${b}-table-column field="script"
                           label="Command"
                           v-slot="props">
             {{ props.row.script || props.row.class_name }}
-          </b-table-column>
-          <b-table-column field="last_date"
+          </${b}-table-column>
+          <${b}-table-column field="last_date"
                           label="Last Date"
                           v-slot="props">
             <span :class="overnightTextClass(props.row)">
               {{ props.row.last_date || "never!" }}
             </span>
-          </b-table-column>
-          <b-table-column label="Actions"
+          </${b}-table-column>
+          <${b}-table-column label="Actions"
                           v-slot="props">
             <b-button type="is-primary"
                       icon-pack="fas"
@@ -128,11 +128,11 @@
                 </footer>
               </div>
             </b-modal>
-          </b-table-column>
+          </${b}-table-column>
           <template #empty>
             <p class="block">No tasks defined.</p>
           </template>
-        </b-table>
+        </${b}-table>
 
     % endif
 
@@ -140,35 +140,35 @@
 
         <h3 class="block is-size-3">Backfill Tasks</h3>
 
-        <b-table :data="backfillTasks" hoverable>
-          <b-table-column field="description"
+        <${b}-table :data="backfillTasks" hoverable>
+          <${b}-table-column field="description"
                           label="Description"
                           v-slot="props">
             {{ props.row.description }}
-          </b-table-column>
-          <b-table-column field="script"
+          </${b}-table-column>
+          <${b}-table-column field="script"
                           label="Script"
                           v-slot="props">
             {{ props.row.script }}
-          </b-table-column>
-          <b-table-column field="forward"
+          </${b}-table-column>
+          <${b}-table-column field="forward"
                           label="Orientation"
                           v-slot="props">
             {{ props.row.forward ? "Forward" : "Backward" }}
-          </b-table-column>
-          <b-table-column field="last_date"
+          </${b}-table-column>
+          <${b}-table-column field="last_date"
                           label="Last Date"
                           v-slot="props">
             <span :class="backfillTextClass(props.row)">
               {{ props.row.last_date }}
             </span>
-          </b-table-column>
-          <b-table-column field="target_date"
+          </${b}-table-column>
+          <${b}-table-column field="target_date"
                           label="Target Date"
                           v-slot="props">
             {{ props.row.target_date }}
-          </b-table-column>
-          <b-table-column label="Actions"
+          </${b}-table-column>
+          <${b}-table-column label="Actions"
                           v-slot="props">
             <b-button type="is-primary"
                       icon-pack="fas"
@@ -176,11 +176,11 @@
                       @click="backfillTaskLaunch(props.row)">
               Launch
             </b-button>
-          </b-table-column>
+          </${b}-table-column>
           <template #empty>
             <p class="block">No tasks defined.</p>
           </template>
-        </b-table>
+        </${b}-table>
 
         <b-modal has-modal-card
                  :active.sync="backfillTaskShowLaunchDialog">
diff --git a/tailbone/templates/master/merge.mako b/tailbone/templates/master/merge.mako
index 6727dc5c..5d90043f 100644
--- a/tailbone/templates/master/merge.mako
+++ b/tailbone/templates/master/merge.mako
@@ -177,6 +177,8 @@
 
     Vue.component('merge-buttons', MergeButtons)
 
+    <% request.register_component('merge-buttons', 'MergeButtons') %>
+
   </script>
 </%def>
 
diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako
index 5973da43..ac0577e0 100644
--- a/tailbone/templates/master/view.mako
+++ b/tailbone/templates/master/view.mako
@@ -12,7 +12,11 @@
       <b-button title="&quot;Touch&quot; this record to trigger sync"
                 @click="touchRecord()"
                 :disabled="touchSubmitting">
-        <span><i class="fa fa-hand-pointer"></i></span>
+        % if request.use_oruga:
+            <o-icon icon="hand-pointer" />
+        % else:
+            <span><i class="fa fa-hand-pointer"></i></span>
+        % endif
       </b-button>
   % endif
   % if expose_versions:
@@ -112,7 +116,11 @@
           <p class="block">
             <a href="${master.get_action_url('versions', instance)}"
                target="_blank">
-              <i class="fas fa-external-link-alt"></i>
+              % if request.use_oruga:
+                  <o-icon icon="external-link-alt" />
+              % else:
+                  <i class="fas fa-external-link-alt"></i>
+              % endif
               View as separate page
             </a>
           </p>
@@ -122,7 +130,13 @@
                        @view-revision="viewRevision">
         </versions-grid>
 
-        <b-modal :active.sync="viewVersionShowDialog" :width="1200">
+        <${b}-modal :width="1200"
+                    % if request.use_oruga:
+                    v-model:active="viewVersionShowDialog"
+                    % else:
+                    :active.sync="viewVersionShowDialog"
+                    % endif
+                    >
           <div class="card">
             <div class="card-content">
               <div style="display: flex; flex-direction: column; gap: 1.5rem;">
@@ -169,7 +183,11 @@
                     <div>
                       <a :href="viewVersionData.url"
                          target="_blank">
-                        <i class="fas fa-external-link-alt"></i>
+                        % if request.use_oruga:
+                            <o-icon icon="external-link-alt" />
+                        % else:
+                            <i class="fas fa-external-link-alt"></i>
+                        % endif
                         View as separate page
                       </a>
                     </div>
@@ -212,10 +230,14 @@
                 </div>
 
               </div>
-              <b-loading :active.sync="viewVersionLoading" :is-full-page="false"></b-loading>
+              % if request.use_oruga:
+                  <o-loading v-model:active="viewVersionLoading" :is-full-page="false" />
+              % else:
+                  <b-loading :active.sync="viewVersionLoading" :is-full-page="false"></b-loading>
+              % endif
             </div>
           </div>
-        </b-modal>
+        </${b}-modal>
       </div>
   % endif
 </%def>
diff --git a/tailbone/templates/page.mako b/tailbone/templates/page.mako
index bf799440..460cc6d6 100644
--- a/tailbone/templates/page.mako
+++ b/tailbone/templates/page.mako
@@ -72,6 +72,7 @@
     ThisPage.data = function() { return ThisPageData }
 
     Vue.component('this-page', ThisPage)
+    <% request.register_component('this-page', 'ThisPage') %>
 
   </script>
 </%def>
diff --git a/tailbone/templates/page_help.mako b/tailbone/templates/page_help.mako
index 19e8e121..ea86c6da 100644
--- a/tailbone/templates/page_help.mako
+++ b/tailbone/templates/page_help.mako
@@ -6,10 +6,8 @@
 
       % if help_url or help_markdown:
 
-          <b-field>
-            <p class="control">
-              <b-button icon-pack="fas"
-                        icon-left="question-circle"
+          % if request.use_oruga:
+              <o-button icon-left="question-circle"
                         % if help_markdown:
                         @click="displayInit()"
                         % elif help_url:
@@ -18,57 +16,117 @@
                         % endif
                         >
                 Help
-              </b-button>
-            </p>
-            % if can_edit_help:
-                ## TODO: this dropdown is duplicated, below
-                <b-dropdown aria-role="list"  position="is-bottom-left">
-                  <template #trigger="{ active }">
-                    <b-button>
-                      <span><i class="fa fa-cog"></i></span>
-                    </b-button>
-                  </template>
-                  <b-dropdown-item aria-role="listitem"
-                                   @click="configureInit()">
-                    Edit Page Help
-                  </b-dropdown-item>
-                  <b-dropdown-item aria-role="listitem"
-                                   @click="configureFieldsInit()">
-                    Edit Fields Help
-                  </b-dropdown-item>
-                </b-dropdown>
-            % endif
-          </b-field>
+              </o-button>
+
+              % if can_edit_help:
+                  ## TODO: this dropdown is duplicated, below
+                  <o-dropdown position="bottom-left"
+                              ## TODO: why does click not work here?!
+                              :triggers="['click', 'hover']">
+                    <template #trigger>
+                      <o-button>
+                        <o-icon icon="cog" />
+                      </o-button>
+                    </template>
+                    <o-dropdown-item label="Edit Page Help"
+                                     @click="configureInit()" />
+                    <o-dropdown-item label="Edit Fields Help"
+                                     @click="configureFieldsInit()" />
+                  </o-dropdown>
+              % endif
+
+          % else:
+              ## buefy
+              <b-field>
+                <p class="control">
+                  <b-button icon-pack="fas"
+                            icon-left="question-circle"
+                            % if help_markdown:
+                            @click="displayInit()"
+                            % elif help_url:
+                            tag="a" href="${help_url}"
+                            target="_blank"
+                            % endif
+                            >
+                    Help
+                  </b-button>
+                </p>
+                % if can_edit_help:
+                    ## TODO: this dropdown is duplicated, below
+                    <b-dropdown aria-role="list"  position="is-bottom-left">
+                      <template #trigger="{ active }">
+                        <b-button>
+                          <span><i class="fa fa-cog"></i></span>
+                        </b-button>
+                      </template>
+                      <b-dropdown-item aria-role="listitem"
+                                       @click="configureInit()">
+                        Edit Page Help
+                      </b-dropdown-item>
+                      <b-dropdown-item aria-role="listitem"
+                                       @click="configureFieldsInit()">
+                        Edit Fields Help
+                      </b-dropdown-item>
+                    </b-dropdown>
+                % endif
+              </b-field>
+          % endif:
 
       % elif can_edit_help:
 
-          <b-field>
-            <p class="control">
-              ## TODO: this dropdown is duplicated, above
-              <b-dropdown aria-role="list"  position="is-bottom-left">
-                <template #trigger="{ active }">
-                  <b-button>
-                    <span><i class="fa fa-question-circle"></i></span>
-                    <span><i class="fa fa-cog"></i></span>
-                  </b-button>
+          ## TODO: this dropdown is duplicated, above
+          % if request.use_oruga:
+              <o-dropdown position="bottom-left"
+                          ## TODO: why does click not work here?!
+                          :triggers="['click', 'hover']">
+                <template #trigger>
+                  <o-button>
+                    <o-icon icon="question-circle" />
+                    <o-icon icon="cog" />
+                  </o-button>
                 </template>
-                <b-dropdown-item aria-role="listitem"
-                                 @click="configureInit()">
-                  Edit Page Help
-                </b-dropdown-item>
-                <b-dropdown-item aria-role="listitem"
-                                 @click="configureFieldsInit()">
-                  Edit Fields Help
-                </b-dropdown-item>
-              </b-dropdown>
-            </p>
-          </b-field>
-
+                <o-dropdown-item label="Edit Page Help"
+                                 @click="configureInit()" />
+                <o-dropdown-item label="Edit Fields Help"
+                                 @click="configureFieldsInit()" />
+              </o-dropdown>
+          % else:
+              <b-field>
+                <p class="control">
+                  <b-dropdown aria-role="list"  position="is-bottom-left">
+                    <template #trigger>
+                      <b-button>
+                        % if request.use_oruga:
+                            <o-icon icon="question-circle" />
+                            <o-icon icon="cog" />
+                        % else:
+                        <span><i class="fa fa-question-circle"></i></span>
+                        <span><i class="fa fa-cog"></i></span>
+                        % endif
+                      </b-button>
+                    </template>
+                    <b-dropdown-item aria-role="listitem"
+                                     @click="configureInit()">
+                      Edit Page Help
+                    </b-dropdown-item>
+                    <b-dropdown-item aria-role="listitem"
+                                     @click="configureFieldsInit()">
+                      Edit Fields Help
+                    </b-dropdown-item>
+                  </b-dropdown>
+                </p>
+              </b-field>
+          % endif
       % endif
 
       % if help_markdown:
-          <b-modal has-modal-card
-                   :active.sync="displayShowDialog">
+          <${b}-modal has-modal-card
+                      % if request.use_oruga:
+                      v-model:active="displayShowDialog"
+                      % else:
+                      :active.sync="displayShowDialog"
+                      % endif
+                      >
             <div class="modal-card">
 
               <header class="modal-card-head">
@@ -94,14 +152,23 @@
                 </b-button>
               </footer>
             </div>
-          </b-modal>
+          </${b}-modal>
       % endif
 
       % if can_edit_help:
 
-          <b-modal has-modal-card
-                   :active.sync="configureShowDialog">
-            <div class="modal-card">
+          <${b}-modal has-modal-card
+                      % if request.use_oruga:
+                      v-model:active="configureShowDialog"
+                      % else:
+                      :active.sync="configureShowDialog"
+                      % endif
+                      >
+            <div class="modal-card"
+                 % if request.use_oruga:
+                 style="margin: auto;"
+                 % endif
+                 >
 
               <header class="modal-card-head">
                 <p class="modal-card-title">Configure Help</p>
@@ -155,7 +222,7 @@
                 </b-button>
               </footer>
             </div>
-          </b-modal>
+          </${b}-modal>
 
       % endif
 
@@ -237,6 +304,7 @@
     PageHelp.data = function() { return PageHelpData }
 
     Vue.component('page-help', PageHelp)
+    <% request.register_component('page-help', 'PageHelp') %>
 
   </script>
 </%def>
diff --git a/tailbone/templates/principal/find_by_perm.mako b/tailbone/templates/principal/find_by_perm.mako
index e2672985..1a0a4b7d 100644
--- a/tailbone/templates/principal/find_by_perm.mako
+++ b/tailbone/templates/principal/find_by_perm.mako
@@ -242,6 +242,8 @@
 
     Vue.component('find-principals', FindPrincipals)
 
+    <% request.register_component('find-principals', 'FindPrincipals') %>
+
   </script>
 </%def>
 
diff --git a/tailbone/templates/settings/email/view.mako b/tailbone/templates/settings/email/view.mako
index 2a29ce0e..c1bc5ed4 100644
--- a/tailbone/templates/settings/email/view.mako
+++ b/tailbone/templates/settings/email/view.mako
@@ -102,6 +102,8 @@
 
     Vue.component('email-preview-tools', EmailPreviewTools)
 
+    <% request.register_component('email-preview-tools', 'EmailPreviewTools') %>
+
   </script>
 </%def>
 
diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako
new file mode 100644
index 00000000..2988f29d
--- /dev/null
+++ b/tailbone/templates/themes/butterball/base.mako
@@ -0,0 +1,1141 @@
+## -*- coding: utf-8; -*-
+<%namespace name="base_meta" file="/base_meta.mako" />
+<%namespace name="page_help" file="/page_help.mako" />
+<%namespace file="/field-components.mako" import="make_field_components" />
+<%namespace file="/formposter.mako" import="declare_formposter_mixin" />
+<%namespace file="/grids/filter-components.mako" import="make_grid_filter_components" />
+<%namespace file="/buefy-components.mako" import="make_buefy_components" />
+<%namespace file="/buefy-plugin.mako" import="make_buefy_plugin" />
+<%namespace file="/http-plugin.mako" import="make_http_plugin" />
+## <%namespace file="/grids/nav.mako" import="grid_index_nav" />
+## <%namespace name="multi_file_upload" file="/multi_file_upload.mako" />
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta http-equiv="content-type" content="text/html; charset=UTF-8" />
+    <title>${base_meta.global_title()} &raquo; ${capture(self.title)|n}</title>
+    ${base_meta.favicon()}
+    ${self.header_core()}
+    ${self.head_tags()}
+  </head>
+
+  <body>
+    <div id="app" style="height: 100%; display: flex; flex-direction: column; justify-content: space-between;">
+      <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()}
+  </body>
+</html>
+
+<%def name="title()"></%def>
+
+<%def name="content_title()">
+  ${self.title()}
+</%def>
+
+<%def name="header_core()">
+  ${self.core_javascript()}
+  ${self.core_styles()}
+</%def>
+
+<%def name="core_javascript()">
+  <script type="importmap">
+    {
+        ## TODO: eventually version / url should be configurable
+        "imports": {
+            "vue": "${h.get_liburl(request, 'bb_vue')}",
+            "@oruga-ui/oruga-next": "${h.get_liburl(request, 'bb_oruga')}",
+            "@oruga-ui/theme-bulma": "${h.get_liburl(request, 'bb_oruga_bulma')}",
+            "@fortawesome/fontawesome-svg-core": "${h.get_liburl(request, 'bb_fontawesome_svg_core')}",
+            "@fortawesome/free-solid-svg-icons": "${h.get_liburl(request, 'bb_free_solid_svg_icons')}",
+            "@fortawesome/vue-fontawesome": "${h.get_liburl(request, 'bb_vue_fontawesome')}"
+        }
+    }
+  </script>
+  <script>
+    // empty stub to avoid errors for older buefy templates
+    const Vue = {
+        component(tagname, classname) {},
+    }
+  </script>
+</%def>
+
+<%def name="core_styles()">
+
+  ## ## TODO: eventually, allow custom css per-user
+  ##   % if user_css:
+  ##       ${h.stylesheet_link(user_css)}
+  ##   % else:
+  ##       ${h.stylesheet_link(h.get_liburl(request, 'bulma.css'))}
+  ##   % endif
+
+  ## TODO: eventually version / url should be configurable
+  ${h.stylesheet_link(h.get_liburl(request, 'bb_oruga_bulma_css'))}
+
+</%def>
+
+<%def name="head_tags()">
+  ${self.extra_javascript()}
+  ${self.extra_styles()}
+</%def>
+
+<%def name="extra_javascript()">
+##   ## some commonly-useful logic for detecting (non-)numeric input
+##   ${h.javascript_link(request.static_url('tailbone:static/js/numeric.js') + '?ver={}'.format(tailbone.__version__))}
+## 
+##   ## debounce, for better autocomplete performance
+##   ${h.javascript_link(request.static_url('tailbone:static/js/debounce.js') + '?ver={}'.format(tailbone.__version__))}
+
+##   ## Tailbone / Buefy stuff
+##   ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.numericinput.js') + '?ver={}'.format(tailbone.__version__))}
+##   ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.timepicker.js') + '?ver={}'.format(tailbone.__version__))}
+
+##   <script type="text/javascript">
+## 
+##     ## NOTE: this code was copied from
+##     ## https://bulma.io/documentation/components/navbar/#navbar-menu
+## 
+##     document.addEventListener('DOMContentLoaded', () => {
+## 
+##         // Get all "navbar-burger" elements
+##         const $navbarBurgers = Array.prototype.slice.call(document.querySelectorAll('.navbar-burger'), 0)
+## 
+##         // Add a click event on each of them
+##         $navbarBurgers.forEach( el => {
+##             el.addEventListener('click', () => {
+## 
+##                 // Get the target from the "data-target" attribute
+##                 const target = el.dataset.target
+##                 const $target = document.getElementById(target)
+## 
+##                 // Toggle the "is-active" class on both the "navbar-burger" and the "navbar-menu"
+##                 el.classList.toggle('is-active')
+##                 $target.classList.toggle('is-active')
+## 
+##             })
+##         })
+##     })
+## 
+##   </script>
+</%def>
+
+<%def name="extra_styles()">
+
+##   ${h.stylesheet_link(request.static_url('tailbone:static/css/base.css') + '?ver={}'.format(tailbone.__version__))}
+##   ${h.stylesheet_link(request.static_url('tailbone:static/css/layout.css') + '?ver={}'.format(tailbone.__version__))}
+##   ${h.stylesheet_link(request.static_url('tailbone:static/css/grids.css') + '?ver={}'.format(tailbone.__version__))}
+##   ${h.stylesheet_link(request.static_url('tailbone:static/css/grids.rowstatus.css') + '?ver={}'.format(tailbone.__version__))}
+##   ${h.stylesheet_link(request.static_url('tailbone:static/css/filters.css') + '?ver={}'.format(tailbone.__version__))}
+##   ${h.stylesheet_link(request.static_url('tailbone:static/css/forms.css') + '?ver={}'.format(tailbone.__version__))}
+
+  ${h.stylesheet_link(request.static_url('tailbone:static/css/diffs.css') + '?ver={}'.format(tailbone.__version__))}
+
+  ## nb. this is used (only?) in /generate-feature page
+  ${h.stylesheet_link(request.static_url('tailbone:static/css/codehilite.css') + '?ver={}'.format(tailbone.__version__))}
+
+  <style>
+
+    /* ****************************** */
+    /* page */
+    /* ****************************** */
+
+    /* nb. helps force footer to bottom of screen */
+    html, body {
+        height: 100%;
+    }
+
+    ## maybe add testing watermark
+    % if not request.rattail_config.production():
+        html, .navbar, .footer {
+          background-image: url(${request.static_url('tailbone:static/img/testing.png')});
+        }
+    % endif
+
+    ## maybe force global background color
+    % if background_color:
+        body, .navbar, .footer {
+            background-color: ${background_color};
+        }
+    % endif
+
+    ## TODO: is this a good idea?
+    h1.title {
+        font-size: 2rem;
+        font-weight: bold;
+        margin-bottom: 0 !important;
+    }
+
+    #context-menu {
+        margin-bottom: 1em;
+        /* margin-left: 1em; */
+        text-align: right;
+        /* white-space: nowrap; */
+    }
+
+    ## TODO: ugh why is this needed to center modal on screen?
+    .modal .modal-content .modal-card {
+        margin: auto;
+    }
+
+    .object-helpers .panel {
+        margin: 1rem;
+        margin-bottom: 1.5rem;
+    }
+
+    /* ****************************** */
+    /* grids */
+    /* ****************************** */
+
+    .filters .filter-fieldname .button {
+        min-width: ${filter_fieldname_width};
+        justify-content: left;
+    }
+    .filters .filter-verb {
+        min-width: ${filter_verb_width};
+    }
+
+    .grid-tools {
+        display: flex;
+        gap: 0.5rem;
+        justify-content: end;
+    }
+
+    a.grid-action {
+        white-space: nowrap;
+    }
+
+    /* ****************************** */
+    /* forms */
+    /* ****************************** */
+
+    /* note that these should only apply to "normal" primary forms */
+
+    .form {
+        padding-left: 5em;
+    }
+
+    /* .form-wrapper .form .field.is-horizontal .field-label .label, */
+    .form-wrapper .form .field.is-horizontal .field-label {
+        text-align: left;
+        white-space: nowrap;
+        min-width: 18em;
+    }
+
+    .form-wrapper .form .field.is-horizontal .field-body {
+        min-width: 30em;
+    }
+
+    .form-wrapper .form .field.is-horizontal .field-body .autocomplete,
+    .form-wrapper .form .field.is-horizontal .field-body .autocomplete .dropdown-trigger,
+    .form-wrapper .form .field.is-horizontal .field-body .select,
+    .form-wrapper .form .field.is-horizontal .field-body .select select {
+        width: 100%;
+    }
+
+    .form-wrapper .form .buttons {
+        padding-left: 10rem;
+    }
+
+  </style>
+</%def>
+
+<%def name="make_feedback_component()">
+  <% request.register_component('feedback-form', 'FeedbackForm') %>
+  <script type="text/x-template" id="feedback-form-template">
+    <div>
+
+      <o-button variant="primary"
+                @click="showFeedback()"
+                icon-left="comment">
+        Feedback
+      </o-button>
+
+      <o-modal v-model:active="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
+                       expanded>
+              </b-input>
+            </b-field>
+
+            <b-field label="Referring URL">
+              <b-input
+                 v-model="referrer"
+                 disabled expanded>
+              </b-input>
+            </b-field>
+
+            <o-field label="Message">
+              <o-input type="textarea"
+                       v-model="message"
+                       ref="message"
+                       expanded>
+              </o-input>
+            </o-field>
+
+            % if request.rattail_config.getbool('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">
+            <o-button @click="showDialog = false">
+              Cancel
+            </o-button>
+            <o-button variant="primary"
+                      @click="sendFeedback()"
+                      :disabled="sending || !message?.trim()">
+              {{ sending ? "Working, please wait..." : "Send Message" }}
+            </o-button>
+          </footer>
+        </div>
+      </o-modal>
+    </div>
+  </script>
+  <script>
+
+    const FeedbackForm = {
+        template: '#feedback-form-template',
+        mixins: [SimpleRequestMixin],
+
+        props: {
+            action: String,
+        },
+
+        data() {
+            return {
+                referrer: null,
+                % if request.user:
+                    userUUID: ${json.dumps(request.user.uuid)|n},
+                    userName: ${json.dumps(six.text_type(request.user))|n},
+                % else:
+                    userUUID: null,
+                    userName: null,
+                % endif
+                message: null,
+                pleaseReply: false,
+                userEmail: null,
+                showDialog: false,
+                sending: false,
+            }
+        },
+
+        methods: {
+
+            pleaseReplyChanged(value) {
+                this.$nextTick(() => {
+                    this.$refs.userEmail.focus()
+                })
+            },
+
+            showFeedback() {
+                this.referrer = location.href
+                this.message = null
+                this.showDialog = true
+                this.$nextTick(function() {
+                    this.$refs.message.focus()
+                })
+            },
+
+            sendFeedback() {
+                this.sending = true
+
+                const params = {
+                    referrer: this.referrer,
+                    user: this.userUUID,
+                    user_name: this.userName,
+                    please_reply_to: this.pleaseReply ? this.userEmail : '',
+                    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.sending = false
+                    this.showDialog = false
+
+                }, response => {
+                    this.sending = false
+                })
+            },
+        }
+    }
+
+  </script>
+</%def>
+
+<%def name="make_menu_search_component()">
+  <% request.register_component('menu-search', 'MenuSearch') %>
+  <script type="text/x-template" id="menu-search-template">
+    <div>
+
+      <a v-show="!searchActive"
+         href="${url('home')}"
+         class="navbar-item">
+        ${base_meta.header_logo()}
+        <div id="global-header-title">
+          ${base_meta.global_title()}
+        </div>
+      </a>
+
+      <div v-show="searchActive"
+           class="navbar-item">
+        <o-autocomplete ref="searchAutocomplete"
+                        v-model="searchTerm"
+                        :data="searchFilteredData"
+                        field="label"
+                        open-on-focus
+                        keep-first
+                        icon-pack="fas"
+                        clearable
+                        @select="searchSelect">
+        </o-autocomplete>
+      </div>
+    </div>
+  </script>
+  <script>
+
+    const MenuSearch = {
+        template: '#menu-search-template',
+
+        props: {
+            searchData: Array,
+        },
+
+        data() {
+            return {
+                searchActive: false,
+                searchTerm: null,
+                searchInput: null,
+            }
+        },
+
+        computed: {
+
+            searchFilteredData() {
+                if (!this.searchTerm || !this.searchTerm.length) {
+                    return this.searchData
+                }
+
+                let terms = []
+                for (let term of this.searchTerm.toLowerCase().split(' ')) {
+                    term = term.trim()
+                    if (term) {
+                        terms.push(term)
+                    }
+                }
+                if (!terms.length) {
+                    return this.searchData
+                }
+
+                // all terms must match
+                return this.searchData.filter((option) => {
+                    let label = option.label.toLowerCase()
+                    for (let term of terms) {
+                        if (label.indexOf(term) < 0) {
+                            return false
+                        }
+                    }
+                    return true
+                })
+            },
+        },
+
+        mounted() {
+            this.searchInput = this.$refs.searchAutocomplete.$el.querySelector('input')
+            this.searchInput.addEventListener('keydown', this.searchKeydown)
+        },
+
+        beforeDestroy() {
+            this.searchInput.removeEventListener('keydown', this.searchKeydown)
+        },
+
+        methods: {
+
+            searchInit() {
+                this.searchTerm = ''
+                this.searchActive = true
+                this.$nextTick(() => {
+                    this.$refs.searchAutocomplete.focus()
+                })
+            },
+
+            searchKeydown(event) {
+                // ESC will dismiss searchbox
+                if (event.which == 27) {
+                    this.searchActive = false
+                }
+            },
+
+            searchSelect(option) {
+                location.href = option.url
+            },
+        },
+    }
+
+  </script>
+</%def>
+
+<%def name="render_whole_page_template()">
+  <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;">
+
+      <div class="header-wrapper">
+
+        <header>
+
+          <!-- this main menu, with search -->
+          <nav class="navbar" role="navigation" aria-label="main navigation">
+
+            <div class="navbar-brand">
+              <menu-search :search-data="globalSearchData"
+                           ref="menuSearch" />
+              <a role="button" class="navbar-burger" data-target="navbarMenu" aria-label="menu" aria-expanded="false">
+                <span aria-hidden="true"></span>
+                <span aria-hidden="true"></span>
+                <span aria-hidden="true"></span>
+                <span aria-hidden="true"></span>
+              </a>
+            </div>
+
+            <div class="navbar-menu" id="navbarMenu">
+              <div class="navbar-start">
+
+                ## global search button
+                <div v-if="globalSearchData.length"
+                     class="navbar-item">
+                  <o-button variant="primary"
+                            size="small"
+                            @click="globalSearchInit()">
+                    <o-icon icon="search" size="small" />
+                  </o-button>
+                </div>
+
+                ## main menu
+                % 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 = f'menu_{item_hash}_shown' %>
+                                    <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><!-- navbar-start -->
+              ${self.render_navbar_end()}
+            </div>
+          </nav>
+
+          <!-- nb. this has index title, help button etc. -->
+          <nav style="display: flex; justify-content: space-between; align-items: center; padding: 0.5rem;">
+
+            ## Current Context
+            <div style="display: flex; gap: 0.5rem; align-items: center;">
+              % if master:
+                  % if master.listing:
+                      <h1 class="title">
+                        ${index_title}
+                      </h1>
+                      % if master.creatable and master.show_create_link and master.has_perm('create'):
+                          <once-button type="is-primary"
+                                       tag="a" href="${url('{}.create'.format(route_prefix))}"
+                                       icon-left="plus"
+                                       style="margin-left: 1rem;"
+                                       text="Create New">
+                          </once-button>
+                      % endif
+                  % elif index_url:
+                      <h1 class="title">
+                        ${h.link_to(index_title, index_url)}
+                      </h1>
+                      % if parent_url is not Undefined:
+                          <h1 class="title">
+                            &nbsp;&raquo;
+                          </h1>
+                          <h1 class="title">
+                            ${h.link_to(parent_title, parent_url)}
+                          </h1>
+                      % elif instance_url is not Undefined:
+                          <h1 class="title">
+                            &nbsp;&raquo;
+                          </h1>
+                          <h1 class="title">
+                            ${h.link_to(instance_title, instance_url)}
+                          </h1>
+                      % elif master.creatable and master.show_create_link and master.has_perm('create'):
+                          % if not request.matched_route.name.endswith('.create'):
+                              <once-button type="is-primary"
+                                           tag="a" href="${url('{}.create'.format(route_prefix))}"
+                                           icon-left="plus"
+                                           style="margin-left: 1rem;"
+                                           text="Create New">
+                              </once-button>
+                          % endif
+                      % endif
+##                         % if master.viewing and grid_index:
+##                             ${grid_index_nav()}
+##                         % endif
+                  % else:
+                      <h1 class="title">
+                        ${index_title}
+                      </h1>
+                  % endif
+              % elif index_title:
+                  % if index_url:
+                      <h1 class="title">
+                        ${h.link_to(index_title, index_url)}
+                      </h1>
+                  % else:
+                      <h1 class="title">
+                        ${index_title}
+                      </h1>
+                  % endif
+              % endif
+
+  ##             % if expose_db_picker is not Undefined and expose_db_picker:
+  ##                 <div class="level-item">
+  ##                   <p>DB:</p>
+  ##                 </div>
+  ##                 <div class="level-item">
+  ##                   ${h.form(url('change_db_engine'), ref='dbPickerForm')}
+  ##                   ${h.csrf_token(request)}
+  ##                   ${h.hidden('engine_type', value=master.engine_type_key)}
+  ##                   <b-select name="dbkey"
+  ##                             value="${db_picker_selected}"
+  ##                             @input="changeDB()">
+  ##                     % for option in db_picker_options:
+  ##                         <option value="${option.value}">
+  ##                           ${option.label}
+  ##                         </option>
+  ##                     % endfor
+  ##                   </b-select>
+  ##                   ${h.end_form()}
+  ##                 </div>
+  ##             % endif
+
+            </div>
+
+            <div style="display: flex; gap: 0.5rem;">
+
+##               ## Quickie Lookup
+##               % if quickie is not Undefined and quickie and request.has_perm(quickie.perm):
+##                   <div class="level-item">
+##                     ${h.form(quickie.url, method="get")}
+##                     <div class="level">
+##                       <div class="level-right">
+##                         <div class="level-item">
+##                           <b-input name="entry"
+##                                    placeholder="${quickie.placeholder}"
+##                                    autocomplete="off">
+##                           </b-input>
+##                         </div>
+##                         <div class="level-item">
+##                           <button type="submit" class="button is-primary">
+##                             <span class="icon is-small">
+##                               <i class="fas fa-search"></i>
+##                             </span>
+##                             <span>Lookup</span>
+##                           </button>
+##                         </div>
+##                       </div>
+##                     </div>
+##                     ${h.end_form()}
+##                   </div>
+##               % endif
+
+              % if master and master.configurable and master.has_perm('configure'):
+                  % if not request.matched_route.name.endswith('.configure'):
+                      <once-button type="is-primary"
+                                   tag="a"
+                                   href="${url('{}.configure'.format(route_prefix))}"
+                                   icon-left="cog"
+                                   text="${(configure_button_title or "Configure") if configure_button_title is not Undefined else "Configure"}">
+                      </once-button>
+                  % endif
+              % endif
+
+              ## Theme Picker
+              % if expose_theme_picker and request.has_perm('common.change_app_theme'):
+                  ${h.form(url('change_theme'), method="post", ref='themePickerForm')}
+                    ${h.csrf_token(request)}
+                    <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()}
+              % endif
+
+              % if help_url or help_markdown or can_edit_help:
+                  <page-help
+                    % if can_edit_help:
+                    @configure-fields-help="configureFieldsHelp = true"
+                    % endif
+                    >
+                  </page-help>
+              % endif
+
+              ## Feedback Button / Dialog
+              % if request.has_perm('common.feedback'):
+                  <feedback-form action="${url('feedback')}" />
+              % endif
+            </div>
+          </nav>
+        </header>
+
+        ## Page Title
+        % if capture(self.content_title):
+            <section class="has-background-primary"
+                     ## TODO: id is only for css, do we need it?
+                     id="content-title"
+                     style="padding: 0.5rem; padding-left: 1rem;">
+              <div style="display: flex; align-items: center; gap: 1rem;">
+
+                <h1 class="title has-text-white" v-html="contentTitleHTML" />
+
+                <div style="flex-grow: 1; display: flex; gap: 0.5rem;">
+                  ${self.render_instance_header_title_extras()}
+                </div>
+
+                <div style="display: flex; gap: 0.5rem;">
+                  ${self.render_instance_header_buttons()}
+                </div>
+
+              </div>
+            </section>
+        % endif
+
+      </div> <!-- header-wrapper -->
+
+      <div class="content-wrapper"
+           style="flex-grow: 1; padding: 0.5rem;">
+
+        ## Page Body
+        <section id="page-body">
+
+          % if request.session.peek_flash('error'):
+              % for error in request.session.pop_flash('error'):
+                  <b-notification type="is-warning">
+                    ${error}
+                  </b-notification>
+              % endfor
+          % endif
+
+          % if request.session.peek_flash('warning'):
+              % for msg in request.session.pop_flash('warning'):
+                  <b-notification type="is-warning">
+                    ${msg}
+                  </b-notification>
+              % endfor
+          % endif
+
+          % if request.session.peek_flash():
+              % for msg in request.session.pop_flash():
+                  <b-notification type="is-info">
+                    ${msg}
+                  </b-notification>
+              % endfor
+          % endif
+
+          ## true page content
+          <div>
+            ${self.render_this_page_component()}
+          </div>
+        </section>
+      </div><!-- content-wrapper -->
+
+      ## Footer
+      <footer class="footer">
+        <div class="content">
+          ${base_meta.footer()}
+        </div>
+      </footer>
+    </div>
+  </script>
+
+##   ${multi_file_upload.render_template()}
+</%def>
+
+<%def name="render_this_page_component()">
+  <this-page @change-content-title="changeContentTitle"
+             % if can_edit_help:
+             :configure-fields-help="configureFieldsHelp"
+             % endif
+             >
+  </this-page>
+</%def>
+
+<%def name="render_navbar_end()">
+  <div class="navbar-end">
+    ${self.render_user_menu()}
+  </div>
+</%def>
+
+<%def name="render_user_menu()">
+  % if request.user:
+      <div class="navbar-item has-dropdown is-hoverable">
+        % if messaging_enabled:
+            <a class="navbar-link ${'has-background-danger has-text-white' if request.is_root else ''}">${request.user}${" ({})".format(inbox_count) if inbox_count else ''}</a>
+        % else:
+            <a class="navbar-link ${'has-background-danger has-text-white' if request.is_root else ''}">${request.user}</a>
+        % endif
+        <div class="navbar-dropdown">
+          % if request.is_root:
+              ${h.link_to("Stop being root", url('stop_root'), class_='navbar-item has-background-danger has-text-white')}
+          % elif request.is_admin:
+              ${h.link_to("Become root", url('become_root'), class_='navbar-item has-background-danger has-text-white')}
+          % endif
+          % if messaging_enabled:
+              ${h.link_to("Messages{}".format(" ({})".format(inbox_count) if inbox_count else ''), url('messages.inbox'), class_='navbar-item')}
+          % endif
+          % 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')}
+          ${h.link_to("Logout", url('logout'), class_='navbar-item')}
+        </div>
+      </div>
+  % else:
+      ${h.link_to("Login", url('login'), class_='navbar-item')}
+  % endif
+</%def>
+
+<%def name="render_instance_header_title_extras()"></%def>
+
+<%def name="render_instance_header_buttons()">
+  ${self.render_crud_header_buttons()}
+  ${self.render_prevnext_header_buttons()}
+</%def>
+
+<%def name="render_crud_header_buttons()">
+  % if master and master.viewing:
+      ## TODO: is there a better way to check if viewing parent?
+      % if parent_instance is Undefined:
+          % if master.editable and instance_editable and master.has_perm('edit'):
+              <once-button tag="a" href="${action_url('edit', instance)}"
+                           icon-left="edit"
+                           text="Edit This">
+              </once-button>
+          % endif
+          % if master.cloneable and master.has_perm('clone'):
+              <once-button tag="a" href="${action_url('clone', instance)}"
+                           icon-left="object-ungroup"
+                           text="Clone This">
+              </once-button>
+          % endif
+          % if master.deletable and instance_deletable and master.has_perm('delete'):
+              <once-button tag="a" href="${action_url('delete', instance)}"
+                           type="is-danger"
+                           icon-left="trash"
+                           text="Delete This">
+              </once-button>
+          % endif
+      % else:
+          ## viewing row
+          % if instance_deletable and master.has_perm('delete_row'):
+              <once-button tag="a" href="${action_url('delete', instance)}"
+                           type="is-danger"
+                           icon-left="trash"
+                           text="Delete This">
+              </once-button>
+          % endif
+      % endif
+  % elif master and master.editing:
+      % if master.viewable and master.has_perm('view'):
+          <once-button tag="a" href="${action_url('view', instance)}"
+                       icon-left="eye"
+                       text="View This">
+          </once-button>
+      % endif
+      % if master.deletable and instance_deletable and master.has_perm('delete'):
+          <once-button tag="a" href="${action_url('delete', instance)}"
+                       type="is-danger"
+                       icon-left="trash"
+                       text="Delete This">
+          </once-button>
+      % endif
+  % elif master and master.deleting:
+      % if master.viewable and master.has_perm('view'):
+          <once-button tag="a" href="${action_url('view', instance)}"
+                       icon-left="eye"
+                       text="View This">
+          </once-button>
+      % endif
+      % if master.editable and instance_editable and master.has_perm('edit'):
+          <once-button tag="a" href="${action_url('edit', instance)}"
+                       icon-left="edit"
+                       text="Edit This">
+          </once-button>
+      % endif
+  % endif
+</%def>
+
+<%def name="render_prevnext_header_buttons()">
+  % if show_prev_next is not Undefined and show_prev_next:
+      % if prev_url:
+          <b-button tag="a" href="${prev_url}"
+                    icon-pack="fas"
+                    icon-left="arrow-left">
+            Older
+          </b-button>
+      % else:
+          <b-button tag="a" href="#"
+                    disabled
+                    icon-pack="fas"
+                    icon-left="arrow-left">
+            Older
+          </b-button>
+      % endif
+      % if next_url:
+          <b-button tag="a" href="${next_url}"
+                    icon-pack="fas"
+                    icon-left="arrow-right">
+            Newer
+          </b-button>
+      % else:
+          <b-button tag="a" href="#"
+                    disabled
+                    icon-pack="fas"
+                    icon-left="arrow-right">
+            Newer
+          </b-button>
+      % endif
+  % endif
+</%def>
+
+<%def name="declare_whole_page_vars()">
+##   ${multi_file_upload.declare_vars()}
+
+  <script>
+
+    const WholePage = {
+        template: '#whole-page-template',
+        mixins: [SimpleRequestMixin],
+
+        mounted() {
+            window.addEventListener('keydown', this.globalKey)
+            for (let hook of this.mountedHooks) {
+                hook(this)
+            }
+        },
+        beforeDestroy() {
+            window.removeEventListener('keydown', this.globalKey)
+        },
+
+        methods: {
+
+            changeContentTitle(newTitle) {
+                this.contentTitleHTML = newTitle
+            },
+
+            % if expose_db_picker is not Undefined and expose_db_picker:
+                changeDB() {
+                    this.$refs.dbPickerForm.submit()
+                },
+            % endif
+
+            % if expose_theme_picker and request.has_perm('common.change_app_theme'):
+                changeTheme() {
+                    this.$refs.themePickerForm.submit()
+                },
+            % endif
+
+            globalKey(event) {
+
+                // Ctrl+8 opens global search
+                if (event.target.tagName == 'BODY') {
+                    if (event.ctrlKey && event.key == '8') {
+                        this.globalSearchInit()
+                    }
+                }
+            },
+
+            globalSearchInit() {
+                this.$refs.menuSearch.searchInit()
+            },
+
+            toggleNestedMenu(hash) {
+                const key = 'menu_' + hash + '_shown'
+                this[key] = !this[key]
+            },
+        },
+    }
+
+    const WholePageData = {
+        contentTitleHTML: ${json.dumps(capture(self.content_title))|n},
+        globalSearchData: ${json.dumps(global_search_data)|n},
+        mountedHooks: [],
+
+        % if expose_theme_picker and request.has_perm('common.change_app_theme'):
+            globalTheme: ${json.dumps(theme)|n},
+        % endif
+
+        % if can_edit_help:
+            configureFieldsHelp: false,
+        % endif
+    }
+
+    ## declare nested menu visibility toggle flags
+    % for topitem in menus:
+        % if topitem['is_menu']:
+            % for item in topitem['items']:
+                % if item['is_menu']:
+                    WholePageData.menu_${id(item)}_shown = false
+                % endif
+            % endfor
+        % endif
+    % endfor
+
+  </script>
+</%def>
+
+<%def name="modify_whole_page_vars()"></%def>
+
+## TODO: do we really need this?
+## <%def name="finalize_whole_page_vars()"></%def>
+
+<%def name="make_whole_page_component()">
+  ${self.render_whole_page_template()}
+  ${self.declare_whole_page_vars()}
+  ${self.modify_whole_page_vars()}
+##   ${self.finalize_whole_page_vars()}
+
+  ${page_help.make_component()}
+##   ${multi_file_upload.make_component()}
+
+  <script>
+    WholePage.data = () => { return WholePageData }
+  </script>
+  <% request.register_component('whole-page', 'WholePage') %>
+</%def>
+
+<%def name="make_whole_page_app()">
+  <script type="module">
+    import {createApp} from 'vue'
+    import {Oruga} from '@oruga-ui/oruga-next'
+    import {bulmaConfig} from '@oruga-ui/theme-bulma'
+    import { library } from "@fortawesome/fontawesome-svg-core"
+    import { fas } from "@fortawesome/free-solid-svg-icons"
+    import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"
+    library.add(fas)
+
+    const app = createApp()
+    app.component('vue-fontawesome', FontAwesomeIcon)
+
+    % if hasattr(request, '_tailbone_registered_components'):
+        % for tagname, classname in request._tailbone_registered_components.items():
+            app.component('${tagname}', ${classname})
+        % endfor
+    % endif
+
+    app.use(Oruga, {
+        ...bulmaConfig,
+        iconComponent: 'vue-fontawesome',
+        iconPack: 'fas',
+    })
+
+    app.use(HttpPlugin)
+    app.use(BuefyPlugin)
+
+    app.mount('#app')
+  </script>
+</%def>
diff --git a/tailbone/templates/themes/butterball/buefy-components.mako b/tailbone/templates/themes/butterball/buefy-components.mako
new file mode 100644
index 00000000..531ae4a5
--- /dev/null
+++ b/tailbone/templates/themes/butterball/buefy-components.mako
@@ -0,0 +1,679 @@
+
+<%def name="make_buefy_components()">
+  ${self.make_b_autocomplete_component()}
+  ${self.make_b_button_component()}
+  ${self.make_b_checkbox_component()}
+  ${self.make_b_collapse_component()}
+  ${self.make_b_datepicker_component()}
+  ${self.make_b_dropdown_component()}
+  ${self.make_b_dropdown_item_component()}
+  ${self.make_b_field_component()}
+  ${self.make_b_icon_component()}
+  ${self.make_b_input_component()}
+  ${self.make_b_loading_component()}
+  ${self.make_b_modal_component()}
+  ${self.make_b_notification_component()}
+  ${self.make_b_select_component()}
+  ${self.make_b_steps_component()}
+  ${self.make_b_step_item_component()}
+  ${self.make_b_table_component()}
+  ${self.make_b_table_column_component()}
+  ${self.make_once_button_component()}
+</%def>
+
+<%def name="make_b_autocomplete_component()">
+  <script type="text/x-template" id="b-autocomplete-template">
+    <o-autocomplete v-model="buefyValue"
+                    :data="data"
+                    :field="field"
+                    :open-on-focus="openOnFocus"
+                    :keep-first="keepFirst"
+                    :clearable="clearable"
+                    :clear-on-select="clearOnSelect"
+                    :formatter="customFormatter"
+                    :placeholder="placeholder"
+                    @update:model-value="buefyValueUpdated"
+                    ref="autocomplete">
+    </o-autocomplete>
+  </script>
+  <script>
+    const BAutocomplete = {
+        template: '#b-autocomplete-template',
+        props: {
+            modelValue: String,
+            data: Array,
+            field: String,
+            openOnFocus: Boolean,
+            keepFirst: Boolean,
+            clearable: Boolean,
+            clearOnSelect: Boolean,
+            customFormatter: null,
+            placeholder: String,
+        },
+        data() {
+            return {
+                buefyValue: this.modelValue,
+            }
+        },
+        watch: {
+            modelValue(to, from) {
+                if (this.buefyValue != to) {
+                    this.buefyValue = to
+                }
+            },
+        },
+        methods: {
+            focus() {
+                const input = this.$refs.autocomplete.$el.querySelector('input')
+                input.focus()
+            },
+            buefyValueUpdated(value) {
+                this.$emit('update:modelValue', value)
+            },
+        },
+    }
+  </script>
+  <% request.register_component('b-autocomplete', 'BAutocomplete') %>
+</%def>
+
+<%def name="make_b_button_component()">
+  <script type="text/x-template" id="b-button-template">
+    <o-button :variant="variant"
+              :size="orugaSize"
+              :native-type="nativeType"
+              :tag="tag"
+              :href="href"
+              :icon-left="iconLeft">
+      <slot />
+    </o-button>
+  </script>
+  <script>
+    const BButton = {
+        template: '#b-button-template',
+        props: {
+            type: String,
+            nativeType: String,
+            tag: String,
+            href: String,
+            size: String,
+            iconPack: String, // ignored
+            iconLeft: String,
+        },
+        computed: {
+            orugaSize() {
+                if (this.size) {
+                    return this.size.replace(/^is-/, '')
+                }
+            },
+            variant() {
+                if (this.type) {
+                    return this.type.replace(/^is-/, '')
+                }
+            },
+        },
+    }
+  </script>
+  <% request.register_component('b-button', 'BButton') %>
+</%def>
+
+<%def name="make_b_checkbox_component()">
+  <script type="text/x-template" id="b-checkbox-template">
+    <o-checkbox v-model="orugaValue"
+                @update:model-value="orugaValueUpdated"
+                :name="name"
+                :native-value="nativeValue">
+      <slot />
+    </o-checkbox>
+  </script>
+  <script>
+    const BCheckbox = {
+        template: '#b-checkbox-template',
+        props: {
+            modelValue: null,
+            name: String,
+            nativeValue: null,
+        },
+        data() {
+            return {
+                orugaValue: this.modelValue,
+            }
+        },
+        watch: {
+            modelValue(to, from) {
+                this.orugaValue = to
+            },
+        },
+        methods: {
+            orugaValueUpdated(value) {
+                this.$emit('update:modelValue', value)
+            },
+        },
+    }
+  </script>
+  <% request.register_component('b-checkbox', 'BCheckbox') %>
+</%def>
+
+<%def name="make_b_collapse_component()">
+  <script type="text/x-template" id="b-collapse-template">
+    <o-collapse :open="open">
+      <slot name="trigger" />
+      <slot />
+    </o-collapse>
+  </script>
+  <script>
+    const BCollapse = {
+        template: '#b-collapse-template',
+        props: {
+            open: Boolean,
+        },
+    }
+  </script>
+  <% request.register_component('b-collapse', 'BCollapse') %>
+</%def>
+
+<%def name="make_b_datepicker_component()">
+  <script type="text/x-template" id="b-datepicker-template">
+    <o-datepicker :name="name"
+                  v-model="buefyValue"
+                  @update:model-value="buefyValueUpdated"
+                  :value="value"
+                  :placeholder="placeholder"
+                  :date-formatter="dateFormatter"
+                  :date-parser="dateParser"
+                  :disabled="disabled"
+                  :editable="editable"
+                  :icon="icon"
+                  :close-on-click="false">
+    </o-datepicker>
+  </script>
+  <script>
+    const BDatepicker = {
+        template: '#b-datepicker-template',
+        props: {
+            dateFormatter: null,
+            dateParser: null,
+            disabled: Boolean,
+            editable: Boolean,
+            icon: String,
+            // iconPack: String,   // ignored
+            modelValue: Date,
+            name: String,
+            placeholder: String,
+            value: null,
+        },
+        data() {
+            return {
+                buefyValue: this.modelValue,
+            }
+        },
+        watch: {
+            modelValue(to, from) {
+                if (this.buefyValue != to) {
+                    this.buefyValue = to
+                }
+            },
+        },
+        methods: {
+            buefyValueUpdated(value) {
+                if (this.modelValue != value) {
+                    this.$emit('update:modelValue', value)
+                }
+            },
+        },
+    }
+  </script>
+  <% request.register_component('b-datepicker', 'BDatepicker') %>
+</%def>
+
+<%def name="make_b_dropdown_component()">
+  <script type="text/x-template" id="b-dropdown-template">
+    <o-dropdown :position="buefyPosition"
+                :triggers="triggers">
+      <slot name="trigger" />
+      <slot />
+    </o-dropdown>
+  </script>
+  <script>
+    const BDropdown = {
+        template: '#b-dropdown-template',
+        props: {
+            position: String,
+            triggers: Array,
+        },
+        computed: {
+            buefyPosition() {
+                if (this.position) {
+                    return this.position.replace(/^is-/, '')
+                }
+            },
+        },
+    }
+  </script>
+  <% request.register_component('b-dropdown', 'BDropdown') %>
+</%def>
+
+<%def name="make_b_dropdown_item_component()">
+  <script type="text/x-template" id="b-dropdown-item-template">
+    <o-dropdown-item :label="label">
+      <slot />
+    </o-dropdown-item>
+  </script>
+  <script>
+    const BDropdownItem = {
+        template: '#b-dropdown-item-template',
+        props: {
+            label: String,
+        },
+    }
+  </script>
+  <% request.register_component('b-dropdown-item', 'BDropdownItem') %>
+</%def>
+
+<%def name="make_b_field_component()">
+  <script type="text/x-template" id="b-field-template">
+    <o-field :grouped="grouped"
+             :label="label"
+             :horizontal="horizontal"
+             :expanded="expanded"
+             :variant="variant">
+      <slot />
+    </o-field>
+  </script>
+  <script>
+    const BField = {
+        template: '#b-field-template',
+        props: {
+            expanded: Boolean,
+            grouped: Boolean,
+            horizontal: Boolean,
+            label: String,
+            type: String,
+        },
+        computed: {
+            variant() {
+                if (this.type) {
+                    return this.type.replace(/^is-/, '')
+                }
+            },
+        },
+    }
+  </script>
+  <% request.register_component('b-field', 'BField') %>
+</%def>
+
+<%def name="make_b_icon_component()">
+  <script type="text/x-template" id="b-icon-template">
+    <o-icon :icon="icon"
+            :size="orugaSize" />
+  </script>
+  <script>
+    const BIcon = {
+        template: '#b-icon-template',
+        props: {
+            icon: String,
+            size: String,
+        },
+        computed: {
+            orugaSize() {
+                if (this.size) {
+                    return this.size.replace(/^is-/, '')
+                }
+            },
+        },
+    }
+  </script>
+  <% request.register_component('b-icon', 'BIcon') %>
+</%def>
+
+<%def name="make_b_input_component()">
+  <script type="text/x-template" id="b-input-template">
+    <o-input :type="type"
+             :disabled="disabled"
+             v-model="buefyValue"
+             @update:modelValue="val => $emit('update:modelValue', val)"
+             autocomplete="off"
+             ref="input">
+      <slot />
+    </o-input>
+  </script>
+  <script>
+    const BInput = {
+        template: '#b-input-template',
+        props: {
+            modelValue: null,
+            type: String,
+            disabled: Boolean,
+        },
+        data() {
+            return {
+                buefyValue: this.modelValue
+            }
+        },
+        watch: {
+            modelValue(to, from) {
+                if (this.buefyValue != to) {
+                    this.buefyValue = to
+                }
+            },
+        },
+        methods: {
+            focus() {
+                if (this.type == 'textarea') {
+                    // TODO: this does not work right
+                    this.$refs.input.$el.querySelector('textarea').focus()
+                }
+            },
+        },
+    }
+  </script>
+  <% request.register_component('b-input', 'BInput') %>
+</%def>
+
+<%def name="make_b_loading_component()">
+  <script type="text/x-template" id="b-loading-template">
+    <o-loading>
+      <slot />
+    </o-loading>
+  </script>
+  <script>
+    const BLoading = {
+        template: '#b-loading-template',
+    }
+  </script>
+  <% request.register_component('b-loading', 'BLoading') %>
+</%def>
+
+<%def name="make_b_modal_component()">
+  <script type="text/x-template" id="b-modal-template">
+    <o-modal v-model:active="trueActive"
+             @update:active="activeChanged">
+      <slot />
+    </o-modal>
+  </script>
+  <script>
+    const BModal = {
+        template: '#b-modal-template',
+        props: {
+            active: Boolean,
+            hasModalCard: Boolean, // nb. this is ignored
+        },
+        data() {
+            return {
+                trueActive: this.active,
+            }
+        },
+        watch: {
+            active(to, from) {
+                this.trueActive = to
+            },
+            trueActive(to, from) {
+                if (this.active != to) {
+                    this.tellParent(to)
+                }
+            },
+        },
+        methods: {
+
+            tellParent(active) {
+                // TODO: this does not work properly
+                this.$emit('update:active', active)
+            },
+
+            activeChanged(active) {
+                this.tellParent(active)
+            },
+        },
+    }
+  </script>
+  <% request.register_component('b-modal', 'BModal') %>
+</%def>
+
+<%def name="make_b_notification_component()">
+  <script type="text/x-template" id="b-notification-template">
+    <o-notification :variant="variant"
+                    :closable="closable">
+      <slot />
+    </o-notification>
+  </script>
+  <script>
+    const BNotification = {
+        template: '#b-notification-template',
+        props: {
+            type: String,
+            closable: {
+                type: Boolean,
+                default: true,
+            },
+        },
+        computed: {
+            variant() {
+                if (this.type) {
+                    return this.type.replace(/^is-/, '')
+                }
+            },
+        },
+    }
+  </script>
+  <% request.register_component('b-notification', 'BNotification') %>
+</%def>
+
+<%def name="make_b_select_component()">
+  <script type="text/x-template" id="b-select-template">
+    <o-select :name="name"
+              v-model="orugaValue"
+              @update:model-value="orugaValueUpdated"
+              :expanded="expanded"
+              :multiple="multiple"
+              :size="orugaSize"
+              :native-size="nativeSize">
+      <slot />
+    </o-select>
+  </script>
+  <script>
+    const BSelect = {
+        template: '#b-select-template',
+        props: {
+            expanded: Boolean,
+            modelValue: null,
+            multiple: Boolean,
+            name: String,
+            nativeSize: null,
+            size: null,
+        },
+        data() {
+            return {
+                orugaValue: this.modelValue,
+            }
+        },
+        watch: {
+            modelValue(to, from) {
+                this.orugaValue = to
+            },
+        },
+        computed: {
+            orugaSize() {
+                if (this.size) {
+                    return this.size.replace(/^is-/, '')
+                }
+            },
+        },
+        methods: {
+            orugaValueUpdated(value) {
+                this.$emit('update:modelValue', value)
+                this.$emit('input', value)
+            },
+        },
+    }
+  </script>
+  <% request.register_component('b-select', 'BSelect') %>
+</%def>
+
+<%def name="make_b_steps_component()">
+  <script type="text/x-template" id="b-steps-template">
+    <o-steps v-model="orugaValue"
+             @update:model-value="orugaValueUpdated"
+             :animated="animated"
+             :rounded="rounded"
+             :has-navigation="hasNavigation"
+             :vertical="vertical">
+      <slot />
+    </o-steps>
+  </script>
+  <script>
+    const BSteps = {
+        template: '#b-steps-template',
+        props: {
+            modelValue: null,
+            animated: Boolean,
+            rounded: Boolean,
+            hasNavigation: Boolean,
+            vertical: Boolean,
+        },
+        data() {
+            return {
+                orugaValue: this.modelValue,
+            }
+        },
+        watch: {
+            modelValue(to, from) {
+                this.orugaValue = to
+            },
+        },
+        methods: {
+            orugaValueUpdated(value) {
+                this.$emit('update:modelValue', value)
+                this.$emit('input', value)
+            },
+        },
+    }
+  </script>
+  <% request.register_component('b-steps', 'BSteps') %>
+</%def>
+
+<%def name="make_b_step_item_component()">
+  <script type="text/x-template" id="b-step-item-template">
+    <o-step-item :step="step"
+                 :value="value"
+                 :label="label"
+                 :clickable="clickable">
+      <slot />
+    </o-step-item>
+  </script>
+  <script>
+    const BStepItem = {
+        template: '#b-step-item-template',
+        props: {
+            step: null,
+            value: null,
+            label: String,
+            clickable: Boolean,
+        },
+    }
+  </script>
+  <% request.register_component('b-step-item', 'BStepItem') %>
+</%def>
+
+<%def name="make_b_table_component()">
+  <script type="text/x-template" id="b-table-template">
+    <o-table :data="data">
+      <slot />
+    </o-table>
+  </script>
+  <script>
+    const BTable = {
+        template: '#b-table-template',
+        props: {
+            data: Array,
+        },
+    }
+  </script>
+  <% request.register_component('b-table', 'BTable') %>
+</%def>
+
+<%def name="make_b_table_column_component()">
+  <script type="text/x-template" id="b-table-column-template">
+    <o-table-column :field="field"
+                    :label="label"
+                    v-slot="props">
+      ## TODO: this does not seem to really work for us...
+      <slot :props="props" />
+    </o-table-column>
+  </script>
+  <script>
+    const BTableColumn = {
+        template: '#b-table-column-template',
+        props: {
+            field: String,
+            label: String,
+        },
+    }
+  </script>
+  <% request.register_component('b-table-column', 'BTableColumn') %>
+</%def>
+
+<%def name="make_once_button_component()">
+  <script type="text/x-template" id="once-button-template">
+    <b-button :type="type"
+              :native-type="nativeType"
+              :tag="tag"
+              :href="href"
+              :title="title"
+              :disabled="buttonDisabled"
+              @click="clicked"
+              icon-pack="fas"
+              :icon-left="iconLeft">
+      {{ buttonText }}
+    </b-button>
+  </script>
+  <script>
+    const OnceButton = {
+        template: '#once-button-template',
+        props: {
+            type: String,
+            nativeType: String,
+            tag: String,
+            href: String,
+            text: String,
+            title: String,
+            iconLeft: String,
+            working: String,
+            workingText: String,
+            disabled: Boolean,
+        },
+        data() {
+            return {
+                currentText: null,
+                currentDisabled: null,
+            }
+        },
+        computed: {
+            buttonText: function() {
+                return this.currentText || this.text
+            },
+            buttonDisabled: function() {
+                if (this.currentDisabled !== null) {
+                    return this.currentDisabled
+                }
+                return this.disabled
+            },
+        },
+        methods: {
+
+            clicked(event) {
+                this.currentDisabled = true
+                if (this.workingText) {
+                    this.currentText = this.workingText
+                } else if (this.working) {
+                    this.currentText = this.working + ", please wait..."
+                } else {
+                    this.currentText = "Working, please wait..."
+                }
+                // this.$nextTick(function() {
+                //     this.$emit('click', event)
+                // })
+            }
+        },
+    }
+  </script>
+  <% request.register_component('once-button', 'OnceButton') %>
+</%def>
diff --git a/tailbone/templates/themes/butterball/buefy-plugin.mako b/tailbone/templates/themes/butterball/buefy-plugin.mako
new file mode 100644
index 00000000..4cbedfea
--- /dev/null
+++ b/tailbone/templates/themes/butterball/buefy-plugin.mako
@@ -0,0 +1,32 @@
+
+<%def name="make_buefy_plugin()">
+  <script>
+
+    const BuefyPlugin = {
+        install(app, options) {
+            app.config.globalProperties.$buefy = {
+
+                toast: {
+                    open(options) {
+
+                        let variant = null
+                        if (options.type) {
+                            variant = options.type.replace(/^is-/, '')
+                        }
+
+                        const opts = {
+                            duration: options.duration,
+                            message: options.message,
+                            position: 'top',
+                            variant,
+                        }
+
+                        const oruga = app.config.globalProperties.$oruga
+                        oruga.notification.open(opts)
+                    },
+                },
+            }
+        },
+    }
+  </script>
+</%def>
diff --git a/tailbone/templates/themes/butterball/field-components.mako b/tailbone/templates/themes/butterball/field-components.mako
new file mode 100644
index 00000000..1925e794
--- /dev/null
+++ b/tailbone/templates/themes/butterball/field-components.mako
@@ -0,0 +1,382 @@
+## -*- coding: utf-8; -*-
+
+<%def name="make_field_components()">
+  ${self.make_tailbone_autocomplete_component()}
+  ${self.make_tailbone_datepicker_component()}
+</%def>
+
+<%def name="make_tailbone_autocomplete_component()">
+  <% request.register_component('tailbone-autocomplete', 'TailboneAutocomplete') %>
+  <script type="text/x-template" id="tailbone-autocomplete-template">
+    <div>
+
+      <o-button v-if="modelValue"
+                style="width: 100%; justify-content: left;"
+                @click="clearSelection(true)"
+                expanded>
+        {{ internalLabel }} (click to change #1)
+      </o-button>
+
+      <o-autocomplete ref="autocompletex"
+                      v-show="!modelValue"
+                      v-model="orugaValue"
+                      :placeholder="placeholder"
+                      :data="filteredData"
+                      :field="field"
+                      :formatter="customFormatter"
+                      @input="inputChanged"
+                      @select="optionSelected"
+                      keep-first
+                      open-on-focus
+                      expanded
+                      :clearable="clearable"
+                      :clear-on-select="clearOnSelect">
+        <template #default="{ option }">
+          {{ option.label }}
+        </template>
+      </o-autocomplete>
+
+      <input type="hidden" :name="name" :value="modelValue" />
+    </div>
+  </script>
+  <script>
+
+    const TailboneAutocomplete = {
+        template: '#tailbone-autocomplete-template',
+
+        props: {
+
+            // this is the "input" field name essentially.  primarily
+            // is useful for "traditional" tailbone forms; it normally
+            // is not used otherwise.  it is passed as-is to the oruga
+            // autocomplete component `name` prop
+            name: String,
+
+            // static data set; used when serviceUrl is not provided
+            data: Array,
+
+            // the url from which search results are to be obtained.  the
+            // url should expect a GET request with a query string with a
+            // single `term` parameter, and return results as a JSON array
+            // containing objects with `value` and `label` properties.
+            serviceUrl: String,
+
+            // callers do not specify this directly but rather by way of
+            // the `v-model` directive.  this component will emit `input`
+            // events when the value changes
+            modelValue: String,
+
+            // callers may set an initial label if needed.  this is useful
+            // in cases where the autocomplete needs to "already have a
+            // value" on page load.  for instance when a user fills out
+            // the autocomplete field, but leaves other required fields
+            // blank and submits the form; page will re-load showing
+            // errors but the autocomplete field should remain "set" -
+            // normally it is only given a "value" (e.g. uuid) but this
+            // allows for the "label" to display correctly as well
+            initialLabel: String,
+
+            // while the `initialLabel` above is useful for setting the
+            // *initial* label (of course), it cannot be used to
+            // arbitrarily update the label during the component's life.
+            // if you do need to *update* the label after initial page
+            // load, then you should set `assignedLabel` instead.  one
+            // place this happens is in /custorders/create page, where
+            // product autocomplete shows some results, and user clicks
+            // one, but then handler logic can forcibly "swap" the
+            // selection, causing *different* product data to come back
+            // from the server, and autocomplete label should be updated
+            // to match.  this feels a bit awkward still but does work..
+            assignedLabel: String,
+
+            // simple placeholder text for the input box
+            placeholder: String,
+
+            // these are passed as-is to <o-autocomplete>
+            clearable: Boolean,
+            clearOnSelect: Boolean,
+            customFormatter: null,
+            expanded: Boolean,
+            field: String,
+        },
+
+        data() {
+
+            const internalLabel = this.assignedLabel || this.initialLabel
+
+            // we want to track the "currently selected option" - which
+            // should normally be `null` to begin with, unless we were
+            // given a value, in which case we use `initialLabel` to
+            // complete the option
+            let selected = null
+            if (this.modelValue) {
+                selected = {
+                    value: this.modelValue,
+                    label: internalLabel,
+                }
+            }
+
+            return {
+
+                // this contains the search results; its contents may
+                // change over time as new searches happen.  the
+                // "currently selected option" should be one of these,
+                // unless it is null
+                fetchedData: [],
+
+                // this tracks our "currently selected option" - per above
+                selected,
+
+                // since we are wrapping a component which also makes
+                // use of the "value" paradigm, we must separate the
+                // concerns.  so we use our own `modelValue` prop to
+                // interact with the caller, but then we use this
+                // `orugaValue` data point to communicate with the
+                // oruga autocomplete component.  note that
+                // `this.modelValue` will always be either a uuid or
+                // null, whereas `this.orugaValue` may be raw text as
+                // entered by the user.
+                // orugaValue: this.modelValue,
+                orugaValue: null,
+
+                // this stores the "internal" label for the button
+                internalLabel,
+            }
+        },
+
+        computed: {
+
+            filteredData() {
+
+                // do not filter if data comes from backend
+                if (this.serviceUrl) {
+                    return this.fetchedData
+                }
+
+                if (!this.orugaValue || !this.orugaValue.length) {
+                    return this.data
+                }
+
+                const terms = []
+                for (let term of this.orugaValue.toLowerCase().split(' ')) {
+                    term = term.trim()
+                    if (term) {
+                        terms.push(term)
+                    }
+                }
+                if (!terms.length) {
+                    return this.data
+                }
+
+                // all terms must match
+                return this.data.filter((option) => {
+                    const label = option.label.toLowerCase()
+                    for (const term of terms) {
+                        if (label.indexOf(term) < 0) {
+                            return false
+                        }
+                    }
+                    return true
+                })
+            },
+        },
+
+        watch: {
+
+            assignedLabel(to, from) {
+                // update button label when caller changes it
+                this.internalLabel = to
+            },
+        },
+
+        methods: {
+
+            inputChanged(entry) {
+                if (this.serviceUrl) {
+                    this.getAsyncData(entry)
+                }
+            },
+
+            // fetch new search results from the server.  this is
+            // invoked via the `@input` event from oruga autocomplete
+            // component.
+            getAsyncData(entry) {
+
+                // since the `@input` event from oruga component does
+                // not "self-regulate" in any way (?), we skip the
+                // search unless we have at least 3 characters of
+                // input from user
+                if (entry.length < 3) {
+                    this.fetchedData = []
+                    return
+                }
+
+                // and perform the search
+                this.$http.get(this.serviceUrl + '?term=' + encodeURIComponent(entry))
+                    .then(({ data }) => {
+                        this.fetchedData = data
+                    })
+                    .catch((error) => {
+                        this.fetchedData = []
+                        throw error
+                    })
+            },
+
+            // this method is invoked via the `@select` event of the
+            // oruga autocomplete component.  the `option` received
+            // will be one of:
+            // - object with (at least) `value` and `label` keys
+            // - simple string (e.g. when data set is static)
+            // - null
+            optionSelected(option) {
+
+                this.selected = option
+                this.internalLabel = option?.label || option
+
+                // reset the internal value for oruga autocomplete
+                // component.  note that this value will normally hold
+                // either the raw text entered by the user, or a uuid.
+                // we will not be needing either of those b/c they are
+                // not visible to user once selection is made, and if
+                // the selection is cleared we want user to start over
+                // anyway
+                this.orugaValue = null
+
+                // here is where we alert callers to the new value
+                if (option) {
+                    this.$emit('newLabel', option.label)
+                }
+                const value = option?.[this.field || 'value'] || option
+                this.$emit('update:modelValue', value)
+                // this.$emit('select', option)
+                // this.$emit('input', value)
+            },
+
+##             // set selection to the given option, which should a simple
+##             // object with (at least) `value` and `label` properties
+##             setSelection(option) {
+##                 this.$refs.autocomplete.setSelected(option)
+##             },
+
+            // clear the field of any value, i.e. set the "currently
+            // selected option" to null.  this is invoked when you click
+            // the button, which is visible while the field has a value.
+            // but callers can invoke it directly as well.
+            clearSelection(focus) {
+
+                this.$emit('update:modelValue', null)
+                this.$emit('input', null)
+                this.$emit('newLabel', null)
+                this.internalLabel = null
+                this.selected = null
+                this.orugaValue = null
+
+##                 // clear selection for the oruga autocomplete component
+##                 this.$refs.autocomplete.setSelected(null)
+
+                // maybe set focus to our (autocomplete) component
+                if (focus) {
+                    this.$nextTick(function() {
+                        this.focus()
+                    })
+                }
+            },
+
+            // set focus to this component, which will just set focus
+            // to the oruga autocomplete component
+            focus() {
+                // TODO: why is this ref null?!
+                if (this.$refs.autocompletex) {
+                    this.$refs.autocompletex.focus()
+                }
+            },
+
+            // returns the "raw" user input from the underlying oruga
+            // autocomplete component
+            getUserInput() {
+                return this.orugaValue
+            },
+        },
+    }
+
+  </script>
+</%def>
+
+<%def name="make_tailbone_datepicker_component()">
+  <% request.register_component('tailbone-datepicker', 'TailboneDatepicker') %>
+  <script type="text/x-template" id="tailbone-datepicker-template">
+    <o-datepicker placeholder="Click to select ..."
+                  icon="calendar-alt"
+                  :date-formatter="formatDate"
+                  :date-parser="parseDate"
+                  v-model="orugaValue"
+                  @update:model-value="orugaValueUpdated"
+                  :disabled="disabled"
+                  ref="trueDatePicker">
+    </o-datepicker>
+  </script>
+  <script>
+
+    const TailboneDatepicker = {
+        template: '#tailbone-datepicker-template',
+
+        props: {
+            modelValue: Date,
+            disabled: Boolean,
+        },
+
+        data() {
+            return {
+                orugaValue: this.parseDate(this.modelValue),
+            }
+        },
+
+        watch: {
+            modelValue(to, from) {
+                if (this.orugaValue != to) {
+                    this.orugaValue = to
+                }
+            },
+        },
+
+        methods: {
+
+            formatDate(date) {
+                if (date === null) {
+                    return null
+                }
+                // just need to convert to simple ISO date format here, seems
+                // like there should be a more obvious way to do that?
+                var year = date.getFullYear()
+                var month = date.getMonth() + 1
+                var day = date.getDate()
+                month = month < 10 ? '0' + month : month
+                day = day < 10 ? '0' + day : day
+                return year + '-' + month + '-' + day
+            },
+
+            parseDate(value) {
+                if (typeof(value) == 'object') {
+                    // nb. we are assuming it is a Date here
+                    return value
+                }
+                if (value) {
+                    // note, this assumes classic YYYY-MM-DD (i.e. ISO?) format
+                    const parts = value.split('-')
+                    return new Date(parts[0], parseInt(parts[1]) - 1, parts[2])
+                }
+                return null
+            },
+
+            orugaValueUpdated(date) {
+                this.$emit('update:modelValue', date)
+            },
+
+            focus() {
+                this.$refs.trueDatePicker.focus()
+            },
+        },
+    }
+
+  </script>
+</%def>
diff --git a/tailbone/templates/themes/butterball/http-plugin.mako b/tailbone/templates/themes/butterball/http-plugin.mako
new file mode 100644
index 00000000..06afc2bb
--- /dev/null
+++ b/tailbone/templates/themes/butterball/http-plugin.mako
@@ -0,0 +1,100 @@
+
+<%def name="make_http_plugin()">
+  <script>
+
+    const HttpPlugin = {
+
+        install(app, options) {
+            app.config.globalProperties.$http = {
+
+                get(url, options) {
+                    if (options === undefined) {
+                        options = {}
+                    }
+
+                    if (options.params) {
+                        // convert params to query string
+                        const data = new URLSearchParams()
+                        for (let [key, value] of Object.entries(options.params)) {
+                            // nb. all values get converted to string here, so
+                            // fallback to empty string to avoid null value
+                            // from being interpreted as "null" string
+                            if (value === null) {
+                                value = ''
+                            }
+                            data.append(key, value)
+                        }
+                        // TODO: this should be smarter in case query string already exists
+                        url += '?' + data.toString()
+                        // params is not a valid arg for options to fetch()
+                        delete options.params
+                    }
+
+                    return new Promise((resolve, reject) => {
+                        fetch(url, options).then(response => {
+                            // original response does not contain 'data'
+                            // attribute, so must use a "mock" response
+                            // which does contain everything
+                            response.json().then(json => {
+                                resolve({
+                                    data: json,
+                                    headers: response.headers,
+                                    ok: response.ok,
+                                    redirected: response.redirected,
+                                    status: response.status,
+                                    statusText: response.statusText,
+                                    type: response.type,
+                                    url: response.url,
+                                })
+                            }, json => {
+                                reject(response)
+                            })
+                        }, response => {
+                            reject(response)
+                        })
+                    })
+                },
+
+                post(url, params, options) {
+
+                    if (params) {
+
+                        // attach params as json
+                        options.body = JSON.stringify(params)
+
+                        // and declare content-type
+                        options.headers = new Headers(options.headers)
+                        options.headers.append('Content-Type', 'application/json')
+                    }
+
+                    options.method = 'POST'
+
+                    return new Promise((resolve, reject) => {
+                        fetch(url, options).then(response => {
+                            // original response does not contain 'data'
+                            // attribute, so must use a "mock" response
+                            // which does contain everything
+                            response.json().then(json => {
+                                resolve({
+                                    data: json,
+                                    headers: response.headers,
+                                    ok: response.ok,
+                                    redirected: response.redirected,
+                                    status: response.status,
+                                    statusText: response.statusText,
+                                    type: response.type,
+                                    url: response.url,
+                                })
+                            }, json => {
+                                reject(response)
+                            })
+                        }, response => {
+                            reject(response)
+                        })
+                    })
+                },
+            }
+        },
+    }
+  </script>
+</%def>
diff --git a/tailbone/templates/themes/butterball/progress.mako b/tailbone/templates/themes/butterball/progress.mako
new file mode 100644
index 00000000..1c389fb8
--- /dev/null
+++ b/tailbone/templates/themes/butterball/progress.mako
@@ -0,0 +1,244 @@
+## -*- coding: utf-8; -*-
+<%namespace name="base_meta" file="/base_meta.mako" />
+<%namespace file="/base.mako" import="core_javascript" />
+<%namespace file="/base.mako" import="core_styles" />
+<%namespace file="/http-plugin.mako" import="make_http_plugin" />
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta http-equiv="content-type" content="text/html; charset=UTF-8" />
+    ${base_meta.favicon()}
+    <title>${initial_msg or "Working"}...</title>
+    ${core_javascript()}
+    ${core_styles()}
+    ${self.extra_styles()}
+  </head>
+
+  <body>
+    <div id="app" style="height: 100%; display: flex; flex-direction: column; justify-content: space-between;">
+      <whole-page></whole-page>
+    </div>
+
+    ${make_http_plugin()}
+    ${self.make_whole_page_component()}
+    ${self.modify_whole_page_vars()}
+    ${self.make_whole_page_app()}
+  </body>
+</html>
+
+<%def name="extra_styles()"></%def>
+
+<%def name="make_whole_page_component()">
+  <script type="text/x-template" id="whole-page-template">
+    <section class="hero is-fullheight">
+      <div class="hero-body">
+        <div class="container">
+
+          <div style="display: flex; flex-direction: column; justify-content: center;">
+            <div style="margin: auto; display: flex; gap: 1rem; align-items: end;">
+
+              <div style="display: flex; flex-direction: column; gap: 1rem;">
+
+                <div style="display: flex; gap: 3rem;">
+                  <span>{{ progressMessage }} ... {{ totalDisplay }}</span>
+                  <span>{{ percentageDisplay }}</span>
+                </div>
+
+                <div style="display: flex; gap: 1rem; align-items: center;">
+
+                  <div>
+                    <progress class="progress is-large"
+                              style="width: 400px;"
+                              :max="progressMax"
+                              :value="progressValue" />
+                  </div>
+
+                  % if can_cancel:
+                      <o-button v-show="canCancel"
+                                @click="cancelProgress()"
+                                :disabled="cancelingProgress"
+                                icon-left="ban">
+                        {{ cancelingProgress ? "Canceling, please wait..." : "Cancel" }}
+                      </o-button>
+                  % endif
+
+                </div>
+              </div>
+
+            </div>
+          </div>
+
+          ${self.after_progress()}
+
+        </div>
+      </div>
+    </section>
+  </script>
+  <script>
+
+    const WholePage = {
+        template: '#whole-page-template',
+
+        computed: {
+
+            percentageDisplay() {
+                if (!this.progressMax) {
+                    return
+                }
+
+                const percent = this.progressValue / this.progressMax
+                return percent.toLocaleString(undefined, {
+                    style: 'percent',
+                    minimumFractionDigits: 0})
+            },
+
+            totalDisplay() {
+
+                % if can_cancel:
+                    if (!this.stillInProgress && !this.cancelingProgress) {
+                        return "done!"
+                    }
+                % else:
+                    if (!this.stillInProgress) {
+                        return "done!"
+                    }
+                % endif
+
+                if (this.progressMaxDisplay) {
+                    return `(${'$'}{this.progressMaxDisplay} total)`
+                }
+            },
+        },
+
+        mounted() {
+
+            // fetch first progress data, one second from now
+            setTimeout(() => {
+                this.updateProgress()
+            }, 1000)
+
+            // custom logic if applicable
+            this.mountedCustom()
+        },
+
+        methods: {
+
+            mountedCustom() {},
+
+            updateProgress() {
+
+                this.$http.get(this.progressURL).then(response => {
+
+                    if (response.data.error) {
+                        // errors stop the show, we redirect to "cancel" page
+                        location.href = '${cancel_url}'
+
+                    } else {
+
+                        if (response.data.complete || response.data.maximum) {
+                            this.progressMessage = response.data.message
+                            this.progressMaxDisplay = response.data.maximum_display
+
+                            if (response.data.complete) {
+                                this.progressValue = this.progressMax
+                                this.stillInProgress = false
+                                % if can_cancel:
+                                this.canCancel = false
+                                % endif
+
+                                location.href = response.data.success_url
+
+                            } else {
+                                this.progressValue = response.data.value
+                                this.progressMax = response.data.maximum
+                            }
+                        }
+
+                        // custom logic if applicable
+                        this.updateProgressCustom(response)
+
+                        if (this.stillInProgress) {
+
+                            // fetch progress data again, in one second from now
+                            setTimeout(() => {
+                                this.updateProgress()
+                            }, 1000)
+                        }
+                    }
+                })
+            },
+
+            updateProgressCustom(response) {},
+
+            % if can_cancel:
+
+                cancelProgress() {
+
+                    if (confirm("Do you really wish to cancel this operation?")) {
+
+                        this.cancelingProgress = true
+                        this.stillInProgress = false
+
+                        let params = {cancel_msg: ${json.dumps(cancel_msg)|n}}
+                        this.$http.get(this.cancelURL, {params: params}).then(response => {
+                            location.href = ${json.dumps(cancel_url)|n}
+                        })
+                    }
+
+                },
+
+            % endif
+        }
+    }
+
+    const WholePageData = {
+
+        progressURL: '${url('progress', key=progress.key, _query={'sessiontype': progress.session.type})}',
+        progressMessage: "${(initial_msg or "Working").replace('"', '\\"')} (please wait)",
+        progressMax: null,
+        progressMaxDisplay: null,
+        progressValue: null,
+        stillInProgress: true,
+
+        % if can_cancel:
+        canCancel: true,
+        cancelURL: '${url('progress.cancel', key=progress.key, _query={'sessiontype': progress.session.type})}',
+        cancelingProgress: false,
+        % endif
+    }
+
+  </script>
+</%def>
+
+<%def name="after_progress()"></%def>
+
+<%def name="modify_whole_page_vars()"></%def>
+
+<%def name="make_whole_page_app()">
+  <script type="module">
+    import {createApp} from 'vue'
+    import {Oruga} from '@oruga-ui/oruga-next'
+    import {bulmaConfig} from '@oruga-ui/theme-bulma'
+    import { library } from "@fortawesome/fontawesome-svg-core"
+    import { fas } from "@fortawesome/free-solid-svg-icons"
+    import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"
+    library.add(fas)
+
+    const app = createApp()
+
+    app.component('vue-fontawesome', FontAwesomeIcon)
+
+    WholePage.data = () => { return WholePageData }
+    app.component('whole-page', WholePage)
+
+    app.use(Oruga, {
+        ...bulmaConfig,
+        iconComponent: 'vue-fontawesome',
+        iconPack: 'fas',
+    })
+
+    app.use(HttpPlugin)
+
+    app.mount('#app')
+  </script>
+</%def>
diff --git a/tailbone/templates/upgrades/configure.mako b/tailbone/templates/upgrades/configure.mako
index fd2c60ad..f7af685c 100644
--- a/tailbone/templates/upgrades/configure.mako
+++ b/tailbone/templates/upgrades/configure.mako
@@ -7,31 +7,35 @@
   <h3 class="is-size-3">Upgradable Systems</h3>
   <div class="block" style="padding-left: 2rem; display: flex;">
 
-    <b-table :data="upgradeSystems"
+    <${b}-table :data="upgradeSystems"
              sortable>
-      <b-table-column field="key"
+      <${b}-table-column field="key"
                       label="Key"
                       v-slot="props"
                       sortable>
         {{ props.row.key }}
-      </b-table-column>
-      <b-table-column field="label"
+      </${b}-table-column>
+      <${b}-table-column field="label"
                       label="Label"
                       v-slot="props"
                       sortable>
         {{ props.row.label }}
-      </b-table-column>
-      <b-table-column field="command"
+      </${b}-table-column>
+      <${b}-table-column field="command"
                       label="Command"
                       v-slot="props"
                       sortable>
         {{ props.row.command }}
-      </b-table-column>
-      <b-table-column label="Actions"
+      </${b}-table-column>
+      <${b}-table-column label="Actions"
                       v-slot="props">
         <a href="#"
            @click.prevent="upgradeSystemEdit(props.row)">
-          <i class="fas fa-edit"></i>
+          % if request.use_oruga:
+              <o-icon icon="edit" />
+          % else:
+              <i class="fas fa-edit"></i>
+          % endif
           Edit
         </a>
         &nbsp;
@@ -39,11 +43,15 @@
            v-if="props.row.key != 'rattail'"
            class="has-text-danger"
            @click.prevent="updateSystemDelete(props.row)">
-          <i class="fas fa-trash"></i>
+          % if request.use_oruga:
+              <o-icon icon="trash" />
+          % else:
+              <i class="fas fa-trash"></i>
+          % endif
           Delete
         </a>
-      </b-table-column>
-    </b-table>
+      </${b}-table-column>
+    </${b}-table>
 
     <div style="margin-left: 1rem;">
       <b-button type="is-primary"
diff --git a/tailbone/util.py b/tailbone/util.py
index f6678316..087bdfcb 100644
--- a/tailbone/util.py
+++ b/tailbone/util.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.
 #
@@ -31,7 +31,6 @@ import warnings
 import humanize
 import markdown
 
-from rattail.time import timezone, make_utc
 from rattail.files import resource_path
 
 import colander
@@ -161,6 +160,25 @@ def get_libver(request, key, fallback=True, default_only=False):
     elif key == 'fontawesome':
         return '5.3.1'
 
+    elif key == 'bb_vue':
+        # TODO: iiuc vue 3.4 does not work with oruga yet
+        return '3.3.11'
+
+    elif key == 'bb_oruga':
+        return '0.8.8'
+
+    elif key in ('bb_oruga_bulma', 'bb_oruga_bulma_css'):
+        return '0.3.0'
+
+    elif key == 'bb_fontawesome_svg_core':
+        return '6.5.2'
+
+    elif key == 'bb_free_solid_svg_icons':
+        return '6.5.2'
+
+    elif key == 'bb_vue_fontawesome':
+        return '3.0.6'
+
 
 def get_liburl(request, key, fallback=True):
     """
@@ -192,6 +210,27 @@ def get_liburl(request, key, fallback=True):
     elif key == 'fontawesome':
         return 'https://use.fontawesome.com/releases/v{}/js/all.js'.format(version)
 
+    elif key == 'bb_vue':
+        return f'https://unpkg.com/vue@{version}/dist/vue.esm-browser.js'
+
+    elif key == 'bb_oruga':
+        return f'https://unpkg.com/@oruga-ui/oruga-next@{version}/dist/esm/index.mjs'
+
+    elif key == 'bb_oruga_bulma':
+        return f'https://unpkg.com/@oruga-ui/theme-bulma@{version}/dist/bulma.mjs'
+
+    elif key == 'bb_oruga_bulma_css':
+        return f'https://unpkg.com/@oruga-ui/theme-bulma@{version}/dist/bulma.css'
+
+    elif key == 'bb_fontawesome_svg_core':
+        return f'https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-svg-core@{version}/+esm'
+
+    elif key == 'bb_free_solid_svg_icons':
+        return f'https://cdn.jsdelivr.net/npm/@fortawesome/free-solid-svg-icons@{version}/+esm'
+
+    elif key == 'bb_vue_fontawesome':
+        return f'https://cdn.jsdelivr.net/npm/@fortawesome/vue-fontawesome@{version}/+esm'
+
 
 def pretty_datetime(config, value):
     """
@@ -214,10 +253,10 @@ def pretty_datetime(config, value):
         value = app.make_utc(value, tzinfo=True)
 
     # Calculate time diff using UTC.
-    time_ago = datetime.datetime.utcnow() - make_utc(value)
+    time_ago = datetime.datetime.utcnow() - app.make_utc(value)
 
     # Convert value to local timezone.
-    local = timezone(config)
+    local = app.get_timezone()
     value = local.normalize(value.astimezone(local))
 
     return HTML.tag('span',
@@ -246,10 +285,10 @@ def raw_datetime(config, value, verbose=False, as_date=False):
         value = app.make_utc(value, tzinfo=True)
 
     # Calculate time diff using UTC.
-    time_ago = datetime.datetime.utcnow() - make_utc(value)
+    time_ago = datetime.datetime.utcnow() - app.make_utc(value)
 
     # Convert value to local timezone.
-    local = timezone(config)
+    local = app.get_timezone()
     value = local.normalize(value.astimezone(local))
 
     kwargs = {}
@@ -378,6 +417,18 @@ def get_effective_theme(rattail_config, theme=None, session=None):
     return theme
 
 
+def should_use_oruga(request):
+    """
+    Returns a flag indicating whether or not the current theme
+    supports (and therefore should use) Oruga + Vue 3 as opposed to
+    the default of Buefy + Vue 2.
+    """
+    theme = request.registry.settings['tailbone.theme']
+    if theme == 'butterball':
+        return True
+    return False
+
+
 def validate_email_address(address):
     """
     Perform basic validation on the given email address.  This leverages the
diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py
index 679f170c..cce5e53d 100644
--- a/tailbone/views/settings.py
+++ b/tailbone/views/settings.py
@@ -107,6 +107,13 @@ class AppInfoView(MasterView):
             ('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"),
         ])
 
         for key in weblibs:
@@ -181,6 +188,41 @@ class AppInfoView(MasterView):
             {'section': 'tailbone',
              'option': 'liburl.fontawesome'},
 
+            {'section': 'tailbone',
+             'option': 'libver.bb_vue'},
+            {'section': 'tailbone',
+             'option': 'liburl.bb_vue'},
+
+            {'section': 'tailbone',
+             'option': 'libver.bb_oruga'},
+            {'section': 'tailbone',
+             'option': 'liburl.bb_oruga'},
+
+            {'section': 'tailbone',
+             'option': 'libver.bb_oruga_bulma'},
+            {'section': 'tailbone',
+             'option': 'liburl.bb_oruga_bulma'},
+
+            {'section': 'tailbone',
+             'option': 'libver.bb_oruga_bulma_css'},
+            {'section': 'tailbone',
+             'option': 'liburl.bb_oruga_bulma_css'},
+
+            {'section': 'tailbone',
+             'option': 'libver.bb_fontawesome_svg_core'},
+            {'section': 'tailbone',
+             'option': 'liburl.bb_fontawesome_svg_core'},
+
+            {'section': 'tailbone',
+             'option': 'libver.bb_free_solid_svg_icons'},
+            {'section': 'tailbone',
+             'option': 'liburl.bb_free_solid_svg_icons'},
+
+            {'section': 'tailbone',
+             'option': 'libver.bb_vue_fontawesome'},
+            {'section': 'tailbone',
+             'option': 'liburl.bb_vue_fontawesome'},
+
             # nb. these are no longer used (deprecated), but we keep
             # them defined here so the tool auto-deletes them
             {'section': 'tailbone',

From e7a44d9979eaecd16c7530f9f11c526a8927099c Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sat, 27 Apr 2024 21:54:55 -0500
Subject: [PATCH 257/542] Let caller use string data for <tailbone-datepicker>

don't require a Date object, since callers thus far have not expected that
---
 tailbone/templates/themes/butterball/field-components.mako | 6 ++----
 1 file changed, 2 insertions(+), 4 deletions(-)

diff --git a/tailbone/templates/themes/butterball/field-components.mako b/tailbone/templates/themes/butterball/field-components.mako
index 1925e794..b8c3d1dc 100644
--- a/tailbone/templates/themes/butterball/field-components.mako
+++ b/tailbone/templates/themes/butterball/field-components.mako
@@ -321,7 +321,7 @@
         template: '#tailbone-datepicker-template',
 
         props: {
-            modelValue: Date,
+            modelValue: [Date, String],
             disabled: Boolean,
         },
 
@@ -333,9 +333,7 @@
 
         watch: {
             modelValue(to, from) {
-                if (this.orugaValue != to) {
-                    this.orugaValue = to
-                }
+                this.orugaValue = this.parseDate(to)
             },
         },
 

From fb81a8302c7e804d473d37f58abe6aeecbfb0ff7 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 28 Apr 2024 00:20:43 -0500
Subject: [PATCH 258/542] Use oruga 0.8.7 by default instead of latest 0.8.8

until the new bug is fixed, https://github.com/oruga-ui/oruga/issues/913
---
 tailbone/util.py | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/tailbone/util.py b/tailbone/util.py
index 087bdfcb..d1624670 100644
--- a/tailbone/util.py
+++ b/tailbone/util.py
@@ -165,7 +165,10 @@ def get_libver(request, key, fallback=True, default_only=False):
         return '3.3.11'
 
     elif key == 'bb_oruga':
-        return '0.8.8'
+        # TODO: as of writing, 0.8.8 is the latest release, but it has
+        # a bug which makes <o-field horizontal> basically not work
+        # cf. https://github.com/oruga-ui/oruga/issues/913
+        return '0.8.7'
 
     elif key in ('bb_oruga_bulma', 'bb_oruga_bulma_css'):
         return '0.3.0'

From 362d545f3405cbd08f0111a5484762eba114ba7c Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 28 Apr 2024 00:25:03 -0500
Subject: [PATCH 259/542] Fix modal state for appinfo/configure page

---
 tailbone/templates/appinfo/configure.mako | 11 ++++++++---
 1 file changed, 8 insertions(+), 3 deletions(-)

diff --git a/tailbone/templates/appinfo/configure.mako b/tailbone/templates/appinfo/configure.mako
index 657e98cf..280b5cb9 100644
--- a/tailbone/templates/appinfo/configure.mako
+++ b/tailbone/templates/appinfo/configure.mako
@@ -153,8 +153,13 @@
         ${h.hidden('tailbone.liburl.{}'.format(weblib['key']), **{':value': "simpleSettings['tailbone.liburl.{}']".format(weblib['key'])})}
     % endfor
 
-    <b-modal has-modal-card
-             :active.sync="editWebLibraryShowDialog">
+    <${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">
@@ -203,7 +208,7 @@
           </b-button>
         </footer>
       </div>
-    </b-modal>
+    </${b}-modal>
 
   </div>
 </%def>

From 358816d9e77721ac130ca20b67dd452cbbe728e9 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 28 Apr 2024 00:51:07 -0500
Subject: [PATCH 260/542] Add oruga overhead for "classic" app only, not API

---
 tailbone/app.py         |  4 ++++
 tailbone/subscribers.py | 12 ++++++++----
 2 files changed, 12 insertions(+), 4 deletions(-)

diff --git a/tailbone/app.py b/tailbone/app.py
index abf2fa09..63610f85 100644
--- a/tailbone/app.py
+++ b/tailbone/app.py
@@ -123,6 +123,9 @@ def make_pyramid_config(settings, configure_csrf=True):
         config.set_root_factory(Root)
     else:
 
+        # declare this web app of the "classic" variety
+        settings.setdefault('tailbone.classic', 'true')
+
         # we want the new themes feature!
         establish_theme(settings)
 
@@ -130,6 +133,7 @@ def make_pyramid_config(settings, configure_csrf=True):
         config = Configurator(settings=settings, root_factory=Root)
 
     # add rattail config directly to registry
+    # TODO: why on earth do we do this again?
     config.registry['rattail_config'] = rattail_config
 
     # configure user authorization / authentication
diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py
index 9b56335a..8d10eb0b 100644
--- a/tailbone/subscribers.py
+++ b/tailbone/subscribers.py
@@ -98,10 +98,14 @@ def new_request(event):
 
     request.set_property(user, reify=True)
 
-    def use_oruga(request):
-        return should_use_oruga(request)
+    # nb. only add oruga check for "classic" web app
+    classic = rattail_config.parse_bool(request.registry.settings.get('tailbone.classic'))
+    if classic:
 
-    request.set_property(use_oruga, reify=True)
+        def use_oruga(request):
+            return should_use_oruga(request)
+
+        request.set_property(use_oruga, reify=True)
 
     # assign client IP address to the session, for sake of versioning
     Session().continuum_remote_addr = request.client_addr
@@ -173,11 +177,11 @@ def before_render(event):
     renderer_globals['colander'] = colander
     renderer_globals['deform'] = deform
     renderer_globals['csrf_header_name'] = csrf_header_name(request.rattail_config)
-    renderer_globals['b'] = 'o' if request.use_oruga else 'b' # for buefy
 
     # theme  - we only want do this for classic web app, *not* API
     # TODO: so, clearly we need a better way to distinguish the two
     if 'tailbone.theme' in request.registry.settings:
+        renderer_globals['b'] = 'o' if request.use_oruga else 'b' # for buefy
         renderer_globals['theme'] = request.registry.settings['tailbone.theme']
         # note, this is just a global flag; user still needs permission to see picker
         expose_picker = request.rattail_config.getbool('tailbone', 'themes.expose_picker',

From 33251e880e7ea0d44ce47749b47b778166bb55ea Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 28 Apr 2024 01:58:19 -0500
Subject: [PATCH 261/542] Fix oruga styles for batch view

also use typical panels, for row status breakdown etc.
---
 tailbone/templates/batch/view.mako            | 19 ++++++++++---------
 .../templates/themes/butterball/base.mako     |  2 +-
 2 files changed, 11 insertions(+), 10 deletions(-)

diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako
index a87b31a6..3c77cd70 100644
--- a/tailbone/templates/batch/view.mako
+++ b/tailbone/templates/batch/view.mako
@@ -68,18 +68,19 @@
 </%def>
 
 <%def name="render_status_breakdown()">
-  <div class="object-helper">
-    <h3>Row Status Breakdown</h3>
-    <div class="object-helper-content">
+  <nav class="panel">
+    <p class="panel-heading">Row Status</p>
+    <div class="panel-block">
       ${status_breakdown_grid}
     </div>
-  </div>
+  </nav>
 </%def>
 
 <%def name="render_execute_helper()">
-  <div class="object-helper">
-    <h3>Batch Execution</h3>
-    <div class="object-helper-content">
+  <nav class="panel">
+    <p class="panel-heading">Execution</p>
+    <div class="panel-block">
+      <div style="display: flex; flex-direction: column; gap: 0.5rem;">
       % if batch.executed:
           <p>
             Batch was executed
@@ -89,7 +90,6 @@
       % elif master.handler.executable(batch):
           % if master.has_perm('execute'):
               <p>Batch has not yet been executed.</p>
-              <br />
               <b-button type="is-primary"
                         % if not execute_enabled:
                         disabled
@@ -144,8 +144,9 @@
       % else:
           <p>TODO: batch cannot be executed..?</p>
       % endif
+      </div>
     </div>
-  </div>
+  </nav>
 </%def>
 
 <%def name="render_form_template()">
diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako
index 2988f29d..ce2eff74 100644
--- a/tailbone/templates/themes/butterball/base.mako
+++ b/tailbone/templates/themes/butterball/base.mako
@@ -152,10 +152,10 @@
 ##   ${h.stylesheet_link(request.static_url('tailbone:static/css/base.css') + '?ver={}'.format(tailbone.__version__))}
 ##   ${h.stylesheet_link(request.static_url('tailbone:static/css/layout.css') + '?ver={}'.format(tailbone.__version__))}
 ##   ${h.stylesheet_link(request.static_url('tailbone:static/css/grids.css') + '?ver={}'.format(tailbone.__version__))}
-##   ${h.stylesheet_link(request.static_url('tailbone:static/css/grids.rowstatus.css') + '?ver={}'.format(tailbone.__version__))}
 ##   ${h.stylesheet_link(request.static_url('tailbone:static/css/filters.css') + '?ver={}'.format(tailbone.__version__))}
 ##   ${h.stylesheet_link(request.static_url('tailbone:static/css/forms.css') + '?ver={}'.format(tailbone.__version__))}
 
+  ${h.stylesheet_link(request.static_url('tailbone:static/css/grids.rowstatus.css') + '?ver={}'.format(tailbone.__version__))}
   ${h.stylesheet_link(request.static_url('tailbone:static/css/diffs.css') + '?ver={}'.format(tailbone.__version__))}
 
   ## nb. this is used (only?) in /generate-feature page

From f2f023e7b3273a84c81c9f5dba50f966c291fe0f Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 28 Apr 2024 02:39:40 -0500
Subject: [PATCH 262/542] Fix v-model handling for grid-filter-numeric-value

---
 .../templates/grids/filter-components.mako    | 60 ++++++++++++-------
 1 file changed, 38 insertions(+), 22 deletions(-)

diff --git a/tailbone/templates/grids/filter-components.mako b/tailbone/templates/grids/filter-components.mako
index 9ec1c049..ff5ba8b7 100644
--- a/tailbone/templates/grids/filter-components.mako
+++ b/tailbone/templates/grids/filter-components.mako
@@ -36,30 +36,15 @@
     const GridFilterNumericValue = {
         template: '#grid-filter-numeric-value-template',
         props: {
-            value: String,
+            ${'modelValue' if request.use_oruga else 'value'}: String,
             wantsRange: Boolean,
         },
         data() {
+            const value = this.${'modelValue' if request.use_oruga else 'value'}
+            const {startValue, endValue} = this.parseValue(value)
             return {
-                startValue: null,
-                endValue: null,
-            }
-        },
-        mounted() {
-            if (this.wantsRange) {
-                if (this.value.includes('|')) {
-                    let values = this.value.split('|')
-                    if (values.length == 2) {
-                        this.startValue = values[0]
-                        this.endValue = values[1]
-                    } else {
-                        this.startValue = this.value
-                    }
-                } else {
-                    this.startValue = this.value
-                }
-            } else {
-                this.startValue = this.value
+                startValue,
+                endValue,
             }
         },
         watch: {
@@ -72,6 +57,12 @@
                     this.$emit('input', this.startValue)
                 }
             },
+
+            ${'modelValue' if request.use_oruga else 'value'}(to, from) {
+                const parsed = this.parseValue(to)
+                this.startValue = parsed.startValue
+                this.endValue = parsed.endValue
+            },
         },
         methods: {
             focus() {
@@ -81,11 +72,36 @@
                 if (this.wantsRange) {
                     value += '|' + this.endValue
                 }
-                this.$emit('input', value)
+                this.$emit("${'update:modelValue' if request.use_oruga else 'input'}", value)
             },
             endValueChanged(value) {
                 value = this.startValue + '|' + value
-                this.$emit('input', value)
+                this.$emit("${'update:modelValue' if request.use_oruga else 'input'}", value)
+            },
+
+            parseValue(value) {
+                let startValue = null
+                let endValue = null
+                if (this.wantsRange) {
+                    if (value.includes('|')) {
+                        let values = value.split('|')
+                        if (values.length == 2) {
+                            startValue = values[0]
+                            endValue = values[1]
+                        } else {
+                            startValue = value
+                        }
+                    } else {
+                        startValue = value
+                    }
+                } else {
+                    startValue = value
+                }
+
+                return {
+                    startValue,
+                    endValue,
+                }
             },
         },
     }

From 855fa7e1e28389a8f67adc14a54c4c3b4c60dcd7 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 28 Apr 2024 02:41:45 -0500
Subject: [PATCH 263/542] Fix centering for "Show Totals" grid tool

---
 tailbone/templates/master/index.mako | 18 ++++++++++--------
 1 file changed, 10 insertions(+), 8 deletions(-)

diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako
index 2ad9a21b..bf7e6455 100644
--- a/tailbone/templates/master/index.mako
+++ b/tailbone/templates/master/index.mako
@@ -30,14 +30,16 @@
 
   ## grid totals
   % if master.supports_grid_totals:
-      <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 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
 

From 1d5a0630ef5d8a8a05d4808a37252aad76d34e92 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 28 Apr 2024 16:14:40 -0500
Subject: [PATCH 264/542] Change default URL for some vue3+oruga libs

apparently the first ones were not ideal / optimized, but these are
---
 tailbone/util.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/tailbone/util.py b/tailbone/util.py
index d1624670..64308f27 100644
--- a/tailbone/util.py
+++ b/tailbone/util.py
@@ -214,10 +214,10 @@ def get_liburl(request, key, fallback=True):
         return 'https://use.fontawesome.com/releases/v{}/js/all.js'.format(version)
 
     elif key == 'bb_vue':
-        return f'https://unpkg.com/vue@{version}/dist/vue.esm-browser.js'
+        return f'https://unpkg.com/vue@{version}/dist/vue.esm-browser.prod.js'
 
     elif key == 'bb_oruga':
-        return f'https://unpkg.com/@oruga-ui/oruga-next@{version}/dist/esm/index.mjs'
+        return f'https://unpkg.com/@oruga-ui/oruga-next@{version}/dist/oruga.mjs'
 
     elif key == 'bb_oruga_bulma':
         return f'https://unpkg.com/@oruga-ui/theme-bulma@{version}/dist/bulma.mjs'

From adaa39f572389d56eea982b6b77b23b2d1521737 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 28 Apr 2024 17:33:06 -0500
Subject: [PATCH 265/542] Update changelog

---
 CHANGES.rst          | 13 +++++++++++++
 tailbone/_version.py |  2 +-
 2 files changed, 14 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 7bdb466d..309f6e13 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -5,6 +5,19 @@ CHANGELOG
 Unreleased
 ----------
 
+0.10.0 (2024-04-28)
+-------------------
+
+This version bump is to reflect adding support for Vue 3 + Oruga via
+the 'butterball' theme.  There is likely more work to be done for that
+yet, but it mostly works at this point.
+
+* Misc. template and view logic tweaks (applicable to all themes) for
+  better patterns, consistency etc.
+
+* Add initial support for Vue 3 + Oruga, via "butterball" theme.
+
+
 0.9.96 (2024-04-25)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index fb15d91c..20a1fc79 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.9.96'
+__version__ = '0.10.0'

From 34878f929357759f3f03e99050b5abc3d52215f4 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 28 Apr 2024 18:37:00 -0500
Subject: [PATCH 266/542] Sort list of available themes

and add `computed` attr for WholePage; needed by some customizations
---
 .../templates/themes/butterball/base.mako     |  1 +
 tailbone/util.py                              | 25 +++++++++++++++----
 2 files changed, 21 insertions(+), 5 deletions(-)

diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako
index ce2eff74..9364accf 100644
--- a/tailbone/templates/themes/butterball/base.mako
+++ b/tailbone/templates/themes/butterball/base.mako
@@ -1010,6 +1010,7 @@
     const WholePage = {
         template: '#whole-page-template',
         mixins: [SimpleRequestMixin],
+        computed: {},
 
         mounted() {
             window.addEventListener('keydown', this.globalKey)
diff --git a/tailbone/util.py b/tailbone/util.py
index 64308f27..bb18f22d 100644
--- a/tailbone/util.py
+++ b/tailbone/util.py
@@ -377,22 +377,37 @@ def get_theme_template_path(rattail_config, theme=None, session=None):
 
 
 def get_available_themes(rattail_config, include=None):
+    """
+    Returns a list of theme names which are available.  If config does
+    not specify, some defaults will be assumed.
+    """
+    # get available list from config, if it has one
     available = rattail_config.getlist('tailbone', 'themes.keys')
     if not available:
         available = rattail_config.getlist('tailbone', 'themes',
                                            ignore_ambiguous=True)
         if available:
-            warnings.warn(f"URGENT: instead of 'tailbone.themes', "
-                          f"you should set 'tailbone.themes.keys'",
+            warnings.warn("URGENT: instead of 'tailbone.themes', "
+                          "you should set 'tailbone.themes.keys'",
                           DeprecationWarning, stacklevel=2)
         else:
             available = []
-    if 'default' not in available:
-        available.insert(0, 'default')
+
+    # include any themes specified by caller
     if include is not None:
         for theme in include:
             if theme not in available:
                 available.append(theme)
+
+    # sort the list by name
+    available.sort()
+
+    # make default theme the first option
+    i = available.index('default')
+    if i >= 0:
+        available.pop(i)
+    available.insert(0, 'default')
+
     return available
 
 
@@ -427,7 +442,7 @@ def should_use_oruga(request):
     the default of Buefy + Vue 2.
     """
     theme = request.registry.settings['tailbone.theme']
-    if theme == 'butterball':
+    if 'butterball' in theme:
         return True
     return False
 

From b3784dcc4a09a73330906841a3a35fb5cd2b7931 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 28 Apr 2024 18:49:11 -0500
Subject: [PATCH 267/542] Update various icon names for oruga compatibility

---
 tailbone/templates/base.mako                   |  2 +-
 tailbone/templates/batch/index.mako            |  2 +-
 tailbone/templates/batch/view.mako             |  6 +++---
 tailbone/templates/custorders/create.mako      | 14 +++++++-------
 tailbone/templates/grids/complete.mako         |  2 +-
 tailbone/templates/importing/configure.mako    |  4 ++--
 tailbone/templates/master/index.mako           |  4 ++--
 tailbone/templates/ordering/view.mako          |  4 ++--
 tailbone/templates/products/lookup.mako        |  4 ++--
 tailbone/templates/units-of-measure/index.mako |  2 +-
 tailbone/templates/workorders/view.mako        | 14 +++++++-------
 11 files changed, 29 insertions(+), 29 deletions(-)

diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako
index 53fac116..44c7dd0f 100644
--- a/tailbone/templates/base.mako
+++ b/tailbone/templates/base.mako
@@ -511,7 +511,7 @@
         <b-button type="is-primary"
                   @click="showFeedback()"
                   icon-pack="fas"
-                  icon-left="fas fa-comment">
+                  icon-left="comment">
           Feedback
         </b-button>
       </div>
diff --git a/tailbone/templates/batch/index.mako b/tailbone/templates/batch/index.mako
index 3ea76641..209fbb0c 100644
--- a/tailbone/templates/batch/index.mako
+++ b/tailbone/templates/batch/index.mako
@@ -9,7 +9,7 @@
       <b-button type="is-primary"
                 :disabled="refreshResultsButtonDisabled"
                 icon-pack="fas"
-                icon-left="fas fa-redo"
+                icon-left="redo"
                 @click="refreshResults()">
         {{ refreshResultsButtonText }}
       </b-button>
diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako
index 3c77cd70..9b214662 100644
--- a/tailbone/templates/batch/view.mako
+++ b/tailbone/templates/batch/view.mako
@@ -50,12 +50,12 @@
       <b-button tag="a"
                 href="${master.get_action_url('download_worksheet', batch)}"
                 icon-pack="fas"
-                icon-left="fas fa-download">
+                icon-left="download">
         Download Worksheet
       </b-button>
       <b-button type="is-primary"
                 icon-pack="fas"
-                icon-left="fas fa-upload"
+                icon-left="upload"
                 @click="$emit('show-upload')">
         Upload Worksheet
       </b-button>
@@ -185,7 +185,7 @@
             <b-button type="is-primary"
                       @click="submitUpload()"
                       icon-pack="fas"
-                      icon-left="fas fa-upload"
+                      icon-left="upload"
                       :disabled="uploadButtonDisabled">
               {{ uploadButtonText }}
             </b-button>
diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako
index 399c1a6b..6b07571e 100644
--- a/tailbone/templates/custorders/create.mako
+++ b/tailbone/templates/custorders/create.mako
@@ -27,18 +27,18 @@
                     @click="submitOrder()"
                     :disabled="submittingOrder"
                     icon-pack="fas"
-                    icon-left="fas fa-upload">
+                    icon-left="upload">
             {{ submittingOrder ? "Working, please wait..." : "Submit this Order" }}
           </b-button>
           <b-button @click="startOverEntirely()"
                     icon-pack="fas"
-                    icon-left="fas fa-redo">
+                    icon-left="redo">
             Start Over Entirely
           </b-button>
           <b-button @click="cancelOrder()"
                     type="is-danger"
                     icon-pack="fas"
-                    icon-left="fas fa-trash">
+                    icon-left="trash">
             Cancel this Order
           </b-button>
         </div>
@@ -493,14 +493,14 @@
             <div class="buttons">
               <b-button type="is-primary"
                         icon-pack="fas"
-                        icon-left="fas fa-plus"
+                        icon-left="plus"
                         @click="showAddItemDialog()">
                 Add Item
               </b-button>
               % if allow_past_item_reorder:
               <b-button v-if="contactUUID"
                         icon-pack="fas"
-                        icon-left="fas fa-plus"
+                        icon-left="plus"
                         @click="showAddPastItem()">
                 Add Past Item
               </b-button>
@@ -1013,12 +1013,12 @@
                       {{ props.row.vendor_name }}
                     </b-table-column>
 
-                    <template slot="empty">
+                    <template #empty>
                       <div class="content has-text-grey has-text-centered">
                         <p>
                           <b-icon
                             pack="fas"
-                            icon="fas fa-sad-tear"
+                            icon="sad-tear"
                             size="is-large">
                           </b-icon>
                         </p>
diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako
index fe9392d3..a54cc127 100644
--- a/tailbone/templates/grids/complete.mako
+++ b/tailbone/templates/grids/complete.mako
@@ -177,7 +177,7 @@
             <p>
               <b-icon
                  pack="fas"
-                 icon="fas fa-sad-tear"
+                 icon="sad-tear"
                  size="is-large">
               </b-icon>
             </p>
diff --git a/tailbone/templates/importing/configure.mako b/tailbone/templates/importing/configure.mako
index fd8bc35b..0396745a 100644
--- a/tailbone/templates/importing/configure.mako
+++ b/tailbone/templates/importing/configure.mako
@@ -58,13 +58,13 @@
         Edit
       </a>
     </${b}-table-column>
-    <template slot="empty">
+    <template #empty>
       <section class="section">
         <div class="content has-text-grey has-text-centered">
           <p>
             <b-icon
                pack="fas"
-               icon="fas fa-sad-tear"
+               icon="sad-tear"
                size="is-large">
             </b-icon>
           </p>
diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako
index bf7e6455..a619d84c 100644
--- a/tailbone/templates/master/index.mako
+++ b/tailbone/templates/master/index.mako
@@ -183,7 +183,7 @@
               <once-button type="is-primary"
                            @click="downloadResultsSubmit()"
                            icon-pack="fas"
-                           icon-left="fas fa-download"
+                           icon-left="download"
                            :disabled="!downloadResultsFieldsIncluded.length"
                            text="Download Results">
               </once-button>
@@ -197,7 +197,7 @@
   % if master.has_rows and master.results_rows_downloadable and master.has_perm('download_results_rows'):
       <b-button type="is-primary"
                 icon-pack="fas"
-                icon-left="fas fa-download"
+                icon-left="download"
                 @click="downloadResultsRows()"
                 :disabled="downloadResultsRowsButtonDisabled">
         {{ downloadResultsRowsButtonText }}
diff --git a/tailbone/templates/ordering/view.mako b/tailbone/templates/ordering/view.mako
index f0e6380a..aed6fd75 100644
--- a/tailbone/templates/ordering/view.mako
+++ b/tailbone/templates/ordering/view.mako
@@ -28,7 +28,7 @@
         <div>
           <b-button type="is-primary"
                     icon-pack="fas"
-                    icon-left="fas fa-play"
+                    icon-left="play"
                     @click="startScanning()">
             Start Scanning
           </b-button>
@@ -111,7 +111,7 @@
                         <div class="buttons">
                           <b-button type="is-primary"
                                     icon-pack="fas"
-                                    icon-left="fas fa-save"
+                                    icon-left="save"
                                     @click="saveCurrentRow()">
                             Save
                           </b-button>
diff --git a/tailbone/templates/products/lookup.mako b/tailbone/templates/products/lookup.mako
index 4e8c3a8b..52b06e88 100644
--- a/tailbone/templates/products/lookup.mako
+++ b/tailbone/templates/products/lookup.mako
@@ -157,12 +157,12 @@
                 </a>
               </b-table-column>
 
-              <template slot="empty">
+              <template #empty>
                 <div class="content has-text-grey has-text-centered">
                   <p>
                     <b-icon
                       pack="fas"
-                      icon="fas fa-sad-tear"
+                      icon="sad-tear"
                       size="is-large">
                     </b-icon>
                   </p>
diff --git a/tailbone/templates/units-of-measure/index.mako b/tailbone/templates/units-of-measure/index.mako
index fb3a3219..597cabfd 100644
--- a/tailbone/templates/units-of-measure/index.mako
+++ b/tailbone/templates/units-of-measure/index.mako
@@ -7,7 +7,7 @@
   % if master.has_perm('collect_wild_uoms'):
   <b-button type="is-primary"
             icon-pack="fas"
-            icon-left="fas fa-shopping-basket"
+            icon-left="shopping-basket"
             @click="showingCollectWildDialog = true">
     Collect from the Wild
   </b-button>
diff --git a/tailbone/templates/workorders/view.mako b/tailbone/templates/workorders/view.mako
index e631c141..8740b4c9 100644
--- a/tailbone/templates/workorders/view.mako
+++ b/tailbone/templates/workorders/view.mako
@@ -24,7 +24,7 @@
             ${h.csrf_token(request)}
             <b-button type="is-primary"
                       icon-pack="fas"
-                      icon-left="fas fa-arrow-right"
+                      icon-left="arrow-right"
                       @click="receive()"
                       :disabled="receiveButtonDisabled">
               {{ receiveButtonText }}
@@ -41,7 +41,7 @@
             ${h.csrf_token(request)}
             <b-button type="is-primary"
                       icon-pack="fas"
-                      icon-left="fas fa-arrow-right"
+                      icon-left="arrow-right"
                       @click="awaitEstimate()"
                       :disabled="awaitEstimateButtonDisabled">
               {{ awaitEstimateButtonText }}
@@ -58,7 +58,7 @@
             ${h.csrf_token(request)}
             <b-button type="is-primary"
                       icon-pack="fas"
-                      icon-left="fas fa-arrow-right"
+                      icon-left="arrow-right"
                       @click="awaitParts()"
                       :disabled="awaitPartsButtonDisabled">
               {{ awaitPartsButtonText }}
@@ -75,7 +75,7 @@
             ${h.csrf_token(request)}
             <b-button type="is-primary"
                       icon-pack="fas"
-                      icon-left="fas fa-arrow-right"
+                      icon-left="arrow-right"
                       @click="workOnIt()"
                       :disabled="workOnItButtonDisabled">
               {{ workOnItButtonText }}
@@ -92,7 +92,7 @@
             ${h.csrf_token(request)}
             <b-button type="is-primary"
                       icon-pack="fas"
-                      icon-left="fas fa-arrow-right"
+                      icon-left="arrow-right"
                       @click="release()"
                       :disabled="releaseButtonDisabled">
               {{ releaseButtonText }}
@@ -109,7 +109,7 @@
             ${h.csrf_token(request)}
             <b-button type="is-primary"
                       icon-pack="fas"
-                      icon-left="fas fa-arrow-right"
+                      icon-left="arrow-right"
                       @click="deliver()"
                       :disabled="deliverButtonDisabled">
               {{ deliverButtonText }}
@@ -132,7 +132,7 @@
             ${h.csrf_token(request)}
             <b-button type="is-warning"
                       icon-pack="fas"
-                      icon-left="fas fa-ban"
+                      icon-left="ban"
                       @click="confirmCancel()"
                       :disabled="cancelButtonDisabled">
               {{ cancelButtonText }}

From 72f48fa9630b6181a87a8c1adfe0f2543a84cc38 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 28 Apr 2024 19:30:35 -0500
Subject: [PATCH 268/542] Fix vertical alignment in main menu bar, for
 butterball

---
 tailbone/templates/themes/butterball/base.mako | 12 ++++++++----
 1 file changed, 8 insertions(+), 4 deletions(-)

diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako
index 9364accf..70442164 100644
--- a/tailbone/templates/themes/butterball/base.mako
+++ b/tailbone/templates/themes/butterball/base.mako
@@ -432,11 +432,12 @@
 <%def name="make_menu_search_component()">
   <% request.register_component('menu-search', 'MenuSearch') %>
   <script type="text/x-template" id="menu-search-template">
-    <div>
+    <div style="display: flex;">
 
       <a v-show="!searchActive"
          href="${url('home')}"
-         class="navbar-item">
+         class="navbar-item"
+         style="display: flex; gap: 0.5rem;">
         ${base_meta.header_logo()}
         <div id="global-header-title">
           ${base_meta.global_title()}
@@ -550,7 +551,8 @@
         <header>
 
           <!-- this main menu, with search -->
-          <nav class="navbar" role="navigation" aria-label="main navigation">
+          <nav class="navbar" role="navigation" aria-label="main navigation"
+               style="display: flex; align-items: center;">
 
             <div class="navbar-brand">
               <menu-search :search-data="globalSearchData"
@@ -563,7 +565,9 @@
               </a>
             </div>
 
-            <div class="navbar-menu" id="navbarMenu">
+            <div class="navbar-menu" id="navbarMenu"
+                 style="display: flex; align-items: center;"
+                 >
               <div class="navbar-start">
 
                 ## global search button

From 9ee6521d6a5318e566a00340a368acedc4807095 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 28 Apr 2024 20:12:06 -0500
Subject: [PATCH 269/542] Fix upgrade execution logic/UI per oruga

---
 tailbone/templates/upgrades/view.mako | 19 +++++++++++++++----
 tailbone/views/master.py              |  5 ++++-
 2 files changed, 19 insertions(+), 5 deletions(-)

diff --git a/tailbone/templates/upgrades/view.mako b/tailbone/templates/upgrades/view.mako
index fe20c1e1..6ae110e0 100644
--- a/tailbone/templates/upgrades/view.mako
+++ b/tailbone/templates/upgrades/view.mako
@@ -19,9 +19,15 @@
   ${parent.render_this_page()}
 
   % if expose_websockets and master.has_perm('execute'):
-      <b-modal :active.sync="upgradeExecuting"
-               full-screen
-               :can-cancel="false">
+      <${b}-modal full-screen
+                  % if request.use_oruga:
+                      v-model:active="upgradeExecuting"
+                      :cancelable="false"
+                  % else:
+                      :active.sync="upgradeExecuting"
+                      :can-cancel="false"
+                  % endif
+                  >
         <div class="card">
           <div class="card-content">
 
@@ -32,6 +38,10 @@
                   Upgrading ${system_title} (please wait) ...
                   {{ executeUpgradeComplete ? "DONE!" : "" }}
                 </p>
+                % if request.use_oruga:
+                    <progress class="progress is-large"
+                              style="width: 400px;" />
+                % else:
                 <b-progress size="is-large"
                             style="width: 400px;"
     ##                             :value="80"
@@ -39,6 +49,7 @@
     ##                             format="percent"
                             >
                 </b-progress>
+                % endif
               </div>
               <div class="level-right">
                 <div class="level-item">
@@ -65,7 +76,7 @@
 
           </div>
         </div>
-      </b-modal>
+      </${b}-modal>
   % endif
 
   % if master.has_perm('execute'):
diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index 87c592ee..cc6e25ea 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -2060,7 +2060,10 @@ class MasterView(View):
 
         # caller must explicitly request websocket behavior; otherwise
         # we will assume traditional behavior for progress
-        ws = self.request.is_xhr and self.request.json_body.get('ws')
+        ws = False
+        if ((self.request.is_xhr or self.request.content_type == 'application/json')
+            and self.request.json_body.get('ws')):
+            ws = True
 
         # make our progress tracker
         progress = self.make_execute_progress(obj, ws=ws)

From 6ce65badebe74e47d11414408199db5505898d4d Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 28 Apr 2024 20:12:49 -0500
Subject: [PATCH 270/542] Show "View This" button when cloning a record

---
 tailbone/templates/themes/butterball/base.mako | 11 +++++++++--
 1 file changed, 9 insertions(+), 2 deletions(-)

diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako
index 70442164..439cf81a 100644
--- a/tailbone/templates/themes/butterball/base.mako
+++ b/tailbone/templates/themes/butterball/base.mako
@@ -911,7 +911,7 @@
 </%def>
 
 <%def name="render_crud_header_buttons()">
-  % if master and master.viewing:
+  % if master and master.viewing and not master.cloning:
       ## TODO: is there a better way to check if viewing parent?
       % if parent_instance is Undefined:
           % if master.editable and instance_editable and master.has_perm('edit'):
@@ -920,7 +920,7 @@
                            text="Edit This">
               </once-button>
           % endif
-          % if master.cloneable and master.has_perm('clone'):
+          % if not master.cloning and master.cloneable and master.has_perm('clone'):
               <once-button tag="a" href="${action_url('clone', instance)}"
                            icon-left="object-ungroup"
                            text="Clone This">
@@ -970,6 +970,13 @@
                        text="Edit This">
           </once-button>
       % endif
+  % elif master and master.cloning:
+      % if master.viewable and master.has_perm('view'):
+          <once-button tag="a" href="${action_url('view', instance)}"
+                       icon-left="eye"
+                       text="View This">
+          </once-button>
+      % endif
   % endif
 </%def>
 

From e9ddd6dc36ecdd68e2f280d3b3f800cb1b5654d8 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 28 Apr 2024 20:21:20 -0500
Subject: [PATCH 271/542] Stop including 'falafel' as available theme

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

diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py
index 8d10eb0b..5f477281 100644
--- a/tailbone/subscribers.py
+++ b/tailbone/subscribers.py
@@ -190,8 +190,7 @@ def before_render(event):
         if expose_picker:
 
             # TODO: should remove 'falafel' option altogether
-            available = get_available_themes(request.rattail_config,
-                                             include=['falafel'])
+            available = get_available_themes(request.rattail_config)
 
             options = [tags.Option(theme, value=theme) for theme in available]
             renderer_globals['theme_picker_options'] = options

From 68384a00dc707ea8b18f95a9f1ef109c15ae3530 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 28 Apr 2024 20:25:55 -0500
Subject: [PATCH 272/542] Update changelog

---
 CHANGES.rst          | 16 ++++++++++++++++
 tailbone/_version.py |  2 +-
 2 files changed, 17 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 309f6e13..5a02c648 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -5,6 +5,22 @@ CHANGELOG
 Unreleased
 ----------
 
+0.10.1 (2024-04-28)
+-------------------
+
+* Sort list of available themes.
+
+* Update various icon names for oruga compatibility.
+
+* Fix vertical alignment in main menu bar, for butterball.
+
+* Fix upgrade execution logic/UI per oruga.
+
+* Show "View This" button when cloning a record.
+
+* Stop including 'falafel' as available theme.
+
+
 0.10.0 (2024-04-28)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 20a1fc79..3ce74007 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.10.0'
+__version__ = '0.10.1'

From 15fedf59768fb3b6a6ef86ce35e536a857ac61db Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 6 May 2024 21:52:53 -0500
Subject: [PATCH 273/542] Fix employees grid when viewing department (per
 oruga)

---
 tailbone/views/departments.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/tailbone/views/departments.py b/tailbone/views/departments.py
index c6998105..6ee1439f 100644
--- a/tailbone/views/departments.py
+++ b/tailbone/views/departments.py
@@ -129,6 +129,7 @@ class DepartmentView(MasterView):
         factory = self.get_grid_factory()
         g = factory(
             key='{}.employees'.format(route_prefix),
+            request=self.request,
             data=[],
             columns=[
                 'first_name',

From e4c42596741b3bc638067b99ff7eff53826ae662 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 6 May 2024 21:53:11 -0500
Subject: [PATCH 274/542] Remove version restriction for pyramid_beaker
 dependency

latest version is 0.9, so this wasn't all that relevant
---
 setup.cfg | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/setup.cfg b/setup.cfg
index 7fcce722..48cc994a 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -57,7 +57,7 @@ install_requires =
         passlib
         Pillow
         pyramid
-        pyramid_beaker>=0.6
+        pyramid_beaker
         pyramid_deform
         pyramid_exclog
         pyramid_mako

From 3d319cbd094a58cf139d8e8afc427580063b06d6 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 6 May 2024 22:13:43 -0500
Subject: [PATCH 275/542] Fix login "enter" key behavior, per oruga

---
 tailbone/templates/login.mako                        | 12 ++++++++++--
 .../themes/butterball/buefy-components.mako          |  7 +++++--
 tailbone/views/auth.py                               |  2 +-
 3 files changed, 16 insertions(+), 5 deletions(-)

diff --git a/tailbone/templates/login.mako b/tailbone/templates/login.mako
index 6e6e347f..d18323b5 100644
--- a/tailbone/templates/login.mako
+++ b/tailbone/templates/login.mako
@@ -60,11 +60,19 @@
 <%def name="modify_this_page_vars()">
   <script type="text/javascript">
 
-    TailboneForm.mounted = function() {
+    ${form.component_studly}Data.usernameInput = null
+
+    ${form.component_studly}.mounted = function() {
         this.$refs.username.focus()
+        this.usernameInput = this.$refs.username.$el.querySelector('input')
+        this.usernameInput.addEventListener('keydown', this.usernameKeydown)
     }
 
-    TailboneForm.methods.usernameKeydown = function(event) {
+    ${form.component_studly}.beforeDestroy = function() {
+        this.usernameInput.removeEventListener('keydown', this.usernameKeydown)
+    }
+
+    ${form.component_studly}.methods.usernameKeydown = function(event) {
         if (event.which == 13) {
             event.preventDefault()
             this.$refs.password.focus()
diff --git a/tailbone/templates/themes/butterball/buefy-components.mako b/tailbone/templates/themes/butterball/buefy-components.mako
index 531ae4a5..9bce4c26 100644
--- a/tailbone/templates/themes/butterball/buefy-components.mako
+++ b/tailbone/templates/themes/butterball/buefy-components.mako
@@ -331,7 +331,7 @@
              :disabled="disabled"
              v-model="buefyValue"
              @update:modelValue="val => $emit('update:modelValue', val)"
-             autocomplete="off"
+             :autocomplete="autocomplete"
              ref="input">
       <slot />
     </o-input>
@@ -342,6 +342,7 @@
         props: {
             modelValue: null,
             type: String,
+            autocomplete: String,
             disabled: Boolean,
         },
         data() {
@@ -359,8 +360,10 @@
         methods: {
             focus() {
                 if (this.type == 'textarea') {
-                    // TODO: this does not work right
+                    // TODO: this does not always work right?
                     this.$refs.input.$el.querySelector('textarea').focus()
+                } else {
+                    this.$refs.input.$el.querySelector('input').focus()
                 }
             },
         },
diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py
index f559a5c4..0f0d1687 100644
--- a/tailbone/views/auth.py
+++ b/tailbone/views/auth.py
@@ -125,7 +125,7 @@ class AuthenticationView(View):
         dform = form.make_deform_form()
         dform['username'].widget.attributes = {
             'ref': 'username',
-            '@keydown.native': 'usernameKeydown',
+            'autocomplete': 'off',
         }
         dform['password'].widget.attributes = {'ref': 'password'}
 

From f0d694cfe5acd75478c3937c5882ede7d605487f Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 6 May 2024 22:56:47 -0500
Subject: [PATCH 276/542] Rename some attrs etc. for buefy components used with
 oruga

---
 .../themes/butterball/buefy-components.mako   | 32 +++++++++----------
 1 file changed, 16 insertions(+), 16 deletions(-)

diff --git a/tailbone/templates/themes/butterball/buefy-components.mako b/tailbone/templates/themes/butterball/buefy-components.mako
index 9bce4c26..a7b42267 100644
--- a/tailbone/templates/themes/butterball/buefy-components.mako
+++ b/tailbone/templates/themes/butterball/buefy-components.mako
@@ -23,7 +23,7 @@
 
 <%def name="make_b_autocomplete_component()">
   <script type="text/x-template" id="b-autocomplete-template">
-    <o-autocomplete v-model="buefyValue"
+    <o-autocomplete v-model="orugaValue"
                     :data="data"
                     :field="field"
                     :open-on-focus="openOnFocus"
@@ -32,7 +32,7 @@
                     :clear-on-select="clearOnSelect"
                     :formatter="customFormatter"
                     :placeholder="placeholder"
-                    @update:model-value="buefyValueUpdated"
+                    @update:model-value="orugaValueUpdated"
                     ref="autocomplete">
     </o-autocomplete>
   </script>
@@ -52,13 +52,13 @@
         },
         data() {
             return {
-                buefyValue: this.modelValue,
+                orugaValue: this.modelValue,
             }
         },
         watch: {
             modelValue(to, from) {
-                if (this.buefyValue != to) {
-                    this.buefyValue = to
+                if (this.orugaValue != to) {
+                    this.orugaValue = to
                 }
             },
         },
@@ -67,7 +67,7 @@
                 const input = this.$refs.autocomplete.$el.querySelector('input')
                 input.focus()
             },
-            buefyValueUpdated(value) {
+            orugaValueUpdated(value) {
                 this.$emit('update:modelValue', value)
             },
         },
@@ -174,8 +174,8 @@
 <%def name="make_b_datepicker_component()">
   <script type="text/x-template" id="b-datepicker-template">
     <o-datepicker :name="name"
-                  v-model="buefyValue"
-                  @update:model-value="buefyValueUpdated"
+                  v-model="orugaValue"
+                  @update:model-value="orugaValueUpdated"
                   :value="value"
                   :placeholder="placeholder"
                   :date-formatter="dateFormatter"
@@ -203,18 +203,18 @@
         },
         data() {
             return {
-                buefyValue: this.modelValue,
+                orugaValue: this.modelValue,
             }
         },
         watch: {
             modelValue(to, from) {
-                if (this.buefyValue != to) {
-                    this.buefyValue = to
+                if (this.orugaValue != to) {
+                    this.orugaValue = to
                 }
             },
         },
         methods: {
-            buefyValueUpdated(value) {
+            orugaValueUpdated(value) {
                 if (this.modelValue != value) {
                     this.$emit('update:modelValue', value)
                 }
@@ -329,7 +329,7 @@
   <script type="text/x-template" id="b-input-template">
     <o-input :type="type"
              :disabled="disabled"
-             v-model="buefyValue"
+             v-model="orugaValue"
              @update:modelValue="val => $emit('update:modelValue', val)"
              :autocomplete="autocomplete"
              ref="input">
@@ -347,13 +347,13 @@
         },
         data() {
             return {
-                buefyValue: this.modelValue
+                orugaValue: this.modelValue
             }
         },
         watch: {
             modelValue(to, from) {
-                if (this.buefyValue != to) {
-                    this.buefyValue = to
+                if (this.orugaValue != to) {
+                    this.orugaValue = to
                 }
             },
         },

From 703d583f6fcb1bd3452a7f775502e3ffc9cd5b9c Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 7 May 2024 11:53:44 -0500
Subject: [PATCH 277/542] Fix "tools" helper for receiving batch view, per
 oruga

---
 tailbone/templates/batch/view.mako     |   4 +-
 tailbone/templates/receiving/view.mako | 175 ++++++++++++-------------
 2 files changed, 90 insertions(+), 89 deletions(-)

diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako
index 9b214662..5e3328d9 100644
--- a/tailbone/templates/batch/view.mako
+++ b/tailbone/templates/batch/view.mako
@@ -71,7 +71,9 @@
   <nav class="panel">
     <p class="panel-heading">Row Status</p>
     <div class="panel-block">
-      ${status_breakdown_grid}
+      <div style="width: 100%;">
+        ${status_breakdown_grid}
+      </div>
     </div>
   </nav>
 </%def>
diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako
index d639ff24..80c45103 100644
--- a/tailbone/templates/receiving/view.mako
+++ b/tailbone/templates/receiving/view.mako
@@ -38,103 +38,102 @@
 
 <%def name="render_tools_helper()">
   % if allow_confirm_all_costs or (master.has_perm('auto_receive') and master.can_auto_receive(batch)):
+      <nav class="panel">
+        <p class="panel-heading">Tools</p>
+        <div class="panel-block">
+          <div style="display: flex; flex-direction: column; gap: 0.5rem; width: 100%;">
 
-      <div class="object-helper">
-        <h3>Tools</h3>
-        <div class="object-helper-content"
-             style="display: flex; flex-direction: column; gap: 1rem;">
+            % if allow_confirm_all_costs:
+                <b-button type="is-primary"
+                          icon-pack="fas"
+                          icon-left="check"
+                          @click="confirmAllCostsShowDialog = true">
+                  Confirm All Costs
+                </b-button>
+                <b-modal has-modal-card
+                         :active.sync="confirmAllCostsShowDialog">
+                  <div class="modal-card">
 
-          % if allow_confirm_all_costs:
-              <b-button type="is-primary"
-                        icon-pack="fas"
-                        icon-left="check"
-                        @click="confirmAllCostsShowDialog = true">
-                Confirm All Costs
-              </b-button>
-              <b-modal has-modal-card
-                       :active.sync="confirmAllCostsShowDialog">
-                <div class="modal-card">
+                    <header class="modal-card-head">
+                      <p class="modal-card-title">Confirm All Costs</p>
+                    </header>
 
-                  <header class="modal-card-head">
-                    <p class="modal-card-title">Confirm All Costs</p>
-                  </header>
+                    <section class="modal-card-body">
+                      <p class="block">
+                        You can automatically mark all catalog and invoice
+                        cost amounts as "confirmed" if you wish.
+                      </p>
+                      <p class="block">
+                        Would you like to do this?
+                      </p>
+                    </section>
 
-                  <section class="modal-card-body">
-                    <p class="block">
-                      You can automatically mark all catalog and invoice
-                      cost amounts as "confirmed" if you wish.
-                    </p>
-                    <p class="block">
-                      Would you like to do this?
-                    </p>
-                  </section>
+                    <footer class="modal-card-foot">
+                      <b-button @click="confirmAllCostsShowDialog = false">
+                        Cancel
+                      </b-button>
+                      ${h.form(url(f'{route_prefix}.confirm_all_costs', uuid=batch.uuid), **{'@submit': 'confirmAllCostsSubmitting = true'})}
+                      ${h.csrf_token(request)}
+                      <b-button type="is-primary"
+                                native-type="submit"
+                                :disabled="confirmAllCostsSubmitting"
+                                icon-pack="fas"
+                                icon-left="check">
+                        {{ confirmAllCostsSubmitting ? "Working, please wait..." : "Confirm All" }}
+                      </b-button>
+                      ${h.end_form()}
+                    </footer>
+                  </div>
+                </b-modal>
+            % endif
 
-                  <footer class="modal-card-foot">
-                    <b-button @click="confirmAllCostsShowDialog = false">
-                      Cancel
-                    </b-button>
-                    ${h.form(url(f'{route_prefix}.confirm_all_costs', uuid=batch.uuid), **{'@submit': 'confirmAllCostsSubmitting = true'})}
-                    ${h.csrf_token(request)}
-                    <b-button type="is-primary"
-                              native-type="submit"
-                              :disabled="confirmAllCostsSubmitting"
-                              icon-pack="fas"
-                              icon-left="check">
-                      {{ confirmAllCostsSubmitting ? "Working, please wait..." : "Confirm All" }}
-                    </b-button>
-                    ${h.end_form()}
-                  </footer>
-                </div>
-              </b-modal>
-          % endif
+            % if master.has_perm('auto_receive') and master.can_auto_receive(batch):
+                <b-button type="is-primary"
+                          @click="autoReceiveShowDialog = true"
+                          icon-pack="fas"
+                          icon-left="check">
+                  Auto-Receive All Items
+                </b-button>
+                <b-modal has-modal-card
+                         :active.sync="autoReceiveShowDialog">
+                  <div class="modal-card">
 
-          % if master.has_perm('auto_receive') and master.can_auto_receive(batch):
-              <b-button type="is-primary"
-                        @click="autoReceiveShowDialog = true"
-                        icon-pack="fas"
-                        icon-left="check">
-                Auto-Receive All Items
-              </b-button>
-              <b-modal has-modal-card
-                       :active.sync="autoReceiveShowDialog">
-                <div class="modal-card">
+                    <header class="modal-card-head">
+                      <p class="modal-card-title">Auto-Receive All Items</p>
+                    </header>
 
-                  <header class="modal-card-head">
-                    <p class="modal-card-title">Auto-Receive All Items</p>
-                  </header>
-
-                  <section class="modal-card-body">
-                    <p class="block">
-                      You can automatically set the "received" quantity to
-                      match the "shipped" quantity for all items, based on
-                      the invoice.
-                    </p>
-                    <p class="block">
-                      Would you like to do so?
-                    </p>
-                  </section>
-
-                  <footer class="modal-card-foot">
-                    <b-button @click="autoReceiveShowDialog = false">
-                      Cancel
-                    </b-button>
-                    ${h.form(url('{}.auto_receive'.format(route_prefix), uuid=batch.uuid), **{'@submit': 'autoReceiveSubmitting = true'})}
-                    ${h.csrf_token(request)}
-                    <b-button type="is-primary"
-                              native-type="submit"
-                              :disabled="autoReceiveSubmitting"
-                              icon-pack="fas"
-                              icon-left="check">
-                      {{ autoReceiveSubmitting ? "Working, please wait..." : "Auto-Receive All Items" }}
-                    </b-button>
-                    ${h.end_form()}
-                  </footer>
-                </div>
-              </b-modal>
-          % endif
+                    <section class="modal-card-body">
+                      <p class="block">
+                        You can automatically set the "received" quantity to
+                        match the "shipped" quantity for all items, based on
+                        the invoice.
+                      </p>
+                      <p class="block">
+                        Would you like to do so?
+                      </p>
+                    </section>
 
+                    <footer class="modal-card-foot">
+                      <b-button @click="autoReceiveShowDialog = false">
+                        Cancel
+                      </b-button>
+                      ${h.form(url('{}.auto_receive'.format(route_prefix), uuid=batch.uuid), **{'@submit': 'autoReceiveSubmitting = true'})}
+                      ${h.csrf_token(request)}
+                      <b-button type="is-primary"
+                                native-type="submit"
+                                :disabled="autoReceiveSubmitting"
+                                icon-pack="fas"
+                                icon-left="check">
+                        {{ autoReceiveSubmitting ? "Working, please wait..." : "Auto-Receive All Items" }}
+                      </b-button>
+                      ${h.end_form()}
+                    </footer>
+                  </div>
+                </b-modal>
+            % endif
+          </div>
         </div>
-      </div>
+      </nav>
   % endif
 </%def>
 

From 9cd648f78f34169518090a21c645ba3a3a1151c6 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 7 May 2024 11:54:16 -0500
Subject: [PATCH 278/542] Fix button text for autocomplete

whoops i think that was a debug thing i forgot to remove
---
 tailbone/templates/themes/butterball/field-components.mako | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/tailbone/templates/themes/butterball/field-components.mako b/tailbone/templates/themes/butterball/field-components.mako
index b8c3d1dc..fbd83421 100644
--- a/tailbone/templates/themes/butterball/field-components.mako
+++ b/tailbone/templates/themes/butterball/field-components.mako
@@ -14,7 +14,7 @@
                 style="width: 100%; justify-content: left;"
                 @click="clearSelection(true)"
                 expanded>
-        {{ internalLabel }} (click to change #1)
+        {{ internalLabel }} (click to change)
       </o-button>
 
       <o-autocomplete ref="autocompletex"

From d607ab298148057d8435db368188fb507a18f6c2 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 7 May 2024 12:43:07 -0500
Subject: [PATCH 279/542] Fix display for "view receiving row" page, per oruga

this page still needs help; "Account for Product" is broken for oruga
---
 tailbone/forms/core.py                        | 24 ++++++++++++-------
 tailbone/templates/receiving/view_row.mako    | 22 +++++++++++++----
 .../templates/themes/butterball/base.mako     |  2 +-
 tailbone/views/purchasing/batch.py            |  1 +
 4 files changed, 34 insertions(+), 15 deletions(-)

diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py
index 857bfccf..7601fa26 100644
--- a/tailbone/forms/core.py
+++ b/tailbone/forms/core.py
@@ -1139,20 +1139,26 @@ class Form(object):
         if field_name not in self.fields:
             return ''
 
-        # TODO: fair bit of duplication here, should merge with deform.mako
         label = kwargs.get('label')
         if not label:
             label = self.get_label(field_name)
-        label = HTML.tag('label', label, for_=field_name)
-        field = self.render_field_value(field_name) or ''
-        field_div = HTML.tag('div', class_='field', c=[field])
-        contents = [label, field_div]
 
-        if self.has_helptext(field_name):
-            contents.append(HTML.tag('span', class_='instructions',
-                                     c=[self.render_helptext(field_name)]))
+        value = self.render_field_value(field_name) or ''
 
-        return HTML.tag('div', class_='field-wrapper {}'.format(field_name), c=contents)
+        if not self.request.use_oruga:
+
+            label = HTML.tag('label', label, for_=field_name)
+            field_div = HTML.tag('div', class_='field', c=[value])
+            contents = [label, field_div]
+
+            if self.has_helptext(field_name):
+                contents.append(HTML.tag('span', class_='instructions',
+                                         c=[self.render_helptext(field_name)]))
+
+            return HTML.tag('div', class_='field-wrapper {}'.format(field_name), c=contents)
+
+        # oruga uses <o-field>
+        return HTML.tag('o-field', label=label, c=[value], **{':horizontal': 'true'})
 
     def render_field_value(self, field_name):
         record = self.model_instance
diff --git a/tailbone/templates/receiving/view_row.mako b/tailbone/templates/receiving/view_row.mako
index 2341cd3e..efc1883a 100644
--- a/tailbone/templates/receiving/view_row.mako
+++ b/tailbone/templates/receiving/view_row.mako
@@ -60,8 +60,12 @@
     <nav class="panel">
       <p class="panel-heading">Product</p>
       <div class="panel-block">
-        <div style="display: flex;">
-          <div>
+        <div style="display: flex; gap: 1rem;">
+          <div style="flex-grow: 1;"
+             % if request.use_oruga:
+                 class="form-wrapper"
+             % endif
+            >
             ${form.render_field_readonly('item_entry')}
             % if row.product:
                 ${form.render_field_readonly(product_key_field)}
@@ -80,7 +84,7 @@
             ${form.render_field_readonly('catalog_unit_cost')}
           </div>
           % if image_url:
-              <div class="is-pulled-right">
+              <div>
                 ${h.image(image_url, "Product Image", width=150, height=150)}
               </div>
           % endif
@@ -429,7 +433,11 @@
         <nav class="panel" >
           <p class="panel-heading">Purchase Order</p>
           <div class="panel-block">
-            <div>
+            <div
+              % if request.use_oruga:
+                  class="form-wrapper"
+              % endif
+              >
               ${form.render_field_readonly('po_line_number')}
               ${form.render_field_readonly('po_unit_cost')}
               ${form.render_field_readonly('po_case_size')}
@@ -443,7 +451,11 @@
         <nav class="panel" >
           <p class="panel-heading">Invoice</p>
           <div class="panel-block">
-            <div>
+            <div
+              % if request.use_oruga:
+                  class="form-wrapper"
+              % endif
+              >
               ${form.render_field_readonly('invoice_number')}
               ${form.render_field_readonly('invoice_line_number')}
               ${form.render_field_readonly('invoice_unit_cost')}
diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako
index 439cf81a..4b6c03f8 100644
--- a/tailbone/templates/themes/butterball/base.mako
+++ b/tailbone/templates/themes/butterball/base.mako
@@ -243,7 +243,7 @@
     }
 
     /* .form-wrapper .form .field.is-horizontal .field-label .label, */
-    .form-wrapper .form .field.is-horizontal .field-label {
+    .form-wrapper .field.is-horizontal .field-label {
         text-align: left;
         white-space: nowrap;
         min-width: 18em;
diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py
index cd369f0a..1d11130c 100644
--- a/tailbone/views/purchasing/batch.py
+++ b/tailbone/views/purchasing/batch.py
@@ -794,6 +794,7 @@ class PurchasingBatchView(BatchMasterView):
 
         g = factory(
             key='{}.row_credits'.format(route_prefix),
+            request=self.request,
             data=[],
             columns=[
                 'credit_type',

From 28fb3f44a7bc7c5948fe6fb22c5c35eb1a0e094a Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 7 May 2024 18:20:26 -0500
Subject: [PATCH 280/542] More data type fixes for <tailbone-datepicker>

traditionally the caller has always dealt with string values only, so
the component should never emit events with date values, etc.
---
 .../static/js/tailbone.buefy.datepicker.js    | 21 ++++++++-----------
 1 file changed, 9 insertions(+), 12 deletions(-)

diff --git a/tailbone/static/js/tailbone.buefy.datepicker.js b/tailbone/static/js/tailbone.buefy.datepicker.js
index c516b97f..0b861fd6 100644
--- a/tailbone/static/js/tailbone.buefy.datepicker.js
+++ b/tailbone/static/js/tailbone.buefy.datepicker.js
@@ -27,20 +27,14 @@ const TailboneDatepicker = {
     },
 
     data() {
-        let buefyValue = this.value
-        if (buefyValue && !buefyValue.getDate) {
-            buefyValue = this.parseDate(this.value)
-        }
         return {
-            buefyValue,
+            buefyValue: this.parseDate(this.value),
         }
     },
 
     watch: {
         value(to, from) {
-            if (this.buefyValue != to) {
-                this.buefyValue = to
-            }
+            this.buefyValue = this.parseDate(to)
         },
     },
 
@@ -61,13 +55,16 @@ const TailboneDatepicker = {
         },
 
         parseDate(date) {
-            // note, this assumes classic YYYY-MM-DD (i.e. ISO?) format
-            var parts = date.split('-')
-            return new Date(parts[0], parseInt(parts[1]) - 1, parts[2])
+            if (typeof(date) == 'string') {
+                // note, this assumes classic YYYY-MM-DD (i.e. ISO?) format
+                var parts = date.split('-')
+                return new Date(parts[0], parseInt(parts[1]) - 1, parts[2])
+            }
+            return date
         },
 
         dateChanged(date) {
-            this.$emit('input', date)
+            this.$emit('input', this.formatDate(date))
         },
 
         focus() {

From b40423fc2db66e8fb42c73821b917546f2847bbd Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 7 May 2024 20:44:26 -0500
Subject: [PATCH 281/542] Fix "view receiving row" page, per oruga

all the buttons and tools *should* work correctly for Vue 2 and 3 now
---
 tailbone/templates/receiving/view_row.mako    | 147 ++++++++++--------
 .../templates/themes/butterball/base.mako     |  24 +++
 .../themes/butterball/field-components.mako   |  83 ++++++++++
 3 files changed, 190 insertions(+), 64 deletions(-)

diff --git a/tailbone/templates/receiving/view_row.mako b/tailbone/templates/receiving/view_row.mako
index efc1883a..5077539c 100644
--- a/tailbone/templates/receiving/view_row.mako
+++ b/tailbone/templates/receiving/view_row.mako
@@ -161,8 +161,13 @@
 
   </div>
 
-  <b-modal has-modal-card
-           :active.sync="accountForProductShowDialog">
+  <${b}-modal has-modal-card
+              % if request.use_oruga:
+                  v-model:active="accountForProductShowDialog"
+              % else:
+                  :active.sync="accountForProductShowDialog"
+              % endif
+              >
     <div class="modal-card">
 
       <header class="modal-card-head">
@@ -212,18 +217,26 @@
 
         </b-field>
 
-        <div class="level">
-          <div class="level-left">
+        <div style="display: flex; gap: 0.5rem; align-items: center;">
 
-            <div class="level-item">
-              <numeric-input v-model="accountForProductQuantity"
-                             ref="accountForProductQuantityInput">
-              </numeric-input>
-            </div>
+          <numeric-input v-model="accountForProductQuantity"
+                         ref="accountForProductQuantityInput">
+          </numeric-input>
 
-            <div class="level-item">
-              % if allow_cases:
-                  <b-field>
+          % if allow_cases:
+              % if request.use_oruga:
+                  <div>
+                    <o-button label="Units"
+                              :variant="accountForProductUOM == 'units' ? 'primary' : null"
+                              @click="accountForProductUOMClicked('units')" />
+                    <o-button label="Cases"
+                              :variant="accountForProductUOM == 'cases' ? 'primary' : null"
+                              @click="accountForProductUOMClicked('cases')" />
+                  </div>
+              % else:
+                  <b-field
+                    ## TODO: a bit hacky, but otherwise buefy styles throw us off here
+                    style="margin-bottom: 0;">
                     <b-radio-button v-model="accountForProductUOM"
                                     @click.native="accountForProductUOMClicked('units')"
                                     native-value="units">
@@ -235,24 +248,17 @@
                       Cases
                     </b-radio-button>
                   </b-field>
-              % else:
-                  <b-field>
-                    <input type="hidden" v-model="accountForProductUOM" />
-                    Units
-                  </b-field>
               % endif
-            </div>
+              <span v-if="accountForProductUOM == 'cases' && accountForProductQuantity">
+                = {{ accountForProductTotalUnits }}
+              </span>
 
-            % if allow_cases:
-                <div class="level-item"
-                     v-if="accountForProductUOM == 'cases' && accountForProductQuantity">
-                  = {{ accountForProductTotalUnits }}
-                </div>
-            % endif
+          % else:
+              <input type="hidden" v-model="accountForProductUOM" />
+              <span>Units</span>
+          % endif
 
-          </div>
         </div>
-
       </section>
 
       <footer class="modal-card-foot">
@@ -268,10 +274,15 @@
         </b-button>
       </footer>
     </div>
-  </b-modal>
+  </${b}-modal>
 
-  <b-modal has-modal-card
-           :active.sync="declareCreditShowDialog">
+  <${b}-modal has-modal-card
+              % if request.use_oruga:
+                  v-model:active="declareCreditShowDialog"
+              % else:
+                  :active.sync="declareCreditShowDialog"
+              % endif
+              >
     <div class="modal-card">
 
       <header class="modal-card-head">
@@ -319,47 +330,51 @@
 
         </b-field>
 
-        <div class="level">
-          <div class="level-left">
+        <div style="display: flex; gap: 0.5rem; align-items: center;">
 
-            <div class="level-item">
-              <numeric-input v-model="declareCreditQuantity"
-                             ref="declareCreditQuantityInput">
-              </numeric-input>
-            </div>
-
-            <div class="level-item">
-              % if allow_cases:
-                  <b-field>
-                    <b-radio-button v-model="declareCreditUOM"
-                                    @click.native="declareCreditUOMClicked('units')"
-                                    native-value="units">
-                      Units
-                    </b-radio-button>
-                    <b-radio-button v-model="declareCreditUOM"
-                                    @click.native="declareCreditUOMClicked('cases')"
-                                    native-value="cases">
-                      Cases
-                    </b-radio-button>
-                  </b-field>
-              % else:
-                  <b-field>
-                    <input type="hidden" v-model="declareCreditUOM" />
-                    Units
-                  </b-field>
-              % endif
-            </div>
+            <numeric-input v-model="declareCreditQuantity"
+                           ref="declareCreditQuantityInput">
+            </numeric-input>
 
             % if allow_cases:
-                <div class="level-item"
-                     v-if="declareCreditUOM == 'cases' && declareCreditQuantity">
+
+                % if request.use_oruga:
+                    <div>
+                      <o-button label="Units"
+                                :variant="declareCreditUOM == 'units' ? 'primary' : null"
+                                @click="declareCreditUOM = 'units'" />
+                      <o-button label="Cases"
+                                :variant="declareCreditUOM == 'cases' ? 'primary' : null"
+                                @click="declareCreditUOM = 'cases'" />
+                    </div>
+                % else:
+                    <b-field
+                      ## TODO: a bit hacky, but otherwise buefy styles throw us off here
+                      style="margin-bottom: 0;">
+                      <b-radio-button v-model="declareCreditUOM"
+                                      @click.native="declareCreditUOMClicked('units')"
+                                      native-value="units">
+                        Units
+                      </b-radio-button>
+                      <b-radio-button v-model="declareCreditUOM"
+                                      @click.native="declareCreditUOMClicked('cases')"
+                                      native-value="cases">
+                        Cases
+                      </b-radio-button>
+                    </b-field>
+                % endif
+                <span v-if="declareCreditUOM == 'cases' && declareCreditQuantity">
                   = {{ declareCreditTotalUnits }}
-                </div>
+                </span>
+
+            % else:
+                <b-field>
+                  <input type="hidden" v-model="declareCreditUOM" />
+                  Units
+                </b-field>
             % endif
 
-          </div>
         </div>
-
       </section>
 
       <footer class="modal-card-foot">
@@ -375,7 +390,7 @@
         </b-button>
       </footer>
     </div>
-  </b-modal>
+  </${b}-modal>
 
   <nav class="panel" >
     <p class="panel-heading">Credits</p>
@@ -527,6 +542,10 @@
 
     ThisPage.methods.accountForProductUOMClicked = function(uom) {
 
+        % if request.use_oruga:
+            this.accountForProductUOM = uom
+        % endif
+
         // TODO: this does not seem to work as expected..even though
         // the code appears to be correct
         this.$nextTick(() => {
diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako
index 4b6c03f8..70a32342 100644
--- a/tailbone/templates/themes/butterball/base.mako
+++ b/tailbone/templates/themes/butterball/base.mako
@@ -264,6 +264,30 @@
         padding-left: 10rem;
     }
 
+    /******************************
+     * fix datepicker within modals
+     * TODO: someday this may not be necessary? cf.
+     * https://github.com/buefy/buefy/issues/292#issuecomment-347365637
+     ******************************/
+
+    /* TODO: this does change some things, but does not actually work 100% */
+    /* right for oruga 0.8.7 or 0.8.9 */
+
+    .modal .animation-content .modal-card {
+        overflow: visible !important;
+    }
+
+    .modal-card-body {
+        overflow: visible !important;
+    }
+
+    /* TODO: a simpler option we might try sometime instead?  */
+    /* cf. https://github.com/buefy/buefy/issues/292#issuecomment-1073851313 */
+
+    /* .dropdown-content{ */
+    /*     position: fixed; */
+    /* } */
+
   </style>
 </%def>
 
diff --git a/tailbone/templates/themes/butterball/field-components.mako b/tailbone/templates/themes/butterball/field-components.mako
index fbd83421..8f9f884a 100644
--- a/tailbone/templates/themes/butterball/field-components.mako
+++ b/tailbone/templates/themes/butterball/field-components.mako
@@ -1,10 +1,93 @@
 ## -*- coding: utf-8; -*-
 
 <%def name="make_field_components()">
+  ${self.make_numeric_input_component()}
   ${self.make_tailbone_autocomplete_component()}
   ${self.make_tailbone_datepicker_component()}
 </%def>
 
+<%def name="make_numeric_input_component()">
+  <% request.register_component('numeric-input', 'NumericInput') %>
+  ${h.javascript_link(request.static_url('tailbone:static/js/numeric.js') + f'?ver={tailbone.__version__}')}
+  <script type="text/x-template" id="numeric-input-template">
+    <o-input v-model="orugaValue"
+             @update:model-value="orugaValueUpdated"
+             ref="input"
+             :disabled="disabled"
+             :icon="icon"
+             :name="name"
+             :placeholder="placeholder"
+             :size="size"
+             />
+  </script>
+  <script>
+
+    const NumericInput = {
+        template: '#numeric-input-template',
+
+        props: {
+            modelValue: [Number, String],
+            allowEnter: Boolean,
+            disabled: Boolean,
+            icon: String,
+            iconPack: String,   // ignored
+            name: String,
+            placeholder: String,
+            size: String,
+        },
+
+        data() {
+            return {
+                orugaValue: this.modelValue,
+                inputElement: null,
+            }
+        },
+
+        watch: {
+            modelValue(to, from) {
+                this.orugaValue = to
+            },
+        },
+
+        mounted() {
+            this.inputElement = this.$refs.input.$el.querySelector('input')
+            this.inputElement.addEventListener('keydown', this.keyDown)
+        },
+
+        beforeDestroy() {
+            this.inputElement.removeEventListener('keydown', this.keyDown)
+        },
+
+        methods: {
+
+            focus() {
+                this.$refs.input.focus()
+            },
+
+            keyDown(event) {
+                // by default we only allow numeric keys, and general navigation
+                // keys, but we might also allow Enter key
+                if (!key_modifies(event) && !key_allowed(event)) {
+                    if (!this.allowEnter || event.which != 13) {
+                        event.preventDefault()
+                    }
+                }
+            },
+
+            orugaValueUpdated(value) {
+                this.$emit('update:modelValue', value)
+                this.$emit('input', value)
+            },
+
+            select() {
+                this.$el.children[0].select()
+            },
+        },
+    }
+
+  </script>
+</%def>
+
 <%def name="make_tailbone_autocomplete_component()">
   <% request.register_component('tailbone-autocomplete', 'TailboneAutocomplete') %>
   <script type="text/x-template" id="tailbone-autocomplete-template">

From 9b65e18261d98609e8f5190c6830c09ca2313a23 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 8 May 2024 11:16:16 -0500
Subject: [PATCH 282/542] Tweak styles for grid action links, per butterball

---
 tailbone/templates/themes/butterball/base.mako | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako
index 70a32342..c30e0156 100644
--- a/tailbone/templates/themes/butterball/base.mako
+++ b/tailbone/templates/themes/butterball/base.mako
@@ -229,6 +229,9 @@
     }
 
     a.grid-action {
+        align-items: center;
+        display: flex;
+        gap: 0.1rem;
         white-space: nowrap;
     }
 

From b65b514270d802840c9c8e97f3ccbf804f38a790 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 8 May 2024 11:17:58 -0500
Subject: [PATCH 283/542] Update changelog

---
 CHANGES.rst          | 22 ++++++++++++++++++++++
 tailbone/_version.py |  2 +-
 2 files changed, 23 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 5a02c648..5d1d2366 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -5,6 +5,28 @@ CHANGELOG
 Unreleased
 ----------
 
+0.10.2 (2024-05-08)
+-------------------
+
+* Fix employees grid when viewing department (per oruga).
+
+* Remove version restriction for pyramid_beaker dependency.
+
+* Fix login "enter" key behavior, per oruga.
+
+* Rename some attrs etc. for buefy components used with oruga.
+
+* Fix "tools" helper for receiving batch view, per oruga.
+
+* Fix button text for autocomplete.
+
+* More data type fixes for ``<tailbone-datepicker>``.
+
+* Fix "view receiving row" page, per oruga.
+
+* Tweak styles for grid action links, per butterball.
+
+
 0.10.1 (2024-04-28)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 3ce74007..cc88ae5b 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.10.1'
+__version__ = '0.10.2'

From c43deb1307ae842861ee26cbe59b10cd667b971e Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 8 May 2024 20:50:54 -0500
Subject: [PATCH 284/542] Fix bug with grid date filters

---
 tailbone/templates/grids/filter-components.mako | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/tailbone/templates/grids/filter-components.mako b/tailbone/templates/grids/filter-components.mako
index ff5ba8b7..e4915065 100644
--- a/tailbone/templates/grids/filter-components.mako
+++ b/tailbone/templates/grids/filter-components.mako
@@ -177,6 +177,9 @@
                 if (date === null) {
                     return null
                 }
+                if (typeof(date) == 'string') {
+                    return date
+                }
                 // just need to convert to simple ISO date format here, seems
                 // like there should be a more obvious way to do that?
                 var year = date.getFullYear()

From 6bb6c16bc7f617ba686912ed1f4d9eecac4206ce Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 10 May 2024 15:00:21 -0500
Subject: [PATCH 285/542] Update changelogo

---
 CHANGES.rst          | 6 ++++++
 tailbone/_version.py | 2 +-
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 5d1d2366..a370a2a9 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -5,6 +5,12 @@ CHANGELOG
 Unreleased
 ----------
 
+0.10.3 (2024-05-10)
+-------------------
+
+* Fix bug with grid date filters.
+
+
 0.10.2 (2024-05-08)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index cc88ae5b..1bfa81a7 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.10.2'
+__version__ = '0.10.3'

From 66304a418ea3b1e218c6957cb38f7e4dfda3aad3 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 10 May 2024 15:50:21 -0500
Subject: [PATCH 286/542] Fix styles for grid actions, per butterball

---
 tailbone/templates/themes/butterball/base.mako | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako
index c30e0156..b62a8de2 100644
--- a/tailbone/templates/themes/butterball/base.mako
+++ b/tailbone/templates/themes/butterball/base.mako
@@ -230,7 +230,7 @@
 
     a.grid-action {
         align-items: center;
-        display: flex;
+        display: inline-flex;
         gap: 0.1rem;
         white-space: nowrap;
     }

From ec61444b3d8000ee887cdaab02b452bfec929692 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 12 May 2024 17:41:09 -0500
Subject: [PATCH 287/542] Update changelog

---
 CHANGES.rst          | 6 ++++++
 tailbone/_version.py | 2 +-
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index a370a2a9..4a85251e 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -5,6 +5,12 @@ CHANGELOG
 Unreleased
 ----------
 
+0.10.4 (2024-05-12)
+-------------------
+
+* Fix styles for grid actions, per butterball.
+
+
 0.10.3 (2024-05-10)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 1bfa81a7..a29d3dbe 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.10.3'
+__version__ = '0.10.4'

From fb9bc019392fae3caa5415fa99c30d00ce184cb3 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 14 May 2024 12:17:50 -0500
Subject: [PATCH 288/542] Add `<tailbone-timepicker>` component for oruga

---
 tailbone/forms/widgets.py                     | 10 +++
 .../themes/butterball/field-components.mako   | 66 +++++++++++++++++++
 2 files changed, 76 insertions(+)

diff --git a/tailbone/forms/widgets.py b/tailbone/forms/widgets.py
index 6b74798c..c0bb0b4d 100644
--- a/tailbone/forms/widgets.py
+++ b/tailbone/forms/widgets.py
@@ -27,6 +27,7 @@ Form Widgets
 import json
 import datetime
 import decimal
+import re
 
 import colander
 from deform import widget as dfwidget
@@ -249,6 +250,8 @@ class FalafelDateTimeWidget(dfwidget.DateTimeInputWidget):
     """
     template = 'datetime_falafel'
 
+    new_pattern = re.compile(r'^\d\d?:\d\d:\d\d [AP]M$')
+
     def serialize(self, field, cstruct, **kw):
         """ """
         readonly = kw.get('readonly', self.readonly)
@@ -260,6 +263,13 @@ class FalafelDateTimeWidget(dfwidget.DateTimeInputWidget):
         """ """
         if pstruct  == '':
             return colander.null
+
+        # nb. we now allow '4:20:00 PM' on the widget side, but the
+        # true node needs it to be '16:20:00' instead
+        if self.new_pattern.match(pstruct['time']):
+            time = datetime.datetime.strptime(pstruct['time'], '%I:%M:%S %p')
+            pstruct['time'] = time.strftime('%H:%M:%S')
+
         return pstruct
 
 
diff --git a/tailbone/templates/themes/butterball/field-components.mako b/tailbone/templates/themes/butterball/field-components.mako
index 8f9f884a..8c1d1d70 100644
--- a/tailbone/templates/themes/butterball/field-components.mako
+++ b/tailbone/templates/themes/butterball/field-components.mako
@@ -4,6 +4,7 @@
   ${self.make_numeric_input_component()}
   ${self.make_tailbone_autocomplete_component()}
   ${self.make_tailbone_datepicker_component()}
+  ${self.make_tailbone_timepicker_component()}
 </%def>
 
 <%def name="make_numeric_input_component()">
@@ -461,3 +462,68 @@
 
   </script>
 </%def>
+
+<%def name="make_tailbone_timepicker_component()">
+  <% request.register_component('tailbone-timepicker', 'TailboneTimepicker') %>
+  <script type="text/x-template" id="tailbone-timepicker-template">
+    <o-timepicker :name="name"
+                  v-model="orugaValue"
+                  @update:model-value="orugaValueUpdated"
+                  placeholder="Click to select ..."
+                  icon="clock"
+                  hour-format="12"
+                  :time-formatter="formatTime" />
+  </script>
+  <script>
+
+    const TailboneTimepicker = {
+        template: '#tailbone-timepicker-template',
+
+        props: {
+            modelValue: [Date, String],
+            name: String,
+        },
+
+        data() {
+            return {
+                orugaValue: this.parseTime(this.modelValue),
+            }
+        },
+
+        watch: {
+            modelValue(to, from) {
+                this.orugaValue = this.parseTime(to)
+            },
+        },
+
+        methods: {
+
+            formatTime(value) {
+                if (!value) {
+                    return null
+                }
+
+                return value.toLocaleTimeString('en-US')
+            },
+
+            parseTime(value) {
+
+                if (value.getHours) {
+                    return value
+                }
+
+                let found = value.match(/^(\d\d):(\d\d):\d\d$/)
+                if (found) {
+                    return new Date(null, null, null,
+                                    parseInt(found[1]), parseInt(found[2]))
+                }
+            },
+
+            orugaValueUpdated(value) {
+                this.$emit('update:modelValue', value)
+            },
+        },
+    }
+
+  </script>
+</%def>

From f8ab8d462c07510b49646449443c4ad79aa33a8c Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 29 May 2024 09:40:50 -0500
Subject: [PATCH 289/542] Update changelog

---
 CHANGES.rst          | 6 ++++++
 tailbone/_version.py | 2 +-
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 4a85251e..92d088b0 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -5,6 +5,12 @@ CHANGELOG
 Unreleased
 ----------
 
+0.10.5 (2024-05-29)
+-------------------
+
+* Add ``<tailbone-timepicker>`` component for oruga.
+
+
 0.10.4 (2024-05-12)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index a29d3dbe..07f1c1c4 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.10.4'
+__version__ = '0.10.5'

From 4ccdf99a43e9659bc3c3192cb05d7d9c3741fa34 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 29 May 2024 15:47:04 -0500
Subject: [PATCH 290/542] Add way to flag organic products within lookup dialog

---
 tailbone/templates/base.mako            |  4 +++-
 tailbone/templates/base_meta.mako       |  2 ++
 tailbone/templates/products/lookup.mako |  6 ++++--
 tailbone/views/products.py              | 20 ++++++++++++++++----
 4 files changed, 25 insertions(+), 7 deletions(-)

diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako
index 44c7dd0f..1554d15d 100644
--- a/tailbone/templates/base.mako
+++ b/tailbone/templates/base.mako
@@ -171,7 +171,9 @@
   % endif
 </%def>
 
-<%def name="extra_styles()"></%def>
+<%def name="extra_styles()">
+  ${base_meta.extra_styles()}
+</%def>
 
 <%def name="head_tags()"></%def>
 
diff --git a/tailbone/templates/base_meta.mako b/tailbone/templates/base_meta.mako
index 568782b7..07b13e61 100644
--- a/tailbone/templates/base_meta.mako
+++ b/tailbone/templates/base_meta.mako
@@ -4,6 +4,8 @@
 
 <%def name="global_title()">${"[STAGE] " if not request.rattail_config.production() else ''}${self.app_title()}</%def>
 
+<%def name="extra_styles()"></%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'))}" />
 </%def>
diff --git a/tailbone/templates/products/lookup.mako b/tailbone/templates/products/lookup.mako
index 52b06e88..bf8e7ef5 100644
--- a/tailbone/templates/products/lookup.mako
+++ b/tailbone/templates/products/lookup.mako
@@ -109,8 +109,10 @@
               <b-table-column label="Description"
                               field="description"
                               v-slot="props">
-                {{ props.row.description }}
-                {{ props.row.size }}
+                <span :class="{organic: props.row.organic}">
+                  {{ props.row.description }}
+                  {{ props.row.size }}
+                </span>
               </b-table-column>
 
               <b-table-column label="Unit Price"
diff --git a/tailbone/views/products.py b/tailbone/views/products.py
index 788cc24d..7219b6b3 100644
--- a/tailbone/views/products.py
+++ b/tailbone/views/products.py
@@ -33,7 +33,8 @@ from sqlalchemy import orm
 import sqlalchemy_continuum as continuum
 
 from rattail import enum, pod, sil
-from rattail.db import model, api, auth, Session as RattailSession
+from rattail.db import api, auth, Session as RattailSession
+from rattail.db.model import Product, PendingProduct, CustomerOrderItem
 from rattail.gpc import GPC
 from rattail.threads import Thread
 from rattail.exceptions import LabelPrintingError
@@ -74,7 +75,7 @@ class ProductView(MasterView):
     """
     Master view for the Product class.
     """
-    model_class = model.Product
+    model_class = Product
     has_versions = True
     results_downloadable_xlsx = True
     supports_autocomplete = True
@@ -173,6 +174,7 @@ class ProductView(MasterView):
 
     def query(self, session):
         query = super().query(session)
+        model = self.model
 
         if not self.has_perm('view_deleted'):
             query = query.filter(model.Product.deleted == False)
@@ -685,6 +687,7 @@ class ProductView(MasterView):
         return data
 
     def get_instance(self):
+        model = self.model
         key = self.request.matchdict['uuid']
         product = self.Session.get(model.Product, key)
         if product:
@@ -696,6 +699,7 @@ class ProductView(MasterView):
 
     def configure_form(self, f):
         super().configure_form(f)
+        model = self.model
         product = f.model_instance
 
         # unit_size
@@ -1396,6 +1400,7 @@ class ProductView(MasterView):
         product's regular price history.
         """
         app = self.get_rattail_app()
+        model = self.model
         Transaction = continuum.transaction_class(model.Product)
         ProductVersion = continuum.version_class(model.Product)
         ProductPriceVersion = continuum.version_class(model.ProductPrice)
@@ -1466,6 +1471,7 @@ class ProductView(MasterView):
         product's current price history.
         """
         app = self.get_rattail_app()
+        model = self.model
         Transaction = continuum.transaction_class(model.Product)
         ProductVersion = continuum.version_class(model.Product)
         ProductPriceVersion = continuum.version_class(model.ProductPrice)
@@ -1609,6 +1615,7 @@ class ProductView(MasterView):
         product's SRP history.
         """
         app = self.get_rattail_app()
+        model = self.model
         Transaction = continuum.transaction_class(model.Product)
         ProductVersion = continuum.version_class(model.Product)
         ProductPriceVersion = continuum.version_class(model.ProductPrice)
@@ -1679,6 +1686,7 @@ class ProductView(MasterView):
         product's cost history.
         """
         app = self.get_rattail_app()
+        model = self.model
         Transaction = continuum.transaction_class(model.Product)
         ProductVersion = continuum.version_class(model.Product)
         ProductCostVersion = continuum.version_class(model.ProductCost)
@@ -1746,6 +1754,7 @@ class ProductView(MasterView):
                                                 'form': form})
 
     def get_version_child_classes(self):
+        model = self.model
         return [
             (model.ProductCode, 'product_uuid'),
             (model.ProductCost, 'product_uuid'),
@@ -1893,6 +1902,7 @@ class ProductView(MasterView):
             'case_price',
             'case_price_display',
             'uom_choices',
+            'organic',
         ])
 
     # TODO: deprecate / remove this?  not sure if/where it is used
@@ -1904,6 +1914,7 @@ class ProductView(MasterView):
         Eventually this should be more generic, or at least offer more fields for
         search.  For now it operates only on the ``Product.upc`` field.
         """
+        model = self.model
         data = None
         upc = self.request.GET.get('upc', '').strip()
         upc = re.sub(r'\D', '', upc)
@@ -2091,6 +2102,7 @@ class ProductView(MasterView):
         """
         Threat target for making a batch from current products query.
         """
+        model = self.model
         session = RattailSession()
         user = session.get(model.User, user_uuid)
         assert user
@@ -2231,7 +2243,7 @@ class PendingProductView(MasterView):
     """
     Master view for the Pending Product class.
     """
-    model_class = model.PendingProduct
+    model_class = PendingProduct
     route_prefix = 'pending_products'
     url_prefix = '/products/pending'
     bulk_deletable = True
@@ -2278,7 +2290,7 @@ class PendingProductView(MasterView):
     ]
 
     has_rows = True
-    model_row_class = model.CustomerOrderItem
+    model_row_class = CustomerOrderItem
     rows_title = "Customer Orders"
     # TODO: add support for this someday
     rows_viewable = False

From 9a841ba5e2df5d8862f040912b9e6b6d086f064a Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 29 May 2024 16:33:30 -0500
Subject: [PATCH 291/542] Expose db picker for butterball theme

---
 .../templates/themes/butterball/base.mako     | 40 +++++++++----------
 1 file changed, 20 insertions(+), 20 deletions(-)

diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako
index b62a8de2..f57c3257 100644
--- a/tailbone/templates/themes/butterball/base.mako
+++ b/tailbone/templates/themes/butterball/base.mako
@@ -715,26 +715,22 @@
                   % endif
               % endif
 
-  ##             % if expose_db_picker is not Undefined and expose_db_picker:
-  ##                 <div class="level-item">
-  ##                   <p>DB:</p>
-  ##                 </div>
-  ##                 <div class="level-item">
-  ##                   ${h.form(url('change_db_engine'), ref='dbPickerForm')}
-  ##                   ${h.csrf_token(request)}
-  ##                   ${h.hidden('engine_type', value=master.engine_type_key)}
-  ##                   <b-select name="dbkey"
-  ##                             value="${db_picker_selected}"
-  ##                             @input="changeDB()">
-  ##                     % for option in db_picker_options:
-  ##                         <option value="${option.value}">
-  ##                           ${option.label}
-  ##                         </option>
-  ##                     % endfor
-  ##                   </b-select>
-  ##                   ${h.end_form()}
-  ##                 </div>
-  ##             % endif
+              % if expose_db_picker is not Undefined and expose_db_picker:
+                  <span>DB:</span>
+                  ${h.form(url('change_db_engine'), ref='dbPickerForm')}
+                  ${h.csrf_token(request)}
+                  ${h.hidden('engine_type', value=master.engine_type_key)}
+                  <b-select name="dbkey"
+                            v-model="dbSelected"
+                            @input="changeDB()">
+                    % for option in db_picker_options:
+                        <option value="${option.value}">
+                          ${option.label}
+                        </option>
+                    % endfor
+                  </b-select>
+                  ${h.end_form()}
+              % endif
 
             </div>
 
@@ -1104,6 +1100,10 @@
         globalSearchData: ${json.dumps(global_search_data)|n},
         mountedHooks: [],
 
+        % if expose_db_picker is not Undefined and expose_db_picker:
+            dbSelected: ${json.dumps(db_picker_selected)|n},
+        % endif
+
         % if expose_theme_picker and request.has_perm('common.change_app_theme'):
             globalTheme: ${json.dumps(theme)|n},
         % endif

From b98d651144d2918cc948f18a68b2a704de0f8906 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 29 May 2024 16:55:42 -0500
Subject: [PATCH 292/542] Expose quickie lookup for butterball theme

---
 .../templates/themes/butterball/base.mako     | 39 +++++++------------
 1 file changed, 14 insertions(+), 25 deletions(-)

diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako
index f57c3257..8a951831 100644
--- a/tailbone/templates/themes/butterball/base.mako
+++ b/tailbone/templates/themes/butterball/base.mako
@@ -736,31 +736,20 @@
 
             <div style="display: flex; gap: 0.5rem;">
 
-##               ## Quickie Lookup
-##               % if quickie is not Undefined and quickie and request.has_perm(quickie.perm):
-##                   <div class="level-item">
-##                     ${h.form(quickie.url, method="get")}
-##                     <div class="level">
-##                       <div class="level-right">
-##                         <div class="level-item">
-##                           <b-input name="entry"
-##                                    placeholder="${quickie.placeholder}"
-##                                    autocomplete="off">
-##                           </b-input>
-##                         </div>
-##                         <div class="level-item">
-##                           <button type="submit" class="button is-primary">
-##                             <span class="icon is-small">
-##                               <i class="fas fa-search"></i>
-##                             </span>
-##                             <span>Lookup</span>
-##                           </button>
-##                         </div>
-##                       </div>
-##                     </div>
-##                     ${h.end_form()}
-##                   </div>
-##               % endif
+              ## Quickie Lookup
+              % if quickie is not Undefined and quickie and request.has_perm(quickie.perm):
+                  ${h.form(quickie.url, method='get', style='display: flex; gap: 0.5rem; margin-right: 1rem;')}
+                    <b-input name="entry"
+                             placeholder="${quickie.placeholder}"
+                             autocomplete="off">
+                    </b-input>
+                    <o-button variant="primary"
+                              native-type="submit"
+                              icon-left="search">
+                      Lookup
+                    </o-button>
+                  ${h.end_form()}
+              % endif
 
               % if master and master.configurable and master.has_perm('configure'):
                   % if not request.matched_route.name.endswith('.configure'):

From 54b75dbe1a7b9bfaf02f6e2d082e738289c2363f Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 29 May 2024 20:47:10 -0500
Subject: [PATCH 293/542] Fix basic problems with people profile view, per
 butterball

plenty more tweaks needed yet i'm sure, but page looks reasonable now
at least
---
 tailbone/templates/people/view_profile.mako   | 428 ++++++++++++------
 .../themes/butterball/buefy-components.mako   |   9 +-
 2 files changed, 288 insertions(+), 149 deletions(-)

diff --git a/tailbone/templates/people/view_profile.mako b/tailbone/templates/people/view_profile.mako
index 65c96fd6..0ca42cef 100644
--- a/tailbone/templates/people/view_profile.mako
+++ b/tailbone/templates/people/view_profile.mako
@@ -115,8 +115,13 @@
                   Edit Name
                 </b-button>
               </div>
-              <b-modal has-modal-card
-                       :active.sync="editNameShowDialog">
+              <${b}-modal has-modal-card
+                          % if request.use_oruga:
+                              v-model:active="editNameShowDialog"
+                          % else:
+                              :active.sync="editNameShowDialog"
+                          % endif
+                          >
                 <div class="modal-card">
 
                   <header class="modal-card-head">
@@ -125,49 +130,52 @@
 
                   <section class="modal-card-body">
 
-                    <b-field grouped>
-
-                      <b-field label="First Name" expanded>
-                        <b-input v-model.trim="editNameFirst"
-                                 :maxlength="maxLengths.person_first_name || null">
-                        </b-input>
-                      </b-field>
-
-                      % if use_preferred_first_name:
+                    % if use_preferred_first_name:
+                        <b-field grouped>
+                          <b-field label="First Name">
+                            <b-input v-model.trim="editNameFirst"
+                                     :maxlength="maxLengths.person_first_name || null" />
+                          </b-field>
                           <b-field label="Preferred First Name" expanded>
                             <b-input v-model.trim="editNameFirstPreferred"
                                      :maxlength="maxLengths.person_preferred_first_name || null">
                             </b-input>
                           </b-field>
-                      % endif
-
-                    </b-field>
+                        </b-field>
+                    % else:
+                        <b-field label="First Name">
+                          <b-input v-model.trim="editNameFirst"
+                                   :maxlength="maxLengths.person_first_name || null"
+                                   expanded />
+                        </b-field>
+                    % endif
 
                     <b-field label="Middle Name">
                       <b-input v-model.trim="editNameMiddle"
-                               :maxlength="maxLengths.person_middle_name || null">
-                      </b-input>
+                               :maxlength="maxLengths.person_middle_name || null"
+                               expanded />
                     </b-field>
                     <b-field label="Last Name">
                       <b-input v-model.trim="editNameLast"
-                               :maxlength="maxLengths.person_last_name || null">
-                      </b-input>
+                               :maxlength="maxLengths.person_last_name || null"
+                               expanded />
                     </b-field>
                   </section>
 
                   <footer class="modal-card-foot">
-                    <once-button type="is-primary"
-                                 @click="editNameSave()"
-                                 :disabled="editNameSaveDisabled"
-                                 icon-left="save"
-                                 text="Save">
-                    </once-button>
+                    <b-button type="is-primary"
+                              @click="editNameSave()"
+                              :disabled="editNameSaveDisabled"
+                              icon-pack="fas"
+                              icon-left="save">
+                      {{ editNameSaving ? "Working, please wait..." : "Save" }}
+                    </b-button>
                     <b-button @click="editNameShowDialog = false">
                       Cancel
                     </b-button>
                   </footer>
                 </div>
-              </b-modal>
+              </${b}-modal>
           % endif
         </div>
       </div>
@@ -219,8 +227,13 @@
                         icon-left="edit">
                 Edit Address
               </b-button>
-              <b-modal has-modal-card
-                       :active.sync="editAddressShowDialog">
+              <${b}-modal has-modal-card
+                          % if request.use_oruga:
+                              v-model:active="editAddressShowDialog"
+                          % else:
+                              :active.sync="editAddressShowDialog"
+                          % endif
+                          >
                 <div class="modal-card">
 
                   <header class="modal-card-head">
@@ -231,20 +244,20 @@
 
                     <b-field label="Street 1" expanded>
                       <b-input v-model.trim="editAddressStreet1"
-                               :maxlength="maxLengths.address_street || null">
-                      </b-input>
+                               :maxlength="maxLengths.address_street || null"
+                               expanded />
                     </b-field>
 
                     <b-field label="Street 2" expanded>
                       <b-input v-model.trim="editAddressStreet2"
-                               :maxlength="maxLengths.address_street2 || null">
-                      </b-input>
+                               :maxlength="maxLengths.address_street2 || null"
+                               expanded />
                     </b-field>
 
                     <b-field label="Zipcode">
                       <b-input v-model.trim="editAddressZipcode"
-                               :maxlength="maxLengths.address_zipcode || null">
-                      </b-input>
+                               :maxlength="maxLengths.address_zipcode || null"
+                               expanded />
                     </b-field>
 
                     <b-field grouped>
@@ -280,7 +293,7 @@
                     </b-button>
                   </footer>
                 </div>
-              </b-modal>
+              </${b}-modal>
           % endif
         </div>
       </div>
@@ -312,8 +325,13 @@
                 Add Phone
               </b-button>
             </div>
-            <b-modal has-modal-card
-                     :active.sync="editPhoneShowDialog">
+            <${b}-modal has-modal-card
+                        % if request.use_oruga:
+                            v-model:active="editPhoneShowDialog"
+                        % else:
+                            :active.sync="editPhoneShowDialog"
+                        % endif
+                        >
               <div class="modal-card">
 
                 <header class="modal-card-head">
@@ -362,50 +380,64 @@
                   </b-button>
                 </footer>
               </div>
-            </b-modal>
+            </${b}-modal>
         % endif
 
-        <b-table :data="person.phones">
+        <${b}-table :data="person.phones">
 
-          <b-table-column field="preference"
+          <${b}-table-column field="preference"
                           label="Preferred"
                           v-slot="props">
             {{ props.row.preferred ? "Yes" : "" }}
-          </b-table-column>
+          </${b}-table-column>
 
-          <b-table-column field="type"
+          <${b}-table-column field="type"
                           label="Type"
                           v-slot="props">
             {{ props.row.type }}
-          </b-table-column>
+          </${b}-table-column>
 
-          <b-table-column field="number"
+          <${b}-table-column field="number"
                           label="Number"
                           v-slot="props">
             {{ props.row.number }}
-          </b-table-column>
+          </${b}-table-column>
 
           % if request.has_perm('people_profile.edit_person'):
-          <b-table-column label="Actions"
+          <${b}-table-column label="Actions"
                           v-slot="props">
-            <a href="#" @click.prevent="editPhoneInit(props.row)">
-              <i class="fas fa-edit"></i>
+            <a class="grid-action"
+               href="#" @click.prevent="editPhoneInit(props.row)">
+              % if request.use_oruga:
+                  <o-icon icon="edit" />
+              % else:
+                  <i class="fas fa-edit"></i>
+              % endif
               Edit
             </a>
-            <a href="#" @click.prevent="deletePhoneInit(props.row)"
-               class="has-text-danger">
-              <i class="fas fa-trash"></i>
+            <a class="grid-action has-text-danger"
+               href="#" @click.prevent="deletePhoneInit(props.row)">
+              % if request.use_oruga:
+                  <o-icon icon="trash" />
+              % else:
+                  <i class="fas fa-trash"></i>
+              % endif
               Delete
             </a>
-            <a href="#" @click.prevent="preferPhoneInit(props.row)"
+            <a class="grid-action"
+               href="#" @click.prevent="preferPhoneInit(props.row)"
                v-if="!props.row.preferred">
-              <i class="fas fa-star"></i>
+              % if request.use_oruga:
+                  <o-icon icon="star" />
+              % else:
+                  <i class="fas fa-star"></i>
+              % endif
               Set Preferred
             </a>
-          </b-table-column>
+          </${b}-table-column>
           % endif
 
-        </b-table>
+        </${b}-table>
 
       </div>
     </div>
@@ -429,8 +461,13 @@
                 Add Email
               </b-button>
             </div>
-            <b-modal has-modal-card
-                     :active.sync="editEmailShowDialog">
+            <${b}-modal has-modal-card
+                        % if request.use_oruga:
+                            v-model:active="editEmailShowDialog"
+                        % else:
+                            :active.sync="editEmailShowDialog"
+                        % endif
+                        >
               <div class="modal-card">
 
                 <header class="modal-card-head">
@@ -488,56 +525,70 @@
                   </b-button>
                 </footer>
               </div>
-            </b-modal>
+            </${b}-modal>
         % endif
 
-        <b-table :data="person.emails">
+        <${b}-table :data="person.emails">
 
-          <b-table-column field="preference"
+          <${b}-table-column field="preference"
                           label="Preferred"
                           v-slot="props">
             {{ props.row.preferred ? "Yes" : "" }}
-          </b-table-column>
+          </${b}-table-column>
 
-          <b-table-column field="type"
+          <${b}-table-column field="type"
                           label="Type"
                           v-slot="props">
             {{ props.row.type }}
-          </b-table-column>
+          </${b}-table-column>
 
-          <b-table-column field="address"
+          <${b}-table-column field="address"
                           label="Address"
                           v-slot="props">
             {{ props.row.address }}
-          </b-table-column>
+          </${b}-table-column>
 
-          <b-table-column field="invalid"
+          <${b}-table-column field="invalid"
                           label="Invalid?"
                           v-slot="props">
             <span v-if="props.row.invalid" class="has-text-danger has-text-weight-bold">Invalid</span>
-          </b-table-column>
+          </${b}-table-column>
 
           % if request.has_perm('people_profile.edit_person'):
-              <b-table-column label="Actions"
+              <${b}-table-column label="Actions"
                               v-slot="props">
-                <a href="#" @click.prevent="editEmailInit(props.row)">
-                  <i class="fas fa-edit"></i>
+                <a class="grid-action"
+                   href="#" @click.prevent="editEmailInit(props.row)">
+                  % if request.use_oruga:
+                      <o-icon icon="edit" />
+                  % else:
+                      <i class="fas fa-edit"></i>
+                  % endif
                   Edit
                 </a>
-                <a href="#" @click.prevent="deleteEmailInit(props.row)"
-                   class="has-text-danger">
-                  <i class="fas fa-trash"></i>
+                <a class="grid-action has-text-danger"
+                   href="#" @click.prevent="deleteEmailInit(props.row)">
+                  % if request.use_oruga:
+                      <o-icon icon="trash" />
+                  % else:
+                      <i class="fas fa-trash"></i>
+                  % endif
                   Delete
                 </a>
-                <a href="#" @click.prevent="preferEmailInit(props.row)"
+                <a class="grid-action"
+                   href="#" @click.prevent="preferEmailInit(props.row)"
                    v-if="!props.row.preferred">
-                  <i class="fas fa-star"></i>
+                  % if request.use_oruga:
+                      <o-icon icon="star" />
+                  % else:
+                      <i class="fas fa-star"></i>
+                  % endif
                   Set Preferred
                 </a>
-              </b-table-column>
+              </${b}-table-column>
           % endif
 
-        </b-table>
+        </${b}-table>
 
       </div>
     </div>
@@ -566,16 +617,22 @@
             </b-button>
         % endif
       </div>
-      <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading>
+      % if request.use_oruga:
+          <o-loading v-model:active="refreshingTab" :full-page="false"></o-loading>
+      % else:
+          <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading>
+      % endif
     </div>
   </script>
 </%def>
 
 <%def name="render_personal_tab()">
-  <b-tab-item label="Personal"
-              value="personal"
-              icon-pack="fas"
-              :icon="tabchecks.personal ? 'check' : null">
+  <${b}-tab-item label="Personal"
+                 value="personal"
+                 % if not request.use_oruga:
+                     icon-pack="fas"
+                 % endif
+                 :icon="tabchecks.personal ? 'check' : null">
     <personal-tab ref="tab_personal"
                   :person="person"
                   @profile-changed="profileChanged"
@@ -583,7 +640,7 @@
                   :email-type-options="emailTypeOptions"
                   :max-lengths="maxLengths">
     </personal-tab>
-  </b-tab-item>
+  </${b}-tab-item>
 </%def>
 
 <%def name="render_member_tab_template()">
@@ -692,13 +749,17 @@
           </div>
       % endif
 
-    <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading>
+      % if request.use_oruga:
+          <o-loading v-model:active="refreshingTab" :full-page="false"></o-loading>
+      % else:
+          <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading>
+      % endif
     </div>
   </script>
 </%def>
 
 <%def name="render_member_tab()">
-  <b-tab-item label="Member"
+  <${b}-tab-item label="Member"
               value="member"
               icon-pack="fas"
               :icon="tabchecks.member ? 'check' : null">
@@ -707,7 +768,7 @@
                 @profile-changed="profileChanged"
                 :phone-type-options="phoneTypeOptions">
     </member-tab>
-  </b-tab-item>
+  </${b}-tab-item>
 </%def>
 
 <%def name="render_customer_tab_template()">
@@ -814,13 +875,17 @@
       <div v-if="!customers.length">
         <p>{{ person.display_name }} does not have a customer account.</p>
       </div>
-      <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading>
+      % if request.use_oruga:
+          <o-loading v-model:active="refreshingTab" :full-page="false"></o-loading>
+      % else:
+          <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading>
+      % endif
     </div>
   </script>
 </%def>
 
 <%def name="render_customer_tab()">
-  <b-tab-item label="Customer"
+  <${b}-tab-item label="Customer"
               value="customer"
               icon-pack="fas"
               :icon="tabchecks.customer ? 'check' : null">
@@ -828,7 +893,7 @@
                   :person="person"
                   @profile-changed="profileChanged">
     </customer-tab>
-  </b-tab-item>
+  </${b}-tab-item>
 </%def>
 
 <%def name="render_shopper_tab_template()">
@@ -890,13 +955,17 @@
       <div v-if="!shoppers.length">
         <p>{{ person.display_name }} is not a shopper.</p>
       </div>
-      <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading>
+      % if request.use_oruga:
+          <o-loading v-model:active="refreshingTab" :full-page="false"></o-loading>
+      % else:
+          <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading>
+      % endif
     </div>
   </script>
 </%def>
 
 <%def name="render_shopper_tab()">
-  <b-tab-item label="Shopper"
+  <${b}-tab-item label="Shopper"
               value="shopper"
               icon-pack="fas"
               :icon="tabchecks.shopper ? 'check' : null">
@@ -904,7 +973,7 @@
                  :person="person"
                  @profile-changed="profileChanged">
     </shopper-tab>
-  </b-tab-item>
+  </${b}-tab-item>
 </%def>
 
 <%def name="render_employee_tab_template()">
@@ -930,8 +999,13 @@
                                   @click="editEmployeeIdInit()">
                           Edit ID
                         </b-button>
-                        <b-modal has-modal-card
-                                 :active.sync="editEmployeeIdShowDialog">
+                        <${b}-modal has-modal-card
+                                    % if request.use_oruga:
+                                        v-model:active="editEmployeeIdShowDialog"
+                                    % else:
+                                        :active.sync="editEmployeeIdShowDialog"
+                                    % endif
+                                    >
                           <div class="modal-card">
 
                             <header class="modal-card-head">
@@ -957,7 +1031,7 @@
                               </b-button>
                             </footer>
                           </div>
-                        </b-modal>
+                        </${b}-modal>
                       </div>
                   % endif
                 </div>
@@ -980,32 +1054,32 @@
             <p><strong>Employee History</strong></p>
             <br />
 
-            <b-table :data="employeeHistory">
+            <${b}-table :data="employeeHistory">
 
-              <b-table-column field="start_date"
+              <${b}-table-column field="start_date"
                               label="Start Date"
                               v-slot="props">
                 {{ props.row.start_date }}
-              </b-table-column>
+              </${b}-table-column>
 
-              <b-table-column field="end_date"
+              <${b}-table-column field="end_date"
                               label="End Date"
                               v-slot="props">
                 {{ props.row.end_date }}
-              </b-table-column>
+              </${b}-table-column>
 
               % if request.has_perm('people_profile.edit_employee_history'):
-                  <b-table-column field="actions"
+                  <${b}-table-column field="actions"
                                   label="Actions"
                                   v-slot="props">
                     <a href="#" @click.prevent="editEmployeeHistoryInit(props.row)">
                       <i class="fas fa-edit"></i>
                       Edit
                     </a>
-                  </b-table-column>
+                  </${b}-table-column>
               % endif
 
-            </b-table>
+            </${b}-table>
 
           </div>
 
@@ -1032,8 +1106,13 @@
                   ${person} is no longer an Employee
                 </b-button>
 
-                <b-modal has-modal-card
-                         :active.sync="startEmployeeShowDialog">
+                <${b}-modal has-modal-card
+                            % if request.use_oruga:
+                                v-model:active="startEmployeeShowDialog"
+                            % else:
+                                :active.sync="startEmployeeShowDialog"
+                            % endif
+                            >
                   <div class="modal-card">
 
                     <header class="modal-card-head">
@@ -1060,10 +1139,15 @@
                       </once-button>
                     </footer>
                   </div>
-                </b-modal>
+                </${b}-modal>
 
-                <b-modal has-modal-card
-                         :active.sync="stopEmployeeShowDialog">
+                <${b}-modal has-modal-card
+                            % if request.use_oruga:
+                                v-model:active="stopEmployeeShowDialog"
+                            % else:
+                                :active.sync="stopEmployeeShowDialog"
+                            % endif
+                            >
                   <div class="modal-card">
 
                     <header class="modal-card-head">
@@ -1092,12 +1176,17 @@
                       </once-button>
                     </footer>
                   </div>
-                </b-modal>
+                </${b}-modal>
             % endif
 
             % if request.has_perm('people_profile.edit_employee_history'):
-                <b-modal has-modal-card
-                         :active.sync="editEmployeeHistoryShowDialog">
+                <${b}-modal has-modal-card
+                            % if request.use_oruga:
+                                v-model:active="editEmployeeHistoryShowDialog"
+                            % else:
+                                :active.sync="editEmployeeHistoryShowDialog"
+                            % endif
+                            >
                   <div class="modal-card">
 
                     <header class="modal-card-head">
@@ -1126,7 +1215,7 @@
                       </once-button>
                     </footer>
                   </div>
-                </b-modal>
+                </${b}-modal>
             % endif
 
             % if request.has_perm('employees.view'):
@@ -1140,13 +1229,17 @@
         </div>
 
       </div>
-      <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading>
+      % if request.use_oruga:
+          <o-loading v-model:active="refreshingTab" :full-page="false"></o-loading>
+      % else:
+          <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading>
+      % endif
     </div>
   </script>
 </%def>
 
 <%def name="render_employee_tab()">
-  <b-tab-item label="Employee"
+  <${b}-tab-item label="Employee"
               value="employee"
               icon-pack="fas"
               :icon="tabchecks.employee ? 'check' : null">
@@ -1154,7 +1247,7 @@
                   :person="person"
                   @profile-changed="profileChanged">
     </employee-tab>
-  </b-tab-item>
+  </${b}-tab-item>
 </%def>
 
 <%def name="render_notes_tab_template()">
@@ -1171,40 +1264,40 @@
           </b-button>
       % endif
 
-      <b-table :data="notes">
+      <${b}-table :data="notes">
 
-        <b-table-column field="note_type"
+        <${b}-table-column field="note_type"
                         label="Type"
                         v-slot="props">
           {{ props.row.note_type_display }}
-        </b-table-column>
+        </${b}-table-column>
 
-        <b-table-column field="subject"
+        <${b}-table-column field="subject"
                         label="Subject"
                         v-slot="props">
           {{ props.row.subject }}
-        </b-table-column>
+        </${b}-table-column>
 
-        <b-table-column field="text"
+        <${b}-table-column field="text"
                         label="Text"
                         v-slot="props">
           {{ props.row.text }}
-        </b-table-column>
+        </${b}-table-column>
 
-        <b-table-column field="created"
+        <${b}-table-column field="created"
                         label="Created"
                         v-slot="props">
           <span v-html="props.row.created_display"></span>
-        </b-table-column>
+        </${b}-table-column>
 
-        <b-table-column field="created_by"
+        <${b}-table-column field="created_by"
                         label="Created By"
                         v-slot="props">
           {{ props.row.created_by_display }}
-        </b-table-column>
+        </${b}-table-column>
 
         % if request.has_any_perm('people_profile.edit_note', 'people_profile.delete_note'):
-            <b-table-column label="Actions"
+            <${b}-table-column label="Actions"
                             v-slot="props">
               % if request.has_perm('people_profile.edit_note'):
                   <a href="#" @click.prevent="editNoteInit(props.row)">
@@ -1219,14 +1312,19 @@
                     Delete
                   </a>
               % endif
-            </b-table-column>
+            </${b}-table-column>
         % endif
 
-      </b-table>
+      </${b}-table>
 
       % if request.has_any_perm('people_profile.add_note', 'people_profile.edit_note', 'people_profile.delete_note'):
-          <b-modal :active.sync="editNoteShowDialog"
-                   has-modal-card>
+          <${b}-modal has-modal-card
+                      % if request.use_oruga:
+                          v-model:active="editNoteShowDialog"
+                      % else:
+                          :active.sync="editNoteShowDialog"
+                      % endif
+                      >
 
             <div class="modal-card">
 
@@ -1285,16 +1383,20 @@
               </footer>
 
             </div>
-          </b-modal>
+          </${b}-modal>
       % endif
 
-      <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading>
+      % if request.use_oruga:
+          <o-loading v-model:active="refreshingTab" :full-page="false"></o-loading>
+      % else:
+          <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading>
+      % endif
     </div>
   </script>
 </%def>
 
 <%def name="render_notes_tab()">
-  <b-tab-item label="Notes"
+  <${b}-tab-item label="Notes"
               value="notes"
               icon-pack="fas"
               :icon="tabchecks.notes ? 'check' : null">
@@ -1302,7 +1404,7 @@
                :person="person"
                @profile-changed="profileChanged">
     </notes-tab>
-  </b-tab-item>
+  </${b}-tab-item>
 </%def>
 
 <%def name="render_user_tab_template()">
@@ -1355,13 +1457,17 @@
       <div v-if="!users.length">
         <p>{{ person.display_name }} does not have a user account.</p>
       </div>
-      <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading>
+      % if request.use_oruga:
+          <o-loading v-model:active="refreshingTab" :full-page="false"></o-loading>
+      % else:
+          <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading>
+      % endif
     </div>
   </script>
 </%def>
 
 <%def name="render_user_tab()">
-  <b-tab-item label="User"
+  <${b}-tab-item label="User"
               value="user"
               icon-pack="fas"
               :icon="tabchecks.user ? 'check' : null">
@@ -1369,7 +1475,7 @@
               :person="person"
               @profile-changed="profileChanged">
     </user-tab>
-  </b-tab-item>
+  </${b}-tab-item>
 </%def>
 
 <%def name="render_profile_tabs()">
@@ -1392,14 +1498,20 @@
 
       ${self.render_profile_info_extra_buttons()}
 
-      <b-tabs v-model="activeTab"
-              % if request.has_perm('people_profile.view_versions'):
-              v-show="!viewingHistory"
-              % endif
-              type="is-boxed"
-              @input="activeTabChanged">
+      <${b}-tabs v-model="activeTab"
+                 % if request.has_perm('people_profile.view_versions'):
+                     v-show="!viewingHistory"
+                 % endif
+                 % if request.use_oruga:
+                     type="boxed"
+                     @change="activeTabChanged"
+                 % else:
+                     type="is-boxed"
+                     @input="activeTabChanged"
+                 % endif
+                 >
         ${self.render_profile_tabs()}
-      </b-tabs>
+      </${b}-tabs>
 
       % if request.has_perm('people_profile.view_versions'):
 
@@ -1408,7 +1520,13 @@
                                                 vshow='viewingHistory',
                                                 loading='gettingRevisions')|n}
 
-          <b-modal :active.sync="showingRevisionDialog">
+          <${b}-modal
+            % if request.use_oruga:
+                v-model:active="showingRevisionDialog"
+            % else:
+                :active.sync="showingRevisionDialog"
+            % endif
+            >
 
             <div class="card">
               <div class="card-content">
@@ -1487,7 +1605,7 @@
 
               </div>
             </div>
-          </b-modal>
+          </${b}-modal>
       % endif
 
     </div>
@@ -1522,6 +1640,7 @@
             % endif
             editNameMiddle: null,
             editNameLast: null,
+            editNameSaving: false,
 
             editAddressShowDialog: false,
             editAddressStreet1: null,
@@ -1562,6 +1681,9 @@
             % if request.has_perm('people_profile.edit_person'):
 
                 editNameSaveDisabled: function() {
+                    if (this.editNameSaving) {
+                        return true
+                    }
                     if (!this.editNameFirst || !this.editNameLast) {
                         return true
                     }
@@ -1622,6 +1744,7 @@
                 },
 
                 editNameSave() {
+                    this.editNameSaving = true
                     let url = '${url('people.profile_edit_name', uuid=person.uuid)}'
                     let params = {
                         first_name: this.editNameFirst,
@@ -1636,6 +1759,9 @@
                         this.$emit('profile-changed', response.data)
                         this.editNameShowDialog = false
                         this.refreshTab()
+                        this.editNameSaving = false
+                    }, response => {
+                        this.editNameSaving = false
                     })
                 },
 
@@ -1827,6 +1953,7 @@
 
     PersonalTab.data = function() { return PersonalTabData }
     Vue.component('personal-tab', PersonalTab)
+    <% request.register_component('personal-tab', 'PersonalTab') %>
 
   </script>
 </%def>
@@ -1872,6 +1999,7 @@
 
     MemberTab.data = function() { return MemberTabData }
     Vue.component('member-tab', MemberTab)
+    <% request.register_component('member-tab', 'MemberTab') %>
 
   </script>
 </%def>
@@ -1908,6 +2036,7 @@
 
     CustomerTab.data = function() { return CustomerTabData }
     Vue.component('customer-tab', CustomerTab)
+    <% request.register_component('customer-tab', 'CustomerTab') %>
 
   </script>
 </%def>
@@ -1944,6 +2073,7 @@
 
     ShopperTab.data = function() { return ShopperTabData }
     Vue.component('shopper-tab', ShopperTab)
+    <% request.register_component('shopper-tab', 'ShopperTab') %>
 
   </script>
 </%def>
@@ -2100,6 +2230,7 @@
 
     EmployeeTab.data = function() { return EmployeeTabData }
     Vue.component('employee-tab', EmployeeTab)
+    <% request.register_component('employee-tab', 'EmployeeTab') %>
 
   </script>
 </%def>
@@ -2220,6 +2351,7 @@
 
     NotesTab.data = function() { return NotesTabData }
     Vue.component('notes-tab', NotesTab)
+    <% request.register_component('notes-tab', 'NotesTab') %>
 
   </script>
 </%def>
@@ -2256,6 +2388,7 @@
 
     UserTab.data = function() { return UserTabData }
     Vue.component('user-tab', UserTab)
+    <% request.register_component('user-tab', 'UserTab') %>
 
   </script>
 </%def>
@@ -2264,7 +2397,7 @@
   <script type="text/javascript">
 
     let ProfileInfoData = {
-        activeTab: location.hash ? location.hash.substring(1) : undefined,
+        activeTab: location.hash ? location.hash.substring(1) : 'personal',
         tabchecks: ${json.dumps(tabchecks)|n},
         today: '${rattail_app.today()}',
         profileLastChanged: Date.now(),
@@ -2360,6 +2493,7 @@
 
     ProfileInfo.data = function() { return ProfileInfoData }
     Vue.component('profile-info', ProfileInfo)
+    <% request.register_component('profile-info', 'ProfileInfo') %>
 
   </script>
 </%def>
diff --git a/tailbone/templates/themes/butterball/buefy-components.mako b/tailbone/templates/themes/butterball/buefy-components.mako
index a7b42267..b11e34ea 100644
--- a/tailbone/templates/themes/butterball/buefy-components.mako
+++ b/tailbone/templates/themes/butterball/buefy-components.mako
@@ -332,7 +332,8 @@
              v-model="orugaValue"
              @update:modelValue="val => $emit('update:modelValue', val)"
              :autocomplete="autocomplete"
-             ref="input">
+             ref="input"
+             :expanded="expanded">
       <slot />
     </o-input>
   </script>
@@ -344,6 +345,7 @@
             type: String,
             autocomplete: String,
             disabled: Boolean,
+            expanded: Boolean,
         },
         data() {
             return {
@@ -374,13 +376,16 @@
 
 <%def name="make_b_loading_component()">
   <script type="text/x-template" id="b-loading-template">
-    <o-loading>
+    <o-loading :full-page="isFullPage">
       <slot />
     </o-loading>
   </script>
   <script>
     const BLoading = {
         template: '#b-loading-template',
+        props: {
+            isFullPage: Boolean,
+        },
     }
   </script>
   <% request.register_component('b-loading', 'BLoading') %>

From 0d8928bdf57c058b0f09756c577a43276df7dabf Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 29 May 2024 22:15:39 -0500
Subject: [PATCH 294/542] Update changelog

---
 CHANGES.rst          | 12 ++++++++++++
 tailbone/_version.py |  2 +-
 2 files changed, 13 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 92d088b0..3f2b16aa 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -5,6 +5,18 @@ CHANGELOG
 Unreleased
 ----------
 
+0.10.6 (2024-05-29)
+-------------------
+
+* Add way to flag organic products within lookup dialog.
+
+* Expose db picker for butterball theme.
+
+* Expose quickie lookup for butterball theme.
+
+* Fix basic problems with people profile view, per butterball.
+
+
 0.10.5 (2024-05-29)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 07f1c1c4..711da994 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.10.5'
+__version__ = '0.10.6'

From 49cd05027299424696c79ab32f008e201f428246 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 31 May 2024 10:57:28 -0500
Subject: [PATCH 295/542] Add setting to allow decimal quantities for receiving

---
 tailbone/templates/receiving/configure.mako | 9 +++++++++
 tailbone/views/purchasing/receiving.py      | 3 +++
 2 files changed, 12 insertions(+)

diff --git a/tailbone/templates/receiving/configure.mako b/tailbone/templates/receiving/configure.mako
index 92003fee..f613e13e 100644
--- a/tailbone/templates/receiving/configure.mako
+++ b/tailbone/templates/receiving/configure.mako
@@ -115,6 +115,15 @@
       </b-checkbox>
     </b-field>
 
+    <b-field message="NB. Allow Decimal Quantities setting also affects Ordering behavior.">
+      <b-checkbox name="rattail.batch.purchase.allow_decimal_quantities"
+                  v-model="simpleSettings['rattail.batch.purchase.allow_decimal_quantities']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Allow Decimal Quantities
+      </b-checkbox>
+    </b-field>
+
     <b-field>
       <b-checkbox name="rattail.batch.purchase.allow_expired_credits"
                   v-model="simpleSettings['rattail.batch.purchase.allow_expired_credits']"
diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py
index 739fe0bd..be15c1a8 100644
--- a/tailbone/views/purchasing/receiving.py
+++ b/tailbone/views/purchasing/receiving.py
@@ -1998,6 +1998,9 @@ class ReceivingBatchView(PurchasingBatchView):
             {'section': 'rattail.batch',
              'option': 'purchase.allow_cases',
              'type': bool},
+            {'section': 'rattail.batch',
+             'option': 'purchase.allow_decimal_quantities',
+             'type': bool},
             {'section': 'rattail.batch',
              'option': 'purchase.allow_expired_credits',
              'type': bool},

From 9b88f01378f1f219f583d3b064a77cd1e7b3903e Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 31 May 2024 12:04:11 -0500
Subject: [PATCH 296/542] Log error if registry has no rattail config

not clear if this is even possible, but if so i want to know about it

trying to figure out the occasional error email we get, latest being
from collectd/curl pinging the /login page, but request.has_perm()
call fails with missing attr?!

seems like either the rattail config is empty, or else the subscriber
events aren't firing (in the correct order) ?
---
 tailbone/app.py         | 3 +--
 tailbone/subscribers.py | 4 ++++
 2 files changed, 5 insertions(+), 2 deletions(-)

diff --git a/tailbone/app.py b/tailbone/app.py
index 63610f85..0519f35b 100644
--- a/tailbone/app.py
+++ b/tailbone/app.py
@@ -132,8 +132,7 @@ def make_pyramid_config(settings, configure_csrf=True):
         settings.setdefault('pyramid_deform.template_search_path', 'tailbone:templates/deform')
         config = Configurator(settings=settings, root_factory=Root)
 
-    # add rattail config directly to registry
-    # TODO: why on earth do we do this again?
+    # add rattail config directly to registry, for access throughout the app
     config.registry['rattail_config'] = rattail_config
 
     # configure user authorization / authentication
diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py
index 5f477281..42d3cab7 100644
--- a/tailbone/subscribers.py
+++ b/tailbone/subscribers.py
@@ -80,11 +80,14 @@ def new_request(event):
 
     * A shortcut method for permission checking, as ``has_perm()``.
     """
+    log.debug("new request: %s", event)
     request = event.request
     rattail_config = request.registry.settings.get('rattail_config')
     # TODO: why would this ever be null?
     if rattail_config:
         request.rattail_config = rattail_config
+    else:
+        log.error("registry has no rattail_config ?!")
 
     def user(request):
         user = None
@@ -158,6 +161,7 @@ def before_render(event):
     """
     Adds goodies to the global template renderer context.
     """
+    log.debug("before_render: %s", event)
 
     request = event.get('request') or threadlocal.get_current_request()
     rattail_config = request.rattail_config

From 3ac131cb5171356e76de5c133ca7512a97f401d4 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 31 May 2024 14:51:01 -0500
Subject: [PATCH 297/542] Add column filters for import/export main grid

---
 tailbone/views/importing.py | 21 +++++++++++++--------
 1 file changed, 13 insertions(+), 8 deletions(-)

diff --git a/tailbone/views/importing.py b/tailbone/views/importing.py
index acfddbf8..e9167132 100644
--- a/tailbone/views/importing.py
+++ b/tailbone/views/importing.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.
 #
@@ -25,13 +25,13 @@ View for running arbitrary import/export jobs
 """
 
 import getpass
-import socket
-import sys
+import json
 import logging
+import socket
 import subprocess
+import sys
 import time
 
-import json
 import sqlalchemy as sa
 
 from rattail.exceptions import ConfigurationError
@@ -152,10 +152,15 @@ class ImportingView(MasterView):
         return data
 
     def configure_grid(self, g):
-        super(ImportingView, self).configure_grid(g)
+        super().configure_grid(g)
 
         g.set_link('host_title')
+        g.set_searchable('host_title')
+
         g.set_link('local_title')
+        g.set_searchable('local_title')
+
+        g.set_searchable('handler_spec')
 
     def get_instance(self):
         """
@@ -177,7 +182,7 @@ class ImportingView(MasterView):
         return ImportHandlerSchema()
 
     def make_form_kwargs(self, **kwargs):
-        kwargs = super(ImportingView, self).make_form_kwargs(**kwargs)
+        kwargs = super().make_form_kwargs(**kwargs)
 
         # nb. this is set as sort of a hack, to prevent SA model
         # inspection logic
@@ -186,7 +191,7 @@ class ImportingView(MasterView):
         return kwargs
 
     def configure_form(self, f):
-        super(ImportingView, self).configure_form(f)
+        super().configure_form(f)
 
         f.set_renderer('models', self.render_models)
 
@@ -198,7 +203,7 @@ class ImportingView(MasterView):
         return HTML.tag('ul', c=items)
 
     def template_kwargs_view(self, **kwargs):
-        kwargs = super(ImportingView, self).template_kwargs_view(**kwargs)
+        kwargs = super().template_kwargs_view(**kwargs)
         handler_info = kwargs['instance']
         kwargs['handler'] = handler_info['_handler']
         return kwargs

From ba519334d17b5d39a3ccbc09c41384f2bea70807 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 31 May 2024 18:01:56 -0500
Subject: [PATCH 298/542] Fix overflow when instance header title is too long

---
 tailbone/templates/themes/butterball/base.mako | 7 +++++++
 1 file changed, 7 insertions(+)

diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako
index 8a951831..ba0f64ba 100644
--- a/tailbone/templates/themes/butterball/base.mako
+++ b/tailbone/templates/themes/butterball/base.mako
@@ -186,6 +186,13 @@
         }
     % endif
 
+    #content-title h1 {
+        max-width: 50%;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+    }
+
     ## TODO: is this a good idea?
     h1.title {
         font-size: 2rem;

From b87b1a3801bdeaf76bba7ba6f0ec64cd11ae6de4 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 31 May 2024 21:20:45 -0500
Subject: [PATCH 299/542] Escape all unsafe html for grid data

---
 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 b428aaa6..91c3d1f5 100644
--- a/tailbone/grids/core.py
+++ b/tailbone/grids/core.py
@@ -1651,7 +1651,11 @@ class Grid(object):
                     value = self.obtain_value(rowobj, name)
                 if value is None:
                     value = ""
-                row[name] = str(value)
+
+                # this value will ultimately be inserted into table
+                # cell a la <td v-html="..."> so we must escape it
+                # here to be safe
+                row[name] = HTML.literal.escape(value)
 
             # maybe add UUID for convenience
             if 'uuid' not in self.columns:

From d05458c7fb48e040b046f74900b0ef24e02a5068 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sat, 1 Jun 2024 13:40:50 -0500
Subject: [PATCH 300/542] Add speedbumps for delete, set preferred email/phone
 in profile view

---
 tailbone/templates/people/view_profile.mako   | 264 +++++++++++++++---
 .../themes/butterball/buefy-components.mako   |   5 +-
 2 files changed, 231 insertions(+), 38 deletions(-)

diff --git a/tailbone/templates/people/view_profile.mako b/tailbone/templates/people/view_profile.mako
index 0ca42cef..bf94b7fa 100644
--- a/tailbone/templates/people/view_profile.mako
+++ b/tailbone/templates/people/view_profile.mako
@@ -341,23 +341,21 @@
                 </header>
 
                 <section class="modal-card-body">
-                  <b-field grouped>
 
-                    <b-field label="Type" expanded>
-                      <b-select v-model="editPhoneType" expanded>
-                        <option v-for="option in phoneTypeOptions"
-                                :key="option.value"
-                                :value="option.value">
-                          {{ option.label }}
-                        </option>
-                      </b-select>
-                    </b-field>
+                  <b-field label="Type">
+                    <b-select v-model="editPhoneType">
+                      <option v-for="option in phoneTypeOptions"
+                              :key="option.value"
+                              :value="option.value">
+                        {{ option.label }}
+                      </option>
+                    </b-select>
+                  </b-field>
 
-                    <b-field label="Number" expanded>
-                      <b-input v-model.trim="editPhoneNumber"
-                               ref="editPhoneInput">
-                      </b-input>
-                    </b-field>
+                  <b-field label="Number">
+                    <b-input v-model.trim="editPhoneNumber"
+                             ref="editPhoneInput"
+                             expanded />
                   </b-field>
 
                   <b-field label="Preferred?">
@@ -439,6 +437,72 @@
 
         </${b}-table>
 
+        <${b}-modal has-modal-card
+                    % if request.use_oruga:
+                        v-model:active="deletePhoneShowDialog"
+                    % else:
+                        :active.sync="deletePhoneShowDialog"
+                    % endif
+                    >
+          <div class="modal-card">
+
+            <header class="modal-card-head">
+              <p class="modal-card-title">Delete Phone</p>
+            </header>
+
+            <section class="modal-card-body">
+              <p class="block">Really delete this phone number?</p>
+              <p class="block has-text-weight-bold">{{ deletePhoneNumber }}</p>
+            </section>
+
+            <footer class="modal-card-foot">
+              <b-button type="is-danger"
+                        @click="deletePhoneSave()"
+                        :disabled="deletePhoneSaving"
+                        icon-pack="fas"
+                        icon-left="trash">
+                {{ deletePhoneSaving ? "Working, please wait..." : "Delete" }}
+              </b-button>
+              <b-button @click="deletePhoneShowDialog = false">
+                Cancel
+              </b-button>
+            </footer>
+          </div>
+        </${b}-modal>
+
+        <${b}-modal has-modal-card
+                    % if request.use_oruga:
+                        v-model:active="preferPhoneShowDialog"
+                    % else:
+                        :active.sync="preferPhoneShowDialog"
+                    % endif
+                    >
+          <div class="modal-card">
+
+            <header class="modal-card-head">
+              <p class="modal-card-title">Set Preferred Phone</p>
+            </header>
+
+            <section class="modal-card-body">
+              <p class="block">Really make this the preferred phone number?</p>
+              <p class="block has-text-weight-bold">{{ preferPhoneNumber }}</p>
+            </section>
+
+            <footer class="modal-card-foot">
+              <b-button type="is-primary"
+                        @click="preferPhoneSave()"
+                        :disabled="preferPhoneSaving"
+                        icon-pack="fas"
+                        icon-left="save">
+                {{ preferPhoneSaving ? "Working, please wait..." : "Set Preferred" }}
+              </b-button>
+              <b-button @click="preferPhoneShowDialog = false">
+                Cancel
+              </b-button>
+            </footer>
+          </div>
+        </${b}-modal>
+
       </div>
     </div>
   </div>
@@ -477,24 +541,21 @@
                 </header>
 
                 <section class="modal-card-body">
-                  <b-field grouped>
 
-                    <b-field label="Type" expanded>
-                      <b-select v-model="editEmailType" expanded>
-                        <option v-for="option in emailTypeOptions"
-                                :key="option.value"
-                                :value="option.value">
-                          {{ option.label }}
-                        </option>
-                      </b-select>
-                    </b-field>
-
-                    <b-field label="Address" expanded>
-                      <b-input v-model.trim="editEmailAddress"
-                               ref="editEmailInput">
-                      </b-input>
-                    </b-field>
+                  <b-field label="Type">
+                    <b-select v-model="editEmailType">
+                      <option v-for="option in emailTypeOptions"
+                              :key="option.value"
+                              :value="option.value">
+                        {{ option.label }}
+                      </option>
+                    </b-select>
+                  </b-field>
 
+                  <b-field label="Address">
+                    <b-input v-model.trim="editEmailAddress"
+                             ref="editEmailInput"
+                             expanded />
                   </b-field>
 
                   <b-field v-if="!editEmailUUID"
@@ -590,6 +651,72 @@
 
         </${b}-table>
 
+        <${b}-modal has-modal-card
+                    % if request.use_oruga:
+                        v-model:active="deleteEmailShowDialog"
+                    % else:
+                        :active.sync="deleteEmailShowDialog"
+                    % endif
+                    >
+          <div class="modal-card">
+
+            <header class="modal-card-head">
+              <p class="modal-card-title">Delete Email</p>
+            </header>
+
+            <section class="modal-card-body">
+              <p class="block">Really delete this email address?</p>
+              <p class="block has-text-weight-bold">{{ deleteEmailAddress }}</p>
+            </section>
+
+            <footer class="modal-card-foot">
+              <b-button type="is-danger"
+                        @click="deleteEmailSave()"
+                        :disabled="deleteEmailSaving"
+                        icon-pack="fas"
+                        icon-left="trash">
+                {{ deleteEmailSaving ? "Working, please wait..." : "Delete" }}
+              </b-button>
+              <b-button @click="deleteEmailShowDialog = false">
+                Cancel
+              </b-button>
+            </footer>
+          </div>
+        </${b}-modal>
+
+        <${b}-modal has-modal-card
+                    % if request.use_oruga:
+                        v-model:active="preferEmailShowDialog"
+                    % else:
+                        :active.sync="preferEmailShowDialog"
+                    % endif
+                    >
+          <div class="modal-card">
+
+            <header class="modal-card-head">
+              <p class="modal-card-title">Set Preferred Email</p>
+            </header>
+
+            <section class="modal-card-body">
+              <p class="block">Really make this the preferred email address?</p>
+              <p class="block has-text-weight-bold">{{ preferEmailAddress }}</p>
+            </section>
+
+            <footer class="modal-card-foot">
+              <b-button type="is-primary"
+                        @click="preferEmailSave()"
+                        :disabled="preferEmailSaving"
+                        icon-pack="fas"
+                        icon-left="save">
+                {{ preferEmailSaving ? "Working, please wait..." : "Set Preferred" }}
+              </b-button>
+              <b-button @click="preferEmailShowDialog = false">
+                Cancel
+              </b-button>
+            </footer>
+          </div>
+        </${b}-modal>
+
       </div>
     </div>
   </div>
@@ -1657,6 +1784,16 @@
             editPhonePreferred: false,
             editPhoneSaving: false,
 
+            deletePhoneShowDialog: false,
+            deletePhoneUUID: null,
+            deletePhoneNumber: null,
+            deletePhoneSaving: false,
+
+            preferPhoneShowDialog: false,
+            preferPhoneUUID: null,
+            preferPhoneNumber: null,
+            preferPhoneSaving: false,
+
             editEmailShowDialog: false,
             editEmailUUID: null,
             editEmailType: null,
@@ -1664,6 +1801,17 @@
             editEmailPreferred: null,
             editEmailInvalid: false,
             editEmailSaving: false,
+
+            deleteEmailShowDialog: false,
+            deleteEmailUUID: null,
+            deleteEmailAddress: null,
+            deleteEmailSaving: false,
+
+            preferEmailShowDialog: false,
+            preferEmailUUID: null,
+            preferEmailAddress: null,
+            preferEmailSaving: false,
+
         % endif
     }
 
@@ -1843,26 +1991,47 @@
                 },
 
                 deletePhoneInit(phone) {
+                    this.deletePhoneUUID = phone.uuid
+                    this.deletePhoneNumber = phone.number
+                    this.deletePhoneShowDialog = true
+                },
+
+                deletePhoneSave() {
+                    this.deletePhoneSaving = true
                     let url = '${url('people.profile_delete_phone', uuid=person.uuid)}'
                     let params = {
-                        phone_uuid: phone.uuid,
+                        phone_uuid: this.deletePhoneUUID,
                     }
-
                     this.simplePOST(url, params, response => {
                         this.$emit('profile-changed', response.data)
                         this.refreshTab()
+                        this.deletePhoneShowDialog = false
+                        this.deletePhoneSaving = false
+                    }, response => {
+                        this.deletePhoneSaving = false
                     })
                 },
 
                 preferPhoneInit(phone) {
+                    this.preferPhoneUUID = phone.uuid
+                    this.preferPhoneNumber = phone.number
+                    this.preferPhoneShowDialog = true
+                },
+
+                preferPhoneSave() {
+                    this.preferPhoneSaving = true
                     let url = '${url('people.profile_set_preferred_phone', uuid=person.uuid)}'
                     let params = {
-                        phone_uuid: phone.uuid,
+                        phone_uuid: this.preferPhoneUUID,
                     }
 
                     this.simplePOST(url, params, response => {
                         this.$emit('profile-changed', response.data)
                         this.refreshTab()
+                        this.preferPhoneShowDialog = false
+                        this.preferPhoneSaving = false
+                    }, response => {
+                        this.preferPhoneSaving = false
                     })
                 },
 
@@ -1917,26 +2086,47 @@
                 },
 
                 deleteEmailInit(email) {
+                    this.deleteEmailUUID = email.uuid
+                    this.deleteEmailAddress = email.address
+                    this.deleteEmailShowDialog = true
+                },
+
+                deleteEmailSave() {
+                    this.deleteEmailSaving = true
                     let url = '${url('people.profile_delete_email', uuid=person.uuid)}'
                     let params = {
-                        email_uuid: email.uuid,
+                        email_uuid: this.deleteEmailUUID,
                     }
-
                     this.simplePOST(url, params, response => {
                         this.$emit('profile-changed', response.data)
                         this.refreshTab()
+                        this.deleteEmailShowDialog = false
+                        this.deleteEmailSaving = false
+                    }, response => {
+                        this.deleteEmailSaving = false
                     })
                 },
 
                 preferEmailInit(email) {
+                    this.preferEmailUUID = email.uuid
+                    this.preferEmailAddress = email.address
+                    this.preferEmailShowDialog = true
+                },
+
+                preferEmailSave() {
+                    this.preferEmailSaving = true
                     let url = '${url('people.profile_set_preferred_email', uuid=person.uuid)}'
                     let params = {
-                        email_uuid: email.uuid,
+                        email_uuid: this.preferEmailUUID,
                     }
 
                     this.simplePOST(url, params, response => {
                         this.$emit('profile-changed', response.data)
                         this.refreshTab()
+                        this.preferEmailShowDialog = false
+                        this.preferEmailSaving = false
+                    }, response => {
+                        this.preferEmailSaving = false
                     })
                 },
 
diff --git a/tailbone/templates/themes/butterball/buefy-components.mako b/tailbone/templates/themes/butterball/buefy-components.mako
index b11e34ea..7553729b 100644
--- a/tailbone/templates/themes/butterball/buefy-components.mako
+++ b/tailbone/templates/themes/butterball/buefy-components.mako
@@ -365,7 +365,10 @@
                     // TODO: this does not always work right?
                     this.$refs.input.$el.querySelector('textarea').focus()
                 } else {
-                    this.$refs.input.$el.querySelector('input').focus()
+                    // TODO: pretty sure we can rely on the <o-input> focus()
+                    // here, but not sure why we weren't already doing that?
+                    //this.$refs.input.$el.querySelector('input').focus()
+                    this.$refs.input.focus()
                 }
             },
         },

From 6b1c313efd30c7b83ad2fb2384e61a1fa8719879 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sat, 1 Jun 2024 14:23:35 -0500
Subject: [PATCH 301/542] Fix file upload widget for oruga

---
 tailbone/forms/core.py                   |  4 ++--
 tailbone/forms/widgets.py                | 17 +++++++++++++++++
 tailbone/templates/deform/file_upload.pt | 24 ++++++++++++++++++++++--
 3 files changed, 41 insertions(+), 4 deletions(-)

diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py
index 7601fa26..6918a9cc 100644
--- a/tailbone/forms/core.py
+++ b/tailbone/forms/core.py
@@ -52,7 +52,7 @@ from tailbone.util import raw_datetime, get_form_data, render_markdown
 from tailbone.forms import types
 from tailbone.forms.widgets import (ReadonlyWidget, PlainDateWidget,
                                     JQueryDateWidget, JQueryTimeWidget,
-                                    MultiFileUploadWidget)
+                                    FileUploadWidget, MultiFileUploadWidget)
 from tailbone.exceptions import TailboneJSONFieldError
 
 
@@ -646,7 +646,7 @@ class Form(object):
             self.set_widget(key, dfwidget.TextAreaWidget(cols=80, rows=8))
         elif type_ == 'file':
             tmpstore = SessionFileUploadTempStore(self.request)
-            kw = {'widget': dfwidget.FileUploadWidget(tmpstore),
+            kw = {'widget': FileUploadWidget(tmpstore, request=self.request),
                   'title': self.get_label(key)}
             if 'required' in kwargs and not kwargs['required']:
                 kw['missing'] = colander.null
diff --git a/tailbone/forms/widgets.py b/tailbone/forms/widgets.py
index c0bb0b4d..2923b7ec 100644
--- a/tailbone/forms/widgets.py
+++ b/tailbone/forms/widgets.py
@@ -337,6 +337,23 @@ class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget):
         return field.renderer(template, **tmpl_values)
 
 
+class FileUploadWidget(dfwidget.FileUploadWidget):
+    """
+    Widget to handle file upload.  Must override to add ``use_oruga``
+    to field template context.
+    """
+
+    def __init__(self, *args, **kwargs):
+        self.request = kwargs.pop('request')
+        super().__init__(*args, **kwargs)
+
+    def get_template_values(self, field, cstruct, kw):
+        values = super().get_template_values(field, cstruct, kw)
+        if self.request:
+            values['use_oruga'] = self.request.use_oruga
+        return values
+
+
 class MultiFileUploadWidget(dfwidget.FileUploadWidget):
     """
     Widget to handle multiple (arbitrary number) of file uploads.
diff --git a/tailbone/templates/deform/file_upload.pt b/tailbone/templates/deform/file_upload.pt
index e165fdfa..af78eaf9 100644
--- a/tailbone/templates/deform/file_upload.pt
+++ b/tailbone/templates/deform/file_upload.pt
@@ -2,11 +2,14 @@
 <tal:block tal:define="oid oid|field.oid;
                        css_class css_class|field.widget.css_class;
                        style style|field.widget.style;
-                       field_name field_name|field.name;">
+                       field_name field_name|field.name;
+                       use_oruga use_oruga;">
 
   <div tal:define="vmodel vmodel|'field_model_' + field_name;">
     ${field.start_mapping()}
-    <b-field class="file">
+
+    <b-field class="file"
+             tal:condition="not use_oruga">
       <b-upload name="upload"
                 v-model="${vmodel}">
         <a class="button is-primary">
@@ -18,6 +21,23 @@
         {{ ${vmodel}.name }}
       </span>
     </b-field>
+
+    <o-field class="file"
+             tal:condition="use_oruga">
+      <o-upload name="upload"
+                v-slot="{ onclick }"
+                v-model="${vmodel}">
+        <o-button variant="primary"
+                  @click="onclick">
+          <o-icon icon="upload" />
+          <span>Click to upload</span>
+        </o-button>
+      </o-upload>
+      <span class="file-name" v-if="${vmodel}">
+        {{ ${vmodel}.name }}
+      </span>
+    </o-field>
+
     ${field.end_mapping()}
   </div>
 

From 43db60bbee167d43032251d856ef77259f1afa23 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sat, 1 Jun 2024 14:26:17 -0500
Subject: [PATCH 302/542] Update changelog

---
 CHANGES.rst          | 18 ++++++++++++++++++
 tailbone/_version.py |  2 +-
 2 files changed, 19 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 3f2b16aa..08b01d5e 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -5,6 +5,24 @@ CHANGELOG
 Unreleased
 ----------
 
+0.10.7 (2024-06-01)
+-------------------
+
+* Add setting to allow decimal quantities for receiving.
+
+* Log error if registry has no rattail config.
+
+* Add column filters for import/export main grid.
+
+* Fix overflow when instance header title is too long (butterball).
+
+* Escape all unsafe html for grid data.
+
+* Add speedbumps for delete, set preferred email/phone in profile view.
+
+* Fix file upload widget for oruga.
+
+
 0.10.6 (2024-05-29)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 711da994..21ae87f2 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.10.6'
+__version__ = '0.10.7'

From 77eeb63b62fb806fdb0b2c5017bc2b81a52228d8 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sat, 1 Jun 2024 17:46:35 -0500
Subject: [PATCH 303/542] Add styling for checked grid rows, per
 oruga/butterball

---
 tailbone/templates/grids/complete.mako        |  9 ++++---
 .../templates/themes/butterball/base.mako     | 25 +++++++++++++++++++
 2 files changed, 31 insertions(+), 3 deletions(-)

diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako
index a54cc127..e200cdc3 100644
--- a/tailbone/templates/grids/complete.mako
+++ b/tailbone/templates/grids/complete.mako
@@ -44,6 +44,9 @@
        :data="visibleData"
        :loading="loading"
        :row-class="getRowClass"
+       % if request.use_oruga:
+           tr-checked-class="is-checked"
+       % endif
 
        % if request.rattail_config.getbool('tailbone', 'sticky_headers'):
        sticky-header
@@ -58,9 +61,9 @@
            % else:
                :checked-rows.sync="checkedRows"
            % endif
-       % if grid.clicking_row_checks_box:
-       @click="rowClick"
-       % endif
+           % if grid.clicking_row_checks_box:
+               @click="rowClick"
+           % endif
        % endif
 
        % if grid.check_handler:
diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako
index ba0f64ba..7a27c0ed 100644
--- a/tailbone/templates/themes/butterball/base.mako
+++ b/tailbone/templates/themes/butterball/base.mako
@@ -242,6 +242,31 @@
         white-space: nowrap;
     }
 
+    /**************************************************
+     * grid rows which are "checked" (selected)
+     **************************************************/
+
+    /* TODO: this references some color values, whereas it would be preferable
+     * to refer to some sort of "state" instead, color of which was
+     * configurable.  b/c these are just the default Buefy theme colors. */
+
+    tr.is-checked {
+        background-color: #7957d5;
+        color: white;
+    }
+
+    tr.is-checked:hover {
+        color: #363636;
+    }
+
+    tr.is-checked a {
+        color: white;
+    }
+
+    tr.is-checked:hover a {
+        color: #7957d5;
+    }
+
     /* ****************************** */
     /* forms */
     /* ****************************** */

From 1bf28eb2862876f9baacd01774c33754c33cfb12 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sat, 1 Jun 2024 19:07:07 -0500
Subject: [PATCH 304/542] Fix product view template for oruga/butterball

---
 tailbone/forms/core.py                |  4 +++
 tailbone/templates/products/view.mako | 35 +++++++++++++++------------
 tailbone/views/products.py            |  9 ++++---
 3 files changed, 29 insertions(+), 19 deletions(-)

diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py
index 6918a9cc..d6303bb1 100644
--- a/tailbone/forms/core.py
+++ b/tailbone/forms/core.py
@@ -1157,6 +1157,10 @@ class Form(object):
 
             return HTML.tag('div', class_='field-wrapper {}'.format(field_name), c=contents)
 
+        # nb. for some reason we must wrap once more for oruga,
+        # otherwise it splits up the field?!
+        value = HTML.tag('span', c=[value])
+
         # oruga uses <o-field>
         return HTML.tag('o-field', label=label, c=[value], **{':horizontal': 'true'})
 
diff --git a/tailbone/templates/products/view.mako b/tailbone/templates/products/view.mako
index c4da08ba..bd4afc7f 100644
--- a/tailbone/templates/products/view.mako
+++ b/tailbone/templates/products/view.mako
@@ -4,6 +4,9 @@
 <%def name="extra_styles()">
   ${parent.extra_styles()}
   <style type="text/css">
+    nav.item-panel {
+        min-width: 600px;
+    }
     #main-product-panel {
         margin-right: 2em;
         margin-top: 1em;
@@ -22,18 +25,18 @@
 </%def>
 
 <%def name="left_column()">
-  <nav class="panel" id="pricing-panel">
+  <nav class="panel item-panel" id="pricing-panel">
     <p class="panel-heading">Pricing</p>
     <div class="panel-block">
-      <div>
+      <div style="width: 100%;">
         ${self.render_price_fields(form)}
       </div>
     </div>
   </nav>
-  <nav class="panel">
+  <nav class="panel item-panel">
     <p class="panel-heading">Flags</p>
     <div class="panel-block">
-      <div>
+      <div style="width: 100%;">
         ${self.render_flag_fields(form)}
       </div>
     </div>
@@ -54,10 +57,10 @@
 <%def name="extra_main_fields(form)"></%def>
 
 <%def name="organization_panel()">
-  <nav class="panel">
+  <nav class="panel item-panel">
     <p class="panel-heading">Organization</p>
     <div class="panel-block">
-      <div>
+      <div style="width: 100%;">
         ${self.render_organization_fields(form)}
       </div>
     </div>
@@ -93,10 +96,10 @@
 </%def>
 
 <%def name="movement_panel()">
-  <nav class="panel">
+  <nav class="panel item-panel">
     <p class="panel-heading">Movement</p>
     <div class="panel-block">
-      <div>
+      <div style="width: 100%;">
         ${self.render_movement_fields(form)}
       </div>
     </div>
@@ -112,7 +115,7 @@
 </%def>
 
 <%def name="lookup_codes_panel()">
-  <nav class="panel">
+  <nav class="panel item-panel">
     <p class="panel-heading">Additional Lookup Codes</p>
     <div class="panel-block">
       ${self.lookup_codes_grid()}
@@ -125,7 +128,7 @@
 </%def>
 
 <%def name="sources_panel()">
-  <nav class="panel">
+  <nav class="panel item-panel">
     <p class="panel-heading">
       Vendor Sources
       % if request.rattail_config.versioning_enabled() and master.has_perm('versions'):
@@ -141,7 +144,7 @@
 </%def>
 
 <%def name="notes_panel()">
-  <nav class="panel">
+  <nav class="panel item-panel">
     <p class="panel-heading">Notes</p>
     <div class="panel-block">
       <div class="field">${form.render_field_readonly('notes')}</div>
@@ -150,7 +153,7 @@
 </%def>
 
 <%def name="ingredients_panel()">
-  <nav class="panel">
+  <nav class="panel item-panel">
     <p class="panel-heading">Ingredients</p>
     <div class="panel-block">
       ${form.render_field_readonly('ingredients')}
@@ -245,13 +248,13 @@
 </%def>
 
 <%def name="page_content()">
-          <div style="display: flex; flex-direction: column;">
+  <div style="display: flex; flex-direction: column;">
 
-    <nav class="panel" id="main-product-panel">
+    <nav class="panel item-panel" id="main-product-panel">
       <p class="panel-heading">Product</p>
       <div class="panel-block">
-        <div style="display: flex; justify-content: space-between; width: 100%;">
-          <div>
+        <div style="display: flex; gap: 2rem; width: 100%;">
+          <div style="flex-grow: 1;">
             ${self.render_main_fields(form)}
           </div>
           <div>
diff --git a/tailbone/views/products.py b/tailbone/views/products.py
index 7219b6b3..28186ac3 100644
--- a/tailbone/views/products.py
+++ b/tailbone/views/products.py
@@ -445,9 +445,12 @@ class ProductView(MasterView):
         if not text:
             return history
 
-        text = HTML.tag('span', c=[text])
-        br = HTML.tag('br')
-        return HTML.tag('div', c=[text, br, history])
+        text = HTML.tag('p', c=[text])
+        history = HTML.tag('p', c=[history])
+        div = HTML.tag('div', c=[text, history])
+        # nb. for some reason we must wrap once more for oruga,
+        # otherwise it splits up the field?!
+        return HTML.tag('div', c=[div])
 
     def show_price_effective_dates(self):
         if not self.rattail_config.versioning_enabled():

From 9258237b85d418137a0013cd27d999b041334130 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sat, 1 Jun 2024 21:37:38 -0500
Subject: [PATCH 305/542] Allow per-user custom styles for butterball

---
 tailbone/templates/themes/butterball/base.mako | 16 +++++-----------
 tailbone/views/users.py                        |  7 +++++--
 2 files changed, 10 insertions(+), 13 deletions(-)

diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako
index 7a27c0ed..420f23d9 100644
--- a/tailbone/templates/themes/butterball/base.mako
+++ b/tailbone/templates/themes/butterball/base.mako
@@ -89,17 +89,11 @@
 </%def>
 
 <%def name="core_styles()">
-
-  ## ## TODO: eventually, allow custom css per-user
-  ##   % if user_css:
-  ##       ${h.stylesheet_link(user_css)}
-  ##   % else:
-  ##       ${h.stylesheet_link(h.get_liburl(request, 'bulma.css'))}
-  ##   % endif
-
-  ## TODO: eventually version / url should be configurable
-  ${h.stylesheet_link(h.get_liburl(request, 'bb_oruga_bulma_css'))}
-
+  % if user_css:
+      ${h.stylesheet_link(user_css)}
+  % else:
+      ${h.stylesheet_link(h.get_liburl(request, 'bb_oruga_bulma_css'))}
+  % endif
 </%def>
 
 <%def name="head_tags()">
diff --git a/tailbone/views/users.py b/tailbone/views/users.py
index 9cc1b5b5..0f844bfb 100644
--- a/tailbone/views/users.py
+++ b/tailbone/views/users.py
@@ -601,8 +601,11 @@ class UserView(PrincipalMasterView):
         styles = self.rattail_config.getlist('tailbone', 'themes.styles',
                                              default=[])
         for name in styles:
-            css = self.rattail_config.get('tailbone',
-                                          'themes.style.{}'.format(name))
+            css = None
+            if self.request.use_oruga:
+                css = self.rattail_config.get(f'tailbone.themes.bb_style.{name}')
+            if not css:
+                css = self.rattail_config.get(f'tailbone.themes.style.{name}')
             if css:
                 options.append({'value': css, 'label': name})
         context['theme_style_options'] = options

From 40edde26942fb5f5a0e0759af0af37e37c68ac3d Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sat, 1 Jun 2024 23:06:19 -0500
Subject: [PATCH 306/542] Use oruga 0.8.9 by default

---
 tailbone/util.py | 5 +----
 1 file changed, 1 insertion(+), 4 deletions(-)

diff --git a/tailbone/util.py b/tailbone/util.py
index bb18f22d..7d838541 100644
--- a/tailbone/util.py
+++ b/tailbone/util.py
@@ -165,10 +165,7 @@ def get_libver(request, key, fallback=True, default_only=False):
         return '3.3.11'
 
     elif key == 'bb_oruga':
-        # TODO: as of writing, 0.8.8 is the latest release, but it has
-        # a bug which makes <o-field horizontal> basically not work
-        # cf. https://github.com/oruga-ui/oruga/issues/913
-        return '0.8.7'
+        return '0.8.9'
 
     elif key in ('bb_oruga_bulma', 'bb_oruga_bulma_css'):
         return '0.3.0'

From 254df6d6f25192cf7e9e4dd3619995751774c6c4 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 2 Jun 2024 14:55:18 -0500
Subject: [PATCH 307/542] Update changelog

---
 CHANGES.rst          | 12 ++++++++++++
 tailbone/_version.py |  2 +-
 2 files changed, 13 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 08b01d5e..762ca455 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -5,6 +5,18 @@ CHANGELOG
 Unreleased
 ----------
 
+0.10.8 (2024-06-02)
+-------------------
+
+* Add styling for checked grid rows, per oruga/butterball.
+
+* Fix product view template for oruga/butterball.
+
+* Allow per-user custom styles for butterball.
+
+* Use oruga 0.8.9 by default.
+
+
 0.10.7 (2024-06-01)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 21ae87f2..e6aa0601 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.10.7'
+__version__ = '0.10.8'

From fa25857680eb8d19fb7be260ed7eea881ed12446 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 2 Jun 2024 15:54:42 -0500
Subject: [PATCH 308/542] Let master view control context menu items for page

that really does not belong in the template if we can help it.  some
templates still define context menu items but can hopefully phase
those out over time
---
 .../templates/datasync/changes/index.mako     |  7 ----
 tailbone/templates/datasync/status.mako       |  7 ----
 tailbone/templates/master/index.mako          | 14 -------
 tailbone/templates/master/view.mako           |  6 ---
 tailbone/templates/page.mako                  |  8 +++-
 .../trainwreck/transactions/index.mako        | 12 ------
 tailbone/templates/users/view.mako            |  7 ----
 tailbone/views/datasync.py                    | 23 +++++++++++
 tailbone/views/master.py                      | 39 +++++++++++++++++++
 tailbone/views/trainwreck/base.py             | 12 ++++++
 tailbone/views/users.py                       | 11 ++++++
 11 files changed, 92 insertions(+), 54 deletions(-)
 delete mode 100644 tailbone/templates/trainwreck/transactions/index.mako

diff --git a/tailbone/templates/datasync/changes/index.mako b/tailbone/templates/datasync/changes/index.mako
index b5aeb79a..6d171619 100644
--- a/tailbone/templates/datasync/changes/index.mako
+++ b/tailbone/templates/datasync/changes/index.mako
@@ -1,13 +1,6 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/master/index.mako" />
 
-<%def name="context_menu_items()">
-  ${parent.context_menu_items()}
-  % if request.has_perm('datasync.status'):
-      <li>${h.link_to("View DataSync Status", url('datasync.status'))}</li>
-  % endif
-</%def>
-
 <%def name="grid_tools()">
   ${parent.grid_tools()}
 
diff --git a/tailbone/templates/datasync/status.mako b/tailbone/templates/datasync/status.mako
index 43d05f51..c782dec6 100644
--- a/tailbone/templates/datasync/status.mako
+++ b/tailbone/templates/datasync/status.mako
@@ -5,13 +5,6 @@
 
 <%def name="content_title()"></%def>
 
-<%def name="context_menu_items()">
-  ${parent.context_menu_items()}
-  % if request.has_perm('datasync_changes.list'):
-      <li>${h.link_to("View DataSync Changes", url('datasyncchanges'))}</li>
-  % endif
-</%def>
-
 <%def name="page_content()">
   % if expose_websockets and not supervisor_error:
       <b-notification type="is-warning"
diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako
index a619d84c..33592559 100644
--- a/tailbone/templates/master/index.mako
+++ b/tailbone/templates/master/index.mako
@@ -12,20 +12,6 @@
 
 <%def name="content_title()"></%def>
 
-<%def name="context_menu_items()">
-  % if master.results_downloadable_csv and request.has_perm('{}.results_csv'.format(permission_prefix)):
-      <li>${h.link_to("Download results as CSV", url('{}.results_csv'.format(route_prefix)))}</li>
-  % endif
-  % if master.results_downloadable_xlsx and request.has_perm('{}.results_xlsx'.format(permission_prefix)):
-      <li>${h.link_to("Download results as XLSX", url('{}.results_xlsx'.format(route_prefix)))}</li>
-  % endif
-  % if master.has_input_file_templates and master.has_perm('create'):
-      % for template in input_file_templates.values():
-          <li>${h.link_to("Download {} Template".format(template['label']), template['effective_url'])}</li>
-      % endfor
-  % endif
-</%def>
-
 <%def name="grid_tools()">
 
   ## grid totals
diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako
index ac0577e0..fe44caa9 100644
--- a/tailbone/templates/master/view.mako
+++ b/tailbone/templates/master/view.mako
@@ -51,12 +51,6 @@
   % endif
 </%def>
 
-<%def name="context_menu_items()">
-  ## TODO: either make this configurable, or just lose it.
-  ## nobody seems to ever find it useful in practice.
-  ## <li>${h.link_to("Permalink for this {}".format(model_title), action_url('view', instance))}</li>
-</%def>
-
 <%def name="render_row_grid_tools()">
   ${rows_grid_tools}
   % if master.rows_downloadable_xlsx and master.has_perm('row_results_xlsx'):
diff --git a/tailbone/templates/page.mako b/tailbone/templates/page.mako
index 460cc6d6..17d87c9a 100644
--- a/tailbone/templates/page.mako
+++ b/tailbone/templates/page.mako
@@ -1,7 +1,13 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/base.mako" />
 
-<%def name="context_menu_items()"></%def>
+<%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="page_content()"></%def>
 
diff --git a/tailbone/templates/trainwreck/transactions/index.mako b/tailbone/templates/trainwreck/transactions/index.mako
deleted file mode 100644
index 31d956fc..00000000
--- a/tailbone/templates/trainwreck/transactions/index.mako
+++ /dev/null
@@ -1,12 +0,0 @@
-## -*- coding: utf-8; -*-
-<%inherit file="/master/index.mako" />
-
-<%def name="context_menu_items()">
-  ${parent.context_menu_items()}
-  % if master.has_perm('rollover'):
-      <li>${h.link_to("Yearly Rollover", url('{}.rollover'.format(route_prefix)))}</li>
-  % endif
-</%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/users/view.mako b/tailbone/templates/users/view.mako
index 94931e52..ed2b5f16 100644
--- a/tailbone/templates/users/view.mako
+++ b/tailbone/templates/users/view.mako
@@ -14,13 +14,6 @@
   % endif
 </%def>
 
-<%def name="context_menu_items()">
-  ${parent.context_menu_items()}
-  % if master.has_perm('preferences'):
-      <li>${h.link_to("Edit User Preferences", action_url('preferences', instance))}</li>
-  % endif
-</%def>
-
 <%def name="render_this_page()">
   ${parent.render_this_page()}
 
diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py
index b734325f..7616d288 100644
--- a/tailbone/views/datasync.py
+++ b/tailbone/views/datasync.py
@@ -34,6 +34,8 @@ from rattail.db.model import DataSyncChange
 from rattail.datasync.util import purge_datasync_settings
 from rattail.util import simple_error
 
+from webhelpers2.html import tags
+
 from tailbone.views import MasterView
 from tailbone.util import raw_datetime
 from tailbone.config import should_expose_websockets
@@ -75,6 +77,16 @@ class DataSyncThreadView(MasterView):
         app = self.get_rattail_app()
         self.datasync_handler = app.get_datasync_handler()
 
+    def get_context_menu_items(self, thread=None):
+        items = super().get_context_menu_items(thread)
+
+        # nb. just one view here, no need to check if listing etc.
+        if self.request.has_perm('datasync_changes.list'):
+            url = self.request.route_url('datasyncchanges')
+            items.append(tags.link_to("View DataSync Changes", url))
+
+        return items
+
     def status(self):
         """
         View to list/filter/sort the model data.
@@ -389,6 +401,17 @@ class DataSyncChangeView(MasterView):
         'consumer',
     ]
 
+    def get_context_menu_items(self, change=None):
+        items = super().get_context_menu_items(change)
+
+        if self.listing:
+
+            if self.request.has_perm('datasync.status'):
+                url = self.request.route_url('datasync.status')
+                items.append(tags.link_to("View DataSync Status", url))
+
+        return items
+
     def configure_grid(self, g):
         super().configure_grid(g)
 
diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index cc6e25ea..48bc32fe 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -2807,6 +2807,13 @@ class MasterView(View):
                     kwargs['db_picker_options'] = [tags.Option(k, value=k) for k in engines]
                     kwargs['db_picker_selected'] = selected
 
+        # context menu
+        obj = kwargs.get('instance')
+        items = self.get_context_menu_items(obj)
+        for supp in self.iter_view_supplements():
+            items.extend(supp.get_context_menu_items(obj) or [])
+        kwargs['context_menu_list_items'] = items
+
         # add info for downloadable input file templates, if any
         if self.has_input_file_templates:
             templates = self.normalize_input_file_templates()
@@ -2914,6 +2921,35 @@ class MasterView(View):
         kwargs['xref_links'] = self.get_xref_links(obj)
         return kwargs
 
+    def get_context_menu_items(self, obj=None):
+        items = []
+        route_prefix = self.get_route_prefix()
+
+        if self.listing:
+
+            if self.results_downloadable_csv and self.has_perm('results_csv'):
+                url = self.request.route_url(f'{route_prefix}.results_csv')
+                items.append(tags.link_to("Download results as CSV", url))
+
+            if self.results_downloadable_xlsx and self.has_perm('results_xlsx'):
+                url = self.request.route_url(f'{route_prefix}.results_xlsx')
+                items.append(tags.link_to("Download results as XLSX", url))
+
+            if self.has_input_file_templates and self.has_perm('create'):
+                templates = self.normalize_input_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.
+        #     # # nobody seems to ever find it useful in practice.
+        #     # url = self.get_action_url('view', instance)
+        #     # items.append(tags.link_to(f"Permalink for this {model_title}", url))
+
+        return items
+
     def get_xref_buttons(self, obj):
         buttons = []
         for supp in self.iter_view_supplements():
@@ -5914,6 +5950,9 @@ class ViewSupplement(object):
     def get_xref_links(self, obj):
         return []
 
+    def get_context_menu_items(self, obj):
+        return []
+
     def get_version_child_classes(self):
         """
         Return a list of additional "version child classes" which are
diff --git a/tailbone/views/trainwreck/base.py b/tailbone/views/trainwreck/base.py
index 1e273c87..59a42301 100644
--- a/tailbone/views/trainwreck/base.py
+++ b/tailbone/views/trainwreck/base.py
@@ -164,6 +164,18 @@ class TransactionView(MasterView):
 
         return TrainwreckSession()
 
+    def get_context_menu_items(self, txn=None):
+        items = super().get_context_menu_items(txn)
+        route_prefix = self.get_route_prefix()
+
+        if self.listing:
+
+            if self.has_perm('rollover'):
+                url = self.request.route_url(f'{route_prefix}.rollover')
+                items.append(tags.link_to("Yearly Rollover", url))
+
+        return items
+
     def configure_grid(self, g):
         super().configure_grid(g)
         app = self.get_rattail_app()
diff --git a/tailbone/views/users.py b/tailbone/views/users.py
index 0f844bfb..dd3f7f7b 100644
--- a/tailbone/views/users.py
+++ b/tailbone/views/users.py
@@ -92,6 +92,17 @@ class UserView(PrincipalMasterView):
         self.auth_handler = app.get_auth_handler()
         self.merge_handler = self.auth_handler
 
+    def get_context_menu_items(self, user=None):
+        items = super().get_context_menu_items(user)
+
+        if self.viewing:
+
+            if self.has_perm('preferences'):
+                url = self.get_action_url('preferences', user)
+                items.append(tags.link_to("Edit User Preferences", url))
+
+        return items
+
     def query(self, session):
         query = super().query(session)
         model = self.model

From 3dc8deef670085d829c67a8d86610a42a4c8b18b Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 2 Jun 2024 15:58:10 -0500
Subject: [PATCH 309/542] Fix panel style for PO vs. Invoice breakdown in
 receiving batch

---
 tailbone/templates/receiving/view.mako | 12 +++++++-----
 1 file changed, 7 insertions(+), 5 deletions(-)

diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako
index 80c45103..5f103d7f 100644
--- a/tailbone/templates/receiving/view.mako
+++ b/tailbone/templates/receiving/view.mako
@@ -27,12 +27,14 @@
 
 <%def name="render_po_vs_invoice_helper()">
   % if master.handler.has_purchase_order(batch) and master.handler.has_invoice_file(batch):
-      <div class="object-helper">
-        <h3>PO vs. Invoice</h3>
-        <div class="object-helper-content">
-          ${po_vs_invoice_breakdown_grid}
+      <nav class="panel">
+        <p class="panel-heading">PO vs. Invoice</p>
+        <div class="panel-block">
+          <div style="width: 100%;">
+            ${po_vs_invoice_breakdown_grid}
+          </div>
         </div>
-      </div>
+      </nav>
   % endif
 </%def>
 

From 58f95882619b3ba2912666c1d402f4d3d325c32a Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 2 Jun 2024 19:56:42 -0500
Subject: [PATCH 310/542] Fix the "new custorder" page for butterball

reasonably confident this one is complete, and didn't break buefy theme..
---
 tailbone/templates/custorders/create.mako     | 764 +++++++++++-------
 tailbone/templates/products/lookup.mako       |  97 ++-
 .../themes/butterball/buefy-components.mako   |  36 +
 .../themes/butterball/field-components.mako   |   9 +-
 4 files changed, 559 insertions(+), 347 deletions(-)

diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako
index 6b07571e..9a3a2d57 100644
--- a/tailbone/templates/custorders/create.mako
+++ b/tailbone/templates/custorders/create.mako
@@ -56,12 +56,19 @@
 
       ${self.order_form_buttons()}
 
-      <b-collapse class="panel" :class="customerPanelType"
-                  :open.sync="customerPanelOpen">
+      <${b}-collapse class="panel"
+                     :class="customerPanelType"
+                     % if request.use_oruga:
+                         v-model:open="customerPanelOpen"
+                     % else:
+                         :open.sync="customerPanelOpen"
+                     % endif
+                     >
 
         <template #trigger="props">
           <div class="panel-heading"
-               role="button">
+               role="button"
+               style="cursor: pointer;">
 
             ## TODO: for some reason buefy will "reuse" the icon
             ## element in such a way that its display does not
@@ -89,11 +96,33 @@
 
             <div style="display: flex; flex-direction: row;">
               <div style="flex-grow: 1; margin-right: 1rem;">
-                <b-notification :type="customerStatusType"
-                                position="is-bottom-right"
-                                :closable="false">
-                  {{ customerStatusText }}
-                </b-notification>
+                % if request.use_oruga:
+                    ## TODO: for some reason o-notification variant is not
+                    ## being updated properly, so for now the workaround is
+                    ## to maintain a separate component for each variant
+                    ## i tried to reproduce the problem in a simple page
+                    ## but was unable; this is really a hack but it works..
+                    <o-notification v-if="customerStatusType == null"
+                                    :closable="false">
+                      {{ customerStatusText }}
+                    </o-notification>
+                    <o-notification v-if="customerStatusType == 'is-warning'"
+                                    variant="warning"
+                                    :closable="false">
+                      {{ customerStatusText }}
+                    </o-notification>
+                    <o-notification v-if="customerStatusType == 'is-danger'"
+                                    variant="danger"
+                                    :closable="false">
+                      {{ customerStatusText }}
+                    </o-notification>
+                % else:
+                    <b-notification :type="customerStatusType"
+                                    position="is-bottom-right"
+                                    :closable="false">
+                      {{ customerStatusText }}
+                    </b-notification>
+                % endif
               </div>
               <!-- <div class="buttons"> -->
               <!--   <b-button @click="startOverCustomer()" -->
@@ -117,23 +146,28 @@
 
               <div :style="{'flex-grow': contactNotes.length ? 0 : 1}">
 
-                <b-field label="Customer" grouped>
-                  <b-field style="margin-left: 1rem;"
-                           :expanded="!contactUUID">
+                <b-field label="Customer">
+                  <div style="display: flex; gap: 1rem; width: 100%;">
                     <tailbone-autocomplete ref="contactAutocomplete"
                                            v-model="contactUUID"
+                                           :style="{'flex-grow': contactUUID ? '0' : '1'}"
+                                           expanded
                                            placeholder="Enter name or phone number"
-                                           :initial-label="contactDisplay"
                                            % if new_order_requires_customer:
                                            serviceUrl="${url('{}.customer_autocomplete'.format(route_prefix))}"
                                            % else:
                                            serviceUrl="${url('{}.person_autocomplete'.format(route_prefix))}"
                                            % endif
-                                           @input="contactChanged">
+                                           % if request.use_oruga:
+                                               :assigned-label="contactDisplay"
+                                               @update:model-value="contactChanged"
+                                           % else:
+                                               :initial-label="contactDisplay"
+                                               @input="contactChanged"
+                                           % endif
+                                           >
                     </tailbone-autocomplete>
-                  </b-field>
-                  <div v-if="contactUUID">
-                    <b-button v-if="contactProfileURL"
+                    <b-button v-if="contactUUID && contactProfileURL"
                               type="is-primary"
                               tag="a" target="_blank"
                               :href="contactProfileURL"
@@ -141,8 +175,8 @@
                               icon-left="external-link-alt">
                       View Profile
                     </b-button>
-                    &nbsp;
-                    <b-button @click="refreshContact"
+                    <b-button v-if="contactUUID"
+                              @click="refreshContact"
                               icon-pack="fas"
                               icon-left="redo"
                               :disabled="refreshingContact">
@@ -186,8 +220,13 @@
                                 Edit
                               </b-button>
 
-                              <b-modal has-modal-card
-                                       :active.sync="editPhoneNumberShowDialog">
+                              <${b}-modal has-modal-card
+                                          % if request.use_oruga:
+                                              v-model:active="editPhoneNumberShowDialog"
+                                          % else:
+                                              :active.sync="editPhoneNumberShowDialog"
+                                          % endif
+                                          >
                                 <div class="modal-card">
 
                                   <header class="modal-card-head">
@@ -241,7 +280,7 @@
                                     </b-button>
                                   </footer>
                                 </div>
-                              </b-modal>
+                              </${b}-modal>
 
                             </div>
                         % endif
@@ -279,8 +318,13 @@
                                         icon-left="edit">
                                 Edit
                               </b-button>
-                              <b-modal has-modal-card
-                                       :active.sync="editEmailAddressShowDialog">
+                              <${b}-modal has-modal-card
+                                          % if request.use_oruga:
+                                              v-model:active.sync="editEmailAddressShowDialog"
+                                          % else:
+                                              :active.sync="editEmailAddressShowDialog"
+                                          % endif
+                                          >
                                 <div class="modal-card">
 
                                   <header class="modal-card-head">
@@ -334,7 +378,7 @@
                                     </b-button>
                                   </footer>
                                 </div>
-                              </b-modal>
+                              </${b}-modal>
                             </div>
                         % endif
                       </div>
@@ -409,8 +453,13 @@
                 </b-notification>
               </div>
 
-              <b-modal has-modal-card
-                       :active.sync="editNewCustomerShowDialog">
+              <${b}-modal has-modal-card
+                          % if request.use_oruga:
+                              v-model:active="editNewCustomerShowDialog"
+                          % else:
+                              :active.sync="editNewCustomerShowDialog"
+                          % endif
+                          >
                 <div class="modal-card">
 
                   <header class="modal-card-head">
@@ -452,20 +501,21 @@
                     </b-button>
                   </footer>
                 </div>
-              </b-modal>
+              </${b}-modal>
 
             </div>
 
           </div>
         </div> <!-- panel-block -->
-      </b-collapse>
+      </${b}-collapse>
 
-      <b-collapse class="panel"
+      <${b}-collapse class="panel"
                   open>
 
         <template #trigger="props">
           <div class="panel-heading"
-               role="button">
+               role="button"
+               style="cursor: pointer;">
 
             ## TODO: for some reason buefy will "reuse" the icon
             ## element in such a way that its display does not
@@ -507,15 +557,28 @@
               % endif
             </div>
 
-            <b-modal :active.sync="showingItemDialog">
+            <${b}-modal
+              % if request.use_oruga:
+                  v-model:active="showingItemDialog"
+              % else:
+                  :active.sync="showingItemDialog"
+              % endif
+              >
               <div class="card">
                 <div class="card-content">
 
-                  <b-tabs type="is-boxed is-toggle"
-                          v-model="itemDialogTabIndex"
-                          :animated="false">
+                  <${b}-tabs :animated="false"
+                             % if request.use_oruga:
+                                 v-model="itemDialogTab"
+                                 type="toggle"
+                             % else:
+                                 v-model="itemDialogTabIndex"
+                                 type="is-boxed is-toggle"
+                             % endif
+                             >
 
-                    <b-tab-item label="Product">
+                    <${b}-tab-item label="Product"
+                                   value="product">
 
                       <div class="field">
                         <b-radio v-model="productIsKnown"
@@ -525,84 +588,82 @@
                       </div>
 
                       <div v-show="productIsKnown"
-                           style="padding-left: 5rem;">
-
-                        <b-field grouped>
-                          <p class="label control">
-                            Product
-                          </p>
-                          <tailbone-product-lookup ref="productLookup"
-                                                   :product="selectedProduct"
-                                                   @selected="productLookupSelected"
-                                                   autocomplete-url="${url(f'{route_prefix}.product_autocomplete')}">
-                          </tailbone-product-lookup>
-                        </b-field>
-
-                        <div v-if="productUUID">
-
-                          <div class="is-pulled-right has-text-centered">
-                            <img :src="productImageURL"
-                                 style="max-height: 150px; max-width: 150px; "/>
-                          </div>
-
-                          <b-field grouped>
-                            <b-field :label="productKeyLabel">
-                              <span>{{ productKey }}</span>
-                            </b-field>
-
-                            <b-field label="Unit Size">
-                              <span>{{ productSize || '' }}</span>
-                            </b-field>
-
-                            <b-field label="Case Size">
-                              <span>{{ productCaseQuantity }}</span>
-                            </b-field>
-
-                            <b-field label="Reg. Price"
-                                     v-if="productSalePriceDisplay">
-                              <span>{{ productUnitRegularPriceDisplay }}</span>
-                            </b-field>
-
-                            <b-field label="Unit Price"
-                                     v-if="!productSalePriceDisplay">
-                              <span
-                                % if product_price_may_be_questionable:
-                                :class="productPriceNeedsConfirmation ? 'has-background-warning' : ''"
-                                % endif
-                                >
-                                {{ productUnitPriceDisplay }}
-                              </span>
-                            </b-field>
-                            <!-- <b-field label="Last Changed"> -->
-                            <!--   <span>2021-01-01</span> -->
-                            <!-- </b-field> -->
-
-                            <b-field label="Sale Price"
-                                     v-if="productSalePriceDisplay">
-                              <span class="has-background-warning">
-                                {{ productSalePriceDisplay }}
-                              </span>
-                            </b-field>
-
-                            <b-field label="Sale Ends"
-                                     v-if="productSaleEndsDisplay">
-                              <span class="has-background-warning">
-                                {{ productSaleEndsDisplay }}
-                              </span>
-                            </b-field>
+                           style="padding-left: 3rem; display: flex; gap: 1rem;">
 
+                        <div style="flex-grow: 1;">
+                          <b-field label="Product">
+                            <tailbone-product-lookup ref="productLookup"
+                                                     :product="selectedProduct"
+                                                     @selected="productLookupSelected"
+                                                     autocomplete-url="${url(f'{route_prefix}.product_autocomplete')}">
+                            </tailbone-product-lookup>
                           </b-field>
 
-                          % if product_price_may_be_questionable:
-                          <b-checkbox v-model="productPriceNeedsConfirmation"
-                                      type="is-warning"
-                                      size="is-small">
-                            This price is questionable and should be confirmed
-                            by someone before order proceeds.
-                          </b-checkbox>
-                          % endif
+                          <div v-if="productUUID">
+
+                            <b-field grouped>
+                              <b-field :label="productKeyLabel">
+                                <span>{{ productKey }}</span>
+                              </b-field>
+
+                              <b-field label="Unit Size">
+                                <span>{{ productSize || '' }}</span>
+                              </b-field>
+
+                              <b-field label="Case Size">
+                                <span>{{ productCaseQuantity }}</span>
+                              </b-field>
+
+                              <b-field label="Reg. Price"
+                                       v-if="productSalePriceDisplay">
+                                <span>{{ productUnitRegularPriceDisplay }}</span>
+                              </b-field>
+
+                              <b-field label="Unit Price"
+                                       v-if="!productSalePriceDisplay">
+                                <span
+                                  % if product_price_may_be_questionable:
+                                  :class="productPriceNeedsConfirmation ? 'has-background-warning' : ''"
+                                  % endif
+                                  >
+                                  {{ productUnitPriceDisplay }}
+                                </span>
+                              </b-field>
+                              <!-- <b-field label="Last Changed"> -->
+                              <!--   <span>2021-01-01</span> -->
+                              <!-- </b-field> -->
+
+                              <b-field label="Sale Price"
+                                       v-if="productSalePriceDisplay">
+                                <span class="has-background-warning">
+                                  {{ productSalePriceDisplay }}
+                                </span>
+                              </b-field>
+
+                              <b-field label="Sale Ends"
+                                       v-if="productSaleEndsDisplay">
+                                <span class="has-background-warning">
+                                  {{ productSaleEndsDisplay }}
+                                </span>
+                              </b-field>
+
+                            </b-field>
+
+                            % if product_price_may_be_questionable:
+                                <b-checkbox v-model="productPriceNeedsConfirmation"
+                                            type="is-warning"
+                                            size="is-small">
+                                  This price is questionable and should be confirmed
+                                  by someone before order proceeds.
+                                </b-checkbox>
+                            % endif
+                          </div>
                         </div>
 
+                        <img v-if="productUUID"
+                             :src="productImageURL"
+                             style="max-height: 150px; max-width: 150px; "/>
+
                       </div>
 
                       <br />
@@ -744,132 +805,148 @@
 
                         <b-field label="Notes">
                           <b-input v-model="pendingProduct.notes"
-                                   type="textarea">
-                          </b-input>
+                                   type="textarea"
+                                   expanded />
                         </b-field>
 
                       </div>
-                    </b-tab-item>
-                    <b-tab-item label="Quantity">
+                    </${b}-tab-item>
+                    <${b}-tab-item label="Quantity"
+                                   value="quantity">
 
-                      <div class="is-pulled-right has-text-centered">
-                        <img :src="productImageURL"
-                             style="max-height: 150px; max-width: 150px; "/>
-                      </div>
+                      <div style="display: flex; gap: 1rem; white-space: nowrap;">
 
-                      <b-field grouped>
-                        <b-field label="Product" horizontal>
-                          <span :class="productIsKnown ? null : 'has-text-success'">
-                            {{ productIsKnown ? productDisplay : (pendingProduct.brand_name || '') + ' ' + (pendingProduct.description || '') + ' ' + (pendingProduct.size || '') }}
-                          </span>
-                        </b-field>
-                      </b-field>
-
-                      <b-field grouped>
-
-                        <b-field label="Unit Size">
-                          <span :class="productIsKnown ? null : 'has-text-success'">
-                            {{ productIsKnown ? productSize : pendingProduct.size }}
-                          </span>
-                        </b-field>
-
-                        <b-field label="Reg. Price"
-                                 v-if="productSalePriceDisplay">
-                          <span>
-                            {{ productUnitRegularPriceDisplay }}
-                          </span>
-                        </b-field>
-
-                        <b-field label="Unit Price"
-                                 v-if="!productSalePriceDisplay">
-                          <span :class="productIsKnown ? null : 'has-text-success'"
-                                % if product_price_may_be_questionable:
-                                    :class="productPriceNeedsConfirmation ? 'has-background-warning' : ''"
-                                % endif
-                                >
-                            {{ productIsKnown ? productUnitPriceDisplay : (pendingProduct.regular_price_amount ? '$' + pendingProduct.regular_price_amount : '') }}
-                          </span>
-                        </b-field>
-
-                        <b-field label="Sale Price"
-                                 v-if="productSalePriceDisplay">
-                          <span class="has-background-warning"
-                                :class="productIsKnown ? null : 'has-text-success'">
-                            {{ productSalePriceDisplay }}
-                          </span>
-                        </b-field>
-
-                        <b-field label="Sale Ends"
-                                 v-if="productSaleEndsDisplay">
-                          <span class="has-background-warning"
-                                :class="productIsKnown ? null : 'has-text-success'">
-                            {{ productSaleEndsDisplay }}
-                          </span>
-                        </b-field>
-
-                        <b-field label="Case Size">
-                          <span :class="productIsKnown ? null : 'has-text-success'">
-                            {{ productIsKnown ? productCaseQuantity : pendingProduct.case_size }}
-                          </span>
-                        </b-field>
-
-                        <b-field label="Case Price">
-                          <span
-                                % if product_price_may_be_questionable:
-                                    :class="{'has-text-success': !productIsKnown, 'has-background-warning': productPriceNeedsConfirmation || productSalePriceDisplay}"
-                                % else:
-                                    :class="{'has-text-success': !productIsKnown, 'has-background-warning': !!productSalePriceDisplay}"
-                                % endif
-                                >
-                            {{ getCasePriceDisplay() }}
-                          </span>
-                        </b-field>
-
-                      </b-field>
-
-                      <b-field grouped>
-
-                        <b-field label="Quantity" horizontal>
-                          <numeric-input v-model="productQuantity"
-                                         style="width: 5rem;">
-                          </numeric-input>
-                        </b-field>
-
-                        <b-select v-model="productUOM">
-                          <option v-for="choice in productUnitChoices"
-                                  :key="choice.key"
-                                  :value="choice.key"
-                                  v-html="choice.value">
-                          </option>
-                        </b-select>
-
-                      </b-field>
-
-                      <b-field grouped>
-                        % if allow_item_discounts:
-                            <b-field label="Discount" horizontal>
-                              <div class="level">
-                                <div class="level-item">
-                                  <numeric-input v-model="productDiscountPercent"
-                                                 style="width: 5rem;"
-                                                 :disabled="!allowItemDiscount">
-                                  </numeric-input>
-                                </div>
-                                <div class="level-item">
-                                  <span>&nbsp;%</span>
-                                </div>
-                              </div>
+                        <div style="flex-grow: 1;">
+                          <b-field grouped>
+                            <b-field label="Product" horizontal>
+                              <span :class="productIsKnown ? null : 'has-text-success'"
+                                    ## nb. hack to force refresh for vue3
+                                    :key="refreshProductDescription">
+                                {{ productIsKnown ? productDisplay : (pendingProduct.brand_name || '') + ' ' + (pendingProduct.description || '') + ' ' + (pendingProduct.size || '') }}
+                              </span>
                             </b-field>
-                        % endif
-                        <b-field label="Total Price" horizontal expanded>
-                          <span :class="productSalePriceDisplay ? 'has-background-warning': null">
-                            {{ getItemTotalPriceDisplay() }}
-                          </span>
-                        </b-field>
-                      </b-field>
+                          </b-field>
 
-                    </b-tab-item>
-                  </b-tabs>
+                          <b-field grouped>
+
+                            <b-field label="Unit Size">
+                              <span :class="productIsKnown ? null : 'has-text-success'">
+                                {{ productIsKnown ? productSize : pendingProduct.size }}
+                              </span>
+                            </b-field>
+
+                            <b-field label="Reg. Price"
+                                     v-if="productSalePriceDisplay">
+                              <span>
+                                {{ productUnitRegularPriceDisplay }}
+                              </span>
+                            </b-field>
+
+                            <b-field label="Unit Price"
+                                     v-if="!productSalePriceDisplay">
+                              <span :class="productIsKnown ? null : 'has-text-success'"
+                                    % if product_price_may_be_questionable:
+                                        :class="productPriceNeedsConfirmation ? 'has-background-warning' : ''"
+                                    % endif
+                                    >
+                                {{ productIsKnown ? productUnitPriceDisplay : (pendingProduct.regular_price_amount ? '$' + pendingProduct.regular_price_amount : '') }}
+                              </span>
+                            </b-field>
+
+                            <b-field label="Sale Price"
+                                     v-if="productSalePriceDisplay">
+                              <span class="has-background-warning"
+                                    :class="productIsKnown ? null : 'has-text-success'">
+                                {{ productSalePriceDisplay }}
+                              </span>
+                            </b-field>
+
+                            <b-field label="Sale Ends"
+                                     v-if="productSaleEndsDisplay">
+                              <span class="has-background-warning"
+                                    :class="productIsKnown ? null : 'has-text-success'">
+                                {{ productSaleEndsDisplay }}
+                              </span>
+                            </b-field>
+
+                            <b-field label="Case Size">
+                              <span :class="productIsKnown ? null : 'has-text-success'">
+                                {{ productIsKnown ? productCaseQuantity : pendingProduct.case_size }}
+                              </span>
+                            </b-field>
+
+                            <b-field label="Case Price">
+                              <span
+                                    % if product_price_may_be_questionable:
+                                        :class="{'has-text-success': !productIsKnown, 'has-background-warning': productPriceNeedsConfirmation || productSalePriceDisplay}"
+                                    % else:
+                                        :class="{'has-text-success': !productIsKnown, 'has-background-warning': !!productSalePriceDisplay}"
+                                    % endif
+                                    >
+                                {{ getCasePriceDisplay() }}
+                              </span>
+                            </b-field>
+
+                          </b-field>
+
+                          <b-field grouped>
+
+                            <b-field label="Quantity" horizontal>
+                              <numeric-input v-model="productQuantity"
+                                             @input="refreshTotalPrice += 1"
+                                             style="width: 5rem;">
+                              </numeric-input>
+                            </b-field>
+
+                            <b-select v-model="productUOM"
+                                      @input="refreshTotalPrice += 1">
+                              <option v-for="choice in productUnitChoices"
+                                      :key="choice.key"
+                                      :value="choice.key"
+                                      v-html="choice.value">
+                              </option>
+                            </b-select>
+
+                          </b-field>
+
+                          <div style="display: flex; gap: 1rem;">
+                            % if allow_item_discounts:
+                                <b-field label="Discount" horizontal>
+                                  <div class="level">
+                                    <div class="level-item">
+                                      <numeric-input v-model="productDiscountPercent"
+                                                     @input="refreshTotalPrice += 1"
+                                                     style="width: 5rem;"
+                                                     :disabled="!allowItemDiscount">
+                                      </numeric-input>
+                                    </div>
+                                    <div class="level-item">
+                                      <span>&nbsp;%</span>
+                                    </div>
+                                  </div>
+                                </b-field>
+                            % endif
+                            <b-field label="Total Price" horizontal expanded
+                                     :key="refreshTotalPrice">
+                              <span :class="productSalePriceDisplay ? 'has-background-warning': null">
+                                {{ getItemTotalPriceDisplay() }}
+                              </span>
+                            </b-field>
+                          </div>
+
+                          <!-- <b-field grouped> -->
+                          <!-- </b-field> -->
+                        </div>
+
+                        <!-- <div class="is-pulled-right has-text-centered"> -->
+                          <img :src="productImageURL"
+                               style="max-height: 150px; max-width: 150px; "/>
+                        <!-- </div> -->
+
+                      </div>
+
+                    </${b}-tab-item>
+                  </${b}-tabs>
 
                   <div class="buttons">
                     <b-button @click="showingItemDialog = false">
@@ -886,11 +963,16 @@
 
                 </div>
               </div>
-            </b-modal>
+            </${b}-modal>
 
             % if unknown_product_confirm_price:
-                <b-modal has-modal-card
-                         :active.sync="confirmPriceShowDialog">
+                <${b}-modal has-modal-card
+                            % if request.use_oruga:
+                                v-model:active="confirmPriceShowDialog"
+                            % else:
+                                :active.sync="confirmPriceShowDialog"
+                            % endif
+                            >
                   <div class="modal-card">
 
                     <header class="modal-card-head">
@@ -931,87 +1013,97 @@
                       </b-button>
                     </footer>
                   </div>
-                </b-modal>
+                </${b}-modal>
             % endif
 
             % if allow_past_item_reorder:
-            <b-modal :active.sync="pastItemsShowDialog">
+            <${b}-modal
+              % if request.use_oruga:
+                  v-model:active="pastItemsShowDialog"
+              % else:
+                  :active.sync="pastItemsShowDialog"
+              % endif
+              >
               <div class="card">
                 <div class="card-content">
 
-                  <b-table :data="pastItems"
-                           icon-pack="fas"
-                           :loading="pastItemsLoading"
-                           :selected.sync="pastItemsSelected"
-                           sortable
-                           paginated
-                           per-page="5"
-                           :debounce-search="1000">
+                  <${b}-table :data="pastItems"
+                              icon-pack="fas"
+                              :loading="pastItemsLoading"
+                              % if request.use_oruga:
+                                  v-model:selected="pastItemsSelected"
+                              % else:
+                                  :selected.sync="pastItemsSelected"
+                              % endif
+                              sortable
+                              paginated
+                              per-page="5"
+                              :debounce-search="1000">
 
-                    <b-table-column :label="productKeyLabel"
+                    <${b}-table-column :label="productKeyLabel"
                                     field="key"
                                     v-slot="props"
                                     sortable>
                       {{ props.row.key }}
-                    </b-table-column>
+                    </${b}-table-column>
 
-                    <b-table-column label="Brand"
+                    <${b}-table-column label="Brand"
                                     field="brand_name"
                                     v-slot="props"
                                     sortable
                                     searchable>
                       {{ props.row.brand_name }}
-                    </b-table-column>
+                    </${b}-table-column>
 
-                    <b-table-column label="Description"
+                    <${b}-table-column label="Description"
                                     field="description"
                                     v-slot="props"
                                     sortable
                                     searchable>
                       {{ props.row.description }}
                       {{ props.row.size }}
-                    </b-table-column>
+                    </${b}-table-column>
 
-                    <b-table-column label="Unit Price"
+                    <${b}-table-column label="Unit Price"
                                     field="unit_price"
                                     v-slot="props"
                                     sortable>
                       {{ props.row.unit_price_display }}
-                    </b-table-column>
+                    </${b}-table-column>
 
-                    <b-table-column label="Sale Price"
+                    <${b}-table-column label="Sale Price"
                                     field="sale_price"
                                     v-slot="props"
                                     sortable>
                       <span class="has-background-warning">
                         {{ props.row.sale_price_display }}
                       </span>
-                    </b-table-column>
+                    </${b}-table-column>
 
-                    <b-table-column label="Sale Ends"
+                    <${b}-table-column label="Sale Ends"
                                     field="sale_ends"
                                     v-slot="props"
                                     sortable>
                       <span class="has-background-warning">
                         {{ props.row.sale_ends_display }}
                       </span>
-                    </b-table-column>
+                    </${b}-table-column>
 
-                    <b-table-column label="Department"
+                    <${b}-table-column label="Department"
                                     field="department_name"
                                     v-slot="props"
                                     sortable
                                     searchable>
                       {{ props.row.department_name }}
-                    </b-table-column>
+                    </${b}-table-column>
 
-                    <b-table-column label="Vendor"
+                    <${b}-table-column label="Vendor"
                                     field="vendor_name"
                                     v-slot="props"
                                     sortable
                                     searchable>
                       {{ props.row.vendor_name }}
-                    </b-table-column>
+                    </${b}-table-column>
 
                     <template #empty>
                       <div class="content has-text-grey has-text-centered">
@@ -1025,7 +1117,7 @@
                         <p>Nothing here.</p>
                       </div>
                     </template>
-                  </b-table>
+                  </${b}-table>
 
                   <div class="buttons">
                     <b-button @click="pastItemsShowDialog = false">
@@ -1042,44 +1134,44 @@
 
                 </div>
               </div>
-            </b-modal>
+            </${b}-modal>
             % endif
 
-            <b-table v-if="items.length"
+            <${b}-table v-if="items.length"
                      :data="items"
                      :row-class="(row, i) => row.product_uuid ? null : 'has-text-success'">
 
-              <b-table-column :label="productKeyLabel"
+              <${b}-table-column :label="productKeyLabel"
                               v-slot="props">
                 {{ props.row.product_key }}
-              </b-table-column>
+              </${b}-table-column>
 
-              <b-table-column label="Brand"
+              <${b}-table-column label="Brand"
                               v-slot="props">
                 {{ props.row.product_brand }}
-              </b-table-column>
+              </${b}-table-column>
 
-              <b-table-column label="Description"
+              <${b}-table-column label="Description"
                               v-slot="props">
                 {{ props.row.product_description }}
-              </b-table-column>
+              </${b}-table-column>
 
-              <b-table-column label="Size"
+              <${b}-table-column label="Size"
                               v-slot="props">
                 {{ props.row.product_size }}
-              </b-table-column>
+              </${b}-table-column>
 
-              <b-table-column label="Department"
+              <${b}-table-column label="Department"
                               v-slot="props">
                 {{ props.row.department_display }}
-              </b-table-column>
+              </${b}-table-column>
 
-              <b-table-column label="Quantity"
+              <${b}-table-column label="Quantity"
                               v-slot="props">
                 <span v-html="props.row.order_quantity_display"></span>
-              </b-table-column>
+              </${b}-table-column>
 
-              <b-table-column label="Unit Price"
+              <${b}-table-column label="Unit Price"
                               v-slot="props">
                 <span
                   % if product_price_may_be_questionable:
@@ -1090,16 +1182,16 @@
                   >
                   {{ props.row.unit_price_display }}
                 </span>
-              </b-table-column>
+              </${b}-table-column>
 
               % if allow_item_discounts:
-                  <b-table-column label="Discount"
+                  <${b}-table-column label="Discount"
                                   v-slot="props">
                     {{ props.row.discount_percent }}{{ props.row.discount_percent ? " %" : "" }}
-                  </b-table-column>
+                  </${b}-table-column>
               % endif
 
-              <b-table-column label="Total"
+              <${b}-table-column label="Total"
                               v-slot="props">
                 <span
                   % if product_price_may_be_questionable:
@@ -1110,35 +1202,57 @@
                   >
                   {{ props.row.total_price_display }}
                 </span>
-              </b-table-column>
+              </${b}-table-column>
 
-              <b-table-column label="Vendor"
+              <${b}-table-column label="Vendor"
                               v-slot="props">
                 {{ props.row.vendor_display }}
-              </b-table-column>
+              </${b}-table-column>
 
-              <b-table-column field="actions"
+              <${b}-table-column field="actions"
                               label="Actions"
                               v-slot="props">
-                <a href="#" class="grid-action"
+                <a href="#"
+                   % if not request.use_oruga:
+                       class="grid-action"
+                   % endif
                    @click.prevent="showEditItemDialog(props.row)">
-                  <i class="fas fa-edit"></i>
-                  Edit
+                  % if request.use_oruga:
+                      <span class="icon-text">
+                        <o-icon icon="edit" />
+                        <span>Edit</span>
+                      </span>
+                  % else:
+                      <i class="fas fa-edit"></i>
+                      Edit
+                  % endif
                 </a>
                 &nbsp;
 
-                <a href="#" class="grid-action has-text-danger"
+                <a href="#"
+                   % if request.use_oruga:
+                       class="has-text-danger"
+                   % else:
+                       class="grid-action has-text-danger"
+                   % endif
                    @click.prevent="deleteItem(props.index)">
-                  <i class="fas fa-trash"></i>
-                  Delete
+                  % if request.use_oruga:
+                      <span class="icon-text">
+                        <o-icon icon="trash" />
+                        <span>Delete</span>
+                      </span>
+                  % else:
+                      <i class="fas fa-trash"></i>
+                      Delete
+                  % endif
                 </a>
                 &nbsp;
-              </b-table-column>
+              </${b}-table-column>
 
-            </b-table>
+            </${b}-table>
           </div>
         </div>
-      </b-collapse>
+      </${b}-collapse>
 
       ${self.order_form_buttons()}
 
@@ -1222,7 +1336,11 @@
                 editingItem: null,
                 showingItemDialog: false,
                 itemDialogSaving: false,
-                itemDialogTabIndex: 0,
+                % if request.use_oruga:
+                    itemDialogTab: 'product',
+                % else:
+                    itemDialogTabIndex: 0,
+                % endif
                 % if allow_past_item_reorder:
                 pastItemsShowDialog: false,
                 pastItemsLoading: false,
@@ -1271,6 +1389,10 @@
                     confirmPriceShowDialog: false,
                 % endif
 
+                // nb. hack to force refresh for vue3
+                refreshProductDescription: 1,
+                refreshTotalPrice: 1,
+
                 submittingOrder: false,
             }
         },
@@ -1632,22 +1754,21 @@
                         uuid: this.contactUUID,
                     }
                 }
-                let that = this
-                this.submitBatchData(params, function(response) {
+                this.submitBatchData(params, response => {
                     % if new_order_requires_customer:
-                    that.contactUUID = response.data.customer_uuid
+                    this.contactUUID = response.data.customer_uuid
                     % else:
-                    that.contactUUID = response.data.person_uuid
+                    this.contactUUID = response.data.person_uuid
                     % endif
-                    that.contactDisplay = response.data.contact_display
-                    that.orderPhoneNumber = response.data.phone_number
-                    that.orderEmailAddress = response.data.email_address
-                    that.addOtherPhoneNumber = response.data.add_phone_number
-                    that.addOtherEmailAddress = response.data.add_email_address
-                    that.contactProfileURL = response.data.contact_profile_url
-                    that.contactPhones = response.data.contact_phones
-                    that.contactEmails = response.data.contact_emails
-                    that.contactNotes = response.data.contact_notes
+                    this.contactDisplay = response.data.contact_display
+                    this.orderPhoneNumber = response.data.phone_number
+                    this.orderEmailAddress = response.data.email_address
+                    this.addOtherPhoneNumber = response.data.add_phone_number
+                    this.addOtherEmailAddress = response.data.add_email_address
+                    this.contactProfileURL = response.data.contact_profile_url
+                    this.contactPhones = response.data.contact_phones
+                    this.contactEmails = response.data.contact_emails
+                    this.contactNotes = response.data.contact_notes
                     if (callback) {
                         callback()
                     }
@@ -1937,7 +2058,11 @@
                     this.productDiscountPercent = ${json.dumps(default_item_discount)|n}
                 % endif
 
-                this.itemDialogTabIndex = 0
+                % if request.use_oruga:
+                    this.itemDialogTab = 'product'
+                % else:
+                    this.itemDialogTabIndex = 0
+                % endif
                 this.showingItemDialog = true
                 this.$nextTick(() => {
                     this.$refs.productLookup.focus()
@@ -1993,7 +2118,15 @@
                 this.productPriceNeedsConfirmation = false
                 % endif
 
-                this.itemDialogTabIndex = 1
+                // nb. hack to force refresh for vue3
+                this.refreshProductDescription += 1
+                this.refreshTotalPrice += 1
+
+                % if request.use_oruga:
+                    this.itemDialogTab = 'quantity'
+                % else:
+                    this.itemDialogTabIndex = 1
+                % endif
                 this.showingItemDialog = true
             },
 
@@ -2050,7 +2183,15 @@
                     this.productDiscountPercent = row.discount_percent
                 % endif
 
-                this.itemDialogTabIndex = 1
+                // nb. hack to force refresh for vue3
+                this.refreshProductDescription += 1
+                this.refreshTotalPrice += 1
+
+                % if request.use_oruga:
+                    this.itemDialogTab = 'quantity'
+                % else:
+                    this.itemDialogTabIndex = 1
+                % endif
                 this.showingItemDialog = true
             },
 
@@ -2160,7 +2301,15 @@
                         this.productPriceNeedsConfirmation = false
                         % endif
 
-                        this.itemDialogTabIndex = 1
+                        % if request.use_oruga:
+                            this.itemDialogTab = 'quantity'
+                        % else:
+                            this.itemDialogTabIndex = 1
+                        % endif
+
+                        // nb. hack to force refresh for vue3
+                        this.refreshProductDescription += 1
+                        this.refreshTotalPrice += 1
 
                     }, response => {
                         this.clearProduct()
@@ -2250,6 +2399,7 @@
     }
 
     Vue.component('customer-order-creator', CustomerOrderCreator)
+    <% request.register_component('customer-order-creator', 'CustomerOrderCreator') %>
 
   </script>
 </%def>
diff --git a/tailbone/templates/products/lookup.mako b/tailbone/templates/products/lookup.mako
index bf8e7ef5..48206de1 100644
--- a/tailbone/templates/products/lookup.mako
+++ b/tailbone/templates/products/lookup.mako
@@ -3,21 +3,26 @@
 <%def name="tailbone_product_lookup_template()">
   <script type="text/x-template" id="tailbone-product-lookup-template">
     <div style="width: 100%;">
+      <div style="display: flex; gap: 0.5rem;">
 
-      <b-field grouped>
-
-        <b-field :expanded="!product">
-          <b-autocomplete ref="productAutocomplete"
-                          v-if="!product"
-                          v-model="autocompleteValue"
-                          placeholder="Enter UPC or brand, description etc."
-                          :data="autocompleteOptions"
-                          field="value"
-                          :custom-formatter="option => option.label"
-                          @typing="getAutocompleteOptions"
-                          @select="autocompleteSelected"
-                          style="width: 100%;">
-          </b-autocomplete>
+        <b-field :style="{'flex-grow': product ? '0' : '1'}">
+          <${b}-autocomplete v-if="!product"
+                             ref="productAutocomplete"
+                             v-model="autocompleteValue"
+                             expanded
+                             placeholder="Enter UPC or brand, description etc."
+                             :data="autocompleteOptions"
+                             % if request.use_oruga:
+                                 @input="getAutocompleteOptions"
+                                 :formatter="option => option.label"
+                             % else:
+                                 @typing="getAutocompleteOptions"
+                                 :custom-formatter="option => option.label"
+                                 field="value"
+                             % endif
+                             @select="autocompleteSelected"
+                             style="width: 100%;">
+          </${b}-autocomplete>
           <b-button v-if="product"
                     @click="clearSelection(true)">
             {{ product.full_description }}
@@ -42,7 +47,7 @@
           View Product
         </b-button>
 
-      </b-field>
+      </div>
 
       <b-modal :active.sync="lookupShowDialog">
         <div class="card">
@@ -88,68 +93,72 @@
 
             </b-field>
 
-            <b-table :data="searchResults"
-                     narrowed
-                     icon-pack="fas"
-                     :loading="searchResultsLoading"
-                     :selected.sync="searchResultSelected">
+            <${b}-table :data="searchResults"
+                        narrowed
+                        % if request.use_oruga:
+                            v-model:selected="searchResultSelected"
+                        % else:
+                            :selected.sync="searchResultSelected"
+                            icon-pack="fas"
+                        % endif
+                        :loading="searchResultsLoading">
 
-              <b-table-column label="${request.rattail_config.product_key_title()}"
+              <${b}-table-column label="${request.rattail_config.product_key_title()}"
                               field="product_key"
                               v-slot="props">
                 {{ props.row.product_key }}
-              </b-table-column>
+              </${b}-table-column>
 
-              <b-table-column label="Brand"
+              <${b}-table-column label="Brand"
                               field="brand_name"
                               v-slot="props">
                 {{ props.row.brand_name }}
-              </b-table-column>
+              </${b}-table-column>
 
-              <b-table-column label="Description"
+              <${b}-table-column label="Description"
                               field="description"
                               v-slot="props">
                 <span :class="{organic: props.row.organic}">
                   {{ props.row.description }}
                   {{ props.row.size }}
                 </span>
-              </b-table-column>
+              </${b}-table-column>
 
-              <b-table-column label="Unit Price"
+              <${b}-table-column label="Unit Price"
                               field="unit_price"
                               v-slot="props">
                 {{ props.row.unit_price_display }}
-              </b-table-column>
+              </${b}-table-column>
 
-              <b-table-column label="Sale Price"
+              <${b}-table-column label="Sale Price"
                               field="sale_price"
                               v-slot="props">
                 <span class="has-background-warning">
                   {{ props.row.sale_price_display }}
                 </span>
-              </b-table-column>
+              </${b}-table-column>
 
-              <b-table-column label="Sale Ends"
+              <${b}-table-column label="Sale Ends"
                               field="sale_ends"
                               v-slot="props">
                 <span class="has-background-warning">
                   {{ props.row.sale_ends_display }}
                 </span>
-              </b-table-column>
+              </${b}-table-column>
 
-              <b-table-column label="Department"
+              <${b}-table-column label="Department"
                               field="department_name"
                               v-slot="props">
                 {{ props.row.department_name }}
-              </b-table-column>
+              </${b}-table-column>
 
-              <b-table-column label="Vendor"
+              <${b}-table-column label="Vendor"
                               field="vendor_name"
                               v-slot="props">
                 {{ props.row.vendor_name }}
-              </b-table-column>
+              </${b}-table-column>
 
-              <b-table-column label="Actions"
+              <${b}-table-column label="Actions"
                               v-slot="props">
                 <a :href="props.row.url"
                    target="_blank"
@@ -157,7 +166,7 @@
                   <i class="fas fa-external-link-alt"></i>
                   View
                 </a>
-              </b-table-column>
+              </${b}-table-column>
 
               <template #empty>
                 <div class="content has-text-grey has-text-centered">
@@ -171,7 +180,7 @@
                   <p>Nothing here.</p>
                 </div>
               </template>
-            </b-table>
+            </${b}-table>
 
             <br />
             <div class="level">
@@ -263,7 +272,12 @@
                 }
             },
 
+            ## TODO: add debounce for oruga?
+            % if request.use_oruga:
+            getAutocompleteOptions(entry) {
+            % else:
             getAutocompleteOptions: debounce(function (entry) {
+            % endif
 
                 // since the `@typing` event from buefy component does not
                 // "self-regulate" in any way, we a) use `debounce` above,
@@ -282,7 +296,11 @@
                         this.autocompleteOptions = []
                         throw error
                     })
+            % if request.use_oruga:
+            },
+            % else:
             }),
+            % endif
 
             autocompleteSelected(option) {
                 this.$emit('selected', {
@@ -359,6 +377,7 @@
     }
 
     Vue.component('tailbone-product-lookup', TailboneProductLookup)
+    <% request.register_component('tailbone-product-lookup', 'TailboneProductLookup') %>
 
   </script>
 </%def>
diff --git a/tailbone/templates/themes/butterball/buefy-components.mako b/tailbone/templates/themes/butterball/buefy-components.mako
index 7553729b..d641cbe7 100644
--- a/tailbone/templates/themes/butterball/buefy-components.mako
+++ b/tailbone/templates/themes/butterball/buefy-components.mako
@@ -13,6 +13,7 @@
   ${self.make_b_loading_component()}
   ${self.make_b_modal_component()}
   ${self.make_b_notification_component()}
+  ${self.make_b_radio_component()}
   ${self.make_b_select_component()}
   ${self.make_b_steps_component()}
   ${self.make_b_step_item_component()}
@@ -468,6 +469,41 @@
   <% request.register_component('b-notification', 'BNotification') %>
 </%def>
 
+<%def name="make_b_radio_component()">
+  <script type="text/x-template" id="b-radio-template">
+    <o-radio v-model="orugaValue"
+             @update:model-value="orugaValueUpdated"
+             :native-value="nativeValue">
+      <slot />
+    </o-radio>
+  </script>
+  <script>
+    const BRadio = {
+        template: '#b-radio-template',
+        props: {
+            modelValue: null,
+            nativeValue: null,
+        },
+        data() {
+            return {
+                orugaValue: this.modelValue,
+            }
+        },
+        watch: {
+            modelValue(to, from) {
+                this.orugaValue = to
+            },
+        },
+        methods: {
+            orugaValueUpdated(value) {
+                this.$emit('update:modelValue', value)
+            },
+        },
+    }
+  </script>
+  <% request.register_component('b-radio', 'BRadio') %>
+</%def>
+
 <%def name="make_b_select_component()">
   <script type="text/x-template" id="b-select-template">
     <o-select :name="name"
diff --git a/tailbone/templates/themes/butterball/field-components.mako b/tailbone/templates/themes/butterball/field-components.mako
index 8c1d1d70..2b9ca342 100644
--- a/tailbone/templates/themes/butterball/field-components.mako
+++ b/tailbone/templates/themes/butterball/field-components.mako
@@ -112,7 +112,7 @@
                       @select="optionSelected"
                       keep-first
                       open-on-focus
-                      expanded
+                      :expanded="expanded"
                       :clearable="clearable"
                       :clear-on-select="clearOnSelect">
         <template #default="{ option }">
@@ -325,6 +325,7 @@
                 // the selection is cleared we want user to start over
                 // anyway
                 this.orugaValue = null
+                this.fetchedData = []
 
                 // here is where we alert callers to the new value
                 if (option) {
@@ -366,6 +367,12 @@
                 }
             },
 
+            // nb. this used to be relevant but now is here only for sake
+            // of backward-compatibility (for callers)
+            getDisplayText() {
+                return this.internalLabel
+            },
+
             // set focus to this component, which will just set focus
             // to the oruga autocomplete component
             focus() {

From 29c9ea1a2b693927d1a1fdf41ac0add09f9bc7a1 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 3 Jun 2024 10:28:42 -0500
Subject: [PATCH 311/542] Update changelog

---
 CHANGES.rst          | 10 ++++++++++
 tailbone/_version.py |  2 +-
 2 files changed, 11 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 762ca455..ca512864 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -5,6 +5,16 @@ CHANGELOG
 Unreleased
 ----------
 
+0.10.9 (2024-06-03)
+-------------------
+
+* Let master view control context menu items for page.
+
+* Fix panel style for PO vs. Invoice breakdown in receiving batch.
+
+* Fix the "new custorder" page for butterball.
+
+
 0.10.8 (2024-06-02)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index e6aa0601..bc09b216 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.10.8'
+__version__ = '0.10.9'

From 30238528fe8d08d04a3c8966fa1223bde82a58ff Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 3 Jun 2024 10:48:59 -0500
Subject: [PATCH 312/542] Fix focus for `<b-select>` shim component

---
 tailbone/templates/themes/butterball/buefy-components.mako | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/tailbone/templates/themes/butterball/buefy-components.mako b/tailbone/templates/themes/butterball/buefy-components.mako
index d641cbe7..f44f30ad 100644
--- a/tailbone/templates/themes/butterball/buefy-components.mako
+++ b/tailbone/templates/themes/butterball/buefy-components.mako
@@ -507,6 +507,7 @@
 <%def name="make_b_select_component()">
   <script type="text/x-template" id="b-select-template">
     <o-select :name="name"
+              ref="select"
               v-model="orugaValue"
               @update:model-value="orugaValueUpdated"
               :expanded="expanded"
@@ -545,6 +546,9 @@
             },
         },
         methods: {
+            focus() {
+                this.$refs.select.focus()
+            },
             orugaValueUpdated(value) {
                 this.$emit('update:modelValue', value)
                 this.$emit('input', value)

From b27987f1d1e12ad7555d48c763e42ca0413dde5e Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 3 Jun 2024 11:15:22 -0500
Subject: [PATCH 313/542] More butterball fixes for "view profile" template

---
 tailbone/templates/people/view_profile.mako | 129 +++++++++++++++-----
 1 file changed, 96 insertions(+), 33 deletions(-)

diff --git a/tailbone/templates/people/view_profile.mako b/tailbone/templates/people/view_profile.mako
index bf94b7fa..c5c86806 100644
--- a/tailbone/templates/people/view_profile.mako
+++ b/tailbone/templates/people/view_profile.mako
@@ -404,33 +404,53 @@
           % if request.has_perm('people_profile.edit_person'):
           <${b}-table-column label="Actions"
                           v-slot="props">
-            <a class="grid-action"
-               href="#" @click.prevent="editPhoneInit(props.row)">
+            <a href="#" @click.prevent="editPhoneInit(props.row)"
+               % if not request.use_oruga:
+                   class="grid-action"
+               % endif
+               >
               % if request.use_oruga:
-                  <o-icon icon="edit" />
+                  <span class="icon-text">
+                    <o-icon icon="edit" />
+                    <span>Edit</span>
+                  </span>
               % else:
                   <i class="fas fa-edit"></i>
+                  Edit
               % endif
-              Edit
             </a>
-            <a class="grid-action has-text-danger"
-               href="#" @click.prevent="deletePhoneInit(props.row)">
+            <a href="#" @click.prevent="deletePhoneInit(props.row)"
+               % if request.use_oruga:
+                   class="has-text-danger"
+               % else:
+                   class="grid-action has-text-danger"
+               % endif
+               >
               % if request.use_oruga:
-                  <o-icon icon="trash" />
+                  <span class="icon-text">
+                    <o-icon icon="trash" />
+                    <span>Delete</span>
+                  </span>
               % else:
                   <i class="fas fa-trash"></i>
+                  Delete
               % endif
-              Delete
             </a>
-            <a class="grid-action"
+            <a v-if="!props.row.preferred"
                href="#" @click.prevent="preferPhoneInit(props.row)"
-               v-if="!props.row.preferred">
+               % if not request.use_oruga:
+                   class="grid-action"
+               % endif
+               >
               % if request.use_oruga:
-                  <o-icon icon="star" />
+                  <span class="icon-text">
+                    <o-icon icon="star" />
+                    <span>Set Preferred</span>
+                  </span>
               % else:
                   <i class="fas fa-star"></i>
+                  Set Preferred
               % endif
-              Set Preferred
             </a>
           </${b}-table-column>
           % endif
@@ -618,33 +638,52 @@
           % if request.has_perm('people_profile.edit_person'):
               <${b}-table-column label="Actions"
                               v-slot="props">
-                <a class="grid-action"
-                   href="#" @click.prevent="editEmailInit(props.row)">
+                <a href="#" @click.prevent="editEmailInit(props.row)"
+                   % if not request.use_oruga:
+                       class="grid-action"
+                   % endif
+                   >
                   % if request.use_oruga:
-                      <o-icon icon="edit" />
+                      <span class="icon-text">
+                        <o-icon icon="edit" />
+                        <span>Edit</span>
+                      </span>
                   % else:
                       <i class="fas fa-edit"></i>
+                      Edit
                   % endif
-                  Edit
                 </a>
-                <a class="grid-action has-text-danger"
-                   href="#" @click.prevent="deleteEmailInit(props.row)">
+                <a href="#" @click.prevent="deleteEmailInit(props.row)"
+                   % if request.use_oruga:
+                       class="has-text-danger"
+                   % else:
+                       class="grid-action has-text-danger"
+                   % endif
+                   >
                   % if request.use_oruga:
-                      <o-icon icon="trash" />
+                      <span class="icon-text">
+                        <o-icon icon="trash" />
+                        <span>Delete</span>
+                      </span>
                   % else:
                       <i class="fas fa-trash"></i>
+                      Delete
                   % endif
-                  Delete
                 </a>
-                <a class="grid-action"
-                   href="#" @click.prevent="preferEmailInit(props.row)"
-                   v-if="!props.row.preferred">
+                <a v-if="!props.row.preferred"
+                   % if not request.use_oruga:
+                       class="grid-action"
+                   % endif
+                   href="#" @click.prevent="preferEmailInit(props.row)">
                   % if request.use_oruga:
-                      <o-icon icon="star" />
+                      <span class="icon-text">
+                        <o-icon icon="star" />
+                        <span>Set Preferred</span>
+                      </span>
                   % else:
                       <i class="fas fa-star"></i>
+                      Set Preferred
                   % endif
-                  Set Preferred
                 </a>
               </${b}-table-column>
           % endif
@@ -1200,8 +1239,15 @@
                                   label="Actions"
                                   v-slot="props">
                     <a href="#" @click.prevent="editEmployeeHistoryInit(props.row)">
-                      <i class="fas fa-edit"></i>
-                      Edit
+                      % if request.use_oruga:
+                          <span class="icon-text">
+                            <o-icon icon="edit" />
+                            <span>Edit</span>
+                          </span>
+                      % else:
+                          <i class="fas fa-edit"></i>
+                          Edit
+                      % endif
                     </a>
                   </${b}-table-column>
               % endif
@@ -1428,15 +1474,30 @@
                             v-slot="props">
               % if request.has_perm('people_profile.edit_note'):
                   <a href="#" @click.prevent="editNoteInit(props.row)">
-                    <i class="fas fa-edit"></i>
-                    Edit
+                    % if request.use_oruga:
+                        <span class="icon-text">
+                          <o-icon icon="edit" />
+                          <span>Edit</span>
+                        </span>
+                    % else:
+                        <i class="fas fa-edit"></i>
+                        Edit
+                    % endif
                   </a>
               % endif
               % if request.has_perm('people_profile.delete_note'):
                   <a href="#" @click.prevent="deleteNoteInit(props.row)"
                      class="has-text-danger">
-                    <i class="fas fa-trash"></i>
-                    Delete
+                    % if request.use_oruga:
+                        <span class="icon-text">
+                          <o-icon icon="trash" />
+                          <span>Delete</span>
+                        </span>
+                    % else:
+                        <i class="fas fa-trash"></i>
+                        Delete
+                    % endif
+
                   </a>
               % endif
             </${b}-table-column>
@@ -1477,14 +1538,16 @@
 
                 <b-field label="Subject">
                   <b-input v-model.trim="editNoteSubject"
-                           :disabled="editNoteDelete">
+                           :disabled="editNoteDelete"
+                           expanded>
                   </b-input>
                 </b-field>
 
                 <b-field label="Text">
                   <b-input v-model.trim="editNoteText"
                            type="textarea"
-                           :disabled="editNoteDelete">
+                           :disabled="editNoteDelete"
+                           expanded>
                   </b-input>
                 </b-field>
 

From ab523719a6b8e42265993383c0dbb8b7b44bf9d8 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 3 Jun 2024 11:16:33 -0500
Subject: [PATCH 314/542] Update changelog

---
 CHANGES.rst          | 8 ++++++++
 tailbone/_version.py | 2 +-
 2 files changed, 9 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index ca512864..012e6ff3 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -5,6 +5,14 @@ CHANGELOG
 Unreleased
 ----------
 
+0.10.10 (2024-06-03)
+--------------------
+
+* Fix focus for ``<b-select>`` shim component.
+
+* More butterball fixes for "view profile" template.
+
+
 0.10.9 (2024-06-03)
 -------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index bc09b216..37b06700 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.10.9'
+__version__ = '0.10.10'

From 9243edf7afea44922acc7f9a9c9ee66249e30be5 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 3 Jun 2024 16:51:29 -0500
Subject: [PATCH 315/542] Fix vue3 refresh for name, address cards in profile
 view

---
 tailbone/templates/people/view_profile.mako | 16 ++++++++++++++--
 1 file changed, 14 insertions(+), 2 deletions(-)

diff --git a/tailbone/templates/people/view_profile.mako b/tailbone/templates/people/view_profile.mako
index c5c86806..467371aa 100644
--- a/tailbone/templates/people/view_profile.mako
+++ b/tailbone/templates/people/view_profile.mako
@@ -78,7 +78,9 @@
 </%def>
 
 <%def name="render_personal_name_card()">
-  <div class="card personal">
+  <div class="card personal"
+        ## nb. hack to force refresh for vue3
+       :key="refreshPersonalCard">
     <header class="card-header">
       <p class="card-header-title">Name</p>
     </header>
@@ -184,7 +186,9 @@
 </%def>
 
 <%def name="render_personal_address_card()">
-  <div class="card personal">
+  <div class="card personal"
+       ## nb. hack to force refresh for vue3
+       :key="refreshAddressCard">
     <header class="card-header">
       <p class="card-header-title">Address</p>
     </header>
@@ -1822,6 +1826,10 @@
     let PersonalTabData = {
         refreshTabURL: '${url('people.profile_tab_personal', uuid=person.uuid)}',
 
+        // nb. hack to force refresh for vue3
+        refreshPersonalCard: 1,
+        refreshAddressCard: 1,
+
         % if request.has_perm('people_profile.edit_person'):
             editNameShowDialog: false,
             editNameFirst: null,
@@ -1971,6 +1979,8 @@
                         this.editNameShowDialog = false
                         this.refreshTab()
                         this.editNameSaving = false
+                        // nb. hack to force refresh for vue3
+                        this.refreshPersonalCard += 1
                     }, response => {
                         this.editNameSaving = false
                     })
@@ -2002,6 +2012,8 @@
                         this.$emit('profile-changed', response.data)
                         this.editAddressShowDialog = false
                         this.refreshTab()
+                        // nb. hack to force refresh for vue3
+                        this.refreshAddressCard += 1
                     })
                 },
 

From 0303014acb700bf22336be26380aabf724572e8e Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 3 Jun 2024 17:37:11 -0500
Subject: [PATCH 316/542] Fix vue3 refresh for employee tab of profile view

and misc. related cleanup
---
 tailbone/templates/people/view_profile.mako   | 257 ++++++++++++------
 .../themes/butterball/field-components.mako   |   3 +
 2 files changed, 175 insertions(+), 85 deletions(-)

diff --git a/tailbone/templates/people/view_profile.mako b/tailbone/templates/people/view_profile.mako
index 467371aa..3520d924 100644
--- a/tailbone/templates/people/view_profile.mako
+++ b/tailbone/templates/people/view_profile.mako
@@ -1155,70 +1155,72 @@
 
           <div v-if="employee.uuid">
 
-            <b-field horizontal label="Employee ID">
-              <div class="level">
-                <div class="level-left">
-                  <div class="level-item">
-                    <span>{{ employee.id }}</span>
+            <div :key="refreshEmployeeCard">
+              <b-field horizontal label="Employee ID">
+                <div class="level">
+                  <div class="level-left">
+                    <div class="level-item">
+                      <span>{{ employee.id }}</span>
+                    </div>
+                    % if request.has_perm('employees.edit'):
+                    <div class="level-item">
+                      <b-button type="is-primary"
+                                icon-pack="fas"
+                                icon-left="edit"
+                                @click="editEmployeeIdInit()">
+                        Edit ID
+                      </b-button>
+                      <${b}-modal has-modal-card
+                                  % if request.use_oruga:
+                                  v-model:active="editEmployeeIdShowDialog"
+                                  % else:
+                                  :active.sync="editEmployeeIdShowDialog"
+                                  % endif
+                                  >
+                        <div class="modal-card">
+
+                          <header class="modal-card-head">
+                            <p class="modal-card-title">Employee ID</p>
+                          </header>
+
+                          <section class="modal-card-body">
+                            <b-field label="Employee ID">
+                              <b-input v-model="editEmployeeIdValue"></b-input>
+                            </b-field>
+                          </section>
+
+                          <footer class="modal-card-foot">
+                            <b-button @click="editEmployeeIdShowDialog = false">
+                              Cancel
+                            </b-button>
+                            <b-button type="is-primary"
+                                      icon-pack="fas"
+                                      icon-left="save"
+                                      :disabled="editEmployeeIdSaving"
+                                      @click="editEmployeeIdSave()">
+                              {{ editEmployeeIdSaving ? "Working, please wait..." : "Save" }}
+                            </b-button>
+                          </footer>
+                        </div>
+                      </${b}-modal>
+                    </div>
+                    % endif
                   </div>
-                  % if request.has_perm('employees.edit'):
-                      <div class="level-item">
-                        <b-button type="is-primary"
-                                  icon-pack="fas"
-                                  icon-left="edit"
-                                  @click="editEmployeeIdInit()">
-                          Edit ID
-                        </b-button>
-                        <${b}-modal has-modal-card
-                                    % if request.use_oruga:
-                                        v-model:active="editEmployeeIdShowDialog"
-                                    % else:
-                                        :active.sync="editEmployeeIdShowDialog"
-                                    % endif
-                                    >
-                          <div class="modal-card">
-
-                            <header class="modal-card-head">
-                              <p class="modal-card-title">Employee ID</p>
-                            </header>
-
-                            <section class="modal-card-body">
-                              <b-field label="Employee ID">
-                                <b-input v-model="editEmployeeIdValue"></b-input>
-                              </b-field>
-                            </section>
-
-                            <footer class="modal-card-foot">
-                              <b-button @click="editEmployeeIdShowDialog = false">
-                                Cancel
-                              </b-button>
-                              <b-button type="is-primary"
-                                        icon-pack="fas"
-                                        icon-left="save"
-                                        :disabled="editEmployeeIdSaving"
-                                        @click="editEmployeeIdSave()">
-                                {{ editEmployeeIdSaving ? "Working, please wait..." : "Save" }}
-                              </b-button>
-                            </footer>
-                          </div>
-                        </${b}-modal>
-                      </div>
-                  % endif
                 </div>
-              </div>
-            </b-field>
+              </b-field>
 
-            <b-field horizontal label="Employee Status">
-              <span>{{ employee.status_display }}</span>
-            </b-field>
+              <b-field horizontal label="Employee Status">
+                <span>{{ employee.status_display }}</span>
+              </b-field>
 
-            <b-field horizontal label="Start Date">
-              <span>{{ employee.start_date }}</span>
-            </b-field>
+              <b-field horizontal label="Start Date">
+                <span>{{ employee.start_date }}</span>
+              </b-field>
 
-            <b-field horizontal label="End Date">
-              <span>{{ employee.end_date }}</span>
-            </b-field>
+              <b-field horizontal label="End Date">
+                <span>{{ employee.end_date }}</span>
+              </b-field>
+            </div>
 
             <br />
             <p><strong>Employee History</strong></p>
@@ -1301,7 +1303,8 @@
                         <b-input v-model="startEmployeeID"></b-input>
                       </b-field>
                       <b-field label="Start Date">
-                        <tailbone-datepicker v-model="startEmployeeStartDate"></tailbone-datepicker>
+                        <tailbone-datepicker v-model="startEmployeeStartDate"
+                                             ref="startEmployeeStartDate" />
                       </b-field>
                     </section>
 
@@ -1309,11 +1312,13 @@
                       <b-button @click="startEmployeeShowDialog = false">
                         Cancel
                       </b-button>
-                      <once-button type="is-primary"
-                                   @click="startEmployeeSave()"
-                                   :disabled="!startEmployeeStartDate"
-                                   text="Save">
-                      </once-button>
+                      <b-button type="is-primary"
+                                @click="startEmployeeSave()"
+                                :disabled="startEmployeeSaveDisabled"
+                                icon-pack="fas"
+                                icon-left="save">
+                        {{ startEmployeeSaving ? "Working, please wait..." : "Save" }}
+                      </b-button>
                     </footer>
                   </div>
                 </${b}-modal>
@@ -1346,11 +1351,13 @@
                       <b-button @click="stopEmployeeShowDialog = false">
                         Cancel
                       </b-button>
-                      <once-button type="is-primary"
-                                   @click="stopEmployeeSave()"
-                                   :disabled="!stopEmployeeEndDate"
-                                   text="Save">
-                      </once-button>
+                      <b-button type="is-primary"
+                                @click="stopEmployeeSave()"
+                                :disabled="stopEmployeeSaveDisabled"
+                                icon-pack="fas"
+                                icon-left="save">
+                        {{ stopEmployeeSaving ? "Working, please wait..." : "Save" }}
+                      </b-button>
                     </footer>
                   </div>
                 </${b}-modal>
@@ -1385,11 +1392,13 @@
                       <b-button @click="editEmployeeHistoryShowDialog = false">
                         Cancel
                       </b-button>
-                      <once-button type="is-primary"
-                                   @click="editEmployeeHistorySave()"
-                                   :disabled="!editEmployeeHistoryStartDate || (editEmployeeHistoryEndDateRequired && !editEmployeeHistoryEndDate)"
-                                   text="Save">
-                      </once-button>
+                      <b-button type="is-primary"
+                                @click="editEmployeeHistorySave()"
+                                :disabled="editEmployeeHistorySaveDisabled"
+                                icon-pack="fas"
+                                icon-left="save">
+                        {{ editEmployeeHistorySaving ? "Working, please wait..." : "Save" }}
+                      </b-button>
                     </footer>
                   </div>
                 </${b}-modal>
@@ -2351,6 +2360,9 @@
         employee: {},
         employeeHistory: [],
 
+        // nb. hack to force refresh for vue3
+        refreshEmployeeCard: 1,
+
         % if request.has_perm('employees.edit'):
             editEmployeeIdShowDialog: false,
             editEmployeeIdValue: null,
@@ -2361,10 +2373,12 @@
             startEmployeeShowDialog: false,
             startEmployeeID: null,
             startEmployeeStartDate: null,
+            startEmployeeSaving: false,
 
             stopEmployeeShowDialog: false,
             stopEmployeeEndDate: null,
             stopEmployeeRevokeAccess: false,
+            stopEmployeeSaving: false,
         % endif
 
         % if request.has_perm('people_profile.edit_employee_history'):
@@ -2373,6 +2387,7 @@
             editEmployeeHistoryStartDate: null,
             editEmployeeHistoryEndDate: null,
             editEmployeeHistoryEndDateRequired: false,
+            editEmployeeHistorySaving: false,
         % endif
     }
 
@@ -2382,11 +2397,56 @@
         props: {
             person: Object,
         },
-        computed: {},
+        computed: {
+
+            % if request.has_perm('people_profile.toggle_employee'):
+
+                startEmployeeSaveDisabled() {
+                    if (this.startEmployeeSaving) {
+                        return true
+                    }
+                    if (!this.startEmployeeStartDate) {
+                        return true
+                    }
+                    return false
+                },
+
+                stopEmployeeSaveDisabled() {
+                    if (this.stopEmployeeSaving) {
+                        return true
+                    }
+                    if (!this.stopEmployeeEndDate) {
+                        return true
+                    }
+                    return false
+                },
+
+            % endif
+
+            % if request.has_perm('people_profile.edit_employee_history'):
+
+                editEmployeeHistorySaveDisabled() {
+                    if (this.editEmployeeHistorySaving) {
+                        return true
+                    }
+                    if (!this.editEmployeeHistoryStartDate) {
+                        return true
+                    }
+                    if (this.editEmployeeHistoryEndDateRequired && !this.editEmployeeHistoryEndDate) {
+                        return true
+                    }
+                    return false
+                },
+
+            % endif
+
+        },
         methods: {
 
             refreshTabSuccess(response) {
                 this.employee = response.data.employee
+                // nb. hack to force refresh for vue3
+                this.refreshEmployeeCard += 1
                 this.employeeHistory = response.data.employee_history
             },
 
@@ -2401,7 +2461,7 @@
                     this.editEmployeeIdSaving = true
                     let url = '${url('people.profile_update_employee_id', uuid=instance.uuid)}'
                     let params = {
-                        'employee_id': this.editEmployeeIdValue,
+                        'employee_id': this.editEmployeeIdValue || null,
                     }
                     this.simplePOST(url, params, response => {
                         this.$emit('profile-changed', response.data)
@@ -2424,34 +2484,52 @@
                 },
 
                 startEmployeeSave() {
-                    let url = '${url('people.profile_start_employee', uuid=person.uuid)}'
-                    let params = {
+                    this.startEmployeeSaving = true
+                    const url = '${url('people.profile_start_employee', uuid=person.uuid)}'
+                    const params = {
                         id: this.startEmployeeID,
-                        start_date: this.startEmployeeStartDate,
+                        % if request.use_oruga:
+                            start_date: this.$refs.startEmployeeStartDate.formatDate(this.startEmployeeStartDate),
+                        % else:
+                            start_date: this.startEmployeeStartDate,
+                        % endif
                     }
 
                     this.simplePOST(url, params, response => {
                         this.$emit('profile-changed', response.data)
                         this.startEmployeeShowDialog = false
                         this.refreshTab()
+                        this.startEmployeeSaving = false
+                    }, response => {
+                        this.startEmployeeSaving = false
                     })
                 },
 
                 stopEmployeeInit() {
+                    this.stopEmployeeEndDate = null
+                    this.stopEmployeeRevokeAccess = false
                     this.stopEmployeeShowDialog = true
                 },
 
                 stopEmployeeSave() {
-                    let url = '${url('people.profile_end_employee', uuid=person.uuid)}'
-                    let params = {
-                        end_date: this.stopEmployeeEndDate,
+                    this.stopEmployeeSaving = true
+                    const url = '${url('people.profile_end_employee', uuid=person.uuid)}'
+                    const params = {
+                        % if request.use_oruga:
+                            end_date: this.$refs.startEmployeeStartDate.formatDate(this.stopEmployeeEndDate),
+                        % else:
+                            end_date: this.stopEmployeeEndDate,
+                        % endif
                         revoke_access: this.stopEmployeeRevokeAccess,
                     }
 
                     this.simplePOST(url, params, response => {
                         this.$emit('profile-changed', response.data)
                         this.stopEmployeeShowDialog = false
+                        this.stopEmployeeSaving = false
                         this.refreshTab()
+                    }, response => {
+                        this.stopEmployeeSaving = false
                     })
                 },
 
@@ -2468,17 +2546,26 @@
                 },
 
                 editEmployeeHistorySave() {
+                    this.editEmployeeHistorySaving = true
                     let url = '${url('people.profile_edit_employee_history', uuid=person.uuid)}'
                     let params = {
                         uuid: this.editEmployeeHistoryUUID,
-                        start_date: this.editEmployeeHistoryStartDate,
-                        end_date: this.editEmployeeHistoryEndDate,
+                        % if request.use_oruga:
+                            start_date: this.$refs.startEmployeeStartDate.formatDate(this.editEmployeeHistoryStartDate),
+                            end_date: this.$refs.startEmployeeStartDate.formatDate(this.editEmployeeHistoryEndDate),
+                        % else:
+                            start_date: this.editEmployeeHistoryStartDate,
+                            end_date: this.editEmployeeHistoryEndDate,
+                        % endif
                     }
 
                     this.simplePOST(url, params, response => {
                         this.$emit('profile-changed', response.data)
                         this.editEmployeeHistoryShowDialog = false
                         this.refreshTab()
+                        this.editEmployeeHistorySaving = false
+                    }, response => {
+                        this.editEmployeeHistorySaving = false
                     })
                 },
 
diff --git a/tailbone/templates/themes/butterball/field-components.mako b/tailbone/templates/themes/butterball/field-components.mako
index 2b9ca342..d79c88f4 100644
--- a/tailbone/templates/themes/butterball/field-components.mako
+++ b/tailbone/templates/themes/butterball/field-components.mako
@@ -434,6 +434,9 @@
                 if (date === null) {
                     return null
                 }
+                if (typeof(date) == 'string') {
+                    return date
+                }
                 // just need to convert to simple ISO date format here, seems
                 // like there should be a more obvious way to do that?
                 var year = date.getFullYear()

From 2791e1c385fd48d7a1602bca7b5bc8319d82033d Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 3 Jun 2024 19:51:16 -0500
Subject: [PATCH 317/542] Fix grid bug for tempmon appliance view, per oruga

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

diff --git a/tailbone/views/tempmon/core.py b/tailbone/views/tempmon/core.py
index 98fe9199..d551d6e6 100644
--- a/tailbone/views/tempmon/core.py
+++ b/tailbone/views/tempmon/core.py
@@ -78,6 +78,7 @@ class MasterView(views.MasterView):
         factory = self.get_grid_factory()
         g = factory(
             key='{}.probes'.format(route_prefix),
+            request=self.request,
             data=[],
             columns=[
                 'description',

From 2498da390948f626263c4438eb00d3392dcc60de Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 3 Jun 2024 20:04:01 -0500
Subject: [PATCH 318/542] Fix ordering worksheet generator, per butterball

---
 tailbone/templates/reports/ordering.mako | 38 ++++++++++++++++--------
 1 file changed, 25 insertions(+), 13 deletions(-)

diff --git a/tailbone/templates/reports/ordering.mako b/tailbone/templates/reports/ordering.mako
index 84e9b819..1e526792 100644
--- a/tailbone/templates/reports/ordering.mako
+++ b/tailbone/templates/reports/ordering.mako
@@ -18,35 +18,46 @@
       <tailbone-autocomplete v-model="vendorUUID"
                              service-url="${url('vendors.autocomplete')}"
                              name="vendor"
-                             @input="vendorChanged">
+                             expanded
+                             % if request.use_oruga:
+                                 @update:model-value="vendorChanged"
+                             % else:
+                                 @input="vendorChanged"
+                             % endif
+                             >
       </tailbone-autocomplete>
     </b-field>
 
     <b-field label="Departments">
-      <b-table v-if="fetchedDepartments"
-        :data="departments"
-        narrowed
-        checkable
-        :checked-rows.sync="checkedDepartments"
-        :loading="fetchingDepartments">
+      <${b}-table v-if="fetchedDepartments"
+                  :data="departments"
+                  narrowed
+                  checkable
+                  % if request.use_oruga:
+                      v-model:checked-rows="checkedDepartments"
+                  % else:
+                      :checked-rows.sync="checkedDepartments"
+                  % endif
+                  :loading="fetchingDepartments">
 
-        <b-table-column field="number"
+        <${b}-table-column field="number"
                         label="Number"
                         v-slot="props">
           {{ props.row.number }}
-        </b-table-column>
+        </${b}-table-column>
 
-        <b-table-column field="name"
+        <${b}-table-column field="name"
                         label="Name"
                         v-slot="props">
           {{ props.row.name }}
-        </b-table-column>
+        </${b}-table-column>
 
-      </b-table>
+      </${b}-table>
     </b-field>
 
     <b-field>
-      <b-checkbox name="preferred_only" :value="true"
+      <b-checkbox name="preferred_only"
+                  v-model="preferredVendorOnly"
                   native-value="1">
         Only include products for which this vendor is preferred.
       </b-checkbox>
@@ -77,6 +88,7 @@
     ThisPageData.vendorUUID = null
     ThisPageData.departments = []
     ThisPageData.checkedDepartments = []
+    ThisPageData.preferredVendorOnly = true
     ThisPageData.fetchingDepartments = false
     ThisPageData.fetchedDepartments = false
 

From 30a8b8e5e4ffab4f568855659c07212185ddc215 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 3 Jun 2024 20:07:42 -0500
Subject: [PATCH 319/542] Fix inventory worksheet generator, per butterball

---
 tailbone/templates/reports/inventory.mako | 6 ++++--
 1 file changed, 4 insertions(+), 2 deletions(-)

diff --git a/tailbone/templates/reports/inventory.mako b/tailbone/templates/reports/inventory.mako
index 6c6e739f..f051959f 100644
--- a/tailbone/templates/reports/inventory.mako
+++ b/tailbone/templates/reports/inventory.mako
@@ -1,4 +1,4 @@
-## -*- coding: utf-8 -*-
+## -*- coding: utf-8; -*-
 <%inherit file="/page.mako" />
 
 <%def name="title()">Inventory Worksheet</%def>
@@ -29,7 +29,8 @@
   </b-field>
 
   <b-field>
-    <b-checkbox name="exclude-not-for-sale" :value="true"
+    <b-checkbox name="exclude-not-for-sale"
+                v-model="excludeNotForSale"
                 native-value="1">
       Exclude items marked "not for sale".
     </b-checkbox>
@@ -52,6 +53,7 @@
   <script type="text/javascript">
 
     ThisPageData.departments = ${json.dumps([{'uuid': d.uuid, 'name': d.name} for d in departments])|n}
+    ThisPageData.excludeNotForSale = true
 
   </script>
 </%def>

From e17ef2edd892cf161afbf2f432327b6e5ba61b9b Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 3 Jun 2024 21:16:42 -0500
Subject: [PATCH 320/542] Update changelog

---
 CHANGES.rst          | 12 ++++++++++++
 tailbone/_version.py |  2 +-
 2 files changed, 13 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 012e6ff3..a3be0af8 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -5,6 +5,18 @@ CHANGELOG
 Unreleased
 ----------
 
+0.10.11 (2024-06-03)
+--------------------
+
+* Fix vue3 refresh bugs for various views.
+
+* Fix grid bug for tempmon appliance view, per oruga.
+
+* Fix ordering worksheet generator, per butterball.
+
+* Fix inventory worksheet generator, per butterball.
+
+
 0.10.10 (2024-06-03)
 --------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 37b06700..d24c3b8e 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.10.10'
+__version__ = '0.10.11'

From efe477d0db86ffc361c5318dfadcbc5387b235a0 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 3 Jun 2024 23:13:25 -0500
Subject: [PATCH 321/542] Require pyramid 2.x; remove 1.x-style auth policies

---
 setup.cfg          |  2 +-
 tailbone/app.py    | 11 ++------
 tailbone/auth.py   | 70 ++--------------------------------------------
 tailbone/webapi.py | 11 ++------
 4 files changed, 8 insertions(+), 86 deletions(-)

diff --git a/setup.cfg b/setup.cfg
index 48cc994a..811afc17 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -56,7 +56,7 @@ install_requires =
         paginate_sqlalchemy
         passlib
         Pillow
-        pyramid
+        pyramid>=2
         pyramid_beaker
         pyramid_deform
         pyramid_exclog
diff --git a/tailbone/app.py b/tailbone/app.py
index 0519f35b..5ca4c5c9 100644
--- a/tailbone/app.py
+++ b/tailbone/app.py
@@ -39,7 +39,7 @@ from pyramid.authentication import SessionAuthenticationPolicy
 from zope.sqlalchemy import register
 
 import tailbone.db
-from tailbone.auth import TailboneAuthorizationPolicy
+from tailbone.auth import TailboneSecurityPolicy
 from tailbone.config import csrf_token_name, csrf_header_name
 from tailbone.util import get_effective_theme, get_theme_template_path
 from tailbone.providers import get_all_providers
@@ -136,14 +136,7 @@ def make_pyramid_config(settings, configure_csrf=True):
     config.registry['rattail_config'] = rattail_config
 
     # configure user authorization / authentication
-    # TODO: security policy should become the default, for pyramid 2.x
-    if rattail_config.getbool('tailbone', 'pyramid.use_security_policy',
-                              usedb=False, default=False):
-        from tailbone.auth import TailboneSecurityPolicy
-        config.set_security_policy(TailboneSecurityPolicy())
-    else:
-        config.set_authorization_policy(TailboneAuthorizationPolicy())
-        config.set_authentication_policy(SessionAuthenticationPolicy())
+    config.set_security_policy(TailboneSecurityPolicy())
 
     # maybe require CSRF token protection
     if configure_csrf:
diff --git a/tailbone/auth.py b/tailbone/auth.py
index 0a5bd903..5a35caa6 100644
--- a/tailbone/auth.py
+++ b/tailbone/auth.py
@@ -30,9 +30,9 @@ import re
 from rattail.util import prettify, NOTSET
 
 from zope.interface import implementer
-from pyramid.interfaces import IAuthorizationPolicy
-from pyramid.security import remember, forget, Everyone, Authenticated
-from pyramid.authentication import SessionAuthenticationPolicy
+from pyramid.authentication import SessionAuthenticationHelper
+from pyramid.request import RequestLocalCache
+from pyramid.security import remember, forget
 
 from tailbone.db import Session
 
@@ -90,73 +90,9 @@ def set_session_timeout(request, timeout):
     request.session['_timeout'] = timeout or None
 
 
-class TailboneAuthenticationPolicy(SessionAuthenticationPolicy):
-    """
-    Custom authentication policy for Tailbone.
-
-    This is mostly Pyramid's built-in session-based policy, but adds
-    logic to accept Rattail User API Tokens in lieu of current user
-    being identified via the session.
-
-    Note that the traditional Tailbone web app does *not* use this
-    policy, only the Tailbone web API uses it by default.
-    """
-
-    def unauthenticated_userid(self, request):
-
-        # figure out userid from header token if present
-        credentials = request.headers.get('Authorization')
-        if credentials:
-            match = re.match(r'^Bearer (\S+)$', credentials)
-            if match:
-                token = match.group(1)
-                rattail_config = request.registry.settings.get('rattail_config')
-                app = rattail_config.get_app()
-                auth = app.get_auth_handler()
-                user = auth.authenticate_user_token(Session(), token)
-                if user:
-                    return user.uuid
-
-        # otherwise do normal session-based logic
-        return super().unauthenticated_userid(request)
-
-
-@implementer(IAuthorizationPolicy)
-class TailboneAuthorizationPolicy(object):
-
-    def permits(self, context, principals, permission):
-        config = context.request.rattail_config
-        model = config.get_model()
-        app = config.get_app()
-        auth = app.get_auth_handler()
-
-        for userid in principals:
-            if userid not in (Everyone, Authenticated):
-                if context.request.user and context.request.user.uuid == userid:
-                    return context.request.has_perm(permission)
-                else:
-                    # this is pretty rare, but can happen in dev after
-                    # re-creating the database, which means new user uuids.
-                    # TODO: the odds of this query returning a user in that
-                    # case, are probably nil, and we should just skip this bit?
-                    user = Session.get(model.User, userid)
-                    if user:
-                        if auth.has_permission(Session(), user, permission):
-                            return True
-        if Everyone in principals:
-            return auth.has_permission(Session(), None, permission)
-        return False
-
-    def principals_allowed_by_permission(self, context, permission):
-        raise NotImplementedError
-
-
 class TailboneSecurityPolicy:
 
     def __init__(self, api_mode=False):
-        from pyramid.authentication import SessionAuthenticationHelper
-        from pyramid.request import RequestLocalCache
-
         self.api_mode = api_mode
         self.session_helper = SessionAuthenticationHelper()
         self.identity_cache = RequestLocalCache(self.load_identity)
diff --git a/tailbone/webapi.py b/tailbone/webapi.py
index 70600e79..1c2fa106 100644
--- a/tailbone/webapi.py
+++ b/tailbone/webapi.py
@@ -30,7 +30,7 @@ from cornice.renderer import CorniceRenderer
 from pyramid.config import Configurator
 
 from tailbone import app
-from tailbone.auth import TailboneAuthenticationPolicy, TailboneAuthorizationPolicy
+from tailbone.auth import TailboneSecurityPolicy
 from tailbone.providers import get_all_providers
 
 
@@ -50,14 +50,7 @@ def make_pyramid_config(settings):
     pyramid_config = Configurator(settings=settings, root_factory=app.Root)
 
     # configure user authorization / authentication
-    # TODO: security policy should become the default, for pyramid 2.x
-    if rattail_config.getbool('tailbone', 'pyramid.use_security_policy',
-                              usedb=False, default=False):
-        from tailbone.auth import TailboneSecurityPolicy
-        pyramid_config.set_security_policy(TailboneSecurityPolicy(api_mode=True))
-    else:
-        pyramid_config.set_authentication_policy(TailboneAuthenticationPolicy())
-        pyramid_config.set_authorization_policy(TailboneAuthorizationPolicy())
+    pyramid_config.set_security_policy(TailboneSecurityPolicy(api_mode=True))
 
     # always require CSRF token protection
     pyramid_config.set_default_csrf_options(require_csrf=True,

From 6a7c06d26ece23cb4003599a5d641fcadc8a91c0 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 3 Jun 2024 23:16:00 -0500
Subject: [PATCH 322/542] Remove version cap for deform

see also commit 95dd8d83dc7af0aadf4d630fe3dd3646312bb181 where i first
added the version cap; it mentions an error that i am not sure how to
reproduce.  so we'll see if there really is still an error..or if it
has since fixed itself
---
 setup.cfg | 5 +----
 1 file changed, 1 insertion(+), 4 deletions(-)

diff --git a/setup.cfg b/setup.cfg
index 811afc17..5f46bc5c 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -39,15 +39,12 @@ classifiers =
 
 [options]
 install_requires =
-
-        # TODO: remove once their bug is fixed?  idk what this is about yet...
-        deform<2.0.15
-
         asgiref
         colander
         ColanderAlchemy
         cornice
         cornice-swagger
+        deform
         humanize
         Mako
         markdown

From 00e2af1561e57667a5ea0f99cda2c58da378bcfe Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 4 Jun 2024 01:05:05 -0500
Subject: [PATCH 323/542] Set explicit referrer when changing app theme

to include url #hash value if there is one, so switching theme is more
seamless from the view profile page
---
 tailbone/templates/base.mako                   |  2 ++
 tailbone/templates/themes/butterball/base.mako |  2 ++
 tailbone/views/common.py                       | 13 ++++++-------
 3 files changed, 10 insertions(+), 7 deletions(-)

diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako
index 1554d15d..f576473d 100644
--- a/tailbone/templates/base.mako
+++ b/tailbone/templates/base.mako
@@ -401,6 +401,7 @@
                 <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"
@@ -856,6 +857,7 @@
 
         % if expose_theme_picker and request.has_perm('common.change_app_theme'):
             globalTheme: ${json.dumps(theme)|n},
+            referrer: location.href,
         % endif
 
         % if can_edit_help:
diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako
index 420f23d9..9c8af78a 100644
--- a/tailbone/templates/themes/butterball/base.mako
+++ b/tailbone/templates/themes/butterball/base.mako
@@ -792,6 +792,7 @@
               % if expose_theme_picker and request.has_perm('common.change_app_theme'):
                   ${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"
@@ -1121,6 +1122,7 @@
 
         % if expose_theme_picker and request.has_perm('common.change_app_theme'):
             globalTheme: ${json.dumps(theme)|n},
+            referrer: location.href,
         % endif
 
         % if can_edit_help:
diff --git a/tailbone/views/common.py b/tailbone/views/common.py
index 35332b6b..266561fd 100644
--- a/tailbone/views/common.py
+++ b/tailbone/views/common.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,16 +24,14 @@
 Various common views
 """
 
+import importlib
 import os
 from collections import OrderedDict
 
 from rattail.batch import consume_batch_id
-from rattail.util import simple_error, import_module_path
+from rattail.util import simple_error
 from rattail.files import resource_path
 
-from pyramid import httpexceptions
-from pyramid.response import Response
-
 from tailbone import forms
 from tailbone.forms.common import Feedback
 from tailbone.db import Session
@@ -110,7 +108,7 @@ class CommonView(View):
             return self.project_version
 
         pkg = self.rattail_config.app_package()
-        mod = import_module_path(pkg)
+        mod = importlib.import_module(pkg)
         return mod.__version__
 
     def exception(self):
@@ -155,7 +153,8 @@ class CommonView(View):
                 self.request.session.flash(msg, 'error')
             else:
                 self.request.session.flash("App theme has been changed to: {}".format(theme))
-        return self.redirect(self.request.get_referrer())
+        referrer = self.request.params.get('referrer') or self.request.get_referrer()
+        return self.redirect(referrer)
 
     def change_db_engine(self):
         """

From 10aac388f01e410977cf8f51d40ac1f868053bba Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 4 Jun 2024 17:15:29 -0500
Subject: [PATCH 324/542] Add `<b-tooltip>` component shim

---
 .../themes/butterball/buefy-components.mako   | 20 +++++++++++++++++++
 1 file changed, 20 insertions(+)

diff --git a/tailbone/templates/themes/butterball/buefy-components.mako b/tailbone/templates/themes/butterball/buefy-components.mako
index f44f30ad..51a0deb9 100644
--- a/tailbone/templates/themes/butterball/buefy-components.mako
+++ b/tailbone/templates/themes/butterball/buefy-components.mako
@@ -19,6 +19,7 @@
   ${self.make_b_step_item_component()}
   ${self.make_b_table_component()}
   ${self.make_b_table_column_component()}
+  ${self.make_b_tooltip_component()}
   ${self.make_once_button_component()}
 </%def>
 
@@ -662,6 +663,25 @@
   <% request.register_component('b-table-column', 'BTableColumn') %>
 </%def>
 
+<%def name="make_b_tooltip_component()">
+  <script type="text/x-template" id="b-tooltip-template">
+    <o-tooltip :label="label"
+               :multiline="multilined">
+      <slot />
+    </o-tooltip>
+  </script>
+  <script>
+    const BTooltip = {
+        template: '#b-tooltip-template',
+        props: {
+            label: String,
+            multilined: Boolean,
+        },
+    }
+  </script>
+  <% request.register_component('b-tooltip', 'BTooltip') %>
+</%def>
+
 <%def name="make_once_button_component()">
   <script type="text/x-template" id="once-button-template">
     <b-button :type="type"

From d02bf0e5c7c4e68d0977789b257ff2278cfdd5cd Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 4 Jun 2024 17:15:42 -0500
Subject: [PATCH 325/542] Include extra styles from `base_meta` template for
 butterball

---
 tailbone/templates/themes/butterball/base.mako | 1 +
 1 file changed, 1 insertion(+)

diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako
index 9c8af78a..3f0253ce 100644
--- a/tailbone/templates/themes/butterball/base.mako
+++ b/tailbone/templates/themes/butterball/base.mako
@@ -318,6 +318,7 @@
     /* } */
 
   </style>
+  ${base_meta.extra_styles()}
 </%def>
 
 <%def name="make_feedback_component()">

From da6ccf4425b778d80d682626ba4b5ffb5470f036 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 4 Jun 2024 17:16:57 -0500
Subject: [PATCH 326/542] Fix product lookup component, per butterball

---
 tailbone/templates/products/lookup.mako | 32 +++++++++++++++++++------
 1 file changed, 25 insertions(+), 7 deletions(-)

diff --git a/tailbone/templates/products/lookup.mako b/tailbone/templates/products/lookup.mako
index 48206de1..7997eb7d 100644
--- a/tailbone/templates/products/lookup.mako
+++ b/tailbone/templates/products/lookup.mako
@@ -56,9 +56,7 @@
             <b-field grouped>
 
               <b-input v-model="searchTerm" 
-                       ref="searchTermInput"
-                       @keydown.native="searchTermInputKeydown">
-              </b-input>
+                       ref="searchTermInput" />
 
               <b-button class="control"
                         type="is-primary"
@@ -161,10 +159,19 @@
               <${b}-table-column label="Actions"
                               v-slot="props">
                 <a :href="props.row.url"
-                   target="_blank"
-                   class="grid-action">
-                  <i class="fas fa-external-link-alt"></i>
-                  View
+                   % if not request.use_oruga:
+                       class="grid-action"
+                   % endif
+                   target="_blank">
+                  % if request.use_oruga:
+                      <span class="icon-text">
+                        <o-icon icon="external-link-alt" />
+                        <span>View</span>
+                      </span>
+                  % else:
+                      <i class="fas fa-external-link-alt"></i>
+                      View
+                  % endif
                 </a>
               </${b}-table-column>
 
@@ -236,6 +243,7 @@
                 lookupShowDialog: false,
 
                 searchTerm: null,
+                searchTermInputElement: null,
                 searchTermLastUsed: null,
 
                 searchProductKey: true,
@@ -250,6 +258,16 @@
                 searchResultSelected: null,
             }
         },
+
+        mounted() {
+            this.searchTermInputElement = this.$refs.searchTermInput.$el.querySelector('input')
+            this.searchTermInputElement.addEventListener('keydown', this.searchTermInputKeydown)
+        },
+
+        beforeDestroy() {
+            this.searchTermInputElement.removeEventListener('keydown', this.searchTermInputKeydown)
+        },
+
         methods: {
 
             focus() {

From 22aceb4d67e184b5a878364489e1858b5eabc668 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 4 Jun 2024 17:28:07 -0500
Subject: [PATCH 327/542] Include butterball theme by default for new apps

but it is not "the" default yet..
---
 tailbone/config.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/tailbone/config.py b/tailbone/config.py
index 9326a3cb..ee906149 100644
--- a/tailbone/config.py
+++ b/tailbone/config.py
@@ -49,7 +49,7 @@ class ConfigExtension(BaseExtension):
         configure_session(config, Session)
 
         # provide default theme selection
-        config.setdefault('tailbone', 'themes.keys', 'default, falafel')
+        config.setdefault('tailbone', 'themes.keys', 'default, butterball')
         config.setdefault('tailbone', 'themes.expose_picker', 'true')
 
 

From c1892734711401c5bd4cb6c0bc92b72ef1c6870c Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 4 Jun 2024 21:12:44 -0500
Subject: [PATCH 328/542] Update changelog

---
 CHANGES.rst          | 18 ++++++++++++++++++
 tailbone/_version.py |  2 +-
 2 files changed, 19 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index a3be0af8..c6809592 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -5,6 +5,24 @@ CHANGELOG
 Unreleased
 ----------
 
+0.10.12 (2024-06-04)
+--------------------
+
+* Require pyramid 2.x; remove 1.x-style auth policies.
+
+* Remove version cap for deform.
+
+* Set explicit referrer when changing app theme.
+
+* Add ``<b-tooltip>`` component shim.
+
+* Include extra styles from ``base_meta`` template for butterball.
+
+* Fix product lookup component, per butterball.
+
+* Include butterball theme by default for new apps.
+
+
 0.10.11 (2024-06-03)
 --------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index d24c3b8e..2af82b6d 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.10.11'
+__version__ = '0.10.12'

From 1afc70e788a650ccb4e29d984b008bd307fa6211 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 4 Jun 2024 22:11:51 -0500
Subject: [PATCH 329/542] Remove old/unused scaffold for use with `pcreate`

we now have a better Generate Project feature
---
 setup.cfg             |  3 ---
 tailbone/scaffolds.py | 45 -------------------------------------------
 2 files changed, 48 deletions(-)
 delete mode 100644 tailbone/scaffolds.py

diff --git a/setup.cfg b/setup.cfg
index 5f46bc5c..6184c7c2 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -98,6 +98,3 @@ rattail.cleaners =
 
 rattail.config.extensions =
         tailbone = tailbone.config:ConfigExtension
-
-pyramid.scaffold =
-        rattail = tailbone.scaffolds:RattailTemplate
diff --git a/tailbone/scaffolds.py b/tailbone/scaffolds.py
deleted file mode 100644
index 10bf9640..00000000
--- a/tailbone/scaffolds.py
+++ /dev/null
@@ -1,45 +0,0 @@
-# -*- coding: utf-8 -*-
-################################################################################
-#
-#  Rattail -- Retail Software Framework
-#  Copyright © 2010-2017 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/>.
-#
-################################################################################
-"""
-Pyramid scaffold templates
-"""
-
-from __future__ import unicode_literals, absolute_import
-
-from rattail.files import resource_path
-from rattail.util import prettify
-
-from pyramid.scaffolds import PyramidTemplate
-
-
-class RattailTemplate(PyramidTemplate):
-    _template_dir = resource_path('rattail:data/project')
-    summary = "Starter project based on Rattail / Tailbone"
-
-    def pre(self, command, output_dir, vars):
-        """
-        Adds some more variables to the template context.
-        """
-        vars['project_title'] = prettify(vars['project'])
-        vars['package_title'] = vars['package'].capitalize()
-        return super(RattailTemplate, self).pre(command, output_dir, vars)

From d9911cf23d5864a772d83777c0605c7040382120 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 5 Jun 2024 23:04:45 -0500
Subject: [PATCH 330/542] Add 'fanstatic' support for sake of libcache assets

for vue.js and oruga etc.

we don't want to include files in tailbone since they are apt to
change over time, and probably need to use different versions for
different apps etc.

much may need to change yet, this is a first attempt but so far it
seems quite promising
---
 setup.cfg        |  1 +
 tailbone/app.py  |  2 ++
 tailbone/util.py | 21 +++++++++++++++++++++
 3 files changed, 24 insertions(+)

diff --git a/setup.cfg b/setup.cfg
index 6184c7c2..50c057f9 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -57,6 +57,7 @@ install_requires =
         pyramid_beaker
         pyramid_deform
         pyramid_exclog
+        pyramid_fanstatic
         pyramid_mako
         pyramid_retry
         pyramid_tm
diff --git a/tailbone/app.py b/tailbone/app.py
index 5ca4c5c9..b0160bd3 100644
--- a/tailbone/app.py
+++ b/tailbone/app.py
@@ -129,6 +129,7 @@ def make_pyramid_config(settings, configure_csrf=True):
         # we want the new themes feature!
         establish_theme(settings)
 
+        settings.setdefault('fanstatic.versioning', 'true')
         settings.setdefault('pyramid_deform.template_search_path', 'tailbone:templates/deform')
         config = Configurator(settings=settings, root_factory=Root)
 
@@ -147,6 +148,7 @@ def make_pyramid_config(settings, configure_csrf=True):
     # Bring in some Pyramid goodies.
     config.include('tailbone.beaker')
     config.include('pyramid_deform')
+    config.include('pyramid_fanstatic')
     config.include('pyramid_mako')
     config.include('pyramid_tm')
 
diff --git a/tailbone/util.py b/tailbone/util.py
index 7d838541..9a993176 100644
--- a/tailbone/util.py
+++ b/tailbone/util.py
@@ -25,6 +25,7 @@ Utilities
 """
 
 import datetime
+import importlib
 import logging
 import warnings
 
@@ -195,6 +196,12 @@ def get_liburl(request, key, fallback=True):
 
     version = get_libver(request, key)
 
+    static = config.get('tailbone.static_libcache.module')
+    if static:
+        static = importlib.import_module(static)
+        needed = request.environ['fanstatic.needed']
+        liburl = needed.library_url(static.libcache) + '/'
+
     if key == 'buefy':
         return 'https://unpkg.com/buefy@{}/dist/buefy.min.js'.format(version)
 
@@ -211,24 +218,38 @@ def get_liburl(request, key, fallback=True):
         return 'https://use.fontawesome.com/releases/v{}/js/all.js'.format(version)
 
     elif key == 'bb_vue':
+        if static and hasattr(static, 'bb_vue_js'):
+            return liburl + static.bb_vue_js.relpath
         return f'https://unpkg.com/vue@{version}/dist/vue.esm-browser.prod.js'
 
     elif key == 'bb_oruga':
+        if static and hasattr(static, 'bb_oruga_js'):
+            return liburl + static.bb_oruga_js.relpath
         return f'https://unpkg.com/@oruga-ui/oruga-next@{version}/dist/oruga.mjs'
 
     elif key == 'bb_oruga_bulma':
+        if static and hasattr(static, 'bb_oruga_bulma_js'):
+            return liburl + static.bb_oruga_bulma_js.relpath
         return f'https://unpkg.com/@oruga-ui/theme-bulma@{version}/dist/bulma.mjs'
 
     elif key == 'bb_oruga_bulma_css':
+        if static and hasattr(static, 'bb_oruga_bulma_css'):
+            return liburl + static.bb_oruga_bulma_css.relpath
         return f'https://unpkg.com/@oruga-ui/theme-bulma@{version}/dist/bulma.css'
 
     elif key == 'bb_fontawesome_svg_core':
+        if static and hasattr(static, 'bb_fontawesome_svg_core_js'):
+            return liburl + static.bb_fontawesome_svg_core_js.relpath
         return f'https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-svg-core@{version}/+esm'
 
     elif key == 'bb_free_solid_svg_icons':
+        if static and hasattr(static, 'bb_free_solid_svg_icons_js'):
+            return liburl + static.bb_free_solid_svg_icons_js.relpath
         return f'https://cdn.jsdelivr.net/npm/@fortawesome/free-solid-svg-icons@{version}/+esm'
 
     elif key == 'bb_vue_fontawesome':
+        if static and hasattr(static, 'bb_vue_fontawesome_js'):
+            return liburl + static.bb_vue_fontawesome_js.relpath
         return f'https://cdn.jsdelivr.net/npm/@fortawesome/vue-fontawesome@{version}/+esm'
 
 

From ce290f5f8b400ba0dc2fa5ec51a145d74adf7a8b Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 6 Jun 2024 15:30:48 -0500
Subject: [PATCH 331/542] Update changelog

---
 CHANGES.rst          | 8 ++++++++
 tailbone/_version.py | 2 +-
 2 files changed, 9 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index c6809592..cc04273e 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -5,6 +5,14 @@ CHANGELOG
 Unreleased
 ----------
 
+0.10.13 (2024-06-06)
+--------------------
+
+* Remove old/unused scaffold for use with ``pcreate``.
+
+* Add 'fanstatic' support for sake of libcache assets.
+
+
 0.10.12 (2024-06-04)
 --------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 2af82b6d..1daf8e32 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.10.12'
+__version__ = '0.10.13'

From f6f2a53a0c7a542ead7bb5dc9a8414e6057ed774 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 6 Jun 2024 20:33:36 -0500
Subject: [PATCH 332/542] Use `pkg_resources` to determine package versions

and always add `app_version` to global template context.  this was for
sake of "About This App v1.0.0" style links in custom page footers
---
 tailbone/subscribers.py  | 5 +++++
 tailbone/views/common.py | 5 ++---
 2 files changed, 7 insertions(+), 3 deletions(-)

diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py
index 42d3cab7..59ef64dc 100644
--- a/tailbone/subscribers.py
+++ b/tailbone/subscribers.py
@@ -28,6 +28,7 @@ import six
 import json
 import datetime
 import logging
+import pkg_resources
 import warnings
 from collections import OrderedDict
 
@@ -168,7 +169,11 @@ def before_render(event):
 
     renderer_globals = event
     renderer_globals['rattail_app'] = request.rattail_config.get_app()
+
     renderer_globals['app_title'] = request.rattail_config.app_title()
+    pkg = rattail_config.app_package()
+    renderer_globals['app_version'] = pkg_resources.get_distribution(pkg).version
+
     renderer_globals['h'] = helpers
     renderer_globals['url'] = request.route_url
     renderer_globals['rattail'] = rattail
diff --git a/tailbone/views/common.py b/tailbone/views/common.py
index 266561fd..58346f3b 100644
--- a/tailbone/views/common.py
+++ b/tailbone/views/common.py
@@ -24,8 +24,8 @@
 Various common views
 """
 
-import importlib
 import os
+import pkg_resources
 from collections import OrderedDict
 
 from rattail.batch import consume_batch_id
@@ -108,8 +108,7 @@ class CommonView(View):
             return self.project_version
 
         pkg = self.rattail_config.app_package()
-        mod = importlib.import_module(pkg)
-        return mod.__version__
+        return pkg_resources.get_distribution(pkg).version
 
     def exception(self):
         """

From 0491d8517c59379bf6e272ba1dc93245e6c82930 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 6 Jun 2024 23:04:47 -0500
Subject: [PATCH 333/542] Update changelog

---
 CHANGES.rst          | 6 ++++++
 tailbone/_version.py | 2 +-
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index cc04273e..178135e4 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -5,6 +5,12 @@ CHANGELOG
 Unreleased
 ----------
 
+0.10.14 (2024-06-06)
+--------------------
+
+* Use ``pkg_resources`` to determine package versions.
+
+
 0.10.13 (2024-06-06)
 --------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 1daf8e32..7a64f8d0 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.10.13'
+__version__ = '0.10.14'

From 94d7836321b34d9892f3deace05c7d369c07808f Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 6 Jun 2024 23:05:40 -0500
Subject: [PATCH 334/542] Ignore dist folder

---
 .gitignore | 1 +
 1 file changed, 1 insertion(+)

diff --git a/.gitignore b/.gitignore
index 906dc226..03545d1a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
 .coverage
 .tox/
+dist/
 docs/_build/
 htmlcov/
 Tailbone.egg-info/

From 610e1666c01b48a5aae4990d51aa1cafc04d60ce Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 7 Jun 2024 10:07:31 -0500
Subject: [PATCH 335/542] Revert "Use `pkg_resources` to determine package
 versions"

This reverts commit f6f2a53a0c7a542ead7bb5dc9a8414e6057ed774.
---
 tailbone/subscribers.py  | 5 -----
 tailbone/views/common.py | 5 +++--
 2 files changed, 3 insertions(+), 7 deletions(-)

diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py
index 59ef64dc..42d3cab7 100644
--- a/tailbone/subscribers.py
+++ b/tailbone/subscribers.py
@@ -28,7 +28,6 @@ import six
 import json
 import datetime
 import logging
-import pkg_resources
 import warnings
 from collections import OrderedDict
 
@@ -169,11 +168,7 @@ def before_render(event):
 
     renderer_globals = event
     renderer_globals['rattail_app'] = request.rattail_config.get_app()
-
     renderer_globals['app_title'] = request.rattail_config.app_title()
-    pkg = rattail_config.app_package()
-    renderer_globals['app_version'] = pkg_resources.get_distribution(pkg).version
-
     renderer_globals['h'] = helpers
     renderer_globals['url'] = request.route_url
     renderer_globals['rattail'] = rattail
diff --git a/tailbone/views/common.py b/tailbone/views/common.py
index 58346f3b..266561fd 100644
--- a/tailbone/views/common.py
+++ b/tailbone/views/common.py
@@ -24,8 +24,8 @@
 Various common views
 """
 
+import importlib
 import os
-import pkg_resources
 from collections import OrderedDict
 
 from rattail.batch import consume_batch_id
@@ -108,7 +108,8 @@ class CommonView(View):
             return self.project_version
 
         pkg = self.rattail_config.app_package()
-        return pkg_resources.get_distribution(pkg).version
+        mod = importlib.import_module(pkg)
+        return mod.__version__
 
     def exception(self):
         """

From a849d8452b6f9ece02a3df231b2e520caa5fc9f8 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 7 Jun 2024 10:25:14 -0500
Subject: [PATCH 336/542] Update changelog

---
 CHANGES.rst | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/CHANGES.rst b/CHANGES.rst
index 178135e4..2295867e 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -5,6 +5,12 @@ CHANGELOG
 Unreleased
 ----------
 
+0.10.15 (unreleased)
+--------------------
+
+* Do *not* Use ``pkg_resources`` to determine package versions.
+
+
 0.10.14 (2024-06-06)
 --------------------
 

From 7c3d5b46f38876c70d6114b23e678df5e810f6c6 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 7 Jun 2024 10:25:48 -0500
Subject: [PATCH 337/542] Update changelog

---
 CHANGES.rst          | 2 +-
 tailbone/_version.py | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 2295867e..a711be5f 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -5,7 +5,7 @@ CHANGELOG
 Unreleased
 ----------
 
-0.10.15 (unreleased)
+0.10.15 (2024-06-07)
 --------------------
 
 * Do *not* Use ``pkg_resources`` to determine package versions.
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 7a64f8d0..f6e50fc4 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.10.14'
+__version__ = '0.10.15'

From b8ace1eb98b76b93025f84311201a4497076dc06 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 9 Jun 2024 23:07:52 -0500
Subject: [PATCH 338/542] fix: avoid deprecated config methods for app/node
 title

---
 tailbone/api/common.py            | 11 ++++++-----
 tailbone/subscribers.py           |  5 +++--
 tailbone/templates/base_meta.mako |  2 +-
 tailbone/views/auth.py            |  3 ++-
 tailbone/views/common.py          | 11 +++++++----
 tailbone/views/settings.py        |  3 ++-
 tailbone/views/upgrades.py        |  3 ++-
 7 files changed, 23 insertions(+), 15 deletions(-)

diff --git a/tailbone/api/common.py b/tailbone/api/common.py
index 30dfeab1..1dcaff08 100644
--- a/tailbone/api/common.py
+++ b/tailbone/api/common.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,8 +27,6 @@ Tailbone Web API - "Common" Views
 from collections import OrderedDict
 
 import rattail
-from rattail.db import model
-from rattail.mail import send_email
 
 from cornice import Service
 from cornice.service import get_services
@@ -66,7 +64,8 @@ class CommonView(APIView):
         }
 
     def get_project_title(self):
-        return self.rattail_config.app_title(default="Tailbone")
+        app = self.get_rattail_app()
+        return app.get_title()
 
     def get_project_version(self):
         import tailbone
@@ -87,6 +86,8 @@ class CommonView(APIView):
         """
         View to handle user feedback form submits.
         """
+        app = self.get_rattail_app()
+        model = self.model
         # TODO: this logic was copied from tailbone.views.common and is largely
         # identical; perhaps should merge somehow?
         schema = Feedback().bind(session=Session())
@@ -106,7 +107,7 @@ class CommonView(APIView):
 
             data['client_ip'] = self.request.client_addr
             email_key = data['email_key'] or self.feedback_email_key
-            send_email(self.rattail_config, email_key, data=data)
+            app.send_email(email_key, data=data)
             return {'ok': True}
 
         return {'error': "Form did not validate!"}
diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py
index 42d3cab7..bc851629 100644
--- a/tailbone/subscribers.py
+++ b/tailbone/subscribers.py
@@ -165,10 +165,11 @@ def before_render(event):
 
     request = event.get('request') or threadlocal.get_current_request()
     rattail_config = request.rattail_config
+    app = rattail_config.get_app()
 
     renderer_globals = event
-    renderer_globals['rattail_app'] = request.rattail_config.get_app()
-    renderer_globals['app_title'] = request.rattail_config.app_title()
+    renderer_globals['rattail_app'] = app
+    renderer_globals['app_title'] = app.get_title()
     renderer_globals['h'] = helpers
     renderer_globals['url'] = request.route_url
     renderer_globals['rattail'] = rattail
diff --git a/tailbone/templates/base_meta.mako b/tailbone/templates/base_meta.mako
index 07b13e61..00cfdfe9 100644
--- a/tailbone/templates/base_meta.mako
+++ b/tailbone/templates/base_meta.mako
@@ -1,6 +1,6 @@
 ## -*- coding: utf-8; -*-
 
-<%def name="app_title()">${request.rattail_config.node_title(default="Rattail")}</%def>
+<%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>
 
diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py
index 0f0d1687..7ecdc6cd 100644
--- a/tailbone/views/auth.py
+++ b/tailbone/views/auth.py
@@ -92,6 +92,7 @@ class AuthenticationView(View):
         """
         The login view, responsible for displaying and handling the login form.
         """
+        app = self.get_rattail_app()
         referrer = self.request.get_referrer(default=self.request.route_url('home'))
 
         # redirect if already logged in
@@ -133,7 +134,7 @@ class AuthenticationView(View):
             'form': form,
             'referrer': referrer,
             'image_url': image_url,
-            'index_title': self.rattail_config.node_title(),
+            'index_title': app.get_node_title(),
             'help_url': global_help_url(self.rattail_config),
         }
 
diff --git a/tailbone/views/common.py b/tailbone/views/common.py
index 266561fd..25eb7dee 100644
--- a/tailbone/views/common.py
+++ b/tailbone/views/common.py
@@ -50,6 +50,7 @@ class CommonView(View):
         """
         Home page view.
         """
+        app = self.get_rattail_app()
         if not self.request.user:
             if self.rattail_config.getbool('tailbone', 'login_is_home', default=True):
                 raise self.redirect(self.request.route_url('login'))
@@ -60,7 +61,7 @@ class CommonView(View):
 
         context = {
             'image_url': image_url,
-            'index_title': self.rattail_config.node_title(),
+            'index_title': app.get_node_title(),
             'help_url': global_help_url(self.rattail_config),
         }
 
@@ -99,7 +100,8 @@ class CommonView(View):
         return response
 
     def get_project_title(self):
-        return self.rattail_config.app_title()
+        app = self.get_rattail_app()
+        return app.get_title()
 
     def get_project_version(self):
 
@@ -121,11 +123,12 @@ class CommonView(View):
         """
         Generic view to show "about project" info page.
         """
+        app = self.get_rattail_app()
         return {
             'project_title': self.get_project_title(),
             'project_version': self.get_project_version(),
             'packages': self.get_packages(),
-            'index_title': self.rattail_config.node_title(),
+            'index_title': app.get_node_title(),
         }
 
     def get_packages(self):
@@ -209,7 +212,7 @@ class CommonView(View):
             raise self.forbidden()
 
         app = self.get_rattail_app()
-        app_title = self.rattail_config.app_title()
+        app_title = app.get_title()
         poser_handler = app.get_poser_handler()
         poser_dir = poser_handler.get_default_poser_dir()
         poser_dir_exists = os.path.isdir(poser_dir)
diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py
index cce5e53d..8d389530 100644
--- a/tailbone/views/settings.py
+++ b/tailbone/views/settings.py
@@ -67,8 +67,9 @@ class AppInfoView(MasterView):
     ]
 
     def get_index_title(self):
+        app = self.get_rattail_app()
         return "{} for {}".format(self.get_model_title_plural(),
-                                  self.rattail_config.app_title())
+                                  app.get_title())
 
     def get_data(self, session=None):
         pip = os.path.join(sys.prefix, 'bin', 'pip')
diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py
index a281062e..3276b64d 100644
--- a/tailbone/views/upgrades.py
+++ b/tailbone/views/upgrades.py
@@ -147,10 +147,11 @@ class UpgradeView(MasterView):
 
     def template_kwargs_view(self, **kwargs):
         kwargs = super().template_kwargs_view(**kwargs)
+        app = self.get_rattail_app()
         model = self.model
         upgrade = kwargs['instance']
 
-        kwargs['system_title'] = self.rattail_config.app_title()
+        kwargs['system_title'] = app.get_title()
         if upgrade.system:
             system = self.upgrade_handler.get_system(upgrade.system)
             if system:

From 2c2727bf6632febc6ec823182498e803c9fd5617 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 10 Jun 2024 09:07:10 -0500
Subject: [PATCH 339/542] feat: standardize how app, package versions are
 determined

---
 tailbone/api/common.py   | 11 +++++------
 tailbone/beaker.py       |  8 ++++----
 tailbone/subscribers.py  |  1 +
 tailbone/views/common.py | 13 +++++--------
 4 files changed, 15 insertions(+), 18 deletions(-)

diff --git a/tailbone/api/common.py b/tailbone/api/common.py
index 1dcaff08..6cacfb06 100644
--- a/tailbone/api/common.py
+++ b/tailbone/api/common.py
@@ -26,13 +26,12 @@ Tailbone Web API - "Common" Views
 
 from collections import OrderedDict
 
-import rattail
+from rattail.util import get_pkg_version
 
 from cornice import Service
 from cornice.service import get_services
 from cornice_swagger import CorniceSwagger
 
-import tailbone
 from tailbone import forms
 from tailbone.forms.common import Feedback
 from tailbone.api import APIView, api
@@ -68,8 +67,8 @@ class CommonView(APIView):
         return app.get_title()
 
     def get_project_version(self):
-        import tailbone
-        return tailbone.__version__
+        app = self.get_rattail_app()
+        return app.get_version()
 
     def get_packages(self):
         """
@@ -77,8 +76,8 @@ class CommonView(APIView):
         'about' page.
         """
         return OrderedDict([
-            ('rattail', rattail.__version__),
-            ('Tailbone', tailbone.__version__),
+            ('rattail', get_pkg_version('rattail')),
+            ('Tailbone', get_pkg_version('Tailbone')),
         ])
 
     @api
diff --git a/tailbone/beaker.py b/tailbone/beaker.py
index b5d592f1..25a450df 100644
--- a/tailbone/beaker.py
+++ b/tailbone/beaker.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -27,11 +27,11 @@ Note that most of the code for this module was copied from the beaker and
 pyramid_beaker projects.
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import time
 from pkg_resources import parse_version
 
+from rattail.util import get_pkg_version
+
 import beaker
 from beaker.session import Session
 from beaker.util import coerce_session_params
@@ -49,7 +49,7 @@ class TailboneSession(Session):
         "Loads the data from this session from persistent storage"
 
         # are we using older version of beaker?
-        old_beaker = parse_version(beaker.__version__) < parse_version('1.12')
+        old_beaker = parse_version(get_pkg_version('beaker')) < parse_version('1.12')
 
         self.namespace = self.namespace_class(self.id,
             data_dir=self.data_dir,
diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py
index bc851629..3fcd1017 100644
--- a/tailbone/subscribers.py
+++ b/tailbone/subscribers.py
@@ -170,6 +170,7 @@ def before_render(event):
     renderer_globals = event
     renderer_globals['rattail_app'] = app
     renderer_globals['app_title'] = app.get_title()
+    renderer_globals['app_version'] = app.get_version()
     renderer_globals['h'] = helpers
     renderer_globals['url'] = request.route_url
     renderer_globals['rattail'] = rattail
diff --git a/tailbone/views/common.py b/tailbone/views/common.py
index 25eb7dee..3c4b659b 100644
--- a/tailbone/views/common.py
+++ b/tailbone/views/common.py
@@ -24,12 +24,11 @@
 Various common views
 """
 
-import importlib
 import os
 from collections import OrderedDict
 
 from rattail.batch import consume_batch_id
-from rattail.util import simple_error
+from rattail.util import get_pkg_version, simple_error
 from rattail.files import resource_path
 
 from tailbone import forms
@@ -109,9 +108,8 @@ class CommonView(View):
         if hasattr(self, 'project_version'):
             return self.project_version
 
-        pkg = self.rattail_config.app_package()
-        mod = importlib.import_module(pkg)
-        return mod.__version__
+        app = self.get_rattail_app()
+        return app.get_version()
 
     def exception(self):
         """
@@ -136,10 +134,9 @@ class CommonView(View):
         Should return the full set of packages which should be displayed on the
         'about' page.
         """
-        import rattail, tailbone
         return OrderedDict([
-            ('rattail', rattail.__version__),
-            ('Tailbone', tailbone.__version__),
+            ('rattail', get_pkg_version('rattail')),
+            ('Tailbone', get_pkg_version('Tailbone')),
         ])
 
     def change_theme(self):

From dd58c640fa2a626efb4fc6a729cedf368ba3668f Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 10 Jun 2024 11:11:06 -0500
Subject: [PATCH 340/542] Update changelog

---
 CHANGES.rst          | 7 +++++++
 tailbone/_version.py | 2 +-
 2 files changed, 8 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index a711be5f..ad65b7bf 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -5,6 +5,13 @@ CHANGELOG
 Unreleased
 ----------
 
+0.10.16 (2024-06-10)
+--------------------
+
+* fix: avoid deprecated config methods for app/node title
+* feat: standardize how app, package versions are determined
+
+
 0.10.15 (2024-06-07)
 --------------------
 
diff --git a/tailbone/_version.py b/tailbone/_version.py
index f6e50fc4..e1187ee4 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.10.15'
+__version__ = '0.10.16'

From 1402d437b5900aee406577696c5b02ae0281d5ba Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 10 Jun 2024 16:23:38 -0500
Subject: [PATCH 341/542] feat: switch from setup.cfg to pyproject.toml +
 hatchling

---
 .gitignore           |   2 +
 pyproject.toml       | 101 +++++++++++++++++++++++++++++++++++++++++++
 setup.cfg            | 101 -------------------------------------------
 setup.py             |  29 -------------
 tailbone/_version.py |   8 +++-
 tasks.py             |  13 +++++-
 6 files changed, 122 insertions(+), 132 deletions(-)
 create mode 100644 pyproject.toml
 delete mode 100644 setup.cfg
 delete mode 100644 setup.py

diff --git a/.gitignore b/.gitignore
index 03545d1a..b3006f90 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,5 @@
+*~
+*.pyc
 .coverage
 .tox/
 dist/
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 00000000..7c894886
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,101 @@
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+
+[project]
+name = "Tailbone"
+version = "0.10.16"
+description = "Backoffice Web Application for Rattail"
+readme = "README.rst"
+authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]
+license = {text = "GNU GPL v3+"}
+classifiers = [
+        "Development Status :: 4 - Beta",
+        "Environment :: Web Environment",
+        "Framework :: Pyramid",
+        "Intended Audience :: Developers",
+        "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
+        "Natural Language :: English",
+        "Operating System :: OS Independent",
+        "Programming Language :: Python",
+        "Programming Language :: Python :: 3",
+        "Programming Language :: Python :: 3.6",
+        "Programming Language :: Python :: 3.7",
+        "Programming Language :: Python :: 3.8",
+        "Programming Language :: Python :: 3.9",
+        "Programming Language :: Python :: 3.10",
+        "Programming Language :: Python :: 3.11",
+        "Topic :: Internet :: WWW/HTTP",
+        "Topic :: Office/Business",
+        "Topic :: Software Development :: Libraries :: Python Modules",
+]
+
+dependencies = [
+        "asgiref",
+        "colander",
+        "ColanderAlchemy",
+        "cornice",
+        "cornice-swagger",
+        "deform",
+        "humanize",
+        "Mako",
+        "markdown",
+        "openpyxl",
+        "paginate",
+        "paginate_sqlalchemy",
+        "passlib",
+        "Pillow",
+        "pyramid>=2",
+        "pyramid_beaker",
+        "pyramid_deform",
+        "pyramid_exclog",
+        "pyramid_fanstatic",
+        "pyramid_mako",
+        "pyramid_retry",
+        "pyramid_tm",
+        "rattail[db,bouncer]",
+        "six",
+        "sa-filters",
+        "simplejson",
+        "transaction",
+        "waitress",
+        "WebHelpers2",
+        "zope.sqlalchemy",
+]
+
+
+[project.optional-dependencies]
+docs = ["Sphinx", "sphinx-rtd-theme"]
+tests = ["coverage", "mock", "pytest", "pytest-cov"]
+
+
+[project.entry-points."paste.app_factory"]
+main = "tailbone.app:main"
+webapi = "tailbone.webapi:main"
+
+
+[project.entry-points."rattail.cleaners"]
+beaker = "tailbone.cleanup:BeakerCleaner"
+
+
+[project.entry-points."rattail.config.extensions"]
+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/CHANGES.rst"
+
+
+[tool.commitizen]
+version_provider = "pep621"
+tag_format = "v$version"
+update_changelog_on_bump = true
+
+
+# [tool.hatch.build.targets.wheel]
+# packages = ["corepos"]
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index 50c057f9..00000000
--- a/setup.cfg
+++ /dev/null
@@ -1,101 +0,0 @@
-# -*- coding: utf-8; -*-
-
-[nosetests]
-nocapture = 1
-cover-package = tailbone
-cover-erase = 1
-cover-html = 1
-cover-html-dir = htmlcov
-
-[metadata]
-name = Tailbone
-version = attr: tailbone.__version__
-author = Lance Edgar
-author_email = lance@edbob.org
-url = http://rattailproject.org/
-license = GNU GPL v3
-description = Backoffice Web Application for Rattail
-long_description = file: README.rst
-classifiers =
-        Development Status :: 4 - Beta
-        Environment :: Web Environment
-        Framework :: Pyramid
-        Intended Audience :: Developers
-        License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
-        Natural Language :: English
-        Operating System :: OS Independent
-        Programming Language :: Python
-        Programming Language :: Python :: 3
-        Programming Language :: Python :: 3.6
-        Programming Language :: Python :: 3.7
-        Programming Language :: Python :: 3.8
-        Programming Language :: Python :: 3.9
-        Programming Language :: Python :: 3.10
-        Programming Language :: Python :: 3.11
-        Topic :: Internet :: WWW/HTTP
-        Topic :: Office/Business
-        Topic :: Software Development :: Libraries :: Python Modules
-
-
-[options]
-install_requires =
-        asgiref
-        colander
-        ColanderAlchemy
-        cornice
-        cornice-swagger
-        deform
-        humanize
-        Mako
-        markdown
-        openpyxl
-        paginate
-        paginate_sqlalchemy
-        passlib
-        Pillow
-        pyramid>=2
-        pyramid_beaker
-        pyramid_deform
-        pyramid_exclog
-        pyramid_fanstatic
-        pyramid_mako
-        pyramid_retry
-        pyramid_tm
-        rattail[db,bouncer]
-        six
-        sa-filters
-        simplejson
-        transaction
-        waitress
-        WebHelpers2
-        zope.sqlalchemy
-
-tests_require = Tailbone[tests]
-test_suite = tests
-packages = find:
-include_package_data = True
-zip_safe = False
-
-
-[options.packages.find]
-exclude =
-        tests.*
-        tests
-
-
-[options.extras_require]
-docs = Sphinx; sphinx-rtd-theme
-tests = coverage; mock; pytest; pytest-cov
-
-
-[options.entry_points]
-
-paste.app_factory =
-        main = tailbone.app:main
-        webapi = tailbone.webapi:main
-
-rattail.cleaners =
-        beaker = tailbone.cleanup:BeakerCleaner
-
-rattail.config.extensions =
-        tailbone = tailbone.config:ConfigExtension
diff --git a/setup.py b/setup.py
deleted file mode 100644
index 5645ddff..00000000
--- a/setup.py
+++ /dev/null
@@ -1,29 +0,0 @@
-# -*- coding: utf-8; -*-
-################################################################################
-#
-#  Rattail -- Retail Software Framework
-#  Copyright © 2010-2023 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/>.
-#
-################################################################################
-"""
-Setup script for Tailbone
-"""
-
-from setuptools import setup
-
-setup()
diff --git a/tailbone/_version.py b/tailbone/_version.py
index e1187ee4..7095f6c8 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,9 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.10.16'
+try:
+    from importlib.metadata import version
+except ImportError:
+    from importlib_metadata import version
+
+
+__version__ = version('Tailbone')
diff --git a/tasks.py b/tasks.py
index fba0b699..e9f47ccd 100644
--- a/tasks.py
+++ b/tasks.py
@@ -25,13 +25,24 @@ Tasks for Tailbone
 """
 
 import os
+import re
 import shutil
 
 from invoke import task
 
 
 here = os.path.abspath(os.path.dirname(__file__))
-exec(open(os.path.join(here, 'tailbone', '_version.py')).read())
+__version__ = None
+pattern = re.compile(r'^version = "(\d+\.\d+\.\d+)"$')
+with open(os.path.join(here, 'pyproject.toml'), 'rt') as f:
+    for line in f:
+        line = line.rstrip('\n')
+        match = pattern.match(line)
+        if match:
+            __version__ = match.group(1)
+            break
+if not __version__:
+    raise RuntimeError("could not parse version!")
 
 
 @task

From f9cb6cb59bdd525540bc46fc85ff1450bc52d11f Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 10 Jun 2024 16:40:55 -0500
Subject: [PATCH 342/542] =?UTF-8?q?bump:=20version=200.10.16=20=E2=86=92?=
 =?UTF-8?q?=200.11.0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 CHANGELOG.md                       | 218 +++++++++++++++++++++++++++++
 CHANGES.rst => docs/OLDCHANGES.rst | 199 +-------------------------
 docs/changelog.rst                 |   8 ++
 docs/index.rst                     |   8 ++
 pyproject.toml                     |   4 +-
 5 files changed, 243 insertions(+), 194 deletions(-)
 create mode 100644 CHANGELOG.md
 rename CHANGES.rst => docs/OLDCHANGES.rst (97%)
 create mode 100644 docs/changelog.rst

diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 00000000..c51f3fda
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,218 @@
+
+# Changelog
+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.11.0 (2024-06-10)
+
+### Feat
+
+- switch from setup.cfg to pyproject.toml + hatchling
+
+## v0.10.16 (2024-06-10)
+
+### Feat
+
+- standardize how app, package versions are determined
+
+### Fix
+
+- avoid deprecated config methods for app/node title
+
+## v0.10.15 (2024-06-07)
+
+### Fix
+
+- do *not* Use `pkg_resources` to determine package versions
+
+## v0.10.14 (2024-06-06)
+
+### Fix
+
+- use `pkg_resources` to determine package versions
+
+## v0.10.13 (2024-06-06)
+
+### Feat
+
+- remove old/unused scaffold for use with `pcreate`
+
+- add 'fanstatic' support for sake of libcache assets
+
+## v0.10.12 (2024-06-04)
+
+### Feat
+
+- require pyramid 2.x; remove 1.x-style auth policies
+
+- remove version cap for deform
+
+- set explicit referrer when changing app theme
+
+- add `<b-tooltip>` component shim
+
+- include extra styles from `base_meta` template for butterball
+
+- include butterball theme by default for new apps
+
+### Fix
+
+- fix product lookup component, per butterball
+
+## v0.10.11 (2024-06-03)
+
+### Feat
+
+- fix vue3 refresh bugs for various views
+
+- fix grid bug for tempmon appliance view, per oruga
+
+- fix ordering worksheet generator, per butterball
+
+- fix inventory worksheet generator, per butterball
+
+## v0.10.10 (2024-06-03)
+
+### Feat
+
+- more butterball fixes for "view profile" template
+
+### Fix
+
+- fix focus for `<b-select>` shim component
+
+## v0.10.9 (2024-06-03)
+
+### Feat
+
+- let master view control context menu items for page
+
+- fix the "new custorder" page for butterball
+
+### Fix
+
+- fix panel style for PO vs. Invoice breakdown in receiving batch
+
+## v0.10.8 (2024-06-02)
+
+### Feat
+
+- add styling for checked grid rows, per oruga/butterball
+
+- fix product view template for oruga/butterball
+
+- allow per-user custom styles for butterball
+
+- use oruga 0.8.9 by default
+
+## v0.10.7 (2024-06-01)
+
+### Feat
+
+- add setting to allow decimal quantities for receiving
+
+- log error if registry has no rattail config
+
+- add column filters for import/export main grid
+
+- escape all unsafe html for grid data
+
+- add speedbumps for delete, set preferred email/phone in profile view
+
+- fix file upload widget for oruga
+
+### Fix
+
+- fix overflow when instance header title is too long (butterball)
+
+## v0.10.6 (2024-05-29)
+
+### Feat
+
+- add way to flag organic products within lookup dialog
+
+- expose db picker for butterball theme
+
+- expose quickie lookup for butterball theme
+
+- fix basic problems with people profile view, per butterball
+
+## v0.10.5 (2024-05-29)
+
+### Feat
+
+- add `<tailbone-timepicker>` component for oruga
+
+## v0.10.4 (2024-05-12)
+
+### Fix
+
+- fix styles for grid actions, per butterball
+
+## v0.10.3 (2024-05-10)
+
+### Fix
+
+- fix bug with grid date filters
+
+## v0.10.2 (2024-05-08)
+
+### Feat
+
+- remove version restriction for pyramid_beaker dependency
+
+- rename some attrs etc. for buefy components used with oruga
+
+- fix "tools" helper for receiving batch view, per oruga
+
+- more data type fixes for ``<tailbone-datepicker>``
+
+- fix "view receiving row" page, per oruga
+
+- tweak styles for grid action links, per butterball
+
+### Fix
+
+- fix employees grid when viewing department (per oruga)
+
+- fix login "enter" key behavior, per oruga
+
+- fix button text for autocomplete
+
+## v0.10.1 (2024-04-28)
+
+### Feat
+
+- sort list of available themes
+
+- update various icon names for oruga compatibility
+
+- show "View This" button when cloning a record
+
+- stop including 'falafel' as available theme
+
+### Fix
+
+- fix vertical alignment in main menu bar, for butterball
+
+- fix upgrade execution logic/UI per oruga
+
+## v0.10.0 (2024-04-28)
+
+This version bump is to reflect adding support for Vue 3 + Oruga via
+the 'butterball' theme.  There is likely more work to be done for that
+yet, but it mostly works at this point.
+
+### Feat
+
+- misc. template and view logic tweaks (applicable to all themes) for
+  better patterns, consistency etc.
+
+- add initial support for Vue 3 + Oruga, via "butterball" theme
+
+
+## Older Releases
+
+Please see `docs/OLDCHANGES.rst` for older release notes.
diff --git a/CHANGES.rst b/docs/OLDCHANGES.rst
similarity index 97%
rename from CHANGES.rst
rename to docs/OLDCHANGES.rst
index ad65b7bf..0a802f40 100644
--- a/CHANGES.rst
+++ b/docs/OLDCHANGES.rst
@@ -2,193 +2,8 @@
 CHANGELOG
 =========
 
-Unreleased
-----------
-
-0.10.16 (2024-06-10)
---------------------
-
-* fix: avoid deprecated config methods for app/node title
-* feat: standardize how app, package versions are determined
-
-
-0.10.15 (2024-06-07)
---------------------
-
-* Do *not* Use ``pkg_resources`` to determine package versions.
-
-
-0.10.14 (2024-06-06)
---------------------
-
-* Use ``pkg_resources`` to determine package versions.
-
-
-0.10.13 (2024-06-06)
---------------------
-
-* Remove old/unused scaffold for use with ``pcreate``.
-
-* Add 'fanstatic' support for sake of libcache assets.
-
-
-0.10.12 (2024-06-04)
---------------------
-
-* Require pyramid 2.x; remove 1.x-style auth policies.
-
-* Remove version cap for deform.
-
-* Set explicit referrer when changing app theme.
-
-* Add ``<b-tooltip>`` component shim.
-
-* Include extra styles from ``base_meta`` template for butterball.
-
-* Fix product lookup component, per butterball.
-
-* Include butterball theme by default for new apps.
-
-
-0.10.11 (2024-06-03)
---------------------
-
-* Fix vue3 refresh bugs for various views.
-
-* Fix grid bug for tempmon appliance view, per oruga.
-
-* Fix ordering worksheet generator, per butterball.
-
-* Fix inventory worksheet generator, per butterball.
-
-
-0.10.10 (2024-06-03)
---------------------
-
-* Fix focus for ``<b-select>`` shim component.
-
-* More butterball fixes for "view profile" template.
-
-
-0.10.9 (2024-06-03)
--------------------
-
-* Let master view control context menu items for page.
-
-* Fix panel style for PO vs. Invoice breakdown in receiving batch.
-
-* Fix the "new custorder" page for butterball.
-
-
-0.10.8 (2024-06-02)
--------------------
-
-* Add styling for checked grid rows, per oruga/butterball.
-
-* Fix product view template for oruga/butterball.
-
-* Allow per-user custom styles for butterball.
-
-* Use oruga 0.8.9 by default.
-
-
-0.10.7 (2024-06-01)
--------------------
-
-* Add setting to allow decimal quantities for receiving.
-
-* Log error if registry has no rattail config.
-
-* Add column filters for import/export main grid.
-
-* Fix overflow when instance header title is too long (butterball).
-
-* Escape all unsafe html for grid data.
-
-* Add speedbumps for delete, set preferred email/phone in profile view.
-
-* Fix file upload widget for oruga.
-
-
-0.10.6 (2024-05-29)
--------------------
-
-* Add way to flag organic products within lookup dialog.
-
-* Expose db picker for butterball theme.
-
-* Expose quickie lookup for butterball theme.
-
-* Fix basic problems with people profile view, per butterball.
-
-
-0.10.5 (2024-05-29)
--------------------
-
-* Add ``<tailbone-timepicker>`` component for oruga.
-
-
-0.10.4 (2024-05-12)
--------------------
-
-* Fix styles for grid actions, per butterball.
-
-
-0.10.3 (2024-05-10)
--------------------
-
-* Fix bug with grid date filters.
-
-
-0.10.2 (2024-05-08)
--------------------
-
-* Fix employees grid when viewing department (per oruga).
-
-* Remove version restriction for pyramid_beaker dependency.
-
-* Fix login "enter" key behavior, per oruga.
-
-* Rename some attrs etc. for buefy components used with oruga.
-
-* Fix "tools" helper for receiving batch view, per oruga.
-
-* Fix button text for autocomplete.
-
-* More data type fixes for ``<tailbone-datepicker>``.
-
-* Fix "view receiving row" page, per oruga.
-
-* Tweak styles for grid action links, per butterball.
-
-
-0.10.1 (2024-04-28)
--------------------
-
-* Sort list of available themes.
-
-* Update various icon names for oruga compatibility.
-
-* Fix vertical alignment in main menu bar, for butterball.
-
-* Fix upgrade execution logic/UI per oruga.
-
-* Show "View This" button when cloning a record.
-
-* Stop including 'falafel' as available theme.
-
-
-0.10.0 (2024-04-28)
--------------------
-
-This version bump is to reflect adding support for Vue 3 + Oruga via
-the 'butterball' theme.  There is likely more work to be done for that
-yet, but it mostly works at this point.
-
-* Misc. template and view logic tweaks (applicable to all themes) for
-  better patterns, consistency etc.
-
-* Add initial support for Vue 3 + Oruga, via "butterball" theme.
+NB. this file contains "old" release notes only.  for newer releases
+see the `CHANGELOG.md` file in the source root folder.
 
 
 0.9.96 (2024-04-25)
@@ -5177,7 +4992,7 @@ and related technologies.
 0.6.47 (2017-11-08)
 -------------------
 
-* Fix manifest to include *.pt deform templates
+* Fix manifest to include ``*.pt`` deform templates
 
 
 0.6.46 (2017-11-08)
@@ -5510,13 +5325,13 @@ and related technologies.
 
 
 0.6.13 (2017-07-26)
-------------------
+-------------------
 
 * Allow master view to decide whether each grid checkbox is checked
 
 
 0.6.12 (2017-07-26)
-------------------
+-------------------
 
 * Add basic support for product inventory and status
 
@@ -5524,7 +5339,7 @@ and related technologies.
 
 
 0.6.11 (2017-07-18)
-------------------
+-------------------
 
 * Tweak some basic styles for forms/grids
 
@@ -5532,7 +5347,7 @@ and related technologies.
 
 
 0.6.10 (2017-07-18)
-------------------
+-------------------
 
 * Fix grid bug if "current page" becomes invalid
 
diff --git a/docs/changelog.rst b/docs/changelog.rst
new file mode 100644
index 00000000..bbf94f4b
--- /dev/null
+++ b/docs/changelog.rst
@@ -0,0 +1,8 @@
+
+Changelog Archive
+=================
+
+.. toctree::
+   :maxdepth: 1
+
+   OLDCHANGES
diff --git a/docs/index.rst b/docs/index.rst
index 351e910d..db05d0c1 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -60,6 +60,14 @@ Package API:
    api/views/purchasing.ordering
 
 
+Changelog:
+
+.. toctree::
+   :maxdepth: 1
+
+   changelog
+
+
 Documentation To-Do
 ===================
 
diff --git a/pyproject.toml b/pyproject.toml
index 7c894886..13a232ae 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.10.16"
+version = "0.11.0"
 description = "Backoffice Web Application for Rattail"
 readme = "README.rst"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]
@@ -88,7 +88,7 @@ tailbone = "tailbone.config:ConfigExtension"
 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/CHANGES.rst"
+Changelog = "https://kallithea.rattailproject.org/rattail-project/tailbone/files/master/CHANGELOG.md"
 
 
 [tool.commitizen]

From fb0c538a2bd0d58f85ea37c4d8524b4fcf8515a0 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 10 Jun 2024 17:42:29 -0500
Subject: [PATCH 343/542] test: skip running tests for py36

we should soon require python 3.8 anyway
---
 tox.ini | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/tox.ini b/tox.ini
index ea833b39..6e45883c 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,6 +1,9 @@
 
 [tox]
-envlist = py36, py37, py38, py39, py310, py311
+# TODO: i had to remove py36 since something (hatchling?) broke it
+# somehow, and i was not able to quickly fix.  as of writing only
+# one app is known to run py36 and hopefully that is not for long.
+envlist = py37, py38, py39, py310, py311
 
 # TODO: can remove this when we drop py36 support
 # nb. need this for testing older python versions

From 6e741f6156a50442426f6a59f2321d11eedcbdf3 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 14 Jun 2024 17:57:01 -0500
Subject: [PATCH 344/542] fix: revert back to setup.py + setup.cfg

apparently with python 3.6 things "mostly" work but then they break if
any specified dependencies have a dot in the name.  which in this
project, is the case for `zope.sqlalchemy`

so until we drop python 3.6 support, we cannot use pyproject.toml here
---
 pyproject.toml | 101 -------------------------------------------------
 setup.cfg      |  97 +++++++++++++++++++++++++++++++++++++++++++++++
 setup.py       |   3 ++
 3 files changed, 100 insertions(+), 101 deletions(-)
 delete mode 100644 pyproject.toml
 create mode 100644 setup.cfg
 create mode 100644 setup.py

diff --git a/pyproject.toml b/pyproject.toml
deleted file mode 100644
index 13a232ae..00000000
--- a/pyproject.toml
+++ /dev/null
@@ -1,101 +0,0 @@
-
-[build-system]
-requires = ["hatchling"]
-build-backend = "hatchling.build"
-
-
-[project]
-name = "Tailbone"
-version = "0.11.0"
-description = "Backoffice Web Application for Rattail"
-readme = "README.rst"
-authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]
-license = {text = "GNU GPL v3+"}
-classifiers = [
-        "Development Status :: 4 - Beta",
-        "Environment :: Web Environment",
-        "Framework :: Pyramid",
-        "Intended Audience :: Developers",
-        "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
-        "Natural Language :: English",
-        "Operating System :: OS Independent",
-        "Programming Language :: Python",
-        "Programming Language :: Python :: 3",
-        "Programming Language :: Python :: 3.6",
-        "Programming Language :: Python :: 3.7",
-        "Programming Language :: Python :: 3.8",
-        "Programming Language :: Python :: 3.9",
-        "Programming Language :: Python :: 3.10",
-        "Programming Language :: Python :: 3.11",
-        "Topic :: Internet :: WWW/HTTP",
-        "Topic :: Office/Business",
-        "Topic :: Software Development :: Libraries :: Python Modules",
-]
-
-dependencies = [
-        "asgiref",
-        "colander",
-        "ColanderAlchemy",
-        "cornice",
-        "cornice-swagger",
-        "deform",
-        "humanize",
-        "Mako",
-        "markdown",
-        "openpyxl",
-        "paginate",
-        "paginate_sqlalchemy",
-        "passlib",
-        "Pillow",
-        "pyramid>=2",
-        "pyramid_beaker",
-        "pyramid_deform",
-        "pyramid_exclog",
-        "pyramid_fanstatic",
-        "pyramid_mako",
-        "pyramid_retry",
-        "pyramid_tm",
-        "rattail[db,bouncer]",
-        "six",
-        "sa-filters",
-        "simplejson",
-        "transaction",
-        "waitress",
-        "WebHelpers2",
-        "zope.sqlalchemy",
-]
-
-
-[project.optional-dependencies]
-docs = ["Sphinx", "sphinx-rtd-theme"]
-tests = ["coverage", "mock", "pytest", "pytest-cov"]
-
-
-[project.entry-points."paste.app_factory"]
-main = "tailbone.app:main"
-webapi = "tailbone.webapi:main"
-
-
-[project.entry-points."rattail.cleaners"]
-beaker = "tailbone.cleanup:BeakerCleaner"
-
-
-[project.entry-points."rattail.config.extensions"]
-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"
-
-
-[tool.commitizen]
-version_provider = "pep621"
-tag_format = "v$version"
-update_changelog_on_bump = true
-
-
-# [tool.hatch.build.targets.wheel]
-# packages = ["corepos"]
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 00000000..83ce9814
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,97 @@
+
+[metadata]
+name = Tailbone
+version = 0.11.0
+author = Lance Edgar
+author_email = lance@edbob.org
+url = http://rattailproject.org/
+license = GNU GPL v3
+description = Backoffice Web Application for Rattail
+long_description = file: README.rst
+classifiers =
+        Development Status :: 4 - Beta
+        Environment :: Web Environment
+        Framework :: Pyramid
+        Intended Audience :: Developers
+        License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
+        Natural Language :: English
+        Operating System :: OS Independent
+        Programming Language :: Python
+        Programming Language :: Python :: 3
+        Programming Language :: Python :: 3.6
+        Programming Language :: Python :: 3.7
+        Programming Language :: Python :: 3.8
+        Programming Language :: Python :: 3.9
+        Programming Language :: Python :: 3.10
+        Programming Language :: Python :: 3.11
+        Topic :: Internet :: WWW/HTTP
+        Topic :: Office/Business
+        Topic :: Software Development :: Libraries :: Python Modules
+
+
+[options]
+packages = find:
+include_package_data = True
+install_requires =
+        asgiref
+        colander
+        ColanderAlchemy
+        cornice
+        cornice-swagger
+        deform
+        humanize
+        Mako
+        markdown
+        openpyxl
+        paginate
+        paginate_sqlalchemy
+        passlib
+        Pillow
+        pyramid>=2
+        pyramid_beaker
+        pyramid_deform
+        pyramid_exclog
+        pyramid_fanstatic
+        pyramid_mako
+        pyramid_retry
+        pyramid_tm
+        rattail[db,bouncer]
+        six
+        sa-filters
+        simplejson
+        transaction
+        waitress
+        WebHelpers2
+        zope.sqlalchemy
+
+
+[options.packages.find]
+exclude =
+        tests.*
+        tests
+
+
+[options.extras_require]
+docs = Sphinx; sphinx-rtd-theme
+tests = coverage; mock; pytest; pytest-cov
+
+
+[options.entry_points]
+
+paste.app_factory =
+        main = tailbone.app:main
+        webapi = tailbone.webapi:main
+
+rattail.cleaners =
+        beaker = tailbone.cleanup:BeakerCleaner
+
+rattail.config.extensions =
+        tailbone = tailbone.config:ConfigExtension
+
+
+[nosetests]
+nocapture = 1
+cover-package = tailbone
+cover-erase = 1
+cover-html = 1
+cover-html-dir = htmlcov
diff --git a/setup.py b/setup.py
new file mode 100644
index 00000000..b908cbe5
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,3 @@
+import setuptools
+
+setuptools.setup()

From ab4dbbedf05ffaf927d191fed670391f74a98eb1 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 14 Jun 2024 18:01:40 -0500
Subject: [PATCH 345/542] =?UTF-8?q?bump:=20version=200.11.0=20=E2=86=92=20?=
 =?UTF-8?q?0.11.1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

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

diff --git a/CHANGELOG.md b/CHANGELOG.md
index c51f3fda..40dfa16e 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.11.1 (2024-06-14)
+
+### Fix
+
+- revert back to setup.py + setup.cfg
+
 ## v0.11.0 (2024-06-10)
 
 ### Feat
diff --git a/setup.cfg b/setup.cfg
index 83ce9814..2ea746e9 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,7 +1,7 @@
 
 [metadata]
 name = Tailbone
-version = 0.11.0
+version = 0.11.1
 author = Lance Edgar
 author_email = lance@edbob.org
 url = http://rattailproject.org/

From da4450b574cef8ec1b8cf77ac0c52085f395d5aa Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 14 Jun 2024 18:02:39 -0500
Subject: [PATCH 346/542] build: avoid version parse when uploading release

---
 tasks.py | 19 +++----------------
 1 file changed, 3 insertions(+), 16 deletions(-)

diff --git a/tasks.py b/tasks.py
index e9f47ccd..b57315a0 100644
--- a/tasks.py
+++ b/tasks.py
@@ -25,26 +25,11 @@ Tasks for Tailbone
 """
 
 import os
-import re
 import shutil
 
 from invoke import task
 
 
-here = os.path.abspath(os.path.dirname(__file__))
-__version__ = None
-pattern = re.compile(r'^version = "(\d+\.\d+\.\d+)"$')
-with open(os.path.join(here, 'pyproject.toml'), 'rt') as f:
-    for line in f:
-        line = line.rstrip('\n')
-        match = pattern.match(line)
-        if match:
-            __version__ = match.group(1)
-            break
-if not __version__:
-    raise RuntimeError("could not parse version!")
-
-
 @task
 def release(c, tests=False):
     """
@@ -53,7 +38,9 @@ def release(c, tests=False):
     if tests:
         c.run('tox')
 
+    if os.path.exists('dist'):
+        shutil.rmtree('dist')
     if os.path.exists('Tailbone.egg-info'):
         shutil.rmtree('Tailbone.egg-info')
     c.run('python -m build --sdist')
-    c.run(f'twine upload dist/tailbone-{__version__}.tar.gz')
+    c.run('twine upload dist/*')

From 0212e52b6611b4906107217113f1fbe0e30d252d Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 14 Jun 2024 19:59:52 -0500
Subject: [PATCH 347/542] fix: hide certain custorder settings if not
 applicable

---
 tailbone/templates/custorders/configure.mako | 51 ++++++++++++--------
 1 file changed, 30 insertions(+), 21 deletions(-)

diff --git a/tailbone/templates/custorders/configure.mako b/tailbone/templates/custorders/configure.mako
index d2f6610d..16d26d21 100644
--- a/tailbone/templates/custorders/configure.mako
+++ b/tailbone/templates/custorders/configure.mako
@@ -24,29 +24,38 @@
       </b-checkbox>
     </b-field>
 
-    <b-field message="Only applies if user is allowed to choose contact info.">
-      <b-checkbox name="rattail.custorders.new_orders.allow_contact_info_create"
-                  v-model="simpleSettings['rattail.custorders.new_orders.allow_contact_info_create']"
-                  native-value="true"
-                  @input="settingsNeedSaved = true">
-        Allow user to enter new contact info
-      </b-checkbox>
-    </b-field>
+    <div v-show="simpleSettings['rattail.custorders.new_orders.allow_contact_info_choice']"
+         style="padding-left: 2rem;">
 
-    <p class="block">
-      If you allow users to enter new contact info, the default action
-      when the order is submitted, is to send email with details of
-      the new contact info.&nbsp; Settings for these are at:
-    </p>
+      <b-field message="Only applies if user is allowed to choose contact info.">
+        <b-checkbox name="rattail.custorders.new_orders.allow_contact_info_create"
+                    v-model="simpleSettings['rattail.custorders.new_orders.allow_contact_info_create']"
+                    native-value="true"
+                    @input="settingsNeedSaved = true">
+          Allow user to enter new contact info
+        </b-checkbox>
+      </b-field>
 
-    <ul class="list">
-      <li class="list-item">
-        ${h.link_to("New Phone Request", url('emailprofiles.view', key='new_phone_requested'))}
-      </li>
-      <li class="list-item">
-        ${h.link_to("New Email Request", url('emailprofiles.view', key='new_email_requested'))}
-      </li>
-    </ul>
+      <div v-show="simpleSettings['rattail.custorders.new_orders.allow_contact_info_create']"
+           style="padding-left: 2rem;">
+
+        <p class="block">
+          If you allow users to enter new contact info, the default action
+          when the order is submitted, is to send email with details of
+          the new contact info.&nbsp; Settings for these are at:
+        </p>
+
+        <ul class="list">
+          <li class="list-item">
+            ${h.link_to("New Phone Request", url('emailprofiles.view', key='new_phone_requested'))}
+          </li>
+          <li class="list-item">
+            ${h.link_to("New Email Request", url('emailprofiles.view', key='new_email_requested'))}
+          </li>
+        </ul>
+
+      </div>
+    </div>
   </div>
 
   <h3 class="block is-size-3">Product Handling</h3>

From 88e7d86087c590d3ae3bc957dc1685ca7b815414 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 18 Jun 2024 15:04:05 -0500
Subject: [PATCH 348/542] fix: use different logic for buefy/oruga for product
 lookup keydown

i could have swore the new logic worked with buefy..but today it didn't
---
 tailbone/templates/products/lookup.mako | 28 +++++++++++++++++--------
 1 file changed, 19 insertions(+), 9 deletions(-)

diff --git a/tailbone/templates/products/lookup.mako b/tailbone/templates/products/lookup.mako
index 7997eb7d..bb9590b2 100644
--- a/tailbone/templates/products/lookup.mako
+++ b/tailbone/templates/products/lookup.mako
@@ -56,7 +56,11 @@
             <b-field grouped>
 
               <b-input v-model="searchTerm" 
-                       ref="searchTermInput" />
+                       ref="searchTermInput"
+                       % if not request.use_oruga:
+                           @keydown.native="searchTermInputKeydown"
+                       % endif
+                       />
 
               <b-button class="control"
                         type="is-primary"
@@ -243,8 +247,10 @@
                 lookupShowDialog: false,
 
                 searchTerm: null,
-                searchTermInputElement: null,
                 searchTermLastUsed: null,
+                % if request.use_oruga:
+                    searchTermInputElement: null,
+                % endif
 
                 searchProductKey: true,
                 searchVendorItemCode: true,
@@ -259,14 +265,18 @@
             }
         },
 
-        mounted() {
-            this.searchTermInputElement = this.$refs.searchTermInput.$el.querySelector('input')
-            this.searchTermInputElement.addEventListener('keydown', this.searchTermInputKeydown)
-        },
+        % if request.use_oruga:
 
-        beforeDestroy() {
-            this.searchTermInputElement.removeEventListener('keydown', this.searchTermInputKeydown)
-        },
+            mounted() {
+                this.searchTermInputElement = this.$refs.searchTermInput.$el.querySelector('input')
+                this.searchTermInputElement.addEventListener('keydown', this.searchTermInputKeydown)
+            },
+
+            beforeDestroy() {
+                this.searchTermInputElement.removeEventListener('keydown', this.searchTermInputKeydown)
+            },
+
+        % endif
 
         methods: {
 

From 231ca0363acf680a3538ad10b289d8ad9148666d Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 18 Jun 2024 16:06:55 -0500
Subject: [PATCH 349/542] fix: product records should be touchable

---
 tailbone/views/products.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/tailbone/views/products.py b/tailbone/views/products.py
index 28186ac3..5265edbc 100644
--- a/tailbone/views/products.py
+++ b/tailbone/views/products.py
@@ -81,6 +81,7 @@ class ProductView(MasterView):
     supports_autocomplete = True
     bulk_deletable = True
     mergeable = True
+    touchable = True
     configurable = True
 
     labels = {

From a0cd8835e038f4952824da17f37172d5fe9fe334 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 18 Jun 2024 16:07:07 -0500
Subject: [PATCH 350/542] fix: show flash error message if resolve pending
 product fails

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

diff --git a/tailbone/views/products.py b/tailbone/views/products.py
index 5265edbc..c395ff24 100644
--- a/tailbone/views/products.py
+++ b/tailbone/views/products.py
@@ -2563,7 +2563,14 @@ class PendingProductView(MasterView):
         app = self.get_rattail_app()
         products_handler = app.get_products_handler()
         kwargs = self.get_resolve_product_kwargs()
-        products_handler.resolve_product(pending, product, self.request.user, **kwargs)
+
+        try:
+            products_handler.resolve_product(pending, product, self.request.user, **kwargs)
+        except Exception as error:
+            log.warning("failed to resolve product", exc_info=True)
+            self.request.session.flash(f"Resolve failed: {simple_error(error)}", 'error')
+            return redirect
+
         return redirect
 
     def get_resolve_product_kwargs(self, **kwargs):

From 525a28f3fe7b0e1b6e21576f06bd0cc341252ff7 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 18 Jun 2024 18:05:05 -0500
Subject: [PATCH 351/542] =?UTF-8?q?bump:=20version=200.11.1=20=E2=86=92=20?=
 =?UTF-8?q?0.11.2?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 CHANGELOG.md | 12 ++++++++++++
 setup.cfg    |  2 +-
 2 files changed, 13 insertions(+), 1 deletion(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 40dfa16e..ed866741 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,18 @@ 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.11.2 (2024-06-18)
+
+### Fix
+
+- hide certain custorder settings if not applicable
+
+- use different logic for buefy/oruga for product lookup keydown
+
+- product records should be touchable
+
+- show flash error message if resolve pending product fails
+
 ## v0.11.1 (2024-06-14)
 
 ### Fix
diff --git a/setup.cfg b/setup.cfg
index 2ea746e9..aa14088a 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,7 +1,7 @@
 
 [metadata]
 name = Tailbone
-version = 0.11.1
+version = 0.11.2
 author = Lance Edgar
 author_email = lance@edbob.org
 url = http://rattailproject.org/

From 067ca5bd4354f8dd47f5a3e9206627e3c6f6ae32 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 27 Jun 2024 23:11:13 -0500
Subject: [PATCH 352/542] fix: add link to "resolved by" user for pending
 products

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

diff --git a/tailbone/views/products.py b/tailbone/views/products.py
index c395ff24..bf2d7f14 100644
--- a/tailbone/views/products.py
+++ b/tailbone/views/products.py
@@ -2457,9 +2457,10 @@ class PendingProductView(MasterView):
         # resolved*
         if self.creating:
             f.remove('resolved', 'resolved_by')
+        elif pending.resolved:
+            f.set_renderer('resolved_by', self.render_user)
         else:
-            if not pending.resolved:
-                f.remove('resolved', 'resolved_by')
+            f.remove('resolved', 'resolved_by')
 
     def render_status_code(self, pending, field):
         status = pending.status_code

From 3b7cc19faa758e83cb6f358e4bcb93fc3f15c06e Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 28 Jun 2024 15:36:08 -0500
Subject: [PATCH 353/542] fix: handle error when merging 2 records fails

should give the user some idea of the problem instead of just sending
error email to admins
---
 tailbone/views/master.py | 10 +++++++---
 1 file changed, 7 insertions(+), 3 deletions(-)

diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index 48bc32fe..1e917902 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -2292,9 +2292,13 @@ class MasterView(View):
                     except Exception as error:
                         self.request.session.flash("Requested merge cannot proceed (maybe swap kept/removed and try again?): {}".format(error), 'error')
                     else:
-                        self.merge_objects(object_to_remove, object_to_keep)
-                        self.request.session.flash("{} has been merged into {}".format(msg, object_to_keep))
-                        return self.redirect(self.get_action_url('view', object_to_keep))
+                        try:
+                            self.merge_objects(object_to_remove, object_to_keep)
+                            self.request.session.flash("{} has been merged into {}".format(msg, object_to_keep))
+                            return self.redirect(self.get_action_url('view', object_to_keep))
+                        except Exception as error:
+                            error = simple_error(error)
+                            self.request.session.flash(f"merge failed: {error}", 'error')
 
         if not object_to_remove or not object_to_keep or object_to_remove is object_to_keep:
             return self.redirect(self.get_index_url())

From d17bd35909444f30807b486a3b0eded5bb4915b4 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 28 Jun 2024 15:39:59 -0500
Subject: [PATCH 354/542] =?UTF-8?q?bump:=20version=200.11.2=20=E2=86=92=20?=
 =?UTF-8?q?0.11.3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 CHANGELOG.md | 8 ++++++++
 setup.cfg    | 2 +-
 2 files changed, 9 insertions(+), 1 deletion(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index ed866741..f18a87ea 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,14 @@ 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.11.3 (2024-06-28)
+
+### Fix
+
+- add link to "resolved by" user for pending products
+
+- handle error when merging 2 records fails
+
 ## v0.11.2 (2024-06-18)
 
 ### Fix
diff --git a/setup.cfg b/setup.cfg
index aa14088a..2dd65a74 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,7 +1,7 @@
 
 [metadata]
 name = Tailbone
-version = 0.11.2
+version = 0.11.3
 author = Lance Edgar
 author_email = lance@edbob.org
 url = http://rattailproject.org/

From ec5ed490d91438b679315ee88cfeb37c2368ac10 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 28 Jun 2024 17:34:54 -0500
Subject: [PATCH 355/542] fix: start/stop being root should submit POST instead
 of GET

obviously it's access-restricted anyway but this just seems more correct

but more importantly this makes the referrer explicit, since for some
unknown reason i am suddenly seeing that be blank for certain installs
where that wasn't the case before (?) - and the result was that every
time you start/stop being root you would be redirected to home page
instead of remaining on current page
---
 .../templates/themes/butterball/base.mako     | 30 +++++++++++++++++--
 tailbone/views/auth.py                        |  3 ++
 2 files changed, 31 insertions(+), 2 deletions(-)

diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako
index 3f0253ce..339d23bd 100644
--- a/tailbone/templates/themes/butterball/base.mako
+++ b/tailbone/templates/themes/butterball/base.mako
@@ -924,9 +924,23 @@
         % endif
         <div class="navbar-dropdown">
           % if request.is_root:
-              ${h.link_to("Stop being root", url('stop_root'), class_='navbar-item has-background-danger has-text-white')}
+              ${h.form(url('stop_root'), ref='stopBeingRootForm')}
+              ${h.csrf_token(request)}
+              <input type="hidden" name="referrer" value="${request.current_route_url()}" />
+              <a @click="stopBeingRoot()"
+                 class="navbar-item has-background-danger has-text-white">
+                Stop being root
+              </a>
+              ${h.end_form()}
           % elif request.is_admin:
-              ${h.link_to("Become root", url('become_root'), class_='navbar-item has-background-danger has-text-white')}
+              ${h.form(url('become_root'), ref='startBeingRootForm')}
+              ${h.csrf_token(request)}
+              <input type="hidden" name="referrer" value="${request.current_route_url()}" />
+              <a @click="startBeingRoot()"
+                 class="navbar-item has-background-danger has-text-white">
+                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')}
@@ -1109,6 +1123,18 @@
                 const key = 'menu_' + hash + '_shown'
                 this[key] = !this[key]
             },
+
+            % if request.is_admin:
+
+                startBeingRoot() {
+                    this.$refs.startBeingRootForm.submit()
+                },
+
+                stopBeingRoot() {
+                    this.$refs.stopBeingRootForm.submit()
+                },
+
+            % endif
         },
     }
 
diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py
index 7ecdc6cd..730d7b6a 100644
--- a/tailbone/views/auth.py
+++ b/tailbone/views/auth.py
@@ -238,6 +238,9 @@ class AuthenticationView(View):
         config.add_view(cls, attr='change_password', route_name='change_password', renderer='/change_password.mako')
 
         # become/stop root
+        # TODO: these should require POST but i won't bother until
+        # after butterball becomes default theme..or probably should
+        # just refactor the falafel theme accordingly..?
         config.add_route('become_root', '/root/yes')
         config.add_view(cls, attr='become_root', route_name='become_root')
         config.add_route('stop_root', '/root/no')

From 9b6447c4cb1c5a51486436ee8ddb4ec675c6fb7d Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 28 Jun 2024 17:58:27 -0500
Subject: [PATCH 356/542] fix: require vendor when making new ordering batch
 via api

pretty sure this pattern needs to be expanded and probably improved,
but wanted to fix this one scenario for now, per error email
---
 tailbone/api/batch/ordering.py |  2 ++
 tailbone/api/master.py         | 14 +++++++++-----
 2 files changed, 11 insertions(+), 5 deletions(-)

diff --git a/tailbone/api/batch/ordering.py b/tailbone/api/batch/ordering.py
index 1b11194e..204be8ad 100644
--- a/tailbone/api/batch/ordering.py
+++ b/tailbone/api/batch/ordering.py
@@ -86,6 +86,8 @@ class OrderingBatchViews(APIBatchView):
         Sets the mode to "ordering" for the new batch.
         """
         data = dict(data)
+        if not data.get('vendor_uuid'):
+            raise ValueError("You must specify the vendor")
         data['mode'] = self.enum.PURCHASE_BATCH_MODE_ORDERING
         batch = super().create_object(data)
         return batch
diff --git a/tailbone/api/master.py b/tailbone/api/master.py
index 70616484..2d17339e 100644
--- a/tailbone/api/master.py
+++ b/tailbone/api/master.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.
 #
@@ -31,7 +31,7 @@ from rattail.db.util import get_fieldnames
 
 from cornice import resource, Service
 
-from tailbone.api import APIView, api
+from tailbone.api import APIView
 from tailbone.db import Session
 from tailbone.util import SortColumn
 
@@ -355,9 +355,13 @@ class APIMasterView(APIView):
         data = self.request.json_body
 
         # add instance to session, and return data for it
-        obj = self.create_object(data)
-        self.Session.flush()
-        return self._get(obj)
+        try:
+            obj = self.create_object(data)
+        except Exception as error:
+            return self.json_response({'error': str(error)})
+        else:
+            self.Session.flush()
+            return self._get(obj)
 
     def create_object(self, data):
         """

From 83e4d95741e098a065a41d81f0776232b8008583 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 30 Jun 2024 10:32:05 -0500
Subject: [PATCH 357/542] fix: don't escape each address for email attempts
 grid

now that we are properly escaping the full cell value, no need
---
 tailbone/views/email.py | 22 ++++++++++------------
 1 file changed, 10 insertions(+), 12 deletions(-)

diff --git a/tailbone/views/email.py b/tailbone/views/email.py
index 22954782..4014c05e 100644
--- a/tailbone/views/email.py
+++ b/tailbone/views/email.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,13 @@ import logging
 import re
 import warnings
 
-from rattail import mail
-from rattail.db import model
-from rattail.config import parse_list
+from wuttjamaican.util import parse_list
+
+from rattail.db.model import EmailAttempt
 from rattail.util import simple_error
 
 import colander
 from deform import widget as dfwidget
-from webhelpers2.html import HTML
 
 from tailbone import grids
 from tailbone.db import Session
@@ -85,7 +84,7 @@ class EmailSettingView(MasterView):
     ]
 
     def __init__(self, request):
-        super(EmailSettingView, self).__init__(request)
+        super().__init__(request)
         self.email_handler = self.get_handler()
 
     @property
@@ -204,7 +203,7 @@ class EmailSettingView(MasterView):
         return True
 
     def configure_form(self, f):
-        super(EmailSettingView, self).configure_form(f)
+        super().configure_form(f)
         profile = f.model_instance['_email']
 
         # key
@@ -437,7 +436,7 @@ class EmailPreview(View):
     """
 
     def __init__(self, request):
-        super(EmailPreview, self).__init__(request)
+        super().__init__(request)
 
         if hasattr(self, 'get_handler'):
             warnings.warn("defining a get_handler() method is deprecated; "
@@ -520,7 +519,7 @@ class EmailAttemptView(MasterView):
     """
     Master view for email attempts.
     """
-    model_class = model.EmailAttempt
+    model_class = EmailAttempt
     route_prefix = 'email_attempts'
     url_prefix = '/email/attempts'
     creatable = False
@@ -553,7 +552,7 @@ class EmailAttemptView(MasterView):
     ]
 
     def configure_grid(self, g):
-        super(EmailAttemptView, self).configure_grid(g)
+        super().configure_grid(g)
 
         # sent
         g.set_sort_defaults('sent', 'desc')
@@ -583,13 +582,12 @@ class EmailAttemptView(MasterView):
             if len(recips) > 2:
                 recips = recips[:2]
                 recips.append('...')
-            recips = [HTML.escape(r) for r in recips]
             return ', '.join(recips)
 
         return value
 
     def configure_form(self, f):
-        super(EmailAttemptView, self).configure_form(f)
+        super().configure_form(f)
 
         # key
         f.set_renderer('key', self.render_email_key)

From eff5341335a898c6770d6a28dd7dde77b2bdad20 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 30 Jun 2024 10:49:54 -0500
Subject: [PATCH 358/542] =?UTF-8?q?bump:=20version=200.11.3=20=E2=86=92=20?=
 =?UTF-8?q?0.11.4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

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

diff --git a/CHANGELOG.md b/CHANGELOG.md
index f18a87ea..8d92a99e 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.11.4 (2024-06-30)
+
+### Fix
+
+- start/stop being root should submit POST instead of GET
+
+- require vendor when making new ordering batch via api
+
+- don't escape each address for email attempts grid
+
 ## v0.11.3 (2024-06-28)
 
 ### Fix
diff --git a/setup.cfg b/setup.cfg
index 2dd65a74..82cf3b25 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,7 +1,7 @@
 
 [metadata]
 name = Tailbone
-version = 0.11.3
+version = 0.11.4
 author = Lance Edgar
 author_email = lance@edbob.org
 url = http://rattailproject.org/

From 1dc632174eed4058f07c75ede528e0b7ec0188a9 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 30 Jun 2024 11:44:33 -0500
Subject: [PATCH 359/542] fix: allow comma in numeric filter input

just remove them and run with the remainder, on the SQL side
---
 tailbone/grids/filters.py | 15 +++++++++++++--
 1 file changed, 13 insertions(+), 2 deletions(-)

diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py
index 3b198614..7e52bb8d 100644
--- a/tailbone/grids/filters.py
+++ b/tailbone/grids/filters.py
@@ -26,6 +26,7 @@ Grid Filters
 
 import re
 import datetime
+import decimal
 import logging
 from collections import OrderedDict
 
@@ -647,12 +648,22 @@ class AlchemyNumericFilter(AlchemyGridFilter):
 
         # first just make sure it's somewhat numeric
         try:
-            float(value)
-        except ValueError:
+            self.parse_decimal(value)
+        except decimal.InvalidOperation:
             return True
 
         return bool(value and len(str(value)) > 8)
 
+    def parse_decimal(self, value):
+        if value:
+            value = value.replace(',', '')
+            return decimal.Decimal(value)
+
+    def encode_value(self, value):
+        if value:
+            value = str(self.parse_decimal(value))
+        return super().encode_value(value)
+
     def filter_equal(self, query, value):
         if self.value_invalid(value):
             return query

From 3f7de5872e50f4ffd6e8c510ed8738bd24e0b870 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 30 Jun 2024 12:40:03 -0500
Subject: [PATCH 360/542] fix: add custom url prefix if needed, for fanstatic

---
 tailbone/util.py | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/tailbone/util.py b/tailbone/util.py
index 9a993176..78c41313 100644
--- a/tailbone/util.py
+++ b/tailbone/util.py
@@ -201,6 +201,9 @@ def get_liburl(request, key, fallback=True):
         static = importlib.import_module(static)
         needed = request.environ['fanstatic.needed']
         liburl = needed.library_url(static.libcache) + '/'
+        # nb. add custom url prefix if needed, e.g. /theo
+        if request.script_name:
+            liburl = request.script_name + liburl
 
     if key == 'buefy':
         return 'https://unpkg.com/buefy@{}/dist/buefy.min.js'.format(version)

From d6939e52b48bd5d6b947deb67a241782c321b7f0 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 30 Jun 2024 18:25:01 -0500
Subject: [PATCH 361/542] fix: use vue 3.4.31 and oruga 0.8.12 by default

i.e. for butterball theme

cf. https://github.com/oruga-ui/oruga/issues/974#issuecomment-2198573369
---
 tailbone/util.py | 5 ++---
 1 file changed, 2 insertions(+), 3 deletions(-)

diff --git a/tailbone/util.py b/tailbone/util.py
index 78c41313..98a7f7d4 100644
--- a/tailbone/util.py
+++ b/tailbone/util.py
@@ -162,11 +162,10 @@ def get_libver(request, key, fallback=True, default_only=False):
         return '5.3.1'
 
     elif key == 'bb_vue':
-        # TODO: iiuc vue 3.4 does not work with oruga yet
-        return '3.3.11'
+        return '3.4.31'
 
     elif key == 'bb_oruga':
-        return '0.8.9'
+        return '0.8.12'
 
     elif key in ('bb_oruga_bulma', 'bb_oruga_bulma_css'):
         return '0.3.0'

From cad50c9149143eff8c6329e77d3c20015d0f0331 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 30 Jun 2024 21:28:56 -0500
Subject: [PATCH 362/542] =?UTF-8?q?bump:=20version=200.11.4=20=E2=86=92=20?=
 =?UTF-8?q?0.11.5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

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

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8d92a99e..510aa6a1 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.11.5 (2024-06-30)
+
+### Fix
+
+- allow comma in numeric filter input
+
+- add custom url prefix if needed, for fanstatic
+
+- use vue 3.4.31 and oruga 0.8.12 by default
+
 ## v0.11.4 (2024-06-30)
 
 ### Fix
diff --git a/setup.cfg b/setup.cfg
index 82cf3b25..4ee92f01 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,7 +1,7 @@
 
 [metadata]
 name = Tailbone
-version = 0.11.4
+version = 0.11.5
 author = Lance Edgar
 author_email = lance@edbob.org
 url = http://rattailproject.org/

From 6f8b825b0b24370af4a66cac02e4faa2eb43fee1 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 1 Jul 2024 15:23:56 -0500
Subject: [PATCH 363/542] fix: set explicit referrer when changing dbkey

since for some reason HTTP_REFERER is not always set now??
---
 tailbone/templates/themes/butterball/base.mako | 1 +
 1 file changed, 1 insertion(+)

diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako
index 339d23bd..e38696c5 100644
--- a/tailbone/templates/themes/butterball/base.mako
+++ b/tailbone/templates/themes/butterball/base.mako
@@ -747,6 +747,7 @@
                   ${h.form(url('change_db_engine'), ref='dbPickerForm')}
                   ${h.csrf_token(request)}
                   ${h.hidden('engine_type', value=master.engine_type_key)}
+                  <input type="hidden" name="referrer" :value="referrer" />
                   <b-select name="dbkey"
                             v-model="dbSelected"
                             @input="changeDB()">

From 2feb07e1d3488a798028be3ab7cc63b3ef40de1c Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 1 Jul 2024 17:01:01 -0500
Subject: [PATCH 364/542] fix: remove references, dependency for `six` package

---
 setup.cfg                                     |  1 -
 tailbone/api/batch/labels.py                  | 10 ++---
 tailbone/api/customers.py                     |  8 +---
 tailbone/api/people.py                        |  8 +---
 tailbone/api/upgrades.py                      |  8 +---
 tailbone/api/vendors.py                       |  8 +---
 tailbone/api/workorders.py                    | 20 ++++------
 tailbone/exceptions.py                        |  7 +---
 tailbone/handler.py                           |  9 ++---
 tailbone/subscribers.py                       |  2 -
 tailbone/templates/base.mako                  |  2 +-
 tailbone/templates/configure.mako             |  2 +-
 tailbone/templates/custorders/items/view.mako |  2 +-
 tailbone/templates/generate_feature.mako      |  2 +-
 tailbone/templates/ordering/worksheet.mako    |  4 +-
 tailbone/templates/poser/views/configure.mako |  2 +-
 tailbone/templates/products/batch.mako        |  2 +-
 tailbone/templates/shifts/base.mako           |  4 +-
 .../templates/themes/butterball/base.mako     |  2 +-
 .../trainwreck/transactions/configure.mako    |  2 +-
 .../trainwreck/transactions/rollover.mako     |  2 +-
 tailbone/tweens.py                            |  7 +---
 tailbone/views/batch/labels.py                | 16 +++-----
 tailbone/views/batch/pricing.py               | 24 +++++------
 tailbone/views/batch/vendorinvoice.py         | 16 +++-----
 tailbone/views/exports.py                     | 24 ++++-------
 tailbone/views/poser/reports.py               | 14 +++----
 tailbone/views/poser/views.py                 | 40 +++++++++----------
 tailbone/views/progress.py                    |  8 +---
 tailbone/views/tempmon/appliances.py          | 16 ++++----
 tailbone/views/vendors/core.py                | 14 +++----
 31 files changed, 105 insertions(+), 181 deletions(-)

diff --git a/setup.cfg b/setup.cfg
index 4ee92f01..8afd9be4 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -56,7 +56,6 @@ install_requires =
         pyramid_retry
         pyramid_tm
         rattail[db,bouncer]
-        six
         sa-filters
         simplejson
         transaction
diff --git a/tailbone/api/batch/labels.py b/tailbone/api/batch/labels.py
index 4787aeb9..4f154b21 100644
--- a/tailbone/api/batch/labels.py
+++ b/tailbone/api/batch/labels.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,10 +24,6 @@
 Tailbone Web API - Label Batches
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
 from rattail.db import model
 
 from tailbone.api.batch import APIBatchView, APIBatchRowView
@@ -56,10 +52,10 @@ class LabelBatchRowViews(APIBatchRowView):
 
     def normalize(self, row):
         batch = row.batch
-        data = super(LabelBatchRowViews, self).normalize(row)
+        data = super().normalize(row)
 
         data['item_id'] = row.item_id
-        data['upc'] = six.text_type(row.upc)
+        data['upc'] = str(row.upc)
         data['upc_pretty'] = row.upc.pretty() if row.upc else None
         data['brand_name'] = row.brand_name
         data['description'] = row.description
diff --git a/tailbone/api/customers.py b/tailbone/api/customers.py
index e9953572..85d28c24 100644
--- a/tailbone/api/customers.py
+++ b/tailbone/api/customers.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,10 +24,6 @@
 Tailbone Web API - Customer Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
 from rattail.db import model
 
 from tailbone.api import APIMasterView
@@ -46,7 +42,7 @@ class CustomerView(APIMasterView):
     def normalize(self, customer):
         return {
             'uuid': customer.uuid,
-            '_str': six.text_type(customer),
+            '_str': str(customer),
             'id': customer.id,
             'number': customer.number,
             'name': customer.name,
diff --git a/tailbone/api/people.py b/tailbone/api/people.py
index 7e06e969..f7c08dfa 100644
--- a/tailbone/api/people.py
+++ b/tailbone/api/people.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,10 +24,6 @@
 Tailbone Web API - Person Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
 from rattail.db import model
 
 from tailbone.api import APIMasterView
@@ -45,7 +41,7 @@ class PersonView(APIMasterView):
     def normalize(self, person):
         return {
             'uuid': person.uuid,
-            '_str': six.text_type(person),
+            '_str': str(person),
             'first_name': person.first_name,
             'last_name': person.last_name,
             'display_name': person.display_name,
diff --git a/tailbone/api/upgrades.py b/tailbone/api/upgrades.py
index 6ce5f778..467c8a0d 100644
--- a/tailbone/api/upgrades.py
+++ b/tailbone/api/upgrades.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,10 +24,6 @@
 Tailbone Web API - Upgrade Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
 from rattail.db import model
 
 from tailbone.api import APIMasterView
@@ -53,7 +49,7 @@ class UpgradeView(APIMasterView):
             data['status_code'] = None
         else:
             data['status_code'] = self.enum.UPGRADE_STATUS.get(upgrade.status_code,
-                                                               six.text_type(upgrade.status_code))
+                                                               str(upgrade.status_code))
         return data
 
 
diff --git a/tailbone/api/vendors.py b/tailbone/api/vendors.py
index 7fa61590..64311b1b 100644
--- a/tailbone/api/vendors.py
+++ b/tailbone/api/vendors.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,10 +24,6 @@
 Tailbone Web API - Vendor Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
 from rattail.db import model
 
 from tailbone.api import APIMasterView
@@ -44,7 +40,7 @@ class VendorView(APIMasterView):
     def normalize(self, vendor):
         return {
             'uuid': vendor.uuid,
-            '_str': six.text_type(vendor),
+            '_str': str(vendor),
             'id': vendor.id,
             'name': vendor.name,
         }
diff --git a/tailbone/api/workorders.py b/tailbone/api/workorders.py
index eabe4cdb..19def6c4 100644
--- a/tailbone/api/workorders.py
+++ b/tailbone/api/workorders.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,12 +24,8 @@
 Tailbone Web API - Work Order Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import datetime
 
-import six
-
 from rattail.db.model import WorkOrder
 
 from cornice import Service
@@ -44,19 +40,19 @@ class WorkOrderView(APIMasterView):
     object_url_prefix = '/workorder'
 
     def __init__(self, *args, **kwargs):
-        super(WorkOrderView, self).__init__(*args, **kwargs)
+        super().__init__(*args, **kwargs)
         app = self.get_rattail_app()
         self.workorder_handler = app.get_workorder_handler()
 
     def normalize(self, workorder):
-        data = super(WorkOrderView, self).normalize(workorder)
+        data = super().normalize(workorder)
         data.update({
             'customer_name': workorder.customer.name,
             'status_label': self.enum.WORKORDER_STATUS[workorder.status_code],
-            'date_submitted': six.text_type(workorder.date_submitted or ''),
-            'date_received': six.text_type(workorder.date_received or ''),
-            'date_released': six.text_type(workorder.date_released or ''),
-            'date_delivered': six.text_type(workorder.date_delivered or ''),
+            'date_submitted': str(workorder.date_submitted or ''),
+            'date_received': str(workorder.date_received or ''),
+            'date_released': str(workorder.date_released or ''),
+            'date_delivered': str(workorder.date_delivered or ''),
         })
         return data
 
@@ -87,7 +83,7 @@ class WorkOrderView(APIMasterView):
         if 'status_code' in data:
             data['status_code'] = int(data['status_code'])
 
-        return super(WorkOrderView, self).update_object(workorder, data)
+        return super().update_object(workorder, data)
 
     def status_codes(self):
         """
diff --git a/tailbone/exceptions.py b/tailbone/exceptions.py
index beea1366..3468562a 100644
--- a/tailbone/exceptions.py
+++ b/tailbone/exceptions.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,10 +24,6 @@
 Tailbone Exceptions
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
 from rattail.exceptions import RattailError
 
 
@@ -37,7 +33,6 @@ class TailboneError(RattailError):
     """
 
 
-@six.python_2_unicode_compatible
 class TailboneJSONFieldError(TailboneError):
     """
     Error raised when JSON serialization of a form field results in an error.
diff --git a/tailbone/handler.py b/tailbone/handler.py
index db95bc71..22f33cca 100644
--- a/tailbone/handler.py
+++ b/tailbone/handler.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,9 +24,6 @@
 Tailbone Handler
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
 from mako.lookup import TemplateLookup
 
 from rattail.app import GenericHandler
@@ -41,7 +38,7 @@ class TailboneHandler(GenericHandler):
     """
 
     def __init__(self, *args, **kwargs):
-        super(TailboneHandler, self).__init__(*args, **kwargs)
+        super().__init__(*args, **kwargs)
 
         # TODO: make templates dir configurable?
         templates = [resource_path('rattail:templates/web')]
@@ -67,7 +64,7 @@ class TailboneHandler(GenericHandler):
         Returns an iterator over all registered Tailbone providers.
         """
         providers = get_all_providers(self.config)
-        return six.itervalues(providers)
+        return providers.values()
 
     def write_model_view(self, data, path, **kwargs):
         """
diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py
index 3fcd1017..bd59a033 100644
--- a/tailbone/subscribers.py
+++ b/tailbone/subscribers.py
@@ -24,7 +24,6 @@
 Event Subscribers
 """
 
-import six
 import json
 import datetime
 import logging
@@ -177,7 +176,6 @@ def before_render(event):
     renderer_globals['tailbone'] = tailbone
     renderer_globals['model'] = request.rattail_config.get_model()
     renderer_globals['enum'] = request.rattail_config.get_enum()
-    renderer_globals['six'] = six
     renderer_globals['json'] = json
     renderer_globals['datetime'] = datetime
     renderer_globals['colander'] = colander
diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako
index f576473d..c4cbd648 100644
--- a/tailbone/templates/base.mako
+++ b/tailbone/templates/base.mako
@@ -890,7 +890,7 @@
 
     % if request.user:
     FeedbackFormData.userUUID = ${json.dumps(request.user.uuid)|n}
-    FeedbackFormData.userName = ${json.dumps(six.text_type(request.user))|n}
+    FeedbackFormData.userName = ${json.dumps(str(request.user))|n}
     % endif
 
   </script>
diff --git a/tailbone/templates/configure.mako b/tailbone/templates/configure.mako
index 3aa60f31..f33779c8 100644
--- a/tailbone/templates/configure.mako
+++ b/tailbone/templates/configure.mako
@@ -236,7 +236,7 @@
 
     % if input_file_template_settings is not Undefined:
         ThisPage.methods.validateInputFileTemplateSettings = function() {
-            % for tmpl in six.itervalues(input_file_templates):
+            % 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']}']) {
diff --git a/tailbone/templates/custorders/items/view.mako b/tailbone/templates/custorders/items/view.mako
index 41567d41..f7a6dd0a 100644
--- a/tailbone/templates/custorders/items/view.mako
+++ b/tailbone/templates/custorders/items/view.mako
@@ -347,7 +347,7 @@
         }
 
         ThisPageData.orderItemStatuses = ${json.dumps(enum.CUSTORDER_ITEM_STATUS)|n}
-        ThisPageData.orderItemStatusOptions = ${json.dumps([dict(key=k, label=v) for k, v in six.iteritems(enum.CUSTORDER_ITEM_STATUS)])|n}
+        ThisPageData.orderItemStatusOptions = ${json.dumps([dict(key=k, label=v) for k, v in enum.CUSTORDER_ITEM_STATUS.items()])|n}
 
         ThisPageData.oldStatusCode = ${instance.status_code}
 
diff --git a/tailbone/templates/generate_feature.mako b/tailbone/templates/generate_feature.mako
index a7064331..18a26f58 100644
--- a/tailbone/templates/generate_feature.mako
+++ b/tailbone/templates/generate_feature.mako
@@ -296,7 +296,7 @@
         % endfor
     }
 
-    % for key, form in six.iteritems(feature_forms):
+    % for key, form in feature_forms.items():
         <% safekey = key.replace('-', '_') %>
         ThisPageData.${safekey} = {
             <% dform = feature_forms[key].make_deform_form() %>
diff --git a/tailbone/templates/ordering/worksheet.mako b/tailbone/templates/ordering/worksheet.mako
index e41fe15f..ca1abf6e 100644
--- a/tailbone/templates/ordering/worksheet.mako
+++ b/tailbone/templates/ordering/worksheet.mako
@@ -73,7 +73,7 @@
   <div class="grid">
     <table class="order-form">
       <% column_count = 8 + len(header_columns) + (0 if ignore_cases else 1) + int(capture(self.extra_count)) %>
-      % for department in sorted(six.itervalues(departments), key=lambda d: d.name if d else ''):
+      % for department in sorted(departments.values(), key=lambda d: d.name if d else ''):
           <thead>
             <tr>
               <th class="department" colspan="${column_count}">Department
@@ -84,7 +84,7 @@
                 % endif
               </th>
             </tr>
-            % for subdepartment in sorted(six.itervalues(department._order_subdepartments), key=lambda s: s.name if s else ''):
+            % for subdepartment in sorted(department._order_subdepartments.values(), key=lambda s: s.name if s else ''):
                 <tr>
                   <th class="subdepartment" colspan="${column_count}">Subdepartment
                     % if subdepartment.number or subdepartment.name:
diff --git a/tailbone/templates/poser/views/configure.mako b/tailbone/templates/poser/views/configure.mako
index f4d75779..cdde15c5 100644
--- a/tailbone/templates/poser/views/configure.mako
+++ b/tailbone/templates/poser/views/configure.mako
@@ -9,7 +9,7 @@
 
   % for topkey, topgroup in sorted(view_settings.items(), key=lambda itm: 'aaaa' if itm[0] == 'rattail' else itm[0]):
       <h3 class="block is-size-3">Views for:&nbsp; ${topkey}</h3>
-      % for group_key, group in six.iteritems(topgroup):
+      % for group_key, group in topgroup.items():
           <h4 class="block is-size-4">${group_key.capitalize()}</h4>
           % for key, label in group:
               ${self.simple_flag(key, label)}
diff --git a/tailbone/templates/products/batch.mako b/tailbone/templates/products/batch.mako
index e0b93bd6..a4a4d503 100644
--- a/tailbone/templates/products/batch.mako
+++ b/tailbone/templates/products/batch.mako
@@ -30,7 +30,7 @@
     ${render_deform_field(form, dform['description'])}
     ${render_deform_field(form, dform['notes'])}
 
-    % for key, pform in six.iteritems(params_forms):
+    % for key, pform in params_forms.items():
         <div v-show="field_model_batch_type == '${key}'">
           % for field in pform.make_deform_form():
               ${render_deform_field(pform, field)}
diff --git a/tailbone/templates/shifts/base.mako b/tailbone/templates/shifts/base.mako
index 4bae5ebf..52b48832 100644
--- a/tailbone/templates/shifts/base.mako
+++ b/tailbone/templates/shifts/base.mako
@@ -57,7 +57,7 @@
                 <div class="field-wrapper employee">
                   <label>Employee</label>
                   <div class="field">
-                    ${dform['employee'].serialize(text=six.text_type(employee), selected_callback='employee_selected')|n}
+                    ${dform['employee'].serialize(text=str(employee), selected_callback='employee_selected')|n}
                   </div>
                 </div>
             % endif
@@ -152,7 +152,7 @@
       </tr>
     </thead>
     <tbody>
-      % for emp in sorted(employees, key=six.text_type):
+      % for emp in sorted(employees, key=str):
           <tr data-employee-uuid="${emp.uuid}">
             <td class="employee">
               ## TODO: add link to single employee schedule / timesheet here...
diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako
index e38696c5..b0e43a37 100644
--- a/tailbone/templates/themes/butterball/base.mako
+++ b/tailbone/templates/themes/butterball/base.mako
@@ -421,7 +421,7 @@
                 referrer: null,
                 % if request.user:
                     userUUID: ${json.dumps(request.user.uuid)|n},
-                    userName: ${json.dumps(six.text_type(request.user))|n},
+                    userName: ${json.dumps(str(request.user))|n},
                 % else:
                     userUUID: null,
                     userName: null,
diff --git a/tailbone/templates/trainwreck/transactions/configure.mako b/tailbone/templates/trainwreck/transactions/configure.mako
index fd6c53a7..99b43fde 100644
--- a/tailbone/templates/trainwreck/transactions/configure.mako
+++ b/tailbone/templates/trainwreck/transactions/configure.mako
@@ -33,7 +33,7 @@
       The selected DBs will be hidden from the DB picker when viewing
       Trainwreck data.
     </p>
-    % for key, engine in six.iteritems(trainwreck_engines):
+    % for key, engine in trainwreck_engines.items():
         <b-field>
           <b-checkbox name="hidedb_${key}"
                       v-model="hiddenDatabases['${key}']"
diff --git a/tailbone/templates/trainwreck/transactions/rollover.mako b/tailbone/templates/trainwreck/transactions/rollover.mako
index 8e27d087..b36e7bc3 100644
--- a/tailbone/templates/trainwreck/transactions/rollover.mako
+++ b/tailbone/templates/trainwreck/transactions/rollover.mako
@@ -8,7 +8,7 @@
 <%def name="page_content()">
   <br />
 
-  % if six.text_type(next_year) not in trainwreck_engines:
+  % if str(next_year) not in trainwreck_engines:
       <b-notification type="is-warning">
         You do not have a database configured for next year (${next_year}).&nbsp;
         You should be sure to configure it before next year rolls around.
diff --git a/tailbone/tweens.py b/tailbone/tweens.py
index f944a66f..9c06c1be 100644
--- a/tailbone/tweens.py
+++ b/tailbone/tweens.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2018 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,9 +24,6 @@
 Tween Factories
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
 from sqlalchemy.exc import OperationalError
 
 
@@ -64,7 +61,7 @@ def sqlerror_tween_factory(handler, registry):
                     mark_error_retryable(error)
                     raise error
                 else:
-                    raise TransientError(six.text_type(error))
+                    raise TransientError(str(error))
 
             # if connection was *not* invalid, raise original error
             raise
diff --git a/tailbone/views/batch/labels.py b/tailbone/views/batch/labels.py
index 79b14a76..7291b05e 100644
--- a/tailbone/views/batch/labels.py
+++ b/tailbone/views/batch/labels.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,10 +24,6 @@
 Views for label batches
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
 from rattail.db import model
 
 from deform import widget as dfwidget
@@ -123,7 +119,7 @@ class LabelBatchView(BatchMasterView):
     ]
 
     def configure_form(self, f):
-        super(LabelBatchView, self).configure_form(f)
+        super().configure_form(f)
 
         # handheld_batches
         if self.creating:
@@ -142,7 +138,7 @@ class LabelBatchView(BatchMasterView):
                 f.replace('label_profile', 'label_profile_uuid')
                 # TODO: should restrict somehow? just allow override?
                 profiles = self.Session.query(model.LabelProfile)
-                values = [(p.uuid, six.text_type(p))
+                values = [(p.uuid, str(p))
                           for p in profiles]
                 require_profile = False
                 if not require_profile:
@@ -159,7 +155,7 @@ class LabelBatchView(BatchMasterView):
         return HTML.tag('ul', c=items)
 
     def configure_row_grid(self, g):
-        super(LabelBatchView, self).configure_row_grid(g)
+        super().configure_row_grid(g)
 
         # short labels
         g.set_label('brand_name', "Brand")
@@ -171,7 +167,7 @@ class LabelBatchView(BatchMasterView):
             return 'warning'
 
     def configure_row_form(self, f):
-        super(LabelBatchView, self).configure_row_form(f)
+        super().configure_row_form(f)
 
         # readonly fields
         f.set_readonly('sequence')
@@ -219,7 +215,7 @@ class LabelBatchView(BatchMasterView):
             profiles = self.Session.query(model.LabelProfile)\
                                    .filter(model.LabelProfile.visible == True)\
                                    .order_by(model.LabelProfile.ordinal)
-            profile_values = [(p.uuid, six.text_type(p))
+            profile_values = [(p.uuid, str(p))
                               for p in profiles]
             f.set_widget('label_profile_uuid', forms.widgets.JQuerySelectWidget(values=profile_values))
 
diff --git a/tailbone/views/batch/pricing.py b/tailbone/views/batch/pricing.py
index 6ba28889..5b5d013b 100644
--- a/tailbone/views/batch/pricing.py
+++ b/tailbone/views/batch/pricing.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,10 +24,6 @@
 Views for pricing batches
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
 from rattail.db import model
 from rattail.time import localtime
 
@@ -155,7 +151,7 @@ class PricingBatchView(BatchMasterView):
         return self.batch_handler.allow_future()
 
     def configure_form(self, f):
-        super(PricingBatchView, self).configure_form(f)
+        super().configure_form(f)
         app = self.get_rattail_app()
         batch = f.model_instance
 
@@ -192,7 +188,7 @@ class PricingBatchView(BatchMasterView):
                 f.set_required('input_filename', False)
 
     def get_batch_kwargs(self, batch, **kwargs):
-        kwargs = super(PricingBatchView, self).get_batch_kwargs(batch, **kwargs)
+        kwargs = super().get_batch_kwargs(batch, **kwargs)
         kwargs['start_date'] = batch.start_date
         kwargs['min_diff_threshold'] = batch.min_diff_threshold
         kwargs['min_diff_percent'] = batch.min_diff_percent
@@ -213,7 +209,7 @@ class PricingBatchView(BatchMasterView):
         return kwargs
 
     def configure_row_grid(self, g):
-        super(PricingBatchView, self).configure_row_grid(g)
+        super().configure_row_grid(g)
 
         g.set_joiner('vendor_id', lambda q: q.outerjoin(model.Vendor))
         g.set_sorter('vendor_id', model.Vendor.id)
@@ -241,13 +237,13 @@ class PricingBatchView(BatchMasterView):
         if row.subdepartment_number:
             if row.subdepartment_name:
                 return HTML.tag('span', title=row.subdepartment_name,
-                                c=six.text_type(row.subdepartment_number))
+                                c=str(row.subdepartment_number))
             return row.subdepartment_number
 
     def render_true_margin(self, row, field):
         margin = row.true_margin
         if margin:
-            margin = six.text_type(margin)
+            margin = str(margin)
         else:
             margin = HTML.literal('&nbsp;')
         if row.old_true_margin is not None:
@@ -295,7 +291,7 @@ class PricingBatchView(BatchMasterView):
         return HTML.tag('span', title=title, c=text)
 
     def configure_row_form(self, f):
-        super(PricingBatchView, self).configure_row_form(f)
+        super().configure_row_form(f)
 
         # readonly fields
         f.set_readonly('product')
@@ -328,7 +324,7 @@ class PricingBatchView(BatchMasterView):
         return tags.link_to(text, url)
 
     def get_row_csv_fields(self):
-        fields = super(PricingBatchView, self).get_row_csv_fields()
+        fields = super().get_row_csv_fields()
 
         if 'vendor_uuid' in fields:
             i = fields.index('vendor_uuid')
@@ -344,7 +340,7 @@ class PricingBatchView(BatchMasterView):
 
     # TODO: this is the same as xlsx row! should merge/share somehow?
     def get_row_csv_row(self, row, fields):
-        csvrow = super(PricingBatchView, self).get_row_csv_row(row, fields)
+        csvrow = super().get_row_csv_row(row, fields)
 
         vendor = row.vendor
         if 'vendor_id' in fields:
@@ -358,7 +354,7 @@ class PricingBatchView(BatchMasterView):
 
     # TODO: this is the same as csv row! should merge/share somehow?
     def get_row_xlsx_row(self, row, fields):
-        xlrow = super(PricingBatchView, self).get_row_xlsx_row(row, fields)
+        xlrow = super().get_row_xlsx_row(row, fields)
 
         vendor = row.vendor
         if 'vendor_id' in fields:
diff --git a/tailbone/views/batch/vendorinvoice.py b/tailbone/views/batch/vendorinvoice.py
index 6b8bdef7..4815d1f4 100644
--- a/tailbone/views/batch/vendorinvoice.py
+++ b/tailbone/views/batch/vendorinvoice.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,10 +24,6 @@
 Views for maintaining vendor invoices
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
 from rattail.db import model
 from rattail.vendors.invoices import iter_invoice_parsers, require_invoice_parser
 
@@ -89,10 +85,10 @@ class VendorInvoiceView(FileBatchMasterView):
     ]
 
     def get_instance_title(self, batch):
-        return six.text_type(batch.vendor)
+        return str(batch.vendor)
 
     def configure_grid(self, g):
-        super(VendorInvoiceView, self).configure_grid(g)
+        super().configure_grid(g)
 
         # vendor
         g.set_joiner('vendor', lambda q: q.join(model.Vendor))
@@ -118,7 +114,7 @@ class VendorInvoiceView(FileBatchMasterView):
         g.set_link('executed', False)
 
     def configure_form(self, f):
-        super(VendorInvoiceView, self).configure_form(f)
+        super().configure_form(f)
 
         # vendor
         if self.creating:
@@ -167,7 +163,7 @@ class VendorInvoiceView(FileBatchMasterView):
     #         raise formalchemy.ValidationError(unicode(error))
 
     def get_batch_kwargs(self, batch):
-        kwargs = super(VendorInvoiceView, self).get_batch_kwargs(batch)
+        kwargs = super().get_batch_kwargs(batch)
         kwargs['parser_key'] = batch.parser_key
         return kwargs
 
@@ -183,7 +179,7 @@ class VendorInvoiceView(FileBatchMasterView):
         return True
 
     def configure_row_grid(self, g):
-        super(VendorInvoiceView, self).configure_row_grid(g)
+        super().configure_row_grid(g)
         g.set_label('upc', "UPC")
         g.set_label('brand_name', "Brand")
         g.set_label('shipped_cases', "Cases")
diff --git a/tailbone/views/exports.py b/tailbone/views/exports.py
index 82591099..44df359f 100644
--- a/tailbone/views/exports.py
+++ b/tailbone/views/exports.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,13 +24,9 @@
 Master class for generic export history views
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import os
 import shutil
 
-import six
-
 from pyramid.response import FileResponse
 from webhelpers2.html import tags
 
@@ -83,7 +79,7 @@ class ExportMasterView(MasterView):
         return self.get_file_path(export)
 
     def configure_grid(self, g):
-        super(ExportMasterView, self).configure_grid(g)
+        super().configure_grid(g)
         model = self.model
 
         # id
@@ -106,7 +102,7 @@ class ExportMasterView(MasterView):
         return export.id_str
 
     def configure_form(self, f):
-        super(ExportMasterView, self).configure_form(f)
+        super().configure_form(f)
         export = f.model_instance
 
         # NOTE: we try to handle the 'creating' scenario even though this class
@@ -149,7 +145,7 @@ class ExportMasterView(MasterView):
             f.set_renderer('filename', self.render_downloadable_file)
 
     def objectify(self, form, data=None):
-        obj = super(ExportMasterView, self).objectify(form, data=data)
+        obj = super().objectify(form, data=data)
         if self.creating:
             obj.created_by = self.request.user
         return obj
@@ -158,7 +154,7 @@ class ExportMasterView(MasterView):
         user = export.created_by
         if not user:
             return ""
-        text = six.text_type(user)
+        text = str(user)
         if self.request.has_perm('users.view'):
             url = self.request.route_url('users.view', uuid=user.uuid)
             return tags.link_to(text, url)
@@ -175,12 +171,8 @@ class ExportMasterView(MasterView):
         export = self.get_instance()
         path = self.get_file_path(export)
         response = FileResponse(path, request=self.request)
-        if six.PY3:
-            response.headers['Content-Length'] = str(os.path.getsize(path))
-            response.headers['Content-Disposition'] = 'attachment; filename="{}"'.format(export.filename)
-        else:
-            response.headers[b'Content-Length'] = six.binary_type(os.path.getsize(path))
-            response.headers[b'Content-Disposition'] = b'attachment; filename="{}"'.format(export.filename)
+        response.headers['Content-Length'] = str(os.path.getsize(path))
+        response.headers['Content-Disposition'] = 'attachment; filename="{}"'.format(export.filename)
         return response
 
     def delete_instance(self, export):
@@ -195,4 +187,4 @@ class ExportMasterView(MasterView):
                 shutil.rmtree(dirname)
 
         # continue w/ normal deletion
-        super(ExportMasterView, self).delete_instance(export)
+        super().delete_instance(export)
diff --git a/tailbone/views/poser/reports.py b/tailbone/views/poser/reports.py
index 43ba211d..462df51d 100644
--- a/tailbone/views/poser/reports.py
+++ b/tailbone/views/poser/reports.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,12 +24,8 @@
 Poser Report Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import os
 
-import six
-
 from rattail.util import simple_error
 
 import colander
@@ -95,7 +91,7 @@ class PoserReportView(PoserMasterView):
         return self.poser_handler.get_all_reports(ignore_errors=False)
 
     def configure_grid(self, g):
-        super(PoserReportView, self).configure_grid(g)
+        super().configure_grid(g)
 
         g.sorters['report_key'] = g.make_simple_sorter('report_key', foldcase=True)
         g.sorters['report_name'] = g.make_simple_sorter('report_name', foldcase=True)
@@ -157,7 +153,7 @@ class PoserReportView(PoserMasterView):
         return report
 
     def configure_form(self, f):
-        super(PoserReportView, self).configure_form(f)
+        super().configure_form(f)
         report = f.model_instance
 
         # report_key
@@ -179,7 +175,7 @@ class PoserReportView(PoserMasterView):
             f.set_helptext('flavor', "Determines the type of sample code to generate.")
             flavors = self.poser_handler.get_supported_report_flavors()
             values = [(key, flavor['description'])
-                      for key, flavor in six.iteritems(flavors)]
+                      for key, flavor in flavors.items()]
             f.set_widget('flavor', dfwidget.SelectWidget(values=values))
             f.set_validator('flavor', colander.OneOf(flavors))
             if flavors:
@@ -231,7 +227,7 @@ class PoserReportView(PoserMasterView):
                     return report
 
     def configure_row_grid(self, g):
-        super(PoserReportView, self).configure_row_grid(g)
+        super().configure_row_grid(g)
 
         g.set_renderer('id', self.render_id_str)
 
diff --git a/tailbone/views/poser/views.py b/tailbone/views/poser/views.py
index 14c97a61..3d3543c7 100644
--- a/tailbone/views/poser/views.py
+++ b/tailbone/views/poser/views.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,10 +24,6 @@
 Poser Views for Views...
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
 import colander
 
 from .master import PoserMasterView
@@ -68,7 +64,7 @@ class PoserViewView(PoserMasterView):
         return self.make_form({})
 
     def configure_form(self, f):
-        super(PoserViewView, self).configure_form(f)
+        super().configure_form(f)
         view = f.model_instance
 
         # key
@@ -224,28 +220,28 @@ class PoserViewView(PoserMasterView):
             },
         }}
 
-        for key, views in six.iteritems(everything['rattail']):
-            for vkey, view in six.iteritems(views):
+        for key, views in everything['rattail'].items():
+            for vkey, view in views.items():
                 view['options'] = [vkey]
 
         providers = get_all_providers(self.rattail_config)
-        for provider in six.itervalues(providers):
+        for provider in providers.values():
 
             # loop thru provider top-level groups
-            for topkey, groups in six.iteritems(provider.get_provided_views()):
+            for topkey, groups in provider.get_provided_views().items()):
 
                 # get or create top group
                 topgroup = everything.setdefault(topkey, {})
 
                 # loop thru provider view groups
-                for key, views in six.iteritems(groups):
+                for key, views in groups.items():
 
                     # add group to top group, if it's new
                     if key not in topgroup:
                         topgroup[key] = views
 
                         # also must init the options for group
-                        for vkey, view in six.iteritems(views):
+                        for vkey, view in views.items():
                             view['options'] = [vkey]
 
                     else: # otherwise must "update" existing group
@@ -254,7 +250,7 @@ class PoserViewView(PoserMasterView):
                         stdgroup = topgroup[key]
 
                         # loop thru views within provider group
-                        for vkey, view in six.iteritems(views):
+                        for vkey, view in views.items():
 
                             # add view to group if it's new
                             if vkey not in stdgroup:
@@ -270,8 +266,8 @@ class PoserViewView(PoserMasterView):
         settings = []
 
         view_settings = self.collect_available_view_settings()
-        for topgroup in six.itervalues(view_settings):
-            for view_section, section_settings in six.iteritems(topgroup):
+        for topgroup in view_settings.values():
+            for view_section, section_settings in topgroup.items():
                 for key in section_settings:
                     settings.append({'section': 'tailbone.includes',
                                      'option': key})
@@ -282,25 +278,25 @@ class PoserViewView(PoserMasterView):
                               input_file_templates=True):
 
         # first get normal context
-        context = super(PoserViewView, self).configure_get_context(
+        context = super().configure_get_context(
             simple_settings=simple_settings,
             input_file_templates=input_file_templates)
 
         # first add available options
         view_settings = self.collect_available_view_settings()
         view_options = {}
-        for topgroup in six.itervalues(view_settings):
-            for key, views in six.iteritems(topgroup):
-                for vkey, view in six.iteritems(views):
+        for topgroup in view_settings.values():
+            for key, views in topgroup.items():
+                for vkey, view in views.items():
                     view_options[vkey] = view['options']
         context['view_options'] = view_options
 
         # then add all available settings as sorted (key, label) options
-        for topkey, topgroup in six.iteritems(view_settings):
+        for topkey, topgroup in view_settings.items():
             for key in list(topgroup):
                 settings = topgroup[key]
                 settings = [(key, setting.get('label', key))
-                            for key, setting in six.iteritems(settings)]
+                            for key, setting in settings.items()]
                 settings.sort(key=lambda itm: itm[1])
                 topgroup[key] = settings
         context['view_settings'] = view_settings
@@ -308,7 +304,7 @@ class PoserViewView(PoserMasterView):
         return context
 
     def configure_flash_settings_saved(self):
-        super(PoserViewView, self).configure_flash_settings_saved()
+        super().configure_flash_settings_saved()
         self.request.session.flash("Please restart the web app!", 'warning')
 
 
diff --git a/tailbone/views/progress.py b/tailbone/views/progress.py
index 169f324e..3f47ba3e 100644
--- a/tailbone/views/progress.py
+++ b/tailbone/views/progress.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,10 +24,6 @@
 Progress Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
 from tailbone.progress import get_progress_session
 
 
@@ -44,7 +40,7 @@ def progress(request):
 
         bits = session.get('extra_session_bits')
         if bits:
-            for key, value in six.iteritems(bits):
+            for key, value in bits.items():
                 request.session[key] = value
 
     elif session.get('error'):
diff --git a/tailbone/views/tempmon/appliances.py b/tailbone/views/tempmon/appliances.py
index c523ae78..4ce52009 100644
--- a/tailbone/views/tempmon/appliances.py
+++ b/tailbone/views/tempmon/appliances.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,11 +24,9 @@
 Views for tempmon appliances
 """
 
-from __future__ import unicode_literals, absolute_import
-
+import io
 import os
 
-import six
 from PIL import Image
 
 from rattail_tempmon.db import model as tempmon
@@ -68,7 +66,7 @@ class TempmonApplianceView(MasterView):
     ]
 
     def configure_grid(self, g):
-        super(TempmonApplianceView, self).configure_grid(g)
+        super().configure_grid(g)
 
         # name
         g.set_sort_defaults('name')
@@ -94,7 +92,7 @@ class TempmonApplianceView(MasterView):
         return HTML.tag('div', class_='image-frame', c=[helper, image])
 
     def configure_form(self, f):
-        super(TempmonApplianceView, self).configure_form(f)
+        super().configure_form(f)
 
         # name
         f.set_validator('name', self.unique_name)
@@ -122,7 +120,7 @@ class TempmonApplianceView(MasterView):
             f.remove_field('probes')
 
     def template_kwargs_view(self, **kwargs):
-        kwargs = super(TempmonApplianceView, self).template_kwargs_view(**kwargs)
+        kwargs = super().template_kwargs_view(**kwargs)
         appliance = kwargs['instance']
 
         kwargs['probes_data'] = self.normalize_probes(appliance.probes)
@@ -176,13 +174,13 @@ class TempmonApplianceView(MasterView):
                 im = Image.open(f)
 
                 im.thumbnail((600, 600), Image.ANTIALIAS)
-                data = six.BytesIO()
+                data = io.BytesIO()
                 im.save(data, 'JPEG')
                 appliance.image_normal = data.getvalue()
                 data.close()
 
                 im.thumbnail((150, 150), Image.ANTIALIAS)
-                data = six.BytesIO()
+                data = io.BytesIO()
                 im.save(data, 'JPEG')
                 appliance.image_thumbnail = data.getvalue()
                 data.close()
diff --git a/tailbone/views/vendors/core.py b/tailbone/views/vendors/core.py
index 8b9361b7..addf153c 100644
--- a/tailbone/views/vendors/core.py
+++ b/tailbone/views/vendors/core.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,10 +24,6 @@
 Vendor Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
 from rattail.db import model
 
 from webhelpers2.html import tags
@@ -158,7 +154,7 @@ class VendorView(MasterView):
         person = vendor.contact
         if not person:
             return ""
-        text = six.text_type(person)
+        text = str(person)
         url = self.request.route_url('people.view', uuid=person.uuid)
         return tags.link_to(text, url)
 
@@ -198,7 +194,7 @@ class VendorView(MasterView):
             data, **kwargs)
 
         supported_vendor_settings = self.configure_get_supported_vendor_settings()
-        for setting in six.itervalues(supported_vendor_settings):
+        for setting in supported_vendor_settings.values():
             name = 'rattail.vendor.{}'.format(setting['key'])
             settings.append({'name': name,
                              'value': data[name]})
@@ -211,7 +207,7 @@ class VendorView(MasterView):
         names = []
 
         supported_vendor_settings = self.configure_get_supported_vendor_settings()
-        for setting in six.itervalues(supported_vendor_settings):
+        for setting in supported_vendor_settings.values():
             names.append('rattail.vendor.{}'.format(setting['key']))
 
         if names:
@@ -236,7 +232,7 @@ class VendorView(MasterView):
             settings[key] = {
                 'key': key,
                 'value': vendor.uuid if vendor else None,
-                'label': six.text_type(vendor) if vendor else None,
+                'label': str(vendor) if vendor else None,
             }
 
         return settings

From c887412825696fc7fe9cd3032d06b28dada4d1b8 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 1 Jul 2024 19:06:04 -0500
Subject: [PATCH 365/542] fix: fix syntax bug

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

diff --git a/tailbone/views/poser/views.py b/tailbone/views/poser/views.py
index 3d3543c7..27efd549 100644
--- a/tailbone/views/poser/views.py
+++ b/tailbone/views/poser/views.py
@@ -228,7 +228,7 @@ class PoserViewView(PoserMasterView):
         for provider in providers.values():
 
             # loop thru provider top-level groups
-            for topkey, groups in provider.get_provided_views().items()):
+            for topkey, groups in provider.get_provided_views().items():
 
                 # get or create top group
                 topgroup = everything.setdefault(topkey, {})

From db67630363ffc7f8d4d7844c91f18b67bbcd57f4 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 1 Jul 2024 23:20:09 -0500
Subject: [PATCH 366/542] =?UTF-8?q?bump:=20version=200.11.5=20=E2=86=92=20?=
 =?UTF-8?q?0.11.6?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 CHANGELOG.md | 8 ++++++++
 setup.cfg    | 2 +-
 2 files changed, 9 insertions(+), 1 deletion(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 510aa6a1..9410fe3f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,14 @@ 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.11.6 (2024-07-01)
+
+### Fix
+
+- set explicit referrer when changing dbkey
+
+- remove references, dependency for `six` package
+
 ## v0.11.5 (2024-06-30)
 
 ### Fix
diff --git a/setup.cfg b/setup.cfg
index 8afd9be4..17f6b151 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,7 +1,7 @@
 
 [metadata]
 name = Tailbone
-version = 0.11.5
+version = 0.11.6
 author = Lance Edgar
 author_email = lance@edbob.org
 url = http://rattailproject.org/

From aab4dec27ebadd60da81efcdf906a049c7af2cea Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 2 Jul 2024 09:05:51 -0500
Subject: [PATCH 367/542] fix: add stacklevel to deprecation warnings

---
 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 84ef451f..f4f74a34 100644
--- a/tailbone/views/batch/core.py
+++ b/tailbone/views/batch/core.py
@@ -853,7 +853,7 @@ class BatchMasterView(MasterView):
                     if isinstance(field.widget, forms.widgets.PlainSelectWidget):
                         warnings.warn("PlainSelectWidget is deprecated; "
                                       "please use deform.widget.SelectWidget instead",
-                                      DeprecationWarning)
+                                      DeprecationWarning, stacklevel=2)
                         field.widget = dfwidget.SelectWidget(values=field.widget.values)
 
         if not schema:

From d72d6f8c7c143c4941169a1b0ad3ee6f2b025cbe Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 2 Jul 2024 11:14:03 -0500
Subject: [PATCH 368/542] fix: require zope.sqlalchemy >= 1.5

so we can do away with some old cruft, since latest zope.sqlalchemy is
3.1 from 2023-09-12
---
 docs/api/db.rst  |   6 ++
 docs/index.rst   |   1 +
 setup.cfg        |   2 +-
 tailbone/db.py   | 231 +++++++++++++++++++++++------------------------
 tests/test_db.py |   7 ++
 5 files changed, 129 insertions(+), 118 deletions(-)
 create mode 100644 docs/api/db.rst
 create mode 100644 tests/test_db.py

diff --git a/docs/api/db.rst b/docs/api/db.rst
new file mode 100644
index 00000000..ace21b68
--- /dev/null
+++ b/docs/api/db.rst
@@ -0,0 +1,6 @@
+
+``tailbone.db``
+===============
+
+.. automodule:: tailbone.db
+  :members:
diff --git a/docs/index.rst b/docs/index.rst
index db05d0c1..3ca6d4e2 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -44,6 +44,7 @@ Package API:
 
    api/api/batch/core
    api/api/batch/ordering
+   api/db
    api/diffs
    api/forms
    api/forms.widgets
diff --git a/setup.cfg b/setup.cfg
index 17f6b151..c42ff675 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -61,7 +61,7 @@ install_requires =
         transaction
         waitress
         WebHelpers2
-        zope.sqlalchemy
+        zope.sqlalchemy>=1.5
 
 
 [options.packages.find]
diff --git a/tailbone/db.py b/tailbone/db.py
index 4a6821f9..8b37f399 100644
--- a/tailbone/db.py
+++ b/tailbone/db.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.
 #
@@ -21,14 +21,13 @@
 #
 ################################################################################
 """
-Database Stuff
+Database sessions etc.
 """
 
 import sqlalchemy as sa
 from zope.sqlalchemy import datamanager
 import sqlalchemy_continuum as continuum
 from sqlalchemy.orm import sessionmaker, scoped_session
-from pkg_resources import get_distribution, parse_version
 
 from rattail.db import SessionBase
 from rattail.db.continuum import versioning_manager
@@ -43,23 +42,28 @@ TrainwreckSession = scoped_session(sessionmaker())
 # empty dict for now, this must populated on app startup (if needed)
 ExtraTrainwreckSessions = {}
 
-# some of the logic below may need to vary somewhat, based on which version of
-# zope.sqlalchemy we have installed
-zope_sqlalchemy_version = get_distribution('zope.sqlalchemy').version
-zope_sqlalchemy_version_parsed = parse_version(zope_sqlalchemy_version)
-
 
 class TailboneSessionDataManager(datamanager.SessionDataManager):
-    """Integrate a top level sqlalchemy session transaction into a zope transaction
+    """
+    Integrate a top level sqlalchemy session transaction into a zope
+    transaction
 
     One phase variant.
 
     .. note::
-       This class appears to be necessary in order for the Continuum
-       integration to work alongside the Zope transaction integration.
+
+       This class appears to be necessary in order for the
+       SQLAlchemy-Continuum integration to work alongside the Zope
+       transaction integration.
+
+       It subclasses
+       ``zope.sqlalchemy.datamanager.SessionDataManager`` but injects
+       some SQLAlchemy-Continuum logic within :meth:`tpc_vote()`, and
+       is sort of monkey-patched into the mix.
     """
 
     def tpc_vote(self, trans):
+        """ """
         # for a one phase data manager commit last in tpc_vote
         if self.tx is not None:  # there may have been no work to do
 
@@ -71,126 +75,120 @@ class TailboneSessionDataManager(datamanager.SessionDataManager):
             self._finish('committed')
 
 
-def join_transaction(session, initial_state=datamanager.STATUS_ACTIVE, transaction_manager=datamanager.zope_transaction.manager, keep_session=False):
-    """Join a session to a transaction using the appropriate datamanager.
+def join_transaction(
+        session,
+        initial_state=datamanager.STATUS_ACTIVE,
+        transaction_manager=datamanager.zope_transaction.manager,
+        keep_session=False,
+):
+    """
+    Join a session to a transaction using the appropriate datamanager.
 
-    It is safe to call this multiple times, if the session is already joined
-    then it just returns.
+    It is safe to call this multiple times, if the session is already
+    joined then it just returns.
 
-    `initial_state` is either STATUS_ACTIVE, STATUS_INVALIDATED or STATUS_READONLY
+    `initial_state` is either STATUS_ACTIVE, STATUS_INVALIDATED or
+    STATUS_READONLY
 
-    If using the default initial status of STATUS_ACTIVE, you must ensure that
-    mark_changed(session) is called when data is written to the database.
+    If using the default initial status of STATUS_ACTIVE, you must
+    ensure that mark_changed(session) is called when data is written
+    to the database.
 
-    The ZopeTransactionExtesion SessionExtension can be used to ensure that this is
-    called automatically after session write operations.
+    The ZopeTransactionExtesion SessionExtension can be used to ensure
+    that this is called automatically after session write operations.
 
     .. note::
-       This function is copied from upstream, and tweaked so that our custom
-       :class:`TailboneSessionDataManager` will be used.
+
+       This function appears to be necessary in order for the
+       SQLAlchemy-Continuum integration to work alongside the Zope
+       transaction integration.
+
+       It overrides ``zope.sqlalchemy.datamanager.join_transaction()``
+       to ensure the custom :class:`TailboneSessionDataManager` is
+       used, and is sort of monkey-patched into the mix.
     """
     # the upstream internals of this function has changed a little over time.
     # unfortunately for us, that means we must include each variant here.
 
-    if zope_sqlalchemy_version_parsed >= parse_version('1.1'): # 1.1+
-        if datamanager._SESSION_STATE.get(session, None) is None:
-            if session.twophase:
-                DataManager = datamanager.TwoPhaseSessionDataManager
-            else:
-                DataManager = TailboneSessionDataManager
-            DataManager(session, initial_state, transaction_manager, keep_session=keep_session)
-
-    else: # pre-1.1
-        if datamanager._SESSION_STATE.get(id(session), None) is None:
-            if session.twophase:
-                DataManager = datamanager.TwoPhaseSessionDataManager
-            else:
-                DataManager = TailboneSessionDataManager
-            DataManager(session, initial_state, transaction_manager, keep_session=keep_session)
+    if datamanager._SESSION_STATE.get(session, None) is None:
+        if session.twophase:
+            DataManager = datamanager.TwoPhaseSessionDataManager
+        else:
+            DataManager = TailboneSessionDataManager
+        DataManager(session, initial_state, transaction_manager, keep_session=keep_session)
 
 
-if zope_sqlalchemy_version_parsed >= parse_version('1.2'): # 1.2+
-
-    class ZopeTransactionEvents(datamanager.ZopeTransactionEvents):
-        """
-        Record that a flush has occurred on a session's
-        connection. This allows the DataManager to rollback rather
-        than commit on read only transactions.
-
-        .. note::
-           This class is copied from upstream, and tweaked so that our
-           custom :func:`join_transaction()` will be used.
-        """
-
-        def after_begin(self, session, transaction, connection):
-            join_transaction(session, self.initial_state,
-                             self.transaction_manager, self.keep_session)
-
-        def after_attach(self, session, instance):
-            join_transaction(session, self.initial_state,
-                             self.transaction_manager, self.keep_session)
-
-        def join_transaction(self, session):
-            join_transaction(session, self.initial_state,
-                             self.transaction_manager, self.keep_session)
-
-else: # pre-1.2
-
-    class ZopeTransactionExtension(datamanager.ZopeTransactionExtension):
-        """
-        Record that a flush has occurred on a session's
-        connection. This allows the DataManager to rollback rather
-        than commit on read only transactions.
-
-        .. note::
-           This class is copied from upstream, and tweaked so that our
-           custom :func:`join_transaction()` will be used.
-        """
-
-        def after_begin(self, session, transaction, connection):
-            join_transaction(session, self.initial_state,
-                             self.transaction_manager, self.keep_session)
-
-        def after_attach(self, session, instance):
-            join_transaction(session, self.initial_state,
-                             self.transaction_manager, self.keep_session)
-
-
-def register(session, initial_state=datamanager.STATUS_ACTIVE,
-             transaction_manager=datamanager.zope_transaction.manager, keep_session=False):
-    """Register ZopeTransaction listener events on the
-    given Session or Session factory/class.
-
-    This function requires at least SQLAlchemy 0.7 and makes use
-    of the newer sqlalchemy.event package in order to register event listeners
-    on the given Session.
-
-    The session argument here may be a Session class or subclass, a
-    sessionmaker or scoped_session instance, or a specific Session instance.
-    Event listening will be specific to the scope of the type of argument
-    passed, including specificity to its subclass as well as its identity.
+class ZopeTransactionEvents(datamanager.ZopeTransactionEvents):
+    """
+    Record that a flush has occurred on a session's connection. This
+    allows the DataManager to rollback rather than commit on read only
+    transactions.
 
     .. note::
-       This function is copied from upstream, and tweaked so that our custom
-       :class:`ZopeTransactionExtension` will be used.
+
+       This class appears to be necessary in order for the
+       SQLAlchemy-Continuum integration to work alongside the Zope
+       transaction integration.
+
+       It subclasses
+       ``zope.sqlalchemy.datamanager.ZopeTransactionEvents`` but
+       overrides various methods to ensure the custom
+       :func:`join_transaction()` is called, and is sort of
+       monkey-patched into the mix.
+    """
+
+    def after_begin(self, session, transaction, connection):
+        """ """
+        join_transaction(session, self.initial_state,
+                         self.transaction_manager, self.keep_session)
+
+    def after_attach(self, session, instance):
+        """ """
+        join_transaction(session, self.initial_state,
+                         self.transaction_manager, self.keep_session)
+
+    def join_transaction(self, session):
+        """ """
+        join_transaction(session, self.initial_state,
+                         self.transaction_manager, self.keep_session)
+
+
+def register(
+        session,
+        initial_state=datamanager.STATUS_ACTIVE,
+        transaction_manager=datamanager.zope_transaction.manager,
+        keep_session=False,
+):
+    """
+    Register ZopeTransaction listener events on the given Session or
+    Session factory/class.
+
+    This function requires at least SQLAlchemy 0.7 and makes use of
+    the newer sqlalchemy.event package in order to register event
+    listeners on the given Session.
+
+    The session argument here may be a Session class or subclass, a
+    sessionmaker or scoped_session instance, or a specific Session
+    instance.  Event listening will be specific to the scope of the
+    type of argument passed, including specificity to its subclass as
+    well as its identity.
+
+    .. note::
+
+       This function appears to be necessary in order for the
+       SQLAlchemy-Continuum integration to work alongside the Zope
+       transaction integration.
+
+       It overrides ``zope.sqlalchemy.datamanager.regsiter()`` to
+       ensure the custom :class:`ZopeTransactionEvents` is used.
     """
     from sqlalchemy import event
 
-    if zope_sqlalchemy_version_parsed >= parse_version('1.2'): # 1.2+
-
-        ext = ZopeTransactionEvents(
-            initial_state=initial_state,
-            transaction_manager=transaction_manager,
-            keep_session=keep_session,
-        )
-
-    else: # pre-1.2
-
-        ext = ZopeTransactionExtension(
-            initial_state=initial_state,
-            transaction_manager=transaction_manager,
-            keep_session=keep_session,
-        )
+    ext = ZopeTransactionEvents(
+        initial_state=initial_state,
+        transaction_manager=transaction_manager,
+        keep_session=keep_session,
+    )
 
     event.listen(session, "after_begin", ext.after_begin)
     event.listen(session, "after_attach", ext.after_attach)
@@ -199,9 +197,8 @@ def register(session, initial_state=datamanager.STATUS_ACTIVE,
     event.listen(session, "after_bulk_delete", ext.after_bulk_delete)
     event.listen(session, "before_commit", ext.before_commit)
 
-    if zope_sqlalchemy_version_parsed >= parse_version('1.5'): # 1.5+
-        if datamanager.SA_GE_14:
-            event.listen(session, "do_orm_execute", ext.do_orm_execute)
+    if datamanager.SA_GE_14:
+        event.listen(session, "do_orm_execute", ext.do_orm_execute)
 
 
 register(Session)
diff --git a/tests/test_db.py b/tests/test_db.py
new file mode 100644
index 00000000..88cb9d41
--- /dev/null
+++ b/tests/test_db.py
@@ -0,0 +1,7 @@
+# -*- coding: utf-8; -*-
+
+# TODO: add real tests at some point but this at least gives us basic
+# coverage when running this "test" module alone
+
+from tailbone import db
+

From 1f38894f02e6279e2180d87185bef6d3651a0065 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 2 Jul 2024 14:14:15 -0500
Subject: [PATCH 369/542] fix: include edit profile email/phone dialogs only if
 user has perms

otherwise we get JS errors when page loads
---
 tailbone/templates/people/view_profile.mako | 238 ++++++++++----------
 1 file changed, 122 insertions(+), 116 deletions(-)

diff --git a/tailbone/templates/people/view_profile.mako b/tailbone/templates/people/view_profile.mako
index 3520d924..22b4b8c6 100644
--- a/tailbone/templates/people/view_profile.mako
+++ b/tailbone/templates/people/view_profile.mako
@@ -461,72 +461,75 @@
 
         </${b}-table>
 
-        <${b}-modal has-modal-card
-                    % if request.use_oruga:
-                        v-model:active="deletePhoneShowDialog"
-                    % else:
-                        :active.sync="deletePhoneShowDialog"
-                    % endif
-                    >
-          <div class="modal-card">
+        % if request.has_perm('people_profile.edit_person'):
 
-            <header class="modal-card-head">
-              <p class="modal-card-title">Delete Phone</p>
-            </header>
+            <${b}-modal has-modal-card
+                        % if request.use_oruga:
+                            v-model:active="deletePhoneShowDialog"
+                        % else:
+                            :active.sync="deletePhoneShowDialog"
+                        % endif
+                        >
+              <div class="modal-card">
 
-            <section class="modal-card-body">
-              <p class="block">Really delete this phone number?</p>
-              <p class="block has-text-weight-bold">{{ deletePhoneNumber }}</p>
-            </section>
+                <header class="modal-card-head">
+                  <p class="modal-card-title">Delete Phone</p>
+                </header>
 
-            <footer class="modal-card-foot">
-              <b-button type="is-danger"
-                        @click="deletePhoneSave()"
-                        :disabled="deletePhoneSaving"
-                        icon-pack="fas"
-                        icon-left="trash">
-                {{ deletePhoneSaving ? "Working, please wait..." : "Delete" }}
-              </b-button>
-              <b-button @click="deletePhoneShowDialog = false">
-                Cancel
-              </b-button>
-            </footer>
-          </div>
-        </${b}-modal>
+                <section class="modal-card-body">
+                  <p class="block">Really delete this phone number?</p>
+                  <p class="block has-text-weight-bold">{{ deletePhoneNumber }}</p>
+                </section>
 
-        <${b}-modal has-modal-card
-                    % if request.use_oruga:
-                        v-model:active="preferPhoneShowDialog"
-                    % else:
-                        :active.sync="preferPhoneShowDialog"
-                    % endif
-                    >
-          <div class="modal-card">
+                <footer class="modal-card-foot">
+                  <b-button type="is-danger"
+                            @click="deletePhoneSave()"
+                            :disabled="deletePhoneSaving"
+                            icon-pack="fas"
+                            icon-left="trash">
+                    {{ deletePhoneSaving ? "Working, please wait..." : "Delete" }}
+                  </b-button>
+                  <b-button @click="deletePhoneShowDialog = false">
+                    Cancel
+                  </b-button>
+                </footer>
+              </div>
+            </${b}-modal>
 
-            <header class="modal-card-head">
-              <p class="modal-card-title">Set Preferred Phone</p>
-            </header>
+            <${b}-modal has-modal-card
+                        % if request.use_oruga:
+                            v-model:active="preferPhoneShowDialog"
+                        % else:
+                            :active.sync="preferPhoneShowDialog"
+                        % endif
+                        >
+              <div class="modal-card">
 
-            <section class="modal-card-body">
-              <p class="block">Really make this the preferred phone number?</p>
-              <p class="block has-text-weight-bold">{{ preferPhoneNumber }}</p>
-            </section>
+                <header class="modal-card-head">
+                  <p class="modal-card-title">Set Preferred Phone</p>
+                </header>
 
-            <footer class="modal-card-foot">
-              <b-button type="is-primary"
-                        @click="preferPhoneSave()"
-                        :disabled="preferPhoneSaving"
-                        icon-pack="fas"
-                        icon-left="save">
-                {{ preferPhoneSaving ? "Working, please wait..." : "Set Preferred" }}
-              </b-button>
-              <b-button @click="preferPhoneShowDialog = false">
-                Cancel
-              </b-button>
-            </footer>
-          </div>
-        </${b}-modal>
+                <section class="modal-card-body">
+                  <p class="block">Really make this the preferred phone number?</p>
+                  <p class="block has-text-weight-bold">{{ preferPhoneNumber }}</p>
+                </section>
 
+                <footer class="modal-card-foot">
+                  <b-button type="is-primary"
+                            @click="preferPhoneSave()"
+                            :disabled="preferPhoneSaving"
+                            icon-pack="fas"
+                            icon-left="save">
+                    {{ preferPhoneSaving ? "Working, please wait..." : "Set Preferred" }}
+                  </b-button>
+                  <b-button @click="preferPhoneShowDialog = false">
+                    Cancel
+                  </b-button>
+                </footer>
+              </div>
+            </${b}-modal>
+
+        % endif
       </div>
     </div>
   </div>
@@ -694,72 +697,75 @@
 
         </${b}-table>
 
-        <${b}-modal has-modal-card
-                    % if request.use_oruga:
-                        v-model:active="deleteEmailShowDialog"
-                    % else:
-                        :active.sync="deleteEmailShowDialog"
-                    % endif
-                    >
-          <div class="modal-card">
+        % if request.has_perm('people_profile.edit_person'):
 
-            <header class="modal-card-head">
-              <p class="modal-card-title">Delete Email</p>
-            </header>
+            <${b}-modal has-modal-card
+                        % if request.use_oruga:
+                            v-model:active="deleteEmailShowDialog"
+                        % else:
+                            :active.sync="deleteEmailShowDialog"
+                        % endif
+                        >
+              <div class="modal-card">
 
-            <section class="modal-card-body">
-              <p class="block">Really delete this email address?</p>
-              <p class="block has-text-weight-bold">{{ deleteEmailAddress }}</p>
-            </section>
+                <header class="modal-card-head">
+                  <p class="modal-card-title">Delete Email</p>
+                </header>
 
-            <footer class="modal-card-foot">
-              <b-button type="is-danger"
-                        @click="deleteEmailSave()"
-                        :disabled="deleteEmailSaving"
-                        icon-pack="fas"
-                        icon-left="trash">
-                {{ deleteEmailSaving ? "Working, please wait..." : "Delete" }}
-              </b-button>
-              <b-button @click="deleteEmailShowDialog = false">
-                Cancel
-              </b-button>
-            </footer>
-          </div>
-        </${b}-modal>
+                <section class="modal-card-body">
+                  <p class="block">Really delete this email address?</p>
+                  <p class="block has-text-weight-bold">{{ deleteEmailAddress }}</p>
+                </section>
 
-        <${b}-modal has-modal-card
-                    % if request.use_oruga:
-                        v-model:active="preferEmailShowDialog"
-                    % else:
-                        :active.sync="preferEmailShowDialog"
-                    % endif
-                    >
-          <div class="modal-card">
+                <footer class="modal-card-foot">
+                  <b-button type="is-danger"
+                            @click="deleteEmailSave()"
+                            :disabled="deleteEmailSaving"
+                            icon-pack="fas"
+                            icon-left="trash">
+                    {{ deleteEmailSaving ? "Working, please wait..." : "Delete" }}
+                  </b-button>
+                  <b-button @click="deleteEmailShowDialog = false">
+                    Cancel
+                  </b-button>
+                </footer>
+              </div>
+            </${b}-modal>
 
-            <header class="modal-card-head">
-              <p class="modal-card-title">Set Preferred Email</p>
-            </header>
+            <${b}-modal has-modal-card
+                        % if request.use_oruga:
+                            v-model:active="preferEmailShowDialog"
+                        % else:
+                            :active.sync="preferEmailShowDialog"
+                        % endif
+                        >
+              <div class="modal-card">
 
-            <section class="modal-card-body">
-              <p class="block">Really make this the preferred email address?</p>
-              <p class="block has-text-weight-bold">{{ preferEmailAddress }}</p>
-            </section>
+                <header class="modal-card-head">
+                  <p class="modal-card-title">Set Preferred Email</p>
+                </header>
 
-            <footer class="modal-card-foot">
-              <b-button type="is-primary"
-                        @click="preferEmailSave()"
-                        :disabled="preferEmailSaving"
-                        icon-pack="fas"
-                        icon-left="save">
-                {{ preferEmailSaving ? "Working, please wait..." : "Set Preferred" }}
-              </b-button>
-              <b-button @click="preferEmailShowDialog = false">
-                Cancel
-              </b-button>
-            </footer>
-          </div>
-        </${b}-modal>
+                <section class="modal-card-body">
+                  <p class="block">Really make this the preferred email address?</p>
+                  <p class="block has-text-weight-bold">{{ preferEmailAddress }}</p>
+                </section>
 
+                <footer class="modal-card-foot">
+                  <b-button type="is-primary"
+                            @click="preferEmailSave()"
+                            :disabled="preferEmailSaving"
+                            icon-pack="fas"
+                            icon-left="save">
+                    {{ preferEmailSaving ? "Working, please wait..." : "Set Preferred" }}
+                  </b-button>
+                  <b-button @click="preferEmailShowDialog = false">
+                    Cancel
+                  </b-button>
+                </footer>
+              </div>
+            </${b}-modal>
+
+        % endif
       </div>
     </div>
   </div>

From 9146cdc835f63518c7ffc2bb98a9fdbd310ce00f Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 2 Jul 2024 14:20:48 -0500
Subject: [PATCH 370/542] fix: allow view supplements to add to profile member
 context

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

diff --git a/tailbone/views/people.py b/tailbone/views/people.py
index d8e36ec9..d3a82dc0 100644
--- a/tailbone/views/people.py
+++ b/tailbone/views/people.py
@@ -742,10 +742,15 @@ class PersonView(MasterView):
         membership = app.get_membership_handler()
 
         data = OrderedDict()
-
         members = membership.get_members_for_account_holder(person)
         for member in members:
-            data[member.uuid] = self.get_context_member(member)
+            context = self.get_context_member(member)
+
+            for supp in self.iter_view_supplements():
+                if hasattr(supp, 'get_context_for_member'):
+                    context = supp.get_context_for_member(member, context)
+
+            data[member.uuid] = context
 
         return list(data.values())
 

From e23193b73083c33c0b79e74be572d406e3e2a16e Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 2 Jul 2024 16:45:10 -0500
Subject: [PATCH 371/542] fix: cast enum as list to satisfy deform widget

seems to only be an issue for deform 2.0.15+
---
 tailbone/views/batch/product.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/tailbone/views/batch/product.py b/tailbone/views/batch/product.py
index af8374ac..590c3ff0 100644
--- a/tailbone/views/batch/product.py
+++ b/tailbone/views/batch/product.py
@@ -46,7 +46,7 @@ class ExecutionOptions(colander.Schema):
     action = colander.SchemaNode(
         colander.String(),
         validator=colander.OneOf(ACTION_OPTIONS),
-        widget=dfwidget.SelectWidget(values=ACTION_OPTIONS.items()))
+        widget=dfwidget.SelectWidget(values=list(ACTION_OPTIONS.items())))
 
 
 class ProductBatchView(BatchMasterView):

From 5e11a2ecf6ab9e258d799ffdb43a8a37a4b27613 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 2 Jul 2024 22:47:03 -0500
Subject: [PATCH 372/542] fix: expand POD image URL setting input

---
 tailbone/templates/products/configure.mako | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/tailbone/templates/products/configure.mako b/tailbone/templates/products/configure.mako
index 10f3c0e5..6121af67 100644
--- a/tailbone/templates/products/configure.mako
+++ b/tailbone/templates/products/configure.mako
@@ -41,8 +41,8 @@
       <b-input name="rattail.pod.pictures.gtin.root_url"
                v-model="simpleSettings['rattail.pod.pictures.gtin.root_url']"
                :disabled="!simpleSettings['tailbone.products.show_pod_image']"
-               @input="settingsNeedSaved = true">
-      </b-input>
+               @input="settingsNeedSaved = true"
+               expanded />
     </b-field>
 
   </div>

From 76897c24dee986b60f63cc0d33f4b18de47c1eb1 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 4 Jul 2024 08:20:31 -0500
Subject: [PATCH 373/542] =?UTF-8?q?bump:=20version=200.11.6=20=E2=86=92=20?=
 =?UTF-8?q?0.11.7?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 CHANGELOG.md | 16 ++++++++++++++++
 setup.cfg    |  2 +-
 2 files changed, 17 insertions(+), 1 deletion(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9410fe3f..672bd2b6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,22 @@ 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.11.7 (2024-07-04)
+
+### Fix
+
+- add stacklevel to deprecation warnings
+
+- require zope.sqlalchemy >= 1.5
+
+- include edit profile email/phone dialogs only if user has perms
+
+- allow view supplements to add to profile member context
+
+- cast enum as list to satisfy deform widget
+
+- expand POD image URL setting input
+
 ## v0.11.6 (2024-07-01)
 
 ### Fix
diff --git a/setup.cfg b/setup.cfg
index c42ff675..e00b92f2 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,7 +1,7 @@
 
 [metadata]
 name = Tailbone
-version = 0.11.6
+version = 0.11.7
 author = Lance Edgar
 author_email = lance@edbob.org
 url = http://rattailproject.org/

From 793a15883e9b16d2f5a8bcedecdafaff63460ddb Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 4 Jul 2024 15:59:05 -0500
Subject: [PATCH 374/542] fix: fix grid action icons for datasync/configure,
 per oruga

---
 tailbone/templates/datasync/configure.mako | 66 ++++++++++++++++++----
 1 file changed, 54 insertions(+), 12 deletions(-)

diff --git a/tailbone/templates/datasync/configure.mako b/tailbone/templates/datasync/configure.mako
index 8b0f5e51..a512745c 100644
--- a/tailbone/templates/datasync/configure.mako
+++ b/tailbone/templates/datasync/configure.mako
@@ -157,15 +157,29 @@
         <a href="#"
            class="grid-action"
            @click.prevent="editProfile(props.row)">
-          <i class="fas fa-edit"></i>
-          Edit
+          % if request.use_oruga:
+              <span class="icon-text">
+                <o-icon icon="edit" />
+                <span>Edit</span>
+              </span>
+          % else:
+              <i class="fas fa-edit"></i>
+              Edit
+          % endif
         </a>
         &nbsp;
         <a href="#"
            class="grid-action has-text-danger"
            @click.prevent="deleteProfile(props.row)">
-          <i class="fas fa-trash"></i>
-          Delete
+          % if request.use_oruga:
+              <span class="icon-text">
+                <o-icon icon="trash" />
+                <span>Delete</span>
+              </span>
+          % else:
+              <i class="fas fa-trash"></i>
+              Delete
+          % endif
         </a>
       </${b}-table-column>
       <template #empty>
@@ -314,15 +328,29 @@
                             v-slot="props">
               <a href="#"
                  @click.prevent="editProfileWatcherKwarg(props.row)">
-                <i class="fas fa-edit"></i>
-                Edit
+                % if request.use_oruga:
+                    <span class="icon-text">
+                      <o-icon icon="edit" />
+                      <span>Edit</span>
+                    </span>
+                % else:
+                    <i class="fas fa-edit"></i>
+                    Edit
+                % endif
               </a>
               &nbsp;
               <a href="#"
                  class="has-text-danger"
                  @click.prevent="deleteProfileWatcherKwarg(props.row)">
-                <i class="fas fa-trash"></i>
-                Delete
+                % if request.use_oruga:
+                    <span class="icon-text">
+                      <o-icon icon="trash" />
+                      <span>Delete</span>
+                    </span>
+                % else:
+                    <i class="fas fa-trash"></i>
+                    Delete
+                % endif
               </a>
             </${b}-table-column>
             <template #empty>
@@ -372,15 +400,29 @@
                 <a href="#"
                    class="grid-action"
                    @click.prevent="editProfileConsumer(props.row)">
-                  <i class="fas fa-edit"></i>
-                  Edit
+                  % if request.use_oruga:
+                      <span class="icon-text">
+                        <o-icon icon="edit" />
+                        <span>Edit</span>
+                      </span>
+                  % else:
+                      <i class="fas fa-edit"></i>
+                      Edit
+                  % endif
                 </a>
                 &nbsp;
                 <a href="#"
                    class="grid-action has-text-danger"
                    @click.prevent="deleteProfileConsumer(props.row)">
-                  <i class="fas fa-trash"></i>
-                  Delete
+                  % if request.use_oruga:
+                      <span class="icon-text">
+                        <o-icon icon="trash" />
+                        <span>Delete</span>
+                      </span>
+                  % else:
+                      <i class="fas fa-trash"></i>
+                      Delete
+                  % endif
                 </a>
               </${b}-table-column>
               <template #empty>

From 89d7009a1855e2062a2a1e8cfd107e801a3356b3 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 4 Jul 2024 18:21:06 -0500
Subject: [PATCH 375/542] fix: allow view supplements to add extra links for
 profile employee tab

---
 tailbone/templates/people/view_profile.mako | 250 ++++++++++----------
 tailbone/views/people.py                    |   6 +
 2 files changed, 136 insertions(+), 120 deletions(-)

diff --git a/tailbone/templates/people/view_profile.mako b/tailbone/templates/people/view_profile.mako
index 22b4b8c6..4767a924 100644
--- a/tailbone/templates/people/view_profile.mako
+++ b/tailbone/templates/people/view_profile.mako
@@ -1274,141 +1274,151 @@
 
         </div>
 
-        <div>
-          <div class="buttons">
+        <div style="display: flex; gap: 0.75rem;">
 
-            % if request.has_perm('people_profile.toggle_employee'):
+          % if request.has_perm('people_profile.toggle_employee'):
 
-                <b-button v-if="!employee.current"
-                          type="is-primary"
-                          @click="startEmployeeInit()">
-                  ${person} is now an Employee
-                </b-button>
+              <b-button v-if="!employee.current"
+                        type="is-primary"
+                        @click="startEmployeeInit()">
+                ${person} is now an Employee
+              </b-button>
 
-                <b-button v-if="employee.current"
-                          type="is-primary"
-                          @click="stopEmployeeInit()">
-                  ${person} is no longer an Employee
-                </b-button>
+              <b-button v-if="employee.current"
+                        type="is-primary"
+                        @click="stopEmployeeInit()">
+                ${person} is no longer an Employee
+              </b-button>
 
-                <${b}-modal has-modal-card
-                            % if request.use_oruga:
-                                v-model:active="startEmployeeShowDialog"
-                            % else:
-                                :active.sync="startEmployeeShowDialog"
-                            % endif
-                            >
-                  <div class="modal-card">
+              <${b}-modal has-modal-card
+                          % if request.use_oruga:
+                              v-model:active="startEmployeeShowDialog"
+                          % else:
+                              :active.sync="startEmployeeShowDialog"
+                          % endif
+                          >
+                <div class="modal-card">
 
-                    <header class="modal-card-head">
-                      <p class="modal-card-title">Employee Start</p>
-                    </header>
+                  <header class="modal-card-head">
+                    <p class="modal-card-title">Employee Start</p>
+                  </header>
 
-                    <section class="modal-card-body">
-                      <b-field label="Employee Number">
-                        <b-input v-model="startEmployeeID"></b-input>
-                      </b-field>
-                      <b-field label="Start Date">
-                        <tailbone-datepicker v-model="startEmployeeStartDate"
-                                             ref="startEmployeeStartDate" />
-                      </b-field>
-                    </section>
+                  <section class="modal-card-body">
+                    <b-field label="Employee Number">
+                      <b-input v-model="startEmployeeID"></b-input>
+                    </b-field>
+                    <b-field label="Start Date">
+                      <tailbone-datepicker v-model="startEmployeeStartDate"
+                                           ref="startEmployeeStartDate" />
+                    </b-field>
+                  </section>
 
-                    <footer class="modal-card-foot">
-                      <b-button @click="startEmployeeShowDialog = false">
-                        Cancel
-                      </b-button>
-                      <b-button type="is-primary"
-                                @click="startEmployeeSave()"
-                                :disabled="startEmployeeSaveDisabled"
-                                icon-pack="fas"
-                                icon-left="save">
-                        {{ startEmployeeSaving ? "Working, please wait..." : "Save" }}
-                      </b-button>
-                    </footer>
-                  </div>
-                </${b}-modal>
+                  <footer class="modal-card-foot">
+                    <b-button @click="startEmployeeShowDialog = false">
+                      Cancel
+                    </b-button>
+                    <b-button type="is-primary"
+                              @click="startEmployeeSave()"
+                              :disabled="startEmployeeSaveDisabled"
+                              icon-pack="fas"
+                              icon-left="save">
+                      {{ startEmployeeSaving ? "Working, please wait..." : "Save" }}
+                    </b-button>
+                  </footer>
+                </div>
+              </${b}-modal>
 
-                <${b}-modal has-modal-card
-                            % if request.use_oruga:
-                                v-model:active="stopEmployeeShowDialog"
-                            % else:
-                                :active.sync="stopEmployeeShowDialog"
-                            % endif
-                            >
-                  <div class="modal-card">
+              <${b}-modal has-modal-card
+                          % if request.use_oruga:
+                              v-model:active="stopEmployeeShowDialog"
+                          % else:
+                              :active.sync="stopEmployeeShowDialog"
+                          % endif
+                          >
+                <div class="modal-card">
 
-                    <header class="modal-card-head">
-                      <p class="modal-card-title">Employee End</p>
-                    </header>
+                  <header class="modal-card-head">
+                    <p class="modal-card-title">Employee End</p>
+                  </header>
 
-                    <section class="modal-card-body">
-                      <b-field label="End Date"
-                               :type="stopEmployeeEndDate ? null : 'is-danger'">
-                        <tailbone-datepicker v-model="stopEmployeeEndDate"></tailbone-datepicker>
-                      </b-field>
-                      <b-field label="Revoke Internal App Access">
-                        <b-checkbox v-model="stopEmployeeRevokeAccess">
-                        </b-checkbox>
-                      </b-field>
-                    </section>
+                  <section class="modal-card-body">
+                    <b-field label="End Date"
+                             :type="stopEmployeeEndDate ? null : 'is-danger'">
+                      <tailbone-datepicker v-model="stopEmployeeEndDate"></tailbone-datepicker>
+                    </b-field>
+                    <b-field label="Revoke Internal App Access">
+                      <b-checkbox v-model="stopEmployeeRevokeAccess">
+                      </b-checkbox>
+                    </b-field>
+                  </section>
 
-                    <footer class="modal-card-foot">
-                      <b-button @click="stopEmployeeShowDialog = false">
-                        Cancel
-                      </b-button>
-                      <b-button type="is-primary"
-                                @click="stopEmployeeSave()"
-                                :disabled="stopEmployeeSaveDisabled"
-                                icon-pack="fas"
-                                icon-left="save">
-                        {{ stopEmployeeSaving ? "Working, please wait..." : "Save" }}
-                      </b-button>
-                    </footer>
-                  </div>
-                </${b}-modal>
-            % endif
+                  <footer class="modal-card-foot">
+                    <b-button @click="stopEmployeeShowDialog = false">
+                      Cancel
+                    </b-button>
+                    <b-button type="is-primary"
+                              @click="stopEmployeeSave()"
+                              :disabled="stopEmployeeSaveDisabled"
+                              icon-pack="fas"
+                              icon-left="save">
+                      {{ stopEmployeeSaving ? "Working, please wait..." : "Save" }}
+                    </b-button>
+                  </footer>
+                </div>
+              </${b}-modal>
+          % endif
 
-            % if request.has_perm('people_profile.edit_employee_history'):
-                <${b}-modal has-modal-card
-                            % if request.use_oruga:
-                                v-model:active="editEmployeeHistoryShowDialog"
-                            % else:
-                                :active.sync="editEmployeeHistoryShowDialog"
-                            % endif
-                            >
-                  <div class="modal-card">
+          % if request.has_perm('people_profile.edit_employee_history'):
+              <${b}-modal has-modal-card
+                          % if request.use_oruga:
+                              v-model:active="editEmployeeHistoryShowDialog"
+                          % else:
+                              :active.sync="editEmployeeHistoryShowDialog"
+                          % endif
+                          >
+                <div class="modal-card">
 
-                    <header class="modal-card-head">
-                      <p class="modal-card-title">Edit Employee History</p>
-                    </header>
+                  <header class="modal-card-head">
+                    <p class="modal-card-title">Edit Employee History</p>
+                  </header>
 
-                    <section class="modal-card-body">
-                      <b-field label="Start Date">
-                        <tailbone-datepicker v-model="editEmployeeHistoryStartDate"></tailbone-datepicker>
-                      </b-field>
-                      <b-field label="End Date">
-                        <tailbone-datepicker v-model="editEmployeeHistoryEndDate"
-                                             :disabled="!editEmployeeHistoryEndDateRequired">
-                        </tailbone-datepicker>
-                      </b-field>
-                    </section>
+                  <section class="modal-card-body">
+                    <b-field label="Start Date">
+                      <tailbone-datepicker v-model="editEmployeeHistoryStartDate"></tailbone-datepicker>
+                    </b-field>
+                    <b-field label="End Date">
+                      <tailbone-datepicker v-model="editEmployeeHistoryEndDate"
+                                           :disabled="!editEmployeeHistoryEndDateRequired">
+                      </tailbone-datepicker>
+                    </b-field>
+                  </section>
 
-                    <footer class="modal-card-foot">
-                      <b-button @click="editEmployeeHistoryShowDialog = false">
-                        Cancel
-                      </b-button>
-                      <b-button type="is-primary"
-                                @click="editEmployeeHistorySave()"
-                                :disabled="editEmployeeHistorySaveDisabled"
-                                icon-pack="fas"
-                                icon-left="save">
-                        {{ editEmployeeHistorySaving ? "Working, please wait..." : "Save" }}
-                      </b-button>
-                    </footer>
-                  </div>
-                </${b}-modal>
-            % endif
+                  <footer class="modal-card-foot">
+                    <b-button @click="editEmployeeHistoryShowDialog = false">
+                      Cancel
+                    </b-button>
+                    <b-button type="is-primary"
+                              @click="editEmployeeHistorySave()"
+                              :disabled="editEmployeeHistorySaveDisabled"
+                              icon-pack="fas"
+                              icon-left="save">
+                      {{ editEmployeeHistorySaving ? "Working, please wait..." : "Save" }}
+                    </b-button>
+                  </footer>
+                </div>
+              </${b}-modal>
+          % endif
+
+          <div style="display: flex; flex-direction: column; align-items: right; gap: 0.75rem;">
+
+            <b-button v-for="link in employee.external_links"
+                      :key="link.url"
+                      type="is-primary"
+                      tag="a" :href="link.url" target="_blank"
+                      icon-pack="fas"
+                      icon-left="external-link-alt">
+              {{ link.label }}
+            </b-button>
 
             % if request.has_perm('employees.view'):
                 <b-button v-if="employee.view_url"
diff --git a/tailbone/views/people.py b/tailbone/views/people.py
index d3a82dc0..b9fe5c4b 100644
--- a/tailbone/views/people.py
+++ b/tailbone/views/people.py
@@ -803,6 +803,12 @@ class PersonView(MasterView):
         app = self.get_rattail_app()
         handler = app.get_employment_handler()
         context = handler.get_context_employee(employee)
+        context.setdefault('external_links', [])
+
+        for supp in self.iter_view_supplements():
+            if hasattr(supp, 'get_context_for_employee'):
+                context = supp.get_context_for_employee(employee, context)
+
         context['view_url'] = self.request.route_url('employees.view', uuid=employee.uuid)
         return context
 

From ddec77c37f6e6cb8b9b54666c63b0a79808ebeee Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 4 Jul 2024 20:33:34 -0500
Subject: [PATCH 376/542] fix: leverage import handler method to determine
 command/subcommand

just moved previous logic to rattail/handler
---
 tailbone/views/importing.py | 18 +-----------------
 1 file changed, 1 insertion(+), 17 deletions(-)

diff --git a/tailbone/views/importing.py b/tailbone/views/importing.py
index e9167132..48b32cc2 100644
--- a/tailbone/views/importing.py
+++ b/tailbone/views/importing.py
@@ -34,7 +34,6 @@ import time
 
 import sqlalchemy as sa
 
-from rattail.exceptions import ConfigurationError
 from rattail.threads import Thread
 
 import colander
@@ -458,22 +457,7 @@ And here is the output:
         return HTML.tag('div', class_='tailbone-markdown', c=[notes])
 
     def get_cmd_for_handler(self, handler, ignore_errors=False):
-        handler_key = handler.get_key()
-
-        cmd = self.rattail_config.getlist('rattail.importing',
-                                          '{}.cmd'.format(handler_key))
-        if not cmd or len(cmd) != 2:
-            cmd = self.rattail_config.getlist('rattail.importing',
-                                              '{}.default_cmd'.format(handler_key))
-
-            if not cmd or len(cmd) != 2:
-                msg = ("Missing or invalid config; please set '{}.default_cmd' in the "
-                       "[rattail.importing] section of your config file".format(handler_key))
-                if ignore_errors:
-                    return
-                raise ConfigurationError(msg)
-
-        return cmd
+        return handler.get_cmd(ignore_errors=ignore_errors)
 
     def get_runas_for_handler(self, handler):
         handler_key = handler.get_key()

From 58be7e9d5b7f62d8e4c8440e8a3d77a16f63e7b9 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 4 Jul 2024 21:32:46 -0500
Subject: [PATCH 377/542] fix: add tool to make user account from profile view

---
 tailbone/templates/people/view_profile.mako | 144 +++++++++++++++++---
 tailbone/views/people.py                    |  40 +++++-
 2 files changed, 167 insertions(+), 17 deletions(-)

diff --git a/tailbone/templates/people/view_profile.mako b/tailbone/templates/people/view_profile.mako
index 4767a924..0b700ca5 100644
--- a/tailbone/templates/people/view_profile.mako
+++ b/tailbone/templates/people/view_profile.mako
@@ -1635,28 +1635,30 @@
         <br />
         <div id="users-accordion">
 
-          <b-collapse class="panel"
-                      v-for="user in users"
-                      :key="user.uuid">
+          <${b}-collapse v-for="user in users"
+                      :key="user.uuid"
+                      class="panel">
 
             <div slot="trigger"
+                 slot-scope="props"
                  class="panel-heading"
                  role="button">
-              <strong>{{ user.username }}</strong>
+                <b-icon pack="fas"
+                        icon="caret-right">
+                </b-icon>
+                <strong>{{ user.username }}</strong>
             </div>
 
             <div class="panel-block">
               <div style="display: flex; justify-content: space-between; width: 100%;">
 
-                <div>
-                  <div class="field-wrapper id">
-                    <div class="field-row">
-                      <label>Username</label>
-                      <div class="field">
-                        {{ user.username }}
-                      </div>
-                    </div>
-                  </div>
+                <div style="flex-grow: 1;">
+                  <b-field horizontal label="Username">
+                    {{ user.username }}
+                  </b-field>
+                  <b-field horizontal label="Active">
+                    {{ user.active ? "Yes" : "No" }}
+                  </b-field>
                 </div>
 
                 <div>
@@ -1669,13 +1671,66 @@
 
               </div>
             </div>
-          </b-collapse>
+          </${b}-collapse>
         </div>
       </div>
 
-      <div v-if="!users.length">
+      <div v-if="!users.length"
+           style="display: flex; justify-content: space-between;">
+
         <p>{{ person.display_name }} does not have a user account.</p>
+
+        % if request.has_perm('users.create'):
+            <b-button type="primary"
+                      icon-pack="fas"
+                      icon-left="plus"
+                      @click="createUserInit()">
+              Create User
+            </b-button>
+
+            <${b}-modal has-modal-card
+                        % if request.use_oruga:
+                            v-model:active="createUserShowDialog"
+                        % else:
+                            :active.sync="createUserShowDialog"
+                        % endif
+                        >
+              <div class="modal-card">
+
+                <header class="modal-card-head">
+                  <p class="modal-card-title">Create User</p>
+                </header>
+
+                <section class="modal-card-body">
+                  <b-field label="Person">
+                    <span>{{ person.display_name }}</span>
+                  </b-field>
+                  <b-field label="Username">
+                    <b-input v-model="createUserUsername"
+                             ref="username" />
+                  </b-field>
+                  <b-field label="Active">
+                    <b-checkbox v-model="createUserActive" />
+                  </b-field>
+                </section>
+
+                <footer class="modal-card-foot">
+                  <b-button @click="createUserShowDialog = false">
+                    Cancel
+                  </b-button>
+                  <b-button type="is-primary"
+                            @click="createUserSave()"
+                            :disabled="createUserSaveDisabled"
+                            icon-pack="fas"
+                            icon-left="save">
+                    {{ createUserSaving ? "Working, please wait..." : "Save" }}
+                  </b-button>
+                </footer>
+              </div>
+            </${b}-modal>
+        % endif
       </div>
+
       % if request.use_oruga:
           <o-loading v-model:active="refreshingTab" :full-page="false"></o-loading>
       % else:
@@ -2730,6 +2785,13 @@
     let UserTabData = {
         refreshTabURL: '${url('people.profile_tab_user', uuid=person.uuid)}',
         users: [],
+
+        % if request.has_perm('users.create'):
+            createUserShowDialog: false,
+            createUserUsername: null,
+            createUserActive: false,
+            createUserSaving: false,
+        % endif
     }
 
     let UserTab = {
@@ -2738,12 +2800,62 @@
         props: {
             person: Object,
         },
-        computed: {},
+
+        computed: {
+
+            % if request.has_perm('users.create'):
+
+                createUserSaveDisabled() {
+                    if (this.createUserSaving) {
+                        return true
+                    }
+                    if (!this.createUserUsername) {
+                        return true
+                    }
+                    return false
+                },
+
+            % endif
+        },
+
         methods: {
 
             refreshTabSuccess(response) {
                 this.users = response.data.users
+                this.createUserSuggestedUsername = response.data.suggested_username
             },
+
+            % if request.has_perm('users.create'):
+
+                createUserInit() {
+                    this.createUserUsername = this.createUserSuggestedUsername
+                    this.createUserActive = true
+                    this.createUserShowDialog = true
+                    this.$nextTick(() => {
+                        this.$refs.username.focus()
+                    })
+                },
+
+                createUserSave() {
+                    this.createUserSaving = true
+
+                    let url = '${master.get_action_url('profile_make_user', instance)}'
+                    let params = {
+                        username: this.createUserUsername,
+                        active: this.createUserActive,
+                    }
+
+                    this.simplePOST(url, params, response => {
+                        this.$emit('profile-changed', response.data)
+                        this.createUserSaving = false
+                        this.createUserShowDialog = false
+                        this.refreshTab()
+                    }, response => {
+                        this.createUserSaving = false
+                    })
+                },
+
+            % endif
         },
     }
 
diff --git a/tailbone/views/people.py b/tailbone/views/people.py
index b9fe5c4b..08e32c3c 100644
--- a/tailbone/views/people.py
+++ b/tailbone/views/people.py
@@ -1280,11 +1280,40 @@ class PersonView(MasterView):
         """
         Fetch user tab data for profile view.
         """
+        app = self.get_rattail_app()
+        auth = app.get_auth_handler()
         person = self.get_instance()
-        return {
+        context = {
             'users': self.get_context_users(person),
         }
 
+        if not context['users']:
+            context['suggested_username'] = auth.generate_unique_username(self.Session(),
+                                                                          person=person)
+
+        return context
+
+    def profile_make_user(self):
+        """
+        Create a new user account, presumably from the profile view.
+        """
+        app = self.get_rattail_app()
+        model = self.model
+        auth = app.get_auth_handler()
+
+        person = self.get_instance()
+        if person.users:
+            return {'error': f"This person already has {len(person.users)} user accounts."}
+
+        data = self.request.json_body
+        user = auth.make_user(session=self.Session(),
+                              person=person,
+                              username=data['username'],
+                              active=data['active'])
+
+        self.Session.flush()
+        return self.profile_changed_response(person)
+
     def profile_revisions_grid(self, person):
         route_prefix = self.get_route_prefix()
         factory = self.get_grid_factory()
@@ -1787,6 +1816,15 @@ class PersonView(MasterView):
                         route_name=f'{route_prefix}.profile_tab_user',
                         renderer='json')
 
+        # profile - make user
+        config.add_route(f'{route_prefix}.profile_make_user',
+                         f'{instance_url_prefix}/make-user',
+                         request_method='POST')
+        config.add_view(cls, attr='profile_make_user',
+                        route_name=f'{route_prefix}.profile_make_user',
+                        permission='users.create',
+                        renderer='json')
+
         # profile - revisions data
         config.add_tailbone_permission('people_profile',
                                        'people_profile.view_versions',

From 431a4d7433d639d6b3850a8f571d1eab0b9230ce Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 4 Jul 2024 23:59:06 -0500
Subject: [PATCH 378/542] =?UTF-8?q?bump:=20version=200.11.7=20=E2=86=92=20?=
 =?UTF-8?q?0.11.8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 CHANGELOG.md | 12 ++++++++++++
 setup.cfg    |  2 +-
 2 files changed, 13 insertions(+), 1 deletion(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 672bd2b6..15fe3a46 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,18 @@ 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.11.8 (2024-07-04)
+
+### Fix
+
+- fix grid action icons for datasync/configure, per oruga
+
+- allow view supplements to add extra links for profile employee tab
+
+- leverage import handler method to determine command/subcommand
+
+- add tool to make user account from profile view
+
 ## v0.11.7 (2024-07-04)
 
 ### Fix
diff --git a/setup.cfg b/setup.cfg
index e00b92f2..6e81a547 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,7 +1,7 @@
 
 [metadata]
 name = Tailbone
-version = 0.11.7
+version = 0.11.8
 author = Lance Edgar
 author_email = lance@edbob.org
 url = http://rattailproject.org/

From 2988ff3ee937a5fadbcda853b5bad97eacde7028 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 5 Jul 2024 12:50:45 -0500
Subject: [PATCH 379/542] fix: do not show flash message when changing app
 theme

it is just distracting esp. when testing different themes
---
 tailbone/views/common.py | 2 --
 1 file changed, 2 deletions(-)

diff --git a/tailbone/views/common.py b/tailbone/views/common.py
index 3c4b659b..7e9ddb09 100644
--- a/tailbone/views/common.py
+++ b/tailbone/views/common.py
@@ -151,8 +151,6 @@ class CommonView(View):
             except Exception as error:
                 msg = "Failed to set theme: {}: {}".format(error.__class__.__name__, error)
                 self.request.session.flash(msg, 'error')
-            else:
-                self.request.session.flash("App theme has been changed to: {}".format(theme))
         referrer = self.request.params.get('referrer') or self.request.get_referrer()
         return self.redirect(referrer)
 

From 735327e46b22663eeafdea588e80d5566a8dc8c4 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 5 Jul 2024 12:53:14 -0500
Subject: [PATCH 380/542] fix: improve collapse panels for butterball theme

---
 tailbone/templates/custorders/create.mako   |  10 +-
 tailbone/templates/people/view_profile.mako | 117 ++++++++++++++------
 2 files changed, 87 insertions(+), 40 deletions(-)

diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako
index 9a3a2d57..63505422 100644
--- a/tailbone/templates/custorders/create.mako
+++ b/tailbone/templates/custorders/create.mako
@@ -78,15 +78,16 @@
 
             <b-icon v-if="props.open"
                     pack="fas"
-                    icon="angle-down">
+                    icon="caret-down">
             </b-icon>
 
             <span v-if="!props.open">
               <b-icon pack="fas"
-                      icon="angle-right">
+                      icon="caret-right">
               </b-icon>
             </span>
 
+            &nbsp;
             <strong v-html="customerPanelHeader"></strong>
           </div>
         </template>
@@ -525,15 +526,16 @@
 
             <b-icon v-if="props.open"
                     pack="fas"
-                    icon="angle-down">
+                    icon="caret-down">
             </b-icon>
 
             <span v-if="!props.open">
               <b-icon pack="fas"
-                      icon="angle-right">
+                      icon="caret-right">
               </b-icon>
             </span>
 
+            &nbsp;
             <strong v-html="itemsPanelHeader"></strong>
           </div>
         </template>
diff --git a/tailbone/templates/people/view_profile.mako b/tailbone/templates/people/view_profile.mako
index 0b700ca5..1eac6a2f 100644
--- a/tailbone/templates/people/view_profile.mako
+++ b/tailbone/templates/people/view_profile.mako
@@ -836,20 +836,35 @@
             </div>
 
             <br />
-            <b-collapse v-for="member in members"
-                        :key="member.uuid"
-                        class="panel"
-                        :open="members.length == 1">
+            <${b}-collapse v-for="member in members"
+                           :key="member.uuid"
+                           class="panel"
+                           :open="members.length == 1">
 
-              <div slot="trigger"
-                   slot-scope="props"
-                   class="panel-heading"
-                   role="button">
-                <b-icon pack="fas"
-                        icon="caret-right">
-                </b-icon>
-                <strong>{{ member._key }} - {{ member.display }}</strong>
-              </div>
+              <template #trigger="props">
+                <div class="panel-heading"
+                     role="button"
+                     style="cursor: pointer;">
+
+                  ## 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="caret-down" />
+
+                  <span v-if="!props.open">
+                    <b-icon pack="fas"
+                            icon="caret-right" />
+                  </span>
+
+                  &nbsp;
+                  <strong>{{ member._key }} - {{ member.display }}</strong>
+                </div>
+              </template>
 
               <div class="panel-block">
                 <div style="display: flex; justify-content: space-between; width: 100%;">
@@ -917,7 +932,7 @@
                   </div>
                 </div>
               </div>
-            </b-collapse>
+            </${b}-collapse>
           </div>
 
           <div v-if="!members.length">
@@ -957,20 +972,35 @@
         </div>
 
         <br />
-        <b-collapse v-for="customer in customers"
-                    :key="customer.uuid"
-                    class="panel"
-                    :open="customers.length == 1">
+        <${b}-collapse v-for="customer in customers"
+                       :key="customer.uuid"
+                       class="panel"
+                       :open="customers.length == 1">
 
-          <div slot="trigger"
-               slot-scope="props"
-               class="panel-heading"
-               role="button">
-            <b-icon pack="fas"
-                    icon="caret-right">
-            </b-icon>
-            <strong>{{ customer._key }} - {{ customer.name }}</strong>
-          </div>
+          <template #trigger="props">
+            <div class="panel-heading"
+                 role="button"
+                 style="cursor: pointer;">
+
+              ## 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="caret-down" />
+
+              <span v-if="!props.open">
+                <b-icon pack="fas"
+                        icon="caret-right" />
+              </span>
+
+              &nbsp;
+              <strong>{{ customer._key }} - {{ customer.name }}</strong>
+            </div>
+          </template>
 
           <div class="panel-block">
             <div style="display: flex; justify-content: space-between; width: 100%;">
@@ -1045,7 +1075,7 @@
               </div>
             </div>
           </div>
-        </b-collapse>
+        </${b}-collapse>
       </div>
 
       <div v-if="!customers.length">
@@ -1639,15 +1669,30 @@
                       :key="user.uuid"
                       class="panel">
 
-            <div slot="trigger"
-                 slot-scope="props"
-                 class="panel-heading"
-                 role="button">
-                <b-icon pack="fas"
-                        icon="caret-right">
-                </b-icon>
+            <template #trigger="props">
+              <div class="panel-heading"
+                   role="button"
+                   style="cursor: pointer;">
+
+                ## 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="caret-down" />
+
+                <span v-if="!props.open">
+                  <b-icon pack="fas"
+                          icon="caret-right" />
+                </span>
+
+                &nbsp;
                 <strong>{{ user.username }}</strong>
-            </div>
+              </div>
+            </template>
 
             <div class="panel-block">
               <div style="display: flex; justify-content: space-between; width: 100%;">

From 19e65f5bb9f2191b90b6e81d26d105f8d26ca3db Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 5 Jul 2024 13:07:08 -0500
Subject: [PATCH 381/542] fix: expand input for butterball theme

---
 tailbone/templates/people/configure.mako | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/tailbone/templates/people/configure.mako b/tailbone/templates/people/configure.mako
index 9e6ce5fb..d821d898 100644
--- a/tailbone/templates/people/configure.mako
+++ b/tailbone/templates/people/configure.mako
@@ -4,7 +4,7 @@
 <%def name="form_content()">
 
   <h3 class="block is-size-3">General</h3>
-  <div class="block" style="padding-left: 2rem;">
+  <div class="block" style="padding-left: 2rem; width: 50%;">
 
     <b-field message="If set, grid links are to Personal tab of Profile view.">
       <b-checkbox name="rattail.people.straight_to_profile"
@@ -28,8 +28,8 @@
              message="Leave blank for default handler.">
       <b-input name="rattail.people.handler"
                v-model="simpleSettings['rattail.people.handler']"
-               @input="settingsNeedSaved = true">
-      </b-input>
+               @input="settingsNeedSaved = true"
+               expanded />
     </b-field>
 
   </div>

From b7d26b6b8ccf896643fc8da2b07678e3dc8e2bf7 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 5 Jul 2024 14:30:52 -0500
Subject: [PATCH 382/542] fix: add xref button to customer profile, for
 trainwreck txn view

---
 tailbone/views/trainwreck/base.py | 17 +++++++++++++++++
 1 file changed, 17 insertions(+)

diff --git a/tailbone/views/trainwreck/base.py b/tailbone/views/trainwreck/base.py
index 59a42301..9a6086d7 100644
--- a/tailbone/views/trainwreck/base.py
+++ b/tailbone/views/trainwreck/base.py
@@ -270,6 +270,23 @@ class TransactionView(MasterView):
 
         return kwargs
 
+    def get_xref_buttons(self, txn):
+        app = self.get_rattail_app()
+        clientele = app.get_clientele_handler()
+        buttons = super().get_xref_buttons(txn)
+
+        if txn.customer_id:
+            customer = clientele.locate_customer_for_key(Session(), txn.customer_id)
+            if customer:
+                person = app.get_person(customer)
+                if person:
+                    url = self.request.route_url('people.view_profile', uuid=person.uuid)
+                    buttons.append(self.make_xref_button(text=str(person),
+                                                         url=url,
+                                                         internal=True))
+
+        return buttons
+
     def get_row_data(self, transaction):
         return self.Session.query(self.model_row_class)\
                            .filter(self.model_row_class.transaction == transaction)

From 16bf13787dff8b0528d20ff360273643139dba0f Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 5 Jul 2024 14:45:35 -0500
Subject: [PATCH 383/542] fix: add optional Transactions tab for profile view

showing Trainwreck data by default
---
 tailbone/templates/people/configure.mako    |  14 +++
 tailbone/templates/people/view_profile.mako |  93 ++++++++++++++++++
 tailbone/views/people.py                    | 103 ++++++++++++++++++++
 3 files changed, 210 insertions(+)

diff --git a/tailbone/templates/people/configure.mako b/tailbone/templates/people/configure.mako
index d821d898..7d7a5618 100644
--- a/tailbone/templates/people/configure.mako
+++ b/tailbone/templates/people/configure.mako
@@ -33,6 +33,20 @@
     </b-field>
 
   </div>
+
+  <h3 class="block is-size-3">Profile View</h3>
+  <div class="block" style="padding-left: 2rem; width: 50%;">
+
+    <b-field>
+      <b-checkbox name="tailbone.people.profile.expose_transactions"
+                  v-model="simpleSettings['tailbone.people.profile.expose_transactions']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Show tab for Customer POS Transactions
+      </b-checkbox>
+    </b-field>
+
+  </div>
 </%def>
 
 
diff --git a/tailbone/templates/people/view_profile.mako b/tailbone/templates/people/view_profile.mako
index 1eac6a2f..9d9ab37d 100644
--- a/tailbone/templates/people/view_profile.mako
+++ b/tailbone/templates/people/view_profile.mako
@@ -1656,6 +1656,34 @@
   </${b}-tab-item>
 </%def>
 
+% if expose_transactions:
+
+    <%def name="render_transactions_tab_template()">
+      <script type="text/x-template" id="transactions-tab-template">
+        <div>
+          <transactions-grid
+            ref="transactionsGrid"
+             />
+        </div>
+      </script>
+    </%def>
+
+    <%def name="render_transactions_tab()">
+      <${b}-tab-item label="Transactions"
+                     value="transactions"
+                     % if not request.use_oruga:
+                         icon-pack="fas"
+                     % endif
+                     icon="bars">
+        <transactions-tab ref="tab_transactions"
+                          :person="person"
+                          @profile-changed="profileChanged" />
+      </${b}-tab-item>
+    </%def>
+
+% endif
+
+
 <%def name="render_user_tab_template()">
   <script type="text/x-template" id="user-tab-template">
     <div>
@@ -1806,6 +1834,9 @@
   % endif
   ${self.render_employee_tab()}
   ${self.render_notes_tab()}
+  % if expose_transactions:
+      ${self.render_transactions_tab()}
+  % endif
   ${self.render_user_tab()}
 </%def>
 
@@ -1941,6 +1972,12 @@
   % 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>
@@ -2824,6 +2861,49 @@
   </script>
 </%def>
 
+% if expose_transactions:
+
+    <%def name="declare_transactions_tab_vars()">
+      <script type="text/javascript">
+
+        let TransactionsTabData = {}
+
+        let TransactionsTab = {
+            template: '#transactions-tab-template',
+            mixins: [TabMixin, SimpleRequestMixin],
+            props: {
+                person: Object,
+            },
+            computed: {},
+            methods: {
+
+                // nb. we override this completely, just tell the grid to refresh
+                refreshTab() {
+                    this.refreshingTab = true
+                    this.$refs.transactionsGrid.loadAsyncData(null, () => {
+                        this.refreshed = Date.now()
+                        this.refreshingTab = false
+                    })
+                }
+            },
+        }
+
+      </script>
+    </%def>
+
+    <%def name="make_transactions_tab_component()">
+      ${self.declare_transactions_tab_vars()}
+      <script type="text/javascript">
+
+        TransactionsTab.data = function() { return TransactionsTabData }
+        Vue.component('transactions-tab', TransactionsTab)
+        <% request.register_component('transactions-tab', 'TransactionsTab') %>
+
+      </script>
+    </%def>
+
+% endif
+
 <%def name="declare_user_tab_vars()">
   <script type="text/javascript">
 
@@ -3086,6 +3166,19 @@
   % 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>
diff --git a/tailbone/views/people.py b/tailbone/views/people.py
index 08e32c3c..2cabf1ec 100644
--- a/tailbone/views/people.py
+++ b/tailbone/views/people.py
@@ -40,6 +40,7 @@ import colander
 from webhelpers2.html import HTML, tags
 
 from tailbone import forms, grids
+from tailbone.db import TrainwreckSession
 from tailbone.views import MasterView
 from tailbone.util import raw_datetime
 
@@ -487,13 +488,101 @@ class PersonView(MasterView):
             'expose_customer_shoppers': self.customers_should_expose_shoppers(),
             'max_one_member': app.get_membership_handler().max_one_per_person(),
             'use_preferred_first_name': self.people_handler.should_use_preferred_first_name(),
+            'expose_transactions': self.should_expose_profile_transactions(),
         }
 
+        if context['expose_transactions']:
+            context['transactions_grid'] = self.profile_transactions_grid(person, empty=True)
+
         if self.request.has_perm('people_profile.view_versions'):
             context['revisions_grid'] = self.profile_revisions_grid(person)
 
         return self.render_to_response('view_profile', context)
 
+    def should_expose_profile_transactions(self):
+        return self.rattail_config.get_bool('tailbone.people.profile.expose_transactions',
+                                            default=False)
+
+    def profile_transactions_grid(self, person, empty=False):
+        app = self.get_rattail_app()
+        trainwreck = app.get_trainwreck_handler()
+        model = trainwreck.get_model()
+        route_prefix = self.get_route_prefix()
+        if empty:
+            # TODO: surely there is a better way to have empty data..? but so
+            # much logic depends on a query, can't just pass empty list here
+            data = TrainwreckSession.query(model.Transaction)\
+                                    .filter(model.Transaction.uuid == 'bogus')
+        else:
+            data = self.profile_transactions_query(person)
+        factory = self.get_grid_factory()
+        g = factory(
+            f'{route_prefix}.profile.transactions.{person.uuid}',
+            data,
+            request=self.request,
+            model_class=model.Transaction,
+            ajax_data_url=self.get_action_url('view_profile_transactions', person),
+            columns=[
+                'start_time',
+                'end_time',
+                'system',
+                'terminal_id',
+                'receipt_number',
+                'cashier_name',
+                'customer_id',
+                'customer_name',
+                'total',
+            ],
+            labels={
+                'terminal_id': "Terminal",
+                'customer_id': "Customer " + app.get_customer_key_label(),
+            },
+            filterable=True,
+            sortable=True,
+            pageable=True,
+            default_sortkey='end_time',
+            default_sortdir='desc',
+            component='transactions-grid',
+        )
+        if self.request.has_perm('trainwreck.transactions.view'):
+            url = lambda row, i: self.request.route_url('trainwreck.transactions.view',
+                                                        uuid=row.uuid)
+            g.main_actions.append(grids.GridAction('view', icon='eye', url=url))
+        g.load_settings()
+
+        g.set_enum('system', self.enum.TRAINWRECK_SYSTEM)
+        g.set_type('total', 'currency')
+
+        return g
+
+    def profile_transactions_query(self, person):
+        """
+        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)
+
+        key_field = app.get_customer_key_field()
+        customer_key = getattr(customer, key_field)
+        if customer_key is not None:
+            customer_key = str(customer_key)
+
+        trainwreck = app.get_trainwreck_handler()
+        model = trainwreck.get_model()
+        query = TrainwreckSession.query(model.Transaction)\
+                                 .filter(model.Transaction.customer_id == customer_key)
+        return query
+
+    def profile_transactions_data(self):
+        """
+        AJAX view to return new sorted, filtered data for transactions
+        grid within profile view.
+        """
+        person = self.get_instance()
+        grid = self.profile_transactions_grid(person)
+        return grid.get_table_data()
+
     def get_context_tabchecks(self, person):
         app = self.get_rattail_app()
         membership = app.get_membership_handler()
@@ -1605,6 +1694,11 @@ class PersonView(MasterView):
             {'section': 'rattail',
              'option': 'people.handler'},
 
+
+            # Profile View
+            {'section': 'tailbone',
+             'option': 'people.profile.expose_transactions',
+             'type': bool},
         ]
 
     @classmethod
@@ -1873,6 +1967,15 @@ class PersonView(MasterView):
                         permission='people_profile.delete_note',
                         renderer='json')
 
+        # profile - transactions data
+        config.add_route(f'{route_prefix}.view_profile_transactions',
+                         f'{instance_url_prefix}/profile/transactions',
+                         request_method='GET')
+        config.add_view(cls, attr='profile_transactions_data',
+                        route_name=f'{route_prefix}.view_profile_transactions',
+                        permission=f'{permission_prefix}.view_profile',
+                        renderer='json')
+
         # make user for person
         config.add_route('{}.make_user'.format(route_prefix), '{}/make-user'.format(url_prefix),
                          request_method='POST')

From 2917463bb6460dc477566457709f614bba5f3de5 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 5 Jul 2024 14:49:59 -0500
Subject: [PATCH 384/542] =?UTF-8?q?bump:=20version=200.11.8=20=E2=86=92=20?=
 =?UTF-8?q?0.11.9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 CHANGELOG.md | 14 ++++++++++++++
 setup.cfg    |  2 +-
 2 files changed, 15 insertions(+), 1 deletion(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 15fe3a46..c493f7c5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,20 @@ 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.11.9 (2024-07-05)
+
+### Fix
+
+- do not show flash message when changing app theme
+
+- improve collapse panels for butterball theme
+
+- expand input for butterball theme
+
+- add xref button to customer profile, for trainwreck txn view
+
+- add optional Transactions tab for profile view
+
 ## v0.11.8 (2024-07-04)
 
 ### Fix
diff --git a/setup.cfg b/setup.cfg
index 6e81a547..c87b903a 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,7 +1,7 @@
 
 [metadata]
 name = Tailbone
-version = 0.11.8
+version = 0.11.9
 author = Lance Edgar
 author_email = lance@edbob.org
 url = http://rattailproject.org/

From 2f2ebd0f079dc47a237b3aab6b3e9c7705f6d438 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 5 Jul 2024 14:57:19 -0500
Subject: [PATCH 385/542] fix: make the Members tab optional, for profile view

and hidden by default
---
 tailbone/templates/people/configure.mako    |  8 +++++++
 tailbone/templates/people/view_profile.mako | 22 +++++++++++++++++---
 tailbone/views/people.py                    | 23 ++++++++++++++-------
 3 files changed, 43 insertions(+), 10 deletions(-)

diff --git a/tailbone/templates/people/configure.mako b/tailbone/templates/people/configure.mako
index 7d7a5618..257432dc 100644
--- a/tailbone/templates/people/configure.mako
+++ b/tailbone/templates/people/configure.mako
@@ -37,6 +37,14 @@
   <h3 class="block is-size-3">Profile View</h3>
   <div class="block" style="padding-left: 2rem; width: 50%;">
 
+    <b-field>
+      <b-checkbox name="tailbone.people.profile.expose_members"
+                  v-model="simpleSettings['tailbone.people.profile.expose_members']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Show tab for Member Accounts
+      </b-checkbox>
+    </b-field>
     <b-field>
       <b-checkbox name="tailbone.people.profile.expose_transactions"
                   v-model="simpleSettings['tailbone.people.profile.expose_transactions']"
diff --git a/tailbone/templates/people/view_profile.mako b/tailbone/templates/people/view_profile.mako
index 9d9ab37d..8044f7c6 100644
--- a/tailbone/templates/people/view_profile.mako
+++ b/tailbone/templates/people/view_profile.mako
@@ -819,6 +819,7 @@
   </${b}-tab-item>
 </%def>
 
+% if expose_members:
 <%def name="render_member_tab_template()">
   <script type="text/x-template" id="member-tab-template">
     <div>
@@ -961,6 +962,7 @@
     </member-tab>
   </${b}-tab-item>
 </%def>
+% endif
 
 <%def name="render_customer_tab_template()">
   <script type="text/x-template" id="customer-tab-template">
@@ -1827,7 +1829,11 @@
 
 <%def name="render_profile_tabs()">
   ${self.render_personal_tab()}
-  ${self.render_member_tab()}
+
+  % if expose_members:
+      ${self.render_member_tab()}
+  % endif
+
   ${self.render_customer_tab()}
   % if expose_customer_shoppers:
       ${self.render_shopper_tab()}
@@ -1965,7 +1971,11 @@
 <%def name="render_this_page_template()">
   ${parent.render_this_page_template()}
   ${self.render_personal_tab_template()}
-  ${self.render_member_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()}
@@ -2385,6 +2395,7 @@
   </script>
 </%def>
 
+% if expose_members:
 <%def name="declare_member_tab_vars()">
   <script type="text/javascript">
 
@@ -2430,6 +2441,7 @@
 
   </script>
 </%def>
+% endif
 
 <%def name="declare_customer_tab_vars()">
   <script type="text/javascript">
@@ -3159,7 +3171,11 @@
 <%def name="make_this_page_component()">
   ${parent.make_this_page_component()}
   ${self.make_personal_tab_component()}
-  ${self.make_member_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()}
diff --git a/tailbone/views/people.py b/tailbone/views/people.py
index 2cabf1ec..9b28b94d 100644
--- a/tailbone/views/people.py
+++ b/tailbone/views/people.py
@@ -488,6 +488,7 @@ class PersonView(MasterView):
             'expose_customer_shoppers': self.customers_should_expose_shoppers(),
             'max_one_member': app.get_membership_handler().max_one_per_person(),
             'use_preferred_first_name': self.people_handler.should_use_preferred_first_name(),
+            'expose_members': self.should_expose_profile_members(),
             'expose_transactions': self.should_expose_profile_transactions(),
         }
 
@@ -499,6 +500,10 @@ class PersonView(MasterView):
 
         return self.render_to_response('view_profile', context)
 
+    def should_expose_profile_members(self):
+        return self.rattail_config.get_bool('tailbone.people.profile.expose_members',
+                                            default=False)
+
     def should_expose_profile_transactions(self):
         return self.rattail_config.get_bool('tailbone.people.profile.expose_transactions',
                                             default=False)
@@ -585,7 +590,6 @@ class PersonView(MasterView):
 
     def get_context_tabchecks(self, person):
         app = self.get_rattail_app()
-        membership = app.get_membership_handler()
         clientele = app.get_clientele_handler()
         tabchecks = {}
 
@@ -596,12 +600,14 @@ class PersonView(MasterView):
         tabchecks['personal'] = True
 
         # member
-        if membership.max_one_per_person():
-            member = app.get_member(person)
-            tabchecks['member'] = bool(member and member.active)
-        else:
-            members = membership.get_members_for_account_holder(person)
-            tabchecks['member'] = any([m.active for m in members])
+        if self.should_expose_profile_members():
+            membership = app.get_membership_handler()
+            if membership.max_one_per_person():
+                member = app.get_member(person)
+                tabchecks['member'] = bool(member and member.active)
+            else:
+                members = membership.get_members_for_account_holder(person)
+                tabchecks['member'] = any([m.active for m in members])
 
         # customer
         customers = clientele.get_customers_for_account_holder(person)
@@ -1696,6 +1702,9 @@ class PersonView(MasterView):
 
 
             # Profile View
+            {'section': 'tailbone',
+             'option': 'people.profile.expose_members',
+             'type': bool},
             {'section': 'tailbone',
              'option': 'people.profile.expose_transactions',
              'type': bool},

From 12f8b7bdf7bde13b69e09fe156323d4a7560b97d Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 5 Jul 2024 14:58:02 -0500
Subject: [PATCH 386/542] =?UTF-8?q?bump:=20version=200.11.9=20=E2=86=92=20?=
 =?UTF-8?q?0.11.10?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

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

diff --git a/CHANGELOG.md b/CHANGELOG.md
index c493f7c5..54b0e1de 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.11.10 (2024-07-05)
+
+### Fix
+
+- make the Members tab optional, for profile view
+
 ## v0.11.9 (2024-07-05)
 
 ### Fix
diff --git a/setup.cfg b/setup.cfg
index c87b903a..1787343a 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,7 +1,7 @@
 
 [metadata]
 name = Tailbone
-version = 0.11.9
+version = 0.11.10
 author = Lance Edgar
 author_email = lance@edbob.org
 url = http://rattailproject.org/

From a86a33445e25c3255eaa5633fea573c33a53d93e Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 9 Jul 2024 16:45:36 -0500
Subject: [PATCH 387/542] feat: drop python 3.6 support, use pyproject.toml
 (again)

---
 pyproject.toml | 102 +++++++++++++++++++++++++++++++++++++++++++++++++
 setup.cfg      |  96 ----------------------------------------------
 setup.py       |   3 --
 tox.ini        |  14 +------
 4 files changed, 103 insertions(+), 112 deletions(-)
 create mode 100644 pyproject.toml
 delete mode 100644 setup.cfg
 delete mode 100644 setup.py

diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 00000000..bc4bb451
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,102 @@
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+
+[project]
+name = "Tailbone"
+version = "0.11.10"
+description = "Backoffice Web Application for Rattail"
+readme = "README.rst"
+authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]
+license = {text = "GNU GPL v3+"}
+classifiers = [
+        "Development Status :: 4 - Beta",
+        "Environment :: Web Environment",
+        "Framework :: Pyramid",
+        "Intended Audience :: Developers",
+        "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
+        "Natural Language :: English",
+        "Operating System :: OS Independent",
+        "Programming Language :: Python",
+        "Programming Language :: Python :: 3",
+        "Programming Language :: Python :: 3.8",
+        "Programming Language :: Python :: 3.9",
+        "Programming Language :: Python :: 3.10",
+        "Programming Language :: Python :: 3.11",
+        "Topic :: Internet :: WWW/HTTP",
+        "Topic :: Office/Business",
+        "Topic :: Software Development :: Libraries :: Python Modules",
+]
+requires-python = ">= 3.8"
+dependencies = [
+        "asgiref",
+        "colander",
+        "ColanderAlchemy",
+        "cornice",
+        "cornice-swagger",
+        "deform",
+        "humanize",
+        "Mako",
+        "markdown",
+        "openpyxl",
+        "paginate",
+        "paginate_sqlalchemy",
+        "passlib",
+        "Pillow",
+        "pyramid>=2",
+        "pyramid_beaker",
+        "pyramid_deform",
+        "pyramid_exclog",
+        "pyramid_fanstatic",
+        "pyramid_mako",
+        "pyramid_retry",
+        "pyramid_tm",
+        "rattail[db,bouncer]",
+        "sa-filters",
+        "simplejson",
+        "transaction",
+        "waitress",
+        "WebHelpers2",
+        "zope.sqlalchemy>=1.5",
+]
+
+
+[project.optional-dependencies]
+docs = ["Sphinx", "sphinx-rtd-theme"]
+tests = ["coverage", "mock", "pytest", "pytest-cov"]
+
+
+[project.entry-points."paste.app_factory"]
+main = "tailbone.app:main"
+webapi = "tailbone.webapi:main"
+
+
+[project.entry-points."rattail.cleaners"]
+beaker = "tailbone.cleanup:BeakerCleaner"
+
+
+[project.entry-points."rattail.config.extensions"]
+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"
+
+
+[tool.commitizen]
+version_provider = "pep621"
+tag_format = "v$version"
+update_changelog_on_bump = true
+
+
+[tool.nosetests]
+nocapture = 1
+cover-package = "tailbone"
+cover-erase = 1
+cover-html = 1
+cover-html-dir = "htmlcov"
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index 1787343a..00000000
--- a/setup.cfg
+++ /dev/null
@@ -1,96 +0,0 @@
-
-[metadata]
-name = Tailbone
-version = 0.11.10
-author = Lance Edgar
-author_email = lance@edbob.org
-url = http://rattailproject.org/
-license = GNU GPL v3
-description = Backoffice Web Application for Rattail
-long_description = file: README.rst
-classifiers =
-        Development Status :: 4 - Beta
-        Environment :: Web Environment
-        Framework :: Pyramid
-        Intended Audience :: Developers
-        License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
-        Natural Language :: English
-        Operating System :: OS Independent
-        Programming Language :: Python
-        Programming Language :: Python :: 3
-        Programming Language :: Python :: 3.6
-        Programming Language :: Python :: 3.7
-        Programming Language :: Python :: 3.8
-        Programming Language :: Python :: 3.9
-        Programming Language :: Python :: 3.10
-        Programming Language :: Python :: 3.11
-        Topic :: Internet :: WWW/HTTP
-        Topic :: Office/Business
-        Topic :: Software Development :: Libraries :: Python Modules
-
-
-[options]
-packages = find:
-include_package_data = True
-install_requires =
-        asgiref
-        colander
-        ColanderAlchemy
-        cornice
-        cornice-swagger
-        deform
-        humanize
-        Mako
-        markdown
-        openpyxl
-        paginate
-        paginate_sqlalchemy
-        passlib
-        Pillow
-        pyramid>=2
-        pyramid_beaker
-        pyramid_deform
-        pyramid_exclog
-        pyramid_fanstatic
-        pyramid_mako
-        pyramid_retry
-        pyramid_tm
-        rattail[db,bouncer]
-        sa-filters
-        simplejson
-        transaction
-        waitress
-        WebHelpers2
-        zope.sqlalchemy>=1.5
-
-
-[options.packages.find]
-exclude =
-        tests.*
-        tests
-
-
-[options.extras_require]
-docs = Sphinx; sphinx-rtd-theme
-tests = coverage; mock; pytest; pytest-cov
-
-
-[options.entry_points]
-
-paste.app_factory =
-        main = tailbone.app:main
-        webapi = tailbone.webapi:main
-
-rattail.cleaners =
-        beaker = tailbone.cleanup:BeakerCleaner
-
-rattail.config.extensions =
-        tailbone = tailbone.config:ConfigExtension
-
-
-[nosetests]
-nocapture = 1
-cover-package = tailbone
-cover-erase = 1
-cover-html = 1
-cover-html-dir = htmlcov
diff --git a/setup.py b/setup.py
deleted file mode 100644
index b908cbe5..00000000
--- a/setup.py
+++ /dev/null
@@ -1,3 +0,0 @@
-import setuptools
-
-setuptools.setup()
diff --git a/tox.ini b/tox.ini
index 6e45883c..3896befb 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,24 +1,12 @@
 
 [tox]
-# TODO: i had to remove py36 since something (hatchling?) broke it
-# somehow, and i was not able to quickly fix.  as of writing only
-# one app is known to run py36 and hopefully that is not for long.
-envlist = py37, py38, py39, py310, py311
-
-# TODO: can remove this when we drop py36 support
-# nb. need this for testing older python versions
-# https://tox.wiki/en/latest/faq.html#testing-end-of-life-python-versions
-requires = virtualenv<20.22.0
+envlist = py38, py39, py310, py311
 
 [testenv]
 deps = rattail-tempmon
 extras = tests
 commands = pytest {posargs}
 
-[testenv:py37]
-# nb. Chameleon 4.3 requires Python 3.9+
-deps = Chameleon<4.3
-
 [testenv:coverage]
 basepython = python3
 extras = tests

From 4eb58663798de21e34391c2298c12b53a7f2b4c5 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 9 Jul 2024 16:45:45 -0500
Subject: [PATCH 388/542] =?UTF-8?q?bump:=20version=200.11.10=20=E2=86=92?=
 =?UTF-8?q?=200.12.0?=
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 54b0e1de..e3832f0f 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.12.0 (2024-07-09)
+
+### Feat
+
+- drop python 3.6 support, use pyproject.toml (again)
+
 ## v0.11.10 (2024-07-05)
 
 ### Fix
diff --git a/pyproject.toml b/pyproject.toml
index bc4bb451..7b4cd713 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.11.10"
+version = "0.12.0"
 description = "Backoffice Web Application for Rattail"
 readme = "README.rst"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]

From ae8212069c731a10cc342965711c562d6f1db603 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 11 Jul 2024 13:16:02 -0500
Subject: [PATCH 389/542] fix: refactor `config.get_model()` => `app.model`

per rattail changes
---
 pyproject.toml                     |  2 +-
 tailbone/forms/core.py             |  3 ++-
 tailbone/forms/widgets.py          | 18 ++++++++++++------
 tailbone/grids/core.py             | 12 +++++++-----
 tailbone/subscribers.py            |  8 +++++---
 tailbone/util.py                   |  2 +-
 tailbone/views/asgi/__init__.py    |  7 ++++---
 tailbone/views/core.py             | 18 +++++++++---------
 tailbone/views/custorders/batch.py | 18 +++++++++---------
 tasks.py                           |  6 ++++--
 10 files changed, 54 insertions(+), 40 deletions(-)

diff --git a/pyproject.toml b/pyproject.toml
index 7b4cd713..3b2b3b6d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -53,7 +53,7 @@ dependencies = [
         "pyramid_mako",
         "pyramid_retry",
         "pyramid_tm",
-        "rattail[db,bouncer]",
+        "rattail[db,bouncer]>=0.16.0",
         "sa-filters",
         "simplejson",
         "transaction",
diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py
index d6303bb1..11d489a7 100644
--- a/tailbone/forms/core.py
+++ b/tailbone/forms/core.py
@@ -875,7 +875,8 @@ class Form(object):
                      for field in self])
 
     def get_field_markdowns(self):
-        model = self.request.rattail_config.get_model()
+        app = self.request.rattail_config.get_app()
+        model = app.model
 
         if not hasattr(self, 'field_markdowns'):
             infos = Session.query(model.TailboneFieldInfo)\
diff --git a/tailbone/forms/widgets.py b/tailbone/forms/widgets.py
index 2923b7ec..8c16726d 100644
--- a/tailbone/forms/widgets.py
+++ b/tailbone/forms/widgets.py
@@ -477,7 +477,8 @@ class CustomerAutocompleteWidget(JQueryAutocompleteWidget):
     def __init__(self, request, *args, **kwargs):
         super().__init__(*args, **kwargs)
         self.request = request
-        model = self.request.rattail_config.get_model()
+        app = self.request.rattail_config.get_app()
+        model = app.model
 
         # must figure out URL providing autocomplete service
         if 'service_url' not in kwargs:
@@ -498,7 +499,8 @@ class CustomerAutocompleteWidget(JQueryAutocompleteWidget):
         """ """
         # fetch customer to provide button label, if we have a value
         if cstruct:
-            model = self.request.rattail_config.get_model()
+            app = self.request.rattail_config.get_app()
+            model = app.model
             customer = Session.get(model.Customer, cstruct)
             if customer:
                 self.field_display = str(customer)
@@ -552,7 +554,8 @@ class DepartmentWidget(dfwidget.SelectWidget):
     def __init__(self, request, **kwargs):
 
         if 'values' not in kwargs:
-            model = request.rattail_config.get_model()
+            app = request.rattail_config.get_app()
+            model = app.model
             departments = Session.query(model.Department)\
                                  .order_by(model.Department.number)
             values = [(dept.uuid, str(dept))
@@ -594,7 +597,8 @@ class VendorAutocompleteWidget(JQueryAutocompleteWidget):
     def __init__(self, request, *args, **kwargs):
         super().__init__(*args, **kwargs)
         self.request = request
-        model = self.request.rattail_config.get_model()
+        app = self.request.rattail_config.get_app()
+        model = app.model
 
         # must figure out URL providing autocomplete service
         if 'service_url' not in kwargs:
@@ -615,7 +619,8 @@ class VendorAutocompleteWidget(JQueryAutocompleteWidget):
         """ """
         # fetch vendor to provide button label, if we have a value
         if cstruct:
-            model = self.request.rattail_config.get_model()
+            app = self.request.rattail_config.get_app()
+            model = app.model
             vendor = Session.get(model.Vendor, cstruct)
             if vendor:
                 self.field_display = str(vendor)
@@ -643,7 +648,8 @@ class VendorDropdownWidget(dfwidget.SelectWidget):
                     vendors = vendors()
 
             else: # default vendor list
-                model = self.request.rattail_config.get_model()
+                app = self.request.rattail_config.get_app()
+                model = app.model
                 vendors = Session.query(model.Vendor)\
                                    .order_by(model.Vendor.name)\
                                    .all()
diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py
index 91c3d1f5..b4610a18 100644
--- a/tailbone/grids/core.py
+++ b/tailbone/grids/core.py
@@ -32,7 +32,7 @@ import sqlalchemy as sa
 from sqlalchemy import orm
 
 from rattail.db.types import GPCType
-from rattail.util import prettify, pretty_boolean, pretty_quantity
+from rattail.util import prettify, pretty_boolean
 
 from pyramid.renderers import render
 from webhelpers2.html import HTML, tags
@@ -60,7 +60,7 @@ class FieldList(list):
         self.insert(i + 1, newfield)
 
 
-class Grid(object):
+class Grid:
     """
     Core grid class.  In sore need of documentation.
 
@@ -532,7 +532,8 @@ class Grid(object):
 
     def render_quantity(self, obj, column_name):
         value = self.obtain_value(obj, column_name)
-        return pretty_quantity(value)
+        app = self.request.rattail_config.get_app()
+        return app.render_quantity(value)
 
     def render_duration(self, obj, column_name):
         seconds = self.obtain_value(obj, column_name)
@@ -1152,10 +1153,12 @@ class Grid(object):
         """
         Persist the given settings in some way, as defined by ``func``.
         """
+        app = self.request.rattail_config.get_app()
+        model = app.model
+
         def persist(key, value=lambda k: settings[k]):
             if to == 'defaults':
                 skey = 'tailbone.{}.grid.{}.{}'.format(self.request.user.uuid, self.key, key)
-                app = self.request.rattail_config.get_app()
                 app.save_setting(Session(), skey, value(key))
             else: # to == session
                 skey = 'grid.{}.{}'.format(self.key, key)
@@ -1172,7 +1175,6 @@ class Grid(object):
             # first clear existing settings for *sorting* only
             # nb. this is because number of sort settings will vary
             if to == 'defaults':
-                model = self.request.rattail_config.get_model()
                 prefix = f'tailbone.{self.request.user.uuid}.grid.{self.key}'
                 query = Session.query(model.Setting)\
                                .filter(sa.or_(
diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py
index bd59a033..b02346a3 100644
--- a/tailbone/subscribers.py
+++ b/tailbone/subscribers.py
@@ -92,7 +92,8 @@ def new_request(event):
         user = None
         uuid = request.authenticated_userid
         if uuid:
-            model = request.rattail_config.get_model()
+            app = request.rattail_config.get_app()
+            model = app.model
             user = Session.get(model.User, uuid)
             if user:
                 Session().set_continuum_user(user)
@@ -174,7 +175,7 @@ def before_render(event):
     renderer_globals['url'] = request.route_url
     renderer_globals['rattail'] = rattail
     renderer_globals['tailbone'] = tailbone
-    renderer_globals['model'] = request.rattail_config.get_model()
+    renderer_globals['model'] = app.model
     renderer_globals['enum'] = request.rattail_config.get_enum()
     renderer_globals['json'] = json
     renderer_globals['datetime'] = datetime
@@ -258,8 +259,9 @@ def add_inbox_count(event):
     request = event.get('request') or threadlocal.get_current_request()
     if request.user:
         renderer_globals = event
+        app = request.rattail_config.get_app()
+        model = app.model
         enum = request.rattail_config.get_enum()
-        model = request.rattail_config.get_model()
         renderer_globals['inbox_count'] = Session.query(model.Message)\
                                                  .outerjoin(model.MessageRecipient)\
                                                  .filter(model.MessageRecipient.recipient == Session.merge(request.user))\
diff --git a/tailbone/util.py b/tailbone/util.py
index 98a7f7d4..c1a0e1d5 100644
--- a/tailbone/util.py
+++ b/tailbone/util.py
@@ -506,7 +506,7 @@ def include_configured_views(pyramid_config):
     """
     rattail_config = pyramid_config.registry.settings.get('rattail_config')
     app = rattail_config.get_app()
-    model = rattail_config.get_model()
+    model = app.model
     session = app.make_session()
 
     # fetch all include-related settings at once
diff --git a/tailbone/views/asgi/__init__.py b/tailbone/views/asgi/__init__.py
index bebe16f3..33888654 100644
--- a/tailbone/views/asgi/__init__.py
+++ b/tailbone/views/asgi/__init__.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.
 #
@@ -41,12 +41,13 @@ class MockRequest(dict):
         pass
 
 
-class WebsocketView(object):
+class WebsocketView:
 
     def __init__(self, pyramid_config):
         self.pyramid_config = pyramid_config
         self.registry = self.pyramid_config.registry
-        self.model = self.rattail_config.get_model()
+        app = self.get_rattail_app()
+        self.model = app.model
 
     @property
     def rattail_config(self):
diff --git a/tailbone/views/core.py b/tailbone/views/core.py
index 97b59c10..b0658d80 100644
--- a/tailbone/views/core.py
+++ b/tailbone/views/core.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.
 #
@@ -26,10 +26,6 @@ Base View Class
 
 import os
 
-from rattail.db import model
-from rattail.core import Object
-from rattail.util import progress_loop
-
 from pyramid import httpexceptions
 from pyramid.renderers import render_to_response
 from pyramid.response import FileResponse
@@ -40,7 +36,7 @@ from tailbone.progress import SessionProgress
 from tailbone.config import protected_usernames
 
 
-class View(object):
+class View:
     """
     Base class for all class-based views.
     """
@@ -62,8 +58,9 @@ class View(object):
 
         config = self.rattail_config
         if config:
+            app = config.get_app()
+            self.model = app.model
             self.enum = config.get_enum()
-            self.model = config.get_model()
 
     @property
     def rattail_config(self):
@@ -94,6 +91,7 @@ class View(object):
         Returns the :class:`rattail:rattail.db.model.User` instance
         corresponding to the "late login" form data (if any), or ``None``.
         """
+        model = self.model
         if self.request.method == 'POST':
             uuid = self.request.POST.get('late-login-user')
             if uuid:
@@ -120,7 +118,8 @@ class View(object):
         return httpexceptions.HTTPFound(location=url, **kwargs)
 
     def progress_loop(self, func, items, factory, *args, **kwargs):
-        return progress_loop(func, items, factory, *args, **kwargs)
+        app = self.get_rattail_app()
+        return app.progress_loop(func, items, factory, *args, **kwargs)
 
     def make_progress(self, key, **kwargs):
         """
@@ -165,7 +164,8 @@ class View(object):
         return self.expose_quickie_search
 
     def get_quickie_context(self):
-        return Object(
+        app = self.get_rattail_app()
+        return app.make_object(
             url=self.get_quickie_url(),
             perm=self.get_quickie_perm(),
             placeholder=self.get_quickie_placeholder())
diff --git a/tailbone/views/custorders/batch.py b/tailbone/views/custorders/batch.py
index 38d2eda7..fa0df901 100644
--- a/tailbone/views/custorders/batch.py
+++ b/tailbone/views/custorders/batch.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,7 +24,7 @@
 Base class for customer order batch views
 """
 
-from rattail.db import model
+from rattail.db.model import CustomerOrderBatch, CustomerOrderBatchRow
 
 import colander
 from webhelpers2.html import tags
@@ -38,8 +38,8 @@ class CustomerOrderBatchView(BatchMasterView):
     Master view base class, for customer order batches.  The views for the
     various mode/workflow batches will derive from this.
     """
-    model_class = model.CustomerOrderBatch
-    model_row_class = model.CustomerOrderBatchRow
+    model_class = CustomerOrderBatch
+    model_row_class = CustomerOrderBatchRow
     default_handler_spec = 'rattail.batch.custorder:CustomerOrderBatchHandler'
 
     grid_columns = [
@@ -122,7 +122,7 @@ class CustomerOrderBatchView(BatchMasterView):
     ]
 
     def configure_grid(self, g):
-        super(CustomerOrderBatchView, self).configure_grid(g)
+        super().configure_grid(g)
 
         g.set_type('total_price', 'currency')
 
@@ -131,9 +131,9 @@ class CustomerOrderBatchView(BatchMasterView):
         g.set_link('created_by')
 
     def configure_form(self, f):
-        super(CustomerOrderBatchView, self).configure_form(f)
+        super().configure_form(f)
         order = f.model_instance
-        model = self.rattail_config.get_model()
+        model = self.model
 
         # readonly fields
         f.set_readonly('rows')
@@ -201,7 +201,7 @@ class CustomerOrderBatchView(BatchMasterView):
             return 'notice'
 
     def configure_row_grid(self, g):
-        super(CustomerOrderBatchView, self).configure_row_grid(g)
+        super().configure_row_grid(g)
 
         g.set_type('case_quantity', 'quantity')
         g.set_type('cases_ordered', 'quantity')
@@ -215,7 +215,7 @@ class CustomerOrderBatchView(BatchMasterView):
         g.set_link('product_description')
 
     def configure_row_form(self, f):
-        super(CustomerOrderBatchView, self).configure_row_form(f)
+        super().configure_row_form(f)
 
         f.set_renderer('product', self.render_product)
         f.set_renderer('pending_product', self.render_pending_product)
diff --git a/tasks.py b/tasks.py
index b57315a0..4ca01bab 100644
--- a/tasks.py
+++ b/tasks.py
@@ -31,16 +31,18 @@ from invoke import task
 
 
 @task
-def release(c, tests=False):
+def release(c, skip_tests=False):
     """
     Release a new version of 'Tailbone'.
     """
-    if tests:
+    if not skip_tests:
         c.run('tox')
 
     if os.path.exists('dist'):
         shutil.rmtree('dist')
     if os.path.exists('Tailbone.egg-info'):
         shutil.rmtree('Tailbone.egg-info')
+
     c.run('python -m build --sdist')
+
     c.run('twine upload dist/*')

From 09ce2d5a40af7204621a921cdb8c448d45f0c5ec Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 11 Jul 2024 13:16:36 -0500
Subject: [PATCH 390/542] =?UTF-8?q?bump:=20version=200.12.0=20=E2=86=92=20?=
 =?UTF-8?q?0.12.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 e3832f0f..dfeabd92 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.12.1 (2024-07-11)
+
+### Fix
+
+- refactor `config.get_model()` => `app.model`
+
 ## v0.12.0 (2024-07-09)
 
 ### Feat
diff --git a/pyproject.toml b/pyproject.toml
index 3b2b3b6d..847b5e28 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.12.0"
+version = "0.12.1"
 description = "Backoffice Web Application for Rattail"
 readme = "README.rst"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]

From e531f98079c7d5a04ef8a003686748e5b1a3cf82 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 11 Jul 2024 13:54:37 -0500
Subject: [PATCH 391/542] fix: cast enum as list to satisfy deform widget

seems to only be an issue for deform 2.0.15+
---
 tailbone/views/batch/handheld.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/tailbone/views/batch/handheld.py b/tailbone/views/batch/handheld.py
index eb22f367..486d8774 100644
--- a/tailbone/views/batch/handheld.py
+++ b/tailbone/views/batch/handheld.py
@@ -46,7 +46,7 @@ class ExecutionOptions(colander.Schema):
     action = colander.SchemaNode(
         colander.String(),
         validator=colander.OneOf(ACTION_OPTIONS),
-        widget=dfwidget.SelectWidget(values=ACTION_OPTIONS.items()))
+        widget=dfwidget.SelectWidget(values=list(ACTION_OPTIONS.items())))
 
 
 class HandheldBatchView(FileBatchMasterView):

From ce156d6278b1b243a714499315c665ca49760fbe Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 12 Jul 2024 09:35:34 -0500
Subject: [PATCH 392/542] feat: begin integrating WuttaWeb as upstream
 dependency

the bare minimum, just to get the relationship established.  mostly
it's calling upstream subscriber / event hooks where applicable.

this also overhauls the docs config to use furo theme etc.
---
 docs/api/subscribers.rst |   3 +-
 docs/conf.py             | 276 ++++-----------------------------------
 pyproject.toml           |   3 +-
 tailbone/app.py          |   7 +-
 tailbone/config.py       |   3 +
 tailbone/subscribers.py  | 138 +++++++++++---------
 6 files changed, 112 insertions(+), 318 deletions(-)

diff --git a/docs/api/subscribers.rst b/docs/api/subscribers.rst
index 8b25c994..d28a1b15 100644
--- a/docs/api/subscribers.rst
+++ b/docs/api/subscribers.rst
@@ -3,5 +3,4 @@
 ========================
 
 .. automodule:: tailbone.subscribers
-
-.. autofunction:: new_request
+   :members:
diff --git a/docs/conf.py b/docs/conf.py
index 505396ed..52e384f5 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -1,38 +1,21 @@
-# -*- coding: utf-8; -*-
+# Configuration file for the Sphinx documentation builder.
 #
-# Tailbone documentation build configuration file, created by
-# sphinx-quickstart on Sat Feb 15 23:15:27 2014.
-#
-# This file is exec()d with the current directory set to its
-# containing dir.
-#
-# Note that not all possible configuration values are present in this
-# autogenerated file.
-#
-# All configuration values have a default; values that are commented out
-# serve to show the default.
+# For the full list of built-in configuration values, see the documentation:
+# https://www.sphinx-doc.org/en/master/usage/configuration.html
 
-import sys
-import os
+# -- Project information -----------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
 
-import sphinx_rtd_theme
+from importlib.metadata import version as get_version
 
-exec(open(os.path.join(os.pardir, 'tailbone', '_version.py')).read())
+project = 'Tailbone'
+copyright = '2010 - 2024, Lance Edgar'
+author = 'Lance Edgar'
+release = get_version('Tailbone')
 
+# -- General configuration ---------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
 
-# If extensions (or modules to document with autodoc) are in another directory,
-# add these directories to sys.path here. If the directory is relative to the
-# documentation root, use os.path.abspath to make it absolute, like shown here.
-#sys.path.insert(0, os.path.abspath('.'))
-
-# -- General configuration ------------------------------------------------
-
-# If your documentation needs a minimal Sphinx version, state it here.
-#needs_sphinx = '1.0'
-
-# Add any Sphinx extension module names here, as strings. They can be
-# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
-# ones.
 extensions = [
     'sphinx.ext.autodoc',
     'sphinx.ext.todo',
@@ -40,241 +23,30 @@ extensions = [
     'sphinx.ext.viewcode',
 ]
 
+templates_path = ['_templates']
+exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
+
 intersphinx_mapping = {
     'rattail': ('https://rattailproject.org/docs/rattail/', None),
     'webhelpers2': ('https://webhelpers2.readthedocs.io/en/latest/', None),
+    'wuttaweb': ('https://rattailproject.org/docs/wuttaweb/', None),
+    'wuttjamaican': ('https://rattailproject.org/docs/wuttjamaican/', None),
 }
 
-# Add any paths that contain templates here, relative to this directory.
-templates_path = ['_templates']
-
-# The suffix of source filenames.
-source_suffix = '.rst'
-
-# The encoding of source files.
-#source_encoding = 'utf-8-sig'
-
-# The master toctree document.
-master_doc = 'index'
-
-# General information about the project.
-project = u'Tailbone'
-copyright = u'2010 - 2020, Lance Edgar'
-
-# The version info for the project you're documenting, acts as replacement for
-# |version| and |release|, also used in various other places throughout the
-# built documents.
-#
-# The short X.Y version.
-# version = '0.3'
-version = '.'.join(__version__.split('.')[:2])
-# The full version, including alpha/beta/rc tags.
-release = __version__
-
-# The language for content autogenerated by Sphinx. Refer to documentation
-# for a list of supported languages.
-#language = None
-
-# There are two options for replacing |today|: either, you set today to some
-# non-false value, then it is used:
-#today = ''
-# Else, today_fmt is used as the format for a strftime call.
-#today_fmt = '%B %d, %Y'
-
-# List of patterns, relative to source directory, that match files and
-# directories to ignore when looking for source files.
-exclude_patterns = ['_build']
-
-# The reST default role (used for this markup: `text`) to use for all
-# documents.
-#default_role = None
-
-# If true, '()' will be appended to :func: etc. cross-reference text.
-#add_function_parentheses = True
-
-# If true, the current module name will be prepended to all description
-# unit titles (such as .. function::).
-#add_module_names = True
-
-# If true, sectionauthor and moduleauthor directives will be shown in the
-# output. They are ignored by default.
-#show_authors = False
-
-# The name of the Pygments (syntax highlighting) style to use.
-pygments_style = 'sphinx'
-
-# A list of ignored prefixes for module index sorting.
-#modindex_common_prefix = []
-
-# If true, keep warnings as "system message" paragraphs in the built documents.
-#keep_warnings = False
-
-# Allow todo entries to show up.
+# allow todo entries to show up
 todo_include_todos = True
 
 
-# -- Options for HTML output ----------------------------------------------
+# -- Options for HTML output -------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
 
-# The theme to use for HTML and HTML Help pages.  See the documentation for
-# a list of builtin themes.
-# html_theme = 'classic'
-html_theme = 'sphinx_rtd_theme'
-
-# Theme options are theme-specific and customize the look and feel of a theme
-# further.  For a list of options available for each theme, see the
-# documentation.
-#html_theme_options = {}
-
-# Add any paths that contain custom themes here, relative to this directory.
-#html_theme_path = []
-html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
-
-# The name for this set of Sphinx documents.  If None, it defaults to
-# "<project> v<release> documentation".
-#html_title = None
-
-# A shorter title for the navigation bar.  Default is the same as html_title.
-#html_short_title = None
+html_theme = 'furo'
+html_static_path = ['_static']
 
 # The name of an image file (relative to this directory) to place at the top
 # of the sidebar.
 #html_logo = None
-html_logo = 'images/rattail_avatar.png'
-
-# The name of an image file (within the static path) to use as favicon of the
-# docs.  This file should be a Windows icon file (.ico) being 16x16 or 32x32
-# pixels large.
-#html_favicon = None
-
-# Add any paths that contain custom static files (such as style sheets) here,
-# relative to this directory. They are copied after the builtin static files,
-# so a file named "default.css" will overwrite the builtin "default.css".
-html_static_path = ['_static']
-
-# Add any extra paths that contain custom files (such as robots.txt or
-# .htaccess) here, relative to this directory. These files are copied
-# directly to the root of the documentation.
-#html_extra_path = []
-
-# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
-# using the given strftime format.
-#html_last_updated_fmt = '%b %d, %Y'
-
-# If true, SmartyPants will be used to convert quotes and dashes to
-# typographically correct entities.
-#html_use_smartypants = True
-
-# Custom sidebar templates, maps document names to template names.
-#html_sidebars = {}
-
-# Additional templates that should be rendered to pages, maps page names to
-# template names.
-#html_additional_pages = {}
-
-# If false, no module index is generated.
-#html_domain_indices = True
-
-# If false, no index is generated.
-#html_use_index = True
-
-# If true, the index is split into individual pages for each letter.
-#html_split_index = False
-
-# If true, links to the reST sources are added to the pages.
-#html_show_sourcelink = True
-
-# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
-#html_show_sphinx = True
-
-# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
-#html_show_copyright = True
-
-# If true, an OpenSearch description file will be output, and all pages will
-# contain a <link> tag referring to it.  The value of this option must be the
-# base URL from which the finished HTML is served.
-#html_use_opensearch = ''
-
-# This is the file name suffix for HTML files (e.g. ".xhtml").
-#html_file_suffix = None
+#html_logo = 'images/rattail_avatar.png'
 
 # Output file base name for HTML help builder.
-htmlhelp_basename = 'Tailbonedoc'
-
-
-# -- Options for LaTeX output ---------------------------------------------
-
-latex_elements = {
-# The paper size ('letterpaper' or 'a4paper').
-#'papersize': 'letterpaper',
-
-# The font size ('10pt', '11pt' or '12pt').
-#'pointsize': '10pt',
-
-# Additional stuff for the LaTeX preamble.
-#'preamble': '',
-}
-
-# Grouping the document tree into LaTeX files. List of tuples
-# (source start file, target name, title,
-#  author, documentclass [howto, manual, or own class]).
-latex_documents = [
-  ('index', 'Tailbone.tex', u'Tailbone Documentation',
-   u'Lance Edgar', 'manual'),
-]
-
-# The name of an image file (relative to this directory) to place at the top of
-# the title page.
-#latex_logo = None
-
-# For "manual" documents, if this is true, then toplevel headings are parts,
-# not chapters.
-#latex_use_parts = False
-
-# If true, show page references after internal links.
-#latex_show_pagerefs = False
-
-# If true, show URL addresses after external links.
-#latex_show_urls = False
-
-# Documents to append as an appendix to all manuals.
-#latex_appendices = []
-
-# If false, no module index is generated.
-#latex_domain_indices = True
-
-
-# -- Options for manual page output ---------------------------------------
-
-# One entry per manual page. List of tuples
-# (source start file, name, description, authors, manual section).
-man_pages = [
-    ('index', 'tailbone', u'Tailbone Documentation',
-     [u'Lance Edgar'], 1)
-]
-
-# If true, show URL addresses after external links.
-#man_show_urls = False
-
-
-# -- Options for Texinfo output -------------------------------------------
-
-# Grouping the document tree into Texinfo files. List of tuples
-# (source start file, target name, title, author,
-#  dir menu entry, description, category)
-texinfo_documents = [
-  ('index', 'Tailbone', u'Tailbone Documentation',
-   u'Lance Edgar', 'Tailbone', 'One line description of project.',
-   'Miscellaneous'),
-]
-
-# Documents to append as an appendix to all manuals.
-#texinfo_appendices = []
-
-# If false, no module index is generated.
-#texinfo_domain_indices = True
-
-# How to display URL addresses: 'footnote', 'no', or 'inline'.
-#texinfo_show_urls = 'footnote'
-
-# If true, do not generate a @detailmenu in the "Top" node's menu.
-#texinfo_no_detailmenu = False
+#htmlhelp_basename = 'Tailbonedoc'
diff --git a/pyproject.toml b/pyproject.toml
index 847b5e28..defb1ffe 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -59,12 +59,13 @@ dependencies = [
         "transaction",
         "waitress",
         "WebHelpers2",
+        "WuttaWeb",
         "zope.sqlalchemy>=1.5",
 ]
 
 
 [project.optional-dependencies]
-docs = ["Sphinx", "sphinx-rtd-theme"]
+docs = ["Sphinx", "furo"]
 tests = ["coverage", "mock", "pytest", "pytest-cov"]
 
 
diff --git a/tailbone/app.py b/tailbone/app.py
index b0160bd3..b7220703 100644
--- a/tailbone/app.py
+++ b/tailbone/app.py
@@ -30,7 +30,9 @@ import warnings
 import sqlalchemy as sa
 from sqlalchemy.orm import sessionmaker, scoped_session
 
-from rattail.config import make_config, parse_list
+from wuttjamaican.util import parse_list
+
+from rattail.config import make_config
 from rattail.exceptions import ConfigurationError
 from rattail.db.types import GPCType
 
@@ -61,6 +63,9 @@ def make_rattail_config(settings):
         rattail_config = make_config(path)
         settings['rattail_config'] = rattail_config
 
+    # nb. this is for compaibility with wuttaweb
+    settings['wutta_config'] = rattail_config
+
     # configure database sessions
     if hasattr(rattail_config, 'rattail_engine'):
         tailbone.db.Session.configure(bind=rattail_config.rattail_engine)
diff --git a/tailbone/config.py b/tailbone/config.py
index ee906149..ce1691ae 100644
--- a/tailbone/config.py
+++ b/tailbone/config.py
@@ -52,6 +52,9 @@ class ConfigExtension(BaseExtension):
         config.setdefault('tailbone', 'themes.keys', 'default, butterball')
         config.setdefault('tailbone', 'themes.expose_picker', 'true')
 
+        # override oruga detection
+        config.setdefault('wuttaweb.oruga_detector.spec', 'tailbone.util:should_use_oruga')
+
 
 def csrf_token_name(config):
     return config.get('tailbone', 'csrf_token_name', default='_csrf')
diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py
index b02346a3..0bf218cb 100644
--- a/tailbone/subscribers.py
+++ b/tailbone/subscribers.py
@@ -24,7 +24,6 @@
 Event Subscribers
 """
 
-import json
 import datetime
 import logging
 import warnings
@@ -37,13 +36,14 @@ import deform
 from pyramid import threadlocal
 from webhelpers2.html import tags
 
+from wuttaweb import subscribers as base
+
 import tailbone
 from tailbone import helpers
 from tailbone.db import Session
 from tailbone.config import csrf_header_name, should_expose_websockets
 from tailbone.menus import make_simple_menus
-from tailbone.util import (get_available_themes, get_global_search_options,
-                           should_use_oruga)
+from tailbone.util import get_available_themes, get_global_search_options
 
 
 log = logging.getLogger(__name__)
@@ -51,42 +51,59 @@ log = logging.getLogger(__name__)
 
 def new_request(event):
     """
-    Identify the current user, and cache their current permissions.  Also adds
-    the ``rattail_config`` attribute to the request.
+    Event hook called when processing a new request.
 
-    A global Rattail ``config`` should already be present within the Pyramid
-    application registry's settings, which would normally be accessed via::
-    
-       request.registry.settings['rattail_config']
+    This first invokes the upstream hook:
+    :func:`wuttaweb:wuttaweb.subscribers.new_request()`
 
-    This function merely "promotes" that config object so that it is more
-    directly accessible, a la::
+    It then adds more things to the request object; among them:
 
-       request.rattail_config
+    .. attribute:: request.rattail_config
 
-    .. note::
-       This of course assumes that a Rattail ``config`` object *has* in fact
-       already been placed in the application registry settings.  If this is
-       not the case, this function will do nothing.
+       Reference to the app :term:`config object`.  Note that this
+       will be the same as ``request.wutta_config``.
 
-    Also, attach some goodies to the request object:
+    .. attribute:: request.user
 
-    * The currently logged-in user instance (if any), as ``user``.
+       Reference to the current authenticated user, or ``None``.
 
-    * ``is_admin`` flag indicating whether user has the Administrator role.
+    .. attribute:: request.is_admin
 
-    * ``is_root`` flag indicating whether user is currently elevated to root.
+       Flag indicating whether current user is a member of the
+       Administrator role.
 
-    * A shortcut method for permission checking, as ``has_perm()``.
+    .. attribute:: request.is_root
+
+       Flag indicating whether user is currently elevated to root
+       privileges.  This is only possible if ``request.is_admin =
+       True``.
+
+    .. method:: request.has_perm(name)
+
+       Function to check if current user has the given permission.
+
+    .. method:: request.has_any_perm(*names)
+
+       Function to check if current user has any of the given
+       permissions.
+
+    .. method:: request.register_component(tagname, classname)
+
+       Function to register a Vue component for use with the app.
+
+       This can be called from wherever a component is defined, and
+       then in the base template all registered components will be
+       properly loaded.
     """
-    log.debug("new request: %s", event)
+    # log.debug("new request: %s", event)
     request = event.request
-    rattail_config = request.registry.settings.get('rattail_config')
-    # TODO: why would this ever be null?
-    if rattail_config:
-        request.rattail_config = rattail_config
-    else:
-        log.error("registry has no rattail_config ?!")
+
+    # invoke upstream logic
+    base.new_request(event)
+
+    # compatibility
+    rattail_config = request.wutta_config
+    request.rattail_config = rattail_config
 
     def user(request):
         user = None
@@ -101,15 +118,6 @@ def new_request(event):
 
     request.set_property(user, reify=True)
 
-    # nb. only add oruga check for "classic" web app
-    classic = rattail_config.parse_bool(request.registry.settings.get('tailbone.classic'))
-    if classic:
-
-        def use_oruga(request):
-            return should_use_oruga(request)
-
-        request.set_property(use_oruga, reify=True)
-
     # assign client IP address to the session, for sake of versioning
     Session().continuum_remote_addr = request.client_addr
 
@@ -161,27 +169,34 @@ def before_render(event):
     """
     Adds goodies to the global template renderer context.
     """
-    log.debug("before_render: %s", event)
+    # log.debug("before_render: %s", event)
+
+    # invoke upstream logic
+    base.before_render(event)
 
     request = event.get('request') or threadlocal.get_current_request()
-    rattail_config = request.rattail_config
-    app = rattail_config.get_app()
+    config = request.wutta_config
+    app = config.get_app()
 
     renderer_globals = event
-    renderer_globals['rattail_app'] = app
-    renderer_globals['app_title'] = app.get_title()
-    renderer_globals['app_version'] = app.get_version()
+
+    # wuttaweb overrides
     renderer_globals['h'] = helpers
-    renderer_globals['url'] = request.route_url
-    renderer_globals['rattail'] = rattail
-    renderer_globals['tailbone'] = tailbone
-    renderer_globals['model'] = app.model
-    renderer_globals['enum'] = request.rattail_config.get_enum()
-    renderer_globals['json'] = json
+
+    # misc.
     renderer_globals['datetime'] = datetime
     renderer_globals['colander'] = colander
     renderer_globals['deform'] = deform
-    renderer_globals['csrf_header_name'] = csrf_header_name(request.rattail_config)
+    renderer_globals['csrf_header_name'] = csrf_header_name(config)
+
+    # TODO: deprecate / remove these
+    renderer_globals['rattail_app'] = app
+    renderer_globals['app_title'] = app.get_title()
+    renderer_globals['app_version'] = app.get_version()
+    renderer_globals['rattail'] = rattail
+    renderer_globals['tailbone'] = tailbone
+    renderer_globals['model'] = app.model
+    renderer_globals['enum'] = app.enum
 
     # theme  - we only want do this for classic web app, *not* API
     # TODO: so, clearly we need a better way to distinguish the two
@@ -189,13 +204,13 @@ def before_render(event):
         renderer_globals['b'] = 'o' if request.use_oruga else 'b' # for buefy
         renderer_globals['theme'] = request.registry.settings['tailbone.theme']
         # note, this is just a global flag; user still needs permission to see picker
-        expose_picker = request.rattail_config.getbool('tailbone', 'themes.expose_picker',
-                                                       default=False)
+        expose_picker = config.get_bool('tailbone.themes.expose_picker',
+                                        default=False)
         renderer_globals['expose_theme_picker'] = expose_picker
         if expose_picker:
 
             # TODO: should remove 'falafel' option altogether
-            available = get_available_themes(request.rattail_config)
+            available = get_available_themes(config)
 
             options = [tags.Option(theme, value=theme) for theme in available]
             renderer_globals['theme_picker_options'] = options
@@ -204,26 +219,25 @@ def before_render(event):
         # (we don't want this to happen for the API either!)
         # TODO: just..awful *shrug*
         # note that we assume "simple" menus nowadays
-        if request.rattail_config.getbool('tailbone', 'menus.simple', default=True):
+        if config.get_bool('tailbone.menus.simple', default=True):
             renderer_globals['menus'] = make_simple_menus(request)
 
         # TODO: ugh, same deal here
-        renderer_globals['messaging_enabled'] = request.rattail_config.getbool(
-            'tailbone', 'messaging.enabled', default=False)
+        renderer_globals['messaging_enabled'] = config.get_bool('tailbone.messaging.enabled',
+                                                                default=False)
 
         # background color may be set per-request, by some apps
         if hasattr(request, 'background_color') and request.background_color:
             renderer_globals['background_color'] = request.background_color
         else: # otherwise we use the one from config
-            renderer_globals['background_color'] = request.rattail_config.get(
-                'tailbone', 'background_color')
+            renderer_globals['background_color'] = config.get('tailbone.background_color')
 
         # maybe set custom stylesheet
         css = None
         if request.user:
-            css = rattail_config.get(f'tailbone.{request.user.uuid}', 'user_css')
+            css = config.get(f'tailbone.{request.user.uuid}', 'user_css')
             if not css:
-                css = rattail_config.get(f'tailbone.{request.user.uuid}', 'buefy_css')
+                css = config.get(f'tailbone.{request.user.uuid}', 'buefy_css')
                 if css:
                     warnings.warn(f"setting 'tailbone.{request.user.uuid}.buefy_css' should be"
                                   f"changed to 'tailbone.{request.user.uuid}.user_css'",
@@ -234,7 +248,7 @@ def before_render(event):
         renderer_globals['global_search_data'] = get_global_search_options(request)
 
         # here we globally declare widths for grid filter pseudo-columns
-        widths = request.rattail_config.get('tailbone', 'grids.filters.column_widths')
+        widths = config.get('tailbone.grids.filters.column_widths')
         if widths:
             widths = widths.split(';')
             if len(widths) < 2:
@@ -245,7 +259,7 @@ def before_render(event):
         renderer_globals['filter_verb_width'] = widths[1]
 
         # declare global support for websockets, or lack thereof
-        renderer_globals['expose_websockets'] = should_expose_websockets(rattail_config)
+        renderer_globals['expose_websockets'] = should_expose_websockets(config)
 
 
 def add_inbox_count(event):

From ca660f408712344683121aea37f7d937f26c6fbc Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 12 Jul 2024 09:38:12 -0500
Subject: [PATCH 393/542] =?UTF-8?q?bump:=20version=200.12.1=20=E2=86=92=20?=
 =?UTF-8?q?0.13.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 dfeabd92..92e849f8 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.13.0 (2024-07-12)
+
+### Feat
+
+- begin integrating WuttaWeb as upstream dependency
+
+### Fix
+
+- cast enum as list to satisfy deform widget
+
 ## v0.12.1 (2024-07-11)
 
 ### Fix
diff --git a/pyproject.toml b/pyproject.toml
index defb1ffe..396ba8dd 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.12.1"
+version = "0.13.0"
 description = "Backoffice Web Application for Rattail"
 readme = "README.rst"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]

From ee781ec48984a4d159fddf805dd88e513a4aad6e Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sat, 13 Jul 2024 15:14:04 -0500
Subject: [PATCH 394/542] fix: fix settings persistence bug(s) for
 datasync/configure page

also hide the Changes context menu link, within the Configure page
---
 tailbone/templates/datasync/configure.mako | 61 ++++++++++++++++------
 tailbone/views/datasync.py                 | 10 ++--
 2 files changed, 50 insertions(+), 21 deletions(-)

diff --git a/tailbone/templates/datasync/configure.mako b/tailbone/templates/datasync/configure.mako
index a512745c..04eda0fb 100644
--- a/tailbone/templates/datasync/configure.mako
+++ b/tailbone/templates/datasync/configure.mako
@@ -1,6 +1,15 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/configure.mako" />
 
+<%def name="extra_styles()">
+  ${parent.extra_styles()}
+  <style>
+    .invisible-watcher {
+        display: none;
+    }
+  </style>
+</%def>
+
 <%def name="buttons_row()">
   <div class="level">
     <div class="level-left">
@@ -106,8 +115,8 @@
     </div>
   </div>
 
-  <${b}-table :data="filteredProfilesData"
-           :row-class="(row, i) => row.enabled ? null : 'has-background-warning'">
+  <${b}-table :data="profilesData"
+              :row-class="getWatcherRowClass">
       <${b}-table-column field="key"
                       label="Watcher Key"
                       v-slot="props">
@@ -625,19 +634,6 @@
     ThisPageData.supervisorProcessName = ${json.dumps(supervisor_process_name)|n}
     ThisPageData.restartCommand = ${json.dumps(restart_command)|n}
 
-    ThisPage.computed.filteredProfilesData = function() {
-        if (this.showDisabledProfiles) {
-            return this.profilesData
-        }
-        let data = []
-        for (let row of this.profilesData) {
-            if (row.enabled) {
-                data.push(row)
-            }
-        }
-        return data
-    }
-
     ThisPage.computed.updateConsumerDisabled = function() {
         if (!this.editingConsumerKey) {
             return true
@@ -665,6 +661,15 @@
         this.showDisabledProfiles = !this.showDisabledProfiles
     }
 
+    ThisPage.methods.getWatcherRowClass = function(row, i) {
+        if (!row.enabled) {
+            if (!this.showDisabledProfiles) {
+                return 'invisible-watcher'
+            }
+            return 'has-background-warning'
+        }
+    }
+
     ThisPage.methods.consumerShortList = function(row) {
         let keys = []
         if (row.watcher_consumes_self) {
@@ -795,9 +800,10 @@
     }
 
     ThisPage.methods.updateProfile = function() {
-        let row = this.editingProfile
+        const row = this.editingProfile
 
-        if (!row.key) {
+        const newRow = !row.key
+        if (newRow) {
             row.consumers_data = []
             this.profilesData.push(row)
         }
@@ -874,10 +880,31 @@
             row.consumers_data.splice(i, 1)
         }
 
+        if (newRow) {
+
+            // nb. must explicitly update the original data row;
+            // otherwise (with vue3) it will remain stale and
+            // submitting the form will keep same settings!
+            // TODO: this probably means i am doing something
+            // sloppy, but at least this hack fixes for now.
+            const profile = this.findProfile(row)
+            for (const key of Object.keys(row)) {
+                profile[key] = row[key]
+            }
+        }
+
         this.settingsNeedSaved = true
         this.editProfileShowDialog = false
     }
 
+    ThisPage.methods.findProfile = function(row) {
+        for (const profile of this.profilesData) {
+            if (profile.key == row.key) {
+                return profile
+            }
+        }
+    }
+
     ThisPage.methods.deleteProfile = function(row) {
         if (confirm("Are you sure you want to delete the '" + row.key + "' profile?")) {
             let i = this.profilesData.indexOf(row)
diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py
index 7616d288..134d6018 100644
--- a/tailbone/views/datasync.py
+++ b/tailbone/views/datasync.py
@@ -79,11 +79,13 @@ class DataSyncThreadView(MasterView):
 
     def get_context_menu_items(self, thread=None):
         items = super().get_context_menu_items(thread)
+        route_prefix = self.get_route_prefix()
 
-        # nb. just one view here, no need to check if listing etc.
-        if self.request.has_perm('datasync_changes.list'):
-            url = self.request.route_url('datasyncchanges')
-            items.append(tags.link_to("View DataSync Changes", url))
+        # nb. do not show this for /configure page
+        if self.request.matched_route.name != f'{route_prefix}.configure':
+            if self.request.has_perm('datasync_changes.list'):
+                url = self.request.route_url('datasyncchanges')
+                items.append(tags.link_to("View DataSync Changes", url))
 
         return items
 

From eede274529db7eaa93a4ab6f4d920d63d2297cde Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sat, 13 Jul 2024 15:15:51 -0500
Subject: [PATCH 395/542] =?UTF-8?q?bump:=20version=200.13.0=20=E2=86=92=20?=
 =?UTF-8?q?0.13.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 92e849f8..c766025a 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.13.1 (2024-07-13)
+
+### Fix
+
+- fix settings persistence bug(s) for datasync/configure page
+
 ## v0.13.0 (2024-07-12)
 
 ### Feat
diff --git a/pyproject.toml b/pyproject.toml
index 396ba8dd..c15e8073 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.13.0"
+version = "0.13.1"
 description = "Backoffice Web Application for Rattail"
 readme = "README.rst"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]

From d2d0206b4503e8d6b525df5e737cf24421591493 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sat, 13 Jul 2024 15:16:45 -0500
Subject: [PATCH 396/542] build: run `pytest` but avoid `tox` when preparing
 release

buildbot can let us know if something goes wrong with an atypical
python version etc.
---
 tasks.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/tasks.py b/tasks.py
index 4ca01bab..6983dbea 100644
--- a/tasks.py
+++ b/tasks.py
@@ -36,7 +36,7 @@ def release(c, skip_tests=False):
     Release a new version of 'Tailbone'.
     """
     if not skip_tests:
-        c.run('tox')
+        c.run('pytest')
 
     if os.path.exists('dist'):
         shutil.rmtree('dist')

From 27214cc62f781aa271f1645ad8c5dac6f3924d4d Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sat, 13 Jul 2024 15:28:28 -0500
Subject: [PATCH 397/542] fix: fix logic bug for datasync/config settings save

dang it
---
 tailbone/templates/datasync/configure.mako | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/tailbone/templates/datasync/configure.mako b/tailbone/templates/datasync/configure.mako
index 04eda0fb..0889b144 100644
--- a/tailbone/templates/datasync/configure.mako
+++ b/tailbone/templates/datasync/configure.mako
@@ -880,7 +880,7 @@
             row.consumers_data.splice(i, 1)
         }
 
-        if (newRow) {
+        if (!newRow) {
 
             // nb. must explicitly update the original data row;
             // otherwise (with vue3) it will remain stale and

From 0b4629ea29edb56abfcfb21e9bc99673143baca8 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sat, 13 Jul 2024 15:28:59 -0500
Subject: [PATCH 398/542] =?UTF-8?q?bump:=20version=200.13.1=20=E2=86=92=20?=
 =?UTF-8?q?0.13.2?=
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 c766025a..40604948 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.13.2 (2024-07-13)
+
+### Fix
+
+- fix logic bug for datasync/config settings save
+
 ## v0.13.1 (2024-07-13)
 
 ### Fix
diff --git a/pyproject.toml b/pyproject.toml
index c15e8073..12f6a538 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.13.1"
+version = "0.13.2"
 description = "Backoffice Web Application for Rattail"
 readme = "README.rst"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]

From fd1ec01128438fffc78996cf6b4f367f48de7f41 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 14 Jul 2024 10:52:32 -0500
Subject: [PATCH 399/542] feat: move core menu logic to wuttaweb

tailbone still defines the default menus, and allows for making dynamic
menus from config (which wuttaweb does not).

also remove some even older logic for "v1" menu functions
---
 tailbone/handler.py     |  13 ++-
 tailbone/menus.py       | 232 +++++++---------------------------------
 tailbone/subscribers.py |  10 +-
 tailbone/views/menus.py |  11 +-
 4 files changed, 54 insertions(+), 212 deletions(-)

diff --git a/tailbone/handler.py b/tailbone/handler.py
index 22f33cca..00f41bc9 100644
--- a/tailbone/handler.py
+++ b/tailbone/handler.py
@@ -24,6 +24,8 @@
 Tailbone Handler
 """
 
+import warnings
+
 from mako.lookup import TemplateLookup
 
 from rattail.app import GenericHandler
@@ -46,11 +48,14 @@ class TailboneHandler(GenericHandler):
 
     def get_menu_handler(self, **kwargs):
         """
-        Get the configured "menu" handler.
-
-        :returns: The :class:`~tailbone.menus.MenuHandler` instance
-           for the app.
+        DEPRECATED; use
+        :meth:`wuttaweb.handler.WebHandler.get_menu_handler()`
+        instead.
         """
+        warnings.warn("TailboneHandler.get_menu_handler() is deprecated; "
+                      "please use WebHandler.get_menu_handler() instead",
+                      DeprecationWarning, stacklevel=2)
+
         if not hasattr(self, 'menu_handler'):
             spec = self.config.get('tailbone.menus', 'handler',
                                    default='tailbone.menus:MenuHandler')
diff --git a/tailbone/menus.py b/tailbone/menus.py
index 50dd3f4a..0752c22d 100644
--- a/tailbone/menus.py
+++ b/tailbone/menus.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,37 +24,48 @@
 App Menus
 """
 
-import re
 import logging
 import warnings
 
-from rattail.app import GenericHandler
 from rattail.util import prettify, simple_error
 
 from webhelpers2.html import tags, HTML
 
+from wuttaweb.menus import MenuHandler as WuttaMenuHandler
+
 from tailbone.db import Session
 
 
 log = logging.getLogger(__name__)
 
 
-class MenuHandler(GenericHandler):
+class TailboneMenuHandler(WuttaMenuHandler):
     """
     Base class and default implementation for menu handler.
     """
 
-    def make_raw_menus(self, request, **kwargs):
-        """
-        Generate a full set of "raw" menus for the app.
+    ##############################
+    # internal methods
+    ##############################
 
-        The "raw" menus are basically just a set of dicts to represent
-        the final menus.
+    def _is_allowed(self, request, item):
+        """
+        TODO: must override this until wuttaweb has proper user auth checks
+        """
+        perm = item.get('perm')
+        if perm:
+            return request.has_perm(perm)
+        return True
+
+    def _make_raw_menus(self, request, **kwargs):
+        """
+        We are overriding this to allow for making dynamic menus from
+        config/settings.  Which may or may not be a good idea..
         """
         # first try to make menus from config, but this is highly
         # susceptible to failure, so try to warn user of problems
         try:
-            menus = self.make_menus_from_config(request)
+            menus = self._make_menus_from_config(request)
             if menus:
                 return menus
         except Exception as error:
@@ -71,9 +82,9 @@ class MenuHandler(GenericHandler):
             request.session.flash(msg, 'warning')
 
         # okay, no config, so menus will be built from code
-        return self.make_menus(request)
+        return self.make_menus(request, **kwargs)
 
-    def make_menus_from_config(self, request, **kwargs):
+    def _make_menus_from_config(self, request, **kwargs):
         """
         Try to build a complete menu set from config/settings.
 
@@ -101,16 +112,15 @@ class MenuHandler(GenericHandler):
                                             query=query, key='name',
                                             normalizer=lambda s: s.value)
             for key in main_keys:
-                menus.append(self.make_single_menu_from_settings(request, key,
-                                                                 settings))
+                menus.append(self._make_single_menu_from_settings(request, key, settings))
 
         else: # read from config file only
             for key in main_keys:
-                menus.append(self.make_single_menu_from_config(request, key))
+                menus.append(self._make_single_menu_from_config(request, key))
 
         return menus
 
-    def make_single_menu_from_config(self, request, key, **kwargs):
+    def _make_single_menu_from_config(self, request, key, **kwargs):
         """
         Makes a single top-level menu dict from config file.  Note
         that this will read from config file(s) *only* and avoids
@@ -178,7 +188,7 @@ class MenuHandler(GenericHandler):
 
         return menu
 
-    def make_single_menu_from_settings(self, request, key, settings, **kwargs):
+    def _make_single_menu_from_settings(self, request, key, settings, **kwargs):
         """
         Makes a single top-level menu dict from DB settings.
         """
@@ -237,6 +247,10 @@ class MenuHandler(GenericHandler):
 
         return menu
 
+    ##############################
+    # menu defaults
+    ##############################
+
     def make_menus(self, request, **kwargs):
         """
         Make the full set of menus for the app.
@@ -723,182 +737,10 @@ class MenuHandler(GenericHandler):
         }
 
 
-def make_simple_menus(request):
-    """
-    Build the main menu list for the app.
-    """
-    app = request.rattail_config.get_app()
-    tailbone_handler = app.get_tailbone_handler()
-    menu_handler = tailbone_handler.get_menu_handler()
+class MenuHandler(TailboneMenuHandler):
 
-    raw_menus = menu_handler.make_raw_menus(request)
-
-    # now we have "simple" (raw) menus definition, but must refine
-    # that somewhat to produce our final menus
-    mark_allowed(request, raw_menus)
-    final_menus = []
-    for topitem in raw_menus:
-
-        if topitem['allowed']:
-
-            if topitem.get('type') == 'link':
-                final_menus.append(make_menu_entry(request, topitem))
-
-            else: # assuming 'menu' type
-
-                menu_items = []
-                for item in topitem['items']:
-                    if not item['allowed']:
-                        continue
-
-                    # nested submenu
-                    if item.get('type') == 'menu':
-                        submenu_items = []
-                        for subitem in item['items']:
-                            if subitem['allowed']:
-                                submenu_items.append(make_menu_entry(request, subitem))
-                        menu_items.append({
-                            'type': 'submenu',
-                            'title': item['title'],
-                            'items': submenu_items,
-                            'is_menu': True,
-                            'is_sep': False,
-                        })
-
-                    elif item.get('type') == 'sep':
-                        # we only want to add a sep, *if* we already have some
-                        # menu items (i.e. there is something to separate)
-                        # *and* the last menu item is not a sep (avoid doubles)
-                        if menu_items and not menu_items[-1]['is_sep']:
-                            menu_items.append(make_menu_entry(request, item))
-
-                    else: # standard menu item
-                        menu_items.append(make_menu_entry(request, item))
-
-                # remove final separator if present
-                if menu_items and menu_items[-1]['is_sep']:
-                    menu_items.pop()
-
-                # only add if we wound up with something
-                assert menu_items
-                if menu_items:
-                    group = {
-                        'type': 'menu',
-                        'key': topitem.get('key'),
-                        'title': topitem['title'],
-                        'items': menu_items,
-                        'is_menu': True,
-                        'is_link':  False,
-                    }
-
-                    # topitem w/ no key likely means it did not come
-                    # from config but rather explicit definition in
-                    # code.  so we are free to "invent" a (safe) key
-                    # for it, since that is only for editing config
-                    if not group['key']:
-                        group['key'] = make_menu_key(request.rattail_config,
-                                                     topitem['title'])
-
-                    final_menus.append(group)
-
-    return final_menus
-
-
-def make_menu_key(config, value):
-    """
-    Generate a normalized menu key for the given value.
-    """
-    return re.sub(r'\W', '', value.lower())
-
-
-def make_menu_entry(request, item):
-    """
-    Convert a simple menu entry dict, into a proper menu-related object, for
-    use in constructing final menu.
-    """
-    # separator
-    if item.get('type') == 'sep':
-        return {
-            'type': 'sep',
-            'is_menu': False,
-            'is_sep': True,
-        }
-
-    # standard menu item
-    entry = {
-        'type': 'item',
-        'title': item['title'],
-        'perm': item.get('perm'),
-        'target': item.get('target'),
-        'is_link': True,
-        'is_menu': False,
-        'is_sep': False,
-    }
-    if item.get('route'):
-        entry['route'] = item['route']
-        try:
-            entry['url'] = request.route_url(entry['route'])
-        except KeyError:        # happens if no such route
-            log.warning("invalid route name for menu entry: %s", entry)
-            entry['url'] = entry['route']
-        entry['key'] = entry['route']
-    else:
-        if item.get('url'):
-            entry['url'] = item['url']
-        entry['key'] = make_menu_key(request.rattail_config, entry['title'])
-    return entry
-
-
-def is_allowed(request, item):
-    """
-    Logic to determine if a given menu item is "allowed" for current user.
-    """
-    perm = item.get('perm')
-    if perm:
-        return request.has_perm(perm)
-    return True
-
-
-def mark_allowed(request, menus):
-    """
-    Traverse the menu set, and mark each item as "allowed" (or not) based on
-    current user permissions.
-    """
-    for topitem in menus:
-
-        if topitem.get('type', 'menu') == 'menu':
-            topitem['allowed'] = False
-
-            for item in topitem['items']:
-
-                if item.get('type') == 'menu':
-                    for subitem in item['items']:
-                        subitem['allowed'] = is_allowed(request, subitem)
-
-                    item['allowed'] = False
-                    for subitem in item['items']:
-                        if subitem['allowed'] and subitem.get('type') != 'sep':
-                            item['allowed'] = True
-                            break
-
-                else:
-                    item['allowed'] = is_allowed(request, item)
-
-            for item in topitem['items']:
-                if item['allowed'] and item.get('type') != 'sep':
-                    topitem['allowed'] = True
-                    break
-
-
-def make_admin_menu(request, **kwargs):
-    """
-    Generate a typical Admin menu
-    """
-    warnings.warn("make_admin_menu() function is deprecated; please use "
-                  "MenuHandler.make_admin_menu() instead",
-                  DeprecationWarning, stacklevel=2)
-
-    app = request.rattail_config.get_app()
-    tailbone_handler = app.get_tailbone_handler()
-    menu_handler = tailbone_handler.get_menu_handler()
-    return menu_handler.make_admin_menu(request, **kwargs)
+    def __init__(self, *args, **kwargs):
+        warnings.warn("tailbone.menus.MenuHandler is deprecated; "
+                      "please use tailbone.menus.TailboneMenuHandler instead",
+                      DeprecationWarning, stacklevel=2)
+        super().__init__(*args, **kwargs)
diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py
index 0bf218cb..12e1e32a 100644
--- a/tailbone/subscribers.py
+++ b/tailbone/subscribers.py
@@ -42,7 +42,6 @@ import tailbone
 from tailbone import helpers
 from tailbone.db import Session
 from tailbone.config import csrf_header_name, should_expose_websockets
-from tailbone.menus import make_simple_menus
 from tailbone.util import get_available_themes, get_global_search_options
 
 
@@ -180,7 +179,7 @@ def before_render(event):
 
     renderer_globals = event
 
-    # wuttaweb overrides
+    # overrides
     renderer_globals['h'] = helpers
 
     # misc.
@@ -215,13 +214,6 @@ def before_render(event):
             options = [tags.Option(theme, value=theme) for theme in available]
             renderer_globals['theme_picker_options'] = options
 
-        # heck while we're assuming the classic web app here...
-        # (we don't want this to happen for the API either!)
-        # TODO: just..awful *shrug*
-        # note that we assume "simple" menus nowadays
-        if config.get_bool('tailbone.menus.simple', default=True):
-            renderer_globals['menus'] = make_simple_menus(request)
-
         # TODO: ugh, same deal here
         renderer_globals['messaging_enabled'] = config.get_bool('tailbone.messaging.enabled',
                                                                 default=False)
diff --git a/tailbone/views/menus.py b/tailbone/views/menus.py
index f60ad274..b606e4e7 100644
--- a/tailbone/views/menus.py
+++ b/tailbone/views/menus.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.
 #
@@ -30,7 +30,6 @@ import sqlalchemy as sa
 
 from tailbone.views import View
 from tailbone.db import Session
-from tailbone.menus import make_menu_key
 
 
 class MenuConfigView(View):
@@ -79,12 +78,16 @@ class MenuConfigView(View):
         return context
 
     def configure_gather_settings(self, data):
+        app = self.get_rattail_app()
+        web = app.get_web_handler()
+        menus = web.get_menu_handler()
+
         settings = [{'name': 'tailbone.menu.from_settings',
                      'value': 'true'}]
 
         main_keys = []
         for topitem in json.loads(data['menus']):
-            key = make_menu_key(self.rattail_config, topitem['title'])
+            key = menus._make_menu_key(self.rattail_config, topitem['title'])
             main_keys.append(key)
 
             settings.extend([
@@ -99,7 +102,7 @@ class MenuConfigView(View):
                     if item.get('route'):
                         item_key = item['route']
                     else:
-                        item_key = make_menu_key(self.rattail_config, item['title'])
+                        item_key = menus._make_menu_key(self.rattail_config, item['title'])
                     item_keys.append(item_key)
 
                     settings.extend([

From d70bac74f099e7f53bc9fe98d3d63e995aeed909 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 14 Jul 2024 11:11:44 -0500
Subject: [PATCH 400/542] =?UTF-8?q?bump:=20version=200.13.2=20=E2=86=92=20?=
 =?UTF-8?q?0.14.0?=
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 40604948..4c5304d6 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.14.0 (2024-07-14)
+
+### Feat
+
+- move core menu logic to wuttaweb
+
 ## v0.13.2 (2024-07-13)
 
 ### Fix
diff --git a/pyproject.toml b/pyproject.toml
index 12f6a538..de65655a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.13.2"
+version = "0.14.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",
+        "WuttaWeb>=0.2.0",
         "zope.sqlalchemy>=1.5",
 ]
 

From 25e62fe6ef06ae2c9366e6f0c9c4445771e5bb16 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 14 Jul 2024 11:47:15 -0500
Subject: [PATCH 401/542] fix: fix bug when making "integration" menus

per recent refactor
---
 tailbone/menus.py | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/tailbone/menus.py b/tailbone/menus.py
index 0752c22d..9048ae43 100644
--- a/tailbone/menus.py
+++ b/tailbone/menus.py
@@ -281,8 +281,9 @@ class TailboneMenuHandler(WuttaMenuHandler):
         """
         Make a set of menus for all registered system integrations.
         """
+        tb = self.app.get_tailbone_handler()
         menus = []
-        for provider in self.tb.iter_providers():
+        for provider in tb.iter_providers():
             menu = provider.make_integration_menu(request)
             if menu:
                 menus.append(menu)

From 5e1c0a5187ab5ab33a63f38cbb0c9da4a7a1f786 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 14 Jul 2024 12:41:08 -0500
Subject: [PATCH 402/542] fix: fix model reference in menu handler

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

diff --git a/tailbone/menus.py b/tailbone/menus.py
index 9048ae43..84c12343 100644
--- a/tailbone/menus.py
+++ b/tailbone/menus.py
@@ -96,7 +96,7 @@ class TailboneMenuHandler(WuttaMenuHandler):
         if not main_keys:
             return
 
-        model = self.model
+        model = self.app.model
         menus = []
 
         # menu definition can come either from config file or db

From ece29d7b6cfeb193e0fe7ee66a238f6dedba1144 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 14 Jul 2024 23:29:17 -0500
Subject: [PATCH 403/542] fix: update usage of auth handler, per rattail
 changes

---
 pyproject.toml          |  2 +-
 tailbone/api/core.py    |  4 ++--
 tailbone/auth.py        | 14 +++++++++-----
 tailbone/subscribers.py |  9 +++++++--
 4 files changed, 19 insertions(+), 10 deletions(-)

diff --git a/pyproject.toml b/pyproject.toml
index de65655a..22fa5676 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -53,7 +53,7 @@ dependencies = [
         "pyramid_mako",
         "pyramid_retry",
         "pyramid_tm",
-        "rattail[db,bouncer]>=0.16.0",
+        "rattail[db,bouncer]>=0.17.0",
         "sa-filters",
         "simplejson",
         "transaction",
diff --git a/tailbone/api/core.py b/tailbone/api/core.py
index b278d4af..0d8eec32 100644
--- a/tailbone/api/core.py
+++ b/tailbone/api/core.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.
 #
@@ -102,7 +102,7 @@ class APIView(View):
         auth = app.get_auth_handler()
 
         # basic / default info
-        is_admin = user.is_admin()
+        is_admin = auth.user_is_admin(user)
         employee = app.get_employee(user)
         info = {
             'uuid': user.uuid,
diff --git a/tailbone/auth.py b/tailbone/auth.py
index 5a35caa6..826c5d40 100644
--- a/tailbone/auth.py
+++ b/tailbone/auth.py
@@ -45,11 +45,12 @@ def login_user(request, user, timeout=NOTSET):
     Perform the steps necessary to login the given user.  Note that this
     returns a ``headers`` dict which you should pass to the redirect.
     """
-    app = request.rattail_config.get_app()
+    config = request.rattail_config
+    app = config.get_app()
     user.record_event(app.enum.USER_EVENT_LOGIN)
     headers = remember(request, user.uuid)
     if timeout is NOTSET:
-        timeout = session_timeout_for_user(user)
+        timeout = session_timeout_for_user(config, user)
     log.debug("setting session timeout for '{}' to {}".format(user.username, timeout))
     set_session_timeout(request, timeout)
     return headers
@@ -70,15 +71,18 @@ def logout_user(request):
     return headers
 
 
-def session_timeout_for_user(user):
+def session_timeout_for_user(config, user):
     """
     Returns the "max" session timeout for the user, according to roles
     """
-    from rattail.db.auth import authenticated_role
+    app = config.get_app()
+    auth = app.get_auth_handler()
 
-    roles = user.roles + [authenticated_role(Session())]
+    authenticated = auth.get_role_authenticated(Session())
+    roles = user.roles + [authenticated]
     timeouts = [role.session_timeout for role in roles
                 if role.session_timeout is not None]
+
     if timeouts and 0 not in timeouts:
         return max(timeouts)
 
diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py
index 12e1e32a..181c84bc 100644
--- a/tailbone/subscribers.py
+++ b/tailbone/subscribers.py
@@ -98,10 +98,15 @@ def new_request(event):
     request = event.request
 
     # invoke upstream logic
+    # nb. this sets request.wutta_config
     base.new_request(event)
 
+    config = request.wutta_config
+    app = config.get_app()
+    auth = app.get_auth_handler()
+
     # compatibility
-    rattail_config = request.wutta_config
+    rattail_config = config
     request.rattail_config = rattail_config
 
     def user(request):
@@ -120,7 +125,7 @@ def new_request(event):
     # assign client IP address to the session, for sake of versioning
     Session().continuum_remote_addr = request.client_addr
 
-    request.is_admin = bool(request.user) and request.user.is_admin()
+    request.is_admin = auth.user_is_admin(request.user)
     request.is_root = request.is_admin and request.session.get('is_root', False)
 
     # TODO: why would this ever be null?

From 57fdacdb834dabab7bd61d1d492bc2c2d41d42dd Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 14 Jul 2024 23:29:35 -0500
Subject: [PATCH 404/542] =?UTF-8?q?bump:=20version=200.14.0=20=E2=86=92=20?=
 =?UTF-8?q?0.14.1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

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

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4c5304d6..df38a20f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,14 @@ 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.14.1 (2024-07-14)
+
+### Fix
+
+- update usage of auth handler, per rattail changes
+- fix model reference in menu handler
+- fix bug when making "integration" menus
+
 ## v0.14.0 (2024-07-14)
 
 ### Feat
diff --git a/pyproject.toml b/pyproject.toml
index 22fa5676..d7fa1c95 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.14.0"
+version = "0.14.1"
 description = "Backoffice Web Application for Rattail"
 readme = "README.rst"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]

From be6eb5f8153e373772278f2786aa72b0c15f8daf Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 15 Jul 2024 21:51:45 -0500
Subject: [PATCH 405/542] fix: add null menu handler, for use with API apps

---
 tailbone/menus.py | 15 +++++++++++++++
 1 file changed, 15 insertions(+)

diff --git a/tailbone/menus.py b/tailbone/menus.py
index 84c12343..abd0b58b 100644
--- a/tailbone/menus.py
+++ b/tailbone/menus.py
@@ -745,3 +745,18 @@ class MenuHandler(TailboneMenuHandler):
                       "please use tailbone.menus.TailboneMenuHandler instead",
                       DeprecationWarning, stacklevel=2)
         super().__init__(*args, **kwargs)
+
+
+class NullMenuHandler(WuttaMenuHandler):
+    """
+    Null menu handler which uses an empty menu set.
+
+    .. note:
+
+       This class shouldn't even exist, but for the moment, it is
+       useful to configure non-traditional (e.g. API) web apps to use
+       this, in order to avoid most of the overhead.
+    """
+
+    def make_menus(self, request, **kwargs):
+        return []

From af0f84762c5dfaecc0c29cf7431d84aa7231f666 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 15 Jul 2024 21:52:05 -0500
Subject: [PATCH 406/542] =?UTF-8?q?bump:=20version=200.14.1=20=E2=86=92=20?=
 =?UTF-8?q?0.14.2?=
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 df38a20f..c27cc130 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.14.2 (2024-07-15)
+
+### Fix
+
+- add null menu handler, for use with API apps
+
 ## v0.14.1 (2024-07-14)
 
 ### Fix
diff --git a/pyproject.toml b/pyproject.toml
index d7fa1c95..c19bb3e2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.14.1"
+version = "0.14.2"
 description = "Backoffice Web Application for Rattail"
 readme = "README.rst"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]

From 3aafe578f03893e0f03fd8e6ff5d57408a0daa38 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 16 Jul 2024 18:59:35 -0500
Subject: [PATCH 407/542] fix: allow auto-collapse of header when viewing
 trainwreck txn

---
 tailbone/templates/form.mako                  | 60 +++++++++++++++++--
 .../trainwreck/transactions/configure.mako    | 13 ++++
 tailbone/views/trainwreck/base.py             | 12 ++++
 3 files changed, 81 insertions(+), 4 deletions(-)

diff --git a/tailbone/templates/form.mako b/tailbone/templates/form.mako
index 0352b04c..9ce7039a 100644
--- a/tailbone/templates/form.mako
+++ b/tailbone/templates/form.mako
@@ -16,10 +16,53 @@
 </%def>
 
 <%def name="page_content()">
-  <div class="form-wrapper">
-    <br />
-    ${self.render_form()}
-  </div>
+  % if main_form_collapsible:
+      <${b}-collapse class="panel"
+                     % if request.use_oruga:
+                         v-model:open="mainFormPanelOpen"
+                     % else:
+                         :open.sync="mainFormPanelOpen"
+                     % endif
+                     >
+        <template #trigger="props">
+          <div class="panel-heading"
+               role="button"
+               style="cursor: pointer;">
+
+            ## 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="caret-down">
+            </b-icon>
+
+            <span v-if="!props.open">
+              <b-icon pack="fas"
+                      icon="caret-right">
+              </b-icon>
+            </span>
+
+            &nbsp;
+            <strong>Transaction Header</strong>
+          </div>
+        </template>
+        <div class="panel-block">
+          <div class="form-wrapper">
+            <br />
+            ${self.render_form()}
+          </div>
+        </div>
+      </${b}-collapse>
+  % else:
+      <div class="form-wrapper">
+        <br />
+        ${self.render_form()}
+      </div>
+  % endif
 </%def>
 
 <%def name="render_this_page()">
@@ -54,6 +97,15 @@
   ${parent.render_this_page_template()}
 </%def>
 
+<%def name="modify_this_page_vars()">
+  ${parent.modify_this_page_vars()}
+  % if main_form_collapsible:
+      <script>
+        ThisPageData.mainFormPanelOpen = ${'false' if main_form_autocollapse else 'true'}
+      </script>
+  % endif
+</%def>
+
 <%def name="finalize_this_page_vars()">
   ${parent.finalize_this_page_vars()}
   % if form is not Undefined:
diff --git a/tailbone/templates/trainwreck/transactions/configure.mako b/tailbone/templates/trainwreck/transactions/configure.mako
index 99b43fde..4569759b 100644
--- a/tailbone/templates/trainwreck/transactions/configure.mako
+++ b/tailbone/templates/trainwreck/transactions/configure.mako
@@ -3,6 +3,19 @@
 
 <%def name="form_content()">
 
+  <h3 class="block is-size-3">Display</h3>
+  <div class="block" style="padding-left: 2rem;">
+
+    <b-field>
+      <b-checkbox name="tailbone.trainwreck.view_txn.autocollapse_header"
+                  v-model="simpleSettings['tailbone.trainwreck.view_txn.autocollapse_header']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Auto-collapse header when viewing transaction
+      </b-checkbox>
+    </b-field>
+  </div>
+
   <h3 class="block is-size-3">Rotation</h3>
   <div class="block" style="padding-left: 2rem;">
 
diff --git a/tailbone/views/trainwreck/base.py b/tailbone/views/trainwreck/base.py
index 9a6086d7..f529eb66 100644
--- a/tailbone/views/trainwreck/base.py
+++ b/tailbone/views/trainwreck/base.py
@@ -256,6 +256,7 @@ class TransactionView(MasterView):
 
     def template_kwargs_view(self, **kwargs):
         kwargs = super().template_kwargs_view(**kwargs)
+        config = self.rattail_config
 
         form = kwargs['form']
         if 'custorder_xref_markers' in form:
@@ -268,6 +269,12 @@ class TransactionView(MasterView):
                 })
             kwargs['custorder_xref_markers_data'] = markers
 
+        # collapse header
+        kwargs['main_form_collapsible'] = True
+        kwargs['main_form_autocollapse'] = config.get_bool(
+            'tailbone.trainwreck.view_txn.autocollapse_header',
+            default=False)
+
         return kwargs
 
     def get_xref_buttons(self, txn):
@@ -419,6 +426,11 @@ class TransactionView(MasterView):
     def configure_get_simple_settings(self):
         return [
 
+            # display
+            {'section': 'tailbone',
+             'option': 'trainwreck.view_txn.autocollapse_header',
+             'type': bool},
+
             # rotation
             {'section': 'trainwreck',
              'option': 'use_rotation',

From e88b8fc9bc25ff8b7632756f193b00ead8246ae4 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 16 Jul 2024 21:21:43 -0500
Subject: [PATCH 408/542] fix: fix auto-collapse title for viewing trainwreck
 txn

---
 tailbone/templates/form.mako      | 2 +-
 tailbone/views/trainwreck/base.py | 1 +
 2 files changed, 2 insertions(+), 1 deletion(-)

diff --git a/tailbone/templates/form.mako b/tailbone/templates/form.mako
index 9ce7039a..c9c8ea88 100644
--- a/tailbone/templates/form.mako
+++ b/tailbone/templates/form.mako
@@ -47,7 +47,7 @@
             </span>
 
             &nbsp;
-            <strong>Transaction Header</strong>
+            <strong>${main_form_title}</strong>
           </div>
         </template>
         <div class="panel-block">
diff --git a/tailbone/views/trainwreck/base.py b/tailbone/views/trainwreck/base.py
index f529eb66..9c150c6a 100644
--- a/tailbone/views/trainwreck/base.py
+++ b/tailbone/views/trainwreck/base.py
@@ -270,6 +270,7 @@ class TransactionView(MasterView):
             kwargs['custorder_xref_markers_data'] = markers
 
         # collapse header
+        kwargs['main_form_title'] = "Transaction Header"
         kwargs['main_form_collapsible'] = True
         kwargs['main_form_autocollapse'] = config.get_bool(
             'tailbone.trainwreck.view_txn.autocollapse_header',

From 9c466796dae12c11e50cc6be04c5a467e478d255 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 17 Jul 2024 18:24:21 -0500
Subject: [PATCH 409/542] =?UTF-8?q?bump:=20version=200.14.2=20=E2=86=92=20?=
 =?UTF-8?q?0.14.3?=
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 c27cc130..70d9b6ec 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.14.3 (2024-07-17)
+
+### Fix
+
+- fix auto-collapse title for viewing trainwreck txn
+- allow auto-collapse of header when viewing trainwreck txn
+
 ## v0.14.2 (2024-07-15)
 
 ### Fix
diff --git a/pyproject.toml b/pyproject.toml
index c19bb3e2..e785fb0c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.14.2"
+version = "0.14.3"
 description = "Backoffice Web Application for Rattail"
 readme = "README.rst"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]

From f4f79f170a5fefec8cbced39fab3f0eb6dff2873 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 17 Jul 2024 19:45:47 -0500
Subject: [PATCH 410/542] fix: fix modals for luigi tasks page, per oruga

---
 tailbone/templates/luigi/index.mako | 22 ++++++++++++++++------
 1 file changed, 16 insertions(+), 6 deletions(-)

diff --git a/tailbone/templates/luigi/index.mako b/tailbone/templates/luigi/index.mako
index bb8d1465..b5134c25 100644
--- a/tailbone/templates/luigi/index.mako
+++ b/tailbone/templates/luigi/index.mako
@@ -79,8 +79,13 @@
                       @click="overnightTaskLaunchInit(props.row)">
               Launch
             </b-button>
-            <b-modal has-modal-card
-                     :active.sync="overnightTaskShowLaunchDialog">
+            <${b}-modal has-modal-card
+                        % if request.use_oruga:
+                            v-model:active="overnightTaskShowLaunchDialog"
+                        % else:
+                            :active.sync="overnightTaskShowLaunchDialog"
+                        % endif
+                        >
               <div class="modal-card">
 
                 <header class="modal-card-head">
@@ -127,7 +132,7 @@
                   </b-button>
                 </footer>
               </div>
-            </b-modal>
+            </${b}-modal>
           </${b}-table-column>
           <template #empty>
             <p class="block">No tasks defined.</p>
@@ -182,8 +187,13 @@
           </template>
         </${b}-table>
 
-        <b-modal has-modal-card
-                 :active.sync="backfillTaskShowLaunchDialog">
+        <${b}-modal has-modal-card
+                    % if request.use_oruga:
+                        v-model:active="backfillTaskShowLaunchDialog"
+                    % else:
+                        :active.sync="backfillTaskShowLaunchDialog"
+                    % endif
+                    >
           <div class="modal-card">
 
             <header class="modal-card-head">
@@ -238,7 +248,7 @@
               </b-button>
             </footer>
           </div>
-        </b-modal>
+        </${b}-modal>
 
     % endif
 

From 1bba6d994744585244f91c008fd93ec4ca2a9bc9 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 18 Jul 2024 17:58:59 -0500
Subject: [PATCH 411/542] fix: fix more settings persistence bug(s) for
 datasync/configure

esp. for the profile consumers info
---
 tailbone/templates/datasync/configure.mako | 27 +++++++++++-----------
 1 file changed, 13 insertions(+), 14 deletions(-)

diff --git a/tailbone/templates/datasync/configure.mako b/tailbone/templates/datasync/configure.mako
index 0889b144..7922d189 100644
--- a/tailbone/templates/datasync/configure.mako
+++ b/tailbone/templates/datasync/configure.mako
@@ -734,16 +734,9 @@
 
         this.editingProfilePendingConsumers = []
         for (let consumer of row.consumers_data) {
-            let pending = {
+            const pending = {
+                ...consumer,
                 original_key: consumer.key,
-                key: consumer.key,
-                consumer_spec: consumer.consumer_spec,
-                consumer_dbkey: consumer.consumer_dbkey,
-                consumer_delay: consumer.consumer_delay,
-                consumer_retry_attempts: consumer.consumer_retry_attempts,
-                consumer_retry_delay: consumer.consumer_retry_delay,
-                consumer_runas: consumer.consumer_runas,
-                enabled: consumer.enabled,
             }
             this.editingProfilePendingConsumers.push(pending)
         }
@@ -791,8 +784,8 @@
         this.editingProfilePendingWatcherKwargs.splice(i, 1)
     }
 
-    ThisPage.methods.findOriginalConsumer = function(key) {
-        for (let consumer of this.editingProfile.consumers_data) {
+    ThisPage.methods.findConsumer = function(profileConsumers, key) {
+        for (const consumer of profileConsumers) {
             if (consumer.key == key) {
                 return consumer
             }
@@ -803,9 +796,12 @@
         const row = this.editingProfile
 
         const newRow = !row.key
+        let originalProfile = null
         if (newRow) {
             row.consumers_data = []
             this.profilesData.push(row)
+        } else {
+            originalProfile = this.findProfile(row)
         }
 
         row.key = this.editingProfileKey
@@ -853,7 +849,8 @@
         for (let pending of this.editingProfilePendingConsumers) {
             persistentConsumers.push(pending.key)
             if (pending.original_key) {
-                let consumer = this.findOriginalConsumer(pending.original_key)
+                const consumer = this.findConsumer(originalProfile.consumers_data,
+                                                   pending.original_key)
                 consumer.key = pending.key
                 consumer.consumer_spec = pending.consumer_spec
                 consumer.consumer_dbkey = pending.consumer_dbkey
@@ -941,8 +938,10 @@
     }
 
     ThisPage.methods.updateConsumer = function() {
-        let pending = this.editingConsumer
-        let isNew = !pending.key
+        const pending = this.findConsumer(
+            this.editingProfilePendingConsumers,
+            this.editingConsumer.key)
+        const isNew = !pending.key
 
         pending.key = this.editingConsumerKey
         pending.consumer_spec = this.editingConsumerSpec

From a9495b6a7059deb256059615eb2aabd3e2308790 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 18 Jul 2024 17:59:55 -0500
Subject: [PATCH 412/542] =?UTF-8?q?bump:=20version=200.14.3=20=E2=86=92=20?=
 =?UTF-8?q?0.14.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 70d9b6ec..44157ba6 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.14.4 (2024-07-18)
+
+### Fix
+
+- fix more settings persistence bug(s) for datasync/configure
+- fix modals for luigi tasks page, per oruga
+
 ## v0.14.3 (2024-07-17)
 
 ### Fix
diff --git a/pyproject.toml b/pyproject.toml
index e785fb0c..5cc0470b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.14.3"
+version = "0.14.4"
 description = "Backoffice Web Application for Rattail"
 readme = "README.rst"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]

From 08a89c490a5ffa07599ec3bee928d07170ca4d78 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 21 Jul 2024 20:20:43 -0500
Subject: [PATCH 413/542] fix: avoid duplicate `partial` param when grid
 reloads data

---
 tailbone/templates/grids/complete.mako | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako
index e200cdc3..a0f927d3 100644
--- a/tailbone/templates/grids/complete.mako
+++ b/tailbone/templates/grids/complete.mako
@@ -480,7 +480,9 @@
               } else {
                   params = new URLSearchParams(params)
               }
-              params.append('partial', true)
+              if (!params.has('partial')) {
+                  params.append('partial', true)
+              }
               params = params.toString()
 
               this.loading = true

From 458c95696a1faab6ea1f567ca38c6b00046f98f4 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sat, 3 Aug 2024 14:13:16 -0500
Subject: [PATCH 414/542] fix: use auth handler instead of deprecated auth
 functions

---
 tailbone/views/users.py | 24 ++++++++++++++----------
 1 file changed, 14 insertions(+), 10 deletions(-)

diff --git a/tailbone/views/users.py b/tailbone/views/users.py
index dd3f7f7b..b641e578 100644
--- a/tailbone/views/users.py
+++ b/tailbone/views/users.py
@@ -28,8 +28,6 @@ import sqlalchemy as sa
 from sqlalchemy import orm
 
 from rattail.db.model import User, UserEvent
-from rattail.db.auth import (administrator_role, guest_role,
-                             authenticated_role, set_user_password)
 
 import colander
 from deform import widget as dfwidget
@@ -360,17 +358,19 @@ class UserView(PrincipalMasterView):
         return tokens
 
     def get_possible_roles(self):
-        model = self.model
+        app = self.get_rattail_app()
+        auth = app.get_auth_handler()
+        model = app.model
 
         # some roles should never have users "belong" to them
         excluded = [
-            guest_role(self.Session()).uuid,
-            authenticated_role(self.Session()).uuid,
+            auth.get_role_anonymous(self.Session()).uuid,
+            auth.get_role_authenticated(self.Session()).uuid,
         ]
 
         # only allow "root" user to change true admin role membership
         if not self.request.is_root:
-            excluded.append(administrator_role(self.Session()).uuid)
+            excluded.append(auth.get_role_administrator(self.Session()).uuid)
 
         # basic list, minus exclusions so far
         roles = self.Session.query(model.Role)\
@@ -385,7 +385,9 @@ class UserView(PrincipalMasterView):
         return roles.order_by(model.Role.name)
 
     def objectify(self, form, data=None):
-        model = self.model
+        app = self.get_rattail_app()
+        auth = app.get_auth_handler()
+        model = app.model
 
         # create/update user as per normal
         if data is None:
@@ -420,7 +422,7 @@ class UserView(PrincipalMasterView):
 
         # maybe set user password
         if 'set_password' in form and data['set_password']:
-            set_user_password(user, data['set_password'])
+            auth.set_user_password(user, data['set_password'])
 
         # update roles for user
         self.update_roles(user, data)
@@ -433,10 +435,12 @@ class UserView(PrincipalMasterView):
         if 'roles' not in data:
             return
 
-        model = self.model
+        app = self.get_rattail_app()
+        auth = app.get_auth_handler()
+        model = app.model
         old_roles = set([r.uuid for r in user.roles])
         new_roles = data['roles']
-        admin = administrator_role(self.Session())
+        admin = auth.get_role_administrator(self.Session())
 
         # add any new roles for the user, taking care not to add the admin role
         # unless acting as root

From 5ec899cf084b67806ae6e21578c6c04071fa5f22 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sat, 3 Aug 2024 17:43:46 -0500
Subject: [PATCH 415/542] =?UTF-8?q?bump:=20version=200.14.4=20=E2=86=92=20?=
 =?UTF-8?q?0.14.5?=
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 44157ba6..412e6e4a 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.14.5 (2024-08-03)
+
+### Fix
+
+- use auth handler instead of deprecated auth functions
+- avoid duplicate `partial` param when grid reloads data
+
 ## v0.14.4 (2024-07-18)
 
 ### Fix
diff --git a/pyproject.toml b/pyproject.toml
index 5cc0470b..0783f2bc 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.14.4"
+version = "0.14.5"
 description = "Backoffice Web Application for Rattail"
 readme = "README.rst"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]

From 3b92bb3a9e365a761b48335d42cca4d6f86e01b8 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 4 Aug 2024 14:56:12 -0500
Subject: [PATCH 416/542] fix: use wuttaweb logic for `util.get_form_data()`

---
 docs/api/util.rst                      |  6 ++++
 docs/index.rst                         |  1 +
 tailbone/forms/core.py                 | 10 +++++--
 tailbone/util.py                       | 18 ++++++------
 tailbone/views/purchasing/receiving.py |  8 +++---
 tests/test_util.py                     | 39 ++++++++++++++++++++++++++
 6 files changed, 65 insertions(+), 17 deletions(-)
 create mode 100644 docs/api/util.rst
 create mode 100644 tests/test_util.py

diff --git a/docs/api/util.rst b/docs/api/util.rst
new file mode 100644
index 00000000..35e66ed3
--- /dev/null
+++ b/docs/api/util.rst
@@ -0,0 +1,6 @@
+
+``tailbone.util``
+=================
+
+.. automodule:: tailbone.util
+   :members:
diff --git a/docs/index.rst b/docs/index.rst
index 3ca6d4e2..d964086f 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -52,6 +52,7 @@ Package API:
    api/grids.core
    api/progress
    api/subscribers
+   api/util
    api/views/batch
    api/views/batch.vendorcatalog
    api/views/core
diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py
index 11d489a7..60c2f61b 100644
--- a/tailbone/forms/core.py
+++ b/tailbone/forms/core.py
@@ -35,7 +35,7 @@ from sqlalchemy import orm
 from sqlalchemy.ext.associationproxy import AssociationProxy, ASSOCIATION_PROXY
 from wuttjamaican.util import UNSPECIFIED
 
-from rattail.util import prettify, pretty_boolean
+from rattail.util import pretty_boolean
 from rattail.db.util import get_fieldnames
 
 import colander
@@ -47,8 +47,10 @@ from pyramid_deform import SessionFileUploadTempStore
 from pyramid.renderers import render
 from webhelpers2.html import tags, HTML
 
+from wuttaweb.util import get_form_data
+
 from tailbone.db import Session
-from tailbone.util import raw_datetime, get_form_data, render_markdown
+from tailbone.util import raw_datetime, render_markdown
 from tailbone.forms import types
 from tailbone.forms.widgets import (ReadonlyWidget, PlainDateWidget,
                                     JQueryDateWidget, JQueryTimeWidget,
@@ -570,7 +572,9 @@ class Form(object):
             self.schema[key].title = label
 
     def get_label(self, key):
-        return self.labels.get(key, prettify(key))
+        config = self.request.rattail_config
+        app = config.get_app()
+        return self.labels.get(key, app.make_title(key))
 
     def set_readonly(self, key, readonly=True):
         if readonly:
diff --git a/tailbone/util.py b/tailbone/util.py
index c1a0e1d5..9a0314a0 100644
--- a/tailbone/util.py
+++ b/tailbone/util.py
@@ -39,6 +39,8 @@ from pyramid.renderers import get_renderer
 from pyramid.interfaces import IRoutesMapper
 from webhelpers2.html import HTML, tags
 
+from wuttaweb.util import get_form_data as wutta_get_form_data
+
 
 log = logging.getLogger(__name__)
 
@@ -75,17 +77,13 @@ def csrf_token(request, name='_csrf'):
 
 def get_form_data(request):
     """
-    Returns the effective form data for the given request.  Mostly
-    this is a convenience, to return either POST or JSON depending on
-    the type of request.
+    DEPECATED - use :func:`wuttaweb:wuttaweb.util.get_form_data()`
+    instead.
     """
-    # nb. we prefer JSON only if no POST is present
-    # TODO: this seems to work for our use case at least, but perhaps
-    # there is a better way?  see also
-    # https://docs.pylonsproject.org/projects/pyramid/en/latest/api/request.html#pyramid.request.Request.is_xhr
-    if (request.is_xhr or request.content_type == 'application/json') and not request.POST:
-        return request.json_body
-    return request.POST
+    warnings.warn("tailbone.util.get_form_data() is deprecated; "
+                  "please use wuttaweb.util.get_form_data() instead",
+                  DeprecationWarning, stacklevel=2)
+    return wutta_get_form_data(request)
 
 
 def get_global_search_options(request):
diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py
index be15c1a8..55936184 100644
--- a/tailbone/views/purchasing/receiving.py
+++ b/tailbone/views/purchasing/receiving.py
@@ -25,22 +25,22 @@ Views for 'receiving' (purchasing) batches
 """
 
 import os
-import re
 import decimal
 import logging
 from collections import OrderedDict
 
-import humanize
+# import humanize
 
 from rattail import pod
-from rattail.util import prettify, simple_error
+from rattail.util import simple_error
 
 import colander
 from deform import widget as dfwidget
 from webhelpers2.html import tags, HTML
 
+from wuttaweb.util import get_form_data
+
 from tailbone import forms, grids
-from tailbone.util import get_form_data
 from tailbone.views.purchasing import PurchasingBatchView
 
 
diff --git a/tests/test_util.py b/tests/test_util.py
new file mode 100644
index 00000000..46684f0c
--- /dev/null
+++ b/tests/test_util.py
@@ -0,0 +1,39 @@
+# -*- coding: utf-8; -*-
+
+from unittest import TestCase
+
+from pyramid import testing
+
+from rattail.config import RattailConfig
+
+from tailbone import util
+
+
+class TestGetFormData(TestCase):
+
+    def setUp(self):
+        self.config = RattailConfig()
+
+    def make_request(self, **kwargs):
+        kwargs.setdefault('wutta_config', self.config)
+        kwargs.setdefault('rattail_config', self.config)
+        kwargs.setdefault('is_xhr', None)
+        kwargs.setdefault('content_type', None)
+        kwargs.setdefault('POST', {'foo1': 'bar'})
+        kwargs.setdefault('json_body', {'foo2': 'baz'})
+        return testing.DummyRequest(**kwargs)
+
+    def test_default(self):
+        request = self.make_request()
+        data = util.get_form_data(request)
+        self.assertEqual(data, {'foo1': 'bar'})
+
+    def test_is_xhr(self):
+        request = self.make_request(POST=None, is_xhr=True)
+        data = util.get_form_data(request)
+        self.assertEqual(data, {'foo2': 'baz'})
+
+    def test_content_type(self):
+        request = self.make_request(POST=None, content_type='application/json')
+        data = util.get_form_data(request)
+        self.assertEqual(data, {'foo2': 'baz'})

From 9d2684046ff4e5bf4b0c0da979e2cb604a915638 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 5 Aug 2024 15:00:11 -0500
Subject: [PATCH 417/542] feat: move more subscriber logic to wuttaweb

---
 tailbone/subscribers.py | 66 ++++++++++-------------------------------
 1 file changed, 15 insertions(+), 51 deletions(-)

diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py
index 181c84bc..c783287b 100644
--- a/tailbone/subscribers.py
+++ b/tailbone/subscribers.py
@@ -52,30 +52,17 @@ def new_request(event):
     """
     Event hook called when processing a new request.
 
-    This first invokes the upstream hook:
-    :func:`wuttaweb:wuttaweb.subscribers.new_request()`
+    This first invokes the upstream hooks:
+
+    * :func:`wuttaweb:wuttaweb.subscribers.new_request()`
+    * :func:`wuttaweb:wuttaweb.subscribers.new_request_set_user()`
 
     It then adds more things to the request object; among them:
 
     .. attribute:: request.rattail_config
 
        Reference to the app :term:`config object`.  Note that this
-       will be the same as ``request.wutta_config``.
-
-    .. attribute:: request.user
-
-       Reference to the current authenticated user, or ``None``.
-
-    .. attribute:: request.is_admin
-
-       Flag indicating whether current user is a member of the
-       Administrator role.
-
-    .. attribute:: request.is_root
-
-       Flag indicating whether user is currently elevated to root
-       privileges.  This is only possible if ``request.is_admin =
-       True``.
+       will be the same as :attr:`wuttaweb:request.wutta_config`.
 
     .. method:: request.has_perm(name)
 
@@ -94,10 +81,9 @@ def new_request(event):
        then in the base template all registered components will be
        properly loaded.
     """
-    # log.debug("new request: %s", event)
     request = event.request
 
-    # invoke upstream logic
+    # invoke main upstream logic
     # nb. this sets request.wutta_config
     base.new_request(event)
 
@@ -109,25 +95,20 @@ def new_request(event):
     rattail_config = config
     request.rattail_config = rattail_config
 
-    def user(request):
-        user = None
-        uuid = request.authenticated_userid
-        if uuid:
-            app = request.rattail_config.get_app()
-            model = app.model
-            user = Session.get(model.User, uuid)
-            if user:
-                Session().set_continuum_user(user)
-        return user
+    def user_getter(request, db_session=None):
+        user = base.default_user_getter(request, db_session=db_session)
+        if user:
+            # nb. we also assign continuum user to session
+            session = db_session or Session()
+            session.set_continuum_user(user)
+            return user
 
-    request.set_property(user, reify=True)
+    # invoke upstream hook to set user
+    base.new_request_set_user(event, user_getter=user_getter, db_session=Session())
 
     # assign client IP address to the session, for sake of versioning
     Session().continuum_remote_addr = request.client_addr
 
-    request.is_admin = auth.user_is_admin(request.user)
-    request.is_root = request.is_admin and request.session.get('is_root', False)
-
     # TODO: why would this ever be null?
     if rattail_config:
 
@@ -286,27 +267,10 @@ def context_found(event):
 
     The following is attached to the request:
 
-    * ``get_referrer()`` function
-
     * ``get_session_timeout()`` function
     """
     request = event.request
 
-    def get_referrer(default=None, **kwargs):
-        if request.params.get('referrer'):
-            return request.params['referrer']
-        if request.session.get('referrer'):
-            return request.session.pop('referrer')
-        referrer = request.referrer
-        if (not referrer or referrer == request.current_route_url()
-            or not referrer.startswith(request.host_url)):
-            if default:
-                referrer = default
-            else:
-                referrer = request.route_url('home')
-        return referrer
-    request.get_referrer = get_referrer
-
     def get_session_timeout():
         """
         Returns the timeout in effect for the current session

From 2903b376b5038a495feaef5a70c3a31a75466476 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 5 Aug 2024 15:35:06 -0500
Subject: [PATCH 418/542] =?UTF-8?q?bump:=20version=200.14.5=20=E2=86=92=20?=
 =?UTF-8?q?0.15.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 412e6e4a..6f1e1ac3 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.15.0 (2024-08-05)
+
+### Feat
+
+- move more subscriber logic to wuttaweb
+
+### Fix
+
+- use wuttaweb logic for `util.get_form_data()`
+
 ## v0.14.5 (2024-08-03)
 
 ### Fix
diff --git a/pyproject.toml b/pyproject.toml
index 0783f2bc..1d05052d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.14.5"
+version = "0.15.0"
 description = "Backoffice Web Application for Rattail"
 readme = "README.rst"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]

From 91ea9021d7aaceb17a7cd56cd48ece52a71abb31 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 5 Aug 2024 21:50:22 -0500
Subject: [PATCH 419/542] fix: move magic `b` template context var to wuttaweb

---
 tailbone/subscribers.py | 1 -
 1 file changed, 1 deletion(-)

diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py
index c783287b..02c4e518 100644
--- a/tailbone/subscribers.py
+++ b/tailbone/subscribers.py
@@ -186,7 +186,6 @@ def before_render(event):
     # theme  - we only want do this for classic web app, *not* API
     # TODO: so, clearly we need a better way to distinguish the two
     if 'tailbone.theme' in request.registry.settings:
-        renderer_globals['b'] = 'o' if request.use_oruga else 'b' # for buefy
         renderer_globals['theme'] = request.registry.settings['tailbone.theme']
         # note, this is just a global flag; user still needs permission to see picker
         expose_picker = config.get_bool('tailbone.themes.expose_picker',

From bd1993f44029d4c0546a5d5224ef06680ce74ca6 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 5 Aug 2024 22:57:02 -0500
Subject: [PATCH 420/542] =?UTF-8?q?bump:=20version=200.15.0=20=E2=86=92=20?=
 =?UTF-8?q?0.15.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 6f1e1ac3..6a02e734 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.15.1 (2024-08-05)
+
+### Fix
+
+- move magic `b` template context var to wuttaweb
+
 ## v0.15.0 (2024-08-05)
 
 ### Feat
diff --git a/pyproject.toml b/pyproject.toml
index 1d05052d..9e68e401 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.15.0"
+version = "0.15.1"
 description = "Backoffice Web Application for Rattail"
 readme = "README.rst"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]

From 518c108c883a3bcceb431c10394d3176922f4658 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 6 Aug 2024 10:36:20 -0500
Subject: [PATCH 421/542] fix: use auth handler, avoid legacy calls for
 role/perm checks

---
 tailbone/views/principal.py |  2 +-
 tailbone/views/roles.py     | 57 +++++++++++++++++++++++--------------
 tailbone/views/users.py     |  2 +-
 3 files changed, 37 insertions(+), 24 deletions(-)

diff --git a/tailbone/views/principal.py b/tailbone/views/principal.py
index fb09306b..b053453d 100644
--- a/tailbone/views/principal.py
+++ b/tailbone/views/principal.py
@@ -194,7 +194,7 @@ class PermissionsRenderer(Object):
             rendered = False
             for key in sorted(perms, key=lambda p: perms[p]['label'].lower()):
                 checked = auth.has_permission(Session(), principal, key,
-                                              include_guest=self.include_guest,
+                                              include_anonymous=self.include_guest,
                                               include_authenticated=self.include_authenticated)
                 if checked:
                     label = perms[key]['label']
diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py
index 0316ea87..09633c6e 100644
--- a/tailbone/views/roles.py
+++ b/tailbone/views/roles.py
@@ -30,7 +30,6 @@ from sqlalchemy import orm
 from openpyxl.styles import Font, PatternFill
 
 from rattail.db.model import Role
-from rattail.db.auth import administrator_role, guest_role, authenticated_role
 from rattail.excel import ExcelWriter
 
 import colander
@@ -107,8 +106,11 @@ class RoleView(PrincipalMasterView):
         if role.node_type and role.node_type != self.rattail_config.node_type():
             return False
 
+        app = self.get_rattail_app()
+        auth = app.get_auth_handler()
+
         # only "root" can edit Administrator
-        if role is administrator_role(self.Session()):
+        if role is auth.get_role_administrator(self.Session()):
             return self.request.is_root
 
         # only "admin" can edit "admin-ish" roles
@@ -116,11 +118,11 @@ class RoleView(PrincipalMasterView):
             return self.request.is_admin
 
         # can edit Authenticated only if user has permission
-        if role is authenticated_role(self.Session()):
+        if role is auth.get_role_authenticated(self.Session()):
             return self.has_perm('edit_authenticated')
 
         # can edit Guest only if user has permission
-        if role is guest_role(self.Session()):
+        if role is auth.get_role_anonymous(self.Session()):
             return self.has_perm('edit_guest')
 
         # current user can edit their own roles, only if they have permission
@@ -139,11 +141,14 @@ class RoleView(PrincipalMasterView):
         if role.node_type and role.node_type != self.rattail_config.node_type():
             return False
 
-        if role is administrator_role(self.Session()):
+        app = self.get_rattail_app()
+        auth = app.get_auth_handler()
+
+        if role is auth.get_role_administrator(self.Session()):
             return False
-        if role is authenticated_role(self.Session()):
+        if role is auth.get_role_authenticated(self.Session()):
             return False
-        if role is guest_role(self.Session()):
+        if role is auth.get_role_anonymous(self.Session()):
             return False
 
         # only "admin" can delete "admin-ish" roles
@@ -186,17 +191,17 @@ class RoleView(PrincipalMasterView):
 
         # session_timeout
         f.set_renderer('session_timeout', self.render_session_timeout)
-        if self.editing and role is guest_role(self.Session()):
+        if self.editing and role is auth.get_role_anonymous(self.Session()):
             f.set_readonly('session_timeout')
 
         # sync_me, node_type
         if not self.creating:
             include = True
-            if role is administrator_role(self.Session()):
+            if role is auth.get_role_administrator(self.Session()):
                 include = False
-            elif role is authenticated_role(self.Session()):
+            elif role is auth.get_role_authenticated(self.Session()):
                 include = False
-            elif role is guest_role(self.Session()):
+            elif role is auth.get_role_anonymous(self.Session()):
                 include = False
             if not include:
                 f.remove('sync_me', 'sync_users', 'node_type')
@@ -227,7 +232,7 @@ class RoleView(PrincipalMasterView):
             for groupkey in self.tailbone_permissions:
                 for key in self.tailbone_permissions[groupkey]['perms']:
                     if auth.has_permission(self.Session(), role, key,
-                                           include_guest=False,
+                                           include_anonymous=False,
                                            include_authenticated=False):
                         granted.append(key)
             f.set_default('permissions', granted)
@@ -235,12 +240,14 @@ class RoleView(PrincipalMasterView):
             f.remove_field('permissions')
 
     def render_users(self, role, field):
+        app = self.get_rattail_app()
+        auth = app.get_auth_handler()
 
-        if role is guest_role(self.Session()):
+        if role is auth.get_role_anonymous(self.Session()):
             return ("The guest role is implied for all anonymous users, "
                     "i.e. when not logged in.")
 
-        if role is authenticated_role(self.Session()):
+        if role is auth.get_role_authenticated(self.Session()):
             return ("The authenticated role is implied for all users, "
                     "but only when logged in.")
 
@@ -308,7 +315,9 @@ class RoleView(PrincipalMasterView):
         return available
 
     def render_session_timeout(self, role, field):
-        if role is guest_role(self.Session()):
+        app = self.get_rattail_app()
+        auth = app.get_auth_handler()
+        if role is auth.get_role_anonymous(self.Session()):
             return "(not applicable)"
         if role.session_timeout is None:
             return ""
@@ -347,6 +356,8 @@ class RoleView(PrincipalMasterView):
                     auth.revoke_permission(role, pkey)
 
     def template_kwargs_view(self, **kwargs):
+        app = self.get_rattail_app()
+        auth = app.get_auth_handler()
         model = self.model
         role = kwargs['instance']
         if role.users:
@@ -362,8 +373,8 @@ class RoleView(PrincipalMasterView):
         else:
             kwargs['users'] = None
 
-        kwargs['guest_role'] = guest_role(self.Session())
-        kwargs['authenticated_role'] = authenticated_role(self.Session())
+        kwargs['guest_role'] = auth.get_role_anonymous(self.Session())
+        kwargs['authenticated_role'] = auth.get_role_authenticated(self.Session())
 
         role = kwargs['instance']
         if role not in (kwargs['guest_role'], kwargs['authenticated_role']):
@@ -384,9 +395,11 @@ class RoleView(PrincipalMasterView):
         return kwargs
 
     def before_delete(self, role):
-        admin = administrator_role(self.Session())
-        guest = guest_role(self.Session())
-        authenticated = authenticated_role(self.Session())
+        app = self.get_rattail_app()
+        auth = app.get_auth_handler()
+        admin = auth.get_role_administrator(self.Session())
+        guest = auth.get_role_anonymous(self.Session())
+        authenticated = auth.get_role_authenticated(self.Session())
         if role in (admin, guest, authenticated):
             self.request.session.flash("You may not delete the {} role.".format(role.name), 'error')
             return self.redirect(self.request.get_referrer(default=self.request.route_url('roles')))
@@ -402,7 +415,7 @@ class RoleView(PrincipalMasterView):
                            .options(orm.joinedload(model.Role._permissions))
         roles = []
         for role in all_roles:
-            if auth.has_permission(session, role, permission, include_guest=False):
+            if auth.has_permission(session, role, permission, include_anonymous=False):
                 roles.append(role)
         return roles
 
@@ -475,7 +488,7 @@ class RoleView(PrincipalMasterView):
                 # and show an 'X' for any role which has this perm
                 for col, role in enumerate(roles, 2):
                     if auth.has_permission(self.Session(), role, key,
-                                           include_guest=False):
+                                           include_anonymous=False):
                         sheet.cell(row=writing_row, column=col, value="X")
 
                 writing_row += 1
diff --git a/tailbone/views/users.py b/tailbone/views/users.py
index b641e578..1012575a 100644
--- a/tailbone/views/users.py
+++ b/tailbone/views/users.py
@@ -279,7 +279,7 @@ class UserView(PrincipalMasterView):
             permissions = self.request.registry.settings.get('tailbone_permissions', {})
             f.set_renderer('permissions', PermissionsRenderer(request=self.request,
                                                               permissions=permissions,
-                                                              include_guest=True,
+                                                              include_anonymous=True,
                                                               include_authenticated=True))
         else:
             f.remove('permissions')

From 80dc4eb7a9a619ba1fa39372045f63f7894aeff1 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 6 Aug 2024 23:19:14 -0500
Subject: [PATCH 422/542] =?UTF-8?q?bump:=20version=200.15.1=20=E2=86=92=20?=
 =?UTF-8?q?0.15.2?=
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 6a02e734..733d990b 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.15.2 (2024-08-06)
+
+### Fix
+
+- use auth handler, avoid legacy calls for role/perm checks
+
 ## v0.15.1 (2024-08-05)
 
 ### Fix
diff --git a/pyproject.toml b/pyproject.toml
index 9e68e401..54f4df73 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.15.1"
+version = "0.15.2"
 description = "Backoffice Web Application for Rattail"
 readme = "README.rst"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]

From ffd694e7b72ae11faf09a086d7f36681f12094e7 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 8 Aug 2024 19:39:01 -0500
Subject: [PATCH 423/542] fix: fix timepicker `parseTime()` when value is null

---
 tailbone/templates/themes/butterball/field-components.mako | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/tailbone/templates/themes/butterball/field-components.mako b/tailbone/templates/themes/butterball/field-components.mako
index d79c88f4..917083c4 100644
--- a/tailbone/templates/themes/butterball/field-components.mako
+++ b/tailbone/templates/themes/butterball/field-components.mako
@@ -517,6 +517,9 @@
             },
 
             parseTime(value) {
+                if (!value) {
+                    return value
+                }
 
                 if (value.getHours) {
                     return value

From 0b8315fc7876ca0cc43547bb0df21e80559a33cb Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 8 Aug 2024 19:39:36 -0500
Subject: [PATCH 424/542] =?UTF-8?q?bump:=20version=200.15.2=20=E2=86=92=20?=
 =?UTF-8?q?0.15.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 733d990b..7cce885b 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.15.3 (2024-08-08)
+
+### Fix
+
+- fix timepicker `parseTime()` when value is null
+
 ## v0.15.2 (2024-08-06)
 
 ### Fix
diff --git a/pyproject.toml b/pyproject.toml
index 54f4df73..800e8ab0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.15.2"
+version = "0.15.3"
 description = "Backoffice Web Application for Rattail"
 readme = "README.rst"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]

From 7e683dfc4af7a5e9830a4fb6d70e153b917b0519 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 9 Aug 2024 10:11:38 -0500
Subject: [PATCH 425/542] fix: avoid bug when checking current theme

this check is happening not only for classic views but API as well,
which doesn't really have a theme..  probably need a proper fix in
wuttaweb but this should be okay for now
---
 tailbone/util.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/tailbone/util.py b/tailbone/util.py
index 9a0314a0..eb6fb8a8 100644
--- a/tailbone/util.py
+++ b/tailbone/util.py
@@ -459,8 +459,8 @@ def should_use_oruga(request):
     supports (and therefore should use) Oruga + Vue 3 as opposed to
     the default of Buefy + Vue 2.
     """
-    theme = request.registry.settings['tailbone.theme']
-    if 'butterball' in theme:
+    theme = request.registry.settings.get('tailbone.theme')
+    if theme and 'butterball' in theme:
         return True
     return False
 

From b5f0ecb165fd9d480577b561ca0cff49ba0dea96 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 9 Aug 2024 10:13:00 -0500
Subject: [PATCH 426/542] =?UTF-8?q?bump:=20version=200.15.3=20=E2=86=92=20?=
 =?UTF-8?q?0.15.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 7cce885b..05648c25 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.15.4 (2024-08-09)
+
+### Fix
+
+- avoid bug when checking current theme
+
 ## v0.15.3 (2024-08-08)
 
 ### Fix
diff --git a/pyproject.toml b/pyproject.toml
index 800e8ab0..4478aef5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.15.3"
+version = "0.15.4"
 description = "Backoffice Web Application for Rattail"
 readme = "README.rst"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]

From f2fce2e30526db7c85c69b0dfc6162c4d2f7e6b0 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 9 Aug 2024 19:22:26 -0500
Subject: [PATCH 427/542] fix: assign convenience attrs for all views (config,
 app, enum, model)

---
 tailbone/views/core.py | 7 ++++---
 1 file changed, 4 insertions(+), 3 deletions(-)

diff --git a/tailbone/views/core.py b/tailbone/views/core.py
index b0658d80..88b2519f 100644
--- a/tailbone/views/core.py
+++ b/tailbone/views/core.py
@@ -58,9 +58,10 @@ class View:
 
         config = self.rattail_config
         if config:
-            app = config.get_app()
-            self.model = app.model
-            self.enum = config.get_enum()
+            self.config = config
+            self.app = self.config.get_app()
+            self.model = self.app.model
+            self.enum = self.app.enum
 
     @property
     def rattail_config(self):

From d57efba3811bc286fe49290f66a69df04b814633 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 9 Aug 2024 19:48:51 -0500
Subject: [PATCH 428/542] =?UTF-8?q?bump:=20version=200.15.4=20=E2=86=92=20?=
 =?UTF-8?q?0.15.5?=
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 05648c25..de92a834 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.15.5 (2024-08-09)
+
+### Fix
+
+- assign convenience attrs for all views (config, app, enum, model)
+
 ## v0.15.4 (2024-08-09)
 
 ### Fix
diff --git a/pyproject.toml b/pyproject.toml
index 4478aef5..c4335903 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.15.4"
+version = "0.15.5"
 description = "Backoffice Web Application for Rattail"
 readme = "README.rst"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]

From 2c46fde74288da664561277f9637a571a494dcaf Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sat, 10 Aug 2024 08:43:54 -0500
Subject: [PATCH 429/542] fix: simplify verbiage for batch execution panel

---
 tailbone/templates/batch/view.mako | 2 --
 1 file changed, 2 deletions(-)

diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako
index 5e3328d9..63cb9056 100644
--- a/tailbone/templates/batch/view.mako
+++ b/tailbone/templates/batch/view.mako
@@ -85,13 +85,11 @@
       <div style="display: flex; flex-direction: column; gap: 0.5rem;">
       % if batch.executed:
           <p>
-            Batch was executed
             ${h.pretty_datetime(request.rattail_config, batch.executed)}
             by ${batch.executed_by}
           </p>
       % elif master.handler.executable(batch):
           % if master.has_perm('execute'):
-              <p>Batch has not yet been executed.</p>
               <b-button type="is-primary"
                         % if not execute_enabled:
                         disabled

From 1f752530d2d757028aa52ac6518cb3d7b0462aed Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sat, 10 Aug 2024 13:49:41 -0500
Subject: [PATCH 430/542] fix: avoid `before_render` subscriber hook for web
 API

the purpose of that function is to setup extra template context, but
API views always render as 'json' with no template
---
 tailbone/webapi.py | 14 ++++++++++----
 1 file changed, 10 insertions(+), 4 deletions(-)

diff --git a/tailbone/webapi.py b/tailbone/webapi.py
index 1c2fa106..7c0e9b41 100644
--- a/tailbone/webapi.py
+++ b/tailbone/webapi.py
@@ -91,15 +91,21 @@ def make_pyramid_config(settings):
     return pyramid_config
 
 
-def main(global_config, **settings):
+def main(global_config, views='tailbone.api', **settings):
     """
     This function returns a Pyramid WSGI application.
     """
     rattail_config = make_rattail_config(settings)
     pyramid_config = make_pyramid_config(settings)
 
-    # bring in some Tailbone
-    pyramid_config.include('tailbone.subscribers')
-    pyramid_config.include('tailbone.api')
+    # event hooks
+    pyramid_config.add_subscriber('tailbone.subscribers.new_request',
+                                  'pyramid.events.NewRequest')
+    # TODO: is this really needed?
+    pyramid_config.add_subscriber('tailbone.subscribers.context_found',
+                                  'pyramid.events.ContextFound')
+
+    # views
+    pyramid_config.include(views)
 
     return pyramid_config.make_wsgi_app()

From b53479f8e46db1e622a773c8367f719d289185f8 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 13 Aug 2024 11:21:38 -0500
Subject: [PATCH 431/542] =?UTF-8?q?bump:=20version=200.15.5=20=E2=86=92=20?=
 =?UTF-8?q?0.15.6?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

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

diff --git a/CHANGELOG.md b/CHANGELOG.md
index de92a834..3836ff08 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.15.6 (2024-08-13)
+
+### Fix
+
+- avoid `before_render` subscriber hook for web API
+- simplify verbiage for batch execution panel
+
 ## v0.15.5 (2024-08-09)
 
 ### Fix
diff --git a/pyproject.toml b/pyproject.toml
index c4335903..e515a0d0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.15.5"
+version = "0.15.6"
 description = "Backoffice Web Application for Rattail"
 readme = "README.rst"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]
@@ -53,7 +53,7 @@ dependencies = [
         "pyramid_mako",
         "pyramid_retry",
         "pyramid_tm",
-        "rattail[db,bouncer]>=0.17.0",
+        "rattail[db,bouncer]>=0.17.11",
         "sa-filters",
         "simplejson",
         "transaction",

From a6ce5eb21d7ba61f187ac1093abc08b4d9ccdb01 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 15 Aug 2024 14:34:20 -0500
Subject: [PATCH 432/542] feat: refactor forms/grids/views/templates per
 wuttaweb compat

this starts to get things more aligned between wuttaweb and tailbone.
the use case in mind so far is for a wuttaweb view to be included in a
tailbone app.

form and grid classes now have some new methods to match wuttaweb, so
templates call the shared method names where possible.

templates can no longer assume they have tailbone-native master view,
form, grid etc. so must inspect context more closely in some cases.
---
 tailbone/app.py                               |  13 +-
 tailbone/auth.py                              |  29 +---
 tailbone/config.py                            |   5 +-
 tailbone/forms/core.py                        | 112 +++++++++++--
 tailbone/grids/core.py                        |  88 +++++++++-
 tailbone/subscribers.py                       |  71 +++-----
 tailbone/templates/base.mako                  |   8 +-
 tailbone/templates/form.mako                  |   8 +-
 tailbone/templates/forms/deform.mako          |  41 +++--
 tailbone/templates/forms/vue_template.mako    |   3 +
 tailbone/templates/grids/complete.mako        |  94 +++++------
 tailbone/templates/grids/vue_template.mako    |   3 +
 tailbone/templates/master/create.mako         |   2 +-
 tailbone/templates/master/delete.mako         |  10 +-
 tailbone/templates/master/form.mako           |   6 +-
 tailbone/templates/master/index.mako          | 128 +++++++--------
 tailbone/templates/master/view.mako           |  10 +-
 tailbone/templates/people/index.mako          |   4 +-
 tailbone/templates/people/view.mako           |   4 +-
 .../templates/principal/find_by_perm.mako     |   4 +-
 .../templates/themes/butterball/base.mako     |  28 ++--
 tailbone/views/master.py                      |   4 +-
 tailbone/views/principal.py                   |   2 +-
 tailbone/views/roles.py                       |   4 +-
 tailbone/views/users.py                       |   2 +-
 tests/__init__.py                             |   3 -
 tests/forms/__init__.py                       |   0
 tests/forms/test_core.py                      | 153 ++++++++++++++++++
 tests/grids/__init__.py                       |   0
 tests/grids/test_core.py                      | 139 ++++++++++++++++
 tests/test_app.py                             |  43 +++--
 tests/test_auth.py                            |   3 +
 tests/test_config.py                          |  12 ++
 tests/test_subscribers.py                     |  58 +++++++
 tests/util.py                                 |  75 +++++++++
 tests/views/test_master.py                    |  26 +++
 tests/views/test_principal.py                 |  29 ++++
 tests/views/test_roles.py                     |  80 +++++++++
 tests/views/test_users.py                     |  33 ++++
 39 files changed, 1037 insertions(+), 300 deletions(-)
 create mode 100644 tailbone/templates/forms/vue_template.mako
 create mode 100644 tailbone/templates/grids/vue_template.mako
 create mode 100644 tests/forms/__init__.py
 create mode 100644 tests/forms/test_core.py
 create mode 100644 tests/grids/__init__.py
 create mode 100644 tests/grids/test_core.py
 create mode 100644 tests/test_auth.py
 create mode 100644 tests/test_config.py
 create mode 100644 tests/test_subscribers.py
 create mode 100644 tests/util.py
 create mode 100644 tests/views/test_master.py
 create mode 100644 tests/views/test_principal.py
 create mode 100644 tests/views/test_roles.py
 create mode 100644 tests/views/test_users.py

diff --git a/tailbone/app.py b/tailbone/app.py
index b7220703..5e8e49d9 100644
--- a/tailbone/app.py
+++ b/tailbone/app.py
@@ -189,9 +189,16 @@ def make_pyramid_config(settings, configure_csrf=True):
             for spec in includes:
                 config.include(spec)
 
-    # Add some permissions magic.
-    config.add_directive('add_tailbone_permission_group', 'tailbone.auth.add_permission_group')
-    config.add_directive('add_tailbone_permission', 'tailbone.auth.add_permission')
+    # add some permissions magic
+    config.add_directive('add_wutta_permission_group',
+                         'wuttaweb.auth.add_permission_group')
+    config.add_directive('add_wutta_permission',
+                         'wuttaweb.auth.add_permission')
+    # TODO: deprecate / remove these
+    config.add_directive('add_tailbone_permission_group',
+                         'wuttaweb.auth.add_permission_group')
+    config.add_directive('add_tailbone_permission',
+                         'wuttaweb.auth.add_permission')
 
     # and some similar magic for certain master views
     config.add_directive('add_tailbone_index_page', 'tailbone.app.add_index_page')
diff --git a/tailbone/auth.py b/tailbone/auth.py
index 826c5d40..fbe6bf2f 100644
--- a/tailbone/auth.py
+++ b/tailbone/auth.py
@@ -27,7 +27,7 @@ Authentication & Authorization
 import logging
 import re
 
-from rattail.util import prettify, NOTSET
+from rattail.util import NOTSET
 
 from zope.interface import implementer
 from pyramid.authentication import SessionAuthenticationHelper
@@ -159,30 +159,3 @@ class TailboneSecurityPolicy:
 
         user = self.identity(request)
         return auth.has_permission(Session(), user, permission)
-
-
-def add_permission_group(config, key, label=None, overwrite=True):
-    """
-    Add a permission group to the app configuration.
-    """
-    def action():
-        perms = config.get_settings().get('tailbone_permissions', {})
-        if key not in perms or overwrite:
-            group = perms.setdefault(key, {'key': key})
-            group['label'] = label or prettify(key)
-        config.add_settings({'tailbone_permissions': perms})
-    config.action(None, action)
-
-
-def add_permission(config, groupkey, key, label=None):
-    """
-    Add a permission to the app configuration.
-    """
-    def action():
-        perms = config.get_settings().get('tailbone_permissions', {})
-        group = perms.setdefault(groupkey, {'key': groupkey})
-        group.setdefault('label', prettify(groupkey))
-        perm = group.setdefault('perms', {}).setdefault(key, {'key': key})
-        perm['label'] = label or prettify(key)
-        config.add_settings({'tailbone_permissions': perms})
-    config.action(None, action)
diff --git a/tailbone/config.py b/tailbone/config.py
index ce1691ae..8392ba0a 100644
--- a/tailbone/config.py
+++ b/tailbone/config.py
@@ -26,13 +26,14 @@ Rattail config extension for Tailbone
 
 import warnings
 
-from rattail.config import ConfigExtension as BaseExtension
+from wuttjamaican.conf import WuttaConfigExtension
+
 from rattail.db.config import configure_session
 
 from tailbone.db import Session
 
 
-class ConfigExtension(BaseExtension):
+class ConfigExtension(WuttaConfigExtension):
     """
     Rattail config extension for Tailbone.  Does the following:
 
diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py
index 60c2f61b..eeae4537 100644
--- a/tailbone/forms/core.py
+++ b/tailbone/forms/core.py
@@ -47,7 +47,7 @@ from pyramid_deform import SessionFileUploadTempStore
 from pyramid.renderers import render
 from webhelpers2.html import tags, HTML
 
-from wuttaweb.util import get_form_data
+from wuttaweb.util import get_form_data, make_json_safe
 
 from tailbone.db import Session
 from tailbone.util import raw_datetime, render_markdown
@@ -328,7 +328,7 @@ class Form(object):
     """
     Base class for all forms.
     """
-    save_label = "Save"
+    save_label = "Submit"
     update_label = "Save"
     show_cancel = True
     auto_disable = True
@@ -339,10 +339,12 @@ class Form(object):
                  model_instance=None, model_class=None, appstruct=UNSPECIFIED, nodes={}, enums={}, labels={},
                  assume_local_times=False, renderers=None, renderer_kwargs={},
                  hidden={}, widgets={}, defaults={}, validators={}, required={}, helptext={}, focus_spec=None,
-                 action_url=None, cancel_url=None, component='tailbone-form',
+                 action_url=None, cancel_url=None,
+                 vue_tagname=None,
                  vuejs_component_kwargs=None, vuejs_field_converters={}, json_data={}, included_templates={},
                  # TODO: ugh this is getting out hand!
                  can_edit_help=False, edit_help_url=None, route_prefix=None,
+                 **kwargs
     ):
         self.fields = None
         if fields is not None:
@@ -380,7 +382,17 @@ class Form(object):
         self.focus_spec = focus_spec
         self.action_url = action_url
         self.cancel_url = cancel_url
-        self.component = component
+
+        # vue_tagname
+        self.vue_tagname = vue_tagname
+        if not self.vue_tagname and kwargs.get('component'):
+            warnings.warn("component kwarg is deprecated for Form(); "
+                          "please use vue_tagname param instead",
+                          DeprecationWarning, stacklevel=2)
+            self.vue_tagname = kwargs['component']
+        if not self.vue_tagname:
+            self.vue_tagname = 'tailbone-form'
+
         self.vuejs_component_kwargs = vuejs_component_kwargs or {}
         self.vuejs_field_converters = vuejs_field_converters or {}
         self.json_data = json_data or {}
@@ -393,10 +405,54 @@ class Form(object):
         return iter(self.fields)
 
     @property
-    def component_studly(self):
-        words = self.component.split('-')
+    def vue_component(self):
+        """
+        String name for the Vue component, e.g. ``'TailboneGrid'``.
+
+        This is a generated value based on :attr:`vue_tagname`.
+        """
+        words = self.vue_tagname.split('-')
         return ''.join([word.capitalize() for word in words])
 
+    @property
+    def component(self):
+        """
+        DEPRECATED - use :attr:`vue_tagname` instead.
+        """
+        warnings.warn("Form.component is deprecated; "
+                      "please use vue_tagname instead",
+                      DeprecationWarning, stacklevel=2)
+        return self.vue_tagname
+
+    @property
+    def component_studly(self):
+        """
+        DEPRECATED - use :attr:`vue_component` instead.
+        """
+        warnings.warn("Form.component_studly is deprecated; "
+                      "please use vue_component instead",
+                      DeprecationWarning, stacklevel=2)
+        return self.vue_component
+
+    def get_button_label_submit(self):
+        """ """
+        if hasattr(self, '_button_label_submit'):
+            return self._button_label_submit
+
+        label = getattr(self, 'submit_label', None)
+        if label:
+            return label
+
+        return self.save_label
+
+    def set_button_label_submit(self, value):
+        """ """
+        self._button_label_submit = value
+
+    # wutta compat
+    button_label_submit = property(get_button_label_submit,
+                                   set_button_label_submit)
+
     def __contains__(self, item):
         return item in self.fields
 
@@ -805,6 +861,10 @@ class Form(object):
                       DeprecationWarning, stacklevel=2)
         return self.render_deform(**kwargs)
 
+    def get_deform(self):
+        """ """
+        return self.make_deform_form()
+
     def make_deform_form(self):
         if not hasattr(self, 'deform_form'):
 
@@ -843,6 +903,10 @@ class Form(object):
 
         return self.deform_form
 
+    def render_vue_template(self, template='/forms/deform.mako', **context):
+        """ """
+        return self.render_deform(template=template, **context)
+
     def render_deform(self, dform=None, template=None, **kwargs):
         if not template:
             template = '/forms/deform.mako'
@@ -865,8 +929,8 @@ class Form(object):
         context.setdefault('form_kwargs', {})
         # TODO: deprecate / remove the latter option here
         if self.auto_disable_save or self.auto_disable:
-            context['form_kwargs'].setdefault('ref', self.component_studly)
-            context['form_kwargs']['@submit'] = 'submit{}'.format(self.component_studly)
+            context['form_kwargs'].setdefault('ref', self.vue_component)
+            context['form_kwargs']['@submit'] = 'submit{}'.format(self.vue_component)
         if self.focus_spec:
             context['form_kwargs']['data-focus'] = self.focus_spec
         context['request'] = self.request
@@ -878,12 +942,13 @@ class Form(object):
         return dict([(field, self.get_label(field))
                      for field in self])
 
-    def get_field_markdowns(self):
+    def get_field_markdowns(self, session=None):
         app = self.request.rattail_config.get_app()
         model = app.model
+        session = session or Session()
 
         if not hasattr(self, 'field_markdowns'):
-            infos = Session.query(model.TailboneFieldInfo)\
+            infos = session.query(model.TailboneFieldInfo)\
                            .filter(model.TailboneFieldInfo.route_prefix == self.route_prefix)\
                            .all()
             self.field_markdowns = dict([(info.field_name, info.markdown_text)
@@ -891,6 +956,18 @@ class Form(object):
 
         return self.field_markdowns
 
+    def get_vue_field_value(self, key):
+        """ """
+        if key not in self.fields:
+            return
+
+        dform = self.get_deform()
+        if key not in dform:
+            return
+
+        field = dform[key]
+        return make_json_safe(field.cstruct)
+
     def get_vuejs_model_value(self, field):
         """
         This method must return "raw" JS which will be assigned as the initial
@@ -957,6 +1034,10 @@ class Form(object):
     def set_vuejs_component_kwargs(self, **kwargs):
         self.vuejs_component_kwargs.update(kwargs)
 
+    def render_vue_tag(self, **kwargs):
+        """ """
+        return self.render_vuejs_component()
+
     def render_vuejs_component(self):
         """
         Render the Vue.js component HTML for the form.
@@ -971,7 +1052,7 @@ class Form(object):
         kwargs = dict(self.vuejs_component_kwargs)
         if self.can_edit_help:
             kwargs.setdefault(':configure-fields-help', 'configureFieldsHelp')
-        return HTML.tag(self.component, **kwargs)
+        return HTML.tag(self.vue_tagname, **kwargs)
 
     def set_json_data(self, key, value):
         """
@@ -997,7 +1078,12 @@ class Form(object):
             templates.append(HTML.literal(render(template, context)))
         return HTML.literal('\n').join(templates)
 
-    def render_field_complete(self, fieldname, bfield_attrs={}):
+    def render_vue_field(self, fieldname, **kwargs):
+        """ """
+        return self.render_field_complete(fieldname, **kwargs)
+
+    def render_field_complete(self, fieldname, bfield_attrs={},
+                              session=None):
         """
         Render the given field completely, i.e. with ``<b-field>``
         wrapper.  Note that this is meant to render *editable* fields,
@@ -1015,7 +1101,7 @@ class Form(object):
 
         if self.field_visible(fieldname):
             label = self.get_label(fieldname)
-            markdowns = self.get_field_markdowns()
+            markdowns = self.get_field_markdowns(session=session)
 
             # these attrs will be for the <b-field> (*not* the widget)
             attrs = {
diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py
index b4610a18..3f1769cf 100644
--- a/tailbone/grids/core.py
+++ b/tailbone/grids/core.py
@@ -198,7 +198,8 @@ class Grid:
                  checkable=None, row_uuid_getter=None,
                  clicking_row_checks_box=False, click_handlers=None,
                  main_actions=[], more_actions=[], delete_speedbump=False,
-                 ajax_data_url=None, component='tailbone-grid',
+                 ajax_data_url=None,
+                 vue_tagname=None,
                  expose_direct_link=False,
                  **kwargs):
 
@@ -268,19 +269,63 @@ class Grid:
         if ajax_data_url:
             self.ajax_data_url = ajax_data_url
         elif self.request:
-            self.ajax_data_url = self.request.current_route_url(_query=None)
+            self.ajax_data_url = self.request.path_url
         else:
             self.ajax_data_url = ''
 
-        self.component = component
+        # vue_tagname
+        self.vue_tagname = vue_tagname
+        if not self.vue_tagname and kwargs.get('component'):
+            warnings.warn("component kwarg is deprecated for Grid(); "
+                          "please use vue_tagname param instead",
+                          DeprecationWarning, stacklevel=2)
+            self.vue_tagname = kwargs['component']
+        if not self.vue_tagname:
+            self.vue_tagname = 'tailbone-grid'
+
         self.expose_direct_link = expose_direct_link
         self._whgrid_kwargs = kwargs
 
     @property
-    def component_studly(self):
-        words = self.component.split('-')
+    def vue_component(self):
+        """
+        String name for the Vue component, e.g. ``'TailboneGrid'``.
+
+        This is a generated value based on :attr:`vue_tagname`.
+        """
+        words = self.vue_tagname.split('-')
         return ''.join([word.capitalize() for word in words])
 
+    @property
+    def component(self):
+        """
+        DEPRECATED - use :attr:`vue_tagname` instead.
+        """
+        warnings.warn("Grid.component is deprecated; "
+                      "please use vue_tagname instead",
+                      DeprecationWarning, stacklevel=2)
+        return self.vue_tagname
+
+    @property
+    def component_studly(self):
+        """
+        DEPRECATED - use :attr:`vue_component` instead.
+        """
+        warnings.warn("Grid.component_studly is deprecated; "
+                      "please use vue_component instead",
+                      DeprecationWarning, stacklevel=2)
+        return self.vue_component
+
+    @property
+    def actions(self):
+        """ """
+        actions = []
+        if self.main_actions:
+            actions.extend(self.main_actions)
+        if self.more_actions:
+            actions.extend(self.more_actions)
+        return actions
+
     def make_columns(self):
         """
         Return a default list of columns, based on :attr:`model_class`.
@@ -1334,6 +1379,21 @@ class Grid:
             data = self.pager
         return data
 
+    def render_vue_tag(self, master=None, **kwargs):
+        """ """
+        kwargs.setdefault('ref', 'grid')
+        kwargs.setdefault(':csrftoken', 'csrftoken')
+
+        if (master and master.deletable and master.has_perm('delete')
+            and master.delete_confirm == 'simple'):
+            kwargs.setdefault('@deleteActionClicked', 'deleteObject')
+
+        return HTML.tag(self.vue_tagname, **kwargs)
+
+    def render_vue_template(self, template='/grids/complete.mako', **context):
+        """ """
+        return self.render_complete(template=template, **context)
+
     def render_complete(self, template='/grids/complete.mako', **kwargs):
         """
         Render the grid, complete with filters.  Note that this also
@@ -1359,7 +1419,8 @@ class Grid:
         context['request'] = self.request
         context.setdefault('allow_save_defaults', True)
         context.setdefault('view_click_handler', self.get_view_click_handler())
-        return render(template, context)
+        html = render(template, context)
+        return HTML.literal(html)
 
     def render_buefy(self, **kwargs):
         warnings.warn("Grid.render_buefy() is deprecated; "
@@ -1575,6 +1636,10 @@ class Grid:
             return True
         return False
 
+    def get_vue_columns(self):
+        """ """
+        return self.get_table_columns()
+
     def get_table_columns(self):
         """
         Return a list of dicts representing all grid columns.  Meant
@@ -1600,11 +1665,19 @@ class Grid:
         if hasattr(rowobj, 'uuid'):
             return rowobj.uuid
 
+    def get_vue_data(self):
+        """ """
+        table_data = self.get_table_data()
+        return table_data['data']
+
     def get_table_data(self):
         """
         Returns a list of data rows for the grid, for use with
         client-side JS table.
         """
+        if hasattr(self, '_table_data'):
+            return self._table_data
+
         # filter / sort / paginate to get "visible" data
         raw_data = self.make_visible_data()
         data = []
@@ -1704,7 +1777,8 @@ class Grid:
         else:
             results['total_items'] = count
 
-        return results
+        self._table_data = results
+        return self._table_data
 
     def set_action_urls(self, row, rowobj, i):
         """
diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py
index 02c4e518..268d4818 100644
--- a/tailbone/subscribers.py
+++ b/tailbone/subscribers.py
@@ -48,7 +48,7 @@ from tailbone.util import get_available_themes, get_global_search_options
 log = logging.getLogger(__name__)
 
 
-def new_request(event):
+def new_request(event, session=None):
     """
     Event hook called when processing a new request.
 
@@ -64,15 +64,6 @@ def new_request(event):
        Reference to the app :term:`config object`.  Note that this
        will be the same as :attr:`wuttaweb:request.wutta_config`.
 
-    .. method:: request.has_perm(name)
-
-       Function to check if current user has the given permission.
-
-    .. method:: request.has_any_perm(*names)
-
-       Function to check if current user has any of the given
-       permissions.
-
     .. method:: request.register_component(tagname, classname)
 
        Function to register a Vue component for use with the app.
@@ -90,6 +81,7 @@ def new_request(event):
     config = request.wutta_config
     app = config.get_app()
     auth = app.get_auth_handler()
+    session = session or Session()
 
     # compatibility
     rattail_config = config
@@ -104,50 +96,31 @@ def new_request(event):
             return user
 
     # invoke upstream hook to set user
-    base.new_request_set_user(event, user_getter=user_getter, db_session=Session())
+    base.new_request_set_user(event, user_getter=user_getter, db_session=session)
 
     # assign client IP address to the session, for sake of versioning
-    Session().continuum_remote_addr = request.client_addr
+    if hasattr(request, 'client_addr'):
+        session.continuum_remote_addr = request.client_addr
 
-    # TODO: why would this ever be null?
-    if rattail_config:
+    # request.register_component()
+    def register_component(tagname, classname):
+        """
+        Register a Vue 3 component, so the base template knows to
+        declare it for use within the app (page).
+        """
+        if not hasattr(request, '_tailbone_registered_components'):
+            request._tailbone_registered_components = OrderedDict()
 
-        app = rattail_config.get_app()
-        auth = app.get_auth_handler()
-        request.tailbone_cached_permissions = auth.get_permissions(
-            Session(), request.user)
+        if tagname in request._tailbone_registered_components:
+            log.warning("component with tagname '%s' already registered "
+                        "with class '%s' but we are replacing that with "
+                        "class '%s'",
+                        tagname,
+                        request._tailbone_registered_components[tagname],
+                        classname)
 
-        def has_perm(name):
-            if name in request.tailbone_cached_permissions:
-                return True
-            return request.is_root
-        request.has_perm = has_perm
-
-        def has_any_perm(*names):
-            for name in names:
-                if has_perm(name):
-                    return True
-            return False
-        request.has_any_perm = has_any_perm
-
-        def register_component(tagname, classname):
-            """
-            Register a Vue 3 component, so the base template knows to
-            declare it for use within the app (page).
-            """
-            if not hasattr(request, '_tailbone_registered_components'):
-                request._tailbone_registered_components = OrderedDict()
-
-            if tagname in request._tailbone_registered_components:
-                log.warning("component with tagname '%s' already registered "
-                            "with class '%s' but we are replacing that with "
-                            "class '%s'",
-                            tagname,
-                            request._tailbone_registered_components[tagname],
-                            classname)
-
-            request._tailbone_registered_components[tagname] = classname
-        request.register_component = register_component
+        request._tailbone_registered_components[tagname] = classname
+    request.register_component = register_component
 
 
 def before_render(event):
diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako
index c4cbd648..6811397b 100644
--- a/tailbone/templates/base.mako
+++ b/tailbone/templates/base.mako
@@ -153,12 +153,16 @@
   <style type="text/css">
     .filters .filter-fieldname,
     .filters .filter-fieldname .button {
+        % if filter_fieldname_width is not Undefined:
         min-width: ${filter_fieldname_width};
+        % endif
         justify-content: left;
     }
+    % if filter_fieldname_width is not Undefined:
     .filters .filter-verb {
         min-width: ${filter_verb_width};
     }
+    % endif
   </style>
 </%def>
 
@@ -856,7 +860,7 @@
         feedbackMessage: "",
 
         % if expose_theme_picker and request.has_perm('common.change_app_theme'):
-            globalTheme: ${json.dumps(theme)|n},
+            globalTheme: ${json.dumps(theme or None)|n},
             referrer: location.href,
         % endif
 
@@ -866,7 +870,7 @@
 
         globalSearchActive: false,
         globalSearchTerm: '',
-        globalSearchData: ${json.dumps(global_search_data)|n},
+        globalSearchData: ${json.dumps(global_search_data or [])|n},
 
         mountedHooks: [],
     }
diff --git a/tailbone/templates/form.mako b/tailbone/templates/form.mako
index c9c8ea88..fec721fd 100644
--- a/tailbone/templates/form.mako
+++ b/tailbone/templates/form.mako
@@ -6,12 +6,12 @@
 <%def name="render_form_buttons()"></%def>
 
 <%def name="render_form_template()">
-  ${form.render_deform(buttons=capture(self.render_form_buttons))|n}
+  ${form.render_vue_template(buttons=capture(self.render_form_buttons))|n}
 </%def>
 
 <%def name="render_form()">
   <div class="form">
-    ${form.render_vuejs_component()}
+    ${form.render_vue_tag()}
   </div>
 </%def>
 
@@ -111,9 +111,9 @@
   % if form is not Undefined:
       <script type="text/javascript">
 
-        ${form.component_studly}.data = function() { return ${form.component_studly}Data }
+        ${form.vue_component}.data = function() { return ${form.vue_component}Data }
 
-        Vue.component('${form.component}', ${form.component_studly})
+        Vue.component('${form.vue_tagname}', ${form.vue_component})
 
       </script>
   % endif
diff --git a/tailbone/templates/forms/deform.mako b/tailbone/templates/forms/deform.mako
index 00cf2c50..26c8b4ee 100644
--- a/tailbone/templates/forms/deform.mako
+++ b/tailbone/templates/forms/deform.mako
@@ -1,19 +1,19 @@
 ## -*- coding: utf-8; -*-
 
-<% request.register_component(form.component, form.component_studly) %>
+<% request.register_component(form.vue_tagname, form.vue_component) %>
 
-<script type="text/x-template" id="${form.component}-template">
+<script type="text/x-template" id="${form.vue_tagname}-template">
 
   <div>
   % if not form.readonly:
-  ${h.form(form.action_url, id=dform.formid, method='post', enctype='multipart/form-data', **form_kwargs)}
+  ${h.form(form.action_url, id=dform.formid, method='post', enctype='multipart/form-data', **(form_kwargs or {}))}
   ${h.csrf_token(request)}
   % endif
 
   <section>
     % if form_body is not Undefined and form_body:
         ${form_body|n}
-    % elif form.grouping:
+    % elif getattr(form, 'grouping', None):
         % for group in form.grouping:
             <nav class="panel">
               <p class="panel-heading">${group}</p>
@@ -27,8 +27,8 @@
             </nav>
         % endfor
     % else:
-        % for field in form.fields:
-            ${form.render_field_complete(field)}
+        % for fieldname in form.fields:
+            ${form.render_vue_field(fieldname, session=session)}
         % endfor
     % endif
   </section>
@@ -54,20 +54,20 @@
             <input type="reset" value="Reset" class="button" />
         % endif
         ## TODO: deprecate / remove the latter option here
-        % if form.auto_disable_save or form.auto_disable:
+        % if getattr(form, 'auto_disable_submit', False) or form.auto_disable_save or form.auto_disable:
             <b-button type="is-primary"
                       native-type="submit"
-                      :disabled="${form.component_studly}Submitting"
+                      :disabled="${form.vue_component}Submitting"
                       icon-pack="fas"
                       icon-left="save">
-              {{ ${form.component_studly}ButtonText }}
+              {{ ${form.vue_component}Submitting ? "Working, please wait..." : "${form.button_label_submit}" }}
             </b-button>
         % else:
             <b-button type="is-primary"
                       native-type="submit"
                       icon-pack="fas"
                       icon-left="save">
-              ${getattr(form, 'submit_label', getattr(form, 'save_label', "Submit"))}
+              ${form.button_label_submit}
             </b-button>
         % endif
       </div>
@@ -122,8 +122,8 @@
 
 <script type="text/javascript">
 
-  let ${form.component_studly} = {
-      template: '#${form.component}-template',
+  let ${form.vue_component} = {
+      template: '#${form.vue_tagname}-template',
       mixins: [FormPosterMixin],
       components: {},
       props: {
@@ -136,10 +136,9 @@
       methods: {
 
           ## TODO: deprecate / remove the latter option here
-          % if form.auto_disable_save or form.auto_disable:
-              submit${form.component_studly}() {
-                  this.${form.component_studly}Submitting = true
-                  this.${form.component_studly}ButtonText = "Working, please wait..."
+          % if getattr(form, 'auto_disable_submit', False) or form.auto_disable_save or form.auto_disable:
+              submit${form.vue_component}() {
+                  this.${form.vue_component}Submitting = true
               },
           % endif
 
@@ -178,7 +177,7 @@
       }
   }
 
-  let ${form.component_studly}Data = {
+  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},
@@ -198,16 +197,14 @@
       % if not form.readonly:
           % for field in form.fields:
               % if field in dform:
-                  <% field = dform[field] %>
-                  field_model_${field.name}: ${form.get_vuejs_model_value(field)|n},
+                  field_model_${field}: ${json.dumps(form.get_vue_field_value(field))|n},
               % endif
           % endfor
       % endif
 
       ## TODO: deprecate / remove the latter option here
-      % if form.auto_disable_save or form.auto_disable:
-          ${form.component_studly}Submitting: false,
-          ${form.component_studly}ButtonText: ${json.dumps(getattr(form, 'submit_label', getattr(form, 'save_label', "Submit")))|n},
+      % if getattr(form, 'auto_disable_submit', False) or form.auto_disable_save or form.auto_disable:
+          ${form.vue_component}Submitting: false,
       % endif
   }
 
diff --git a/tailbone/templates/forms/vue_template.mako b/tailbone/templates/forms/vue_template.mako
new file mode 100644
index 00000000..ac096f67
--- /dev/null
+++ b/tailbone/templates/forms/vue_template.mako
@@ -0,0 +1,3 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/forms/deform.mako" />
+${parent.body()}
diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako
index a0f927d3..fc48916b 100644
--- a/tailbone/templates/grids/complete.mako
+++ b/tailbone/templates/grids/complete.mako
@@ -1,15 +1,15 @@
 ## -*- coding: utf-8; -*-
 
-<% request.register_component(grid.component, grid.component_studly) %>
+<% request.register_component(grid.vue_tagname, grid.vue_component) %>
 
-<script type="text/x-template" id="${grid.component}-template">
+<script type="text/x-template" id="${grid.vue_tagname}-template">
   <div>
 
     <div style="display: flex; justify-content: space-between; margin-bottom: 0.5em;">
 
       <div style="display: flex; flex-direction: column; justify-content: end;">
         <div class="filters">
-          % if grid.filterable:
+          % if getattr(grid, 'filterable', False):
               ## TODO: stop using |n filter
               ${grid.render_filters(allow_save_defaults=allow_save_defaults)|n}
           % endif
@@ -55,7 +55,7 @@
 
        :checkable="checkable"
 
-       % if grid.checkboxes:
+       % if getattr(grid, 'checkboxes', False):
            % if request.use_oruga:
                v-model:checked-rows="checkedRows"
            % else:
@@ -66,20 +66,22 @@
            % endif
        % endif
 
-       % if grid.check_handler:
+       % if getattr(grid, 'check_handler', None):
        @check="${grid.check_handler}"
        % endif
-       % if grid.check_all_handler:
+       % if getattr(grid, 'check_all_handler', None):
        @check-all="${grid.check_all_handler}"
        % endif
 
+       % if hasattr(grid, 'checkable'):
        % if isinstance(grid.checkable, str):
        :is-row-checkable="${grid.row_checkable}"
        % elif grid.checkable:
        :is-row-checkable="row => row._checkable"
        % endif
+       % endif
 
-       % if grid.sortable:
+       % if getattr(grid, 'sortable', False):
            backend-sorting
            @sort="onSort"
            @sorting-priority-removed="sortingPriorityRemoved"
@@ -101,7 +103,7 @@
            sort-multiple-key="ctrlKey"
        % endif
 
-       % if grid.click_handlers:
+       % if getattr(grid, 'click_handlers', None):
        @cellclick="cellClick"
        % endif
 
@@ -119,17 +121,17 @@
        :hoverable="true"
        :narrowed="true">
 
-      % for column in grid_columns:
+      % for column in grid.get_vue_columns():
           <${b}-table-column field="${column['field']}"
                           label="${column['label']}"
                           v-slot="props"
-                          :sortable="${json.dumps(column['sortable'])}"
-                          % if grid.is_searchable(column['field']):
+                          :sortable="${json.dumps(column.get('sortable', False))}"
+                          % if hasattr(grid, 'is_searchable') and grid.is_searchable(column['field']):
                           searchable
                           % endif
                           cell-class="c_${column['field']}"
-                          :visible="${json.dumps(column['visible'])}">
-            % if column['field'] in grid.raw_renderers:
+                          :visible="${json.dumps(column.get('visible', True))}">
+            % if hasattr(grid, 'raw_renderers') and column['field'] in grid.raw_renderers:
                 ${grid.raw_renderers[column['field']]()}
             % elif grid.is_linked(column['field']):
                 <a :href="props.row._action_url_view"
@@ -144,20 +146,20 @@
           </${b}-table-column>
       % endfor
 
-      % if grid.main_actions or grid.more_actions:
+      % if grid.actions:
           <${b}-table-column field="actions"
                           label="Actions"
                           v-slot="props">
             ## TODO: we do not currently differentiate for "main vs. more"
             ## here, but ideally we would tuck "more" away in a drawer etc.
-            % for action in grid.main_actions + grid.more_actions:
+            % for action in grid.actions:
                 <a v-if="props.row._action_url_${action.key}"
                    :href="props.row._action_url_${action.key}"
                    class="grid-action${' has-text-danger' if action.key == 'delete' else ''} ${action.link_class or ''}"
-                   % if action.click_handler:
+                   % if getattr(action, 'click_handler', None):
                    @click.prevent="${action.click_handler}"
                    % endif
-                   % if action.target:
+                   % if getattr(action, 'target', None):
                    target="${action.target}"
                    % endif
                    >
@@ -192,7 +194,7 @@
       <template #footer>
         <div style="display: flex; justify-content: space-between;">
 
-          % if grid.expose_direct_link:
+          % if getattr(grid, 'expose_direct_link', False):
               <b-button type="is-primary"
                         size="is-small"
                         @click="copyDirectLink()"
@@ -207,7 +209,7 @@
               <div></div>
           % endif
 
-          % if grid.pageable:
+          % if getattr(grid, 'pageable', False):
               <div v-if="firstItem"
                    style="display: flex; gap: 0.5rem; align-items: center;">
                 <span>
@@ -234,7 +236,7 @@
     </${b}-table>
 
     ## dummy input field needed for sharing links on *insecure* sites
-    % if request.scheme == 'http':
+    % if getattr(request, 'scheme', None) == 'http':
         <b-input v-model="shareLink" ref="shareLink" v-show="shareLink"></b-input>
     % endif
 
@@ -243,30 +245,30 @@
 
 <script type="text/javascript">
 
-  let ${grid.component_studly}CurrentData = ${json.dumps(grid_data['data'])|n}
+  let ${grid.vue_component}CurrentData = ${json.dumps(grid.get_vue_data())|n}
 
-  let ${grid.component_studly}Data = {
+  let ${grid.vue_component}Data = {
       loading: false,
-      ajaxDataUrl: ${json.dumps(grid.ajax_data_url)|n},
+      ajaxDataUrl: ${json.dumps(getattr(grid, 'ajax_data_url', request.path_url))|n},
 
       savingDefaults: false,
 
-      data: ${grid.component_studly}CurrentData,
-      rowStatusMap: ${json.dumps(grid_data['row_status_map'])|n},
+      data: ${grid.vue_component}CurrentData,
+      rowStatusMap: ${json.dumps(grid_data['row_status_map'] if grid_data is not Undefined else {})|n},
 
-      checkable: ${json.dumps(grid.checkboxes)|n},
-      % if grid.checkboxes:
+      checkable: ${json.dumps(getattr(grid, 'checkboxes', False))|n},
+      % if getattr(grid, 'checkboxes', False):
       checkedRows: ${grid_data['checked_rows_code']|n},
       % endif
 
-      paginated: ${json.dumps(grid.pageable)|n},
-      total: ${len(grid_data['data']) if static_data else grid_data['total_items']},
-      perPage: ${json.dumps(grid.pagesize if grid.pageable else None)|n},
-      currentPage: ${json.dumps(grid.page if grid.pageable else None)|n},
-      firstItem: ${json.dumps(grid_data['first_item'] if grid.pageable else None)|n},
-      lastItem: ${json.dumps(grid_data['last_item'] if grid.pageable else None)|n},
+      paginated: ${json.dumps(getattr(grid, 'pageable', False))|n},
+      total: ${len(grid_data['data']) if static_data else (grid_data['total_items'] if grid_data is not Undefined else 0)},
+      perPage: ${json.dumps(grid.pagesize if getattr(grid, 'pageable', False) else None)|n},
+      currentPage: ${json.dumps(grid.page if getattr(grid, 'pageable', False) else None)|n},
+      firstItem: ${json.dumps(grid_data['first_item'] if getattr(grid, 'pageable', False) else None)|n},
+      lastItem: ${json.dumps(grid_data['last_item'] if getattr(grid, 'pageable', False) else None)|n},
 
-      % if grid.sortable:
+      % if getattr(grid, 'sortable', False):
 
           ## TODO: there is a bug (?) which prevents the arrow from
           ## displaying for simple default single-column sort.  so to
@@ -289,19 +291,19 @@
       % endif
 
       ## filterable: ${json.dumps(grid.filterable)|n},
-      filters: ${json.dumps(filters_data if grid.filterable else None)|n},
-      filtersSequence: ${json.dumps(filters_sequence if grid.filterable else None)|n},
+      filters: ${json.dumps(filters_data if getattr(grid, 'filterable', False) else None)|n},
+      filtersSequence: ${json.dumps(filters_sequence if getattr(grid, 'filterable', False) else None)|n},
       addFilterTerm: '',
       addFilterShow: false,
 
       ## dummy input value needed for sharing links on *insecure* sites
-      % if request.scheme == 'http':
+      % if getattr(request, 'scheme', None) == 'http':
       shareLink: null,
       % endif
   }
 
-  let ${grid.component_studly} = {
-      template: '#${grid.component}-template',
+  let ${grid.vue_component} = {
+      template: '#${grid.vue_tagname}-template',
 
       mixins: [FormPosterMixin],
 
@@ -358,7 +360,7 @@
 
           directLink() {
               let params = new URLSearchParams(this.getAllParams())
-              return `${request.current_route_url(_query=None)}?${'$'}{params}`
+              return `${request.path_url}?${'$'}{params}`
           },
       },
 
@@ -380,7 +382,7 @@
               return filtr.label || filtr.key
           },
 
-          % if grid.click_handlers:
+          % if getattr(grid, 'click_handlers', None):
               cellClick(row, column, rowIndex, columnIndex) {
                   % for key in grid.click_handlers:
                       if (column._props.field == '${key}') {
@@ -437,13 +439,13 @@
 
           getBasicParams() {
               let params = {}
-              % if grid.sortable:
+              % if getattr(grid, 'sortable', False):
                   for (let i = 1; i <= this.backendSorters.length; i++) {
                       params['sort'+i+'key'] = this.backendSorters[i-1].field
                       params['sort'+i+'dir'] = this.backendSorters[i-1].order
                   }
               % endif
-              % if grid.pageable:
+              % if getattr(grid, 'pageable', False):
                   params.pagesize = this.perPage
                   params.page = this.currentPage
               % endif
@@ -488,8 +490,8 @@
               this.loading = true
               this.$http.get(`${'$'}{this.ajaxDataUrl}?${'$'}{params}`).then(({ data }) => {
                   if (!data.error) {
-                      ${grid.component_studly}CurrentData = data.data
-                      this.data = ${grid.component_studly}CurrentData
+                      ${grid.vue_component}CurrentData = data.data
+                      this.data = ${grid.vue_component}CurrentData
                       this.rowStatusMap = data.row_status_map
                       this.total = data.total_items
                       this.firstItem = data.first_item
@@ -776,7 +778,7 @@
               } else {
                   this.checkedRows.push(row)
               }
-              % if grid.check_handler:
+              % if getattr(grid, 'check_handler', None):
               this.${grid.check_handler}(this.checkedRows, row)
               % endif
           },
diff --git a/tailbone/templates/grids/vue_template.mako b/tailbone/templates/grids/vue_template.mako
new file mode 100644
index 00000000..625f046b
--- /dev/null
+++ b/tailbone/templates/grids/vue_template.mako
@@ -0,0 +1,3 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/grids/complete.mako" />
+${parent.body()}
diff --git a/tailbone/templates/master/create.mako b/tailbone/templates/master/create.mako
index 27cd404c..d7dcbbd8 100644
--- a/tailbone/templates/master/create.mako
+++ b/tailbone/templates/master/create.mako
@@ -1,6 +1,6 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/form.mako" />
 
-<%def name="title()">New ${model_title_plural if master.creates_multiple else model_title}</%def>
+<%def name="title()">New ${model_title_plural if getattr(master, 'creates_multiple', False) else model_title}</%def>
 
 ${parent.body()}
diff --git a/tailbone/templates/master/delete.mako b/tailbone/templates/master/delete.mako
index 30bb50ab..c6187d55 100644
--- a/tailbone/templates/master/delete.mako
+++ b/tailbone/templates/master/delete.mako
@@ -27,7 +27,7 @@
       <b-button type="is-primary is-danger"
                 native-type="submit"
                 :disabled="formSubmitting">
-        {{ formButtonText }}
+        {{ formSubmitting ? "Working, please wait..." : "${form.button_label_submit}" }}
       </b-button>
     </div>
   ${h.end_form()}
@@ -35,14 +35,12 @@
 
 <%def name="modify_this_page_vars()">
   ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+  <script>
 
-    TailboneFormData.formSubmitting = false
-    TailboneFormData.formButtonText = "Yes, please DELETE this data forever!"
+    ${form.vue_component}Data.formSubmitting = false
 
-    TailboneForm.methods.submitForm = function() {
+    ${form.vue_component}.methods.submitForm = function() {
         this.formSubmitting = true
-        this.formButtonText = "Working, please wait..."
     }
 
   </script>
diff --git a/tailbone/templates/master/form.mako b/tailbone/templates/master/form.mako
index dfe56fa8..dc9743ea 100644
--- a/tailbone/templates/master/form.mako
+++ b/tailbone/templates/master/form.mako
@@ -6,13 +6,13 @@
   <script type="text/javascript">
 
     ## declare extra data needed by form
-    % if form is not Undefined:
+    % if form is not Undefined and getattr(form, 'json_data', None):
         % for key, value in form.json_data.items():
             ${form.component_studly}Data.${key} = ${json.dumps(value)|n}
         % endfor
     % endif
 
-    % if master.deletable and instance_deletable and master.has_perm('delete') and master.delete_confirm == 'simple':
+    % if master.deletable and instance_deletable and master.has_perm('delete') and getattr(master, 'delete_confirm', 'full') == 'simple':
 
         ThisPage.methods.deleteObject = function() {
             if (confirm("Are you sure you wish to delete this ${model_title}?")) {
@@ -23,7 +23,7 @@
     % endif
   </script>
 
-  % if form is not Undefined:
+  % if form is not Undefined and hasattr(form, 'render_included_templates'):
       ${form.render_included_templates()}
   % endif
 
diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako
index 33592559..81c11213 100644
--- a/tailbone/templates/master/index.mako
+++ b/tailbone/templates/master/index.mako
@@ -15,7 +15,7 @@
 <%def name="grid_tools()">
 
   ## grid totals
-  % if master.supports_grid_totals:
+  % if getattr(master, 'supports_grid_totals', False):
       <div style="display: flex; align-items: center;">
         <b-button v-if="gridTotalsDisplay == null"
                   :disabled="gridTotalsFetching"
@@ -30,7 +30,7 @@
   % endif
 
   ## download search results
-  % if master.results_downloadable and master.has_perm('download_results'):
+  % if getattr(master, 'results_downloadable', False) and master.has_perm('download_results'):
       <div>
         <b-button type="is-primary"
                   icon-pack="fas"
@@ -180,7 +180,7 @@
   % endif
 
   ## download rows for search results
-  % if master.has_rows and master.results_rows_downloadable and master.has_perm('download_results_rows'):
+  % 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"
@@ -194,7 +194,7 @@
   % endif
 
   ## merge 2 objects
-  % if master.mergeable and request.has_perm('{}.merge'.format(permission_prefix)):
+  % 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)}
@@ -212,7 +212,7 @@
   % endif
 
   ## enable / disable selected objects
-  % if master.supports_set_enabled_toggle and master.has_perm('enable_disable_set'):
+  % 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)}
@@ -234,7 +234,7 @@
   % endif
 
   ## delete selected objects
-  % if master.set_deletable and master.has_perm('delete_set'):
+  % 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')}
@@ -249,7 +249,7 @@
   % endif
 
   ## delete search results
-  % if master.bulk_deletable and request.has_perm('{}.bulk_delete'.format(permission_prefix)):
+  % 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"
@@ -283,7 +283,7 @@
 
   ${self.render_grid_component()}
 
-  % if master.deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple':
+  % if master.deletable and master.has_perm('delete') and getattr(master, 'delete_confirm', 'full') == 'simple':
       ${h.form('#', ref='deleteObjectForm')}
       ${h.csrf_token(request)}
       ${h.end_form()}
@@ -291,17 +291,11 @@
 </%def>
 
 <%def name="make_grid_component()">
-  ## TODO: stop using |n filter?
-  ${grid.render_complete(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())|n}
+  ${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.component} ref="grid" :csrftoken="csrftoken"
-     % if master.deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple':
-     @deleteActionClicked="deleteObject"
-     % endif
-     >
-  </${grid.component}>
+  ${grid.render_vue_tag()}
 </%def>
 
 <%def name="make_this_page_component()">
@@ -313,10 +307,8 @@
 
   ## finalize grid
   <script>
-
-    ${grid.component_studly}.data = () => { return ${grid.component_studly}Data }
-    Vue.component('${grid.component}', ${grid.component_studly})
-
+    ${grid.vue_component}.data = function() { return ${grid.vue_component}Data }
+    Vue.component('${grid.vue_tagname}', ${grid.vue_component})
   </script>
 </%def>
 
@@ -328,11 +320,11 @@
   ${parent.modify_this_page_vars()}
   <script type="text/javascript">
 
-    % if master.supports_grid_totals:
-        ${grid.component_studly}Data.gridTotalsDisplay = null
-        ${grid.component_studly}Data.gridTotalsFetching = false
+    % if getattr(master, 'supports_grid_totals', False):
+        ${grid.vue_component}Data.gridTotalsDisplay = null
+        ${grid.vue_component}Data.gridTotalsFetching = false
 
-        ${grid.component_studly}.methods.gridTotalsFetch = function() {
+        ${grid.vue_component}.methods.gridTotalsFetch = function() {
             this.gridTotalsFetching = true
 
             let url = '${url(f'{route_prefix}.fetch_grid_totals')}'
@@ -344,7 +336,7 @@
             })
         }
 
-        ${grid.component_studly}.methods.appliedFiltersHook = function() {
+        ${grid.vue_component}.methods.appliedFiltersHook = function() {
             this.gridTotalsDisplay = null
             this.gridTotalsFetching = false
         }
@@ -388,7 +380,7 @@
     % endif
 
     ## delete single object
-    % if master.deletable and master.has_perm('delete') and master.delete_confirm == 'simple':
+    % if master.deletable and master.has_perm('delete') and getattr(master, 'delete_confirm', 'full') == 'simple':
         ThisPage.methods.deleteObject = function(url) {
             if (confirm("Are you sure you wish to delete this ${model_title}?")) {
                 let form = this.$refs.deleteObjectForm
@@ -399,19 +391,19 @@
     % endif
 
     ## download results
-    % if master.results_downloadable and master.has_perm('download_results'):
+    % if getattr(master, 'results_downloadable', False) and master.has_perm('download_results'):
 
-        ${grid.component_studly}Data.downloadResultsFormat = '${master.download_results_default_format()}'
-        ${grid.component_studly}Data.showDownloadResultsDialog = false
-        ${grid.component_studly}Data.downloadResultsFieldsMode = 'default'
-        ${grid.component_studly}Data.downloadResultsFieldsAvailable = ${json.dumps(download_results_fields_available)|n}
-        ${grid.component_studly}Data.downloadResultsFieldsDefault = ${json.dumps(download_results_fields_default)|n}
-        ${grid.component_studly}Data.downloadResultsFieldsIncluded = ${json.dumps(download_results_fields_default)|n}
+        ${grid.vue_component}Data.downloadResultsFormat = '${master.download_results_default_format()}'
+        ${grid.vue_component}Data.showDownloadResultsDialog = false
+        ${grid.vue_component}Data.downloadResultsFieldsMode = 'default'
+        ${grid.vue_component}Data.downloadResultsFieldsAvailable = ${json.dumps(download_results_fields_available)|n}
+        ${grid.vue_component}Data.downloadResultsFieldsDefault = ${json.dumps(download_results_fields_default)|n}
+        ${grid.vue_component}Data.downloadResultsFieldsIncluded = ${json.dumps(download_results_fields_default)|n}
 
-        ${grid.component_studly}Data.downloadResultsExcludedFieldsSelected = []
-        ${grid.component_studly}Data.downloadResultsIncludedFieldsSelected = []
+        ${grid.vue_component}Data.downloadResultsExcludedFieldsSelected = []
+        ${grid.vue_component}Data.downloadResultsIncludedFieldsSelected = []
 
-        ${grid.component_studly}.computed.downloadResultsFieldsExcluded = function() {
+        ${grid.vue_component}.computed.downloadResultsFieldsExcluded = function() {
             let excluded = []
             this.downloadResultsFieldsAvailable.forEach(field => {
                 if (!this.downloadResultsFieldsIncluded.includes(field)) {
@@ -421,7 +413,7 @@
             return excluded
         }
 
-        ${grid.component_studly}.methods.downloadResultsExcludeFields = function() {
+        ${grid.vue_component}.methods.downloadResultsExcludeFields = function() {
             const selected = Array.from(this.downloadResultsIncludedFieldsSelected)
             if (!selected) {
                 return
@@ -445,7 +437,7 @@
             })
         }
 
-        ${grid.component_studly}.methods.downloadResultsIncludeFields = function() {
+        ${grid.vue_component}.methods.downloadResultsIncludeFields = function() {
             const selected = Array.from(this.downloadResultsExcludedFieldsSelected)
             if (!selected) {
                 return
@@ -466,28 +458,28 @@
             })
         }
 
-        ${grid.component_studly}.methods.downloadResultsUseDefaultFields = function() {
+        ${grid.vue_component}.methods.downloadResultsUseDefaultFields = function() {
             this.downloadResultsFieldsIncluded = Array.from(this.downloadResultsFieldsDefault)
             this.downloadResultsFieldsMode = 'default'
         }
 
-        ${grid.component_studly}.methods.downloadResultsUseAllFields = function() {
+        ${grid.vue_component}.methods.downloadResultsUseAllFields = function() {
             this.downloadResultsFieldsIncluded = Array.from(this.downloadResultsFieldsAvailable)
             this.downloadResultsFieldsMode = 'all'
         }
 
-        ${grid.component_studly}.methods.downloadResultsSubmit = function() {
+        ${grid.vue_component}.methods.downloadResultsSubmit = function() {
             this.$refs.download_results_form.submit()
         }
     % endif
 
     ## download rows for results
-    % if master.has_rows and master.results_rows_downloadable and master.has_perm('download_results_rows'):
+    % if getattr(master, 'has_rows', False) and master.results_rows_downloadable and master.has_perm('download_results_rows'):
 
-        ${grid.component_studly}Data.downloadResultsRowsButtonDisabled = false
-        ${grid.component_studly}Data.downloadResultsRowsButtonText = "Download Rows for Results"
+        ${grid.vue_component}Data.downloadResultsRowsButtonDisabled = false
+        ${grid.vue_component}Data.downloadResultsRowsButtonText = "Download Rows for Results"
 
-        ${grid.component_studly}.methods.downloadResultsRows = function() {
+        ${grid.vue_component}.methods.downloadResultsRows = function() {
             if (confirm("This will generate an Excel file which contains "
                         + "not the results themselves, but the *rows* for "
                         + "each.\n\nAre you sure you want this?")) {
@@ -499,12 +491,12 @@
     % endif
 
     ## enable / disable selected objects
-    % if master.supports_set_enabled_toggle and master.has_perm('enable_disable_set'):
+    % if getattr(master, 'supports_set_enabled_toggle', False) and master.has_perm('enable_disable_set'):
 
-        ${grid.component_studly}Data.enableSelectedSubmitting = false
-        ${grid.component_studly}Data.enableSelectedText = "Enable Selected"
+        ${grid.vue_component}Data.enableSelectedSubmitting = false
+        ${grid.vue_component}Data.enableSelectedText = "Enable Selected"
 
-        ${grid.component_studly}.computed.enableSelectedDisabled = function() {
+        ${grid.vue_component}.computed.enableSelectedDisabled = function() {
             if (this.enableSelectedSubmitting) {
                 return true
             }
@@ -514,7 +506,7 @@
             return false
         }
 
-        ${grid.component_studly}.methods.enableSelectedSubmit = function() {
+        ${grid.vue_component}.methods.enableSelectedSubmit = function() {
             let uuids = this.checkedRowUUIDs()
             if (!uuids.length) {
                 alert("You must first select one or more objects to disable.")
@@ -529,10 +521,10 @@
             this.$refs.enable_selected_form.submit()
         }
 
-        ${grid.component_studly}Data.disableSelectedSubmitting = false
-        ${grid.component_studly}Data.disableSelectedText = "Disable Selected"
+        ${grid.vue_component}Data.disableSelectedSubmitting = false
+        ${grid.vue_component}Data.disableSelectedText = "Disable Selected"
 
-        ${grid.component_studly}.computed.disableSelectedDisabled = function() {
+        ${grid.vue_component}.computed.disableSelectedDisabled = function() {
             if (this.disableSelectedSubmitting) {
                 return true
             }
@@ -542,7 +534,7 @@
             return false
         }
 
-        ${grid.component_studly}.methods.disableSelectedSubmit = function() {
+        ${grid.vue_component}.methods.disableSelectedSubmit = function() {
             let uuids = this.checkedRowUUIDs()
             if (!uuids.length) {
                 alert("You must first select one or more objects to disable.")
@@ -560,12 +552,12 @@
     % endif
 
     ## delete selected objects
-    % if master.set_deletable and master.has_perm('delete_set'):
+    % if getattr(master, 'set_deletable', False) and master.has_perm('delete_set'):
 
-        ${grid.component_studly}Data.deleteSelectedSubmitting = false
-        ${grid.component_studly}Data.deleteSelectedText = "Delete Selected"
+        ${grid.vue_component}Data.deleteSelectedSubmitting = false
+        ${grid.vue_component}Data.deleteSelectedText = "Delete Selected"
 
-        ${grid.component_studly}.computed.deleteSelectedDisabled = function() {
+        ${grid.vue_component}.computed.deleteSelectedDisabled = function() {
             if (this.deleteSelectedSubmitting) {
                 return true
             }
@@ -575,7 +567,7 @@
             return false
         }
 
-        ${grid.component_studly}.methods.deleteSelectedSubmit = function() {
+        ${grid.vue_component}.methods.deleteSelectedSubmit = function() {
             let uuids = this.checkedRowUUIDs()
             if (!uuids.length) {
                 alert("You must first select one or more objects to disable.")
@@ -591,12 +583,12 @@
         }
     % endif
 
-    % if master.bulk_deletable and master.has_perm('bulk_delete'):
+    % if getattr(master, 'bulk_deletable', False) and master.has_perm('bulk_delete'):
 
-        ${grid.component_studly}Data.deleteResultsSubmitting = false
-        ${grid.component_studly}Data.deleteResultsText = "Delete Results"
+        ${grid.vue_component}Data.deleteResultsSubmitting = false
+        ${grid.vue_component}Data.deleteResultsText = "Delete Results"
 
-        ${grid.component_studly}.computed.deleteResultsDisabled = function() {
+        ${grid.vue_component}.computed.deleteResultsDisabled = function() {
             if (this.deleteResultsSubmitting) {
                 return true
             }
@@ -606,7 +598,7 @@
             return false
         }
 
-        ${grid.component_studly}.methods.deleteResultsSubmit = function() {
+        ${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
@@ -619,12 +611,12 @@
 
     % endif
 
-    % if master.mergeable and master.has_perm('merge'):
+    % if getattr(master, 'mergeable', False) and master.has_perm('merge'):
 
-        ${grid.component_studly}Data.mergeFormButtonText = "Merge 2 ${model_title_plural}"
-        ${grid.component_studly}Data.mergeFormSubmitting = false
+        ${grid.vue_component}Data.mergeFormButtonText = "Merge 2 ${model_title_plural}"
+        ${grid.vue_component}Data.mergeFormSubmitting = false
 
-        ${grid.component_studly}.methods.submitMergeForm = function() {
+        ${grid.vue_component}.methods.submitMergeForm = function() {
             this.mergeFormSubmitting = true
             this.mergeFormButtonText = "Working, please wait..."
         }
diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako
index fe44caa9..a61020f3 100644
--- a/tailbone/templates/master/view.mako
+++ b/tailbone/templates/master/view.mako
@@ -8,7 +8,7 @@
 </%def>
 
 <%def name="render_instance_header_title_extras()">
-  % if master.touchable and master.has_perm('touch'):
+  % if getattr(master, 'touchable', False) and master.has_perm('touch'):
       <b-button title="&quot;Touch&quot; this record to trigger sync"
                 @click="touchRecord()"
                 :disabled="touchSubmitting">
@@ -93,7 +93,7 @@
     ${parent.render_this_page()}
 
     ## render row grid
-    % if master.has_rows:
+    % if getattr(master, 'has_rows', False):
         <br />
         % if rows_title:
             <h4 class="block is-size-4">${rows_title}</h4>
@@ -241,7 +241,7 @@
 </%def>
 
 <%def name="render_this_page_template()">
-  % if master.has_rows:
+  % 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}
   % endif
@@ -318,7 +318,7 @@
   ${parent.modify_whole_page_vars()}
   <script type="text/javascript">
 
-    % if master.touchable and master.has_perm('touch'):
+    % if getattr(master, 'touchable', False) and master.has_perm('touch'):
 
         WholePageData.touchSubmitting = false
 
@@ -340,7 +340,7 @@
   ${parent.finalize_this_page_vars()}
   <script type="text/javascript">
 
-    % if master.has_rows:
+    % if getattr(master, 'has_rows', False):
         TailboneGrid.data = function() { return TailboneGridData }
         Vue.component('tailbone-grid', TailboneGrid)
     % endif
diff --git a/tailbone/templates/people/index.mako b/tailbone/templates/people/index.mako
index c819050a..9339dfd5 100644
--- a/tailbone/templates/people/index.mako
+++ b/tailbone/templates/people/index.mako
@@ -3,7 +3,7 @@
 
 <%def name="grid_tools()">
 
-  % if master.mergeable and master.has_perm('request_merge'):
+  % if getattr(master, 'mergeable', False) and master.has_perm('request_merge'):
       <b-button @click="showMergeRequest()"
                 icon-pack="fas"
                 icon-left="object-ungroup"
@@ -65,7 +65,7 @@
   ${parent.modify_this_page_vars()}
   <script type="text/javascript">
 
-    % if master.mergeable and master.has_perm('request_merge'):
+    % if getattr(master, 'mergeable', False) and master.has_perm('request_merge'):
 
         ${grid.component_studly}Data.mergeRequestShowDialog = false
         ${grid.component_studly}Data.mergeRequestRows = []
diff --git a/tailbone/templates/people/view.mako b/tailbone/templates/people/view.mako
index 184f2b91..d28d7558 100644
--- a/tailbone/templates/people/view.mako
+++ b/tailbone/templates/people/view.mako
@@ -9,7 +9,7 @@
 
 <%def name="render_form()">
   <div class="form">
-    <tailbone-form v-on:make-user="makeUser"></tailbone-form>
+    <${form.vue_tagname} v-on:make-user="makeUser"></${form.vue_tagname}>
   </div>
 </%def>
 
@@ -17,7 +17,7 @@
   ${parent.modify_this_page_vars()}
   <script type="text/javascript">
 
-    TailboneForm.methods.clickMakeUser = function(event) {
+    ${form.vue_component}.methods.clickMakeUser = function(event) {
         this.$emit('make-user')
     }
 
diff --git a/tailbone/templates/principal/find_by_perm.mako b/tailbone/templates/principal/find_by_perm.mako
index 1a0a4b7d..2ea289c8 100644
--- a/tailbone/templates/principal/find_by_perm.mako
+++ b/tailbone/templates/principal/find_by_perm.mako
@@ -15,7 +15,7 @@
   <script type="text/x-template" id="find-principals-template">
     <div>
 
-      ${h.form(request.current_route_url(), method='GET', **{'@submit': 'formSubmitting = true'})}
+      ${h.form(request.url, method='GET', **{'@submit': 'formSubmitting = true'})}
         <div style="margin-left: 10rem; max-width: 50%;">
 
           ${h.hidden('permission_group', **{':value': 'selectedGroup'})}
@@ -63,7 +63,7 @@
           <b-field horizontal>
             <div class="buttons" style="margin-top: 1rem;">
               <once-button tag="a"
-                           href="${request.current_route_url(_query=None)}"
+                           href="${request.path_url}"
                            text="Reset Form">
               </once-button>
               <b-button type="is-primary"
diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako
index b0e43a37..f06b45f9 100644
--- a/tailbone/templates/themes/butterball/base.mako
+++ b/tailbone/templates/themes/butterball/base.mako
@@ -686,7 +686,7 @@
                       <h1 class="title">
                         ${index_title}
                       </h1>
-                      % if master.creatable and master.show_create_link and master.has_perm('create'):
+                      % if master.creatable and getattr(master, 'show_create_link', True) and master.has_perm('create'):
                           <once-button type="is-primary"
                                        tag="a" href="${url('{}.create'.format(route_prefix))}"
                                        icon-left="plus"
@@ -712,7 +712,7 @@
                           <h1 class="title">
                             ${h.link_to(instance_title, instance_url)}
                           </h1>
-                      % elif master.creatable and master.show_create_link and master.has_perm('create'):
+                      % elif master.creatable and getattr(master, 'show_create_link', True) and master.has_perm('create'):
                           % if not request.matched_route.name.endswith('.create'):
                               <once-button type="is-primary"
                                            tag="a" href="${url('{}.create'.format(route_prefix))}"
@@ -966,23 +966,23 @@
 </%def>
 
 <%def name="render_crud_header_buttons()">
-  % if master and master.viewing and not master.cloning:
+% if master and master.viewing and not getattr(master, 'cloning', False):
       ## TODO: is there a better way to check if viewing parent?
       % if parent_instance is Undefined:
           % if master.editable and instance_editable and master.has_perm('edit'):
-              <once-button tag="a" href="${action_url('edit', instance)}"
+              <once-button tag="a" href="${master.get_action_url('edit', instance)}"
                            icon-left="edit"
                            text="Edit This">
               </once-button>
           % endif
-          % if not master.cloning and master.cloneable and master.has_perm('clone'):
-              <once-button tag="a" href="${action_url('clone', instance)}"
+          % if not getattr(master, 'cloning', False) and getattr(master, 'cloneable', False) and master.has_perm('clone'):
+              <once-button tag="a" href="${master.get_action_url('clone', instance)}"
                            icon-left="object-ungroup"
                            text="Clone This">
               </once-button>
           % endif
           % if master.deletable and instance_deletable and master.has_perm('delete'):
-              <once-button tag="a" href="${action_url('delete', instance)}"
+              <once-button tag="a" href="${master.get_action_url('delete', instance)}"
                            type="is-danger"
                            icon-left="trash"
                            text="Delete This">
@@ -991,7 +991,7 @@
       % else:
           ## viewing row
           % if instance_deletable and master.has_perm('delete_row'):
-              <once-button tag="a" href="${action_url('delete', instance)}"
+              <once-button tag="a" href="${master.get_action_url('delete', instance)}"
                            type="is-danger"
                            icon-left="trash"
                            text="Delete This">
@@ -1000,13 +1000,13 @@
       % endif
   % elif master and master.editing:
       % if master.viewable and master.has_perm('view'):
-          <once-button tag="a" href="${action_url('view', instance)}"
+          <once-button tag="a" href="${master.get_action_url('view', instance)}"
                        icon-left="eye"
                        text="View This">
           </once-button>
       % endif
       % if master.deletable and instance_deletable and master.has_perm('delete'):
-          <once-button tag="a" href="${action_url('delete', instance)}"
+          <once-button tag="a" href="${master.get_action_url('delete', instance)}"
                        type="is-danger"
                        icon-left="trash"
                        text="Delete This">
@@ -1014,20 +1014,20 @@
       % endif
   % elif master and master.deleting:
       % if master.viewable and master.has_perm('view'):
-          <once-button tag="a" href="${action_url('view', instance)}"
+          <once-button tag="a" href="${master.get_action_url('view', instance)}"
                        icon-left="eye"
                        text="View This">
           </once-button>
       % endif
       % if master.editable and instance_editable and master.has_perm('edit'):
-          <once-button tag="a" href="${action_url('edit', instance)}"
+          <once-button tag="a" href="${master.get_action_url('edit', instance)}"
                        icon-left="edit"
                        text="Edit This">
           </once-button>
       % endif
-  % elif master and master.cloning:
+  % elif master and getattr(master, 'cloning', False):
       % if master.viewable and master.has_perm('view'):
-          <once-button tag="a" href="${action_url('view', instance)}"
+          <once-button tag="a" href="${master.get_action_url('view', instance)}"
                        icon-left="eye"
                        text="View This">
           </once-button>
diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index 1e917902..f2d78b80 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -1366,7 +1366,7 @@ class MasterView(View):
                                                         txnid=txn.id)
 
         kwargs = {
-            'component': 'versions-grid',
+            'vue_tagname': 'versions-grid',
             'ajax_data_url': self.get_action_url('revisions_data', obj),
             'sortable': True,
             'default_sortkey': 'changed',
@@ -4421,7 +4421,7 @@ class MasterView(View):
             'request': self.request,
             'readonly': self.viewing,
             'model_class': getattr(self, 'model_class', None),
-            'action_url': self.request.current_route_url(_query=None),
+            'action_url': self.request.path_url,
             'assume_local_times': self.has_local_times,
             'route_prefix': route_prefix,
             'can_edit_help': self.can_edit_help(),
diff --git a/tailbone/views/principal.py b/tailbone/views/principal.py
index b053453d..bb799efc 100644
--- a/tailbone/views/principal.py
+++ b/tailbone/views/principal.py
@@ -54,7 +54,7 @@ class PrincipalMasterView(MasterView):
         View for finding all users who have been granted a given permission
         """
         permissions = copy.deepcopy(
-            self.request.registry.settings.get('tailbone_permissions', {}))
+            self.request.registry.settings.get('wutta_permissions', {}))
 
         # sort groups, and permissions for each group, for UI's sake
         sorted_perms = sorted(permissions.items(), key=self.perm_sortkey)
diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py
index 09633c6e..b34b3673 100644
--- a/tailbone/views/roles.py
+++ b/tailbone/views/roles.py
@@ -287,8 +287,8 @@ class RoleView(PrincipalMasterView):
         if the current user is an admin; otherwise it will be the "subset" of
         permissions which the current user has been granted.
         """
-        # fetch full set of permissions registered in the app
-        permissions = self.request.registry.settings.get('tailbone_permissions', {})
+        # get all known permissions from settings cache
+        permissions = self.request.registry.settings.get('wutta_permissions', {})
 
         # admin user gets to manage all permissions
         if self.request.is_admin:
diff --git a/tailbone/views/users.py b/tailbone/views/users.py
index 1012575a..f8bcb1b8 100644
--- a/tailbone/views/users.py
+++ b/tailbone/views/users.py
@@ -276,7 +276,7 @@ class UserView(PrincipalMasterView):
         # fs.confirm_password.attrs(autocomplete='new-password')
 
         if self.viewing:
-            permissions = self.request.registry.settings.get('tailbone_permissions', {})
+            permissions = self.request.registry.settings.get('wutta_permissions', {})
             f.set_renderer('permissions', PermissionsRenderer(request=self.request,
                                                               permissions=permissions,
                                                               include_anonymous=True,
diff --git a/tests/__init__.py b/tests/__init__.py
index 7dec63f0..40d8071f 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -12,9 +12,6 @@ class TestCase(unittest.TestCase):
 
     def setUp(self):
         self.config = testing.setUp()
-        # TODO: this probably shouldn't (need to) be here
-        self.config.add_directive('add_tailbone_permission_group', 'tailbone.auth.add_permission_group')
-        self.config.add_directive('add_tailbone_permission', 'tailbone.auth.add_permission')
 
     def tearDown(self):
         testing.tearDown()
diff --git a/tests/forms/__init__.py b/tests/forms/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/forms/test_core.py b/tests/forms/test_core.py
new file mode 100644
index 00000000..894d2302
--- /dev/null
+++ b/tests/forms/test_core.py
@@ -0,0 +1,153 @@
+# -*- coding: utf-8; -*-
+
+from unittest.mock import patch
+
+import deform
+from pyramid import testing
+
+from tailbone.forms import core as mod
+from tests.util import WebTestCase
+
+
+class TestForm(WebTestCase):
+
+    def setUp(self):
+        self.setup_web()
+        self.config.setdefault('rattail.web.menus.handler_spec', 'tests.util:NullMenuHandler')
+
+    def make_form(self, **kwargs):
+        kwargs.setdefault('request', self.request)
+        return mod.Form(**kwargs)
+
+    def test_basic(self):
+        form = self.make_form()
+        self.assertIsInstance(form, mod.Form)
+
+    def test_vue_tagname(self):
+
+        # default
+        form = self.make_form()
+        self.assertEqual(form.vue_tagname, 'tailbone-form')
+
+        # can override with param
+        form = self.make_form(vue_tagname='something-else')
+        self.assertEqual(form.vue_tagname, 'something-else')
+
+        # can still pass old param
+        form = self.make_form(component='legacy-name')
+        self.assertEqual(form.vue_tagname, 'legacy-name')
+
+    def test_vue_component(self):
+
+        # default
+        form = self.make_form()
+        self.assertEqual(form.vue_component, 'TailboneForm')
+
+        # can override with param
+        form = self.make_form(vue_tagname='something-else')
+        self.assertEqual(form.vue_component, 'SomethingElse')
+
+        # can still pass old param
+        form = self.make_form(component='legacy-name')
+        self.assertEqual(form.vue_component, 'LegacyName')
+
+    def test_component(self):
+
+        # default
+        form = self.make_form()
+        self.assertEqual(form.component, 'tailbone-form')
+
+        # can override with param
+        form = self.make_form(vue_tagname='something-else')
+        self.assertEqual(form.component, 'something-else')
+
+        # can still pass old param
+        form = self.make_form(component='legacy-name')
+        self.assertEqual(form.component, 'legacy-name')
+
+    def test_component_studly(self):
+
+        # default
+        form = self.make_form()
+        self.assertEqual(form.component_studly, 'TailboneForm')
+
+        # can override with param
+        form = self.make_form(vue_tagname='something-else')
+        self.assertEqual(form.component_studly, 'SomethingElse')
+
+        # can still pass old param
+        form = self.make_form(component='legacy-name')
+        self.assertEqual(form.component_studly, 'LegacyName')
+
+    def test_button_label_submit(self):
+        form = self.make_form()
+
+        # default
+        self.assertEqual(form.button_label_submit, "Submit")
+
+        # can set submit_label
+        with patch.object(form, 'submit_label', new="Submit Label", create=True):
+            self.assertEqual(form.button_label_submit, "Submit Label")
+
+        # can set save_label
+        with patch.object(form, 'save_label', new="Save Label"):
+            self.assertEqual(form.button_label_submit, "Save Label")
+
+        # can set button_label_submit
+        form.button_label_submit = "New Label"
+        self.assertEqual(form.button_label_submit, "New Label")
+
+    def test_get_deform(self):
+        model = self.app.model
+
+        # sanity check
+        form = self.make_form(model_class=model.Setting)
+        dform = form.get_deform()
+        self.assertIsInstance(dform, deform.Form)
+
+    def test_render_vue_tag(self):
+        model = self.app.model
+
+        # sanity check
+        form = self.make_form(model_class=model.Setting)
+        html = form.render_vue_tag()
+        self.assertIn('<tailbone-form', html)
+
+    def test_render_vue_template(self):
+        self.pyramid_config.include('tailbone.views.common')
+        model = self.app.model
+
+        # sanity check
+        form = self.make_form(model_class=model.Setting)
+        html = form.render_vue_template(session=self.session)
+        self.assertIn('<form ', html)
+
+    def test_get_vue_field_value(self):
+        model = self.app.model
+        form = self.make_form(model_class=model.Setting)
+
+        # TODO: yikes what a hack (?)
+        dform = form.get_deform()
+        dform.set_appstruct({'name': 'foo', 'value': 'bar'})
+
+        # null for missing field
+        value = form.get_vue_field_value('doesnotexist')
+        self.assertIsNone(value)
+
+        # normal value is returned
+        value = form.get_vue_field_value('name')
+        self.assertEqual(value, 'foo')
+
+        # but not if we remove field from deform
+        # TODO: what is the use case here again?
+        dform.children.remove(dform['name'])
+        value = form.get_vue_field_value('name')
+        self.assertIsNone(value)
+
+    def test_render_vue_field(self):
+        model = self.app.model
+
+        # sanity check
+        form = self.make_form(model_class=model.Setting)
+        html = form.render_vue_field('name', session=self.session)
+        self.assertIn('<b-field ', html)
diff --git a/tests/grids/__init__.py b/tests/grids/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/grids/test_core.py b/tests/grids/test_core.py
new file mode 100644
index 00000000..e6f9d675
--- /dev/null
+++ b/tests/grids/test_core.py
@@ -0,0 +1,139 @@
+# -*- coding: utf-8; -*-
+
+from unittest.mock import MagicMock
+
+from tailbone.grids import core as mod
+from tests.util import WebTestCase
+
+
+class TestGrid(WebTestCase):
+
+    def setUp(self):
+        self.setup_web()
+        self.config.setdefault('rattail.web.menus.handler_spec', 'tests.util:NullMenuHandler')
+
+    def make_grid(self, key, data=[], **kwargs):
+        kwargs.setdefault('request', self.request)
+        return mod.Grid(key, data=data, **kwargs)
+
+    def test_basic(self):
+        grid = self.make_grid('foo')
+        self.assertIsInstance(grid, mod.Grid)
+
+    def test_vue_tagname(self):
+
+        # default
+        grid = self.make_grid('foo')
+        self.assertEqual(grid.vue_tagname, 'tailbone-grid')
+
+        # can override with param
+        grid = self.make_grid('foo', vue_tagname='something-else')
+        self.assertEqual(grid.vue_tagname, 'something-else')
+
+        # can still pass old param
+        grid = self.make_grid('foo', component='legacy-name')
+        self.assertEqual(grid.vue_tagname, 'legacy-name')
+
+    def test_vue_component(self):
+
+        # default
+        grid = self.make_grid('foo')
+        self.assertEqual(grid.vue_component, 'TailboneGrid')
+
+        # can override with param
+        grid = self.make_grid('foo', vue_tagname='something-else')
+        self.assertEqual(grid.vue_component, 'SomethingElse')
+
+        # can still pass old param
+        grid = self.make_grid('foo', component='legacy-name')
+        self.assertEqual(grid.vue_component, 'LegacyName')
+
+    def test_component(self):
+
+        # default
+        grid = self.make_grid('foo')
+        self.assertEqual(grid.component, 'tailbone-grid')
+
+        # can override with param
+        grid = self.make_grid('foo', vue_tagname='something-else')
+        self.assertEqual(grid.component, 'something-else')
+
+        # can still pass old param
+        grid = self.make_grid('foo', component='legacy-name')
+        self.assertEqual(grid.component, 'legacy-name')
+
+    def test_component_studly(self):
+
+        # default
+        grid = self.make_grid('foo')
+        self.assertEqual(grid.component_studly, 'TailboneGrid')
+
+        # can override with param
+        grid = self.make_grid('foo', vue_tagname='something-else')
+        self.assertEqual(grid.component_studly, 'SomethingElse')
+
+        # can still pass old param
+        grid = self.make_grid('foo', component='legacy-name')
+        self.assertEqual(grid.component_studly, 'LegacyName')
+
+    def test_actions(self):
+
+        # default
+        grid = self.make_grid('foo')
+        self.assertEqual(grid.actions, [])
+
+        # main actions
+        grid = self.make_grid('foo', main_actions=['foo'])
+        self.assertEqual(grid.actions, ['foo'])
+
+        # more actions
+        grid = self.make_grid('foo', main_actions=['foo'], more_actions=['bar'])
+        self.assertEqual(grid.actions, ['foo', 'bar'])
+
+    def test_render_vue_tag(self):
+        model = self.app.model
+
+        # standard
+        grid = self.make_grid('settings', model_class=model.Setting)
+        html = grid.render_vue_tag()
+        self.assertIn('<tailbone-grid', html)
+        self.assertNotIn('@deleteActionClicked', html)
+
+        # with delete hook
+        master = MagicMock(deletable=True, delete_confirm='simple')
+        master.has_perm.return_value = True
+        grid = self.make_grid('settings', model_class=model.Setting)
+        html = grid.render_vue_tag(master=master)
+        self.assertIn('<tailbone-grid', html)
+        self.assertIn('@deleteActionClicked', html)
+
+    def test_render_vue_template(self):
+        # self.pyramid_config.include('tailbone.views.common')
+        model = self.app.model
+
+        # sanity check
+        grid = self.make_grid('settings', model_class=model.Setting)
+        html = grid.render_vue_template(session=self.session)
+        self.assertIn('<b-table', html)
+
+    def test_get_vue_columns(self):
+        model = self.app.model
+
+        # sanity check
+        grid = self.make_grid('settings', model_class=model.Setting)
+        columns = grid.get_vue_columns()
+        self.assertEqual(len(columns), 2)
+        self.assertEqual(columns[0]['field'], 'name')
+        self.assertEqual(columns[1]['field'], 'value')
+
+    def test_get_vue_data(self):
+        model = self.app.model
+
+        # sanity check
+        grid = self.make_grid('settings', model_class=model.Setting)
+        data = grid.get_vue_data()
+        self.assertEqual(data, [])
+
+        # calling again returns same data
+        data2 = grid.get_vue_data()
+        self.assertIs(data2, data)
diff --git a/tests/test_app.py b/tests/test_app.py
index 2523c424..e16461ba 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -3,14 +3,14 @@
 import os
 from unittest import TestCase
 
-from sqlalchemy import create_engine
+from pyramid.config import Configurator
+
+from wuttjamaican.testing import FileConfigTestCase
 
-from rattail.config import RattailConfig
 from rattail.exceptions import ConfigurationError
-from rattail.db import Session as RattailSession
-
-from tailbone import app
-from tailbone.db import Session as TailboneSession
+from rattail.config import RattailConfig
+from tailbone import app as mod
+from tests.util import DataTestCase
 
 
 class TestRattailConfig(TestCase):
@@ -18,15 +18,34 @@ class TestRattailConfig(TestCase):
     config_path = os.path.abspath(
         os.path.join(os.path.dirname(__file__), 'data', 'tailbone.conf'))
 
-    def tearDown(self):
-        # may or may not be necessary depending on test
-        TailboneSession.remove()
-
     def test_settings_arg_must_include_config_path_by_default(self):
         # error raised if path not provided
-        self.assertRaises(ConfigurationError, app.make_rattail_config, {})
+        self.assertRaises(ConfigurationError, mod.make_rattail_config, {})
         # get a config object if path provided
-        result = app.make_rattail_config({'rattail.config': self.config_path})
+        result = mod.make_rattail_config({'rattail.config': self.config_path})
         # nb. cannot test isinstance(RattailConfig) b/c now uses wrapper!
         self.assertIsNotNone(result)
         self.assertTrue(hasattr(result, 'get'))
+
+
+class TestMakePyramidConfig(DataTestCase):
+
+    def make_config(self):
+        myconf = self.write_file('web.conf', """
+[rattail.db]
+default.url = sqlite://
+""")
+
+        self.settings = {
+            'rattail.config': myconf,
+            'mako.directories': 'tailbone:templates',
+        }
+        return mod.make_rattail_config(self.settings)
+
+    def test_basic(self):
+        model = self.app.model
+        model.Base.metadata.create_all(bind=self.config.appdb_engine)
+
+        # sanity check
+        pyramid_config = mod.make_pyramid_config(self.settings)
+        self.assertIsInstance(pyramid_config, Configurator)
diff --git a/tests/test_auth.py b/tests/test_auth.py
new file mode 100644
index 00000000..4519e152
--- /dev/null
+++ b/tests/test_auth.py
@@ -0,0 +1,3 @@
+# -*- coding: utf-8; -*-
+
+from tailbone import auth as mod
diff --git a/tests/test_config.py b/tests/test_config.py
new file mode 100644
index 00000000..0cd1938c
--- /dev/null
+++ b/tests/test_config.py
@@ -0,0 +1,12 @@
+# -*- coding: utf-8; -*-
+
+from tailbone import config as mod
+from tests.util import DataTestCase
+
+
+class TestConfigExtension(DataTestCase):
+
+    def test_basic(self):
+        # sanity / coverage check
+        ext = mod.ConfigExtension()
+        ext.configure(self.config)
diff --git a/tests/test_subscribers.py b/tests/test_subscribers.py
new file mode 100644
index 00000000..81bc2869
--- /dev/null
+++ b/tests/test_subscribers.py
@@ -0,0 +1,58 @@
+# -*- coding: utf-8; -*-
+
+from unittest.mock import MagicMock
+
+from pyramid import testing
+
+from tailbone import subscribers as mod
+from tests.util import DataTestCase
+
+
+class TestNewRequest(DataTestCase):
+
+    def setUp(self):
+        self.setup_db()
+        self.request = self.make_request()
+        self.pyramid_config = testing.setUp(request=self.request, settings={
+            'wutta_config': self.config,
+        })
+
+    def tearDown(self):
+        self.teardown_db()
+        testing.tearDown()
+
+    def make_request(self, **kwargs):
+        return testing.DummyRequest(**kwargs)
+
+    def make_event(self):
+        return MagicMock(request=self.request)
+
+    def test_continuum_remote_addr(self):
+        event = self.make_event()
+
+        # nothing happens
+        mod.new_request(event, session=self.session)
+        self.assertFalse(hasattr(self.session, 'continuum_remote_addr'))
+
+        # unless request has client_addr
+        self.request.client_addr = '127.0.0.1'
+        mod.new_request(event, session=self.session)
+        self.assertEqual(self.session.continuum_remote_addr, '127.0.0.1')
+
+    def test_register_component(self):
+        event = self.make_event()
+
+        # function added
+        self.assertFalse(hasattr(self.request, 'register_component'))
+        mod.new_request(event, session=self.session)
+        self.assertTrue(callable(self.request.register_component))
+
+        # call function
+        self.request.register_component('tailbone-datepicker', 'TailboneDatepicker')
+        self.assertEqual(self.request._tailbone_registered_components,
+                         {'tailbone-datepicker': 'TailboneDatepicker'})
+
+        # duplicate registration ignored
+        self.request.register_component('tailbone-datepicker', 'TailboneDatepicker')
+        self.assertEqual(self.request._tailbone_registered_components,
+                         {'tailbone-datepicker': 'TailboneDatepicker'})
diff --git a/tests/util.py b/tests/util.py
new file mode 100644
index 00000000..98d89ce0
--- /dev/null
+++ b/tests/util.py
@@ -0,0 +1,75 @@
+# -*- coding: utf-8; -*-
+
+from unittest.mock import MagicMock
+
+from pyramid import testing
+
+from tailbone import subscribers
+from wuttaweb.menus import MenuHandler
+# from wuttaweb.subscribers import new_request_set_user
+from rattail.testing import DataTestCase
+
+
+class WebTestCase(DataTestCase):
+    """
+    Base class for test suites requiring a full (typical) web app.
+    """
+
+    def setUp(self):
+        self.setup_web()
+
+    def setup_web(self):
+        self.setup_db()
+        self.request = self.make_request()
+        self.pyramid_config = testing.setUp(request=self.request, settings={
+            'wutta_config': self.config,
+            'rattail_config': self.config,
+            'mako.directories': ['tailbone:templates'],
+            # 'pyramid_deform.template_search_path': 'wuttaweb:templates/deform',
+        })
+
+        # init web
+        # self.pyramid_config.include('pyramid_deform')
+        self.pyramid_config.include('pyramid_mako')
+        self.pyramid_config.add_directive('add_wutta_permission_group',
+                                          'wuttaweb.auth.add_permission_group')
+        self.pyramid_config.add_directive('add_wutta_permission',
+                                          'wuttaweb.auth.add_permission')
+        self.pyramid_config.add_directive('add_tailbone_permission_group',
+                                          'wuttaweb.auth.add_permission_group')
+        self.pyramid_config.add_directive('add_tailbone_permission',
+                                          'wuttaweb.auth.add_permission')
+        self.pyramid_config.add_directive('add_tailbone_index_page',
+                                          'tailbone.app.add_index_page')
+        self.pyramid_config.add_directive('add_tailbone_model_view',
+                                          'tailbone.app.add_model_view')
+        self.pyramid_config.add_subscriber('tailbone.subscribers.before_render',
+                                           'pyramid.events.BeforeRender')
+        self.pyramid_config.include('tailbone.static')
+
+        # setup new request w/ anonymous user
+        event = MagicMock(request=self.request)
+        subscribers.new_request(event, session=self.session)
+        # def user_getter(request, **kwargs): pass
+        # new_request_set_user(event, db_session=self.session,
+        #                      user_getter=user_getter)
+
+    def tearDown(self):
+        self.teardown_web()
+
+    def teardown_web(self):
+        testing.tearDown()
+        self.teardown_db()
+
+    def make_request(self, **kwargs):
+        kwargs.setdefault('rattail_config', self.config)
+        # kwargs.setdefault('wutta_config', self.config)
+        return testing.DummyRequest(**kwargs)
+
+
+class NullMenuHandler(MenuHandler):
+    """
+    Dummy menu handler for testing.
+    """
+    def make_menus(self, request, **kwargs):
+        return []
diff --git a/tests/views/test_master.py b/tests/views/test_master.py
new file mode 100644
index 00000000..19321496
--- /dev/null
+++ b/tests/views/test_master.py
@@ -0,0 +1,26 @@
+# -*- coding: utf-8; -*-
+
+from unittest.mock import patch
+
+from tailbone.views import master as mod
+from tests.util import WebTestCase
+
+
+class TestMasterView(WebTestCase):
+
+    def make_view(self):
+        return mod.MasterView(self.request)
+
+    def test_make_form_kwargs(self):
+        self.pyramid_config.add_route('settings.view', '/settings/{name}')
+        model = self.app.model
+        setting = model.Setting(name='foo', value='bar')
+        self.session.add(setting)
+        self.session.commit()
+        with patch.multiple(mod.MasterView, create=True,
+                            model_class=model.Setting):
+            view = self.make_view()
+
+            # sanity / coverage check
+            kw = view.make_form_kwargs(model_instance=setting)
+            self.assertIsNotNone(kw['action_url'])
diff --git a/tests/views/test_principal.py b/tests/views/test_principal.py
new file mode 100644
index 00000000..2b31531c
--- /dev/null
+++ b/tests/views/test_principal.py
@@ -0,0 +1,29 @@
+# -*- coding: utf-8; -*-
+
+from unittest.mock import patch, MagicMock
+
+from tailbone.views import principal as mod
+from tests.util import WebTestCase
+
+
+class TestPrincipalMasterView(WebTestCase):
+
+    def make_view(self):
+        return mod.PrincipalMasterView(self.request)
+
+    def test_find_by_perm(self):
+        model = self.app.model
+        self.config.setdefault('rattail.web.menus.handler_spec', 'tests.util:NullMenuHandler')
+        self.pyramid_config.include('tailbone.views.common')
+        self.pyramid_config.include('tailbone.views.auth')
+        self.pyramid_config.add_route('roles', '/roles/')
+        with patch.multiple(mod.PrincipalMasterView, create=True,
+                            model_class=model.Role,
+                            get_help_url=MagicMock(return_value=None),
+                            get_help_markdown=MagicMock(return_value=None),
+                            can_edit_help=MagicMock(return_value=False)):
+
+            # sanity / coverage check
+            view = self.make_view()
+            response = view.find_by_perm()
+            self.assertEqual(response.status_code, 200)
diff --git a/tests/views/test_roles.py b/tests/views/test_roles.py
new file mode 100644
index 00000000..0cdc724e
--- /dev/null
+++ b/tests/views/test_roles.py
@@ -0,0 +1,80 @@
+# -*- coding: utf-8; -*-
+
+from unittest.mock import patch
+
+from tailbone.views import roles as mod
+from tests.util import WebTestCase
+
+
+class TestRoleView(WebTestCase):
+
+    def make_view(self):
+        return mod.RoleView(self.request)
+
+    def test_includeme(self):
+        self.pyramid_config.include('tailbone.views.roles')
+
+    def get_permissions(self):
+        return {
+            'widgets': {
+                'label': "Widgets",
+                'perms': {
+                    'widgets.list': {
+                        'label': "List widgets",
+                    },
+                    'widgets.polish': {
+                        'label': "Polish the widgets",
+                    },
+                    'widgets.view': {
+                        'label': "View widget",
+                    },
+                },
+            },
+        }
+
+    def test_get_available_permissions(self):
+        model = self.app.model
+        auth = self.app.get_auth_handler()
+        blokes = model.Role(name="Blokes")
+        auth.grant_permission(blokes, 'widgets.list')
+        self.session.add(blokes)
+        barney = model.User(username='barney')
+        barney.roles.append(blokes)
+        self.session.add(barney)
+        self.session.commit()
+        view = self.make_view()
+        all_perms = self.get_permissions()
+        self.request.registry.settings['wutta_permissions'] = all_perms
+
+        def has_perm(perm):
+            if perm == 'widgets.list':
+                return True
+            return False
+
+        with patch.object(self.request, 'has_perm', new=has_perm, create=True):
+
+            # sanity check; current request has 1 perm
+            self.assertTrue(self.request.has_perm('widgets.list'))
+            self.assertFalse(self.request.has_perm('widgets.polish'))
+            self.assertFalse(self.request.has_perm('widgets.view'))
+
+            # when editing, user sees only the 1 perm
+            with patch.object(view, 'editing', new=True):
+                perms = view.get_available_permissions()
+                self.assertEqual(list(perms), ['widgets'])
+                self.assertEqual(list(perms['widgets']['perms']), ['widgets.list'])
+
+            # but when viewing, same user sees all perms
+            with patch.object(view, 'viewing', new=True):
+                perms = view.get_available_permissions()
+                self.assertEqual(list(perms), ['widgets'])
+                self.assertEqual(list(perms['widgets']['perms']),
+                                 ['widgets.list', 'widgets.polish', 'widgets.view'])
+
+            # also, when admin user is editing, sees all perms
+            self.request.is_admin = True
+            with patch.object(view, 'editing', new=True):
+                perms = view.get_available_permissions()
+                self.assertEqual(list(perms), ['widgets'])
+                self.assertEqual(list(perms['widgets']['perms']),
+                                 ['widgets.list', 'widgets.polish', 'widgets.view'])
diff --git a/tests/views/test_users.py b/tests/views/test_users.py
new file mode 100644
index 00000000..4b94caf2
--- /dev/null
+++ b/tests/views/test_users.py
@@ -0,0 +1,33 @@
+# -*- coding: utf-8; -*-
+
+from unittest.mock import patch, MagicMock
+
+from tailbone.views import users as mod
+from tailbone.views.principal import PermissionsRenderer
+from tests.util import WebTestCase
+
+
+class TestUserView(WebTestCase):
+
+    def make_view(self):
+        return mod.UserView(self.request)
+
+    def test_includeme(self):
+        self.pyramid_config.include('tailbone.views.users')
+
+    def test_configure_form(self):
+        self.pyramid_config.include('tailbone.views.users')
+        model = self.app.model
+        barney = model.User(username='barney')
+        self.session.add(barney)
+        self.session.commit()
+        view = self.make_view()
+
+        # must use mock configure when making form
+        def configure(form): pass
+        form = view.make_form(instance=barney, configure=configure)
+
+        with patch.object(view, 'viewing', new=True):
+            self.assertNotIn('permissions', form.renderers)
+            view.configure_form(form)
+            self.assertIsInstance(form.renderers['permissions'], PermissionsRenderer)

From dd176a5e9e43752ef87e16440e74f45ce303f1f3 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 15 Aug 2024 16:05:53 -0500
Subject: [PATCH 433/542] feat: add first wutta-based master, for PersonView

still opt-in-only at this point, the traditional tailbone-native
master is used by default.

new wutta master is not feature complete yet.  but at least things
seem to render and form posts work okay..

when enabled, this uses a "completely" wutta-based stack for the view,
grid and forms.  but the underlying DB is of course rattail, and the
templates are still traditional/tailbone.
---
 tailbone/views/people.py         |   6 +-
 tailbone/views/wutta/__init__.py |   0
 tailbone/views/wutta/people.py   | 102 +++++++++++++++++++++++++++++++
 tests/util.py                    |   2 +
 tests/views/test_people.py       |  17 ++++++
 tests/views/wutta/__init__.py    |   0
 tests/views/wutta/test_people.py |  47 ++++++++++++++
 7 files changed, 173 insertions(+), 1 deletion(-)
 create mode 100644 tailbone/views/wutta/__init__.py
 create mode 100644 tailbone/views/wutta/people.py
 create mode 100644 tests/views/test_people.py
 create mode 100644 tests/views/wutta/__init__.py
 create mode 100644 tests/views/wutta/test_people.py

diff --git a/tailbone/views/people.py b/tailbone/views/people.py
index 9b28b94d..94c85821 100644
--- a/tailbone/views/people.py
+++ b/tailbone/views/people.py
@@ -2187,4 +2187,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.people')
+    else:
+        defaults(config)
diff --git a/tailbone/views/wutta/__init__.py b/tailbone/views/wutta/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tailbone/views/wutta/people.py b/tailbone/views/wutta/people.py
new file mode 100644
index 00000000..44cc26d9
--- /dev/null
+++ b/tailbone/views/wutta/people.py
@@ -0,0 +1,102 @@
+# -*- 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/>.
+#
+################################################################################
+"""
+Person Views
+"""
+
+from rattail.db.model import Person
+
+from wuttaweb.views import people as wutta
+from tailbone.views import people as tailbone
+from tailbone.db import Session
+
+
+class PersonView(wutta.PersonView):
+    """
+    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 = Person
+    Session = Session
+
+    # labels = {
+    #     'display_name': "Full Name",
+    # }
+
+    grid_columns = [
+        'display_name',
+        'first_name',
+        'last_name',
+        'phone',
+        'email',
+        'merge_requested',
+    ]
+
+    form_fields = [
+        'first_name',
+        'middle_name',
+        'last_name',
+        'display_name',
+        'default_phone',
+        'default_email',
+        # 'address',
+        # 'employee',
+        'customers',
+        # 'members',
+        'users',
+    ]
+
+    def get_query(self, session=None):
+        """ """
+        model = self.app.model
+        session = session or self.Session()
+        return session.query(model.Person)\
+                      .order_by(model.Person.display_name)
+
+    def configure_form(self, f):
+        """ """
+        super().configure_form(f)
+
+        # default_phone
+        f.set_required('default_phone', False)
+
+        # default_email
+        f.set_required('default_email', False)
+
+        # customers
+        if self.creating or self.editing:
+            f.remove('customers')
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    kwargs.setdefault('PersonView', base['PersonView'])
+    tailbone.defaults(config, **kwargs)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tests/util.py b/tests/util.py
index 98d89ce0..3aa04f5e 100644
--- a/tests/util.py
+++ b/tests/util.py
@@ -43,6 +43,8 @@ class WebTestCase(DataTestCase):
                                           'tailbone.app.add_index_page')
         self.pyramid_config.add_directive('add_tailbone_model_view',
                                           'tailbone.app.add_model_view')
+        self.pyramid_config.add_directive('add_tailbone_config_page',
+                                          'tailbone.app.add_config_page')
         self.pyramid_config.add_subscriber('tailbone.subscribers.before_render',
                                            'pyramid.events.BeforeRender')
         self.pyramid_config.include('tailbone.static')
diff --git a/tests/views/test_people.py b/tests/views/test_people.py
new file mode 100644
index 00000000..f85577e7
--- /dev/null
+++ b/tests/views/test_people.py
@@ -0,0 +1,17 @@
+# -*- coding: utf-8; -*-
+
+from tailbone.views import users as mod
+from tests.util import WebTestCase
+
+
+class TestPersonView(WebTestCase):
+
+    def make_view(self):
+        return mod.PersonView(self.request)
+
+    def test_includeme(self):
+        self.pyramid_config.include('tailbone.views.people')
+
+    def test_includeme_wutta(self):
+        self.config.setdefault('tailbone.use_wutta_views', 'true')
+        self.pyramid_config.include('tailbone.views.people')
diff --git a/tests/views/wutta/__init__.py b/tests/views/wutta/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/views/wutta/test_people.py b/tests/views/wutta/test_people.py
new file mode 100644
index 00000000..7795d641
--- /dev/null
+++ b/tests/views/wutta/test_people.py
@@ -0,0 +1,47 @@
+# -*- coding: utf-8; -*-
+
+from unittest.mock import patch
+
+from sqlalchemy import orm
+
+from tailbone.views.wutta import people as mod
+from tests.util import WebTestCase
+
+
+class TestPersonView(WebTestCase):
+
+    def make_view(self):
+        return mod.PersonView(self.request)
+
+    def test_includeme(self):
+        self.pyramid_config.include('tailbone.views.wutta.people')
+
+    def test_get_query(self):
+        view = self.make_view()
+
+        # sanity / coverage check
+        query = view.get_query(session=self.session)
+        self.assertIsInstance(query, orm.Query)
+
+    def test_configure_form(self):
+        model = self.app.model
+        barney = model.User(username='barney')
+        self.session.add(barney)
+        self.session.commit()
+        view = self.make_view()
+
+        # customers field remains when viewing
+        with patch.object(view, 'viewing', new=True):
+            form = view.make_form(model_instance=barney,
+                                  fields=view.get_form_fields())
+            self.assertIn('customers', form.fields)
+            view.configure_form(form)
+            self.assertIn('customers', form)
+
+        # customers field removed when editing
+        with patch.object(view, 'editing', new=True):
+            form = view.make_form(model_instance=barney,
+                                  fields=view.get_form_fields())
+            self.assertIn('customers', form.fields)
+            view.configure_form(form)
+            self.assertNotIn('customers', form)

From bab09e3fe73af86f3ecb9501ac35f26a270ff35a Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 15 Aug 2024 16:22:35 -0500
Subject: [PATCH 434/542] =?UTF-8?q?bump:=20version=200.15.6=20=E2=86=92=20?=
 =?UTF-8?q?0.16.0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

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

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3836ff08..401c1b25 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.16.0 (2024-08-15)
+
+### Feat
+
+- add first wutta-based master, for PersonView
+- refactor forms/grids/views/templates per wuttaweb compat
+
 ## v0.15.6 (2024-08-13)
 
 ### Fix
diff --git a/pyproject.toml b/pyproject.toml
index e515a0d0..dc0887d4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.15.6"
+version = "0.16.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.2.0",
+        "WuttaWeb>=0.7.0",
         "zope.sqlalchemy>=1.5",
 ]
 

From 1cacfab2a63a5d9b6216a3d1173e9efbc7919848 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 15 Aug 2024 18:44:14 -0500
Subject: [PATCH 435/542] fix: tweak template for `people/view_profile` per
 wutta compat

wutta has the view defined but it returns minimal context
---
 tailbone/templates/people/view_profile.mako | 26 +++++++++++++++------
 1 file changed, 19 insertions(+), 7 deletions(-)

diff --git a/tailbone/templates/people/view_profile.mako b/tailbone/templates/people/view_profile.mako
index 8044f7c6..cdb6c5cc 100644
--- a/tailbone/templates/people/view_profile.mako
+++ b/tailbone/templates/people/view_profile.mako
@@ -15,7 +15,7 @@
 </%def>
 
 <%def name="content_title()">
-  ${dynamic_content_title}
+  ${dynamic_content_title or str(instance)}
 </%def>
 
 <%def name="render_instance_header_title_extras()">
@@ -1008,7 +1008,7 @@
             <div style="display: flex; justify-content: space-between; width: 100%;">
               <div style="flex-grow: 1;">
 
-                <b-field horizontal label="${customer_key_label}">
+                <b-field horizontal label="${customer_key_label or 'TODO: Customer Key'}">
                   {{ customer._key }}
                 </b-field>
 
@@ -1996,7 +1996,9 @@
   <script type="text/javascript">
 
     let PersonalTabData = {
+        % if hasattr(master, 'profile_tab_personal'):
         refreshTabURL: '${url('people.profile_tab_personal', uuid=person.uuid)}',
+        % endif
 
         // nb. hack to force refresh for vue3
         refreshPersonalCard: 1,
@@ -2447,7 +2449,9 @@
   <script type="text/javascript">
 
     let CustomerTabData = {
+        % if hasattr(master, 'profile_tab_customer'):
         refreshTabURL: '${url('people.profile_tab_customer', uuid=person.uuid)}',
+        % endif
         customers: [],
     }
 
@@ -2521,7 +2525,9 @@
   <script type="text/javascript">
 
     let EmployeeTabData = {
+        % if hasattr(master, 'profile_tab_employee'):
         refreshTabURL: '${url('people.profile_tab_employee', uuid=person.uuid)}',
+        % endif
         employee: {},
         employeeHistory: [],
 
@@ -2756,7 +2762,9 @@
   <script type="text/javascript">
 
     let NotesTabData = {
+        % if hasattr(master, 'profile_tab_notes'):
         refreshTabURL: '${url('people.profile_tab_notes', uuid=person.uuid)}',
+        % endif
         notes: [],
         noteTypeOptions: [],
 
@@ -2920,7 +2928,9 @@
   <script type="text/javascript">
 
     let UserTabData = {
+        % if hasattr(master, 'profile_tab_user'):
         refreshTabURL: '${url('people.profile_tab_user', uuid=person.uuid)}',
+        % endif
         users: [],
 
         % if request.has_perm('users.create'):
@@ -2976,7 +2986,9 @@
                 createUserSave() {
                     this.createUserSaving = true
 
+                    % if hasattr(master, 'profile_make_user'):
                     let url = '${master.get_action_url('profile_make_user', instance)}'
+                    % endif
                     let params = {
                         username: this.createUserUsername,
                         active: this.createUserActive,
@@ -3015,13 +3027,13 @@
 
     let ProfileInfoData = {
         activeTab: location.hash ? location.hash.substring(1) : 'personal',
-        tabchecks: ${json.dumps(tabchecks)|n},
+        tabchecks: ${json.dumps(tabchecks or {})|n},
         today: '${rattail_app.today()}',
         profileLastChanged: Date.now(),
-        person: ${json.dumps(person_data)|n},
-        phoneTypeOptions: ${json.dumps(phone_type_options)|n},
-        emailTypeOptions: ${json.dumps(email_type_options)|n},
-        maxLengths: ${json.dumps(max_lengths)|n},
+        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,

From 53040dc6befed83aaf691000c951b2678669a499 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 15 Aug 2024 20:29:36 -0500
Subject: [PATCH 436/542] fix: update references to `get_class_hierarchy()`

per upstream changes
---
 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 f2d78b80..0d322da3 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -39,8 +39,9 @@ from sqlalchemy import orm
 import sqlalchemy_continuum as continuum
 from sqlalchemy_utils.functions import get_primary_keys, get_columns
 
+from wuttjamaican.util import get_class_hierarchy
 from rattail.db.continuum import model_transaction_query
-from rattail.util import simple_error, get_class_hierarchy
+from rattail.util import simple_error
 from rattail.threads import Thread
 from rattail.csvutil import UnicodeDictWriter
 from rattail.excel import ExcelWriter

From 7f0c571a446a71520e70d666e11f6b9be5aeecb8 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 15 Aug 2024 21:12:34 -0500
Subject: [PATCH 437/542] fix: improve wutta People view a bit

try to behave more like traditional tailbone, for the few things
supported so far.  taking a conservative approach here for now since
probably other things are more pressing.
---
 tailbone/views/wutta/people.py   | 85 ++++++++++++++++++++++++--------
 tests/views/wutta/test_people.py | 52 ++++++++++++++++---
 2 files changed, 110 insertions(+), 27 deletions(-)

diff --git a/tailbone/views/wutta/people.py b/tailbone/views/wutta/people.py
index 44cc26d9..c92e34ae 100644
--- a/tailbone/views/wutta/people.py
+++ b/tailbone/views/wutta/people.py
@@ -24,11 +24,14 @@
 Person Views
 """
 
-from rattail.db.model import Person
+import colander
+import sqlalchemy as sa
+from webhelpers2.html import HTML
 
 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
 
 
 class PersonView(wutta.PersonView):
@@ -42,9 +45,9 @@ class PersonView(wutta.PersonView):
     model_class = Person
     Session = Session
 
-    # labels = {
-    #     'display_name': "Full Name",
-    # }
+    labels = {
+        'display_name': "Full Name",
+    }
 
     grid_columns = [
         'display_name',
@@ -60,15 +63,16 @@ class PersonView(wutta.PersonView):
         'middle_name',
         'last_name',
         'display_name',
-        'default_phone',
-        'default_email',
+        'phone',
+        'email',
+        # TODO
         # 'address',
-        # 'employee',
-        'customers',
-        # 'members',
-        'users',
     ]
 
+    ##############################
+    # CRUD methods
+    ##############################
+
     def get_query(self, session=None):
         """ """
         model = self.app.model
@@ -76,25 +80,64 @@ class PersonView(wutta.PersonView):
         return session.query(model.Person)\
                       .order_by(model.Person.display_name)
 
+    def configure_grid(self, g):
+        """ """
+        super().configure_grid(g)
+
+        # display_name
+        g.set_link('display_name')
+
+        # first_name
+        g.set_link('first_name')
+
+        # last_name
+        g.set_link('last_name')
+
+        # merge_requested
+        g.set_label('merge_requested', "MR")
+        g.set_renderer('merge_requested', self.render_merge_requested)
+
     def configure_form(self, f):
         """ """
         super().configure_form(f)
 
-        # default_phone
-        f.set_required('default_phone', False)
-
-        # default_email
-        f.set_required('default_email', False)
-
-        # customers
+        # email
         if self.creating or self.editing:
-            f.remove('customers')
+            f.remove('email')
+        else:
+            # nb. avoid colanderalchemy
+            f.set_node('email', colander.String())
+
+        # phone
+        if self.creating or self.editing:
+            f.remove('phone')
+        else:
+            # nb. avoid colanderalchemy
+            f.set_node('phone', colander.String())
+
+    ##############################
+    # support methods
+    ##############################
+
+    def render_merge_requested(self, person, key, value, session=None):
+        """ """
+        model = self.app.model
+        session = session or self.Session()
+        merge_request = session.query(model.MergePeopleRequest)\
+                               .filter(sa.or_(
+                                   model.MergePeopleRequest.removing_uuid == person.uuid,
+                                   model.MergePeopleRequest.keeping_uuid == person.uuid))\
+                               .filter(model.MergePeopleRequest.merged == None)\
+                               .first()
+        if merge_request:
+            return HTML.tag('span',
+                            class_='has-text-danger has-text-weight-bold',
+                            title="A merge has been requested for this person.",
+                            c="MR")
 
 
 def defaults(config, **kwargs):
-    base = globals()
-
-    kwargs.setdefault('PersonView', base['PersonView'])
+    kwargs.setdefault('PersonView', PersonView)
     tailbone.defaults(config, **kwargs)
 
 
diff --git a/tests/views/wutta/test_people.py b/tests/views/wutta/test_people.py
index 7795d641..f178a64f 100644
--- a/tests/views/wutta/test_people.py
+++ b/tests/views/wutta/test_people.py
@@ -23,6 +23,19 @@ class TestPersonView(WebTestCase):
         query = view.get_query(session=self.session)
         self.assertIsInstance(query, orm.Query)
 
+    def test_configure_grid(self):
+        model = self.app.model
+        barney = model.User(username='barney')
+        self.session.add(barney)
+        self.session.commit()
+        view = self.make_view()
+
+        # sanity / coverage check
+        grid = view.make_grid(model_class=model.Person)
+        self.assertNotIn('first_name', grid.linked_columns)
+        view.configure_grid(grid)
+        self.assertIn('first_name', grid.linked_columns)
+
     def test_configure_form(self):
         model = self.app.model
         barney = model.User(username='barney')
@@ -30,18 +43,45 @@ class TestPersonView(WebTestCase):
         self.session.commit()
         view = self.make_view()
 
-        # customers field remains when viewing
+        # email field remains when viewing
         with patch.object(view, 'viewing', new=True):
             form = view.make_form(model_instance=barney,
                                   fields=view.get_form_fields())
-            self.assertIn('customers', form.fields)
+            self.assertIn('email', form.fields)
             view.configure_form(form)
-            self.assertIn('customers', form)
+            self.assertIn('email', form)
 
-        # customers field removed when editing
+        # email field removed when editing
         with patch.object(view, 'editing', new=True):
             form = view.make_form(model_instance=barney,
                                   fields=view.get_form_fields())
-            self.assertIn('customers', form.fields)
+            self.assertIn('email', form.fields)
             view.configure_form(form)
-            self.assertNotIn('customers', form)
+            self.assertNotIn('email', form)
+
+    def test_render_merge_requested(self):
+        model = self.app.model
+        barney = model.Person(display_name="Barney Rubble")
+        self.session.add(barney)
+        user = model.User(username='user')
+        self.session.add(user)
+        self.session.commit()
+        view = self.make_view()
+
+        # null by default
+        html = view.render_merge_requested(barney, 'merge_requested', None,
+                                           session=self.session)
+        self.assertIsNone(html)
+
+        # unless a merge request exists
+        barney2 = model.Person(display_name="Barney Rubble")
+        self.session.add(barney2)
+        self.session.commit()
+        mr = model.MergePeopleRequest(removing_uuid=barney2.uuid,
+                                      keeping_uuid=barney.uuid,
+                                      requested_by=user)
+        self.session.add(mr)
+        self.session.commit()
+        html = view.render_merge_requested(barney, 'merge_requested', None,
+                                           session=self.session)
+        self.assertIn('<span ', html)

From bbc2c584ec030b69e7c8f711d7d3e1a31a18bceb Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 15 Aug 2024 21:16:53 -0500
Subject: [PATCH 438/542] =?UTF-8?q?bump:=20version=200.16.0=20=E2=86=92=20?=
 =?UTF-8?q?0.16.1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

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

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 401c1b25..f532ae03 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,14 @@ 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.16.1 (2024-08-15)
+
+### Fix
+
+- improve wutta People view a bit
+- update references to `get_class_hierarchy()`
+- tweak template for `people/view_profile` per wutta compat
+
 ## v0.16.0 (2024-08-15)
 
 ### Feat
diff --git a/pyproject.toml b/pyproject.toml
index dc0887d4..69c35a68 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.16.0"
+version = "0.16.1"
 description = "Backoffice Web Application for Rattail"
 readme = "README.rst"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]
@@ -53,7 +53,7 @@ dependencies = [
         "pyramid_mako",
         "pyramid_retry",
         "pyramid_tm",
-        "rattail[db,bouncer]>=0.17.11",
+        "rattail[db,bouncer]>=0.18.1",
         "sa-filters",
         "simplejson",
         "transaction",

From da0f6bd5e10a6f623d47d017ee862ea7e455faa2 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 15 Aug 2024 23:12:02 -0500
Subject: [PATCH 439/542] feat: use wuttaweb for `get_liburl()` logic

thankfully this is already handled and we can remove from tailbone.
although this adds some new cruft as well, to handle auto-migrating
any existing liburl config for apps.

eventually once all apps have migrated to new settings we can remove
the prefix from our calls here but also in wuttaweb signature
---
 tailbone/helpers.py                           |   4 +-
 tailbone/templates/appinfo/configure.mako     |   8 +-
 tailbone/templates/base.mako                  |  10 +-
 .../templates/themes/butterball/base.mako     |  14 +-
 tailbone/util.py                              | 162 +++---------------
 tailbone/views/settings.py                    | 131 +++++++-------
 tests/views/test_settings.py                  |  10 ++
 7 files changed, 110 insertions(+), 229 deletions(-)
 create mode 100644 tests/views/test_settings.py

diff --git a/tailbone/helpers.py b/tailbone/helpers.py
index d4065cc5..23988423 100644
--- a/tailbone/helpers.py
+++ b/tailbone/helpers.py
@@ -36,11 +36,11 @@ 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,
                            render_markdown,
-                           route_exists,
-                           get_liburl)
+                           route_exists)
 
 
 def pretty_date(date):
diff --git a/tailbone/templates/appinfo/configure.mako b/tailbone/templates/appinfo/configure.mako
index 280b5cb9..aab180c4 100644
--- a/tailbone/templates/appinfo/configure.mako
+++ b/tailbone/templates/appinfo/configure.mako
@@ -149,8 +149,8 @@
     </${b}-table>
 
     % for weblib in weblibs:
-        ${h.hidden('tailbone.libver.{}'.format(weblib['key']), **{':value': "simpleSettings['tailbone.libver.{}']".format(weblib['key'])})}
-        ${h.hidden('tailbone.liburl.{}'.format(weblib['key']), **{':value': "simpleSettings['tailbone.liburl.{}']".format(weblib['key'])})}
+        ${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
@@ -236,8 +236,8 @@
         this.editWebLibraryRecord.configured_url = this.editWebLibraryURL
         this.editWebLibraryRecord.modified = true
 
-        this.simpleSettings[`tailbone.libver.${'$'}{this.editWebLibraryRecord.key}`] = this.editWebLibraryVersion
-        this.simpleSettings[`tailbone.liburl.${'$'}{this.editWebLibraryRecord.key}`] = this.editWebLibraryURL
+        this.simpleSettings[`wuttaweb.libver.${'$'}{this.editWebLibraryRecord.key}`] = this.editWebLibraryVersion
+        this.simpleSettings[`wuttaweb.liburl.${'$'}{this.editWebLibraryRecord.key}`] = this.editWebLibraryURL
 
         this.settingsNeedSaved = true
         this.editWebLibraryShowDialog = false
diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako
index 6811397b..27e900e4 100644
--- a/tailbone/templates/base.mako
+++ b/tailbone/templates/base.mako
@@ -122,16 +122,16 @@
 </%def>
 
 <%def name="vuejs()">
-  ${h.javascript_link(h.get_liburl(request, 'vue'))}
-  ${h.javascript_link(h.get_liburl(request, 'vue_resource'))}
+  ${h.javascript_link(h.get_liburl(request, 'vue', prefix='tailbone'))}
+  ${h.javascript_link(h.get_liburl(request, 'vue_resource', prefix='tailbone'))}
 </%def>
 
 <%def name="buefy()">
-  ${h.javascript_link(h.get_liburl(request, 'buefy'))}
+  ${h.javascript_link(h.get_liburl(request, 'buefy', prefix='tailbone'))}
 </%def>
 
 <%def name="fontawesome()">
-  <script defer src="${h.get_liburl(request, 'fontawesome')}"></script>
+  <script defer src="${h.get_liburl(request, 'fontawesome', prefix='tailbone')}"></script>
 </%def>
 
 <%def name="extra_javascript()"></%def>
@@ -171,7 +171,7 @@
       ${h.stylesheet_link(user_css)}
   % else:
       ## upstream Buefy CSS
-      ${h.stylesheet_link(h.get_liburl(request, 'buefy.css'))}
+      ${h.stylesheet_link(h.get_liburl(request, 'buefy.css', prefix='tailbone'))}
   % endif
 </%def>
 
diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako
index f06b45f9..306b3430 100644
--- a/tailbone/templates/themes/butterball/base.mako
+++ b/tailbone/templates/themes/butterball/base.mako
@@ -71,12 +71,12 @@
     {
         ## TODO: eventually version / url should be configurable
         "imports": {
-            "vue": "${h.get_liburl(request, 'bb_vue')}",
-            "@oruga-ui/oruga-next": "${h.get_liburl(request, 'bb_oruga')}",
-            "@oruga-ui/theme-bulma": "${h.get_liburl(request, 'bb_oruga_bulma')}",
-            "@fortawesome/fontawesome-svg-core": "${h.get_liburl(request, 'bb_fontawesome_svg_core')}",
-            "@fortawesome/free-solid-svg-icons": "${h.get_liburl(request, 'bb_free_solid_svg_icons')}",
-            "@fortawesome/vue-fontawesome": "${h.get_liburl(request, 'bb_vue_fontawesome')}"
+            "vue": "${h.get_liburl(request, 'bb_vue', prefix='tailbone')}",
+            "@oruga-ui/oruga-next": "${h.get_liburl(request, 'bb_oruga', prefix='tailbone')}",
+            "@oruga-ui/theme-bulma": "${h.get_liburl(request, 'bb_oruga_bulma', prefix='tailbone')}",
+            "@fortawesome/fontawesome-svg-core": "${h.get_liburl(request, 'bb_fontawesome_svg_core', prefix='tailbone')}",
+            "@fortawesome/free-solid-svg-icons": "${h.get_liburl(request, 'bb_free_solid_svg_icons', prefix='tailbone')}",
+            "@fortawesome/vue-fontawesome": "${h.get_liburl(request, 'bb_vue_fontawesome', prefix='tailbone')}"
         }
     }
   </script>
@@ -92,7 +92,7 @@
   % if user_css:
       ${h.stylesheet_link(user_css)}
   % else:
-      ${h.stylesheet_link(h.get_liburl(request, 'bb_oruga_bulma_css'))}
+      ${h.stylesheet_link(h.get_liburl(request, 'bb_oruga_bulma_css', prefix='tailbone'))}
   % endif
 </%def>
 
diff --git a/tailbone/util.py b/tailbone/util.py
index eb6fb8a8..594fd69b 100644
--- a/tailbone/util.py
+++ b/tailbone/util.py
@@ -39,7 +39,9 @@ from pyramid.renderers import get_renderer
 from pyramid.interfaces import IRoutesMapper
 from webhelpers2.html import HTML, tags
 
-from wuttaweb.util import get_form_data as wutta_get_form_data
+from wuttaweb.util import (get_form_data as wutta_get_form_data,
+                           get_libver as wutta_get_libver,
+                           get_liburl as wutta_get_liburl)
 
 
 log = logging.getLogger(__name__)
@@ -103,154 +105,32 @@ def get_global_search_options(request):
     return options
 
 
-def get_libver(request, key, fallback=True, default_only=False):
+def get_libver(request, key, fallback=True, default_only=False): # pragma: no cover
     """
-    Return the appropriate URL for the library identified by ``key``.
+    DEPRECATED - use :func:`wuttaweb:wuttaweb.util.get_libver()`
+    instead.
     """
-    config = request.rattail_config
+    warnings.warn("tailbone.util.get_libver() is deprecated; "
+                  "please use wuttaweb.util.get_libver() instead",
+                  DeprecationWarning, stacklevel=2)
 
-    if not default_only:
-        version = config.get('tailbone', 'libver.{}'.format(key))
-        if version:
-            return version
-
-    if not fallback and not default_only:
-
-        if key == 'buefy':
-            version = config.get('tailbone', 'buefy_version')
-            if version:
-                return version
-
-        elif key == 'buefy.css':
-            version = get_libver(request, 'buefy', fallback=False)
-            if version:
-                return version
-
-        elif key == 'vue':
-            version = config.get('tailbone', 'vue_version')
-            if version:
-                return version
-
-        return
-
-    if key == 'buefy':
-        if not default_only:
-            version = config.get('tailbone', 'buefy_version')
-            if version:
-                return version
-        return 'latest'
-
-    elif key == 'buefy.css':
-        version = get_libver(request, 'buefy', default_only=default_only)
-        if version:
-            return version
-        return 'latest'
-
-    elif key == 'vue':
-        if not default_only:
-            version = config.get('tailbone', 'vue_version')
-            if version:
-                return version
-        return '2.6.14'
-
-    elif key == 'vue_resource':
-        return 'latest'
-
-    elif key == 'fontawesome':
-        return '5.3.1'
-
-    elif key == 'bb_vue':
-        return '3.4.31'
-
-    elif key == 'bb_oruga':
-        return '0.8.12'
-
-    elif key in ('bb_oruga_bulma', 'bb_oruga_bulma_css'):
-        return '0.3.0'
-
-    elif key == 'bb_fontawesome_svg_core':
-        return '6.5.2'
-
-    elif key == 'bb_free_solid_svg_icons':
-        return '6.5.2'
-
-    elif key == 'bb_vue_fontawesome':
-        return '3.0.6'
+    return wutta_get_libver(request, key, prefix='tailbone',
+                            configured_only=not fallback,
+                            default_only=default_only)
 
 
-def get_liburl(request, key, fallback=True):
+def get_liburl(request, key, fallback=True): # pragma: no cover
     """
-    Return the appropriate URL for the library identified by ``key``.
+    DEPRECATED - use :func:`wuttaweb:wuttaweb.util.get_liburl()`
+    instead.
     """
-    config = request.rattail_config
+    warnings.warn("tailbone.util.get_liburl() is deprecated; "
+                  "please use wuttaweb.util.get_liburl() instead",
+                  DeprecationWarning, stacklevel=2)
 
-    url = config.get('tailbone', 'liburl.{}'.format(key))
-    if url:
-        return url
-
-    if not fallback:
-        return
-
-    version = get_libver(request, key)
-
-    static = config.get('tailbone.static_libcache.module')
-    if static:
-        static = importlib.import_module(static)
-        needed = request.environ['fanstatic.needed']
-        liburl = needed.library_url(static.libcache) + '/'
-        # nb. add custom url prefix if needed, e.g. /theo
-        if request.script_name:
-            liburl = request.script_name + liburl
-
-    if key == 'buefy':
-        return 'https://unpkg.com/buefy@{}/dist/buefy.min.js'.format(version)
-
-    elif key == 'buefy.css':
-        return 'https://unpkg.com/buefy@{}/dist/buefy.min.css'.format(version)
-
-    elif key == 'vue':
-        return 'https://unpkg.com/vue@{}/dist/vue.min.js'.format(version)
-
-    elif key == 'vue_resource':
-        return 'https://cdn.jsdelivr.net/npm/vue-resource@{}'.format(version)
-
-    elif key == 'fontawesome':
-        return 'https://use.fontawesome.com/releases/v{}/js/all.js'.format(version)
-
-    elif key == 'bb_vue':
-        if static and hasattr(static, 'bb_vue_js'):
-            return liburl + static.bb_vue_js.relpath
-        return f'https://unpkg.com/vue@{version}/dist/vue.esm-browser.prod.js'
-
-    elif key == 'bb_oruga':
-        if static and hasattr(static, 'bb_oruga_js'):
-            return liburl + static.bb_oruga_js.relpath
-        return f'https://unpkg.com/@oruga-ui/oruga-next@{version}/dist/oruga.mjs'
-
-    elif key == 'bb_oruga_bulma':
-        if static and hasattr(static, 'bb_oruga_bulma_js'):
-            return liburl + static.bb_oruga_bulma_js.relpath
-        return f'https://unpkg.com/@oruga-ui/theme-bulma@{version}/dist/bulma.mjs'
-
-    elif key == 'bb_oruga_bulma_css':
-        if static and hasattr(static, 'bb_oruga_bulma_css'):
-            return liburl + static.bb_oruga_bulma_css.relpath
-        return f'https://unpkg.com/@oruga-ui/theme-bulma@{version}/dist/bulma.css'
-
-    elif key == 'bb_fontawesome_svg_core':
-        if static and hasattr(static, 'bb_fontawesome_svg_core_js'):
-            return liburl + static.bb_fontawesome_svg_core_js.relpath
-        return f'https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-svg-core@{version}/+esm'
-
-    elif key == 'bb_free_solid_svg_icons':
-        if static and hasattr(static, 'bb_free_solid_svg_icons_js'):
-            return liburl + static.bb_free_solid_svg_icons_js.relpath
-        return f'https://cdn.jsdelivr.net/npm/@fortawesome/free-solid-svg-icons@{version}/+esm'
-
-    elif key == 'bb_vue_fontawesome':
-        if static and hasattr(static, 'bb_vue_fontawesome_js'):
-            return liburl + static.bb_vue_fontawesome_js.relpath
-        return f'https://cdn.jsdelivr.net/npm/@fortawesome/vue-fontawesome@{version}/+esm'
+    return wutta_get_liburl(request, key, prefix='tailbone',
+                            configured_only=not fallback,
+                            default_only=False)
 
 
 def pretty_datetime(config, value):
diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py
index 8d389530..9d7f6e02 100644
--- a/tailbone/views/settings.py
+++ b/tailbone/views/settings.py
@@ -24,24 +24,23 @@
 Settings Views
 """
 
+import json
 import os
 import re
 import subprocess
 import sys
 from collections import OrderedDict
 
-import json
+import colander
 
 from rattail.db.model import Setting
 from rattail.settings import Setting as AppSetting
 from rattail.util import import_module_path
 
-import colander
-
 from tailbone import forms
 from tailbone.db import Session
 from tailbone.views import MasterView, View
-from tailbone.util import get_libver, get_liburl
+from wuttaweb.util import get_libver, get_liburl
 
 
 class AppInfoView(MasterView):
@@ -99,10 +98,9 @@ class AppInfoView(MasterView):
         kwargs['configure_button_title'] = "Configure App"
         return kwargs
 
-    def configure_get_context(self, **kwargs):
-        context = super().configure_get_context(**kwargs)
-
-        weblibs = OrderedDict([
+    def get_weblibs(self):
+        """ """
+        return OrderedDict([
             ('vue', "Vue"),
             ('vue_resource', "vue-resource"),
             ('buefy', "Buefy"),
@@ -117,6 +115,12 @@ class AppInfoView(MasterView):
             ('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()
+
         for key in weblibs:
             title = weblibs[key]
             weblibs[key] = {
@@ -125,19 +129,33 @@ class AppInfoView(MasterView):
 
                 # nb. these values are exactly as configured, and are
                 # used for editing the settings
-                'configured_version': get_libver(self.request, key, fallback=False),
-                'configured_url': get_liburl(self.request, key, fallback=False),
+                '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, default_only=True),
-                'live_url': get_liburl(self.request, key),
+                'default_version': get_libver(self.request, key,
+                                              prefix='tailbone',
+                                              default_only=True),
+                'live_url': get_liburl(self.request, key,
+                                       prefix='tailbone'),
             }
 
+            # TODO: this is only needed to migrate legacy settings to
+            # use the newer wutaweb 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']
+
         context['weblibs'] = list(weblibs.values())
         return context
 
     def configure_get_simple_settings(self):
-        return [
+        """ """
+        simple_settings = [
 
             # basics
             {'section': 'rattail',
@@ -167,63 +185,6 @@ class AppInfoView(MasterView):
              # 'type': int
             },
 
-            # web libs
-            {'section': 'tailbone',
-             'option': 'libver.vue'},
-            {'section': 'tailbone',
-             'option': 'liburl.vue'},
-            {'section': 'tailbone',
-             'option': 'libver.vue_resource'},
-            {'section': 'tailbone',
-             'option': 'liburl.vue_resource'},
-            {'section': 'tailbone',
-             'option': 'libver.buefy'},
-            {'section': 'tailbone',
-             'option': 'liburl.buefy'},
-            {'section': 'tailbone',
-             'option': 'libver.buefy.css'},
-            {'section': 'tailbone',
-             'option': 'liburl.buefy.css'},
-            {'section': 'tailbone',
-             'option': 'libver.fontawesome'},
-            {'section': 'tailbone',
-             'option': 'liburl.fontawesome'},
-
-            {'section': 'tailbone',
-             'option': 'libver.bb_vue'},
-            {'section': 'tailbone',
-             'option': 'liburl.bb_vue'},
-
-            {'section': 'tailbone',
-             'option': 'libver.bb_oruga'},
-            {'section': 'tailbone',
-             'option': 'liburl.bb_oruga'},
-
-            {'section': 'tailbone',
-             'option': 'libver.bb_oruga_bulma'},
-            {'section': 'tailbone',
-             'option': 'liburl.bb_oruga_bulma'},
-
-            {'section': 'tailbone',
-             'option': 'libver.bb_oruga_bulma_css'},
-            {'section': 'tailbone',
-             'option': 'liburl.bb_oruga_bulma_css'},
-
-            {'section': 'tailbone',
-             'option': 'libver.bb_fontawesome_svg_core'},
-            {'section': 'tailbone',
-             'option': 'liburl.bb_fontawesome_svg_core'},
-
-            {'section': 'tailbone',
-             'option': 'libver.bb_free_solid_svg_icons'},
-            {'section': 'tailbone',
-             'option': 'liburl.bb_free_solid_svg_icons'},
-
-            {'section': 'tailbone',
-             'option': 'libver.bb_vue_fontawesome'},
-            {'section': 'tailbone',
-             'option': 'liburl.bb_vue_fontawesome'},
-
             # nb. these are no longer used (deprecated), but we keep
             # them defined here so the tool auto-deletes them
             {'section': 'tailbone',
@@ -233,6 +194,36 @@ class AppInfoView(MasterView):
 
         ]
 
+        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}",
+            })
+
+        return simple_settings
+
 
 class SettingView(MasterView):
     """
diff --git a/tests/views/test_settings.py b/tests/views/test_settings.py
new file mode 100644
index 00000000..b8523729
--- /dev/null
+++ b/tests/views/test_settings.py
@@ -0,0 +1,10 @@
+# -*- coding: utf-8; -*-
+
+from tailbone.views import settings as mod
+from tests.util import WebTestCase
+
+
+class TestSettingView(WebTestCase):
+
+    def test_includeme(self):
+        self.pyramid_config.include('tailbone.views.settings')

From bbd98e7b2f0ec57c3b4ffcd0b30786e8f0449504 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 15 Aug 2024 23:15:25 -0500
Subject: [PATCH 440/542] =?UTF-8?q?bump:=20version=200.16.1=20=E2=86=92=20?=
 =?UTF-8?q?0.17.0?=
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 f532ae03..5724e685 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.17.0 (2024-08-15)
+
+### Feat
+
+- use wuttaweb for `get_liburl()` logic
+
 ## v0.16.1 (2024-08-15)
 
 ### Fix
diff --git a/pyproject.toml b/pyproject.toml
index 69c35a68..31c7ef8d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.16.1"
+version = "0.17.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.7.0",
+        "WuttaWeb>=0.8.1",
         "zope.sqlalchemy>=1.5",
 ]
 

From 09612b1921af0a7b3bb7141381c3bb861b4d64ab Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 15 Aug 2024 23:46:58 -0500
Subject: [PATCH 441/542] fix: fix some more wutta compat for base template

missed those earlier
---
 tailbone/templates/base.mako | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako
index 27e900e4..3a12859e 100644
--- a/tailbone/templates/base.mako
+++ b/tailbone/templates/base.mako
@@ -280,7 +280,7 @@
                       <span class="header-text">
                         ${index_title}
                       </span>
-                      % if master.creatable and master.show_create_link and master.has_perm('create'):
+                      % if master.creatable and getattr(master, 'show_create_link', True) and master.has_perm('create'):
                           <once-button type="is-primary"
                                        tag="a" href="${url('{}.create'.format(route_prefix))}"
                                        icon-left="plus"
@@ -306,7 +306,7 @@
                           <span class="header-text">
                             ${h.link_to(instance_title, instance_url)}
                           </span>
-                      % elif master.creatable and master.show_create_link and master.has_perm('create'):
+                      % elif master.creatable and getattr(master, 'show_create_link', True) and master.has_perm('create'):
                           % if not request.matched_route.name.endswith('.create'):
                               <once-button type="is-primary"
                                            tag="a" href="${url('{}.create'.format(route_prefix))}"

From 1b78bd617c09f229a40161c14d07d883159b3668 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 16 Aug 2024 11:56:12 -0500
Subject: [PATCH 442/542] feat: inherit most logic from wuttaweb, for
 GridAction

---
 tailbone/grids/core.py                 | 65 ++++++++++----------------
 tailbone/templates/grids/b-table.mako  | 11 ++---
 tailbone/templates/grids/complete.mako |  8 +---
 tailbone/views/master.py               |  8 +++-
 tailbone/views/people.py               |  2 +-
 tailbone/views/purchasing/receiving.py |  4 +-
 tailbone/views/roles.py                |  2 +-
 tests/grids/test_core.py               | 17 +++++++
 tests/views/test_master.py             |  9 ++++
 9 files changed, 65 insertions(+), 61 deletions(-)

diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py
index 3f1769cf..b9254c18 100644
--- a/tailbone/grids/core.py
+++ b/tailbone/grids/core.py
@@ -38,6 +38,7 @@ from pyramid.renderers import render
 from webhelpers2.html import HTML, tags
 from paginate_sqlalchemy import SqlalchemyOrmPage
 
+from wuttaweb.grids import GridAction as WuttaGridAction
 from . import filters as gridfilters
 from tailbone.db import Session
 from tailbone.util import raw_datetime
@@ -1801,18 +1802,20 @@ class Grid:
         return False
 
 
-class GridAction(object):
+class GridAction(WuttaGridAction):
     """
-    Represents an action available to a grid.  This is used to construct the
-    'actions' column when rendering the grid.
+    Represents a "row action" hyperlink within a grid context.
 
-    :param key: Key for the action (e.g. ``'edit'``), unique within
-       the grid.
+    This is a subclass of
+    :class:`wuttaweb:wuttaweb.grids.base.GridAction`.
 
-    :param label: Label to be displayed for the action.  If not set,
-       will be a capitalized version of ``key``.
+    .. warning::
 
-    :param icon: Icon name for the action.
+       This class remains for now, to retain compatibility with
+       existing code.  But at some point the WuttaWeb class will
+       supersede this one entirely.
+
+    :param target: HTML "target" attribute for the ``<a>`` tag.
 
     :param click_handler: Optional JS click handler for the action.
        This value will be rendered as-is within the final grid
@@ -1824,41 +1827,23 @@ class GridAction(object):
        * ``$emit('do-something', props.row)``
     """
 
-    def __init__(self, key, label=None, url='#', icon=None, target=None,
-                 link_class=None, click_handler=None):
-        self.key = key
-        self.label = label or prettify(key)
-        self.icon = icon
-        self.url = url
+    def __init__(
+            self,
+            request,
+            key,
+            target=None,
+            click_handler=None,
+            **kwargs,
+    ):
+        # TODO: previously url default was '#' - but i don't think we
+        # need that anymore?  guess we'll see..
+        #kwargs.setdefault('url', '#')
+
+        super().__init__(request, key, **kwargs)
+
         self.target = target
-        self.link_class = link_class
         self.click_handler = click_handler
 
-    def get_url(self, row, i):
-        """
-        Returns an action URL for the given row.
-        """
-        if callable(self.url):
-            return self.url(row, i)
-        return self.url
-
-    def render_icon(self):
-        """
-        Render the HTML snippet for the action link icon.
-        """
-        return HTML.tag('i', class_='fas fa-{}'.format(self.icon))
-
-    def render_label(self):
-        """
-        Render the label "text" within the actions column of a grid
-        row.  Most actions have a static label that never varies, but
-        you can override this to add e.g. HTML content.  Note that the
-        return value will be treated / rendered as HTML whether or not
-        it contains any, so perhaps be careful that it is trusted
-        content.
-        """
-        return self.label
-
 
 class URLMaker(object):
     """
diff --git a/tailbone/templates/grids/b-table.mako b/tailbone/templates/grids/b-table.mako
index 632193b5..da9f2aae 100644
--- a/tailbone/templates/grids/b-table.mako
+++ b/tailbone/templates/grids/b-table.mako
@@ -53,11 +53,11 @@
       </${b}-table-column>
   % endfor
 
-  % if grid.main_actions or grid.more_actions:
+  % if grid.actions:
       <${b}-table-column field="actions"
                       label="Actions"
                       v-slot="props">
-        % for action in grid.main_actions:
+        % for action in grid.actions:
             <a :href="props.row._action_url_${action.key}"
                % if action.link_class:
                class="${action.link_class}"
@@ -68,12 +68,7 @@
                @click.prevent="${action.click_handler}"
                % endif
                >
-              % if request.use_oruga:
-                  <o-icon icon="${action.icon}" />
-              % else:
-                  <i class="fas fa-${action.icon}"></i>
-              % endif
-              ${action.label}
+              ${action.render_icon_and_label()}
             </a>
             &nbsp;
         % endfor
diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako
index fc48916b..93bb6c26 100644
--- a/tailbone/templates/grids/complete.mako
+++ b/tailbone/templates/grids/complete.mako
@@ -163,13 +163,7 @@
                    target="${action.target}"
                    % endif
                    >
-                  % if request.use_oruga:
-                      <o-icon icon="${action.icon}" />
-                      <span>${action.render_label()|n}</span>
-                  % else:
-                      ${action.render_icon()|n}
-                      ${action.render_label()|n}
-                  % endif
+                  ${action.render_icon_and_label()}
                 </a>
                 &nbsp;
             % endfor
diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index 0d322da3..097cb229 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -3220,14 +3220,18 @@ class MasterView(View):
 
     def make_action(self, key, url=None, factory=None, **kwargs):
         """
-        Make a new :class:`GridAction` instance for the current grid.
+        Make and return a new :class:`~tailbone.grids.core.GridAction`
+        instance.
+
+        This can be called to make actions for any grid, not just the
+        one from :meth:`index()`.
         """
         if url is None:
             route = '{}.{}'.format(self.get_route_prefix(), key)
             url = lambda r, i: self.request.route_url(route, **self.get_action_route_kwargs(r))
         if not factory:
             factory = grids.GridAction
-        return factory(key, url=url, **kwargs)
+        return factory(self.request, key, url=url, **kwargs)
 
     def get_action_route_kwargs(self, obj):
         """
diff --git a/tailbone/views/people.py b/tailbone/views/people.py
index 94c85821..163a9a52 100644
--- a/tailbone/views/people.py
+++ b/tailbone/views/people.py
@@ -552,7 +552,7 @@ class PersonView(MasterView):
         if self.request.has_perm('trainwreck.transactions.view'):
             url = lambda row, i: self.request.route_url('trainwreck.transactions.view',
                                                         uuid=row.uuid)
-            g.main_actions.append(grids.GridAction('view', icon='eye', url=url))
+            g.main_actions.append(self.make_action('view', icon='eye', url=url))
         g.load_settings()
 
         g.set_enum('system', self.enum.TRAINWRECK_SYSTEM)
diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py
index 55936184..0a305f0a 100644
--- a/tailbone/views/purchasing/receiving.py
+++ b/tailbone/views/purchasing/receiving.py
@@ -40,7 +40,7 @@ from webhelpers2.html import tags, HTML
 
 from wuttaweb.util import get_form_data
 
-from tailbone import forms, grids
+from tailbone import forms
 from tailbone.views.purchasing import PurchasingBatchView
 
 
@@ -1031,7 +1031,7 @@ class ReceivingBatchView(PurchasingBatchView):
         if batch.is_truck_dump_parent():
             permission_prefix = self.get_permission_prefix()
             if self.request.has_perm('{}.edit_row'.format(permission_prefix)):
-                transform = grids.GridAction('transform',
+                transform = self.make_action('transform',
                                              icon='shuffle',
                                              label="Transform to Unit",
                                              url=self.transform_unit_url)
diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py
index b34b3673..fb834479 100644
--- a/tailbone/views/roles.py
+++ b/tailbone/views/roles.py
@@ -363,7 +363,7 @@ class RoleView(PrincipalMasterView):
         if role.users:
             users = sorted(role.users, key=lambda u: u.username)
             actions = [
-                grids.GridAction('view', icon='zoomin',
+                self.make_action('view', icon='zoomin',
                                  url=lambda r, i: self.request.route_url('users.view', uuid=r.uuid))
             ]
             kwargs['users'] = grids.Grid(None, users, ['username', 'active'],
diff --git a/tests/grids/test_core.py b/tests/grids/test_core.py
index e6f9d675..0a8d5d66 100644
--- a/tests/grids/test_core.py
+++ b/tests/grids/test_core.py
@@ -137,3 +137,20 @@ class TestGrid(WebTestCase):
         # calling again returns same data
         data2 = grid.get_vue_data()
         self.assertIs(data2, data)
+
+
+class TestGridAction(WebTestCase):
+
+    def test_constructor(self):
+
+        # null by default
+        action = mod.GridAction(self.request, 'view')
+        self.assertIsNone(action.target)
+        self.assertIsNone(action.click_handler)
+
+        # but can set them
+        action = mod.GridAction(self.request, 'view',
+                                target='_blank',
+                                click_handler='doSomething(props.row)')
+        self.assertEqual(action.target, '_blank')
+        self.assertEqual(action.click_handler, 'doSomething(props.row)')
diff --git a/tests/views/test_master.py b/tests/views/test_master.py
index 19321496..572875a0 100644
--- a/tests/views/test_master.py
+++ b/tests/views/test_master.py
@@ -3,6 +3,7 @@
 from unittest.mock import patch
 
 from tailbone.views import master as mod
+from wuttaweb.grids import GridAction
 from tests.util import WebTestCase
 
 
@@ -24,3 +25,11 @@ class TestMasterView(WebTestCase):
             # sanity / coverage check
             kw = view.make_form_kwargs(model_instance=setting)
             self.assertIsNotNone(kw['action_url'])
+
+    def test_make_action(self):
+        model = self.app.model
+        with patch.multiple(mod.MasterView, create=True,
+                            model_class=model.Setting):
+            view = self.make_view()
+            action = view.make_action('view')
+            self.assertIsInstance(action, GridAction)

From f7641218cb44c6ad18d6672361d1f1243c05e397 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 16 Aug 2024 11:56:54 -0500
Subject: [PATCH 443/542] fix: avoid route error in user view, when using wutta
 people view

kind of a temporary edge case here, can eventually change it back
---
 tailbone/views/users.py | 10 +++++++---
 1 file changed, 7 insertions(+), 3 deletions(-)

diff --git a/tailbone/views/users.py b/tailbone/views/users.py
index f8bcb1b8..9eae74d8 100644
--- a/tailbone/views/users.py
+++ b/tailbone/views/users.py
@@ -208,9 +208,13 @@ class UserView(PrincipalMasterView):
                             person_display = str(person)
                 elif self.editing:
                     person_display = str(user.person or '')
-                people_url = self.request.route_url('people.autocomplete')
-                f.set_widget('person_uuid', forms.widgets.JQueryAutocompleteWidget(
-                    field_display=person_display, service_url=people_url))
+                try:
+                    people_url = self.request.route_url('people.autocomplete')
+                except KeyError:
+                    pass        # TODO: wutta compat
+                else:
+                    f.set_widget('person_uuid', forms.widgets.JQueryAutocompleteWidget(
+                        field_display=person_display, service_url=people_url))
                 f.set_validator('person_uuid', self.valid_person)
                 f.set_label('person_uuid', "Person")
 

From 2a0b6da2f9169c22c099ca2c367a3ab2d89fa6e2 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 16 Aug 2024 14:34:50 -0500
Subject: [PATCH 444/542] feat: inherit from wutta base class for Grid

---
 tailbone/grids/core.py                 | 241 ++++++++++---------------
 tailbone/views/batch/core.py           |   8 +-
 tailbone/views/batch/pos.py            |   1 +
 tailbone/views/customers.py            |  19 +-
 tailbone/views/custorders/items.py     |   1 +
 tailbone/views/custorders/orders.py    |  71 ++++----
 tailbone/views/departments.py          |   8 +-
 tailbone/views/email.py                |   2 +-
 tailbone/views/employees.py            |   3 +-
 tailbone/views/master.py               |  48 +++--
 tailbone/views/members.py              |   3 +-
 tailbone/views/people.py               |  19 +-
 tailbone/views/poser/reports.py        |   2 +-
 tailbone/views/principal.py            |   6 +-
 tailbone/views/products.py             |  28 +--
 tailbone/views/purchasing/batch.py     |   4 +-
 tailbone/views/purchasing/receiving.py |  18 +-
 tailbone/views/reports.py              |  12 +-
 tailbone/views/roles.py                |  15 +-
 tailbone/views/tempmon/core.py         |   6 +-
 tailbone/views/trainwreck/base.py      |  12 +-
 tailbone/views/users.py                |  15 +-
 tests/grids/test_core.py               |  49 ++++-
 23 files changed, 317 insertions(+), 274 deletions(-)

diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py
index b9254c18..a5617215 100644
--- a/tailbone/grids/core.py
+++ b/tailbone/grids/core.py
@@ -38,7 +38,7 @@ from pyramid.renderers import render
 from webhelpers2.html import HTML, tags
 from paginate_sqlalchemy import SqlalchemyOrmPage
 
-from wuttaweb.grids import GridAction as WuttaGridAction
+from wuttaweb.grids import Grid as WuttaGrid, GridAction as WuttaGridAction
 from . import filters as gridfilters
 from tailbone.db import Session
 from tailbone.util import raw_datetime
@@ -61,7 +61,7 @@ class FieldList(list):
         self.insert(i + 1, newfield)
 
 
-class Grid:
+class Grid(WuttaGrid):
     """
     Core grid class.  In sore need of documentation.
 
@@ -186,32 +186,59 @@ class Grid:
           grid.row_uuid_getter = fake_uuid
     """
 
-    def __init__(self, key, data, columns=None, width='auto', request=None,
-                 model_class=None, model_title=None, model_title_plural=None,
-                 enums={}, labels={}, assume_local_times=False, renderers={}, invisible=[],
-                 raw_renderers={},
-                 extra_row_class=None, linked_columns=[], url='#',
-                 joiners={}, filterable=False, filters={}, use_byte_string_filters=False,
-                 searchable={},
-                 sortable=False, sorters={}, default_sortkey=None, default_sortdir='asc',
-                 pageable=False, default_pagesize=None, default_page=1,
-                 checkboxes=False, checked=None, check_handler=None, check_all_handler=None,
-                 checkable=None, row_uuid_getter=None,
-                 clicking_row_checks_box=False, click_handlers=None,
-                 main_actions=[], more_actions=[], delete_speedbump=False,
-                 ajax_data_url=None,
-                 vue_tagname=None,
-                 expose_direct_link=False,
-                 **kwargs):
+    def __init__(
+            self,
+            request,
+            key=None,
+            data=None,
+            width='auto',
+            model_title=None,
+            model_title_plural=None,
+            enums={},
+            assume_local_times=False,
+            invisible=[],
+            raw_renderers={},
+            extra_row_class=None,
+            url='#',
+            joiners={},
+            filterable=False,
+            filters={},
+            use_byte_string_filters=False,
+            searchable={},
+            sortable=False,
+            sorters={},
+            default_sortkey=None,
+            default_sortdir='asc',
+            pageable=False,
+            default_pagesize=None,
+            default_page=1,
+            checkboxes=False,
+            checked=None,
+            check_handler=None,
+            check_all_handler=None,
+            checkable=None,
+            row_uuid_getter=None,
+            clicking_row_checks_box=False,
+            click_handlers=None,
+            main_actions=[],
+            more_actions=[],
+            delete_speedbump=False,
+            ajax_data_url=None,
+            expose_direct_link=False,
+            **kwargs,
+    ):
+        if kwargs.get('component'):
+            warnings.warn("component param is deprecated for Grid(); "
+                          "please use vue_tagname param instead",
+                          DeprecationWarning, stacklevel=2)
+            kwargs.setdefault('vue_tagname', kwargs.pop('component'))
 
-        self.key = key
-        self.data = data
-        self.columns = FieldList(columns) if columns is not None else None
-        self.width = width
-        self.request = request
-        self.model_class = model_class
-        if self.model_class and self.columns is None:
-            self.columns = self.make_columns()
+        # TODO: pretty sure this should go away?
+        kwargs.setdefault('vue_tagname', 'tailbone-grid')
+
+        kwargs['key'] = key
+        kwargs['data'] = data
+        super().__init__(request, **kwargs)
 
         self.model_title = model_title
         if not self.model_title and self.model_class and hasattr(self.model_class, 'get_model_title'):
@@ -224,15 +251,13 @@ class Grid:
             if not self.model_title_plural:
                 self.model_title_plural = '{}s'.format(self.model_title)
 
+        self.width = width
         self.enums = enums or {}
-
-        self.labels = labels or {}
         self.assume_local_times = assume_local_times
-        self.renderers = self.make_default_renderers(renderers or {})
+        self.renderers = self.make_default_renderers(self.renderers)
         self.raw_renderers = raw_renderers or {}
         self.invisible = invisible or []
         self.extra_row_class = extra_row_class
-        self.linked_columns = linked_columns or []
         self.url = url
         self.joiners = joiners or {}
 
@@ -263,8 +288,6 @@ class Grid:
 
         self.click_handlers = click_handlers or {}
 
-        self.main_actions = main_actions or []
-        self.more_actions = more_actions or []
         self.delete_speedbump = delete_speedbump
 
         if ajax_data_url:
@@ -274,29 +297,22 @@ class Grid:
         else:
             self.ajax_data_url = ''
 
-        # vue_tagname
-        self.vue_tagname = vue_tagname
-        if not self.vue_tagname and kwargs.get('component'):
-            warnings.warn("component kwarg is deprecated for Grid(); "
-                          "please use vue_tagname param instead",
+        self.main_actions = main_actions or []
+        if self.main_actions:
+            warnings.warn("main_actions param is deprecated for Grdi(); "
+                          "please use actions param instead",
                           DeprecationWarning, stacklevel=2)
-            self.vue_tagname = kwargs['component']
-        if not self.vue_tagname:
-            self.vue_tagname = 'tailbone-grid'
+            self.actions.extend(self.main_actions)
+        self.more_actions = more_actions or []
+        if self.more_actions:
+            warnings.warn("more_actions param is deprecated for Grdi(); "
+                          "please use actions param instead",
+                          DeprecationWarning, stacklevel=2)
+            self.actions.extend(self.more_actions)
 
         self.expose_direct_link = expose_direct_link
         self._whgrid_kwargs = kwargs
 
-    @property
-    def vue_component(self):
-        """
-        String name for the Vue component, e.g. ``'TailboneGrid'``.
-
-        This is a generated value based on :attr:`vue_tagname`.
-        """
-        words = self.vue_tagname.split('-')
-        return ''.join([word.capitalize() for word in words])
-
     @property
     def component(self):
         """
@@ -317,34 +333,6 @@ class Grid:
                       DeprecationWarning, stacklevel=2)
         return self.vue_component
 
-    @property
-    def actions(self):
-        """ """
-        actions = []
-        if self.main_actions:
-            actions.extend(self.main_actions)
-        if self.more_actions:
-            actions.extend(self.more_actions)
-        return actions
-
-    def make_columns(self):
-        """
-        Return a default list of columns, based on :attr:`model_class`.
-        """
-        if not self.model_class:
-            raise ValueError("Must define model_class to use make_columns()")
-
-        mapper = orm.class_mapper(self.model_class)
-        return [prop.key for prop in mapper.iterate_properties]
-
-    def remove(self, *keys):
-        """
-        This *removes* some column(s) from the grid, altogether.
-        """
-        for key in keys:
-            if key in self.columns:
-                self.columns.remove(key)
-
     def hide_column(self, key):
         """
         This *removes* a column from the grid, altogether.
@@ -377,9 +365,6 @@ class Grid:
             if key in self.invisible:
                 self.invisible.remove(key)
 
-    def append(self, field):
-        self.columns.append(field)
-
     def insert_before(self, field, newfield):
         self.columns.insert_before(field, newfield)
 
@@ -430,24 +415,22 @@ class Grid:
         self.filters.pop(key, None)
 
     def set_label(self, key, label, column_only=False):
-        self.labels[key] = label
+        """
+        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 get_label(self, key):
-        """
-        Returns the label text for given field key.
-        """
-        return self.labels.get(key, prettify(key))
-
-    def set_link(self, key, link=True):
-        if link:
-            if key not in self.linked_columns:
-                self.linked_columns.append(key)
-        else: # unlink
-            if self.linked_columns and key in self.linked_columns:
-                self.linked_columns.remove(key)
-
     def set_click_handler(self, key, handler):
         if handler:
             self.click_handlers[key] = handler
@@ -457,9 +440,6 @@ class Grid:
     def has_click_handler(self, key):
         return key in self.click_handlers
 
-    def set_renderer(self, key, renderer):
-        self.renderers[key] = renderer
-
     def set_raw_renderer(self, key, renderer):
         """
         Set or remove the "raw" renderer for the given field.
@@ -1450,22 +1430,13 @@ class Grid:
         return render(template, context)
 
     def get_view_click_handler(self):
-
+        """ """
         # locate the 'view' action
         # TODO: this should be easier, and/or moved elsewhere?
         view = None
-        for action in self.main_actions:
+        for action in self.actions:
             if action.key == 'view':
-                view = action
-                break
-        if not view:
-            for action in self.more_actions:
-                if action.key == 'view':
-                    view = action
-                    break
-
-        if view:
-            return view.click_handler
+                return action.click_handler
 
     def set_filters_sequence(self, filters, only=False):
         """
@@ -1561,26 +1532,21 @@ class Grid:
         kwargs['form'] = form
         return render(template, kwargs)
 
-    def render_actions(self, row, i):
-        """
-        Returns the rendered contents of the 'actions' column for a given row.
-        """
-        main_actions = [self.render_action(a, row, i)
-                        for a in self.main_actions]
-        main_actions = [a for a in main_actions if a]
-        more_actions = [self.render_action(a, row, i)
-                        for a in self.more_actions]
-        more_actions = [a for a in more_actions if a]
-        if more_actions:
-            icon = HTML.tag('span', class_='ui-icon ui-icon-carat-1-e')
-            link = tags.link_to("More" + icon, '#', class_='more')
-            main_actions.append(HTML.literal('&nbsp; ') + link + HTML.tag('div', class_='more', c=more_actions))
-        return HTML.literal('').join(main_actions)
+    def render_actions(self, row, i): # pragma: no cover
+        """ """
+        warnings.warn("grid.render_actions() is deprecated!",
+                      DeprecationWarning, stacklevel=2)
+
+        actions = [self.render_action(a, row, i)
+                   for a in self.actions]
+        actions = [a for a in actions if a]
+        return HTML.literal('').join(actions)
+
+    def render_action(self, action, row, i): # pragma: no cover
+        """ """
+        warnings.warn("grid.render_action() is deprecated!",
+                      DeprecationWarning, stacklevel=2)
 
-    def render_action(self, action, row, i):
-        """
-        Renders an action menu item (link) for the given row.
-        """
         url = action.get_url(row, i)
         if url:
             kwargs = {'class_': action.key, 'target': action.target}
@@ -1786,21 +1752,10 @@ class Grid:
         Pre-generate all action URLs for the given data row.  Meant for use
         with client-side table, since we can't generate URLs from JS.
         """
-        for action in (self.main_actions + self.more_actions):
+        for action in self.actions:
             url = action.get_url(rowobj, i)
             row['_action_url_{}'.format(action.key)] = url
 
-    def is_linked(self, name):
-        """
-        Should return ``True`` if the given column name is configured to be
-        "linked" (i.e. table cell should contain a link to "view object"),
-        otherwise ``False``.
-        """
-        if self.linked_columns:
-            if name in self.linked_columns:
-                return True
-        return False
-
 
 class GridAction(WuttaGridAction):
     """
diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py
index f4f74a34..5dd7b548 100644
--- a/tailbone/views/batch/core.py
+++ b/tailbone/views/batch/core.py
@@ -186,7 +186,9 @@ class BatchMasterView(MasterView):
         breakdown = self.make_status_breakdown(batch)
 
         factory = self.get_grid_factory()
-        g = factory('batch_row_status_breakdown', [],
+        g = factory(self.request,
+                    key='batch_row_status_breakdown',
+                    data=[],
                     columns=['title', 'count'])
         g.set_click_handler('title', "autoFilterStatus(props.row)")
         kwargs['status_breakdown_data'] = breakdown
@@ -693,7 +695,7 @@ class BatchMasterView(MasterView):
         batch = self.get_instance()
 
         # TODO: most of this logic is copied from MasterView, should refactor/merge somehow...
-        if 'main_actions' not in kwargs:
+        if 'actions' not in kwargs:
             actions = []
 
             # view action
@@ -714,7 +716,7 @@ class BatchMasterView(MasterView):
                     actions.append(self.make_action('delete', icon='trash', url=self.row_delete_action_url))
                     kwargs.setdefault('delete_speedbump', self.rows_deletable_speedbump)
 
-            kwargs['main_actions'] = actions
+            kwargs['actions'] = actions
 
         return super().make_row_grid_kwargs(**kwargs)
 
diff --git a/tailbone/views/batch/pos.py b/tailbone/views/batch/pos.py
index 11031353..b6fef6c8 100644
--- a/tailbone/views/batch/pos.py
+++ b/tailbone/views/batch/pos.py
@@ -195,6 +195,7 @@ class POSBatchView(BatchMasterView):
 
         factory = self.get_grid_factory()
         g = factory(
+            self.request,
             key=f'{route_prefix}.taxes',
             data=[],
             columns=[
diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py
index 2958a98a..7e49ccef 100644
--- a/tailbone/views/customers.py
+++ b/tailbone/views/customers.py
@@ -208,8 +208,7 @@ class CustomerView(MasterView):
             url = lambda r, i: self.request.route_url(
                 f'{route_prefix}.view', **self.get_action_route_kwargs(r))
             # nb. insert to slot 1, just after normal View action
-            g.main_actions.insert(1, self.make_action(
-                'view_raw', url=url, icon='eye'))
+            g.actions.insert(1, self.make_action('view_raw', url=url, icon='eye'))
 
         g.set_link('name')
         g.set_link('person')
@@ -471,7 +470,8 @@ class CustomerView(MasterView):
 
         factory = self.get_grid_factory()
         g = factory(
-            key='{}.people'.format(route_prefix),
+            self.request,
+            key=f'{route_prefix}.people',
             data=[],
             columns=[
                 'shopper_number',
@@ -500,7 +500,8 @@ class CustomerView(MasterView):
 
         factory = self.get_grid_factory()
         g = factory(
-            key='{}.people'.format(route_prefix),
+            self.request,
+            key=f'{route_prefix}.people',
             data=[],
             columns=[
                 'full_name',
@@ -512,13 +513,13 @@ class CustomerView(MasterView):
         )
 
         if self.request.has_perm('people.view'):
-            g.main_actions.append(self.make_action('view', icon='eye'))
+            g.actions.append(self.make_action('view', icon='eye'))
         if self.request.has_perm('people.edit'):
-            g.main_actions.append(self.make_action('edit', icon='edit'))
+            g.actions.append(self.make_action('edit', icon='edit'))
         if self.people_detachable and self.has_perm('detach_person'):
-            g.main_actions.append(self.make_action('detach', icon='minus-circle',
-                                                   link_class='has-text-warning',
-                                                   click_handler="$emit('detach-person', props.row._action_url_detach)"))
+            g.actions.append(self.make_action('detach', icon='minus-circle',
+                                              link_class='has-text-warning',
+                                              click_handler="$emit('detach-person', props.row._action_url_detach)"))
 
         return HTML.literal(
             g.render_table_element(data_prop='peopleData'))
diff --git a/tailbone/views/custorders/items.py b/tailbone/views/custorders/items.py
index d8e39f55..e7edf3aa 100644
--- a/tailbone/views/custorders/items.py
+++ b/tailbone/views/custorders/items.py
@@ -385,6 +385,7 @@ class CustomerOrderItemView(MasterView):
 
         factory = self.get_grid_factory()
         g = factory(
+            self.request,
             key=f'{route_prefix}.events',
             data=[],
             columns=[
diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py
index f76d4d93..b1a9831a 100644
--- a/tailbone/views/custorders/orders.py
+++ b/tailbone/views/custorders/orders.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.
 #
@@ -29,13 +29,12 @@ import logging
 
 from sqlalchemy import orm
 
-from rattail.db import model
-from rattail.util import pretty_quantity, simple_error
+from rattail.db.model import CustomerOrder, CustomerOrderItem
+from rattail.util import simple_error
 from rattail.batch import get_batch_handler
 
 from webhelpers2.html import tags, HTML
 
-from tailbone.db import Session
 from tailbone.views import MasterView
 
 
@@ -46,7 +45,7 @@ class CustomerOrderView(MasterView):
     """
     Master view for customer orders
     """
-    model_class = model.CustomerOrder
+    model_class = CustomerOrder
     route_prefix = 'custorders'
     editable = False
     configurable = True
@@ -80,7 +79,7 @@ class CustomerOrderView(MasterView):
     ]
 
     has_rows = True
-    model_row_class = model.CustomerOrderItem
+    model_row_class = CustomerOrderItem
     rows_viewable = False
 
     row_labels = {
@@ -116,15 +115,17 @@ class CustomerOrderView(MasterView):
     ]
 
     def __init__(self, request):
-        super(CustomerOrderView, self).__init__(request)
+        super().__init__(request)
         self.batch_handler = self.get_batch_handler()
 
     def query(self, session):
+        model = self.app.model
         return session.query(model.CustomerOrder)\
                       .options(orm.joinedload(model.CustomerOrder.customer))
 
     def configure_grid(self, g):
         super().configure_grid(g)
+        model = self.app.model
 
         # id
         g.set_link('id')
@@ -163,7 +164,7 @@ class CustomerOrderView(MasterView):
         return f"#{order.id} for {order.customer or order.person}"
 
     def configure_form(self, f):
-        super(CustomerOrderView, self).configure_form(f)
+        super().configure_form(f)
         order = f.model_instance
 
         f.set_readonly('id')
@@ -233,6 +234,7 @@ class CustomerOrderView(MasterView):
                             class_='has-background-warning')
 
     def get_row_data(self, order):
+        model = self.app.model
         return self.Session.query(model.CustomerOrderItem)\
                            .filter(model.CustomerOrderItem.order == order)
 
@@ -240,11 +242,13 @@ class CustomerOrderView(MasterView):
         return item.order
 
     def make_row_grid_kwargs(self, **kwargs):
-        kwargs = super(CustomerOrderView, self).make_row_grid_kwargs(**kwargs)
+        kwargs = super().make_row_grid_kwargs(**kwargs)
 
-        assert not kwargs['main_actions']
-        kwargs['main_actions'].append(
-            self.make_action('view', icon='eye', url=self.row_view_action_url))
+        actions = kwargs.get('actions', [])
+        if not actions:
+            actions.append(self.make_action('view', icon='eye',
+                                            url=self.row_view_action_url))
+            kwargs['actions'] = actions
 
         return kwargs
 
@@ -253,7 +257,7 @@ class CustomerOrderView(MasterView):
             return self.request.route_url('custorders.items.view', uuid=item.uuid)
 
     def configure_row_grid(self, g):
-        super(CustomerOrderView, self).configure_row_grid(g)
+        super().configure_row_grid(g)
         app = self.get_rattail_app()
         handler = app.get_batch_handler(
             'custorder',
@@ -423,6 +427,7 @@ class CustomerOrderView(MasterView):
         if not user:
             raise RuntimeError("this feature requires a user to be logged in")
 
+        model = self.app.model
         try:
             # there should be at most *one* new batch per user
             batch = self.Session.query(model.CustomerOrderBatch)\
@@ -488,6 +493,7 @@ class CustomerOrderView(MasterView):
         if not uuid:
             return {'error': "Must specify a customer UUID"}
 
+        model = self.app.model
         customer = self.Session.get(model.Customer, uuid)
         if not customer:
             return {'error': "Customer not found"}
@@ -508,6 +514,7 @@ class CustomerOrderView(MasterView):
         return info
 
     def assign_contact(self, batch, data):
+        model = self.app.model
         kwargs = {}
 
         # this will either be a Person or Customer UUID
@@ -662,6 +669,7 @@ class CustomerOrderView(MasterView):
         if not uuid:
             return {'error': "Must specify a product UUID"}
 
+        model = self.app.model
         product = self.Session.get(model.Product, uuid)
         if not product:
             return {'error': "Product not found"}
@@ -725,8 +733,7 @@ class CustomerOrderView(MasterView):
             return app.render_currency(obj.unit_price)
 
     def normalize_row(self, row):
-        app = self.get_rattail_app()
-        products_handler = app.get_products_handler()
+        products_handler = self.app.get_products_handler()
 
         data = {
             'uuid': row.uuid,
@@ -742,20 +749,20 @@ class CustomerOrderView(MasterView):
             'product_size': row.product_size,
             'product_weighed': row.product_weighed,
 
-            'case_quantity': pretty_quantity(row.case_quantity),
-            'cases_ordered': pretty_quantity(row.cases_ordered),
-            'units_ordered': pretty_quantity(row.units_ordered),
-            'order_quantity': pretty_quantity(row.order_quantity),
+            'case_quantity': self.app.render_quantity(row.case_quantity),
+            'cases_ordered': self.app.render_quantity(row.cases_ordered),
+            'units_ordered': self.app.render_quantity(row.units_ordered),
+            'order_quantity': self.app.render_quantity(row.order_quantity),
             'order_uom': row.order_uom,
             'order_uom_choices': self.uom_choices_for_row(row),
-            'discount_percent': pretty_quantity(row.discount_percent),
+            'discount_percent': self.app.render_quantity(row.discount_percent),
 
             'department_display': row.department_name,
 
             'unit_price': float(row.unit_price) if row.unit_price is not None else None,
             'unit_price_display': self.get_unit_price_display(row),
             'total_price': float(row.total_price) if row.total_price is not None else None,
-            'total_price_display': app.render_currency(row.total_price),
+            'total_price_display': self.app.render_currency(row.total_price),
 
             'status_code': row.status_code,
             'status_text': row.status_text,
@@ -763,15 +770,15 @@ class CustomerOrderView(MasterView):
 
         if row.unit_regular_price:
             data['unit_regular_price'] = float(row.unit_regular_price)
-            data['unit_regular_price_display'] = app.render_currency(row.unit_regular_price)
+            data['unit_regular_price_display'] = self.app.render_currency(row.unit_regular_price)
 
         if row.unit_sale_price:
             data['unit_sale_price'] = float(row.unit_sale_price)
-            data['unit_sale_price_display'] = app.render_currency(row.unit_sale_price)
+            data['unit_sale_price_display'] = self.app.render_currency(row.unit_sale_price)
         if row.sale_ends:
-            sale_ends = app.localtime(row.sale_ends, from_utc=True).date()
+            sale_ends = self.app.localtime(row.sale_ends, from_utc=True).date()
             data['sale_ends'] = str(sale_ends)
-            data['sale_ends_display'] = app.render_date(sale_ends)
+            data['sale_ends_display'] = self.app.render_date(sale_ends)
 
         if row.unit_sale_price and row.unit_price == row.unit_sale_price:
             data['pricing_reflects_sale'] = True
@@ -808,12 +815,12 @@ class CustomerOrderView(MasterView):
 
         case_price = self.batch_handler.get_case_price_for_row(row)
         data['case_price'] = float(case_price) if case_price is not None else None
-        data['case_price_display'] = app.render_currency(case_price)
+        data['case_price_display'] = self.app.render_currency(case_price)
 
         if self.batch_handler.product_price_may_be_questionable():
             data['price_needs_confirmation'] = row.price_needs_confirmation
 
-        key = app.get_product_key_field()
+        key = self.app.get_product_key_field()
         if key == 'upc':
             data['product_key'] = data['product_upc_pretty']
         elif key == 'item_id':
@@ -837,7 +844,7 @@ class CustomerOrderView(MasterView):
                 case_qty = unit_qty = '??'
             else:
                 case_qty = data['case_quantity']
-                unit_qty = pretty_quantity(row.order_quantity * row.case_quantity)
+                unit_qty = self.app.render_quantity(row.order_quantity * row.case_quantity)
             data.update({
                 'order_quantity_display': "{} {} (&times; {} {} = {} {})".format(
                     data['order_quantity'],
@@ -850,14 +857,14 @@ class CustomerOrderView(MasterView):
         else:
             data.update({
                 'order_quantity_display': "{} {}".format(
-                    pretty_quantity(row.order_quantity),
+                    self.app.render_quantity(row.order_quantity),
                     self.enum.UNIT_OF_MEASURE[unit_uom]),
             })
 
         return data
 
     def add_item(self, batch, data):
-        app = self.get_rattail_app()
+        model = self.app.model
 
         order_quantity = decimal.Decimal(data.get('order_quantity') or '0')
         order_uom = data.get('order_uom')
@@ -888,7 +895,7 @@ class CustomerOrderView(MasterView):
             pending_info = dict(data['pending_product'])
 
             if 'upc' in pending_info:
-                pending_info['upc'] = app.make_gpc(pending_info['upc'])
+                pending_info['upc'] = self.app.make_gpc(pending_info['upc'])
 
             for field in ('unit_cost', 'regular_price_amount', 'case_size'):
                 if field in pending_info:
@@ -917,6 +924,7 @@ class CustomerOrderView(MasterView):
         if not uuid:
             return {'error': "Must specify a row UUID"}
 
+        model = self.app.model
         row = self.Session.get(model.CustomerOrderBatchRow, uuid)
         if not row:
             return {'error': "Row not found"}
@@ -975,6 +983,7 @@ class CustomerOrderView(MasterView):
         if not uuid:
             return {'error': "Must specify a row UUID"}
 
+        model = self.app.model
         row = self.Session.get(model.CustomerOrderBatchRow, uuid)
         if not row:
             return {'error': "Row not found"}
diff --git a/tailbone/views/departments.py b/tailbone/views/departments.py
index 6ee1439f..47de8dca 100644
--- a/tailbone/views/departments.py
+++ b/tailbone/views/departments.py
@@ -128,8 +128,8 @@ class DepartmentView(MasterView):
 
         factory = self.get_grid_factory()
         g = factory(
-            key='{}.employees'.format(route_prefix),
-            request=self.request,
+            self.request,
+            key=f'{route_prefix}.employees',
             data=[],
             columns=[
                 'first_name',
@@ -140,9 +140,9 @@ class DepartmentView(MasterView):
         )
 
         if self.request.has_perm('employees.view'):
-            g.main_actions.append(self.make_action('view', icon='eye'))
+            g.actions.append(self.make_action('view', icon='eye'))
         if self.request.has_perm('employees.edit'):
-            g.main_actions.append(self.make_action('edit', icon='edit'))
+            g.actions.append(self.make_action('edit', icon='edit'))
 
         return HTML.literal(
             g.render_table_element(data_prop='employeesData'))
diff --git a/tailbone/views/email.py b/tailbone/views/email.py
index 4014c05e..a99e8553 100644
--- a/tailbone/views/email.py
+++ b/tailbone/views/email.py
@@ -141,7 +141,7 @@ class EmailSettingView(MasterView):
 
         # toggle hidden
         if self.has_perm('configure'):
-            g.main_actions.append(
+            g.actions.append(
                 self.make_action('toggle_hidden', url='#', icon='ban',
                                  click_handler='toggleHidden(props.row)',
                                  factory=ToggleHidden))
diff --git a/tailbone/views/employees.py b/tailbone/views/employees.py
index f4f99058..debd8fcb 100644
--- a/tailbone/views/employees.py
+++ b/tailbone/views/employees.py
@@ -167,8 +167,7 @@ class EmployeeView(MasterView):
             url = lambda r, i: self.request.route_url(
                 f'{route_prefix}.view', **self.get_action_route_kwargs(r))
             # nb. insert to slot 1, just after normal View action
-            g.main_actions.insert(1, self.make_action(
-                'view_raw', url=url, icon='eye'))
+            g.actions.insert(1, self.make_action('view_raw', url=url, icon='eye'))
 
     def default_view_url(self):
         if (self.request.has_perm('people.view_profile')
diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index 097cb229..8f65fc88 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -392,9 +392,8 @@ class MasterView(View):
         if columns is None:
             columns = self.get_grid_columns()
 
-        kwargs.setdefault('request', self.request)
         kwargs = self.make_grid_kwargs(**kwargs)
-        grid = factory(key, data, columns, **kwargs)
+        grid = factory(self.request, key=key, data=data, columns=columns, **kwargs)
         self.configure_grid(grid)
         grid.load_settings()
         return grid
@@ -454,10 +453,26 @@ class MasterView(View):
         if self.sortable or self.pageable or self.filterable:
             defaults['expose_direct_link'] = True
 
-        if 'main_actions' not in kwargs and 'more_actions' not in kwargs:
-            main, more = self.get_grid_actions()
-            defaults['main_actions'] = main
-            defaults['more_actions'] = more
+        if 'actions' not in kwargs:
+
+            if 'main_actions' in kwargs:
+                warnings.warn("main_actions param is deprecated for make_grid_kwargs(); "
+                              "please use actions param instead",
+                              DeprecationWarning, stacklevel=2)
+                main = kwargs.pop('main_actions')
+            else:
+                main = self.get_main_actions()
+
+            if 'more_actions' in kwargs:
+                warnings.warn("more_actions param is deprecated for make_grid_kwargs(); "
+                              "please use actions param instead",
+                              DeprecationWarning, stacklevel=2)
+                more = kwargs.pop('more_actions')
+            else:
+                more = self.get_more_actions()
+
+            defaults['actions'] = main + more
+
         defaults.update(kwargs)
         return defaults
 
@@ -548,9 +563,8 @@ class MasterView(View):
         if columns is None:
             columns = self.get_row_grid_columns()
 
-        kwargs.setdefault('request', self.request)
         kwargs = self.make_row_grid_kwargs(**kwargs)
-        grid = factory(key, data, columns, **kwargs)
+        grid = factory(self.request, key=key, data=data, columns=columns, **kwargs)
         self.configure_row_grid(grid)
         grid.load_settings()
         return grid
@@ -577,7 +591,7 @@ class MasterView(View):
         if self.rows_default_pagesize:
             defaults['default_pagesize'] = self.rows_default_pagesize
 
-        if self.has_rows and 'main_actions' not in defaults:
+        if self.has_rows and 'actions' not in defaults:
             actions = []
 
             # view action
@@ -595,7 +609,7 @@ class MasterView(View):
                 actions.append(self.make_action('delete', icon='trash', url=self.row_delete_action_url))
                 defaults['delete_speedbump'] = self.rows_deletable_speedbump
 
-            defaults['main_actions'] = actions
+            defaults['actions'] = actions
 
         defaults.update(kwargs)
         return defaults
@@ -630,9 +644,8 @@ class MasterView(View):
         if columns is None:
             columns = self.get_version_grid_columns()
 
-        kwargs.setdefault('request', self.request)
         kwargs = self.make_version_grid_kwargs(**kwargs)
-        grid = factory(key, data, columns, **kwargs)
+        grid = factory(self.request, key=key, data=data, columns=columns, **kwargs)
         self.configure_version_grid(grid)
         grid.load_settings()
         return grid
@@ -661,9 +674,9 @@ class MasterView(View):
             'pageable': True,
             'url': lambda txn: self.request.route_url(route, uuid=instance.uuid, txnid=txn.id),
         }
-        if 'main_actions' not in kwargs:
+        if 'actions' not in kwargs:
             url = lambda txn, i: self.request.route_url(route, uuid=instance.uuid, txnid=txn.id)
-            defaults['main_actions'] = [
+            defaults['actions'] = [
                 self.make_action('view', icon='eye', url=url),
             ]
         defaults.update(kwargs)
@@ -1372,7 +1385,7 @@ class MasterView(View):
             'sortable': True,
             'default_sortkey': 'changed',
             'default_sortdir': 'desc',
-            'main_actions': [
+            'actions': [
                 self.make_action('view', icon='eye', url='#',
                                  click_handler='viewRevision(props.row)'),
                 self.make_action('view_separate', url=row_url, target='_blank',
@@ -3111,6 +3124,11 @@ class MasterView(View):
         return key
 
     def get_grid_actions(self):
+        """ """
+        warnings.warn("get_grid_actions() method is deprecated; "
+                      "please use get_main_actions() or get_more_actions() instead",
+                      DeprecationWarning, stacklevel=2)
+
         main, more = self.get_main_actions(), self.get_more_actions()
         if len(more) == 1:
             main, more = main + more, []
diff --git a/tailbone/views/members.py b/tailbone/views/members.py
index de844eb7..46ed7e4b 100644
--- a/tailbone/views/members.py
+++ b/tailbone/views/members.py
@@ -229,8 +229,7 @@ class MemberView(MasterView):
             url = lambda r, i: self.request.route_url(
                 f'{route_prefix}.view', **self.get_action_route_kwargs(r))
             # nb. insert to slot 1, just after normal View action
-            g.main_actions.insert(1, self.make_action(
-                'view_raw', url=url, icon='eye'))
+            g.actions.insert(1, self.make_action('view_raw', url=url, icon='eye'))
 
         # equity_total
         # TODO: should make this configurable
diff --git a/tailbone/views/people.py b/tailbone/views/people.py
index 163a9a52..020babc5 100644
--- a/tailbone/views/people.py
+++ b/tailbone/views/people.py
@@ -175,8 +175,7 @@ class PersonView(MasterView):
             url = lambda r, i: self.request.route_url(
                 f'{route_prefix}.view', **self.get_action_route_kwargs(r))
             # nb. insert to slot 1, just after normal View action
-            g.main_actions.insert(1, self.make_action(
-                'view_raw', url=url, icon='eye'))
+            g.actions.insert(1, self.make_action('view_raw', url=url, icon='eye'))
 
         g.set_link('display_name')
         g.set_link('first_name')
@@ -522,9 +521,9 @@ class PersonView(MasterView):
             data = self.profile_transactions_query(person)
         factory = self.get_grid_factory()
         g = factory(
-            f'{route_prefix}.profile.transactions.{person.uuid}',
-            data,
-            request=self.request,
+            self.request,
+            key=f'{route_prefix}.profile.transactions.{person.uuid}',
+            data=data,
             model_class=model.Transaction,
             ajax_data_url=self.get_action_url('view_profile_transactions', person),
             columns=[
@@ -552,7 +551,7 @@ class PersonView(MasterView):
         if self.request.has_perm('trainwreck.transactions.view'):
             url = lambda row, i: self.request.route_url('trainwreck.transactions.view',
                                                         uuid=row.uuid)
-            g.main_actions.append(self.make_action('view', icon='eye', url=url))
+            g.actions.append(self.make_action('view', icon='eye', url=url))
         g.load_settings()
 
         g.set_enum('system', self.enum.TRAINWRECK_SYSTEM)
@@ -1413,9 +1412,9 @@ class PersonView(MasterView):
         route_prefix = self.get_route_prefix()
         factory = self.get_grid_factory()
         g = factory(
-            '{}.profile.revisions'.format(route_prefix),
-            [],                 # start with empty data!
-            request=self.request,
+            self.request,
+            key=f'{route_prefix}.profile.revisions',
+            data=[],                 # start with empty data!
             columns=[
                 'changed',
                 'changed_by',
@@ -1430,7 +1429,7 @@ class PersonView(MasterView):
                 'changed_by',
                 'comment',
             ],
-            main_actions=[
+            actions=[
                 self.make_action('view', icon='eye', url='#',
                                  click_handler='viewRevision(props.row)'),
             ],
diff --git a/tailbone/views/poser/reports.py b/tailbone/views/poser/reports.py
index 462df51d..ded80b18 100644
--- a/tailbone/views/poser/reports.py
+++ b/tailbone/views/poser/reports.py
@@ -110,7 +110,7 @@ class PoserReportView(PoserMasterView):
         g.set_searchable('description')
 
         if self.request.has_perm('report_output.create'):
-            g.more_actions.append(self.make_action(
+            g.actions.append(self.make_action(
                 'generate', icon='arrow-circle-right',
                 url=self.get_generate_url))
 
diff --git a/tailbone/views/principal.py b/tailbone/views/principal.py
index bb799efc..3986f8b0 100644
--- a/tailbone/views/principal.py
+++ b/tailbone/views/principal.py
@@ -124,11 +124,11 @@ class PrincipalMasterView(MasterView):
     def find_by_perm_make_results_grid(self, principals):
         route_prefix = self.get_route_prefix()
         factory = self.get_grid_factory()
-        g = factory(key=f'{route_prefix}.results',
-                    request=self.request,
+        g = factory(self.request,
+                    key=f'{route_prefix}.results',
                     data=[],
                     columns=[],
-                    main_actions=[
+                    actions=[
                         self.make_action('view', icon='eye',
                                          click_handler='navigateTo(props.row._url)'),
                     ])
diff --git a/tailbone/views/products.py b/tailbone/views/products.py
index bf2d7f14..c546a0f4 100644
--- a/tailbone/views/products.py
+++ b/tailbone/views/products.py
@@ -384,7 +384,7 @@ class ProductView(MasterView):
         g.set_filter('report_code_name', model.ReportCode.name)
 
         if self.expose_label_printing and self.has_perm('print_labels'):
-            g.more_actions.append(self.make_action(
+            g.actions.append(self.make_action(
                 'print_label', icon='print', url='#',
                 click_handler='quickLabelPrint(props.row)'))
 
@@ -1197,8 +1197,9 @@ class ProductView(MasterView):
 
             # regular price
             data = []       # defer fetching until user asks for it
-            grid = grids.Grid('products.regular_price_history', data,
-                              request=self.request,
+            grid = grids.Grid(self.request,
+                              key='products.regular_price_history',
+                              data=data,
                               columns=[
                                   'price',
                                   'since',
@@ -1211,8 +1212,9 @@ class ProductView(MasterView):
 
             # current price
             data = []       # defer fetching until user asks for it
-            grid = grids.Grid('products.current_price_history', data,
-                              request=self.request,
+            grid = grids.Grid(self.request,
+                              key='products.current_price_history',
+                              data=data,
                               columns=[
                                   'price',
                                   'price_type',
@@ -1229,8 +1231,9 @@ class ProductView(MasterView):
 
             # suggested price
             data = []       # defer fetching until user asks for it
-            grid = grids.Grid('products.suggested_price_history', data,
-                              request=self.request,
+            grid = grids.Grid(self.request,
+                              key='products.suggested_price_history',
+                              data=data,
                               columns=[
                                   'price',
                                   'since',
@@ -1243,8 +1246,9 @@ class ProductView(MasterView):
 
             # cost history
             data = []       # defer fetching until user asks for it
-            grid = grids.Grid('products.cost_history', data,
-                              request=self.request,
+            grid = grids.Grid(self.request,
+                              key='products.cost_history',
+                              data=data,
                               columns=[
                                   'cost',
                                   'vendor',
@@ -1335,7 +1339,8 @@ class ProductView(MasterView):
 
         factory = self.get_grid_factory()
         g = factory(
-            key='{}.vendor_sources'.format(route_prefix),
+            self.request,
+            key=f'{route_prefix}.vendor_sources',
             data=[],
             columns=columns,
             labels={
@@ -1376,7 +1381,8 @@ class ProductView(MasterView):
 
         factory = self.get_grid_factory()
         g = factory(
-            key='{}.lookup_codes'.format(route_prefix),
+            self.request,
+            key=f'{route_prefix}.lookup_codes',
             data=[],
             columns=[
                 'sequence',
diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py
index 1d11130c..590b9af5 100644
--- a/tailbone/views/purchasing/batch.py
+++ b/tailbone/views/purchasing/batch.py
@@ -793,8 +793,8 @@ class PurchasingBatchView(BatchMasterView):
         factory = self.get_grid_factory()
 
         g = factory(
-            key='{}.row_credits'.format(route_prefix),
-            request=self.request,
+            self.request,
+            key=f'{route_prefix}.row_credits',
             data=[],
             columns=[
                 'credit_type',
diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py
index 0a305f0a..de19a2b9 100644
--- a/tailbone/views/purchasing/receiving.py
+++ b/tailbone/views/purchasing/receiving.py
@@ -774,8 +774,10 @@ class ReceivingBatchView(PurchasingBatchView):
             breakdown = self.make_po_vs_invoice_breakdown(batch)
             factory = self.get_grid_factory()
 
-            g = factory('batch_po_vs_invoice_breakdown', [],
-                columns=['title', 'count'])
+            g = factory(self.request,
+                        key='batch_po_vs_invoice_breakdown',
+                        data=[],
+                        columns=['title', 'count'])
             g.set_click_handler('title', "autoFilterPoVsInvoice(props.row)")
             kwargs['po_vs_invoice_breakdown_data'] = breakdown
             kwargs['po_vs_invoice_breakdown_grid'] = HTML.literal(
@@ -1035,10 +1037,12 @@ class ReceivingBatchView(PurchasingBatchView):
                                              icon='shuffle',
                                              label="Transform to Unit",
                                              url=self.transform_unit_url)
-                g.more_actions.append(transform)
-                if g.main_actions and g.main_actions[-1].key == 'delete':
-                    delete = g.main_actions.pop()
-                    g.more_actions.append(delete)
+                if g.actions and g.actions[-1].key == 'delete':
+                    delete = g.actions.pop()
+                    g.actions.append(transform)
+                    g.actions.append(delete)
+                else:
+                    g.actions.append(transform)
 
         # truck_dump_status
         if not batch.is_truck_dump_parent():
@@ -1111,7 +1115,7 @@ class ReceivingBatchView(PurchasingBatchView):
             and self.row_editable(row)):
 
             # add the Un-Declare action
-            g.main_actions.append(self.make_action(
+            g.actions.append(self.make_action(
                 'remove', label="Un-Declare",
                 url='#', icon='trash',
                 link_class='has-text-danger',
diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py
index aedda61c..099224be 100644
--- a/tailbone/views/reports.py
+++ b/tailbone/views/reports.py
@@ -308,7 +308,8 @@ class ReportOutputView(ExportMasterView):
         route_prefix = self.get_route_prefix()
         factory = self.get_grid_factory()
         g = factory(
-            key='{}.params'.format(route_prefix),
+            self.request,
+            key=f'{route_prefix}.params',
             data=params,
             columns=['key', 'value'],
             labels={'key': "Name"},
@@ -705,9 +706,12 @@ class ProblemReportView(MasterView):
         return ', '.join(recips)
 
     def render_days(self, report_info, field):
-        g = self.get_grid_factory()('days', [],
-                                    columns=['weekday_name', 'enabled'],
-                                    labels={'weekday_name': "Weekday"})
+        factory = self.get_grid_factory()
+        g = factory(self.request,
+                    key='days',
+                    data=[],
+                    columns=['weekday_name', 'enabled'],
+                    labels={'weekday_name': "Weekday"})
         return HTML.literal(g.render_table_element(data_prop='weekdaysData'))
 
     def template_kwargs_view(self, **kwargs):
diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py
index fb834479..e8a6d8a2 100644
--- a/tailbone/views/roles.py
+++ b/tailbone/views/roles.py
@@ -255,8 +255,8 @@ class RoleView(PrincipalMasterView):
         permission_prefix = self.get_permission_prefix()
         factory = self.get_grid_factory()
         g = factory(
-            key='{}.users'.format(route_prefix),
-            request=self.request,
+            self.request,
+            key=f'{route_prefix}.users',
             data=[],
             columns=[
                 'full_name',
@@ -269,9 +269,9 @@ class RoleView(PrincipalMasterView):
         )
 
         if self.request.has_perm('users.view'):
-            g.main_actions.append(self.make_action('view', icon='eye'))
+            g.actions.append(self.make_action('view', icon='eye'))
         if self.request.has_perm('users.edit'):
-            g.main_actions.append(self.make_action('edit', icon='edit'))
+            g.actions.append(self.make_action('edit', icon='edit'))
 
         return HTML.literal(
             g.render_table_element(data_prop='usersData'))
@@ -366,10 +366,11 @@ class RoleView(PrincipalMasterView):
                 self.make_action('view', icon='zoomin',
                                  url=lambda r, i: self.request.route_url('users.view', uuid=r.uuid))
             ]
-            kwargs['users'] = grids.Grid(None, users, ['username', 'active'],
-                                         request=self.request,
+            kwargs['users'] = grids.Grid(self.request,
+                                         data=users,
+                                         columns=['username', 'active'],
                                          model_class=model.User,
-                                         main_actions=actions)
+                                         actions=actions)
         else:
             kwargs['users'] = None
 
diff --git a/tailbone/views/tempmon/core.py b/tailbone/views/tempmon/core.py
index d551d6e6..7540abbe 100644
--- a/tailbone/views/tempmon/core.py
+++ b/tailbone/views/tempmon/core.py
@@ -77,8 +77,8 @@ class MasterView(views.MasterView):
 
         factory = self.get_grid_factory()
         g = factory(
-            key='{}.probes'.format(route_prefix),
-            request=self.request,
+            self.request,
+            key=f'{route_prefix}.probes',
             data=[],
             columns=[
                 'description',
@@ -96,7 +96,7 @@ class MasterView(views.MasterView):
                 'critical_temp_max': "Crit. Max",
             },
             linked_columns=['description'],
-            main_actions=actions,
+            actions=actions,
         )
         return HTML.literal(
             g.render_table_element(data_prop='probesData'))
diff --git a/tailbone/views/trainwreck/base.py b/tailbone/views/trainwreck/base.py
index 9c150c6a..d5f077aa 100644
--- a/tailbone/views/trainwreck/base.py
+++ b/tailbone/views/trainwreck/base.py
@@ -246,10 +246,10 @@ class TransactionView(MasterView):
         factory = self.get_grid_factory()
 
         g = factory(
-            key='{}.custorder_xref_markers'.format(route_prefix),
+            self.request,
+            key=f'{route_prefix}.custorder_xref_markers',
             data=[],
-            columns=['custorder_xref', 'custorder_item_xref'],
-            request=self.request)
+            columns=['custorder_xref', 'custorder_item_xref'])
 
         return HTML.literal(
             g.render_table_element(data_prop='custorderXrefMarkersData'))
@@ -355,11 +355,11 @@ class TransactionView(MasterView):
         factory = self.get_grid_factory()
 
         g = factory(
-            key='{}.discounts'.format(route_prefix),
+            self.request,
+            key=f'{route_prefix}.discounts',
             data=[],
             columns=['discount_type', 'description', 'amount'],
-            labels={'discount_type': "Type"},
-            request=self.request)
+            labels={'discount_type': "Type"})
 
         return HTML.literal(
             g.render_table_element(data_prop='discountsData'))
diff --git a/tailbone/views/users.py b/tailbone/views/users.py
index 9eae74d8..9b533efe 100644
--- a/tailbone/views/users.py
+++ b/tailbone/views/users.py
@@ -44,9 +44,6 @@ class UserView(PrincipalMasterView):
     Master view for the User model.
     """
     model_class = User
-    has_rows = True
-    rows_title = "User Events"
-    model_row_class = UserEvent
     has_versions = True
     touchable = True
     mergeable = True
@@ -77,6 +74,11 @@ class UserView(PrincipalMasterView):
         'permissions',
     ]
 
+    has_rows = True
+    model_row_class = UserEvent
+    rows_title = "User Events"
+    rows_viewable = False
+
     row_grid_columns = [
         'type_code',
         'occurred',
@@ -297,11 +299,11 @@ class UserView(PrincipalMasterView):
 
         factory = self.get_grid_factory()
         g = factory(
-            request=self.request,
-            key='{}.api_tokens'.format(route_prefix),
+            self.request,
+            key=f'{route_prefix}.api_tokens',
             data=[],
             columns=['description', 'created'],
-            main_actions=[
+            actions=[
                 self.make_action('delete', icon='trash',
                                  click_handler="$emit('api-token-delete', props.row)")])
 
@@ -514,7 +516,6 @@ class UserView(PrincipalMasterView):
         g.set_sort_defaults('occurred', 'desc')
         g.set_enum('type_code', self.enum.USER_EVENT)
         g.set_label('type_code', "Event Type")
-        g.main_actions = []
 
     def get_version_child_classes(self):
         model = self.model
diff --git a/tests/grids/test_core.py b/tests/grids/test_core.py
index 0a8d5d66..0d0fe112 100644
--- a/tests/grids/test_core.py
+++ b/tests/grids/test_core.py
@@ -12,9 +12,8 @@ class TestGrid(WebTestCase):
         self.setup_web()
         self.config.setdefault('rattail.web.menus.handler_spec', 'tests.util:NullMenuHandler')
 
-    def make_grid(self, key, data=[], **kwargs):
-        kwargs.setdefault('request', self.request)
-        return mod.Grid(key, data=data, **kwargs)
+    def make_grid(self, key=None, data=[], **kwargs):
+        return mod.Grid(self.request, key=key, data=data, **kwargs)
 
     def test_basic(self):
         grid = self.make_grid('foo')
@@ -90,6 +89,50 @@ class TestGrid(WebTestCase):
         grid = self.make_grid('foo', main_actions=['foo'], more_actions=['bar'])
         self.assertEqual(grid.actions, ['foo', 'bar'])
 
+    def test_set_label(self):
+        model = self.app.model
+        grid = self.make_grid(model_class=model.Setting)
+        self.assertEqual(grid.labels, {})
+
+        # basic
+        grid.set_label('name', "NAME COL")
+        self.assertEqual(grid.labels['name'], "NAME COL")
+
+        # can replace label
+        grid.set_label('name', "Different")
+        self.assertEqual(grid.labels['name'], "Different")
+        self.assertEqual(grid.get_label('name'), "Different")
+
+        # can update only column, not filter
+        self.assertEqual(grid.labels, {'name': "Different"})
+        self.assertIn('name', grid.filters)
+        self.assertEqual(grid.filters['name'].label, "Different")
+        grid.set_label('name', "COLUMN ONLY", column_only=True)
+        self.assertEqual(grid.get_label('name'), "COLUMN ONLY")
+        self.assertEqual(grid.filters['name'].label, "Different")
+
+    def test_get_view_click_handler(self):
+        model = self.app.model
+        grid = self.make_grid(model_class=model.Setting)
+
+        grid.actions.append(
+            mod.GridAction(self.request, 'view',
+                           click_handler='clickHandler(props.row)'))
+
+        handler = grid.get_view_click_handler()
+        self.assertEqual(handler, 'clickHandler(props.row)')
+
+    def test_set_action_urls(self):
+        model = self.app.model
+        grid = self.make_grid(model_class=model.Setting)
+
+        grid.actions.append(
+            mod.GridAction(self.request, 'view', url='/blarg'))
+
+        setting = {'name': 'foo', 'value': 'bar'}
+        grid.set_action_urls(setting, setting, 0)
+        self.assertEqual(setting['_action_url_view'], '/blarg')
+
     def test_render_vue_tag(self):
         model = self.app.model
 

From 9da2a148c65ebde63b39903028bdf77577d53780 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 16 Aug 2024 18:45:04 -0500
Subject: [PATCH 445/542] feat: move "basic" grid pagination logic to wuttaweb

so far only "simple" pagination is supported by wuttaweb, so basically
the main feature flag, page size, current page.  in this
scenario *all* data is written to client-side JSON and Buefy handles
the actual pagination.

backend pagination coming soon for wuttaweb but for now tailbone still
handles all that.
---
 tailbone/grids/core.py                 | 130 +++++++++++++++++--------
 tailbone/templates/grids/complete.mako |  18 ++--
 tailbone/views/master.py               |   4 +-
 tailbone/views/wutta/people.py         |   4 +
 tests/grids/test_core.py               |  86 ++++++++++++++++
 5 files changed, 195 insertions(+), 47 deletions(-)

diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py
index a5617215..0b23fb78 100644
--- a/tailbone/grids/core.py
+++ b/tailbone/grids/core.py
@@ -31,6 +31,7 @@ import logging
 import sqlalchemy as sa
 from sqlalchemy import orm
 
+from wuttjamaican.util import UNSPECIFIED
 from rattail.db.types import GPCType
 from rattail.util import prettify, pretty_boolean
 
@@ -209,9 +210,6 @@ class Grid(WuttaGrid):
             sorters={},
             default_sortkey=None,
             default_sortdir='asc',
-            pageable=False,
-            default_pagesize=None,
-            default_page=1,
             checkboxes=False,
             checked=None,
             check_handler=None,
@@ -233,7 +231,26 @@ class Grid(WuttaGrid):
                           DeprecationWarning, stacklevel=2)
             kwargs.setdefault('vue_tagname', kwargs.pop('component'))
 
-        # TODO: pretty sure this should go away?
+        if kwargs.get('pageable'):
+            warnings.warn("component 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'):
+            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'):
+            warnings.warn("default_page param is deprecated for Grid(); "
+                          "please use page param instead",
+                          DeprecationWarning, stacklevel=2)
+            kwargs.setdefault('page', kwargs.pop('default_page'))
+
+        # TODO: this should not be needed once all templates correctly
+        # reference grid.vue_component etc.
         kwargs.setdefault('vue_tagname', 'tailbone-grid')
 
         kwargs['key'] = key
@@ -272,10 +289,6 @@ class Grid(WuttaGrid):
         self.default_sortkey = default_sortkey
         self.default_sortdir = default_sortdir
 
-        self.pageable = pageable
-        self.default_pagesize = default_pagesize
-        self.default_page = default_page
-
         self.checkboxes = checkboxes
         self.checked = checked
         if self.checked is None:
@@ -333,6 +346,16 @@ class Grid(WuttaGrid):
                       DeprecationWarning, stacklevel=2)
         return self.vue_component
 
+    def get_pageable(self):
+        """ """
+        return self.paginated
+
+    def set_pageable(self, value):
+        """ """
+        self.paginated = value
+
+    pageable = property(get_pageable, set_pageable)
+
     def hide_column(self, key):
         """
         This *removes* a column from the grid, altogether.
@@ -756,18 +779,61 @@ class Grid(WuttaGrid):
             keyfunc = lambda v: v[key]
         return lambda q, d: sorted(q, key=keyfunc, reverse=d == 'desc')
 
-    def get_default_pagesize(self):
+    def get_pagesize_options(self, default=None):
+        """ """
+        # let upstream check config
+        options = super().get_pagesize_options(default=UNSPECIFIED)
+        if options is not UNSPECIFIED:
+            return options
+
+        # fallback to legacy config
+        options = self.config.get_list('tailbone.grid.pagesize_options')
+        if options:
+            warnings.warn("tailbone.grid.pagesize_options setting is deprecated; "
+                          "please set wuttaweb.grids.default_pagesize_options instead",
+                          DeprecationWarning)
+            options = [int(size) for size in options
+                       if size.isdigit()]
+            if options:
+                return options
+
+        if default:
+            return default
+
+        # use upstream default
+        return super().get_pagesize_options()
+
+    def get_pagesize(self, default=None):
+        """ """
+        # let upstream check config
+        pagesize = super().get_pagesize(default=UNSPECIFIED)
+        if pagesize is not UNSPECIFIED:
+            return pagesize
+
+        # fallback to legacy config
+        pagesize = self.config.get_int('tailbone.grid.default_pagesize')
+        if pagesize:
+            warnings.warn("tailbone.grid.default_pagesize setting is deprecated; "
+                          "please use wuttaweb.grids.default_pagesize instead",
+                          DeprecationWarning)
+            return pagesize
+
+        if default:
+            return default
+
+        # use upstream default
+        return super().get_pagesize()
+
+    def get_default_pagesize(self): # pragma: no cover
+        """ """
+        warnings.warn("Grid.get_default_pagesize() method is deprecated; "
+                      "please use Grid.get_pagesize() of Grid.page instead",
+                      DeprecationWarning, stacklevel=2)
+
         if self.default_pagesize:
             return self.default_pagesize
 
-        pagesize = self.request.rattail_config.getint('tailbone',
-                                                      'grid.default_pagesize',
-                                                      default=0)
-        if pagesize:
-            return pagesize
-
-        options = self.get_pagesize_options()
-        return options[0]
+        return self.get_pagesize()
 
     def load_settings(self, store=True):
         """
@@ -789,9 +855,9 @@ class Grid(WuttaGrid):
                 settings['sorters.1.dir'] = self.default_sortdir
             else:
                 settings['sorters.length'] = 0
-        if self.pageable:
-            settings['pagesize'] = self.get_default_pagesize()
-            settings['page'] = self.default_page
+        if self.paginated:
+            settings['pagesize'] = self.pagesize
+            settings['page'] = self.page
         if self.filterable:
             for filtr in self.iter_filters():
                 settings['filter.{}.active'.format(filtr.key)] = filtr.default_active
@@ -867,7 +933,7 @@ class Grid(WuttaGrid):
                     'field': settings[f'sorters.{i}.key'],
                     'order': settings[f'sorters.{i}.dir'],
                 })
-        if self.pageable:
+        if self.paginated:
             self.pagesize = settings['pagesize']
             self.page = settings['page']
 
@@ -971,7 +1037,7 @@ class Grid(WuttaGrid):
                     merge(f'sorters.{i}.key')
                     merge(f'sorters.{i}.dir')
 
-        if self.pageable:
+        if self.paginated:
             merge('pagesize', int)
             merge('page', int)
 
@@ -1154,7 +1220,7 @@ class Grid(WuttaGrid):
 
         :param settings: Dictionary of initial settings, which is to be updated.
         """
-        if not self.pageable:
+        if not self.paginated:
             return
 
         pagesize = self.request.GET.get('pagesize')
@@ -1231,7 +1297,7 @@ class Grid(WuttaGrid):
                 persist(f'sorters.{i}.key')
                 persist(f'sorters.{i}.dir')
 
-        if self.pageable:
+        if self.paginated:
             persist('pagesize')
             persist('page')
 
@@ -1355,7 +1421,7 @@ class Grid(WuttaGrid):
             data = self.filter_data(data)
         if self.sortable:
             data = self.sort_data(data)
-        if self.pageable:
+        if self.paginated:
             self.pager = self.paginate_data(data)
             data = self.pager
         return data
@@ -1580,18 +1646,6 @@ class Grid(WuttaGrid):
         return tags.checkbox('checkbox-{}-{}'.format(self.key, self.get_row_key(item)),
                              checked=self.checked(item))
 
-    def get_pagesize_options(self):
-
-        # use values from config, if defined
-        options = self.request.rattail_config.getlist('tailbone', 'grid.pagesize_options')
-        if options:
-            options = [int(size) for size in options
-                       if size.isdigit()]
-            if options:
-                return options
-
-        return [5, 10, 20, 50, 100, 200]
-
     def has_static_data(self):
         """
         Should return ``True`` if the grid data can be considered "static"
@@ -1734,7 +1788,7 @@ class Grid(WuttaGrid):
             results['checked_rows_code'] = '[{}]'.format(
                 ', '.join(['{}[{}]'.format(var, i) for i in checked]))
 
-        if self.pageable and self.pager is not None:
+        if self.paginated and self.pager is not None:
             results['total_items'] = self.pager.item_count
             results['per_page'] = self.pager.items_per_page
             results['page'] = self.pager.page
diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako
index 93bb6c26..53043803 100644
--- a/tailbone/templates/grids/complete.mako
+++ b/tailbone/templates/grids/complete.mako
@@ -107,12 +107,14 @@
        @cellclick="cellClick"
        % endif
 
+       % if grid.paginated:
        :paginated="paginated"
        :per-page="perPage"
        :current-page="currentPage"
        backend-pagination
        :total="total"
        @page-change="onPageChange"
+       % endif
 
        ## TODO: should let grid (or master view) decide how to set these?
        icon-pack="fas"
@@ -203,7 +205,7 @@
               <div></div>
           % endif
 
-          % if getattr(grid, 'pageable', False):
+          % if grid.paginated:
               <div v-if="firstItem"
                    style="display: flex; gap: 0.5rem; align-items: center;">
                 <span>
@@ -255,12 +257,14 @@
       checkedRows: ${grid_data['checked_rows_code']|n},
       % endif
 
-      paginated: ${json.dumps(getattr(grid, 'pageable', False))|n},
+      % if grid.paginated:
+      paginated: ${json.dumps(grid.paginated)|n},
       total: ${len(grid_data['data']) if static_data else (grid_data['total_items'] if grid_data is not Undefined else 0)},
-      perPage: ${json.dumps(grid.pagesize if getattr(grid, 'pageable', False) else None)|n},
-      currentPage: ${json.dumps(grid.page if getattr(grid, 'pageable', False) else None)|n},
-      firstItem: ${json.dumps(grid_data['first_item'] if getattr(grid, 'pageable', False) else None)|n},
-      lastItem: ${json.dumps(grid_data['last_item'] if getattr(grid, 'pageable', False) else None)|n},
+      perPage: ${json.dumps(grid.pagesize if grid.paginated else None)|n},
+      currentPage: ${json.dumps(grid.page if grid.paginated else None)|n},
+      firstItem: ${json.dumps(grid_data['first_item'] if grid.paginated else None)|n},
+      lastItem: ${json.dumps(grid_data['last_item'] if grid.paginated else None)|n},
+      % endif
 
       % if getattr(grid, 'sortable', False):
 
@@ -439,7 +443,7 @@
                       params['sort'+i+'dir'] = this.backendSorters[i-1].order
                   }
               % endif
-              % if getattr(grid, 'pageable', False):
+              % if grid.paginated:
                   params.pagesize = this.perPage
                   params.page = this.currentPage
               % endif
diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index 8f65fc88..58b93568 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -439,7 +439,7 @@ class MasterView(View):
             'filterable': self.filterable,
             'use_byte_string_filters': self.use_byte_string_filters,
             'sortable': self.sortable,
-            'pageable': self.pageable,
+            'paginated': self.pageable,
             'extra_row_class': self.grid_extra_class,
             'url': lambda obj: self.get_action_url('view', obj),
             'checkboxes': checkboxes,
@@ -589,7 +589,7 @@ class MasterView(View):
         }
 
         if self.rows_default_pagesize:
-            defaults['default_pagesize'] = self.rows_default_pagesize
+            defaults['pagesize'] = self.rows_default_pagesize
 
         if self.has_rows and 'actions' not in defaults:
             actions = []
diff --git a/tailbone/views/wutta/people.py b/tailbone/views/wutta/people.py
index c92e34ae..3158b478 100644
--- a/tailbone/views/wutta/people.py
+++ b/tailbone/views/wutta/people.py
@@ -45,6 +45,10 @@ class PersonView(wutta.PersonView):
     model_class = Person
     Session = Session
 
+    # TODO: /grids/complete.mako is too aggressive for the
+    # limited support we have in wuttaweb thus far
+    paginated = False
+
     labels = {
         'display_name': "Full Name",
     }
diff --git a/tests/grids/test_core.py b/tests/grids/test_core.py
index 0d0fe112..7cba917a 100644
--- a/tests/grids/test_core.py
+++ b/tests/grids/test_core.py
@@ -19,6 +19,32 @@ class TestGrid(WebTestCase):
         grid = self.make_grid('foo')
         self.assertIsInstance(grid, mod.Grid)
 
+    def test_deprecated_params(self):
+
+        # component
+        grid = self.make_grid()
+        self.assertEqual(grid.vue_tagname, 'tailbone-grid')
+        grid = self.make_grid(component='blarg')
+        self.assertEqual(grid.vue_tagname, 'blarg')
+
+        # pageable
+        grid = self.make_grid()
+        self.assertFalse(grid.paginated)
+        grid = self.make_grid(pageable=True)
+        self.assertTrue(grid.paginated)
+
+        # default_pagesize
+        grid = self.make_grid()
+        self.assertEqual(grid.pagesize, 20)
+        grid = self.make_grid(default_pagesize=15)
+        self.assertEqual(grid.pagesize, 15)
+
+        # default_page
+        grid = self.make_grid()
+        self.assertEqual(grid.page, 1)
+        grid = self.make_grid(default_page=42)
+        self.assertEqual(grid.page, 42)
+
     def test_vue_tagname(self):
 
         # default
@@ -133,6 +159,66 @@ class TestGrid(WebTestCase):
         grid.set_action_urls(setting, setting, 0)
         self.assertEqual(setting['_action_url_view'], '/blarg')
 
+    def test_pageable(self):
+        grid = self.make_grid()
+        self.assertFalse(grid.paginated)
+        grid.pageable = True
+        self.assertTrue(grid.paginated)
+        grid.paginated = False
+        self.assertFalse(grid.pageable)
+
+    def test_get_pagesize_options(self):
+        grid = self.make_grid()
+
+        # default
+        options = grid.get_pagesize_options()
+        self.assertEqual(options, [5, 10, 20, 50, 100, 200])
+
+        # override default
+        options = grid.get_pagesize_options(default=[42])
+        self.assertEqual(options, [42])
+
+        # from legacy config
+        self.config.setdefault('tailbone.grid.pagesize_options', '1 2 3')
+        grid = self.make_grid()
+        options = grid.get_pagesize_options()
+        self.assertEqual(options, [1, 2, 3])
+
+        # from new config
+        self.config.setdefault('wuttaweb.grids.default_pagesize_options', '4, 5, 6')
+        grid = self.make_grid()
+        options = grid.get_pagesize_options()
+        self.assertEqual(options, [4, 5, 6])
+
+    def test_get_pagesize(self):
+        grid = self.make_grid()
+
+        # default
+        size = grid.get_pagesize()
+        self.assertEqual(size, 20)
+
+        # override default
+        size = grid.get_pagesize(default=42)
+        self.assertEqual(size, 42)
+
+        # override default options
+        self.config.setdefault('wuttaweb.grids.default_pagesize_options', '10 15 30')
+        grid = self.make_grid()
+        size = grid.get_pagesize()
+        self.assertEqual(size, 10)
+
+        # from legacy config
+        self.config.setdefault('tailbone.grid.default_pagesize', '12')
+        grid = self.make_grid()
+        size = grid.get_pagesize()
+        self.assertEqual(size, 12)
+
+        # from new config
+        self.config.setdefault('wuttaweb.grids.default_pagesize', '15')
+        grid = self.make_grid()
+        size = grid.get_pagesize()
+        self.assertEqual(size, 15)
+
     def test_render_vue_tag(self):
         model = self.app.model
 

From f4c8176d8325f052e4aa46666b6ae9d5a5779e75 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 16 Aug 2024 22:54:22 -0500
Subject: [PATCH 446/542] =?UTF-8?q?bump:=20version=200.17.0=20=E2=86=92=20?=
 =?UTF-8?q?0.18.0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

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

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5724e685..0671e03b 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.18.0 (2024-08-16)
+
+### Feat
+
+- move "basic" grid pagination logic to wuttaweb
+- inherit from wutta base class for Grid
+- inherit most logic from wuttaweb, for GridAction
+
+### Fix
+
+- avoid route error in user view, when using wutta people view
+- fix some more wutta compat for base template
+
 ## v0.17.0 (2024-08-15)
 
 ### Feat
diff --git a/pyproject.toml b/pyproject.toml
index 31c7ef8d..bd4882c6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.17.0"
+version = "0.18.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.8.1",
+        "WuttaWeb>=0.9.0",
         "zope.sqlalchemy>=1.5",
 ]
 

From 5e82fe3946d4a65c67527b704198ac5a8d73c6e1 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 18 Aug 2024 10:20:09 -0500
Subject: [PATCH 447/542] fix: fix broken permission directives in web api
 startup

---
 tailbone/webapi.py | 11 +++++++++--
 1 file changed, 9 insertions(+), 2 deletions(-)

diff --git a/tailbone/webapi.py b/tailbone/webapi.py
index 7c0e9b41..d0edb412 100644
--- a/tailbone/webapi.py
+++ b/tailbone/webapi.py
@@ -85,8 +85,15 @@ def make_pyramid_config(settings):
         provider.configure_db_sessions(rattail_config, pyramid_config)
 
     # add some permissions magic
-    pyramid_config.add_directive('add_tailbone_permission_group', 'tailbone.auth.add_permission_group')
-    pyramid_config.add_directive('add_tailbone_permission', 'tailbone.auth.add_permission')
+    pyramid_config.add_directive('add_wutta_permission_group',
+                                 'wuttaweb.auth.add_permission_group')
+    pyramid_config.add_directive('add_wutta_permission',
+                                 'wuttaweb.auth.add_permission')
+    # TODO: deprecate / remove these
+    pyramid_config.add_directive('add_tailbone_permission_group',
+                                 'wuttaweb.auth.add_permission_group')
+    pyramid_config.add_directive('add_tailbone_permission',
+                                 'wuttaweb.auth.add_permission')
 
     return pyramid_config
 

From c95e42bf828b93f22247660e15df67f2c431a5c4 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sat, 17 Aug 2024 11:05:15 -0500
Subject: [PATCH 448/542] fix: fix misc. errors in grid template per wuttaweb

---
 tailbone/templates/grids/complete.mako | 91 +++++++++++++++++++-------
 tailbone/views/master.py               |  5 +-
 tailbone/views/wutta/people.py         | 10 ---
 3 files changed, 70 insertions(+), 36 deletions(-)

diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako
index 53043803..d3981a16 100644
--- a/tailbone/templates/grids/complete.mako
+++ b/tailbone/templates/grids/complete.mako
@@ -107,13 +107,17 @@
        @cellclick="cellClick"
        % endif
 
+       ## paging
        % if grid.paginated:
-       :paginated="paginated"
-       :per-page="perPage"
-       :current-page="currentPage"
-       backend-pagination
-       :total="total"
-       @page-change="onPageChange"
+           paginated
+           pagination-size="is-small"
+           :per-page="perPage"
+           :current-page="currentPage"
+           @page-change="onPageChange"
+           % if grid.paginate_on_backend:
+               backend-pagination
+               :total="pagerStats.item_count"
+           % endif
        % endif
 
        ## TODO: should let grid (or master view) decide how to set these?
@@ -206,12 +210,13 @@
           % endif
 
           % if grid.paginated:
-              <div v-if="firstItem"
+              <div v-if="pagerStats.first_item"
                    style="display: flex; gap: 0.5rem; align-items: center;">
                 <span>
                   showing
-                  {{ firstItem.toLocaleString('en') }} - {{ lastItem.toLocaleString('en') }}
-                  of {{ total.toLocaleString('en') }} results;
+                  {{ renderNumber(pagerStats.first_item) }}
+                  - {{ renderNumber(pagerStats.last_item) }}
+                  of {{ renderNumber(pagerStats.item_count) }} results;
                 </span>
                 <b-select v-model="perPage"
                           size="is-small"
@@ -257,13 +262,14 @@
       checkedRows: ${grid_data['checked_rows_code']|n},
       % endif
 
+      ## paging
       % if grid.paginated:
-      paginated: ${json.dumps(grid.paginated)|n},
-      total: ${len(grid_data['data']) if static_data else (grid_data['total_items'] if grid_data is not Undefined else 0)},
-      perPage: ${json.dumps(grid.pagesize if grid.paginated else None)|n},
-      currentPage: ${json.dumps(grid.page if grid.paginated else None)|n},
-      firstItem: ${json.dumps(grid_data['first_item'] if grid.paginated else None)|n},
-      lastItem: ${json.dumps(grid_data['last_item'] if grid.paginated else None)|n},
+          pageSizeOptions: ${json.dumps(grid.pagesize_options)|n},
+          perPage: ${json.dumps(grid.pagesize)|n},
+          currentPage: ${json.dumps(grid.page)|n},
+          % if grid.paginate_on_backend:
+              pagerStats: ${json.dumps(grid.get_vue_pager_stats())|n},
+          % endif
       % endif
 
       % if getattr(grid, 'sortable', False):
@@ -311,6 +317,32 @@
 
       computed: {
 
+          ## TODO: this should be temporary? but anyway 'total' is
+          ## still referenced in other places, e.g. "delete results"
+          % if grid.paginated:
+              total() { return this.pagerStats.item_count },
+          % endif
+
+          % if not grid.paginate_on_backend:
+
+              pagerStats() {
+                  const data = this.visibleData
+                  let last = this.currentPage * this.perPage
+                  let first = last - this.perPage + 1
+                  if (last > data.length) {
+                      last = data.length
+                  }
+                  return {
+                      'item_count': data.length,
+                      'items_per_page': this.perPage,
+                      'page': this.currentPage,
+                      'first_item': first,
+                      'last_item': last,
+                  }
+              },
+
+          % endif
+
           addFilterChoices() {
               // nb. this returns all choices available for "Add Filter" operation
 
@@ -373,6 +405,12 @@
 
       methods: {
 
+          renderNumber(value) {
+              if (value != undefined) {
+                  return value.toLocaleString('en')
+              }
+          },
+
           formatAddFilterItem(filtr) {
               if (!filtr.key) {
                   filtr = this.filters[filtr]
@@ -486,23 +524,23 @@
               params = params.toString()
 
               this.loading = true
-              this.$http.get(`${'$'}{this.ajaxDataUrl}?${'$'}{params}`).then(({ data }) => {
-                  if (!data.error) {
-                      ${grid.vue_component}CurrentData = data.data
+              this.$http.get(`${'$'}{this.ajaxDataUrl}?${'$'}{params}`).then(response => {
+                  if (!response.data.error) {
+                      ${grid.vue_component}CurrentData = response.data.data.data
                       this.data = ${grid.vue_component}CurrentData
-                      this.rowStatusMap = data.row_status_map
-                      this.total = data.total_items
-                      this.firstItem = data.first_item
-                      this.lastItem = data.last_item
+                      % if grid.paginated and grid.paginate_on_backend:
+                          this.pagerStats = response.data.pager_stats
+                      % endif
+                      this.rowStatusMap = response.data.data.row_status_map
                       this.loading = false
                       this.savingDefaults = false
-                      this.checkedRows = this.locateCheckedRows(data.checked_rows)
+                      this.checkedRows = this.locateCheckedRows(response.data.data.checked_rows)
                       if (success) {
                           success()
                       }
                   } else {
                       this.$buefy.toast.open({
-                          message: data.error,
+                          message: response.data.error,
                           type: 'is-danger',
                           duration: 2000, // 4 seconds
                       })
@@ -514,8 +552,11 @@
                   }
               })
               .catch((error) => {
+                  ${grid.vue_component}CurrentData = []
                   this.data = []
-                  this.total = 0
+                  % if grid.paginated and grid.paginate_on_backend:
+                      this.pagerStats = {}
+                  % endif
                   this.loading = false
                   this.savingDefaults = false
                   if (failure) {
diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index 58b93568..1fa0ae40 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -346,7 +346,10 @@ class MasterView(View):
 
         # return grid data only, if partial page was requested
         if self.request.params.get('partial'):
-            return self.json_response(grid.get_table_data())
+            context = {'data': 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 = {
             'grid': grid,
diff --git a/tailbone/views/wutta/people.py b/tailbone/views/wutta/people.py
index 3158b478..c10020ea 100644
--- a/tailbone/views/wutta/people.py
+++ b/tailbone/views/wutta/people.py
@@ -45,10 +45,6 @@ class PersonView(wutta.PersonView):
     model_class = Person
     Session = Session
 
-    # TODO: /grids/complete.mako is too aggressive for the
-    # limited support we have in wuttaweb thus far
-    paginated = False
-
     labels = {
         'display_name': "Full Name",
     }
@@ -91,12 +87,6 @@ class PersonView(wutta.PersonView):
         # display_name
         g.set_link('display_name')
 
-        # first_name
-        g.set_link('first_name')
-
-        # last_name
-        g.set_link('last_name')
-
         # merge_requested
         g.set_label('merge_requested', "MR")
         g.set_renderer('merge_requested', self.render_merge_requested)

From ec36df4a341a1e8c7ba5821fa270fdb1125b1848 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 18 Aug 2024 14:05:52 -0500
Subject: [PATCH 449/542] feat: move single-column grid sorting logic to
 wuttaweb

---
 tailbone/forms/core.py                 |  26 +--
 tailbone/grids/core.py                 | 272 ++++++++++++++-----------
 tailbone/templates/grids/complete.mako |  63 +++---
 tailbone/views/master.py               |  28 +--
 tailbone/views/wutta/people.py         |   8 +-
 tests/grids/test_core.py               | 245 +++++++++++++++++++++-
 tests/views/test_master.py             |  33 ++-
 7 files changed, 475 insertions(+), 200 deletions(-)

diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py
index eeae4537..704d3b54 100644
--- a/tailbone/forms/core.py
+++ b/tailbone/forms/core.py
@@ -47,7 +47,7 @@ from pyramid_deform import SessionFileUploadTempStore
 from pyramid.renderers import render
 from webhelpers2.html import tags, HTML
 
-from wuttaweb.util import get_form_data, make_json_safe
+from wuttaweb.util import FieldList, get_form_data, make_json_safe
 
 from tailbone.db import Session
 from tailbone.util import raw_datetime, render_markdown
@@ -1418,30 +1418,6 @@ class Form(object):
             return False
 
 
-class FieldList(list):
-    """
-    Convenience wrapper for a form's field list.
-    """
-
-    def insert_before(self, field, newfield):
-        if field in self:
-            i = self.index(field)
-            self.insert(i, newfield)
-        else:
-            log.warning("field '%s' not found, will append new field: %s",
-                        field, newfield)
-            self.append(newfield)
-
-    def insert_after(self, field, newfield):
-        if field in self:
-            i = self.index(field)
-            self.insert(i + 1, newfield)
-        else:
-            log.warning("field '%s' not found, will append new field: %s",
-                        field, newfield)
-            self.append(newfield)
-
-
 @colander.deferred
 def upload_widget(node, kw):
     request = kw['request']
diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py
index 0b23fb78..cc1888fb 100644
--- a/tailbone/grids/core.py
+++ b/tailbone/grids/core.py
@@ -39,7 +39,8 @@ from pyramid.renderers import render
 from webhelpers2.html import HTML, tags
 from paginate_sqlalchemy import SqlalchemyOrmPage
 
-from wuttaweb.grids import Grid as WuttaGrid, GridAction as WuttaGridAction
+from wuttaweb.grids import Grid as WuttaGrid, GridAction as WuttaGridAction, SortInfo
+from wuttaweb.util import FieldList
 from . import filters as gridfilters
 from tailbone.db import Session
 from tailbone.util import raw_datetime
@@ -48,23 +49,17 @@ from tailbone.util import raw_datetime
 log = logging.getLogger(__name__)
 
 
-class FieldList(list):
-    """
-    Convenience wrapper for a field list.
-    """
-
-    def insert_before(self, field, newfield):
-        i = self.index(field)
-        self.insert(i, newfield)
-
-    def insert_after(self, field, newfield):
-        i = self.index(field)
-        self.insert(i + 1, newfield)
-
-
 class Grid(WuttaGrid):
     """
-    Core grid class.  In sore need of documentation.
+    Base class for all grids.
+
+    This is now a subclass of
+    :class:`wuttaweb:wuttaweb.grids.base.Grid`, and exists to add
+    customizations which have traditionally been part of Tailbone.
+
+    Some of these customizations are still undocumented.  Some will
+    eventually be moved to the upstream/parent class, and possibly
+    some will be removed outright.  What docs we have, are shown here.
 
     .. _Buefy docs: https://buefy.org/documentation/table/
 
@@ -206,10 +201,6 @@ class Grid(WuttaGrid):
             filters={},
             use_byte_string_filters=False,
             searchable={},
-            sortable=False,
-            sorters={},
-            default_sortkey=None,
-            default_sortdir='asc',
             checkboxes=False,
             checked=None,
             check_handler=None,
@@ -231,6 +222,20 @@ class Grid(WuttaGrid):
                           DeprecationWarning, stacklevel=2)
             kwargs.setdefault('vue_tagname', kwargs.pop('component'))
 
+        if kwargs.get('default_sortkey'):
+            warnings.warn("default_sortkey param is deprecated for Grid(); "
+                          "please use sort_defaults param instead",
+                          DeprecationWarning, stacklevel=2)
+        if kwargs.get('default_sortdir'):
+            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'):
+            sortkey = kwargs.pop('default_sortkey', None)
+            sortdir = kwargs.pop('default_sortdir', 'asc')
+            if sortkey:
+                kwargs.setdefault('sort_defaults', [(sortkey, sortdir)])
+
         if kwargs.get('pageable'):
             warnings.warn("component param is deprecated for Grid(); "
                           "please use vue_tagname param instead",
@@ -284,11 +289,6 @@ class Grid(WuttaGrid):
 
         self.searchable = searchable or {}
 
-        self.sortable = sortable
-        self.sorters = self.make_sorters(sorters)
-        self.default_sortkey = default_sortkey
-        self.default_sortdir = default_sortdir
-
         self.checkboxes = checkboxes
         self.checked = checked
         if self.checked is None:
@@ -328,9 +328,7 @@ class Grid(WuttaGrid):
 
     @property
     def component(self):
-        """
-        DEPRECATED - use :attr:`vue_tagname` instead.
-        """
+        """ """
         warnings.warn("Grid.component is deprecated; "
                       "please use vue_tagname instead",
                       DeprecationWarning, stacklevel=2)
@@ -338,20 +336,66 @@ class Grid(WuttaGrid):
 
     @property
     def component_studly(self):
-        """
-        DEPRECATED - use :attr:`vue_component` instead.
-        """
+        """ """
         warnings.warn("Grid.component_studly is deprecated; "
                       "please use vue_component instead",
                       DeprecationWarning, stacklevel=2)
         return self.vue_component
 
+    def get_default_sortkey(self):
+        """ """
+        warnings.warn("Grid.default_sortkey is deprecated; "
+                      "please use Grid.sort_defaults instead",
+                      DeprecationWarning, stacklevel=2)
+        if self.sort_defaults:
+            return self.sort_defaults[0].sortkey
+
+    def set_default_sortkey(self, value):
+        """ """
+        warnings.warn("Grid.default_sortkey is deprecated; "
+                      "please use Grid.sort_defaults instead",
+                      DeprecationWarning, stacklevel=2)
+        if self.sort_defaults:
+            info = self.sort_defaults[0]
+            self.sort_defaults[0] = SortInfo(value, info.sortdir)
+        else:
+            self.sort_defaults = [SortInfo(value, 'asc')]
+
+    default_sortkey = property(get_default_sortkey, set_default_sortkey)
+
+    def get_default_sortdir(self):
+        """ """
+        warnings.warn("Grid.default_sortdir is deprecated; "
+                      "please use Grid.sort_defaults instead",
+                      DeprecationWarning, stacklevel=2)
+        if self.sort_defaults:
+            return self.sort_defaults[0].sortdir
+
+    def set_default_sortdir(self, value):
+        """ """
+        warnings.warn("Grid.default_sortdir is deprecated; "
+                      "please use Grid.sort_defaults instead",
+                      DeprecationWarning, stacklevel=2)
+        if self.sort_defaults:
+            info = self.sort_defaults[0]
+            self.sort_defaults[0] = SortInfo(info.sortkey, value)
+        else:
+            raise ValueError("cannot set default_sortdir without default_sortkey")
+
+    default_sortdir = property(get_default_sortdir, set_default_sortdir)
+
     def get_pageable(self):
         """ """
+        warnings.warn("Grid.pageable is deprecated; "
+                      "please use Grid.paginated instead",
+                      DeprecationWarning, stacklevel=2)
         return self.paginated
 
     def set_pageable(self, value):
         """ """
+        warnings.warn("Grid.pageable is deprecated; "
+                      "please use Grid.paginated instead",
+                      DeprecationWarning, stacklevel=2)
         self.paginated = value
 
     pageable = property(get_pageable, set_pageable)
@@ -405,18 +449,30 @@ class Grid(WuttaGrid):
             self.joiners[key] = joiner
 
     def set_sorter(self, key, *args, **kwargs):
-        if len(args) == 1 and args[0] is None:
-            self.remove_sorter(key)
+        """ """
+
+        if len(args) == 1:
+            if kwargs:
+                warnings.warn("kwargs are ignored for Grid.set_sorter(); "
+                              "please refactor your code accordingly",
+                              DeprecationWarning, stacklevel=2)
+            if args[0] is None:
+                warnings.warn("specifying None is deprecated for Grid.set_sorter(); "
+                              "please use Grid.remove_sorter() instead",
+                              DeprecationWarning, stacklevel=2)
+                self.remove_sorter(key)
+            else:
+                super().set_sorter(key, args[0])
+
+        elif len(args) == 0:
+            super().set_sorter(key)
+
         else:
+            warnings.warn("multiple args are deprecated for Grid.set_sorter(); "
+                          "please refactor your code accordingly",
+                          DeprecationWarning, stacklevel=2)
             self.sorters[key] = self.make_sorter(*args, **kwargs)
 
-    def remove_sorter(self, key):
-        self.sorters.pop(key, None)
-
-    def set_sort_defaults(self, sortkey, sortdir='asc'):
-        self.default_sortkey = sortkey
-        self.default_sortdir = sortdir
-
     def set_filter(self, key, *args, **kwargs):
         if len(args) == 1 and args[0] is None:
             self.remove_filter(key)
@@ -731,53 +787,12 @@ class Grid(WuttaGrid):
             if filtr.active:
                 yield filtr
 
-    def make_sorters(self, sorters=None):
-        """
-        Returns an initial set of sorters which will be available to the grid.
-        The grid itself may or may not provide some default sorters, and the
-        ``sorters`` kwarg may contain additions and/or overrides.
-        """
-        sorters, updates = {}, sorters
-        if self.model_class:
-            mapper = orm.class_mapper(self.model_class)
-            for prop in mapper.iterate_properties:
-                if isinstance(prop, orm.ColumnProperty) and not prop.key.endswith('uuid'):
-                    sorters[prop.key] = self.make_sorter(prop)
-        if updates:
-            sorters.update(updates)
-        return sorters
-
-    def make_sorter(self, model_property):
-        """
-        Returns a function suitable for a sort map callable, with typical logic
-        built in for sorting applied to ``field``.
-        """
-        class_ = getattr(model_property, 'class_', self.model_class)
-        column = getattr(class_, model_property.key)
-
-        def sorter(query, direction):
-            # TODO: this seems hacky..normally we expect a true query
-            # of course, but in some cases it may be a list instead.
-            # if so then we can't actually sort
-            if isinstance(query, list):
-                return query
-            return query.order_by(getattr(column, direction)())
-
-        sorter._class = class_
-        sorter._column = column
-
-        return sorter
-
     def make_simple_sorter(self, key, foldcase=False):
-        """
-        Returns a function suitable for a sort map callable, with typical logic
-        built in for sorting a data set comprised of dicts, on the given key.
-        """
-        if foldcase:
-            keyfunc = lambda v: v[key].lower()
-        else:
-            keyfunc = lambda v: v[key]
-        return lambda q, d: sorted(q, key=keyfunc, reverse=d == 'desc')
+        """ """
+        warnings.warn("Grid.make_simple_sorter() is deprecated; "
+                      "please use Grid.make_sorter() instead",
+                      DeprecationWarning, stacklevel=2)
+        return self.make_sorter(key, foldcase=foldcase)
 
     def get_pagesize_options(self, default=None):
         """ """
@@ -849,10 +864,17 @@ class Grid(WuttaGrid):
         # initial default settings
         settings = {}
         if self.sortable:
-            if self.default_sortkey:
+            if self.sort_defaults:
+                sort_defaults = self.sort_defaults
+                if len(sort_defaults) > 1:
+                    log.warning("multiple sort defaults are not yet supported; "
+                                "list will be pruned to first element for '%s' grid: %s",
+                                self.key, sort_defaults)
+                    sort_defaults = [sort_defaults[0]]
+                sortinfo = sort_defaults[0]
                 settings['sorters.length'] = 1
-                settings['sorters.1.key'] = self.default_sortkey
-                settings['sorters.1.dir'] = self.default_sortdir
+                settings['sorters.1.key'] = sortinfo.sortkey
+                settings['sorters.1.dir'] = sortinfo.sortdir
             else:
                 settings['sorters.length'] = 0
         if self.paginated:
@@ -927,11 +949,12 @@ class Grid(WuttaGrid):
                 filtr.verb = settings['filter.{}.verb'.format(filtr.key)]
                 filtr.value = settings['filter.{}.value'.format(filtr.key)]
         if self.sortable:
+            # and self.sort_on_backend:
             self.active_sorters = []
             for i in range(1, settings['sorters.length'] + 1):
                 self.active_sorters.append({
-                    'field': settings[f'sorters.{i}.key'],
-                    'order': settings[f'sorters.{i}.dir'],
+                    'key': settings[f'sorters.{i}.key'],
+                    'dir': settings[f'sorters.{i}.dir'],
                 })
         if self.paginated:
             self.pagesize = settings['pagesize']
@@ -1321,21 +1344,24 @@ class Grid(WuttaGrid):
 
         return data
 
-    def sort_data(self, data):
-        """
-        Sort the given query according to current settings, and return the result.
-        """
-        # bail if no sort settings
-        if not self.active_sorters:
+    def sort_data(self, data, sorters=None):
+        """ """
+        if sorters is None:
+            sorters = self.active_sorters
+        if not sorters:
             return data
 
-        # TODO: is there a better way to check for SA sorting?
-        if self.model_class:
+        # sqlalchemy queries require special handling, in case of
+        # multi-column sorting
+        if isinstance(data, orm.Query):
 
             # collect actual column sorters for order_by clause
-            sorters = []
-            for sorter in self.active_sorters:
-                sortkey = sorter['field']
+            query_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:
                     log.warning("unknown sorter: %s", sorter)
@@ -1347,34 +1373,36 @@ class Grid(WuttaGrid):
                     self.joined.add(sortkey)
 
                 # add column/dir to collection
-                sortdir = sorter['order']
-                sorters.append(getattr(sortfunc._column, sortdir)())
+                query_sorters.append(getattr(sortfunc._column, sortdir)())
 
             # apply sorting to query
-            if sorters:
-                data = data.order_by(*sorters)
+            if query_sorters:
+                data = data.order_by(*query_sorters)
 
             return data
 
-        else:
-            # not a SQLAlchemy grid, custom sorter
+        # manual sorting; only one column allowed
+        if len(sorters) != 1:
+            raise NotImplementedError("mulit-column manual sorting not yet supported")
 
-            assert len(self.active_sorters) < 2
+        # our one and only active sorter
+        sorter = sorters[0]
+        sortkey = sorter['key']
+        sortdir = sorter['dir']
 
-            sortkey = self.active_sorters[0]['field']
-            sortdir = self.active_sorters[0]['order'] or 'asc'
+        # cannot sort unless we have a sorter callable
+        sortfunc = self.sorters.get(sortkey)
+        if not sortfunc:
+            return data
 
-            # Cannot sort unless we have a sort function.
-            sortfunc = self.sorters.get(sortkey)
-            if not sortfunc:
-                return data
+        # apply joins needed for this sorter
+        # TODO: is this actually relevant for manual sort?
+        if sortkey in self.joiners and sortkey not in self.joined:
+            data = self.joiners[sortkey](data)
+            self.joined.add(sortkey)
 
-            # apply joins needed for this sorter
-            if sortkey in self.joiners and sortkey not in self.joined:
-                data = self.joiners[sortkey](data)
-                self.joined.add(sortkey)
-
-            return sortfunc(data, sortdir)
+        # invoke the sorter
+        return sortfunc(data, sortdir)
 
     def paginate_data(self, data):
         """
@@ -1671,7 +1699,7 @@ class Grid(WuttaGrid):
             columns.append({
                 'field': name,
                 'label': self.get_label(name),
-                'sortable': self.sortable and name in self.sorters,
+                'sortable': self.is_sortable(name),
                 'visible': name not in self.invisible,
             })
         return columns
diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako
index d3981a16..5a005c2e 100644
--- a/tailbone/templates/grids/complete.mako
+++ b/tailbone/templates/grids/complete.mako
@@ -81,7 +81,11 @@
        % endif
        % endif
 
-       % if getattr(grid, 'sortable', False):
+       ## sorting
+       % if grid.sortable:
+           ## nb. buefy only supports *one* default sorter
+           :default-sort="sorters.length ? [sorters[0].key, sorters[0].dir] : null"
+
            backend-sorting
            @sort="onSort"
            @sorting-priority-removed="sortingPriorityRemoved"
@@ -93,8 +97,6 @@
            ## https://github.com/buefy/buefy/issues/2584
            :sort-multiple="allowMultiSort"
 
-           ## nb. specify default sort only if single-column
-           :default-sort="backendSorters.length == 1 ? [backendSorters[0].field, backendSorters[0].order] : null"
 
            ## nb. otherwise there may be default multi-column sort
            :sort-multiple-data="sortingPriority"
@@ -272,7 +274,9 @@
           % endif
       % endif
 
-      % if getattr(grid, 'sortable', False):
+      ## sorting
+      % if grid.sortable:
+          sorters: ${json.dumps(grid.active_sorters)|n},
 
           ## TODO: there is a bug (?) which prevents the arrow from
           ## displaying for simple default single-column sort.  so to
@@ -281,10 +285,7 @@
           ## https://github.com/buefy/buefy/issues/2584
           allowMultiSort: false,
 
-          ## nb. this contains all truly active sorters
-          backendSorters: ${json.dumps(grid.active_sorters)|n},
-
-          ## nb. whereas this will only contain multi-column sorters,
+          ## nb. this will only contain multi-column sorters,
           ## but will be *empty* for single-column sorting
           % if len(grid.active_sorters) > 1:
               sortingPriority: ${json.dumps(grid.active_sorters)|n},
@@ -474,17 +475,18 @@
           },
 
           getBasicParams() {
-              let params = {}
-              % if getattr(grid, 'sortable', False):
-                  for (let i = 1; i <= this.backendSorters.length; i++) {
-                      params['sort'+i+'key'] = this.backendSorters[i-1].field
-                      params['sort'+i+'dir'] = this.backendSorters[i-1].order
+              const params = {
+                  % if grid.paginated and grid.paginate_on_backend:
+                      pagesize: this.perPage,
+                      page: this.currentPage,
+                  % endif
+              }
+              % if grid.sortable and grid.sort_on_backend:
+                  for (let i = 1; i <= this.sorters.length; i++) {
+                      params['sort'+i+'key'] = this.sorters[i-1].key
+                      params['sort'+i+'dir'] = this.sorters[i-1].dir
                   }
               % endif
-              % if grid.paginated:
-                  params.pagesize = this.perPage
-                  params.page = this.currentPage
-              % endif
               return params
           },
 
@@ -526,15 +528,15 @@
               this.loading = true
               this.$http.get(`${'$'}{this.ajaxDataUrl}?${'$'}{params}`).then(response => {
                   if (!response.data.error) {
-                      ${grid.vue_component}CurrentData = response.data.data.data
+                      ${grid.vue_component}CurrentData = response.data.data
                       this.data = ${grid.vue_component}CurrentData
                       % if grid.paginated and grid.paginate_on_backend:
                           this.pagerStats = response.data.pager_stats
                       % endif
-                      this.rowStatusMap = response.data.data.row_status_map
+                      this.rowStatusMap = response.data.row_status_map || {}
                       this.loading = false
                       this.savingDefaults = false
-                      this.checkedRows = this.locateCheckedRows(response.data.data.checked_rows)
+                      this.checkedRows = this.locateCheckedRows(response.data.checked_rows || [])
                       if (success) {
                           success()
                       }
@@ -597,26 +599,26 @@
 
           onSort(field, order, event) {
 
-              // nb. buefy passes field name, oruga passes object
-              if (field.field) {
+              ## nb. buefy passes field name; oruga passes field object
+              % if request.use_oruga:
                   field = field.field
-              }
+              % endif
 
               if (event.ctrlKey) {
 
                   // engage or enhance multi-column sorting
-                  let sorter = this.backendSorters.filter(i => i.field === field)[0]
+                  const sorter = this.sorters.filter(s => s.key === field)[0]
                   if (sorter) {
-                      sorter.order = sorter.order === 'desc' ? 'asc' : 'desc'
+                      sorter.dir = sorter.dir === 'desc' ? 'asc' : 'desc'
                   } else {
-                      this.backendSorters.push({field, order})
+                      this.sorters.push({key: field, dir: order})
                   }
-                  this.sortingPriority = this.backendSorters
+                  this.sortingPriority = this.sorters
 
               } else {
 
                   // sort by single column only
-                  this.backendSorters = [{field, order}]
+                  this.sorters = [{key: field, dir: order}]
                   this.sortingPriority = []
               }
 
@@ -629,12 +631,11 @@
           sortingPriorityRemoved(field) {
 
               // prune field from active sorters
-              this.backendSorters = this.backendSorters.filter(
-                  (sorter) => sorter.field !== field)
+              this.sorters = this.sorters.filter(s => s.key !== field)
 
               // nb. must keep active sorter list "as-is" even if
               // there is only one sorter; buefy seems to expect it
-              this.sortingPriority = this.backendSorters
+              this.sortingPriority = this.sorters
 
               this.loadAsyncData()
           },
diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index 1fa0ae40..53f46020 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -345,8 +345,8 @@ class MasterView(View):
             self.first_visible_grid_index = grid.pager.first_item
 
         # return grid data only, if partial page was requested
-        if self.request.params.get('partial'):
-            context = {'data': grid.get_table_data()}
+        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)
@@ -2565,11 +2565,12 @@ class MasterView(View):
         so if you like you can return a different help URL depending on which
         type of CRUD view is in effect, etc.
         """
+        # nb. self.Session may differ, so use tailbone.db.Session
+        session = Session()
         model = self.model
         route_prefix = self.get_route_prefix()
 
-        # nb. self.Session may differ, so use tailbone.db.Session
-        info = Session.query(model.TailbonePageHelp)\
+        info = session.query(model.TailbonePageHelp)\
                       .filter(model.TailbonePageHelp.route_prefix == route_prefix)\
                       .first()
         if info and info.help_url:
@@ -2587,11 +2588,12 @@ class MasterView(View):
         """
         Return the markdown help text for current page, if defined.
         """
+        # nb. self.Session may differ, so use tailbone.db.Session
+        session = Session()
         model = self.model
         route_prefix = self.get_route_prefix()
 
-        # nb. self.Session may differ, so use tailbone.db.Session
-        info = Session.query(model.TailbonePageHelp)\
+        info = session.query(model.TailbonePageHelp)\
                       .filter(model.TailbonePageHelp.route_prefix == route_prefix)\
                       .first()
         if info and info.markdown_text:
@@ -2608,6 +2610,8 @@ class MasterView(View):
         if not self.can_edit_help():
             raise self.forbidden()
 
+        # nb. self.Session may differ, so use tailbone.db.Session
+        session = Session()
         model = self.model
         route_prefix = self.get_route_prefix()
         schema = colander.Schema()
@@ -2625,13 +2629,12 @@ class MasterView(View):
         if not form.validate():
             return {'error': "Form did not validate"}
 
-        # nb. self.Session may differ, so use tailbone.db.Session
-        info = Session.query(model.TailbonePageHelp)\
+        info = session.query(model.TailbonePageHelp)\
                       .filter(model.TailbonePageHelp.route_prefix == route_prefix)\
                       .first()
         if not info:
             info = model.TailbonePageHelp(route_prefix=route_prefix)
-            Session.add(info)
+            session.add(info)
 
         info.help_url = form.validated['help_url']
         info.markdown_text = form.validated['markdown_text']
@@ -2641,6 +2644,8 @@ class MasterView(View):
         if not self.can_edit_help():
             raise self.forbidden()
 
+        # nb. self.Session may differ, so use tailbone.db.Session
+        session = Session()
         model = self.model
         route_prefix = self.get_route_prefix()
         schema = colander.Schema()
@@ -2657,15 +2662,14 @@ class MasterView(View):
         if not form.validate():
             return {'error': "Form did not validate"}
 
-        # nb. self.Session may differ, so use tailbone.db.Session
-        info = Session.query(model.TailboneFieldInfo)\
+        info = session.query(model.TailboneFieldInfo)\
                       .filter(model.TailboneFieldInfo.route_prefix == route_prefix)\
                       .filter(model.TailboneFieldInfo.field_name == form.validated['field_name'])\
                       .first()
         if not info:
             info = model.TailboneFieldInfo(route_prefix=route_prefix,
                                            field_name=form.validated['field_name'])
-            Session.add(info)
+            session.add(info)
 
         info.markdown_text = form.validated['markdown_text']
         return {'ok': True}
diff --git a/tailbone/views/wutta/people.py b/tailbone/views/wutta/people.py
index c10020ea..968eaf3d 100644
--- a/tailbone/views/wutta/people.py
+++ b/tailbone/views/wutta/people.py
@@ -44,6 +44,7 @@ class PersonView(wutta.PersonView):
     """
     model_class = Person
     Session = Session
+    sort_defaults = 'display_name'
 
     labels = {
         'display_name': "Full Name",
@@ -73,13 +74,6 @@ class PersonView(wutta.PersonView):
     # CRUD methods
     ##############################
 
-    def get_query(self, session=None):
-        """ """
-        model = self.app.model
-        session = session or self.Session()
-        return session.query(model.Person)\
-                      .order_by(model.Person.display_name)
-
     def configure_grid(self, g):
         """ """
         super().configure_grid(g)
diff --git a/tests/grids/test_core.py b/tests/grids/test_core.py
index 7cba917a..9f9b816f 100644
--- a/tests/grids/test_core.py
+++ b/tests/grids/test_core.py
@@ -1,6 +1,8 @@
 # -*- coding: utf-8; -*-
 
-from unittest.mock import MagicMock
+from unittest.mock import MagicMock, patch
+
+from sqlalchemy import orm
 
 from tailbone.grids import core as mod
 from tests.util import WebTestCase
@@ -27,6 +29,16 @@ class TestGrid(WebTestCase):
         grid = self.make_grid(component='blarg')
         self.assertEqual(grid.vue_tagname, 'blarg')
 
+        # default_sortkey, default_sortdir
+        grid = self.make_grid()
+        self.assertEqual(grid.sort_defaults, [])
+        grid = self.make_grid(default_sortkey='name')
+        self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'asc')])
+        grid = self.make_grid(default_sortdir='desc')
+        self.assertEqual(grid.sort_defaults, [])
+        grid = self.make_grid(default_sortkey='name', default_sortdir='desc')
+        self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'desc')])
+
         # pageable
         grid = self.make_grid()
         self.assertFalse(grid.paginated)
@@ -159,6 +171,27 @@ class TestGrid(WebTestCase):
         grid.set_action_urls(setting, setting, 0)
         self.assertEqual(setting['_action_url_view'], '/blarg')
 
+    def test_default_sortkey(self):
+        grid = self.make_grid()
+        self.assertEqual(grid.sort_defaults, [])
+        self.assertIsNone(grid.default_sortkey)
+        grid.default_sortkey = 'name'
+        self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'asc')])
+        self.assertEqual(grid.default_sortkey, 'name')
+        grid.default_sortkey = 'value'
+        self.assertEqual(grid.sort_defaults, [mod.SortInfo('value', 'asc')])
+        self.assertEqual(grid.default_sortkey, 'value')
+
+    def test_default_sortdir(self):
+        grid = self.make_grid()
+        self.assertEqual(grid.sort_defaults, [])
+        self.assertIsNone(grid.default_sortdir)
+        self.assertRaises(ValueError, setattr, grid, 'default_sortdir', 'asc')
+        grid.sort_defaults = [mod.SortInfo('name', 'asc')]
+        grid.default_sortdir = 'desc'
+        self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'desc')])
+        self.assertEqual(grid.default_sortdir, 'desc')
+
     def test_pageable(self):
         grid = self.make_grid()
         self.assertFalse(grid.paginated)
@@ -219,6 +252,212 @@ class TestGrid(WebTestCase):
         size = grid.get_pagesize()
         self.assertEqual(size, 15)
 
+    def test_set_sorter(self):
+        model = self.app.model
+        grid = self.make_grid(model_class=model.Setting,
+                              sortable=True, sort_on_backend=True)
+
+        # passing None will remove sorter
+        self.assertIn('name', grid.sorters)
+        grid.set_sorter('name', None)
+        self.assertNotIn('name', grid.sorters)
+
+        # can recreate sorter with just column name
+        grid.set_sorter('name')
+        self.assertIn('name', grid.sorters)
+        grid.remove_sorter('name')
+        self.assertNotIn('name', grid.sorters)
+        grid.set_sorter('name', 'name')
+        self.assertIn('name', grid.sorters)
+
+        # can recreate sorter with model property
+        grid.remove_sorter('name')
+        self.assertNotIn('name', grid.sorters)
+        grid.set_sorter('name', model.Setting.name)
+        self.assertIn('name', grid.sorters)
+
+        # extra kwargs are ignored
+        grid.remove_sorter('name')
+        self.assertNotIn('name', grid.sorters)
+        grid.set_sorter('name', model.Setting.name, foo='bar')
+        self.assertIn('name', grid.sorters)
+
+        # passing multiple args will invoke make_filter() directly
+        grid.remove_sorter('name')
+        self.assertNotIn('name', grid.sorters)
+        with patch.object(grid, 'make_sorter') as make_sorter:
+            make_sorter.return_value = 42
+            grid.set_sorter('name', 'foo', 'bar')
+            make_sorter.assert_called_once_with('foo', 'bar')
+            self.assertEqual(grid.sorters['name'], 42)
+
+    def test_make_simple_sorter(self):
+        model = self.app.model
+        grid = self.make_grid(model_class=model.Setting,
+                              sortable=True, sort_on_backend=True)
+
+        # delegates to grid.make_sorter()
+        with patch.object(grid, 'make_sorter') as make_sorter:
+            make_sorter.return_value = 42
+            sorter = grid.make_simple_sorter('name', foldcase=True)
+            make_sorter.assert_called_once_with('name', foldcase=True)
+            self.assertEqual(sorter, 42)
+
+    def test_load_settings(self):
+        model = self.app.model
+
+        # nb. first use a paging grid
+        grid = self.make_grid(key='foo', paginated=True, paginate_on_backend=True,
+                              pagesize=20, page=1)
+
+        # settings are loaded, applied, saved
+        self.assertEqual(grid.page, 1)
+        self.assertNotIn('grid.foo.page', self.request.session)
+        self.request.GET = {'pagesize': '10', 'page': '2'}
+        grid.load_settings()
+        self.assertEqual(grid.page, 2)
+        self.assertEqual(self.request.session['grid.foo.page'], 2)
+
+        # can skip the saving step
+        self.request.GET = {'pagesize': '10', 'page': '3'}
+        grid.load_settings(store=False)
+        self.assertEqual(grid.page, 3)
+        self.assertEqual(self.request.session['grid.foo.page'], 2)
+
+        # no error for non-paginated grid
+        grid = self.make_grid(key='foo', paginated=False)
+        grid.load_settings()
+        self.assertFalse(grid.paginated)
+
+        # nb. next use a sorting grid
+        grid = self.make_grid(key='settings', model_class=model.Setting,
+                              sortable=True, sort_on_backend=True)
+
+        # settings are loaded, applied, saved
+        self.assertEqual(grid.sort_defaults, [])
+        self.assertFalse(hasattr(grid, 'active_sorters'))
+        self.request.GET = {'sort1key': 'name', 'sort1dir': 'desc'}
+        grid.load_settings()
+        self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'desc'}])
+        self.assertEqual(self.request.session['grid.settings.sorters.length'], 1)
+        self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name')
+        self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'desc')
+
+        # can skip the saving step
+        self.request.GET = {'sort1key': 'name', 'sort1dir': 'asc'}
+        grid.load_settings(store=False)
+        self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'asc'}])
+        self.assertEqual(self.request.session['grid.settings.sorters.length'], 1)
+        self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name')
+        self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'desc')
+
+        # no error for non-sortable grid
+        grid = self.make_grid(key='foo', sortable=False)
+        grid.load_settings()
+        self.assertFalse(grid.sortable)
+
+        # with sort defaults
+        grid = self.make_grid(model_class=model.Setting, sortable=True,
+                              sort_on_backend=True, sort_defaults='name')
+        self.assertFalse(hasattr(grid, 'active_sorters'))
+        grid.load_settings()
+        self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'asc'}])
+
+        # with multi-column sort defaults
+        grid = self.make_grid(model_class=model.Setting, sortable=True,
+                              sort_on_backend=True)
+        grid.sort_defaults = [
+            mod.SortInfo('name', 'asc'),
+            mod.SortInfo('value', 'desc'),
+        ]
+        self.assertFalse(hasattr(grid, 'active_sorters'))
+        grid.load_settings()
+        self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'asc'}])
+
+        # load settings from session when nothing is in request
+        self.request.GET = {}
+        self.request.session.invalidate()
+        self.assertNotIn('grid.settings.sorters.length', self.request.session)
+        self.request.session['grid.settings.sorters.length'] = 1
+        self.request.session['grid.settings.sorters.1.key'] = 'name'
+        self.request.session['grid.settings.sorters.1.dir'] = 'desc'
+        grid = self.make_grid(key='settings', model_class=model.Setting,
+                              sortable=True, sort_on_backend=True,
+                              paginated=True, paginate_on_backend=True)
+        self.assertFalse(hasattr(grid, 'active_sorters'))
+        grid.load_settings()
+        self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'desc'}])
+
+    def test_sort_data(self):
+        model = self.app.model
+        sample_data = [
+            {'name': 'foo1', 'value': 'ONE'},
+            {'name': 'foo2', 'value': 'two'},
+            {'name': 'foo3', 'value': 'three'},
+            {'name': 'foo4', 'value': 'four'},
+            {'name': 'foo5', 'value': 'five'},
+            {'name': 'foo6', 'value': 'six'},
+            {'name': 'foo7', 'value': 'seven'},
+            {'name': 'foo8', 'value': 'eight'},
+            {'name': 'foo9', 'value': 'nine'},
+        ]
+        for setting in sample_data:
+            self.app.save_setting(self.session, setting['name'], setting['value'])
+        self.session.commit()
+        sample_query = self.session.query(model.Setting)
+
+        grid = self.make_grid(model_class=model.Setting,
+                              sortable=True, sort_on_backend=True,
+                              sort_defaults=('name', 'desc'))
+        grid.load_settings()
+
+        # can sort a simple list of data
+        sorted_data = grid.sort_data(sample_data)
+        self.assertIsInstance(sorted_data, list)
+        self.assertEqual(len(sorted_data), 9)
+        self.assertEqual(sorted_data[0]['name'], 'foo9')
+        self.assertEqual(sorted_data[-1]['name'], 'foo1')
+
+        # can also sort a data query
+        sorted_query = grid.sort_data(sample_query)
+        self.assertIsInstance(sorted_query, orm.Query)
+        sorted_data = sorted_query.all()
+        self.assertEqual(len(sorted_data), 9)
+        self.assertEqual(sorted_data[0]['name'], 'foo9')
+        self.assertEqual(sorted_data[-1]['name'], 'foo1')
+
+        # cannot sort data if sorter missing in overrides
+        sorted_data = grid.sort_data(sample_data, sorters=[])
+        # nb. sorted data is in same order as original sample (not sorted)
+        self.assertEqual(sorted_data[0]['name'], 'foo1')
+        self.assertEqual(sorted_data[-1]['name'], 'foo9')
+
+        # error if mult-column sort attempted
+        self.assertRaises(NotImplementedError, grid.sort_data, sample_data, sorters=[
+            {'key': 'name', 'dir': 'desc'},
+            {'key': 'value', 'dir': 'asc'},
+        ])
+
+        # cannot sort data if sortfunc is missing for column
+        grid.remove_sorter('name')
+        sorted_data = grid.sort_data(sample_data)
+        # nb. sorted data is in same order as original sample (not sorted)
+        self.assertEqual(sorted_data[0]['name'], 'foo1')
+        self.assertEqual(sorted_data[-1]['name'], 'foo9')
+
+        # cannot sort data if sortfunc is missing for column
+        grid.remove_sorter('name')
+        # nb. attempting multi-column sort, but only one sorter exists
+        self.assertEqual(list(grid.sorters), ['value'])
+        grid.active_sorters = [{'key': 'name', 'dir': 'asc'},
+                               {'key': 'value', 'dir': 'asc'}]
+        with patch.object(sample_query, 'order_by') as order_by:
+            order_by.return_value = 42
+            sorted_query = grid.sort_data(sample_query)
+            order_by.assert_called_once()
+            self.assertEqual(len(order_by.call_args.args), 1)
+            self.assertEqual(sorted_query, 42)
+
     def test_render_vue_tag(self):
         model = self.app.model
 
@@ -249,11 +488,13 @@ class TestGrid(WebTestCase):
         model = self.app.model
 
         # sanity check
-        grid = self.make_grid('settings', model_class=model.Setting)
+        grid = self.make_grid('settings', model_class=model.Setting, sortable=True)
         columns = grid.get_vue_columns()
         self.assertEqual(len(columns), 2)
         self.assertEqual(columns[0]['field'], 'name')
+        self.assertTrue(columns[0]['sortable'])
         self.assertEqual(columns[1]['field'], 'value')
+        self.assertTrue(columns[1]['sortable'])
 
     def test_get_vue_data(self):
         model = self.app.model
diff --git a/tests/views/test_master.py b/tests/views/test_master.py
index 572875a0..0e459e7d 100644
--- a/tests/views/test_master.py
+++ b/tests/views/test_master.py
@@ -1,6 +1,6 @@
 # -*- coding: utf-8; -*-
 
-from unittest.mock import patch
+from unittest.mock import patch, MagicMock
 
 from tailbone.views import master as mod
 from wuttaweb.grids import GridAction
@@ -33,3 +33,34 @@ class TestMasterView(WebTestCase):
             view = self.make_view()
             action = view.make_action('view')
             self.assertIsInstance(action, GridAction)
+
+    def test_index(self):
+        self.pyramid_config.include('tailbone.views.common')
+        self.pyramid_config.include('tailbone.views.auth')
+        model = self.app.model
+
+        # mimic view for /settings
+        with patch.object(mod, 'Session', return_value=self.session):
+            with patch.multiple(mod.MasterView, create=True,
+                                model_class=model.Setting,
+                                Session=MagicMock(return_value=self.session),
+                                get_index_url=MagicMock(return_value='/settings/'),
+                                get_help_url=MagicMock(return_value=None)):
+
+                # basic
+                view = self.make_view()
+                response = view.index()
+                self.assertEqual(response.status_code, 200)
+
+                # then again with data, to include view action url
+                data = [{'name': 'foo', 'value': 'bar'}]
+                with patch.object(view, 'get_data', return_value=data):
+                    response = view.index()
+                    self.assertEqual(response.status_code, 200)
+                    self.assertEqual(response.content_type, 'text/html')
+
+                    # then once more as 'partial' - aka. data only
+                    self.request.GET = {'partial': '1'}
+                    response = view.index()
+                    self.assertEqual(response.status_code, 200)
+                    self.assertEqual(response.content_type, 'application/json')

From 290f8fd51eddca9e2f3778a23f44bfe356e94ad7 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 18 Aug 2024 19:22:04 -0500
Subject: [PATCH 450/542] feat: move multi-column grid sorting logic to
 wuttaweb

tailbone grid template still duplicates much for Vue, and will until
we can port the filters and anything else remaining..
---
 tailbone/grids/core.py                 | 251 +++++++------------------
 tailbone/templates/base.mako           |  18 +-
 tailbone/templates/grids/complete.mako | 181 ++++++++++--------
 tailbone/views/master.py               |   3 +-
 tests/grids/test_core.py               |  91 ++++++---
 5 files changed, 252 insertions(+), 292 deletions(-)

diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py
index cc1888fb..9c445fec 100644
--- a/tailbone/grids/core.py
+++ b/tailbone/grids/core.py
@@ -850,28 +850,23 @@ class Grid(WuttaGrid):
 
         return self.get_pagesize()
 
-    def load_settings(self, store=True):
-        """
-        Load current/effective settings for the grid, from the request query
-        string and/or session storage.  If ``store`` is true, then once
-        settings have been fully read, they are stored in current session for
-        next time.  Finally, various instance attributes of the grid and its
-        filters are updated in-place to reflect the settings; this is so code
-        needn't access the settings dict directly, but the more Pythonic
-        instance attributes.
-        """
+    def load_settings(self, **kwargs):
+        """ """
+        if 'store' in kwargs:
+            warnings.warn("the 'store' param is deprecated for load_settings(); "
+                          "please use the 'persist' param instead",
+                          DeprecationWarning, stacklevel=2)
+            kwargs.setdefault('persist', kwargs.pop('store'))
+
+        persist = kwargs.get('persist', True)
 
         # initial default settings
         settings = {}
         if self.sortable:
             if self.sort_defaults:
-                sort_defaults = self.sort_defaults
-                if len(sort_defaults) > 1:
-                    log.warning("multiple sort defaults are not yet supported; "
-                                "list will be pruned to first element for '%s' grid: %s",
-                                self.key, sort_defaults)
-                    sort_defaults = [sort_defaults[0]]
-                sortinfo = sort_defaults[0]
+                # nb. as of writing neither Buefy nor Oruga support a
+                # multi-column *default* sort; so just use first sorter
+                sortinfo = self.sort_defaults[0]
                 settings['sorters.length'] = 1
                 settings['sorters.1.key'] = sortinfo.sortkey
                 settings['sorters.1.dir'] = sortinfo.sortdir
@@ -900,16 +895,16 @@ class Grid(WuttaGrid):
         elif self.filterable and self.request_has_settings('filter'):
             self.update_filter_settings(settings, 'request')
             if self.request_has_settings('sort'):
-                self.update_sort_settings(settings, 'request')
+                self.update_sort_settings(settings, src='request')
             else:
-                self.update_sort_settings(settings, 'session')
+                self.update_sort_settings(settings, src='session')
             self.update_page_settings(settings)
 
         # If request has no filter settings but does have sort settings, grab
         # those, then grab filter settings from session, then grab pager
         # settings from request or session.
         elif self.request_has_settings('sort'):
-            self.update_sort_settings(settings, 'request')
+            self.update_sort_settings(settings, src='request')
             self.update_filter_settings(settings, 'session')
             self.update_page_settings(settings)
 
@@ -921,26 +916,26 @@ class Grid(WuttaGrid):
         elif self.request_has_settings('page'):
             self.update_page_settings(settings)
             self.update_filter_settings(settings, 'session')
-            self.update_sort_settings(settings, '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_sort_settings(settings, 'session')
+            self.update_sort_settings(settings, src='session')
             self.update_page_settings(settings)
 
         # If no settings were found in request or session, don't store result.
         else:
-            store = False
+            persist = False
             
         # Maybe store settings for next time.
-        if store:
-            self.persist_settings(settings, 'session')
+        if persist:
+            self.persist_settings(settings, dest='session')
 
         # If request contained instruction to save current settings as defaults
         # for the current user, then do that.
         if self.request.GET.get('save-current-filters-as-defaults') == 'true':
-            self.persist_settings(settings, 'defaults')
+            self.persist_settings(settings, dest='defaults')
 
         # update ourself to reflect settings
         if self.filterable:
@@ -1107,44 +1102,6 @@ class Grid(WuttaGrid):
         return any([key.startswith(f'{prefix}.filter')
                     for key in self.request.session])
 
-    def get_setting(self, source, settings, key, normalize=lambda v: v, default=None):
-        """
-        Get the effective value for a particular setting, preferring ``source``
-        but falling back to existing ``settings`` and finally the ``default``.
-        """
-        if source not in ('request', 'session'):
-            raise ValueError("Invalid source identifier: {}".format(source))
-
-        # If source is query string, try that first.
-        if source == 'request':
-            value = self.request.GET.get(key)
-            if value is not None:
-                try:
-                    value = normalize(value)
-                except ValueError:
-                    pass
-                else:
-                    return value
-
-        # Or, if source is session, try that first.
-        else:
-            value = self.request.session.get('grid.{}.{}'.format(self.key, key))
-            if value is not None:
-                return normalize(value)
-
-        # If source had nothing, try default/existing settings.
-        value = settings.get(key)
-        if value is not None:
-            try:
-                value = normalize(value)
-            except ValueError:
-                pass
-            else:
-                return value
-
-        # Okay then, default it is.
-        return default
-
     def update_filter_settings(self, settings, source):
         """
         Updates a settings dictionary according to filter settings data found
@@ -1165,71 +1122,18 @@ class Grid(WuttaGrid):
                 # 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(
-                    source, settings, '{}.verb'.format(filtr.key), default='')
+                    settings, f'{filtr.key}.verb', src='request', default='')
                 settings['{}.value'.format(prefix)] = self.get_setting(
-                    source, settings, filtr.key, default='')
+                    settings, filtr.key, src='request', default='')
 
             else: # source = session
                 settings['{}.active'.format(prefix)] = self.get_setting(
-                    source, settings, '{}.active'.format(prefix),
+                    settings, f'{prefix}.active', src='session',
                     normalize=lambda v: str(v).lower() == 'true', default=False)
                 settings['{}.verb'.format(prefix)] = self.get_setting(
-                    source, settings, '{}.verb'.format(prefix), default='')
+                    settings, f'{prefix}.verb', src='session', default='')
                 settings['{}.value'.format(prefix)] = self.get_setting(
-                    source, settings, '{}.value'.format(prefix), default='')
-
-    def update_sort_settings(self, settings, source):
-        """
-        Updates a settings dictionary according to sort 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.sortable:
-            return
-
-        if source == 'request':
-
-            # TODO: remove this eventually, but some links in the wild
-            # may still include these params, so leave it for now
-            if 'sortkey' in self.request.GET:
-                settings['sorters.length'] = 1
-                settings['sorters.1.key'] = self.get_setting(source, settings, 'sortkey')
-                settings['sorters.1.dir'] = self.get_setting(source, settings, 'sortdir')
-
-            else: # the future
-                i = 1
-                while True:
-                    skey = f'sort{i}key'
-                    if skey in self.request.GET:
-                        settings[f'sorters.{i}.key'] = self.get_setting(source, settings, skey)
-                        settings[f'sorters.{i}.dir'] = self.get_setting(source, settings, f'sort{i}dir')
-                    else:
-                        break
-                    i += 1
-                settings['sorters.length'] = i - 1
-
-        else: # session
-
-            # TODO: definitely will remove this, but leave it for now
-            # so it doesn't monkey with current user sessions when
-            # next upgrade happens.  so, remove after all are upgraded
-            sortkey = self.get_setting(source, settings, 'sortkey')
-            if sortkey:
-                settings['sorters.length'] = 1
-                settings['sorters.1.key'] = sortkey
-                settings['sorters.1.dir'] = self.get_setting(source, settings, 'sortdir')
-
-            else: # the future
-                settings['sorters.length'] = self.get_setting(source, settings,
-                                                              'sorters.length', int)
-                for i in range(1, settings['sorters.length'] + 1):
-                    for key in ('key', 'dir'):
-                        skey = f'sorters.{i}.{key}'
-                        settings[skey] = self.get_setting(source, settings, skey)
+                    settings, f'{prefix}.value', src='session', default='')
 
     def update_page_settings(self, settings):
         """
@@ -1264,18 +1168,19 @@ class Grid(WuttaGrid):
             if page is not None:
                 settings['page'] = int(page)
 
-    def persist_settings(self, settings, to='session'):
-        """
-        Persist the given settings in some way, as defined by ``func``.
-        """
+    def persist_settings(self, settings, dest='session'):
+        """ """
+        if dest not in ('defaults', 'session'):
+            raise ValueError(f"invalid dest identifier: {dest}")
+
         app = self.request.rattail_config.get_app()
         model = app.model
 
-        def persist(key, value=lambda k: settings[k]):
-            if to == 'defaults':
+        def persist(key, value=lambda k: settings.get(k)):
+            if dest == 'defaults':
                 skey = 'tailbone.{}.grid.{}.{}'.format(self.request.user.uuid, self.key, key)
                 app.save_setting(Session(), skey, value(key))
-            else: # to == session
+            else: # dest == session
                 skey = 'grid.{}.{}'.format(self.key, key)
                 self.request.session[skey] = value(key)
 
@@ -1287,9 +1192,11 @@ class Grid(WuttaGrid):
 
         if self.sortable:
 
-            # first clear existing settings for *sorting* only
-            # nb. this is because number of sort settings will vary
-            if to == 'defaults':
+            # first must clear all sort settings from dest. this is
+            # because number of sort settings will vary, so we delete
+            # all and then write all
+
+            if dest == 'defaults':
                 prefix = f'tailbone.{self.request.user.uuid}.grid.{self.key}'
                 query = Session.query(model.Setting)\
                                .filter(sa.or_(
@@ -1303,7 +1210,9 @@ class Grid(WuttaGrid):
                 for setting in query.all():
                     Session.delete(setting)
                 Session.flush()
+
             else: # session
+                # remove sort settings from user session
                 prefix = f'grid.{self.key}'
                 for key in list(self.request.session):
                     if key.startswith(f'{prefix}.sorters.'):
@@ -1315,10 +1224,12 @@ class Grid(WuttaGrid):
                 self.request.session.pop(f'{prefix}.sortkey', None)
                 self.request.session.pop(f'{prefix}.sortdir', None)
 
-            persist('sorters.length')
-            for i in range(1, settings['sorters.length'] + 1):
-                persist(f'sorters.{i}.key')
-                persist(f'sorters.{i}.dir')
+            # now save sort settings to dest
+            if 'sorters.length' in settings:
+                persist('sorters.length')
+                for i in range(1, settings['sorters.length'] + 1):
+                    persist(f'sorters.{i}.key')
+                    persist(f'sorters.{i}.dir')
 
         if self.paginated:
             persist('pagesize')
@@ -1351,58 +1262,32 @@ class Grid(WuttaGrid):
         if not sorters:
             return data
 
-        # sqlalchemy queries require special handling, in case of
-        # multi-column sorting
-        if isinstance(data, orm.Query):
+        # 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)
 
-            # collect actual column sorters for order_by clause
-            query_sorters = []
-            for sorter in sorters:
-                sortkey = sorter['key']
-                sortdir = sorter['dir']
+        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:
-                    log.warning("unknown sorter: %s", sorter)
-                    continue
+            # 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)
+            # 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)
 
-                # add column/dir to collection
-                query_sorters.append(getattr(sortfunc._column, sortdir)())
+            # invoke the sorter
+            data = sortfunc(data, sortdir)
 
-            # apply sorting to query
-            if query_sorters:
-                data = data.order_by(*query_sorters)
-
-            return data
-
-        # manual sorting; only one column allowed
-        if len(sorters) != 1:
-            raise NotImplementedError("mulit-column manual sorting not yet supported")
-
-        # our one and only active sorter
-        sorter = sorters[0]
-        sortkey = sorter['key']
-        sortdir = sorter['dir']
-
-        # cannot sort unless we have a sorter callable
-        sortfunc = self.sorters.get(sortkey)
-        if not sortfunc:
-            return data
-
-        # apply joins needed for this sorter
-        # TODO: is this actually relevant for manual sort?
-        if sortkey in self.joiners and sortkey not in self.joined:
-            data = self.joiners[sortkey](data)
-            self.joined.add(sortkey)
-
-        # invoke the sorter
-        return sortfunc(data, sortdir)
+        return data
 
     def paginate_data(self, data):
         """
diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako
index 3a12859e..8e3b7785 100644
--- a/tailbone/templates/base.mako
+++ b/tailbone/templates/base.mako
@@ -658,19 +658,19 @@
       ## TODO: is there a better way to check if viewing parent?
       % if parent_instance is Undefined:
           % if master.editable and instance_editable and master.has_perm('edit'):
-              <once-button tag="a" href="${action_url('edit', instance)}"
+              <once-button tag="a" href="${master.get_action_url('edit', instance)}"
                            icon-left="edit"
                            text="Edit This">
               </once-button>
           % endif
-          % if master.cloneable and master.has_perm('clone'):
-              <once-button tag="a" href="${action_url('clone', instance)}"
+          % if getattr(master, 'cloneable', False) and master.has_perm('clone'):
+              <once-button tag="a" href="${master.get_action_url('clone', instance)}"
                            icon-left="object-ungroup"
                            text="Clone This">
               </once-button>
           % endif
           % if master.deletable and instance_deletable and master.has_perm('delete'):
-              <once-button tag="a" href="${action_url('delete', instance)}"
+              <once-button tag="a" href="${master.get_action_url('delete', instance)}"
                            type="is-danger"
                            icon-left="trash"
                            text="Delete This">
@@ -679,7 +679,7 @@
       % else:
           ## viewing row
           % if instance_deletable and master.has_perm('delete_row'):
-              <once-button tag="a" href="${action_url('delete', instance)}"
+              <once-button tag="a" href="${master.get_action_url('delete', instance)}"
                            type="is-danger"
                            icon-left="trash"
                            text="Delete This">
@@ -688,13 +688,13 @@
       % endif
   % elif master and master.editing:
       % if master.viewable and master.has_perm('view'):
-          <once-button tag="a" href="${action_url('view', instance)}"
+          <once-button tag="a" href="${master.get_action_url('view', instance)}"
                        icon-left="eye"
                        text="View This">
           </once-button>
       % endif
       % if master.deletable and instance_deletable and master.has_perm('delete'):
-          <once-button tag="a" href="${action_url('delete', instance)}"
+          <once-button tag="a" href="${master.get_action_url('delete', instance)}"
                        type="is-danger"
                        icon-left="trash"
                        text="Delete This">
@@ -702,13 +702,13 @@
       % endif
   % elif master and master.deleting:
       % if master.viewable and master.has_perm('view'):
-          <once-button tag="a" href="${action_url('view', instance)}"
+          <once-button tag="a" href="${master.get_action_url('view', instance)}"
                        icon-left="eye"
                        text="View This">
           </once-button>
       % endif
       % if master.editable and instance_editable and master.has_perm('edit'):
-          <once-button tag="a" href="${action_url('edit', instance)}"
+          <once-button tag="a" href="${master.get_action_url('edit', instance)}"
                        icon-left="edit"
                        text="Edit This">
           </once-button>
diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako
index 5a005c2e..8dc2d6dc 100644
--- a/tailbone/templates/grids/complete.mako
+++ b/tailbone/templates/grids/complete.mako
@@ -83,26 +83,29 @@
 
        ## sorting
        % if grid.sortable:
-           ## nb. buefy only supports *one* default sorter
-           :default-sort="sorters.length ? [sorters[0].key, sorters[0].dir] : null"
-
-           backend-sorting
-           @sort="onSort"
-           @sorting-priority-removed="sortingPriorityRemoved"
-
-           ## TODO: there is a bug (?) which prevents the arrow from
-           ## displaying for simple default single-column sort.  so to
-           ## work around that, we *disable* multi-sort until the
-           ## component is mounted.  seems to work for now..see also
-           ## https://github.com/buefy/buefy/issues/2584
-           :sort-multiple="allowMultiSort"
-
-
-           ## nb. otherwise there may be default multi-column sort
-           :sort-multiple-data="sortingPriority"
-
-           ## user must ctrl-click column header to do multi-sort
-           sort-multiple-key="ctrlKey"
+           ## nb. buefy/oruga only support *one* default sorter
+           :default-sort="sorters.length ? [sorters[0].field, sorters[0].order] : null"
+           % if grid.sort_on_backend:
+               backend-sorting
+               @sort="onSort"
+           % endif
+           % if grid.sort_multiple:
+               % if grid.sort_on_backend:
+                   ## TODO: there is a bug (?) which prevents the arrow
+                   ## from displaying for simple default single-column sort,
+                   ## when multi-column sort is allowed for the table.  for
+                   ## now we work around that by waiting until mount to
+                   ## enable the multi-column support.  see also
+                   ## https://github.com/buefy/buefy/issues/2584
+                   :sort-multiple="allowMultiSort"
+                   :sort-multiple-data="sortingPriority"
+                   @sorting-priority-removed="sortingPriorityRemoved"
+               % else:
+                   sort-multiple
+               % endif
+               ## nb. user must ctrl-click column header for multi-sort
+               sort-multiple-key="ctrlKey"
+           % endif
        % endif
 
        % if getattr(grid, 'click_handlers', None):
@@ -276,23 +279,24 @@
 
       ## sorting
       % if grid.sortable:
-          sorters: ${json.dumps(grid.active_sorters)|n},
-
-          ## TODO: there is a bug (?) which prevents the arrow from
-          ## displaying for simple default single-column sort.  so to
-          ## work around that, we *disable* multi-sort until the
-          ## component is mounted.  seems to work for now..see also
-          ## https://github.com/buefy/buefy/issues/2584
-          allowMultiSort: false,
-
-          ## nb. this will only contain multi-column sorters,
-          ## but will be *empty* for single-column sorting
-          % if len(grid.active_sorters) > 1:
-              sortingPriority: ${json.dumps(grid.active_sorters)|n},
-          % else:
-              sortingPriority: [],
+          sorters: ${json.dumps(grid.get_vue_active_sorters())|n},
+          % if grid.sort_multiple:
+              % if grid.sort_on_backend:
+                  ## TODO: there is a bug (?) which prevents the arrow
+                  ## from displaying for simple default single-column sort,
+                  ## when multi-column sort is allowed for the table.  for
+                  ## now we work around that by waiting until mount to
+                  ## enable the multi-column support.  see also
+                  ## https://github.com/buefy/buefy/issues/2584
+                  allowMultiSort: false,
+                  ## nb. this should be empty when current sort is single-column
+                  % if len(grid.active_sorters) > 1:
+                      sortingPriority: ${json.dumps(grid.get_vue_active_sorters())|n},
+                  % else:
+                      sortingPriority: [],
+                  % endif
+              % endif
           % endif
-
       % endif
 
       ## filterable: ${json.dumps(grid.filterable)|n},
@@ -395,14 +399,19 @@
           },
       },
 
-      mounted() {
-          ## TODO: there is a bug (?) which prevents the arrow from
-          ## displaying for simple default single-column sort.  so to
-          ## work around that, we *disable* multi-sort until the
-          ## component is mounted.  seems to work for now..see also
-          ## https://github.com/buefy/buefy/issues/2584
-          this.allowMultiSort = true
-      },
+      % if grid.sortable and grid.sort_multiple and grid.sort_on_backend:
+
+            ## TODO: there is a bug (?) which prevents the arrow
+            ## from displaying for simple default single-column sort,
+            ## when multi-column sort is allowed for the table.  for
+            ## now we work around that by waiting until mount to
+            ## enable the multi-column support.  see also
+            ## https://github.com/buefy/buefy/issues/2584
+            mounted() {
+                this.allowMultiSort = true
+            },
+
+      % endif
 
       methods: {
 
@@ -483,8 +492,8 @@
               }
               % if grid.sortable and grid.sort_on_backend:
                   for (let i = 1; i <= this.sorters.length; i++) {
-                      params['sort'+i+'key'] = this.sorters[i-1].key
-                      params['sort'+i+'dir'] = this.sorters[i-1].dir
+                      params['sort'+i+'key'] = this.sorters[i-1].field
+                      params['sort'+i+'dir'] = this.sorters[i-1].order
                   }
               % endif
               return params
@@ -597,48 +606,66 @@
               })
           },
 
-          onSort(field, order, event) {
+          % if grid.sortable and grid.sort_on_backend:
 
-              ## nb. buefy passes field name; oruga passes field object
-              % if request.use_oruga:
-                  field = field.field
-              % endif
+              onSort(field, order, event) {
 
-              if (event.ctrlKey) {
+                  ## nb. buefy passes field name; oruga passes field object
+                  % if request.use_oruga:
+                      field = field.field
+                  % endif
 
-                  // engage or enhance multi-column sorting
-                  const sorter = this.sorters.filter(s => s.key === field)[0]
-                  if (sorter) {
-                      sorter.dir = sorter.dir === 'desc' ? 'asc' : 'desc'
-                  } else {
-                      this.sorters.push({key: field, dir: order})
-                  }
-                  this.sortingPriority = this.sorters
+                  % if grid.sort_multiple:
 
-              } else {
+                      // did user ctrl-click the column header?
+                      if (event.ctrlKey) {
+
+                          // toggle direction for existing, or add new sorter
+                          const sorter = this.sorters.filter(s => s.field === field)[0]
+                          if (sorter) {
+                              sorter.order = sorter.order === 'desc' ? 'asc' : 'desc'
+                          } else {
+                              this.sorters.push({field, order})
+                          }
+
+                          // apply multi-column sorting
+                          this.sortingPriority = this.sorters
+
+                      } else {
+
+                  % endif
 
                   // sort by single column only
-                  this.sorters = [{key: field, dir: order}]
-                  this.sortingPriority = []
-              }
+                  this.sorters = [{field, order}]
 
-              // always reset to first page when changing sort options
-              // TODO: i mean..right? would we ever not want that?
-              this.currentPage = 1
-              this.loadAsyncData()
-          },
+                  % if grid.sort_multiple:
+                          // multi-column sort not engaged
+                          this.sortingPriority = []
+                      }
+                  % endif
 
-          sortingPriorityRemoved(field) {
+                  // nb. always reset to first page when sorting changes
+                  this.currentPage = 1
+                  this.loadAsyncData()
+              },
 
-              // prune field from active sorters
-              this.sorters = this.sorters.filter(s => s.key !== field)
+              % if grid.sort_multiple:
 
-              // nb. must keep active sorter list "as-is" even if
-              // there is only one sorter; buefy seems to expect it
-              this.sortingPriority = this.sorters
+                  sortingPriorityRemoved(field) {
 
-              this.loadAsyncData()
-          },
+                      // prune from active sorters
+                      this.sorters = this.sorters.filter(s => s.field !== field)
+
+                      // nb. even though we might have just one sorter
+                      // now, we are still technically in multi-sort mode
+                      this.sortingPriority = this.sorters
+
+                      this.loadAsyncData()
+                  },
+
+              % endif
+
+          % endif
 
           resetView() {
               this.loading = true
diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index 53f46020..dde72106 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -341,7 +341,7 @@ class MasterView(View):
             return self.redirect(self.request.current_route_url(**kw))
 
         # Stash some grid stats, for possible use when generating URLs.
-        if grid.pageable and hasattr(grid, 'pager'):
+        if grid.paginated and hasattr(grid, 'pager'):
             self.first_visible_grid_index = grid.pager.first_item
 
         # return grid data only, if partial page was requested
@@ -442,6 +442,7 @@ class MasterView(View):
             'filterable': self.filterable,
             'use_byte_string_filters': self.use_byte_string_filters,
             'sortable': self.sortable,
+            'sort_multiple': not self.request.use_oruga,
             'paginated': self.pageable,
             'extra_row_class': self.grid_extra_class,
             'url': lambda obj: self.get_action_url('view', obj),
diff --git a/tests/grids/test_core.py b/tests/grids/test_core.py
index 9f9b816f..c621627a 100644
--- a/tests/grids/test_core.py
+++ b/tests/grids/test_core.py
@@ -388,14 +388,63 @@ class TestGrid(WebTestCase):
         grid.load_settings()
         self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'desc'}])
 
+    def test_persist_settings(self):
+        model = self.app.model
+
+        # nb. start out with paginated-only grid
+        grid = self.make_grid(key='foo', paginated=True, paginate_on_backend=True)
+
+        # invalid dest
+        self.assertRaises(ValueError, grid.persist_settings, {}, dest='doesnotexist')
+
+        # nb. no error if empty settings, but it saves null values
+        grid.persist_settings({}, dest='session')
+        self.assertIsNone(self.request.session['grid.foo.page'])
+
+        # provided values are saved
+        grid.persist_settings({'pagesize': 15, 'page': 3}, dest='session')
+        self.assertEqual(self.request.session['grid.foo.page'], 3)
+
+        # nb. now switch to sortable-only grid
+        grid = self.make_grid(key='settings', model_class=model.Setting,
+                              sortable=True, sort_on_backend=True)
+
+        # no error if empty settings; does not save values
+        grid.persist_settings({}, dest='session')
+        self.assertNotIn('grid.settings.sorters.length', self.request.session)
+
+        # provided values are saved
+        grid.persist_settings({'sorters.length': 2,
+                               'sorters.1.key': 'name',
+                               'sorters.1.dir': 'desc',
+                               'sorters.2.key': 'value',
+                               'sorters.2.dir': 'asc'},
+                              dest='session')
+        self.assertEqual(self.request.session['grid.settings.sorters.length'], 2)
+        self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name')
+        self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'desc')
+        self.assertEqual(self.request.session['grid.settings.sorters.2.key'], 'value')
+        self.assertEqual(self.request.session['grid.settings.sorters.2.dir'], 'asc')
+
+        # old values removed when new are saved
+        grid.persist_settings({'sorters.length': 1,
+                               'sorters.1.key': 'name',
+                               'sorters.1.dir': 'desc'},
+                              dest='session')
+        self.assertEqual(self.request.session['grid.settings.sorters.length'], 1)
+        self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name')
+        self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'desc')
+        self.assertNotIn('grid.settings.sorters.2.key', self.request.session)
+        self.assertNotIn('grid.settings.sorters.2.dir', self.request.session)
+
     def test_sort_data(self):
         model = self.app.model
         sample_data = [
             {'name': 'foo1', 'value': 'ONE'},
             {'name': 'foo2', 'value': 'two'},
-            {'name': 'foo3', 'value': 'three'},
-            {'name': 'foo4', 'value': 'four'},
-            {'name': 'foo5', 'value': 'five'},
+            {'name': 'foo3', 'value': 'ggg'},
+            {'name': 'foo4', 'value': 'ggg'},
+            {'name': 'foo5', 'value': 'ggg'},
             {'name': 'foo6', 'value': 'six'},
             {'name': 'foo7', 'value': 'seven'},
             {'name': 'foo8', 'value': 'eight'},
@@ -432,32 +481,30 @@ class TestGrid(WebTestCase):
         self.assertEqual(sorted_data[0]['name'], 'foo1')
         self.assertEqual(sorted_data[-1]['name'], 'foo9')
 
-        # error if mult-column sort attempted
-        self.assertRaises(NotImplementedError, grid.sort_data, sample_data, sorters=[
-            {'key': 'name', 'dir': 'desc'},
-            {'key': 'value', 'dir': 'asc'},
-        ])
+        # multi-column sorting for list data
+        sorted_data = grid.sort_data(sample_data, sorters=[{'key': 'value', 'dir': 'asc'},
+                                                           {'key': 'name', 'dir': 'asc'}])
+        self.assertEqual(dict(sorted_data[0]), {'name': 'foo8', 'value': 'eight'})
+        self.assertEqual(dict(sorted_data[1]), {'name': 'foo3', 'value': 'ggg'})
+        self.assertEqual(dict(sorted_data[3]), {'name': 'foo5', 'value': 'ggg'})
+        self.assertEqual(dict(sorted_data[-1]), {'name': 'foo2', 'value': 'two'})
+
+        # multi-column sorting for query
+        sorted_query = grid.sort_data(sample_query, sorters=[{'key': 'value', 'dir': 'asc'},
+                                                             {'key': 'name', 'dir': 'asc'}])
+        self.assertEqual(dict(sorted_data[0]), {'name': 'foo8', 'value': 'eight'})
+        self.assertEqual(dict(sorted_data[1]), {'name': 'foo3', 'value': 'ggg'})
+        self.assertEqual(dict(sorted_data[3]), {'name': 'foo5', 'value': 'ggg'})
+        self.assertEqual(dict(sorted_data[-1]), {'name': 'foo2', 'value': 'two'})
 
         # cannot sort data if sortfunc is missing for column
         grid.remove_sorter('name')
-        sorted_data = grid.sort_data(sample_data)
+        sorted_data = grid.sort_data(sample_data, sorters=[{'key': 'value', 'dir': 'asc'},
+                                                           {'key': 'name', 'dir': 'asc'}])
         # nb. sorted data is in same order as original sample (not sorted)
         self.assertEqual(sorted_data[0]['name'], 'foo1')
         self.assertEqual(sorted_data[-1]['name'], 'foo9')
 
-        # cannot sort data if sortfunc is missing for column
-        grid.remove_sorter('name')
-        # nb. attempting multi-column sort, but only one sorter exists
-        self.assertEqual(list(grid.sorters), ['value'])
-        grid.active_sorters = [{'key': 'name', 'dir': 'asc'},
-                               {'key': 'value', 'dir': 'asc'}]
-        with patch.object(sample_query, 'order_by') as order_by:
-            order_by.return_value = 42
-            sorted_query = grid.sort_data(sample_query)
-            order_by.assert_called_once()
-            self.assertEqual(len(order_by.call_args.args), 1)
-            self.assertEqual(sorted_query, 42)
-
     def test_render_vue_tag(self):
         model = self.app.model
 

From b7955a587179e4c7819a9d0a67a60be280e9c386 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 18 Aug 2024 19:58:50 -0500
Subject: [PATCH 451/542] =?UTF-8?q?bump:=20version=200.18.0=20=E2=86=92=20?=
 =?UTF-8?q?0.19.0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

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

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0671e03b..72798b30 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,18 @@ 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.0 (2024-08-18)
+
+### Feat
+
+- move multi-column grid sorting logic to wuttaweb
+- move single-column grid sorting logic to wuttaweb
+
+### Fix
+
+- fix misc. errors in grid template per wuttaweb
+- fix broken permission directives in web api startup
+
 ## v0.18.0 (2024-08-16)
 
 ### Feat
diff --git a/pyproject.toml b/pyproject.toml
index bd4882c6..1840de77 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.18.0"
+version = "0.19.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.9.0",
+        "WuttaWeb>=0.10.0",
         "zope.sqlalchemy>=1.5",
 ]
 

From 0fb3c0f3d2dde74157b77d0313756151c1373317 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 19 Aug 2024 09:23:31 -0500
Subject: [PATCH 452/542] fix: fix broken user auth for web API app

---
 tailbone/api/auth.py | 12 +++++------
 tailbone/app.py      |  4 ----
 tailbone/auth.py     | 50 ++++++++++----------------------------------
 3 files changed, 16 insertions(+), 50 deletions(-)

diff --git a/tailbone/api/auth.py b/tailbone/api/auth.py
index 1b347b21..a710e30d 100644
--- a/tailbone/api/auth.py
+++ b/tailbone/api/auth.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,8 +24,6 @@
 Tailbone Web API - Auth Views
 """
 
-from rattail.db.auth import set_user_password
-
 from cornice import Service
 
 from tailbone.api import APIView, api
@@ -42,11 +40,10 @@ class AuthenticationView(APIView):
         This will establish a server-side web session for the user if none
         exists.  Note that this also resets the user's session timer.
         """
-        data = {'ok': True}
+        data = {'ok': True, 'permissions': []}
         if self.request.user:
             data['user'] = self.get_user_info(self.request.user)
-
-        data['permissions'] = list(self.request.tailbone_cached_permissions)
+            data['permissions'] = list(self.request.user_permissions)
 
         # background color may be set per-request, by some apps
         if hasattr(self.request, 'background_color') and self.request.background_color:
@@ -176,7 +173,8 @@ class AuthenticationView(APIView):
             return {'error': "The current/old password you provided is incorrect"}
 
         # okay then, set new password
-        set_user_password(self.request.user, data['new_password'])
+        auth = self.app.get_auth_handler()
+        auth.set_user_password(self.request.user, data['new_password'])
         return {
             'ok': True,
             'user': self.get_user_info(self.request.user),
diff --git a/tailbone/app.py b/tailbone/app.py
index 5e8e49d9..626c9206 100644
--- a/tailbone/app.py
+++ b/tailbone/app.py
@@ -25,19 +25,15 @@ Application Entry Point
 """
 
 import os
-import warnings
 
-import sqlalchemy as sa
 from sqlalchemy.orm import sessionmaker, scoped_session
 
 from wuttjamaican.util import parse_list
 
 from rattail.config import make_config
 from rattail.exceptions import ConfigurationError
-from rattail.db.types import GPCType
 
 from pyramid.config import Configurator
-from pyramid.authentication import SessionAuthenticationPolicy
 from zope.sqlalchemy import register
 
 import tailbone.db
diff --git a/tailbone/auth.py b/tailbone/auth.py
index fbe6bf2f..95bf90ba 100644
--- a/tailbone/auth.py
+++ b/tailbone/auth.py
@@ -27,20 +27,18 @@ Authentication & Authorization
 import logging
 import re
 
-from rattail.util import NOTSET
+from wuttjamaican.util import UNSPECIFIED
 
-from zope.interface import implementer
-from pyramid.authentication import SessionAuthenticationHelper
-from pyramid.request import RequestLocalCache
 from pyramid.security import remember, forget
 
+from wuttaweb.auth import WuttaSecurityPolicy
 from tailbone.db import Session
 
 
 log = logging.getLogger(__name__)
 
 
-def login_user(request, user, timeout=NOTSET):
+def login_user(request, user, timeout=UNSPECIFIED):
     """
     Perform the steps necessary to login the given user.  Note that this
     returns a ``headers`` dict which you should pass to the redirect.
@@ -49,7 +47,7 @@ def login_user(request, user, timeout=NOTSET):
     app = config.get_app()
     user.record_event(app.enum.USER_EVENT_LOGIN)
     headers = remember(request, user.uuid)
-    if timeout is NOTSET:
+    if timeout is UNSPECIFIED:
         timeout = session_timeout_for_user(config, user)
     log.debug("setting session timeout for '{}' to {}".format(user.username, timeout))
     set_session_timeout(request, timeout)
@@ -94,12 +92,12 @@ def set_session_timeout(request, timeout):
     request.session['_timeout'] = timeout or None
 
 
-class TailboneSecurityPolicy:
+class TailboneSecurityPolicy(WuttaSecurityPolicy):
 
-    def __init__(self, api_mode=False):
+    def __init__(self, db_session=None, api_mode=False, **kwargs):
+        kwargs['db_session'] = db_session or Session()
+        super().__init__(**kwargs)
         self.api_mode = api_mode
-        self.session_helper = SessionAuthenticationHelper()
-        self.identity_cache = RequestLocalCache(self.load_identity)
 
     def load_identity(self, request):
         config = request.registry.settings.get('rattail_config')
@@ -115,7 +113,7 @@ class TailboneSecurityPolicy:
                 if match:
                     token = match.group(1)
                     auth = app.get_auth_handler()
-                    user = auth.authenticate_user_token(Session(), token)
+                    user = auth.authenticate_user_token(self.db_session, token)
 
         if not user:
 
@@ -126,36 +124,10 @@ class TailboneSecurityPolicy:
 
             # fetch user object from db
             model = app.model
-            user = Session.get(model.User, uuid)
+            user = self.db_session.get(model.User, uuid)
             if not user:
                 return
 
         # this user is responsible for data changes in current request
-        Session().set_continuum_user(user)
+        self.db_session.set_continuum_user(user)
         return user
-
-    def identity(self, request):
-        return self.identity_cache.get_or_create(request)
-
-    def authenticated_userid(self, request):
-        user = self.identity(request)
-        if user is not None:
-            return user.uuid
-
-    def remember(self, request, userid, **kw):
-        return self.session_helper.remember(request, userid, **kw)
-
-    def forget(self, request, **kw):
-        return self.session_helper.forget(request, **kw)
-
-    def permits(self, request, context, permission):
-        # nb. root user can do anything
-        if request.is_root:
-            return True
-
-        config = request.registry.settings.get('rattail_config')
-        app = config.get_app()
-        auth = app.get_auth_handler()
-
-        user = self.identity(request)
-        return auth.has_permission(Session(), user, permission)

From b642c98d4091729ef8f957abb213d70c2c2e8fb8 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 19 Aug 2024 09:23:55 -0500
Subject: [PATCH 453/542] =?UTF-8?q?bump:=20version=200.19.0=20=E2=86=92=20?=
 =?UTF-8?q?0.19.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 72798b30..ce64ec60 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.1 (2024-08-19)
+
+### Fix
+
+- fix broken user auth for web API app
+
 ## v0.19.0 (2024-08-18)
 
 ### Feat
diff --git a/pyproject.toml b/pyproject.toml
index 1840de77..fa33a2df 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.19.0"
+version = "0.19.1"
 description = "Backoffice Web Application for Rattail"
 readme = "README.rst"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]

From 1d56a4c0d09d857f3d9276ac010743eceb8e2eac Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 19 Aug 2024 09:53:10 -0500
Subject: [PATCH 454/542] fix: replace all occurrences of `component_studly` =>
 `vue_component`

---
 tailbone/grids/core.py                        |  2 +-
 tailbone/templates/batch/index.mako           |  6 +++---
 .../batch/inventory/desktop_form.mako         |  4 ++--
 tailbone/templates/batch/pos/view.mako        |  2 +-
 .../templates/batch/vendorcatalog/create.mako | 12 +++++------
 tailbone/templates/batch/view.mako            | 18 ++++++++---------
 tailbone/templates/customers/view.mako        |  4 ++--
 tailbone/templates/custorders/items/view.mako |  6 +++---
 tailbone/templates/departments/view.mako      |  2 +-
 tailbone/templates/importing/runjob.mako      | 14 ++++++-------
 tailbone/templates/login.mako                 |  8 ++++----
 tailbone/templates/master/form.mako           |  2 +-
 tailbone/templates/people/index.mako          | 16 +++++++--------
 tailbone/templates/poser/reports/view.mako    |  6 +++---
 tailbone/templates/products/batch.mako        | 20 +++++++++----------
 tailbone/templates/products/index.mako        |  8 ++++----
 .../templates/purchases/credits/index.mako    | 12 +++++------
 tailbone/templates/receiving/view.mako        |  8 ++++----
 .../templates/reports/generated/delete.mako   |  2 +-
 .../templates/reports/generated/view.mako     |  2 +-
 tailbone/templates/reports/problems/view.mako |  2 +-
 tailbone/templates/roles/view.mako            |  2 +-
 tailbone/templates/settings/email/index.mako  |  8 ++++----
 .../templates/tempmon/appliances/view.mako    |  2 +-
 tailbone/templates/tempmon/clients/view.mako  |  2 +-
 .../trainwreck/transactions/view.mako         |  2 +-
 .../trainwreck/transactions/view_row.mako     |  2 +-
 tailbone/templates/users/view.mako            |  2 +-
 28 files changed, 88 insertions(+), 88 deletions(-)

diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py
index 9c445fec..d00a85ae 100644
--- a/tailbone/grids/core.py
+++ b/tailbone/grids/core.py
@@ -1697,7 +1697,7 @@ class Grid(WuttaGrid):
             results['checked_rows'] = checked
             # TODO: this seems a bit hacky, but is required for now to
             # initialize things on the client side...
-            var = '{}CurrentData'.format(self.component_studly)
+            var = '{}CurrentData'.format(self.vue_component)
             results['checked_rows_code'] = '[{}]'.format(
                 ', '.join(['{}[{}]'.format(var, i) for i in checked]))
 
diff --git a/tailbone/templates/batch/index.mako b/tailbone/templates/batch/index.mako
index 209fbb0c..a7808590 100644
--- a/tailbone/templates/batch/index.mako
+++ b/tailbone/templates/batch/index.mako
@@ -83,7 +83,7 @@
   % if master.results_executable and master.has_perm('execute_multiple'):
       <script type="text/javascript">
 
-        ${execute_form.component_studly}.methods.submit = function() {
+        ${execute_form.vue_component}.methods.submit = function() {
             this.$refs.actualExecuteForm.submit()
         }
 
@@ -123,9 +123,9 @@
   % if master.results_executable and master.has_perm('execute_multiple'):
       <script type="text/javascript">
 
-        ${execute_form.component_studly}.data = function() { return ${execute_form.component_studly}Data }
+        ${execute_form.vue_component}.data = function() { return ${execute_form.vue_component}Data }
 
-        Vue.component('${execute_form.component}', ${execute_form.component_studly})
+        Vue.component('${execute_form.component}', ${execute_form.vue_component})
 
       </script>
   % endif
diff --git a/tailbone/templates/batch/inventory/desktop_form.mako b/tailbone/templates/batch/inventory/desktop_form.mako
index 7e4795a8..8ca32ce0 100644
--- a/tailbone/templates/batch/inventory/desktop_form.mako
+++ b/tailbone/templates/batch/inventory/desktop_form.mako
@@ -147,7 +147,7 @@
 
   <script type="text/javascript">
 
-    let ${form.component_studly} = {
+    let ${form.vue_component} = {
         template: '#${form.component}-template',
         mixins: [SimpleRequestMixin],
 
@@ -278,7 +278,7 @@
         },
     }
 
-    let ${form.component_studly}Data = {
+    let ${form.vue_component}Data = {
         submitting: false,
 
         productUPC: null,
diff --git a/tailbone/templates/batch/pos/view.mako b/tailbone/templates/batch/pos/view.mako
index 0da755aa..bdb8709d 100644
--- a/tailbone/templates/batch/pos/view.mako
+++ b/tailbone/templates/batch/pos/view.mako
@@ -5,7 +5,7 @@
   ${parent.modify_this_page_vars()}
   <script type="text/javascript">
 
-    ${form.component_studly}Data.taxesData = ${json.dumps(taxes_data)|n}
+    ${form.vue_component}Data.taxesData = ${json.dumps(taxes_data)|n}
 
   </script>
 </%def>
diff --git a/tailbone/templates/batch/vendorcatalog/create.mako b/tailbone/templates/batch/vendorcatalog/create.mako
index d25c8f16..63865bd5 100644
--- a/tailbone/templates/batch/vendorcatalog/create.mako
+++ b/tailbone/templates/batch/vendorcatalog/create.mako
@@ -5,12 +5,12 @@
   ${parent.modify_this_page_vars()}
   <script type="text/javascript">
 
-    ${form.component_studly}Data.parsers = ${json.dumps(parsers_data)|n}
+    ${form.vue_component}Data.parsers = ${json.dumps(parsers_data)|n}
 
-    ${form.component_studly}Data.vendorName = null
-    ${form.component_studly}Data.vendorNameReplacement = null
+    ${form.vue_component}Data.vendorName = null
+    ${form.vue_component}Data.vendorNameReplacement = null
 
-    ${form.component_studly}.watch.field_model_parser_key = function(val) {
+    ${form.vue_component}.watch.field_model_parser_key = function(val) {
         let parser = this.parsers[val]
         if (parser.vendor_uuid) {
             if (this.field_model_vendor_uuid != parser.vendor_uuid) {
@@ -24,11 +24,11 @@
         }
     }
 
-    ${form.component_studly}.methods.vendorLabelChanging = function(label) {
+    ${form.vue_component}.methods.vendorLabelChanging = function(label) {
         this.vendorNameReplacement = label
     }
 
-    ${form.component_studly}.methods.vendorChanged = function(uuid) {
+    ${form.vue_component}.methods.vendorChanged = function(uuid) {
         if (uuid) {
             this.vendorName = this.vendorNameReplacement
             this.vendorNameReplacement = null
diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako
index 63cb9056..bef18cd4 100644
--- a/tailbone/templates/batch/view.mako
+++ b/tailbone/templates/batch/view.mako
@@ -285,7 +285,7 @@
     }
 
     % if not batch.executed and master.has_perm('edit'):
-        ${form.component_studly}Data.togglingBatchComplete = false
+        ${form.vue_component}Data.togglingBatchComplete = false
     % endif
 
     % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'):
@@ -306,7 +306,7 @@
             form.submit()
         }
 
-        ${upload_worksheet_form.component_studly}.methods.submit = function() {
+        ${upload_worksheet_form.vue_component}.methods.submit = function() {
             this.$refs.actualUploadForm.submit()
         }
 
@@ -321,7 +321,7 @@
             this.$refs.executeBatchForm.submit()
         }
 
-        ${execute_form.component_studly}.methods.submit = function() {
+        ${execute_form.vue_component}.methods.submit = function() {
             this.$refs.actualExecuteForm.submit()
         }
 
@@ -329,9 +329,9 @@
 
     % if master.rows_bulk_deletable and not batch.executed and master.has_perm('delete_rows'):
 
-        ${rows_grid.component_studly}Data.deleteResultsShowDialog = false
+        ${rows_grid.vue_component}Data.deleteResultsShowDialog = false
 
-        ${rows_grid.component_studly}.methods.deleteResultsInit = function() {
+        ${rows_grid.vue_component}.methods.deleteResultsInit = function() {
             this.deleteResultsShowDialog = true
         }
 
@@ -346,8 +346,8 @@
       <script type="text/javascript">
 
         ## UploadForm
-        ${upload_worksheet_form.component_studly}.data = function() { return ${upload_worksheet_form.component_studly}Data }
-        Vue.component('${upload_worksheet_form.component}', ${upload_worksheet_form.component_studly})
+        ${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
@@ -356,8 +356,8 @@
       <script type="text/javascript">
 
         ## ExecuteForm
-        ${execute_form.component_studly}.data = function() { return ${execute_form.component_studly}Data }
-        Vue.component('${execute_form.component}', ${execute_form.component_studly})
+        ${execute_form.vue_component}.data = function() { return ${execute_form.vue_component}Data }
+        Vue.component('${execute_form.component}', ${execute_form.vue_component})
 
       </script>
   % endif
diff --git a/tailbone/templates/customers/view.mako b/tailbone/templates/customers/view.mako
index 8b07bdb3..bbca9580 100644
--- a/tailbone/templates/customers/view.mako
+++ b/tailbone/templates/customers/view.mako
@@ -21,10 +21,10 @@
   <script type="text/javascript">
 
     % if expose_shoppers:
-    ${form.component_studly}Data.shoppers = ${json.dumps(shoppers_data)|n}
+    ${form.vue_component}Data.shoppers = ${json.dumps(shoppers_data)|n}
     % endif
     % if expose_people:
-    ${form.component_studly}Data.peopleData = ${json.dumps(people_data)|n}
+    ${form.vue_component}Data.peopleData = ${json.dumps(people_data)|n}
     % endif
 
     ThisPage.methods.detachPerson = function(url) {
diff --git a/tailbone/templates/custorders/items/view.mako b/tailbone/templates/custorders/items/view.mako
index f7a6dd0a..8eaee69a 100644
--- a/tailbone/templates/custorders/items/view.mako
+++ b/tailbone/templates/custorders/items/view.mako
@@ -295,7 +295,7 @@
   ${parent.modify_this_page_vars()}
   <script type="text/javascript">
 
-    ${form.component_studly}Data.eventsData = ${json.dumps(events_data)|n}
+    ${form.vue_component}Data.eventsData = ${json.dumps(events_data)|n}
 
     % if master.has_perm('confirm_price'):
 
@@ -392,9 +392,9 @@
             this.$refs.changeStatusForm.submit()
         }
 
-        ${form.component_studly}Data.changeFlaggedSubmitting = false
+        ${form.vue_component}Data.changeFlaggedSubmitting = false
 
-        ${form.component_studly}.methods.changeFlaggedSubmit = function() {
+        ${form.vue_component}.methods.changeFlaggedSubmit = function() {
             this.changeFlaggedSubmitting = true
         }
 
diff --git a/tailbone/templates/departments/view.mako b/tailbone/templates/departments/view.mako
index 442f045f..f892f333 100644
--- a/tailbone/templates/departments/view.mako
+++ b/tailbone/templates/departments/view.mako
@@ -5,7 +5,7 @@
   ${parent.modify_this_page_vars()}
   <script type="text/javascript">
 
-    ${form.component_studly}Data.employeesData = ${json.dumps(employees_data)|n}
+    ${form.vue_component}Data.employeesData = ${json.dumps(employees_data)|n}
 
   </script>
 </%def>
diff --git a/tailbone/templates/importing/runjob.mako b/tailbone/templates/importing/runjob.mako
index 2bc2a4e9..23526ed2 100644
--- a/tailbone/templates/importing/runjob.mako
+++ b/tailbone/templates/importing/runjob.mako
@@ -67,21 +67,21 @@
   ${parent.modify_this_page_vars()}
   <script type="text/javascript">
 
-    ${form.component_studly}Data.submittingRun = false
-    ${form.component_studly}Data.submittingExplain = false
-    ${form.component_studly}Data.runJob = false
+    ${form.vue_component}Data.submittingRun = false
+    ${form.vue_component}Data.submittingExplain = false
+    ${form.vue_component}Data.runJob = false
 
-    ${form.component_studly}.methods.submitRun = function() {
+    ${form.vue_component}.methods.submitRun = function() {
         this.submittingRun = true
         this.runJob = true
         this.$nextTick(() => {
-            this.$refs.${form.component_studly}.submit()
+            this.$refs.${form.vue_component}.submit()
         })
     }
 
-    ${form.component_studly}.methods.submitExplain = function() {
+    ${form.vue_component}.methods.submitExplain = function() {
         this.submittingExplain = true
-        this.$refs.${form.component_studly}.submit()
+        this.$refs.${form.vue_component}.submit()
     }
 
   </script>
diff --git a/tailbone/templates/login.mako b/tailbone/templates/login.mako
index d18323b5..f898660f 100644
--- a/tailbone/templates/login.mako
+++ b/tailbone/templates/login.mako
@@ -60,19 +60,19 @@
 <%def name="modify_this_page_vars()">
   <script type="text/javascript">
 
-    ${form.component_studly}Data.usernameInput = null
+    ${form.vue_component}Data.usernameInput = null
 
-    ${form.component_studly}.mounted = function() {
+    ${form.vue_component}.mounted = function() {
         this.$refs.username.focus()
         this.usernameInput = this.$refs.username.$el.querySelector('input')
         this.usernameInput.addEventListener('keydown', this.usernameKeydown)
     }
 
-    ${form.component_studly}.beforeDestroy = function() {
+    ${form.vue_component}.beforeDestroy = function() {
         this.usernameInput.removeEventListener('keydown', this.usernameKeydown)
     }
 
-    ${form.component_studly}.methods.usernameKeydown = function(event) {
+    ${form.vue_component}.methods.usernameKeydown = function(event) {
         if (event.which == 13) {
             event.preventDefault()
             this.$refs.password.focus()
diff --git a/tailbone/templates/master/form.mako b/tailbone/templates/master/form.mako
index dc9743ea..fac18ee2 100644
--- a/tailbone/templates/master/form.mako
+++ b/tailbone/templates/master/form.mako
@@ -8,7 +8,7 @@
     ## declare extra data needed by form
     % if form is not Undefined and getattr(form, 'json_data', None):
         % for key, value in form.json_data.items():
-            ${form.component_studly}Data.${key} = ${json.dumps(value)|n}
+            ${form.vue_component}Data.${key} = ${json.dumps(value)|n}
         % endfor
     % endif
 
diff --git a/tailbone/templates/people/index.mako b/tailbone/templates/people/index.mako
index 9339dfd5..6ce14633 100644
--- a/tailbone/templates/people/index.mako
+++ b/tailbone/templates/people/index.mako
@@ -67,31 +67,31 @@
 
     % if getattr(master, 'mergeable', False) and master.has_perm('request_merge'):
 
-        ${grid.component_studly}Data.mergeRequestShowDialog = false
-        ${grid.component_studly}Data.mergeRequestRows = []
-        ${grid.component_studly}Data.mergeRequestSubmitText = "Submit Merge Request"
-        ${grid.component_studly}Data.mergeRequestSubmitting = false
+        ${grid.vue_component}Data.mergeRequestShowDialog = false
+        ${grid.vue_component}Data.mergeRequestRows = []
+        ${grid.vue_component}Data.mergeRequestSubmitText = "Submit Merge Request"
+        ${grid.vue_component}Data.mergeRequestSubmitting = false
 
-        ${grid.component_studly}.computed.mergeRequestRemovingUUID = function() {
+        ${grid.vue_component}.computed.mergeRequestRemovingUUID = function() {
             if (this.mergeRequestRows.length) {
                 return this.mergeRequestRows[0].uuid
             }
             return null
         }
 
-        ${grid.component_studly}.computed.mergeRequestKeepingUUID = function() {
+        ${grid.vue_component}.computed.mergeRequestKeepingUUID = function() {
             if (this.mergeRequestRows.length) {
                 return this.mergeRequestRows[1].uuid
             }
             return null
         }
 
-        ${grid.component_studly}.methods.showMergeRequest = function() {
+        ${grid.vue_component}.methods.showMergeRequest = function() {
             this.mergeRequestRows = this.checkedRows
             this.mergeRequestShowDialog = true
         }
 
-        ${grid.component_studly}.methods.submitMergeRequest = function() {
+        ${grid.vue_component}.methods.submitMergeRequest = function() {
             this.mergeRequestSubmitting = true
             this.mergeRequestSubmitText = "Working, please wait..."
         }
diff --git a/tailbone/templates/poser/reports/view.mako b/tailbone/templates/poser/reports/view.mako
index aac0c7ae..274a8806 100644
--- a/tailbone/templates/poser/reports/view.mako
+++ b/tailbone/templates/poser/reports/view.mako
@@ -67,11 +67,11 @@
   % if master.has_perm('replace'):
   <script type="text/javascript">
 
-    ${form.component_studly}Data.showUploadForm = false
+    ${form.vue_component}Data.showUploadForm = false
 
-    ${form.component_studly}Data.uploadFile = null
+    ${form.vue_component}Data.uploadFile = null
 
-    ${form.component_studly}Data.uploadSubmitting = false
+    ${form.vue_component}Data.uploadSubmitting = false
 
   </script>
   % endif
diff --git a/tailbone/templates/products/batch.mako b/tailbone/templates/products/batch.mako
index a4a4d503..66e38028 100644
--- a/tailbone/templates/products/batch.mako
+++ b/tailbone/templates/products/batch.mako
@@ -22,7 +22,7 @@
 </%def>
 
 <%def name="render_form_innards()">
-  ${h.form(request.current_route_url(), **{'@submit': 'submit{}'.format(form.component_studly)})}
+  ${h.form(request.current_route_url(), **{'@submit': 'submit{}'.format(form.vue_component)})}
   ${h.csrf_token(request)}
 
   <section>
@@ -43,8 +43,8 @@
   <div class="buttons">
     <b-button type="is-primary"
               native-type="submit"
-              :disabled="${form.component_studly}Submitting">
-      {{ ${form.component_studly}ButtonText }}
+              :disabled="${form.vue_component}Submitting">
+      {{ ${form.vue_component}ButtonText }}
     </b-button>
     <b-button tag="a" href="${url('products')}">
       Cancel
@@ -66,21 +66,21 @@
 
     ## TODO: ugh, an awful lot of duplicated code here (from /forms/deform.mako)
 
-    let ${form.component_studly} = {
+    let ${form.vue_component} = {
         template: '#${form.component}-template',
         methods: {
 
             ## TODO: deprecate / remove the latter option here
             % if form.auto_disable_save or form.auto_disable:
-                submit${form.component_studly}() {
-                    this.${form.component_studly}Submitting = true
-                    this.${form.component_studly}ButtonText = "Working, please wait..."
+                submit${form.vue_component}() {
+                    this.${form.vue_component}Submitting = true
+                    this.${form.vue_component}ButtonText = "Working, please wait..."
                 }
             % endif
         }
     }
 
-    let ${form.component_studly}Data = {
+    let ${form.vue_component}Data = {
 
         ## TODO: ugh, this seems pretty hacky.  need to declare some data models
         ## for various field components to bind to...
@@ -95,8 +95,8 @@
 
         ## TODO: deprecate / remove the latter option here
         % if form.auto_disable_save or form.auto_disable:
-            ${form.component_studly}Submitting: false,
-            ${form.component_studly}ButtonText: ${json.dumps(getattr(form, 'submit_label', getattr(form, 'save_label', "Submit")))|n},
+            ${form.vue_component}Submitting: false,
+            ${form.vue_component}ButtonText: ${json.dumps(getattr(form, 'submit_label', getattr(form, 'save_label', "Submit")))|n},
         % endif
 
         ## TODO: more hackiness, this is for the sake of batch params
diff --git a/tailbone/templates/products/index.mako b/tailbone/templates/products/index.mako
index 0d4bc410..b4731dee 100644
--- a/tailbone/templates/products/index.mako
+++ b/tailbone/templates/products/index.mako
@@ -41,11 +41,11 @@
   % if label_profiles and master.has_perm('print_labels'):
       <script type="text/javascript">
 
-        ${grid.component_studly}Data.quickLabelProfile = ${json.dumps(label_profiles[0].uuid)|n}
-        ${grid.component_studly}Data.quickLabelQuantity = 1
-        ${grid.component_studly}Data.quickLabelSpeedbumpThreshold = ${json.dumps(quick_label_speedbump_threshold)|n}
+        ${grid.vue_component}Data.quickLabelProfile = ${json.dumps(label_profiles[0].uuid)|n}
+        ${grid.vue_component}Data.quickLabelQuantity = 1
+        ${grid.vue_component}Data.quickLabelSpeedbumpThreshold = ${json.dumps(quick_label_speedbump_threshold)|n}
 
-        ${grid.component_studly}.methods.quickLabelPrint = function(row) {
+        ${grid.vue_component}.methods.quickLabelPrint = function(row) {
 
             let quantity = parseInt(this.quickLabelQuantity)
             if (isNaN(quantity)) {
diff --git a/tailbone/templates/purchases/credits/index.mako b/tailbone/templates/purchases/credits/index.mako
index 4248d4ad..0cfbc031 100644
--- a/tailbone/templates/purchases/credits/index.mako
+++ b/tailbone/templates/purchases/credits/index.mako
@@ -63,17 +63,17 @@
   ${parent.modify_this_page_vars()}
   <script type="text/javascript">
 
-    ${grid.component_studly}Data.changeStatusShowDialog = false
-    ${grid.component_studly}Data.changeStatusOptions = ${json.dumps(status_options)|n}
-    ${grid.component_studly}Data.changeStatusValue = null
-    ${grid.component_studly}Data.changeStatusSubmitting = false
+    ${grid.vue_component}Data.changeStatusShowDialog = false
+    ${grid.vue_component}Data.changeStatusOptions = ${json.dumps(status_options)|n}
+    ${grid.vue_component}Data.changeStatusValue = null
+    ${grid.vue_component}Data.changeStatusSubmitting = false
 
-    ${grid.component_studly}.methods.changeStatusInit = function() {
+    ${grid.vue_component}.methods.changeStatusInit = function() {
         this.changeStatusValue = null
         this.changeStatusShowDialog = true
     }
 
-    ${grid.component_studly}.methods.changeStatusSubmit = function() {
+    ${grid.vue_component}.methods.changeStatusSubmit = function() {
         this.changeStatusSubmitting = true
         this.$refs.changeStatusForm.submit()
     }
diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako
index 5f103d7f..45a8d66b 100644
--- a/tailbone/templates/receiving/view.mako
+++ b/tailbone/templates/receiving/view.mako
@@ -318,13 +318,13 @@
 
     % if allow_edit_catalog_unit_cost:
 
-        ${rows_grid.component_studly}.methods.catalogUnitCostClicked = function(row) {
+        ${rows_grid.vue_component}.methods.catalogUnitCostClicked = function(row) {
 
             // start edit for clicked cell
             this.$refs['catalogUnitCost_' + row.uuid].startEdit()
         }
 
-        ${rows_grid.component_studly}.methods.catalogCostConfirmed = function(amount, index) {
+        ${rows_grid.vue_component}.methods.catalogCostConfirmed = function(amount, index) {
 
             // update display to indicate cost was confirmed
             this.addRowClass(index, 'catalog_cost_confirmed')
@@ -353,13 +353,13 @@
 
     % if allow_edit_invoice_unit_cost:
 
-        ${rows_grid.component_studly}.methods.invoiceUnitCostClicked = function(row) {
+        ${rows_grid.vue_component}.methods.invoiceUnitCostClicked = function(row) {
 
             // start edit for clicked cell
             this.$refs['invoiceUnitCost_' + row.uuid].startEdit()
         }
 
-        ${rows_grid.component_studly}.methods.invoiceCostConfirmed = function(amount, index) {
+        ${rows_grid.vue_component}.methods.invoiceCostConfirmed = function(amount, index) {
 
             // update display to indicate cost was confirmed
             this.addRowClass(index, 'invoice_cost_confirmed')
diff --git a/tailbone/templates/reports/generated/delete.mako b/tailbone/templates/reports/generated/delete.mako
index 0c994ad0..bce54662 100644
--- a/tailbone/templates/reports/generated/delete.mako
+++ b/tailbone/templates/reports/generated/delete.mako
@@ -6,7 +6,7 @@
   <script type="text/javascript">
 
     % if params_data is not Undefined:
-        ${form.component_studly}Data.paramsData = ${json.dumps(params_data)|n}
+        ${form.vue_component}Data.paramsData = ${json.dumps(params_data)|n}
     % endif
 
   </script>
diff --git a/tailbone/templates/reports/generated/view.mako b/tailbone/templates/reports/generated/view.mako
index 6260efba..e5bcc9e4 100644
--- a/tailbone/templates/reports/generated/view.mako
+++ b/tailbone/templates/reports/generated/view.mako
@@ -28,7 +28,7 @@
   <script type="text/javascript">
 
     % if params_data is not Undefined:
-        ${form.component_studly}Data.paramsData = ${json.dumps(params_data)|n}
+        ${form.vue_component}Data.paramsData = ${json.dumps(params_data)|n}
     % endif
 
   </script>
diff --git a/tailbone/templates/reports/problems/view.mako b/tailbone/templates/reports/problems/view.mako
index 026c73dc..1d5cb14f 100644
--- a/tailbone/templates/reports/problems/view.mako
+++ b/tailbone/templates/reports/problems/view.mako
@@ -67,7 +67,7 @@
   <script type="text/javascript">
 
     % if weekdays_data is not Undefined:
-        ${form.component_studly}Data.weekdaysData = ${json.dumps(weekdays_data)|n}
+        ${form.vue_component}Data.weekdaysData = ${json.dumps(weekdays_data)|n}
     % endif
 
     ThisPageData.runReportShowDialog = false
diff --git a/tailbone/templates/roles/view.mako b/tailbone/templates/roles/view.mako
index 0f4ce472..0dc2956f 100644
--- a/tailbone/templates/roles/view.mako
+++ b/tailbone/templates/roles/view.mako
@@ -11,7 +11,7 @@
   <script type="text/javascript">
 
     % if users_data is not Undefined:
-        ${form.component_studly}Data.usersData = ${json.dumps(users_data)|n}
+        ${form.vue_component}Data.usersData = ${json.dumps(users_data)|n}
     % endif
 
     ThisPage.methods.detachPerson = function(url) {
diff --git a/tailbone/templates/settings/email/index.mako b/tailbone/templates/settings/email/index.mako
index dbc963b9..050a5833 100644
--- a/tailbone/templates/settings/email/index.mako
+++ b/tailbone/templates/settings/email/index.mako
@@ -26,9 +26,9 @@
             this.$refs.grid.showEmails = this.showEmails
         }
 
-        ${grid.component_studly}Data.showEmails = 'available'
+        ${grid.vue_component}Data.showEmails = 'available'
 
-        ${grid.component_studly}.computed.visibleData = function() {
+        ${grid.vue_component}.computed.visibleData = function() {
 
             if (this.showEmails == 'available') {
                 return this.data.filter(email => email.hidden == 'No')
@@ -41,11 +41,11 @@
             return this.data
         }
 
-        ${grid.component_studly}.methods.renderLabelToggleHidden = function(row) {
+        ${grid.vue_component}.methods.renderLabelToggleHidden = function(row) {
             return row.hidden == 'Yes' ? "Un-hide" : "Hide"
         }
 
-        ${grid.component_studly}.methods.toggleHidden = function(row) {
+        ${grid.vue_component}.methods.toggleHidden = function(row) {
             let url = '${url('{}.toggle_hidden'.format(route_prefix))}'
             let params = {
                 key: row.key,
diff --git a/tailbone/templates/tempmon/appliances/view.mako b/tailbone/templates/tempmon/appliances/view.mako
index 07a524b8..7dd9314a 100644
--- a/tailbone/templates/tempmon/appliances/view.mako
+++ b/tailbone/templates/tempmon/appliances/view.mako
@@ -12,7 +12,7 @@
   ${parent.modify_this_page_vars()}
   <script type="text/javascript">
 
-    ${form.component_studly}Data.probesData = ${json.dumps(probes_data)|n}
+    ${form.vue_component}Data.probesData = ${json.dumps(probes_data)|n}
 
   </script>
 </%def>
diff --git a/tailbone/templates/tempmon/clients/view.mako b/tailbone/templates/tempmon/clients/view.mako
index cff22fed..b1db423b 100644
--- a/tailbone/templates/tempmon/clients/view.mako
+++ b/tailbone/templates/tempmon/clients/view.mako
@@ -26,7 +26,7 @@
   ${parent.modify_this_page_vars()}
   <script type="text/javascript">
 
-    ${form.component_studly}Data.probesData = ${json.dumps(probes_data)|n}
+    ${form.vue_component}Data.probesData = ${json.dumps(probes_data)|n}
 
   </script>
 </%def>
diff --git a/tailbone/templates/trainwreck/transactions/view.mako b/tailbone/templates/trainwreck/transactions/view.mako
index 2be51c7d..02950941 100644
--- a/tailbone/templates/trainwreck/transactions/view.mako
+++ b/tailbone/templates/trainwreck/transactions/view.mako
@@ -6,7 +6,7 @@
   <script type="text/javascript">
 
     % if custorder_xref_markers_data is not Undefined:
-        ${form.component_studly}Data.custorderXrefMarkersData = ${json.dumps(custorder_xref_markers_data)|n}
+        ${form.vue_component}Data.custorderXrefMarkersData = ${json.dumps(custorder_xref_markers_data)|n}
     % endif
 
   </script>
diff --git a/tailbone/templates/trainwreck/transactions/view_row.mako b/tailbone/templates/trainwreck/transactions/view_row.mako
index 9abcb8ba..9c76f7bd 100644
--- a/tailbone/templates/trainwreck/transactions/view_row.mako
+++ b/tailbone/templates/trainwreck/transactions/view_row.mako
@@ -6,7 +6,7 @@
   <script type="text/javascript">
 
     % if discounts_data is not Undefined:
-        ${form.component_studly}Data.discountsData = ${json.dumps(discounts_data)|n}
+        ${form.vue_component}Data.discountsData = ${json.dumps(discounts_data)|n}
     % endif
 
   </script>
diff --git a/tailbone/templates/users/view.mako b/tailbone/templates/users/view.mako
index ed2b5f16..06087927 100644
--- a/tailbone/templates/users/view.mako
+++ b/tailbone/templates/users/view.mako
@@ -81,7 +81,7 @@
   % if master.has_perm('manage_api_tokens'):
     <script type="text/javascript">
 
-      ${form.component_studly}.props.apiTokens = null
+      ${form.vue_component}.props.apiTokens = null
 
       ThisPageData.apiTokens = ${json.dumps(api_tokens_data)|n}
 

From 0eeeb4bd35981ee1ff4213f0b3cbd1b5dd774baf Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 19 Aug 2024 11:09:49 -0500
Subject: [PATCH 455/542] fix: prefer attr over key lookup when getting model
 values

applies to both forms and grids.

the base model class can still handle `obj[key]` but now it is limited
to the column fields only, no association proxies.

so, better to just try `getattr(obj, key)` first and only fall back to
the other if it fails.

unless the obj is clearly a dict in which case try `obj[key]` only
---
 tailbone/forms/core.py | 13 ++++++++-----
 tailbone/grids/core.py | 10 ++++++----
 2 files changed, 14 insertions(+), 9 deletions(-)

diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py
index 704d3b54..2f1c9370 100644
--- a/tailbone/forms/core.py
+++ b/tailbone/forms/core.py
@@ -1359,12 +1359,15 @@ class Form(object):
 
     def obtain_value(self, record, field_name):
         if record:
-            try:
+
+            if isinstance(record, dict):
                 return record[field_name]
-            except KeyError:
-                return None
-            except TypeError:
-                return getattr(record, field_name, None)
+
+            try:
+                return getattr(record, field_name)
+            except AttributeError:
+                pass
+            return record[field_name]
 
         # TODO: is this always safe to do?
         elif self.defaults and field_name in self.defaults:
diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py
index d00a85ae..3caf909c 100644
--- a/tailbone/grids/core.py
+++ b/tailbone/grids/core.py
@@ -586,12 +586,14 @@ class Grid(WuttaGrid):
         if isinstance(obj, sa.engine.Row):
             return obj._mapping[column_name]
 
-        try:
+        if isinstance(obj, dict):
             return obj[column_name]
-        except KeyError:
+
+        try:
+            return getattr(obj, column_name)
+        except AttributeError:
             pass
-        except TypeError:
-            return getattr(obj, column_name, None)
+        return obj[column_name]
 
     def render_currency(self, obj, column_name):
         value = self.obtain_value(obj, column_name)

From f5661fe349a456de2ef68e56b9afed02ad765fa7 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 19 Aug 2024 11:56:46 -0500
Subject: [PATCH 456/542] fix: sort on frontend for appinfo package listing
 grid

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

diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py
index 9d7f6e02..bda62ccc 100644
--- a/tailbone/views/settings.py
+++ b/tailbone/views/settings.py
@@ -83,14 +83,15 @@ class AppInfoView(MasterView):
     def configure_grid(self, g):
         super().configure_grid(g)
 
-        g.sorters['name'] = g.make_simple_sorter('name', foldcase=True)
+        # sort on frontend
+        g.sort_on_backend = False
+        g.sort_multiple = False
         g.set_sort_defaults('name')
+
+        # name
         g.set_searchable('name')
 
-        g.sorters['version'] = g.make_simple_sorter('version', foldcase=True)
-
-        g.sorters['editable_project_location'] = g.make_simple_sorter(
-            'editable_project_location', foldcase=True)
+        # editable_project_location
         g.set_searchable('editable_project_location')
 
     def template_kwargs_index(self, **kwargs):

From 41945c5e3777958d9940e94add680a0fd2e8d476 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 19 Aug 2024 12:01:42 -0500
Subject: [PATCH 457/542] =?UTF-8?q?bump:=20version=200.19.1=20=E2=86=92=20?=
 =?UTF-8?q?0.19.2?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

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

diff --git a/CHANGELOG.md b/CHANGELOG.md
index ce64ec60..1fe71f3f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,14 @@ 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.2 (2024-08-19)
+
+### Fix
+
+- sort on frontend for appinfo package listing grid
+- prefer attr over key lookup when getting model values
+- replace all occurrences of `component_studly` => `vue_component`
+
 ## v0.19.1 (2024-08-19)
 
 ### Fix
diff --git a/pyproject.toml b/pyproject.toml
index fa33a2df..8f840642 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.19.1"
+version = "0.19.2"
 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.0",
+        "WuttaWeb>=0.10.1",
         "zope.sqlalchemy>=1.5",
 ]
 

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 458/542] 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 459/542] =?UTF-8?q?bump:=20version=200.19.2=20=E2=86=92=20?=
 =?UTF-8?q?0.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 460/542] 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 461/542] 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 462/542] 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 463/542] =?UTF-8?q?bump:=20version=200.19.3=20=E2=86=92=20?=
 =?UTF-8?q?0.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 464/542] 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 465/542] =?UTF-8?q?bump:=20version=200.20.0=20=E2=86=92=20?=
 =?UTF-8?q?0.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 466/542] 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 467/542] 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 468/542] 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 469/542] 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 470/542] 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 471/542] 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 472/542] 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 473/542] 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 474/542] 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 475/542] 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 476/542] 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 477/542] =?UTF-8?q?bump:=20version=200.20.1=20=E2=86=92=20?=
 =?UTF-8?q?0.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 478/542] 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 479/542] 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 480/542] =?UTF-8?q?bump:=20version=200.21.0=20=E2=86=92=20?=
 =?UTF-8?q?0.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 481/542] 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 482/542] 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 483/542] 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 484/542] 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 485/542] 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 486/542] 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 487/542] 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 488/542] 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 489/542] =?UTF-8?q?bump:=20version=200.21.1=20=E2=86=92=20?=
 =?UTF-8?q?0.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 490/542] 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 491/542] =?UTF-8?q?bump:=20version=200.21.2=20=E2=86=92=20?=
 =?UTF-8?q?0.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 492/542] 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 493/542] =?UTF-8?q?bump:=20version=200.21.3=20=E2=86=92=20?=
 =?UTF-8?q?0.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 494/542] 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 495/542] =?UTF-8?q?bump:=20version=200.21.4=20=E2=86=92=20?=
 =?UTF-8?q?0.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 496/542] 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 497/542] 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 498/542] =?UTF-8?q?bump:=20version=200.21.5=20=E2=86=92=20?=
 =?UTF-8?q?0.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 499/542] 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 500/542] =?UTF-8?q?bump:=20version=200.21.6=20=E2=86=92=20?=
 =?UTF-8?q?0.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 501/542] 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 502/542] =?UTF-8?q?bump:=20version=200.21.7=20=E2=86=92=20?=
 =?UTF-8?q?0.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 503/542] 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 504/542] =?UTF-8?q?bump:=20version=200.21.8=20=E2=86=92=20?=
 =?UTF-8?q?0.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 505/542] 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 506/542] 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 507/542] 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 508/542] 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 509/542] 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 510/542] =?UTF-8?q?bump:=20version=200.21.9=20=E2=86=92=20?=
 =?UTF-8?q?0.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 511/542] 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 512/542] 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 513/542] =?UTF-8?q?bump:=20version=200.21.10=20=E2=86=92?=
 =?UTF-8?q?=200.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 514/542] 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 515/542] 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 516/542] =?UTF-8?q?bump:=20version=200.21.11=20=E2=86=92?=
 =?UTF-8?q?=200.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 517/542] 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 518/542] 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 519/542] =?UTF-8?q?bump:=20version=200.22.0=20=E2=86=92=20?=
 =?UTF-8?q?0.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 520/542] 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 521/542] 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 522/542] 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 523/542] 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 524/542] 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 525/542] =?UTF-8?q?bump:=20version=200.22.1=20=E2=86=92=20?=
 =?UTF-8?q?0.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 526/542] 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 527/542] =?UTF-8?q?bump:=20version=200.22.2=20=E2=86=92=20?=
 =?UTF-8?q?0.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 528/542] 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 529/542] 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 530/542] =?UTF-8?q?bump:=20version=200.22.3=20=E2=86=92=20?=
 =?UTF-8?q?0.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 531/542] 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 532/542] 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 533/542] 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 534/542] 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 535/542] =?UTF-8?q?bump:=20version=200.22.4=20=E2=86=92=20?=
 =?UTF-8?q?0.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 536/542] 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 537/542] =?UTF-8?q?bump:=20version=200.22.5=20=E2=86=92=20?=
 =?UTF-8?q?0.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 538/542] 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 539/542] 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 540/542] 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 541/542] =?UTF-8?q?bump:=20version=200.22.6=20=E2=86=92=20?=
 =?UTF-8?q?0.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 542/542] 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)