From b4d5d70e4cc9f68a89b132c3bcc1a8746820ff54 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 26 Mar 2022 15:29:28 -0500 Subject: [PATCH 001/978] Force session flush within try/catch, for batch refresh --- tailbone/views/batch/core.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 7d6216d3..393bde08 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -1114,6 +1114,7 @@ class BatchMasterView(MasterView): cognizer = session.query(model.User).get(user_uuid) if user_uuid else None try: self.refresh_data(session, batch, cognizer, progress=progress) + session.flush() except Exception as error: session.rollback() log.warning("refreshing data for batch failed: {}".format(batch), exc_info=True) From dfc88193b2ff6ac06c7243b66611771dabe23f5d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 29 Mar 2022 10:32:03 -0500 Subject: [PATCH 002/978] 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 46885a95..a63480d7 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.225 (2022-03-29) +-------------------- + +* Force session flush within try/catch, for batch refresh. + + 0.8.224 (2022-03-25) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index f1a759ec..47c10b7b 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.224' +__version__ = '0.8.225' From 700b5f0b9135f1a64e6df329d92ce5a53a66c0aa Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 29 Mar 2022 11:39:32 -0500 Subject: [PATCH 003/978] Let errors raise when showing poser reports --- tailbone/views/poser/reports.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/poser/reports.py b/tailbone/views/poser/reports.py index 80876233..43ba211d 100644 --- a/tailbone/views/poser/reports.py +++ b/tailbone/views/poser/reports.py @@ -92,7 +92,7 @@ class PoserReportView(PoserMasterView): ] def get_poser_data(self, session=None): - return self.poser_handler.get_all_reports() + return self.poser_handler.get_all_reports(ignore_errors=False) def configure_grid(self, g): super(PoserReportView, self).configure_grid(g) From efcfd787af704e31b830b91e38ec6671088c096a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 29 Mar 2022 11:49:24 -0500 Subject: [PATCH 004/978] 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 a63480d7..d60acb6f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.226 (2022-03-29) +-------------------- + +* Let errors raise when showing poser reports. + + 0.8.225 (2022-03-29) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 47c10b7b..90cc478e 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.225' +__version__ = '0.8.226' From fc32542f557378938fa2b1a992097604ce61bf4f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 29 Mar 2022 17:19:14 -0500 Subject: [PATCH 005/978] Add touch for report codes --- tailbone/views/reportcodes.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/reportcodes.py b/tailbone/views/reportcodes.py index ee9a009e..0f85aecb 100644 --- a/tailbone/views/reportcodes.py +++ b/tailbone/views/reportcodes.py @@ -38,6 +38,7 @@ class ReportCodeView(MasterView): model_class = model.ReportCode model_title = "Report Code" has_versions = True + touchable = True results_downloadable_xlsx = True grid_columns = [ From edef0841217435b1d678e0417a1b628f7a2a0f91 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 29 Mar 2022 17:19:23 -0500 Subject: [PATCH 006/978] Raise 404 if report not found --- tailbone/views/reports.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index ced301c8..f2e27d58 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -387,6 +387,8 @@ class ReportOutputView(ExportMasterView): use_buefy = self.get_use_buefy() type_key = self.request.matchdict['type_key'] report = self.report_handler.get_report(type_key) + if not report: + return self.notfound() report_params = report.make_params(Session()) route_prefix = self.get_route_prefix() From 80b9593651557c8e0c13605549539aad194c7e78 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 29 Mar 2022 17:30:37 -0500 Subject: [PATCH 007/978] Add template kwargs stub for view_row() --- tailbone/views/master.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index e6e96a67..c3b9d4c1 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2423,6 +2423,12 @@ class MasterView(View): """ return kwargs + def template_kwargs_view_row(self, **kwargs): + """ + Method stub, so subclass can always invoke super() for it. + """ + return kwargs + def get_db_engines(self): """ Must return a dict (or even better, OrderedDict) which contains all From 4e25e87bfb2c80f1a5d2df969b8f5b9f2ae3a06c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 29 Mar 2022 17:43:42 -0500 Subject: [PATCH 008/978] Log error when failing to submit new custorder batch --- tailbone/views/custorders/orders.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index 12a0c339..8d5e01b7 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -27,12 +27,13 @@ Customer Order Views from __future__ import unicode_literals, absolute_import import decimal +import logging import six from sqlalchemy import orm from rattail.db import model -from rattail.util import pretty_quantity +from rattail.util import pretty_quantity, simple_error from rattail.batch import get_batch_handler from webhelpers2.html import tags, HTML @@ -41,6 +42,9 @@ from tailbone.db import Session from tailbone.views import MasterView +log = logging.getLogger(__name__) + + class CustomerOrderView(MasterView): """ Master view for customer orders @@ -908,7 +912,9 @@ class CustomerOrderView(MasterView): try: result = self.execute_new_order_batch(batch, data) except Exception as error: - return {'error': six.text_type(error)} + log.warning("failed to execute new order batch: %s", batch, + exc_info=True) + return {'error': simple_error(error)} else: if not result: return {'error': "Batch failed to execute"} From 1bb41b21afdb970d6a1db2209eeccf9277f4f9fe Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 29 Mar 2022 18:19:14 -0500 Subject: [PATCH 009/978] Honor case vs. unit restrictions for new custorder and expose them in config view --- tailbone/templates/custorders/configure.mako | 26 +++++++++++++++++--- tailbone/templates/custorders/create.mako | 17 ++++++------- tailbone/views/custorders/orders.py | 15 ++++++++++- 3 files changed, 43 insertions(+), 15 deletions(-) diff --git a/tailbone/templates/custorders/configure.mako b/tailbone/templates/custorders/configure.mako index 976f1564..1abbd7b2 100644 --- a/tailbone/templates/custorders/configure.mako +++ b/tailbone/templates/custorders/configure.mako @@ -52,12 +52,21 @@

Product Handling

- - + - Allow creating orders for "unknown" products + Allow "case" orders + + + + + + Allow "unit" orders @@ -70,6 +79,15 @@ + + + Allow creating orders for "unknown" products + + +
diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index ddabfc4d..4a92c063 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -790,9 +790,8 @@ - - + + @@ -1040,13 +1039,8 @@ template: '#customer-order-creator-template', data() { - ## TODO: these should come from handler - let defaultUnitChoices = [ - {key: '${enum.UNIT_OF_MEASURE_EACH}', value: "Each"}, - {key: '${enum.UNIT_OF_MEASURE_POUND}', value: "Pound"}, - {key: '${enum.UNIT_OF_MEASURE_CASE}', value: "Case"}, - ] - let defaultUOM = '${enum.UNIT_OF_MEASURE_CASE}' + let defaultUnitChoices = ${json.dumps(default_uom_choices)|n} + let defaultUOM = ${json.dumps(default_uom)|n} return { batchAction: null, @@ -1329,6 +1323,9 @@ return true } } + if (!this.productUOM) { + return true + } return false }, diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index 8d5e01b7..6c84f4ab 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -347,8 +347,15 @@ class CustomerOrderView(MasterView): 'product_key_label': self.rattail_config.product_key_title(), '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), + 'default_uom': None, }) + if self.batch_handler.allow_case_orders(): + context['default_uom'] = self.enum.UNIT_OF_MEASURE_CASE + elif self.batch_handler.allow_unit_orders(): + context['default_uom'] = self.enum.UNIT_OF_MEASURE_EACH + return self.render_to_response(template, context) def get_department_options(self): @@ -944,11 +951,17 @@ class CustomerOrderView(MasterView): # product handling {'section': 'rattail.custorders', - 'option': 'allow_unknown_product', + 'option': 'allow_case_orders', + 'type': bool}, + {'section': 'rattail.custorders', + 'option': 'allow_unit_orders', 'type': bool}, {'section': 'rattail.custorders', 'option': 'product_price_may_be_questionable', 'type': bool}, + {'section': 'rattail.custorders', + 'option': 'allow_unknown_product', + 'type': bool}, ] @classmethod From aa37fc3addc2f1591ed7cd95a7ae3f5e3b947324 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 3 Apr 2022 14:42:40 -0500 Subject: [PATCH 010/978] Tweak where description field is shown for receiving batch --- tailbone/views/purchasing/receiving.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index d2fc2fc5..20f70e5d 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -115,7 +115,6 @@ class ReceivingBatchView(PurchasingBatchView): 'store', 'vendor', 'receiving_workflow', - 'description', 'truck_dump', 'truck_dump_children_first', 'truck_dump_children', @@ -137,6 +136,7 @@ class ReceivingBatchView(PurchasingBatchView): 'invoice_number', 'invoice_total', 'invoice_total_calculated', + 'description', 'notes', 'created', 'created_by', From d48a92c88d3019f226022c70e4dda16aa51ed443 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 4 Apr 2022 13:56:27 -0500 Subject: [PATCH 011/978] Fix "touch" url for non-standard record types --- tailbone/templates/master/view.mako | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako index b311a14a..4ede63dc 100644 --- a/tailbone/templates/master/view.mako +++ b/tailbone/templates/master/view.mako @@ -69,7 +69,7 @@
  • ${h.link_to("Clone this as new {}".format(model_title), url('{}.clone'.format(route_prefix), uuid=instance.uuid))}
  • % endif % if master.touchable and request.has_perm('{}.touch'.format(permission_prefix)): -
  • ${h.link_to("\"Touch\" this {}".format(model_title), url('{}.touch'.format(route_prefix), uuid=instance.uuid))}
  • +
  • ${h.link_to("\"Touch\" this {}".format(model_title), master.get_action_url('touch', instance))}
  • % endif % if not use_buefy and master.has_rows and master.rows_downloadable_csv and master.has_perm('row_results_csv'):
  • ${h.link_to("Download row results as CSV", master.get_action_url('row_results_csv', instance))}
  • From 56c5c4e540c5aa6a5b1e2b1cacb2c57f3033f90c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 4 Apr 2022 13:57:31 -0500 Subject: [PATCH 012/978] 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 d60acb6f..884bfedc 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,24 @@ CHANGELOG ========= +0.8.227 (2022-04-04) +-------------------- + +* Add touch for report codes. + +* Raise 404 if report not found. + +* Add template kwargs stub for ``view_row()``. + +* Log error when failing to submit new custorder batch. + +* Honor case vs. unit restrictions for new custorder. + +* Tweak where description field is shown for receiving batch. + +* Fix "touch" url for non-standard record types. + + 0.8.226 (2022-03-29) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 90cc478e..d2bc5538 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.226' +__version__ = '0.8.227' From aea7f010475a2ab711911d02c9647a0c63e0b268 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 7 Apr 2022 12:57:40 -0500 Subject: [PATCH 013/978] Fix quotes for field helptext --- tailbone/forms/core.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 3d87cee7..e1e54a65 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -718,7 +718,9 @@ class Form(object): """ Render the help text for the given field. """ - return self.helptext[key] + text = self.helptext[key] + text = text.replace('"', '"') + return HTML.literal(text) def set_vuejs_field_converter(self, field, converter): self.vuejs_field_converters[field] = converter From 10a801aa103cfb656997e17554994d07a3bbb17d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 13 Apr 2022 16:42:47 -0500 Subject: [PATCH 014/978] Flush early when populating batch, to ensure error is shown --- tailbone/views/batch/core.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 393bde08..84b2e77c 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -1057,6 +1057,7 @@ class BatchMasterView(MasterView): user = session.query(model.User).get(user_uuid) try: self.handler.do_populate(batch, user, progress=progress) + session.flush() except Exception as error: session.rollback() log.exception("batch population failed: %s", batch) From 129455a31f206ab5722401baf4c8f7c533a45675 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 13 Apr 2022 20:19:00 -0500 Subject: [PATCH 015/978] 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 884bfedc..fb31f603 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.228 (2022-04-13) +-------------------- + +* Fix quotes for field helptext. + +* Flush early when populating batch, to ensure error is shown. + + 0.8.227 (2022-04-04) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index d2bc5538..e1a117c0 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.227' +__version__ = '0.8.228' From a49aa77ec0ba1e9f906d9b2114b055d207aa2283 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 3 May 2022 13:36:14 -0500 Subject: [PATCH 016/978] Tweak how family data is displayed --- tailbone/views/families.py | 12 +++++------- tailbone/views/master.py | 4 +++- tailbone/views/products.py | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/tailbone/views/families.py b/tailbone/views/families.py index b2a5ebe3..1190ad06 100644 --- a/tailbone/views/families.py +++ b/tailbone/views/families.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2021 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -54,12 +54,8 @@ class FamilyView(MasterView): has_rows = True model_row_class = model.Product - row_labels = { - 'upc': "UPC", - } - row_grid_columns = [ - 'upc', + '_product_key_', 'brand', 'description', 'size', @@ -94,7 +90,9 @@ class FamilyView(MasterView): g.set_renderer('regular_price', self.render_price) g.set_renderer('current_price', self.render_price) - g.set_sort_defaults('upc') + key = self.rattail_config.product_key() + field = self.product_key_fields.get(key, key) + g.set_sort_defaults(field) def render_price(self, product, field): if not product.not_for_sale: diff --git a/tailbone/views/master.py b/tailbone/views/master.py index c3b9d4c1..83f77b69 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -180,7 +180,9 @@ class MasterView(View): rows_downloadable_csv = False rows_downloadable_xlsx = False - row_labels = {} + row_labels = { + 'upc': "UPC", + } @property def Session(self): diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 145f55cb..a5706a25 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -900,7 +900,7 @@ class ProductView(MasterView): f.set_label('family_uuid', "Family") else: f.set_readonly('family') - # f.set_renderer('family', self.render_family) + f.set_renderer('family', self.render_family) # report_code if self.creating or self.editing: From c371db3534a7ea192643b5495821e7a5cb538e43 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 3 May 2022 13:43:57 -0500 Subject: [PATCH 017/978] 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 fb31f603..97f6881b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.229 (2022-05-03) +-------------------- + +* Tweak how family data is displayed. + + 0.8.228 (2022-04-13) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index e1a117c0..f03c2d82 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.228' +__version__ = '0.8.229' From 18c3c579309af1e2146f2017db942ce6078f3070 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 3 May 2022 14:13:47 -0500 Subject: [PATCH 018/978] Sort roles list when viewing a user --- tailbone/views/users.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/users.py b/tailbone/views/users.py index d9815f50..1fb1250d 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -386,7 +386,7 @@ class UserView(PrincipalMasterView): return six.text_type(name) def render_roles(self, user, field): - roles = user.roles + roles = sorted(user.roles, key=lambda r: r.name) items = [] for role in roles: text = role.name From 75319c0d6ae17862e7ae5fa956bb511273a8ebce Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 10 May 2022 20:06:21 -0500 Subject: [PATCH 019/978] Add grid workarounds when data is list instead of query ugh, this is not very intuitive. pretty sure all that needs an overhaul someday --- tailbone/grids/core.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 75c2ffd5..7bc0c01d 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -596,7 +596,16 @@ class Grid(object): """ class_ = getattr(model_property, 'class_', self.model_class) column = getattr(class_, model_property.key) - return lambda q, d: q.order_by(getattr(column, d)()) + + 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)()) + + return sorter def make_simple_sorter(self, key, foldcase=False): """ @@ -984,6 +993,16 @@ class Grid(object): 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, From 983a06abe3dceda123ba94b2bb4da7f73c387d59 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 10 May 2022 20:08:06 -0500 Subject: [PATCH 020/978] 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 97f6881b..fdd38ca1 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.230 (2022-05-10) +-------------------- + +* Sort roles list when viewing a user. + +* Add grid workarounds when data is list instead of query. + + 0.8.229 (2022-05-03) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index f03c2d82..8dca8afa 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.229' +__version__ = '0.8.230' From e3b1be583545a3480a7c79478ac994c867391264 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 15 May 2022 16:04:22 -0500 Subject: [PATCH 021/978] Expose config for identifying supported vendors unfortunately must identify vendors at each app node separately, but this is definitely still an improvement.. --- tailbone/templates/vendors/configure.mako | 35 ++++++++++++++ tailbone/views/master.py | 15 ++++++ tailbone/views/vendors/core.py | 57 +++++++++++++++++++++++ 3 files changed, 107 insertions(+) diff --git a/tailbone/templates/vendors/configure.mako b/tailbone/templates/vendors/configure.mako index cb370e43..79dad455 100644 --- a/tailbone/templates/vendors/configure.mako +++ b/tailbone/templates/vendors/configure.mako @@ -16,6 +16,41 @@
    + +

    Supported Vendors

    +
    + +

    + The following vendor "keys" are defined within various places in + the software.  You must identify each explicitly with a + Vendor record, for things to work as designed. +

    + + + + + + + +
    + + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 83f77b69..e7dc7c64 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -4359,6 +4359,21 @@ class MasterView(View): def configure_get_context(self, simple_settings=None, input_file_templates=True): + """ + Returns the full context dict, for rendering the configure + page template. + + Default context will include the "simple" settings, as well as + any "input file template" settings. + + You may need to override this method, to add additional + "custom" settings. + + :param simple_settings: Optional list of simple settings, if + already initialized. + + :returns: Context dict for the page template. + """ context = {} if simple_settings is None: simple_settings = self.configure_get_simple_settings() diff --git a/tailbone/views/vendors/core.py b/tailbone/views/vendors/core.py index b3b003e7..9f964d2c 100644 --- a/tailbone/views/vendors/core.py +++ b/tailbone/views/vendors/core.py @@ -33,6 +33,7 @@ from rattail.db import model from webhelpers2.html import tags from tailbone.views import MasterView +from tailbone.db import Session class VendorView(MasterView): @@ -179,6 +180,62 @@ class VendorView(MasterView): 'type': bool}, ] + def configure_get_context(self, **kwargs): + context = super(VendorView, self).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( + data, **kwargs) + + supported_vendor_settings = self.configure_get_supported_vendor_settings() + for setting in six.itervalues(supported_vendor_settings): + name = 'rattail.vendor.{}'.format(setting['key']) + settings.append({'name': name, + 'value': data[name]}) + + return settings + + def configure_remove_settings(self, **kwargs): + super(VendorView, self).configure_remove_settings(**kwargs) + + model = self.model + names = [] + + supported_vendor_settings = self.configure_get_supported_vendor_settings() + for setting in six.itervalues(supported_vendor_settings): + names.append('rattail.vendor.{}'.format(setting['key'])) + + if names: + # nb. we do not use self.Session b/c that may not point to + # the Rattail DB for the subclass + Session().query(model.Setting)\ + .filter(model.Setting.name.in_(names))\ + .delete(synchronize_session=False) + + def configure_get_supported_vendor_settings(self): + app = self.get_rattail_app() + vendor_handler = app.get_vendor_handler() + batch_handler = app.get_batch_handler('purchase') + settings = {} + + for parser in batch_handler.get_supported_invoice_parsers(): + key = parser.vendor_key + if not key: + continue + + vendor = vendor_handler.get_vendor(self.Session(), key) + settings[key] = { + 'key': key, + 'value': vendor.uuid if vendor else None, + 'label': six.text_type(vendor) if vendor else None, + } + + return settings + def defaults(config, **kwargs): base = globals() From cff494276955da5e635bf8d60b290baebf566431 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 15 May 2022 16:45:31 -0500 Subject: [PATCH 022/978] Allow restricting to supported vendors only, for Receiving --- tailbone/templates/receiving/configure.mako | 20 +++++++- tailbone/views/purchasing/receiving.py | 53 +++++++++++++++------ 2 files changed, 57 insertions(+), 16 deletions(-) diff --git a/tailbone/templates/receiving/configure.mako b/tailbone/templates/receiving/configure.mako index dff280bb..e93dbd51 100644 --- a/tailbone/templates/receiving/configure.mako +++ b/tailbone/templates/receiving/configure.mako @@ -3,9 +3,13 @@ <%def name="form_content()"> -

    Supported Workflows

    +

    Workflows

    +

    + Users can only choose from the workflows enabled below. +

    + +

    Vendors

    +
    + + + + Only allow batch for "supported" vendors + + + +
    +

    Display

    diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 20f70e5d..bca9ef64 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -311,26 +311,44 @@ class ReceivingBatchView(PurchasingBatchView): # configure vendor field app = self.get_rattail_app() vendor_handler = app.get_vendor_handler() - use_dropdown = vendor_handler.choice_uses_dropdown() - if use_dropdown: - vendors = self.Session.query(model.Vendor)\ - .order_by(model.Vendor.id) - vendor_values = [(vendor.uuid, "({}) {}".format(vendor.id, vendor.name)) + 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] if use_buefy: form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values)) else: form.set_widget('vendor', forms.widgets.JQuerySelectWidget(values=vendor_values)) else: - vendor_display = "" - if self.request.method == 'POST': - if self.request.POST.get('vendor'): - vendor = self.Session.query(model.Vendor).get(self.request.POST['vendor']) - if vendor: - vendor_display = six.text_type(vendor) - vendors_url = self.request.route_url('vendors.autocomplete') - form.set_widget('vendor', forms.widgets.JQueryAutocompleteWidget( - field_display=vendor_display, service_url=vendors_url)) + # 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) + vendor_values = [(vendor.uuid, "({}) {}".format(vendor.id, vendor.name)) + for vendor in vendors] + if use_buefy: + form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values)) + else: + form.set_widget('vendor', forms.widgets.JQuerySelectWidget(values=vendor_values)) + else: + vendor_display = "" + if self.request.method == 'POST': + if self.request.POST.get('vendor'): + vendor = self.Session.query(model.Vendor).get(self.request.POST['vendor']) + if vendor: + vendor_display = six.text_type(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 @@ -1876,7 +1894,7 @@ class ReceivingBatchView(PurchasingBatchView): config = self.rattail_config return [ - # supported workflows + # workflows {'section': 'rattail.batch', 'option': 'purchase.allow_receiving_from_scratch', 'type': bool}, @@ -1893,6 +1911,11 @@ class ReceivingBatchView(PurchasingBatchView): 'option': 'purchase.allow_truck_dump_receiving', 'type': bool}, + # vendors + {'section': 'rattail.batch', + 'option': 'purchase.supported_vendors_only', + 'type': bool}, + # display {'section': 'rattail.batch', 'option': 'purchase.receiving.show_ordered_column_in_grid', From 78a9ba50849555e1289dc4675cadcf4977fe8b24 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 15 May 2022 16:47:31 -0500 Subject: [PATCH 023/978] 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 fdd38ca1..de4021e0 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.231 (2022-05-15) +-------------------- + +* Expose config for identifying supported vendors. + +* Allow restricting to supported vendors only, for Receiving. + + 0.8.230 (2022-05-10) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 8dca8afa..7e9c0b77 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.230' +__version__ = '0.8.231' From cb6499522eb697a5d35db0563896f737740f2c3b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 14 Jun 2022 11:25:29 -0500 Subject: [PATCH 024/978] Let default grid page size correspond to first option --- tailbone/grids/core.py | 11 +++++++++-- tailbone/views/master.py | 6 ++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 7bc0c01d..0e2833dc 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -78,7 +78,7 @@ class Grid(object): joiners={}, filterable=False, filters={}, use_byte_string_filters=False, searchable={}, sortable=False, sorters={}, default_sortkey=None, default_sortdir='asc', - pageable=False, default_pagesize=20, default_page=1, + pageable=False, default_pagesize=None, default_page=1, checkboxes=False, checked=None, check_handler=None, check_all_handler=None, clicking_row_checks_box=False, click_handlers=None, main_actions=[], more_actions=[], delete_speedbump=False, @@ -618,6 +618,13 @@ class Grid(object): keyfunc = lambda v: v[key] return lambda q, d: sorted(q, key=keyfunc, reverse=d == 'desc') + def get_default_pagesize(self): + if self.default_pagesize: + return self.default_pagesize + + options = self.get_pagesize_options() + return options[0] + def load_settings(self, store=True): """ Load current/effective settings for the grid, from the request query @@ -635,7 +642,7 @@ class Grid(object): settings['sortkey'] = self.default_sortkey settings['sortdir'] = self.default_sortdir if self.pageable: - settings['pagesize'] = self.default_pagesize + settings['pagesize'] = self.get_default_pagesize() settings['page'] = self.default_page if self.filterable: for filtr in self.iter_filters(): diff --git a/tailbone/views/master.py b/tailbone/views/master.py index e7dc7c64..fe77c7b5 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -176,7 +176,7 @@ class MasterView(View): rows_deletable = False rows_deletable_speedbump = True rows_bulk_deletable = False - rows_default_pagesize = 20 + rows_default_pagesize = None rows_downloadable_csv = False rows_downloadable_xlsx = False @@ -523,11 +523,13 @@ class MasterView(View): 'use_byte_string_filters': self.use_byte_string_filters, 'sortable': self.rows_sortable, 'pageable': self.rows_pageable, - 'default_pagesize': self.rows_default_pagesize, 'extra_row_class': self.row_grid_extra_class, 'url': lambda obj: self.get_row_action_url('view', obj), } + if self.rows_default_pagesize: + defaults['default_pagesize'] = self.rows_default_pagesize + if self.has_rows and 'main_actions' not in defaults: actions = [] use_buefy = self.get_use_buefy() From 6b466bb90fd0d190a2aa68681c73e28526196790 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 14 Jun 2022 13:51:00 -0500 Subject: [PATCH 025/978] Add start date support for "future" pricing batch --- .../templates/batch/pricing/configure.mako | 22 ++++++++++++++ tailbone/views/batch/pricing.py | 30 ++++++++++++++++++- tailbone/views/products.py | 20 +++++++++++-- 3 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 tailbone/templates/batch/pricing/configure.mako diff --git a/tailbone/templates/batch/pricing/configure.mako b/tailbone/templates/batch/pricing/configure.mako new file mode 100644 index 00000000..8b5a90bb --- /dev/null +++ b/tailbone/templates/batch/pricing/configure.mako @@ -0,0 +1,22 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="form_content()"> + +

    Options

    +
    + + + + Allow "future" pricing + + + +
    + + + +${parent.body()} diff --git a/tailbone/views/batch/pricing.py b/tailbone/views/batch/pricing.py index 1f054e61..18a4ea90 100644 --- a/tailbone/views/batch/pricing.py +++ b/tailbone/views/batch/pricing.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2021 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -52,6 +52,7 @@ class PricingBatchView(BatchMasterView): bulk_deletable = True rows_editable = True rows_bulk_deletable = True + configurable = True labels = { 'min_diff_threshold': "Min $ Diff", @@ -62,6 +63,7 @@ class PricingBatchView(BatchMasterView): grid_columns = [ 'id', 'description', + 'start_date', 'created', 'created_by', 'rowcount', @@ -75,6 +77,7 @@ class PricingBatchView(BatchMasterView): 'id', 'input_filename', 'description', + 'start_date', 'min_diff_threshold', 'min_diff_percent', 'calculate_for_manual', @@ -147,8 +150,24 @@ class PricingBatchView(BatchMasterView): 'status_text', ] + def allow_future_pricing(self): + return self.batch_handler.allow_future() + def configure_form(self, f): super(PricingBatchView, self).configure_form(f) + app = self.get_rattail_app() + batch = f.model_instance + + if self.creating or self.editing: + if self.allow_future_pricing(): + f.set_type('start_date', 'date_jquery') + f.set_helptext('start_date', "Only set this for a \"FUTURE\" batch.") + else: + f.remove('start_date') + else: # viewing or deleting + if not self.allow_future_pricing(): + if not batch.start_date: + f.remove('start_date') f.set_type('min_diff_threshold', 'currency') @@ -349,6 +368,15 @@ class PricingBatchView(BatchMasterView): return xlrow + def configure_get_simple_settings(self): + return [ + + # options + {'section': 'rattail.batch', + 'option': 'pricing.allow_future', + 'type': bool}, + ] + def includeme(config): PricingBatchView.defaults(config) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index a5706a25..6cdb001a 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -1892,14 +1892,15 @@ class ProductView(MasterView): return {'product': data} def get_supported_batches(self): + app = self.get_rattail_app() + pricing = app.get_batch_handler('pricing') return OrderedDict([ ('labels', { 'spec': self.rattail_config.get('rattail.batch', 'labels.handler', default='rattail.batch.labels:LabelBatchHandler'), }), ('pricing', { - 'spec': self.rattail_config.get('rattail.batch', 'pricing.handler', - default='rattail.batch.pricing:PricingBatchHandler'), + 'spec': pricing.get_spec(), }), ('delproduct', { 'spec': self.rattail_config.get('rattail.batch', 'delproduct.handler', @@ -2003,7 +2004,9 @@ class ProductView(MasterView): """ Return params schema for making a pricing batch. """ - return colander.SchemaNode( + app = self.get_rattail_app() + + schema = colander.SchemaNode( colander.Mapping(), colander.SchemaNode(colander.Decimal(), name='min_diff_threshold', quant='1.00', missing=colander.null, @@ -2014,6 +2017,17 @@ class ProductView(MasterView): colander.SchemaNode(colander.Boolean(), name='calculate_for_manual'), ) + pricing = app.get_batch_handler('pricing') + if pricing.allow_future(): + schema.insert(0, colander.SchemaNode( + colander.Date(), + name='start_date', + missing=colander.null, + title="Start Date (FUTURE only)", + widget=forms.widgets.JQueryDateWidget())) + + return schema + def make_batch_params_schema_delproduct(self): """ Return params schema for making a "delete products" batch. From a28d9b9748e5b721ca61961177e5d5afabcd7dd0 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 14 Jun 2022 14:03:10 -0500 Subject: [PATCH 026/978] Use `build` module instead of invoking `setup.py` for release --- tasks.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tasks.py b/tasks.py index e2ba5670..ed19d68f 100644 --- a/tasks.py +++ b/tasks.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -45,5 +45,5 @@ def release(ctx, skip_tests=False): ctx.run('tox') shutil.rmtree('Tailbone.egg-info') - ctx.run('python setup.py sdist --formats=gztar') + ctx.run('python -m build --sdist') ctx.run('twine upload dist/Tailbone-{}.tar.gz'.format(__version__)) From 4fb226ad98678b2da9cd8e9dd9091e625ffa8c0e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 14 Jun 2022 14:03:42 -0500 Subject: [PATCH 027/978] 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 de4021e0..64e2ebff 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.232 (2022-06-14) +-------------------- + +* Let default grid page size correspond to first option. + +* Add start date support for "future" pricing batch. + + 0.8.231 (2022-05-15) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 7e9c0b77..ee6252f1 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.231' +__version__ = '0.8.232' From c79ecab7198f3de63395fc4c9d6b6ba789bc569d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 14 Jun 2022 17:39:42 -0500 Subject: [PATCH 028/978] Add minimal buefy support for 'percentinput' field widget this isn't complete but seems to work well enough so far.. --- tailbone/templates/deform/percentinput.pt | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/deform/percentinput.pt b/tailbone/templates/deform/percentinput.pt index 59b15341..40aa71f1 100644 --- a/tailbone/templates/deform/percentinput.pt +++ b/tailbone/templates/deform/percentinput.pt @@ -1,12 +1,14 @@ + +
    +
    + +
    + + + +
    From a289216eacf511d0e3a036be09229e381f5aeadb Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 14 Jun 2022 17:52:59 -0500 Subject: [PATCH 029/978] Add autocomplete support for subdepartments --- tailbone/views/subdepartments.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tailbone/views/subdepartments.py b/tailbone/views/subdepartments.py index b94b0f1b..a03cabff 100644 --- a/tailbone/views/subdepartments.py +++ b/tailbone/views/subdepartments.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2021 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -37,6 +37,7 @@ class SubdepartmentView(MasterView): Master view for the Subdepartment class. """ model_class = model.Subdepartment + supports_autocomplete = True touchable = True has_versions = True From 11cda10ca5b7c137e2a63dffaf10f1952b60c137 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 24 Jun 2022 14:20:17 -0500 Subject: [PATCH 030/978] 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 64e2ebff..edbd0db3 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.233 (2022-06-24) +-------------------- + +* Add minimal buefy support for 'percentinput' field widget. + +* Add autocomplete support for subdepartments. + + 0.8.232 (2022-06-14) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index ee6252f1..e708c502 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.232' +__version__ = '0.8.233' From 7e0e881017c104f4a9a105ff64660bd544a3604a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 1 Jul 2022 12:00:06 -0500 Subject: [PATCH 031/978] Fix form validation for app settings page w/ buefy theme --- tailbone/templates/appsettings.mako | 33 ++++++++++++----------------- tailbone/views/settings.py | 28 ++++++++++++++++++------ 2 files changed, 34 insertions(+), 27 deletions(-) diff --git a/tailbone/templates/appsettings.mako b/tailbone/templates/appsettings.mako index e3fa2ccf..a80dafc2 100644 --- a/tailbone/templates/appsettings.mako +++ b/tailbone/templates/appsettings.mako @@ -52,16 +52,12 @@ ${h.csrf_token(request)} % if dform.error: -
    -
    - - Please see errors below. -
    -
    - - ${dform.error} -
    -
    + + Please see errors below. + + + ${dform.error} + % endif
    @@ -115,17 +111,13 @@ ## :class="'field-wrapper' + (setting.error ? ' with-error' : '')" > -
    - - {{ msg }} - -
    -
    + :label="setting.label" + :type="setting.error ? 'is-danger' : null" + ## TODO: what if there are multiple error messages? + :message="setting.error ? setting.error_messages[0] : null"> - - {{ setting.helptext }} +
    diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index acb74f7b..7b10f1d0 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2021 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -28,12 +28,12 @@ from __future__ import unicode_literals, absolute_import import re +import json import six from rattail.db import model, api from rattail.settings import Setting from rattail.util import import_module_path -from rattail.config import parse_bool import colander from webhelpers2.html import tags @@ -171,14 +171,28 @@ class AppSettingsView(View): 'data_type': setting.data_type.__name__, 'choices': setting.choices, 'helptext': form.render_helptext(field.name) if form.has_helptext(field.name) else None, - 'error': field.error, + 'error': False, # nb. may set to True below } - value = self.get_setting_value(setting) - if setting.data_type is bool: - value = parse_bool(value) + + # we want the value from the form, i.e. in case of a POST + # request with validation errors. we also want to make + # sure value is JSON-compatible, but we must represent it + # as Python value here, and it will be JSON-encoded later. + value = form.get_vuejs_model_value(field) + value = json.loads(value) s['value'] = value + + # 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. if field.error: - s['error_messages'] = field.error_messages() + s['error'] = True + if isinstance(field.error, colander.Invalid): + s['error_messages'] = [field.errormsg] + else: + s['error_messages'] = field.error_messages() + grouped[setting.group].append(s) data = [] From 496e03a3ecbf593473c0490486c45d449fde1ce6 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 1 Jul 2022 12:00:17 -0500 Subject: [PATCH 032/978] Honor default pagesize for all grids, per setting --- tailbone/grids/core.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 0e2833dc..95465b1e 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -622,6 +622,12 @@ class Grid(object): 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] From c6df827311a282b6cecdfc29d4ba2ffd891a037a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 8 Jul 2022 12:57:57 -0500 Subject: [PATCH 033/978] Add basic "download results" for Subdepartments grid --- tailbone/views/subdepartments.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/subdepartments.py b/tailbone/views/subdepartments.py index a03cabff..67945581 100644 --- a/tailbone/views/subdepartments.py +++ b/tailbone/views/subdepartments.py @@ -39,6 +39,7 @@ class SubdepartmentView(MasterView): model_class = model.Subdepartment supports_autocomplete = True touchable = True + results_downloadable = True has_versions = True grid_columns = [ From d16290cb7051ae0f9a3ae43e07858376f2084528 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 18 Jul 2022 12:31:54 -0500 Subject: [PATCH 034/978] Add new-style config defaults for BrandView --- tailbone/views/brands.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tailbone/views/brands.py b/tailbone/views/brands.py index b73060a3..92c6a41b 100644 --- a/tailbone/views/brands.py +++ b/tailbone/views/brands.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2021 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -135,5 +135,12 @@ class BrandView(MasterView): self.Session.delete(removing) -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + BrandView = kwargs.get('BrandView', base['BrandView']) BrandView.defaults(config) + + +def includeme(config): + defaults(config) From 5e0253927c03dda822f3a89b68607a1a6a48ca43 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 18 Jul 2022 12:41:27 -0500 Subject: [PATCH 035/978] 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 edbd0db3..ce145e2c 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,18 @@ CHANGELOG ========= +0.8.234 (2022-07-18) +-------------------- + +* Fix form validation for app settings page w/ buefy theme. + +* Honor default pagesize for all grids, per setting. + +* Add basic "download results" for Subdepartments grid. + +* Add new-style config defaults for BrandView. + + 0.8.233 (2022-06-24) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index e708c502..7f7d1eb2 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.233' +__version__ = '0.8.234' From 9c5f3a3b6494e8f8c855b85ce5130d9189f0221a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 19 Jul 2022 11:45:52 -0500 Subject: [PATCH 036/978] Split out rendering of `this-page` component in falafel theme it's possible a template may need to override that --- tailbone/templates/themes/falafel/base.mako | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index 61471aaa..9bd092ab 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -452,9 +452,7 @@ % endfor % endif - - + ${self.render_this_page_component()} ## Footer @@ -539,6 +537,12 @@ ${tailbone_autocomplete_template()} +<%def name="render_this_page_component()"> + + + + <%def name="render_navbar_end()"> diff --git a/tailbone/templates/deform/select.pt b/tailbone/templates/deform/select.pt index 4d09f16f..4295380b 100644 --- a/tailbone/templates/deform/select.pt +++ b/tailbone/templates/deform/select.pt @@ -69,7 +69,8 @@ native-size size; style style; v-model vmodel; - @input input_handler;"> + @input input_handler; + attributes|field.widget.attributes|{};"> + @input input_handler; + attributes|field.widget.attributes|{};">
    From e77ca93d80959b402202064c09c23e04463b9fdc Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 22 Jul 2022 12:41:54 -0500 Subject: [PATCH 042/978] 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 ce145e2c..728e63b1 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,22 @@ CHANGELOG ========= +0.8.235 (2022-07-22) +-------------------- + +* Split out rendering of ``this-page`` component in falafel theme. + +* Allow download of results for common product-related tables. + +* Make caching products optional, when creating vendor catalog batch. + +* Expose the ``complete`` flag for pricing batch. + +* Add ``template_kwargs_clone()`` stub for master view. + +* Misc deform template improvements. + + 0.8.234 (2022-07-18) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 7f7d1eb2..0674f910 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.234' +__version__ = '0.8.235' From 28238c6fb540b393182c839e0cda71685cad1961 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 23 Jul 2022 22:06:18 -0500 Subject: [PATCH 043/978] Add setting to expose/hide "active in POS" customer flag --- tailbone/templates/customers/configure.mako | 23 +++++++++++++++ tailbone/views/customers.py | 32 ++++++++++++++++++--- 2 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 tailbone/templates/customers/configure.mako diff --git a/tailbone/templates/customers/configure.mako b/tailbone/templates/customers/configure.mako new file mode 100644 index 00000000..13093a7b --- /dev/null +++ b/tailbone/templates/customers/configure.mako @@ -0,0 +1,23 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="form_content()"> + +

    POS

    +
    + + + + Expose/track the "Active in POS" flag for customers. + + + +
    + + + + +${parent.body()} diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index 310bddb5..d061701f 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -50,6 +50,7 @@ class CustomerView(MasterView): people_detachable = True touchable = True supports_autocomplete = True + configurable = True # whether to show "view full profile" helper for customer view show_profiles_helper = True @@ -92,6 +93,13 @@ class CustomerView(MasterView): 'members', ] + def get_expose_active_in_pos(self): + if not hasattr(self, '_expose_active_in_pos'): + self._expose_active_in_pos = self.rattail_config.getbool( + 'rattail', 'customers.active_in_pos', + default=False) + return self._expose_active_in_pos + def configure_grid(self, g): super(CustomerView, self).configure_grid(g) @@ -132,8 +140,9 @@ class CustomerView(MasterView): g.set_renderer('person', self.grid_render_person) # active_in_pos - g.filters['active_in_pos'].default_active = True - g.filters['active_in_pos'].default_verb = 'is_true' + if self.get_expose_active_in_pos(): + g.filters['active_in_pos'].default_active = True + g.filters['active_in_pos'].default_verb = 'is_true' g.set_link('id') g.set_link('number') @@ -142,8 +151,9 @@ class CustomerView(MasterView): g.set_link('email') def grid_extra_class(self, customer, i): - if not customer.active_in_pos: - return 'warning' + if self.get_expose_active_in_pos(): + if not customer.active_in_pos: + return 'warning' def get_instance(self): try: @@ -246,6 +256,10 @@ class CustomerView(MasterView): customer = f.model_instance permission_prefix = self.get_permission_prefix() + if not self.get_expose_active_in_pos(): + f.remove('active_in_pos', + 'active_in_pos_sticky') + # members if self.creating: f.remove_field('members') @@ -430,6 +444,16 @@ class CustomerView(MasterView): return self.redirect(self.request.get_referrer()) + def configure_get_simple_settings(self): + return [ + + # POS + {'section': 'rattail', + 'option': 'customers.active_in_pos', + 'type': bool}, + + ] + @classmethod def defaults(cls, config): cls._defaults(config) From e656f769b1c4b9f92570b1e25bf05988585b9008 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 23 Jul 2022 22:18:17 -0500 Subject: [PATCH 044/978] Allow optional row grid title for master view --- tailbone/templates/master/view.mako | 3 +++ tailbone/views/master.py | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako index 4ede63dc..17a4f852 100644 --- a/tailbone/templates/master/view.mako +++ b/tailbone/templates/master/view.mako @@ -104,6 +104,9 @@ % if master.has_rows: % if use_buefy:
    + % if rows_title: +

    ${rows_title}

    + % endif % else: ${rows_grid|n} diff --git a/tailbone/views/master.py b/tailbone/views/master.py index b3a99e49..b2002f49 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -166,6 +166,7 @@ class MasterView(View): has_rows = False model_row_class = None + rows_title = None rows_pageable = True rows_sortable = True rows_filterable = True @@ -224,6 +225,12 @@ class MasterView(View): """ return getattr(cls, 'grid_factory', grids.Grid) + @classmethod + def get_rows_title(cls): + # nb. we do not provide a default value for this, since it + # will not always make sense to show a row title + return cls.rows_title + @classmethod def get_row_grid_factory(cls): """ @@ -2208,6 +2215,7 @@ class MasterView(View): context['grid_count'] = self.grid_count if self.has_rows: + context['rows_title'] = self.get_rows_title() context['row_permission_prefix'] = self.get_row_permission_prefix() context['row_model_title'] = self.get_row_model_title() context['row_model_title_plural'] = self.get_row_model_title_plural() From 25f39f4173dbbfc0b855e902eafe3d3dd4650302 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 24 Jul 2022 12:05:05 -0500 Subject: [PATCH 045/978] Add basic/minimal merge support for customers --- tailbone/views/customers.py | 43 +++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index d061701f..693cd323 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -93,6 +93,18 @@ class CustomerView(MasterView): 'members', ] + mergeable = True + + merge_coalesce_fields = [ + 'email_addresses', + 'phone_numbers', + ] + + merge_fields = merge_coalesce_fields + [ + 'uuid', + 'name', + ] + def get_expose_active_in_pos(self): if not hasattr(self, '_expose_active_in_pos'): self._expose_active_in_pos = self.rattail_config.getbool( @@ -444,6 +456,37 @@ class CustomerView(MasterView): return self.redirect(self.request.get_referrer()) + def get_merge_data(self, customer): + return { + 'uuid': customer.uuid, + 'name': customer.name, + 'email_addresses': [e.address for e in customer.emails], + 'phone_numbers': [p.number for p in customer.phones], + } + + def merge_objects(self, removing, keeping): + coalesce = self.get_merge_coalesce_fields() + if coalesce: + + if 'email_addresses' in coalesce: + keeping_emails = [e.address for e in keeping.emails] + for email in removing.emails: + if email.address not in keeping_emails: + keeping.add_email(address=email.address, + type=email.type, + invalid=email.invalid) + keeping_emails.append(email.address) + + if 'phone_numbers' in coalesce: + keeping_phones = [e.number for e in keeping.phones] + for phone in removing.phones: + if phone.number not in keeping_phones: + keeping.add_phone(number=phone.number, + type=phone.type) + keeping_phones.append(phone.number) + + self.Session.delete(removing) + def configure_get_simple_settings(self): return [ From 0dc344b821523dc630062c51c80b546bb6274c58 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 24 Jul 2022 15:05:51 -0500 Subject: [PATCH 046/978] Assume default vendor for new receiving batch i.e. if there is only one vendor --- tailbone/views/purchasing/receiving.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index bca9ef64..3f49bf9a 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -332,13 +332,16 @@ class ReceivingBatchView(PurchasingBatchView): use_dropdown = vendor_handler.choice_uses_dropdown() if use_dropdown: vendors = self.Session.query(model.Vendor)\ - .order_by(model.Vendor.id) + .order_by(model.Vendor.id)\ + .all() vendor_values = [(vendor.uuid, "({}) {}".format(vendor.id, vendor.name)) for vendor in vendors] if use_buefy: form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values)) else: form.set_widget('vendor', forms.widgets.JQuerySelectWidget(values=vendor_values)) + if len(vendors) == 1: + form.set_default('vendor', vendors[0].uuid) else: vendor_display = "" if self.request.method == 'POST': From 36d4f0a5f763f1d1a9e77e291ddece693ef8a799 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 24 Jul 2022 21:10:52 -0500 Subject: [PATCH 047/978] Add basic edit support for Purchases --- tailbone/views/purchases/core.py | 50 +++++++++++++++++++++----- tailbone/views/purchasing/receiving.py | 2 +- 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/tailbone/views/purchases/core.py b/tailbone/views/purchases/core.py index eb32fa73..eca5de34 100644 --- a/tailbone/views/purchases/core.py +++ b/tailbone/views/purchases/core.py @@ -42,7 +42,6 @@ class PurchaseView(MasterView): """ model_class = model.Purchase creatable = False - editable = False has_rows = True model_row_class = model.PurchaseItem @@ -73,7 +72,6 @@ class PurchaseView(MasterView): 'status', 'buyer', 'date_ordered', - 'date_received', 'po_number', 'po_total', 'ship_method', @@ -81,6 +79,7 @@ class PurchaseView(MasterView): 'invoice_date', 'invoice_number', 'invoice_total', + 'date_received', 'created', 'created_by', 'batches', @@ -201,22 +200,57 @@ class PurchaseView(MasterView): def configure_form(self, f): super(PurchaseView, self).configure_form(f) + # id f.set_renderer('id', self.render_id_str) + f.set_readonly('id') f.set_renderer('store', self.render_store) + + # vendor f.set_renderer('vendor', self.render_vendor) + f.set_readonly('vendor') + + # department f.set_renderer('department', self.render_department) + # buyer + f.set_readonly('buyer') + + # date_ordered + f.set_type('date_ordered', 'date_jquery') + + # po_number + f.set_label('po_number', "PO Number") + + # po_total + f.set_type('po_total', 'currency') + f.set_readonly('po_total') + f.set_label('po_total', "PO Total") + + # notes_to_vendor + f.set_type('notes_to_vendor', 'text_wrapped') + + # date_received + f.set_type('date_received', 'date_jquery') + + # invoice_date + f.set_type('invoice_date', 'date_jquery') + + # invoice_total + f.set_type('invoice_total', 'currency') + f.set_readonly('invoice_total') + + # status f.set_readonly('status') f.set_enum('status', self.enum.PURCHASE_STATUS) - f.set_label('po_number', "PO Number") - f.set_label('po_total', "PO Total") - f.set_type('po_total', 'currency') - - f.set_type('invoice_total', 'currency') - + # batches f.set_renderer('batches', self.render_batches) + f.set_readonly('batches') + + # created + f.set_readonly('created') + f.set_readonly('created_by') if self.viewing: purchase = f.model_instance diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 3f49bf9a..a250ba0c 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -539,7 +539,7 @@ class ReceivingBatchView(PurchasingBatchView): f.set_widget('purchase_uuid', dfwidget.SelectWidget(values=values)) f.set_label('purchase_uuid', "Purchase Order") f.set_required('purchase_uuid') - else: + elif self.creating or not batch.purchase: f.remove_field('purchase') # department From f33d7b7f90fa9b56dd7026b2a1b75e16c4689010 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 24 Jul 2022 21:11:12 -0500 Subject: [PATCH 048/978] Add `iter(Form)` logic, to loop through fields --- tailbone/forms/core.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index e1e54a65..7278cd2b 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -374,6 +374,9 @@ class Form(object): self.component = component self.vuejs_field_converters = vuejs_field_converters or {} + def __iter__(self): + return iter(self.fields) + @property def component_studly(self): words = self.component.split('-') From ad7b347e16155fdbb5b57843d6a753980a79865f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 24 Jul 2022 22:29:55 -0500 Subject: [PATCH 049/978] Add "auto-receive all items" support for receiving batch API --- tailbone/api/batch/receiving.py | 21 ++++++++++++++++++++- tailbone/templates/receiving/configure.mako | 6 +++--- tailbone/views/purchasing/receiving.py | 21 +++------------------ 3 files changed, 26 insertions(+), 22 deletions(-) diff --git a/tailbone/api/batch/receiving.py b/tailbone/api/batch/receiving.py index 905a0872..0ddda845 100644 --- a/tailbone/api/batch/receiving.py +++ b/tailbone/api/batch/receiving.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2021 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -74,6 +74,8 @@ class ReceivingBatchViews(APIBatchView): data['invoice_total'] = batch.invoice_total data['invoice_total_calculated'] = batch.invoice_total_calculated + data['can_auto_receive'] = self.handler.can_auto_receive(batch) + return data def create_object(self, data): @@ -82,6 +84,15 @@ class ReceivingBatchViews(APIBatchView): batch = super(ReceivingBatchViews, self).create_object(data) return batch + def auto_receive(self): + """ + View which handles auto-marking as received, all items within + a pending batch. + """ + batch = self.get_object() + self.handler.auto_receive_all_items(batch) + return self._get(obj=batch) + def mark_receiving_complete(self): """ Mark the given batch as "receiving complete". @@ -136,6 +147,14 @@ class ReceivingBatchViews(APIBatchView): collection_url_prefix = cls.get_collection_url_prefix() object_url_prefix = cls.get_object_url_prefix() + # auto-receive + config.add_route('{}.auto_receive'.format(route_prefix), + '{}/{{uuid}}/auto-receive'.format(object_url_prefix)) + config.add_view(cls, attr='auto_receive', + route_name='{}.auto_receive'.format(route_prefix), + permission='{}.auto_receive'.format(permission_prefix), + renderer='json') + # mark receiving complete config.add_route('{}.mark_receiving_complete'.format(route_prefix), '{}/{{uuid}}/mark-receiving-complete'.format(object_url_prefix)) config.add_view(cls, attr='mark_receiving_complete', route_name='{}.mark_receiving_complete'.format(route_prefix), diff --git a/tailbone/templates/receiving/configure.mako b/tailbone/templates/receiving/configure.mako index e93dbd51..36ff5c39 100644 --- a/tailbone/templates/receiving/configure.mako +++ b/tailbone/templates/receiving/configure.mako @@ -129,7 +129,7 @@ - + - + - Allow "Quick Receive All" + Quick Receive "All or Nothing" diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index a250ba0c..eebb5855 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -129,9 +129,9 @@ class ReceivingBatchView(PurchasingBatchView): 'vendor_contact', 'vendor_phone', 'date_ordered', - 'date_received', 'po_number', 'po_total', + 'date_received', 'invoice_date', 'invoice_number', 'invoice_total', @@ -1824,22 +1824,7 @@ class ReceivingBatchView(PurchasingBatchView): return pod.get_image_url(self.rattail_config, row.upc) def can_auto_receive(self, batch): - if batch.executed: - return False - if batch.complete: - return False - - if batch.is_truck_dump_related(): - if not batch.is_truck_dump_parent(): - return False - if not batch.truck_dump_children_first(): - return False - - # only auto-receive once per batch - if batch.get_param('auto_received'): - return False - - return True + return self.handler.can_auto_receive(batch) def auto_receive(self): """ @@ -1865,7 +1850,7 @@ class ReceivingBatchView(PurchasingBatchView): """ session = RattailSession() batch = session.query(model.PurchaseBatch).get(uuid) - user = session.query(model.User).get(user_uuid) + # user = session.query(model.User).get(user_uuid) try: self.handler.auto_receive_all_items(batch, progress=progress) From 9589606fb5404aafe0be7212fef1ce79b1ff06e6 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 25 Jul 2022 11:42:46 -0500 Subject: [PATCH 050/978] 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 728e63b1..8accc39d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,24 @@ CHANGELOG ========= +0.8.236 (2022-07-25) +-------------------- + +* Add setting to expose/hide "active in POS" customer flag. + +* Allow optional row grid title for master view. + +* Add basic/minimal merge support for customers. + +* Assume default vendor for new receiving batch. + +* Add basic edit support for Purchases. + +* Add ``iter(Form)`` logic, to loop through fields. + +* Add "auto-receive all items" support for receiving batch API. + + 0.8.235 (2022-07-22) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 0674f910..c4a65bf4 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.235' +__version__ = '0.8.236' From 92a52133dee3354f74019e20e0c8a4d2cdabd6c1 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 26 Jul 2022 14:25:20 -0500 Subject: [PATCH 051/978] Add some more views to potentially include via poser --- tailbone/views/poser/views.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tailbone/views/poser/views.py b/tailbone/views/poser/views.py index e69d51d3..14c97a61 100644 --- a/tailbone/views/poser/views.py +++ b/tailbone/views/poser/views.py @@ -129,6 +129,11 @@ class PoserViewView(PoserMasterView): 'label': "Departments", }, + 'tailbone.views.ifps': { + # 'spec': 'tailbone.views.ifps', + 'label': "IFPS PLU Codes", + }, + 'tailbone.views.subdepartments': { # 'spec': 'tailbone.views.subdepartments', 'label': "Subdepartments", @@ -197,6 +202,16 @@ class PoserViewView(PoserMasterView): 'other': { + 'tailbone.views.datasync': { + # 'spec': 'tailbone.views.datasync', + 'label': "DataSync", + }, + + 'tailbone.views.importing': { + # 'spec': 'tailbone.views.importing', + 'label': "Importing / Exporting", + }, + 'tailbone.views.stores': { # 'spec': 'tailbone.views.stores', 'label': "Stores", From 17810d9cae18586d714e82909f9c27230e9f884d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 26 Jul 2022 16:30:04 -0500 Subject: [PATCH 052/978] Misc. improvements for desktop receiving views - don't expose "cases" if config says not to - don't expose "expired" if config says not to - use `numeric-input` for quantity fields - add `product_key_field` to global-ish template context --- .../static/js/tailbone.buefy.numericinput.js | 6 +- tailbone/templates/receiving/view_row.mako | 155 +++++++++++------- tailbone/views/custorders/orders.py | 1 - tailbone/views/master.py | 3 + tailbone/views/products.py | 3 - tailbone/views/purchasing/batch.py | 21 ++- tailbone/views/purchasing/receiving.py | 20 ++- 7 files changed, 125 insertions(+), 84 deletions(-) diff --git a/tailbone/static/js/tailbone.buefy.numericinput.js b/tailbone/static/js/tailbone.buefy.numericinput.js index 3fc0d74f..b2f2ac0c 100644 --- a/tailbone/static/js/tailbone.buefy.numericinput.js +++ b/tailbone/static/js/tailbone.buefy.numericinput.js @@ -20,7 +20,7 @@ const NumericInput = { props: { name: String, - value: String, + value: [Number, String], placeholder: String, iconPack: String, icon: String, @@ -53,6 +53,10 @@ const NumericInput = { } }, + select() { + this.$el.children[0].select() + }, + valueChanged(value) { this.$emit('input', value) } diff --git a/tailbone/templates/receiving/view_row.mako b/tailbone/templates/receiving/view_row.mako index bee71475..bb4275b8 100644 --- a/tailbone/templates/receiving/view_row.mako +++ b/tailbone/templates/receiving/view_row.mako @@ -82,11 +82,11 @@
    % if row.product: - ${form.render_field_readonly('upc')} + ${form.render_field_readonly(product_key_field)} ${form.render_field_readonly('product')} % else: ${form.render_field_readonly('item_entry')} - ${form.render_field_readonly('upc')} + ${form.render_field_readonly(product_key_field)} ${form.render_field_readonly('brand_name')} ${form.render_field_readonly('description')} ${form.render_field_readonly('size')} @@ -192,15 +192,17 @@ - - - {{ rowData.case_quantity }} - - + % if allow_cases: + + + {{ rowData.case_quantity }} + + - -   - + +   + + % endif @@ -226,31 +228,39 @@
    - - + +
    - - - Units - - - Cases - - + % if allow_cases: + + + Units + + + Cases + + + % else: + + + Units + + % endif
    -
    - = {{ accountForProductTotalUnits }} -
    + % if allow_cases: +
    + = {{ accountForProductTotalUnits }} +
    + % endif
    @@ -325,31 +335,39 @@
    - - + +
    - - - Units - - - Cases - - + % if allow_cases: + + + Units + + + Cases + + + % else: + + + Units + + % endif
    -
    - = {{ declareCreditTotalUnits }} -
    + % if allow_cases: +
    + = {{ declareCreditTotalUnits }} +
    + % endif
    @@ -494,7 +512,7 @@ if (this.accountForProductMode == 'expired' && !this.accountForProductExpiration) { return true } - if (!this.accountForProductQuantity) { + if (!this.accountForProductQuantity || this.accountForProductQuantity == 0) { return true } if (this.accountForProductSubmitting) { @@ -506,9 +524,13 @@ ThisPage.methods.accountForProductInit = function() { this.accountForProductMode = 'received' this.accountForProductExpiration = null - this.accountForProductQuantity = null + this.accountForProductQuantity = 0 this.accountForProductUOM = 'units' this.accountForProductShowDialog = true + this.$nextTick(() => { + this.$refs.accountForProductQuantityInput.select() + this.$refs.accountForProductQuantityInput.focus() + }) } ThisPage.methods.accountForProductUOMClicked = function(uom) { @@ -606,7 +628,7 @@ if (this.declareCreditType == 'expired' && !this.declareCreditExpiration) { return true } - if (!this.declareCreditQuantity) { + if (!this.declareCreditQuantity || this.declareCreditQuantity == 0) { return true } if (this.declareCreditSubmitting) { @@ -618,13 +640,18 @@ ThisPage.methods.declareCreditInit = function() { this.declareCreditType = null this.declareCreditExpiration = null - if (this.rowData.cases_received) { - this.declareCreditQuantity = this.rowData.cases_received - this.declareCreditUOM = 'cases' - } else { + % if allow_cases: + if (this.rowData.cases_received) { + this.declareCreditQuantity = this.rowData.cases_received + this.declareCreditUOM = 'cases' + } else { + this.declareCreditQuantity = this.rowData.units_received + this.declareCreditUOM = 'units' + } + % else: this.declareCreditQuantity = this.rowData.units_received this.declareCreditUOM = 'units' - } + % endif this.declareCreditShowDialog = true } @@ -638,11 +665,15 @@ expiration_date: this.declareCreditExpiration, } - if (this.declareCreditUOM == 'cases') { - params.cases = this.declareCreditQuantity - } else { + % if allow_cases: + if (this.declareCreditUOM == 'cases') { + params.cases = this.declareCreditQuantity + } else { + params.units = this.declareCreditQuantity + } + % else: params.units = this.declareCreditQuantity - } + % endif this.submitForm(url, params, response => { this.rowData = response.data.row diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index 6c84f4ab..50a108ef 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -339,7 +339,6 @@ class CustomerOrderView(MasterView): 'batch': batch, 'normalized_batch': self.normalize_batch(batch), 'new_order_requires_customer': self.batch_handler.new_order_requires_customer(), - 'product_key_field': self.rattail_config.product_key(), 'product_price_may_be_questionable': self.batch_handler.product_price_may_be_questionable(), 'allow_contact_info_choice': self.batch_handler.allow_contact_info_choice(), 'allow_contact_info_create': self.batch_handler.allow_contact_info_creation(), diff --git a/tailbone/views/master.py b/tailbone/views/master.py index b2002f49..b182c839 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2208,6 +2208,9 @@ class MasterView(View): 'quickie': None, } + key = self.rattail_config.product_key() + context['product_key_field'] = self.product_key_fields.get(key, key) + if self.expose_quickie_search: context['quickie'] = self.get_quickie_context() diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 6cdb001a..33999781 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -1180,9 +1180,6 @@ class ProductView(MasterView): product = kwargs['instance'] use_buefy = self.get_use_buefy() - key = self.rattail_config.product_key() - kwargs['product_key_field'] = self.product_key_fields.get(key, key) - kwargs['image_url'] = self.products_handler.get_image_url(product) # add price history, if user has access diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index 86ee057a..4209a35d 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -803,7 +803,9 @@ class PurchasingBatchView(BatchMasterView): app = self.get_rattail_app() cases = getattr(row, 'cases_{}'.format(field)) units = getattr(row, 'units_{}'.format(field)) - return app.render_cases_units(cases, units) + # nb. do not render anything if empty quantities + if cases or units: + return app.render_cases_units(cases, units) def make_row_credits_grid(self, row): use_buefy = self.get_use_buefy() @@ -815,8 +817,6 @@ class PurchasingBatchView(BatchMasterView): data=[] if use_buefy else row.credits, columns=[ 'credit_type', - # 'cases_shorted', - # 'units_shorted', 'shorted', 'credit_total', 'expiration_date', @@ -827,20 +827,19 @@ class PurchasingBatchView(BatchMasterView): ], labels={ 'credit_type': "Type", - 'cases_shorted': "Cases", - 'units_shorted': "Units", 'shorted': "Quantity", 'credit_total': "Total", - 'mispick_upc': "Mispick UPC", - 'mispick_brand_name': "MP Brand", - 'mispick_description': "MP Description", - 'mispick_size': "MP Size", + # 'mispick_upc': "Mispick UPC", + # 'mispick_brand_name': "MP Brand", + # 'mispick_description': "MP Description", + # 'mispick_size': "MP Size", }) - g.set_type('cases_shorted', 'quantity') - g.set_type('units_shorted', 'quantity') g.set_type('credit_total', 'currency') + if not self.batch_handler.allow_expired_credits(): + g.remove('expiration_date') + return g def render_row_credits(self, row, field): diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index eebb5855..c66c3664 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -152,8 +152,7 @@ class ReceivingBatchView(PurchasingBatchView): row_grid_columns = [ 'sequence', - 'upc', - # 'item_id', + '_product_key_', 'vendor_code', 'brand_name', 'description', @@ -177,8 +176,7 @@ class ReceivingBatchView(PurchasingBatchView): row_form_fields = [ 'sequence', 'item_entry', - 'upc', - 'item_id', + '_product_key_', 'vendor_code', 'product', 'brand_name', @@ -769,6 +767,8 @@ class ReceivingBatchView(PurchasingBatchView): products_handler = app.get_products_handler() row = kwargs['instance'] + kwargs['allow_cases'] = self.batch_handler.allow_cases() + if row.product: kwargs['image_url'] = products_handler.get_image_url(row.product) elif row.upc: @@ -776,8 +776,16 @@ class ReceivingBatchView(PurchasingBatchView): if use_buefy: kwargs['row_context'] = self.get_context_row(row) - kwargs['possible_receiving_modes'] = POSSIBLE_RECEIVING_MODES - kwargs['possible_credit_types'] = POSSIBLE_CREDIT_TYPES + + modes = list(POSSIBLE_RECEIVING_MODES) + types = list(POSSIBLE_CREDIT_TYPES) + if not self.batch_handler.allow_expired_credits(): + if 'expired' in modes: + modes.remove('expired') + if 'expired' in types: + types.remove('expired') + kwargs['possible_receiving_modes'] = modes + kwargs['possible_credit_types'] = types return kwargs From 3726a2685a9d37e4f3e92fd5471e5e15941298c8 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 27 Jul 2022 10:21:08 -0500 Subject: [PATCH 053/978] 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 8accc39d..1107b43f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.237 (2022-07-27) +-------------------- + +* Add some more views to potentially include via poser. + +* Misc. improvements for desktop receiving views. + + 0.8.236 (2022-07-25) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index c4a65bf4..0bf30000 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.236' +__version__ = '0.8.237' From 862198cf82e96a7162a8a99d11fb105924c40275 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 3 Aug 2022 11:13:43 -0500 Subject: [PATCH 054/978] Improve "touch" logic for employees also use app handler for default touch logic --- tailbone/views/employees.py | 5 +++++ tailbone/views/master.py | 14 ++++++++------ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/tailbone/views/employees.py b/tailbone/views/employees.py index 46375bb4..e42d32fa 100644 --- a/tailbone/views/employees.py +++ b/tailbone/views/employees.py @@ -304,6 +304,11 @@ class EmployeeView(MasterView): items.append(HTML.tag('li', c=six.text_type(department))) return HTML.tag('ul', c=items) + def touch_instance(self, employee): + app = self.get_rattail_app() + employment = app.get_employment_handler() + employment.touch_employee(self.Session(), employee) + def get_version_child_classes(self): return [ (model.Person, 'uuid', 'person_uuid'), diff --git a/tailbone/views/master.py b/tailbone/views/master.py index b182c839..a9e2110f 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -1159,11 +1159,8 @@ class MasterView(View): """ Perform actual "touch" logic for the given object. """ - change = model.Change() - change.class_name = obj.__class__.__name__ - change.instance_uuid = obj.uuid - change = self.Session.merge(change) - change.deleted = False + app = self.get_rattail_app() + app.touch_object(self.Session(), obj) def versions(self): """ @@ -4795,7 +4792,12 @@ class MasterView(View): if cls.touchable: config.add_tailbone_permission(permission_prefix, '{}.touch'.format(permission_prefix), "\"Touch\" a {} to trigger datasync for it".format(model_title)) - config.add_route('{}.touch'.format(route_prefix), '{}/touch'.format(instance_url_prefix)) + config.add_route('{}.touch'.format(route_prefix), + '{}/touch'.format(instance_url_prefix), + # TODO: should add this restriction after the old + # jquery theme is no longer in use + #request_method='POST' + ) config.add_view(cls, attr='touch', route_name='{}.touch'.format(route_prefix), permission='{}.touch'.format(permission_prefix)) From 4ff0450632373e3958e56f143f6a0100ef6a85f3 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 3 Aug 2022 14:44:38 -0500 Subject: [PATCH 055/978] Stop using the old `rattail.db.api.settings` module --- tailbone/grids/core.py | 10 ++++++---- tailbone/util.py | 23 ++++++++++++++++++----- tailbone/views/email.py | 19 ++++++++++--------- tailbone/views/settings.py | 5 +++-- 4 files changed, 37 insertions(+), 20 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 95465b1e..5360c894 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -34,7 +34,6 @@ from six.moves import urllib import sqlalchemy as sa from sqlalchemy import orm -from rattail.db import api from rattail.db.types import GPCType from rattail.util import prettify, pretty_boolean, pretty_quantity, pretty_hours from rattail.time import localtime @@ -743,7 +742,8 @@ class Grid(object): # User defaults should have all or nothing, so just check one key. key = 'tailbone.{}.grid.{}.sortkey'.format(user.uuid, self.key) - return api.get_setting(session, key) is not None + app = self.request.rattail_config.get_app() + return app.get_setting(Session(), key) is not None def apply_user_defaults(self, settings): """ @@ -751,7 +751,8 @@ class Grid(object): """ def merge(key, normalize=lambda v: v): skey = 'tailbone.{}.grid.{}.{}'.format(self.request.user.uuid, self.key, key) - value = api.get_setting(Session(), skey) + app = self.request.rattail_config.get_app() + value = app.get_setting(Session(), skey) settings[key] = normalize(value) if self.filterable: @@ -929,7 +930,8 @@ class Grid(object): def persist(key, value=lambda k: settings[k]): if to == 'defaults': skey = 'tailbone.{}.grid.{}.{}'.format(self.request.user.uuid, self.key, key) - api.save_setting(Session(), skey, value(key)) + app = self.request.rattail_config.get_app() + app.save_setting(Session(), skey, value(key)) else: # to == session skey = 'grid.{}.{}'.format(self.key, key) self.request.session[skey] = value(key) diff --git a/tailbone/util.py b/tailbone/util.py index 38b9a0c2..c7eabae6 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -174,8 +174,6 @@ def set_app_theme(request, theme, session=None): This also saves the setting for the new theme, and updates the running app registry settings with the new theme. """ - from rattail.db import api - theme = get_effective_theme(request.rattail_config, theme=theme, session=session) theme_path = get_theme_template_path(request.rattail_config, theme=theme, session=session) @@ -190,7 +188,16 @@ def set_app_theme(request, theme, session=None): # clear template cache for lookup object, so it will reload each (as needed) lookup._collection.clear() - api.save_setting(session, 'tailbone.theme', theme) + app = request.rattail_config.get_app() + close = False + if not session: + session = app.make_session() + close = True + app.save_setting(session, 'tailbone.theme', theme) + if close: + session.commit() + session.close() + request.registry.settings['tailbone.theme'] = theme @@ -209,10 +216,16 @@ def get_effective_theme(rattail_config, theme=None, session=None): Validates and returns the "effective" theme. If you provide a theme, that will be used; otherwise it is read from database setting. """ - from rattail.db import api + app = rattail_config.get_app() if not theme: - theme = api.get_setting(session, 'tailbone.theme') or 'default' + close = False + if not session: + session = app.make_session() + close = True + theme = app.get_setting(session, 'tailbone.theme') or 'default' + if close: + session.close() # confirm requested theme is available available = rattail_config.getlist('tailbone', 'themes', diff --git a/tailbone/views/email.py b/tailbone/views/email.py index a5687254..8a03f8a2 100644 --- a/tailbone/views/email.py +++ b/tailbone/views/email.py @@ -31,7 +31,7 @@ import re import six from rattail import mail -from rattail.db import api, model +from rattail.db import model from rattail.config import parse_list import colander @@ -213,15 +213,16 @@ class EmailSettingView(MasterView): def save_edit_form(self, form): key = self.request.matchdict['key'] data = self.form_deserialized + app = self.get_rattail_app() session = self.Session() - api.save_setting(session, 'rattail.mail.{}.prefix'.format(key), data['prefix']) - api.save_setting(session, 'rattail.mail.{}.subject'.format(key), data['subject']) - api.save_setting(session, 'rattail.mail.{}.from'.format(key), data['sender']) - api.save_setting(session, 'rattail.mail.{}.replyto'.format(key), data['replyto']) - api.save_setting(session, 'rattail.mail.{}.to'.format(key), (data['to'] or '').replace('\n', ', ')) - api.save_setting(session, 'rattail.mail.{}.cc'.format(key), (data['cc'] or '').replace('\n', ', ')) - api.save_setting(session, 'rattail.mail.{}.bcc'.format(key), (data['bcc'] or '').replace('\n', ', ')) - api.save_setting(session, 'rattail.mail.{}.enabled'.format(key), six.text_type(data['enabled']).lower()) + app.save_setting(session, 'rattail.mail.{}.prefix'.format(key), data['prefix']) + app.save_setting(session, 'rattail.mail.{}.subject'.format(key), data['subject']) + app.save_setting(session, 'rattail.mail.{}.from'.format(key), data['sender']) + app.save_setting(session, 'rattail.mail.{}.replyto'.format(key), data['replyto']) + app.save_setting(session, 'rattail.mail.{}.to'.format(key), (data['to'] or '').replace('\n', ', ')) + app.save_setting(session, 'rattail.mail.{}.cc'.format(key), (data['cc'] or '').replace('\n', ', ')) + app.save_setting(session, 'rattail.mail.{}.bcc'.format(key), (data['bcc'] or '').replace('\n', ', ')) + app.save_setting(session, 'rattail.mail.{}.enabled'.format(key), six.text_type(data['enabled']).lower()) return data def template_kwargs_view(self, **kwargs): diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index 7b10f1d0..6fc0e66f 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -31,7 +31,7 @@ import re import json import six -from rattail.db import model, api +from rattail.db import model from rattail.settings import Setting from rattail.util import import_module_path @@ -273,7 +273,8 @@ class AppSettingsView(View): value = ', '.join(entries) else: value = six.text_type(value) - api.save_setting(Session(), legacy_name, value) + app = self.get_rattail_app() + app.save_setting(Session(), legacy_name, value) def clean_list_entry(self, value): value = value.strip() From 927470db724b6211ff059376e02db9ce56552932 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 3 Aug 2022 15:15:49 -0500 Subject: [PATCH 056/978] Force cache invalidation when Raw Setting is edited only applies if caching is actually in use --- tailbone/views/settings.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index 6fc0e66f..da797bed 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -80,6 +80,12 @@ class SettingView(MasterView): return not bool(self.feedback.match(setting.name)) return True + def after_edit(self, setting): + # nb. force cache invalidation - normally this happens when a + # setting is saved via app handler, but here that is being + # bypassed and it is saved directly via standard ORM calls + self.rattail_config.beaker_invalidate_setting(setting.name) + def deletable_instance(self, setting): if self.rattail_config.demo(): return not bool(self.feedback.match(setting.name)) From ba8faacbd047098cba7c1ccf7c58e06d59190fe1 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 3 Aug 2022 16:58:06 -0500 Subject: [PATCH 057/978] 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 1107b43f..6946e2f6 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.8.238 (2022-08-03) +-------------------- + +* Improve "touch" logic for employees. + +* Stop using the old ``rattail.db.api.settings`` module. + +* Force cache invalidation when Raw Setting is edited. + + 0.8.237 (2022-07-27) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 0bf30000..1fd8fb0e 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.237' +__version__ = '0.8.238' From cd9004b32b58b2afe3807370b800686f662a9aa2 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 4 Aug 2022 08:14:04 -0500 Subject: [PATCH 058/978] Invalidate config cache when raw setting is deleted --- tailbone/views/settings.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index da797bed..eaebce93 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -91,6 +91,15 @@ class SettingView(MasterView): return not bool(self.feedback.match(setting.name)) return True + def delete_instance(self, setting): + + # nb. force cache invalidation + self.rattail_config.beaker_invalidate_setting(setting.name) + + # otherwise delete like normal + super(SettingView, self).delete_instance(setting) + + # TODO: deprecate / remove this SettingsView = SettingView From 9c31e92c0177d98e76c81181f6600ed8f95d83e6 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 4 Aug 2022 09:08:56 -0500 Subject: [PATCH 059/978] 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 6946e2f6..c48bb972 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.239 (2022-08-04) +-------------------- + +* Invalidate config cache when raw setting is deleted. + + 0.8.238 (2022-08-03) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 1fd8fb0e..1b94a8bc 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.238' +__version__ = '0.8.239' From 8776cd19dddc7dc8b20521230960f99887e53d79 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 5 Aug 2022 12:09:32 -0500 Subject: [PATCH 060/978] Clean up URL routes for row CRUD --- tailbone/views/master.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index a9e2110f..80af4ca1 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -4870,7 +4870,8 @@ class MasterView(View): # view row if cls.has_rows: if cls.rows_viewable: - config.add_route('{}.view_row'.format(route_prefix), '{}/{{uuid}}/rows/{{row_uuid}}'.format(url_prefix)) + config.add_route('{}.view_row'.format(route_prefix), + '{}/rows/{{row_uuid}}'.format(instance_url_prefix)) config.add_view(cls, attr='view_row', route_name='{}.view_row'.format(route_prefix), permission='{}.view'.format(permission_prefix)) @@ -4880,7 +4881,8 @@ class MasterView(View): config.add_tailbone_permission(permission_prefix, '{}.edit_row'.format(permission_prefix), "Edit individual {}".format(row_model_title_plural)) if cls.rows_editable: - config.add_route('{}.edit_row'.format(route_prefix), '{}/{{uuid}}/rows/{{row_uuid}}/edit'.format(url_prefix)) + config.add_route('{}.edit_row'.format(route_prefix), + '{}/rows/{{row_uuid}}/edit'.format(instance_url_prefix)) config.add_view(cls, attr='edit_row', route_name='{}.edit_row'.format(route_prefix), permission='{}.edit_row'.format(permission_prefix)) @@ -4889,6 +4891,7 @@ class MasterView(View): if cls.rows_deletable: config.add_tailbone_permission(permission_prefix, '{}.delete_row'.format(permission_prefix), "Delete individual {}".format(row_model_title_plural)) - config.add_route('{}.delete_row'.format(route_prefix), '{}/{{uuid}}/rows/{{row_uuid}}/delete'.format(url_prefix)) + config.add_route('{}.delete_row'.format(route_prefix), + '{}/rows/{{row_uuid}}/delete'.format(instance_url_prefix)) config.add_view(cls, attr='delete_row', route_name='{}.delete_row'.format(route_prefix), permission='{}.delete_row'.format(permission_prefix)) From 7d3f2e6bdf2722f485b227802b3249ebae1122b5 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 5 Aug 2022 13:28:47 -0500 Subject: [PATCH 061/978] 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 c48bb972..7012a8e2 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.240 (2022-08-05) +-------------------- + +* Clean up URL routes for row CRUD. + + 0.8.239 (2022-08-04) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 1b94a8bc..cf771da0 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.239' +__version__ = '0.8.240' From d52a186e1254fb31813189e8fba7337543d00030 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 6 Aug 2022 18:38:17 -0500 Subject: [PATCH 062/978] Add support for toggling visibility of email profile settings --- tailbone/grids/core.py | 6 + tailbone/templates/grids/buefy.mako | 13 +- tailbone/templates/master/index.mako | 2 +- tailbone/templates/settings/email/index.mako | 63 ++++++++++ tailbone/views/email.py | 122 ++++++++++++++++--- 5 files changed, 188 insertions(+), 18 deletions(-) create mode 100644 tailbone/templates/settings/email/index.mako diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 5360c894..b15dcafd 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -1492,6 +1492,12 @@ class GridAction(object): 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 diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako index 0801bbe8..11b9a86b 100644 --- a/tailbone/templates/grids/buefy.mako +++ b/tailbone/templates/grids/buefy.mako @@ -135,7 +135,7 @@
    - + ${action.render_icon()|n} ${action.render_label()|n}   @@ -296,15 +296,24 @@ let ${grid.component_studly} = { template: '#${grid.component}-template', + mixins: [FormPosterMixin], + props: { csrftoken: String, }, computed: { + // note, can use this with v-model for hidden 'uuids' fields selected_uuids: function() { return this.checkedRowUUIDs().join(',') }, + + // nb. this can be overridden if needed, e.g. to dynamically + // show/hide certain records in a static data set + visibleData() { + return this.data + }, }, methods: { diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako index 5830519b..053e09fb 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -481,7 +481,7 @@ <%def name="render_grid_component()"> - <${grid.component} :csrftoken="csrftoken" + <${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 diff --git a/tailbone/templates/settings/email/index.mako b/tailbone/templates/settings/email/index.mako new file mode 100644 index 00000000..11881285 --- /dev/null +++ b/tailbone/templates/settings/email/index.mako @@ -0,0 +1,63 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/index.mako" /> + +<%def name="render_grid_component()"> + % if master.has_perm('configure'): + + + + + + + + % endif + + ${parent.render_grid_component()} + + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + % if master.has_perm('configure'): + + % endif + + +${parent.body()} diff --git a/tailbone/views/email.py b/tailbone/views/email.py index 8a03f8a2..a6620932 100644 --- a/tailbone/views/email.py +++ b/tailbone/views/email.py @@ -27,6 +27,7 @@ Email Views from __future__ import unicode_literals, absolute_import import re +import warnings import six @@ -38,6 +39,7 @@ import colander from deform import widget as dfwidget from webhelpers2.html import HTML +from tailbone import grids from tailbone.db import Session from tailbone.views import View, MasterView @@ -63,6 +65,7 @@ class EmailSettingView(MasterView): 'subject', 'to', 'enabled', + 'hidden', ] form_fields = [ @@ -77,11 +80,19 @@ class EmailSettingView(MasterView): 'cc', 'bcc', 'enabled', + 'hidden', ] def __init__(self, request): super(EmailSettingView, self).__init__(request) - self.handler = self.get_handler() + self.email_handler = self.get_handler() + + @property + def handler(self): + warnings.warn("the `handler` property is deprecated! " + "please use `email_handler` instead", + DeprecationWarning, stacklevel=2) + return self.email_handler def get_handler(self): app = self.get_rattail_app() @@ -89,9 +100,12 @@ class EmailSettingView(MasterView): def get_data(self, session=None): data = [] - for email in self.handler.iter_emails(): - key = email.key or email.__name__ - email = email(self.rattail_config, key) + if self.has_perm('configure'): + emails = self.email_handler.get_all_emails() + else: + emails = self.email_handler.get_available_emails() + for key, Email in six.iteritems(emails): + email = Email(self.rattail_config, key) data.append(self.normalize(email)) return data @@ -112,6 +126,20 @@ class EmailSettingView(MasterView): 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') + + # toggle hidden + if self.has_perm('configure'): + g.main_actions.append( + self.make_action('toggle_hidden', url='#', icon='ban', + click_handler='toggleHidden(props.row)', + factory=ToggleHidden)) + def render_to_short(self, email, column): profile = email['_email'] if self.rattail_config.production(): @@ -133,7 +161,7 @@ class EmailSettingView(MasterView): if recips: return ', '.join(recips) data = email.obtain_sample_data(self.request) - return { + normal = { '_email': email, 'key': email.key, 'fallback_key': email.fallback_key, @@ -147,10 +175,13 @@ class EmailSettingView(MasterView): 'bcc': get_recips('bcc') or '', 'enabled': email.get_enabled(), } + if self.has_perm('configure'): + normal['hidden'] = self.email_handler.email_is_hidden(email.key) + return normal def get_instance(self): key = self.request.matchdict['key'] - return self.normalize(self.handler.get_email(key)) + return self.normalize(self.email_handler.get_email(key)) def get_instance_title(self, email): return email['_email'].get_complete_subject(render=False) @@ -207,8 +238,20 @@ class EmailSettingView(MasterView): # enabled f.set_type('enabled', 'boolean') + # hidden + if self.has_perm('configure'): + f.set_type('hidden', 'boolean') + else: + f.remove('hidden') + def make_form_schema(self): - return EmailProfileSchema() + schema = EmailProfileSchema() + + if not self.has_perm('configure'): + hidden = schema.get('hidden') + schema.children.remove(hidden) + + return schema def save_edit_form(self, form): key = self.request.matchdict['key'] @@ -223,11 +266,13 @@ class EmailSettingView(MasterView): app.save_setting(session, 'rattail.mail.{}.cc'.format(key), (data['cc'] or '').replace('\n', ', ')) app.save_setting(session, 'rattail.mail.{}.bcc'.format(key), (data['bcc'] or '').replace('\n', ', ')) app.save_setting(session, 'rattail.mail.{}.enabled'.format(key), six.text_type(data['enabled']).lower()) + if self.has_perm('configure'): + app.save_setting(session, 'rattail.mail.{}.hidden'.format(key), six.text_type(data['hidden']).lower()) return data def template_kwargs_view(self, **kwargs): key = self.request.matchdict['key'] - kwargs['email'] = self.handler.get_email(key) + kwargs['email'] = self.email_handler.get_email(key) return kwargs def configure_get_simple_settings(self): @@ -240,10 +285,48 @@ class EmailSettingView(MasterView): 'type': bool}, ] + def toggle_hidden(self): + app = self.get_rattail_app() + data = self.request.json_body + name = 'rattail.mail.{}.hidden'.format(data['key']) + app.save_setting(self.Session(), name, + 'true' if data['hidden'] else 'false') + return {'ok': True} + + @classmethod + def defaults(cls, config): + cls._email_defaults(config) + cls._defaults(config) + + @classmethod + def _email_defaults(cls, config): + route_prefix = cls.get_route_prefix() + url_prefix = cls.get_url_prefix() + permission_prefix = cls.get_permission_prefix() + model_title_plural = cls.get_model_title_plural() + + # toggle hidden + config.add_route('{}.toggle_hidden'.format(route_prefix), + '{}/toggle-hidden'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='toggle_hidden', + route_name='{}.toggle_hidden'.format(route_prefix), + permission='{}.configure'.format(permission_prefix), + renderer='json') + # TODO: deprecate / remove this ProfilesView = EmailSettingView +class ToggleHidden(grids.GridAction): + """ + Grid action for toggling the 'hidden' flag for an email profile. + """ + + def render_label(self): + return '{{ renderLabelToggleHidden(props.row) }}' + + class RecipientsType(colander.String): """ Custom schema type for email recipients. This is used to present the @@ -284,6 +367,8 @@ class EmailProfileSchema(colander.MappingSchema): enabled = colander.SchemaNode(colander.Boolean()) + hidden = colander.SchemaNode(colander.Boolean()) + class EmailPreview(View): """ @@ -292,7 +377,14 @@ class EmailPreview(View): def __init__(self, request): super(EmailPreview, self).__init__(request) - self.handler = self.get_handler() + self.email_handler = self.get_handler() + + @property + def handler(self): + warnings.warn("the `handler` property is deprecated! " + "please use `email_handler` instead", + DeprecationWarning, stacklevel=2) + return self.email_handler def get_handler(self): app = self.get_rattail_app() @@ -319,20 +411,20 @@ class EmailPreview(View): if recipient: key = self.request.POST.get('email_key') if key: - email = self.handler.get_email(key) + email = self.email_handler.get_email(key) data = email.obtain_sample_data(self.request) - self.handler.send_message(email, data, - subject_prefix="[PREVIEW] ", - to=[recipient], - cc=None, bcc=None) + self.email_handler.send_message(email, data, + subject_prefix="[PREVIEW] ", + to=[recipient], + cc=None, bcc=None) self.request.session.flash( "Preview for '{}' was emailed to {}".format( key, recipient)) def preview_template(self, key, type_): - email = self.handler.get_email(key) + email = self.email_handler.get_email(key) template = email.get_template(type_) data = email.obtain_sample_data(self.request) self.request.response.text = template.render(**data) From dd2631d27c7141bda7560acc2eb0ca99aeb8eaf4 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 6 Aug 2022 19:18:49 -0500 Subject: [PATCH 063/978] Only show "all" emails if config says to use the entry points otherwise traditional behavior needs to be preserved as the default, for now... --- tailbone/views/email.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/email.py b/tailbone/views/email.py index a6620932..b3135d6a 100644 --- a/tailbone/views/email.py +++ b/tailbone/views/email.py @@ -100,7 +100,7 @@ class EmailSettingView(MasterView): def get_data(self, session=None): data = [] - if self.has_perm('configure'): + if self.has_perm('configure') and self.email_handler.use_entry_points(): emails = self.email_handler.get_all_emails() else: emails = self.email_handler.get_available_emails() From d74025318ee0b4ee390e12a90dcea5f96d653159 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 6 Aug 2022 20:48:34 -0500 Subject: [PATCH 064/978] 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 7012a8e2..4c3d3272 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.241 (2022-08-06) +-------------------- + +* Add support for toggling visibility of email profile settings. + + 0.8.240 (2022-08-05) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index cf771da0..32b6fb8c 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.240' +__version__ = '0.8.241' From 1152fba06715ad7bbd23370aa03c43ed94c56694 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 6 Aug 2022 22:57:10 -0500 Subject: [PATCH 065/978] Always show "all" email settings if user has config perm also tweak view config, per newer convention --- tailbone/views/email.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tailbone/views/email.py b/tailbone/views/email.py index b3135d6a..3798639a 100644 --- a/tailbone/views/email.py +++ b/tailbone/views/email.py @@ -100,7 +100,7 @@ class EmailSettingView(MasterView): def get_data(self, session=None): data = [] - if self.has_perm('configure') and self.email_handler.use_entry_points(): + if self.has_perm('configure'): emails = self.email_handler.get_all_emails() else: emails = self.email_handler.get_available_emails() @@ -525,7 +525,18 @@ class EmailAttemptView(MasterView): f.set_enum('status_code', self.enum.EMAIL_ATTEMPT) -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + EmailSettingView = kwargs.get('EmailSettingView', base['EmailSettingView']) EmailSettingView.defaults(config) + + EmailPreview = kwargs.get('EmailPreview', base['EmailPreview']) EmailPreview.defaults(config) + + EmailAttemptView = kwargs.get('EmailAttemptView', base['EmailAttemptView']) EmailAttemptView.defaults(config) + + +def includeme(config): + defaults(config) From 172dbba8aaa47e44a105d00a00c9652a5866a0f5 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 7 Aug 2022 10:10:17 -0500 Subject: [PATCH 066/978] 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 4c3d3272..bc9e45a5 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.242 (2022-08-07) +-------------------- + +* Always show "all" email settings if user has config perm. + + 0.8.241 (2022-08-06) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 32b6fb8c..b9e968ed 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.241' +__version__ = '0.8.242' From 6352a6dc9aaa94f88e91f434f97e47d3b6853ded Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 7 Aug 2022 12:58:49 -0500 Subject: [PATCH 067/978] Add button to raise bogus error, for testing email alerts --- .../templates/settings/email/configure.mako | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tailbone/templates/settings/email/configure.mako b/tailbone/templates/settings/email/configure.mako index 1e2e86a0..31da4f8e 100644 --- a/tailbone/templates/settings/email/configure.mako +++ b/tailbone/templates/settings/email/configure.mako @@ -16,6 +16,57 @@
    + + % if request.has_perm('errors.bogus'): +

    Testing

    +
    + + +

    + You can raise a "bogus" error to test if/how it generates email: +

    + + {{ raisingBogusError ? "Working, please wait..." : "Raise Bogus Error" }} + +
    + +
    + + % endif + + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + % if request.has_perm('errors.bogus'): + + % endif From fe4c3d4942cdaf16a15f60527c8e8b67075a77ca Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 7 Aug 2022 18:23:15 -0500 Subject: [PATCH 068/978] Make sure "configure" pages use AppHandler to save/delete settings so that beaker config cache is invalidated, if in use --- tailbone/views/batch/vendorcatalog.py | 11 +++++++---- tailbone/views/importing.py | 20 +++++++++++++------- tailbone/views/master.py | 22 ++++++++++++---------- tailbone/views/trainwreck/base.py | 12 +++++++----- tailbone/views/vendors/core.py | 13 ++++++------- 5 files changed, 45 insertions(+), 33 deletions(-) diff --git a/tailbone/views/batch/vendorcatalog.py b/tailbone/views/batch/vendorcatalog.py index e630c57e..ba4d3482 100644 --- a/tailbone/views/batch/vendorcatalog.py +++ b/tailbone/views/batch/vendorcatalog.py @@ -436,15 +436,18 @@ class VendorCatalogView(FileBatchMasterView): def configure_remove_settings(self): super(VendorCatalogView, self).configure_remove_settings() - model = self.model + app = self.get_rattail_app() + names = [ 'rattail.vendors.supported_catalog_parsers', 'tailbone.batch.vendorcatalog.supported_parsers', # deprecated ] - Session().query(model.Setting)\ - .filter(model.Setting.name.in_(names))\ - .delete(synchronize_session=False) + # 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: + app.delete_setting(session, name) # TODO: deprecate / remove this diff --git a/tailbone/views/importing.py b/tailbone/views/importing.py index 2a660b08..a6126c9e 100644 --- a/tailbone/views/importing.py +++ b/tailbone/views/importing.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2021 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -576,13 +576,19 @@ cd {prefix} return settings def configure_remove_settings(self): + app = self.get_rattail_app() model = self.model - self.Session.query(model.Setting)\ - .filter(sa.or_( - model.Setting.name.like('rattail.importing.%.handler'), - model.Setting.name.like('rattail.importing.%.cmd'), - model.Setting.name.like('rattail.importing.%.runas')))\ - .delete(synchronize_session=False) + session = self.Session() + + to_delete = session.query(model.Setting)\ + .filter(sa.or_( + model.Setting.name.like('rattail.importing.%.handler'), + model.Setting.name.like('rattail.importing.%.cmd'), + model.Setting.name.like('rattail.importing.%.runas')))\ + .all() + + for setting in to_delete: + app.delete_setting(session, setting) @classmethod def defaults(cls, config): diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 80af4ca1..610c2c2e 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -4482,6 +4482,7 @@ class MasterView(View): def configure_remove_settings(self, simple_settings=None, input_file_templates=True): + app = self.get_rattail_app() model = self.model names = [] @@ -4500,20 +4501,21 @@ class MasterView(View): ]) if names: - # nb. we do not use self.Session b/c that may not point to - # the Rattail DB for the subclass - Session().query(model.Setting)\ - .filter(model.Setting.name.in_(names))\ - .delete(synchronize_session=False) + # 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: + app.delete_setting(session, name) def configure_save_settings(self, settings): - model = self.model - # nb. we do not use self.Session b/c that may not point to the - # Rattail DB for the subclass + app = self.get_rattail_app() + + # nb. using thread-local session here; we do not use + # self.Session b/c it may not point to Rattail session = Session() for setting in settings: - session.add(model.Setting(name=setting['name'], - value=setting['value'])) + app.save_setting(session, setting['name'], setting['value'], + force_create=True) ############################## # Pyramid View Config diff --git a/tailbone/views/trainwreck/base.py b/tailbone/views/trainwreck/base.py index 43b52657..163d17b0 100644 --- a/tailbone/views/trainwreck/base.py +++ b/tailbone/views/trainwreck/base.py @@ -417,16 +417,18 @@ class TransactionView(MasterView): def configure_remove_settings(self): super(TransactionView, self).configure_remove_settings() + app = self.get_rattail_app() - model = self.model names = [ 'trainwreck.db.hide', 'tailbone.engines.trainwreck.hidden', # deprecated ] - # nb. we do not use self.Session b/c that points to trainwreck - Session.query(model.Setting)\ - .filter(model.Setting.name.in_(names))\ - .delete(synchronize_session=False) + + # 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: + app.delete_setting(session, name) @classmethod def defaults(cls, config): diff --git a/tailbone/views/vendors/core.py b/tailbone/views/vendors/core.py index 63da1ca9..87b2de75 100644 --- a/tailbone/views/vendors/core.py +++ b/tailbone/views/vendors/core.py @@ -202,8 +202,7 @@ class VendorView(MasterView): def configure_remove_settings(self, **kwargs): super(VendorView, self).configure_remove_settings(**kwargs) - - model = self.model + app = self.get_rattail_app() names = [] supported_vendor_settings = self.configure_get_supported_vendor_settings() @@ -211,11 +210,11 @@ class VendorView(MasterView): names.append('rattail.vendor.{}'.format(setting['key'])) if names: - # nb. we do not use self.Session b/c that may not point to - # the Rattail DB for the subclass - Session().query(model.Setting)\ - .filter(model.Setting.name.in_(names))\ - .delete(synchronize_session=False) + # 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: + app.delete_setting(session, name) def configure_get_supported_vendor_settings(self): app = self.get_rattail_app() From 3413d7c6f6b2afda438d0c4007c89450912dd70f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 7 Aug 2022 18:45:45 -0500 Subject: [PATCH 069/978] Expose setting for sendmail failure alerts --- .../templates/settings/email/configure.mako | 9 ++++++++ tailbone/views/email.py | 23 ++++++++++++------- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/tailbone/templates/settings/email/configure.mako b/tailbone/templates/settings/email/configure.mako index 31da4f8e..13bceb3e 100644 --- a/tailbone/templates/settings/email/configure.mako +++ b/tailbone/templates/settings/email/configure.mako @@ -15,6 +15,15 @@ + + + When sending an email fails, send another to report the failure + + + % if request.has_perm('errors.bogus'): diff --git a/tailbone/views/email.py b/tailbone/views/email.py index 3798639a..d381907d 100644 --- a/tailbone/views/email.py +++ b/tailbone/views/email.py @@ -34,6 +34,7 @@ import six from rattail import mail from rattail.db import model from rattail.config import parse_list +from rattail.util import simple_error import colander from deform import widget as dfwidget @@ -283,6 +284,9 @@ class EmailSettingView(MasterView): {'section': 'rattail.mail', 'option': 'record_attempts', 'type': bool}, + {'section': 'rattail.mail', + 'option': 'send_email_on_failure', + 'type': bool}, ] def toggle_hidden(self): @@ -414,14 +418,17 @@ class EmailPreview(View): email = self.email_handler.get_email(key) data = email.obtain_sample_data(self.request) - self.email_handler.send_message(email, data, - subject_prefix="[PREVIEW] ", - to=[recipient], - cc=None, bcc=None) - - self.request.session.flash( - "Preview for '{}' was emailed to {}".format( - key, recipient)) + try: + self.email_handler.send_message(email, data, + subject_prefix="[PREVIEW] ", + to=[recipient], + cc=None, bcc=None) + except Exception as error: + self.request.session.flash(simple_error(error), 'error') + else: + self.request.session.flash( + "Preview for '{}' was emailed to {}".format( + key, recipient)) def preview_template(self, key, type_): email = self.email_handler.get_email(key) From 903afc111e28af44705959a48b9dedbf66c83df7 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 8 Aug 2022 09:42:54 -0500 Subject: [PATCH 070/978] 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 bc9e45a5..6cc79a2a 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.8.243 (2022-08-08) +-------------------- + +* Add button to raise bogus error, for testing email alerts. + +* Make sure "configure" pages use AppHandler to save/delete settings. + +* Expose setting for sendmail failure alerts. + + 0.8.242 (2022-08-07) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index b9e968ed..38d2ae19 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.242' +__version__ = '0.8.243' From a999b996fbe907dca02a15caf881d4399e652ea6 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 8 Aug 2022 14:39:26 -0500 Subject: [PATCH 071/978] Add separate product grid filters for Category Code, Category Name this also fixes a join bug in some edge cases --- tailbone/views/products.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 33999781..a9376faf 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -225,6 +225,7 @@ class ProductView(MasterView): def configure_grid(self, g): super(ProductView, self).configure_grid(g) app = self.get_rattail_app() + model = self.model use_buefy = self.get_use_buefy() def join_vendor(q): @@ -335,8 +336,16 @@ class ProductView(MasterView): g.set_label('vendor_code_any', "Vendor Code (any)") # category - g.set_joiner('category', lambda q: q.outerjoin(model.Category)) - g.set_filter('category', model.Category.name) + CategoryByCode = orm.aliased(model.Category) + CategoryByName = orm.aliased(model.Category) + g.set_joiner('category_code', + lambda q: q.outerjoin(CategoryByCode, + CategoryByCode.uuid == model.Product.category_uuid)) + g.set_filter('category_code', CategoryByCode.code) + g.set_joiner('category_name', + lambda q: q.outerjoin(CategoryByName, + CategoryByName.uuid == model.Product.category_uuid)) + g.set_filter('category_name', CategoryByName.name) # family g.set_joiner('family', lambda q: q.outerjoin(model.Family)) From 5334cf1871620b23394e22eab07b1227a3c23100 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 8 Aug 2022 18:13:34 -0500 Subject: [PATCH 072/978] 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 6cc79a2a..e4dcff1a 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.244 (2022-08-08) +-------------------- + +* Add separate product grid filters for Category Code, Category Name. + + 0.8.243 (2022-08-08) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 38d2ae19..a8b40f0c 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.243' +__version__ = '0.8.244' From d6aeb1d10f5c9e28ec2f8655421d2dfe3cfd86d9 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 8 Aug 2022 23:34:40 -0500 Subject: [PATCH 073/978] Add convenience wrapper to make customer field widget, etc. customer widget is either autocomplete or dropdown, per config also added a way to pass arbitrary kwargs to the chameleon template rendering for a field also moved the logic for rendering a out of the template and into the Form class also start to prefer `input_handler` over `input_callback` when specifying client-side JS hook --- tailbone/forms/core.py | 71 ++++++++++++++- tailbone/forms/widgets.py | 89 +++++++++++++++++++ tailbone/templates/customers/configure.mako | 14 +++ .../templates/deform/autocomplete_jquery.pt | 2 +- tailbone/templates/forms/deform_buefy.mako | 3 +- tailbone/templates/forms/util.mako | 26 +----- tailbone/views/customers.py | 5 ++ 7 files changed, 183 insertions(+), 27 deletions(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 7278cd2b..14703eb7 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -332,7 +332,7 @@ class Form(object): def __init__(self, fields=None, schema=None, request=None, readonly=False, readonly_fields=[], model_instance=None, model_class=None, appstruct=UNSPECIFIED, nodes={}, enums={}, labels={}, - assume_local_times=False, renderers=None, + assume_local_times=False, renderers=None, renderer_kwargs={}, hidden={}, widgets={}, defaults={}, validators={}, required={}, helptext={}, focus_spec=None, action_url=None, cancel_url=None, use_buefy=None, component='tailbone-form', vuejs_field_converters={}, @@ -361,6 +361,7 @@ class Form(object): self.renderers = self.make_renderers() else: self.renderers = renderers or {} + self.renderer_kwargs = renderer_kwargs or {} self.hidden = hidden or {} self.widgets = widgets or {} self.defaults = defaults or {} @@ -660,6 +661,22 @@ class Form(object): else: self.renderers[key] = renderer + def add_renderer_kwargs(self, key, kwargs): + self.renderer_kwargs.setdefault(key, {}).update(kwargs) + + def get_renderer_kwargs(self, key): + return self.renderer_kwargs.get(key, {}) + + def set_renderer_kwargs(self, key, kwargs): + self.renderer_kwargs[key] = kwargs + + def set_input_handler(self, key, value): + """ + Convenience method to assign "input handler" callback code for + the given field. + """ + self.add_renderer_kwargs(key, {'input_handler': value}) + def set_hidden(self, key, hidden=True): self.hidden[key] = hidden @@ -858,6 +875,58 @@ class Form(object): return False return True + def render_buefy_field(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. + """ + dform = self.make_deform_form() + field = dform[fieldname] + + if self.field_visible(fieldname): + + # these attrs will be for the (*not* the widget) + attrs = { + ':horizontal': 'true', + 'label': self.get_label(fieldname), + } + + # add some magic for file input fields + if isinstance(field.schema.typ, deform.FileData): + attrs['class_'] = 'file' + + # show helptext if present + if self.has_helptext(fieldname): + attrs['message'] = self.render_helptext(fieldname) + + # show errors if present + error_messages = self.get_error_messages(field) + if error_messages: + attrs.update({ + 'type': 'is-danger', + # ':message': self.messages_json(error_messages), + ':message': error_messages, + }) + + # merge anything caller provided + attrs.update(bfield_attrs) + + # render the field widget or whatever + html = field.serialize(use_buefy=True, + **self.get_renderer_kwargs(fieldname)) + # TODO: why do we not get HTML literal from serialize() ? + html = HTML.literal(html) + + # and finally wrap it all in a + return HTML.tag('b-field', c=[html], **attrs) + + else: # hidden field + + # can just do normal thing for these + return field.serialize() + def render_field_readonly(self, field_name, **kwargs): """ Render the given field completely, but in read-only fashion. diff --git a/tailbone/forms/widgets.py b/tailbone/forms/widgets.py index 91b6cb32..e72ab6b9 100644 --- a/tailbone/forms/widgets.py +++ b/tailbone/forms/widgets.py @@ -289,6 +289,95 @@ class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget): return field.renderer(template, **tmpl_values) +def make_customer_widget(request, **kwargs): + """ + Make a customer widget; will be either autocomplete or dropdown + depending on config. + """ + # use autocomplete widget by default + factory = CustomerAutocompleteWidget + + # caller may request dropdown widget + if kwargs.pop('dropdown', False): + factory = CustomerDropdownWidget + + else: # or, config may say to use dropdown + if request.rattail_config.getbool( + 'rattail', 'customers.choice_uses_dropdown', + default=False): + factory = CustomerDropdownWidget + + # instantiate whichever + return factory(request, **kwargs) + + +class CustomerAutocompleteWidget(JQueryAutocompleteWidget): + """ + Autocomplete widget for a Customer reference field. + """ + + def __init__(self, request, *args, **kwargs): + super(CustomerAutocompleteWidget, self).__init__(*args, **kwargs) + self.request = request + model = self.request.rattail_config.get_model() + + # must figure out URL providing autocomplete service + if 'service_url' not in kwargs: + + # caller can just pass 'url' instead of 'service_url' + if 'url' in kwargs: + self.service_url = kwargs['url'] + + else: # use default url + self.service_url = self.request.route_url('customers.autocomplete') + + # TODO + if 'input_callback' not in kwargs: + if 'input_handler' in kwargs: + 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() + customer = Session.query(model.Customer).get(cstruct) + if customer: + self.field_display = six.text_type(customer) + + return super(CustomerAutocompleteWidget, self).serialize( + field, cstruct, **kw) + + +class CustomerDropdownWidget(dfwidget.SelectWidget): + """ + Dropdown widget for a Customer reference field. + """ + + def __init__(self, request, *args, **kwargs): + super(CustomerDropdownWidget, self).__init__(*args, **kwargs) + self.request = request + + # must figure out dropdown values, if they weren't given + if 'values' not in kwargs: + + # use what caller gave us, if they did + if 'customers' in kwargs: + customers = kwargs['customers'] + if callable(customers): + customers = customers() + + else: # default customer list + model = self.request.rattail_config.get_model() + customers = Session.query(model.Customer)\ + .order_by(model.Customer.name)\ + .all() + + # convert customer list to option values + self.values = [(c.uuid, c.name) + for c in customers] + + class DepartmentWidget(dfwidget.SelectWidget): """ Custom select widget for a Department reference field. diff --git a/tailbone/templates/customers/configure.mako b/tailbone/templates/customers/configure.mako index 13093a7b..f465fdf5 100644 --- a/tailbone/templates/customers/configure.mako +++ b/tailbone/templates/customers/configure.mako @@ -3,6 +3,20 @@ <%def name="form_content()"> +

    General

    +
    + + + + Show customer chooser as dropdown (select) element + + + +
    +

    POS

    diff --git a/tailbone/templates/deform/autocomplete_jquery.pt b/tailbone/templates/deform/autocomplete_jquery.pt index 4ebc17b2..dd9a6084 100644 --- a/tailbone/templates/deform/autocomplete_jquery.pt +++ b/tailbone/templates/deform/autocomplete_jquery.pt @@ -113,7 +113,7 @@ v-model="${vmodel}" initial-label="${field_display}" tal:attributes=":assigned-label assigned_label or 'null'; - @input input_callback|''; + @input input_handler|input_callback|''; @new-label new_label_callback|'';"> diff --git a/tailbone/templates/forms/deform_buefy.mako b/tailbone/templates/forms/deform_buefy.mako index a26c946a..860449fb 100644 --- a/tailbone/templates/forms/deform_buefy.mako +++ b/tailbone/templates/forms/deform_buefy.mako @@ -1,5 +1,4 @@ ## -*- coding: utf-8; -*- -<%namespace file="/forms/util.mako" import="render_buefy_field" /> + + + +${parent.body()} diff --git a/tailbone/views/workorders.py b/tailbone/views/workorders.py new file mode 100644 index 00000000..dff57e96 --- /dev/null +++ b/tailbone/views/workorders.py @@ -0,0 +1,419 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2022 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 . +# +################################################################################ +""" +Work Order Views +""" + +from __future__ import unicode_literals, absolute_import + +import sqlalchemy as sa + +from rattail.db.model import WorkOrder, WorkOrderEvent + +from webhelpers2.html import HTML + +from tailbone import forms, grids +from tailbone.views import MasterView + + +class WorkOrderView(MasterView): + """ + Master view for work orders + """ + model_class = WorkOrder + route_prefix = 'workorders' + url_prefix = '/workorders' + bulk_deletable = True + + labels = { + 'id': "ID", + 'status_code': "Status", + } + + grid_columns = [ + 'id', + 'customer', + 'date_received', + 'date_released', + 'status_code', + ] + + form_fields = [ + 'id', + 'customer', + 'notes', + 'date_submitted', + 'date_received', + 'date_released', + 'date_delivered', + 'status_code', + ] + + has_rows = True + model_row_class = WorkOrderEvent + rows_viewable = False + + row_labels = { + 'type_code': "Event Type", + } + + row_grid_columns = [ + 'type_code', + 'occurred', + 'user', + 'note', + ] + + def __init__(self, request): + super(WorkOrderView, self).__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) + model = self.model + + # customer + g.set_joiner('customer', lambda q: q.join(model.Customer)) + g.set_sorter('customer', model.Customer.name) + g.set_filter('customer', model.Customer.name) + + # status + g.set_filter('status_code', model.WorkOrder.status_code, + factory=StatusFilter, + default_active=True, + default_verb='is_active') + g.set_enum('status_code', self.enum.WORKORDER_STATUS) + + g.set_sort_defaults('id', 'desc') + + g.set_link('id') + g.set_link('customer') + + def grid_extra_class(self, workorder, i): + if workorder.status_code == self.enum.WORKORDER_STATUS_CANCELED: + return 'warning' + + def configure_form(self, f): + super(WorkOrderView, self).configure_form(f) + model = self.model + use_buefy = self.get_use_buefy() + SelectWidget = forms.widgets.JQuerySelectWidget + + # id + if self.creating: + f.remove_field('id') + else: + f.set_readonly('id') + + # customer + if self.creating: + f.replace('customer', 'customer_uuid') + f.set_label('customer_uuid', "Customer") + f.set_widget('customer_uuid', + forms.widgets.make_customer_widget(self.request)) + f.set_input_handler('customer_uuid', 'customerChanged') + else: + f.set_readonly('customer') + f.set_renderer('customer', self.render_customer) + + # notes + f.set_type('notes', 'text') + + # status_code + if self.creating: + f.remove('status_code') + else: + f.set_enum('status_code', self.enum.WORKORDER_STATUS) + f.set_renderer('status_code', self.render_status_code) + if not self.has_perm('edit_status'): + f.set_readonly('status_code') + + # date fields + f.set_type('date_submitted', 'date_jquery') + f.set_type('date_received', 'date_jquery') + f.set_type('date_released', 'date_jquery') + f.set_type('date_delivered', 'date_jquery') + if self.creating: + f.remove('date_submitted', + 'date_received', + 'date_released', + 'date_delivered') + elif not self.has_perm('edit_status'): + f.set_readonly('date_submitted') + f.set_readonly('date_received') + f.set_readonly('date_released') + f.set_readonly('date_delivered') + + def objectify(self, form, data=None): + """ + Supplements the default logic as follows: + + If creating a new Work Order, will automatically set its status to + "submitted" and its ``date_submitted`` to the current date. + """ + if data is None: + data = form.validated + + # first let deform do its thing. if editing, this will update + # the record like we want. but if creating, this will + # populate the initial object *without* adding it to session, + # which is also what we want, so that we can "replace" the new + # object with one the handler creates, below + workorder = form.schema.objectify(data, context=form.model_instance) + + if self.creating: + + # now make the "real" work order + data = dict([(key, getattr(workorder, key)) + for key in data]) + workorder = self.workorder_handler.make_workorder(self.Session(), **data) + + return workorder + + def render_status_code(self, obj, field): + status_code = getattr(obj, field) + if status_code is None: + return "" + if status_code in self.enum.WORKORDER_STATUS: + text = self.enum.WORKORDER_STATUS[status_code] + if status_code == self.enum.WORKORDER_STATUS_CANCELED: + use_buefy = self.get_use_buefy() + if use_buefy: + return HTML.tag('span', class_='has-text-danger', c=text) + else: + return HTML.tag('span', style='color: red;', c=text) + return text + return str(status_code) + + def get_row_data(self, workorder): + model = self.model + return self.Session.query(model.WorkOrderEvent)\ + .filter(model.WorkOrderEvent.workorder == workorder) + + def get_parent(self, event): + return event.workorder + + def configure_row_grid(self, g): + super(WorkOrderView, self).configure_row_grid(g) + g.set_enum('type_code', self.enum.WORKORDER_EVENT) + g.set_sort_defaults('occurred') + + def receive(self): + """ + Sets work order status to "received". + """ + workorder = self.get_instance() + self.workorder_handler.receive(workorder) + self.Session.flush() + return self.redirect(self.get_action_url('view', workorder)) + + def await_estimate(self): + """ + Sets work order status to "awaiting estimate confirmation". + """ + workorder = self.get_instance() + self.workorder_handler.await_estimate(workorder) + self.Session.flush() + return self.redirect(self.get_action_url('view', workorder)) + + def await_parts(self): + """ + Sets work order status to "awaiting parts". + """ + workorder = self.get_instance() + self.workorder_handler.await_parts(workorder) + self.Session.flush() + return self.redirect(self.get_action_url('view', workorder)) + + def work_on_it(self): + """ + Sets work order status to "working on it". + """ + workorder = self.get_instance() + self.workorder_handler.work_on_it(workorder) + self.Session.flush() + return self.redirect(self.get_action_url('view', workorder)) + + def release(self): + """ + Sets work order status to "released". + """ + workorder = self.get_instance() + self.workorder_handler.release(workorder) + self.Session.flush() + return self.redirect(self.get_action_url('view', workorder)) + + def deliver(self): + """ + Sets work order status to "delivered". + """ + workorder = self.get_instance() + self.workorder_handler.deliver(workorder) + self.Session.flush() + return self.redirect(self.get_action_url('view', workorder)) + + def cancel(self): + """ + Sets work order status to "canceled". + """ + workorder = self.get_instance() + self.workorder_handler.cancel(workorder) + self.Session.flush() + return self.redirect(self.get_action_url('view', workorder)) + + @classmethod + def defaults(cls, config): + cls._defaults(config) + cls._workorder_defaults(config) + + @classmethod + def _workorder_defaults(cls, config): + route_prefix = cls.get_route_prefix() + permission_prefix = cls.get_permission_prefix() + instance_url_prefix = cls.get_instance_url_prefix() + model_title = cls.get_model_title() + + # perm for editing status + config.add_tailbone_permission( + permission_prefix, + '{}.edit_status'.format(permission_prefix), + "Directly edit status and related fields for {}".format(model_title)) + + # receive + config.add_route('{}.receive'.format(route_prefix), + '{}/receive'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='receive', + route_name='{}.receive'.format(route_prefix), + permission='{}.edit'.format(permission_prefix)) + + # await_estimate + config.add_route('{}.await_estimate'.format(route_prefix), + '{}/await-estimate'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='await_estimate', + route_name='{}.await_estimate'.format(route_prefix), + permission='{}.edit'.format(permission_prefix)) + + # await_parts + config.add_route('{}.await_parts'.format(route_prefix), + '{}/await-parts'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='await_parts', + route_name='{}.await_parts'.format(route_prefix), + permission='{}.edit'.format(permission_prefix)) + + # work_on_it + config.add_route('{}.work_on_it'.format(route_prefix), + '{}/work-on-it'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='work_on_it', + route_name='{}.work_on_it'.format(route_prefix), + permission='{}.edit'.format(permission_prefix)) + + # release + config.add_route('{}.release'.format(route_prefix), + '{}/release'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='release', + route_name='{}.release'.format(route_prefix), + permission='{}.edit'.format(permission_prefix)) + + # deliver + config.add_route('{}.deliver'.format(route_prefix), + '{}/deliver'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='deliver', + route_name='{}.deliver'.format(route_prefix), + permission='{}.edit'.format(permission_prefix)) + + # cancel + config.add_route('{}.cancel'.format(route_prefix), + '{}/cancel'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='cancel', + route_name='{}.cancel'.format(route_prefix), + permission='{}.edit'.format(permission_prefix)) + + +class StatusFilter(grids.filters.AlchemyIntegerFilter): + + def __init__(self, *args, **kwargs): + super(StatusFilter, self).__init__(*args, **kwargs) + + from drild import enum + + self.active_status_codes = [ + # enum.WORKORDER_STATUS_CREATED, + enum.WORKORDER_STATUS_SUBMITTED, + enum.WORKORDER_STATUS_RECEIVED, + enum.WORKORDER_STATUS_PENDING_ESTIMATE, + enum.WORKORDER_STATUS_WAITING_FOR_PARTS, + enum.WORKORDER_STATUS_WORKING_ON_IT, + enum.WORKORDER_STATUS_RELEASED, + ] + + @property + def verb_labels(self): + labels = dict(super(StatusFilter, self).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.extend([ + 'is_active', + 'not_active', + ]) + return verbs + + @property + def default_verbs(self): + verbs = list(super(StatusFilter, self).default_verbs) + verbs.insert(0, 'is_active') + verbs.insert(1, 'not_active') + return verbs + + def filter_is_active(self, query, value): + return query.filter( + WorkOrder.status_code.in_(self.active_status_codes)) + + def filter_not_active(self, query, value): + return query.filter(sa.or_( + ~WorkOrder.status_code.in_(self.active_status_codes), + WorkOrder.status_code == None, + )) + + +def defaults(config, **kwargs): + base = globals() + + WorkOrderView = kwargs.get('WorkOrderView', base['WorkOrderView']) + WorkOrderView.defaults(config) + + +def includeme(config): + defaults(config) From 0e8f383c14c26dc6f8ece6a4b2029ec17c81e76a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 9 Aug 2022 23:26:41 -0500 Subject: [PATCH 079/978] Fix sequence of events re: grid component creation somehow if the master view template had rows, the Delete Results button was not working. not clear when that problem started?! but this seemed to be the correct fix --- tailbone/templates/master/view.mako | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako index 17a4f852..32176712 100644 --- a/tailbone/templates/master/view.mako +++ b/tailbone/templates/master/view.mako @@ -122,7 +122,8 @@ ${parent.render_this_page_template()} -<%def name="make_this_page_component()"> +<%def name="finalize_this_page_vars()"> + ${parent.finalize_this_page_vars()} % if master.has_rows: % endif - ${parent.make_this_page_component()} From 51aeb50d39e4e38970b11589dd93357c8f22a395 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 10 Aug 2022 18:55:59 -0500 Subject: [PATCH 080/978] Allow download results for Customers grid --- tailbone/views/customers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index 84d53925..a905ea07 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -47,6 +47,7 @@ class CustomerView(MasterView): model_class = model.Customer is_contact = True has_versions = True + results_downloadable = True people_detachable = True touchable = True supports_autocomplete = True From 8d70107b5d6bb1eec620eb9ba9f6fc7626c17cd5 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 10 Aug 2022 18:58:18 -0500 Subject: [PATCH 081/978] 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 e4dcff1a..d43ff74d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,24 @@ CHANGELOG ========= +0.8.245 (2022-08-10) +-------------------- + +* Add convenience wrapper to make customer field widget, etc.. + +* Some API tweaks to support a byjove app. + +* Tweak flash msg, logging when batch population fails. + +* Log traceback output when batch action subprocess fails. + +* Add initial views for work orders. + +* Fix sequence of events re: grid component creation. + +* Allow download results for Customers grid. + + 0.8.244 (2022-08-08) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index a8b40f0c..dbae26e8 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.244' +__version__ = '0.8.245' From 4c29a667cb5d30cbb247988aea6f9c20ce37d7ca Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 11 Aug 2022 00:15:12 -0500 Subject: [PATCH 082/978] Couple of API tweaks for work orders made a change to sorting such that it assumes the primary model is being sorted, if caller does not specify --- tailbone/api/master.py | 11 ++++------- tailbone/api/workorders.py | 8 +++++++- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/tailbone/api/master.py b/tailbone/api/master.py index 27030f5b..7cb911be 100644 --- a/tailbone/api/master.py +++ b/tailbone/api/master.py @@ -177,17 +177,14 @@ class APIMasterView(APIView): """ return self.sortcol(order_by) - def sortcol(self, *args): + def sortcol(self, field_name, model_name=None): """ Return a simple ``SortColumn`` object which denotes the field and optionally, the model, to be used when sorting. """ - if len(args) == 1: - return SortColumn(args[0]) - elif len(args) == 2: - return SortColumn(args[1], args[0]) - else: - raise ValueError("must pass 1 arg (field_name) or 2 args (model_name, field_name)") + if not model_name: + model_name = self.model_class.__name__ + return SortColumn(field_name, model_name) def join_for_sort_spec(self, query, sort_spec): """ diff --git a/tailbone/api/workorders.py b/tailbone/api/workorders.py index 315a92bb..d559589d 100644 --- a/tailbone/api/workorders.py +++ b/tailbone/api/workorders.py @@ -66,6 +66,12 @@ class WorkOrderView(APIMasterView): 'date_delivered': six.text_type(workorder.date_delivered or ''), } + def create_object(self, data): + + # invoke the handler instead of normal API CRUD logic + workorder = self.workorder_handler.make_workorder(self.Session(), **data) + return workorder + def update_object(self, workorder, data): date_fields = [ 'date_submitted', @@ -79,7 +85,7 @@ class WorkOrderView(APIMasterView): if field in data: if data[field] == '': data[field] = None - else: + elif not isinstance(data[field], datetime.date): date = datetime.datetime.strptime(data[field], '%Y-%m-%d').date() data[field] = date From 409a49ba200be04f1e4ec779e4581a07703e6f1e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 12 Aug 2022 14:27:26 -0500 Subject: [PATCH 083/978] Standardize merge logic when a handler is defined for it also adds basic merge support for products view --- tailbone/views/master.py | 33 ++++++++++++++++++++++++++++++++- tailbone/views/people.py | 32 +++++--------------------------- tailbone/views/products.py | 2 ++ 3 files changed, 39 insertions(+), 28 deletions(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 610c2c2e..1915ac83 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -108,6 +108,7 @@ class MasterView(View): supports_set_enabled_toggle = False populatable = False mergeable = False + merge_handler = None downloadable = False cloneable = False touchable = False @@ -1931,17 +1932,34 @@ class MasterView(View): def get_merge_fields(self): if hasattr(self, 'merge_fields'): return self.merge_fields + + if self.merge_handler: + fields = self.merge_handler.get_merge_preview_fields() + return [field['name'] for field in fields] + mapper = orm.class_mapper(self.get_model_class()) return mapper.columns.keys() def get_merge_coalesce_fields(self): if hasattr(self, 'merge_coalesce_fields'): return self.merge_coalesce_fields + + if self.merge_handler: + fields = self.merge_handler.get_merge_preview_fields() + return [field['name'] for field in fields + if field.get('coalesce')] + return [] def get_merge_additive_fields(self): if hasattr(self, 'merge_additive_fields'): return self.merge_additive_fields + + if self.merge_handler: + fields = self.merge_handler.get_merge_preview_fields() + return [field['name'] for field in fields + if field.get('additive')] + return [] def merge(self): @@ -1985,8 +2003,15 @@ class MasterView(View): the requested merge is valid, in your context. If it is not - for *any reason* - you should raise an exception; the type does not matter. """ + if self.merge_handler: + reason = self.merge_handler.why_not_merge(removing, keeping) + if reason: + raise Exception(reason) def get_merge_data(self, obj): + if self.merge_handler: + return self.merge_handler.get_merge_preview_data(obj) + raise NotImplementedError("please implement `{}.get_merge_data()`".format(self.__class__.__name__)) def get_merge_resulting_data(self, remove, keep): @@ -2008,7 +2033,13 @@ class MasterView(View): Merge the two given objects. You should probably override this; default behavior is merely to delete the 'removing' object. """ - self.Session.delete(removing) + if self.merge_handler: + self.merge_handler.perform_merge(removing, keeping, + user=self.request.user) + + else: + # nb. default "merge" does not update kept object! + self.Session.delete(removing) ############################## # Core Stuff diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 55f35927..5dc76b73 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -95,10 +95,13 @@ class PersonView(MasterView): def __init__(self, request): super(PersonView, self).__init__(request) + app = self.get_rattail_app() # always get a reference to the People Handler - app = self.get_rattail_app() - self.handler = app.get_people_handler() + self.people_handler = app.get_people_handler() + self.merge_handler = self.people_handler + # TODO: deprecate / remove this + self.handler = self.people_handler def make_grid_kwargs(self, **kwargs): kwargs = super(PersonView, self).make_grid_kwargs(**kwargs) @@ -396,31 +399,6 @@ class PersonView(MasterView): (model.VendorContact, 'person_uuid'), ] - def get_merge_fields(self): - fields = self.handler.get_merge_preview_fields() - return [field['name'] for field in fields] - - def get_merge_additive_fields(self): - fields = self.handler.get_merge_preview_fields() - return [field['name'] for field in fields - if field.get('additive')] - - def get_merge_coalesce_fields(self): - fields = self.handler.get_merge_preview_fields() - return [field['name'] for field in fields - if field.get('coalesce')] - - def get_merge_data(self, person): - return self.handler.get_merge_preview_data(person) - - def validate_merge(self, removing, keeping): - reason = self.handler.why_not_merge(removing, keeping) - if reason: - raise Exception(reason) - - def merge_objects(self, removing, keeping): - self.handler.perform_merge(removing, keeping, user=self.request.user) - def view_profile(self): """ View which exposes the "full profile" for a given person, i.e. all diff --git a/tailbone/views/products.py b/tailbone/views/products.py index a9376faf..8f1ea545 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -83,6 +83,7 @@ class ProductView(MasterView): has_versions = True results_downloadable_xlsx = True supports_autocomplete = True + mergeable = True configurable = True labels = { @@ -180,6 +181,7 @@ class ProductView(MasterView): app = self.get_rattail_app() self.products_handler = app.get_products_handler() + self.merge_handler = self.products_handler # TODO: deprecate / remove these self.product_handler = self.products_handler self.handler = self.products_handler From d5a9aa69255396eebf325714e9131df52f7452f0 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 12 Aug 2022 18:29:46 -0500 Subject: [PATCH 084/978] 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 d43ff74d..0543e130 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.246 (2022-08-12) +-------------------- + +* Couple of API tweaks for work orders. + +* Standardize merge logic when a handler is defined for it. + + 0.8.245 (2022-08-10) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index dbae26e8..fafaab99 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.245' +__version__ = '0.8.246' From e49a31df6ac73fd48b7799b2359cde73eaee041f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 12 Aug 2022 19:47:25 -0500 Subject: [PATCH 085/978] Avoid double-quotes in field error messages JS code --- tailbone/forms/core.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index bd939272..ac17c1b4 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -904,10 +904,19 @@ class Form(object): # show errors if present error_messages = self.get_error_messages(field) if 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([ + "'{}'".format(msg.replace("'", r"\'")) + for msg in error_messages])) + attrs.update({ 'type': 'is-danger', - # ':message': self.messages_json(error_messages), - ':message': error_messages, + ':message': message, }) # merge anything caller provided From 2388ab88b65b96ec73886776244c6403154086f1 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 12 Aug 2022 20:47:32 -0500 Subject: [PATCH 086/978] Add the FormPosterMixin to ProfileInfo component --- tailbone/templates/people/view_profile_buefy.mako | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako index 766ca5f1..cf665da9 100644 --- a/tailbone/templates/people/view_profile_buefy.mako +++ b/tailbone/templates/people/view_profile_buefy.mako @@ -1573,6 +1573,7 @@ let ProfileInfo = { template: '#profile-info-template', + mixins: [FormPosterMixin], computed: {}, methods: { personUpdated(person) { From db3ea2e34afa0482daed113072a909917686e323 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 13 Aug 2022 23:12:39 -0500 Subject: [PATCH 087/978] Fix default help URLs for ordering, receiving --- tailbone/views/purchasing/batch.py | 1 - tailbone/views/purchasing/ordering.py | 3 ++- tailbone/views/purchasing/receiving.py | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index 4209a35d..bca52b24 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -49,7 +49,6 @@ class PurchasingBatchView(BatchMasterView): default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler' supports_new_product = False cloneable = True - default_help_url = 'https://rattailproject.org/docs/rattail-manual/features/purchasing/receiving/index.html' labels = { 'po_total': "PO Total", diff --git a/tailbone/views/purchasing/ordering.py b/tailbone/views/purchasing/ordering.py index 69e361ed..c864ec35 100644 --- a/tailbone/views/purchasing/ordering.py +++ b/tailbone/views/purchasing/ordering.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2021 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -53,6 +53,7 @@ class OrderingBatchView(PurchasingBatchView): index_title = "Ordering" rows_editable = True has_worksheet = True + default_help_url = 'https://rattailproject.org/docs/rattail-manual/features/purchasing/ordering/index.html' labels = { 'po_total_calculated': "PO Total", diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index c66c3664..a7286b07 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -80,6 +80,7 @@ class ReceivingBatchView(PurchasingBatchView): bulk_deletable = True configurable = True config_title = "Receiving" + default_help_url = 'https://rattailproject.org/docs/rattail-manual/features/purchasing/receiving/index.html' rows_editable = False rows_editable_but_not_directly = True From 2f5de67ee71ae0b16f1db168ff15448118d8b6ab Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 13 Aug 2022 23:23:30 -0500 Subject: [PATCH 088/978] Move handheld batch view module to appropriate location --- tailbone/views/batch/handheld.py | 210 +++++++++++++++++++++++++++++++ tailbone/views/handheld.py | 185 ++------------------------- 2 files changed, 219 insertions(+), 176 deletions(-) create mode 100644 tailbone/views/batch/handheld.py diff --git a/tailbone/views/batch/handheld.py b/tailbone/views/batch/handheld.py new file mode 100644 index 00000000..d4f15ffd --- /dev/null +++ b/tailbone/views/batch/handheld.py @@ -0,0 +1,210 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2022 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 . +# +################################################################################ +""" +Views for handheld batches +""" + +from __future__ import unicode_literals, absolute_import + +from rattail.db import model +from rattail.util import OrderedDict + +import colander +from webhelpers2.html import tags + +from tailbone import forms +from tailbone.views.batch import FileBatchMasterView + + +ACTION_OPTIONS = OrderedDict([ + ('make_label_batch', "Make a new Label Batch"), + ('make_inventory_batch', "Make a new Inventory Batch"), +]) + + +class ExecutionOptions(colander.Schema): + + action = colander.SchemaNode( + colander.String(), + validator=colander.OneOf(ACTION_OPTIONS), + widget=forms.widgets.PlainSelectWidget(values=ACTION_OPTIONS.items())) + + +class HandheldBatchView(FileBatchMasterView): + """ + Master view for handheld batches. + """ + model_class = model.HandheldBatch + default_handler_spec = 'rattail.batch.handheld:HandheldBatchHandler' + model_title_plural = "Handheld Batches" + route_prefix = 'batch.handheld' + url_prefix = '/batch/handheld' + execution_options_schema = ExecutionOptions + editable = False + + model_row_class = model.HandheldBatchRow + rows_creatable = False + rows_editable = True + + grid_columns = [ + 'id', + 'device_type', + 'device_name', + 'created', + 'created_by', + 'rowcount', + 'status_code', + 'executed', + ] + + form_fields = [ + 'id', + 'device_type', + 'device_name', + 'filename', + 'created', + 'created_by', + 'rowcount', + 'status_code', + 'executed', + 'executed_by', + ] + + row_labels = { + 'upc': "UPC", + } + + row_grid_columns = [ + 'sequence', + 'upc', + 'brand_name', + 'description', + 'size', + 'cases', + 'units', + 'status_code', + ] + + row_form_fields = [ + 'sequence', + 'upc', + 'brand_name', + 'description', + 'size', + 'status_code', + 'cases', + 'units', + ] + + def configure_grid(self, g): + super(HandheldBatchView, self).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) + + def grid_extra_class(self, batch, i): + if batch.status_code is not None and batch.status_code != batch.STATUS_OK: + return 'notice' + + def configure_form(self, f): + super(HandheldBatchView, self).configure_form(f) + batch = f.model_instance + + # device_type + device_types = OrderedDict(sorted(self.enum.HANDHELD_DEVICE_TYPE.items(), + key=lambda item: item[1])) + f.set_enum('device_type', device_types) + f.widgets['device_type'].values.insert(0, ('', "(none)")) + + if self.creating: + f.set_fields([ + 'filename', + 'device_type', + 'device_name', + ]) + + if self.viewing: + if batch.inventory_batch: + f.append('inventory_batch') + f.set_renderer('inventory_batch', self.render_inventory_batch) + + def render_inventory_batch(self, handheld_batch, field): + batch = handheld_batch.inventory_batch + if not batch: + return "" + text = batch.id_str + url = self.request.route_url('batch.inventory.view', uuid=batch.uuid) + return tags.link_to(text, url) + + def get_batch_kwargs(self, batch): + kwargs = super(HandheldBatchView, self).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) + g.set_type('cases', 'quantity') + g.set_type('units', 'quantity') + g.set_label('brand_name', "Brand") + + def row_grid_extra_class(self, row, i): + if row.status_code == row.STATUS_PRODUCT_NOT_FOUND: + return 'warning' + + def configure_row_form(self, f): + super(HandheldBatchView, self).configure_row_form(f) + + # readonly fields + f.set_readonly('upc') + f.set_readonly('brand_name') + f.set_readonly('description') + f.set_readonly('size') + + # upc + f.set_renderer('upc', self.render_upc) + + def get_execute_success_url(self, batch, result, **kwargs): + if kwargs['action'] == 'make_inventory_batch': + 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) + + def get_execute_results_success_url(self, result, **kwargs): + if result is True: + # no batches were actually executed + return self.get_index_url() + batch = result + return self.get_execute_success_url(batch, result, **kwargs) + + +def defaults(config, **kwargs): + base = globals() + + HandheldBatchView = kwargs.get('HandheldBatchView', base['HandheldBatchView']) + HandheldBatchView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/handheld.py b/tailbone/views/handheld.py index b0392c13..4d702c92 100644 --- a/tailbone/views/handheld.py +++ b/tailbone/views/handheld.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -21,186 +21,19 @@ # ################################################################################ """ -Views for handheld batches +(DEPRECATED) Views for handheld batches """ from __future__ import unicode_literals, absolute_import -import os +import warnings -from rattail.db import model -from rattail.util import OrderedDict - -import colander -from webhelpers2.html import tags - -from tailbone import forms -from tailbone.db import Session -from tailbone.views.batch import FileBatchMasterView - - -ACTION_OPTIONS = OrderedDict([ - ('make_label_batch', "Make a new Label Batch"), - ('make_inventory_batch', "Make a new Inventory Batch"), -]) - - -class ExecutionOptions(colander.Schema): - - action = colander.SchemaNode( - colander.String(), - validator=colander.OneOf(ACTION_OPTIONS), - widget=forms.widgets.PlainSelectWidget(values=ACTION_OPTIONS.items())) - - -class HandheldBatchView(FileBatchMasterView): - """ - Master view for handheld batches. - """ - model_class = model.HandheldBatch - default_handler_spec = 'rattail.batch.handheld:HandheldBatchHandler' - model_title_plural = "Handheld Batches" - route_prefix = 'batch.handheld' - url_prefix = '/batch/handheld' - execution_options_schema = ExecutionOptions - editable = False - - model_row_class = model.HandheldBatchRow - rows_creatable = False - rows_editable = True - - grid_columns = [ - 'id', - 'device_type', - 'device_name', - 'created', - 'created_by', - 'rowcount', - 'status_code', - 'executed', - ] - - form_fields = [ - 'id', - 'device_type', - 'device_name', - 'filename', - 'created', - 'created_by', - 'rowcount', - 'status_code', - 'executed', - 'executed_by', - ] - - row_labels = { - 'upc': "UPC", - } - - row_grid_columns = [ - 'sequence', - 'upc', - 'brand_name', - 'description', - 'size', - 'cases', - 'units', - 'status_code', - ] - - row_form_fields = [ - 'sequence', - 'upc', - 'brand_name', - 'description', - 'size', - 'status_code', - 'cases', - 'units', - ] - - def configure_grid(self, g): - super(HandheldBatchView, self).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) - - def grid_extra_class(self, batch, i): - if batch.status_code is not None and batch.status_code != batch.STATUS_OK: - return 'notice' - - def configure_form(self, f): - super(HandheldBatchView, self).configure_form(f) - batch = f.model_instance - - # device_type - device_types = OrderedDict(sorted(self.enum.HANDHELD_DEVICE_TYPE.items(), - key=lambda item: item[1])) - f.set_enum('device_type', device_types) - f.widgets['device_type'].values.insert(0, ('', "(none)")) - - if self.creating: - f.set_fields([ - 'filename', - 'device_type', - 'device_name', - ]) - - if self.viewing: - if batch.inventory_batch: - f.append('inventory_batch') - f.set_renderer('inventory_batch', self.render_inventory_batch) - - def render_inventory_batch(self, handheld_batch, field): - batch = handheld_batch.inventory_batch - if not batch: - return "" - text = batch.id_str - url = self.request.route_url('batch.inventory.view', uuid=batch.uuid) - return tags.link_to(text, url) - - def get_batch_kwargs(self, batch): - kwargs = super(HandheldBatchView, self).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) - g.set_type('cases', 'quantity') - g.set_type('units', 'quantity') - g.set_label('brand_name', "Brand") - - def row_grid_extra_class(self, row, i): - if row.status_code == row.STATUS_PRODUCT_NOT_FOUND: - return 'warning' - - def configure_row_form(self, f): - super(HandheldBatchView, self).configure_row_form(f) - - # readonly fields - f.set_readonly('upc') - f.set_readonly('brand_name') - f.set_readonly('description') - f.set_readonly('size') - - # upc - f.set_renderer('upc', self.render_upc) - - def get_execute_success_url(self, batch, result, **kwargs): - if kwargs['action'] == 'make_inventory_batch': - 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) - - def get_execute_results_success_url(self, result, **kwargs): - if result is True: - # no batches were actually executed - return self.get_index_url() - batch = result - return self.get_execute_success_url(batch, result, **kwargs) +# nb. this is imported only for sake of legacy callers +from tailbone.views.batch.handheld import HandheldBatchView def includeme(config): - HandheldBatchView.defaults(config) + warnings.warn("tailbone.views.handheld is a deprecated module; " + "please use tailbone.views.batch.handheld instead", + DeprecationWarning) + config.include('tailbone.views.batch.handheld') From f2c73acd3bdf235f71e46817276f748fb0c1f785 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 13 Aug 2022 23:59:09 -0500 Subject: [PATCH 089/978] Refactor usage of `get_vendor()` lookup --- tailbone/views/batch/vendorinvoice.py | 8 +++++--- tailbone/views/purchasing/batch.py | 5 ----- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/tailbone/views/batch/vendorinvoice.py b/tailbone/views/batch/vendorinvoice.py index 9cfd5dc9..6b8bdef7 100644 --- a/tailbone/views/batch/vendorinvoice.py +++ b/tailbone/views/batch/vendorinvoice.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2021 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -28,7 +28,7 @@ from __future__ import unicode_literals, absolute_import import six -from rattail.db import model, api +from rattail.db import model from rattail.vendors.invoices import iter_invoice_parsers, require_invoice_parser # import formalchemy @@ -172,8 +172,10 @@ class VendorInvoiceView(FileBatchMasterView): return kwargs def init_batch(self, batch): + app = self.get_rattail_app() + vendor_handler = app.get_vendor_handler() parser = require_invoice_parser(self.rattail_config, batch.parser_key) - vendor = api.get_vendor(self.Session(), parser.vendor_key) + vendor = vendor_handler.get_vendor(self.Session(), parser.vendor_key) if not vendor: self.request.session.flash("No vendor setting found in database for key: {}".format(parser.vendor_key)) return False diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index bca52b24..ee460192 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -476,11 +476,6 @@ class PurchasingBatchView(BatchMasterView): return [(v.uuid, "({}) {}".format(v.id, v.name)) for v in vendors] - def get_vendor_values(self): - vendors = self.get_vendors() - return [(v.uuid, "({}) {}".format(v.id, v.name)) - for v in vendors] - def get_buyers(self): return self.Session.query(model.Employee)\ .join(model.Person)\ From bc51a868ce76f2e1c54a3f1f63a4be1ad1c683bf Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 14 Aug 2022 00:52:53 -0500 Subject: [PATCH 090/978] Consolidate master API view logic also let all API views use new config defaults convention --- tailbone/api/__init__.py | 3 +- tailbone/api/auth.py | 9 +- tailbone/api/batch/core.py | 18 ++-- tailbone/api/batch/inventory.py | 13 ++- tailbone/api/batch/labels.py | 13 ++- tailbone/api/batch/ordering.py | 14 ++- tailbone/api/batch/receiving.py | 14 ++- tailbone/api/common.py | 11 +- tailbone/api/core.py | 8 +- tailbone/api/customers.py | 11 +- tailbone/api/master.py | 180 +++++++++++++++++++++++++++++++- tailbone/api/master2.py | 180 ++------------------------------ tailbone/api/people.py | 13 ++- tailbone/api/products.py | 13 ++- tailbone/api/upgrades.py | 13 ++- tailbone/api/users.py | 15 ++- tailbone/api/vendors.py | 13 ++- tailbone/api/workorders.py | 4 +- 18 files changed, 320 insertions(+), 225 deletions(-) diff --git a/tailbone/api/__init__.py b/tailbone/api/__init__.py index 0b669b6c..1fae059f 100644 --- a/tailbone/api/__init__.py +++ b/tailbone/api/__init__.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -28,6 +28,7 @@ from __future__ import unicode_literals, absolute_import from .core import APIView, api from .master import APIMasterView, SortColumn +# TODO: remove this from .master2 import APIMasterView2 diff --git a/tailbone/api/auth.py b/tailbone/api/auth.py index 584f397e..867c15a8 100644 --- a/tailbone/api/auth.py +++ b/tailbone/api/auth.py @@ -219,5 +219,12 @@ class AuthenticationView(APIView): config.add_cornice_service(change_password) -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + AuthenticationView = kwargs.get('AuthenticationView', base['AuthenticationView']) AuthenticationView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/api/batch/core.py b/tailbone/api/batch/core.py index a2f44596..bbba1fb3 100644 --- a/tailbone/api/batch/core.py +++ b/tailbone/api/batch/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2021 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -30,12 +30,9 @@ import logging import six -from rattail.time import localtime -from rattail.util import load_object +from cornice import Service -from cornice import resource, Service - -from tailbone.api import APIMasterView2 as APIMasterView +from tailbone.api import APIMasterView log = logging.getLogger(__name__) @@ -70,10 +67,11 @@ class APIBatchMixin(object): table name, although technically it is whatever value returns from the ``batch_key`` attribute of the main batch model class. """ + 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 load_object(spec)(self.rattail_config) + return app.load_object(spec)(self.rattail_config) class APIBatchView(APIBatchMixin, APIMasterView): @@ -89,12 +87,12 @@ class APIBatchView(APIBatchMixin, APIMasterView): self.handler = self.get_handler() def normalize(self, batch): - - created = localtime(self.rattail_config, batch.created, from_utc=True) + app = self.get_rattail_app() + created = app.localtime(batch.created, from_utc=True) executed = None if batch.executed: - executed = localtime(self.rattail_config, batch.executed, from_utc=True) + executed = app.localtime(batch.executed, from_utc=True) return { 'uuid': batch.uuid, diff --git a/tailbone/api/batch/inventory.py b/tailbone/api/batch/inventory.py index a798c58e..f0c68030 100644 --- a/tailbone/api/batch/inventory.py +++ b/tailbone/api/batch/inventory.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2021 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -178,6 +178,15 @@ class InventoryBatchRowViews(APIBatchRowView): return row -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + InventoryBatchViews = kwargs.get('InventoryBatchViews', base['InventoryBatchViews']) InventoryBatchViews.defaults(config) + + InventoryBatchRowViews = kwargs.get('InventoryBatchRowViews', base['InventoryBatchRowViews']) InventoryBatchRowViews.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/api/batch/labels.py b/tailbone/api/batch/labels.py index 11a3d20d..4787aeb9 100644 --- a/tailbone/api/batch/labels.py +++ b/tailbone/api/batch/labels.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2021 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -68,6 +68,15 @@ class LabelBatchRowViews(APIBatchRowView): return data -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + LabelBatchViews = kwargs.get('LabelBatchViews', base['LabelBatchViews']) LabelBatchViews.defaults(config) + + LabelBatchRowViews = kwargs.get('LabelBatchRowViews', base['LabelBatchRowViews']) LabelBatchRowViews.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/api/batch/ordering.py b/tailbone/api/batch/ordering.py index 21de8da0..b7bd45cb 100644 --- a/tailbone/api/batch/ordering.py +++ b/tailbone/api/batch/ordering.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2021 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -31,7 +31,6 @@ from __future__ import unicode_literals, absolute_import import six -from rattail.core import Object from rattail.db import model from rattail.util import pretty_quantity @@ -274,6 +273,15 @@ class OrderingBatchRowViews(APIBatchRowView): return row -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + OrderingBatchViews = kwargs.get('OrderingBatchViews', base['OrderingBatchViews']) OrderingBatchViews.defaults(config) + + OrderingBatchRowViews = kwargs.get('OrderingBatchRowViews', base['OrderingBatchRowViews']) OrderingBatchRowViews.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/api/batch/receiving.py b/tailbone/api/batch/receiving.py index 0ddda845..ce7c34f6 100644 --- a/tailbone/api/batch/receiving.py +++ b/tailbone/api/batch/receiving.py @@ -32,7 +32,6 @@ import six import humanize from rattail.db import model -from rattail.time import make_utc from rattail.util import pretty_quantity from deform import widget as dfwidget @@ -392,7 +391,7 @@ class ReceivingBatchRowViews(APIBatchRowView): data['received_alert'] = None if self.handler.get_units_confirmed(row): msg = "You have already received some of this product; last update was {}.".format( - humanize.naturaltime(make_utc() - row.modified)) + humanize.naturaltime(app.make_utc() - row.modified)) data['received_alert'] = msg return data @@ -444,6 +443,15 @@ class ReceivingBatchRowViews(APIBatchRowView): renderer='json') -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + ReceivingBatchViews = kwargs.get('ReceivingBatchViews', base['ReceivingBatchViews']) ReceivingBatchViews.defaults(config) + + ReceivingBatchRowViews = kwargs.get('ReceivingBatchRowViews', base['ReceivingBatchRowViews']) ReceivingBatchRowViews.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/api/common.py b/tailbone/api/common.py index 81458c01..3e96609a 100644 --- a/tailbone/api/common.py +++ b/tailbone/api/common.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2021 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -129,5 +129,12 @@ class CommonView(APIView): config.add_cornice_service(feedback) -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + CommonView = kwargs.get('CommonView', base['CommonView']) CommonView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/api/core.py b/tailbone/api/core.py index 65aa9699..c2cea0a8 100644 --- a/tailbone/api/core.py +++ b/tailbone/api/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -26,8 +26,6 @@ Tailbone Web API - Core Views from __future__ import unicode_literals, absolute_import -from rattail.util import load_object - from tailbone.views import View @@ -102,6 +100,8 @@ class APIView(View): info.pop('short_name', None) return info """ + app = self.get_rattail_app() + # basic / default info is_admin = user.is_admin() employee = user.employee @@ -119,7 +119,7 @@ class APIView(View): extra = self.rattail_config.get('tailbone.api', 'extra_user_info', usedb=False) if extra: - extra = load_object(extra) + extra = app.load_object(extra) info = extra(self.request, user, **info) return info diff --git a/tailbone/api/customers.py b/tailbone/api/customers.py index 9a06caaa..e9953572 100644 --- a/tailbone/api/customers.py +++ b/tailbone/api/customers.py @@ -30,7 +30,7 @@ import six from rattail.db import model -from tailbone.api import APIMasterView2 as APIMasterView +from tailbone.api import APIMasterView class CustomerView(APIMasterView): @@ -53,5 +53,12 @@ class CustomerView(APIMasterView): } -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + CustomerView = kwargs.get('CustomerView', base['CustomerView']) CustomerView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/api/master.py b/tailbone/api/master.py index 7cb911be..670a6104 100644 --- a/tailbone/api/master.py +++ b/tailbone/api/master.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2021 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -27,10 +27,11 @@ Tailbone Web API - Master View from __future__ import unicode_literals, absolute_import import json -import six from rattail.config import parse_bool +from cornice import resource, Service + from tailbone.api import APIView, api from tailbone.db import Session @@ -46,6 +47,14 @@ class APIMasterView(APIView): """ Base class for data model REST API views. """ + listable = True + creatable = True + viewable = True + editable = True + deletable = True + supports_autocomplete = False + supports_download = False + supports_rawbytes = False @property def Session(self): @@ -120,6 +129,34 @@ class APIMasterView(APIView): return cls.collection_key return '{}s'.format(cls.get_object_key()) + @classmethod + def establish_method(cls, method_name): + """ + Establish the given HTTP method for this Cornice Resource. + + Cornice will auto-register any class methods for a resource, if they + are named according to what it expects (i.e. 'get', 'collection_get' + etc.). Tailbone API tries to make things automagical for the sake of + e.g. Poser logic, but in this case if we predefine all of these methods + and then some subclass view wants to *not* allow one, it's not clear + how to "undefine" it per se. Or at least, the more straightforward + thing (I think) is to not define such a method in the first place, if + it was not wanted. + + Enter ``establish_method()``, which is what finally "defines" each + resource method according to what the subclass has declared via its + various attributes (:attr:`creatable`, :attr:`deletable` etc.). + + Note that you will not likely have any need to use this + ``establish_method()`` yourself! But we describe its purpose here, for + clarity. + """ + def method(self): + internal_method = getattr(self, '_{}'.format(method_name)) + return internal_method() + + setattr(cls, method_name, method) + def make_filter_spec(self): if not self.request.GET.has_key('filters'): return [] @@ -371,6 +408,67 @@ class APIMasterView(APIView): # that's all we can do here, subclass must override if more needed return obj + ############################## + # delete + ############################## + + def _delete(self): + """ + View to handle DELETE action for an existing record/object. + """ + obj = self.get_object() + self.delete_object(obj) + + def delete_object(self, obj): + """ + Delete the object, or mark it as deleted, or whatever you need to do. + """ + # flush immediately to force any pending integrity errors etc. + self.Session.delete(obj) + self.Session.flush() + + ############################## + # download + ############################## + + def download(self): + """ + GET view allowing for download of a single file, which is attached to a + given record. + """ + obj = self.get_object() + + filename = self.request.GET.get('filename', None) + if not filename: + raise self.notfound() + path = self.download_path(obj, filename) + + response = self.file_response(path) + return response + + def download_path(self, obj, filename): + """ + Should return absolute path on disk, for the given object and filename. + Result will be used to return a file response to client. + """ + raise NotImplementedError + + def rawbytes(self): + """ + GET view allowing for direct access to the raw bytes of a file, which + is attached to a given record. Basically the same as 'download' except + this does not come as an attachment. + """ + obj = self.get_object() + + filename = self.request.GET.get('filename', None) + if not filename: + raise self.notfound() + path = self.download_path(obj, filename) + + response = self.file_response(path, attachment=False) + return response + ############################## # autocomplete ############################## @@ -426,3 +524,81 @@ class APIMasterView(APIView): autocomplete query. """ return term + + @classmethod + def defaults(cls, config): + cls._defaults(config) + + @classmethod + def _defaults(cls, config): + route_prefix = cls.get_route_prefix() + permission_prefix = cls.get_permission_prefix() + collection_url_prefix = cls.get_collection_url_prefix() + object_url_prefix = cls.get_object_url_prefix() + + # first, the primary resource API + + # list/search + if cls.listable: + cls.establish_method('collection_get') + resource.add_view(cls.collection_get, permission='{}.list'.format(permission_prefix)) + + # create + if cls.creatable: + cls.establish_method('collection_post') + if hasattr(cls, 'permission_to_create'): + permission = cls.permission_to_create + else: + permission = '{}.create'.format(permission_prefix) + resource.add_view(cls.collection_post, permission=permission) + + # view + if cls.viewable: + cls.establish_method('get') + resource.add_view(cls.get, permission='{}.view'.format(permission_prefix)) + + # edit + if cls.editable: + cls.establish_method('post') + resource.add_view(cls.post, permission='{}.edit'.format(permission_prefix)) + + # delete + if cls.deletable: + cls.establish_method('delete') + resource.add_view(cls.delete, permission='{}.delete'.format(permission_prefix)) + + # register primary resource API via cornice + object_resource = resource.add_resource( + cls, + collection_path=collection_url_prefix, + # TODO: probably should allow for other (composite?) key fields + path='{}/{{uuid}}'.format(object_url_prefix)) + config.add_cornice_resource(object_resource) + + # now for some more "custom" things, which are still somewhat generic + + # autocomplete + if cls.supports_autocomplete: + autocomplete = Service(name='{}.autocomplete'.format(route_prefix), + path='{}/autocomplete'.format(collection_url_prefix)) + autocomplete.add_view('GET', 'autocomplete', klass=cls, + permission='{}.list'.format(permission_prefix)) + config.add_cornice_service(autocomplete) + + # download + if cls.supports_download: + download = Service(name='{}.download'.format(route_prefix), + # TODO: probably should allow for other (composite?) key fields + path='{}/{{uuid}}/download'.format(object_url_prefix)) + download.add_view('GET', 'download', klass=cls, + permission='{}.download'.format(permission_prefix)) + config.add_cornice_service(download) + + # rawbytes + if cls.supports_rawbytes: + rawbytes = Service(name='{}.rawbytes'.format(route_prefix), + # TODO: probably should allow for other (composite?) key fields + path='{}/{{uuid}}/rawbytes'.format(object_url_prefix)) + rawbytes.add_view('GET', 'rawbytes', klass=cls, + permission='{}.download'.format(permission_prefix)) + config.add_cornice_service(rawbytes) diff --git a/tailbone/api/master2.py b/tailbone/api/master2.py index 7f62489e..4a5abb3e 100644 --- a/tailbone/api/master2.py +++ b/tailbone/api/master2.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2021 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -26,8 +26,7 @@ Tailbone Web API - Master View (v2) from __future__ import unicode_literals, absolute_import -from pyramid.response import FileResponse -from cornice import resource, Service +import warnings from tailbone.api import APIMasterView @@ -36,174 +35,9 @@ class APIMasterView2(APIMasterView): """ Base class for data model REST API views. """ - listable = True - creatable = True - viewable = True - editable = True - deletable = True - supports_autocomplete = False - supports_download = False - supports_rawbytes = False - @classmethod - def establish_method(cls, method_name): - """ - Establish the given HTTP method for this Cornice Resource. - - Cornice will auto-register any class methods for a resource, if they - are named according to what it expects (i.e. 'get', 'collection_get' - etc.). Tailbone API tries to make things automagical for the sake of - e.g. Poser logic, but in this case if we predefine all of these methods - and then some subclass view wants to *not* allow one, it's not clear - how to "undefine" it per se. Or at least, the more straightforward - thing (I think) is to not define such a method in the first place, if - it was not wanted. - - Enter ``establish_method()``, which is what finally "defines" each - resource method according to what the subclass has declared via its - various attributes (:attr:`creatable`, :attr:`deletable` etc.). - - Note that you will not likely have any need to use this - ``establish_method()`` yourself! But we describe its purpose here, for - clarity. - """ - def method(self): - internal_method = getattr(self, '_{}'.format(method_name)) - return internal_method() - - setattr(cls, method_name, method) - - def _delete(self): - """ - View to handle DELETE action for an existing record/object. - """ - obj = self.get_object() - self.delete_object(obj) - - def delete_object(self, obj): - """ - Delete the object, or mark it as deleted, or whatever you need to do. - """ - # flush immediately to force any pending integrity errors etc. - self.Session.delete(obj) - self.Session.flush() - - ############################## - # download - ############################## - - def download(self): - """ - GET view allowing for download of a single file, which is attached to a - given record. - """ - obj = self.get_object() - - filename = self.request.GET.get('filename', None) - if not filename: - raise self.notfound() - path = self.download_path(obj, filename) - - response = self.file_response(path) - return response - - def download_path(self, obj, filename): - """ - Should return absolute path on disk, for the given object and filename. - Result will be used to return a file response to client. - """ - raise NotImplementedError - - def rawbytes(self): - """ - GET view allowing for direct access to the raw bytes of a file, which - is attached to a given record. Basically the same as 'download' except - this does not come as an attachment. - """ - obj = self.get_object() - - filename = self.request.GET.get('filename', None) - if not filename: - raise self.notfound() - path = self.download_path(obj, filename) - - response = self.file_response(path, attachment=False) - return response - - @classmethod - def defaults(cls, config): - cls._defaults(config) - - @classmethod - def _defaults(cls, config): - route_prefix = cls.get_route_prefix() - permission_prefix = cls.get_permission_prefix() - collection_url_prefix = cls.get_collection_url_prefix() - object_url_prefix = cls.get_object_url_prefix() - - # first, the primary resource API - - # list/search - if cls.listable: - cls.establish_method('collection_get') - resource.add_view(cls.collection_get, permission='{}.list'.format(permission_prefix)) - - # create - if cls.creatable: - cls.establish_method('collection_post') - if hasattr(cls, 'permission_to_create'): - permission = cls.permission_to_create - else: - permission = '{}.create'.format(permission_prefix) - resource.add_view(cls.collection_post, permission=permission) - - # view - if cls.viewable: - cls.establish_method('get') - resource.add_view(cls.get, permission='{}.view'.format(permission_prefix)) - - # edit - if cls.editable: - cls.establish_method('post') - resource.add_view(cls.post, permission='{}.edit'.format(permission_prefix)) - - # delete - if cls.deletable: - cls.establish_method('delete') - resource.add_view(cls.delete, permission='{}.delete'.format(permission_prefix)) - - # register primary resource API via cornice - object_resource = resource.add_resource( - cls, - collection_path=collection_url_prefix, - # TODO: probably should allow for other (composite?) key fields - path='{}/{{uuid}}'.format(object_url_prefix)) - config.add_cornice_resource(object_resource) - - # now for some more "custom" things, which are still somewhat generic - - # autocomplete - if cls.supports_autocomplete: - autocomplete = Service(name='{}.autocomplete'.format(route_prefix), - path='{}/autocomplete'.format(collection_url_prefix)) - autocomplete.add_view('GET', 'autocomplete', klass=cls, - permission='{}.list'.format(permission_prefix)) - config.add_cornice_service(autocomplete) - - # download - if cls.supports_download: - download = Service(name='{}.download'.format(route_prefix), - # TODO: probably should allow for other (composite?) key fields - path='{}/{{uuid}}/download'.format(object_url_prefix)) - download.add_view('GET', 'download', klass=cls, - permission='{}.download'.format(permission_prefix)) - config.add_cornice_service(download) - - # rawbytes - if cls.supports_rawbytes: - rawbytes = Service(name='{}.rawbytes'.format(route_prefix), - # TODO: probably should allow for other (composite?) key fields - path='{}/{{uuid}}/rawbytes'.format(object_url_prefix)) - rawbytes.add_view('GET', 'rawbytes', klass=cls, - permission='{}.download'.format(permission_prefix)) - config.add_cornice_service(rawbytes) + def __init__(self, request, context=None): + warnings.warn("APIMasterView2 class is deprecated; please use " + "APIMasterView instead", + DeprecationWarning, stacklevel=2) + super(APIMasterView2, self).__init__(request, context=context) diff --git a/tailbone/api/people.py b/tailbone/api/people.py index bb8dd883..7e06e969 100644 --- a/tailbone/api/people.py +++ b/tailbone/api/people.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2021 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -30,7 +30,7 @@ import six from rattail.db import model -from tailbone.api import APIMasterView2 as APIMasterView +from tailbone.api import APIMasterView class PersonView(APIMasterView): @@ -52,5 +52,12 @@ class PersonView(APIMasterView): } -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + PersonView = kwargs.get('PersonView', base['PersonView']) PersonView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/api/products.py b/tailbone/api/products.py index d7aeabcd..48a6e4aa 100644 --- a/tailbone/api/products.py +++ b/tailbone/api/products.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -32,7 +32,7 @@ from sqlalchemy import orm from rattail.db import model -from tailbone.api import APIMasterView2 as APIMasterView +from tailbone.api import APIMasterView class ProductView(APIMasterView): @@ -78,5 +78,12 @@ class ProductView(APIMasterView): return product.full_description -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + ProductView = kwargs.get('ProductView', base['ProductView']) ProductView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/api/upgrades.py b/tailbone/api/upgrades.py index 85e4a91e..6ce5f778 100644 --- a/tailbone/api/upgrades.py +++ b/tailbone/api/upgrades.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -30,7 +30,7 @@ import six from rattail.db import model -from tailbone.api import APIMasterView2 as APIMasterView +from tailbone.api import APIMasterView class UpgradeView(APIMasterView): @@ -57,5 +57,12 @@ class UpgradeView(APIMasterView): return data -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + UpgradeView = kwargs.get('UpgradeView', base['UpgradeView']) UpgradeView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/api/users.py b/tailbone/api/users.py index 8474fd97..2b6476a2 100644 --- a/tailbone/api/users.py +++ b/tailbone/api/users.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -26,11 +26,9 @@ Tailbone Web API - User Views from __future__ import unicode_literals, absolute_import -import six - from rattail.db import model -from tailbone.api import APIMasterView2 as APIMasterView +from tailbone.api import APIMasterView class UserView(APIMasterView): @@ -60,5 +58,12 @@ class UserView(APIMasterView): return query -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + UserView = kwargs.get('UserView', base['UserView']) UserView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/api/vendors.py b/tailbone/api/vendors.py index ce885e07..7fa61590 100644 --- a/tailbone/api/vendors.py +++ b/tailbone/api/vendors.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -30,7 +30,7 @@ import six from rattail.db import model -from tailbone.api import APIMasterView2 as APIMasterView +from tailbone.api import APIMasterView class VendorView(APIMasterView): @@ -50,5 +50,12 @@ class VendorView(APIMasterView): } -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + VendorView = kwargs.get('VendorView', base['VendorView']) VendorView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/api/workorders.py b/tailbone/api/workorders.py index d559589d..cac9e372 100644 --- a/tailbone/api/workorders.py +++ b/tailbone/api/workorders.py @@ -31,12 +31,10 @@ import datetime import six from rattail.db.model import WorkOrder -from rattail.time import localtime -from rattail.util import OrderedDict from cornice import Service -from tailbone.api import APIMasterView2 as APIMasterView +from tailbone.api import APIMasterView class WorkOrderView(APIMasterView): From 303eba6bca2c2e10d9c6f218ee0ce1de3b9f4028 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 14 Aug 2022 10:17:52 -0500 Subject: [PATCH 091/978] 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 0543e130..f5b143c2 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,22 @@ CHANGELOG ========= +0.8.247 (2022-08-14) +-------------------- + +* Avoid double-quotes in field error messages JS code. + +* Add the FormPosterMixin to ProfileInfo component. + +* Fix default help URLs for ordering, receiving. + +* Move handheld batch view module to appropriate location. + +* Refactor usage of ``get_vendor()`` lookup. + +* Consolidate master API view logic. + + 0.8.246 (2022-08-12) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index fafaab99..b2022e77 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.246' +__version__ = '0.8.247' From a20eb468df177c464899897026e13d8054d8091f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 14 Aug 2022 15:53:43 -0500 Subject: [PATCH 092/978] Redirect to custom index URL when user cancels new custorder entry --- tailbone/views/custorders/orders.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index 50a108ef..41f7c5f5 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -410,8 +410,7 @@ class CustomerOrderView(MasterView): self.request.session.flash("New customer order has been deleted.") # send user back to customer orders page, w/ no new batch generated - route_prefix = self.get_route_prefix() - url = self.request.route_url(route_prefix) + url = self.get_index_url() return self.redirect(url) def customer_autocomplete(self): @@ -1005,5 +1004,12 @@ class CustomerOrderView(MasterView): CustomerOrdersView = CustomerOrderView -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + CustomerOrderView = kwargs.get('CustomerOrderView', base['CustomerOrderView']) CustomerOrderView.defaults(config) + + +def includeme(config): + defaults(config) From 839c4e0c28387435da2df70baf33a537289d55b2 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 14 Aug 2022 17:33:12 -0500 Subject: [PATCH 093/978] Add `get_next_url_after_submit_new_order()` for customer orders after new custorder batch is executed, where do we send user? --- tailbone/views/custorders/orders.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index 41f7c5f5..cf231374 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -924,11 +924,16 @@ class CustomerOrderView(MasterView): if not result: return {'error': "Batch failed to execute"} - next_url = None - if isinstance(result, model.CustomerOrder): - next_url = self.get_action_url('view', result) + return { + 'ok': True, + 'next_url': self.get_next_url_after_submit_new_order(batch, result), + } - return {'ok': True, 'next_url': next_url} + def get_next_url_after_submit_new_order(self, batch, result, **kwargs): + model = self.model + + if isinstance(result, model.CustomerOrder): + return self.get_action_url('view', result) def execute_new_order_batch(self, batch, data): return self.batch_handler.do_execute(batch, self.request.user) From 065f84570778dc950b419e4f97c6692e02c8373b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 15 Aug 2022 21:06:19 -0500 Subject: [PATCH 094/978] Add proper status page for datasync or rather, it's a good start.. plenty more could be added --- .../templates/datasync/changes/index.mako | 2 +- tailbone/templates/datasync/configure.mako | 64 +++-- tailbone/templates/datasync/index.mako | 19 -- tailbone/templates/datasync/status.mako | 121 ++++++++ tailbone/util.py | 8 +- tailbone/views/datasync.py | 260 +++++++++++++----- 6 files changed, 361 insertions(+), 113 deletions(-) delete mode 100644 tailbone/templates/datasync/index.mako create mode 100644 tailbone/templates/datasync/status.mako diff --git a/tailbone/templates/datasync/changes/index.mako b/tailbone/templates/datasync/changes/index.mako index 7a79010f..632f50ee 100644 --- a/tailbone/templates/datasync/changes/index.mako +++ b/tailbone/templates/datasync/changes/index.mako @@ -4,7 +4,7 @@ <%def name="context_menu_items()"> ${parent.context_menu_items()} % if request.has_perm('datasync.list'): -
  • ${h.link_to("View DataSync Threads", url('datasync'))}
  • +
  • ${h.link_to("View DataSync Status", url('datasync.status'))}
  • % endif diff --git a/tailbone/templates/datasync/configure.mako b/tailbone/templates/datasync/configure.mako index ca57a468..2d6d6435 100644 --- a/tailbone/templates/datasync/configure.mako +++ b/tailbone/templates/datasync/configure.mako @@ -53,29 +53,30 @@

    This tool works by modifying settings in the DB.  It does not modify any config - files.  If you intend to manage datasync config via files - only then you should - not use this tool! + files.  If you intend to manage datasync watcher/consumer + config via files only then you should be sure to UNCHECK the + "Use these Settings.." checkbox near the top of page.

    - If you have managed config via files thus far, and want to use - this tool anyway/instead, that's fine - but after saving - the settings via this tool you should probably remove all + If you have managed config via files thus far, and want to + start using this tool to manage via DB settings instead, + that's fine - but after saving the settings via this tool + you should probably remove all [rattail.datasync] entries from your config file (and restart apps) so as to avoid confusion.

    -

    - Finally, you should know that this tool will - overwrite the entire - rattail.datasync namespace - within the DB settings.  In other words if you have - manually created any ${h.link_to("Raw Settings", url('settings'))} - within that namepsace, they will be lost when you save settings - with this tool. -

    + + + Use these Settings to configure watchers and consumers + + +
    @@ -83,7 +84,8 @@
    -
    +
    {{ props.row.enabled ? "Yes" : "No" }} - + @@ -397,15 +400,22 @@

    Misc.

    - - - - - + + + + + + + + @@ -417,6 +427,7 @@ 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 @@ -441,6 +452,7 @@ ThisPageData.editingConsumerRunas = null ThisPageData.editingConsumerEnabled = true + ThisPageData.supervisorProcessName = ${json.dumps(supervisor_process_name)|n} ThisPageData.restartCommand = ${json.dumps(restart_command)|n} ThisPage.computed.filteredProfilesData = function() { diff --git a/tailbone/templates/datasync/index.mako b/tailbone/templates/datasync/index.mako deleted file mode 100644 index fd7c39c6..00000000 --- a/tailbone/templates/datasync/index.mako +++ /dev/null @@ -1,19 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/master/index.mako" /> - -<%def name="context_menu_items()"> - ${parent.context_menu_items()} - % if request.has_perm('datasync_changes.list'): -
  • ${h.link_to("View DataSync Changes", url('datasyncchanges'))}
  • - % endif - - -<%def name="render_grid_component()"> - - TODO: this page coming soon... - - ${parent.render_grid_component()} - - - -${parent.body()} diff --git a/tailbone/templates/datasync/status.mako b/tailbone/templates/datasync/status.mako new file mode 100644 index 00000000..7a36bcd1 --- /dev/null +++ b/tailbone/templates/datasync/status.mako @@ -0,0 +1,121 @@ +## -*- coding: utf-8; -*- +<%inherit file="/page.mako" /> + +<%def name="content_title()"> + +<%def name="context_menu_items()"> + ${parent.context_menu_items()} + % if request.has_perm('datasync_changes.list'): +
  • ${h.link_to("View DataSync Changes", url('datasyncchanges'))}
  • + % endif + + +<%def name="page_content()"> + +
    + + % if process_info: +
    ${process_info['group']}:${process_info['name']}    ${process_info['statename']}    ${process_info['description']}
    + % else: +
    ${supervisor_error}
    + % endif + +
    + % if request.has_perm('datasync.restart'): + ${h.form(url('datasync.restart'), **{'@submit': 'restartProcess'})} + ${h.csrf_token(request)} + + {{ restartingProcess ? "Working, please wait..." : "Restart Process" }} + + ${h.end_form()} + % endif +
    + +
    +
    + + + + + + + + + + + + + + +<%def name="modify_this_page_vars()"> + + + + +${parent.body()} diff --git a/tailbone/util.py b/tailbone/util.py index c7eabae6..cd6c9237 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -127,6 +127,8 @@ def raw_datetime(config, value, verbose=False, as_date=False): 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: @@ -150,10 +152,8 @@ def raw_datetime(config, value, verbose=False, as_date=False): else: kwargs['c'] = six.text_type(value) - # avoid humanize error when calculating huge time diff - time_diff = None - if abs(time_ago.days) < 100000: - time_diff = humanize.naturaltime(time_ago) + time_diff = app.render_time_ago(time_ago, fallback=None) + if time_diff is not None: # by "verbose" we mean the result text to look like "YYYY-MM-DD (X days ago)" if verbose: diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py index 6c6db9f1..e55c4ee3 100644 --- a/tailbone/views/datasync.py +++ b/tailbone/views/datasync.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2021 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -31,11 +31,15 @@ import json import subprocess import logging +import six +import sqlalchemy as sa + from rattail.db import model -from rattail.datasync.config import load_profiles from rattail.datasync.util import purge_datasync_settings +from rattail.util import simple_error from tailbone.views import MasterView +from tailbone.util import raw_datetime log = logging.getLogger(__name__) @@ -49,11 +53,12 @@ class DataSyncThreadView(MasterView): index view, with status for each, sort of akin to "dashboard". For now it only serves the config view. """ - normalized_model_name = 'datasyncthread' model_title = "DataSync Thread" + model_title_plural = "DataSync Daemon" model_key = 'key' route_prefix = 'datasync' url_prefix = '/datasync' + listable = False viewable = False creatable = False editable = False @@ -68,26 +73,122 @@ class DataSyncThreadView(MasterView): 'key', ] + def __init__(self, request, context=None): + super(DataSyncThreadView, self).__init__(request, context=context) + app = self.get_rattail_app() + self.datasync_handler = app.get_datasync_handler() + + def status(self): + """ + View to list/filter/sort the model data. + + If this view receives a non-empty 'partial' parameter in the query + string, then the view will return the rendered grid only. Otherwise + returns the full page. + """ + app = self.get_rattail_app() + model = self.model + + try: + process_info = self.datasync_handler.get_supervisor_process_info() + supervisor_error = None + except Exception as error: + process_info = None + supervisor_error = simple_error(error) + + profiles = self.datasync_handler.get_configured_profiles() + + sql = """ + select source, consumer, count(*) as changes + from datasync_change + group by source, consumer + """ + result = self.Session.execute(sql) + all_changes = {} + for row in result: + all_changes[(row.source, row.consumer)] = row.changes + + watcher_data = [] + consumer_data = [] + now = app.localtime() + for key, profile in six.iteritems(profiles): + watcher = profile.watcher + + lastrun = self.datasync_handler.get_watcher_lastrun( + watcher.key, local=True, session=self.Session()) + + status = "okay" + if (now - lastrun).total_seconds() >= (watcher.delay * 2): + status = "dead watcher" + + watcher_data.append({ + 'key': watcher.key, + 'spec': profile.watcher_spec, + 'dbkey': watcher.dbkey, + 'delay': watcher.delay, + 'lastrun': raw_datetime(self.rattail_config, lastrun, verbose=True), + 'status': status, + }) + + for consumer in profile.consumers: + if consumer.watcher is watcher: + + changes = all_changes.get((watcher.key, consumer.key), 0) + if changes: + oldest = self.Session.query(sa.func.min(model.DataSyncChange.obtained))\ + .filter(model.DataSyncChange.source == watcher.key)\ + .filter(model.DataSyncChange.consumer == consumer.key)\ + .scalar() + oldest = app.localtime(oldest, from_utc=True) + changes = "{} (oldest from {})".format( + changes, + app.render_time_ago(now - oldest)) + + status = "okay" + if changes: + status = "processing changes" + + consumer_data.append({ + 'key': '{} -> {}'.format(watcher.key, consumer.key), + 'spec': consumer.spec, + 'dbkey': consumer.dbkey, + 'delay': consumer.delay, + 'changes': changes, + 'status': status, + }) + + watcher_data.sort(key=lambda w: w['key']) + consumer_data.sort(key=lambda c: c['key']) + + context = { + 'index_title': "DataSync Status", + 'index_url': None, + 'process_info': process_info, + 'supervisor_error': supervisor_error, + 'watcher_data': watcher_data, + 'consumer_data': consumer_data, + } + return self.render_to_response('status', context) + def get_data(self, session=None): data = [] return data def restart(self): - cmd = self.rattail_config.getlist('tailbone', 'datasync.restart', - # nb. simulate by default - default='/bin/sleep 3') - log.debug("attempting datasync restart with command: %s", cmd) - result = subprocess.call(cmd) - if result == 0: + try: + self.datasync_handler.restart_supervisor_process() self.request.session.flash("DataSync daemon has been restarted.") - else: - self.request.session.flash("DataSync daemon could not be restarted; result was: {}".format(result), 'error') - return self.redirect(self.request.get_referrer(default=self.request.route_url('datasyncchanges'))) + + except Exception as error: + self.request.session.flash(simple_error(error), 'error') + + return self.redirect(self.request.get_referrer( + default=self.request.route_url('datasyncchanges'))) def configure_get_context(self): - profiles = load_profiles(self.rattail_config, - include_disabled=True, - ignore_problems=True) + profiles = self.datasync_handler.get_configured_profiles( + include_disabled=True, + ignore_problems=True) profiles_data = [] for profile in sorted(profiles.values(), key=lambda p: p.key): @@ -125,7 +226,12 @@ class DataSyncThreadView(MasterView): return { 'profiles': profiles, 'profiles_data': profiles_data, - 'restart_command': self.rattail_config.get('tailbone', 'datasync.restart'), + 'use_profile_settings': self.rattail_config.getbool( + 'rattail.datasync', 'use_profile_settings'), + 'supervisor_process_name': self.rattail_config.get( + 'rattail.datasync', 'supervisor_process_name'), + 'restart_command': self.rattail_config.get( + 'tailbone', 'datasync.restart'), 'system_user': getpass.getuser(), } @@ -133,58 +239,67 @@ class DataSyncThreadView(MasterView): settings = [] watch = [] - for profile in json.loads(data['profiles']): - pkey = profile['key'] - if profile['enabled']: - watch.append(pkey) + 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'}) - settings.extend([ - {'name': 'rattail.datasync.{}.watcher'.format(pkey), - 'value': profile['watcher_spec']}, - {'name': 'rattail.datasync.{}.watcher.db'.format(pkey), - 'value': profile['watcher_dbkey']}, - {'name': 'rattail.datasync.{}.watcher.delay'.format(pkey), - 'value': profile['watcher_delay']}, - {'name': 'rattail.datasync.{}.watcher.retry_attempts'.format(pkey), - 'value': profile['watcher_retry_attempts']}, - {'name': 'rattail.datasync.{}.watcher.retry_delay'.format(pkey), - 'value': profile['watcher_retry_delay']}, - {'name': 'rattail.datasync.{}.consumers.runas'.format(pkey), - 'value': profile['watcher_default_runas']}, - ]) + if use_profile_settings: - consumers = [] - if profile['watcher_consumes_self']: - consumers = ['self'] - else: + for profile in json.loads(data['profiles']): + pkey = profile['key'] + if profile['enabled']: + watch.append(pkey) - for consumer in profile['consumers_data']: - ckey = consumer['key'] - if consumer['enabled']: - consumers.append(ckey) - settings.extend([ - {'name': 'rattail.datasync.{}.consumer.{}'.format(pkey, ckey), - 'value': consumer['consumer_spec']}, - {'name': 'rattail.datasync.{}.consumer.{}.db'.format(pkey, ckey), - 'value': consumer['consumer_dbkey']}, - {'name': 'rattail.datasync.{}.consumer.{}.delay'.format(pkey, ckey), - 'value': consumer['consumer_delay']}, - {'name': 'rattail.datasync.{}.consumer.{}.retry_attempts'.format(pkey, ckey), - 'value': consumer['consumer_retry_attempts']}, - {'name': 'rattail.datasync.{}.consumer.{}.retry_delay'.format(pkey, ckey), - 'value': consumer['consumer_retry_delay']}, - {'name': 'rattail.datasync.{}.consumer.{}.runas'.format(pkey, ckey), - 'value': consumer['consumer_runas']}, - ]) + settings.extend([ + {'name': 'rattail.datasync.{}.watcher'.format(pkey), + 'value': profile['watcher_spec']}, + {'name': 'rattail.datasync.{}.watcher.db'.format(pkey), + 'value': profile['watcher_dbkey']}, + {'name': 'rattail.datasync.{}.watcher.delay'.format(pkey), + 'value': profile['watcher_delay']}, + {'name': 'rattail.datasync.{}.watcher.retry_attempts'.format(pkey), + 'value': profile['watcher_retry_attempts']}, + {'name': 'rattail.datasync.{}.watcher.retry_delay'.format(pkey), + 'value': profile['watcher_retry_delay']}, + {'name': 'rattail.datasync.{}.consumers.runas'.format(pkey), + 'value': profile['watcher_default_runas']}, + ]) - settings.extend([ - {'name': 'rattail.datasync.{}.consumers'.format(pkey), - 'value': ', '.join(consumers)}, - ]) + consumers = [] + if profile['watcher_consumes_self']: + consumers = ['self'] + else: - if watch: - settings.append({'name': 'rattail.datasync.watch', - 'value': ', '.join(watch)}) + for consumer in profile['consumers_data']: + ckey = consumer['key'] + if consumer['enabled']: + consumers.append(ckey) + settings.extend([ + {'name': 'rattail.datasync.{}.consumer.{}'.format(pkey, ckey), + 'value': consumer['consumer_spec']}, + {'name': 'rattail.datasync.{}.consumer.{}.db'.format(pkey, ckey), + 'value': consumer['consumer_dbkey']}, + {'name': 'rattail.datasync.{}.consumer.{}.delay'.format(pkey, ckey), + 'value': consumer['consumer_delay']}, + {'name': 'rattail.datasync.{}.consumer.{}.retry_attempts'.format(pkey, ckey), + 'value': consumer['consumer_retry_attempts']}, + {'name': 'rattail.datasync.{}.consumer.{}.retry_delay'.format(pkey, ckey), + 'value': consumer['consumer_retry_delay']}, + {'name': 'rattail.datasync.{}.consumer.{}.runas'.format(pkey, ckey), + 'value': consumer['consumer_runas']}, + ]) + + settings.extend([ + {'name': 'rattail.datasync.{}.consumers'.format(pkey), + 'value': ', '.join(consumers)}, + ]) + + if watch: + settings.append({'name': 'rattail.datasync.watch', + 'value': ', '.join(watch)}) + + settings.append({'name': 'rattail.datasync.supervisor_process_name', + 'value': data['supervisor_process_name']}) settings.append({'name': 'tailbone.datasync.restart', 'value': data['restart_command']}) @@ -204,6 +319,25 @@ class DataSyncThreadView(MasterView): permission_prefix = cls.get_permission_prefix() route_prefix = cls.get_route_prefix() url_prefix = cls.get_url_prefix() + index_title = cls.get_index_title() + + # view status + config.add_tailbone_permission(permission_prefix, + '{}.status'.format(permission_prefix), + "View status for DataSync daemon") + # nb. simple 'datasync' route points to 'datasync.status' for now.. + config.add_route(route_prefix, + '{}/status/'.format(url_prefix)) + config.add_route('{}.status'.format(route_prefix), + '{}/status/'.format(url_prefix)) + config.add_view(cls, attr='status', + route_name=route_prefix, + permission='{}.status'.format(permission_prefix)) + config.add_view(cls, attr='status', + route_name='{}.status'.format(route_prefix), + permission='{}.status'.format(permission_prefix)) + config.add_tailbone_index_page(route_prefix, index_title, + '{}.status'.format(permission_prefix)) # restart config.add_tailbone_permission(permission_prefix, From 2375733d0f4168428dda30fa2a92f25afff75fe8 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 17 Aug 2022 18:19:37 -0500 Subject: [PATCH 095/978] Add first experiment with websockets, for datasync status page --- setup.py | 3 +- tailbone/app.py | 39 ++++++++ tailbone/asgi.py | 108 ++++++++++++++++++++++ tailbone/config.py | 7 +- tailbone/subscribers.py | 26 ++++-- tailbone/templates/datasync/status.mako | 49 ++++++++-- tailbone/views/asgi/__init__.py | 70 +++++++++++++++ tailbone/views/asgi/datasync.py | 113 ++++++++++++++++++++++++ tailbone/views/datasync.py | 16 +++- 9 files changed, 414 insertions(+), 17 deletions(-) create mode 100644 tailbone/asgi.py create mode 100644 tailbone/views/asgi/__init__.py create mode 100644 tailbone/views/asgi/datasync.py diff --git a/setup.py b/setup.py index e24e3f98..44a5910a 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2021 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -84,6 +84,7 @@ requires = [ # TODO: cornice<5 requires pyramid<2 (see above) 'pyramid<2', # 1.3b2 1.10.8 + 'asgiref', # 3.2.3 'colander', # 1.7.0 'ColanderAlchemy', # 0.3.3 'humanize', # 0.5.1 diff --git a/tailbone/app.py b/tailbone/app.py index 0f24f1fb..5eb0911e 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -129,6 +129,9 @@ 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 + config.registry['rattail_config'] = rattail_config + # configure user authorization / authentication config.set_authorization_policy(TailboneAuthorizationPolicy()) config.set_authentication_policy(SessionAuthenticationPolicy()) @@ -175,9 +178,45 @@ def make_pyramid_config(settings, configure_csrf=True): config.add_directive('add_tailbone_index_page', 'tailbone.app.add_index_page') config.add_directive('add_tailbone_config_page', 'tailbone.app.add_config_page') + config.add_directive('add_tailbone_websocket', 'tailbone.app.add_websocket') + return config +def add_websocket(config, name, view, attr=None): + """ + Register a websocket entry point for the app. + """ + def action(): + rattail_config = config.registry.settings['rattail_config'] + rattail_app = rattail_config.get_app() + + if isinstance(view, six.string_types): + view_callable = rattail_app.load_object(view) + else: + view_callable = view + view_callable = view_callable(config.registry) + if attr: + view_callable = getattr(view_callable, attr) + + path = '/ws/{}'.format(name) + + # register route + config.add_route('ws.{}'.format(name), + path, + static=True) + + # register view callable + websockets = config.registry.setdefault('tailbone_websockets', {}) + websockets[path] = view_callable + + config.action('tailbone-add-websocket', action, + # nb. since this action adds routes, it must happen + # sooner in the order than it normally would, hence + # we declare that + order=-20) + + def add_index_page(config, route_name, label, permission): """ Register a config page for the app. diff --git a/tailbone/asgi.py b/tailbone/asgi.py new file mode 100644 index 00000000..f2146577 --- /dev/null +++ b/tailbone/asgi.py @@ -0,0 +1,108 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2022 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 . +# +################################################################################ +""" +ASGI App Utilities +""" + +from __future__ import unicode_literals, absolute_import + +import os +import logging + +import six +from six.moves import configparser + +from rattail.util import load_object + +from asgiref.wsgi import WsgiToAsgi + + +log = logging.getLogger(__name__) + + +class TailboneWsgiToAsgi(WsgiToAsgi): + """ + Custom WSGI -> ASGI wrapper, to add routing for websockets. + """ + + async def __call__(self, scope, *args, **kwargs): + protocol = scope['type'] + path = scope['path'] + + if protocol == 'websocket': + websockets = self.wsgi_application.registry.get( + 'tailbone_websockets', {}) + if path in websockets: + await websockets[path](scope, *args, **kwargs) + + try: + await super().__call__(scope, *args, **kwargs) + except ValueError as e: + # The developer may wish to improve handling of this exception. + # See https://github.com/Pylons/pyramid_cookbook/issues/225 and + # https://asgi.readthedocs.io/en/latest/specs/www.html#websocket + pass + except Exception as e: + raise e + + +def make_asgi_app(main_app=None): + """ + This function returns an ASGI application. + """ + path = os.environ.get('TAILBONE_ASGI_CONFIG') + if not path: + raise RuntimeError("You must define TAILBONE_ASGI_CONFIG env variable.") + + # make a config parser good enough to load pyramid settings + configdir = os.path.dirname(path) + parser = configparser.ConfigParser(defaults={'__file__': path, + 'here': configdir}) + + # read the config file + parser.read(path) + + # parse the settings needed for pyramid app + settings = dict(parser.items('app:main')) + + if isinstance(main_app, six.string_types): + make_wsgi_app = load_object(main_app) + elif callable(main_app): + make_wsgi_app = main_app + else: + if main_app: + log.warning("specified main app of unknown type: %s", main_app) + make_wsgi_app = load_object('tailbone.app:main') + + # construct a pyramid app "per usual" + app = make_wsgi_app({}, **settings) + + # then wrap it with ASGI + return TailboneWsgiToAsgi(app) + + +def asgi_main(): + """ + This function returns an ASGI application. + """ + return make_asgi_app() diff --git a/tailbone/config.py b/tailbone/config.py index 90799016..4c393b49 100644 --- a/tailbone/config.py +++ b/tailbone/config.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2021 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -67,3 +67,8 @@ def global_help_url(config): def protected_usernames(config): return config.getlist('tailbone', 'protected_usernames') + + +def should_expose_websockets(config): + return config.getbool('tailbone', 'expose_websockets', + usedb=False, default=False) diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index e830f1f4..6e8e2d33 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -31,7 +31,6 @@ import json import datetime import rattail -from rattail.db import model import colander import deform @@ -41,7 +40,7 @@ from webhelpers2.html import tags import tailbone from tailbone import helpers from tailbone.db import Session -from tailbone.config import csrf_header_name +from tailbone.config import csrf_header_name, should_expose_websockets from tailbone.menus import make_simple_menus from tailbone.util import should_use_buefy @@ -72,13 +71,17 @@ def new_request(event): if rattail_config: request.rattail_config = rattail_config - request.user = None - uuid = request.authenticated_userid - if uuid: - request.user = Session.query(model.User).get(uuid) - if request.user: - # assign user to the session, for sake of versioning - Session().set_continuum_user(request.user) + def user(request): + user = None + uuid = request.authenticated_userid + if uuid: + model = request.rattail_config.get_model() + user = Session.query(model.User).get(uuid) + if user: + Session().set_continuum_user(user) + return user + + request.set_property(user, reify=True) # assign client IP address to the session, for sake of versioning Session().continuum_remote_addr = request.client_addr @@ -99,6 +102,7 @@ def before_render(event): """ request = event.get('request') or threadlocal.get_current_request() + rattail_config = request.rattail_config renderer_globals = event renderer_globals['rattail_app'] = request.rattail_config.get_app() @@ -183,6 +187,9 @@ def before_render(event): renderer_globals['filter_fieldname_width'] = widths[0] renderer_globals['filter_verb_width'] = widths[1] + # declare global support for websockets, or lack thereof + renderer_globals['expose_websockets'] = should_expose_websockets(rattail_config) + def add_inbox_count(event): """ @@ -196,6 +203,7 @@ def add_inbox_count(event): if request.user: renderer_globals = event 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/templates/datasync/status.mako b/tailbone/templates/datasync/status.mako index 7a36bcd1..452ba248 100644 --- a/tailbone/templates/datasync/status.mako +++ b/tailbone/templates/datasync/status.mako @@ -11,14 +11,17 @@ <%def name="page_content()"> + % if expose_websockets: + + Server connection was broken - please refresh page to see accurate status! + + % endif
    - % if process_info: -
    ${process_info['group']}:${process_info['name']}    ${process_info['statename']}    ${process_info['description']}
    - % else: -
    ${supervisor_error}
    - % endif +
    {{ processDescription }}
    % if request.has_perm('datasync.restart'): @@ -106,6 +109,17 @@ <%def name="modify_this_page_vars()"> diff --git a/tailbone/views/asgi/__init__.py b/tailbone/views/asgi/__init__.py new file mode 100644 index 00000000..a3450c11 --- /dev/null +++ b/tailbone/views/asgi/__init__.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2022 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 . +# +################################################################################ +""" +ASGI Views +""" + +from __future__ import unicode_literals, absolute_import + +import http.cookies + +from beaker.cache import clsmap +from beaker.session import SessionObject, SignedCookie + + +class WebsocketView(object): + + def __init__(self, registry): + self.registry = registry + + async def get_user_session(self, scope): + settings = self.registry.settings + beaker_key = settings['beaker.session.key'] + beaker_secret = settings['beaker.session.secret'] + beaker_type = settings['beaker.session.type'] + beaker_data_dir = settings['beaker.session.data_dir'] + beaker_lock_dir = settings['beaker.session.lock_dir'] + + # get ahold of session identifier cookie + headers = dict(scope['headers']) + cookie = headers.get(b'cookie') + if not cookie: + return + cookie = cookie.decode('utf_8') + cookie = http.cookies.SimpleCookie(cookie) + morsel = cookie[beaker_key] + + # simulate pyramid_beaker logic to get at the session + cookieheader = morsel.output(header='') + cookie = SignedCookie(beaker_secret, input=cookieheader) + session_id = cookie[beaker_key].value + request = {'cookie': cookieheader} + session = SessionObject( + request, + id=session_id, + key=beaker_key, + namespace_class=clsmap[beaker_type], + data_dir=beaker_data_dir, + lock_dir=beaker_lock_dir) + + return session diff --git a/tailbone/views/asgi/datasync.py b/tailbone/views/asgi/datasync.py new file mode 100644 index 00000000..ffb63174 --- /dev/null +++ b/tailbone/views/asgi/datasync.py @@ -0,0 +1,113 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2022 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 . +# +################################################################################ +""" +DataSync Views +""" + +from __future__ import unicode_literals, absolute_import + +import asyncio +import json + +from tailbone.views.asgi import WebsocketView + + +class DatasyncWS(WebsocketView): + + async def status(self, scope, receive, send): + rattail_config = self.registry['rattail_config'] + app = rattail_config.get_app() + model = app.model + auth_handler = app.get_auth_handler() + datasync_handler = app.get_datasync_handler() + + authorized = False + user_session = await self.get_user_session(scope) + if user_session: + user_uuid = user_session.get('auth.userid') + session = app.make_session() + + user = None + if user_uuid: + user = session.query(model.User).get(user_uuid) + + # figure out if user is authorized for this websocket + permission = 'datasync.status' + authorized = auth_handler.has_permission(session, user, permission) + session.close() + + # wait for client to connect + message = await receive() + assert message['type'] == 'websocket.connect' + + # allow or deny access, per authorization + if authorized: + await send({'type': 'websocket.accept'}) + else: # forbidden + await send({'type': 'websocket.close'}) + return + + # this tracks when client disconnects + state = {'disconnected': False} + + async def wait_for_disconnect(): + message = await receive() + if message['type'] == 'websocket.disconnect': + state['disconnected'] = True + + # watch for client disconnect, while we do other things + asyncio.create_task(wait_for_disconnect()) + + # do the rest forever, until client disconnects + while not state['disconnected']: + + # give client latest supervisor process info + info = datasync_handler.get_supervisor_process_info() + await send({'type': 'websocket.send', + 'subtype': 'datasync.supervisor_process_info', + 'text': json.dumps(info)}) + + # pause for 1 second + await asyncio.sleep(1) + + @classmethod + def defaults(cls, config): + cls._defaults(config) + + @classmethod + def _defaults(cls, config): + + # status + config.add_tailbone_websocket('datasync.status', + cls, attr='status') + + +def defaults(config, **kwargs): + base = globals() + + DatasyncWS = kwargs.get('DatasyncWS', base['DatasyncWS']) + DatasyncWS.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py index e55c4ee3..93302fea 100644 --- a/tailbone/views/datasync.py +++ b/tailbone/views/datasync.py @@ -40,6 +40,7 @@ from rattail.util import simple_error from tailbone.views import MasterView from tailbone.util import raw_datetime +from tailbone.config import should_expose_websockets log = logging.getLogger(__name__) @@ -400,6 +401,19 @@ class DataSyncChangeView(MasterView): DataSyncChangesView = DataSyncChangeView -def includeme(config): +def defaults(config, **kwargs): + base = globals() + rattail_config = config.registry['rattail_config'] + + DataSyncThreadView = kwargs.get('DataSyncThreadView', base['DataSyncThreadView']) DataSyncThreadView.defaults(config) + + DataSyncChangeView = kwargs.get('DataSyncChangeView', base['DataSyncChangeView']) DataSyncChangeView.defaults(config) + + if should_expose_websockets(rattail_config): + config.include('tailbone.views.asgi.datasync') + + +def includeme(config): + defaults(config) From ed55fbca9e01fedddc475a12aa01da5e315faa30 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 17 Aug 2022 18:44:10 -0500 Subject: [PATCH 096/978] Log a warning if can't get supervisor process info --- tailbone/views/datasync.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py index 93302fea..20f970e4 100644 --- a/tailbone/views/datasync.py +++ b/tailbone/views/datasync.py @@ -94,6 +94,7 @@ class DataSyncThreadView(MasterView): process_info = self.datasync_handler.get_supervisor_process_info() supervisor_error = None except Exception as error: + log.warning("failed to get supervisor process info", exc_info=True) process_info = None supervisor_error = simple_error(error) From 5fb99c54c9c30d7e2930d65b08857f3772126aba Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 17 Aug 2022 19:06:02 -0500 Subject: [PATCH 097/978] Fix initial datasync status display when supervisor error occurs --- tailbone/templates/datasync/status.mako | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tailbone/templates/datasync/status.mako b/tailbone/templates/datasync/status.mako index 452ba248..c80615ce 100644 --- a/tailbone/templates/datasync/status.mako +++ b/tailbone/templates/datasync/status.mako @@ -11,7 +11,7 @@ <%def name="page_content()"> - % if expose_websockets: + % if expose_websockets and not supervisor_error: @@ -21,7 +21,11 @@
    -
    {{ processDescription }}
    + % if supervisor_error: +
    ${supervisor_error}
    + % else: +
    {{ processDescription }}
    + % endif
    % if request.has_perm('datasync.restart'): @@ -128,7 +132,7 @@ this.restartingProcess = true } - % if expose_websockets: + % if expose_websockets and not supervisor_error: ThisPageData.ws = null ThisPageData.websocketClosed = false From 2fde1db83cc0fb4762d45154e4c85dbe0b2e4080 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 17 Aug 2022 21:08:54 -0500 Subject: [PATCH 098/978] Allow user feedback to request email reply back --- tailbone/forms/common.py | 5 ++++- .../themes/falafel/js/tailbone.feedback.js | 9 +++++++++ tailbone/templates/themes/falafel/base.mako | 20 ++++++++++++++++++- tailbone/views/common.py | 6 +++--- 4 files changed, 35 insertions(+), 5 deletions(-) diff --git a/tailbone/forms/common.py b/tailbone/forms/common.py index 26934479..4d58b943 100644 --- a/tailbone/forms/common.py +++ b/tailbone/forms/common.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2021 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -58,4 +58,7 @@ class Feedback(colander.Schema): user_name = colander.SchemaNode(colander.String(), missing=colander.null) + please_reply_to = colander.SchemaNode(colander.String(), + missing=colander.null) + message = colander.SchemaNode(colander.String()) diff --git a/tailbone/static/themes/falafel/js/tailbone.feedback.js b/tailbone/static/themes/falafel/js/tailbone.feedback.js index 11745ab4..6f687b80 100644 --- a/tailbone/static/themes/falafel/js/tailbone.feedback.js +++ b/tailbone/static/themes/falafel/js/tailbone.feedback.js @@ -5,6 +5,12 @@ let FeedbackForm = { mixins: [FormPosterMixin], methods: { + pleaseReplyChanged(value) { + this.$nextTick(() => { + this.$refs.userEmail.focus() + }) + }, + showFeedback() { this.showDialog = true this.$nextTick(function() { @@ -18,6 +24,7 @@ let FeedbackForm = { referrer: this.referrer, user: this.userUUID, user_name: this.userName, + please_reply_to: this.pleaseReply ? this.userEmail : null, message: this.message.trim(), } @@ -41,5 +48,7 @@ let FeedbackFormData = { referrer: null, userUUID: null, userName: null, + pleaseReply: false, + userEmail: null, showDialog: false, } diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index 9bd092ab..9b9236fe 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -487,7 +487,7 @@
    diff --git a/tailbone/views/common.py b/tailbone/views/common.py index 1a0567e5..c2ec897f 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -29,9 +29,7 @@ from __future__ import unicode_literals, absolute_import import os import six -from rattail.db import model from rattail.batch import consume_batch_id -from rattail.mail import send_email from rattail.util import OrderedDict, simple_error, import_module_path from rattail.files import resource_path @@ -172,6 +170,8 @@ class CommonView(View): """ Generic view to handle the user feedback form. """ + app = self.get_rattail_app() + model = self.model schema = Feedback().bind(session=Session()) form = forms.Form(schema=schema, request=self.request) if form.validate(newstyle=True): @@ -180,7 +180,7 @@ class CommonView(View): data['user'] = Session.query(model.User).get(data['user']) data['user_url'] = self.request.route_url('users.view', uuid=data['user'].uuid) data['client_ip'] = self.request.client_addr - send_email(self.rattail_config, 'user_feedback', data=data) + app.send_email('user_feedback', data=data) return {'ok': True} return {'error': "Form did not validate!"} From d8de36b5ac1e0ae71fdb4fa1d8c5aed66c326da2 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 17 Aug 2022 21:30:39 -0500 Subject: [PATCH 099/978] 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 f5b143c2..a7c0c344 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,18 @@ CHANGELOG ========= +0.8.248 (2022-08-17) +-------------------- + +* Redirect to custom index URL when user cancels new custorder entry. + +* Add ``get_next_url_after_submit_new_order()`` for customer orders. + +* Add first experiment with websockets, for datasync status page. + +* Allow user feedback to request email reply back. + + 0.8.247 (2022-08-14) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index b2022e77..c45030ec 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.247' +__version__ = '0.8.248' From 9de35a6e8b0dbf555cc336d3f44f2d261917ce56 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 17 Aug 2022 22:59:50 -0500 Subject: [PATCH 100/978] Add brief delay before declaring websocket broken --- tailbone/templates/datasync/status.mako | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tailbone/templates/datasync/status.mako b/tailbone/templates/datasync/status.mako index c80615ce..29ca00cf 100644 --- a/tailbone/templates/datasync/status.mako +++ b/tailbone/templates/datasync/status.mako @@ -13,7 +13,7 @@ <%def name="page_content()"> % if expose_websockets and not supervisor_error: Server connection was broken - please refresh page to see accurate status! @@ -135,7 +135,7 @@ % if expose_websockets and not supervisor_error: ThisPageData.ws = null - ThisPageData.websocketClosed = false + ThisPageData.websocketBroken = false ThisPage.mounted = function() { @@ -147,7 +147,14 @@ let that = this this.ws.onclose = (event) => { - that.websocketClosed = true + // websocket closing means 1 of 2 things: + // - user navigated away from page intentionally + // - server connection was broken somehow + // only one of those is "bad" and we only want to + // display warning in 2nd case. so we simply use a + // brief delay to "rule out" the 1st scenario + setTimeout(() => { that.websocketBroken = true }, + 3000) } this.ws.onmessage = (event) => { From d23e5d169adeb8aa1ccac649f9f6e7c79d9ea8f5 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 18 Aug 2022 15:11:09 -0500 Subject: [PATCH 101/978] Add basic views for Luigi / overnight tasks --- tailbone/templates/luigi/configure.mako | 129 +++++++++++++++++++ tailbone/templates/luigi/index.mako | 126 ++++++++++++++++++ tailbone/views/datasync.py | 2 - tailbone/views/luigi.py | 164 ++++++++++++++++++++++++ tailbone/views/master.py | 5 + 5 files changed, 424 insertions(+), 2 deletions(-) create mode 100644 tailbone/templates/luigi/configure.mako create mode 100644 tailbone/templates/luigi/index.mako create mode 100644 tailbone/views/luigi.py diff --git a/tailbone/templates/luigi/configure.mako b/tailbone/templates/luigi/configure.mako new file mode 100644 index 00000000..b8fba490 --- /dev/null +++ b/tailbone/templates/luigi/configure.mako @@ -0,0 +1,129 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="form_content()"> + ${h.hidden('overnight_tasks', **{':value': 'JSON.stringify(overnightTasks)'})} + +

    Overnight Tasks

    +
    + + + + + +
    + + New Task + + + + + + +
    +
    + +

    Luigi Proper

    +
    + + + + + + + + + + + + + + + + +
    + + + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + + + + +${parent.body()} diff --git a/tailbone/templates/luigi/index.mako b/tailbone/templates/luigi/index.mako new file mode 100644 index 00000000..16ea3489 --- /dev/null +++ b/tailbone/templates/luigi/index.mako @@ -0,0 +1,126 @@ +## -*- coding: utf-8; -*- +<%inherit file="/page.mako" /> + +<%def name="title()">Luigi Jobs + +<%def name="page_content()"> +
    +
    + +
    + + + Luigi Task Visualiser + + + + Luigi Task History + + + % if master.has_perm('restart_scheduler'): + ${h.form(url('{}.restart_scheduler'.format(route_prefix)), **{'@submit': 'submitRestartSchedulerForm'})} + ${h.csrf_token(request)} + + {{ restartSchedulerFormSubmitting ? "Working, please wait..." : "Restart Luigi Scheduler" }} + + ${h.end_form()} + % endif +
    + + % if master.has_perm('launch'): +

    Overnight Tasks

    + % for task in overnight_tasks: + + + % endfor + % endif + +
    + + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + % if master.has_perm('restart_scheduler'): + + % endif + + +<%def name="finalize_this_page_vars()"> + ${parent.finalize_this_page_vars()} + % if master.has_perm('launch'): + + % endif + + +<%def name="render_this_page_template()"> + ${parent.render_this_page_template()} + % if master.has_perm('launch'): + + % endif + + + +${parent.body()} diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py index 20f970e4..0f198795 100644 --- a/tailbone/views/datasync.py +++ b/tailbone/views/datasync.py @@ -26,7 +26,6 @@ DataSync Views from __future__ import unicode_literals, absolute_import -import getpass import json import subprocess import logging @@ -234,7 +233,6 @@ class DataSyncThreadView(MasterView): 'rattail.datasync', 'supervisor_process_name'), 'restart_command': self.rattail_config.get( 'tailbone', 'datasync.restart'), - 'system_user': getpass.getuser(), } def configure_gather_settings(self, data): diff --git a/tailbone/views/luigi.py b/tailbone/views/luigi.py new file mode 100644 index 00000000..6b0b60e3 --- /dev/null +++ b/tailbone/views/luigi.py @@ -0,0 +1,164 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2022 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 . +# +################################################################################ +""" +Views for Luigi +""" + +from __future__ import unicode_literals, absolute_import + +import json + +from rattail.util import simple_error + +from tailbone.views import MasterView + + +class LuigiJobView(MasterView): + """ + Simple views for Luigi jobs. + """ + normalized_model_name = 'luigijobs' + model_key = 'jobname' + model_title = "Luigi Job" + route_prefix = 'luigi' + url_prefix = '/luigi' + + viewable = False + creatable = False + editable = False + deletable = False + configurable = True + + def __init__(self, request, context=None): + super(LuigiJobView, self).__init__(request, context=context) + app = self.get_rattail_app() + self.luigi_handler = app.get_luigi_handler() + + def index(self): + luigi_url = self.rattail_config.get('luigi', 'url') + history_url = '{}/history'.format(luigi_url.rstrip('/')) if luigi_url else None + return self.render_to_response('index', { + 'use_buefy': self.get_use_buefy(), + 'index_url': None, + 'luigi_url': luigi_url, + 'luigi_history_url': history_url, + 'overnight_tasks': self.luigi_handler.get_all_overnight_tasks(), + }) + + def launch(self): + key = self.request.POST['job'] + assert key + self.luigi_handler.restart_overnight_task(key) + self.request.session.flash("Scheduled overnight task for immediate launch: {}".format(key)) + return self.redirect(self.get_index_url()) + + def restart_scheduler(self): + try: + self.luigi_handler.restart_supervisor_process() + self.request.session.flash("Luigi scheduler has been restarted.") + + except Exception as error: + self.request.session.flash(simple_error(error), 'error') + + return self.redirect(self.request.get_referrer( + default=self.get_index_url())) + + def configure_get_simple_settings(self): + return [ + + # luigi proper + {'section': 'luigi', + 'option': 'url'}, + {'section': 'luigi', + 'option': 'scheduler.supervisor_process_name'}, + {'section': 'luigi', + 'option': 'scheduler.restart_command'}, + + ] + + def configure_get_context(self, **kwargs): + context = super(LuigiJobView, self).configure_get_context(**kwargs) + context['overnight_tasks'] = self.luigi_handler.get_all_overnight_tasks() + return context + + def configure_gather_settings(self, data): + settings = super(LuigiJobView, self).configure_gather_settings(data) + + keys = [] + for task in json.loads(data['overnight_tasks']): + keys.append(task['key']) + + if keys: + settings.append({'name': 'luigi.overnight_tasks', + 'value': ', '.join(keys)}) + + return settings + + def configure_remove_settings(self): + super(LuigiJobView, self).configure_remove_settings() + self.luigi_handler.purge_luigi_settings(self.Session()) + + @classmethod + def defaults(cls, config): + cls._defaults(config) + cls._luigi_defaults(config) + + @classmethod + def _luigi_defaults(cls, config): + route_prefix = cls.get_route_prefix() + permission_prefix = cls.get_permission_prefix() + url_prefix = cls.get_url_prefix() + model_title_plural = cls.get_model_title_plural() + + # launch job + config.add_tailbone_permission(permission_prefix, + '{}.launch'.format(permission_prefix), + label="Launch any Luigi job") + config.add_route('{}.launch'.format(route_prefix), + '{}/launch'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='launch', + route_name='{}.launch'.format(route_prefix), + permission='{}.launch'.format(permission_prefix)) + + # restart luigid scheduler + config.add_tailbone_permission(permission_prefix, + '{}.restart_scheduler'.format(permission_prefix), + label="Restart the Luigi Scheduler daemon") + config.add_route('{}.restart_scheduler'.format(route_prefix), + '{}/restart-scheduler'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='restart_scheduler', + route_name='{}.restart_scheduler'.format(route_prefix), + permission='{}.restart_scheduler'.format(permission_prefix)) + + +def defaults(config, **kwargs): + base = globals() + + LuigiJobView = kwargs.get('LuigiJobView', base['LuigiJobView']) + LuigiJobView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 1915ac83..1906d620 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -29,6 +29,7 @@ from __future__ import unicode_literals, absolute_import import os import csv import datetime +import getpass import shutil import tempfile import logging @@ -4324,6 +4325,10 @@ class MasterView(View): context = self.configure_get_context() return self.render_to_response('configure', context) + def template_kwargs_configure(self, **kwargs): + kwargs['system_user'] = getpass.getuser() + return kwargs + def configure_flash_settings_saved(self): self.request.session.flash("Settings have been saved.") From 89da6ae5011672ec84a0238e0f40a08ceaf5075b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 18 Aug 2022 17:27:30 -0500 Subject: [PATCH 102/978] Expose setting for auto-correct when receiving from invoice --- tailbone/templates/receiving/configure.mako | 7 +++++++ tailbone/views/purchasing/receiving.py | 3 +++ 2 files changed, 10 insertions(+) diff --git a/tailbone/templates/receiving/configure.mako b/tailbone/templates/receiving/configure.mako index 36ff5c39..f4a697f4 100644 --- a/tailbone/templates/receiving/configure.mako +++ b/tailbone/templates/receiving/configure.mako @@ -115,6 +115,13 @@ + + Try to auto-correct "case vs. unit" mistakes from invoice parser + +

    Mobile Interface

    diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index a7286b07..af96448f 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -1928,6 +1928,9 @@ class ReceivingBatchView(PurchasingBatchView): {'section': 'rattail.batch', 'option': 'purchase.allow_expired_credits', 'type': bool}, + {'section': 'rattail.batch', + 'option': 'purchase.receiving.should_autofix_invoice_case_vs_unit', + 'type': bool}, # mobile interface {'section': 'rattail.batch', From 8afc3766365b503b7b4a9627dced0db8470fbc3b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 18 Aug 2022 17:29:13 -0500 Subject: [PATCH 103/978] 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 a7c0c344..b3631727 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.8.249 (2022-08-18) +-------------------- + +* Add brief delay before declaring websocket broken. + +* Add basic views for Luigi / overnight tasks. + +* Expose setting for auto-correct when receiving from invoice. + + 0.8.248 (2022-08-17) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index c45030ec..5e741492 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.248' +__version__ = '0.8.249' From 7d72a43ecd68123486564a27176cfd3a43b495bf Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 18 Aug 2022 18:19:54 -0500 Subject: [PATCH 104/978] Use pytest instead of nosetests, for tox runs --- setup.py | 2 ++ tox.ini | 19 +++++++------------ 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/setup.py b/setup.py index 44a5910a..1f65ca97 100644 --- a/setup.py +++ b/setup.py @@ -128,6 +128,8 @@ extras = { 'fixture', # 1.5 'mock', # 1.0.1 'nose', # 1.3.0 + 'pytest', # 4.6.11 + 'pytest-cov', # 2.12.1 ], } diff --git a/tox.ini b/tox.ini index 6dd5ada3..9cda1c76 100644 --- a/tox.ini +++ b/tox.ini @@ -1,35 +1,30 @@ + [tox] -envlist = py27, py35 +envlist = py27, py35, py37 [testenv] -deps = - coverage - fixture - mock - nose commands = pip install --upgrade pip + pip install --upgrade setuptools wheel pip install --upgrade --upgrade-strategy eager Tailbone rattail[auth,bouncer,db] rattail-tempmon - nosetests {posargs} + pytest {posargs} [testenv:py27] commands = pip install --upgrade pip + pip install --upgrade setuptools wheel pip install --upgrade --upgrade-strategy eager Tailbone rattail[auth,bouncer,db] rattail-tempmon SQLAlchemy-Utils<0.36.7 SQLAlchemy-Continuum<1.3.12 - nosetests {posargs} + pytest {posargs} [testenv:coverage] basepython = python3 commands = pip install --upgrade pip pip install --upgrade --upgrade-strategy eager Tailbone rattail[auth,bouncer,db] rattail-tempmon - nosetests {posargs:--with-coverage --cover-html-dir={envtmpdir}/coverage} + pytest --cov=tailbone --cov-report=html [testenv:docs] basepython = python3 -deps = - Sphinx - sphinx-rtd-theme changedir = docs commands = pip install --upgrade pip From 9566a882b58549c81a011ea54e7dff2b1ff92bd6 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 18 Aug 2022 18:23:30 -0500 Subject: [PATCH 105/978] Install dependencies when running tests etc. via tox --- tox.ini | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tox.ini b/tox.ini index 9cda1c76..401b5e62 100644 --- a/tox.ini +++ b/tox.ini @@ -6,21 +6,21 @@ envlist = py27, py35, py37 commands = pip install --upgrade pip pip install --upgrade setuptools wheel - pip install --upgrade --upgrade-strategy eager Tailbone rattail[auth,bouncer,db] rattail-tempmon + pip install --upgrade --upgrade-strategy eager Tailbone[tests] rattail[auth,bouncer,db] rattail-tempmon pytest {posargs} [testenv:py27] commands = pip install --upgrade pip pip install --upgrade setuptools wheel - pip install --upgrade --upgrade-strategy eager Tailbone rattail[auth,bouncer,db] rattail-tempmon SQLAlchemy-Utils<0.36.7 SQLAlchemy-Continuum<1.3.12 + pip install --upgrade --upgrade-strategy eager Tailbone[tests] rattail[auth,bouncer,db] rattail-tempmon SQLAlchemy-Utils<0.36.7 SQLAlchemy-Continuum<1.3.12 pytest {posargs} [testenv:coverage] basepython = python3 commands = pip install --upgrade pip - pip install --upgrade --upgrade-strategy eager Tailbone rattail[auth,bouncer,db] rattail-tempmon + pip install --upgrade --upgrade-strategy eager Tailbone[tests] rattail[auth,bouncer,db] rattail-tempmon pytest --cov=tailbone --cov-report=html [testenv:docs] @@ -28,5 +28,5 @@ basepython = python3 changedir = docs commands = pip install --upgrade pip - pip install --upgrade --upgrade-strategy eager Tailbone rattail[auth,bouncer,db] rattail-tempmon + pip install --upgrade --upgrade-strategy eager Tailbone[docs] rattail[auth,bouncer,db] rattail-tempmon sphinx-build -b html -d {envtmpdir}/doctrees -W -T . {envtmpdir}/docs From 8470126918903f98deea2d1a4f3d951c84031ad2 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 18 Aug 2022 19:22:04 -0500 Subject: [PATCH 106/978] Add `render_person_profile()` method to MasterView --- tailbone/views/master.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 1906d620..62502035 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -897,6 +897,14 @@ class MasterView(View): url = self.request.route_url('people.view', uuid=person.uuid) return tags.link_to(text, url) + def render_person_profile(self, obj, field): + person = getattr(obj, field) + if not person: + return "" + text = six.text_type(person) + url = self.request.route_url('people.view_profile', uuid=person.uuid) + return tags.link_to(text, url) + def render_user(self, obj, field): user = getattr(obj, field) if not user: From db3f215ebeb0ef8ddf483e373372a16049db824b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 19 Aug 2022 17:20:01 -0500 Subject: [PATCH 107/978] Add way to declare failure for an upgrade doesn't really cancel it, since Tailbone isn't actually tracking the subprocess etc. but saves a step when something goes off the rails --- tailbone/templates/upgrades/view.mako | 38 ++++++++++++++++ tailbone/views/upgrades.py | 65 ++++++++++++++++++++++++--- 2 files changed, 97 insertions(+), 6 deletions(-) diff --git a/tailbone/templates/upgrades/view.mako b/tailbone/templates/upgrades/view.mako index 03fd9b6b..6a027921 100644 --- a/tailbone/templates/upgrades/view.mako +++ b/tailbone/templates/upgrades/view.mako @@ -38,6 +38,27 @@ % endif +<%def name="render_this_page()"> + ${parent.render_this_page()} + + % if master.has_perm('execute'): + ${h.form(master.get_action_url('declare_failure', instance), ref='declareFailureForm')} + ${h.csrf_token(request)} + ${h.end_form()} + % endif + + +<%def name="render_buefy_form()"> +
    + <${form.component} + % if master.has_perm('execute'): + @declare-failure="declareFailure" + % endif + > + +
    + + <%def name="render_form_buttons()"> % if not instance.executed and instance.status_code == enum.UPGRADE_STATUS_PENDING and request.has_perm('{}.execute'.format(permission_prefix)):
    @@ -81,6 +102,23 @@ this.formButtonText = "Working, please wait..." } + % if master.has_perm('execute'): + + TailboneFormData.declareFailureSubmitting = false + + TailboneForm.methods.declareFailureClick = function() { + if (confirm("Really declare this upgrade a failure?")) { + this.declareFailureSubmitting = true + this.$emit('declare-failure') + } + } + + ThisPage.methods.declareFailure = function() { + this.$refs.declareFailureForm.submit() + } + + % endif + diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index ff4de768..2e7c2fc4 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.py @@ -162,7 +162,7 @@ class UpgradeView(MasterView): f.remove_field('status_code') else: f.set_enum('status_code', self.enum.UPGRADE_STATUS) - # f.set_readonly('status_code') + f.set_renderer('status_code', self.render_status_code) # executing if not self.editing: @@ -205,6 +205,33 @@ class UpgradeView(MasterView): f.remove_field('package_diff') f.remove_field('exit_code') + def render_status_code(self, upgrade, field): + code = getattr(upgrade, field) + text = self.enum.UPGRADE_STATUS[code] + + if self.get_use_buefy(): + if code == self.enum.UPGRADE_STATUS_EXECUTING: + + text = HTML.tag('span', c=[text]) + + button = HTML.tag('b-button', + type='is-warning', + icon_pack='fas', + icon_left='sad-tear', + c=['{{ declareFailureSubmitting ? "Working, please wait..." : "Declare Failure" }}'], + **{':disabled': 'declareFailureSubmitting', + '@click': 'declareFailureClick'}) + + return HTML.tag('div', class_='level', c=[ + HTML.tag('div', class_='level-left', c=[ + HTML.tag('div', class_='level-item', c=[text]), + HTML.tag('div', class_='level-item', c=[button]), + ]), + ]) + + # just show status per normal + return text + def configure_clone_form(self, f): f.fields = ['description', 'notes', 'enabled'] @@ -446,23 +473,49 @@ class UpgradeView(MasterView): return data + def declare_failure(self): + upgrade = self.get_instance() + if upgrade.executing and upgrade.status_code == self.enum.UPGRADE_STATUS_EXECUTING: + upgrade.executing = False + upgrade.status_code = self.enum.UPGRADE_STATUS_FAILED + self.request.session.flash("Upgrade was declared a failure.", 'warning') + else: + self.request.session.flash("Upgrade was not currently executing! " + "So it was not declared a failure.", + 'error') + return self.redirect(self.get_action_url('view', upgrade)) + def delete_instance(self, upgrade): self.handler.delete_files(upgrade) super(UpgradeView, self).delete_instance(upgrade) @classmethod def defaults(cls, config): + cls._defaults(config) + cls._upgrade_defaults(config) + + @classmethod + def _upgrade_defaults(cls, config): route_prefix = cls.get_route_prefix() - url_prefix = cls.get_url_prefix() permission_prefix = cls.get_permission_prefix() + instance_url_prefix = cls.get_instance_url_prefix() model_key = cls.get_model_key() # execution progress - config.add_route('{}.execute_progress'.format(route_prefix), '{}/{{{}}}/execute/progress'.format(url_prefix, model_key)) - config.add_view(cls, attr='execute_progress', route_name='{}.execute_progress'.format(route_prefix), - permission='{}.execute'.format(permission_prefix), renderer='json') + config.add_route('{}.execute_progress'.format(route_prefix), + '{}/execute/progress'.format(instance_url_prefix)) + config.add_view(cls, attr='execute_progress', + route_name='{}.execute_progress'.format(route_prefix), + permission='{}.execute'.format(permission_prefix), + renderer='json') - cls._defaults(config) + # declare failure + config.add_route('{}.declare_failure'.format(route_prefix), + '{}/declare-failure'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='declare_failure', + route_name='{}.declare_failure'.format(route_prefix), + permission='{}.execute'.format(permission_prefix)) def defaults(config, **kwargs): From 18cec49a86a97481700f46254ba7c55e4373b5e2 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 20 Aug 2022 17:39:33 -0500 Subject: [PATCH 108/978] Add websockets progress, "multi-system" support for upgrades and related things to better support that --- tailbone/app.py | 12 +- tailbone/progress.py | 34 +++- tailbone/templates/forms/deform_buefy.mako | 1 + tailbone/templates/themes/falafel/base.mako | 42 ++++- tailbone/templates/upgrades/configure.mako | 156 ++++++++++++++++++ tailbone/templates/upgrades/view.mako | 168 +++++++++++++++++--- tailbone/views/asgi/__init__.py | 100 +++++++++--- tailbone/views/asgi/datasync.py | 33 +--- tailbone/views/asgi/upgrades.py | 131 +++++++++++++++ tailbone/views/core.py | 6 +- tailbone/views/master.py | 27 +++- tailbone/views/upgrades.py | 135 +++++++++++++--- 12 files changed, 731 insertions(+), 114 deletions(-) create mode 100644 tailbone/templates/upgrades/configure.mako create mode 100644 tailbone/views/asgi/upgrades.py diff --git a/tailbone/app.py b/tailbone/app.py index 5eb0911e..d7155829 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -195,22 +195,20 @@ def add_websocket(config, name, view, attr=None): view_callable = rattail_app.load_object(view) else: view_callable = view - view_callable = view_callable(config.registry) + view_callable = view_callable(config) if attr: view_callable = getattr(view_callable, attr) - path = '/ws/{}'.format(name) - # register route - config.add_route('ws.{}'.format(name), - path, - static=True) + path = '/ws/{}'.format(name) + route_name = 'ws.{}'.format(name) + config.add_route(route_name, path, static=True) # register view callable websockets = config.registry.setdefault('tailbone_websockets', {}) websockets[path] = view_callable - config.action('tailbone-add-websocket', action, + config.action('tailbone-add-websocket-{}'.format(name), action, # nb. since this action adds routes, it must happen # sooner in the order than it normally would, hence # we declare that diff --git a/tailbone/progress.py b/tailbone/progress.py index 90fa21be..5c45f390 100644 --- a/tailbone/progress.py +++ b/tailbone/progress.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -27,22 +27,33 @@ Progress Indicator from __future__ import unicode_literals, absolute_import import os +import warnings from rattail.progress import ProgressBase from beaker.session import Session +def get_basic_session(config, request={}, **kwargs): + """ + Create/get a "basic" Beaker session object. + """ + kwargs['use_cookies'] = False + session = Session(request, **kwargs) + return session + + def get_progress_session(request, key, **kwargs): """ Create/get a Beaker session object, to be used for progress. """ - id = '{}.progress.{}'.format(request.session.id, key) - kwargs['use_cookies'] = False + kwargs['id'] = '{}.progress.{}'.format(request.session.id, key) if kwargs.get('type') == 'file': + warnings.warn("Passing a 'type' kwarg to get_progress_session() " + "is deprecated...i think", + DeprecationWarning, stacklevel=2) kwargs['data_dir'] = os.path.join(request.rattail_config.appdir(), 'sessions') - session = Session(request, id, **kwargs) - return session + return get_basic_session(request.rattail_config, request, **kwargs) class SessionProgress(ProgressBase): @@ -52,11 +63,20 @@ class SessionProgress(ProgressBase): This class is only responsible for keeping the progress *data* current. It is the responsibility of some client-side AJAX (etc.) to consume the data for display to the user. + + :param ws: If true, then websockets are assumed, and the progress will + behave accordingly. The default is false, "traditional" behavior. """ - def __init__(self, request, key, session_type=None): + def __init__(self, request, key, session_type=None, ws=False): self.key = key - self.session = get_progress_session(request, key, type=session_type) + self.ws = ws + + if self.ws: + self.session = get_basic_session(request.rattail_config, id=key) + else: + self.session = get_progress_session(request, key, type=session_type) + self.canceled = False self.clear() diff --git a/tailbone/templates/forms/deform_buefy.mako b/tailbone/templates/forms/deform_buefy.mako index 860449fb..c387d965 100644 --- a/tailbone/templates/forms/deform_buefy.mako +++ b/tailbone/templates/forms/deform_buefy.mako @@ -73,6 +73,7 @@ let ${form.component_studly} = { template: '#${form.component}-template', + mixins: [FormPosterMixin], components: {}, props: {}, watch: {}, diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index 9b9236fe..fe3ef429 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -682,20 +682,54 @@ % if show_prev_next is not Undefined and show_prev_next: % if prev_url:
    - ${h.link_to(u"« Older", prev_url, class_='button autodisable')} + % if use_buefy: + + Older + + % else: + ${h.link_to(u"« Older", prev_url, class_='button autodisable')} + % endif
    % else:
    - ${h.link_to(u"« Older", '#', class_='button', disabled='disabled')} + % if use_buefy: + + Older + + % else: + ${h.link_to(u"« Older", '#', class_='button', disabled='disabled')} + % endif
    % endif % if next_url:
    - ${h.link_to(u"Newer »", next_url, class_='button autodisable')} + % if use_buefy: + + Newer + + % else: + ${h.link_to(u"Newer »", next_url, class_='button autodisable')} + % endif
    % else:
    - ${h.link_to(u"Newer »", '#', class_='button', disabled='disabled')} + % if use_buefy: + + Newer + + % else: + ${h.link_to(u"Newer »", '#', class_='button', disabled='disabled')} + % endif
    % endif % endif diff --git a/tailbone/templates/upgrades/configure.mako b/tailbone/templates/upgrades/configure.mako new file mode 100644 index 00000000..cde81b9e --- /dev/null +++ b/tailbone/templates/upgrades/configure.mako @@ -0,0 +1,156 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="form_content()"> + ${h.hidden('upgrade_systems', **{':value': 'JSON.stringify(upgradeSystems)'})} + +

    Upgradable Systems

    +
    + + + + + +
    + + New System + + + + + + +
    +
    + + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + + + + +${parent.body()} diff --git a/tailbone/templates/upgrades/view.mako b/tailbone/templates/upgrades/view.mako index 6a027921..ed23c83a 100644 --- a/tailbone/templates/upgrades/view.mako +++ b/tailbone/templates/upgrades/view.mako @@ -38,6 +38,18 @@ % endif +<%def name="extra_styles()"> + ${parent.extra_styles()} + + + <%def name="render_this_page()"> ${parent.render_this_page()} @@ -60,31 +72,86 @@ <%def name="render_form_buttons()"> - % if not instance.executed and instance.status_code == enum.UPGRADE_STATUS_PENDING and request.has_perm('{}.execute'.format(permission_prefix)): + % if not instance.executed and instance.status_code == enum.UPGRADE_STATUS_PENDING and master.has_perm('execute'):
    % if instance.enabled and not instance.executing: - % if use_buefy: - ${h.form(url('{}.execute'.format(route_prefix), uuid=instance.uuid), **{'@submit': 'submitForm'})} - % else: - ${h.form(url('{}.execute'.format(route_prefix), uuid=instance.uuid), class_='autodisable')} - % endif - ${h.csrf_token(request)} - % if use_buefy: + % if use_buefy and expose_websockets: + + {{ upgradeExecuting ? "Working, please wait..." : "Execute this upgrade" }} + + % elif use_buefy: + ${h.form(url('{}.execute'.format(route_prefix), uuid=instance.uuid), **{'@submit': 'submitForm'})} + ${h.csrf_token(request)} - {{ formButtonText }} + {{ formSubmitting ? "Working, please wait..." : "Execute this upgrade" }} + ${h.end_form()} % else: + ${h.form(url('{}.execute'.format(route_prefix), uuid=instance.uuid), class_='autodisable')} + ${h.csrf_token(request)} ${h.submit('execute', "Execute this upgrade", class_='button is-primary')} + ${h.end_form()} % endif - ${h.end_form()} % elif instance.enabled: % else: % endif
    + + +
    +
    + +
    +
    +

    Upgrading (please wait) ...

    + + +
    +
    +
    + + Declare Failure + +
    +
    +
    + +
    + + + + ## nb. we auto-scroll down to "see" this element +
    +
    + +
    +
    +
    + % endif @@ -94,16 +161,81 @@ TailboneFormData.showingPackages = 'diffs' - TailboneFormData.formButtonText = "Execute this upgrade" - TailboneFormData.formSubmitting = false - - TailboneForm.methods.submitForm = function() { - this.formSubmitting = true - this.formButtonText = "Working, please wait..." - } - % if master.has_perm('execute'): + % if expose_websockets: + + TailboneFormData.upgradeExecuting = ${json.dumps(instance.executing)|n} + TailboneFormData.progressOutput = [] + TailboneFormData.progressOutputCounter = 0 + + TailboneForm.methods.executeUpgrade = function() { + this.upgradeExecuting = true + + // grow the textout area to fill most of screen + this.$nextTick(() => { + let textout = this.$refs.textout + let height = window.innerHeight - textout.offsetTop - 50 + textout.style.height = height + 'px' + }) + + let url = '${master.get_action_url('execute', instance)}' + this.submitForm(url, {ws: true}, response => { + + ## TODO: should be a cleaner way to get this url? + url = '${request.route_url('ws.upgrades.execution_progress', _query={'uuid': instance.uuid})}' + url = url.replace(/^https?:/, 'wss:') + + this.ws = new WebSocket(url) + let that = this + + ## TODO: add support for this here? + // this.ws.onclose = (event) => { + // // websocket closing means 1 of 2 things: + // // - user navigated away from page intentionally + // // - server connection was broken somehow + // // only one of those is "bad" and we only want to + // // display warning in 2nd case. so we simply use a + // // brief delay to "rule out" the 1st scenario + // setTimeout(() => { that.websocketBroken = true }, + // 3000) + // } + + this.ws.onmessage = (event) => { + let data = JSON.parse(event.data) + + if (data.complete) { + + // upgrade has completed; reload page to view result + location.reload() + + } else if (data.stdout) { + + // add lines to textout area + that.progressOutput.push({ + key: ++that.progressOutputCounter, + text: data.stdout}) + + // scroll down to end of textout area + this.$nextTick(() => { + this.$refs.seeme.scrollIntoView() + }) + } + } + }) + } + + % else: + ## no websockets + + TailboneFormData.formSubmitting = false + + TailboneForm.methods.submitForm = function() { + this.formSubmitting = true + } + + % endif + TailboneFormData.declareFailureSubmitting = false TailboneForm.methods.declareFailureClick = function() { diff --git a/tailbone/views/asgi/__init__.py b/tailbone/views/asgi/__init__.py index a3450c11..01649f97 100644 --- a/tailbone/views/asgi/__init__.py +++ b/tailbone/views/asgi/__init__.py @@ -24,26 +24,77 @@ ASGI Views """ -from __future__ import unicode_literals, absolute_import +from http.cookies import SimpleCookie -import http.cookies +from beaker.session import SignedCookie +from pyramid.interfaces import ISessionFactory -from beaker.cache import clsmap -from beaker.session import SessionObject, SignedCookie + +class MockRequest(dict): + """ + Fake request class, needed for re-construction of the user's web + session. + """ + environ = {} + + def add_response_callback(self, func): + pass class WebsocketView(object): - def __init__(self, registry): - self.registry = registry + def __init__(self, pyramid_config): + self.pyramid_config = pyramid_config + self.registry = self.pyramid_config.registry + self.model = self.rattail_config.get_model() + + @property + def rattail_config(self): + return self.registry['rattail_config'] + + def get_rattail_app(self): + return self.rattail_config.get_app() + + async def authorize(self, scope, receive, send, permission): + + # is user authorized for this socket? + authorized = await self.has_permission(scope, permission) + + # wait for client to connect + message = await receive() + assert message['type'] == 'websocket.connect' + + # allow or deny access, per authorization + if authorized: + await send({'type': 'websocket.accept'}) + else: # forbidden + await send({'type': 'websocket.close'}) + + return authorized + + async def get_user(self, scope, session=None): + app = self.get_rattail_app() + model = self.model + + # load the user's web session + user_session = await self.get_user_session(scope) + if user_session: + + # determine user uuid + user_uuid = user_session.get('auth.userid') + if user_uuid: + + # use given db session, or make a new one + with app.short_session(config=self.rattail_config, + session=session): + + # load user proper + return session.query(model.User).get(user_uuid) async def get_user_session(self, scope): settings = self.registry.settings beaker_key = settings['beaker.session.key'] beaker_secret = settings['beaker.session.secret'] - beaker_type = settings['beaker.session.type'] - beaker_data_dir = settings['beaker.session.data_dir'] - beaker_lock_dir = settings['beaker.session.lock_dir'] # get ahold of session identifier cookie headers = dict(scope['headers']) @@ -51,20 +102,31 @@ class WebsocketView(object): if not cookie: return cookie = cookie.decode('utf_8') - cookie = http.cookies.SimpleCookie(cookie) + cookie = SimpleCookie(cookie) morsel = cookie[beaker_key] - # simulate pyramid_beaker logic to get at the session + # simulate pyramid_beaker logic to get at the actual session cookieheader = morsel.output(header='') cookie = SignedCookie(beaker_secret, input=cookieheader) session_id = cookie[beaker_key].value - request = {'cookie': cookieheader} - session = SessionObject( - request, - id=session_id, - key=beaker_key, - namespace_class=clsmap[beaker_type], - data_dir=beaker_data_dir, - lock_dir=beaker_lock_dir) + factory = self.registry.queryUtility(ISessionFactory) + request = MockRequest() + # nb. cannot pass 'id' to our factory, but things still work + # if we assign it immediately, before load() is called + session = factory(request) + session.id = session_id + session.load() return session + + async def has_permission(self, scope, permission): + app = self.get_rattail_app() + auth_handler = app.get_auth_handler() + + # figure out if user is authorized for this websocket + session = app.make_session() + user = await self.get_user(scope, session=session) + authorized = auth_handler.has_permission(session, user, permission) + session.close() + + return authorized diff --git a/tailbone/views/asgi/datasync.py b/tailbone/views/asgi/datasync.py index ffb63174..2dec06ea 100644 --- a/tailbone/views/asgi/datasync.py +++ b/tailbone/views/asgi/datasync.py @@ -24,8 +24,6 @@ DataSync Views """ -from __future__ import unicode_literals, absolute_import - import asyncio import json @@ -35,36 +33,11 @@ from tailbone.views.asgi import WebsocketView class DatasyncWS(WebsocketView): async def status(self, scope, receive, send): - rattail_config = self.registry['rattail_config'] - app = rattail_config.get_app() - model = app.model - auth_handler = app.get_auth_handler() + app = self.get_rattail_app() datasync_handler = app.get_datasync_handler() - authorized = False - user_session = await self.get_user_session(scope) - if user_session: - user_uuid = user_session.get('auth.userid') - session = app.make_session() - - user = None - if user_uuid: - user = session.query(model.User).get(user_uuid) - - # figure out if user is authorized for this websocket - permission = 'datasync.status' - authorized = auth_handler.has_permission(session, user, permission) - session.close() - - # wait for client to connect - message = await receive() - assert message['type'] == 'websocket.connect' - - # allow or deny access, per authorization - if authorized: - await send({'type': 'websocket.accept'}) - else: # forbidden - await send({'type': 'websocket.close'}) + # is user allowed to see this? + if not await self.authorize(scope, receive, send, 'datasync.status'): return # this tracks when client disconnects diff --git a/tailbone/views/asgi/upgrades.py b/tailbone/views/asgi/upgrades.py new file mode 100644 index 00000000..fc066326 --- /dev/null +++ b/tailbone/views/asgi/upgrades.py @@ -0,0 +1,131 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2022 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 . +# +################################################################################ +""" +Upgrade Views for ASGI +""" + +import asyncio +import json +import os +from urllib.parse import parse_qs + +from tailbone.views.asgi import WebsocketView +from tailbone.progress import get_basic_session + + +class UpgradeWS(WebsocketView): + + async def execution_progress(self, scope, receive, send): + rattail_config = self.registry['rattail_config'] + + # is user allowed to see this? + if not await self.authorize(scope, receive, send, 'upgrades.execute'): + return + + # this tracks when client disconnects + state = {'disconnected': False} + + async def wait_for_disconnect(): + message = await receive() + if message['type'] == 'websocket.disconnect': + state['disconnected'] = True + + # watch for client disconnect, while we do other things + asyncio.create_task(wait_for_disconnect()) + + query = scope['query_string'].decode('utf_8') + query = parse_qs(query) + uuid = query['uuid'][0] + progress_session_id = 'upgrades.{}.execution_progress'.format(uuid) + progress_session = get_basic_session(rattail_config, + id=progress_session_id) + + # do the rest forever, until client disconnects + while not state['disconnected']: + + # load latest progress data + progress_session.load() + + # when upgrade progress is complete... + if progress_session.get('complete'): + + # maybe set success flash msg + msg = progress_session.get('success_msg') + if msg: + user_session = await self.get_user_session(scope) + user_session.flash(msg) + user_session.persist() + + # tell client progress is complete + await send({'type': 'websocket.send', + 'subtype': 'upgrades.execute_progress', + 'text': json.dumps({'complete': True})}) + + # this websocket is done + break + + # we will send this data down to client + data = dict(progress_session) + + # maybe add more lines from command output + path = rattail_config.upgrade_filepath(uuid, filename='stdout.log') + offset = progress_session.get('stdout.offset', 0) + if os.path.exists(path): + size = os.path.getsize(path) - offset + if size > 0: + with open(path, 'rb') as f: + f.seek(offset) + chunk = f.read(size) + data['stdout'] = chunk.decode('utf8').replace('\n', '
    ') + progress_session['stdout.offset'] = offset + size + progress_session.save() + + # send data to client + await send({'type': 'websocket.send', + 'subtype': 'upgrades.execute_progress', + 'text': json.dumps(data)}) + + # pause for 1 second + await asyncio.sleep(1) + + @classmethod + def defaults(cls, config): + cls._defaults(config) + + @classmethod + def _defaults(cls, config): + + # execution progress + config.add_tailbone_websocket('upgrades.execution_progress', + cls, attr='execution_progress') + + +def defaults(config, **kwargs): + base = globals() + + UpgradeWS = kwargs.get('UpgradeWS', base['UpgradeWS']) + UpgradeWS.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/core.py b/tailbone/views/core.py index bcb5b01b..c0f03e19 100644 --- a/tailbone/views/core.py +++ b/tailbone/views/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2021 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -134,12 +134,12 @@ class View(object): def progress_loop(self, func, items, factory, *args, **kwargs): return progress_loop(func, items, factory, *args, **kwargs) - def make_progress(self, key): + def make_progress(self, key, **kwargs): """ Create and return a :class:`tailbone.progress.SessionProgress` instance, with the given key. """ - return SessionProgress(self.request, key) + return SessionProgress(self.request, key, **kwargs) # TODO: this signature seems wonky def render_progress(self, progress, kwargs, template=None): diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 62502035..05c05ffd 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -1790,14 +1790,28 @@ class MasterView(View): """ obj = self.get_instance() model_title = self.get_model_title() - progress = self.make_execute_progress(obj) + # 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') + + # make our progress tracker + progress = self.make_execute_progress(obj, ws=ws) + + # start execution in a separate thread kwargs = {'progress': progress} key = [self.request.matchdict[k] for k in self.get_model_key(as_tuple=True)] - thread = Thread(target=self.execute_thread, args=(key, self.request.user.uuid), kwargs=kwargs) + thread = Thread(target=self.execute_thread, + args=(key, self.request.user.uuid), + kwargs=kwargs) thread.start() + # we're done here if using websockets + if ws: + return self.json_response({'ok': True}) + + # traditional behavior sends user to dedicated progress page return self.render_progress(progress, { 'instance': obj, 'initial_msg': self.execute_progress_initial_msg, @@ -1805,9 +1819,12 @@ class MasterView(View): 'cancel_msg': "{} execution was canceled".format(model_title), }, template=self.execute_progress_template) - def make_execute_progress(self, obj): - key = '{}.execute'.format(self.get_grid_key()) - return self.make_progress(key) + def make_execute_progress(self, obj, ws=False): + if ws: + key = '{}.{}.execution_progress'.format(self.get_route_prefix(), obj.uuid) + else: + key = '{}.execute'.format(self.get_grid_key()) + return self.make_progress(key, ws=ws) def get_instance_for_key(self, key, session): model_key = self.get_model_key(as_tuple=True) diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index 2e7c2fc4..dcab7980 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.py @@ -26,24 +26,27 @@ Views for app upgrades from __future__ import unicode_literals, absolute_import +import json import os import re import logging +import warnings import six -from sqlalchemy import orm +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.threads import Thread -from rattail.upgrades import get_upgrade_handler +from rattail.util import OrderedDict from deform import widget as dfwidget from webhelpers2.html import tags, HTML from tailbone.views import MasterView from tailbone.progress import get_progress_session #, SessionProgress +from tailbone.config import should_expose_websockets log = logging.getLogger(__name__) @@ -56,6 +59,7 @@ class UpgradeView(MasterView): model_class = model.Upgrade downloadable = True cloneable = True + configurable = True executable = True execute_progress_template = '/upgrade.mako' execute_progress_initial_msg = "Upgrading" @@ -68,6 +72,7 @@ class UpgradeView(MasterView): } grid_columns = [ + 'system', 'created', 'description', # 'not_until', @@ -78,6 +83,7 @@ class UpgradeView(MasterView): ] form_fields = [ + 'system', 'description', # 'not_until', # 'requirements', @@ -97,28 +103,40 @@ class UpgradeView(MasterView): def __init__(self, request): super(UpgradeView, self).__init__(request) - self.handler = self.get_handler() - def get_handler(self): - """ - Returns the ``UpgradeHandler`` instance for the view. The handler - factory for this may be defined by config, e.g.: + if hasattr(self, 'get_handler'): + warnings.warn("defining get_handler() is deprecated. please " + "override AppHandler.get_upgrade_handler() instead", + DeprecationWarning, stacklevel=2) + self.upgrade_handler = self.get_handler() - .. code-block:: ini + else: + app = self.get_rattail_app() + self.upgrade_handler = app.get_upgrade_handler() - [rattail.upgrades] - handler = myapp.upgrades:CustomUpgradeHandler - """ - return get_upgrade_handler(self.rattail_config) + @property + def handler(self): + warnings.warn("handler attribute is deprecated; " + "please use upgrade_handler instead", + DeprecationWarning, stacklevel=2) + return self.upgrade_handler def configure_grid(self, g): super(UpgradeView, self).configure_grid(g) + + # system + systems = self.upgrade_handler.get_all_systems() + systems_enum = dict([(s['key'], s['label']) for s in systems]) + g.set_enum('system', systems_enum) + g.set_joiner('executed_by', lambda q: q.join(model.User, model.User.uuid == model.Upgrade.executed_by_uuid).outerjoin(model.Person)) g.set_sorter('executed_by', model.Person.display_name) g.set_enum('status_code', self.enum.UPGRADE_STATUS) g.set_type('created', 'datetime') g.set_type('executed', 'datetime') g.set_sort_defaults('created', 'desc') + + g.set_link('system') g.set_link('created') g.set_link('description') # g.set_link('not_until') @@ -157,6 +175,16 @@ class UpgradeView(MasterView): super(UpgradeView, self).configure_form(f) upgrade = f.model_instance + # system + systems = self.upgrade_handler.get_all_systems() + systems_enum = OrderedDict([(s['key'], s['label']) + for s in systems]) + f.set_enum('system', systems_enum) + f.set_required('system') + if self.creating: + if len(systems) == 1: + f.set_default('system', list(systems_enum)[0]) + # status_code if self.creating: f.remove_field('status_code') @@ -174,7 +202,15 @@ class UpgradeView(MasterView): f.set_widget('notes', dfwidget.TextAreaWidget(cols=80, rows=8)) f.set_renderer('stdout_file', self.render_stdout_file) f.set_renderer('stderr_file', self.render_stdout_file) - f.set_renderer('package_diff', self.render_package_diff) + + # package_diff + if self.viewing and upgrade.executed and ( + upgrade.system == 'rattail' + or not upgrade.system): + f.set_renderer('package_diff', self.render_package_diff) + else: + f.remove_field('package_diff') + # f.set_readonly('created') # f.set_readonly('created_by') f.set_readonly('executed') @@ -202,7 +238,6 @@ class UpgradeView(MasterView): f.set_default('enabled', True) if not self.viewing or not upgrade.executed: - f.remove_field('package_diff') f.remove_field('exit_code') def render_status_code(self, upgrade, field): @@ -233,10 +268,11 @@ class UpgradeView(MasterView): return text def configure_clone_form(self, f): - f.fields = ['description', 'notes', 'enabled'] + f.fields = ['system', 'description', 'notes', 'enabled'] def clone_instance(self, original): cloned = self.model_class() + cloned.system = original.system cloned.created = make_utc() cloned.created_by = self.request.user cloned.description = original.description @@ -439,13 +475,22 @@ class UpgradeView(MasterView): # key = '{}.execute'.format(self.get_grid_key()) # return SessionProgress(self.request, key, session_type='file') - def execute_instance(self, upgrade, user, **kwargs): - session = orm.object_session(upgrade) - self.handler.mark_executing(upgrade) + def execute_instance(self, upgrade, user, progress=None, **kwargs): + app = self.get_rattail_app() + session = app.get_session(upgrade) + + # record the fact that execution has begun for this ugprade + self.upgrade_handler.mark_executing(upgrade) session.commit() - self.handler.do_execute(upgrade, user, **kwargs) - return ("Execution has finished, for better or worse. " - "You may need to restart your web app.") + + # let handler execute the upgrade + self.upgrade_handler.do_execute(upgrade, user, **kwargs) + + # success msg + msg = "Execution has finished, for better or worse." + if not upgrade.system or upgrade.system == 'rattail': + msg += " You may need to restart your web app." + return msg def execute_progress(self): upgrade = self.get_instance() @@ -489,6 +534,50 @@ class UpgradeView(MasterView): self.handler.delete_files(upgrade) super(UpgradeView, self).delete_instance(upgrade) + def configure_get_context(self, **kwargs): + context = super(UpgradeView, self).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) + + keys = [] + for system in json.loads(data['upgrade_systems']): + key = system['key'] + if key == 'rattail': + settings.append({'name': 'rattail.upgrades.command', + 'value': system['command']}) + else: + keys.append(key) + settings.append({'name': 'rattail.upgrades.system.{}.label'.format(key), + 'value': system['label']}) + settings.append({'name': 'rattail.upgrades.system.{}.command'.format(key), + 'value': system['command']}) + if keys: + settings.append({'name': 'rattail.upgrades.systems', + 'value': ', '.join(keys)}) + + return settings + + def configure_remove_settings(self): + super(UpgradeView, self).configure_remove_settings() + app = self.get_rattail_app() + model = self.model + + to_delete = self.Session.query(model.Setting)\ + .filter(sa.or_( + model.Setting.name == 'rattail.upgrades.command', + model.Setting.name == 'rattail.upgrades.systems', + model.Setting.name.like('rattail.upgrades.system.%.label'), + model.Setting.name.like('rattail.upgrades.system.%.command')))\ + .all() + + for setting in to_delete: + app.delete_setting(self.Session(), setting.name) + @classmethod def defaults(cls, config): cls._defaults(config) @@ -520,10 +609,14 @@ class UpgradeView(MasterView): def defaults(config, **kwargs): base = globals() + rattail_config = config.registry['rattail_config'] UpgradeView = kwargs.get('UpgradeView', base['UpgradeView']) UpgradeView.defaults(config) + if should_expose_websockets(rattail_config): + config.include('tailbone.views.asgi.upgrades') + def includeme(config): defaults(config) From e93063a3440288757b268bca2e89e8393c92ea05 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 20 Aug 2022 18:55:33 -0500 Subject: [PATCH 109/978] Refactor upgrade websocket progress, so "anyone" can join in to see now while an upgrade is executing, anyone with permission can "view" the upgrade and see the same progress the executor is seeing --- tailbone/templates/upgrades/view.mako | 314 +++++++++++++++----------- tailbone/views/master.py | 10 + tailbone/views/upgrades.py | 7 + 3 files changed, 204 insertions(+), 127 deletions(-) diff --git a/tailbone/templates/upgrades/view.mako b/tailbone/templates/upgrades/view.mako index ed23c83a..f3884340 100644 --- a/tailbone/templates/upgrades/view.mako +++ b/tailbone/templates/upgrades/view.mako @@ -40,73 +40,22 @@ <%def name="extra_styles()"> ${parent.extra_styles()} - + % if master.has_perm('execute'): + + % endif <%def name="render_this_page()"> ${parent.render_this_page()} - % if master.has_perm('execute'): - ${h.form(master.get_action_url('declare_failure', instance), ref='declareFailureForm')} - ${h.csrf_token(request)} - ${h.end_form()} - % endif - - -<%def name="render_buefy_form()"> -
    - <${form.component} - % if master.has_perm('execute'): - @declare-failure="declareFailure" - % endif - > - -
    - - -<%def name="render_form_buttons()"> - % if not instance.executed and instance.status_code == enum.UPGRADE_STATUS_PENDING and master.has_perm('execute'): -
    - % if instance.enabled and not instance.executing: - % if use_buefy and expose_websockets: - - {{ upgradeExecuting ? "Working, please wait..." : "Execute this upgrade" }} - - % elif use_buefy: - ${h.form(url('{}.execute'.format(route_prefix), uuid=instance.uuid), **{'@submit': 'submitForm'})} - ${h.csrf_token(request)} - - {{ formSubmitting ? "Working, please wait..." : "Execute this upgrade" }} - - ${h.end_form()} - % else: - ${h.form(url('{}.execute'.format(route_prefix), uuid=instance.uuid), class_='autodisable')} - ${h.csrf_token(request)} - ${h.submit('execute', "Execute this upgrade", class_='button is-primary')} - ${h.end_form()} - % endif - % elif instance.enabled: - - % else: - - % endif -
    - + % if expose_websockets and master.has_perm('execute'): @@ -116,12 +65,15 @@
    -

    Upgrading (please wait) ...

    +

    + Upgrading (please wait) ... + {{ executeUpgradeComplete ? "DONE!" : "" }} +

    @@ -151,7 +103,64 @@
    + % endif + % if master.has_perm('execute'): + ${h.form(master.get_action_url('declare_failure', instance), ref='declareFailureForm')} + ${h.csrf_token(request)} + ${h.end_form()} + % endif + + +<%def name="render_buefy_form()"> +
    + <${form.component} + % if expose_websockets and master.has_perm('execute'): + @execute-upgrade-click="executeUpgrade" + :upgrade-executing="upgradeExecuting" + @declare-failure-click="declareFailureClick" + :declare-failure-submitting="declareFailureSubmitting" + % endif + > + +
    + + +<%def name="render_form_buttons()"> + % if instance_executable and master.has_perm('execute'): +
    + % if instance.enabled and not instance.executing: + % if use_buefy and expose_websockets: + + {{ upgradeExecuting ? "Working, please wait..." : "Execute this upgrade" }} + + % elif use_buefy: + ${h.form(url('{}.execute'.format(route_prefix), uuid=instance.uuid), **{'@submit': 'submitForm'})} + ${h.csrf_token(request)} + + {{ formSubmitting ? "Working, please wait..." : "Execute this upgrade" }} + + ${h.end_form()} + % else: + ${h.form(url('{}.execute'.format(route_prefix), uuid=instance.uuid), class_='autodisable')} + ${h.csrf_token(request)} + ${h.submit('execute', "Execute this upgrade", class_='button is-primary')} + ${h.end_form()} + % endif + % elif instance.enabled: + + % else: + + % endif +
    % endif @@ -165,69 +174,111 @@ % if expose_websockets: - TailboneFormData.upgradeExecuting = ${json.dumps(instance.executing)|n} - TailboneFormData.progressOutput = [] - TailboneFormData.progressOutputCounter = 0 + ThisPageData.ws = null - TailboneForm.methods.executeUpgrade = function() { - this.upgradeExecuting = true + ////////////////////////////// + // execute upgrade + ////////////////////////////// + + TailboneForm.props.upgradeExecuting = { + type: Boolean, + default: false, + } + + ThisPageData.upgradeExecuting = false + ThisPageData.progressOutput = [] + ThisPageData.progressOutputCounter = 0 + ThisPageData.executeUpgradeComplete = false + + ThisPage.methods.adjustTextoutHeight = function() { // grow the textout area to fill most of screen + let textout = this.$refs.textout + let height = window.innerHeight - textout.offsetTop - 50 + textout.style.height = height + 'px' + } + + ThisPage.methods.showExecuteDialog = function() { + this.upgradeExecuting = true this.$nextTick(() => { - let textout = this.$refs.textout - let height = window.innerHeight - textout.offsetTop - 50 - textout.style.height = height + 'px' - }) - - let url = '${master.get_action_url('execute', instance)}' - this.submitForm(url, {ws: true}, response => { - - ## TODO: should be a cleaner way to get this url? - url = '${request.route_url('ws.upgrades.execution_progress', _query={'uuid': instance.uuid})}' - url = url.replace(/^https?:/, 'wss:') - - this.ws = new WebSocket(url) - let that = this - - ## TODO: add support for this here? - // this.ws.onclose = (event) => { - // // websocket closing means 1 of 2 things: - // // - user navigated away from page intentionally - // // - server connection was broken somehow - // // only one of those is "bad" and we only want to - // // display warning in 2nd case. so we simply use a - // // brief delay to "rule out" the 1st scenario - // setTimeout(() => { that.websocketBroken = true }, - // 3000) - // } - - this.ws.onmessage = (event) => { - let data = JSON.parse(event.data) - - if (data.complete) { - - // upgrade has completed; reload page to view result - location.reload() - - } else if (data.stdout) { - - // add lines to textout area - that.progressOutput.push({ - key: ++that.progressOutputCounter, - text: data.stdout}) - - // scroll down to end of textout area - this.$nextTick(() => { - this.$refs.seeme.scrollIntoView() - }) - } - } + this.adjustTextoutHeight() }) } + ThisPage.methods.establishWebsocket = function() { + + ## TODO: should be a cleaner way to get this url? + url = '${request.route_url('ws.upgrades.execution_progress', _query={'uuid': instance.uuid})}' + url = url.replace(/^https?:/, 'wss:') + + this.ws = new WebSocket(url) + + ## TODO: add support for this here? + // this.ws.onclose = (event) => { + // // websocket closing means 1 of 2 things: + // // - user navigated away from page intentionally + // // - server connection was broken somehow + // // only one of those is "bad" and we only want to + // // display warning in 2nd case. so we simply use a + // // brief delay to "rule out" the 1st scenario + // setTimeout(() => { that.websocketBroken = true }, + // 3000) + // } + + this.ws.onmessage = (event) => { + let data = JSON.parse(event.data) + + if (data.complete) { + + // upgrade has completed; reload page to view result + this.executeUpgradeComplete = true + this.$nextTick(() => { + location.reload() + }) + + } else if (data.stdout) { + + // add lines to textout area + this.progressOutput.push({ + key: ++this.progressOutputCounter, + text: data.stdout}) + + // scroll down to end of textout area + this.$nextTick(() => { + this.$refs.seeme.scrollIntoView() + }) + } + } + } + + % if instance.executing: + ThisPage.mounted = function() { + this.showExecuteDialog() + this.establishWebsocket() + } + % endif + + % if instance_executable: + + ThisPage.methods.executeUpgrade = function() { + this.showExecuteDialog() + + let url = '${master.get_action_url('execute', instance)}' + this.submitForm(url, {ws: true}, response => { + + this.establishWebsocket() + }) + } + + % endif + % else: ## no websockets + ////////////////////////////// + // execute upgrade + ////////////////////////////// + TailboneFormData.formSubmitting = false TailboneForm.methods.submitForm = function() { @@ -236,17 +287,26 @@ % endif - TailboneFormData.declareFailureSubmitting = false + ////////////////////////////// + // declare failure + ////////////////////////////// - TailboneForm.methods.declareFailureClick = function() { - if (confirm("Really declare this upgrade a failure?")) { - this.declareFailureSubmitting = true - this.$emit('declare-failure') - } + TailboneForm.props.declareFailureSubmitting = { + type: Boolean, + default: false, } - ThisPage.methods.declareFailure = function() { - this.$refs.declareFailureForm.submit() + TailboneForm.methods.declareFailureClick = function() { + this.$emit('declare-failure-click') + } + + ThisPageData.declareFailureSubmitting = false + + ThisPage.methods.declareFailureClick = function() { + if (confirm("Really declare this upgrade a failure?")) { + this.declareFailureSubmitting = true + this.$refs.declareFailureForm.submit() + } } % endif diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 05c05ffd..ad1d088d 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -1063,6 +1063,8 @@ class MasterView(View): 'instance_deletable': self.deletable_instance(instance), 'form': form, } + if self.executable: + context['instance_executable'] = self.executable_instance(instance) if hasattr(form, 'make_deform_form'): context['dform'] = form.make_deform_form() @@ -1784,6 +1786,14 @@ class MasterView(View): elif importer.allow_create: return importer.create_object(key, host_data) + def executable_instance(self, instance): + """ + Returns boolean indicating whether or not the given instance + can be considered "executable". Returns ``True`` by default; + override as necessary. + """ + return True + def execute(self): """ Execute an object. diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index dcab7980..0b5e4b87 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.py @@ -475,6 +475,13 @@ class UpgradeView(MasterView): # key = '{}.execute'.format(self.get_grid_key()) # return SessionProgress(self.request, key, session_type='file') + def executable_instance(self, upgrade): + if upgrade.executed: + return False + if upgrade.status_code != self.enum.UPGRADE_STATUS_PENDING: + return False + return True + def execute_instance(self, upgrade, user, progress=None, **kwargs): app = self.get_rattail_app() session = app.get_session(upgrade) From 0a113611e865659890509e73c42efd4d8456508c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 20 Aug 2022 21:19:20 -0500 Subject: [PATCH 110/978] Let just one "task" handle collect/transmit of progress for websocket first client to connect, will cause task to start; subsequent clients are just added to running set, for broadcast messaging --- tailbone/views/asgi/__init__.py | 4 +- tailbone/views/asgi/upgrades.py | 131 +++++++++++++++++++++++--------- 2 files changed, 99 insertions(+), 36 deletions(-) diff --git a/tailbone/views/asgi/__init__.py b/tailbone/views/asgi/__init__.py index 01649f97..68300a44 100644 --- a/tailbone/views/asgi/__init__.py +++ b/tailbone/views/asgi/__init__.py @@ -77,7 +77,7 @@ class WebsocketView(object): model = self.model # load the user's web session - user_session = await self.get_user_session(scope) + user_session = self.get_user_session(scope) if user_session: # determine user uuid @@ -91,7 +91,7 @@ class WebsocketView(object): # load user proper return session.query(model.User).get(user_uuid) - async def get_user_session(self, scope): + def get_user_session(self, scope): settings = self.registry.settings beaker_key = settings['beaker.session.key'] beaker_secret = settings['beaker.session.secret'] diff --git a/tailbone/views/asgi/upgrades.py b/tailbone/views/asgi/upgrades.py index fc066326..f06fc7d3 100644 --- a/tailbone/views/asgi/upgrades.py +++ b/tailbone/views/asgi/upgrades.py @@ -33,35 +33,92 @@ from tailbone.views.asgi import WebsocketView from tailbone.progress import get_basic_session -class UpgradeWS(WebsocketView): +class UpgradeExecutionProgressWS(WebsocketView): - async def execution_progress(self, scope, receive, send): - rattail_config = self.registry['rattail_config'] + # keep track of all "global" state for this socket + global_state = { + 'upgrades': {}, + } + + async def __call__(self, scope, receive, send): + app = self.get_rattail_app() # is user allowed to see this? if not await self.authorize(scope, receive, send, 'upgrades.execute'): return - # this tracks when client disconnects - state = {'disconnected': False} + # keep track of client state + client_state = { + 'uuid': app.make_uuid(), + 'disconnected': False, + 'scope': scope, + 'receive': receive, + 'send': send, + } + + # parse upgrade uuid from query string + query = scope['query_string'].decode('utf_8') + query = parse_qs(query) + uuid = query['uuid'][0] + + # first client to request progress for this upgrade, must + # start a task to manage the collect/transmit logic for + # progress data, on behalf of this and/or any future clients + started_task = None + if uuid not in self.global_state['upgrades']: + + # this upgrade is new to us; establish state and add first client + upgrade_state = self.global_state['upgrades'][uuid] = { + 'clients': {client_state['uuid']: client_state}, + } + + # start task for transmit of progress data to all clients + started_task = asyncio.create_task(self.manage_progress(uuid)) + + else: + + # progress task is already running, just add new client + upgrade_state = self.global_state['upgrades'][uuid] + upgrade_state['clients'][client_state['uuid']] = client_state async def wait_for_disconnect(): message = await receive() if message['type'] == 'websocket.disconnect': - state['disconnected'] = True + client_state['disconnected'] = True - # watch for client disconnect, while we do other things + # wait forever, until client disconnects asyncio.create_task(wait_for_disconnect()) + while not client_state['disconnected']: - query = scope['query_string'].decode('utf_8') - query = parse_qs(query) - uuid = query['uuid'][0] + # can stop if upgrade has completed + if uuid not in self.global_state['upgrades']: + break + + await asyncio.sleep(0.1) + + # remove client from global set, if upgrade still running + if client_state['disconnected']: + upgrade_state = self.global_state['upgrades'].get(uuid) + if upgrade_state: + del upgrade_state['clients'][client_state['uuid']] + + # must continue to wait for other clients, if this client was + # the first to request progress + if started_task: + await started_task + + async def manage_progress(self, uuid): + """ + Task which handles collect / transmit of progress data, for + sake of all attached clients. + """ progress_session_id = 'upgrades.{}.execution_progress'.format(uuid) - progress_session = get_basic_session(rattail_config, + progress_session = get_basic_session(self.rattail_config, id=progress_session_id) - # do the rest forever, until client disconnects - while not state['disconnected']: + upgrade_state = self.global_state['upgrades'][uuid] + clients = upgrade_state['clients'] + while clients: # load latest progress data progress_session.load() @@ -69,26 +126,30 @@ class UpgradeWS(WebsocketView): # when upgrade progress is complete... if progress_session.get('complete'): - # maybe set success flash msg + # maybe set success flash msg (for all clients) msg = progress_session.get('success_msg') if msg: - user_session = await self.get_user_session(scope) - user_session.flash(msg) - user_session.persist() + for client in clients.values(): + user_session = self.get_user_session(client['scope']) + user_session.flash(msg) + user_session.persist() - # tell client progress is complete - await send({'type': 'websocket.send', - 'subtype': 'upgrades.execute_progress', - 'text': json.dumps({'complete': True})}) + # tell clients progress is complete + for client in clients.values(): + await client['send']({ + 'type': 'websocket.send', + 'subtype': 'upgrades.execute_progress', + 'text': json.dumps({'complete': True})}) - # this websocket is done + # this websocket is done, so remove all clients + clients.clear() break # we will send this data down to client - data = dict(progress_session) + data = {} # maybe add more lines from command output - path = rattail_config.upgrade_filepath(uuid, filename='stdout.log') + path = self.rattail_config.upgrade_filepath(uuid, filename='stdout.log') offset = progress_session.get('stdout.offset', 0) if os.path.exists(path): size = os.path.getsize(path) - offset @@ -100,31 +161,33 @@ class UpgradeWS(WebsocketView): progress_session['stdout.offset'] = offset + size progress_session.save() - # send data to client - await send({'type': 'websocket.send', - 'subtype': 'upgrades.execute_progress', - 'text': json.dumps(data)}) + # send data to clients + for client in clients.values(): + await client['send']({ + 'type': 'websocket.send', + 'subtype': 'upgrades.execute_progress', + 'text': json.dumps(data)}) # pause for 1 second await asyncio.sleep(1) + # no more clients, no more reason to track this upgrade + del self.global_state['upgrades'][uuid] + @classmethod def defaults(cls, config): cls._defaults(config) @classmethod def _defaults(cls, config): - - # execution progress - config.add_tailbone_websocket('upgrades.execution_progress', - cls, attr='execution_progress') + config.add_tailbone_websocket('upgrades.execution_progress', cls) def defaults(config, **kwargs): base = globals() - UpgradeWS = kwargs.get('UpgradeWS', base['UpgradeWS']) - UpgradeWS.defaults(config) + UpgradeExecutionProgressWS = kwargs.get('UpgradeExecutionProgressWS', base['UpgradeExecutionProgressWS']) + UpgradeExecutionProgressWS.defaults(config) def includeme(config): From 2ca93a07e9f6475f437a031d32a8fa37966e93f4 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 20 Aug 2022 22:40:16 -0500 Subject: [PATCH 111/978] Make separate tasks for collect vs. transmit of upgrade progress data --- tailbone/templates/upgrades/view.mako | 2 + tailbone/views/asgi/upgrades.py | 101 ++++++++++++++++++-------- 2 files changed, 72 insertions(+), 31 deletions(-) diff --git a/tailbone/templates/upgrades/view.mako b/tailbone/templates/upgrades/view.mako index f3884340..90450c94 100644 --- a/tailbone/templates/upgrades/view.mako +++ b/tailbone/templates/upgrades/view.mako @@ -116,7 +116,9 @@
    <${form.component} % if expose_websockets and master.has_perm('execute'): + % if instance_executable: @execute-upgrade-click="executeUpgrade" + % endif :upgrade-executing="upgradeExecuting" @declare-failure-click="declareFailureClick" :declare-failure-submitting="declareFailureSubmitting" diff --git a/tailbone/views/asgi/upgrades.py b/tailbone/views/asgi/upgrades.py index f06fc7d3..13458f23 100644 --- a/tailbone/views/asgi/upgrades.py +++ b/tailbone/views/asgi/upgrades.py @@ -40,6 +40,8 @@ class UpgradeExecutionProgressWS(WebsocketView): 'upgrades': {}, } + new_messages = asyncio.Queue() + async def __call__(self, scope, receive, send): app = self.get_rattail_app() @@ -116,10 +118,34 @@ class UpgradeExecutionProgressWS(WebsocketView): progress_session = get_basic_session(self.rattail_config, id=progress_session_id) + # start collecting status, textout messages + asyncio.create_task(self.collect_status(uuid, progress_session)) + asyncio.create_task(self.collect_textout(uuid)) + upgrade_state = self.global_state['upgrades'][uuid] clients = upgrade_state['clients'] while clients: + msg = await self.new_messages.get() + + # send message to all clients + for client in clients.values(): + await client['send']({ + 'type': 'websocket.send', + 'subtype': 'upgrades.execute_progress', + 'text': json.dumps(msg)}) + + await asyncio.sleep(0.1) + + # no more clients, no more reason to track this upgrade + del self.global_state['upgrades'][uuid] + + async def collect_status(self, uuid, progress_session): + + upgrade_state = self.global_state['upgrades'][uuid] + clients = upgrade_state['clients'] + while True: + # load latest progress data progress_session.load() @@ -134,45 +160,58 @@ class UpgradeExecutionProgressWS(WebsocketView): user_session.flash(msg) user_session.persist() - # tell clients progress is complete - for client in clients.values(): - await client['send']({ - 'type': 'websocket.send', - 'subtype': 'upgrades.execute_progress', - 'text': json.dumps({'complete': True})}) + # push "complete" message to queue + await self.new_messages.put({'complete': True}) - # this websocket is done, so remove all clients - clients.clear() + # there will be no more status coming break - # we will send this data down to client - data = {} + await asyncio.sleep(0.1) - # maybe add more lines from command output - path = self.rattail_config.upgrade_filepath(uuid, filename='stdout.log') - offset = progress_session.get('stdout.offset', 0) - if os.path.exists(path): + async def collect_textout(self, uuid): + path = self.rattail_config.upgrade_filepath(uuid, filename='stdout.log') + + # wait until stdout file exists + while not os.path.exists(path): + + # bail if upgrade is complete + if uuid not in self.global_state['upgrades']: + return + + await asyncio.sleep(0.1) + + offset = 0 + while True: + + # wait until we have something new to read + size = os.path.getsize(path) - offset + while not size: + + # bail if upgrade is complete + if uuid not in self.global_state['upgrades']: + return + + # wait a whole second, then look again + # (the less frequent we look, the bigger the chunk) + await asyncio.sleep(1) size = os.path.getsize(path) - offset - if size > 0: - with open(path, 'rb') as f: - f.seek(offset) - chunk = f.read(size) - data['stdout'] = chunk.decode('utf8').replace('\n', '
    ') - progress_session['stdout.offset'] = offset + size - progress_session.save() - # send data to clients - for client in clients.values(): - await client['send']({ - 'type': 'websocket.send', - 'subtype': 'upgrades.execute_progress', - 'text': json.dumps(data)}) + # bail if upgrade is complete + if uuid not in self.global_state['upgrades']: + return - # pause for 1 second - await asyncio.sleep(1) + # read the latest chunk and bookmark new offset + with open(path, 'rb') as f: + f.seek(offset) + chunk = f.read(size) + textout = chunk.decode('utf_8') + offset += size - # no more clients, no more reason to track this upgrade - del self.global_state['upgrades'][uuid] + # push new chunk onto message queue + textout = textout.replace('\n', '
    ') + await self.new_messages.put({'stdout': textout}) + + await asyncio.sleep(0.1) @classmethod def defaults(cls, config): From bdbbe990ddab24c0cee651d3aabd5e1141a026c8 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 20 Aug 2022 23:07:19 -0500 Subject: [PATCH 112/978] Add global context from handler, for email previews --- tailbone/views/email.py | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/tailbone/views/email.py b/tailbone/views/email.py index d381907d..536bf6ed 100644 --- a/tailbone/views/email.py +++ b/tailbone/views/email.py @@ -381,7 +381,15 @@ class EmailPreview(View): def __init__(self, request): super(EmailPreview, self).__init__(request) - self.email_handler = self.get_handler() + + if hasattr(self, 'get_handler'): + warnings.warn("defining a get_handler() method is deprecated; " + "please use AppHandler.get_email_handler() instead", + DeprecationWarning, stacklevel=2) + self.email_handler = get_handler() + else: + app = self.get_rattail_app() + self.email_handler = app.get_email_handler() @property def handler(self): @@ -390,10 +398,6 @@ class EmailPreview(View): DeprecationWarning, stacklevel=2) return self.email_handler - def get_handler(self): - app = self.get_rattail_app() - return app.get_email_handler() - def __call__(self): # Forms submitted via POST are only used for sending emails. @@ -416,10 +420,12 @@ class EmailPreview(View): key = self.request.POST.get('email_key') if key: email = self.email_handler.get_email(key) - data = email.obtain_sample_data(self.request) + + context = self.email_handler.make_context() + context.update(email.obtain_sample_data(self.request)) try: - self.email_handler.send_message(email, data, + self.email_handler.send_message(email, context, subject_prefix="[PREVIEW] ", to=[recipient], cc=None, bcc=None) @@ -433,8 +439,11 @@ class EmailPreview(View): def preview_template(self, key, type_): email = self.email_handler.get_email(key) template = email.get_template(type_) - data = email.obtain_sample_data(self.request) - self.request.response.text = template.render(**data) + + context = self.email_handler.make_context() + context.update(email.obtain_sample_data(self.request)) + + self.request.response.text = template.render(**context) if type_ == 'txt': self.request.response.content_type = str('text/plain') return self.request.response From 2ce242ba427bacaedeb1076507991ed2251e4a40 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 20 Aug 2022 23:33:46 -0500 Subject: [PATCH 113/978] Make textout scrolling "smooth" for upgrade progress --- tailbone/templates/upgrades/view.mako | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/templates/upgrades/view.mako b/tailbone/templates/upgrades/view.mako index 90450c94..c6ae11f2 100644 --- a/tailbone/templates/upgrades/view.mako +++ b/tailbone/templates/upgrades/view.mako @@ -247,7 +247,7 @@ // scroll down to end of textout area this.$nextTick(() => { - this.$refs.seeme.scrollIntoView() + this.$refs.seeme.scrollIntoView({behavior: 'smooth'}) }) } } From 87cced1637a88fa08dc022586048094e1782c228 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 21 Aug 2022 11:32:39 -0500 Subject: [PATCH 114/978] Fix perm check --- tailbone/templates/datasync/changes/index.mako | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/templates/datasync/changes/index.mako b/tailbone/templates/datasync/changes/index.mako index 632f50ee..e92c3c3c 100644 --- a/tailbone/templates/datasync/changes/index.mako +++ b/tailbone/templates/datasync/changes/index.mako @@ -3,7 +3,7 @@ <%def name="context_menu_items()"> ${parent.context_menu_items()} - % if request.has_perm('datasync.list'): + % if request.has_perm('datasync.status'):
  • ${h.link_to("View DataSync Status", url('datasync.status'))}
  • % endif From 7b2fef5f093a615c812b473bcf460ec011ada6c4 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 21 Aug 2022 15:22:29 -0500 Subject: [PATCH 115/978] Allow configuring datasync watcher kwargs --- tailbone/templates/datasync/configure.mako | 197 ++++++++++++++++++++- tailbone/views/asgi/__init__.py | 4 +- tailbone/views/datasync.py | 23 ++- 3 files changed, 209 insertions(+), 15 deletions(-) diff --git a/tailbone/templates/datasync/configure.mako b/tailbone/templates/datasync/configure.mako index 2d6d6435..014668be 100644 --- a/tailbone/templates/datasync/configure.mako +++ b/tailbone/templates/datasync/configure.mako @@ -218,9 +218,111 @@ + + + {{ editingWatcherKwargs ? "Stop Editing" : "Edit Kwargs" }} + + + -
    +
    + + + New Watcher Kwarg + + +
    + + + + + + + + + + + + + + + + + + + Cancel + + + + Update Kwarg + + + + +
    + + + + + + + +
    + +
    @@ -512,6 +614,7 @@ ThisPage.methods.newProfile = function() { this.editingProfile = {} this.editingConsumer = null + this.editingWatcherKwargs = false this.editingProfileKey = null this.editingProfileWatcherSpec = null @@ -523,6 +626,7 @@ this.editingProfileWatcherConsumesSelf = false this.editingProfileEnabled = true this.editingProfilePendingConsumers = [] + this.editingProfilePendingWatcherKwargs = [] this.editProfileShowDialog = true this.$nextTick(() => { @@ -533,6 +637,7 @@ ThisPage.methods.editProfile = function(row) { this.editingProfile = row this.editingConsumer = null + this.editingWatcherKwargs = false this.editingProfileKey = row.key this.editingProfileWatcherSpec = row.watcher_spec @@ -544,6 +649,16 @@ this.editingProfileWatcherConsumesSelf = row.watcher_consumes_self this.editingProfileEnabled = row.enabled + this.editingProfilePendingWatcherKwargs = [] + for (let kwarg of row.watcher_kwargs_data) { + let pending = { + original_key: kwarg.key, + key: kwarg.key, + value: kwarg.value, + } + this.editingProfilePendingWatcherKwargs.push(pending) + } + this.editingProfilePendingConsumers = [] for (let consumer of row.consumers_data) { let pending = { @@ -563,6 +678,46 @@ this.editProfileShowDialog = true } + ThisPageData.editingWatcherKwargs = false + ThisPageData.editingProfilePendingWatcherKwargs = [] + ThisPageData.editingWatcherKwarg = null + ThisPageData.editingWatcherKwargKey = null + ThisPageData.editingWatcherKwargValue = null + + ThisPage.methods.newWatcherKwarg = function() { + this.editingWatcherKwargKey = null + this.editingWatcherKwargValue = null + this.editingWatcherKwarg = {key: null, value: null} + this.$nextTick(() => { + this.$refs.watcherKwargKey.focus() + }) + } + + ThisPage.methods.editProfileWatcherKwarg = function(row) { + this.editingWatcherKwargKey = row.key + this.editingWatcherKwargValue = row.value + this.editingWatcherKwarg = row + } + + ThisPage.methods.updateWatcherKwarg = function() { + let pending = this.editingWatcherKwarg + let isNew = !pending.key + + pending.key = this.editingWatcherKwargKey + pending.value = this.editingWatcherKwargValue + + if (isNew) { + this.editingProfilePendingWatcherKwargs.push(pending) + } + + this.editingWatcherKwarg = null + } + + ThisPage.methods.deleteProfileWatcherKwarg = function(row) { + let i = this.editingProfilePendingWatcherKwargs.indexOf(row) + this.editingProfilePendingWatcherKwargs.splice(i, 1) + } + ThisPage.methods.findOriginalConsumer = function(key) { for (let consumer of this.editingProfile.consumers_data) { if (consumer.key == key) { @@ -590,11 +745,39 @@ row.enabled = this.editingProfileEnabled // track which keys still belong (persistent) - let persistent = [] + let persistentWatcherKwargs = [] + + // transfer pending data to profile watcher kwargs + for (let pending of this.editingProfilePendingWatcherKwargs) { + persistentWatcherKwargs.push(pending.key) + if (pending.original_key) { + let kwarg = this.findOriginalWatcherKwarg(pending.original_key) + kwarg.key = pending.key + kwarg.value = pending.value + } else { + row.watcher_kwargs_data.push(pending) + } + } + + // remove any kwargs not being persisted + let removeWatcherKwargs = [] + for (let kwarg of row.watcher_kwargs_data) { + let i = persistentWatcherKwargs.indexOf(kwarg.key) + if (i < 0) { + removeWatcherKwargs.push(kwarg) + } + } + for (let kwarg of removeWatcherKwargs) { + let i = row.watcher_kwargs_data.indexOf(kwarg) + row.watcher_kwargs_data.splice(i, 1) + } + + // track which keys still belong (persistent) + let persistentConsumers = [] // transfer pending data to profile consumers for (let pending of this.editingProfilePendingConsumers) { - persistent.push(pending.key) + persistentConsumers.push(pending.key) if (pending.original_key) { let consumer = this.findOriginalConsumer(pending.original_key) consumer.key = pending.key @@ -611,14 +794,14 @@ } // remove any consumers not being persisted - let remove = [] + let removeConsumers = [] for (let consumer of row.consumers_data) { - let i = persistent.indexOf(consumer.key) + let i = persistentConsumers.indexOf(consumer.key) if (i < 0) { - remove.push(consumer) + removeConsumers.push(consumer) } } - for (let consumer of remove) { + for (let consumer of removeConsumers) { let i = row.consumers_data.indexOf(consumer) row.consumers_data.splice(i, 1) } diff --git a/tailbone/views/asgi/__init__.py b/tailbone/views/asgi/__init__.py index 68300a44..d0c12d9c 100644 --- a/tailbone/views/asgi/__init__.py +++ b/tailbone/views/asgi/__init__.py @@ -86,10 +86,10 @@ class WebsocketView(object): # use given db session, or make a new one with app.short_session(config=self.rattail_config, - session=session): + session=session) as s: # load user proper - return session.query(model.User).get(user_uuid) + return s.query(model.User).get(user_uuid) def get_user_session(self, scope): settings = self.registry.settings diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py index 0f198795..c40d6aa2 100644 --- a/tailbone/views/datasync.py +++ b/tailbone/views/datasync.py @@ -202,6 +202,9 @@ class DataSyncThreadView(MasterView): 'watcher_retry_delay': profile.watcher.retry_delay, 'watcher_default_runas': profile.watcher.default_runas, 'watcher_consumes_self': profile.watcher.consumes_self, + 'watcher_kwargs_data': [{'key': key, + 'value': profile.watcher_kwargs[key]} + for key in sorted(profile.watcher_kwargs)], # 'notes': None, # TODO 'enabled': profile.enabled, } @@ -227,8 +230,7 @@ class DataSyncThreadView(MasterView): return { 'profiles': profiles, 'profiles_data': profiles_data, - 'use_profile_settings': self.rattail_config.getbool( - 'rattail.datasync', 'use_profile_settings'), + '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( @@ -265,6 +267,13 @@ class DataSyncThreadView(MasterView): 'value': profile['watcher_default_runas']}, ]) + for kwarg in profile['watcher_kwargs_data']: + settings.append({ + 'name': 'rattail.datasync.{}.watcher.kwarg.{}'.format( + pkey, kwarg['key']), + 'value': kwarg['value'], + }) + consumers = [] if profile['watcher_consumes_self']: consumers = ['self'] @@ -298,11 +307,13 @@ class DataSyncThreadView(MasterView): settings.append({'name': 'rattail.datasync.watch', 'value': ', '.join(watch)}) - settings.append({'name': 'rattail.datasync.supervisor_process_name', - 'value': data['supervisor_process_name']}) + if data['supervisor_process_name']: + settings.append({'name': 'rattail.datasync.supervisor_process_name', + 'value': data['supervisor_process_name']}) - settings.append({'name': 'tailbone.datasync.restart', - 'value': data['restart_command']}) + if data['restart_command']: + settings.append({'name': 'tailbone.datasync.restart', + 'value': data['restart_command']}) return settings From e50356d276f75fbafac586ca7474c98a2d67ead4 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 21 Aug 2022 19:36:48 -0500 Subject: [PATCH 116/978] Expose, honor "admin-ish" flag for roles prevent user (un)assignment etc. unless admin is doing it --- tailbone/views/roles.py | 16 +++++++++++++++- tailbone/views/users.py | 17 +++++++++++++---- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py index 78389d5d..61de606a 100644 --- a/tailbone/views/roles.py +++ b/tailbone/views/roles.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2021 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -54,6 +54,7 @@ class RoleView(PrincipalMasterView): touchable = True labels = { + 'adminish': "Admin-ish", 'sync_me': "Sync Attrs & Perms", } @@ -68,6 +69,7 @@ class RoleView(PrincipalMasterView): form_fields = [ 'name', + 'adminish', 'session_timeout', 'notes', 'sync_me', @@ -112,6 +114,10 @@ class RoleView(PrincipalMasterView): if role is administrator_role(self.Session()): return self.request.is_root + # only "admin" can edit "admin-ish" roles + if role.adminish: + return self.request.is_admin + # can edit Authenticated only if user has permission if role is authenticated_role(self.Session()): return self.has_perm('edit_authenticated') @@ -143,6 +149,10 @@ class RoleView(PrincipalMasterView): if role is guest_role(self.Session()): return False + # only "admin" can delete "admin-ish" roles + if role.adminish: + return self.request.is_admin + # current user can delete their own roles, only if they have permission user = self.request.user if user and role in user.roles: @@ -169,6 +179,10 @@ class RoleView(PrincipalMasterView): # name f.set_validator('name', self.unique_name) + # adminish + if not self.request.is_admin: + f.remove('adminish') + # session_timeout f.set_renderer('session_timeout', self.render_session_timeout) if self.editing and role is guest_role(self.Session()): diff --git a/tailbone/views/users.py b/tailbone/views/users.py index 1fb1250d..0c5821b5 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -27,6 +27,7 @@ User Views from __future__ import unicode_literals, absolute_import import six +import sqlalchemy as sa from sqlalchemy import orm from rattail.db.model import User, UserEvent @@ -276,13 +277,21 @@ class UserView(PrincipalMasterView): authenticated_role(self.Session()).uuid, ] - # only allow "root" user to change admin role membership + # only allow "root" user to change true admin role membership if not self.request.is_root: excluded.append(administrator_role(self.Session()).uuid) - return self.Session.query(model.Role)\ - .filter(~model.Role.uuid.in_(excluded))\ - .order_by(model.Role.name) + # basic list, minus exclusions so far + roles = self.Session.query(model.Role)\ + .filter(~model.Role.uuid.in_(excluded)) + + # only allow "admin" user to change admin-ish role memberships + if not self.request.is_admin: + roles = roles.filter(sa.or_( + model.Role.adminish == False, + model.Role.adminish == None)) + + return roles.order_by(model.Role.name) def objectify(self, form, data=None): model = self.model From 6dfda201169e7eb1efd7c82d54a76c8f0b50d123 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 21 Aug 2022 20:41:55 -0500 Subject: [PATCH 117/978] 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 b3631727..886c5399 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,22 @@ CHANGELOG ========= +0.8.250 (2022-08-21) +-------------------- + +* Add ``render_person_profile()`` method to MasterView. + +* Add way to declare failure for an upgrade. + +* Add websockets progress, "multi-system" support for upgrades. + +* Add global context from handler, for email previews. + +* Allow configuring datasync watcher kwargs. + +* Expose, honor "admin-ish" flag for roles. + + 0.8.249 (2022-08-18) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 5e741492..1063c3d3 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.249' +__version__ = '0.8.250' From 488696cb39717e61c53abe114db9083b3e3696a4 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 22 Aug 2022 01:07:58 -0500 Subject: [PATCH 118/978] Fix index title for datasync configure page --- tailbone/views/datasync.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py index c40d6aa2..316e17fe 100644 --- a/tailbone/views/datasync.py +++ b/tailbone/views/datasync.py @@ -54,7 +54,7 @@ class DataSyncThreadView(MasterView): For now it only serves the config view. """ model_title = "DataSync Thread" - model_title_plural = "DataSync Daemon" + model_title_plural = "DataSync Status" model_key = 'key' route_prefix = 'datasync' url_prefix = '/datasync' From 78500770d9e1c3089785f9925f7d759986d7774d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 23 Aug 2022 23:27:47 -0500 Subject: [PATCH 119/978] Add basic support for backfill Luigi tasks idea being, sometimes you must import many days worth of data into Trainwreck or what-not, and it must be split up b/c e.g. it would take too long to import all at once (i.e. might interfere with overnight tasks) --- tailbone/templates/luigi/configure.mako | 341 ++++++++++++++++++++---- tailbone/templates/luigi/index.mako | 279 +++++++++++++++---- tailbone/views/luigi.py | 205 +++++++++++--- 3 files changed, 688 insertions(+), 137 deletions(-) diff --git a/tailbone/templates/luigi/configure.mako b/tailbone/templates/luigi/configure.mako index b8fba490..cf590adb 100644 --- a/tailbone/templates/luigi/configure.mako +++ b/tailbone/templates/luigi/configure.mako @@ -3,61 +3,213 @@ <%def name="form_content()"> ${h.hidden('overnight_tasks', **{':value': 'JSON.stringify(overnightTasks)'})} + ${h.hidden('backfill_tasks', **{':value': 'JSON.stringify(backfillTasks)'})} -

    Overnight Tasks

    +
    +
    +
    +

    Overnight Tasks

    +
    +
    + + New Task + +
    +
    +
    -
    - - New Task - + + - +
    +
    +
    +
    +

    Backfill Tasks

    +
    +
    + + New Task + +
    +
    + + + + + + + + + +

    Luigi Proper

    @@ -65,8 +217,8 @@ - @@ -74,8 +226,8 @@ - @@ -83,8 +235,8 @@ - @@ -100,28 +252,113 @@ ThisPageData.overnightTasks = ${json.dumps(overnight_tasks)|n} ThisPageData.overnightTaskShowDialog = false ThisPageData.overnightTask = null + ThisPageData.overnightTaskCounter = 0 ThisPageData.overnightTaskKey = null + ThisPageData.overnightTaskDescription = null + ThisPageData.overnightTaskScript = null + ThisPageData.overnightTaskNotes = null ThisPage.methods.overnightTaskCreate = function() { - this.overnightTask = null + this.overnightTask = {key: null} this.overnightTaskKey = null + this.overnightTaskDescription = null + this.overnightTaskScript = null + this.overnightTaskNotes = null this.overnightTaskShowDialog = true this.$nextTick(() => { - this.$refs.overnightTaskKey.focus() + this.$refs.overnightTaskDescription.focus() }) } + ThisPage.methods.overnightTaskEdit = function(task) { + this.overnightTask = task + this.overnightTaskKey = task.key + this.overnightTaskDescription = task.description + this.overnightTaskScript = task.script + this.overnightTaskNotes = task.notes + this.overnightTaskShowDialog = true + } + ThisPage.methods.overnightTaskSave = function() { - if (this.overnightTask) { - this.overnightTask.key = this.overnightTaskKey - } else { - let task = {key: this.overnightTaskKey} - this.overnightTasks.push(task) + this.overnightTask.description = this.overnightTaskDescription + this.overnightTask.script = this.overnightTaskScript + this.overnightTask.notes = this.overnightTaskNotes + + if (!this.overnightTask.key) { + this.overnightTask.key = `_new_${'$'}{++this.overnightTaskCounter}` + this.overnightTasks.push(this.overnightTask) } + this.overnightTaskShowDialog = false this.settingsNeedSaved = true } + ThisPage.methods.overnightTaskDelete = function(task) { + if (confirm("Really delete this task?")) { + let i = this.overnightTasks.indexOf(task) + this.overnightTasks.splice(i, 1) + this.settingsNeedSaved = true + } + } + + ThisPageData.backfillTasks = ${json.dumps(backfill_tasks)|n} + ThisPageData.backfillTaskShowDialog = false + ThisPageData.backfillTask = null + ThisPageData.backfillTaskCounter = 0 + ThisPageData.backfillTaskKey = null + ThisPageData.backfillTaskDescription = null + ThisPageData.backfillTaskScript = null + ThisPageData.backfillTaskForward = false + ThisPageData.backfillTaskTargetDate = null + ThisPageData.backfillTaskNotes = null + + ThisPage.methods.backfillTaskCreate = function() { + this.backfillTask = {key: null} + this.backfillTaskDescription = null + this.backfillTaskScript = null + this.backfillTaskForward = false + this.backfillTaskTargetDate = null + this.backfillTaskNotes = null + this.backfillTaskShowDialog = true + this.$nextTick(() => { + this.$refs.backfillTaskDescription.focus() + }) + } + + ThisPage.methods.backfillTaskEdit = function(task) { + this.backfillTask = task + this.backfillTaskDescription = task.description + this.backfillTaskScript = task.script + this.backfillTaskForward = task.forward + this.backfillTaskTargetDate = task.target_date + this.backfillTaskNotes = task.notes + this.backfillTaskShowDialog = true + } + + ThisPage.methods.backfillTaskDelete = function(task) { + if (confirm("Really delete this task?")) { + let i = this.backfillTasks.indexOf(task) + this.backfillTasks.splice(i, 1) + this.settingsNeedSaved = true + } + } + + ThisPage.methods.backfillTaskSave = function() { + this.backfillTask.description = this.backfillTaskDescription + this.backfillTask.script = this.backfillTaskScript + this.backfillTask.forward = this.backfillTaskForward + this.backfillTask.target_date = this.backfillTaskTargetDate + this.backfillTask.notes = this.backfillTaskNotes + + if (!this.backfillTask.key) { + this.backfillTask.key = `_new_${'$'}{++this.backfillTaskCounter}` + this.backfillTasks.push(this.backfillTask) + } + + this.backfillTaskShowDialog = false + this.settingsNeedSaved = true + } + diff --git a/tailbone/templates/luigi/index.mako b/tailbone/templates/luigi/index.mako index 16ea3489..c4407ff1 100644 --- a/tailbone/templates/luigi/index.mako +++ b/tailbone/templates/luigi/index.mako @@ -1,7 +1,7 @@ ## -*- coding: utf-8; -*- <%inherit file="/page.mako" /> -<%def name="title()">Luigi Jobs +<%def name="title()">View / Launch Tasks <%def name="page_content()">
    @@ -49,13 +49,141 @@ % endif
    - % if master.has_perm('launch'): + % if master.has_perm('launch_overnight'): +

    Overnight Tasks

    - % for task in overnight_tasks: - - - % endfor + + + + + + + % endif + + % if master.has_perm('launch_backfill'): + +

    Backfill Tasks

    + + + + + + + + + + % endif
    @@ -63,8 +191,9 @@ <%def name="modify_this_page_vars()"> ${parent.modify_this_page_vars()} - % if master.has_perm('restart_scheduler'): - - % endif - + % endif -<%def name="finalize_this_page_vars()"> - ${parent.finalize_this_page_vars()} - % if master.has_perm('launch'): - - % endif - + let url = '${url('{}.launch_overnight'.format(route_prefix))}' + let params = {key: task.key} -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} - % if master.has_perm('launch'): - - % endif + this.submitForm(url, params, response => { + this.$buefy.toast.open({ + message: "Task has been scheduled for immediate launch!", + type: 'is-success', + duration: 5000, // 5 seconds + }) + this.overnightTaskLaunching = false + }) + } + + % endif + + % if master.has_perm('launch_backfill'): + + ThisPageData.backfillTasks = ${json.dumps(backfill_tasks)|n} + ThisPageData.backfillTask = null + ThisPageData.backfillTaskStartDate = null + ThisPageData.backfillTaskEndDate = null + ThisPageData.backfillTaskShowLaunchDialog = false + ThisPageData.backfillTaskLaunching = false + + ThisPage.methods.backfillTextClass = function(task) { + if (task.target_date) { + if (task.last_date) { + if (task.forward) { + if (task.last_date >= task.target_date) { + return 'has-text-success' + } else { + return 'has-text-warning' + } + } else { + if (task.last_date <= task.target_date) { + return 'has-text-success' + } else { + return 'has-text-warning' + } + } + } + } + } + + ThisPage.methods.backfillTaskLaunch = function(task) { + this.backfillTask = task + this.backfillTaskStartDate = null + this.backfillTaskEndDate = null + this.backfillTaskShowLaunchDialog = true + } + + ThisPage.methods.backfillTaskLaunchSubmit = function() { + this.backfillTaskLaunching = true + + let url = '${url('{}.launch_backfill'.format(route_prefix))}' + let params = { + key: this.backfillTask.key, + start_date: this.backfillTaskStartDate, + end_date: this.backfillTaskEndDate, + } + + this.submitForm(url, params, response => { + this.$buefy.toast.open({ + message: "Task has been scheduled for immediate launch!", + type: 'is-success', + duration: 5000, // 5 seconds + }) + this.backfillTaskLaunching = false + this.backfillTaskShowLaunchDialog = false + }) + } + + % endif + + diff --git a/tailbone/views/luigi.py b/tailbone/views/luigi.py index 6b0b60e3..dfc68d2f 100644 --- a/tailbone/views/luigi.py +++ b/tailbone/views/luigi.py @@ -27,19 +27,29 @@ Views for Luigi from __future__ import unicode_literals, absolute_import import json +import logging +import os +import re +import shlex + +import six +import sqlalchemy as sa from rattail.util import simple_error from tailbone.views import MasterView -class LuigiJobView(MasterView): +log = logging.getLogger(__name__) + + +class LuigiTaskView(MasterView): """ - Simple views for Luigi jobs. + Simple views for Luigi tasks. """ - normalized_model_name = 'luigijobs' - model_key = 'jobname' - model_title = "Luigi Job" + normalized_model_name = 'luigitasks' + model_key = 'key' + model_title = "Luigi Task" route_prefix = 'luigi' url_prefix = '/luigi' @@ -50,27 +60,57 @@ class LuigiJobView(MasterView): configurable = True def __init__(self, request, context=None): - super(LuigiJobView, self).__init__(request, context=context) + super(LuigiTaskView, self).__init__(request, context=context) app = self.get_rattail_app() self.luigi_handler = app.get_luigi_handler() def index(self): - luigi_url = self.rattail_config.get('luigi', 'url') + luigi_url = self.rattail_config.get('rattail.luigi', 'url') history_url = '{}/history'.format(luigi_url.rstrip('/')) if luigi_url else None return self.render_to_response('index', { 'use_buefy': self.get_use_buefy(), 'index_url': None, 'luigi_url': luigi_url, 'luigi_history_url': history_url, - 'overnight_tasks': self.luigi_handler.get_all_overnight_tasks(), + 'overnight_tasks': self.get_overnight_tasks(), + 'backfill_tasks': self.get_backfill_tasks(), }) - def launch(self): - key = self.request.POST['job'] - assert key - self.luigi_handler.restart_overnight_task(key) - self.request.session.flash("Scheduled overnight task for immediate launch: {}".format(key)) - return self.redirect(self.get_index_url()) + def launch_overnight(self): + app = self.get_rattail_app() + data = self.request.json_body + + key = data.get('key') + task = self.luigi_handler.get_overnight_task(key) if key else None + if not task: + return self.json_response({'error': "Task not found"}) + + try: + self.luigi_handler.launch_overnight_task(task, app.yesterday()) + except Exception as error: + log.warning("failed to launch overnight task: %s", task, + exc_info=True) + return self.json_response({'error': simple_error(error)}) + return self.json_response({'ok': True}) + + def launch_backfill(self): + app = self.get_rattail_app() + data = self.request.json_body + + key = data.get('key') + task = self.luigi_handler.get_backfill_task(key) if key else None + if not task: + return self.json_response({'error': "Task not found"}) + + start_date = app.parse_date(data['start_date']) + end_date = app.parse_date(data['end_date']) + try: + self.luigi_handler.launch_backfill_task(task, start_date, end_date) + except Exception as error: + log.warning("failed to launch backfill task: %s", task, + exc_info=True) + return self.json_response({'error': simple_error(error)}) + return self.json_response({'ok': True}) def restart_scheduler(self): try: @@ -87,36 +127,120 @@ class LuigiJobView(MasterView): return [ # luigi proper - {'section': 'luigi', + {'section': 'rattail.luigi', 'option': 'url'}, - {'section': 'luigi', + {'section': 'rattail.luigi', 'option': 'scheduler.supervisor_process_name'}, - {'section': 'luigi', + {'section': 'rattail.luigi', 'option': 'scheduler.restart_command'}, ] def configure_get_context(self, **kwargs): - context = super(LuigiJobView, self).configure_get_context(**kwargs) - context['overnight_tasks'] = self.luigi_handler.get_all_overnight_tasks() + context = super(LuigiTaskView, self).configure_get_context(**kwargs) + context['overnight_tasks'] = self.get_overnight_tasks() + context['backfill_tasks'] = self.get_backfill_tasks() return context - def configure_gather_settings(self, data): - settings = super(LuigiJobView, self).configure_gather_settings(data) + def get_overnight_tasks(self): + tasks = self.luigi_handler.get_all_overnight_tasks() + for task in tasks: + if task['last_date']: + task['last_date'] = six.text_type(task['last_date']) + return tasks + def get_backfill_tasks(self): + tasks = self.luigi_handler.get_all_backfill_tasks() + for task in tasks: + if task['last_date']: + task['last_date'] = six.text_type(task['last_date']) + if task['target_date']: + task['target_date'] = six.text_type(task['target_date']) + return tasks + + def configure_gather_settings(self, data): + settings = super(LuigiTaskView, self).configure_gather_settings(data) + app = self.get_rattail_app() + + # overnight tasks keys = [] for task in json.loads(data['overnight_tasks']): - keys.append(task['key']) + key = task['key'] + if key.startswith('_new_'): + key = app.make_uuid() + + key = task['key'] + if key.startswith('_new_'): + cmd = shlex.split(task['script']) + script = os.path.basename(cmd[0]) + root, ext = os.path.splitext(script) + key = re.sub(r'\s+', '-', root) + + keys.append(key) + settings.extend([ + {'name': 'rattail.luigi.overnight.{}.description'.format(key), + 'value': task['description']}, + {'name': 'rattail.luigi.overnight.{}.script'.format(key), + 'value': task['script']}, + {'name': 'rattail.luigi.overnight.{}.notes'.format(key), + 'value': task['notes']}, + ]) if keys: - settings.append({'name': 'luigi.overnight_tasks', + settings.append({'name': 'rattail.luigi.overnight_tasks', + 'value': ', '.join(keys)}) + + # backfill tasks + keys = [] + for task in json.loads(data['backfill_tasks']): + + key = task['key'] + if key.startswith('_new_'): + script = os.path.basename(task['script']) + root, ext = os.path.splitext(script) + key = re.sub(r'\s+', '-', root) + + keys.append(key) + settings.extend([ + {'name': 'rattail.luigi.backfill.{}.description'.format(key), + 'value': task['description']}, + {'name': 'rattail.luigi.backfill.{}.script'.format(key), + 'value': task['script']}, + {'name': 'rattail.luigi.backfill.{}.forward'.format(key), + 'value': 'true' if task['forward'] else 'false'}, + {'name': 'rattail.luigi.backfill.{}.notes'.format(key), + 'value': task['notes']}, + {'name': 'rattail.luigi.backfill.{}.target_date'.format(key), + 'value': six.text_type(task['target_date'])}, + ]) + if keys: + settings.append({'name': 'rattail.luigi.backfill_tasks', 'value': ', '.join(keys)}) return settings def configure_remove_settings(self): - super(LuigiJobView, self).configure_remove_settings() - self.luigi_handler.purge_luigi_settings(self.Session()) + super(LuigiTaskView, self).configure_remove_settings() + app = self.get_rattail_app() + model = self.model + session = self.Session() + + to_delete = session.query(model.Setting)\ + .filter(sa.or_( + model.Setting.name == 'rattail.luigi.backfill_tasks', + model.Setting.name.like('rattail.luigi.backfill.%.description'), + model.Setting.name.like('rattail.luigi.backfill.%.forward'), + model.Setting.name.like('rattail.luigi.backfill.%.notes'), + model.Setting.name.like('rattail.luigi.backfill.%.script'), + model.Setting.name.like('rattail.luigi.backfill.%.target_date'), + model.Setting.name == 'rattail.luigi.overnight_tasks', + model.Setting.name.like('rattail.luigi.overnight.%.description'), + model.Setting.name.like('rattail.luigi.overnight.%.notes'), + model.Setting.name.like('rattail.luigi.overnight.%.script')))\ + .all() + + for setting in to_delete: + app.delete_setting(session, setting.name) @classmethod def defaults(cls, config): @@ -130,16 +254,27 @@ class LuigiJobView(MasterView): url_prefix = cls.get_url_prefix() model_title_plural = cls.get_model_title_plural() - # launch job + # launch overnight config.add_tailbone_permission(permission_prefix, - '{}.launch'.format(permission_prefix), - label="Launch any Luigi job") - config.add_route('{}.launch'.format(route_prefix), - '{}/launch'.format(url_prefix), + '{}.launch_overnight'.format(permission_prefix), + label="Launch any Overnight Task") + config.add_route('{}.launch_overnight'.format(route_prefix), + '{}/launch-overnight'.format(url_prefix), request_method='POST') - config.add_view(cls, attr='launch', - route_name='{}.launch'.format(route_prefix), - permission='{}.launch'.format(permission_prefix)) + config.add_view(cls, attr='launch_overnight', + route_name='{}.launch_overnight'.format(route_prefix), + permission='{}.launch_overnight'.format(permission_prefix)) + + # launch backfill + config.add_tailbone_permission(permission_prefix, + '{}.launch_backfill'.format(permission_prefix), + label="Launch any Backfill Task") + config.add_route('{}.launch_backfill'.format(route_prefix), + '{}/launch-backfill'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='launch_backfill', + route_name='{}.launch_backfill'.format(route_prefix), + permission='{}.launch_backfill'.format(permission_prefix)) # restart luigid scheduler config.add_tailbone_permission(permission_prefix, @@ -156,8 +291,8 @@ class LuigiJobView(MasterView): def defaults(config, **kwargs): base = globals() - LuigiJobView = kwargs.get('LuigiJobView', base['LuigiJobView']) - LuigiJobView.defaults(config) + LuigiTaskView = kwargs.get('LuigiTaskView', base['LuigiTaskView']) + LuigiTaskView.defaults(config) def includeme(config): From bcedc58d9f958944ba24b3931c28062b62be853d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 24 Aug 2022 18:24:42 -0500 Subject: [PATCH 120/978] 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 886c5399..e691cc2f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.251 (2022-08-24) +-------------------- + +* Fix index title for datasync configure page. + +* Add basic support for backfill Luigi tasks. + + 0.8.250 (2022-08-21) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 1063c3d3..5cff828f 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.250' +__version__ = '0.8.251' From 2dbba970b9905f96676a734d56da9aa828e80009 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 24 Aug 2022 18:29:46 -0500 Subject: [PATCH 121/978] Only run tests if requested, for release task --- tasks.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tasks.py b/tasks.py index ed19d68f..48b51b39 100644 --- a/tasks.py +++ b/tasks.py @@ -37,13 +37,14 @@ exec(open(os.path.join(here, 'tailbone', '_version.py')).read()) @task -def release(ctx, skip_tests=False): +def release(c, tests=False): """ Release a new version of 'Tailbone'. """ - if not skip_tests: - ctx.run('tox') + if tests: + c.run('tox') - shutil.rmtree('Tailbone.egg-info') - ctx.run('python -m build --sdist') - ctx.run('twine upload dist/Tailbone-{}.tar.gz'.format(__version__)) + 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__)) From 6a0a4627b4a127c40665dd93c810ddeef6b6f88f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 24 Aug 2022 20:06:38 -0500 Subject: [PATCH 122/978] Avoid error when no datasync profiles configured at least, according to the web app none are configured..but they may be in another config file --- tailbone/views/datasync.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py index 316e17fe..e6c31721 100644 --- a/tailbone/views/datasync.py +++ b/tailbone/views/datasync.py @@ -97,7 +97,12 @@ class DataSyncThreadView(MasterView): process_info = None supervisor_error = simple_error(error) - profiles = self.datasync_handler.get_configured_profiles() + try: + profiles = self.datasync_handler.get_configured_profiles() + except Exception as error: + log.warning("could not load profiles!", exc_info=True) + self.request.session.flash(simple_error(error), 'error') + profiles = {} sql = """ select source, consumer, count(*) as changes From f005ef4d523b5c026a55eb252724a3c702f86a0f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 25 Aug 2022 22:15:56 -0500 Subject: [PATCH 123/978] Add max lengths when editing person name via profile view --- tailbone/templates/people/view_profile_buefy.mako | 12 +++++++++--- tailbone/views/people.py | 3 +++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako index cf665da9..51ecaed0 100644 --- a/tailbone/templates/people/view_profile_buefy.mako +++ b/tailbone/templates/people/view_profile_buefy.mako @@ -69,13 +69,19 @@ diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 5dc76b73..1993c2e3 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -447,6 +447,9 @@ class PersonView(MasterView): def get_max_lengths(self): model = self.model return { + '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), From 36ba6f146341503f54c635218c162f9d67ce4757 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 25 Aug 2022 22:18:33 -0500 Subject: [PATCH 124/978] 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 e691cc2f..1bdff255 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.252 (2022-08-25) +-------------------- + +* Avoid error when no datasync profiles configured. + +* Add max lengths when editing person name via profile view. + + 0.8.251 (2022-08-24) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 5cff828f..c2efe75a 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.251' +__version__ = '0.8.252' From 187fea6d1b4deee67e39358915025e09643a7287 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 27 Aug 2022 22:45:52 -0500 Subject: [PATCH 125/978] Convert value for date filter; only add condition if valid --- tailbone/grids/filters.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py index 06c4e7db..00f73e9b 100644 --- a/tailbone/grids/filters.py +++ b/tailbone/grids/filters.py @@ -682,6 +682,23 @@ class AlchemyDateFilter(AlchemyGridFilter): else: return dt.date() + def filter_equal(self, query, value): + date = self.make_date(value) + if not date: + return query + + return query.filter(self.column == self.encode_value(date)) + + def filter_not_equal(self, query, value): + date = self.make_date(value) + if not date: + return query + + return query.filter(sa.or_( + self.column == None, + self.column != self.encode_value(date), + )) + def filter_between(self, query, value): """ Filter data with a "between" query. Really this uses ">=" and "<=" From 6ea8a02b57b8a9020b621b06cf8882f6b3a9bd45 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 27 Aug 2022 23:36:09 -0500 Subject: [PATCH 126/978] Add 'warning' flash messages to old jquery base template --- tailbone/templates/base.mako | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index daa60e2d..43f3a1dd 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -138,6 +138,17 @@
    % endif + % if request.session.peek_flash('warning'): +
    + % for msg in request.session.pop_flash('warning'): +
    + + ${msg} +
    + % endfor +
    + % endif + % if request.session.peek_flash():
    % for msg in request.session.pop_flash(): From bb4e98af8d3d1eccd911cbecc00a0036daf7435d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 30 Aug 2022 10:58:13 -0500 Subject: [PATCH 127/978] Add uom fields, configurable template for newproduct batch --- .../static/files/newproduct_template.xlsx | Bin 0 -> 5041 bytes .../templates/batch/newproduct/configure.mako | 9 +++++ tailbone/views/batch/newproduct.py | 38 +++++++++++++++--- 3 files changed, 41 insertions(+), 6 deletions(-) create mode 100644 tailbone/static/files/newproduct_template.xlsx create mode 100644 tailbone/templates/batch/newproduct/configure.mako diff --git a/tailbone/static/files/newproduct_template.xlsx b/tailbone/static/files/newproduct_template.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..82ce5ff1e5fb5f5f29db60c98f2a020aef725a17 GIT binary patch literal 5041 zcmaJ_by$>p)24UnB?P6@rCUl;x|ee4E=lPUL|UX31f--Cq*FQs$pr$zus_uMlOHB2mWG$0U&7U4>#k9N(dkoSJhyyhOZ&Yrx! zZXQlnZf;IIelE^Ds9(yGG@L&05k&4@+)zC3Q2|)TinhkeR$QE*f7D+7)GeGmQFj&0 zLv4&_yY=b$r^$mh0OSkUB^5z#ESQ9GgN#C`Q zO@&aZ@EX+|h#K-(btT?4E$CO(ES~Id3ftD^9Ym;%Wun7w=$08{*1B>+nmxO&Dy=68 ziOll@bNKoQabX^GNrIns608q74wfFaHo9IO4_xg% zQNW}m%c^1V6GZG@#*8-4x)U?0y}=(|R=~2P+4a8NteaL;r$YH-(s-T0FKLIr{}|E5 z9B{Qn=^iQ1W5MYr97i5voko*wB-SEfvY;aC>|U)VuuN~BuQ^03D&G%7P3+49B9My} zai6|l&O*^3c}0k>GG%VwW15n^Vbbxano-l-rgxKUSED5o`KLH2)C6;%c^)D$8Tcb= zH~&TrRY{%>wzgiLynlW0qmXNbYIx2HgIbTF5B>UzZ>Shn+eGPnOvx85vkK$_r*9B4 z$&y%uEYKj;7vG!FPS_nKG{;x~5e5=%4O6DcBTLP_vEp~RSeBgEa4;9V`JVZyPx-6` zx0zq^d(#$!o{_8)jLd<#;Jyb7U6Pc<0zC#|G94>J8Yu%Kl3I%F!-+I|G=ftq@B0!% z3#S4EC~x+x6~zX~ElB?mkWX{EHQeJQ>X)1dG)KJcGx;MGl5^QEJTFkWH_ z=irjszd5Q2O1XLDQQPBGDz$EB4nHsk>dnqe91gr%w^Wyrh4zq$rZ-Y{o^pHwVxHXF zGpjGBO-EdiBDmO+Z)D*HgILT+A{k5MwQ+V&f;%^E=wae5%%1@8%>Y=_MTtvz;F2Oe zhnkP%N_f57XRde(vNjhlTtv%otuk{%IkGVN+_igr6ZeN~n@Y*q{L5J>(sY8ld^(E0 zUfe=JL`Ky_6hl6ZUYFfY2#v81SQsiCg^``#5*(w}rGnSYg{T$9ycV;nsXrA(7*DTS z3%S6E#`ds_ECn@x&TW5p^hmX@+a3gnO}lc!)+kFBmI#gB=hut68x}q}xH= z??XW4g1_a>vc~A8pJ5I+kMSr5zh#NT!v&nHTBS@>z3Aq*Q}T{;h@`XUA!lG7^3o^) zOR_Yv*;Peg6La4*ILBg-qYliIpJ|>8*EijGXUep%?>`OJJA6u_FTX=Yq z?j1n(TA8=xT!LMQj`VeMnuAqnZLB+aTk6zFTf@3e>(&&w@kG8SoVxBr&1>2lGF@&l zhw@l`Rw8JQ9+X-xc;>gD6x$SSOV7WWE~FXSqUZx%An|nQ{+N zOp`dqgObfo89jRPyNrzt>hP71-eT-0H!GjT?M@44uk&HdzsqLe=@Yca`;2WM#$_Z& z9ITi0wtdME^BDIjH@c-`W|DN8I{51-&mFasBWmfpbefxw`O1!rgPp;5d`cOWMWJPN z&{w@o0P{ras@o$|QmovMUw{}>d}JpT>N}6v1r%!@g}<}Cc(>8rL*CGmrH1ax*mDH@ihJsCMfnxBiSJyi)q*<98mz18VlI{9B6BOpHUcpp}`;fPJ?C~=IJ}=jCtJ4{YQ@-bOz!L`wOuhYe(PkPW$#Hi7)F2R4f9QByJwF zKbCPQQ0I^vp4ewuw0YcXJjOWxfZ43}9#2E#XPu7(uqC6o=)AtG8`Je+0W0a-S?l(% z@q%J*m3>331$#`|Yg;JbXU5xoo%aP;K`n>umL#m`5L>Tl^@eVvOL9h3eTxw#*UX}M zvROxQXQh3kZ@W}lausLOERtJad>6lD%PW*z9FJ2#B^GuM(lJMwB)4Vx7M{7r3nEwr z|ICfPH{~G!2_D6kEydlYdTcuUWj;h+lO;+PRA2eq<>S#|B6ILX@3< z*Ft_MxFtt%%tkm4=d(BsVb`9IzzH9f7S4uC#^_?QhJe=N(8k#HSpK1nKP$(7T>E-Wi`&X0SHZXO2 z#YY~rP*Hi_c5hV31$^&|_dxB-Vk6z+(OLk2z~BdL#6q_bOEbQwln>L z=cD&+6p`N?*+m2LX82gaeh*2lNdZL2I5iO?8n!@*-ojW?>^)RNcqpHqVSBW2;*3JzNP`?rX^fcgeDNEhPgt zdnYseBVvIcIf;p!RJ7)v(Cyp`+5wlEK|~rnIW|8Qrn%1-`9({Dlvt}$9mOKh9e>u8 zga;);%7njDqbwJ zrXYS-#%hF983!iVs*LoLq5IsZS>tvG0(bR%SpPzsb`nX5XV;yR4WxHO!D9Gpbs`#3;1RBI>`eW=q!qPnx(2T1{^ z>pgv=g%?F!`y7iKhR;24EMCdq9t*Hc6O6D!xEB?bW>TxRqO9%wgbpIpvqmO95B+oT*eSj_J1n9hJQ{#GRLl_^ z_rq2n3zK|P2iwD#@>#F^X84mSs1j{bZN-w1p_ePK9r36G?gnFuXHyPK2Z!XNJr zP}so?3T0aK4-C?Q5ZmdS*Y~HUlZc)=B?V)cDAv z{#)1ot>u$PR5AHMQM*AgiGjZLH{@+fQQ%`vZRz8ysuAI&?=pT*seIu3tXS^Qlw%6G*KLs9JW zGBhud`>vL#t#W6* zls3&u)Y3vuIz79OVmM~%G>mgIiiF63?ipB7q3`t>-5YYxb(b~a4d9U4?IGC6$j|9u z=Vk+48phk(rdk;#x3NVxQhd&`TL?#1Gstlxbi4cjl47z<#=#%V@`*v(avTDkh1oQq z50RrR#1;xA)!WsArw-V5w~oiPo~Q1utU7lxx_ocSuVfI#E+MdIIAwN?KQVucK&-bN zN@d<#a~BRFRAhLl$h*3zohEC%*w7e6vBij$U)YfeT-X=^^0sq2K<|7B`cm#Q0~Os^+FzaBQ^JH_E7|o^5aFu+TkToRvJz%s%_# zcF6GYa{mgvw zf11?4vkE~Rjw^QwLGUBi36k69I$AX8a_)P+P-2OBM0!S6&E)Pu4IVo?Ccg?;pgM9d zJ4ltpq@=V+TN`MiK3Yi#|8OH^b(mWMuu<_c%SOHKP3hYzeBVRDsO6<^66GtA@!jml zyGcdbq{+{caKg%~BPHFg%;>xol%{-KeCTsJoTW#BCeDpzxmv_aIag_qF;N-vVSj&M zjm*yMA1j#*nIU62H&-uPS1)rt|A)4orhg4udN8dI4CL)+P$3fy%L9wp2MuBLN~X&a z)Zvp9lNjj24OjC@>#3s6n$};Rb_BNb(zytmig;IJtfzbyyER6lB7@JI!sCgMXxFOf z^YTcAiBLQWZJk*!!0R*m}QCCUWSZ zl}!>Lh=w}FNHd8WMH8b7I)8#6TvJ8aZY}~aZ-Hq%xy2f3x%-jZEY1*8D-VeGm7jvn zJ$ZyjWWV#^icOqFLz@kKG}<-{v{{D96NC-o>!jQvld?LbST}5r1sqkcUp+~H194jM zOdfnlk-C#mS!}r{TU!t`FnfGV??%KuoLQC4xSwNv?;OF>H_pWRKKG~YPF;liUf{mz zMD%63UuxBnlGy2AJX6BJRBejP4ip=8e$X+<(SB`Lu5TAm+m-*cf8VwI9_RYJjoL;0 zGAiVgQEKJ)&BX5!uIpvgT>i@-$f`m{_@7hy?@_KRD3rGTWurL%ALSpV{k!w^PK6R# zzYGWWH_7$8>vb+iwX$C}i~M@6vHk9No!n4V|H}>u{+sxJ_r8AcPz3nPXo#+P|3inr kyI-#i)am?XZAkF`$61A_VIyHgL&HU0(#W)!{PocP0~fMi5dZ)H literal 0 HcmV?d00001 diff --git a/tailbone/templates/batch/newproduct/configure.mako b/tailbone/templates/batch/newproduct/configure.mako new file mode 100644 index 00000000..e4fa346a --- /dev/null +++ b/tailbone/templates/batch/newproduct/configure.mako @@ -0,0 +1,9 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="form_content()"> + ${self.input_file_templates_section()} + + + +${parent.body()} diff --git a/tailbone/views/batch/newproduct.py b/tailbone/views/batch/newproduct.py index e74ffcf6..23f5937b 100644 --- a/tailbone/views/batch/newproduct.py +++ b/tailbone/views/batch/newproduct.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2021 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -46,6 +46,9 @@ class NewProductBatchView(BatchMasterView): rows_editable = True rows_bulk_deletable = True + configurable = True + has_input_file_templates = True + form_fields = [ 'id', 'input_filename', @@ -64,14 +67,14 @@ class NewProductBatchView(BatchMasterView): row_grid_columns = [ 'sequence', - 'upc', + '_product_key_', 'brand_name', 'description', 'size', 'vendor', 'vendor_item_code', - 'department', - 'subdepartment', + 'department_name', + 'subdepartment_name', 'regular_price', 'status_code', ] @@ -79,16 +82,20 @@ class NewProductBatchView(BatchMasterView): row_form_fields = [ 'sequence', 'product', - 'upc', + '_product_key_', 'brand_name', 'description', 'size', + 'unit_size', + 'unit_of_measure_entry', 'vendor_id', 'vendor', 'vendor_item_code', 'department_number', + 'department_name', 'department', 'subdepartment_number', + 'subdepartment_name', 'subdepartment', 'case_size', 'case_cost', @@ -108,6 +115,14 @@ class NewProductBatchView(BatchMasterView): 'status_text', ] + def get_input_file_templates(self): + return [ + {'key': 'default', + 'label': "Default", + 'default_url': self.request.static_url( + 'tailbone:static/files/newproduct_template.xlsx')}, + ] + def configure_form(self, f): super(NewProductBatchView, self).configure_form(f) @@ -127,6 +142,10 @@ class NewProductBatchView(BatchMasterView): g.set_type('pack_price', 'currency') g.set_type('suggested_price', 'currency') + g.set_link('brand_name') + g.set_link('description') + g.set_link('size') + def row_grid_extra_class(self, row, i): if row.status_code in (row.STATUS_MISSING_KEY, row.STATUS_PRODUCT_EXISTS, @@ -159,5 +178,12 @@ class NewProductBatchView(BatchMasterView): f.set_renderer('report', self.render_report) -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + NewProductBatchView = kwargs.get('NewProductBatchView', base['NewProductBatchView']) NewProductBatchView.defaults(config) + + +def includeme(config): + defaults(config) From ef045607d9d93590df0d70c34b84d92d464fce13 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 30 Aug 2022 11:04:26 -0500 Subject: [PATCH 128/978] 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 1bdff255..baf791a6 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.8.253 (2022-08-30) +-------------------- + +* Convert value for date filter; only add condition if valid. + +* Add 'warning' flash messages to old jquery base template. + +* Add uom fields, configurable template for newproduct batch. + + 0.8.252 (2022-08-25) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index c2efe75a..2dc92815 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.252' +__version__ = '0.8.253' From 731c2168b0914d07a8ed144d596a9f51a5f240db Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 30 Aug 2022 11:28:16 -0500 Subject: [PATCH 129/978] Improve parsing of purchase order quantities --- tailbone/views/purchasing/ordering.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/views/purchasing/ordering.py b/tailbone/views/purchasing/ordering.py index c864ec35..d772a359 100644 --- a/tailbone/views/purchasing/ordering.py +++ b/tailbone/views/purchasing/ordering.py @@ -390,7 +390,7 @@ class OrderingBatchView(PurchasingBatchView): if cases_ordered == '': cases_ordered = 0 else: - cases_ordered = int(cases_ordered) + cases_ordered = int(float(cases_ordered)) if cases_ordered >= 100000: # TODO: really this depends on underlying column return {'error': "Invalid value for cases ordered: {}".format(cases_ordered)} @@ -401,7 +401,7 @@ class OrderingBatchView(PurchasingBatchView): if units_ordered == '': units_ordered = 0 else: - units_ordered = int(units_ordered) + units_ordered = int(float(units_ordered)) if units_ordered >= 100000: # TODO: really this depends on underlying column return {'error': "Invalid value for units ordered: {}".format(units_ordered)} From 12e4b0a1393d19d39383eede65df1918cb428322 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 30 Aug 2022 13:57:18 -0500 Subject: [PATCH 130/978] Expose more attrs for new product batch rows --- tailbone/views/batch/newproduct.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tailbone/views/batch/newproduct.py b/tailbone/views/batch/newproduct.py index 23f5937b..03ca638b 100644 --- a/tailbone/views/batch/newproduct.py +++ b/tailbone/views/batch/newproduct.py @@ -97,6 +97,10 @@ class NewProductBatchView(BatchMasterView): 'subdepartment_number', 'subdepartment_name', 'subdepartment', + 'weighed', + 'tax1', + 'tax2', + 'tax3', 'case_size', 'case_cost', 'unit_cost', @@ -111,6 +115,7 @@ class NewProductBatchView(BatchMasterView): 'family', 'report_code', 'report', + 'ecommerce_available', 'status_code', 'status_text', ] From 9ea103c0ebe0c1124a6c14f1b8676828f9cfe2f7 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 30 Aug 2022 14:18:57 -0500 Subject: [PATCH 131/978] 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 baf791a6..96adc463 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.254 (2022-08-30) +-------------------- + +* Improve parsing of purchase order quantities. + +* Expose more attrs for new product batch rows. + + 0.8.253 (2022-08-30) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 2dc92815..2867b87f 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.253' +__version__ = '0.8.254' From 960d6279a9c70aa2b750ca8b3ef90cc23181e25f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 30 Aug 2022 21:14:01 -0500 Subject: [PATCH 132/978] Include `WorkOrder.estimated_total` for API --- tailbone/api/workorders.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/api/workorders.py b/tailbone/api/workorders.py index cac9e372..991df36a 100644 --- a/tailbone/api/workorders.py +++ b/tailbone/api/workorders.py @@ -55,6 +55,7 @@ class WorkOrderView(APIMasterView): 'id': workorder.id, 'customer_uuid': workorder.customer.uuid, 'customer_name': workorder.customer.name, + 'estimated_total': workorder.estimated_total, 'notes': workorder.notes, 'status_code': workorder.status_code, 'status_label': self.enum.WORKORDER_STATUS[workorder.status_code], From 35728e20be1898d39c494829170538df30bc65df Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 30 Aug 2022 21:56:46 -0500 Subject: [PATCH 133/978] Add default normalize logic for API views and use common logic for getting field list in traditional Form class --- tailbone/api/master.py | 18 ++++++++++++++++++ tailbone/api/workorders.py | 13 ++++--------- tailbone/forms/core.py | 16 +++------------- 3 files changed, 25 insertions(+), 22 deletions(-) diff --git a/tailbone/api/master.py b/tailbone/api/master.py index 670a6104..97426214 100644 --- a/tailbone/api/master.py +++ b/tailbone/api/master.py @@ -28,7 +28,10 @@ from __future__ import unicode_literals, absolute_import import json +import six + from rattail.config import parse_bool +from rattail.db.util import get_fieldnames from cornice import resource, Service @@ -268,6 +271,21 @@ class APIMasterView(APIView): query = self.Session.query(cls) return query + def get_fieldnames(self): + if not hasattr(self, '_fieldnames'): + self._fieldnames = get_fieldnames( + self.rattail_config, self.model_class, + columns=True, proxies=True, relations=False) + return self._fieldnames + + def normalize(self, obj): + data = {'_str': six.text_type(obj)} + + for field in self.get_fieldnames(): + data[field] = getattr(obj, field) + + return data + def _collection_get(self): from sqlalchemy_filters import apply_filters, apply_sort, apply_pagination diff --git a/tailbone/api/workorders.py b/tailbone/api/workorders.py index 991df36a..eabe4cdb 100644 --- a/tailbone/api/workorders.py +++ b/tailbone/api/workorders.py @@ -49,21 +49,16 @@ class WorkOrderView(APIMasterView): self.workorder_handler = app.get_workorder_handler() def normalize(self, workorder): - return { - '_str': six.text_type(workorder), - 'uuid': workorder.uuid, - 'id': workorder.id, - 'customer_uuid': workorder.customer.uuid, + data = super(WorkOrderView, self).normalize(workorder) + data.update({ 'customer_name': workorder.customer.name, - 'estimated_total': workorder.estimated_total, - 'notes': workorder.notes, - 'status_code': workorder.status_code, '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 ''), - } + }) + return data def create_object(self, data): diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index ac17c1b4..ee916d5f 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -37,6 +37,7 @@ from sqlalchemy.ext.associationproxy import AssociationProxy, ASSOCIATION_PROXY from rattail.time import localtime from rattail.util import prettify, pretty_boolean, pretty_quantity from rattail.core import UNSPECIFIED +from rattail.db.util import get_fieldnames import colander import deform @@ -396,19 +397,8 @@ class Form(object): if not self.model_class: raise ValueError("Must define model_class to use make_fields()") - mapper = orm.class_mapper(self.model_class) - - # first add primary column fields - fields = FieldList([prop.key for prop in mapper.iterate_properties - if not prop.key.startswith('_') - and prop.key != 'versions']) - - # then add association proxy fields - for key, desc in sa.inspect(self.model_class).all_orm_descriptors.items(): - if desc.extension_type == ASSOCIATION_PROXY: - fields.append(key) - - return fields + return get_fieldnames(self.request.rattail_config, self.model_class, + columns=True, proxies=True, relations=True) def make_renderers(self): """ From b5a519d132ef75c5b9366bb4a61c6e91706dcf49 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 31 Aug 2022 16:41:58 -0500 Subject: [PATCH 134/978] Disable "Delete Results" button if no results, for row grid --- tailbone/templates/batch/view.mako | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako index 919924f0..66a6881a 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -361,6 +361,7 @@ % if use_buefy and master.rows_bulk_deletable and not batch.executed and master.has_perm('delete_rows'): Delete Results From c43a4edec7ef1ea59794021fbf61658fe716f60f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 31 Aug 2022 20:52:17 -0500 Subject: [PATCH 135/978] Move logic for "bulk-delete row objects" into MasterView i guess so far it has only been needed for batch, but some day surely it will be needed for something else..? some of the template logic is still batch only i think.. --- tailbone/views/batch/core.py | 25 +++++++--------------- tailbone/views/master.py | 40 ++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 18 deletions(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 24aa94d4..6dc2436d 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -1264,22 +1264,19 @@ class BatchMasterView(MasterView): """ self.handler.do_remove_row(row) - def bulk_delete_rows(self): - """ - "Delete" all rows matching the current row grid view query. This sets - the ``removed`` flag on the rows but does not truly delete them. - """ + def delete_row_objects(self, rows): + deleted = super(BatchMasterView, self).delete_row_objects(rows) batch = self.get_instance() - query = self.get_effective_row_data(sort=False) - # TODO: this should surely be handled by the handler... + # decrement rowcount for batch if batch.rowcount is not None: - batch.rowcount -= query.count() - query.update({'removed': True}, synchronize_session=False) + batch.rowcount -= deleted + + # refresh batch status self.Session.refresh(batch) self.handler.refresh_batch_status(batch) - return self.redirect(self.get_action_url('view', batch)) + return deleted def execute(self): """ @@ -1505,14 +1502,6 @@ class BatchMasterView(MasterView): config.add_tailbone_permission(permission_prefix, '{}.refresh'.format(permission_prefix), "Refresh data for {}".format(model_title)) - # bulk delete rows - if cls.rows_bulk_deletable: - config.add_route('{}.delete_rows'.format(route_prefix), '{}/{{uuid}}/rows/delete'.format(url_prefix)) - config.add_view(cls, attr='bulk_delete_rows', route_name='{}.delete_rows'.format(route_prefix), - permission='{}.delete_rows'.format(permission_prefix)) - config.add_tailbone_permission(permission_prefix, '{}.delete_rows'.format(permission_prefix), - "Bulk-delete data rows from {}".format(model_title)) - # 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), diff --git a/tailbone/views/master.py b/tailbone/views/master.py index ad1d088d..c98d1a0e 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -4182,6 +4182,30 @@ class MasterView(View): self.delete_row_object(row) return self.redirect(self.get_action_url('view', self.get_parent(row))) + def bulk_delete_rows(self): + """ + Delete all row objects matching the current row grid query. + """ + obj = self.get_instance() + rows = self.get_effective_row_data(sort=False).all() + + # TODO: this should use a separate thread with progress + self.delete_row_objects(rows) + self.Session.refresh(obj) + + return self.redirect(self.get_action_url('view', obj)) + + def delete_row_objects(self, rows): + """ + Perform the actual deletion of given row objects. + """ + deleted = 0 + for row in rows: + if self.row_deletable(row): + self.delete_row_object(row) + deleted += 1 + return deleted + def get_parent(self, row): raise NotImplementedError @@ -4940,6 +4964,22 @@ class MasterView(View): config.add_view(cls, attr='create_row', route_name='{}.create_row'.format(route_prefix), permission='{}.create_row'.format(permission_prefix)) + # bulk-delete rows + # nb. must be defined before view_row b/c of url similarity + if cls.rows_bulk_deletable: + config.add_tailbone_permission(permission_prefix, + '{}.delete_rows'.format(permission_prefix), + "Bulk-delete {} from {}".format( + row_model_title_plural, model_title)) + config.add_route('{}.delete_rows'.format(route_prefix), + '{}/rows/delete'.format(instance_url_prefix), + # TODO: should enforce this + # request_method='POST' + ) + config.add_view(cls, attr='bulk_delete_rows', + route_name='{}.delete_rows'.format(route_prefix), + permission='{}.delete_rows'.format(permission_prefix)) + # view row if cls.has_rows: if cls.rows_viewable: From 365e4a41946eabfd5d79f4d630717c14eed0dd8a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 6 Sep 2022 13:09:14 -0500 Subject: [PATCH 136/978] Convert value for more date filters; only add condition if valid missed these in 187fea6d1b4deee67e39358915025e09643a7287 --- tailbone/grids/filters.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py index 00f73e9b..f504664b 100644 --- a/tailbone/grids/filters.py +++ b/tailbone/grids/filters.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2021 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -699,6 +699,30 @@ class AlchemyDateFilter(AlchemyGridFilter): self.column != self.encode_value(date), )) + def filter_greater_than(self, query, value): + date = self.make_date(value) + if not date: + return query + return query.filter(self.column > self.encode_value(date)) + + def filter_greater_equal(self, query, value): + date = self.make_date(value) + if not date: + return query + return query.filter(self.column >= self.encode_value(date)) + + def filter_less_than(self, query, value): + date = self.make_date(value) + if not date: + return query + return query.filter(self.column < self.encode_value(date)) + + def filter_less_equal(self, query, value): + date = self.make_date(value) + if not date: + return query + return query.filter(self.column <= self.encode_value(date)) + def filter_between(self, query, value): """ Filter data with a "between" query. Really this uses ">=" and "<=" From b37f63a2319700e9ced88523cd1d9227a9afeeb3 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 6 Sep 2022 13:21:29 -0500 Subject: [PATCH 137/978] 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 96adc463..daa91c4a 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,20 @@ CHANGELOG ========= +0.8.255 (2022-09-06) +-------------------- + +* Include ``WorkOrder.estimated_total`` for API. + +* Add default normalize logic for API views. + +* Disable "Delete Results" button if no results, for row grid. + +* Move logic for "bulk-delete row objects" into MasterView. + +* Convert value for more date filters; only add condition if valid. + + 0.8.254 (2022-08-30) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 2867b87f..cc4c6300 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.254' +__version__ = '0.8.255' From 2950827c63e533abf0497e0662333cd3bcbdd53b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 6 Sep 2022 16:31:59 -0500 Subject: [PATCH 138/978] Add basic per-item discount support for custorders --- tailbone/templates/custorders/configure.mako | 9 ++++ tailbone/templates/custorders/create.mako | 52 +++++++++++++++++++- tailbone/views/custorders/orders.py | 23 ++++++++- 3 files changed, 82 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/custorders/configure.mako b/tailbone/templates/custorders/configure.mako index 1abbd7b2..0ce07f30 100644 --- a/tailbone/templates/custorders/configure.mako +++ b/tailbone/templates/custorders/configure.mako @@ -88,6 +88,15 @@ + + + Allow per-item discounts + + +
    diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index 4a92c063..f8d7096e 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -805,7 +805,21 @@ - + % if allow_item_discounts: + +
    +
    + + +
    +
    +  % +
    +
    +
    + % endif + {{ getItemTotalPriceDisplay() }} @@ -981,6 +995,12 @@ + % if allow_item_discounts: + + {{ props.row.discount_percent }}{{ props.row.discount_percent ? " %" : "" }} + + % endif + { @@ -1882,6 +1922,10 @@ this.productUnitChoices = row.order_uom_choices this.productUOM = row.order_uom + % if allow_item_discounts: + this.productDiscountPercent = row.discount_percent + % endif + this.itemDialogTabIndex = 1 this.showingItemDialog = true }, @@ -1992,6 +2036,7 @@ }, itemDialogSave() { + this.itemDialogSaving = true let params = { product_is_known: this.productIsKnown, @@ -2002,6 +2047,10 @@ order_uom: this.productUOM, } + % if allow_item_discounts: + params.discount_percent = this.productDiscountPercent + % endif + if (this.productIsKnown) { params.product_uuid = this.productUUID } else { @@ -2032,6 +2081,7 @@ // also update the batch total price this.batchTotalPriceDisplay = response.data.batch.total_price_display + this.itemDialogSaving = false this.showingItemDialog = false }) }, diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index cf231374..224ec33a 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -348,6 +348,7 @@ class CustomerOrderView(MasterView): 'department_options': self.get_department_options(), 'default_uom_choices': self.batch_handler.uom_choices_for_product(None), 'default_uom': None, + 'allow_item_discounts': self.batch_handler.allow_item_discounts(), }) if self.batch_handler.allow_case_orders(): @@ -695,6 +696,7 @@ class CustomerOrderView(MasterView): 'order_quantity': pretty_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), 'department_display': row.department_name, @@ -807,6 +809,7 @@ class CustomerOrderView(MasterView): order_quantity = decimal.Decimal(data.get('order_quantity') or '0') order_uom = data.get('order_uom') + discount_percent = decimal.Decimal(data.get('discount_percent') or '0') if data.get('product_is_known'): @@ -822,6 +825,9 @@ class CustomerOrderView(MasterView): if self.batch_handler.product_price_may_be_questionable(): kwargs['price_needs_confirmation'] = data.get('price_needs_confirmation') + if self.batch_handler.allow_item_discounts(): + kwargs['discount_percent'] = discount_percent + row = self.batch_handler.add_product(batch, product, order_quantity, order_uom, **kwargs) @@ -838,9 +844,14 @@ class CustomerOrderView(MasterView): pending_info['user'] = self.request.user + kwargs = {} + if self.batch_handler.allow_item_discounts(): + kwargs['discount_percent'] = discount_percent + row = self.batch_handler.add_pending_product(batch, pending_info, - order_quantity, order_uom) + order_quantity, order_uom, + **kwargs) self.Session.flush() return {'batch': self.normalize_batch(batch), @@ -860,6 +871,7 @@ class CustomerOrderView(MasterView): order_quantity = decimal.Decimal(data.get('order_quantity') or '0') order_uom = data.get('order_uom') + discount_percent = decimal.Decimal(data.get('discount_percent') or '0') if data.get('product_is_known'): @@ -879,6 +891,9 @@ class CustomerOrderView(MasterView): if self.batch_handler.product_price_may_be_questionable(): row.price_needs_confirmation = data.get('price_needs_confirmation') + if self.batch_handler.allow_item_discounts(): + row.discount_percent = discount_percent + self.batch_handler.refresh_row(row) else: # product is not known @@ -887,6 +902,9 @@ class CustomerOrderView(MasterView): row.order_quantity = order_quantity row.order_uom = order_uom + if self.batch_handler.allow_item_discounts(): + row.discount_percent = discount_percent + # nb. this will refresh the row pending_info = dict(data['pending_product']) self.batch_handler.update_pending_product(row, pending_info) @@ -965,6 +983,9 @@ class CustomerOrderView(MasterView): {'section': 'rattail.custorders', 'option': 'allow_unknown_product', 'type': bool}, + {'section': 'rattail.custorders', + 'option': 'allow_item_discounts', + 'type': bool}, ] @classmethod From f7a019ed83e0b1657ef66e8b34ebce34325e935d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 6 Sep 2022 16:44:26 -0500 Subject: [PATCH 139/978] Make past item lookup optional for custorders --- tailbone/templates/custorders/configure.mako | 9 +++++++++ tailbone/templates/custorders/create.mako | 12 ++++++++++++ tailbone/views/custorders/orders.py | 4 ++++ 3 files changed, 25 insertions(+) diff --git a/tailbone/templates/custorders/configure.mako b/tailbone/templates/custorders/configure.mako index 0ce07f30..6d51e433 100644 --- a/tailbone/templates/custorders/configure.mako +++ b/tailbone/templates/custorders/configure.mako @@ -97,6 +97,15 @@ + + + Allow re-order via past item lookup + + +
    diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index f8d7096e..cdbf584c 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -485,12 +485,14 @@ @click="showAddItemDialog()"> Add Item + % if allow_past_item_reorder: Add Past Item + % endif
    @@ -851,6 +853,7 @@ @selected="productLookupSelected"> + % if allow_past_item_reorder:
    @@ -953,6 +956,7 @@
    + % endif Date: Tue, 6 Sep 2022 22:19:01 -0500 Subject: [PATCH 140/978] Do not convert date if already a date --- tailbone/grids/filters.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py index f504664b..edce41dd 100644 --- a/tailbone/grids/filters.py +++ b/tailbone/grids/filters.py @@ -675,6 +675,9 @@ class AlchemyDateFilter(AlchemyGridFilter): Convert user input to a proper ``datetime.date`` object. """ if value: + if isinstance(value, datetime.date): + return value + try: dt = datetime.datetime.strptime(value, '%Y-%m-%d') except ValueError: From e67cde4255c53761c9dba630b3ddd150a2eec517 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 7 Sep 2022 20:46:18 -0500 Subject: [PATCH 141/978] Avoid use of `self.handler` within batch API views --- tailbone/api/batch/core.py | 41 ++++++++++++++++++++++----------- tailbone/api/batch/inventory.py | 8 +++---- tailbone/api/batch/ordering.py | 12 +++++----- tailbone/api/batch/receiving.py | 24 +++++++++++-------- 4 files changed, 52 insertions(+), 33 deletions(-) diff --git a/tailbone/api/batch/core.py b/tailbone/api/batch/core.py index bbba1fb3..5b6102ed 100644 --- a/tailbone/api/batch/core.py +++ b/tailbone/api/batch/core.py @@ -27,6 +27,7 @@ Tailbone Web API - Batch Views from __future__ import unicode_literals, absolute_import import logging +import warnings import six @@ -84,7 +85,14 @@ class APIBatchView(APIBatchMixin, APIMasterView): def __init__(self, request, **kwargs): super(APIBatchView, self).__init__(request, **kwargs) - self.handler = self.get_handler() + self.batch_handler = self.get_handler() + + @property + def handler(self): + warnings.warn("the `handler` property is deprecated; " + "please use `batch_handler` instead", + DeprecationWarning, stacklevel=2) + return self.batch_handler def normalize(self, batch): app = self.get_rattail_app() @@ -115,7 +123,7 @@ class APIBatchView(APIBatchMixin, APIMasterView): 'executed_display': self.pretty_datetime(executed) if executed else None, 'executed_by_uuid': batch.executed_by_uuid, 'executed_by_display': six.text_type(batch.executed_by or ''), - 'mutable': self.handler.is_mutable(batch), + 'mutable': self.batch_handler.is_mutable(batch), } def create_object(self, data): @@ -128,9 +136,9 @@ class APIBatchView(APIBatchMixin, APIMasterView): user = self.request.user kwargs = dict(data) kwargs['user'] = user - batch = self.handler.make_batch(self.Session(), **kwargs) - if self.handler.should_populate(batch): - self.handler.do_populate(batch, user) + batch = self.batch_handler.make_batch(self.Session(), **kwargs) + if self.batch_handler.should_populate(batch): + self.batch_handler.do_populate(batch, user) return batch def update_object(self, batch, data): @@ -198,7 +206,7 @@ class APIBatchView(APIBatchMixin, APIMasterView): kwargs = dict(self.request.json_body) kwargs.pop('user', None) kwargs.pop('progress', None) - result = self.handler.do_execute(batch, self.request.user, **kwargs) + result = self.batch_handler.do_execute(batch, self.request.user, **kwargs) return {'ok': bool(result), 'batch': self.normalize(batch)} @classmethod @@ -252,7 +260,14 @@ class APIBatchRowView(APIBatchMixin, APIMasterView): def __init__(self, request, **kwargs): super(APIBatchRowView, self).__init__(request, **kwargs) - self.handler = self.get_handler() + self.batch_handler = self.get_handler() + + @property + def handler(self): + warnings.warn("the `handler` property is deprecated; " + "please use `batch_handler` instead", + DeprecationWarning, stacklevel=2) + return self.batch_handler def normalize(self, row): batch = row.batch @@ -267,7 +282,7 @@ class APIBatchRowView(APIBatchMixin, APIMasterView): 'batch_description': batch.description, 'batch_complete': batch.complete, 'batch_executed': bool(batch.executed), - 'batch_mutable': self.handler.is_mutable(batch), + 'batch_mutable': self.batch_handler.is_mutable(batch), 'sequence': row.sequence, 'status_code': row.status_code, 'status_display': row.STATUS.get(row.status_code, six.text_type(row.status_code)), @@ -280,14 +295,14 @@ class APIBatchRowView(APIBatchMixin, APIMasterView): Invokes the batch handler's ``refresh_row()`` method after updating the row's field data per usual. """ - if not self.handler.is_mutable(row.batch): + if not self.batch_handler.is_mutable(row.batch): return {'error': "Batch is not mutable"} # update row per usual row = super(APIBatchRowView, self).update_object(row, data) # okay now we apply handler refresh logic - self.handler.refresh_row(row) + self.batch_handler.refresh_row(row) return row def delete_object(self, row): @@ -296,7 +311,7 @@ class APIBatchRowView(APIBatchMixin, APIMasterView): Delegates deletion of the row to the batch handler. """ - self.handler.do_remove_row(row) + self.batch_handler.do_remove_row(row) def quick_entry(self): """ @@ -312,10 +327,10 @@ class APIBatchRowView(APIBatchMixin, APIMasterView): entry = data['quick_entry'] try: - row = self.handler.quick_entry(self.Session(), batch, entry) + row = self.batch_handler.quick_entry(self.Session(), batch, entry) except Exception as error: log.warning("quick entry failed for '%s' batch %s: %s", - self.handler.batch_key, batch.id_str, entry, + self.batch_handler.batch_key, batch.id_str, entry, exc_info=True) msg = six.text_type(error) if not msg and isinstance(error, NotImplementedError): diff --git a/tailbone/api/batch/inventory.py b/tailbone/api/batch/inventory.py index f0c68030..5e56fe46 100644 --- a/tailbone/api/batch/inventory.py +++ b/tailbone/api/batch/inventory.py @@ -67,9 +67,9 @@ class InventoryBatchViews(APIBatchView): """ permission_prefix = self.get_permission_prefix() if self.request.is_root: - modes = self.handler.get_count_modes() + modes = self.batch_handler.get_count_modes() else: - modes = self.handler.get_allowed_count_modes( + modes = self.batch_handler.get_allowed_count_modes( self.Session(), self.request.user, permission_prefix=permission_prefix) return modes @@ -79,7 +79,7 @@ class InventoryBatchViews(APIBatchView): Retrieve info about the available "reasons" for inventory adjustment batches. """ - raw_reasons = self.handler.get_adjustment_reasons(self.Session()) + raw_reasons = self.batch_handler.get_adjustment_reasons(self.Session()) reasons = [] for reason in raw_reasons: reasons.append({ @@ -149,7 +149,7 @@ class InventoryBatchRowViews(APIBatchRowView): pretty_quantity(row.cases or row.units), 'CS' if row.cases else data['unit_uom']) - data['allow_cases'] = self.handler.allow_cases(batch) + data['allow_cases'] = self.batch_handler.allow_cases(batch) return data diff --git a/tailbone/api/batch/ordering.py b/tailbone/api/batch/ordering.py index b7bd45cb..9ab9617c 100644 --- a/tailbone/api/batch/ordering.py +++ b/tailbone/api/batch/ordering.py @@ -104,10 +104,10 @@ class OrderingBatchViews(APIBatchView): # organize vendor catalog costs by dept / subdept departments = {} - costs = self.handler.get_order_form_costs(self.Session(), batch.vendor) - costs = self.handler.sort_order_form_costs(costs) + costs = self.batch_handler.get_order_form_costs(self.Session(), batch.vendor) + costs = self.batch_handler.sort_order_form_costs(costs) costs = list(costs) # we must have a stable list for the rest of this - self.handler.decorate_order_form_costs(batch, costs) + self.batch_handler.decorate_order_form_costs(batch, costs) for cost in costs: department = cost.product.department @@ -175,7 +175,7 @@ class OrderingBatchViews(APIBatchView): sorted_departments.append(dept) # fetch recent purchase history, sort/pad for template convenience - history = self.handler.get_order_form_history(batch, costs, 6) + history = self.batch_handler.get_order_form_history(batch, costs, 6) for i in range(6 - len(history)): history.append(None) history = list(reversed(history)) @@ -266,10 +266,10 @@ class OrderingBatchRowViews(APIBatchRowView): Note that the "normal" logic for this method is not invoked at all. """ - if not self.handler.is_mutable(row.batch): + if not self.batch_handler.is_mutable(row.batch): return {'error': "Batch is not mutable"} - self.handler.update_row_quantity(row, **data) + self.batch_handler.update_row_quantity(row, **data) return row diff --git a/tailbone/api/batch/receiving.py b/tailbone/api/batch/receiving.py index ce7c34f6..c755de65 100644 --- a/tailbone/api/batch/receiving.py +++ b/tailbone/api/batch/receiving.py @@ -73,7 +73,7 @@ class ReceivingBatchViews(APIBatchView): data['invoice_total'] = batch.invoice_total data['invoice_total_calculated'] = batch.invoice_total_calculated - data['can_auto_receive'] = self.handler.can_auto_receive(batch) + data['can_auto_receive'] = self.batch_handler.can_auto_receive(batch) return data @@ -89,7 +89,7 @@ class ReceivingBatchViews(APIBatchView): a pending batch. """ batch = self.get_object() - self.handler.auto_receive_all_items(batch) + self.batch_handler.auto_receive_all_items(batch) return self._get(obj=batch) def mark_receiving_complete(self): @@ -119,7 +119,7 @@ class ReceivingBatchViews(APIBatchView): if not vendor: return {'error': "Vendor not found"} - purchases = self.handler.get_eligible_purchases( + purchases = self.batch_handler.get_eligible_purchases( vendor, self.enum.PURCHASE_BATCH_MODE_RECEIVING) purchases = [self.normalize_eligible_purchase(p) @@ -128,10 +128,10 @@ class ReceivingBatchViews(APIBatchView): return {'purchases': purchases} def normalize_eligible_purchase(self, purchase): - return self.handler.normalize_eligible_purchase(purchase) + return self.batch_handler.normalize_eligible_purchase(purchase) def render_eligible_purchase(self, purchase): - return self.handler.render_eligible_purchase(purchase) + return self.batch_handler.render_eligible_purchase(purchase) @classmethod def defaults(cls, config): @@ -321,6 +321,10 @@ class ReceivingBatchRowViews(APIBatchRowView): data['cases_expired'] = row.cases_expired data['units_expired'] = row.units_expired + cases, units = self.batch_handler.get_unconfirmed_counts(row) + data['cases_unconfirmed'] = cases + data['units_unconfirmed'] = units + data['po_unit_cost'] = row.po_unit_cost data['po_total'] = row.po_total @@ -328,7 +332,7 @@ class ReceivingBatchRowViews(APIBatchRowView): data['invoice_total'] = row.invoice_total data['invoice_total_calculated'] = row.invoice_total_calculated - data['allow_cases'] = self.handler.allow_cases() + data['allow_cases'] = self.batch_handler.allow_cases() data['quick_receive'] = self.rattail_config.getbool( 'rattail.batch', 'purchase.mobile_quick_receive', @@ -346,8 +350,8 @@ class ReceivingBatchRowViews(APIBatchRowView): raise NotImplementedError("TODO: add CS support for quick_receive_all") else: data['quick_receive_uom'] = data['unit_uom'] - accounted_for = self.handler.get_units_accounted_for(row) - remainder = self.handler.get_units_ordered(row) - accounted_for + accounted_for = self.batch_handler.get_units_accounted_for(row) + remainder = self.batch_handler.get_units_ordered(row) - accounted_for if accounted_for: # some product accounted for; button should receive "remainder" only @@ -389,7 +393,7 @@ class ReceivingBatchRowViews(APIBatchRowView): default=False) if alert_received: data['received_alert'] = None - if self.handler.get_units_confirmed(row): + 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)) data['received_alert'] = msg @@ -418,7 +422,7 @@ class ReceivingBatchRowViews(APIBatchRowView): # handler takes care of the row receiving logic for us kwargs = dict(form.validated) del kwargs['row'] - self.handler.receive_row(row, **kwargs) + self.batch_handler.receive_row(row, **kwargs) self.Session.flush() return self._get(obj=row) From 3877346b3a377dd35098819a66ff865de845ff5c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 9 Sep 2022 14:53:47 -0500 Subject: [PATCH 142/978] 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 daa91c4a..c3cf9d7e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,18 @@ CHANGELOG ========= +0.8.256 (2022-09-09) +-------------------- + +* Add basic per-item discount support for custorders. + +* Make past item lookup optional for custorders. + +* Do not convert date if already a date (for grid filters). + +* Avoid use of ``self.handler`` within batch API views. + + 0.8.255 (2022-09-06) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index cc4c6300..2383e66f 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.255' +__version__ = '0.8.256' From 733e7ee00c1de7f0cc890eecc79314cba60fb308 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 24 Sep 2022 10:34:32 -0500 Subject: [PATCH 143/978] Add template method for rendering row grid component so custom event hooks can be added more easily, when needed --- tailbone/templates/master/view.mako | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako index 32176712..7b0b2de5 100644 --- a/tailbone/templates/master/view.mako +++ b/tailbone/templates/master/view.mako @@ -107,13 +107,17 @@ % if rows_title:

    ${rows_title}

    % endif - + ${self.render_row_grid_component()} % else: ${rows_grid|n} % endif % endif +<%def name="render_row_grid_component()"> + + + <%def name="render_this_page_template()"> % if master.has_rows: ## TODO: stop using |n filter From 620447f02912ddad09f0beeee97bd6812ef1db2c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 25 Sep 2022 09:18:34 -0500 Subject: [PATCH 144/978] Add version workaround for sphinx-rtd-theme bug --- setup.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 1f65ca97..3328785e 100644 --- a/setup.py +++ b/setup.py @@ -116,7 +116,9 @@ extras = { # # package # low high - 'Sphinx', # 1.2 + # TODO: remove version workaround after next sphinx[-rtd-theme] release + # cf. https://github.com/readthedocs/sphinx_rtd_theme/issues/1343 + 'Sphinx!=5.2.0.post0', # 1.2 'sphinx-rtd-theme', # 0.2.4 ], From 9b101963e5a944f42727a56d7fed239c6022ab84 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 18 Oct 2022 10:55:47 -0500 Subject: [PATCH 145/978] Use people handler to update address --- tailbone/views/people.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 1993c2e3..6d517e3a 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -859,16 +859,8 @@ class PersonView(MasterView): data = dict(self.request.json_body) # update person address - address = person.address - if not address: - address = person.add_address() - address.street = data['street'] - address.street2 = data['street2'] - address.city = data['city'] - address.state = data['state'] - address.zipcode = data['zipcode'] - - self.handler.mark_address_invalid(person, address, data['invalid']) + address = self.people_handler.ensure_address(person) + self.people_handler.update_address(person, address, **data) self.Session.flush() return { From 22c33b58c7dcc81ead922c7a0bfed2f2a7805dce Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 19 Oct 2022 16:26:05 -0500 Subject: [PATCH 146/978] Fix start_date param for pricing batch upload --- tailbone/views/batch/pricing.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/batch/pricing.py b/tailbone/views/batch/pricing.py index cb0f3be9..6ba28889 100644 --- a/tailbone/views/batch/pricing.py +++ b/tailbone/views/batch/pricing.py @@ -193,6 +193,7 @@ class PricingBatchView(BatchMasterView): def get_batch_kwargs(self, batch, **kwargs): kwargs = super(PricingBatchView, self).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 kwargs['calculate_for_manual'] = batch.calculate_for_manual From c2b2d1114187f264102f95e6989a6ad0b417d483 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 29 Oct 2022 13:40:35 -0500 Subject: [PATCH 147/978] Use shared logic for rendering percentage values --- tailbone/forms/core.py | 5 ++--- tailbone/grids/core.py | 5 ++--- tailbone/views/products.py | 4 +++- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index ee916d5f..fb11ffba 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -1006,10 +1006,9 @@ class Form(object): return pretty_quantity(value) def render_percent(self, obj, field): + app = self.request.rattail_config.get_app() value = self.obtain_value(obj, field) - if value is None: - return "" - return "{:0.3f} %".format(value * 100) + return app.render_percent(value, places=3) def render_gpc(self, obj, field): value = self.obtain_value(obj, field) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index b15dcafd..db976432 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -369,10 +369,9 @@ class Grid(object): return value.pretty() def render_percent(self, obj, column_name): + app = self.request.rattail_config.get_app() value = self.obtain_value(obj, column_name) - if value is None: - return "" - return "{:0.3f} %".format(value * 100) + return app.render_percent(value, places=3) def render_quantity(self, obj, column_name): value = self.obtain_value(obj, column_name) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 8f1ea545..ab9f55c6 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -670,7 +670,9 @@ class ProductView(MasterView): return "" if product.volatile.true_margin is None: return "" - return "{:0.3f} %".format(product.volatile.true_margin * 100) + app = self.get_rattail_app() + return app.render_percent(product.volatile.true_margin, + places=3) def render_on_hand(self, product, column): inventory = product.inventory From 38e6441b61cafdda81b744c888738fa966d7d89e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 31 Oct 2022 21:41:01 -0500 Subject: [PATCH 148/978] Log a warning to troubleshoot luigi restart failure --- tailbone/views/luigi.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/luigi.py b/tailbone/views/luigi.py index dfc68d2f..054f24ee 100644 --- a/tailbone/views/luigi.py +++ b/tailbone/views/luigi.py @@ -118,6 +118,7 @@ class LuigiTaskView(MasterView): self.request.session.flash("Luigi scheduler has been restarted.") except Exception as error: + log.warning("restart failed", exc_info=True) self.request.session.flash(simple_error(error), 'error') return self.redirect(self.request.get_referrer( From be533922a2c2dbea83e670ae8092a4170519a3f7 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 3 Nov 2022 11:28:38 -0500 Subject: [PATCH 149/978] Show UPC for receiving line item if no product reference to help with troubleshooting invoice file parsing etc. --- tailbone/templates/receiving/view_row.mako | 5 ++++- tailbone/views/purchasing/receiving.py | 8 ++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/tailbone/templates/receiving/view_row.mako b/tailbone/templates/receiving/view_row.mako index bb4275b8..dca71c35 100644 --- a/tailbone/templates/receiving/view_row.mako +++ b/tailbone/templates/receiving/view_row.mako @@ -85,8 +85,11 @@ ${form.render_field_readonly(product_key_field)} ${form.render_field_readonly('product')} % else: - ${form.render_field_readonly('item_entry')} ${form.render_field_readonly(product_key_field)} + ${form.render_field_readonly('item_entry')} + % if product_key_field != 'upc': + ${form.render_field_readonly('upc')} + % endif ${form.render_field_readonly('brand_name')} ${form.render_field_readonly('description')} ${form.render_field_readonly('size')} diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index af96448f..2fe692f0 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -1479,6 +1479,14 @@ class ReceivingBatchView(PurchasingBatchView): super(ReceivingBatchView, self).configure_row_form(f) batch = self.get_instance() + # when viewing a row which has no product reference, enable + # the 'upc' field to help with troubleshooting + # TODO: this maybe should be optional..? + if self.viewing and 'upc' not in f: + row = self.get_row_instance() + if not row.product: + f.append('upc') + # allow input for certain fields only; all others are readonly mutable = [ 'invoice_unit_cost', From 3b64950a3852bd0e2ee49d8e73e1bae3e6072a82 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 3 Nov 2022 11:34:32 -0500 Subject: [PATCH 150/978] 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 c3cf9d7e..a1a03d46 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,22 @@ CHANGELOG ========= +0.8.257 (2022-11-03) +-------------------- + +* Add template method for rendering row grid component. + +* Use people handler to update address. + +* Fix start_date param for pricing batch upload. + +* Use shared logic for rendering percentage values. + +* Log a warning to troubleshoot luigi restart failure. + +* Show UPC for receiving line item if no product reference. + + 0.8.256 (2022-09-09) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 2383e66f..8f293897 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.256' +__version__ = '0.8.257' From fec259629e164e0be9e301b286970de3c54445aa Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 15 Nov 2022 13:37:37 -0600 Subject: [PATCH 151/978] Let the auth handler manage user merge --- tailbone/views/users.py | 50 +++++++---------------------------------- 1 file changed, 8 insertions(+), 42 deletions(-) diff --git a/tailbone/views/users.py b/tailbone/views/users.py index 0c5821b5..31842d0b 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -52,6 +52,7 @@ class UserView(PrincipalMasterView): model_row_class = UserEvent has_versions = True touchable = True + mergeable = True grid_columns = [ 'username', @@ -78,23 +79,13 @@ class UserView(PrincipalMasterView): 'occurred', ] - mergeable = True - merge_additive_fields = [ - 'sent_message_count', - 'received_message_count', - ] - merge_coalesce_fields = [ - 'person_uuid', - 'person_name', - 'active', - ] - merge_fields = merge_additive_fields + [ - 'uuid', - 'username', - 'person_uuid', - 'person_name', - 'role_count', - ] + def __init__(self, request): + super(UserView, self).__init__(request) + app = self.get_rattail_app() + + # always get a reference to the auth/merge handler + self.auth_handler = app.get_auth_handler() + self.merge_handler = self.auth_handler def query(self, session): query = super(UserView, self).query(session) @@ -441,31 +432,6 @@ class UserView(PrincipalMasterView): users.append(user) return users - def get_merge_data(self, user): - return { - 'uuid': user.uuid, - 'username': user.username, - 'person_uuid': user.person_uuid, - 'person_name': user.person.display_name if user.person else None, - '_roles': user.roles, - 'role_count': len(user.roles), - 'active': user.active, - 'sent_message_count': len(user.sent_messages), - 'received_message_count': len(user._messages), - } - - def get_merge_resulting_data(self, remove, keep): - result = super(UserView, self).get_merge_resulting_data(remove, keep) - result['role_count'] = len(set(remove['_roles'] + keep['_roles'])) - return result - - def merge_objects(self, removing, keeping): - # TODO: merge roles, messages - assert not removing.sent_messages - assert not removing._messages - assert not removing._roles - self.Session.delete(removing) - def preferences(self, user=None): """ View to modify preferences for a particular user. From 3e8924e7ccb248df6f35898e6349a216715ffd6f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 15 Nov 2022 13:39:17 -0600 Subject: [PATCH 152/978] 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 a1a03d46..8eca2ac4 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.258 (2022-11-15) +-------------------- + +* Let the auth handler manage user merge. + + 0.8.257 (2022-11-03) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 8f293897..3447d6bf 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.257' +__version__ = '0.8.258' From deed2111fbd3d31cc44c8bd4cf668358e1facc45 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 15 Nov 2022 16:29:15 -0600 Subject: [PATCH 153/978] Add "between" verb for numeric grid filters --- tailbone/grids/filters.py | 57 +++++++++++++++++++++-- tailbone/static/js/tailbone.buefy.grid.js | 49 +++++++++++++++++++ tailbone/templates/grids/buefy.mako | 31 ++++++++++++ 3 files changed, 134 insertions(+), 3 deletions(-) diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py index edce41dd..2818b78a 100644 --- a/tailbone/grids/filters.py +++ b/tailbone/grids/filters.py @@ -76,8 +76,7 @@ class NumericValueRenderer(FilterValueRenderer): """ Input renderer for numeric values. """ - # TODO - # data_type = 'number' + data_type = 'number' def render(self, value=None, **kwargs): kwargs.setdefault('step', '0.001') @@ -137,6 +136,7 @@ class GridFilter(object): 'less_equal': "less than or equal to", 'is_empty': "is empty", 'is_not_empty': "is not empty", + 'between': "between", 'is_null': "is null", 'is_not_null': "is not null", 'is_true': "is true", @@ -378,6 +378,47 @@ class AlchemyGridFilter(GridFilter): return query return query.filter(self.column <= self.encode_value(value)) + def filter_between(self, query, value): + """ + Filter data with a "between" query. Really this uses ">=" and + "<=" (inclusive) logic instead of SQL "between" keyword. + """ + if value is None or value == '': + return query + + if '|' not in value: + return query + + values = value.split('|') + if len(values) != 2: + return query + + start_value, end_value = values + + # we'll only filter if we have start and/or end value + if not start_value and not end_value: + return query + + return self.filter_for_range(query, start_value, end_value) + + def filter_for_range(self, query, start_value, end_value): + """ + This method should actually apply filter(s) to the query, + according to the given value range. Subclasses may override + this logic. + """ + if start_value: + if self.value_invalid(start_value): + return query + query = query.filter(self.column >= start_value) + + if end_value: + if self.value_invalid(end_value): + return query + query = query.filter(self.column <= end_value) + + return query + class AlchemyStringFilter(AlchemyGridFilter): """ @@ -532,7 +573,8 @@ class AlchemyNumericFilter(AlchemyGridFilter): # expose greater-than / less-than verbs in addition to core default_verbs = ['equal', 'not_equal', 'greater_than', 'greater_equal', - 'less_than', 'less_equal', 'is_null', 'is_not_null', 'is_any'] + '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 @@ -541,6 +583,13 @@ class AlchemyNumericFilter(AlchemyGridFilter): # term for integer field... def value_invalid(self, value): + + # first just make sure it's somewhat numeric + try: + float(value) + except ValueError: + return True + return bool(value and len(six.text_type(value)) > 8) def filter_equal(self, query, value): @@ -726,6 +775,7 @@ class AlchemyDateFilter(AlchemyGridFilter): return query return query.filter(self.column <= self.encode_value(date)) + # TODO: this should be merged into parent class def filter_between(self, query, value): """ Filter data with a "between" query. Really this uses ">=" and "<=" @@ -753,6 +803,7 @@ class AlchemyDateFilter(AlchemyGridFilter): return self.filter_date_range(query, start_date, end_date) + # TODO: this should be merged into parent class def filter_date_range(self, query, start_date, end_date): """ This method should actually apply filter(s) to the query, according to diff --git a/tailbone/static/js/tailbone.buefy.grid.js b/tailbone/static/js/tailbone.buefy.grid.js index a4139bc6..75037448 100644 --- a/tailbone/static/js/tailbone.buefy.grid.js +++ b/tailbone/static/js/tailbone.buefy.grid.js @@ -1,4 +1,53 @@ +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 + } + }, + 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: { diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako index 11b9a86b..ec1a4875 100644 --- a/tailbone/templates/grids/buefy.mako +++ b/tailbone/templates/grids/buefy.mako @@ -1,5 +1,29 @@ ## -*- coding: utf-8; -*- + + + % endif + + <%def name="object_helpers()"> ${self.render_status_breakdown()} ${self.render_po_vs_invoice_helper()} @@ -418,13 +452,128 @@ % endif + % if allow_edit_catalog_unit_cost: + + let ReceivingCostEditor = { + template: '#receiving-cost-editor-template', + props: { + row: Object, + value: String, + }, + data() { + return { + inputValue: this.value, + editing: false, + } + }, + methods: { + + startEdit() { + this.inputValue = this.value + this.editing = true + this.$nextTick(() => { + this.$refs.input.focus() + }) + }, + + inputKeyDown(event) { + + // when user presses Enter while editing cost value, submit + // value to server for immediate persistence + if (event.which == 13) { + this.submitEdit() + + // when user presses Escape, cancel the edit + } else if (event.which == 27) { + this.cancelEdit() + } + }, + + inputBlur(event) { + // always assume user meant to cancel + this.cancelEdit() + }, + + cancelEdit() { + // reset input to discard any user entry + this.inputValue = this.value + this.editing = false + this.$emit('cancel-edit') + }, + + 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, + catalog_unit_cost: this.inputValue, + } + + this.$http.post(url, params, {headers: headers}).then(response => { + if (!response.data.error) { + + // 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.catalog_unit_cost, + 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 + }) + }) + }, + }, + } + + Vue.component('receiving-cost-editor', ReceivingCostEditor) + + ${rows_grid.component_studly}.methods.catalogUnitCostClicked = function(row) { + + // start edit for clicked cell + this.$refs['catalogUnitCost_' + row.uuid].startEdit() + } + + ${rows_grid.component_studly}.methods.catalogCostConfirmed = function(amount, index) { + + // 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() + } + } + + % endif + ${parent.body()} -% if master.handler.allow_truck_dump_receiving() and master.has_perm('edit_row'): +% if not use_buefy and master.handler.allow_truck_dump_receiving() and master.has_perm('edit_row'): ${h.form(url('{}.transform_unit_row'.format(route_prefix), uuid=batch.uuid), name='transform-unit-form')} ${h.csrf_token(request)} ${h.hidden('row_uuid')} diff --git a/tailbone/util.py b/tailbone/util.py index cd6c9237..5dee997f 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -64,6 +64,21 @@ def csrf_token(request, name='_csrf'): return HTML.tag("div", tags.hidden(name, value=token), style="display:none;") +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. + """ + # 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 and not request.POST: + return request.json_body + return request.POST + + def should_use_buefy(request): """ Returns a flag indicating whether or not the current theme supports (and diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 78136ef3..09a28099 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -46,6 +46,7 @@ from pyramid import httpexceptions from webhelpers2.html import tags, HTML from tailbone import forms, grids +from tailbone.util import get_form_data from tailbone.views.purchasing import PurchasingBatchView @@ -715,6 +716,11 @@ 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()) + def template_kwargs_view(self, **kwargs): kwargs = super(ReceivingBatchView, self).template_kwargs_view(**kwargs) batch = kwargs['instance'] @@ -739,6 +745,8 @@ class ReceivingBatchView(PurchasingBatchView): data=breakdown, columns=['title', 'count']) + kwargs['allow_edit_catalog_unit_cost'] = self.allow_edit_catalog_unit_cost(batch) + return kwargs def get_context_credits(self, row): @@ -933,6 +941,7 @@ class ReceivingBatchView(PurchasingBatchView): def configure_row_grid(self, g): super(ReceivingBatchView, self).configure_row_grid(g) + use_buefy = self.get_use_buefy() batch = self.get_instance() # vendor_code @@ -943,6 +952,10 @@ class ReceivingBatchView(PurchasingBatchView): if (self.handler.has_purchase_order(batch) or self.handler.has_invoice_file(batch)): g.remove('catalog_unit_cost') + elif use_buefy and 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)') # po_unit_cost if self.handler.has_invoice_file(batch): @@ -1001,6 +1014,14 @@ class ReceivingBatchView(PurchasingBatchView): else: g.set_enum('truck_dump_status', model.PurchaseBatchRow.STATUS) + def render_catalog_unit_cost(self): + return HTML.tag('receiving-cost-editor', **{ + 'v-model': 'props.row.catalog_unit_cost', + ':ref': "'catalogUnitCost_' + props.row.uuid", + ':row': 'props.row', + '@input': 'catalogCostConfirmed', + }) + def row_grid_extra_class(self, row, i): css_class = super(ReceivingBatchView, self).row_grid_extra_class(row, i) @@ -1790,10 +1811,10 @@ class ReceivingBatchView(PurchasingBatchView): def update_row_cost(self): """ - AJAX view for updating the invoice (actual) unit cost for a row. + AJAX view for updating various cost fields in a data row. """ batch = self.get_instance() - data = dict(self.request.POST) + data = dict(get_form_data(self.request)) # validate row uuid = data.get('row_uuid') @@ -1939,6 +1960,9 @@ class ReceivingBatchView(PurchasingBatchView): {'section': 'rattail.batch', 'option': 'purchase.receiving.should_autofix_invoice_case_vs_unit', 'type': bool}, + {'section': 'rattail.batch', + 'option': 'purchase.receiving.allow_edit_catalog_unit_cost', + 'type': bool}, # mobile interface {'section': 'rattail.batch', From 9c54a4ada16289043cc6b0a7c335437bf50afce4 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 5 Dec 2022 15:22:59 -0600 Subject: [PATCH 178/978] Add receiving workflow as param when making receiving batch --- tailbone/views/purchasing/receiving.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 09a28099..26156516 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -116,6 +116,7 @@ class ReceivingBatchView(PurchasingBatchView): 'batch_type', # TODO: ideally would get rid of this one 'store', 'vendor', + 'description', 'receiving_workflow', 'truck_dump', 'truck_dump_children_first', @@ -126,6 +127,7 @@ class ReceivingBatchView(PurchasingBatchView): 'invoice_parser_key', 'department', 'purchase', + 'params', 'vendor_email', 'vendor_fax', 'vendor_contact', @@ -138,7 +140,6 @@ class ReceivingBatchView(PurchasingBatchView): 'invoice_number', 'invoice_total', 'invoice_total_calculated', - 'description', 'notes', 'created', 'created_by', @@ -647,6 +648,8 @@ class ReceivingBatchView(PurchasingBatchView): 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': kwargs.pop('truck_dump_batch', None) kwargs.pop('truck_dump_batch_uuid', None) From 36a5f2ab492c46d3ea4e5086690409425248d51a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 5 Dec 2022 16:05:27 -0600 Subject: [PATCH 179/978] Show invoice cost in receiving batch, if "from scratch" --- tailbone/views/purchasing/receiving.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 26156516..4937b80f 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -960,13 +960,13 @@ class ReceivingBatchView(PurchasingBatchView): g.set_click_handler('catalog_unit_cost', 'catalogUnitCostClicked(props.row)') - # po_unit_cost - if self.handler.has_invoice_file(batch): - g.remove('po_unit_cost') - - # invoice_unit_cost - if not self.handler.has_invoice_file(batch): + # 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(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. From cceb66e50024c7d55310db6021441b91fc3492ec Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 5 Dec 2022 16:25:55 -0600 Subject: [PATCH 180/978] Add support for editing invoice cost in receiving batch, per new theme --- tailbone/templates/receiving/configure.mako | 9 +++ tailbone/templates/receiving/view.mako | 67 ++++++++++++++++----- tailbone/views/purchasing/receiving.py | 25 ++++++++ 3 files changed, 85 insertions(+), 16 deletions(-) diff --git a/tailbone/templates/receiving/configure.mako b/tailbone/templates/receiving/configure.mako index 9d06d811..9f4a6c3b 100644 --- a/tailbone/templates/receiving/configure.mako +++ b/tailbone/templates/receiving/configure.mako @@ -133,6 +133,15 @@ + + + Allow edit of Invoice Unit Cost + + +

    Mobile Interface

    diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako index d7a2a287..b16aa5b8 100644 --- a/tailbone/templates/receiving/view.mako +++ b/tailbone/templates/receiving/view.mako @@ -264,19 +264,26 @@ <%def name="extra_styles()"> ${parent.extra_styles()} - % if use_buefy and allow_edit_catalog_unit_cost: + % if use_buefy: % elif not use_buefy and not batch.executed and master.has_perm('edit_row'): - - <%def name="page_content()">
    -
    ${rendered_result or ""|n}
    +
    ${rendered_result or ""|n}
    diff --git a/tailbone/templates/page_help.mako b/tailbone/templates/page_help.mako new file mode 100644 index 00000000..cd13011e --- /dev/null +++ b/tailbone/templates/page_help.mako @@ -0,0 +1,204 @@ +## -*- coding: utf-8; -*- + +<%def name="render_template()"> + + + +<%def name="declare_vars()"> + + + +<%def name="make_component()"> + + diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index fe3ef429..e46be1a5 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/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 name="page_help" file="/page_help.mako" /> @@ -383,17 +384,9 @@
    % endif - ## Help Button - % if help_url is not Undefined and help_url: -
    - - Help - -
    - % endif +
    + +
    ## Feedback Button / Dialog % if request.has_perm('common.feedback'): @@ -466,6 +459,8 @@
    + ${page_help.render_template()} + + % endif + + <%def name="finalize_this_page_vars()"> ${parent.finalize_this_page_vars()} % if form is not Undefined: diff --git a/tailbone/templates/forms/deform_buefy.mako b/tailbone/templates/forms/deform_buefy.mako index c387d965..0b1e8d90 100644 --- a/tailbone/templates/forms/deform_buefy.mako +++ b/tailbone/templates/forms/deform_buefy.mako @@ -66,6 +66,47 @@ % if not form.readonly: ${h.end_form()} % endif + + % if can_edit_help: + + + + % endif +
    @@ -85,7 +126,29 @@ submit${form.component_studly}() { this.${form.component_studly}Submitting = true this.${form.component_studly}ButtonText = "Working, please wait..." - } + }, + % endif + + % if can_edit_help: + configureFieldSave() { + this.configureFieldSaving = true + let url = '${edit_help_url}' + let params = { + field_name: this.configureFieldName, + markdown_text: this.configureFieldMarkdown, + } + this.submitForm(url, params, response => { + this.configureFieldShowDialog = false + this.$buefy.toast.open({ + message: "Info was saved; please refresh page to see changes.", + type: 'is-info', + duration: 4000, // 4 seconds + }) + this.configureFieldSaving = false + }, response => { + this.configureFieldSaving = false + }) + }, % endif } } @@ -95,6 +158,16 @@ ## 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}, + % if can_edit_help: + fieldLabels: ${json.dumps(field_labels)|n}, + fieldMarkdowns: ${json.dumps(field_markdowns)|n}, + configureFieldShowDialog: false, + configureFieldSaving: false, + configureFieldName: null, + configureFieldLabel: null, + configureFieldMarkdown: null, + % endif + ## TODO: ugh, this seems pretty hacky. need to declare some data models ## for various field components to bind to... % if not form.readonly: diff --git a/tailbone/templates/page_help.mako b/tailbone/templates/page_help.mako index cd13011e..b745965a 100644 --- a/tailbone/templates/page_help.mako +++ b/tailbone/templates/page_help.mako @@ -108,7 +108,7 @@ - + diff --git a/tailbone/util.py b/tailbone/util.py index f5457149..ca8d0933 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -183,12 +183,14 @@ def raw_datetime(config, value, verbose=False, as_date=False): return HTML.tag('span', **kwargs) -def render_markdown(text, **kwargs): +def render_markdown(text, raw=False, **kwargs): """ Render the given markdown text as HTML. """ kwargs.setdefault('extensions', ['fenced_code', 'codehilite']) md = markdown.markdown(text, **kwargs) + if raw: + return md md = HTML.literal(md) return HTML.tag('div', class_='rendered-markdown', c=[md]) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 396c953e..2431b437 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2333,6 +2333,40 @@ class MasterView(View): info.markdown_text = form.validated['markdown_text'] return {'ok': True} + def edit_field_help(self): + if (not self.has_perm('edit_help') + and not self.request.has_perm('common.edit_help')): + raise self.forbidden() + + model = self.model + route_prefix = self.get_route_prefix() + schema = colander.Schema() + + schema.add(colander.SchemaNode(colander.String(), + name='field_name')) + + schema.add(colander.SchemaNode(colander.String(), + name='markdown_text', + missing=None)) + + factory = self.get_form_factory() + form = factory(schema=schema, request=self.request) + if not form.validate(newstyle=True): + return {'error': "Form did not validate"} + + # nb. self.Session may differ, so use tailbone.db.Session + 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) + + info.markdown_text = form.validated['markdown_text'] + return {'ok': True} + def render_to_response(self, template, data, **kwargs): """ Return a response with the given template rendered with the given data. @@ -3944,6 +3978,7 @@ class MasterView(View): Return a dictionary of kwargs to be passed to the factory when creating new form instances. """ + route_prefix = self.get_route_prefix() defaults = { 'request': self.request, 'readonly': self.viewing, @@ -3951,12 +3986,21 @@ class MasterView(View): 'action_url': self.request.current_route_url(_query=None), 'use_buefy': self.get_use_buefy(), 'assume_local_times': self.has_local_times, + 'route_prefix': route_prefix, + 'can_edit_help': (self.has_perm('edit_help') + or self.request.has_perm('common.edit_help')), } + + if defaults['can_edit_help']: + defaults['edit_help_url'] = self.request.route_url( + '{}.edit_field_help'.format(route_prefix)) + if self.creating: kwargs.setdefault('cancel_url', self.get_index_url()) else: instance = kwargs['model_instance'] kwargs.setdefault('cancel_url', self.get_action_url('view', instance)) + defaults.update(kwargs) return defaults @@ -4832,6 +4876,12 @@ class MasterView(View): config.add_view(cls, attr='edit_help', route_name='{}.edit_help'.format(route_prefix), renderer='json') + config.add_route('{}.edit_field_help'.format(route_prefix), + '{}/edit-field-help'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='edit_field_help', + route_name='{}.edit_field_help'.format(route_prefix), + renderer='json') # list/search if cls.listable: From b04c1054fcbd6e8acb4f626f235a92e02a8d00f9 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 25 Dec 2022 12:25:55 -0600 Subject: [PATCH 215/978] Override document title when upgrading when using websockets, to mimic old behavior without them --- tailbone/templates/upgrades/view.mako | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tailbone/templates/upgrades/view.mako b/tailbone/templates/upgrades/view.mako index c6ae11f2..a5b6445e 100644 --- a/tailbone/templates/upgrades/view.mako +++ b/tailbone/templates/upgrades/view.mako @@ -66,7 +66,7 @@

    - Upgrading (please wait) ... + Upgrading ${app_title} (please wait) ... {{ executeUpgradeComplete ? "DONE!" : "" }}

    { this.adjustTextoutHeight() }) From cd466a64e53406d98aca0f6e9af8724a398d27f3 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 25 Dec 2022 12:45:23 -0600 Subject: [PATCH 216/978] Filter by person instead of user, for Generated Reports "Created by" --- tailbone/views/exports.py | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/tailbone/views/exports.py b/tailbone/views/exports.py index 3f6d417c..82591099 100644 --- a/tailbone/views/exports.py +++ b/tailbone/views/exports.py @@ -31,12 +31,9 @@ import shutil import six -from rattail.db import model - from pyramid.response import FileResponse -from webhelpers2.html import HTML, tags +from webhelpers2.html import tags -from tailbone import forms from tailbone.views import MasterView @@ -49,6 +46,11 @@ class ExportMasterView(MasterView): downloadable = False delete_export_files = False + labels = { + 'id': "ID", + 'created_by': "Created by", + } + grid_columns = [ 'id', 'created', @@ -82,19 +84,23 @@ class ExportMasterView(MasterView): def configure_grid(self, g): super(ExportMasterView, self).configure_grid(g) + model = self.model - g.joiners['created_by'] = lambda q: q.join(model.User) - g.sorters['created_by'] = g.make_sorter(model.User.username) - g.filters['created_by'] = g.make_filter('created_by', model.User.username) + # id + g.set_renderer('id', self.render_id) + g.set_link('id') + + # filename + g.set_link('filename') + + # created g.set_sort_defaults('created', 'desc') - g.set_renderer('id', self.render_id) - - g.set_label('id', "ID") - g.set_label('created_by', "Created by") - - g.set_link('id') - g.set_link('filename') + # created_by + g.set_joiner('created_by', + lambda q: q.join(model.User).outerjoin(model.Person)) + g.set_sorter('created_by', model.Person.display_name) + g.set_filter('created_by', model.Person.display_name) def render_id(self, export, field): return export.id_str From 8264a69ceca86ee95a049687f7dab0d0542f8a36 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 25 Dec 2022 14:41:58 -0600 Subject: [PATCH 217/978] Add "direct link" support for master grids --- tailbone/grids/core.py | 4 +- tailbone/templates/grids/buefy.mako | 133 ++++++++++++++------ tailbone/templates/grids/filters_buefy.mako | 2 +- tailbone/views/master.py | 4 + 4 files changed, 105 insertions(+), 38 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 54f578ed..78fd2cc6 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -189,6 +189,7 @@ class Grid(object): clicking_row_checks_box=False, click_handlers=None, main_actions=[], more_actions=[], delete_speedbump=False, ajax_data_url=None, component='tailbone-grid', + expose_direct_link=False, **kwargs): self.key = key @@ -256,11 +257,12 @@ class Grid(object): if ajax_data_url: self.ajax_data_url = ajax_data_url elif self.request: - self.ajax_data_url = self.request.current_route_url() + self.ajax_data_url = self.request.current_route_url(_query=None) else: self.ajax_data_url = '' self.component = component + self.expose_direct_link = expose_direct_link self._whgrid_kwargs = kwargs @property diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako index 12231606..c99d0f70 100644 --- a/tailbone/templates/grids/buefy.mako +++ b/tailbone/templates/grids/buefy.mako @@ -289,26 +289,41 @@ - % if grid.pageable: -