From 8947a4d14ff5bb52e4cd0784ac103cefabdcf4ba Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 1 Jan 2020 12:05:08 -0600 Subject: [PATCH 0001/1681] Add `Grid.set_filters_sequence()` convenience method sometimes a properly-ordered filter sequence can really help --- tailbone/grids/core.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 0baa7ab9..ba988689 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -996,6 +996,22 @@ class Grid(object): return render(template, context) + def set_filters_sequence(self, filters): + """ + Explicitly set the sequence for grid filters, using the sequence + provided. If the grid currently has more filters than are mentioned in + the given sequence, the sequence will come first and all others will be + tacked on at the end. + + :param filters: Sequence of filter keys, i.e. field names. + """ + new_filters = gridfilters.GridFilterSet() + for field in filters: + new_filters[field] = self.filters.pop(field) + for field in self.filters: + new_filters[field] = self.filters[field] + self.filters = new_filters + def get_filters_sequence(self): """ Returns a list of filter keys (strings) in the sequence with which they From 7dce154cc35c5bc5530bbfc5cd67e9f53dbb27e1 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 2 Jan 2020 06:55:02 -0600 Subject: [PATCH 0002/1681] Add dialog for viewing product SRP history only old jquery theme is supported, for now --- tailbone/templates/products/view.mako | 45 ++++++++- tailbone/views/products.py | 135 +++++++++++++++++++++++--- 2 files changed, 163 insertions(+), 17 deletions(-) diff --git a/tailbone/templates/products/view.mako b/tailbone/templates/products/view.mako index 8f27fb02..e401e880 100644 --- a/tailbone/templates/products/view.mako +++ b/tailbone/templates/products/view.mako @@ -1,10 +1,41 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> +<%def name="extra_javascript()"> + ${parent.extra_javascript()} + % if not use_buefy and request.rattail_config.versioning_enabled() and master.has_perm('versions'): + <script type="text/javascript"> + + $(function() { + + $('#view-srp-history').on('click', function() { + $('#srp-history-dialog').dialog({ + title: "SRP History", + width: 550, + height: 300, + modal: true, + buttons: [ + { + text: "Close", + click: function() { + $(this).dialog('close'); + } + } + ] + }); + return false; + }); + + }); + + </script> + % endif +</%def> + <%def name="extra_styles()"> ${parent.extra_styles()} + <style type="text/css"> % if use_buefy: - <style type="text/css"> #main-product-panel { margin-right: 2em; margin-top: 1em; @@ -12,8 +43,12 @@ #pricing-panel .field-wrapper .field { white-space: nowrap; } - </style> + % else: + #srp-history-dialog .grid { + color: black; + } % endif + </style> </%def> <%def name="render_main_fields(form)"> @@ -341,6 +376,12 @@ </div> </div> + + % if request.rattail_config.versioning_enabled() and master.has_perm('versions'): + <div id="srp-history-dialog" style="display: none;"> + ${srp_history_grid.render_grid()|n} + </div> + % endif % endif % if buttons: diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 99f2106f..c95ec47a 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -30,16 +30,19 @@ import re import logging import six +import humanize import sqlalchemy as sa from sqlalchemy import orm +import sqlalchemy_continuum as continuum from rattail import enum, pod, sil from rattail.db import model, api, auth, Session as RattailSession from rattail.gpc import GPC from rattail.threads import Thread from rattail.exceptions import LabelPrintingError -from rattail.util import load_object, pretty_quantity +from rattail.util import load_object, pretty_quantity, OrderedDict from rattail.batch import get_batch_handler +from rattail.time import localtime, make_utc import colander from deform import widget as dfwidget @@ -383,7 +386,7 @@ class ProductsView(MasterView): f.remove_field('suggested_price') else: f.set_readonly('suggested_price') - f.set_renderer('suggested_price', self.render_price) + f.set_renderer('suggested_price', self.render_suggested_price) # regular_price if self.creating: @@ -445,11 +448,11 @@ class ProductsView(MasterView): def render_cost(self, product, field): cost = getattr(product, field) - if cost: - if cost.unit_cost: - return "$ {:0.2f}".format(cost.unit_cost) - else: - return "TODO: does this item have a cost?" + if not cost: + return "" + if cost.unit_cost is None: + return "" + return "${:0.2f}".format(cost.unit_cost) def render_price(self, product, column): price = product[column] @@ -470,13 +473,31 @@ class ProductsView(MasterView): return "$ {:0.2f} / {}".format(price.pack_price, price.pack_multiple) return "" - def render_cost(self, product, column): - cost = product.cost - if not cost: - return "" - if cost.unit_cost is None: - return "" - return "${:0.2f}".format(cost.unit_cost) + def add_srp_history_link(self, text): + if not self.rattail_config.versioning_enabled(): + return text + if not self.has_perm('versions'): + return text + + history = tags.link_to("(view history)", '#', + id='view-srp-history') + if not text: + return history + + text = HTML.tag('span', c=text) + br = HTML.tag('br') + return HTML.tag('div', c=[text, br, history]) + + def render_suggested_price(self, product, column): + text = self.render_price(product, column) + + if text and self.rattail_config.versioning_enabled(): + history = self.get_srp_history(product) + if history: + date = localtime(self.rattail_config, history[0]['changed'], from_utc=True).date() + text = "{} (as of {})".format(text, date) + + return self.add_srp_history_link(text) def render_true_cost(self, product, field): if not product.volatile: @@ -906,12 +927,96 @@ class ProductsView(MasterView): if not kwargs.get('image_url'): kwargs['image_url'] = self.request.static_url('tailbone:static/img/product.png') + # add SRP history, if user has access + if self.rattail_config.versioning_enabled() and self.has_perm('versions'): + data = self.get_srp_history(product) + grid = grids.Grid('products.srp_history', data, + request=self.request, + columns=[ + 'price', + 'since', + 'changed', + 'changed_by', + ]) + grid.set_type('price', 'currency') + grid.set_type('changed', 'datetime') + kwargs['srp_history_grid'] = grid + kwargs['costs_label_preferred'] = "Pref." kwargs['costs_label_vendor'] = "Vendor" kwargs['costs_label_code'] = "Order Code" kwargs['costs_label_case_size'] = "Case Size" return kwargs + def get_srp_history(self, product): + """ + Returns a sequence of "records" which corresponds to the given + product's SRP history. + """ + Transaction = continuum.transaction_class(model.Product) + ProductVersion = continuum.version_class(model.Product) + ProductPriceVersion = continuum.version_class(model.ProductPrice) + now = make_utc() + history = [] + + # first we find all relevant ProductVersion records + versions = self.Session.query(ProductVersion)\ + .join(Transaction, + Transaction.id == ProductVersion.transaction_id)\ + .filter(ProductVersion.uuid == product.uuid)\ + .order_by(Transaction.issued_at, + Transaction.id)\ + .all() + + last_uuid = None + for version in versions: + if version.suggested_price_uuid != last_uuid: + changed = version.transaction.issued_at + if version.suggested_price: + assert isinstance(version.suggested_price, ProductPriceVersion) + price = version.suggested_price.price + else: + price = None + history.append({ + 'transaction_id': version.transaction.id, + 'price': price, + 'since': humanize.naturaltime(now - changed), + 'changed': changed, + 'changed_by': version.transaction.user, + }) + last_uuid = version.suggested_price_uuid + + # next we find all relevant ProductPriceVersion records + versions = self.Session.query(ProductPriceVersion)\ + .join(Transaction, + Transaction.id == ProductPriceVersion.transaction_id)\ + .filter(ProductPriceVersion.product_uuid == product.uuid)\ + .filter(ProductPriceVersion.type == self.enum.PRICE_TYPE_MFR_SUGGESTED)\ + .order_by(Transaction.issued_at, + Transaction.id)\ + .all() + + last_price = None + for version in versions: + if version.price != last_price: + changed = version.transaction.issued_at + price = version.price + history.append({ + 'transaction_id': version.transaction.id, + 'price': version.price, + 'since': humanize.naturaltime(now - changed), + 'changed': changed, + 'changed_by': version.transaction.user, + }) + last_price = version.price + + final_history = OrderedDict() + for hist in reversed(history): + if hist['transaction_id'] not in final_history: + final_history[hist['transaction_id']] = hist + + return list(final_history.values()) + def edit(self): # TODO: Should add some more/better hooks, so don't have to duplicate # so much code here. From 03c8d3409a5a4bf33d57ac8b96f547006e194bde Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 2 Jan 2020 12:39:32 -0600 Subject: [PATCH 0003/1681] 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 7e9a0864..4cff1078 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.78 (2020-01-02) +------------------- + +* Add ``Grid.set_filters_sequence()`` convenience method. + +* Add dialog for viewing product SRP history. + + 0.8.77 (2019-12-04) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 6971fc9b..bc5cbd06 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.77' +__version__ = '0.8.78' From 4c5b01f28768a96edc095cf4de59dc359310e86d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 6 Jan 2020 07:46:10 -0600 Subject: [PATCH 0004/1681] Move "delete results" logic for master grid should be easier to customize this way..? previous way seemed to be broken --- tailbone/static/js/tailbone.buefy.grid.js | 10 ------ tailbone/templates/master/index.mako | 43 +++++++++++++++++++---- 2 files changed, 37 insertions(+), 16 deletions(-) diff --git a/tailbone/static/js/tailbone.buefy.grid.js b/tailbone/static/js/tailbone.buefy.grid.js index 17da854d..45f6581d 100644 --- a/tailbone/static/js/tailbone.buefy.grid.js +++ b/tailbone/static/js/tailbone.buefy.grid.js @@ -244,16 +244,6 @@ let TailboneGrid = { this.$emit('deleteActionClicked', event.target.href) }, - deleteResults(event) { - - // submit form if user confirms - // TODO: how/where to get/show "plural model title" here? - // if (confirm("You are about to delete " + this.total + " ${grid.model_title_plural}.\n\nAre you sure?")) { - if (confirm("You are about to delete " + this.total.toLocaleString('en') + " objects.\n\nAre you sure?")) { - event.target.form.submit() - } - }, - checkedRowUUIDs() { let uuids = [] for (let row of this.$data.checkedRows) { diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako index 6889ac44..cd785bad 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -233,21 +233,24 @@ ## delete search results % if master.bulk_deletable and request.has_perm('{}.bulk_delete'.format(permission_prefix)): - ${h.form(url('{}.bulk_delete'.format(route_prefix)), name='bulk-delete', class_='control')} - ${h.csrf_token(request)} % if use_buefy: + ${h.form(url('{}.bulk_delete'.format(route_prefix)), ref='delete_results_form', class_='control')} + ${h.csrf_token(request)} <b-button type="is-danger" - :disabled="! total" + :disabled="deleteResultsDisabled" :title="total ? null : 'There are no results to delete'" - @click="deleteResults" + @click="deleteResultsSubmit()" icon-pack="fas" icon-left="trash"> - Delete Results + {{ deleteResultsText }} </b-button> + ${h.end_form()} % else: + ${h.form(url('{}.bulk_delete'.format(route_prefix)), name='bulk-delete', class_='control')} + ${h.csrf_token(request)} <button type="button">Delete Results</button> + ${h.end_form()} % endif - ${h.end_form()} % endif </%def> @@ -396,6 +399,34 @@ } % endif + % if master.bulk_deletable and master.has_perm('bulk_delete'): + + TailboneGridData.deleteResultsSubmitting = false + TailboneGridData.deleteResultsText = "Delete Results" + + TailboneGrid.computed.deleteResultsDisabled = function() { + if (this.deleteResultsSubmitting) { + return true + } + if (!this.total) { + return true + } + return false + } + + TailboneGrid.methods.deleteResultsSubmit = function() { + // TODO: show "plural model title" here? + if (!confirm("You are about to delete " + this.total.toLocaleString('en') + " objects.\n\nAre you sure?")) { + return + } + + this.deleteResultsSubmitting = true + this.deleteResultsText = "Working, please wait..." + this.$refs.delete_results_form.submit() + } + + % endif + % if master.mergeable and master.has_perm('merge'): TailboneGridData.mergeFormButtonText = "Merge 2 ${model_title_plural}" From 3fc8254219e083d7b20e31383b0d1e47c3c01f6d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 6 Jan 2020 08:03:29 -0600 Subject: [PATCH 0005/1681] 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 4cff1078..4b2c5ffb 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.79 (2020-01-06) +------------------- + +* Move "delete results" logic for master grid. + + 0.8.78 (2020-01-02) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index bc5cbd06..0cbfa91b 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.78' +__version__ = '0.8.79' From 910e82a7953d232b6231bc10a0e1b24018969171 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 7 Jan 2020 06:44:27 -0600 Subject: [PATCH 0006/1681] Hide the SRP history link for new buefy themes until support for that is added... --- tailbone/views/products.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index c95ec47a..2de5d727 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -497,7 +497,11 @@ class ProductsView(MasterView): date = localtime(self.rattail_config, history[0]['changed'], from_utc=True).date() text = "{} (as of {})".format(text, date) - return self.add_srp_history_link(text) + if self.get_use_buefy(): + # TODO: should add history link here too... + return text + else: # not buefy + return self.add_srp_history_link(text) def render_true_cost(self, product, field): if not product.volatile: From 02649709aadf1eb5559f2db2e75164dfeb991341 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 8 Jan 2020 08:04:48 -0600 Subject: [PATCH 0007/1681] Add regular price history dialog for product view --- tailbone/templates/products/view.mako | 52 +++++++---- tailbone/views/products.py | 124 +++++++++++++++++++++++--- 2 files changed, 146 insertions(+), 30 deletions(-) diff --git a/tailbone/templates/products/view.mako b/tailbone/templates/products/view.mako index e401e880..6ca39c28 100644 --- a/tailbone/templates/products/view.mako +++ b/tailbone/templates/products/view.mako @@ -6,23 +6,33 @@ % if not use_buefy and request.rattail_config.versioning_enabled() and master.has_perm('versions'): <script type="text/javascript"> + function showPriceHistory(typ) { + var dialog = $('#' + typ + '-price-history-dialog'); + dialog.dialog({ + title: typ[0].toUpperCase() + typ.slice(1) + " Price History", + width: 500, + height: 300, + modal: true, + buttons: [ + { + text: "Close", + click: function() { + dialog.dialog('close'); + } + } + ] + }); + } + $(function() { - $('#view-srp-history').on('click', function() { - $('#srp-history-dialog').dialog({ - title: "SRP History", - width: 550, - height: 300, - modal: true, - buttons: [ - { - text: "Close", - click: function() { - $(this).dialog('close'); - } - } - ] - }); + $('#view-regular-price-history').on('click', function() { + showPriceHistory('regular'); + return false; + }); + + $('#view-suggested-price-history').on('click', function() { + showPriceHistory('suggested'); return false; }); @@ -44,7 +54,10 @@ white-space: nowrap; } % else: - #srp-history-dialog .grid { + .price-history-dialog { + display: none; + } + .price-history-dialog .grid { color: black; } % endif @@ -378,8 +391,11 @@ </div> % if request.rattail_config.versioning_enabled() and master.has_perm('versions'): - <div id="srp-history-dialog" style="display: none;"> - ${srp_history_grid.render_grid()|n} + <div class="price-history-dialog" id="regular-price-history-dialog"> + ${regular_price_history_grid.render_grid()|n} + </div> + <div class="price-history-dialog" id="suggested-price-history-dialog"> + ${suggested_price_history_grid.render_grid()|n} </div> % endif % endif diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 2de5d727..5c4eb5fc 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -393,7 +393,7 @@ class ProductsView(MasterView): f.remove_field('regular_price') else: f.set_readonly('regular_price') - f.set_renderer('regular_price', self.render_price) + f.set_renderer('regular_price', self.render_regular_price) # current_price if self.creating: @@ -473,14 +473,14 @@ class ProductsView(MasterView): return "$ {:0.2f} / {}".format(price.pack_price, price.pack_multiple) return "" - def add_srp_history_link(self, text): + def add_price_history_link(self, text, typ): if not self.rattail_config.versioning_enabled(): return text if not self.has_perm('versions'): return text history = tags.link_to("(view history)", '#', - id='view-srp-history') + id='view-{}-price-history'.format(typ)) if not text: return history @@ -488,11 +488,11 @@ class ProductsView(MasterView): br = HTML.tag('br') return HTML.tag('div', c=[text, br, history]) - def render_suggested_price(self, product, column): - text = self.render_price(product, column) + def render_regular_price(self, product, field): + text = self.render_price(product, field) if text and self.rattail_config.versioning_enabled(): - history = self.get_srp_history(product) + history = self.get_regular_price_history(product) if history: date = localtime(self.rattail_config, history[0]['changed'], from_utc=True).date() text = "{} (as of {})".format(text, date) @@ -501,7 +501,22 @@ class ProductsView(MasterView): # TODO: should add history link here too... return text else: # not buefy - return self.add_srp_history_link(text) + return self.add_price_history_link(text, 'regular') + + def render_suggested_price(self, product, column): + text = self.render_price(product, column) + + if text and self.rattail_config.versioning_enabled(): + history = self.get_suggested_price_history(product) + if history: + date = localtime(self.rattail_config, history[0]['changed'], from_utc=True).date() + text = "{} (as of {})".format(text, date) + + if self.get_use_buefy(): + # TODO: should add history link here too... + return text + else: # not buefy + return self.add_price_history_link(text, 'suggested') def render_true_cost(self, product, field): if not product.volatile: @@ -931,10 +946,12 @@ class ProductsView(MasterView): if not kwargs.get('image_url'): kwargs['image_url'] = self.request.static_url('tailbone:static/img/product.png') - # add SRP history, if user has access + # add price history, if user has access if self.rattail_config.versioning_enabled() and self.has_perm('versions'): - data = self.get_srp_history(product) - grid = grids.Grid('products.srp_history', data, + + # regular price + data = self.get_regular_price_history(product) + grid = grids.Grid('products.regular_price_history', data, request=self.request, columns=[ 'price', @@ -944,7 +961,21 @@ class ProductsView(MasterView): ]) grid.set_type('price', 'currency') grid.set_type('changed', 'datetime') - kwargs['srp_history_grid'] = grid + kwargs['regular_price_history_grid'] = grid + + # suggested price + data = self.get_suggested_price_history(product) + grid = grids.Grid('products.suggested_price_history', data, + request=self.request, + columns=[ + 'price', + 'since', + 'changed', + 'changed_by', + ]) + grid.set_type('price', 'currency') + grid.set_type('changed', 'datetime') + kwargs['suggested_price_history_grid'] = grid kwargs['costs_label_preferred'] = "Pref." kwargs['costs_label_vendor'] = "Vendor" @@ -952,7 +983,76 @@ class ProductsView(MasterView): kwargs['costs_label_case_size'] = "Case Size" return kwargs - def get_srp_history(self, product): + def get_regular_price_history(self, product): + """ + Returns a sequence of "records" which corresponds to the given + product's regular price history. + """ + Transaction = continuum.transaction_class(model.Product) + ProductVersion = continuum.version_class(model.Product) + ProductPriceVersion = continuum.version_class(model.ProductPrice) + now = make_utc() + history = [] + + # first we find all relevant ProductVersion records + versions = self.Session.query(ProductVersion)\ + .join(Transaction, + Transaction.id == ProductVersion.transaction_id)\ + .filter(ProductVersion.uuid == product.uuid)\ + .order_by(Transaction.issued_at, + Transaction.id)\ + .all() + + last_uuid = None + for version in versions: + if version.regular_price_uuid != last_uuid: + changed = version.transaction.issued_at + if version.regular_price: + assert isinstance(version.regular_price, ProductPriceVersion) + price = version.regular_price.price + else: + price = None + history.append({ + 'transaction_id': version.transaction.id, + 'price': price, + 'since': humanize.naturaltime(now - changed), + 'changed': changed, + 'changed_by': version.transaction.user, + }) + last_uuid = version.regular_price_uuid + + # next we find all relevant ProductPriceVersion records + versions = self.Session.query(ProductPriceVersion)\ + .join(Transaction, + Transaction.id == ProductPriceVersion.transaction_id)\ + .filter(ProductPriceVersion.product_uuid == product.uuid)\ + .filter(ProductPriceVersion.type == self.enum.PRICE_TYPE_REGULAR)\ + .order_by(Transaction.issued_at, + Transaction.id)\ + .all() + + last_price = None + for version in versions: + if version.price != last_price: + changed = version.transaction.issued_at + price = version.price + history.append({ + 'transaction_id': version.transaction.id, + 'price': version.price, + 'since': humanize.naturaltime(now - changed), + 'changed': changed, + 'changed_by': version.transaction.user, + }) + last_price = version.price + + final_history = OrderedDict() + for hist in reversed(history): + if hist['transaction_id'] not in final_history: + final_history[hist['transaction_id']] = hist + + return list(final_history.values()) + + def get_suggested_price_history(self, product): """ Returns a sequence of "records" which corresponds to the given product's SRP history. From 234fd8b2e1310963f57355e9269c42d2dd603050 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 14 Jan 2020 11:54:00 -0600 Subject: [PATCH 0008/1681] Add support for Row Status Breakdown, for Import/Export batches --- docs/conf.py | 2 +- setup.py | 2 +- tailbone/views/batch/core.py | 11 +++++++---- tailbone/views/batch/importer.py | 18 ++++++++++++++---- 4 files changed, 23 insertions(+), 10 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 7d3e5831..87de553a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -58,7 +58,7 @@ master_doc = 'index' # General information about the project. project = u'Tailbone' -copyright = u'2010 - 2018, Lance Edgar' +copyright = u'2010 - 2020, Lance Edgar' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the diff --git a/setup.py b/setup.py index 922f9f88..59c3ef9e 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 169796f8..276bb167 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -166,19 +166,22 @@ class BatchMasterView(MasterView): kwargs['status_breakdown'] = self.make_status_breakdown(batch) return kwargs - def make_status_breakdown(self, batch): + def make_status_breakdown(self, batch, rows=None, status_enum=None): """ Returns a simple list of 2-tuples, each of which has the status display title as first member, and number of rows with that status as second member. """ breakdown = {} - for row in batch.active_rows(): + if rows is None: + rows = batch.active_rows() + for row in rows: if row.status_code is not None: if row.status_code not in breakdown: + status = status_enum or row.STATUS breakdown[row.status_code] = { 'code': row.status_code, - 'title': row.STATUS[row.status_code], + 'title': status[row.status_code], 'count': 0, } breakdown[row.status_code]['count'] += 1 diff --git a/tailbone/views/batch/importer.py b/tailbone/views/batch/importer.py index b2803183..77ebd6b4 100644 --- a/tailbone/views/batch/importer.py +++ b/tailbone/views/batch/importer.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -103,9 +103,19 @@ class ImporterBatchView(BatchMasterView): f.set_readonly('importer_key') f.set_readonly('row_table') - def make_status_breakdown(self, batch): - # TODO: should implement this, just can't use batch.data_rows apparently - pass + def make_status_breakdown(self, batch, **kwargs): + """ + Returns a simple list of 2-tuples, each of which has the status display + title as first member, and number of rows with that status as second + member. + """ + if kwargs.get('rows') is None: + self.make_row_table(batch.row_table) + kwargs['rows'] = self.Session.query(self.current_row_table).all() + kwargs.setdefault('status_enum', self.enum.IMPORTER_BATCH_ROW_STATUS) + breakdown = super(ImporterBatchView, self).make_status_breakdown( + batch, **kwargs) + return breakdown def delete_instance(self, batch): self.make_row_table(batch.row_table) From bbd462c85a4dde7451d87d830d3b9603e6500a8b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 14 Jan 2020 12:14:47 -0600 Subject: [PATCH 0009/1681] Cleanup "diff" table for importer batch row view, per Buefy theme --- .../templates/batch/importer/view_row.mako | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/batch/importer/view_row.mako b/tailbone/templates/batch/importer/view_row.mako index 55efb6d1..24eb6456 100644 --- a/tailbone/templates/batch/importer/view_row.mako +++ b/tailbone/templates/batch/importer/view_row.mako @@ -7,8 +7,7 @@ % endif </%def> -${parent.body()} - +<%def name="field_diff_table()"> % if instance.status_code == enum.IMPORTER_BATCH_ROW_STATUS_CREATE: <table class="diff monospace new"> <thead> @@ -67,3 +66,22 @@ ${parent.body()} </tbody> </table> % endif +</%def> + +<%def name="render_buefy_form()"> + <div class="form"> + <tailbone-form></tailbone-form> + <br /> + ${self.field_diff_table()} + </div> +</%def> + +<%def name="render_form()"> + ${parent.render_form()} + % if not use_buefy: + ${self.field_diff_table()} + % endif +</%def> + + +${parent.body()} From 8f07f27a613ea346b182d3b2fd6593a29444c458 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 14 Jan 2020 16:35:30 -0600 Subject: [PATCH 0010/1681] Highlight SRP in red, if reg price is greater (in products grid) seems like a good enough idea generally... --- tailbone/views/products.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 5c4eb5fc..fae009e3 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -305,6 +305,9 @@ class ProductsView(MasterView): g.set_sorter('current_price', self.CurrentPrice.price) g.set_filter('current_price', self.CurrentPrice.price, label="Current Price") + # suggested_price + g.set_renderer('suggested_price', self.render_grid_suggested_price) + # (unit) cost g.set_joiner('cost', lambda q: q.outerjoin(model.ProductCost, sa.and_( @@ -518,6 +521,18 @@ class ProductsView(MasterView): else: # not buefy return self.add_price_history_link(text, 'suggested') + def render_grid_suggested_price(self, product, field): + text = self.render_price(product, field) + if not text: + return "" + + sugprice = product.suggested_price.price if product.suggested_price else None + regprice = product.regular_price.price if product.regular_price else None + if sugprice and regprice and sugprice < regprice: + return HTML.tag('div', style='color: red;', c=text) + + return text + def render_true_cost(self, product, field): if not product.volatile: return "" From 0fbe3380cdbe81edaa533ed08b482defa1f26b23 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 14 Jan 2020 16:45:08 -0600 Subject: [PATCH 0011/1681] Highlight SRP in red, if reg price is greater (in product view) --- tailbone/views/products.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index fae009e3..0b60e79d 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -487,7 +487,7 @@ class ProductsView(MasterView): if not text: return history - text = HTML.tag('span', c=text) + text = HTML.tag('span', c=[text]) br = HTML.tag('br') return HTML.tag('div', c=[text, br, history]) @@ -506,6 +506,13 @@ class ProductsView(MasterView): else: # not buefy return self.add_price_history_link(text, 'regular') + def warn_if_regprice_more_than_srp(self, product, text): + sugprice = product.suggested_price.price if product.suggested_price else None + regprice = product.regular_price.price if product.regular_price else None + if sugprice and regprice and sugprice > regprice: + return HTML.tag('span', style='color: red;', c=text) + return text + def render_suggested_price(self, product, column): text = self.render_price(product, column) @@ -515,6 +522,8 @@ class ProductsView(MasterView): date = localtime(self.rattail_config, history[0]['changed'], from_utc=True).date() text = "{} (as of {})".format(text, date) + text = self.warn_if_regprice_more_than_srp(product, text) + if self.get_use_buefy(): # TODO: should add history link here too... return text @@ -526,11 +535,7 @@ class ProductsView(MasterView): if not text: return "" - sugprice = product.suggested_price.price if product.suggested_price else None - regprice = product.regular_price.price if product.regular_price else None - if sugprice and regprice and sugprice < regprice: - return HTML.tag('div', style='color: red;', c=text) - + text = self.warn_if_regprice_more_than_srp(product, text) return text def render_true_cost(self, product, field): From 133ca622a02d4c390f50d5b34d0331bc45819546 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 15 Jan 2020 19:03:16 -0600 Subject: [PATCH 0012/1681] Expose batch ID, sequence for datasync change queue --- tailbone/views/datasync.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py index b01d088b..688a479a 100644 --- a/tailbone/views/datasync.py +++ b/tailbone/views/datasync.py @@ -48,8 +48,14 @@ class DataSyncChangesView(MasterView): editable = False bulk_deletable = True + labels = { + 'batch_id': "Batch ID", + } + grid_columns = [ 'source', + 'batch_id', + 'batch_sequence', 'payload_type', 'payload_key', 'deletion', @@ -59,6 +65,11 @@ class DataSyncChangesView(MasterView): def configure_grid(self, g): super(DataSyncChangesView, self).configure_grid(g) + + # batch_sequence + g.set_label('batch_sequence', "Batch Seq.") + g.filters['batch_sequence'].label = "Batch Sequence" + g.set_sort_defaults('obtained') g.set_type('obtained', 'datetime') From 09a383f89c229ba7bc68391c0f43fe650ebd2ce9 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 15 Jan 2020 19:26:28 -0600 Subject: [PATCH 0013/1681] Fix SRP warning logic! dang, had it reversed for some testing and then forgot to switch back --- tailbone/views/products.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 0b60e79d..43b6d93e 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -509,7 +509,7 @@ class ProductsView(MasterView): def warn_if_regprice_more_than_srp(self, product, text): sugprice = product.suggested_price.price if product.suggested_price else None regprice = product.regular_price.price if product.regular_price else None - if sugprice and regprice and sugprice > regprice: + if sugprice and regprice and sugprice < regprice: return HTML.tag('span', style='color: red;', c=text) return text From 91c1c1c5c8b8c7c4bac3fa21485a084d7987012a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 16 Jan 2020 11:31:49 -0600 Subject: [PATCH 0014/1681] Add "current price history" dialog for product view hopefully this does everything it needs to...guess we'll see --- tailbone/templates/products/view.mako | 8 ++ tailbone/views/products.py | 154 +++++++++++++++++++++++++- 2 files changed, 159 insertions(+), 3 deletions(-) diff --git a/tailbone/templates/products/view.mako b/tailbone/templates/products/view.mako index 6ca39c28..35095a1d 100644 --- a/tailbone/templates/products/view.mako +++ b/tailbone/templates/products/view.mako @@ -31,6 +31,11 @@ return false; }); + $('#view-current-price-history').on('click', function() { + showPriceHistory('current'); + return false; + }); + $('#view-suggested-price-history').on('click', function() { showPriceHistory('suggested'); return false; @@ -394,6 +399,9 @@ <div class="price-history-dialog" id="regular-price-history-dialog"> ${regular_price_history_grid.render_grid()|n} </div> + <div class="price-history-dialog" id="current-price-history-dialog"> + ${current_price_history_grid.render_grid()|n} + </div> <div class="price-history-dialog" id="suggested-price-history-dialog"> ${suggested_price_history_grid.render_grid()|n} </div> diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 43b6d93e..3ce27e87 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -403,7 +403,7 @@ class ProductsView(MasterView): f.remove_field('current_price') else: f.set_readonly('current_price') - f.set_renderer('current_price', self.render_price) + f.set_renderer('current_price', self.render_current_price) # current_price_ends if self.creating: @@ -506,6 +506,21 @@ class ProductsView(MasterView): else: # not buefy return self.add_price_history_link(text, 'regular') + def render_current_price(self, product, field): + text = self.render_price(product, field) + + if text and self.rattail_config.versioning_enabled(): + history = self.get_current_price_history(product) + if history: + date = localtime(self.rattail_config, history[0]['changed'], from_utc=True).date() + text = "{} (as of {})".format(text, date) + + if self.get_use_buefy(): + # TODO: should add history link here too... + return text + else: # not buefy + return self.add_price_history_link(text, 'current') + def warn_if_regprice_more_than_srp(self, product, text): sugprice = product.suggested_price.price if product.suggested_price else None regprice = product.regular_price.price if product.regular_price else None @@ -983,6 +998,24 @@ class ProductsView(MasterView): grid.set_type('changed', 'datetime') kwargs['regular_price_history_grid'] = grid + # current price + data = self.get_current_price_history(product) + grid = grids.Grid('products.current_price_history', data, + request=self.request, + columns=[ + 'price', + 'price_type', + 'since', + 'changed', + 'changed_by', + ], + labels={ + 'price_type': "Type", + }) + grid.set_type('price', 'currency') + grid.set_type('changed', 'datetime') + kwargs['current_price_history_grid'] = grid + # suggested price data = self.get_suggested_price_history(product) grid = grids.Grid('products.suggested_price_history', data, @@ -1066,7 +1099,122 @@ class ProductsView(MasterView): last_price = version.price final_history = OrderedDict() - for hist in reversed(history): + for hist in sorted(history, key=lambda h: h['changed'], reverse=True): + if hist['transaction_id'] not in final_history: + final_history[hist['transaction_id']] = hist + + return list(final_history.values()) + + def get_current_price_history(self, product): + """ + Returns a sequence of "records" which corresponds to the given + product's current price history. + """ + Transaction = continuum.transaction_class(model.Product) + ProductVersion = continuum.version_class(model.Product) + ProductPriceVersion = continuum.version_class(model.ProductPrice) + now = make_utc() + history = [] + + # first we find all relevant ProductVersion records + versions = self.Session.query(ProductVersion)\ + .join(Transaction, + Transaction.id == ProductVersion.transaction_id)\ + .filter(ProductVersion.uuid == product.uuid)\ + .order_by(Transaction.issued_at, + Transaction.id)\ + .all() + + last_current_uuid = None + last_regular_uuid = None + for version in versions: + + changed = False + if version.current_price_uuid != last_current_uuid: + changed = True + elif not version.current_price_uuid and version.regular_price_uuid != last_regular_uuid: + changed = True + + if changed: + changed = version.transaction.issued_at + if version.current_price: + assert isinstance(version.current_price, ProductPriceVersion) + price = version.current_price.price + price_type = self.enum.PRICE_TYPE.get(version.current_price.type) + elif version.regular_price: + price = version.regular_price.price + price_type = self.enum.PRICE_TYPE.get(version.regular_price.type) + else: + price = None + price_type = None + history.append({ + 'transaction_id': version.transaction.id, + 'price': price, + 'price_type': price_type, + 'since': humanize.naturaltime(now - changed), + 'changed': changed, + 'changed_by': version.transaction.user, + }) + + last_current_uuid = version.current_price_uuid + last_regular_uuid = version.regular_price_uuid + + # next we find all relevant *SALE* ProductPriceVersion records + versions = self.Session.query(ProductPriceVersion)\ + .join(Transaction, + Transaction.id == ProductPriceVersion.transaction_id)\ + .filter(ProductPriceVersion.product_uuid == product.uuid)\ + .filter(ProductPriceVersion.type == self.enum.PRICE_TYPE_SALE)\ + .order_by(Transaction.issued_at, + Transaction.id)\ + .all() + + last_price = None + for version in versions: + # only include this version if it was "current" at the time + if version.uuid == version.product.current_price_uuid: + if version.price != last_price: + changed = version.transaction.issued_at + price = version.price + history.append({ + 'transaction_id': version.transaction.id, + 'price': version.price, + 'price_type': self.enum.PRICE_TYPE[version.type], + 'since': humanize.naturaltime(now - changed), + 'changed': changed, + 'changed_by': version.transaction.user, + }) + last_price = version.price + + # next we find all relevant *TPR* ProductPriceVersion records + versions = self.Session.query(ProductPriceVersion)\ + .join(Transaction, + Transaction.id == ProductPriceVersion.transaction_id)\ + .filter(ProductPriceVersion.product_uuid == product.uuid)\ + .filter(ProductPriceVersion.type == self.enum.PRICE_TYPE_TPR)\ + .order_by(Transaction.issued_at, + Transaction.id)\ + .all() + + last_price = None + for version in versions: + # only include this version if it was "current" at the time + if version.uuid == version.product.current_price_uuid: + if version.price != last_price: + changed = version.transaction.issued_at + price = version.price + history.append({ + 'transaction_id': version.transaction.id, + 'price': version.price, + 'price_type': self.enum.PRICE_TYPE[version.type], + 'since': humanize.naturaltime(now - changed), + 'changed': changed, + 'changed_by': version.transaction.user, + }) + last_price = version.price + + final_history = OrderedDict() + for hist in sorted(history, key=lambda h: h['changed'], reverse=True): if hist['transaction_id'] not in final_history: final_history[hist['transaction_id']] = hist @@ -1135,7 +1283,7 @@ class ProductsView(MasterView): last_price = version.price final_history = OrderedDict() - for hist in reversed(history): + for hist in sorted(history, key=lambda h: h['changed'], reverse=True): if hist['transaction_id'] not in final_history: final_history[hist['transaction_id']] = hist From 0e4b33be96174c15e04e5829bbc2dc7154d319b5 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 16 Jan 2020 11:56:33 -0600 Subject: [PATCH 0015/1681] Add "cost history" dialog for product view older jquery theme only, for now --- tailbone/templates/products/view.mako | 35 +++++++++++++- tailbone/views/products.py | 70 +++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/products/view.mako b/tailbone/templates/products/view.mako index 35095a1d..1c36c63c 100644 --- a/tailbone/templates/products/view.mako +++ b/tailbone/templates/products/view.mako @@ -10,7 +10,25 @@ var dialog = $('#' + typ + '-price-history-dialog'); dialog.dialog({ title: typ[0].toUpperCase() + typ.slice(1) + " Price History", - width: 500, + width: 600, + height: 300, + modal: true, + buttons: [ + { + text: "Close", + click: function() { + dialog.dialog('close'); + } + } + ] + }); + } + + function showCostHistory() { + var dialog = $('#cost-history-dialog'); + dialog.dialog({ + title: "Cost History", + width: 600, height: 300, modal: true, buttons: [ @@ -41,6 +59,11 @@ return false; }); + $('#view-cost-history').on('click', function() { + showCostHistory(); + return false; + }); + }); </script> @@ -286,7 +309,12 @@ </nav> % else: <div class="panel-grid" id="product-costs"> - <h2>Vendor Sources</h2> + <h2> + Vendor Sources + % if request.rattail_config.versioning_enabled() and master.has_perm('versions'): + <a id="view-cost-history" href="#">(view cost history)</a> + % endif + </h2> ${self.sources_grid()} </div> % endif @@ -405,6 +433,9 @@ <div class="price-history-dialog" id="suggested-price-history-dialog"> ${suggested_price_history_grid.render_grid()|n} </div> + <div class="price-history-dialog" id="cost-history-dialog"> + ${cost_history_grid.render_grid()|n} + </div> % endif % endif diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 3ce27e87..14e17247 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -1030,6 +1030,24 @@ class ProductsView(MasterView): grid.set_type('changed', 'datetime') kwargs['suggested_price_history_grid'] = grid + # cost history + data = self.get_cost_history(product) + grid = grids.Grid('products.cost_history', data, + request=self.request, + columns=[ + 'cost', + 'vendor', + 'since', + 'changed', + 'changed_by', + ], + labels={ + 'price_type': "Type", + }) + grid.set_type('cost', 'currency') + grid.set_type('changed', 'datetime') + kwargs['cost_history_grid'] = grid + kwargs['costs_label_preferred'] = "Pref." kwargs['costs_label_vendor'] = "Vendor" kwargs['costs_label_code'] = "Order Code" @@ -1289,6 +1307,58 @@ class ProductsView(MasterView): return list(final_history.values()) + def get_cost_history(self, product): + """ + Returns a sequence of "records" which corresponds to the given + product's cost history. + """ + Transaction = continuum.transaction_class(model.Product) + ProductVersion = continuum.version_class(model.Product) + ProductCostVersion = continuum.version_class(model.ProductCost) + now = make_utc() + history = [] + + # we just find all relevant (preferred!) ProductCostVersion records + versions = self.Session.query(ProductCostVersion)\ + .join(Transaction, + Transaction.id == ProductCostVersion.transaction_id)\ + .filter(ProductCostVersion.product_uuid == product.uuid)\ + .filter(ProductCostVersion.preference == 1)\ + .order_by(Transaction.issued_at, + Transaction.id)\ + .all() + + last_cost = None + last_vendor_uuid = None + for version in versions: + + changed = False + if version.unit_cost != last_cost: + changed = True + elif version.vendor_uuid != last_vendor_uuid: + changed = True + + if changed: + changed = version.transaction.issued_at + history.append({ + 'transaction_id': version.transaction.id, + 'cost': version.unit_cost, + 'vendor': version.vendor.name, + 'since': humanize.naturaltime(now - changed), + 'changed': changed, + 'changed_by': version.transaction.user, + }) + + last_cost = version.unit_cost + last_vendor_uuid = version.vendor_uuid + + final_history = OrderedDict() + for hist in sorted(history, key=lambda h: h['changed'], reverse=True): + if hist['transaction_id'] not in final_history: + final_history[hist['transaction_id']] = hist + + return list(final_history.values()) + def edit(self): # TODO: Should add some more/better hooks, so don't have to duplicate # so much code here. From 09e18b064da7cdfbed075031d95ccc1650e472b2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 20 Jan 2020 12:28:49 -0600 Subject: [PATCH 0016/1681] Update changelog --- CHANGES.rst | 20 ++++++++++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 4b2c5ffb..82dc7899 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,26 @@ CHANGELOG ========= +0.8.80 (2020-01-20) +------------------- + +* Hide the SRP history link for new buefy themes. + +* Add regular price history dialog for product view. + +* Add support for Row Status Breakdown, for Import/Export batches. + +* Cleanup "diff" table for importer batch row view, per Buefy theme. + +* Highlight SRP in red, if reg price is greater. + +* Expose batch ID, sequence for datasync change queue. + +* Add "current price history" dialog for product view. + +* Add "cost history" dialog for product view. + + 0.8.79 (2020-01-06) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 0cbfa91b..5b141269 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.79' +__version__ = '0.8.80' From 842882e766c7a99d88dc46bcc22a80f195869902 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 21 Jan 2020 11:41:37 -0600 Subject: [PATCH 0017/1681] Include regular price changes, for current price history dialog --- tailbone/views/products.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 14e17247..1d611400 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -1231,6 +1231,33 @@ class ProductsView(MasterView): }) last_price = version.price + # next we find all relevant *Regular* ProductPriceVersion records + versions = self.Session.query(ProductPriceVersion)\ + .join(Transaction, + Transaction.id == ProductPriceVersion.transaction_id)\ + .filter(ProductPriceVersion.product_uuid == product.uuid)\ + .filter(ProductPriceVersion.type == self.enum.PRICE_TYPE_REGULAR)\ + .order_by(Transaction.issued_at, + Transaction.id)\ + .all() + + last_price = None + for version in versions: + # only include this version if it was "regular" at the time + if version.uuid == version.product.regular_price_uuid: + if version.price != last_price: + changed = version.transaction.issued_at + price = version.price + history.append({ + 'transaction_id': version.transaction.id, + 'price': version.price, + 'price_type': self.enum.PRICE_TYPE[version.type], + 'since': humanize.naturaltime(now - changed), + 'changed': changed, + 'changed_by': version.transaction.user, + }) + last_price = version.price + final_history = OrderedDict() for hist in sorted(history, key=lambda h: h['changed'], reverse=True): if hist['transaction_id'] not in final_history: From e9533727dbc0e1c35b10630ec747da1d19fb0b24 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 23 Jan 2020 10:48:21 -0600 Subject: [PATCH 0018/1681] Allow populate of new pricing batch from products w/ "SRP breach" --- tailbone/views/batch/pricing.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tailbone/views/batch/pricing.py b/tailbone/views/batch/pricing.py index 8fe415a7..84b17736 100644 --- a/tailbone/views/batch/pricing.py +++ b/tailbone/views/batch/pricing.py @@ -53,6 +53,7 @@ class PricingBatchView(BatchMasterView): labels = { 'min_diff_threshold': "Min $ Diff", 'min_diff_percent': "Min % Diff", + 'auto_generate_from_srp_breach': "Automatic (from SRP Breach)", } grid_columns = [ @@ -74,6 +75,7 @@ class PricingBatchView(BatchMasterView): 'min_diff_threshold', 'min_diff_percent', 'calculate_for_manual', + 'auto_generate_from_srp_breach', 'notes', 'created', 'created_by', @@ -153,11 +155,36 @@ class PricingBatchView(BatchMasterView): f.set_readonly('input_filename') f.set_renderer('input_filename', self.render_downloadable_file) + # auto_generate_from_srp_breach + if self.creating: + f.set_type('auto_generate_from_srp_breach', 'boolean') + else: + f.remove_field('auto_generate_from_srp_breach') + + # note, the input file is normally required, but should *not* be if the + # user wants to auto-generate the new batch + if self.request.method == 'POST': + if self.request.POST.get('auto_generate_from_srp_breach') == 'true': + f.set_required('input_filename', False) + def get_batch_kwargs(self, batch, mobile=False): kwargs = super(PricingBatchView, self).get_batch_kwargs(batch, mobile=mobile) kwargs['min_diff_threshold'] = batch.min_diff_threshold kwargs['min_diff_percent'] = batch.min_diff_percent kwargs['calculate_for_manual'] = batch.calculate_for_manual + + # are we auto-generating from SRP breach? + if self.request.POST.get('auto_generate_from_srp_breach') == 'true': + + # assign batch param + params = kwargs.get('params', {}) + params['auto_generate_from_srp_breach'] = True + kwargs['params'] = params + + # provide default description + if not kwargs.get('description'): + kwargs['description'] = "auto-generated from SRP breach" + return kwargs def configure_row_grid(self, g): From 35875b7826b35b79eab45ffd02362a437ed1f0fd Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 27 Jan 2020 12:57:40 -0600 Subject: [PATCH 0019/1681] Tweak how we import pip internal things, for upgrade view ugh, just kicking the can down the road here --- tailbone/views/upgrades.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index 8c7b1216..2a37709b 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -35,13 +35,18 @@ from sqlalchemy import orm # TODO: pip has declared these to be "not public API" so we should find another way.. try: - # this works for now, with pip 10.0.1 - from pip._internal.download import PipSession + # this works for now, with pip 20.0 + from pip._internal.network.session import PipSession from pip._internal.req import parse_requirements except ImportError: - # this should work with pip < 10.0 - from pip.download import PipSession - from pip.req import parse_requirements + try: + # this works for now, with pip 10.0.1 + from pip._internal.download import PipSession + from pip._internal.req import parse_requirements + except ImportError: + # this should work with pip < 10.0 + from pip.download import PipSession + from pip.req import parse_requirements from rattail.db import model, Session as RattailSession from rattail.time import make_utc From 99f1e000bf26b600d2263ac356add35b2e86f161 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 27 Jan 2020 16:13:28 -0600 Subject: [PATCH 0020/1681] Stop including deprecated views probably this only affected the "tests" --- tailbone/views/vendors/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tailbone/views/vendors/__init__.py b/tailbone/views/vendors/__init__.py index 31cef989..51b528f2 100644 --- a/tailbone/views/vendors/__init__.py +++ b/tailbone/views/vendors/__init__.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -31,5 +31,3 @@ from .core import VendorsView, VendorsAutocomplete def includeme(config): config.include('tailbone.views.vendors.core') - config.include('tailbone.views.vendors.catalogs') - config.include('tailbone.views.vendors.invoices') From 6e7ee99b476b8c72a8b423e8ddff010b4d84b64b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 28 Jan 2020 06:47:59 -0600 Subject: [PATCH 0021/1681] Sort report options by name, when choosing which to generate --- tailbone/views/reports.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index c13c6af1..7fbdde03 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -305,6 +305,7 @@ class GenerateReport(View): # TODO: should probably "group" certain reports together somehow? # e.g. some for customers/membership, others for product movement etc. values = [(r.type_key, r.name) for r in reports.values()] + values.sort(key=lambda r: r[1]) if use_buefy: form.set_widget('report_type', forms.widgets.CustomSelectWidget(values=values, size=10)) form.widgets['report_type'].set_template_values(input_handler='reportTypeChanged') From 201f7cc21e2ccf777467816f6d2784e8c1c3e158 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 28 Jan 2020 11:59:40 -0600 Subject: [PATCH 0022/1681] Add warning for "price breaches SRP" rows in pricing batch --- tailbone/views/batch/pricing.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tailbone/views/batch/pricing.py b/tailbone/views/batch/pricing.py index 84b17736..be870fbc 100644 --- a/tailbone/views/batch/pricing.py +++ b/tailbone/views/batch/pricing.py @@ -207,7 +207,8 @@ class PricingBatchView(BatchMasterView): def row_grid_extra_class(self, row, i): if row.status_code in (row.STATUS_PRODUCT_NOT_FOUND, - row.STATUS_CANNOT_CALCULATE_PRICE): + row.STATUS_CANNOT_CALCULATE_PRICE, + row.STATUS_PRICE_BREACHES_SRP): return 'warning' if row.status_code in (row.STATUS_PRICE_INCREASE, row.STATUS_PRICE_DECREASE): return 'notice' From b875540397ef9ae129e5327e11b1802aebd7838e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 28 Jan 2020 15:11:31 -0600 Subject: [PATCH 0023/1681] 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 82dc7899..0a3df663 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,20 @@ CHANGELOG ========= +0.8.81 (2020-01-28) +------------------- + +* Include regular price changes, for current price history dialog. + +* Allow populate of new pricing batch from products w/ "SRP breach". + +* Tweak how we import pip internal things, for upgrade view. + +* Sort report options by name, when choosing which to generate. + +* Add warning for "price breaches SRP" rows in pricing batch. + + 0.8.80 (2020-01-20) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 5b141269..a4edfdc9 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.80' +__version__ = '0.8.81' From 132b2b9ec792f4f60a92d19ec94f78b5414ae86d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 28 Jan 2020 16:33:23 -0600 Subject: [PATCH 0024/1681] Fix vendor ID/name for Excel download of pricing batch rows --- tailbone/views/batch/pricing.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tailbone/views/batch/pricing.py b/tailbone/views/batch/pricing.py index be870fbc..996cbb2b 100644 --- a/tailbone/views/batch/pricing.py +++ b/tailbone/views/batch/pricing.py @@ -261,6 +261,7 @@ class PricingBatchView(BatchMasterView): return fields + # TODO: this is the same as xlsx row! should merge/share somehow? def get_row_csv_row(self, row, fields): csvrow = super(PricingBatchView, self).get_row_csv_row(row, fields) @@ -274,6 +275,20 @@ class PricingBatchView(BatchMasterView): return csvrow + # TODO: this is the same as csv row! should merge/share somehow? + def get_row_xlsx_row(self, row, fields): + xlrow = super(PricingBatchView, self).get_row_xlsx_row(row, fields) + + vendor = row.vendor + if 'vendor_id' in fields: + xlrow['vendor_id'] = (vendor.id or '') if vendor else '' + if 'vendor_abbreviation' in fields: + xlrow['vendor_abbreviation'] = (vendor.abbreviation or '') if vendor else '' + if 'vendor_name' in fields: + xlrow['vendor_name'] = (vendor.name or '') if vendor else '' + + return xlrow + def includeme(config): PricingBatchView.defaults(config) From b633c91b666ece56645202ed389f67db58af914d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 28 Jan 2020 17:24:10 -0600 Subject: [PATCH 0025/1681] Add red highlight for SRP breach, for generic product batch --- tailbone/views/batch/product.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/tailbone/views/batch/product.py b/tailbone/views/batch/product.py index e649d7bf..50b18953 100644 --- a/tailbone/views/batch/product.py +++ b/tailbone/views/batch/product.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -30,6 +30,7 @@ from rattail.db import model from rattail.util import OrderedDict import colander +from webhelpers2.html import HTML from tailbone import forms from tailbone.views.batch import BatchMasterView @@ -156,6 +157,9 @@ class ProductBatchView(BatchMasterView): g.set_type('current_price', 'currency') g.set_type('suggested_price', 'currency') + # highlight red for SRP breaches + g.set_renderer('suggested_price', self.render_suggested_price) + def row_grid_extra_class(self, row, i): if row.status_code in (row.STATUS_MISSING_KEY, row.STATUS_PRODUCT_NOT_FOUND): @@ -174,6 +178,28 @@ class ProductBatchView(BatchMasterView): f.set_renderer('family', self.render_family) f.set_renderer('reportcode', self.render_report) + f.set_type('regular_cost', 'currency') + f.set_type('current_cost', 'currency') + f.set_type('regular_price', 'currency') + f.set_type('current_price', 'currency') + f.set_type('suggested_price', 'currency') + + # highlight red for SRP breaches + f.set_renderer('suggested_price', self.render_suggested_price) + + def render_suggested_price(self, row, field): + price = getattr(row, field) + if not price: + return "" + + text = "${:0,.2f}".format(price) + + if row.regular_price and row.suggested_price and ( + row.regular_price > row.suggested_price): + text = HTML.tag('span', style='color: red;', c=text) + + return text + def get_execute_success_url(self, batch, result, **kwargs): if kwargs['action'] == 'make_label_batch': return self.request.route_url('labels.batch.view', uuid=result.uuid) From 77f26f01d43213c7cb2b4eb69a6d6109c2a2db71 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 29 Jan 2020 22:01:44 -0600 Subject: [PATCH 0026/1681] Make sure falafel theme is somewhat available by default --- tailbone/config.py | 3 +++ tailbone/subscribers.py | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/tailbone/config.py b/tailbone/config.py index 00a8a6f7..b39a495a 100644 --- a/tailbone/config.py +++ b/tailbone/config.py @@ -48,6 +48,9 @@ class ConfigExtension(BaseExtension): Session.configure(rattail_config=config) configure_session(config, Session) + # provide default theme selection + config.setdefault('tailbone', 'themes', 'default, falafel') + def expose_vuejs_experiments(config): return config.getbool('tailbone', 'expose_vuejs_experiments', diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index b0a73458..90930e60 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -115,8 +115,10 @@ def before_render(event): default=False) renderer_globals['expose_theme_picker'] = expose_picker if expose_picker: + # tailbone's config extension provides a default theme selection, + # so the default we specify here *probably* should not matter available = request.rattail_config.getlist('tailbone', 'themes', - default=['bobcat']) + default=['falafel']) if 'default' not in available: available.insert(0, 'default') options = [tags.Option(theme) for theme in available] From d00449465f3553678c9f595ffd01b73856fac196 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 29 Jan 2020 22:20:53 -0600 Subject: [PATCH 0027/1681] Go ahead and expose theme picker by default might as well let everyone see that out of the gate..right? --- tailbone/config.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/config.py b/tailbone/config.py index b39a495a..aa15dc07 100644 --- a/tailbone/config.py +++ b/tailbone/config.py @@ -50,6 +50,7 @@ class ConfigExtension(BaseExtension): # provide default theme selection config.setdefault('tailbone', 'themes', 'default, falafel') + config.setdefault('tailbone', 'themes.expose_picker', 'true') def expose_vuejs_experiments(config): From f1dc773bfd7ec828454fed617a71c2e4fff3d23c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 3 Feb 2020 18:46:19 -0600 Subject: [PATCH 0028/1681] 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 0a3df663..1869af80 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.8.82 (2020-02-03) +------------------- + +* Fix vendor ID/name for Excel download of pricing batch rows. + +* Add red highlight for SRP breach, for generic product batch. + +* Make sure falafel theme is somewhat available by default. + + 0.8.81 (2020-01-28) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index a4edfdc9..ba268d4d 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.81' +__version__ = '0.8.82' From 6a8f64a9e8103e1539b314a060eca6fe88b6216e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 7 Feb 2020 16:21:51 -0600 Subject: [PATCH 0029/1681] Use new `Email.obtain_sample_data()` method when generating preview per upstream changes --- tailbone/views/email.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tailbone/views/email.py b/tailbone/views/email.py index d4c9e702..39b96733 100644 --- a/tailbone/views/email.py +++ b/tailbone/views/email.py @@ -125,7 +125,7 @@ class ProfilesView(MasterView): recips = email.get_recips(type_) if recips: return ', '.join(recips) - data = email.sample_data(self.request) + data = email.obtain_sample_data(self.request) return { '_email': email, 'key': email.key, @@ -299,7 +299,7 @@ class EmailPreview(View): key = self.request.POST.get('email_key') if key: email = self.handler.get_email(key) - data = email.sample_data(self.request) + data = email.obtain_sample_data(self.request) msg = email.make_message(data) subject = msg['Subject'] @@ -320,7 +320,7 @@ class EmailPreview(View): def preview_template(self, key, type_): email = self.handler.get_email(key) template = email.get_template(type_) - data = email.sample_data(self.request) + data = email.obtain_sample_data(self.request) self.request.response.text = template.render(**data) if type_ == 'txt': self.request.response.content_type = b'text/plain' From 6925c460c541a3c85d1c47ee314c71c89dde99de Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 7 Feb 2020 18:12:44 -0600 Subject: [PATCH 0030/1681] Add some custom display logic for "current price" in pricing batch --- tailbone/views/batch/pricing.py | 43 +++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/tailbone/views/batch/pricing.py b/tailbone/views/batch/pricing.py index 996cbb2b..61cf1ea7 100644 --- a/tailbone/views/batch/pricing.py +++ b/tailbone/views/batch/pricing.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -27,8 +27,9 @@ Views for pricing batches from __future__ import unicode_literals, absolute_import from rattail.db import model +from rattail.time import localtime -from webhelpers2.html import tags +from webhelpers2.html import tags, HTML from tailbone.views.batch import BatchMasterView @@ -199,6 +200,8 @@ class PricingBatchView(BatchMasterView): g.set_type('new_price', 'currency') g.set_type('price_diff', 'currency') + g.set_renderer('current_price', self.render_current_price) + def render_vendor_id(self, row, field): vendor_id = row.vendor.id if row.vendor else None if not vendor_id: @@ -206,12 +209,42 @@ class PricingBatchView(BatchMasterView): return vendor_id def row_grid_extra_class(self, row, i): + extra_class = None + + # primary class comes from row status if row.status_code in (row.STATUS_PRODUCT_NOT_FOUND, row.STATUS_CANNOT_CALCULATE_PRICE, row.STATUS_PRICE_BREACHES_SRP): - return 'warning' - if row.status_code in (row.STATUS_PRICE_INCREASE, row.STATUS_PRICE_DECREASE): - return 'notice' + extra_class = 'warning' + elif row.status_code in (row.STATUS_PRICE_INCREASE, + row.STATUS_PRICE_DECREASE): + extra_class = 'notice' + + # but we want to indicate presence of current price also + if row.current_price: + extra_class = "{} has-current-price".format(extra_class or '') + + return extra_class + + def render_current_price(self, row, field): + value = row.current_price + if value is None: + return "" + + if value < 0: + text = "(${:0,.2f})".format(0 - value) + else: + text = "${:0,.2f}".format(value) + + if row.current_price_ends: + ends = localtime(self.rattail_config, row.current_price_ends, from_utc=True) + ends = "ends on {}".format(ends.date()) + else: + ends = "never ends" + title = "{}, {}".format( + self.enum.PRICE_TYPE.get(row.current_price_type, "unknown type"), + ends) + return HTML.tag('span', title=title, c=text) def configure_row_form(self, f): super(PricingBatchView, self).configure_row_form(f) From 76839c48cf2ad5e59e145f9b835591558bda4715 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 9 Feb 2020 15:32:22 -0600 Subject: [PATCH 0031/1681] Fix email preview for TXT templates on python3 --- tailbone/views/email.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/views/email.py b/tailbone/views/email.py index 39b96733..1349d4cc 100644 --- a/tailbone/views/email.py +++ b/tailbone/views/email.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -323,7 +323,7 @@ class EmailPreview(View): data = email.obtain_sample_data(self.request) self.request.response.text = template.render(**data) if type_ == 'txt': - self.request.response.content_type = b'text/plain' + self.request.response.content_type = str('text/plain') return self.request.response @classmethod From 4a35620820617719f82ebfd3e6c4c0b4d28db987 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 10 Feb 2020 12:35:30 -0600 Subject: [PATCH 0032/1681] Allow override of "email key" for user feedback, sent via API --- tailbone/api/common.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tailbone/api/common.py b/tailbone/api/common.py index dd74fe6f..e8e297f3 100644 --- a/tailbone/api/common.py +++ b/tailbone/api/common.py @@ -41,7 +41,13 @@ from tailbone.db import Session class CommonView(APIView): """ Misc. "common" views for the API. + + .. attribute:: feedback_email_key + + This is the email key which will be used when sending "user feedback" + email. Default value is ``'user_feedback'``. """ + feedback_email_key = 'user_feedback' @api def about(self): @@ -94,7 +100,7 @@ class CommonView(APIView): data['user_url'] = '#' # TODO: could get from config? data['client_ip'] = self.request.client_addr - send_email(self.rattail_config, 'user_feedback', data=data) + send_email(self.rattail_config, self.feedback_email_key, data=data) return {'ok': True} return {'error': "Form did not validate!"} From 5faced8d22b4912f7a271c9d6ce274c839818e3a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 10 Feb 2020 14:13:50 -0600 Subject: [PATCH 0033/1681] Tweak how default config is defined for auth API views so it may be more easily extended --- tailbone/api/auth.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tailbone/api/auth.py b/tailbone/api/auth.py index a707bf70..cf421444 100644 --- a/tailbone/api/auth.py +++ b/tailbone/api/auth.py @@ -128,6 +128,10 @@ class AuthenticationView(APIView): @classmethod def defaults(cls, config): + cls._auth_defaults(config) + + @classmethod + def _auth_defaults(cls, config): # session config.add_route('api.session', '/session', request_method='GET') From a6f80e07e0694c06d3acb8f26dfdbfcc9ea4ddb8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 10 Feb 2020 15:43:10 -0600 Subject: [PATCH 0034/1681] Add way to prevent user login via API, per custom logic --- tailbone/api/auth.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tailbone/api/auth.py b/tailbone/api/auth.py index cf421444..0fbe8b82 100644 --- a/tailbone/api/auth.py +++ b/tailbone/api/auth.py @@ -83,16 +83,33 @@ class AuthenticationView(APIView): if not (username and password): return {'error': "Invalid username or password"} + # make sure credentials are valid user = self.authenticate_user(username, password) if not user: return {'error': "Invalid username or password"} + # is there some reason this user should not login? + error = self.why_cant_user_login(user) + if error: + return {'error': error} + login_user(self.request, user) return self.user_info(user) def authenticate_user(self, username, password): return authenticate_user(Session(), username, password) + def why_cant_user_login(self, user): + """ + This method is given a ``User`` instance, which represents someone who + is just now trying to login, and has already cleared the basic hurdle + of providing the correct credentials for a user on file. This method + is responsible then, for further verification that this user *should* + in fact be allowed to login to this app node. If the method determines + a reason the user should *not* be allowed to login, then it should + return that reason as a simple string. + """ + @api def logout(self): """ From c95008703c74b8ca3ce39a219cdc74c3983ac318 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 11 Feb 2020 13:31:02 -0600 Subject: [PATCH 0035/1681] Add common `get_user_info()` method for all API views --- tailbone/api/auth.py | 30 +++++++++++------------- tailbone/api/core.py | 55 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 67 insertions(+), 18 deletions(-) diff --git a/tailbone/api/auth.py b/tailbone/api/auth.py index 0fbe8b82..4306da78 100644 --- a/tailbone/api/auth.py +++ b/tailbone/api/auth.py @@ -35,17 +35,6 @@ from tailbone.auth import login_user, logout_user class AuthenticationView(APIView): - def user_info(self, user): - return { - 'ok': True, - 'user': { - 'uuid': user.uuid, - 'username': user.username, - 'display_name': user.display_name, - 'short_name': user.get_short_name(), - }, - } - @api def check_session(self): """ @@ -55,9 +44,7 @@ class AuthenticationView(APIView): """ data = {'ok': True} if self.request.user: - data = self.user_info(self.request.user) - data['user']['is_admin'] = self.request.is_admin - data['user']['is_root'] = self.request.is_root + data['user'] = self.get_user_info(self.request.user) data['permissions'] = list(self.request.tailbone_cached_permissions) @@ -94,7 +81,10 @@ class AuthenticationView(APIView): return {'error': error} login_user(self.request, user) - return self.user_info(user) + return { + 'ok': True, + 'user': self.get_user_info(user), + } def authenticate_user(self, username, password): return authenticate_user(Session(), username, password) @@ -130,7 +120,10 @@ class AuthenticationView(APIView): raise self.forbidden() self.request.user.record_event(self.enum.USER_EVENT_BECOME_ROOT) self.request.session['is_root'] = True - return self.user_info(self.request.user) + return { + 'ok': True, + 'user': self.get_user_info(self.request.user), + } @api def stop_root(self): @@ -141,7 +134,10 @@ class AuthenticationView(APIView): raise self.forbidden() self.request.user.record_event(self.enum.USER_EVENT_STOP_ROOT) self.request.session['is_root'] = False - return self.user_info(self.request.user) + return { + 'ok': True, + 'user': self.get_user_info(self.request.user), + } @classmethod def defaults(cls, config): diff --git a/tailbone/api/core.py b/tailbone/api/core.py index f129ec82..f263450b 100644 --- a/tailbone/api/core.py +++ b/tailbone/api/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -26,6 +26,8 @@ Tailbone Web API - Core Views from __future__ import unicode_literals, absolute_import +from rattail.util import load_object + from tailbone.views import View @@ -68,3 +70,54 @@ class APIView(View): if not dt: return "" return dt.strftime('%Y-%m-%d @ %I:%M %p') + + def get_user_info(self, user): + """ + This method is present on *all* API views, and is meant to provide a + single means of obtaining "common" user info, for return to the caller. + Such info may be returned in several places, e.g. upon login but also + in the "check session" call, or e.g. as part of a broader return value + from any other call. + + :returns: Dictionary of user info data, ready for JSON serialization. + + Note that you should *not* (usually) override this method in any view, + but instead configure a "supplemental" function which can then add or + replace info entries. Config for that looks like e.g.: + + .. code-block:: ini + + [tailbone.api] + extra_user_info = poser.web.api.util:extra_user_info + + Note that the above config assumes a simple *function* defined in your + ``util`` module; such a function would look like e.g.:: + + def extra_user_info(request, user, **info): + # add favorite color + info['favorite_color'] = 'green' + # override display name + info['display_name'] = "TODO" + # remove short_name + info.pop('short_name', None) + return info + """ + # basic / default info + is_admin = user.is_admin() + info = { + 'uuid': user.uuid, + 'username': user.username, + 'display_name': user.display_name, + 'short_name': user.get_short_name(), + 'is_admin': is_admin, + 'is_root': is_admin and self.request.session.get('is_root', False), + } + + # maybe get/use "extra" info + extra = self.rattail_config.get('tailbone.api', 'extra_user_info', + usedb=False) + if extra: + extra = load_object(extra) + info = extra(self.request, user, **info) + + return info From c9cf59762a0e2181b587c711f582ee8863c416a1 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 12 Feb 2020 14:47:48 -0600 Subject: [PATCH 0036/1681] Return package names as list, from "about" page from API so client knows in what order to display package versions --- tailbone/api/common.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tailbone/api/common.py b/tailbone/api/common.py index e8e297f3..7af46bf3 100644 --- a/tailbone/api/common.py +++ b/tailbone/api/common.py @@ -54,10 +54,12 @@ class CommonView(APIView): """ Generic view to show "about project" info page. """ + packages = self.get_packages() return { 'project_title': self.get_project_title(), 'project_version': self.get_project_version(), - 'packages': self.get_packages(), + 'packages': packages, + 'package_names': list(packages), } def get_project_title(self): From da16f25cf23f27723a84a37741d61d90a5a21750 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 12 Feb 2020 14:49:32 -0600 Subject: [PATCH 0037/1681] 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 1869af80..ca52ae51 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,24 @@ CHANGELOG ========= +0.8.83 (2020-02-12) +------------------- + +* Use new ``Email.obtain_sample_data()`` method when generating preview. + +* Add some custom display logic for "current price" in pricing batch. + +* Fix email preview for TXT templates on python3. + +* Allow override of "email key" for user feedback, sent via API. + +* Add way to prevent user login via API, per custom logic. + +* Add common ``get_user_info()`` method for all API views. + +* Return package names as list, from "about" page from API. + + 0.8.82 (2020-02-03) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index ba268d4d..1fbd0c60 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.82' +__version__ = '0.8.83' From 5e028ce547cb3cb08dbeecc8fe1c2e882743c619 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 12 Feb 2020 17:32:18 -0600 Subject: [PATCH 0038/1681] Add API view for changing current user password --- tailbone/api/auth.py | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/tailbone/api/auth.py b/tailbone/api/auth.py index 4306da78..05123818 100644 --- a/tailbone/api/auth.py +++ b/tailbone/api/auth.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -26,7 +26,7 @@ Tailbone Web API - Auth Views from __future__ import unicode_literals, absolute_import -from rattail.db.auth import authenticate_user +from rattail.db.auth import authenticate_user, set_user_password from tailbone.api import APIView, api from tailbone.db import Session @@ -139,6 +139,30 @@ class AuthenticationView(APIView): 'user': self.get_user_info(self.request.user), } + @api + def change_password(self): + """ + View which allows a user to change their password. + """ + if self.request.method == 'OPTIONS': + return self.request.response + + if not self.request.user: + raise self.forbidden() + + data = self.request.json_body + + # first make sure "current" password is accurate + if not authenticate_user(Session(), self.request.user, data['current_password']): + return {'error': "The current/old password you provided is incorrect"} + + # okay then, set new password + set_user_password(self.request.user, data['new_password']) + return { + 'ok': True, + 'user': self.get_user_info(self.request.user), + } + @classmethod def defaults(cls, config): cls._auth_defaults(config) @@ -166,6 +190,10 @@ class AuthenticationView(APIView): config.add_route('api.stop_root', '/stop-root', request_method=('OPTIONS', 'POST')) config.add_view(cls, attr='stop_root', route_name='api.stop_root', renderer='json') + # change password + config.add_route('api.change_password', '/change-password', request_method=('OPTIONS', 'POST')) + config.add_view(cls, attr='change_password', route_name='api.change_password', renderer='json') + def includeme(config): AuthenticationView.defaults(config) From c96ab426a417e603282de95f9db2e520ac73f2ba Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 21 Feb 2020 12:36:11 -0600 Subject: [PATCH 0039/1681] Return new user permissions when logging in via API --- tailbone/api/auth.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tailbone/api/auth.py b/tailbone/api/auth.py index 05123818..63a47ed8 100644 --- a/tailbone/api/auth.py +++ b/tailbone/api/auth.py @@ -26,7 +26,7 @@ Tailbone Web API - Auth Views from __future__ import unicode_literals, absolute_import -from rattail.db.auth import authenticate_user, set_user_password +from rattail.db.auth import authenticate_user, set_user_password, cache_permissions from tailbone.api import APIView, api from tailbone.db import Session @@ -84,6 +84,7 @@ class AuthenticationView(APIView): return { 'ok': True, 'user': self.get_user_info(user), + 'permissions': list(cache_permissions(Session(), user)), } def authenticate_user(self, username, password): From 877e6088e2ae71af432de64cdd2e0adf17e9ef90 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 21 Feb 2020 14:30:08 -0600 Subject: [PATCH 0040/1681] 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 ca52ae51..6f327424 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.84 (2020-02-21) +------------------- + +* Add API view for changing current user password. + +* Return new user permissions when logging in via API. + + 0.8.83 (2020-02-12) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 1fbd0c60..d609fb7e 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.83' +__version__ = '0.8.84' From 6c5cc95e51cd12d60c752d674e7980d8bdbba8da Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 23 Feb 2020 21:07:50 -0600 Subject: [PATCH 0041/1681] Overhaul the /ordering batch API somewhat; update docs mostly a savepoint; the /ordering API still needs some work for sure --- docs/api/api/batch/core.rst | 15 +++ docs/api/api/batch/ordering.rst | 41 ++++++++ docs/conf.py | 1 + docs/index.rst | 2 + tailbone/api/batch/core.py | 3 + tailbone/api/batch/ordering.py | 161 ++++++++++++++++++++++---------- 6 files changed, 176 insertions(+), 47 deletions(-) create mode 100644 docs/api/api/batch/core.rst create mode 100644 docs/api/api/batch/ordering.rst diff --git a/docs/api/api/batch/core.rst b/docs/api/api/batch/core.rst new file mode 100644 index 00000000..48d34315 --- /dev/null +++ b/docs/api/api/batch/core.rst @@ -0,0 +1,15 @@ + +``tailbone.api.batch.core`` +=========================== + +.. automodule:: tailbone.api.batch.core + +.. autoclass:: APIBatchMixin + +.. autoclass:: APIBatchView + +.. autoclass:: APIBatchRowView + + .. autoattribute:: editable + + .. autoattribute:: supports_quick_entry diff --git a/docs/api/api/batch/ordering.rst b/docs/api/api/batch/ordering.rst new file mode 100644 index 00000000..4b07e1f2 --- /dev/null +++ b/docs/api/api/batch/ordering.rst @@ -0,0 +1,41 @@ + +``tailbone.api.batch.ordering`` +=============================== + +.. automodule:: tailbone.api.batch.ordering + +.. autoclass:: OrderingBatchViews + + .. autoattribute:: collection_url_prefix + + .. autoattribute:: object_url_prefix + + .. autoattribute:: model_class + + .. autoattribute:: route_prefix + + .. autoattribute:: permission_prefix + + .. autoattribute:: default_handler_spec + + .. automethod:: base_query + + .. automethod:: create_object + +.. autoclass:: OrderingBatchRowViews + + .. autoattribute:: collection_url_prefix + + .. autoattribute:: object_url_prefix + + .. autoattribute:: model_class + + .. autoattribute:: route_prefix + + .. autoattribute:: permission_prefix + + .. autoattribute:: default_handler_spec + + .. autoattribute:: supports_quick_entry + + .. automethod:: update_object diff --git a/docs/conf.py b/docs/conf.py index 87de553a..f96b4fec 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -41,6 +41,7 @@ extensions = [ ] intersphinx_mapping = { + 'rattail': ('https://rattailproject.org/docs/rattail/', None), 'webhelpers2': ('https://webhelpers2.readthedocs.io/en/latest/', None), } diff --git a/docs/index.rst b/docs/index.rst index 3157ae40..bc7d3005 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -42,6 +42,8 @@ Package API: .. toctree:: :maxdepth: 1 + api/api/batch/core + api/api/batch/ordering api/forms api/grids api/progress diff --git a/tailbone/api/batch/core.py b/tailbone/api/batch/core.py index 9f056d9f..4a24603e 100644 --- a/tailbone/api/batch/core.py +++ b/tailbone/api/batch/core.py @@ -219,6 +219,7 @@ class APIBatchRowView(APIBatchMixin, APIMasterView): """ Base class for all API views which are meant to handle "batch rows" data. """ + editable = False supports_quick_entry = False def __init__(self, request, **kwargs): @@ -280,6 +281,8 @@ class APIBatchRowView(APIBatchMixin, APIMasterView): resource.add_view(cls.collection_get, permission='{}.view'.format(permission_prefix)) resource.add_view(cls.get, permission='{}.view'.format(permission_prefix)) + if cls.editable: + resource.add_view(cls.post, permission='{}.edit'.format(permission_prefix)) rows_resource = resource.add_resource(cls, collection_path=collection_url_prefix, path='{}/{{uuid}}'.format(object_url_prefix)) config.add_cornice_resource(rows_resource) diff --git a/tailbone/api/batch/ordering.py b/tailbone/api/batch/ordering.py index a1bdf4e4..4907ffe7 100644 --- a/tailbone/api/batch/ordering.py +++ b/tailbone/api/batch/ordering.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -22,6 +22,9 @@ ################################################################################ """ Tailbone Web API - Ordering Batches + +These views expose the basic CRUD interface to "ordering" batches, for the web +API. """ from __future__ import unicode_literals, absolute_import @@ -29,73 +32,137 @@ from __future__ import unicode_literals, absolute_import import six from rattail.db import model -from rattail.time import localtime +from rattail.util import pretty_quantity -from cornice.resource import resource, view - -from tailbone.api import APIMasterView +from tailbone.api.batch import APIBatchView, APIBatchRowView -@resource(collection_path='/ordering-batches', path='/ordering-batch/{uuid}') -class OrderingBatchView(APIMasterView): +class OrderingBatchViews(APIBatchView): model_class = model.PurchaseBatch + default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler' + route_prefix = 'orderingbatchviews' + permission_prefix = 'ordering' + collection_url_prefix = '/ordering-batches' + object_url_prefix = '/ordering-batch' def base_query(self): - return self.Session.query(model.PurchaseBatch)\ - .filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING) + """ + Modifies the default logic as follows: - def pretty_datetime(self, dt): - if not dt: - return "" - return dt.strftime('%Y-%m-%d @ %I:%M %p') + Adds a condition to the query, to ensure only purchase batches with + "ordering" mode are returned. + """ + query = super(OrderingBatchViews, self).base_query() + query = query.filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING) + return query def normalize(self, batch): + data = super(OrderingBatchViews, self).normalize(batch) - created = batch.created - created = localtime(self.rattail_config, created, from_utc=True) - created = self.pretty_datetime(created) + data['vendor_uuid'] = batch.vendor.uuid + data['vendor_display'] = six.text_type(batch.vendor) - executed = batch.executed - if executed: - executed = localtime(self.rattail_config, executed, from_utc=True) - executed = self.pretty_datetime(executed) + data['department_uuid'] = batch.department_uuid + data['department_display'] = six.text_type(batch.department) if batch.department else None - return { - 'uuid': batch.uuid, - '_str': six.text_type(batch), - 'id': batch.id, - 'id_str': batch.id_str, - 'description': batch.description, - 'vendor_uuid': batch.vendor.uuid, - 'vendor_name': batch.vendor.name, - 'po_total_calculated': batch.po_total_calculated, - 'po_total_calculated_display': "${:0.2f}".format(batch.po_total_calculated) if batch.po_total_calculated is not None else None, - 'date_ordered': six.text_type(batch.date_ordered or ''), - 'created': created, - 'created_by_uuid': batch.created_by.uuid, - 'created_by_display': six.text_type(batch.created_by), - 'executed': executed, - 'executed_by_uuid': batch.executed_by_uuid, - 'executed_by_display': six.text_type(batch.executed_by or ''), - } + data['po_total_calculated_display'] = "${:0.2f}".format(batch.po_total_calculated) if batch.po_total_calculated is not None else None + + return data + + def create_object(self, data): + """ + Modifies the default logic as follows: + + Sets the mode to "ordering" for the new batch. + """ + data = dict(data) + data['mode'] = self.enum.PURCHASE_BATCH_MODE_ORDERING + batch = super(OrderingBatchViews, self).create_object(data) + return batch - @view(permission='ordering.list') def collection_get(self): return self._collection_get() - # @view(permission='ordering.create') - # def collection_post(self): - # return self._collection_post() + def collection_post(self): + return self._collection_post() - @view(permission='ordering.view') def get(self): return self._get() - # @view(permission='ordering.edit') - # def post(self): - # return self._post() + +class OrderingBatchRowViews(APIBatchRowView): + + model_class = model.PurchaseBatchRow + default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler' + route_prefix = 'ordering.rows' + permission_prefix = 'ordering' + collection_url_prefix = '/ordering-batch-rows' + object_url_prefix = '/ordering-batch-row' + supports_quick_entry = True + + def normalize(self, row): + batch = row.batch + data = super(OrderingBatchRowViews, self).normalize(row) + + data['item_id'] = row.item_id + # data['upc'] = six.text_type(row.upc) + # data['upc_pretty'] = row.upc.pretty() if row.upc else None + # data['brand_name'] = row.brand_name + data['description'] = row.description + # data['size'] = row.size + # data['full_description'] = row.product.full_description if row.product else row.description + + # # only provide image url if so configured + # if self.rattail_config.getbool('rattail.batch', 'purchase.mobile_images', default=True): + # data['image_url'] = pod.get_image_url(self.rattail_config, row.upc) if row.upc else None + + # # unit_uom can vary by product + # data['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA' + + # data['case_quantity'] = row.case_quantity + # data['order_quantities_known'] = batch.order_quantities_known + + data['cases_ordered'] = row.cases_ordered + data['units_ordered'] = row.units_ordered + + data['units_ordered_display'] = pretty_quantity(row.units_ordered or 0, empty_zero=False) + # 'po_unit_cost': row.po_unit_cost, + data['po_unit_cost_display'] = "${:0.2f}".format(row.po_unit_cost) if row.po_unit_cost is not None else None + # 'po_total_calculated': row.po_total_calculated, + data['po_total_calculated_display'] = "${:0.2f}".format(row.po_total_calculated) if row.po_total_calculated is not None else None + # 'status_code': row.status_code, + # 'status_display': row.STATUS.get(row.status_code, six.text_type(row.status_code)), + + return data + + def collection_get(self): + return self._collection_get() + + def get(self): + return self._get() + + def post(self): + return self._post() + + def update_object(self, row, data): + """ + Overrides the default logic as follows: + + So far, we only allow updating the ``cases_ordered`` and/or + ``units_ordered`` quantities; therefore ``data`` should have one or + both of those keys. + + This data is then passed to the + :meth:`~rattail:rattail.batch.purchase.PurchaseBatchHandler.update_row_quantity()` + method of the batch handler. + + Note that the "normal" logic for this method is not invoked at all. + """ + self.handler.update_row_quantity(row, **data) + return row def includeme(config): - config.scan(__name__) + OrderingBatchViews.defaults(config) + OrderingBatchRowViews.defaults(config) From c3f4a3d9ea6c8aee2b23140435f44660039ab7d1 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 24 Feb 2020 12:27:26 -0600 Subject: [PATCH 0042/1681] Tweak `save_edit_row_form()` of purchase batch view, to leverage handler specifically this is to make use of handler's `update_row_quantity()` method, when editing a row for ordering batches --- docs/api/views/purchasing.batch.rst | 9 ++++ docs/conf.py | 3 ++ docs/index.rst | 1 + tailbone/views/purchasing/batch.py | 65 +++++++++++++++++++++-------- 4 files changed, 61 insertions(+), 17 deletions(-) create mode 100644 docs/api/views/purchasing.batch.rst diff --git a/docs/api/views/purchasing.batch.rst b/docs/api/views/purchasing.batch.rst new file mode 100644 index 00000000..9bb62c8b --- /dev/null +++ b/docs/api/views/purchasing.batch.rst @@ -0,0 +1,9 @@ + +``tailbone.views.purchasing.batch`` +=================================== + +.. automodule:: tailbone.views.purchasing.batch + +.. autoclass:: PurchasingBatchView + + .. automethod:: save_edit_row_form diff --git a/docs/conf.py b/docs/conf.py index f96b4fec..505396ed 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -109,6 +109,9 @@ pygments_style = 'sphinx' # If true, keep warnings as "system message" paragraphs in the built documents. #keep_warnings = False +# Allow todo entries to show up. +todo_include_todos = True + # -- Options for HTML output ---------------------------------------------- diff --git a/docs/index.rst b/docs/index.rst index bc7d3005..4fd9bdd7 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -52,6 +52,7 @@ Package API: api/views/batch.vendorcatalog api/views/core api/views/master + api/views/purchasing.batch Documentation To-Do diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index 7eaaa7a2..d92ee03b 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -21,7 +21,7 @@ # ################################################################################ """ -Base views for purchasing batches +Base class for purchasing batch views """ from __future__ import unicode_literals, absolute_import @@ -42,7 +42,8 @@ from tailbone.views.batch import BatchMasterView class PurchasingBatchView(BatchMasterView): """ - Master view for purchase order batches. + Master view base class, for purchase batches. The views for both + "ordering" and "receiving" batches will inherit from this. """ model_class = model.PurchaseBatch model_row_class = model.PurchaseBatchRow @@ -891,26 +892,56 @@ class PurchasingBatchView(BatchMasterView): # self.handler.refresh_row(row) def save_edit_row_form(self, form): + """ + Supplements or overrides the default logic, as follows: + + *Ordering Mode* + + So far, we only allow updating the ``cases_ordered`` and/or + ``units_ordered`` quantities; therefore the form data should have one + or both of those fields. + + This data is then passed to the + :meth:`~rattail:rattail.batch.purchase.PurchaseBatchHandler.update_row_quantity()` + method of the batch handler. + + Note that the "normal" logic for this method is not invoked at all, for + ordering batches. + + .. note:: + There is some logic in place for receiving mode, which sort of tries + to update the overall invoice total for the batch, since the form + data might cause those to need adjustment. However the logic is + incomplete as of this writing. + + .. todo:: + Need to fully implement ``save_edit_row_form()`` for receiving batch. + """ row = form.model_instance batch = row.batch - # first undo any totals previously in effect for the row - if batch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING and row.po_total_calculated: - batch.po_total_calculated -= row.po_total_calculated - elif batch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING and row.invoice_total: - # TODO: pretty sure this should update the `_calculated` value instead? - # TODO: also, should update the value again after the super() call - batch.invoice_total -= row.invoice_total + if batch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING: - row = super(PurchasingBatchView, self).save_edit_row_form(form) + # let handler update data, per given order quantities + data = self.form_deserialized + self.handler.update_row_quantity(row, **data) - # TODO: is this needed? - # self.handler.refresh_row(row) + else: # *not* ordering mode - # now apply new totals based on current row quantity - if batch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING and row.po_unit_cost is not None: - row.po_total_calculated = row.po_unit_cost * self.handler.get_units_ordered(row) - batch.po_total_calculated = (batch.po_total_calculated or 0) + row.po_total_calculated + if batch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING: + + # TODO: should stop doing it this way! (use the ordering mode way instead) + # first undo any totals previously in effect for the row + if row.invoice_total: + # TODO: pretty sure this should update the `_calculated` value instead? + # TODO: also, should update the value again after the super() call + batch.invoice_total -= row.invoice_total + + # do the "normal" save logic... + row = super(PurchasingBatchView, self).save_edit_row_form(form) + + # TODO: is this needed? + # self.handler.refresh_row(row) return row From fc830f60e8f2276d80bf6697fff2df02b761e9f2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 24 Feb 2020 12:36:47 -0600 Subject: [PATCH 0043/1681] Tweak `worksheet_update()` of ordering batch view, to leverage handler specifically this is to make use of handler's `update_row_quantity()` method, when user enters new order quantities via worksheet --- docs/api/views/purchasing.ordering.rst | 13 +++ docs/index.rst | 1 + tailbone/templates/ordering/worksheet.mako | 20 ++-- tailbone/views/purchasing/ordering.py | 118 +++++++++++++++------ 4 files changed, 112 insertions(+), 40 deletions(-) create mode 100644 docs/api/views/purchasing.ordering.rst diff --git a/docs/api/views/purchasing.ordering.rst b/docs/api/views/purchasing.ordering.rst new file mode 100644 index 00000000..7dffd964 --- /dev/null +++ b/docs/api/views/purchasing.ordering.rst @@ -0,0 +1,13 @@ + +``tailbone.views.purchasing.ordering`` +====================================== + +.. automodule:: tailbone.views.purchasing.ordering + +.. autoclass:: OrderingBatchView + + .. autoattribute:: model_class + + .. autoattribute:: default_handler_spec + + .. automethod:: worksheet_update diff --git a/docs/index.rst b/docs/index.rst index 4fd9bdd7..ffa516e9 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -53,6 +53,7 @@ Package API: api/views/core api/views/master api/views/purchasing.batch + api/views/purchasing.ordering Documentation To-Do diff --git a/tailbone/templates/ordering/worksheet.mako b/tailbone/templates/ordering/worksheet.mako index 214e29a2..7e5dbe79 100644 --- a/tailbone/templates/ordering/worksheet.mako +++ b/tailbone/templates/ordering/worksheet.mako @@ -36,10 +36,16 @@ if (data.error) { alert(data.error); } else { - row.find('input[name^="cases_ordered_"]').val(data.row_cases_ordered); - row.find('input[name^="units_ordered_"]').val(data.row_units_ordered); - row.find('td.po-total').html(data.row_po_total); - $('.po-total .field').html(data.batch_po_total); + if (data.row_cases_ordered || data.row_units_ordered) { + row.find('input[name^="cases_ordered_"]').val(data.row_cases_ordered); + row.find('input[name^="units_ordered_"]').val(data.row_units_ordered); + row.find('td.po-total').html(data.row_po_total_calculated); + } else { + row.find('input[name^="cases_ordered_"]').val(''); + row.find('input[name^="units_ordered_"]').val(''); + row.find('td.po-total').html(''); + } + $('.po-total .field').html(data.batch_po_total_calculated); } submitting = false; }); @@ -151,7 +157,8 @@ <div class="field-wrapper po-total"> <label>PO Total</label> - <div class="field">$${'{:0,.2f}'.format(batch.po_total or 0)}</div> + ## TODO: should not fall back to po_total + <div class="field">$${'{:0,.2f}'.format(batch.po_total_calculated or batch.po_total or 0)}</div> </div> </div><!-- form-wrapper --> @@ -270,7 +277,8 @@ ${h.end_form()} <td class="current-order"> ${h.text('units_ordered_{}'.format(cost.uuid), value=int(cost._batchrow.units_ordered or 0) if cost._batchrow else None)} </td> - <td class="po-total">${'${:0,.2f}'.format(cost._batchrow.po_total or 0) if cost._batchrow else ''}</td> + ## TODO: should not fall back to po_total + <td class="po-total">${'${:0,.2f}'.format(cost._batchrow.po_total_calculated or cost._batchrow.po_total or 0) if cost._batchrow else ''}</td> ${self.extra_td(cost)} </tr> % endfor diff --git a/tailbone/views/purchasing/ordering.py b/tailbone/views/purchasing/ordering.py index 9437b40d..68ec9f9d 100644 --- a/tailbone/views/purchasing/ordering.py +++ b/tailbone/views/purchasing/ordering.py @@ -43,7 +43,7 @@ from tailbone.views.purchasing import PurchasingBatchView class OrderingBatchView(PurchasingBatchView): """ - Master view for purchase order batches. + Master view for "ordering" batches. """ route_prefix = 'ordering' url_prefix = '/ordering' @@ -58,6 +58,33 @@ class OrderingBatchView(PurchasingBatchView): mobile_rows_deletable = True has_worksheet = True + labels = { + 'po_total_calculated': "PO Total", + } + + form_fields = [ + 'id', + 'store', + 'buyer', + 'vendor', + 'department', + 'purchase', + 'vendor_email', + 'vendor_fax', + 'vendor_contact', + 'vendor_phone', + 'date_ordered', + 'po_number', + 'po_total_calculated', + 'notes', + 'created', + 'created_by', + 'status_code', + 'complete', + 'executed', + 'executed_by', + ] + mobile_form_fields = [ 'vendor', 'department', @@ -73,6 +100,10 @@ class OrderingBatchView(PurchasingBatchView): 'executed_by', ] + row_labels = { + 'po_total_calculated': "PO Total", + } + row_grid_columns = [ 'sequence', 'upc', @@ -84,7 +115,7 @@ class OrderingBatchView(PurchasingBatchView): 'units_ordered', # 'cases_received', # 'units_received', - 'po_total', + 'po_total_calculated', # 'invoice_total', # 'credits', 'status_code', @@ -227,7 +258,24 @@ class OrderingBatchView(PurchasingBatchView): def worksheet_update(self): """ - Handles AJAX requests to update current batch, from Order Form view. + Handles AJAX requests to update the order quantities for some row + within the current batch, from the worksheet view. POST data should + include: + + * ``product_uuid`` + * ``cases_ordered`` + * ``units_ordered`` + + If a row already exists for the given product, it will be updated; + otherwise a new row is created for the product and then that is + updated. The handler's + :meth:`~rattail:rattail.batch.purchase.PurchaseBatchHandler.update_row_quantity()` + method is invoked to update the row. + + However, if both of the quantities given are empty, and a row exists + for the given product, then that row is removed from the batch, instead + of being updated. If a matching row is not found, it will not be + created. """ batch = self.get_instance() @@ -250,41 +298,43 @@ class OrderingBatchView(PurchasingBatchView): if not product: return {'error': "Product not found"} - row = None - rows = [r for r in batch.data_rows if r.product_uuid == uuid] - if rows: - assert len(rows) == 1 - row = rows[0] - if row.po_total and not row.removed: - batch.po_total -= row.po_total - if cases_ordered or units_ordered: - row.cases_ordered = cases_ordered or None - row.units_ordered = units_ordered or None - if row.removed: - row.removed = False - batch.rowcount += 1 - self.handler.refresh_row(row) - if row.po_unit_cost: - row.po_total = row.po_unit_cost * self.handler.get_units_ordered(row) - batch.po_total = (batch.po_total or 0) + row.po_total - else: - row.removed = True + # first we find out which existing row(s) match the given product + matches = [row for row in batch.active_rows() + if row.product_uuid == product.uuid] + if matches and len(matches) != 1: + raise RuntimeError("found too many ({}) matches for product {} in batch {}".format( + len(matches), product.uuid, batch.uuid)) - elif cases_ordered or units_ordered: - row = model.PurchaseBatchRow() - row.product = product - row.cases_ordered = cases_ordered or None - row.units_ordered = units_ordered or None - self.handler.add_row(batch, row) - if row.po_unit_cost: - row.po_total = row.po_unit_cost * self.handler.get_units_ordered(row) - batch.po_total = (batch.po_total or 0) + row.po_total + row = None + if cases_ordered or units_ordered: + + # make a new row if necessary + if matches: + row = matches[0] + else: + row = self.handler.make_row() + row.product = product + self.handler.add_row(batch, row) + + # update row quantities + self.handler.update_row_quantity(row, cases_ordered=cases_ordered, + units_ordered=units_ordered) + + else: # empty order quantities + + # remove row if present + if matches: + row = matches[0] + self.handler.do_remove_row(row) + row = None return { - 'row_cases_ordered': '' if not row or row.removed else int(row.cases_ordered or 0), - 'row_units_ordered': '' if not row or row.removed else int(row.units_ordered or 0), - 'row_po_total': '' if not row or row.removed else '${:0,.2f}'.format(row.po_total or 0), + 'row_cases_ordered': int(row.cases_ordered or 0) if row else None, + 'row_units_ordered': int(row.units_ordered or 0) if row else None, + 'row_po_total': '${:0,.2f}'.format(row.po_total or 0) if row else None, + 'row_po_total_calculated': '${:0,.2f}'.format(row.po_total_calculated or 0) if row else None, 'batch_po_total': '${:0,.2f}'.format(batch.po_total or 0), + 'batch_po_total_calculated': '${:0,.2f}'.format(batch.po_total_calculated or 0), } def render_mobile_listitem(self, batch, i): From 2b70ed14071ba6607e0c448abf6eeee7ca402915 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 24 Feb 2020 13:38:58 -0600 Subject: [PATCH 0044/1681] Fix "edit row" logic for ordering batch previous logic allowed `colander.null` to be passed to batch handler, which caused an error. also it allowed editing "all" fields for the row, which we really don't need to do, so now we just support the order quantities --- docs/api/views/purchasing.ordering.rst | 2 ++ tailbone/views/purchasing/batch.py | 16 ++++++++-- tailbone/views/purchasing/ordering.py | 41 +++++++++++++++++++++++++- 3 files changed, 55 insertions(+), 4 deletions(-) diff --git a/docs/api/views/purchasing.ordering.rst b/docs/api/views/purchasing.ordering.rst index 7dffd964..38d46b07 100644 --- a/docs/api/views/purchasing.ordering.rst +++ b/docs/api/views/purchasing.ordering.rst @@ -10,4 +10,6 @@ .. autoattribute:: default_handler_spec + .. automethod:: configure_row_form + .. automethod:: worksheet_update diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index d92ee03b..8b557188 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -922,9 +922,19 @@ class PurchasingBatchView(BatchMasterView): if batch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING: - # let handler update data, per given order quantities - data = self.form_deserialized - self.handler.update_row_quantity(row, **data) + # figure out which values need updating + form_data = self.form_deserialized + data = {} + for key in ('cases_ordered', 'units_ordered'): + if key in form_data: + # this is really to convert/avoid colander.null, but the + # handler method also assumes that if we pass a value, it + # will not be None + data[key] = form_data[key] or 0 + if data: + + # let handler do the actual updating + self.handler.update_row_quantity(row, **data) else: # *not* ordering mode diff --git a/tailbone/views/purchasing/ordering.py b/tailbone/views/purchasing/ordering.py index 68ec9f9d..15ce6f5b 100644 --- a/tailbone/views/purchasing/ordering.py +++ b/tailbone/views/purchasing/ordering.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -121,6 +121,23 @@ class OrderingBatchView(PurchasingBatchView): 'status_code', ] + row_form_fields = [ + 'item_entry', + 'item_id', + 'upc', + 'product', + 'brand_name', + 'description', + 'size', + 'case_quantity', + 'cases_ordered', + 'units_ordered', + 'po_line_number', + 'po_unit_cost', + 'po_total_calculated', + 'status_code', + ] + order_form_header_columns = [ "UPC", "Brand", @@ -147,6 +164,28 @@ class OrderingBatchView(PurchasingBatchView): kwargs['notes_to_vendor'] = batch.notes_to_vendor return kwargs + def configure_row_form(self, f): + """ + Supplements the default logic as follows: + + When editing, only these fields allow changes; all others are made + read-only: + + * ``cases_ordered`` + * ``units_ordered`` + """ + super(OrderingBatchView, self).configure_row_form(f) + + # when editing, only certain fields should allow changes + if self.editing: + editable_fields = [ + 'cases_ordered', + 'units_ordered', + ] + for field in f.fields: + if field not in editable_fields: + f.set_readonly(field) + def worksheet(self): """ View for editing batch row data as an order form worksheet. From 5f8dc20312706133f84d80b90191bdf97125b520 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 25 Feb 2020 15:35:39 -0600 Subject: [PATCH 0045/1681] Raise 404 not found instead of error, when user is not employee i.e. when they try to view "employee schedule" or "time sheet" --- tailbone/views/shifts/lib.py | 4 +++- tailbone/views/shifts/timesheet.py | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/tailbone/views/shifts/lib.py b/tailbone/views/shifts/lib.py index 2706b10e..73d9603a 100644 --- a/tailbone/views/shifts/lib.py +++ b/tailbone/views/shifts/lib.py @@ -158,8 +158,8 @@ class TimeSheetView(View): # force current user if not allowed to view all data if not self.request.has_perm('{}.viewall'.format(self.key)): employee = self.request.user.employee - assert employee + # note that employee may still be None, e.g. if current user is not employee return {'date': date, 'employee': employee} def process_filter_form(self, form): @@ -257,6 +257,8 @@ class TimeSheetView(View): View time sheet for single employee. """ context = self.get_employee_context() + if not context['employee']: + raise self.notfound() form = self.make_employee_filter_form(context) self.process_employee_filter_form(form) context['form'] = form diff --git a/tailbone/views/shifts/timesheet.py b/tailbone/views/shifts/timesheet.py index a5e06d1a..84d303e9 100644 --- a/tailbone/views/shifts/timesheet.py +++ b/tailbone/views/shifts/timesheet.py @@ -49,6 +49,8 @@ class TimeSheetView(BaseTimeSheetView): """ # process filters; redirect if any were received context = self.get_employee_context() + if not context['employee']: + raise self.notfound() form = self.make_employee_filter_form(context) self.process_employee_filter_form(form) From cd8d70de0e064cccb3ecef56ed2812f22110790f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 26 Feb 2020 14:27:17 -0600 Subject: [PATCH 0046/1681] Send batch params as part of normalized API --- tailbone/api/batch/core.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/api/batch/core.py b/tailbone/api/batch/core.py index 4a24603e..1ceab308 100644 --- a/tailbone/api/batch/core.py +++ b/tailbone/api/batch/core.py @@ -100,6 +100,7 @@ class APIBatchView(APIBatchMixin, APIMasterView): 'id_str': batch.id_str, 'description': batch.description, 'notes': batch.notes, + 'params': batch.params or {}, 'rowcount': batch.rowcount, 'created': created, 'created_by_uuid': batch.created_by.uuid, From 77eead761e564b5a13ff55b2ae9adb938cec580e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 26 Feb 2020 15:04:56 -0600 Subject: [PATCH 0047/1681] 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 6f327424..d1ef4ad6 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,22 @@ CHANGELOG ========= +0.8.85 (2020-02-26) +------------------- + +* Overhaul the /ordering batch API somewhat; update docs. + +* Tweak ``save_edit_row_form()`` of purchase batch view, to leverage handler. + +* Tweak ``worksheet_update()`` of ordering batch view, to leverage handler. + +* Fix "edit row" logic for ordering batch. + +* Raise 404 not found instead of error, when user is not employee. + +* Send batch params as part of normalized API. + + 0.8.84 (2020-02-21) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index d609fb7e..3cb047fe 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.84' +__version__ = '0.8.85' From a79bf3f055980735e9a40dc831e7fe44707baeb2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 26 Feb 2020 17:45:19 -0600 Subject: [PATCH 0048/1681] Add toggle complete, more normalized row fields for odering batch API --- tailbone/api/batch/ordering.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/tailbone/api/batch/ordering.py b/tailbone/api/batch/ordering.py index 4907ffe7..926a3d70 100644 --- a/tailbone/api/batch/ordering.py +++ b/tailbone/api/batch/ordering.py @@ -45,6 +45,7 @@ class OrderingBatchViews(APIBatchView): permission_prefix = 'ordering' collection_url_prefix = '/ordering-batches' object_url_prefix = '/ordering-batch' + supports_toggle_complete = True def base_query(self): """ @@ -106,33 +107,32 @@ class OrderingBatchRowViews(APIBatchRowView): data = super(OrderingBatchRowViews, self).normalize(row) data['item_id'] = row.item_id - # data['upc'] = six.text_type(row.upc) - # data['upc_pretty'] = row.upc.pretty() if row.upc else None - # data['brand_name'] = row.brand_name + data['upc'] = six.text_type(row.upc) + data['upc_pretty'] = row.upc.pretty() if row.upc else None + data['brand_name'] = row.brand_name data['description'] = row.description - # data['size'] = row.size - # data['full_description'] = row.product.full_description if row.product else row.description + data['size'] = row.size + data['full_description'] = row.product.full_description if row.product else row.description # # only provide image url if so configured # if self.rattail_config.getbool('rattail.batch', 'purchase.mobile_images', default=True): # data['image_url'] = pod.get_image_url(self.rattail_config, row.upc) if row.upc else None - # # unit_uom can vary by product - # data['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA' - - # data['case_quantity'] = row.case_quantity - # data['order_quantities_known'] = batch.order_quantities_known + # unit_uom can vary by product + data['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA' + data['case_quantity'] = row.case_quantity data['cases_ordered'] = row.cases_ordered data['units_ordered'] = row.units_ordered - + data['cases_ordered_display'] = pretty_quantity(row.cases_ordered or 0, empty_zero=False) data['units_ordered_display'] = pretty_quantity(row.units_ordered or 0, empty_zero=False) - # 'po_unit_cost': row.po_unit_cost, + + data['po_unit_cost'] = row.po_unit_cost data['po_unit_cost_display'] = "${:0.2f}".format(row.po_unit_cost) if row.po_unit_cost is not None else None - # 'po_total_calculated': row.po_total_calculated, + data['po_total_calculated'] = row.po_total_calculated data['po_total_calculated_display'] = "${:0.2f}".format(row.po_total_calculated) if row.po_total_calculated is not None else None - # 'status_code': row.status_code, - # 'status_display': row.STATUS.get(row.status_code, six.text_type(row.status_code)), + data['status_code'] = row.status_code + data['status_display'] = row.STATUS.get(row.status_code, six.text_type(row.status_code)) return data From c145d077cded72cd76389c7f8deed82174e95b56 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 26 Feb 2020 21:29:37 -0600 Subject: [PATCH 0049/1681] Return employee_uuid along with user info, from API occasionally that is useful --- tailbone/api/core.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tailbone/api/core.py b/tailbone/api/core.py index f263450b..65aa9699 100644 --- a/tailbone/api/core.py +++ b/tailbone/api/core.py @@ -104,6 +104,7 @@ class APIView(View): """ # basic / default info is_admin = user.is_admin() + employee = user.employee info = { 'uuid': user.uuid, 'username': user.username, @@ -111,6 +112,7 @@ class APIView(View): 'short_name': user.get_short_name(), 'is_admin': is_admin, 'is_root': is_admin and self.request.session.get('is_root', False), + 'employee_uuid': employee.uuid if employee else None, } # maybe get/use "extra" info From 7b43164831af0700ac37a2d25f929b0f174ec396 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 26 Feb 2020 21:29:59 -0600 Subject: [PATCH 0050/1681] Add support for executing ordering batches via API --- tailbone/api/batch/core.py | 28 ++++++++++++++++++++++++++++ tailbone/api/batch/ordering.py | 6 ++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/tailbone/api/batch/core.py b/tailbone/api/batch/core.py index 1ceab308..1f725641 100644 --- a/tailbone/api/batch/core.py +++ b/tailbone/api/batch/core.py @@ -77,6 +77,7 @@ class APIBatchView(APIBatchMixin, APIMasterView): "batch row" data. """ supports_toggle_complete = False + supports_execute = False def __init__(self, request, **kwargs): super(APIBatchView, self).__init__(request, **kwargs) @@ -178,6 +179,22 @@ class APIBatchView(APIBatchMixin, APIMasterView): batch.complete = False return self._get(obj=batch) + def execute(self): + """ + Execute the given batch. + """ + batch = self.get_object() + + if batch.executed: + return {'error': "Batch {} has already been executed: {}".format( + batch.id_str, batch.description)} + + kwargs = dict(self.request.json_body) + kwargs.pop('user', None) + kwargs.pop('progress', None) + result = self.handler.do_execute(batch, self.request.user, **kwargs) + return {'ok': bool(result), 'batch': self.normalize(batch)} + @classmethod def defaults(cls, config): cls._batch_defaults(config) @@ -211,6 +228,15 @@ class APIBatchView(APIBatchMixin, APIMasterView): permission='{}.edit'.format(permission_prefix), renderer='json') + if cls.supports_execute: + + # execute + config.add_route('{}.execute'.format(route_prefix), '{}/{{uuid}}/execute'.format(object_url_prefix), + request_method=('OPTIONS', 'POST')) + config.add_view(cls, attr='execute', route_name='{}.execute'.format(route_prefix), + permission='{}.execute'.format(permission_prefix), + renderer='json') + # TODO: deprecate / remove this BatchAPIMasterView = APIBatchView @@ -238,6 +264,8 @@ class APIBatchRowView(APIBatchMixin, APIMasterView): 'batch_id': batch.id, 'batch_id_str': batch.id_str, 'batch_description': batch.description, + 'batch_complete': batch.complete, + 'batch_executed': bool(batch.executed), 'sequence': row.sequence, 'status_code': row.status_code, 'status_display': row.STATUS.get(row.status_code, six.text_type(row.status_code)), diff --git a/tailbone/api/batch/ordering.py b/tailbone/api/batch/ordering.py index 926a3d70..de8fde0b 100644 --- a/tailbone/api/batch/ordering.py +++ b/tailbone/api/batch/ordering.py @@ -46,6 +46,7 @@ class OrderingBatchViews(APIBatchView): collection_url_prefix = '/ordering-batches' object_url_prefix = '/ordering-batch' supports_toggle_complete = True + supports_execute = True def base_query(self): """ @@ -67,8 +68,9 @@ class OrderingBatchViews(APIBatchView): data['department_uuid'] = batch.department_uuid data['department_display'] = six.text_type(batch.department) if batch.department else None - data['po_total_calculated_display'] = "${:0.2f}".format(batch.po_total_calculated) if batch.po_total_calculated is not None else None - + data['po_total_calculated_display'] = "${:0.2f}".format(batch.po_total_calculated or 0) + data['ship_method'] = batch.ship_method + data['notes_to_vendor'] = batch.notes_to_vendor return data def create_object(self, data): From 6d929dd95a5bf9bbc1f2be3fc5e2904fc2b9413a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 28 Feb 2020 13:10:25 -0600 Subject: [PATCH 0051/1681] Fix how we fetch employee history, for profile view --- tailbone/views/people.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index e3e1c8e8..c1eb2dfb 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -261,7 +261,7 @@ class PeopleView(MasterView): def get_context_employee_history(self, employee): data = [] if employee: - for history in sorted(employee.history, key=lambda h: h.start_date, reverse=True): + for history in employee.sorted_history(reverse=True): data.append({ 'uuid': history.uuid, 'start_date': six.text_type(history.start_date), From a2277feb105d11b1c27c15426da6009f590f0abc Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 28 Feb 2020 15:45:27 -0600 Subject: [PATCH 0052/1681] Cleanup main version history views for Buefy theme --- tailbone/templates/master/versions.mako | 32 +++++++++++++++++++-- tailbone/templates/themes/falafel/base.mako | 2 +- tailbone/views/master.py | 25 ++++++++++------ 3 files changed, 47 insertions(+), 12 deletions(-) diff --git a/tailbone/templates/master/versions.mako b/tailbone/templates/master/versions.mako index ccd0c6b8..2d1b4db3 100644 --- a/tailbone/templates/master/versions.mako +++ b/tailbone/templates/master/versions.mako @@ -20,12 +20,38 @@ </%def> <%def name="content_title()"> - History for ${instance_title} + Version History +</%def> + +<%def name="render_this_page()"> + ${self.page_content()} +</%def> + +<%def name="make_this_page_component()"> + ${parent.make_this_page_component()} + <script type="text/javascript"> + + TailboneGrid.data = function() { return TailboneGridData } + + Vue.component('tailbone-grid', TailboneGrid) + + </script> +</%def> + +<%def name="render_this_page_template()"> + ${parent.render_this_page_template()} + + ## TODO: stop using |n filter + ${grid.render_buefy()|n} </%def> <%def name="page_content()"> - ${grid.render_complete()|n} + % if use_buefy: + <tailbone-grid :csrftoken="csrftoken"> + </tailbone-grid> + % else: + ${grid.render_complete()|n} + % endif </%def> - ${parent.body()} diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index b3ca7d54..0e2754f1 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -252,7 +252,7 @@ <span>»</span> ${h.link_to(parent_title, parent_url)} % elif instance_url is not Undefined: - <span>»</span> + <span> »</span> ${h.link_to(instance_title, instance_url)} % endif % if master.viewing and grid_index: diff --git a/tailbone/views/master.py b/tailbone/views/master.py index da08aa62..ab8bf658 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -553,17 +553,19 @@ class MasterView(View): Return a dictionary of kwargs to be passed to the factory when constructing a new version grid. """ + use_buefy = self.get_use_buefy() + instance = kwargs.get('instance') or self.get_instance() + route = '{}.version'.format(self.get_route_prefix()) defaults = { 'model_class': continuum.transaction_class(self.get_model_class()), 'width': 'full', 'pageable': True, + 'url': lambda txn: self.request.route_url(route, uuid=instance.uuid, txnid=txn.id), } if 'main_actions' not in kwargs: - route = '{}.version'.format(self.get_route_prefix()) - instance = kwargs.get('instance') or self.get_instance() url = lambda txn, i: self.request.route_url(route, uuid=instance.uuid, txnid=txn.id) defaults['main_actions'] = [ - self.make_action('view', icon='zoomin', url=url), + self.make_action('view', icon='eye' if use_buefy else 'zoomin', url=url), ] defaults.update(kwargs) return defaults @@ -574,8 +576,10 @@ class MasterView(View): g.set_label('issued_at', "Changed") g.set_label('user', "Changed by") g.set_label('remote_addr', "IP Address") - # TODO: why does this render '#' as url? - # g.set_link('issued_at') + + g.set_link('issued_at') + g.set_link('user') + g.set_link('comment') def render_version_comment(self, transaction, column): return transaction.meta.get('comment', "") @@ -1181,9 +1185,14 @@ class MasterView(View): # return grid only, if partial page was requested if self.request.params.get('partial'): - self.request.response.content_type = b'text/html' - self.request.response.text = grid.render_grid() - return self.request.response + if use_buefy: + # render grid data only, as JSON + return render_to_response('json', grid.get_buefy_data(), + request=self.request) + else: # just do traditional thing, render grid HTML + self.request.response.content_type = str('text/html') + self.request.response.text = grid.render_grid() + return self.request.response return self.render_to_response('versions', { 'instance': instance, From 815cdbdd0ac7abd68042454b3c40c4c55b5880c4 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 28 Feb 2020 17:06:30 -0600 Subject: [PATCH 0053/1681] Fix product price, cost history dialogs, for Buefy theme --- tailbone/templates/master/view_version.mako | 2 +- tailbone/templates/products/view.mako | 112 +++++++++++++++++++- tailbone/views/products.py | 26 ++--- 3 files changed, 120 insertions(+), 20 deletions(-) diff --git a/tailbone/templates/master/view_version.mako b/tailbone/templates/master/view_version.mako index 68c76c6d..71b51d39 100644 --- a/tailbone/templates/master/view_version.mako +++ b/tailbone/templates/master/view_version.mako @@ -1,7 +1,7 @@ ## -*- coding: utf-8; -*- <%inherit file="/page.mako" /> -<%def name="title()">${instance_title_normal} @ ver ${transaction.id}</%def> +<%def name="title()">changes @ ver ${transaction.id}</%def> <%def name="page_content()"> ## TODO: this was basically copied from Revel diff template..need to abstract diff --git a/tailbone/templates/products/view.mako b/tailbone/templates/products/view.mako index 1c36c63c..99f555ae 100644 --- a/tailbone/templates/products/view.mako +++ b/tailbone/templates/products/view.mako @@ -302,7 +302,14 @@ <%def name="sources_panel()"> % if use_buefy: <nav class="panel"> - <p class="panel-heading">Vendor Sources</p> + <p class="panel-heading"> + Vendor Sources + % if request.rattail_config.versioning_enabled() and master.has_perm('versions'): + <a href="#" @click.prevent="showingCostHistory = true"> + (view cost history) + </a> + % endif + </p> <div class="panel-block"> ${self.sources_grid()} </div> @@ -360,6 +367,88 @@ <%def name="extra_right_panels()"></%def> +<%def name="render_this_page()"> + ${parent.render_this_page()} + % if use_buefy and request.rattail_config.versioning_enabled() and master.has_perm('versions'): + + <b-modal :active.sync="showingPriceHistory_regular" + has-modal-card> + <div class="modal-card"> + <header class="modal-card-head"> + <p class="modal-card-title"> + Regular Price History + </p> + </header> + <section class="modal-card-body"> + ${regular_price_history_grid.render_buefy_table_element(data_prop='regularPriceHistoryData')|n} + </section> + <footer class="modal-card-foot"> + <b-button @click="showingPriceHistory_regular = false"> + Close + </b-button> + </footer> + </div> + </b-modal> + + <b-modal :active.sync="showingPriceHistory_current" + has-modal-card> + <div class="modal-card"> + <header class="modal-card-head"> + <p class="modal-card-title"> + Current Price History + </p> + </header> + <section class="modal-card-body"> + ${current_price_history_grid.render_buefy_table_element(data_prop='currentPriceHistoryData')|n} + </section> + <footer class="modal-card-foot"> + <b-button @click="showingPriceHistory_current = false"> + Close + </b-button> + </footer> + </div> + </b-modal> + + <b-modal :active.sync="showingPriceHistory_suggested" + has-modal-card> + <div class="modal-card"> + <header class="modal-card-head"> + <p class="modal-card-title"> + Suggested Price History + </p> + </header> + <section class="modal-card-body"> + ${suggested_price_history_grid.render_buefy_table_element(data_prop='suggestedPriceHistoryData')|n} + </section> + <footer class="modal-card-foot"> + <b-button @click="showingPriceHistory_suggested = false"> + Close + </b-button> + </footer> + </div> + </b-modal> + + <b-modal :active.sync="showingCostHistory" + has-modal-card> + <div class="modal-card"> + <header class="modal-card-head"> + <p class="modal-card-title"> + Cost History + </p> + </header> + <section class="modal-card-body"> + ${cost_history_grid.render_buefy_table_element(data_prop='costHistoryData')|n} + </section> + <footer class="modal-card-foot"> + <b-button @click="showingCostHistory = false"> + Close + </b-button> + </footer> + </div> + </b-modal> + % endif +</%def> + <%def name="page_content()"> % if use_buefy: <div style="display: flex; flex-direction: column;"> @@ -444,5 +533,26 @@ % endif </%def> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + % if request.rattail_config.versioning_enabled() and master.has_perm('versions'): + <script type="text/javascript"> + + ThisPageData.showingPriceHistory_regular = false + ThisPageData.regularPriceHistoryData = ${json.dumps(regular_price_history_grid.get_buefy_data()['data'])|n} + + ThisPageData.showingPriceHistory_current = false + ThisPageData.currentPriceHistoryData = ${json.dumps(current_price_history_grid.get_buefy_data()['data'])|n} + + ThisPageData.showingPriceHistory_suggested = false + ThisPageData.suggestedPriceHistoryData = ${json.dumps(suggested_price_history_grid.get_buefy_data()['data'])|n} + + ThisPageData.showingCostHistory = false + ThisPageData.costHistoryData = ${json.dumps(cost_history_grid.get_buefy_data()['data'])|n} + + </script> + % endif +</%def> + ${parent.body()} diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 1d611400..e86eaaa0 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -482,8 +482,11 @@ class ProductsView(MasterView): if not self.has_perm('versions'): return text - history = tags.link_to("(view history)", '#', - id='view-{}-price-history'.format(typ)) + if self.get_use_buefy(): + kwargs = {'@click.prevent': 'showingPriceHistory_{} = true'.format(typ)} + else: + kwargs = {'id': 'view-{}-price-history'.format(typ)} + history = tags.link_to("(view history)", '#', **kwargs) if not text: return history @@ -500,11 +503,7 @@ class ProductsView(MasterView): date = localtime(self.rattail_config, history[0]['changed'], from_utc=True).date() text = "{} (as of {})".format(text, date) - if self.get_use_buefy(): - # TODO: should add history link here too... - return text - else: # not buefy - return self.add_price_history_link(text, 'regular') + return self.add_price_history_link(text, 'regular') def render_current_price(self, product, field): text = self.render_price(product, field) @@ -515,11 +514,7 @@ class ProductsView(MasterView): date = localtime(self.rattail_config, history[0]['changed'], from_utc=True).date() text = "{} (as of {})".format(text, date) - if self.get_use_buefy(): - # TODO: should add history link here too... - return text - else: # not buefy - return self.add_price_history_link(text, 'current') + return self.add_price_history_link(text, 'current') def warn_if_regprice_more_than_srp(self, product, text): sugprice = product.suggested_price.price if product.suggested_price else None @@ -538,12 +533,7 @@ class ProductsView(MasterView): text = "{} (as of {})".format(text, date) text = self.warn_if_regprice_more_than_srp(product, text) - - if self.get_use_buefy(): - # TODO: should add history link here too... - return text - else: # not buefy - return self.add_price_history_link(text, 'suggested') + return self.add_price_history_link(text, 'suggested') def render_grid_suggested_price(self, product, field): text = self.render_price(product, field) From 86617e410fe37686f6c38bda67f2fc1bb55d894e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 28 Feb 2020 18:11:54 -0600 Subject: [PATCH 0054/1681] Fix some basic product editing features mostly for sake of online demo --- tailbone/exceptions.py | 54 ++++++++++++++++++++++++++++++++++++++ tailbone/forms/core.py | 6 ++++- tailbone/views/products.py | 51 +++++++++++++++++++++++++---------- 3 files changed, 96 insertions(+), 15 deletions(-) create mode 100644 tailbone/exceptions.py diff --git a/tailbone/exceptions.py b/tailbone/exceptions.py new file mode 100644 index 00000000..beea1366 --- /dev/null +++ b/tailbone/exceptions.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2020 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Tailbone Exceptions +""" + +from __future__ import unicode_literals, absolute_import + +import six + +from rattail.exceptions import RattailError + + +class TailboneError(RattailError): + """ + Base class for all Tailbone exceptions. + """ + + +@six.python_2_unicode_compatible +class TailboneJSONFieldError(TailboneError): + """ + Error raised when JSON serialization of a form field results in an error. + This is just a simple wrapper, to make the error message more helpful for + the developer. + """ + + def __init__(self, field, error): + self.field = field + self.error = error + + def __str__(self): + return ("Failed to serialize field '{}' as JSON! " + "Original error was: {}".format(self.field, self.error)) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index a923346c..5cc41771 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -51,6 +51,7 @@ from webhelpers2.html import tags, HTML from tailbone.util import raw_datetime from . import types from .widgets import ReadonlyWidget, PlainDateWidget, JQueryDateWidget, JQueryTimeWidget +from tailbone.exceptions import TailboneJSONFieldError log = logging.getLogger(__name__) @@ -786,7 +787,10 @@ class Form(object): if field.cstruct is colander.null: return 'null' - return json.dumps(field.cstruct) + try: + return json.dumps(field.cstruct) + except Exception as error: + raise TailboneJSONFieldError(field.name, error) def messages_json(self, messages): dump = json.dumps(messages) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index e86eaaa0..bfe01fc1 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -786,11 +786,17 @@ class ProductsView(MasterView): if not require_report_code: report_code_values.insert(0, ('', "(none)")) f.set_widget('report_code_uuid', dfwidget.SelectWidget(values=report_code_values)) - f.set_label('report_code_uuid', "Report_Code") + f.set_label('report_code_uuid', "Report Code") else: f.set_readonly('report_code') # f.set_renderer('report_code', self.render_report_code) + # regular_price_amount + if self.editing: + f.set_node('regular_price_amount', colander.Decimal()) + f.set_default('regular_price_amount', product.regular_price.price if product.regular_price else None) + f.set_label('regular_price_amount', "Regular Price") + # deposit_link if self.creating or self.editing: if 'deposit_link' in f.fields: @@ -803,7 +809,7 @@ class ProductsView(MasterView): if not require_deposit_link: deposit_link_values.insert(0, ('', "(none)")) f.set_widget('deposit_link_uuid', dfwidget.SelectWidget(values=deposit_link_values)) - f.set_label('deposit_link_uuid', "Deposit_Link") + f.set_label('deposit_link_uuid', "Deposit Link") else: f.set_readonly('deposit_link') # f.set_renderer('deposit_link', self.render_deposit_link) @@ -831,18 +837,24 @@ class ProductsView(MasterView): f.set_readonly('tax3') # brand - if self.creating: - f.replace('brand', 'brand_uuid') - brand_display = "" - if self.request.method == 'POST': - if self.request.POST.get('brand_uuid'): - brand = self.Session.query(model.Brand).get(self.request.POST['brand_uuid']) - if brand: - brand_display = six.text_type(brand) - brands_url = self.request.route_url('brands.autocomplete') - f.set_widget('brand_uuid', forms.widgets.JQueryAutocompleteWidget( - field_display=brand_display, service_url=brands_url)) - f.set_label('brand_uuid', "Brand") + if self.creating or self.editing: + if 'brand' in f.fields: + f.replace('brand', 'brand_uuid') + f.set_node('brand_uuid', colander.String(), missing=colander.null) + brand_display = "" + if self.request.method == 'POST': + if self.request.POST.get('brand_uuid'): + brand = self.Session.query(model.Brand).get(self.request.POST['brand_uuid']) + if brand: + brand_display = six.text_type(brand) + elif self.editing: + brand_display = six.text_type(product.brand or '') + brands_url = self.request.route_url('brands.autocomplete') + f.set_widget('brand_uuid', forms.widgets.JQueryAutocompleteWidget( + field_display=brand_display, service_url=brands_url)) + f.set_label('brand_uuid', "Brand") + else: + f.set_readonly('brand') # status_code f.set_label('status_code', "Status") @@ -856,6 +868,17 @@ class ProductsView(MasterView): if not self.request.has_perm('products.view_deleted'): f.remove('deleted') + def objectify(self, form, data=None): + if data is None: + data = form.validated + product = super(ProductsView, self).objectify(form, data=data) + + # regular_price_amount + if (self.creating or self.editing) and 'regular_price_amount' in form.fields: + api.set_regular_price(product, data['regular_price_amount']) + + return product + def render_department(self, product, field): department = product.department if not department: From df00dd600af25c7b31bdf91b4cac218feaf1672a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 1 Mar 2020 12:24:41 -0600 Subject: [PATCH 0055/1681] 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 d1ef4ad6..098533ed 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,24 @@ CHANGELOG ========= +0.8.86 (2020-03-01) +------------------- + +* Add toggle complete, more normalized row fields for odering batch API. + +* Return employee_uuid along with user info, from API. + +* Add support for executing ordering batches via API. + +* Fix how we fetch employee history, for profile view. + +* Cleanup main version history views for Buefy theme. + +* Fix product price, cost history dialogs, for Buefy theme. + +* Fix some basic product editing features. + + 0.8.85 (2020-02-26) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 3cb047fe..977efbd9 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.85' +__version__ = '0.8.86' From 113c0af49dc405676afa637ea64ac7784d6ca30b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 1 Mar 2020 16:45:24 -0600 Subject: [PATCH 0056/1681] Add new "master" API view class; refactor products and batches to use it --- tailbone/api/__init__.py | 3 +- tailbone/api/batch/core.py | 22 +---- tailbone/api/batch/labels.py | 17 +--- tailbone/api/batch/ordering.py | 19 +---- tailbone/api/batch/receiving.py | 19 +---- tailbone/api/master.py | 8 +- tailbone/api/master2.py | 141 ++++++++++++++++++++++++++++++++ tailbone/api/products.py | 44 +++++----- 8 files changed, 183 insertions(+), 90 deletions(-) create mode 100644 tailbone/api/master2.py diff --git a/tailbone/api/__init__.py b/tailbone/api/__init__.py index 787165fe..0b669b6c 100644 --- a/tailbone/api/__init__.py +++ b/tailbone/api/__init__.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar +# Copyright © 2010-2020 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 +from .master2 import APIMasterView2 def includeme(config): diff --git a/tailbone/api/batch/core.py b/tailbone/api/batch/core.py index 1f725641..7f9232a9 100644 --- a/tailbone/api/batch/core.py +++ b/tailbone/api/batch/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -33,7 +33,7 @@ from rattail.util import load_object from cornice import resource -from tailbone.api import APIMasterView +from tailbone.api import APIMasterView2 as APIMasterView class APIBatchMixin(object): @@ -197,6 +197,7 @@ class APIBatchView(APIBatchMixin, APIMasterView): @classmethod def defaults(cls, config): + cls._defaults(config) cls._batch_defaults(config) @classmethod @@ -206,14 +207,6 @@ class APIBatchView(APIBatchMixin, APIMasterView): collection_url_prefix = cls.get_collection_url_prefix() object_url_prefix = cls.get_object_url_prefix() - # primary / typical API - resource.add_view(cls.collection_get, permission='{}.list'.format(permission_prefix)) - resource.add_view(cls.collection_post, permission='{}.create'.format(permission_prefix)) - resource.add_view(cls.get, permission='{}.view'.format(permission_prefix)) - batch_resource = resource.add_resource(cls, collection_path=collection_url_prefix, - path='{}/{{uuid}}'.format(object_url_prefix)) - config.add_cornice_resource(batch_resource) - if cls.supports_toggle_complete: # mark complete @@ -299,6 +292,7 @@ class APIBatchRowView(APIBatchMixin, APIMasterView): @classmethod def defaults(cls, config): + cls._defaults(config) cls._batch_row_defaults(config) @classmethod @@ -308,14 +302,6 @@ class APIBatchRowView(APIBatchMixin, APIMasterView): collection_url_prefix = cls.get_collection_url_prefix() object_url_prefix = cls.get_object_url_prefix() - resource.add_view(cls.collection_get, permission='{}.view'.format(permission_prefix)) - resource.add_view(cls.get, permission='{}.view'.format(permission_prefix)) - if cls.editable: - resource.add_view(cls.post, permission='{}.edit'.format(permission_prefix)) - rows_resource = resource.add_resource(cls, collection_path=collection_url_prefix, - path='{}/{{uuid}}'.format(object_url_prefix)) - config.add_cornice_resource(rows_resource) - if cls.supports_quick_entry: # quick entry diff --git a/tailbone/api/batch/labels.py b/tailbone/api/batch/labels.py index 02af03ba..0648a0c9 100644 --- a/tailbone/api/batch/labels.py +++ b/tailbone/api/batch/labels.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -43,15 +43,6 @@ class LabelBatchViews(APIBatchView): object_url_prefix = '/label-batch' supports_toggle_complete = True - def collection_get(self): - return self._collection_get() - - def collection_post(self): - return self._collection_post() - - def get(self): - return self._get() - class LabelBatchRowViews(APIBatchRowView): @@ -74,12 +65,6 @@ class LabelBatchRowViews(APIBatchRowView): data['full_description'] = row.product.full_description if row.product else row.description return data - def collection_get(self): - return self._collection_get() - - def get(self): - return self._get() - def includeme(config): LabelBatchViews.defaults(config) diff --git a/tailbone/api/batch/ordering.py b/tailbone/api/batch/ordering.py index de8fde0b..1b611381 100644 --- a/tailbone/api/batch/ordering.py +++ b/tailbone/api/batch/ordering.py @@ -84,15 +84,6 @@ class OrderingBatchViews(APIBatchView): batch = super(OrderingBatchViews, self).create_object(data) return batch - def collection_get(self): - return self._collection_get() - - def collection_post(self): - return self._collection_post() - - def get(self): - return self._get() - class OrderingBatchRowViews(APIBatchRowView): @@ -103,6 +94,7 @@ class OrderingBatchRowViews(APIBatchRowView): collection_url_prefix = '/ordering-batch-rows' object_url_prefix = '/ordering-batch-row' supports_quick_entry = True + editable = True def normalize(self, row): batch = row.batch @@ -138,15 +130,6 @@ class OrderingBatchRowViews(APIBatchRowView): return data - def collection_get(self): - return self._collection_get() - - def get(self): - return self._get() - - def post(self): - return self._post() - def update_object(self, row, data): """ Overrides the default logic as follows: diff --git a/tailbone/api/batch/receiving.py b/tailbone/api/batch/receiving.py index a5437eaf..a44540b8 100644 --- a/tailbone/api/batch/receiving.py +++ b/tailbone/api/batch/receiving.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -86,15 +86,6 @@ class ReceivingBatchViews(APIBatchView): batch = super(ReceivingBatchViews, self).create_object(data) return batch - def collection_get(self): - return self._collection_get() - - def collection_post(self): - return self._collection_post() - - def get(self): - return self._get() - def mark_receiving_complete(self): """ Mark the given batch as "receiving complete". @@ -148,6 +139,7 @@ class ReceivingBatchViews(APIBatchView): @classmethod def defaults(cls, config): + cls._defaults(config) cls._batch_defaults(config) cls._receiving_batch_defaults(config) @@ -340,12 +332,6 @@ class ReceivingBatchRowViews(APIBatchRowView): return data - def collection_get(self): - return self._collection_get() - - def get(self): - return self._get() - def receive(self): """ View which handles "receiving" against a particular batch row. @@ -375,6 +361,7 @@ class ReceivingBatchRowViews(APIBatchRowView): @classmethod def defaults(cls, config): + cls._defaults(config) cls._batch_row_defaults(config) cls._receiving_batch_row_defaults(config) diff --git a/tailbone/api/master.py b/tailbone/api/master.py index 78bc9262..114efdc0 100644 --- a/tailbone/api/master.py +++ b/tailbone/api/master.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -81,7 +81,7 @@ class APIMasterView(APIView): Returns a prefix which (by default) applies to all permissions leveraged by this view class. """ - prefix = getattr(cls, 'permission_prefix') + prefix = getattr(cls, 'permission_prefix', None) if prefix: return prefix return cls.get_route_prefix() @@ -371,6 +371,10 @@ class APIMasterView(APIView): ############################## def autocomplete(self): + """ + View which accepts a single ``term`` param, and returns a list of + autocomplete results to match. + """ term = self.request.params.get('term', '').strip() term = self.prepare_autocomplete_term(term) if not term: diff --git a/tailbone/api/master2.py b/tailbone/api/master2.py new file mode 100644 index 00000000..a062343f --- /dev/null +++ b/tailbone/api/master2.py @@ -0,0 +1,141 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2020 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Tailbone Web API - Master View (v2) +""" + +from __future__ import unicode_literals, absolute_import + +from cornice import resource, Service + +from tailbone.api import APIMasterView + + +class APIMasterView2(APIMasterView): + """ + Base class for data model REST API views. + """ + listable = True + creatable = True + viewable = True + editable = True + deletable = True + supports_autocomplete = 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() + + @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') + resource.add_view(cls.collection_post, permission='{}.create'.format(permission_prefix)) + + # 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) + config.add_cornice_service(autocomplete) diff --git a/tailbone/api/products.py b/tailbone/api/products.py index 0d7415b1..d7aeabcd 100644 --- a/tailbone/api/products.py +++ b/tailbone/api/products.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -27,18 +27,22 @@ Tailbone Web API - Product Views from __future__ import unicode_literals, absolute_import import six +import sqlalchemy as sa +from sqlalchemy import orm from rattail.db import model -from cornice.resource import resource, view - -from tailbone.api import APIMasterView +from tailbone.api import APIMasterView2 as APIMasterView -@resource(collection_path='/products', path='/product/{uuid}') class ProductView(APIMasterView): - + """ + API views for Product data + """ model_class = model.Product + collection_url_prefix = '/products' + object_url_prefix = '/product' + supports_autocomplete = True def normalize(self, product): cost = product.cost @@ -55,22 +59,24 @@ class ProductView(APIMasterView): 'default_unit_cost_display': "${:0.2f}".format(cost.unit_cost) if cost and cost.unit_cost is not None else None, } - @view(permission='products.list') - def collection_get(self): - return self._collection_get() + def make_autocomplete_query(self, term): + query = self.Session.query(model.Product)\ + .outerjoin(model.Brand)\ + .filter(sa.or_( + model.Brand.name.ilike('%{}%'.format(term)), + model.Product.description.ilike('%{}%'.format(term)))) - @view(permission='products.create') - def collection_post(self): - return self._collection_post() + if not self.request.has_perm('products.view_deleted'): + query = query.filter(model.Product.deleted == False) - @view(permission='products.view') - def get(self): - return self._get() + query = query.order_by(model.Brand.name, + model.Product.description)\ + .options(orm.joinedload(model.Product.brand)) + return query - @view(permission='products.edit') - def post(self): - return self._post() + def autocomplete_display(self, product): + return product.full_description def includeme(config): - config.scan(__name__) + ProductView.defaults(config) From c55830e5338c16a2e6cff8b1fe3057fe592f8960 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 1 Mar 2020 17:17:54 -0600 Subject: [PATCH 0057/1681] Refactor all API views thus far, to use new v2 master --- tailbone/api/customers.py | 31 ++++++++----------------------- tailbone/api/upgrades.py | 21 ++++++--------------- tailbone/api/users.py | 23 ++++++++--------------- tailbone/api/vendors.py | 33 ++++++--------------------------- 4 files changed, 28 insertions(+), 80 deletions(-) diff --git a/tailbone/api/customers.py b/tailbone/api/customers.py index ddca770c..2e0a9d4c 100644 --- a/tailbone/api/customers.py +++ b/tailbone/api/customers.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -30,15 +30,16 @@ import six from rattail.db import model -from cornice.resource import resource, view - -from tailbone.api import APIMasterView +from tailbone.api import APIMasterView2 as APIMasterView -@resource(collection_path='/customers', path='/customer/{uuid}') class CustomerView(APIMasterView): - + """ + API views for Customer data + """ model_class = model.Customer + collection_url_prefix = '/customers' + object_url_prefix = '/customer' def normalize(self, customer): return { @@ -48,22 +49,6 @@ class CustomerView(APIMasterView): 'name': customer.name, } - @view(permission='customers.list') - def collection_get(self): - return self._collection_get() - - @view(permission='customers.create') - def collection_post(self): - return self._collection_post() - - @view(permission='customers.view') - def get(self): - return self._get() - - @view(permission='customers.edit') - def post(self): - return self._post() - def includeme(config): - config.scan(__name__) + CustomerView.defaults(config) diff --git a/tailbone/api/upgrades.py b/tailbone/api/upgrades.py index 620ed4f8..85e4a91e 100644 --- a/tailbone/api/upgrades.py +++ b/tailbone/api/upgrades.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -30,17 +30,16 @@ import six from rattail.db import model -from cornice.resource import resource, view - -from tailbone.api import APIMasterView +from tailbone.api import APIMasterView2 as APIMasterView -@resource(collection_path='/upgrades', path='/upgrades/{uuid}') -class UpgradeAPIView(APIMasterView): +class UpgradeView(APIMasterView): """ REST API views for Upgrade model. """ model_class = model.Upgrade + collection_url_prefix = '/upgrades' + object_url_prefix = '/upgrades' def normalize(self, upgrade): data = { @@ -57,14 +56,6 @@ class UpgradeAPIView(APIMasterView): six.text_type(upgrade.status_code)) return data - @view(permission='upgrades.list') - def collection_get(self): - return self._collection_get() - - @view(permission='upgrades.view') - def get(self): - return self._get() - def includeme(config): - config.scan(__name__) + UpgradeView.defaults(config) diff --git a/tailbone/api/users.py b/tailbone/api/users.py index a21d3a2c..8474fd97 100644 --- a/tailbone/api/users.py +++ b/tailbone/api/users.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -30,15 +30,16 @@ import six from rattail.db import model -from cornice.resource import resource, view - -from tailbone.api import APIMasterView +from tailbone.api import APIMasterView2 as APIMasterView -@resource(collection_path='/users', path='/users/{uuid}') class UserView(APIMasterView): - + """ + API views for User data + """ model_class = model.User + collection_url_prefix = '/users' + object_url_prefix = '/user' def normalize(self, user): return { @@ -58,14 +59,6 @@ class UserView(APIMasterView): query = query.outerjoin(model.Person) return query - @view(permission='users.list') - def collection_get(self): - return self._collection_get() - - @view(permission='users.view') - def get(self): - return self._get() - def includeme(config): - config.scan(__name__) + UserView.defaults(config) diff --git a/tailbone/api/vendors.py b/tailbone/api/vendors.py index 533d7094..ce885e07 100644 --- a/tailbone/api/vendors.py +++ b/tailbone/api/vendors.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -30,16 +30,15 @@ import six from rattail.db import model -from cornice import Service -from cornice.resource import resource, view - -from tailbone.api import APIMasterView +from tailbone.api import APIMasterView2 as APIMasterView -@resource(collection_path='/vendors', path='/vendor/{uuid}') class VendorView(APIMasterView): model_class = model.Vendor + collection_url_prefix = '/vendors' + object_url_prefix = '/vendor' + supports_autocomplete = True autocomplete_fieldname = 'name' def normalize(self, vendor): @@ -50,26 +49,6 @@ class VendorView(APIMasterView): 'name': vendor.name, } - @view(permission='vendors.list') - def collection_get(self): - return self._collection_get() - - @view(permission='vendors.create') - def collection_post(self): - return self._collection_post() - - @view(permission='vendors.view') - def get(self): - return self._get() - - @view(permission='vendors.edit') - def post(self): - return self._post() - def includeme(config): - config.scan(__name__) - - autocomplete = Service(name='vendors.autocomplete', path='/vendors/autocomplete') - autocomplete.add_view('GET', 'autocomplete', klass=VendorView) - config.add_cornice_service(autocomplete) + VendorView.defaults(config) From 0e46b25f6e96dc15cf2dd4d0c7bc8795cfc09d2a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 1 Mar 2020 17:18:11 -0600 Subject: [PATCH 0058/1681] Use Cornice when registering all "service" API views pretty sure we'll get *something* for "free" if we do it their way --- tailbone/api/auth.py | 32 ++++++++++++++++++++------------ tailbone/api/common.py | 14 +++++++++----- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/tailbone/api/auth.py b/tailbone/api/auth.py index 63a47ed8..16e48e82 100644 --- a/tailbone/api/auth.py +++ b/tailbone/api/auth.py @@ -28,6 +28,8 @@ from __future__ import unicode_literals, absolute_import from rattail.db.auth import authenticate_user, set_user_password, cache_permissions +from cornice import Service + from tailbone.api import APIView, api from tailbone.db import Session from tailbone.auth import login_user, logout_user @@ -172,28 +174,34 @@ class AuthenticationView(APIView): def _auth_defaults(cls, config): # session - config.add_route('api.session', '/session', request_method='GET') - config.add_view(cls, attr='check_session', route_name='api.session', renderer='json') + check_session = Service(name='check_session', path='/session') + check_session.add_view('GET', 'check_session', klass=cls) + config.add_cornice_service(check_session) # login - config.add_route('api.login', '/login', request_method=('OPTIONS', 'POST')) - config.add_view(cls, attr='login', route_name='api.login', renderer='json') + login = Service(name='login', path='/login') + login.add_view('POST', 'login', klass=cls) + config.add_cornice_service(login) # logout - config.add_route('api.logout', '/logout', request_method=('OPTIONS', 'POST')) - config.add_view(cls, attr='logout', route_name='api.logout', renderer='json') + logout = Service(name='logout', path='/logout') + logout.add_view('POST', 'logout', klass=cls) + config.add_cornice_service(logout) # become root - config.add_route('api.become_root', '/become-root', request_method=('OPTIONS', 'POST')) - config.add_view(cls, attr='become_root', route_name='api.become_root', renderer='json') + become_root = Service(name='become_root', path='/become-root') + become_root.add_view('POST', 'become_root', klass=cls) + config.add_cornice_service(become_root) # stop root - config.add_route('api.stop_root', '/stop-root', request_method=('OPTIONS', 'POST')) - config.add_view(cls, attr='stop_root', route_name='api.stop_root', renderer='json') + stop_root = Service(name='stop_root', path='/stop-root') + stop_root.add_view('POST', 'stop_root', klass=cls) + config.add_cornice_service(stop_root) # change password - config.add_route('api.change_password', '/change-password', request_method=('OPTIONS', 'POST')) - config.add_view(cls, attr='change_password', route_name='api.change_password', renderer='json') + change_password = Service(name='change_password', path='/change-password') + change_password.add_view('POST', 'change_password', klass=cls) + config.add_cornice_service(change_password) def includeme(config): diff --git a/tailbone/api/common.py b/tailbone/api/common.py index 7af46bf3..0b752adf 100644 --- a/tailbone/api/common.py +++ b/tailbone/api/common.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -31,6 +31,8 @@ from rattail.db import model from rattail.mail import send_email from rattail.util import OrderedDict +from cornice import Service + import tailbone from tailbone import forms from tailbone.forms.common import Feedback @@ -111,12 +113,14 @@ class CommonView(APIView): def defaults(cls, config): # about - config.add_route('api.about', '/about', request_method='GET') - config.add_view(cls, attr='about', route_name='api.about', renderer='json') + about = Service(name='about', path='/about') + about.add_view('GET', 'about', klass=cls) + config.add_cornice_service(about) # feedback - config.add_route('api.feedback', '/feedback', request_method=('OPTIONS', 'POST')) - config.add_view(cls, attr='feedback', route_name='api.feedback', renderer='json') + feedback = Service(name='feedback', path='/feedback') + feedback.add_view('POST', 'feedback', klass=cls) + config.add_cornice_service(feedback) def includeme(config): From 2100f0461d8b8f82fe24ea82b632b9de4351ec3a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 2 Mar 2020 11:53:15 -0600 Subject: [PATCH 0059/1681] 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 098533ed..b7b90b9c 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.8.87 (2020-03-02) +------------------- + +* Add new "master" API view class; refactor products and batches to use it. + +* Refactor all API views thus far, to use new v2 master. + +* Use Cornice when registering all "service" API views. + + 0.8.86 (2020-03-01) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 977efbd9..0108cb65 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.86' +__version__ = '0.8.87' From 2605f5ab793ab0c10bee490ae6deb2910582ddd9 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 2 Mar 2020 14:38:06 -0600 Subject: [PATCH 0060/1681] Fix batch row status breakdown for Buefy themes also, fix the "import batch from file" feature UI, per Buefy theme --- tailbone/grids/core.py | 5 ++++- tailbone/templates/batch/view.mako | 10 +++++++++- tailbone/templates/grids/b-table.mako | 13 +++++++++++-- tailbone/views/batch/core.py | 7 +++++++ tailbone/views/master.py | 3 ++- 5 files changed, 33 insertions(+), 5 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index ba988689..2593ed4c 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -966,7 +966,9 @@ class Grid(object): return self.render_complete(template=template, **kwargs) - def render_buefy_table_element(self, template='/grids/b-table.mako', data_prop='gridData', **kwargs): + def render_buefy_table_element(self, template='/grids/b-table.mako', + data_prop='gridData', empty_labels=False, + **kwargs): """ This is intended for ad-hoc "small" grids with static data. Renders just a ``<b-table>`` element instead of the typical "full" grid. @@ -974,6 +976,7 @@ class Grid(object): context = dict(kwargs) context['grid'] = self context['data_prop'] = data_prop + context['empty_labels'] = empty_labels if 'grid_columns' not in context: context['grid_columns'] = self.get_buefy_columns() diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako index aacc756c..1f1fd4f6 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -108,7 +108,9 @@ <div class="object-helper"> <h3>Row Status Breakdown</h3> <div class="object-helper-content"> - % if status_breakdown: + % if use_buefy: + ${status_breakdown_grid.render_buefy_table_element(data_prop='statusBreakdownData', empty_labels=True)|n} + % elif status_breakdown: <div class="grid full"> <table> % for i, (status, count) in enumerate(status_breakdown): @@ -197,6 +199,12 @@ <%def name="modify_this_page_vars()"> ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + ThisPageData.statusBreakdownData = ${json.dumps(status_breakdown_grid.get_buefy_data()['data'])|n} + + </script> + % if not batch.executed and request.has_perm('{}.execute'.format(permission_prefix)): <script type="text/javascript"> diff --git a/tailbone/templates/grids/b-table.mako b/tailbone/templates/grids/b-table.mako index b7b9124f..42e82273 100644 --- a/tailbone/templates/grids/b-table.mako +++ b/tailbone/templates/grids/b-table.mako @@ -14,8 +14,17 @@ > <template slot-scope="props"> - % for column in grid_columns: - <b-table-column field="${column['field']}" label="${column['label']}" ${'sortable' if column['sortable'] else ''}> + % for i, column in enumerate(grid_columns): + <b-table-column field="${column['field']}" + % if not empty_labels: + label="${column['label']}" + % elif i > 0: + label=" " + % endif + ${'sortable' if column['sortable'] else ''}> + % if empty_labels and i == 0: + <template slot="header" slot-scope="{ column }"></template> + % endif % if grid.is_linked(column['field']): <a :href="props.row._action_url_view" v-html="props.row.${column['field']}" diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 276bb167..112574c3 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -147,6 +147,7 @@ class BatchMasterView(MasterView): return self.rattail_config.batch_filepath(batch.batch_key, batch.uuid, filename) def template_kwargs_view(self, **kwargs): + use_buefy = self.get_use_buefy() batch = kwargs['instance'] kwargs['batch'] = batch kwargs['handler'] = self.handler @@ -164,6 +165,12 @@ class BatchMasterView(MasterView): else: kwargs['why_not_execute'] = self.handler.why_not_execute(batch) kwargs['status_breakdown'] = self.make_status_breakdown(batch) + if use_buefy: + data = [{'title': title, 'count': count} + for title, count in kwargs['status_breakdown']] + Grid = self.get_grid_factory() + kwargs['status_breakdown_grid'] = Grid('batch_row_status_breakdown', + data, ['title', 'count']) return kwargs def make_status_breakdown(self, batch, rows=None, status_enum=None): diff --git a/tailbone/views/master.py b/tailbone/views/master.py index ab8bf658..e30598bc 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -818,10 +818,11 @@ class MasterView(View): delete=False, schema=None, importer_host_title=None): handler = handler_factory(self.rattail_config) + use_buefy = self.get_use_buefy() if not schema: schema = forms.SimpleFileImport().bind(request=self.request) - form = forms.Form(schema=schema, request=self.request) + form = forms.Form(schema=schema, request=self.request, use_buefy=use_buefy) form.save_label = "Upload" form.cancel_url = self.get_index_url() if form.validate(newstyle=True): From 0483f47b26b9f0e810429b3b9ce9c56e5541dabd Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 2 Mar 2020 18:11:13 -0600 Subject: [PATCH 0061/1681] Add support for refreshing multiple batches (results) at once --- tailbone/templates/batch/index.mako | 48 ++++++++++++++++++--- tailbone/views/batch/core.py | 66 +++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 6 deletions(-) diff --git a/tailbone/templates/batch/index.mako b/tailbone/templates/batch/index.mako index 5e6d0318..21e3d7aa 100644 --- a/tailbone/templates/batch/index.mako +++ b/tailbone/templates/batch/index.mako @@ -4,7 +4,7 @@ <%def name="extra_javascript()"> ${parent.extra_javascript()} % if not use_buefy: - % if master.results_executable and request.has_perm('{}.execute_multiple'.format(permission_prefix)): + % if master.results_executable and master.has_perm('execute_multiple'): <script type="text/javascript"> var has_execution_options = ${'true' if master.has_execution_options(batch) else 'false'}; @@ -12,6 +12,17 @@ $(function() { + $('#refresh-results-button').click(function() { + var count = $('.grid-wrapper').gridwrapper('results_count'); + if (!count) { + alert("There are no batch results to refresh."); + return; + } + var form = $('form[name="refresh-results"]'); + $(this).button('option', 'label', "Refreshing, please wait...").button('disable'); + form.submit(); + }); + $('#execute-results-button').click(function() { var count = $('.grid-wrapper').gridwrapper('results_count'); if (!count) { @@ -65,7 +76,24 @@ <%def name="grid_tools()"> ${parent.grid_tools()} - % if master.results_executable and request.has_perm('{}.execute_multiple'.format(permission_prefix)): + + ## Refresh Results + % if master.results_refreshable and master.has_perm('refresh'): + % if use_buefy: + <b-button type="is-primary" + disabled + title="TODO: need to implement this for new theme"> + Refresh Results + </b-button> + % else: + <button type="button" id="refresh-results-button"> + Refresh Results + </button> + % endif + % endif + + ## Execute Results + % if master.results_executable and master.has_perm('execute_multiple'): % if use_buefy: <b-button type="is-primary" @click="executeResults()" @@ -110,7 +138,7 @@ <%def name="modify_this_page_vars()"> ${parent.modify_this_page_vars()} - % if master.results_executable and request.has_perm('{}.execute_multiple'.format(permission_prefix)): + % if master.results_executable and master.has_perm('execute_multiple'): <script type="text/javascript"> TailboneForm.methods.submit = function() { @@ -150,7 +178,7 @@ <%def name="make_this_page_component()"> ${parent.make_this_page_component()} - % if master.results_executable and request.has_perm('{}.execute_multiple'.format(permission_prefix)): + % if master.results_executable and master.has_perm('execute_multiple'): <script type="text/javascript"> TailboneForm.data = function() { return TailboneFormData } @@ -163,7 +191,7 @@ <%def name="render_this_page_template()"> ${parent.render_this_page_template()} - % if master.results_executable and request.has_perm('{}.execute_multiple'.format(permission_prefix)): + % if master.results_executable and master.has_perm('execute_multiple'): ${execute_form.render_deform(form_kwargs={'ref': 'actualForm'}, buttons=False)|n} % endif </%def> @@ -172,7 +200,15 @@ ${parent.body()} % if not use_buefy: -% if master.results_executable and request.has_perm('{}.execute_multiple'.format(permission_prefix)): + +## Refresh Results +% if master.results_refreshable and master.has_perm('refresh'): + ${h.form(url('{}.refresh_results'.format(route_prefix)), name='refresh-results')} + ${h.csrf_token(request)} + ${h.end_form()} +% endif + +% if master.results_executable and master.has_perm('execute_multiple'): <div id="execution-options-dialog" style="display: none;"> <br /> <p> diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 112574c3..7afc4dbd 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -79,6 +79,7 @@ class BatchMasterView(MasterView): refresh_after_create = False cloneable = False executable = True + results_refreshable = False results_executable = False supports_mobile = True mobile_filterable = True @@ -1138,6 +1139,64 @@ class BatchMasterView(MasterView): progress.session['success_url'] = self.get_action_url('view', batch) progress.session.save() + def refresh_results(self): + """ + Refresh all batches which are returned from the current index query. + Starts a separate thread for the refresh, and displays a progress + indicator page. + """ + key = '{}.refresh_results'.format(self.get_route_prefix()) + batches = self.get_effective_data() + progress = self.make_progress(key) + kwargs = {'progress': progress} + thread = Thread(target=self.refresh_results_thread, + args=(batches, self.request.user.uuid), + kwargs=kwargs) + thread.start() + + return self.render_progress(progress, { + 'cancel_url': self.get_index_url(), + 'cancel_msg': "Batch execution was canceled", + }) + + def refresh_results_thread(self, batches, user_uuid, progress=None): + """ + Thread target for refreshing multiple batches with progress indicator. + """ + session = RattailSession() + batches = batches.with_session(session).all() + user = session.query(model.User).get(user_uuid) + try: + self.handler.refresh_many(batches, user=user, progress=progress) + + except Exception as error: + session.rollback() + log.exception("refresh failed for batch(es)!") + session.close() + if progress: + progress.session.load() + progress.session['error'] = True + progress.session['error_msg'] = self.refresh_error_message(error) + progress.session.save() + + else: + session.commit() + self.request.session.flash("{} {} were refreshed".format( + len(batches), self.get_model_title_plural())) + success_url = self.get_refresh_results_success_url() + session.close() + if progress: + progress.session.load() + progress.session['complete'] = True + progress.session['success_url'] = success_url + progress.session.save() + + def refresh_error_message(self, error): + return "Batch refresh failed: {}".format(simple_error(error)) + + def get_refresh_results_success_url(self): + return self.get_index_url() + ######################################## # batch rows ######################################## @@ -1478,6 +1537,13 @@ class BatchMasterView(MasterView): config.add_view(cls, attr='mobile_mark_pending', route_name='mobile.{}.mark_pending'.format(route_prefix), permission='{}.edit'.format(permission_prefix)) + # refresh multiple batches (results) + if cls.results_refreshable: + config.add_route('{}.refresh_results'.format(route_prefix), '{}/refresh-results'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='refresh_results', route_name='{}.refresh_results'.format(route_prefix), + permission='{}.refresh'.format(permission_prefix)) + # execute (multiple) batch results if cls.results_executable: config.add_route('{}.execute_results'.format(route_prefix), '{}/execute-results'.format(url_prefix), From 11cc9a752a384ba27b94a48d23b7333098cb4f2c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 3 Mar 2020 17:10:41 -0600 Subject: [PATCH 0062/1681] Remove "api." prefix for default route names, in API master views --- tailbone/api/master.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/api/master.py b/tailbone/api/master.py index 114efdc0..f215bee1 100644 --- a/tailbone/api/master.py +++ b/tailbone/api/master.py @@ -73,7 +73,7 @@ class APIMasterView(APIView): if prefix: return prefix model_name = cls.get_normalized_model_name() - return 'api.{}s'.format(model_name) + return '{}s'.format(model_name) @classmethod def get_permission_prefix(cls): From 0f5999c8d887c4e081c35aafd5f500d696cc8f1f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 4 Mar 2020 12:59:11 -0600 Subject: [PATCH 0063/1681] Allow "touch" for vendor records --- tailbone/views/vendors/core.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/vendors/core.py b/tailbone/views/vendors/core.py index f555ba5e..7f9c064e 100644 --- a/tailbone/views/vendors/core.py +++ b/tailbone/views/vendors/core.py @@ -41,6 +41,7 @@ class VendorsView(MasterView): """ model_class = model.Vendor has_versions = True + touchable = True labels = { 'id': "ID", From cd0703ba12d07bc40cd9e1ffe632827462ae9958 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 5 Mar 2020 13:03:59 -0600 Subject: [PATCH 0064/1681] 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 b7b90b9c..1619c5f3 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,18 @@ CHANGELOG ========= +0.8.88 (2020-03-05) +------------------- + +* Fix batch row status breakdown for Buefy themes. + +* Add support for refreshing multiple batches (results) at once. + +* Remove "api." prefix for default route names, in API master views. + +* Allow "touch" for vendor records. + + 0.8.87 (2020-03-02) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 0108cb65..3ff5d100 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.87' +__version__ = '0.8.88' From 1db6d642e7c31a99ffb519456382eb51dcbcca74 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 6 Mar 2020 14:01:10 -0600 Subject: [PATCH 0065/1681] Refactor "view profile" page per latest Buefy theme conventions --- .../templates/people/view_profile_buefy.mako | 654 +++++++++--------- 1 file changed, 338 insertions(+), 316 deletions(-) diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako index 6c3bf351..ecb8d60a 100644 --- a/tailbone/templates/people/view_profile_buefy.mako +++ b/tailbone/templates/people/view_profile_buefy.mako @@ -1,287 +1,100 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -<div id="profile-info-app"> - <b-tabs v-model="activeTab" type="is-boxed"> +<%def name="page_content()"> + <profile-info></profile-info> +</%def> - <b-tab-item label="Personal" icon="check" icon-pack="fas"> - <div style="display: flex; justify-content: space-between;"> +<%def name="render_this_page()"> + ${self.page_content()} +</%def> - <div> +<%def name="render_this_page_template()"> + ${parent.render_this_page_template()} - <div class="field-wrapper first_name"> - <div class="field-row"> - <label>First Name</label> - <div class="field"> - ${person.first_name} - </div> - </div> - </div> + <script type="text/x-template" id="profile-info-template"> + <div> + <b-tabs v-model="activeTab" type="is-boxed"> - <div class="field-wrapper middle_name"> - <div class="field-row"> - <label>Middle Name</label> - <div class="field"> - ${person.middle_name} - </div> - </div> - </div> - - <div class="field-wrapper last_name"> - <div class="field-row"> - <label>Last Name</label> - <div class="field"> - ${person.last_name} - </div> - </div> - </div> - - <div class="field-wrapper street"> - <div class="field-row"> - <label>Street 1</label> - <div class="field"> - ${person.address.street if person.address else ''} - </div> - </div> - </div> - - <div class="field-wrapper street2"> - <div class="field-row"> - <label>Street 2</label> - <div class="field"> - ${person.address.street2 if person.address else ''} - </div> - </div> - </div> - - <div class="field-wrapper city"> - <div class="field-row"> - <label>City</label> - <div class="field"> - ${person.address.city if person.address else ''} - </div> - </div> - </div> - - <div class="field-wrapper state"> - <div class="field-row"> - <label>State</label> - <div class="field"> - ${person.address.state if person.address else ''} - </div> - </div> - </div> - - <div class="field-wrapper zipcode"> - <div class="field-row"> - <label>Zipcode</label> - <div class="field"> - ${person.address.zipcode if person.address else ''} - </div> - </div> - </div> - - % if person.phones: - % for phone in person.phones: - <div class="field-wrapper"> - <div class="field-row"> - <label>Phone Number</label> - <div class="field"> - ${phone.number} (type: ${phone.type}) - </div> - </div> - </div> - % endfor - % else: - <div class="field-wrapper"> - <div class="field-row"> - <label>Phone Number</label> - <div class="field"> - (none on file) - </div> - </div> - </div> - % endif - - % if person.emails: - % for email in person.emails: - <div class="field-wrapper"> - <div class="field-row"> - <label>Email Address</label> - <div class="field"> - ${email.address} (type: ${email.type}) - </div> - </div> - </div> - % endfor - % else: - <div class="field-wrapper"> - <div class="field-row"> - <label>Email Address</label> - <div class="field"> - (none on file) - </div> - </div> - </div> - % endif - - </div> - - <div> - % if request.has_perm('people.view'): - ${h.link_to("View Person", url('people.view', uuid=person.uuid), class_='button')} - % endif - </div> - - </div> - </b-tab-item><!-- Personal --> - - <b-tab-item label="Customer" ${'icon="check" icon-pack="fas"' if person.customers else ''|n}> - % if person.customers: - <p>${person} is associated with <strong>${len(person.customers)}</strong> customer account(s)</p> - <br /> - <div id="customers-accordion"> - % for customer in person.customers: - - <b-collapse class="panel" - ## TODO: what's up with aria-id here? - ## aria-id="contentIdForA11y2" - > - - <div - slot="trigger" - class="panel-heading" - role="button" - ## TODO: what's up with aria-id here? - ## aria-controls="contentIdForA11y2" - > - <strong>${customer.id} - ${customer.name}</strong> - </div> - - <div class="panel-block"> - - <div style="display: flex; justify-content: space-between; width: 100%;"> - - <div> - - <div class="field-wrapper id"> - <div class="field-row"> - <label>ID</label> - <div class="field"> - ${customer.id or ''} - </div> - </div> - </div> - - <div class="field-wrapper name"> - <div class="field-row"> - <label>Name</label> - <div class="field"> - ${customer.name} - </div> - </div> - </div> - - % if customer.phones: - % for phone in customer.phones: - <div class="field-wrapper"> - <div class="field-row"> - <label>Phone Number</label> - <div class="field"> - ${phone.number} (type: ${phone.type}) - </div> - </div> - </div> - % endfor - % else: - <div class="field-wrapper"> - <div class="field-row"> - <label>Phone Number</label> - <div class="field"> - (none on file) - </div> - </div> - </div> - % endif - - % if customer.emails: - % for email in customer.emails: - <div class="field-wrapper"> - <div class="field-row"> - <label>Email Address</label> - <div class="field"> - ${email.address} (type: ${email.type}) - </div> - </div> - </div> - % endfor - % else: - <div class="field-wrapper"> - <div class="field-row"> - <label>Email Address</label> - <div class="field"> - (none on file) - </div> - </div> - </div> - % endif - - </div> - - <div> - % if request.has_perm('customers.view'): - ${h.link_to("View Customer", url('customers.view', uuid=customer.uuid), class_='button')} - % endif - </div> - - </div> - - </div> - </b-collapse> - % endfor - </div> - - % else: - <p>${person} has never been a customer.</p> - % endif - </b-tab-item><!-- Customer --> - - <b-tab-item label="Employee" ${'icon="check" icon-pack="fas"' if employee else ''|n}> - - % if employee: + <b-tab-item label="Personal" icon="check" icon-pack="fas"> <div style="display: flex; justify-content: space-between;"> <div> - <div class="field-wrapper id"> + <div class="field-wrapper first_name"> <div class="field-row"> - <label>ID</label> + <label>First Name</label> <div class="field"> - ${employee.id or ''} + ${person.first_name} </div> </div> </div> - <div class="field-wrapper display_name"> + <div class="field-wrapper middle_name"> <div class="field-row"> - <label>Display Name</label> + <label>Middle Name</label> <div class="field"> - ${employee.display_name or ''} + ${person.middle_name} </div> </div> </div> - <div class="field-wrapper status"> + <div class="field-wrapper last_name"> <div class="field-row"> - <label>Status</label> + <label>Last Name</label> <div class="field"> - ${enum.EMPLOYEE_STATUS.get(employee.status, '')} + ${person.last_name} </div> </div> </div> - % if employee.phones: - % for phone in employee.phones: + <div class="field-wrapper street"> + <div class="field-row"> + <label>Street 1</label> + <div class="field"> + ${person.address.street if person.address else ''} + </div> + </div> + </div> + + <div class="field-wrapper street2"> + <div class="field-row"> + <label>Street 2</label> + <div class="field"> + ${person.address.street2 if person.address else ''} + </div> + </div> + </div> + + <div class="field-wrapper city"> + <div class="field-row"> + <label>City</label> + <div class="field"> + ${person.address.city if person.address else ''} + </div> + </div> + </div> + + <div class="field-wrapper state"> + <div class="field-row"> + <label>State</label> + <div class="field"> + ${person.address.state if person.address else ''} + </div> + </div> + </div> + + <div class="field-wrapper zipcode"> + <div class="field-row"> + <label>Zipcode</label> + <div class="field"> + ${person.address.zipcode if person.address else ''} + </div> + </div> + </div> + + % if person.phones: + % for phone in person.phones: <div class="field-wrapper"> <div class="field-row"> <label>Phone Number</label> @@ -302,8 +115,8 @@ </div> % endif - % if employee.emails: - % for email in employee.emails: + % if person.emails: + % for email in person.emails: <div class="field-wrapper"> <div class="field-row"> <label>Email Address</label> @@ -327,87 +140,296 @@ </div> <div> - % if request.has_perm('employees.view'): - ${h.link_to("View Employee", url('employees.view', uuid=employee.uuid), class_='button')} + % if request.has_perm('people.view'): + ${h.link_to("View Person", url('people.view', uuid=person.uuid), class_='button')} % endif </div> </div> + </b-tab-item><!-- Personal --> - % else: - <p>${person} has never been an employee.</p> - % endif - </b-tab-item><!-- Employee --> + <b-tab-item label="Customer" ${'icon="check" icon-pack="fas"' if person.customers else ''|n}> + % if person.customers: + <p>${person} is associated with <strong>${len(person.customers)}</strong> customer account(s)</p> + <br /> + <div id="customers-accordion"> + % for customer in person.customers: - <b-tab-item label="User" ${'icon="check" icon-pack="fas"' if person.users else ''|n}> - % if person.users: - <p>${person} is associated with <strong>${len(person.users)}</strong> user account(s)</p> - <br /> - <div id="users-accordion"> - % for user in person.users: + <b-collapse class="panel" + ## TODO: what's up with aria-id here? + ## aria-id="contentIdForA11y2" + > - <b-collapse class="panel" - ## TODO: what's up with aria-id here? - ## aria-id="contentIdForA11y2" - > + <div + slot="trigger" + class="panel-heading" + role="button" + ## TODO: what's up with aria-id here? + ## aria-controls="contentIdForA11y2" + > + <strong>${customer.id} - ${customer.name}</strong> + </div> - <div - slot="trigger" - class="panel-heading" - role="button" - ## TODO: what's up with aria-id here? - ## aria-controls="contentIdForA11y2" - > - <strong>${user.username}</strong> - </div> + <div class="panel-block"> - <div class="panel-block"> + <div style="display: flex; justify-content: space-between; width: 100%;"> - <div style="display: flex; justify-content: space-between; width: 100%;"> + <div> - <div> - - <div class="field-wrapper id"> - <div class="field-row"> - <label>Username</label> - <div class="field"> - ${user.username} + <div class="field-wrapper id"> + <div class="field-row"> + <label>ID</label> + <div class="field"> + ${customer.id or ''} + </div> + </div> </div> + + <div class="field-wrapper name"> + <div class="field-row"> + <label>Name</label> + <div class="field"> + ${customer.name} + </div> + </div> + </div> + + % if customer.phones: + % for phone in customer.phones: + <div class="field-wrapper"> + <div class="field-row"> + <label>Phone Number</label> + <div class="field"> + ${phone.number} (type: ${phone.type}) + </div> + </div> + </div> + % endfor + % else: + <div class="field-wrapper"> + <div class="field-row"> + <label>Phone Number</label> + <div class="field"> + (none on file) + </div> + </div> + </div> + % endif + + % if customer.emails: + % for email in customer.emails: + <div class="field-wrapper"> + <div class="field-row"> + <label>Email Address</label> + <div class="field"> + ${email.address} (type: ${email.type}) + </div> + </div> + </div> + % endfor + % else: + <div class="field-wrapper"> + <div class="field-row"> + <label>Email Address</label> + <div class="field"> + (none on file) + </div> + </div> + </div> + % endif + </div> + + <div> + % if request.has_perm('customers.view'): + ${h.link_to("View Customer", url('customers.view', uuid=customer.uuid), class_='button')} + % endif + </div> + </div> </div> + </b-collapse> + % endfor + </div> - <div> - % if request.has_perm('users.view'): - ${h.link_to("View User", url('users.view', uuid=user.uuid), class_='button')} - % endif + % else: + <p>${person} has never been a customer.</p> + % endif + </b-tab-item><!-- Customer --> + + <b-tab-item label="Employee" ${'icon="check" icon-pack="fas"' if employee else ''|n}> + + % if employee: + <div style="display: flex; justify-content: space-between;"> + + <div> + + <div class="field-wrapper id"> + <div class="field-row"> + <label>ID</label> + <div class="field"> + ${employee.id or ''} + </div> + </div> + </div> + + <div class="field-wrapper display_name"> + <div class="field-row"> + <label>Display Name</label> + <div class="field"> + ${employee.display_name or ''} + </div> + </div> + </div> + + <div class="field-wrapper status"> + <div class="field-row"> + <label>Status</label> + <div class="field"> + ${enum.EMPLOYEE_STATUS.get(employee.status, '')} + </div> + </div> + </div> + + % if employee.phones: + % for phone in employee.phones: + <div class="field-wrapper"> + <div class="field-row"> + <label>Phone Number</label> + <div class="field"> + ${phone.number} (type: ${phone.type}) + </div> + </div> + </div> + % endfor + % else: + <div class="field-wrapper"> + <div class="field-row"> + <label>Phone Number</label> + <div class="field"> + (none on file) + </div> + </div> + </div> + % endif + + % if employee.emails: + % for email in employee.emails: + <div class="field-wrapper"> + <div class="field-row"> + <label>Email Address</label> + <div class="field"> + ${email.address} (type: ${email.type}) + </div> + </div> + </div> + % endfor + % else: + <div class="field-wrapper"> + <div class="field-row"> + <label>Email Address</label> + <div class="field"> + (none on file) + </div> + </div> + </div> + % endif + + </div> + + <div> + % if request.has_perm('employees.view'): + ${h.link_to("View Employee", url('employees.view', uuid=employee.uuid), class_='button')} + % endif + </div> + + </div> + + % else: + <p>${person} has never been an employee.</p> + % endif + </b-tab-item><!-- Employee --> + + <b-tab-item label="User" ${'icon="check" icon-pack="fas"' if person.users else ''|n}> + % if person.users: + <p>${person} is associated with <strong>${len(person.users)}</strong> user account(s)</p> + <br /> + <div id="users-accordion"> + % for user in person.users: + + <b-collapse class="panel" + ## TODO: what's up with aria-id here? + ## aria-id="contentIdForA11y2" + > + + <div + slot="trigger" + class="panel-heading" + role="button" + ## TODO: what's up with aria-id here? + ## aria-controls="contentIdForA11y2" + > + <strong>${user.username}</strong> </div> - </div> + <div class="panel-block"> - </div> - </b-collapse> - % endfor - </div> + <div style="display: flex; justify-content: space-between; width: 100%;"> - % else: - <p>${person} has never been a user.</p> - % endif - </b-tab-item><!-- User --> + <div> - </b-tabs> -</div> + <div class="field-wrapper id"> + <div class="field-row"> + <label>Username</label> + <div class="field"> + ${user.username} + </div> + </div> + </div> -<script type="text/javascript"> + </div> - new Vue({ - el: '#profile-info-app', - data() { - return { - activeTab: 0 - } - } - }) + <div> + % if request.has_perm('users.view'): + ${h.link_to("View User", url('users.view', uuid=user.uuid), class_='button')} + % endif + </div> -</script> + </div> + + </div> + </b-collapse> + % endfor + </div> + + % else: + <p>${person} has never been a user.</p> + % endif + </b-tab-item><!-- User --> + + </b-tabs> + </div> + </script> +</%def> + +<%def name="make_this_page_component()"> + ${parent.make_this_page_component()} + <script type="text/javascript"> + + const ProfileInfo = { + template: '#profile-info-template', + data() { + return { + activeTab: 0, + } + }, + } + + Vue.component('profile-info', ProfileInfo) + + </script> +</%def> + + +${parent.body()} From 12b0ac10371247299c3c955b4675c982ad7b119c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 6 Mar 2020 19:53:03 -0600 Subject: [PATCH 0066/1681] Move logic for Order Form worksheet into purchase batch handler i.e. get it out of Tailbone! --- tailbone/views/purchasing/ordering.py | 46 +++------------------------ 1 file changed, 5 insertions(+), 41 deletions(-) diff --git a/tailbone/views/purchasing/ordering.py b/tailbone/views/purchasing/ordering.py index 15ce6f5b..77e631f5 100644 --- a/tailbone/views/purchasing/ordering.py +++ b/tailbone/views/purchasing/ordering.py @@ -201,8 +201,10 @@ class OrderingBatchView(PurchasingBatchView): # organize vendor catalog costs by dept / subdept departments = {} - costs = self.get_order_form_costs(batch.vendor) - costs = self.sort_order_form_costs(costs) + costs = self.handler.get_order_form_costs(self.Session(), batch.vendor) + costs = self.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) for cost in costs: department = cost.product.department @@ -233,11 +235,8 @@ class OrderingBatchView(PurchasingBatchView): subdept_costs.append(cost) cost._batchrow = order_items.get(cost.product_uuid) - # do anything else needed to satisfy template display requirements etc. - self.decorate_order_form_cost(cost) - # fetch recent purchase history, sort/pad for template convenience - history = self.get_order_form_history(batch, costs, 6) + history = self.handler.get_order_form_history(batch, costs, 6) for i in range(6 - len(history)): history.append(None) history = list(reversed(history)) @@ -260,41 +259,6 @@ class OrderingBatchView(PurchasingBatchView): 'ignore_cases': not self.handler.allow_cases(), }) - def get_order_form_history(self, batch, costs, count): - - # fetch last 6 purchases for this vendor, organize line items by product - history = [] - purchases = self.Session.query(model.Purchase)\ - .filter(model.Purchase.vendor == batch.vendor)\ - .filter(model.Purchase.status >= self.enum.PURCHASE_STATUS_ORDERED)\ - .order_by(model.Purchase.date_ordered.desc(), model.Purchase.created.desc())\ - .options(orm.joinedload(model.Purchase.items)) - for purchase in purchases[:count]: - items = {} - for item in purchase.items: - items[item.product_uuid] = item - history.append({'purchase': purchase, 'items': items}) - - return history - - def get_order_form_costs(self, vendor): - return self.Session.query(model.ProductCost)\ - .join(model.Product)\ - .outerjoin(model.Brand)\ - .filter(model.ProductCost.vendor == vendor)\ - .options(orm.joinedload(model.ProductCost.product)\ - .joinedload(model.Product.department))\ - .options(orm.joinedload(model.ProductCost.product)\ - .joinedload(model.Product.subdepartment)) - - def sort_order_form_costs(self, costs): - return costs.order_by(model.Brand.name, - model.Product.description, - model.Product.size) - - def decorate_order_form_cost(self, cost): - pass - def worksheet_update(self): """ Handles AJAX requests to update the order quantities for some row From d72f61a98d4643b506adf12dca933ad908606b60 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 11 Mar 2020 13:30:04 -0500 Subject: [PATCH 0067/1681] Make sure all contact info is "touched" when touching person record --- tailbone/views/master.py | 11 ++--------- tailbone/views/people.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index e30598bc..ae38f2b9 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -1154,27 +1154,20 @@ class MasterView(View): alternative. """ obj = self.get_instance() - change = self.touch_instance(obj) + self.touch_instance(obj) self.request.session.flash("{} has been touched: {}".format( self.get_model_title(), self.get_instance_title(obj))) return self.redirect(self.get_action_url('view', obj)) def touch_instance(self, obj): """ - Perform actual "touch" logic for the given object. Must return the - :class:`rattail:~rattail.db.model.Change` record involved. - - .. todo:: - Why should this return the change object? We're not using it for - anything (yet?) but some views may generate multiple changes when - touching the primary object, i.e. touch related objects also. + 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 - return change def versions(self): """ diff --git a/tailbone/views/people.py b/tailbone/views/people.py index c1eb2dfb..88612d97 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -146,6 +146,35 @@ class PeopleView(MasterView): return not bool(person.user and person.user.username == 'chuck') return True + def touch_instance(self, person): + """ + Supplements the default logic as follows: + + In addition to "touching" the person proper, we also "touch" each + contact info record associated with them. + """ + # touch person, as per usual + super(PeopleView, self).touch_instance(person) + + def touch(obj): + change = model.Change() + change.class_name = obj.__class__.__name__ + change.instance_uuid = obj.uuid + change.deleted = False + self.Session.add(change) + + # phone numbers + for phone in person.phones: + touch(phone) + + # email addresses + for email in person.emails: + touch(email) + + # mailing addresses + for address in person.addresses: + touch(address) + def configure_common_form(self, f): super(PeopleView, self).configure_common_form(f) From d8b9ae9ff17005ad3ca11834588606f7f8ea84cf Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 11 Mar 2020 13:31:59 -0500 Subject: [PATCH 0068/1681] 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 1619c5f3..2a7095d1 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.8.89 (2020-03-11) +------------------- + +* Refactor "view profile" page per latest Buefy theme conventions. + +* Move logic for Order Form worksheet into purchase batch handler. + +* Make sure all contact info is "touched" when touching person record. + + 0.8.88 (2020-03-05) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 3ff5d100..7afe788d 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.88' +__version__ = '0.8.89' From 136d1813635cb382f903153fc10a4d8e8ec1aba2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 13 Mar 2020 13:15:27 -0500 Subject: [PATCH 0069/1681] Add basic "ordering worksheet" API display-only for the moment, pending review/feedback --- tailbone/api/batch/ordering.py | 123 +++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/tailbone/api/batch/ordering.py b/tailbone/api/batch/ordering.py index 1b611381..031bccdf 100644 --- a/tailbone/api/batch/ordering.py +++ b/tailbone/api/batch/ordering.py @@ -31,9 +31,12 @@ 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 +from cornice import Service + from tailbone.api.batch import APIBatchView, APIBatchRowView @@ -84,6 +87,126 @@ class OrderingBatchViews(APIBatchView): batch = super(OrderingBatchViews, self).create_object(data) return batch + def worksheet(self): + """ + Returns primary data for the Ordering Worksheet view. + """ + batch = self.get_object() + if batch.executed: + raise self.forbidden() + + # TODO: much of the logic below was copied from the traditional master + # view for ordering batches. should maybe let them share it somehow? + + # organize existing batch rows by product + order_items = {} + for row in batch.active_rows(): + order_items[row.product_uuid] = row + + # 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 = list(costs) # we must have a stable list for the rest of this + self.handler.decorate_order_form_costs(batch, costs) + for cost in costs: + + department = cost.product.department + if department: + department_dict = departments.setdefault(department.uuid, { + 'uuid': department.uuid, + 'number': department.number, + 'name': department.name, + }) + else: + if None not in departments: + departments[None] = { + 'uuid': None, + 'number': None, + 'name': "", + } + department_dict = departments[None] + + subdepartments = department_dict.setdefault('subdepartments', {}) + + subdepartment = cost.product.subdepartment + if subdepartment: + subdepartment_dict = subdepartments.setdefault(subdepartment.uuid, { + 'uuid': subdepartment.uuid, + 'number': subdepartment.number, + 'name': subdepartment.name, + }) + else: + if None not in subdepartments: + subdepartments[None] = { + 'uuid': None, + 'number': None, + 'name': "", + } + subdepartment_dict = subdepartments[None] + + subdept_costs = subdepartment_dict.setdefault('costs', []) + product = cost.product + subdept_costs.append({ + 'uuid': cost.uuid, + 'upc': six.text_type(product.upc), + 'upc_pretty': product.upc.pretty() if product.upc else None, + 'brand_name': product.brand.name if product.brand else None, + 'description': product.description, + 'size': product.size, + 'case_size': cost.case_size, + 'uom_display': "LB" if product.weighed else "EA", + 'vendor_item_code': cost.code, + 'preference': cost.preference, + 'preferred': cost.preference == 1, + 'unit_cost': cost.unit_cost, + 'unit_cost_display': "${:0.2f}".format(cost.unit_cost) if cost.unit_cost is not None else "", + # TODO + # 'cases_ordered': None, + # 'units_ordered': None, + # 'po_total': None, + # 'po_total_display': None, + }) + + # sort the (sub)department groupings + sorted_departments = [] + for dept in sorted(six.itervalues(departments), key=lambda d: d['name']): + dept['subdepartments'] = sorted(six.itervalues(dept['subdepartments']), + key=lambda s: s['name']) + sorted_departments.append(dept) + + # fetch recent purchase history, sort/pad for template convenience + history = self.handler.get_order_form_history(batch, costs, 6) + for i in range(6 - len(history)): + history.append(None) + history = list(reversed(history)) + + return { + 'batch': self.normalize(batch), + 'departments': departments, + 'sorted_departments': sorted_departments, + 'history': history, + } + + @classmethod + def defaults(cls, config): + cls._defaults(config) + cls._batch_defaults(config) + cls._ordering_batch_defaults(config) + + @classmethod + def _ordering_batch_defaults(cls, config): + route_prefix = cls.get_route_prefix() + permission_prefix = cls.get_permission_prefix() + object_url_prefix = cls.get_object_url_prefix() + + # worksheet + worksheet = Service(name='{}.worksheet'.format(route_prefix), + path='{}/{{uuid}}/worksheet'.format(object_url_prefix)) + worksheet.add_view('GET', 'worksheet', klass=cls, + permission='{}.worksheet'.format(permission_prefix)) + config.add_cornice_service(worksheet) + class OrderingBatchRowViews(APIBatchRowView): From 9a61f55f76cb808ed7b2652a0cfd93bc215d0df9 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 14 Mar 2020 18:58:06 -0500 Subject: [PATCH 0070/1681] Tweak GPC grid filter, to better handle spaces in user input i.e. when a user copy/pastes a UPC with leading/trailing space --- tailbone/grids/filters.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py index 3f23196d..b80f6050 100644 --- a/tailbone/grids/filters.py +++ b/tailbone/grids/filters.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -859,8 +859,13 @@ class AlchemyGPCFilter(AlchemyGridFilter): """ Filter data with an equal ('=') query. """ - if value is None or value == '': + if value is None: return query + + value = value.strip() + if not value: + return query + try: return query.filter(self.column.in_(( GPC(value), @@ -870,9 +875,13 @@ class AlchemyGPCFilter(AlchemyGridFilter): def filter_not_equal(self, query, value): """ - Filter data with a not eqaul ('!=') query. + Filter data with a not equal ('!=') query. """ - if value is None or value == '': + if value is None: + return query + + value = value.strip() + if not value: return query # When saying something is 'not equal' to something else, we must also From 59cae7d207d93da3d8efa7533edb2b4b8a543504 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 15 Mar 2020 09:25:10 -0500 Subject: [PATCH 0071/1681] Only show tables for "public" schema i.e. avoid the "batch" schema --- tailbone/views/tables.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tailbone/views/tables.py b/tailbone/views/tables.py index fc6ee6a9..78363f66 100644 --- a/tailbone/views/tables.py +++ b/tailbone/views/tables.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -52,13 +52,18 @@ class TablesView(MasterView): """ Fetch existing table names and estimate row counts via PG SQL """ + # note that we only show 'public' schema tables, i.e. avoid the 'batch' + # schema, at least for now? maybe should include all, plus show the + # schema name within the results grid? sql = """ - select schemaname, relname, n_live_tup + select relname, n_live_tup from pg_stat_user_tables + where schemaname = 'public' order by n_live_tup desc; """ result = self.Session.execute(sql) - return [dict(name=row[1], row_count=row[2]) for row in result] + return [dict(name=row['relname'], row_count=row['n_live_tup']) + for row in result] def configure_grid(self, g): g.sorters['name'] = g.make_simple_sorter('name', foldcase=True) From 413e9b0f1ea3299f5901a0caeeb5daabc0a968e8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 15 Mar 2020 09:40:11 -0500 Subject: [PATCH 0072/1681] Remove old/unwanted Vue.js index experiment, for Users table --- tailbone/config.py | 9 +- tailbone/templates/users/index.mako | 11 -- tailbone/templates/users/vue_index.mako | 145 ------------------------ tailbone/views/users.py | 33 +----- 4 files changed, 3 insertions(+), 195 deletions(-) delete mode 100644 tailbone/templates/users/index.mako delete mode 100644 tailbone/templates/users/vue_index.mako diff --git a/tailbone/config.py b/tailbone/config.py index aa15dc07..5553924e 100644 --- a/tailbone/config.py +++ b/tailbone/config.py @@ -1,8 +1,8 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -51,8 +51,3 @@ class ConfigExtension(BaseExtension): # provide default theme selection config.setdefault('tailbone', 'themes', 'default, falafel') config.setdefault('tailbone', 'themes.expose_picker', 'true') - - -def expose_vuejs_experiments(config): - return config.getbool('tailbone', 'expose_vuejs_experiments', - default=False) diff --git a/tailbone/templates/users/index.mako b/tailbone/templates/users/index.mako deleted file mode 100644 index 4c5351b7..00000000 --- a/tailbone/templates/users/index.mako +++ /dev/null @@ -1,11 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/principal/index.mako" /> - -<%def name="context_menu_items()"> - ${parent.context_menu_items()} - % if expose_vuejs_experiments: - <li>${h.link_to("Vue.js Index", url('{}.vue_index'.format(route_prefix)))}</li> - % endif -</%def> - -${parent.body()} diff --git a/tailbone/templates/users/vue_index.mako b/tailbone/templates/users/vue_index.mako deleted file mode 100644 index e3a4675d..00000000 --- a/tailbone/templates/users/vue_index.mako +++ /dev/null @@ -1,145 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/users/index.mako" /> - -## <%def name="head_tags()"> -## ${parent.head_tags()} -## ## TODO: this is needed according to Bulma docs? -## ## https://bulma.io/documentation/overview/start/#code-requirements -## <meta name="viewport" content="width=device-width, initial-scale=1"> -## </%def> - -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - - <!-- vue --> - ${h.javascript_link('https://cdn.jsdelivr.net/npm/vue')} - - <!-- vuex --> - ${h.javascript_link('https://unpkg.com/vuex')} - - <!-- vue-tables-2 --> - ${h.javascript_link('https://cdn.jsdelivr.net/npm/vue-tables-2@1.4.70/dist/vue-tables-2.min.js')} - ## ${h.javascript_link(request.static_url('tailbone:static/js/lib/vue-tables.js'))} - - <!-- bulma --> - ${h.stylesheet_link('https://cdnjs.cloudflare.com/ajax/libs/bulma/0.7.2/css/bulma.min.css')} - - <!-- fontawesome --> - <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.5.0/css/all.css" integrity="sha384-B4dIYHKNBt8Bc12p+WXckhzcICo0wtJAoU8YZTY5qE0Id1GSseTk6S+L3BlXeVIU" crossorigin="anonymous"> - - <style type="text/css"> - /* workaround for header logo, needed for Bulma (ugh) */ - ## TODO: this img should be 49px for height, what gives here? - .home img { height: 59px; } - </style> - -</%def> - -<div id="vue-app"> - - ## TODO: need to make endpoint a bit more configurable somehow - <v-server-table name="users" url="/api/users" :columns="columns" :options="options"> - - ## TODO: make URLs more flexible / configurable... also perms? - % if request.has_perm('users.view'): - <span slot="username" slot-scope="props"><a :href="'/users/'+props.row.uuid">{{ props.row.username }}</a></span> - <span slot="person_display_name" slot-scope="props"><a :href="'/users/'+props.row.uuid">{{ props.row.person_display_name }}</a></span> - % endif - - ## TODO: why on earth doesn't it render bool as string by default? - <span slot="active" slot-scope="props">{{ props.row.active }}</span> - - ## TODO: make URLs more flexible / configurable... also perms? - <span slot="actions" slot-scope="props"> - % if request.has_perm('users.view'): - <a :href="'/users/'+props.row.uuid">View</a> - % endif - % if request.has_perm('users.edit'): - | <a :href="'/users/'+props.row.uuid+'/edit'">Edit</a> - % endif - </span> - - </v-server-table> -</div> - -<script type="text/javascript"> - -// Vue.use(Vuex); - -var store = new Vuex.Store({ - // state: { - // appVersion: null, - // // TODO: is this really needed or can we just always check appsettings? - // production: appsettings.production, - // user: null, - // pageTitle: null - // }, - // mutations: { - // setAppVersion(state, payload) { - // state.appVersion = payload; - // }, - // setPageTitle(state, payload) { - // state.pageTitle = payload; - // }, - // setUser(state, payload) { - // state.user = payload; - // } - // }, - // actions: { - // } -}) - -Vue.use(VueTables.ServerTable, { - sortIcon: { - is: 'fa-sort', - base: 'fas', - up: 'fa-sort-up', - down: 'fa-sort-down' - } -}, true, 'bulma', 'default'); - -<% - columns = [ - 'username', - 'person_display_name', - 'active', - ] - if request.has_any_perm('users.view', 'users.edit'): - columns.append('actions') -%> - -var app = new Vue({ - el: '#vue-app', - store: store, - data: { - columns: ${json.dumps(columns)|n}, - options: { - columnsDropdown: true, - filterable: false, - headings: { - person_display_name: "Person" - }, - sortable: [ - 'username', - 'person_display_name', - 'active' - ], - orderBy: { - column: 'username', - ascending: true - }, - perPageValues: [10, 25, 50, 100, 200], - // preserveState: true, - saveState: true, - // TODO: why doesn't local storage work? but alas, table does not - // properly submit the 'orderBy' param, and results aren't paginated - storage: 'session' - } - } -}); - -// $.get('/api/users', {sort: 'username|desc', page: 1, per_page: 10}, function(data) { -// app.users = data.users; -// }); - -</script> diff --git a/tailbone/views/users.py b/tailbone/views/users.py index 02908eb9..21e7538f 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -42,7 +42,6 @@ from tailbone import forms from tailbone.db import Session from tailbone.views import MasterView from tailbone.views.principal import PrincipalMasterView, PermissionsRenderer -from tailbone.config import expose_vuejs_experiments class UsersView(PrincipalMasterView): @@ -133,16 +132,6 @@ class UsersView(PrincipalMasterView): g.set_link('last_name') g.set_link('display_name') - def template_kwargs_index(self, **kwargs): - kwargs['expose_vuejs_experiments'] = expose_vuejs_experiments(self.rattail_config) - return kwargs - - def vue_index(self): - if not expose_vuejs_experiments(self.rattail_config): - raise self.notfound() - - return self.render_to_response('vue_index', {}) - def unique_username(self, node, value): query = self.Session.query(model.User)\ .filter(model.User.username == value) @@ -408,10 +397,6 @@ class UsersView(PrincipalMasterView): @classmethod def defaults(cls, config): - - # TODO: probably should stop doing this one - cls._vue_index_defaults(config) - cls._user_defaults(config) cls._principal_defaults(config) cls._defaults(config) @@ -430,22 +415,6 @@ class UsersView(PrincipalMasterView): config.add_tailbone_permission(permission_prefix, '{}.edit_roles'.format(permission_prefix), "Edit the Roles to which a {} belongs".format(model_title)) - @classmethod - def _vue_index_defaults(cls, config): - """ - Provide default configuration for the "Vue.js index" view. This was - essentially an experiment and probably should be abandoned. - """ - rattail_config = config.registry.settings.get('rattail_config') - route_prefix = cls.get_route_prefix() - url_prefix = cls.get_url_prefix() - permission_prefix = cls.get_permission_prefix() - - # vue-index - config.add_route('{}.vue_index'.format(route_prefix), '{}/vue-index/'.format(url_prefix)) - config.add_view(cls, attr='vue_index', route_name='{}.vue_index'.format(route_prefix), - permission='{}.list'.format(permission_prefix)) - class UserEventsView(MasterView): """ From edd48ef66739f7f038f1aee8015e7a4fd3b26cef Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 15 Mar 2020 11:39:52 -0500 Subject: [PATCH 0073/1681] Misc. changes to User, Role permissions and management thereof * only "root" can edit the Administrator role * edit of Authenticated and Guest roles requires dedicated permission * edit of role(s) to which current user belongs, requires dedicated permission * delete is not allowed for any built-in role * when editing a role, user can only add/remove permissions they themselves have * settings can define some "protected" users, which only "root" can edit/delete --- tailbone/views/roles.py | 123 ++++++++++++++++++++++++++++++++++++++-- tailbone/views/users.py | 48 ++++++++++++---- 2 files changed, 157 insertions(+), 14 deletions(-) diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py index 3d10d349..5e9b0887 100644 --- a/tailbone/views/roles.py +++ b/tailbone/views/roles.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -30,7 +30,8 @@ import six from sqlalchemy import orm from rattail.db import model -from rattail.db.auth import has_permission, administrator_role, guest_role, authenticated_role +from rattail.db.auth import (has_permission, grant_permission, revoke_permission, + administrator_role, guest_role, authenticated_role) import colander from deform import widget as dfwidget @@ -65,6 +66,42 @@ class RolesView(PrincipalMasterView): g.set_sort_defaults('name') g.set_link('name') + def editable_instance(self, role): + """ + We must prevent edit for certain built-in roles etc., depending on + current user's permissions. + """ + # only "root" can edit Administrator + if role is administrator_role(self.Session()): + return self.request.is_root + + # can edit Authenticated only if user has permission + if role is authenticated_role(self.Session()): + return self.has_perm('edit_authenticated') + + # can edit Guest only if user has permission + if role is guest_role(self.Session()): + return self.has_perm('edit_guest') + + # current user can edit their own roles, only if they have permission + user = self.request.user + if user and role in user.roles: + return self.has_perm('edit_my') + + return True + + def deletable_instance(self, role): + """ + We must prevent deletion for all built-in roles. + """ + if role is administrator_role(self.Session()): + return False + if role is authenticated_role(self.Session()): + return False + if role is guest_role(self.Session()): + return False + return True + def unique_name(self, node, value): query = self.Session.query(model.Role)\ .filter(model.Role.name == value) @@ -82,7 +119,7 @@ class RolesView(PrincipalMasterView): f.set_validator('name', self.unique_name) # permissions - self.tailbone_permissions = self.request.registry.settings.get('tailbone_permissions', {}) + self.tailbone_permissions = self.get_available_permissions() f.set_renderer('permissions', PermissionsRenderer(permissions=self.tailbone_permissions)) f.set_node('permissions', colander.Set()) f.set_widget('permissions', PermissionsWidget(permissions=self.tailbone_permissions)) @@ -101,6 +138,44 @@ class RolesView(PrincipalMasterView): if self.editing and role is guest_role(self.Session()): f.set_readonly('session_timeout') + def get_available_permissions(self): + """ + Should return a dictionary with all "available" permissions. The + result of this will vary depending on the current user, because a user + is only allowed to "manage" permissions which they themselves have been + granted. + + In other words the return value will be the "full set" of permissions, + if the current user is an admin; otherwise it will be the "subset" of + permissions which the current user has been granted. + """ + # fetch full set of permissions registered in the app + permissions = self.request.registry.settings.get('tailbone_permissions', {}) + + # admin user gets to manage all permissions + if self.request.is_admin: + return permissions + + # when viewing, we allow all permissions to be exposed for all users + if self.viewing: + return permissions + + # non-admin user can only manage permissions they've been granted + # TODO: it seems a bit ugly, to "rebuild" permission groups like this, + # but not sure if there's a better way? + available = {} + for gkey, group in six.iteritems(permissions): + for pkey, perm in six.iteritems(group['perms']): + if self.request.has_perm(pkey): + if gkey not in available: + available[gkey] = { + 'key': gkey, + 'label': group['label'], + 'perms': {}, + } + available[gkey]['perms'][pkey] = perm + return available + def render_session_timeout(self, role, field): if role is guest_role(self.Session()): return "(not applicable)" @@ -109,12 +184,34 @@ class RolesView(PrincipalMasterView): return six.text_type(role.session_timeout) def objectify(self, form, data=None): + """ + Supplements the default logic, as follows: + + The role is updated as per usual, and then we also invoke + :meth:`update_permissions()` in order to correctly handle that part, + i.e. ensure the user can't modify permissions which they do not have. + """ if data is None: data = form.validated role = super(RolesView, self).objectify(form, data) - role.permissions = data['permissions'] + self.update_permissions(role, data['permissions']) return role + def update_permissions(self, role, permissions): + """ + Update the given role's permissions, according to those specified. + Note that this will not simply "clobber" the role's existing + permissions, but rather each "available" permission (depends on current + user) will be examined individually, and updated as needed. + """ + available = self.tailbone_permissions + for gkey, group in six.iteritems(available): + for pkey, perm in six.iteritems(group['perms']): + if pkey in permissions: + grant_permission(role, pkey) + else: + revoke_permission(role, pkey) + def template_kwargs_view(self, **kwargs): role = kwargs['instance'] if role.users: @@ -152,6 +249,24 @@ class RolesView(PrincipalMasterView): roles.append(role) return roles + @classmethod + def defaults(cls, config): + cls._principal_defaults(config) + cls._role_defaults(config) + cls._defaults(config) + + @classmethod + def _role_defaults(cls, config): + permission_prefix = cls.get_permission_prefix() + + # extra permissions for editing built-in roles etc. + config.add_tailbone_permission(permission_prefix, '{}.edit_authenticated'.format(permission_prefix), + "Edit the \"Authenticated\" Role") + config.add_tailbone_permission(permission_prefix, '{}.edit_guest'.format(permission_prefix), + "Edit the \"Guest\" Role") + config.add_tailbone_permission(permission_prefix, '{}.edit_my'.format(permission_prefix), + "Edit Role(s) to which current user belongs") + class PermissionsWidget(dfwidget.Widget): template = 'permissions' diff --git a/tailbone/views/users.py b/tailbone/views/users.py index 21e7538f..8b45ca55 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -132,6 +132,44 @@ class UsersView(PrincipalMasterView): g.set_link('last_name') g.set_link('display_name') + def editable_instance(self, user): + """ + If the given user is "protected" then we only allow edit if current + user is "root". But if the given user is not protected, this simply + returns ``True``. + """ + if self.user_is_protected(user): + return self.request.is_root + return True + + def deletable_instance(self, user): + """ + If the given user is "protected" then we only allow delete if current + user is "root". But if the given user is not protected, this simply + returns ``True``. + """ + if self.user_is_protected(user): + return self.request.is_root + return True + + def user_is_protected(self, user): + """ + This logic will consult the settings, for a list of "protected" + usernames, which should require root privileges to edit. If no setting + is found, or the given ``user`` is not represented in the setting, then + edit is allowed. + + But if there is a setting, and the ``user`` is represented in it, then + this method will return ``True`` only if the "current" app user is + "root", otherwise will return ``False``. + """ + if not hasattr(self, 'protected_usernames'): + self.protected_usernames = self.rattail_config.getlist( + 'tailbone', 'protected_usernames') + if self.protected_usernames and user.username in self.protected_usernames: + return True + return False + def unique_username(self, node, value): query = self.Session.query(model.User)\ .filter(model.User.username == value) @@ -328,16 +366,6 @@ class UsersView(PrincipalMasterView): items.append(HTML.tag('li', c=[tags.link_to(text, url)])) return HTML.tag('ul', c=items) - def editable_instance(self, user): - if self.rattail_config.demo(): - return user.username != 'chuck' - return True - - def deletable_instance(self, user): - if self.rattail_config.demo(): - return user.username != 'chuck' - return True - def get_row_data(self, user): return self.Session.query(model.UserEvent)\ .filter(model.UserEvent.user == user) From 964671fcbf8e986479402d251be7fd32429e3981 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 15 Mar 2020 11:59:23 -0500 Subject: [PATCH 0074/1681] Don't let user delete roles to which they belong, without permission --- tailbone/views/roles.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py index 5e9b0887..02d7daf1 100644 --- a/tailbone/views/roles.py +++ b/tailbone/views/roles.py @@ -100,6 +100,12 @@ class RolesView(PrincipalMasterView): return False if role is guest_role(self.Session()): return False + + # current user can delete their own roles, only if they have permission + user = self.request.user + if user and role in user.roles: + return self.has_perm('edit_my') + return True def unique_name(self, node, value): From 9b00e829b8ef01bd3a8e3247225c69426bde5047 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 15 Mar 2020 13:01:52 -0500 Subject: [PATCH 0075/1681] Prevent deletion of department which still has products --- tailbone/views/departments.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tailbone/views/departments.py b/tailbone/views/departments.py index c3d48058..4d827018 100644 --- a/tailbone/views/departments.py +++ b/tailbone/views/departments.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -82,6 +82,19 @@ class DepartmentsView(MasterView): kwargs['employees'] = None return kwargs + def before_delete(self, department): + """ + Check to see if there are any products which belong to the department; + if there are then we do not allow delete and redirect the user. + """ + count = self.Session.query(model.Product)\ + .filter(model.Product.department == department)\ + .count() + if count: + self.request.session.flash("Will not delete department which still has {} products: {}".format( + count, department), 'error') + raise self.redirect(self.get_action_url('view', department)) + def list_by_vendor(self): """ View list of departments by vendor From da4f2b2081574847db8b97c9b690d692b83655c4 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 15 Mar 2020 14:26:56 -0500 Subject: [PATCH 0076/1681] Add sort/filter for Department Name, in Subdepartments grid --- tailbone/views/subdepartments.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tailbone/views/subdepartments.py b/tailbone/views/subdepartments.py index 4550999f..584cd7f7 100644 --- a/tailbone/views/subdepartments.py +++ b/tailbone/views/subdepartments.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -58,9 +58,17 @@ class SubdepartmentsView(MasterView): def configure_grid(self, g): super(SubdepartmentsView, self).configure_grid(g) + + # name g.filters['name'].default_active = True g.filters['name'].default_verb = 'contains' g.set_sort_defaults('name') + + # department (name) + g.set_joiner('department', lambda q: q.outerjoin(model.Department)) + g.set_sorter('department', model.Department.name) + g.set_filter('department', model.Department.name) + g.set_link('number') g.set_link('name') From 4fe885995fda09cc371c44c835b2356a9e3b4f25 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 15 Mar 2020 15:52:10 -0500 Subject: [PATCH 0077/1681] Allow "touch" for Department, Subdepartment --- tailbone/views/departments.py | 1 + tailbone/views/subdepartments.py | 1 + 2 files changed, 2 insertions(+) diff --git a/tailbone/views/departments.py b/tailbone/views/departments.py index 4d827018..7b31fd31 100644 --- a/tailbone/views/departments.py +++ b/tailbone/views/departments.py @@ -41,6 +41,7 @@ class DepartmentsView(MasterView): Master view for the Department class. """ model_class = model.Department + touchable = True has_versions = True grid_columns = [ diff --git a/tailbone/views/subdepartments.py b/tailbone/views/subdepartments.py index 584cd7f7..1e65d56f 100644 --- a/tailbone/views/subdepartments.py +++ b/tailbone/views/subdepartments.py @@ -37,6 +37,7 @@ class SubdepartmentsView(MasterView): Master view for the Subdepartment class. """ model_class = model.Subdepartment + touchable = True has_versions = True grid_columns = [ From 7994c7d7704e90b37386d4d637c820de94d34af6 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 15 Mar 2020 19:28:11 -0500 Subject: [PATCH 0078/1681] Expose `Customer.number` field --- tailbone/views/customers.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index e8ada107..084ee783 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -68,6 +68,7 @@ class CustomersView(MasterView): grid_columns = [ 'id', + 'number', 'name', 'phone', 'email', @@ -75,6 +76,7 @@ class CustomersView(MasterView): form_fields = [ 'id', + 'number', 'name', 'default_phone', 'default_address', From 907a356bea3af85785b7752e6532f5852e0d04c0 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 16 Mar 2020 17:47:06 -0500 Subject: [PATCH 0079/1681] Add support for "bulk-delete" of Person table --- tailbone/views/master.py | 10 +++++++--- tailbone/views/people.py | 20 ++++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index ae38f2b9..016ea41a 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -1855,7 +1855,7 @@ class MasterView(View): def bulk_delete_objects(self, session, objects, progress=None): def delete(obj, i): - session.delete(obj) + self.delete_instance(obj) if i % 1000 == 0: session.flush() @@ -3142,10 +3142,14 @@ class MasterView(View): """ Delete the instance, or mark it as deleted, or whatever you need to do. """ + # note, we don't use self.Session here, in case we're being called from + # a separate (bulk-delete) thread + session = orm.object_session(instance) + session.delete(instance) + # Flush immediately to force any pending integrity errors etc.; that # way we don't set flash message until we know we have success. - self.Session.delete(instance) - self.Session.flush() + session.flush() def get_after_delete_url(self, instance): """ diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 88612d97..2173be83 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -51,6 +51,7 @@ class PeopleView(MasterView): touchable = True has_versions = True supports_mobile = True + bulk_deletable = True manage_notes_from_profile_view = False grid_columns = [ @@ -146,6 +147,25 @@ class PeopleView(MasterView): return not bool(person.user and person.user.username == 'chuck') return True + def delete_instance(self, person): + """ + Supplements the default logic as follows: + + Any customer associations are first deleted for the person. Once that + is complete, deletion continues as per usual. + """ + session = orm.object_session(person) + + # must explicitly remove all CustomerPerson records + for cp in list(person._customers): + customer = cp.customer + session.delete(cp) + # session.flush() + customer._people.reorder() + + # continue with normal logic + super(PeopleView, self).delete_instance(person) + def touch_instance(self, person): """ Supplements the default logic as follows: From 60157abd46ba1d7d3c0c06672736f06f0c439948 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 17 Mar 2020 12:28:13 -0500 Subject: [PATCH 0080/1681] Allow customization for Customers tab of Profile view more tabs to come, this was all i needed for now --- .../templates/people/view_profile_buefy.mako | 192 ++++++++---------- tailbone/views/people.py | 29 +++ 2 files changed, 112 insertions(+), 109 deletions(-) diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako index ecb8d60a..b34de9ac 100644 --- a/tailbone/templates/people/view_profile_buefy.mako +++ b/tailbone/templates/people/view_profile_buefy.mako @@ -9,6 +9,86 @@ ${self.page_content()} </%def> +<%def name="render_customer_tab()"> + <b-tab-item label="Customer" icon-pack="fas" :icon="customers.length ? 'check' : null"> + + <div v-if="customers.length"> + + <div style="display: flex; justify-content: space-between;"> + <p>{{ person.fullName }} is associated with <strong>{{ customers.length }}</strong> customer account(s)</p> + </div> + + <br /> + <b-collapse v-for="customer in customers" + :key="customer.uuid" + class="panel" + :open="customers.length == 1"> + + <div slot="trigger" + slot-scope="props" + class="panel-heading" + role="button"> + <b-icon pack="fas" + icon="caret-right"> + </b-icon> + <strong>#{{ customer.number }} {{ customer.name }}</strong> + </div> + + <div class="panel-block"> + <div style="display: flex; justify-content: space-between; width: 100%;"> + <div style="flex-grow: 1;"> + + <b-field horizontal label="Number"> + {{ customer.number }} + </b-field> + + <b-field horizontal label="ID"> + {{ customer.id }} + </b-field> + + <b-field horizontal label="Name"> + {{ customer.name }} + </b-field> + + <b-field horizontal label="People"> + <ul> + <li v-for="p in customer.people" + :key="p.uuid"> + <a v-if="p.uuid != person.uuid" + :href="p.view_profile_url"> + {{ p.display_name }} + </a> + <span v-if="p.uuid == person.uuid"> + {{ p.display_name }} + </span> + </li> + </ul> + </b-field> + + </div> + <div class="buttons" style="align-items: start;"> + ${self.render_customer_panel_buttons(customer)} + </div> + </div> + </div> + </b-collapse> + </div> + + <div v-if="!customers.length"> + <p>{{ person.fullName }} has never had a customer account.</p> + </div> + + </b-tab-item> <!-- Customer --> +</%def> + +<%def name="render_customer_panel_buttons(customer)"> + % if request.has_perm('customers.view'): + <b-button tag="a" :href="customer.view_url"> + View Customer + </b-button> + % endif +</%def> + <%def name="render_this_page_template()"> ${parent.render_this_page_template()} @@ -148,115 +228,7 @@ </div> </b-tab-item><!-- Personal --> - <b-tab-item label="Customer" ${'icon="check" icon-pack="fas"' if person.customers else ''|n}> - % if person.customers: - <p>${person} is associated with <strong>${len(person.customers)}</strong> customer account(s)</p> - <br /> - <div id="customers-accordion"> - % for customer in person.customers: - - <b-collapse class="panel" - ## TODO: what's up with aria-id here? - ## aria-id="contentIdForA11y2" - > - - <div - slot="trigger" - class="panel-heading" - role="button" - ## TODO: what's up with aria-id here? - ## aria-controls="contentIdForA11y2" - > - <strong>${customer.id} - ${customer.name}</strong> - </div> - - <div class="panel-block"> - - <div style="display: flex; justify-content: space-between; width: 100%;"> - - <div> - - <div class="field-wrapper id"> - <div class="field-row"> - <label>ID</label> - <div class="field"> - ${customer.id or ''} - </div> - </div> - </div> - - <div class="field-wrapper name"> - <div class="field-row"> - <label>Name</label> - <div class="field"> - ${customer.name} - </div> - </div> - </div> - - % if customer.phones: - % for phone in customer.phones: - <div class="field-wrapper"> - <div class="field-row"> - <label>Phone Number</label> - <div class="field"> - ${phone.number} (type: ${phone.type}) - </div> - </div> - </div> - % endfor - % else: - <div class="field-wrapper"> - <div class="field-row"> - <label>Phone Number</label> - <div class="field"> - (none on file) - </div> - </div> - </div> - % endif - - % if customer.emails: - % for email in customer.emails: - <div class="field-wrapper"> - <div class="field-row"> - <label>Email Address</label> - <div class="field"> - ${email.address} (type: ${email.type}) - </div> - </div> - </div> - % endfor - % else: - <div class="field-wrapper"> - <div class="field-row"> - <label>Email Address</label> - <div class="field"> - (none on file) - </div> - </div> - </div> - % endif - - </div> - - <div> - % if request.has_perm('customers.view'): - ${h.link_to("View Customer", url('customers.view', uuid=customer.uuid), class_='button')} - % endif - </div> - - </div> - - </div> - </b-collapse> - % endfor - </div> - - % else: - <p>${person} has never been a customer.</p> - % endif - </b-tab-item><!-- Customer --> + ${self.render_customer_tab()} <b-tab-item label="Employee" ${'icon="check" icon-pack="fas"' if employee else ''|n}> @@ -422,6 +394,8 @@ data() { return { activeTab: 0, + person: ${json.dumps(person_data)|n}, + customers: ${json.dumps(customers_data)|n}, } }, } diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 2173be83..ef78c91f 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -297,6 +297,8 @@ class PeopleView(MasterView): 'instance': person, 'instance_title': self.get_instance_title(person), 'today': localtime(self.rattail_config).date(), + 'person_data': self.get_context_person(person), + 'customers_data': self.get_context_customers(person), 'employee': employee, 'employee_view_url': self.request.route_url('employees.view', uuid=employee.uuid) if employee else None, 'employee_history': employee.get_current_history() if employee else None, @@ -307,6 +309,33 @@ class PeopleView(MasterView): template = 'view_profile_buefy' if use_buefy else 'view_profile' return self.render_to_response(template, context) + def get_context_person(self, person): + return { + 'uuid': person.uuid, + 'first_name': person.first_name, + 'last_name': person.last_name, + 'display_name': person.display_name, + 'view_url': self.get_action_url('view', person), + 'view_profile_url': self.get_action_url('view_profile', person), + } + + def get_context_customers(self, person): + data = [] + for cp in person._customers: + customer = cp.customer + data.append({ + 'uuid': customer.uuid, + 'ordinal': cp.ordinal, + 'id': customer.id, + 'number': customer.number, + 'name': customer.name, + 'view_url': self.request.route_url('customers.view', + uuid=customer.uuid), + 'people': [self.get_context_person(p) + for p in customer.people], + }) + return data + def get_context_employee_history(self, employee): data = [] if employee: From ff3e83b1c52c7fe396369229a27aaf4fda56feca Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 17 Mar 2020 12:58:36 -0500 Subject: [PATCH 0081/1681] Fix name display bug in profile view --- tailbone/templates/people/view_profile_buefy.mako | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako index b34de9ac..e683ce17 100644 --- a/tailbone/templates/people/view_profile_buefy.mako +++ b/tailbone/templates/people/view_profile_buefy.mako @@ -15,7 +15,7 @@ <div v-if="customers.length"> <div style="display: flex; justify-content: space-between;"> - <p>{{ person.fullName }} is associated with <strong>{{ customers.length }}</strong> customer account(s)</p> + <p>{{ person.display_name }} is associated with <strong>{{ customers.length }}</strong> customer account(s)</p> </div> <br /> @@ -75,7 +75,7 @@ </div> <div v-if="!customers.length"> - <p>{{ person.fullName }} has never had a customer account.</p> + <p>{{ person.display_name }} has never had a customer account.</p> </div> </b-tab-item> <!-- Customer --> From 8ac0bb23342174d6a6833990e917661794091680 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 17 Mar 2020 18:50:07 -0500 Subject: [PATCH 0082/1681] Expose default email address, phone number when editing a Person --- .../templates/people/view_profile_buefy.mako | 6 ++++ tailbone/views/customers.py | 10 +----- tailbone/views/master.py | 16 ++++++++- tailbone/views/people.py | 36 +++++++++++++++++-- 4 files changed, 55 insertions(+), 13 deletions(-) diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako index e683ce17..d44fd66a 100644 --- a/tailbone/templates/people/view_profile_buefy.mako +++ b/tailbone/templates/people/view_profile_buefy.mako @@ -65,6 +65,12 @@ </ul> </b-field> + <b-field horizontal label="Address" + v-for="address in customer.addresses" + :key="address.uuid"> + {{ address.display }} + </b-field> + </div> <div class="buttons" style="align-items: start;"> ${self.render_customer_panel_buttons(customer)} diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index 084ee783..210393c1 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -263,14 +263,6 @@ class CustomersView(MasterView): if query.count(): raise colander.Invalid(node, "Customer ID must be unique") - def render_default_email(self, customer, field): - if customer.emails: - return customer.emails[0].address - - def render_default_phone(self, customer, field): - if customer.phones: - return customer.phones[0].number - def render_default_address(self, customer, field): if customer.addresses: return six.text_type(customer.addresses[0]) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 016ea41a..4c5aacc3 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -847,6 +847,20 @@ class MasterView(View): 'importer_host_title': importer_host_title, }) + def render_default_phone(self, obj, field): + """ + Render the "default" (first) phone number for the given contact. + """ + if obj.phones: + return obj.phones[0].number + + def render_default_email(self, obj, field): + """ + Render the "default" (first) email address for the given contact. + """ + if obj.emails: + return obj.emails[0].address + def render_product_key_value(self, obj): """ Render the "canonical" product key value for the given object. diff --git a/tailbone/views/people.py b/tailbone/views/people.py index ef78c91f..eb3f39fa 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -52,8 +52,14 @@ class PeopleView(MasterView): has_versions = True supports_mobile = True bulk_deletable = True + is_contact = True manage_notes_from_profile_view = False + labels = { + 'default_phone': "Phone Number", + 'default_email': "Email Address", + } + grid_columns = [ 'display_name', 'first_name', @@ -67,8 +73,8 @@ class PeopleView(MasterView): 'middle_name', 'last_name', 'display_name', - 'phone', - 'email', + 'default_phone', + 'default_email', 'address', 'employee', 'customers', @@ -197,15 +203,26 @@ class PeopleView(MasterView): def configure_common_form(self, f): super(PeopleView, self).configure_common_form(f) + person = f.model_instance f.set_label('display_name', "Full Name") + # TODO: should remove this? f.set_readonly('phone') f.set_label('phone', "Phone Number") + f.set_renderer('default_phone', self.render_default_phone) + if not self.creating and person.phones: + f.set_default('default_phone', person.phones[0].number) + + # TODO: should remove this? f.set_readonly('email') f.set_label('email', "Email Address") + f.set_renderer('default_email', self.render_default_email) + if not self.creating and person.emails: + f.set_default('default_email', person.emails[0].address) + f.set_readonly('address') f.set_label('address', "Mailing Address") @@ -319,6 +336,17 @@ class PeopleView(MasterView): 'view_profile_url': self.get_action_url('view_profile', person), } + def get_context_address(self, address): + return { + 'uuid': address.uuid, + 'street': address.street, + 'street2': address.street2, + 'city': address.city, + 'state': address.state, + 'zipcode': address.zipcode, + 'display': six.text_type(address), + } + def get_context_customers(self, person): data = [] for cp in person._customers: @@ -333,6 +361,8 @@ class PeopleView(MasterView): uuid=customer.uuid), 'people': [self.get_context_person(p) for p in customer.people], + 'addresses': [self.get_context_address(a) + for a in customer.addresses], }) return data From 970b5871e5078ae0af43e7cb904846ca3814dfef Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 18 Mar 2020 11:27:58 -0500 Subject: [PATCH 0083/1681] Add/improve various display of Member data --- tailbone/templates/members/view.mako | 17 +++++ .../templates/people/view_profile_buefy.mako | 73 +++++++++++++++++++ tailbone/views/customers.py | 24 ++++++ tailbone/views/members.py | 4 +- tailbone/views/people.py | 26 +++++++ 5 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 tailbone/templates/members/view.mako diff --git a/tailbone/templates/members/view.mako b/tailbone/templates/members/view.mako new file mode 100644 index 00000000..1af22f98 --- /dev/null +++ b/tailbone/templates/members/view.mako @@ -0,0 +1,17 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> +<%namespace file="/util.mako" import="view_profiles_helper" /> + +<%def name="object_helpers()"> + ${parent.object_helpers()} + <% people = [] %> + % if instance.person: + <% people.append(instance.person) %> + % endif + % if instance.customer: + <% people.extend(instance.customer.people) %> + % endif + ${view_profiles_helper(people)} +</%def> + +${parent.body()} diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako index d44fd66a..c10b808b 100644 --- a/tailbone/templates/people/view_profile_buefy.mako +++ b/tailbone/templates/people/view_profile_buefy.mako @@ -9,6 +9,76 @@ ${self.page_content()} </%def> +<%def name="render_member_tab()"> + <b-tab-item label="Member" icon-pack="fas" :icon="members.length ? 'check' : null"> + + <div v-if="members.length"> + + <div style="display: flex; justify-content: space-between;"> + <p>{{ person.display_name }} is associated with <strong>{{ members.length }}</strong> member account(s)</p> + </div> + + <br /> + <b-collapse v-for="member in members" + :key="member.uuid" + class="panel" + :open="members.length == 1"> + + <div slot="trigger" + slot-scope="props" + class="panel-heading" + role="button"> + <b-icon pack="fas" + icon="caret-right"> + </b-icon> + <strong>#{{ member.id }} {{ member.display }}</strong> + </div> + + <div class="panel-block"> + <div style="display: flex; justify-content: space-between; width: 100%;"> + <div style="flex-grow: 1;"> + + <b-field horizontal label="ID"> + {{ member.id }} + </b-field> + + <b-field horizontal label="Active"> + {{ member.active }} + </b-field> + + <b-field horizontal label="Joined"> + {{ member.joined }} + </b-field> + + <b-field horizontal label="Withdrew" + v-if="member.withdrew"> + {{ member.withdrew }} + </b-field> + + </div> + <div class="buttons" style="align-items: start;"> + ${self.render_member_panel_buttons(member)} + </div> + </div> + </div> + </b-collapse> + </div> + + <div v-if="!members.length"> + <p>{{ person.display_name }} has never had a member account.</p> + </div> + + </b-tab-item> +</%def> + +<%def name="render_member_panel_buttons(member)"> + % if request.has_perm('members.view'): + <b-button tag="a" :href="member.view_url"> + View Member + </b-button> + % endif +</%def> + <%def name="render_customer_tab()"> <b-tab-item label="Customer" icon-pack="fas" :icon="customers.length ? 'check' : null"> @@ -236,6 +306,8 @@ ${self.render_customer_tab()} + ${self.render_member_tab()} + <b-tab-item label="Employee" ${'icon="check" icon-pack="fas"' if employee else ''|n}> % if employee: @@ -402,6 +474,7 @@ activeTab: 0, person: ${json.dumps(person_data)|n}, customers: ${json.dumps(customers_data)|n}, + members: ${json.dumps(members_data)|n}, } }, } diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index 210393c1..cdb44429 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -92,6 +92,7 @@ class CustomersView(MasterView): 'active_in_pos_sticky', 'people', 'groups', + 'members', ] mobile_form_fields = [ @@ -250,6 +251,18 @@ class CustomersView(MasterView): f.set_renderer('groups', self.render_groups) f.set_readonly('groups') + def configure_form(self, f): + super(CustomersView, self).configure_form(f) + customer = f.model_instance + permission_prefix = self.get_permission_prefix() + + # members + if self.creating: + f.remove_field('members') + else: + f.set_renderer('members', self.render_members) + f.set_readonly('members') + def template_kwargs_view(self, **kwargs): kwargs['show_profiles_helper'] = self.show_profiles_helper return kwargs @@ -336,6 +349,17 @@ class CustomersView(MasterView): items.append(HTML.tag('li', tags.link_to(text, url))) return HTML.tag('ul', HTML.literal('').join(items)) + def render_members(self, customer, field): + members = customer.members + if not members: + return "" + items = [] + for member in members: + text = six.text_type(member) + url = self.request.route_url('members.view', uuid=member.uuid) + items.append(HTML.tag('li', tags.link_to(text, url))) + return HTML.tag('ul', HTML.literal('').join(items)) + def get_version_child_classes(self): return [ (model.CustomerPhoneNumber, 'parent_uuid'), diff --git a/tailbone/views/members.py b/tailbone/views/members.py index a39fb8c1..f887ae9f 100644 --- a/tailbone/views/members.py +++ b/tailbone/views/members.py @@ -113,7 +113,7 @@ class MemberView(MasterView): def grid_extra_class(self, member, i): if not member.active: return 'warning' - if not member.equity_current: + if member.equity_current is False: return 'notice' def configure_form(self, f): @@ -158,7 +158,7 @@ class MemberView(MasterView): f.set_label('customer_uuid', "Customer") else: f.set_readonly('customer') - # f.set_renderer('customer', self.render_customer) + f.set_renderer('customer', self.render_customer) # default_email f.set_renderer('default_email', self.render_default_email) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index eb3f39fa..402cd493 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -32,6 +32,7 @@ from sqlalchemy import orm from rattail.db import model, api from rattail.time import localtime +from rattail.util import OrderedDict import colander from pyramid.httpexceptions import HTTPFound, HTTPNotFound @@ -316,6 +317,7 @@ class PeopleView(MasterView): 'today': localtime(self.rattail_config).date(), 'person_data': self.get_context_person(person), 'customers_data': self.get_context_customers(person), + 'members_data': self.get_context_members(person), 'employee': employee, 'employee_view_url': self.request.route_url('employees.view', uuid=employee.uuid) if employee else None, 'employee_history': employee.get_current_history() if employee else None, @@ -366,6 +368,30 @@ class PeopleView(MasterView): }) return data + def get_context_members(self, person): + data = OrderedDict() + + for member in person.members: + data[member.uuid] = self.get_context_member(member) + + for customer in person.customers: + for member in customer.members: + if member.uuid not in data: + data[member.uuid] = self.get_context_member(member) + + return list(data.values()) + + def get_context_member(self, member): + return { + 'uuid': member.uuid, + 'id': member.id, + 'active': member.active, + 'joined': six.text_type(member.joined) if member.joined else None, + 'withdrew': six.text_type(member.withdrew) if member.withdrew else None, + 'display': six.text_type(member), + 'view_url': self.request.route_url('members.view', uuid=member.uuid), + } + def get_context_employee_history(self, employee): data = [] if employee: From 72796d1e04cc4c5f0819a64541c599facb8ea482 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 18 Mar 2020 12:29:18 -0500 Subject: [PATCH 0084/1681] Expose new `Member.number` field --- tailbone/templates/people/view_profile_buefy.mako | 4 ++++ tailbone/views/members.py | 6 ++++-- tailbone/views/people.py | 1 + 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako index c10b808b..6c592d45 100644 --- a/tailbone/templates/people/view_profile_buefy.mako +++ b/tailbone/templates/people/view_profile_buefy.mako @@ -38,6 +38,10 @@ <div style="display: flex; justify-content: space-between; width: 100%;"> <div style="flex-grow: 1;"> + <b-field horizontal label="Number"> + {{ member.number }} + </b-field> + <b-field horizontal label="ID"> {{ member.id }} </b-field> diff --git a/tailbone/views/members.py b/tailbone/views/members.py index f887ae9f..f45032d6 100644 --- a/tailbone/views/members.py +++ b/tailbone/views/members.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -50,6 +50,7 @@ class MemberView(MasterView): } grid_columns = [ + 'number', 'id', 'person', 'customer', @@ -62,6 +63,7 @@ class MemberView(MasterView): ] form_fields = [ + 'number', 'id', 'person', 'customer', @@ -105,7 +107,7 @@ class MemberView(MasterView): g.set_filter('email', model.MemberEmailAddress.address) g.set_label('email', "Email Address") - g.set_sort_defaults('id') + g.set_sort_defaults('number') g.set_link('person') g.set_link('customer') diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 402cd493..2c1b7b27 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -384,6 +384,7 @@ class PeopleView(MasterView): def get_context_member(self, member): return { 'uuid': member.uuid, + 'number': member.number, 'id': member.id, 'active': member.active, 'joined': six.text_type(member.joined) if member.joined else None, From eb57ebe62b81b9848dfcd699ff2199cf1248f36e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 18 Mar 2020 12:45:11 -0500 Subject: [PATCH 0085/1681] Show member number by default instead of ID for now.. should probably make configurable though --- tailbone/templates/people/view_profile_buefy.mako | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako index 6c592d45..9f53e6dc 100644 --- a/tailbone/templates/people/view_profile_buefy.mako +++ b/tailbone/templates/people/view_profile_buefy.mako @@ -31,7 +31,7 @@ <b-icon pack="fas" icon="caret-right"> </b-icon> - <strong>#{{ member.id }} {{ member.display }}</strong> + <strong>#{{ member.number }} {{ member.display }}</strong> </div> <div class="panel-block"> From 0ea4b98b1f88790e4af71be049ee1ffa8048c43b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 18 Mar 2020 13:15:11 -0500 Subject: [PATCH 0086/1681] Expose more Member data, relationships with Customer, Person --- tailbone/helpers.py | 5 ++- tailbone/templates/members/view.mako | 10 +++-- .../templates/people/view_profile_buefy.mako | 10 +++++ tailbone/views/members.py | 2 +- tailbone/views/people.py | 39 +++++++++++++++++-- 5 files changed, 56 insertions(+), 10 deletions(-) diff --git a/tailbone/helpers.py b/tailbone/helpers.py index 603a85f1..6ede14a4 100644 --- a/tailbone/helpers.py +++ b/tailbone/helpers.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -30,7 +30,8 @@ import datetime from decimal import Decimal from rattail.time import localtime, make_utc -from rattail.util import pretty_quantity, pretty_hours, hours_as_decimal +from rattail.util import (pretty_quantity, pretty_hours, hours_as_decimal, + OrderedDict) from webhelpers2.html import * from webhelpers2.html.tags import * diff --git a/tailbone/templates/members/view.mako b/tailbone/templates/members/view.mako index 1af22f98..3f2b6c14 100644 --- a/tailbone/templates/members/view.mako +++ b/tailbone/templates/members/view.mako @@ -4,14 +4,16 @@ <%def name="object_helpers()"> ${parent.object_helpers()} - <% people = [] %> + <% people = h.OrderedDict() %> % if instance.person: - <% people.append(instance.person) %> + <% people[instance.person.uuid] = instance.person %> % endif % if instance.customer: - <% people.extend(instance.customer.people) %> + % for person in instance.customer.people: + <% people[person.uuid] = person %> + % endfor % endif - ${view_profiles_helper(people)} + ${view_profiles_helper(people.values())} </%def> ${parent.body()} diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako index 9f53e6dc..c3017aa0 100644 --- a/tailbone/templates/people/view_profile_buefy.mako +++ b/tailbone/templates/people/view_profile_buefy.mako @@ -59,6 +59,16 @@ {{ member.withdrew }} </b-field> + <b-field horizontal label="Person"> + <a v-if="member.person_uuid != person.uuid" + :href="member.view_profile_url"> + {{ member.person_display_name }} + </a> + <span v-if="member.person_uuid == person.uuid"> + {{ member.person_display_name }} + </span> + </b-field> + </div> <div class="buttons" style="align-items: start;"> ${self.render_member_panel_buttons(member)} diff --git a/tailbone/views/members.py b/tailbone/views/members.py index f45032d6..070b543a 100644 --- a/tailbone/views/members.py +++ b/tailbone/views/members.py @@ -143,7 +143,7 @@ class MemberView(MasterView): f.set_label('person_uuid', "Person") else: f.set_readonly('person') - # f.set_renderer('person', self.render_person) + f.set_renderer('person', self.render_person) # customer if self.creating or self.editing: diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 2c1b7b27..f21a88b6 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -79,6 +79,7 @@ class PeopleView(MasterView): 'address', 'employee', 'customers', + 'members', 'users', ] @@ -241,6 +242,13 @@ class PeopleView(MasterView): f.set_readonly('customers') f.set_renderer('customers', self.render_customers) + # members + if self.creating: + f.remove_field('members') + else: + f.set_readonly('members') + f.set_renderer('members', self.render_members) + # users if self.creating: f.remove_field('users') @@ -264,15 +272,30 @@ class PeopleView(MasterView): for customer in customers: customer = customer.customer text = six.text_type(customer) - if customer.id: + if customer.number: + text = "(#{}) {}".format(customer.number, text) + elif customer.id: text = "({}) {}".format(customer.id, text) - elif customer.number: - text = "({}) {}".format(customer.number, text) route = '{}customers.view'.format('mobile.' if self.mobile else '') url = self.request.route_url(route, uuid=customer.uuid) items.append(HTML.tag('li', c=[tags.link_to(text, url)])) return HTML.tag('ul', c=items) + def render_members(self, person, field): + members = person.members + if not members: + return "" + items = [] + for member in members: + text = six.text_type(member) + if member.number: + text = "(#{}) {}".format(member.number, text) + elif member.id: + text = "({}) {}".format(member.id, text) + url = self.request.route_url('members.view', uuid=member.uuid) + items.append(HTML.tag('li', c=[tags.link_to(text, url)])) + return HTML.tag('ul', c=items) + def render_users(self, person, field): use_buefy = self.get_use_buefy() users = person.users @@ -382,6 +405,11 @@ class PeopleView(MasterView): return list(data.values()) def get_context_member(self, member): + profile_url = None + if member.person: + profile_url = self.request.route_url('people.view_profile', + uuid=member.person_uuid) + return { 'uuid': member.uuid, 'number': member.number, @@ -389,8 +417,13 @@ class PeopleView(MasterView): 'active': member.active, 'joined': six.text_type(member.joined) if member.joined else None, 'withdrew': six.text_type(member.withdrew) if member.withdrew else None, + 'customer_uuid': member.customer_uuid, + 'customer_name': member.customer.name if member.customer else None, + 'person_uuid': member.person_uuid, 'display': six.text_type(member), + 'person_display_name': member.person.display_name if member.person else None, 'view_url': self.request.route_url('members.view', uuid=member.uuid), + 'view_profile_url': profile_url, } def get_context_employee_history(self, employee): From e57010cd3da4ae40e20577f930cbf3954c40f88e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 18 Mar 2020 23:44:34 -0500 Subject: [PATCH 0087/1681] Update changelog --- CHANGES.rst | 32 ++++++++++++++++++++++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 2a7095d1..ee769424 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,38 @@ CHANGELOG ========= +0.8.90 (2020-03-18) +------------------- + +* Add basic "ordering worksheet" API. + +* Tweak GPC grid filter, to better handle spaces in user input. + +* Only show tables for "public" schema. + +* Remove old/unwanted Vue.js index experiment, for Users table. + +* Misc. changes to User, Role permissions and management thereof. + +* Don't let user delete roles to which they belong, without permission. + +* Prevent deletion of department which still has products. + +* Add sort/filter for Department Name, in Subdepartments grid. + +* Allow "touch" for Department, Subdepartment. + +* Expose ``Customer.number`` field. + +* Add support for "bulk-delete" of Person table. + +* Allow customization for Customers tab of Profile view. + +* Expose default email address, phone number when editing a Person. + +* Add/improve various display of Member data. + + 0.8.89 (2020-03-11) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 7afe788d..b8155370 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.89' +__version__ = '0.8.90' From 3223a77cb182f544b8dea7f54af920b8835b8580 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 19 Mar 2020 00:02:27 -0500 Subject: [PATCH 0088/1681] Add "danger" style for "delete" grid row action --- tailbone/templates/grids/buefy.mako | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako index ee6a1fe7..1d47310b 100644 --- a/tailbone/templates/grids/buefy.mako +++ b/tailbone/templates/grids/buefy.mako @@ -180,7 +180,7 @@ % for action in grid.main_actions: <a v-if="props.row._action_url_${action.key}" :href="props.row._action_url_${action.key}" - class="grid-action" + class="grid-action${' has-text-danger' if action.key == 'delete' else ''}" % if action.click_handler: @click.prevent="${action.click_handler}" % endif From ad9c193061d38039cb36d91dbe079fc4a38e198b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 19 Mar 2020 14:36:43 -0500 Subject: [PATCH 0089/1681] Clean up some purchasing views --- tailbone/views/purchases/core.py | 8 ++++++++ tailbone/views/purchasing/batch.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/tailbone/views/purchases/core.py b/tailbone/views/purchases/core.py index bc39fc3e..fbb77bc9 100644 --- a/tailbone/views/purchases/core.py +++ b/tailbone/views/purchases/core.py @@ -54,7 +54,9 @@ class PurchaseView(MasterView): 'department', 'buyer', 'date_ordered', + 'po_total', 'date_received', + 'invoice_total', 'invoice_number', 'status', ] @@ -177,7 +179,13 @@ class PurchaseView(MasterView): g.filters['status'].verbs = ['equal', 'not_equal', 'is_any'] g.filters['status'].default_verb = 'is_any' + g.set_type('po_total', 'currency') + g.set_type('invoice_total', 'currency') g.set_label('invoice_number', "Invoice No.") + g.set_link('date_ordered') + g.set_link('po_total') + g.set_link('date_received') + g.set_link('invoice_total') def configure_form(self, f): super(PurchaseView, self).configure_form(f) diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index 8b557188..96f0d18c 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -520,7 +520,7 @@ class PurchasingBatchView(BatchMasterView): elif purchase.status == self.enum.PURCHASE_STATUS_RECEIVED: date = purchase.date_received total = purchase.invoice_total - return '{} for ${:0,.2f} ({})'.format(date, total, purchase.department or purchase.buyer) + return '{} for ${:0,.2f} ({})'.format(date, total or 0, purchase.department or purchase.buyer) def get_batch_kwargs(self, batch, mobile=False): kwargs = super(PurchasingBatchView, self).get_batch_kwargs(batch, mobile=mobile) From a721ec4a43b7170f10239bae9596324fe4000fd6 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 20 Mar 2020 13:51:34 -0500 Subject: [PATCH 0090/1681] Misc. API improvements for sake of mobile receiving --- tailbone/api/batch/core.py | 3 ++ tailbone/api/batch/receiving.py | 74 ++++++++++++++++++++++---- tailbone/templates/receiving/view.mako | 3 +- tailbone/views/purchasing/receiving.py | 11 ++-- 4 files changed, 74 insertions(+), 17 deletions(-) diff --git a/tailbone/api/batch/core.py b/tailbone/api/batch/core.py index 7f9232a9..fcd27283 100644 --- a/tailbone/api/batch/core.py +++ b/tailbone/api/batch/core.py @@ -107,6 +107,9 @@ class APIBatchView(APIBatchMixin, APIMasterView): 'created_by_uuid': batch.created_by.uuid, 'created_by_display': six.text_type(batch.created_by), 'complete': batch.complete, + 'status_code': batch.status_code, + 'status_display': batch.STATUS.get(batch.status_code, + six.text_type(batch.status_code)), 'executed': executed, 'executed_by_uuid': batch.executed_by_uuid, 'executed_by_display': six.text_type(batch.executed_by or ''), diff --git a/tailbone/api/batch/receiving.py b/tailbone/api/batch/receiving.py index a44540b8..71a8bcba 100644 --- a/tailbone/api/batch/receiving.py +++ b/tailbone/api/batch/receiving.py @@ -34,6 +34,7 @@ import humanize from rattail import pod from rattail.db import model from rattail.time import make_utc +from rattail.util import pretty_quantity from deform import widget as dfwidget @@ -54,6 +55,7 @@ class ReceivingBatchViews(APIBatchView): collection_url_prefix = '/receiving-batches' object_url_prefix = '/receiving-batch' supports_toggle_complete = True + supports_execute = True def base_query(self): query = super(ReceivingBatchViews, self).base_query() @@ -69,20 +71,15 @@ class ReceivingBatchViews(APIBatchView): data['department_uuid'] = batch.department_uuid data['department_display'] = six.text_type(batch.department) if batch.department else None - return data + data['po_total'] = batch.po_total + data['invoice_total'] = batch.invoice_total + data['invoice_total_calculated'] = batch.invoice_total_calculated - def get_purchase(self, uuid): - return self.Session.query(model.Purchase).get(uuid) + return data def create_object(self, data): data = dict(data) - data['mode'] = self.enum.PURCHASE_BATCH_MODE_RECEIVING - - # if 'purchase_key' in data: - # purchase = self.get_purchase(data['purchase_key']) - # data['purchase'] = purchase - batch = super(ReceivingBatchViews, self).create_object(data) return batch @@ -135,7 +132,7 @@ class ReceivingBatchViews(APIBatchView): elif purchase.status == self.enum.PURCHASE_STATUS_RECEIVED: date = purchase.date_received total = purchase.invoice_total - return '{} for ${:0,.2f} ({})'.format(date, total, purchase.department or purchase.buyer) + return '{} for ${:0,.2f} ({})'.format(date, total or 0, purchase.department or purchase.buyer) @classmethod def defaults(cls, config): @@ -241,6 +238,15 @@ class ReceivingBatchRowViews(APIBatchRowView): {'field': 'units_shipped', 'op': 'is_null'}, {'field': 'units_shipped', 'op': '==', 'value': 0}, ]}, + {'or': [ + # but "unexpected" also implies we have some confirmed amount(s) + {'field': 'cases_received', 'op': '!=', 'value': 0}, + {'field': 'units_received', 'op': '!=', 'value': 0}, + {'field': 'cases_damaged', 'op': '!=', 'value': 0}, + {'field': 'units_damaged', 'op': '!=', 'value': 0}, + {'field': 'cases_expired', 'op': '!=', 'value': 0}, + {'field': 'units_expired', 'op': '!=', 'value': 0}, + ]}, ]}, ]) @@ -271,6 +277,7 @@ class ReceivingBatchRowViews(APIBatchRowView): batch = row.batch data = super(ReceivingBatchRowViews, self).normalize(row) + data['product_uuid'] = row.product_uuid data['item_id'] = row.item_id data['upc'] = six.text_type(row.upc) data['upc_pretty'] = row.upc.pretty() if row.upc else None @@ -304,6 +311,53 @@ class ReceivingBatchRowViews(APIBatchRowView): data['cases_expired'] = row.cases_expired data['units_expired'] = row.units_expired + data['po_unit_cost'] = row.po_unit_cost + data['po_total'] = row.po_total + + data['invoice_unit_cost'] = row.invoice_unit_cost + data['invoice_total'] = row.invoice_total + data['invoice_total_calculated'] = row.invoice_total_calculated + + data['allow_cases'] = self.handler.allow_cases() + + data['quick_receive'] = self.rattail_config.getbool( + 'rattail.batch', 'purchase.mobile_quick_receive', + default=True) + + if batch.order_quantities_known: + data['quick_receive_all'] = self.rattail_config.getbool( + 'rattail.batch', 'purchase.mobile_quick_receive_all', + default=False) + + # TODO: this was copied from regular view receive_row() method; should merge + if data['quick_receive'] and data.get('quick_receive_all'): + if data['allow_cases']: + data['quick_receive_uom'] = 'CS' + 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 + + if accounted_for: + # some product accounted for; button should receive "remainder" only + if remainder: + remainder = pretty_quantity(remainder) + data['quick_receive_quantity'] = remainder + data['quick_receive_text'] = "Receive Remainder ({} {})".format( + remainder, data['unit_uom']) + else: + # unless there is no remainder, in which case disable it + data['quick_receive'] = False + + else: # nothing yet accounted for, button should receive "all" + if not remainder: + log.warning("quick receive remainder is empty for row %s", row.uuid) + remainder = pretty_quantity(remainder) + data['quick_receive_quantity'] = remainder + data['quick_receive_text'] = "Receive ALL ({} {})".format( + remainder, data['unit_uom']) + data['unexpected_alert'] = None if batch.order_quantities_known and not row.cases_ordered and not row.units_ordered: warn = True diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako index 1bd2427e..4bdf5862 100644 --- a/tailbone/templates/receiving/view.mako +++ b/tailbone/templates/receiving/view.mako @@ -285,7 +285,8 @@ <%def name="object_helpers()"> ${parent.object_helpers()} - % if not request.rattail_config.production(): + ## TODO: for now this is a truck-dump-only feature? maybe should change that + % if not request.rattail_config.production() and master.allow_truck_dump: % if not batch.executed and not batch.complete and request.has_perm('admin'): % if (batch.is_truck_dump_parent() and batch.truck_dump_children_first) or not batch.is_truck_dump_related(): <div class="object-helper"> diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index b37fe9ee..8a8ca0dd 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -130,6 +130,7 @@ class ReceivingBatchView(PurchasingBatchView): model_title_plural = "Receiving Batches" index_title = "Receiving" downloadable = True + bulk_deletable = True rows_editable = True mobile_creatable = True mobile_rows_filterable = True @@ -200,6 +201,7 @@ class ReceivingBatchView(PurchasingBatchView): 'truck_dump_status', 'rowcount', 'order_quantities_known', + 'receiving_complete', 'complete', 'executed', 'executed_by', @@ -639,9 +641,6 @@ class ReceivingBatchView(PurchasingBatchView): default_value=default_status) return filters - def get_purchase(self, uuid): - return self.Session.query(model.Purchase).get(uuid) - def mobile_create(self): """ Mobile view for creating a new receiving batch @@ -759,9 +758,9 @@ class ReceivingBatchView(PurchasingBatchView): Assign the original purchase order to the given batch. Default behavior assumes a Rattail Purchase object is what we're after. """ - purchase = self.get_purchase(po_form.validated[self.purchase_order_fieldname]) - if isinstance(purchase, model.Purchase): - batch.purchase_uuid = purchase.uuid + purchase = self.handler.assign_purchase_order( + batch, po_form.validated[self.purchase_order_fieldname], + session=self.Session()) department = self.department_for_purchase(purchase) if department: From 1570871884345877d7c04e918e7ba6af59beaa24 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 20 Mar 2020 14:40:27 -0500 Subject: [PATCH 0091/1681] Use proper cornice service registration, for API batch execute etc. --- tailbone/api/batch/core.py | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/tailbone/api/batch/core.py b/tailbone/api/batch/core.py index fcd27283..a3e4ab71 100644 --- a/tailbone/api/batch/core.py +++ b/tailbone/api/batch/core.py @@ -31,7 +31,7 @@ import six from rattail.time import localtime from rattail.util import load_object -from cornice import resource +from cornice import resource, Service from tailbone.api import APIMasterView2 as APIMasterView @@ -213,25 +213,27 @@ class APIBatchView(APIBatchMixin, APIMasterView): if cls.supports_toggle_complete: # mark complete - config.add_route('{}.mark_complete'.format(route_prefix), '{}/{{uuid}}/mark-complete'.format(object_url_prefix)) - config.add_view(cls, attr='mark_complete', route_name='{}.mark_complete'.format(route_prefix), - permission='{}.edit'.format(permission_prefix), - renderer='json') + mark_complete = Service(name='{}.mark_complete'.format(route_prefix), + path='{}/{{uuid}}/mark-complete'.format(object_url_prefix)) + mark_complete.add_view('POST', 'mark_complete', klass=cls, + permission='{}.edit'.format(permission_prefix)) + config.add_cornice_service(mark_complete) # mark incomplete - config.add_route('{}.mark_incomplete'.format(route_prefix), '{}/{{uuid}}/mark-incomplete'.format(object_url_prefix)) - config.add_view(cls, attr='mark_incomplete', route_name='{}.mark_incomplete'.format(route_prefix), - permission='{}.edit'.format(permission_prefix), - renderer='json') + mark_incomplete = Service(name='{}.mark_incomplete'.format(route_prefix), + path='{}/{{uuid}}/mark-incomplete'.format(object_url_prefix)) + mark_incomplete.add_view('POST', 'mark_incomplete', klass=cls, + permission='{}.edit'.format(permission_prefix)) + config.add_cornice_service(mark_incomplete) if cls.supports_execute: - # execute - config.add_route('{}.execute'.format(route_prefix), '{}/{{uuid}}/execute'.format(object_url_prefix), - request_method=('OPTIONS', 'POST')) - config.add_view(cls, attr='execute', route_name='{}.execute'.format(route_prefix), - permission='{}.execute'.format(permission_prefix), - renderer='json') + # execute batch + execute = Service(name='{}.execute'.format(route_prefix), + path='{}/{{uuid}}/execute'.format(object_url_prefix)) + execute.add_view('POST', 'execute', klass=cls, + permission='{}.execute'.format(permission_prefix)) + config.add_cornice_service(execute) # TODO: deprecate / remove this From 297ca3fe1145561803a7eaabf471243912dc24a1 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 20 Mar 2020 14:58:29 -0500 Subject: [PATCH 0092/1681] Fix default row grid config logic for batches make sure we don't overwrite configured row labels --- tailbone/views/batch/core.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 7afc4dbd..9f992bf8 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -583,22 +583,20 @@ class BatchMasterView(MasterView): def configure_row_grid(self, g): super(BatchMasterView, self).configure_row_grid(g) + g.set_sort_defaults('sequence') + g.set_link('sequence') + g.set_label('sequence', "Seq.") + if 'sequence' in g.filters: + g.filters['sequence'].label = "Sequence" + if 'status_code' in g.filters: g.filters['status_code'].set_value_renderer(grids.filters.EnumValueRenderer(self.model_row_class.STATUS)) - g.set_sort_defaults('sequence') - if self.model_row_class: g.set_enum('status_code', self.model_row_class.STATUS) g.set_renderer('status_code', self.render_row_status) - g.set_label('sequence', "Seq.") - g.set_label('status_code', "Status") - g.set_label('item_id', "Item ID") - - g.set_link('sequence') - def get_row_status_enum(self): return self.model_row_class.STATUS From 51e1a85f0b2521e691920859e9d413d16ac7bad7 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 22 Mar 2020 16:42:05 -0500 Subject: [PATCH 0093/1681] Fix some spacing in header for Buefy theme --- tailbone/templates/themes/falafel/base.mako | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index 0e2754f1..24f3acf5 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -249,7 +249,7 @@ % elif index_url: ${h.link_to(index_title, index_url)} % if parent_url is not Undefined: - <span>»</span> + <span> »</span> ${h.link_to(parent_title, parent_url)} % elif instance_url is not Undefined: <span> »</span> From e04e67774e04a2bacf7b6989a6c40440f7a46a0f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 23 Mar 2020 19:33:00 -0500 Subject: [PATCH 0094/1681] Add common permission for sending user feedback there can be valid reasons to *not* expose that, so let admin decide --- tailbone/api/common.py | 3 ++- tailbone/templates/base.mako | 4 +++- tailbone/templates/themes/falafel/base.mako | 8 +++++--- tailbone/views/common.py | 10 +++++++--- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/tailbone/api/common.py b/tailbone/api/common.py index 0b752adf..0552b68d 100644 --- a/tailbone/api/common.py +++ b/tailbone/api/common.py @@ -119,7 +119,8 @@ class CommonView(APIView): # feedback feedback = Service(name='feedback', path='/feedback') - feedback.add_view('POST', 'feedback', klass=cls) + feedback.add_view('POST', 'feedback', klass=cls, + permission='common.feedback') config.add_cornice_service(feedback) diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index aea0c0e5..daa60e2d 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -71,7 +71,9 @@ % if help_url is not Undefined and help_url: ${h.link_to("Help", help_url, target='_blank', class_='button')} % endif - <button type="button" id="feedback">Feedback</button> + % if request.has_perm('common.feedback'): + <button type="button" id="feedback">Feedback</button> + % endif </div> % if expose_theme_picker and request.has_perm('common.change_app_theme'): diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index 24f3acf5..713d9547 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -330,9 +330,11 @@ % endif ## Feedback Button / Dialog - <feedback-form - action="${url('feedback')}"> - </feedback-form> + % if request.has_perm('common.feedback'): + <feedback-form + action="${url('feedback')}"> + </feedback-form> + % endif </div><!-- level-right --> </nav><!-- level --> diff --git a/tailbone/views/common.py b/tailbone/views/common.py index 8aced214..dd02e614 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -229,10 +229,14 @@ class CommonView(View): config.add_view(cls, attr='change_theme', route_name='change_theme') # feedback + config.add_tailbone_permission('common', 'common.feedback', + "Send user feedback (to admins) about the app") config.add_route('feedback', '/feedback', request_method='POST') - config.add_view(cls, attr='feedback', route_name='feedback', renderer='json') + config.add_view(cls, attr='feedback', route_name='feedback', + renderer='json', permission='common.feedback') config.add_route('mobile.feedback', '/mobile/feedback', request_method='POST') - config.add_view(cls, attr='mobile_feedback', route_name='mobile.feedback', renderer='json') + config.add_view(cls, attr='mobile_feedback', route_name='mobile.feedback', + renderer='json', permission='common.feedback') # consume batch ID config.add_tailbone_permission('common', 'common.consume_batch_id', From cd019fb05bb5b5dfee9a38e6ca9a8992be8d1ad8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 23 Mar 2020 19:33:56 -0500 Subject: [PATCH 0095/1681] Fix the "change password" form per Buefy theme --- tailbone/templates/change_password.mako | 8 +------- tailbone/views/auth.py | 9 +++------ 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/tailbone/templates/change_password.mako b/tailbone/templates/change_password.mako index 52cd55fd..c64acebf 100644 --- a/tailbone/templates/change_password.mako +++ b/tailbone/templates/change_password.mako @@ -1,13 +1,7 @@ ## -*- coding: utf-8; -*- -<%inherit file="/page.mako" /> +<%inherit file="/form.mako" /> <%def name="title()">Change Password</%def> -<%def name="page_content()"> - <div class="form"> - ${form.render_deform()|n} - </div> -</%def> - ${parent.body()} diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index d95ee9a5..2dd37e2c 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -172,18 +172,15 @@ class AuthenticationView(View): if not self.request.user: return self.redirect(self.request.route_url('home')) - if self.rattail_config.demo() and self.request.user.username == 'chuck': - self.request.session.flash("Cannot change password for 'chuck' in demo mode", 'error') - return self.redirect(self.request.get_referrer()) - + use_buefy = self.get_use_buefy() schema = ChangePassword().bind(user=self.request.user) - form = forms.Form(schema=schema, request=self.request) + form = forms.Form(schema=schema, request=self.request, use_buefy=use_buefy) if form.validate(newstyle=True): set_user_password(self.request.user, form.validated['new_password']) self.request.session.flash("Your password has been changed.") return self.redirect(self.request.get_referrer()) - return {'form': form} + return {'form': form, 'use_buefy': use_buefy} def become_root(self): """ From 917d5ab3facdfa480d3f18a0d028fd86e375de9e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 23 Mar 2020 19:59:28 -0500 Subject: [PATCH 0096/1681] Expose the `Role.notes` field for view/edit also add a simple "<pre> with sans-serif font" renderer --- tailbone/forms/core.py | 12 +++++++++++- tailbone/static/css/base.css | 7 +++++++ tailbone/views/roles.py | 20 ++++++++++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 5cc41771..cf7dd49e 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -609,6 +609,7 @@ class Form(object): self.set_renderer(key, self.render_codeblock) self.set_widget(key, dfwidget.TextAreaWidget(cols=80, rows=8)) elif type_ == 'text': + self.set_renderer(key, self.render_pre_sans_serif) self.set_widget(key, dfwidget.TextAreaWidget(cols=80, rows=8)) elif type_ == 'file': tmpstore = SessionFileUploadTempStore(self.request) @@ -903,6 +904,15 @@ class Form(object): return "" return HTML.tag('pre', value) + def render_pre_sans_serif(self, record, field_name): + value = self.obtain_value(record, field_name) + if value is None: + return "" + # this uses a Bulma helper class, for which we also add custom styles + # to our "default" base.css (for jquery theme) + return HTML.tag('pre', class_='is-family-sans-serif', + c=value) + def obtain_value(self, record, field_name): if record: try: diff --git a/tailbone/static/css/base.css b/tailbone/static/css/base.css index 466ca50b..689fb000 100644 --- a/tailbone/static/css/base.css +++ b/tailbone/static/css/base.css @@ -91,6 +91,13 @@ ul.error li { list-style-type: none; } +pre.is-family-sans-serif { + background-color: white; + font-family: Verdana, Arial, sans-serif; + font-size: 11pt; + padding: 1em; +} + /****************************** * jQuery UI tweaks ******************************/ diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py index 02d7daf1..48ef827c 100644 --- a/tailbone/views/roles.py +++ b/tailbone/views/roles.py @@ -35,6 +35,7 @@ from rattail.db.auth import (has_permission, grant_permission, revoke_permission import colander from deform import widget as dfwidget +from webhelpers2.html import HTML from tailbone import grids from tailbone.db import Session @@ -51,21 +52,37 @@ class RolesView(PrincipalMasterView): grid_columns = [ 'name', 'session_timeout', + 'notes', ] form_fields = [ 'name', 'session_timeout', + 'notes', 'permissions', ] def configure_grid(self, g): super(RolesView, self).configure_grid(g) + + # name g.filters['name'].default_active = True g.filters['name'].default_verb = 'contains' g.set_sort_defaults('name') g.set_link('name') + # notes + g.set_renderer('notes', self.render_short_notes) + + def render_short_notes(self, role, field): + value = getattr(role, field) + if value is None: + return "" + if len(value) < 100: + return value + return HTML.tag('span', title=value, + c="{}...".format(value[:100])) + def editable_instance(self, role): """ We must prevent edit for certain built-in roles etc., depending on @@ -124,6 +141,9 @@ class RolesView(PrincipalMasterView): # name f.set_validator('name', self.unique_name) + # notes + f.set_type('notes', 'text') + # permissions self.tailbone_permissions = self.get_available_permissions() f.set_renderer('permissions', PermissionsRenderer(permissions=self.tailbone_permissions)) From af4be59fe09b846d9b21df5e0a32a74cc167b7c1 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 23 Mar 2020 20:24:03 -0500 Subject: [PATCH 0097/1681] Add "local only" column to Users grid but only show if user has perm of course --- tailbone/views/master.py | 1 + tailbone/views/users.py | 1 + 2 files changed, 2 insertions(+) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 4c5aacc3..2d66a1e4 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -411,6 +411,7 @@ class MasterView(View): # hide "local only" grid filter, unless global access allowed if self.secure_global_objects: if not self.has_perm('view_global'): + grid.hide_column('local_only') grid.remove_filter('local_only') def grid_extra_class(self, obj, i): diff --git a/tailbone/views/users.py b/tailbone/views/users.py index 8b45ca55..79e2590c 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -57,6 +57,7 @@ class UsersView(PrincipalMasterView): 'username', 'person', 'active', + 'local_only', ] form_fields = [ From eaeda6ca367ba2ae92fe6035caf1d29806288b05 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 23 Mar 2020 20:55:46 -0500 Subject: [PATCH 0098/1681] Fix row status filter for Import/Export batches per Buefy theme --- tailbone/grids/filters.py | 2 +- tailbone/views/batch/importer.py | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py index b80f6050..54643c94 100644 --- a/tailbone/grids/filters.py +++ b/tailbone/grids/filters.py @@ -210,7 +210,7 @@ class GridFilter(object): normalized = choices elif isinstance(choices, dict): normalized = OrderedDict([ - (key, choices[value]) + (key, choices[key]) for key in sorted(choices)]) elif isinstance(choices, list): normalized = OrderedDict([ diff --git a/tailbone/views/batch/importer.py b/tailbone/views/batch/importer.py index 77ebd6b4..001de0ff 100644 --- a/tailbone/views/batch/importer.py +++ b/tailbone/views/batch/importer.py @@ -140,6 +140,7 @@ class ImporterBatchView(BatchMasterView): def configure_row_grid(self, g): super(ImporterBatchView, self).configure_row_grid(g) + use_buefy = self.get_use_buefy() def make_filter(field, **kwargs): column = getattr(self.current_row_table.c, field) @@ -147,8 +148,14 @@ class ImporterBatchView(BatchMasterView): make_filter('object_key') make_filter('object_str') - make_filter('status_code', label="Status", - value_enum=self.enum.IMPORTER_BATCH_ROW_STATUS) + + # for some reason we have to do this differently for Buefy? + kwargs = {} + if not use_buefy: + kwargs['value_enum'] = self.enum.IMPORTER_BATCH_ROW_STATUS + make_filter('status_code', label="Status", **kwargs) + if use_buefy: + g.filters['status_code'].set_choices(self.enum.IMPORTER_BATCH_ROW_STATUS) def make_sorter(field): column = getattr(self.current_row_table.c, field) From 13802c49a8d4d19cad0bf7c3fb89fa4bbebacbad Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 23 Mar 2020 21:25:43 -0500 Subject: [PATCH 0099/1681] Add "generic" `render_id_str()` method to MasterView not sure how useful, but maybe --- tailbone/views/batch/core.py | 5 +---- tailbone/views/master.py | 6 ++++++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 9f992bf8..3158c621 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -228,7 +228,7 @@ class BatchMasterView(MasterView): g.set_enum('status_code', self.model_class.STATUS) - g.set_renderer('id', self.render_batch_id) + g.set_renderer('id', self.render_id_str) g.set_link('id') g.set_link('description') @@ -238,9 +238,6 @@ class BatchMasterView(MasterView): g.set_label('rowcount', "Rows") g.set_label('status_code', "Status") - def render_batch_id(self, batch, column): - return batch.id_str - def template_kwargs_index(self, **kwargs): route_prefix = self.get_route_prefix() if self.results_executable: diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 2d66a1e4..5acf68ba 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -848,6 +848,12 @@ class MasterView(View): 'importer_host_title': importer_host_title, }) + def render_id_str(self, obj, field): + """ + Render the ``id_str`` attribute value for the given object. + """ + return obj.id_str + def render_default_phone(self, obj, field): """ Render the "default" (first) phone number for the given contact. From af07b433ad455c5325dc5b2d7cc0a532e248ee7d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 23 Mar 2020 21:41:44 -0500 Subject: [PATCH 0100/1681] Fix rendering of batch ID in forms --- tailbone/views/batch/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 3158c621..dbde90cb 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -267,7 +267,7 @@ class BatchMasterView(MasterView): # id f.set_readonly('id') - f.set_renderer('id', self.render_batch_id) + f.set_renderer('id', self.render_id_str) f.set_label('id', "Batch ID") # created From febe651e31c8826ea1b3942b81897295fde97279 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 23 Mar 2020 22:35:24 -0500 Subject: [PATCH 0101/1681] Stop raising an error if view doesn't define row grid columns just show whatever is gonna show by default; they can edit list if they want --- tailbone/views/master.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 5acf68ba..4a16927c 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -461,8 +461,6 @@ class MasterView(View): def get_row_grid_columns(self): if hasattr(self, 'row_grid_columns'): return self.row_grid_columns - # TODO - raise NotImplementedError def make_row_grid_kwargs(self, **kwargs): """ From c14ecd294803e03c3a2126fbb9fa552d3cd0a064 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 24 Mar 2020 18:19:05 -0500 Subject: [PATCH 0102/1681] Add helper function, `get_csrf_token()` --- tailbone/helpers.py | 2 +- tailbone/util.py | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/tailbone/helpers.py b/tailbone/helpers.py index 6ede14a4..3a3d8365 100644 --- a/tailbone/helpers.py +++ b/tailbone/helpers.py @@ -36,7 +36,7 @@ from rattail.util import (pretty_quantity, pretty_hours, hours_as_decimal, from webhelpers2.html import * from webhelpers2.html.tags import * -from tailbone.util import csrf_token, pretty_datetime, raw_datetime +from tailbone.util import csrf_token, get_csrf_token, pretty_datetime, raw_datetime def pretty_date(date): diff --git a/tailbone/util.py b/tailbone/util.py index 85918330..08ffd4cd 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -40,13 +40,22 @@ from pyramid.renderers import get_renderer from webhelpers2.html import HTML, tags -def csrf_token(request, name='_csrf'): +def get_csrf_token(request): """ - Convenience function. Returns CSRF hidden tag inside hidden DIV. + Convenience function to retrieve the effective CSRF token for the given + request. """ token = request.session.get_csrf_token() if token is None: token = request.session.new_csrf_token() + return token + + +def csrf_token(request, name='_csrf'): + """ + Convenience function. Returns CSRF hidden tag inside hidden DIV. + """ + token = get_csrf_token(request) return HTML.tag("div", tags.hidden(name, value=token), style="display:none;") From 2a4832f9b996a411efd004d355533c49eddce10e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 24 Mar 2020 18:19:25 -0500 Subject: [PATCH 0103/1681] Declare the v-model for "dynamic select" widget --- tailbone/templates/deform/select_dynamic.pt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tailbone/templates/deform/select_dynamic.pt b/tailbone/templates/deform/select_dynamic.pt index 6d2516a4..4ce3b01c 100644 --- a/tailbone/templates/deform/select_dynamic.pt +++ b/tailbone/templates/deform/select_dynamic.pt @@ -8,6 +8,7 @@ unicode unicode|str; optgroup_class optgroup_class|field.widget.optgroup_class; multiple multiple|field.widget.multiple; + vmodel vmodel|'field_model_' + name; input_handler input_handler|'';" tal:omit-tag=""> @@ -18,6 +19,7 @@ multiple multiple; size size; style style; + v-model vmodel; @input input_handler;"> <option v-for="item in ${name}_options" From 89ffbd6efc6ac65b54b86a24f0a55c7c57c5d336 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 26 Mar 2020 15:24:16 -0500 Subject: [PATCH 0104/1681] Add support for "choice" widget, for report params also add support for default value, for a param --- tailbone/views/reports.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index 7fbdde03..ace0a7aa 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -41,6 +41,7 @@ from rattail.threads import Thread from rattail.util import simple_error import colander +from deform import widget as dfwidget from mako.template import Template from pyramid.response import Response from webhelpers2.html import HTML @@ -251,9 +252,10 @@ class ReportOutputView(ExportMasterView): if not params: return "" - # TODO: should sort these, perhaps according to Report definition? params = [{'key': key, 'value': value} for key, value in params.items()] + # TODO: should sort these according to true Report definition instead? + params.sort(key=lambda param: param['key']) route_prefix = self.get_route_prefix() g = grids.Grid( @@ -342,6 +344,7 @@ class GenerateReport(View): report_params = report.make_params(Session()) NODE_TYPES = { + bool: colander.Boolean, datetime.date: colander.Date, } @@ -352,10 +355,19 @@ class GenerateReport(View): node_type = NODE_TYPES.get(param.type, colander.String) node = colander.SchemaNode(typ=node_type(), name=param.name) + # maybe setup choices, if appropriate + if param.type == 'choice': + node.widget = dfwidget.SelectWidget( + values=report.get_choices(param.name, Session())) + # allow empty value if param is optional if not param.required: node.missing = colander.null + # maybe set default value + if hasattr(param, 'default'): + node.default = param.default + schema.add(node) form = forms.Form(schema=schema, request=self.request, From aaabde5c5aae30e2fe81b43690ca98e996467e26 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 26 Mar 2020 15:30:14 -0500 Subject: [PATCH 0105/1681] Add link to generate new report, when viewing one --- tailbone/templates/reports/generated/view.mako | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 tailbone/templates/reports/generated/view.mako diff --git a/tailbone/templates/reports/generated/view.mako b/tailbone/templates/reports/generated/view.mako new file mode 100644 index 00000000..c7d34efa --- /dev/null +++ b/tailbone/templates/reports/generated/view.mako @@ -0,0 +1,11 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> + +<%def name="context_menu_items()"> + ${parent.context_menu_items()} + % if request.has_perm('{}.generate'.format(permission_prefix)): + <li>${h.link_to("Generate new Report", url('generate_report'))}</li> + % endif +</%def> + +${parent.body()} From 65f41480ebb978b0bb47124a07e41af900e30d81 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 27 Mar 2020 18:15:33 -0500 Subject: [PATCH 0106/1681] Allow bulk-delete, merge for Brands table --- tailbone/templates/master/index.mako | 2 +- tailbone/views/brands.py | 30 ++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako index cd785bad..cae42362 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -147,7 +147,7 @@ % if master.mergeable and request.has_perm('{}.merge'.format(permission_prefix)): % if use_buefy: - ${h.form(url('{}.merge'.format(route_prefix)), **{'@submit': 'submitMergeForm'})} + ${h.form(url('{}.merge'.format(route_prefix)), class_='control', **{'@submit': 'submitMergeForm'})} % else: ${h.form(url('{}.merge'.format(route_prefix)), name='merge-things')} % endif diff --git a/tailbone/views/brands.py b/tailbone/views/brands.py index c3796af8..b66c3f42 100644 --- a/tailbone/views/brands.py +++ b/tailbone/views/brands.py @@ -37,6 +37,16 @@ class BrandsView(MasterView): """ model_class = model.Brand has_versions = True + bulk_deletable = True + + mergeable = True + merge_additive_fields = [ + 'product_count', + ] + merge_fields = merge_additive_fields + [ + 'uuid', + 'name', + ] grid_columns = [ 'name', @@ -60,6 +70,26 @@ class BrandsView(MasterView): # confirmed g.set_type('confirmed', 'boolean') + def get_merge_data(self, brand): + product_count = self.Session.query(model.Product)\ + .filter(model.Product.brand == brand)\ + .count() + return { + 'uuid': brand.uuid, + 'name': brand.name, + 'product_count': product_count, + } + + def merge_objects(self, removing, keeping): + products = self.Session.query(model.Product)\ + .filter(model.Product.brand == removing)\ + .all() + for product in products: + product.brand = keeping + + self.Session.flush() + self.Session.delete(removing) + class BrandsAutocomplete(AutocompleteView): From 35bef2c3dd044e38017b312fee8e3590cd4fc5c8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 29 Mar 2020 12:05:05 -0500 Subject: [PATCH 0107/1681] Move inventory batch view to its proper location but keep "inventory adjustment reasons" where it was; that also is proper --- tailbone/views/batch/inventory.py | 786 ++++++++++++++++++++++++++++++ tailbone/views/inventory.py | 752 +--------------------------- 2 files changed, 787 insertions(+), 751 deletions(-) create mode 100644 tailbone/views/batch/inventory.py diff --git a/tailbone/views/batch/inventory.py b/tailbone/views/batch/inventory.py new file mode 100644 index 00000000..65b2fe8c --- /dev/null +++ b/tailbone/views/batch/inventory.py @@ -0,0 +1,786 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2020 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Views for inventory batches +""" + +from __future__ import unicode_literals, absolute_import + +import re +import decimal +import logging + +import six + +from rattail import pod +from rattail.db import model, api +from rattail.db.util import make_full_description +from rattail.time import localtime +from rattail.gpc import GPC +from rattail.util import pretty_quantity + +import colander +from deform import widget as dfwidget +from webhelpers2.html import HTML, tags + +from tailbone import forms, grids +from tailbone.views import MasterView +from tailbone.views.batch import BatchMasterView + + +log = logging.getLogger(__name__) + + +class InventoryBatchView(BatchMasterView): + """ + Master view for inventory batches. + """ + model_class = model.InventoryBatch + model_title_plural = "Inventory Batches" + default_handler_spec = 'rattail.batch.inventory:InventoryBatchHandler' + route_prefix = 'batch.inventory' + url_prefix = '/batch/inventory' + index_title = "Inventory" + rows_creatable = True + results_executable = True + mobile_creatable = True + mobile_rows_creatable = True + + # set to False to disable "zero all" batch count mode + allow_zero_all = True + + # set to False to disable "variance" batch count mode + allow_variance = True + + # set to False to prevent exposing case fields for user input, + # when the batch count mode is "adjust only" + allow_adjustment_cases = True + + # set to True for the UI to "prefer" case amounts, as opposed to unit + prefer_cases = False + + labels = { + 'mode': "Count Mode", + } + + grid_columns = [ + 'id', + 'created', + 'created_by', + 'description', + 'mode', + 'rowcount', + 'total_cost', + 'executed', + 'executed_by', + ] + + form_fields = [ + 'id', + 'description', + 'notes', + 'created', + 'created_by', + 'handheld_batches', + 'mode', + 'reason_code', + 'total_cost', + 'rowcount', + 'complete', + 'executed', + 'executed_by', + ] + + mobile_form_fields = [ + 'mode', + 'reason_code', + 'rowcount', + 'complete', + 'executed', + 'executed_by', + ] + + model_row_class = model.InventoryBatchRow + rows_editable = True + + row_labels = { + 'upc': "UPC", + 'previous_units_on_hand': "Prev. On Hand", + } + + row_grid_columns = [ + 'sequence', + 'upc', + 'item_id', + 'brand_name', + 'description', + 'size', + 'previous_units_on_hand', + 'cases', + 'units', + 'unit_cost', + 'total_cost', + 'status_code', + ] + + row_form_fields = [ + 'sequence', + 'upc', + 'brand_name', + 'description', + 'size', + 'status_code', + 'previous_units_on_hand', + 'case_quantity', + 'cases', + 'units', + 'unit_cost', + 'total_cost', + 'variance', + ] + + def configure_grid(self, g): + super(InventoryBatchView, self).configure_grid(g) + + # mode + g.set_enum('mode', self.enum.INVENTORY_MODE) + g.filters['mode'].set_value_renderer( + grids.filters.EnumValueRenderer(self.enum.INVENTORY_MODE)) + + # total_cost + g.set_type('total_cost', 'currency') + + def render_mobile_listitem(self, batch, i): + return "({}) {} rows - {}, {}".format( + batch.id_str, + "?" if batch.rowcount is None else batch.rowcount, + batch.created_by, + localtime(self.request.rattail_config, batch.created, from_utc=True).strftime('%Y-%m-%d')) + + def mutable_batch(self, batch): + return not batch.executed and not batch.complete and batch.mode != self.enum.INVENTORY_MODE_ZERO_ALL + + def allow_worksheet(self, batch): + return self.mutable_batch(batch) + + def get_available_modes(self): + permission_prefix = self.get_permission_prefix() + modes = dict(self.enum.INVENTORY_MODE) + if not self.request.has_perm('{}.create.replace'.format(permission_prefix)): + if hasattr(self.enum, 'INVENTORY_MODE_REPLACE'): + modes.pop(self.enum.INVENTORY_MODE_REPLACE, None) + if hasattr(self.enum, 'INVENTORY_MODE_REPLACE_ADJUST'): + modes.pop(self.enum.INVENTORY_MODE_REPLACE_ADJUST, None) + if not self.allow_zero_all or not self.request.has_perm('{}.create.zero'.format(permission_prefix)): + if hasattr(self.enum, 'INVENTORY_MODE_ZERO_ALL'): + modes.pop(self.enum.INVENTORY_MODE_ZERO_ALL, None) + if not self.allow_variance or not self.request.has_perm('{}.create.variance'.format(permission_prefix)): + if hasattr(self.enum, 'INVENTORY_MODE_VARIANCE'): + modes.pop(self.enum.INVENTORY_MODE_VARIANCE, None) + return modes + + def configure_form(self, f): + super(InventoryBatchView, self).configure_form(f) + + # mode + modes = self.get_available_modes() + f.set_enum('mode', modes) + f.set_label('mode', "Count Mode") + if len(modes) == 1: + f.set_widget('mode', forms.widgets.ReadonlyWidget()) + f.set_default('mode', list(modes)[0]) + + # total_cost + if self.creating: + f.remove_field('total_cost') + else: + f.set_readonly('total_cost') + f.set_type('total_cost', 'currency') + + # handheld_batches + if self.creating: + f.remove_field('handheld_batches') + else: + f.set_readonly('handheld_batches') + f.set_renderer('handheld_batches', self.render_handheld_batches) + + # complete + if self.creating: + f.remove_field('complete') + + def render_handheld_batches(self, inventory_batch, field): + items = [] + for handheld in inventory_batch._handhelds: + text = handheld.handheld.id_str + url = self.request.route_url('batch.handheld.view', uuid=handheld.handheld_uuid) + items.append(HTML.tag('li', c=[tags.link_to(text, url)])) + return HTML.tag('ul', c=items) + + def row_editable(self, row): + return self.mutable_batch(row.batch) + + def row_deletable(self, row): + return self.mutable_batch(row.batch) + + def save_edit_row_form(self, form): + row = form.model_instance + batch = row.batch + if batch.total_cost is not None and row.total_cost is not None: + batch.total_cost -= row.total_cost + return super(InventoryBatchView, self).save_edit_row_form(form) + + def delete_row(self): + row = self.Session.query(model.InventoryBatchRow).get(self.request.matchdict['row_uuid']) + if not row: + raise self.notfound() + batch = row.batch + if batch.total_cost is not None and row.total_cost is not None: + batch.total_cost -= row.total_cost + return super(InventoryBatchView, self).delete_row() + + def create_row(self): + """ + Desktop workflow view for adding items to inventory batch. + """ + batch = self.get_instance() + if batch.executed: + return self.redirect(self.get_action_url('view', batch)) + + schema = DesktopForm().bind(session=self.Session()) + form = forms.Form(schema=schema, request=self.request) + if form.validate(newstyle=True): + + product = self.Session.query(model.Product).get(form.validated['product']) + + row = None + if self.should_aggregate_products(batch): + row = self.find_row_for_product(batch, product) + if row: + row.cases = form.validated['cases'] + row.units = form.validated['units'] + self.handler.refresh_row(row) + + if not row: + row = model.InventoryBatchRow() + row.product = product + row.upc = form.validated['upc'] + row.brand_name = form.validated['brand_name'] + row.description = form.validated['description'] + row.size = form.validated['size'] + row.case_quantity = form.validated['case_quantity'] + row.cases = form.validated['cases'] + row.units = form.validated['units'] + self.handler.capture_current_units(row) + self.handler.add_row(batch, row) + + description = make_full_description(form.validated['brand_name'], + form.validated['description'], + form.validated['size']) + self.request.session.flash("{} cases, {} units: {} {}".format( + form.validated['cases'] or 0, form.validated['units'] or 0, + form.validated['upc'].pretty(), description)) + return self.redirect(self.request.current_route_url()) + + title = self.get_instance_title(batch) + return self.render_to_response('desktop_form', { + 'batch': batch, + 'instance': batch, + 'instance_title': title, + 'index_title': "{}: {}".format(self.get_model_title(), title), + 'index_url': self.get_action_url('view', batch), + 'form': form, + 'dform': form.make_deform_form(), + 'allow_cases': self.allow_cases(batch), + 'prefer_cases': self.prefer_cases, + }) + + def allow_cases(self, batch): + if batch.mode == self.enum.INVENTORY_MODE_ADJUST: + if self.allow_adjustment_cases: + return True + return False + return True + + def should_aggregate_products(self, batch): + """ + Must return a boolean indicating whether rows should be aggregated by + product for the given batch. + """ + if batch.mode == self.enum.INVENTORY_MODE_VARIANCE: + return True + return False + + def desktop_lookup(self): + """ + Try to locate a product by UPC, and validate it in the context of + current batch, returning some data for client JS. + """ + batch = self.get_instance() + if batch.executed: + return { + 'error': "Current batch has already been executed", + 'redirect': self.get_action_url('view', batch), + } + entry = self.request.GET.get('upc', '') + aggregate = self.should_aggregate_products(batch) + + type2 = self.find_type2_product(entry) + if type2: + product, price = type2 + else: + product = self.find_product(entry) + + force_unit_item = True # TODO: make configurable? + unit_forced = False + if force_unit_item and product and product.is_pack_item(): + product = product.unit + unit_forced = True + + data = self.product_info(product) + if type2: + data['type2'] = True + if not aggregate: + if price is None: + data['units'] = 1 + else: + data['units'] = float((price / product.regular_price.price).quantize(decimal.Decimal('0.01'))) + + result = {'product': data, 'upc_raw': entry, 'upc': None, 'force_unit_item': unit_forced} + if not data: + upc = re.sub(r'\D', '', entry.strip()) + if upc: + upc = GPC(upc) + result['upc'] = six.text_type(upc) + result['upc_pretty'] = upc.pretty() + result['image_url'] = pod.get_image_url(self.rattail_config, upc) + + if product and aggregate: + row = self.find_row_for_product(batch, product) + if row: + result['already_present_in_batch'] = True + result['cases'] = float(row.cases) if row.cases is not None else None + result['units'] = float(row.units) if row.units is not None else None + + return result + + def find_row_for_product(self, batch, product): + rows = self.Session.query(model.InventoryBatchRow)\ + .filter(model.InventoryBatchRow.batch == batch)\ + .filter(model.InventoryBatchRow.product == product)\ + .filter(model.InventoryBatchRow.removed == False)\ + .all() + if rows: + if len(rows) > 1: + log.error("inventory batch %s should aggregate products, but has %s rows for: %s", + batch.id_str, len(rows), product) + return rows[0] + + def find_product(self, entry): + upc = re.sub(r'\D', '', entry.strip()) + if upc: + + # first try to locate existing batch row by UPC match + provided = GPC(upc, calc_check_digit=False) + checked = GPC(upc, calc_check_digit='upc') + product = api.get_product_by_upc(self.Session(), provided) + if product: + return product + product = api.get_product_by_upc(self.Session(), checked) + if product: + return product + + # maybe try to locate product by alternate code + if self.rattail_config.getbool('tailbone', 'inventory.lookup_by_code', default=False): + product = api.get_product_by_code(self.Session(), entry) + if product: + return product + + def product_info(self, product): + data = {} + if product and (not product.deleted or self.request.has_perm('products.view_deleted')): + data['uuid'] = product.uuid + data['upc'] = six.text_type(product.upc) + data['upc_pretty'] = product.upc.pretty() + data['full_description'] = product.full_description + data['brand_name'] = six.text_type(product.brand or '') + data['description'] = product.description + data['size'] = product.size + data['case_quantity'] = 1 # default + data['cost_found'] = False + data['image_url'] = pod.get_image_url(self.rattail_config, product.upc) + return data + + def configure_mobile_form(self, f): + super(InventoryBatchView, self).configure_mobile_form(f) + batch = f.model_instance + + # mode + modes = self.get_available_modes() + f.set_enum('mode', modes) + mode_values = [(k, v) for k, v in sorted(modes.items())] + f.set_widget('mode', forms.widgets.PlainSelectWidget(values=mode_values)) + + # complete + if self.creating or batch.executed or not batch.complete: + f.remove_field('complete') + + # rowcount + if self.viewing and not batch.executed and not batch.complete: + f.remove_field('rowcount') + + # TODO: document this, maybe move it etc. + unknown_product_creates_row = True + + # TODO: this view can create new rows, with only a GET query. that should + # probably be changed to require POST; for now we just require the "create + # batch row" perm and call it good.. + def mobile_row_from_upc(self): + """ + Locate and/or create a row within the batch, according to the given + product UPC, then redirect to the row view page. + """ + batch = self.get_instance() + row = None + raw_entry = self.request.GET.get('upc', '') + entry = raw_entry.strip() + entry = re.sub(r'\D', '', entry) + if entry: + + if len(entry) <= 14: + row = self.add_row_for_upc(batch, entry, warn_if_present=True) + if not row: + self.request.session.flash("Product not found: {}".format(entry), 'error') + return self.redirect(self.get_action_url('view', batch, mobile=True)) + + else: + self.request.session.flash("UPC has too many digits ({}): {}".format(len(entry), entry), 'error') + return self.redirect(self.get_action_url('view', batch, mobile=True)) + + else: + self.request.session.flash("Product not found: {}".format(raw_entry), 'error') + return self.redirect(self.get_action_url('view', batch, mobile=True)) + + self.Session.flush() + return self.redirect(self.mobile_row_route_url('view', uuid=row.batch_uuid, row_uuid=row.uuid)) + + def add_row_for_upc(self, batch, entry, warn_if_present=False): + """ + Add a row to the batch for the given UPC, if applicable. + """ + type2 = self.find_type2_product(entry) + if type2: + product, price = type2 + else: + product = self.find_product(entry) + if product: + + force_unit_item = self.rattail_config.getbool( + 'tailbone', 'inventory.force_unit_item', default=False) + if force_unit_item and product.is_pack_item(): + product = product.unit + self.request.session.flash("You scanned a pack item, but must count the units instead.", 'error') + + aggregate = self.should_aggregate_products(batch) + if aggregate: + row = self.find_row_for_product(batch, product) + if row: + if warn_if_present: + self.request.session.flash("Product already exists in batch; please confirm counts", 'error') + return row + + row = model.InventoryBatchRow() + row.product = product + row.upc = product.upc + self.handler.capture_current_units(row) + if type2 and not aggregate: + if price is None: + row.units = 1 + else: + row.units = (price / product.regular_price.price).quantize(decimal.Decimal('0.01')) + self.handler.add_row(batch, row) + return row + + elif self.unknown_product_creates_row: + row = model.InventoryBatchRow() + row.upc = GPC(upc, calc_check_digit=False) # TODO: why not calc check digit? + row.description = "(unknown product)" + self.handler.capture_current_units(row) + self.handler.add_row(batch, row) + return row + + def template_kwargs_view_row(self, **kwargs): + row = kwargs['instance'] + kwargs['product_image_url'] = pod.get_image_url(self.rattail_config, row.upc) + return kwargs + + def get_batch_kwargs(self, batch, mobile=False): + kwargs = super(InventoryBatchView, self).get_batch_kwargs(batch, mobile=False) + kwargs['mode'] = batch.mode + kwargs['complete'] = False + kwargs['reason_code'] = batch.reason_code + return kwargs + + def get_mobile_row_data(self, batch): + # we want newest on top, for inventory batch rows + return self.get_row_data(batch)\ + .order_by(self.model_row_class.sequence.desc()) + + # TODO: ugh, the hackiness. needs a refactor fo sho + def mobile_view_row(self): + """ + Mobile view for inventory batch rows. Note that this also handles + updating a row...ugh. + """ + self.viewing = True + row = self.get_row_instance() + batch = self.get_parent(row) + form = self.make_mobile_row_form(row) + + allow_cases = self.allow_cases(batch) + unit_uom = 'LB' if row.product and row.product.weighed else 'EA' + if row.cases and allow_cases: + uom = 'CS' + elif row.units: + uom = unit_uom + elif row.case_quantity and allow_cases and self.prefer_cases: + uom = 'CS' + else: + uom = unit_uom + + context = { + 'row': row, + 'batch': batch, + 'instance': row, + 'instance_title': self.get_row_instance_title(row), + 'parent_model_title': self.get_model_title(), + 'parent_title': self.get_instance_title(batch), + 'parent_url': self.get_action_url('view', batch, mobile=True), + 'product_image_url': pod.get_image_url(self.rattail_config, row.upc), + 'form': form, + 'allow_cases': allow_cases, + 'unit_uom': unit_uom, + 'uom': uom, + } + + if self.request.has_perm('{}.edit_row'.format(self.get_permission_prefix())): + schema = InventoryForm().bind(session=self.Session()) + update_form = forms.Form(schema=schema, request=self.request) + if update_form.validate(newstyle=True): + row = self.Session.query(model.InventoryBatchRow).get(update_form.validated['row']) + cases = update_form.validated['cases'] + units = update_form.validated['units'] + if cases is not colander.null: + row.cases = cases + row.units = None + elif units is not colander.null: + row.cases = None + row.units = units + else: + raise NotImplementedError + self.handler.refresh_row(row) + route_prefix = self.get_route_prefix() + return self.redirect(self.request.route_url('mobile.{}.view'.format(route_prefix), uuid=batch.uuid)) + + return self.render_to_response('view_row', context, mobile=True) + + def get_row_instance_title(self, row): + if row.upc: + return row.upc.pretty() + if row.item_id: + return row.item_id + return "row {}".format(row.sequence) + + def configure_row_grid(self, g): + super(InventoryBatchView, self).configure_row_grid(g) + + # quantity fields + g.set_type('previous_units_on_hand', 'quantity') + g.set_type('cases', 'quantity') + g.set_type('units', 'quantity') + + # currency fields + g.set_type('unit_cost', 'currency') + g.set_type('total_cost', 'currency') + + # short labels + g.set_label('brand_name', "Brand") + g.set_label('status_code', "Status") + + # links + g.set_link('upc') + g.set_link('item_id') + g.set_link('description') + + def row_grid_extra_class(self, row, i): + if row.status_code == row.STATUS_PRODUCT_NOT_FOUND: + return 'warning' + + def render_mobile_row_listitem(self, row, i): + description = row.product.full_description if row.product else row.description + unit_uom = 'LB' if row.product and row.product.weighed else 'EA' + qty = "{} {}".format(pretty_quantity(row.cases or row.units), 'CS' if row.cases else unit_uom) + return "({}) {} - {}".format(row.upc.pretty(), description, qty) + + def configure_row_form(self, f): + super(InventoryBatchView, self).configure_row_form(f) + row = f.model_instance + + # readonly fields + f.set_readonly('upc') + f.set_readonly('item_id') + f.set_readonly('brand_name') + f.set_readonly('description') + f.set_readonly('size') + f.set_readonly('previous_units_on_hand') + f.set_readonly('case_quantity') + f.set_readonly('variance') + f.set_readonly('total_cost') + + # quantity fields + f.set_type('case_quantity', 'quantity') + f.set_type('previous_units_on_hand', 'quantity') + f.set_type('cases', 'quantity') + f.set_type('units', 'quantity') + f.set_type('variance', 'quantity') + + # currency fields + f.set_type('unit_cost', 'currency') + f.set_type('total_cost', 'currency') + + # upc + f.set_renderer('upc', self.render_upc) + + # cases + if self.editing: + if not self.allow_cases(row.batch): + f.set_readonly('cases') + + def render_upc(self, row, field): + upc = row.upc + if not upc: + return "" + text = upc.pretty() + if row.product_uuid: + url = self.request.route_url('products.view', uuid=row.product_uuid) + return tags.link_to(text, url) + return text + + @classmethod + def defaults(cls, config): + cls._batch_defaults(config) + cls._defaults(config) + cls._inventory_defaults(config) + + @classmethod + def _inventory_defaults(cls, config): + model_key = cls.get_model_key() + model_title = cls.get_model_title() + route_prefix = cls.get_route_prefix() + url_prefix = cls.get_url_prefix() + permission_prefix = cls.get_permission_prefix() + + # extra perms for creating batches per "mode" + config.add_tailbone_permission(permission_prefix, '{}.create.replace'.format(permission_prefix), + "Create new {} with 'replace' mode".format(model_title)) + if cls.allow_zero_all: + config.add_tailbone_permission(permission_prefix, '{}.create.zero'.format(permission_prefix), + "Create new {} with 'zero' mode".format(model_title)) + if cls.allow_variance: + config.add_tailbone_permission(permission_prefix, '{}.create.variance'.format(permission_prefix), + "Create new {} with 'variance' mode".format(model_title)) + + # row UPC lookup, for desktop + config.add_route('{}.desktop_lookup'.format(route_prefix), '{}/{{{}}}/desktop-form/lookup'.format(url_prefix, model_key)) + config.add_view(cls, attr='desktop_lookup', route_name='{}.desktop_lookup'.format(route_prefix), + renderer='json', permission='{}.create_row'.format(permission_prefix)) + + # mobile - make new row from UPC + config.add_route('mobile.{}.row_from_upc'.format(route_prefix), '/mobile{}/{{{}}}/row-from-upc'.format(url_prefix, model_key)) + config.add_view(cls, attr='mobile_row_from_upc', route_name='mobile.{}.row_from_upc'.format(route_prefix), + permission='{}.create_row'.format(permission_prefix)) + + +# TODO: this is a stopgap measure to fix an obvious bug, which exists when the +# session is not provided by the view at runtime (i.e. when it was instead +# being provided by the type instance, which was created upon app startup). +@colander.deferred +def valid_inventory_batch_row(node, kw): + session = kw['session'] + def validate(node, value): + row = session.query(model.InventoryBatchRow).get(value) + if not row: + raise colander.Invalid(node, "Batch row not found") + if row.batch.executed: + raise colander.Invalid(node, "Batch has already been executed") + return row.uuid + return validate + + +class InventoryForm(colander.MappingSchema): + + row = colander.SchemaNode(colander.String(), + validator=valid_inventory_batch_row) + + cases = colander.SchemaNode(colander.Decimal(), missing=colander.null) + + units = colander.SchemaNode(colander.Decimal(), missing=colander.null) + + +# TODO: this is a stopgap measure to fix an obvious bug, which exists when the +# session is not provided by the view at runtime (i.e. when it was instead +# being provided by the type instance, which was created upon app startup). +@colander.deferred +def valid_product(node, kw): + session = kw['session'] + def validate(node, value): + product = session.query(model.Product).get(value) + if not product: + raise colander.Invalid(node, "Product not found") + return product.uuid + return validate + + +class DesktopForm(colander.Schema): + + product = colander.SchemaNode(colander.String(), + validator=valid_product) + + upc = colander.SchemaNode(forms.types.GPCType()) + + brand_name = colander.SchemaNode(colander.String()) + + description = colander.SchemaNode(colander.String()) + + size = colander.SchemaNode(colander.String(), missing=colander.null) + + case_quantity = colander.SchemaNode(colander.Decimal()) + + cases = colander.SchemaNode(colander.Decimal(), + missing=None) + + units = colander.SchemaNode(colander.Decimal(), + missing=None) + + +def includeme(config): + InventoryBatchView.defaults(config) diff --git a/tailbone/views/inventory.py b/tailbone/views/inventory.py index 96ec46b3..7e4ed33f 100644 --- a/tailbone/views/inventory.py +++ b/tailbone/views/inventory.py @@ -26,29 +26,11 @@ Views for inventory batches from __future__ import unicode_literals, absolute_import -import re -import decimal -import logging - -import six - -from rattail import pod -from rattail.db import model, api -from rattail.db.util import make_full_description -from rattail.time import localtime -from rattail.gpc import GPC -from rattail.util import pretty_quantity +from rattail.db import model import colander -from deform import widget as dfwidget -from webhelpers2.html import HTML, tags -from tailbone import forms, grids from tailbone.views import MasterView -from tailbone.views.batch import BatchMasterView - - -log = logging.getLogger(__name__) class InventoryAdjustmentReasonsView(MasterView): @@ -85,737 +67,5 @@ class InventoryAdjustmentReasonsView(MasterView): raise colander.Invalid(node, "Code must be unique") -class InventoryBatchView(BatchMasterView): - """ - Master view for inventory batches. - """ - model_class = model.InventoryBatch - model_title_plural = "Inventory Batches" - default_handler_spec = 'rattail.batch.inventory:InventoryBatchHandler' - route_prefix = 'batch.inventory' - url_prefix = '/batch/inventory' - index_title = "Inventory" - rows_creatable = True - results_executable = True - mobile_creatable = True - mobile_rows_creatable = True - - # set to False to disable "zero all" batch count mode - allow_zero_all = True - - # set to False to disable "variance" batch count mode - allow_variance = True - - # set to False to prevent exposing case fields for user input, - # when the batch count mode is "adjust only" - allow_adjustment_cases = True - - # set to True for the UI to "prefer" case amounts, as opposed to unit - prefer_cases = False - - labels = { - 'mode': "Count Mode", - } - - grid_columns = [ - 'id', - 'created', - 'created_by', - 'description', - 'mode', - 'rowcount', - 'total_cost', - 'executed', - 'executed_by', - ] - - form_fields = [ - 'id', - 'description', - 'notes', - 'created', - 'created_by', - 'handheld_batches', - 'mode', - 'reason_code', - 'total_cost', - 'rowcount', - 'complete', - 'executed', - 'executed_by', - ] - - mobile_form_fields = [ - 'mode', - 'reason_code', - 'rowcount', - 'complete', - 'executed', - 'executed_by', - ] - - model_row_class = model.InventoryBatchRow - rows_editable = True - - row_labels = { - 'upc': "UPC", - 'previous_units_on_hand': "Prev. On Hand", - } - - row_grid_columns = [ - 'sequence', - 'upc', - 'item_id', - 'brand_name', - 'description', - 'size', - 'previous_units_on_hand', - 'cases', - 'units', - 'unit_cost', - 'total_cost', - 'status_code', - ] - - row_form_fields = [ - 'sequence', - 'upc', - 'brand_name', - 'description', - 'size', - 'status_code', - 'previous_units_on_hand', - 'case_quantity', - 'cases', - 'units', - 'unit_cost', - 'total_cost', - 'variance', - ] - - def configure_grid(self, g): - super(InventoryBatchView, self).configure_grid(g) - - # mode - g.set_enum('mode', self.enum.INVENTORY_MODE) - g.filters['mode'].set_value_renderer( - grids.filters.EnumValueRenderer(self.enum.INVENTORY_MODE)) - - # total_cost - g.set_type('total_cost', 'currency') - - def render_mobile_listitem(self, batch, i): - return "({}) {} rows - {}, {}".format( - batch.id_str, - "?" if batch.rowcount is None else batch.rowcount, - batch.created_by, - localtime(self.request.rattail_config, batch.created, from_utc=True).strftime('%Y-%m-%d')) - - def mutable_batch(self, batch): - return not batch.executed and not batch.complete and batch.mode != self.enum.INVENTORY_MODE_ZERO_ALL - - def allow_worksheet(self, batch): - return self.mutable_batch(batch) - - def get_available_modes(self): - permission_prefix = self.get_permission_prefix() - modes = dict(self.enum.INVENTORY_MODE) - if not self.request.has_perm('{}.create.replace'.format(permission_prefix)): - if hasattr(self.enum, 'INVENTORY_MODE_REPLACE'): - modes.pop(self.enum.INVENTORY_MODE_REPLACE, None) - if hasattr(self.enum, 'INVENTORY_MODE_REPLACE_ADJUST'): - modes.pop(self.enum.INVENTORY_MODE_REPLACE_ADJUST, None) - if not self.allow_zero_all or not self.request.has_perm('{}.create.zero'.format(permission_prefix)): - if hasattr(self.enum, 'INVENTORY_MODE_ZERO_ALL'): - modes.pop(self.enum.INVENTORY_MODE_ZERO_ALL, None) - if not self.allow_variance or not self.request.has_perm('{}.create.variance'.format(permission_prefix)): - if hasattr(self.enum, 'INVENTORY_MODE_VARIANCE'): - modes.pop(self.enum.INVENTORY_MODE_VARIANCE, None) - return modes - - def configure_form(self, f): - super(InventoryBatchView, self).configure_form(f) - - # mode - modes = self.get_available_modes() - f.set_enum('mode', modes) - f.set_label('mode', "Count Mode") - if len(modes) == 1: - f.set_widget('mode', forms.widgets.ReadonlyWidget()) - f.set_default('mode', list(modes)[0]) - - # total_cost - if self.creating: - f.remove_field('total_cost') - else: - f.set_readonly('total_cost') - f.set_type('total_cost', 'currency') - - # handheld_batches - if self.creating: - f.remove_field('handheld_batches') - else: - f.set_readonly('handheld_batches') - f.set_renderer('handheld_batches', self.render_handheld_batches) - - # complete - if self.creating: - f.remove_field('complete') - - def render_handheld_batches(self, inventory_batch, field): - items = [] - for handheld in inventory_batch._handhelds: - text = handheld.handheld.id_str - url = self.request.route_url('batch.handheld.view', uuid=handheld.handheld_uuid) - items.append(HTML.tag('li', c=[tags.link_to(text, url)])) - return HTML.tag('ul', c=items) - - def row_editable(self, row): - return self.mutable_batch(row.batch) - - def row_deletable(self, row): - return self.mutable_batch(row.batch) - - def save_edit_row_form(self, form): - row = form.model_instance - batch = row.batch - if batch.total_cost is not None and row.total_cost is not None: - batch.total_cost -= row.total_cost - return super(InventoryBatchView, self).save_edit_row_form(form) - - def delete_row(self): - row = self.Session.query(model.InventoryBatchRow).get(self.request.matchdict['row_uuid']) - if not row: - raise self.notfound() - batch = row.batch - if batch.total_cost is not None and row.total_cost is not None: - batch.total_cost -= row.total_cost - return super(InventoryBatchView, self).delete_row() - - def create_row(self): - """ - Desktop workflow view for adding items to inventory batch. - """ - batch = self.get_instance() - if batch.executed: - return self.redirect(self.get_action_url('view', batch)) - - schema = DesktopForm().bind(session=self.Session()) - form = forms.Form(schema=schema, request=self.request) - if form.validate(newstyle=True): - - product = self.Session.query(model.Product).get(form.validated['product']) - - row = None - if self.should_aggregate_products(batch): - row = self.find_row_for_product(batch, product) - if row: - row.cases = form.validated['cases'] - row.units = form.validated['units'] - self.handler.refresh_row(row) - - if not row: - row = model.InventoryBatchRow() - row.product = product - row.upc = form.validated['upc'] - row.brand_name = form.validated['brand_name'] - row.description = form.validated['description'] - row.size = form.validated['size'] - row.case_quantity = form.validated['case_quantity'] - row.cases = form.validated['cases'] - row.units = form.validated['units'] - self.handler.capture_current_units(row) - self.handler.add_row(batch, row) - - description = make_full_description(form.validated['brand_name'], - form.validated['description'], - form.validated['size']) - self.request.session.flash("{} cases, {} units: {} {}".format( - form.validated['cases'] or 0, form.validated['units'] or 0, - form.validated['upc'].pretty(), description)) - return self.redirect(self.request.current_route_url()) - - title = self.get_instance_title(batch) - return self.render_to_response('desktop_form', { - 'batch': batch, - 'instance': batch, - 'instance_title': title, - 'index_title': "{}: {}".format(self.get_model_title(), title), - 'index_url': self.get_action_url('view', batch), - 'form': form, - 'dform': form.make_deform_form(), - 'allow_cases': self.allow_cases(batch), - 'prefer_cases': self.prefer_cases, - }) - - def allow_cases(self, batch): - if batch.mode == self.enum.INVENTORY_MODE_ADJUST: - if self.allow_adjustment_cases: - return True - return False - return True - - def should_aggregate_products(self, batch): - """ - Must return a boolean indicating whether rows should be aggregated by - product for the given batch. - """ - if batch.mode == self.enum.INVENTORY_MODE_VARIANCE: - return True - return False - - def desktop_lookup(self): - """ - Try to locate a product by UPC, and validate it in the context of - current batch, returning some data for client JS. - """ - batch = self.get_instance() - if batch.executed: - return { - 'error': "Current batch has already been executed", - 'redirect': self.get_action_url('view', batch), - } - entry = self.request.GET.get('upc', '') - aggregate = self.should_aggregate_products(batch) - - type2 = self.find_type2_product(entry) - if type2: - product, price = type2 - else: - product = self.find_product(entry) - - force_unit_item = True # TODO: make configurable? - unit_forced = False - if force_unit_item and product and product.is_pack_item(): - product = product.unit - unit_forced = True - - data = self.product_info(product) - if type2: - data['type2'] = True - if not aggregate: - if price is None: - data['units'] = 1 - else: - data['units'] = float((price / product.regular_price.price).quantize(decimal.Decimal('0.01'))) - - result = {'product': data, 'upc_raw': entry, 'upc': None, 'force_unit_item': unit_forced} - if not data: - upc = re.sub(r'\D', '', entry.strip()) - if upc: - upc = GPC(upc) - result['upc'] = six.text_type(upc) - result['upc_pretty'] = upc.pretty() - result['image_url'] = pod.get_image_url(self.rattail_config, upc) - - if product and aggregate: - row = self.find_row_for_product(batch, product) - if row: - result['already_present_in_batch'] = True - result['cases'] = float(row.cases) if row.cases is not None else None - result['units'] = float(row.units) if row.units is not None else None - - return result - - def find_row_for_product(self, batch, product): - rows = self.Session.query(model.InventoryBatchRow)\ - .filter(model.InventoryBatchRow.batch == batch)\ - .filter(model.InventoryBatchRow.product == product)\ - .filter(model.InventoryBatchRow.removed == False)\ - .all() - if rows: - if len(rows) > 1: - log.error("inventory batch %s should aggregate products, but has %s rows for: %s", - batch.id_str, len(rows), product) - return rows[0] - - def find_product(self, entry): - upc = re.sub(r'\D', '', entry.strip()) - if upc: - - # first try to locate existing batch row by UPC match - provided = GPC(upc, calc_check_digit=False) - checked = GPC(upc, calc_check_digit='upc') - product = api.get_product_by_upc(self.Session(), provided) - if product: - return product - product = api.get_product_by_upc(self.Session(), checked) - if product: - return product - - # maybe try to locate product by alternate code - if self.rattail_config.getbool('tailbone', 'inventory.lookup_by_code', default=False): - product = api.get_product_by_code(self.Session(), entry) - if product: - return product - - def product_info(self, product): - data = {} - if product and (not product.deleted or self.request.has_perm('products.view_deleted')): - data['uuid'] = product.uuid - data['upc'] = six.text_type(product.upc) - data['upc_pretty'] = product.upc.pretty() - data['full_description'] = product.full_description - data['brand_name'] = six.text_type(product.brand or '') - data['description'] = product.description - data['size'] = product.size - data['case_quantity'] = 1 # default - data['cost_found'] = False - data['image_url'] = pod.get_image_url(self.rattail_config, product.upc) - return data - - def configure_mobile_form(self, f): - super(InventoryBatchView, self).configure_mobile_form(f) - batch = f.model_instance - - # mode - modes = self.get_available_modes() - f.set_enum('mode', modes) - mode_values = [(k, v) for k, v in sorted(modes.items())] - f.set_widget('mode', forms.widgets.PlainSelectWidget(values=mode_values)) - - # complete - if self.creating or batch.executed or not batch.complete: - f.remove_field('complete') - - # rowcount - if self.viewing and not batch.executed and not batch.complete: - f.remove_field('rowcount') - - # TODO: document this, maybe move it etc. - unknown_product_creates_row = True - - # TODO: this view can create new rows, with only a GET query. that should - # probably be changed to require POST; for now we just require the "create - # batch row" perm and call it good.. - def mobile_row_from_upc(self): - """ - Locate and/or create a row within the batch, according to the given - product UPC, then redirect to the row view page. - """ - batch = self.get_instance() - row = None - raw_entry = self.request.GET.get('upc', '') - entry = raw_entry.strip() - entry = re.sub(r'\D', '', entry) - if entry: - - if len(entry) <= 14: - row = self.add_row_for_upc(batch, entry, warn_if_present=True) - if not row: - self.request.session.flash("Product not found: {}".format(entry), 'error') - return self.redirect(self.get_action_url('view', batch, mobile=True)) - - else: - self.request.session.flash("UPC has too many digits ({}): {}".format(len(entry), entry), 'error') - return self.redirect(self.get_action_url('view', batch, mobile=True)) - - else: - self.request.session.flash("Product not found: {}".format(raw_entry), 'error') - return self.redirect(self.get_action_url('view', batch, mobile=True)) - - self.Session.flush() - return self.redirect(self.mobile_row_route_url('view', uuid=row.batch_uuid, row_uuid=row.uuid)) - - def add_row_for_upc(self, batch, entry, warn_if_present=False): - """ - Add a row to the batch for the given UPC, if applicable. - """ - type2 = self.find_type2_product(entry) - if type2: - product, price = type2 - else: - product = self.find_product(entry) - if product: - - force_unit_item = self.rattail_config.getbool( - 'tailbone', 'inventory.force_unit_item', default=False) - if force_unit_item and product.is_pack_item(): - product = product.unit - self.request.session.flash("You scanned a pack item, but must count the units instead.", 'error') - - aggregate = self.should_aggregate_products(batch) - if aggregate: - row = self.find_row_for_product(batch, product) - if row: - if warn_if_present: - self.request.session.flash("Product already exists in batch; please confirm counts", 'error') - return row - - row = model.InventoryBatchRow() - row.product = product - row.upc = product.upc - self.handler.capture_current_units(row) - if type2 and not aggregate: - if price is None: - row.units = 1 - else: - row.units = (price / product.regular_price.price).quantize(decimal.Decimal('0.01')) - self.handler.add_row(batch, row) - return row - - elif self.unknown_product_creates_row: - row = model.InventoryBatchRow() - row.upc = GPC(upc, calc_check_digit=False) # TODO: why not calc check digit? - row.description = "(unknown product)" - self.handler.capture_current_units(row) - self.handler.add_row(batch, row) - return row - - def template_kwargs_view_row(self, **kwargs): - row = kwargs['instance'] - kwargs['product_image_url'] = pod.get_image_url(self.rattail_config, row.upc) - return kwargs - - def get_batch_kwargs(self, batch, mobile=False): - kwargs = super(InventoryBatchView, self).get_batch_kwargs(batch, mobile=False) - kwargs['mode'] = batch.mode - kwargs['complete'] = False - kwargs['reason_code'] = batch.reason_code - return kwargs - - def get_mobile_row_data(self, batch): - # we want newest on top, for inventory batch rows - return self.get_row_data(batch)\ - .order_by(self.model_row_class.sequence.desc()) - - # TODO: ugh, the hackiness. needs a refactor fo sho - def mobile_view_row(self): - """ - Mobile view for inventory batch rows. Note that this also handles - updating a row...ugh. - """ - self.viewing = True - row = self.get_row_instance() - batch = self.get_parent(row) - form = self.make_mobile_row_form(row) - - allow_cases = self.allow_cases(batch) - unit_uom = 'LB' if row.product and row.product.weighed else 'EA' - if row.cases and allow_cases: - uom = 'CS' - elif row.units: - uom = unit_uom - elif row.case_quantity and allow_cases and self.prefer_cases: - uom = 'CS' - else: - uom = unit_uom - - context = { - 'row': row, - 'batch': batch, - 'instance': row, - 'instance_title': self.get_row_instance_title(row), - 'parent_model_title': self.get_model_title(), - 'parent_title': self.get_instance_title(batch), - 'parent_url': self.get_action_url('view', batch, mobile=True), - 'product_image_url': pod.get_image_url(self.rattail_config, row.upc), - 'form': form, - 'allow_cases': allow_cases, - 'unit_uom': unit_uom, - 'uom': uom, - } - - if self.request.has_perm('{}.edit_row'.format(self.get_permission_prefix())): - schema = InventoryForm().bind(session=self.Session()) - update_form = forms.Form(schema=schema, request=self.request) - if update_form.validate(newstyle=True): - row = self.Session.query(model.InventoryBatchRow).get(update_form.validated['row']) - cases = update_form.validated['cases'] - units = update_form.validated['units'] - if cases is not colander.null: - row.cases = cases - row.units = None - elif units is not colander.null: - row.cases = None - row.units = units - else: - raise NotImplementedError - self.handler.refresh_row(row) - route_prefix = self.get_route_prefix() - return self.redirect(self.request.route_url('mobile.{}.view'.format(route_prefix), uuid=batch.uuid)) - - return self.render_to_response('view_row', context, mobile=True) - - def get_row_instance_title(self, row): - if row.upc: - return row.upc.pretty() - if row.item_id: - return row.item_id - return "row {}".format(row.sequence) - - def configure_row_grid(self, g): - super(InventoryBatchView, self).configure_row_grid(g) - - # quantity fields - g.set_type('previous_units_on_hand', 'quantity') - g.set_type('cases', 'quantity') - g.set_type('units', 'quantity') - - # currency fields - g.set_type('unit_cost', 'currency') - g.set_type('total_cost', 'currency') - - # short labels - g.set_label('brand_name', "Brand") - g.set_label('status_code', "Status") - - # links - g.set_link('upc') - g.set_link('item_id') - g.set_link('description') - - def row_grid_extra_class(self, row, i): - if row.status_code == row.STATUS_PRODUCT_NOT_FOUND: - return 'warning' - - def render_mobile_row_listitem(self, row, i): - description = row.product.full_description if row.product else row.description - unit_uom = 'LB' if row.product and row.product.weighed else 'EA' - qty = "{} {}".format(pretty_quantity(row.cases or row.units), 'CS' if row.cases else unit_uom) - return "({}) {} - {}".format(row.upc.pretty(), description, qty) - - def configure_row_form(self, f): - super(InventoryBatchView, self).configure_row_form(f) - row = f.model_instance - - # readonly fields - f.set_readonly('upc') - f.set_readonly('item_id') - f.set_readonly('brand_name') - f.set_readonly('description') - f.set_readonly('size') - f.set_readonly('previous_units_on_hand') - f.set_readonly('case_quantity') - f.set_readonly('variance') - f.set_readonly('total_cost') - - # quantity fields - f.set_type('case_quantity', 'quantity') - f.set_type('previous_units_on_hand', 'quantity') - f.set_type('cases', 'quantity') - f.set_type('units', 'quantity') - f.set_type('variance', 'quantity') - - # currency fields - f.set_type('unit_cost', 'currency') - f.set_type('total_cost', 'currency') - - # upc - f.set_renderer('upc', self.render_upc) - - # cases - if self.editing: - if not self.allow_cases(row.batch): - f.set_readonly('cases') - - def render_upc(self, row, field): - upc = row.upc - if not upc: - return "" - text = upc.pretty() - if row.product_uuid: - url = self.request.route_url('products.view', uuid=row.product_uuid) - return tags.link_to(text, url) - return text - - @classmethod - def defaults(cls, config): - cls._batch_defaults(config) - cls._defaults(config) - cls._inventory_defaults(config) - - @classmethod - def _inventory_defaults(cls, config): - model_key = cls.get_model_key() - model_title = cls.get_model_title() - route_prefix = cls.get_route_prefix() - url_prefix = cls.get_url_prefix() - permission_prefix = cls.get_permission_prefix() - - # extra perms for creating batches per "mode" - config.add_tailbone_permission(permission_prefix, '{}.create.replace'.format(permission_prefix), - "Create new {} with 'replace' mode".format(model_title)) - if cls.allow_zero_all: - config.add_tailbone_permission(permission_prefix, '{}.create.zero'.format(permission_prefix), - "Create new {} with 'zero' mode".format(model_title)) - if cls.allow_variance: - config.add_tailbone_permission(permission_prefix, '{}.create.variance'.format(permission_prefix), - "Create new {} with 'variance' mode".format(model_title)) - - # row UPC lookup, for desktop - config.add_route('{}.desktop_lookup'.format(route_prefix), '{}/{{{}}}/desktop-form/lookup'.format(url_prefix, model_key)) - config.add_view(cls, attr='desktop_lookup', route_name='{}.desktop_lookup'.format(route_prefix), - renderer='json', permission='{}.create_row'.format(permission_prefix)) - - # mobile - make new row from UPC - config.add_route('mobile.{}.row_from_upc'.format(route_prefix), '/mobile{}/{{{}}}/row-from-upc'.format(url_prefix, model_key)) - config.add_view(cls, attr='mobile_row_from_upc', route_name='mobile.{}.row_from_upc'.format(route_prefix), - permission='{}.create_row'.format(permission_prefix)) - - -# TODO: this is a stopgap measure to fix an obvious bug, which exists when the -# session is not provided by the view at runtime (i.e. when it was instead -# being provided by the type instance, which was created upon app startup). -@colander.deferred -def valid_inventory_batch_row(node, kw): - session = kw['session'] - def validate(node, value): - row = session.query(model.InventoryBatchRow).get(value) - if not row: - raise colander.Invalid(node, "Batch row not found") - if row.batch.executed: - raise colander.Invalid(node, "Batch has already been executed") - return row.uuid - return validate - - -class InventoryForm(colander.MappingSchema): - - row = colander.SchemaNode(colander.String(), - validator=valid_inventory_batch_row) - - cases = colander.SchemaNode(colander.Decimal(), missing=colander.null) - - units = colander.SchemaNode(colander.Decimal(), missing=colander.null) - - -# TODO: this is a stopgap measure to fix an obvious bug, which exists when the -# session is not provided by the view at runtime (i.e. when it was instead -# being provided by the type instance, which was created upon app startup). -@colander.deferred -def valid_product(node, kw): - session = kw['session'] - def validate(node, value): - product = session.query(model.Product).get(value) - if not product: - raise colander.Invalid(node, "Product not found") - return product.uuid - return validate - - -class DesktopForm(colander.Schema): - - product = colander.SchemaNode(colander.String(), - validator=valid_product) - - upc = colander.SchemaNode(forms.types.GPCType()) - - brand_name = colander.SchemaNode(colander.String()) - - description = colander.SchemaNode(colander.String()) - - size = colander.SchemaNode(colander.String(), missing=colander.null) - - case_quantity = colander.SchemaNode(colander.Decimal()) - - cases = colander.SchemaNode(colander.Decimal(), - missing=None) - - units = colander.SchemaNode(colander.Decimal(), - missing=None) - - def includeme(config): InventoryAdjustmentReasonsView.defaults(config) - InventoryBatchView.defaults(config) From 242e14e8a92e8f1f29781c326ef5b89e3a34f3e3 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 29 Mar 2020 12:07:42 -0500 Subject: [PATCH 0108/1681] Allow bulk-delete for Inventory Batches --- tailbone/views/batch/inventory.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/batch/inventory.py b/tailbone/views/batch/inventory.py index 65b2fe8c..f6ac4221 100644 --- a/tailbone/views/batch/inventory.py +++ b/tailbone/views/batch/inventory.py @@ -62,6 +62,7 @@ class InventoryBatchView(BatchMasterView): url_prefix = '/batch/inventory' index_title = "Inventory" rows_creatable = True + bulk_deletable = True results_executable = True mobile_creatable = True mobile_rows_creatable = True From 0704717ec5d97fcbe5691aa3d5c6e356dd782990 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 29 Mar 2020 12:46:41 -0500 Subject: [PATCH 0109/1681] Let inventory batch handler declare which count modes are allowed preparing for API/mobile usage --- tailbone/views/batch/core.py | 41 +++++++++++++++++++++---------- tailbone/views/batch/inventory.py | 19 +++++++------- 2 files changed, 37 insertions(+), 23 deletions(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index dbde90cb..e3e14ee8 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -71,6 +71,7 @@ class BatchMasterView(MasterView): Base class for all "batch master" views. """ default_handler_spec = None + batch_handler_class = None has_rows = True rows_deletable = True rows_downloadable_csv = True @@ -120,29 +121,43 @@ class BatchMasterView(MasterView): super(BatchMasterView, self).__init__(request) self.handler = self.get_handler() - def get_handler(self): + @classmethod + def get_handler_factory(cls, rattail_config): """ - Returns a `BatchHandler` instance for the view. All (?) custom batch - views should define a default handler class; however this may in all - (?) cases be overridden by config also. The specific setting required - to do so will depend on the 'key' for the type of batch involved, e.g. - assuming the 'vendor_catalog' batch: + Returns the "factory" (class) which will be used to create the batch + handler. All (?) custom batch views should define a default handler + class; however this may in all (?) cases be overridden by config also. + The specific setting required to do so will depend on the 'key' for the + type of batch involved, e.g. assuming the 'inventory' batch: .. code-block:: ini [rattail.batch] - vendor_catalog.handler = myapp.batch.vendorcatalog:CustomCatalogHandler + inventory.handler = poser.batch.inventory:InventoryBatchHandler Note that the 'key' for a batch is generally the same as its primary table name, although technically it is whatever value returns from the ``batch_key`` attribute of the main batch model class. """ - key = self.model_class.batch_key - spec = self.rattail_config.get('rattail.batch', '{}.handler'.format(key), - default=self.default_handler_spec) - if spec: - return load_object(spec)(self.rattail_config) - return self.batch_handler_class(self.rattail_config) + # first try to figure out if config defines a factory class + model_class = cls.get_model_class() + batch_key = model_class.batch_key + spec = rattail_config.get('rattail.batch', '{}.handler'.format(batch_key), + default=cls.default_handler_spec) + if spec: # yep, so use that + return load_object(spec) + + # fall back to whatever class was defined statically + return cls.batch_handler_class + + def get_handler(self): + """ + Returns a batch handler instance to be used by the view. Note that + this will use the factory provided by :meth:`get_handler_factory()` to + create the handler instance. + """ + factory = self.get_handler_factory(self.rattail_config) + return factory(self.rattail_config) def download_path(self, batch, filename): return self.rattail_config.batch_filepath(batch.batch_key, batch.uuid, filename) diff --git a/tailbone/views/batch/inventory.py b/tailbone/views/batch/inventory.py index f6ac4221..d7780467 100644 --- a/tailbone/views/batch/inventory.py +++ b/tailbone/views/batch/inventory.py @@ -67,12 +67,6 @@ class InventoryBatchView(BatchMasterView): mobile_creatable = True mobile_rows_creatable = True - # set to False to disable "zero all" batch count mode - allow_zero_all = True - - # set to False to disable "variance" batch count mode - allow_variance = True - # set to False to prevent exposing case fields for user input, # when the batch count mode is "adjust only" allow_adjustment_cases = True @@ -192,10 +186,10 @@ class InventoryBatchView(BatchMasterView): modes.pop(self.enum.INVENTORY_MODE_REPLACE, None) if hasattr(self.enum, 'INVENTORY_MODE_REPLACE_ADJUST'): modes.pop(self.enum.INVENTORY_MODE_REPLACE_ADJUST, None) - if not self.allow_zero_all or not self.request.has_perm('{}.create.zero'.format(permission_prefix)): + if not self.handler.allow_zero_all or not self.request.has_perm('{}.create.zero'.format(permission_prefix)): if hasattr(self.enum, 'INVENTORY_MODE_ZERO_ALL'): modes.pop(self.enum.INVENTORY_MODE_ZERO_ALL, None) - if not self.allow_variance or not self.request.has_perm('{}.create.variance'.format(permission_prefix)): + if not self.handler.allow_variance or not self.request.has_perm('{}.create.variance'.format(permission_prefix)): if hasattr(self.enum, 'INVENTORY_MODE_VARIANCE'): modes.pop(self.enum.INVENTORY_MODE_VARIANCE, None) return modes @@ -694,19 +688,24 @@ class InventoryBatchView(BatchMasterView): @classmethod def _inventory_defaults(cls, config): + rattail_config = config.registry.settings['rattail_config'] model_key = cls.get_model_key() model_title = cls.get_model_title() route_prefix = cls.get_route_prefix() url_prefix = cls.get_url_prefix() permission_prefix = cls.get_permission_prefix() + # we need batch handler to determine available permissions + factory = cls.get_handler_factory(rattail_config) + handler = factory(rattail_config) + # extra perms for creating batches per "mode" config.add_tailbone_permission(permission_prefix, '{}.create.replace'.format(permission_prefix), "Create new {} with 'replace' mode".format(model_title)) - if cls.allow_zero_all: + if handler.allow_zero_all: config.add_tailbone_permission(permission_prefix, '{}.create.zero'.format(permission_prefix), "Create new {} with 'zero' mode".format(model_title)) - if cls.allow_variance: + if handler.allow_variance: config.add_tailbone_permission(permission_prefix, '{}.create.variance'.format(permission_prefix), "Create new {} with 'variance' mode".format(model_title)) From 2532fcbea20c1779529a70ff559f448f3ed625fd Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 29 Mar 2020 13:04:11 -0500 Subject: [PATCH 0110/1681] Let inventory batch handler decide if case input is allowed --- tailbone/views/batch/inventory.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/tailbone/views/batch/inventory.py b/tailbone/views/batch/inventory.py index d7780467..373b8f56 100644 --- a/tailbone/views/batch/inventory.py +++ b/tailbone/views/batch/inventory.py @@ -67,10 +67,6 @@ class InventoryBatchView(BatchMasterView): mobile_creatable = True mobile_rows_creatable = True - # set to False to prevent exposing case fields for user input, - # when the batch count mode is "adjust only" - allow_adjustment_cases = True - # set to True for the UI to "prefer" case amounts, as opposed to unit prefer_cases = False @@ -309,12 +305,9 @@ class InventoryBatchView(BatchMasterView): 'prefer_cases': self.prefer_cases, }) + # TODO: deprecate / remove this def allow_cases(self, batch): - if batch.mode == self.enum.INVENTORY_MODE_ADJUST: - if self.allow_adjustment_cases: - return True - return False - return True + return self.handler.allow_cases(batch) def should_aggregate_products(self, batch): """ From 12b567d3d21f5eb87044fa6a2b98a1482b70ebb0 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 29 Mar 2020 13:09:14 -0500 Subject: [PATCH 0111/1681] Let inventory batch handler decide what to do about unknown product scan --- tailbone/views/batch/inventory.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tailbone/views/batch/inventory.py b/tailbone/views/batch/inventory.py index 373b8f56..b15ed089 100644 --- a/tailbone/views/batch/inventory.py +++ b/tailbone/views/batch/inventory.py @@ -436,9 +436,6 @@ class InventoryBatchView(BatchMasterView): if self.viewing and not batch.executed and not batch.complete: f.remove_field('rowcount') - # TODO: document this, maybe move it etc. - unknown_product_creates_row = True - # TODO: this view can create new rows, with only a GET query. that should # probably be changed to require POST; for now we just require the "create # batch row" perm and call it good.. @@ -508,7 +505,7 @@ class InventoryBatchView(BatchMasterView): self.handler.add_row(batch, row) return row - elif self.unknown_product_creates_row: + elif self.handler.unknown_product_creates_row: row = model.InventoryBatchRow() row.upc = GPC(upc, calc_check_digit=False) # TODO: why not calc check digit? row.description = "(unknown product)" From 6a58f5f5d3d69014d6b1feb0ca27edbae2c2ef1c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 29 Mar 2020 13:33:38 -0500 Subject: [PATCH 0112/1681] Let inventory batch handler decide if products should be aggregated --- tailbone/views/batch/inventory.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tailbone/views/batch/inventory.py b/tailbone/views/batch/inventory.py index b15ed089..1932cf97 100644 --- a/tailbone/views/batch/inventory.py +++ b/tailbone/views/batch/inventory.py @@ -309,14 +309,13 @@ class InventoryBatchView(BatchMasterView): def allow_cases(self, batch): return self.handler.allow_cases(batch) + # TODO: deprecate / remove this def should_aggregate_products(self, batch): """ Must return a boolean indicating whether rows should be aggregated by product for the given batch. """ - if batch.mode == self.enum.INVENTORY_MODE_VARIANCE: - return True - return False + return self.handler.should_aggregate_products(batch) def desktop_lookup(self): """ From dc4531f545fd8e594b3a558ba39ae1ef5637646b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 29 Mar 2020 13:37:50 -0500 Subject: [PATCH 0113/1681] Let inventory batch handler decide which count modes are available --- tailbone/views/batch/inventory.py | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/tailbone/views/batch/inventory.py b/tailbone/views/batch/inventory.py index 1932cf97..f1d0dd4b 100644 --- a/tailbone/views/batch/inventory.py +++ b/tailbone/views/batch/inventory.py @@ -37,7 +37,7 @@ from rattail.db import model, api from rattail.db.util import make_full_description from rattail.time import localtime from rattail.gpc import GPC -from rattail.util import pretty_quantity +from rattail.util import pretty_quantity, OrderedDict import colander from deform import widget as dfwidget @@ -176,18 +176,15 @@ class InventoryBatchView(BatchMasterView): def get_available_modes(self): permission_prefix = self.get_permission_prefix() - modes = dict(self.enum.INVENTORY_MODE) - if not self.request.has_perm('{}.create.replace'.format(permission_prefix)): - if hasattr(self.enum, 'INVENTORY_MODE_REPLACE'): - modes.pop(self.enum.INVENTORY_MODE_REPLACE, None) - if hasattr(self.enum, 'INVENTORY_MODE_REPLACE_ADJUST'): - modes.pop(self.enum.INVENTORY_MODE_REPLACE_ADJUST, None) - if not self.handler.allow_zero_all or not self.request.has_perm('{}.create.zero'.format(permission_prefix)): - if hasattr(self.enum, 'INVENTORY_MODE_ZERO_ALL'): - modes.pop(self.enum.INVENTORY_MODE_ZERO_ALL, None) - if not self.handler.allow_variance or not self.request.has_perm('{}.create.variance'.format(permission_prefix)): - if hasattr(self.enum, 'INVENTORY_MODE_VARIANCE'): - modes.pop(self.enum.INVENTORY_MODE_VARIANCE, None) + if self.request.is_root: + modes = self.handler.get_count_modes() + else: + modes = self.handler.get_allowed_count_modes( + self.Session(), self.request.user, + permission_prefix=permission_prefix) + + modes = OrderedDict([(mode['code'], mode['label']) + for mode in modes]) return modes def configure_form(self, f): From 069eac1cf6a8d268e37662c7620662fd402027df Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 29 Mar 2020 14:30:48 -0500 Subject: [PATCH 0114/1681] Add temporary method for inventory batch view calling code should invoke handler directly instead of using this method, but for now we need it to exist --- tailbone/views/batch/inventory.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tailbone/views/batch/inventory.py b/tailbone/views/batch/inventory.py index f1d0dd4b..30412eb9 100644 --- a/tailbone/views/batch/inventory.py +++ b/tailbone/views/batch/inventory.py @@ -314,6 +314,10 @@ class InventoryBatchView(BatchMasterView): """ return self.handler.should_aggregate_products(batch) + # TODO: deprecate / remove + def find_type2_product(self, entry): + return self.handler.get_type2_product_info(self.Session(), entry) + def desktop_lookup(self): """ Try to locate a product by UPC, and validate it in the context of From 0e7835e2d9546c93c3c5ba4534a810d607491cee Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 29 Mar 2020 14:41:16 -0500 Subject: [PATCH 0115/1681] Make inventory batch handler responsible for finding row for product --- tailbone/views/batch/inventory.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/tailbone/views/batch/inventory.py b/tailbone/views/batch/inventory.py index 30412eb9..91577277 100644 --- a/tailbone/views/batch/inventory.py +++ b/tailbone/views/batch/inventory.py @@ -371,17 +371,9 @@ class InventoryBatchView(BatchMasterView): return result + # TODO: deprecate / remove def find_row_for_product(self, batch, product): - rows = self.Session.query(model.InventoryBatchRow)\ - .filter(model.InventoryBatchRow.batch == batch)\ - .filter(model.InventoryBatchRow.product == product)\ - .filter(model.InventoryBatchRow.removed == False)\ - .all() - if rows: - if len(rows) > 1: - log.error("inventory batch %s should aggregate products, but has %s rows for: %s", - batch.id_str, len(rows), product) - return rows[0] + return self.handler.find_row_for_product(self.Session(), batch, product) def find_product(self, entry): upc = re.sub(r'\D', '', entry.strip()) From 71a9010579b863211663cb38c446095321b9e10c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 29 Mar 2020 14:49:33 -0500 Subject: [PATCH 0116/1681] Make handler responsible for locating product for inventory batch --- tailbone/views/batch/inventory.py | 24 ++++++------------------ 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/tailbone/views/batch/inventory.py b/tailbone/views/batch/inventory.py index 91577277..0fcb9e87 100644 --- a/tailbone/views/batch/inventory.py +++ b/tailbone/views/batch/inventory.py @@ -33,7 +33,7 @@ import logging import six from rattail import pod -from rattail.db import model, api +from rattail.db import model from rattail.db.util import make_full_description from rattail.time import localtime from rattail.gpc import GPC @@ -375,25 +375,13 @@ class InventoryBatchView(BatchMasterView): def find_row_for_product(self, batch, product): return self.handler.find_row_for_product(self.Session(), batch, product) + # TODO: deprecate / remove (?) def find_product(self, entry): - upc = re.sub(r'\D', '', entry.strip()) - if upc: + lookup_by_code = self.rattail_config.getbool( + 'tailbone', 'inventory.lookup_by_code', default=False) - # first try to locate existing batch row by UPC match - provided = GPC(upc, calc_check_digit=False) - checked = GPC(upc, calc_check_digit='upc') - product = api.get_product_by_upc(self.Session(), provided) - if product: - return product - product = api.get_product_by_upc(self.Session(), checked) - if product: - return product - - # maybe try to locate product by alternate code - if self.rattail_config.getbool('tailbone', 'inventory.lookup_by_code', default=False): - product = api.get_product_by_code(self.Session(), entry) - if product: - return product + return self.handler.locate_product_for_entry( + self.Session(), entry, lookup_by_code=lookup_by_code) def product_info(self, product): data = {} From e9fc9ccbf79ede387aec6e6568f8301d7cc303bc Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 29 Mar 2020 15:19:22 -0500 Subject: [PATCH 0117/1681] Use "quick entry" logic from batch handler, for mobile inventory pretty sure desktop version still needs cleanup, but later... --- tailbone/views/batch/inventory.py | 40 ++++--------------------------- 1 file changed, 5 insertions(+), 35 deletions(-) diff --git a/tailbone/views/batch/inventory.py b/tailbone/views/batch/inventory.py index 0fcb9e87..843b7061 100644 --- a/tailbone/views/batch/inventory.py +++ b/tailbone/views/batch/inventory.py @@ -452,45 +452,15 @@ class InventoryBatchView(BatchMasterView): """ Add a row to the batch for the given UPC, if applicable. """ - type2 = self.find_type2_product(entry) - if type2: - product, price = type2 - else: - product = self.find_product(entry) - if product: + row = self.handler.quick_entry(self.Session(), batch, entry) + if row: - force_unit_item = self.rattail_config.getbool( - 'tailbone', 'inventory.force_unit_item', default=False) - if force_unit_item and product.is_pack_item(): - product = product.unit + if row.product and getattr(row.product, '__forced_unit_item__', False): self.request.session.flash("You scanned a pack item, but must count the units instead.", 'error') - aggregate = self.should_aggregate_products(batch) - if aggregate: - row = self.find_row_for_product(batch, product) - if row: - if warn_if_present: - self.request.session.flash("Product already exists in batch; please confirm counts", 'error') - return row + if warn_if_present and getattr(row, '__existing_reused__', False): + self.request.session.flash("Product already exists in batch; please confirm counts", 'error') - row = model.InventoryBatchRow() - row.product = product - row.upc = product.upc - self.handler.capture_current_units(row) - if type2 and not aggregate: - if price is None: - row.units = 1 - else: - row.units = (price / product.regular_price.price).quantize(decimal.Decimal('0.01')) - self.handler.add_row(batch, row) - return row - - elif self.handler.unknown_product_creates_row: - row = model.InventoryBatchRow() - row.upc = GPC(upc, calc_check_digit=False) # TODO: why not calc check digit? - row.description = "(unknown product)" - self.handler.capture_current_units(row) - self.handler.add_row(batch, row) return row def template_kwargs_view_row(self, **kwargs): From 0fbc8c9247d50b28d4ff5b7f846c40a98b12bcc3 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 29 Mar 2020 16:31:16 -0500 Subject: [PATCH 0118/1681] Add initial API views for inventory batches --- tailbone/api/batch/core.py | 47 +++++++-- tailbone/api/batch/inventory.py | 179 ++++++++++++++++++++++++++++++++ 2 files changed, 217 insertions(+), 9 deletions(-) create mode 100644 tailbone/api/batch/inventory.py diff --git a/tailbone/api/batch/core.py b/tailbone/api/batch/core.py index a3e4ab71..1200f703 100644 --- a/tailbone/api/batch/core.py +++ b/tailbone/api/batch/core.py @@ -26,6 +26,8 @@ Tailbone Web API - Batch Views from __future__ import unicode_literals, absolute_import +import logging + import six from rattail.time import localtime @@ -36,6 +38,9 @@ from cornice import resource, Service from tailbone.api import APIMasterView2 as APIMasterView +log = logging.getLogger(__name__) + + class APIBatchMixin(object): """ Base class for all API views which are meant to handle "batch" *and/or* @@ -85,14 +90,11 @@ class APIBatchView(APIBatchMixin, APIMasterView): def normalize(self, batch): - created = batch.created - created = localtime(self.rattail_config, created, from_utc=True) - created = self.pretty_datetime(created) + created = localtime(self.rattail_config, batch.created, from_utc=True) - executed = batch.executed - if executed: - executed = localtime(self.rattail_config, executed, from_utc=True) - executed = self.pretty_datetime(executed) + executed = None + if batch.executed: + executed = localtime(self.rattail_config, batch.executed, from_utc=True) return { 'uuid': batch.uuid, @@ -103,14 +105,16 @@ class APIBatchView(APIBatchMixin, APIMasterView): 'notes': batch.notes, 'params': batch.params or {}, 'rowcount': batch.rowcount, - 'created': created, + 'created': six.text_type(created), + 'created_display': self.pretty_datetime(created), 'created_by_uuid': batch.created_by.uuid, 'created_by_display': six.text_type(batch.created_by), 'complete': batch.complete, 'status_code': batch.status_code, 'status_display': batch.STATUS.get(batch.status_code, six.text_type(batch.status_code)), - 'executed': executed, + 'executed': six.text_type(executed) if executed else None, + '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 ''), } @@ -269,6 +273,28 @@ class APIBatchRowView(APIBatchMixin, APIMasterView): 'status_display': row.STATUS.get(row.status_code, six.text_type(row.status_code)), } + def update_object(self, row, data): + """ + Supplements the default logic as follows: + + Invokes the batch handler's ``refresh_row()`` method after updating the + row's field data per usual. + """ + # update row per usual + row = super(APIBatchRowView, self).update_object(row, data) + + # okay now we apply handler refresh logic + self.handler.refresh_row(row) + return row + + def delete_object(self, row): + """ + Overrides the default logic as follows: + + Delegates deletion of the row to the batch handler. + """ + self.handler.do_remove_row(row) + def quick_entry(self): """ View for handling "quick entry" user input, for a batch. @@ -285,6 +311,9 @@ class APIBatchRowView(APIBatchMixin, APIMasterView): try: row = self.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, + exc_info=True) msg = six.text_type(error) if not msg and isinstance(error, NotImplementedError): msg = "Feature is not implemented" diff --git a/tailbone/api/batch/inventory.py b/tailbone/api/batch/inventory.py new file mode 100644 index 00000000..40ab8ef6 --- /dev/null +++ b/tailbone/api/batch/inventory.py @@ -0,0 +1,179 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2020 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Tailbone Web API - Inventory Batches +""" + +from __future__ import unicode_literals, absolute_import + +import six + +from rattail import pod +from rattail.db import model +from rattail.util import pretty_quantity + +from cornice import Service + +from tailbone.api.batch import APIBatchView, APIBatchRowView + + +class InventoryBatchViews(APIBatchView): + + model_class = model.InventoryBatch + default_handler_spec = 'rattail.batch.inventory:InventoryBatchHandler' + route_prefix = 'inventory' + permission_prefix = 'batch.inventory' + collection_url_prefix = '/inventory-batches' + object_url_prefix = '/inventory-batch' + supports_toggle_complete = True + + def normalize(self, batch): + data = super(InventoryBatchViews, self).normalize(batch) + + data['mode'] = batch.mode + data['mode_display'] = self.enum.INVENTORY_MODE.get(batch.mode) + if data['mode_display'] is None and batch.mode is not None: + data['mode_display'] = six.text_type(batch.mode) + + data['reason_code'] = batch.reason_code + + return data + + def count_modes(self): + """ + Retrieve info about the available batch count modes. + """ + permission_prefix = self.get_permission_prefix() + if self.request.is_root: + modes = self.handler.get_count_modes() + else: + modes = self.handler.get_allowed_count_modes( + self.Session(), self.request.user, + permission_prefix=permission_prefix) + return modes + + def adjustment_reasons(self): + """ + Retrieve info about the available "reasons" for inventory adjustment + batches. + """ + raw_reasons = self.handler.get_adjustment_reasons(self.Session()) + reasons = [] + for reason in raw_reasons: + reasons.append({ + 'uuid': reason.uuid, + 'code': reason.code, + 'description': reason.description, + 'hidden': reason.hidden, + }) + return reasons + + @classmethod + def defaults(cls, config): + cls._defaults(config) + cls._batch_defaults(config) + cls._inventory_defaults(config) + + @classmethod + def _inventory_defaults(cls, config): + route_prefix = cls.get_route_prefix() + permission_prefix = cls.get_permission_prefix() + collection_url_prefix = cls.get_collection_url_prefix() + + # get count modes + count_modes = Service(name='{}.count_modes'.format(route_prefix), + path='{}/count-modes'.format(collection_url_prefix)) + count_modes.add_view('GET', 'count_modes', klass=cls, + permission='{}.list'.format(permission_prefix)) + config.add_cornice_service(count_modes) + + # get adjustment reasons + adjustment_reasons = Service(name='{}.adjustment_reasons'.format(route_prefix), + path='{}/adjustment-reasons'.format(collection_url_prefix)) + adjustment_reasons.add_view('GET', 'adjustment_reasons', klass=cls, + permission='{}.list'.format(permission_prefix)) + config.add_cornice_service(adjustment_reasons) + + +class InventoryBatchRowViews(APIBatchRowView): + + model_class = model.InventoryBatchRow + default_handler_spec = 'rattail.batch.inventory:InventoryBatchHandler' + route_prefix = 'inventory.rows' + permission_prefix = 'batch.inventory' + collection_url_prefix = '/inventory-batch-rows' + object_url_prefix = '/inventory-batch-row' + editable = True + supports_quick_entry = True + + def normalize(self, row): + batch = row.batch + data = super(InventoryBatchRowViews, self).normalize(row) + + data['item_id'] = row.item_id + data['upc'] = six.text_type(row.upc) + data['upc_pretty'] = row.upc.pretty() if row.upc else None + data['brand_name'] = row.brand_name + data['description'] = row.description + data['size'] = row.size + data['full_description'] = row.product.full_description if row.product else row.description + data['image_url'] = pod.get_image_url(self.rattail_config, row.upc) if row.upc else None + data['case_quantity'] = pretty_quantity(row.case_quantity or 1) + + data['cases'] = row.cases + data['units'] = row.units + data['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA' + data['quantity_display'] = "{} {}".format( + pretty_quantity(row.cases or row.units), + 'CS' if row.cases else data['unit_uom']) + + data['allow_cases'] = self.handler.allow_cases(batch) + + return data + + def update_object(self, row, data): + """ + Supplements the default logic as follows: + + Converts certain fields within the data, to proper "native" types. + """ + # convert some data types as needed + if 'cases' in data: + if data['cases'] == '': + data['cases'] = None + elif data['cases']: + data['cases'] = int(data['cases']) + if 'units' in data: + if data['units'] == '': + data['units'] = None + elif data['units']: + data['units'] = int(data['units']) + + # update row per usual + row = super(InventoryBatchRowViews, self).update_object(row, data) + return row + + +def includeme(config): + InventoryBatchViews.defaults(config) + InventoryBatchRowViews.defaults(config) From d2c479161139b1091d6537b7bcce9d021fe03733 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 4 Apr 2020 19:33:47 -0500 Subject: [PATCH 0119/1681] Add basic dashboard page for TempMon only the older jQuery theme is supported for now... --- .../templates/tempmon/appliances/index.mako | 8 + .../templates/tempmon/appliances/view.mako | 12 ++ tailbone/templates/tempmon/clients/index.mako | 12 ++ tailbone/templates/tempmon/clients/view.mako | 7 + tailbone/templates/tempmon/dashboard.mako | 135 +++++++++++++++ tailbone/templates/tempmon/probes/graph.mako | 10 ++ tailbone/templates/tempmon/probes/index.mako | 12 ++ tailbone/templates/tempmon/probes/view.mako | 3 + .../templates/tempmon/readings/index.mako | 12 ++ tailbone/views/tempmon/__init__.py | 3 +- tailbone/views/tempmon/dashboard.py | 162 ++++++++++++++++++ 11 files changed, 375 insertions(+), 1 deletion(-) create mode 100644 tailbone/templates/tempmon/appliances/view.mako create mode 100644 tailbone/templates/tempmon/clients/index.mako create mode 100644 tailbone/templates/tempmon/dashboard.mako create mode 100644 tailbone/templates/tempmon/probes/index.mako create mode 100644 tailbone/templates/tempmon/readings/index.mako create mode 100644 tailbone/views/tempmon/dashboard.py diff --git a/tailbone/templates/tempmon/appliances/index.mako b/tailbone/templates/tempmon/appliances/index.mako index 68334aa8..910cea35 100644 --- a/tailbone/templates/tempmon/appliances/index.mako +++ b/tailbone/templates/tempmon/appliances/index.mako @@ -27,4 +27,12 @@ </style> </%def> +<%def name="context_menu_items()"> + ${parent.context_menu_items()} + % if request.has_perm('tempmon.appliances.dashboard'): + <li>${h.link_to("Go to the Dashboard", url('tempmon.dashboard'))}</li> + % endif +</%def> + + ${parent.body()} diff --git a/tailbone/templates/tempmon/appliances/view.mako b/tailbone/templates/tempmon/appliances/view.mako new file mode 100644 index 00000000..bbaa0e3f --- /dev/null +++ b/tailbone/templates/tempmon/appliances/view.mako @@ -0,0 +1,12 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> + +<%def name="context_menu_items()"> + ${parent.context_menu_items()} + % if request.has_perm('tempmon.appliances.dashboard'): + <li>${h.link_to("Go to the Dashboard", url('tempmon.dashboard'))}</li> + % endif +</%def> + + +${parent.body()} diff --git a/tailbone/templates/tempmon/clients/index.mako b/tailbone/templates/tempmon/clients/index.mako new file mode 100644 index 00000000..829f9159 --- /dev/null +++ b/tailbone/templates/tempmon/clients/index.mako @@ -0,0 +1,12 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/index.mako" /> + +<%def name="context_menu_items()"> + ${parent.context_menu_items()} + % if request.has_perm('tempmon.appliances.dashboard'): + <li>${h.link_to("Go to the Dashboard", url('tempmon.dashboard'))}</li> + % endif +</%def> + + +${parent.body()} diff --git a/tailbone/templates/tempmon/clients/view.mako b/tailbone/templates/tempmon/clients/view.mako index f309b6c0..ab65bac6 100644 --- a/tailbone/templates/tempmon/clients/view.mako +++ b/tailbone/templates/tempmon/clients/view.mako @@ -15,6 +15,13 @@ % endif </%def> +<%def name="context_menu_items()"> + ${parent.context_menu_items()} + % if request.has_perm('tempmon.appliances.dashboard'): + <li>${h.link_to("Go to the Dashboard", url('tempmon.dashboard'))}</li> + % endif +</%def> + <%def name="object_helpers()"> % if instance.enabled and master.restartable_client(instance) and request.has_perm('{}.restart'.format(route_prefix)): <div class="object-helper"> diff --git a/tailbone/templates/tempmon/dashboard.mako b/tailbone/templates/tempmon/dashboard.mako new file mode 100644 index 00000000..815eb89e --- /dev/null +++ b/tailbone/templates/tempmon/dashboard.mako @@ -0,0 +1,135 @@ +## -*- coding: utf-8; -*- +<%inherit file="/page.mako" /> + +<%def name="title()">TempMon Appliances » Dashboard</%def> + +<%def name="content_title()">Dashboard</%def> + +<%def name="extra_javascript()"> + ${parent.extra_javascript()} + <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.5.0/Chart.bundle.min.js"></script> + % if not use_buefy: + <script type="text/javascript"> + + var contexts = {}; + var charts = {}; + + function fetchReadings(appliance_uuid) { + if (appliance_uuid === undefined) { + appliance_uuid = $('#appliance_uuid').val(); + } + + $('.form-wrapper').mask("Fetching data"); + + if (Object.keys(charts).length) { + Object.keys(charts).forEach(function(key) { + charts[key].destroy(); + delete charts[key]; + }); + } + + var url = '${url("tempmon.dashboard.readings")}'; + var params = {'appliance_uuid': appliance_uuid}; + $.get(url, params, function(data) { + + if (data.probes) { + data.probes.forEach(function(probe) { + charts[probe.uuid] = new Chart(contexts[probe.uuid], { + type: 'scatter', + data: { + datasets: [{ + label: probe.description, + data: probe.readings + }] + }, + options: { + scales: { + xAxes: [{ + type: 'time', + time: {unit: 'minute'}, + position: 'bottom' + }] + } + } + }); + }); + } else { + // TODO: should improve this + alert(data.error); + } + + $('.form-wrapper').unmask(); + }); + } + + $(function() { + + % for probe in appliance.probes: + contexts['${probe.uuid}'] = $('#tempchart-${probe.uuid}'); + % endfor + + $('#appliance_uuid').selectmenu({ + change: function(event, ui) { + $('.form-wrapper').mask("Fetching data"); + $(this).parents('form').submit(); + } + }); + + fetchReadings(); + }); + + </script> + % endif +</%def> + +<%def name="render_this_page()"> + <div style="display: flex;"> + + <div class="form-wrapper"> + <div class="form"> + ${h.form(request.current_route_url())} + ${h.csrf_token(request)} + % if use_buefy: + <b-field horizontal label="Appliance"> + ${appliance_select} + </b-field> + % else: + <div class="field-wrapper"> + <label>Appliance</label> + <div class="field"> + ${appliance_select} + </div> + </div> + % endif + ${h.end_form()} + </div> + </div> + + <a href="${url('tempmon.appliances.view', uuid=appliance.uuid)}"> + ${h.image(url('tempmon.appliances.thumbnail', uuid=appliance.uuid), "")} + </a> + </div> + + % if appliance.probes: + % for probe in appliance.probes: + <h3> + Probe: ${h.link_to(probe.description, url('tempmon.probes.graph', uuid=probe.uuid))} + (status: ${enum.TEMPMON_PROBE_STATUS[probe.status]}) + </h3> + % if probe.enabled: + % if use_buefy: + <canvas ref="tempchart" width="400" height="150"></canvas> + % else: + <canvas id="tempchart-${probe.uuid}" width="400" height="60"></canvas> + % endif + % else: + <p>This probe is not enabled.</p> + % endif + % endfor + % else: + <h3>This appliance has no probes configured!</h3> + % endif +</%def> + + +${parent.body()} diff --git a/tailbone/templates/tempmon/probes/graph.mako b/tailbone/templates/tempmon/probes/graph.mako index c197b33f..3255edb7 100644 --- a/tailbone/templates/tempmon/probes/graph.mako +++ b/tailbone/templates/tempmon/probes/graph.mako @@ -73,6 +73,16 @@ % endif </%def> +<%def name="context_menu_items()"> + ${parent.context_menu_items()} + % if master.has_perm('view'): + <li>${h.link_to("View this {}".format(model_title), master.get_action_url('view', probe))}</li> + % endif + % if request.has_perm('tempmon.appliances.dashboard'): + <li>${h.link_to("Go to the Dashboard", url('tempmon.dashboard'))}</li> + % endif +</%def> + <%def name="render_this_page()"> <div style="display: flex; justify-content: space-between;"> diff --git a/tailbone/templates/tempmon/probes/index.mako b/tailbone/templates/tempmon/probes/index.mako new file mode 100644 index 00000000..829f9159 --- /dev/null +++ b/tailbone/templates/tempmon/probes/index.mako @@ -0,0 +1,12 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/index.mako" /> + +<%def name="context_menu_items()"> + ${parent.context_menu_items()} + % if request.has_perm('tempmon.appliances.dashboard'): + <li>${h.link_to("Go to the Dashboard", url('tempmon.dashboard'))}</li> + % endif +</%def> + + +${parent.body()} diff --git a/tailbone/templates/tempmon/probes/view.mako b/tailbone/templates/tempmon/probes/view.mako index 8d22f95f..1e309129 100644 --- a/tailbone/templates/tempmon/probes/view.mako +++ b/tailbone/templates/tempmon/probes/view.mako @@ -92,6 +92,9 @@ <%def name="context_menu_items()"> ${parent.context_menu_items()} <li>${h.link_to("View Readings as Graph", action_url('graph', instance))}</li> + % if request.has_perm('tempmon.appliances.dashboard'): + <li>${h.link_to("Go to the Dashboard", url('tempmon.dashboard'))}</li> + % endif </%def> <%def name="render_main_fields(form)"> diff --git a/tailbone/templates/tempmon/readings/index.mako b/tailbone/templates/tempmon/readings/index.mako new file mode 100644 index 00000000..829f9159 --- /dev/null +++ b/tailbone/templates/tempmon/readings/index.mako @@ -0,0 +1,12 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/index.mako" /> + +<%def name="context_menu_items()"> + ${parent.context_menu_items()} + % if request.has_perm('tempmon.appliances.dashboard'): + <li>${h.link_to("Go to the Dashboard", url('tempmon.dashboard'))}</li> + % endif +</%def> + + +${parent.body()} diff --git a/tailbone/views/tempmon/__init__.py b/tailbone/views/tempmon/__init__.py index 61ce6bf0..840a38c1 100644 --- a/tailbone/views/tempmon/__init__.py +++ b/tailbone/views/tempmon/__init__.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -34,3 +34,4 @@ def includeme(config): config.include('tailbone.views.tempmon.clients') config.include('tailbone.views.tempmon.probes') config.include('tailbone.views.tempmon.readings') + config.include('tailbone.views.tempmon.dashboard') diff --git a/tailbone/views/tempmon/dashboard.py b/tailbone/views/tempmon/dashboard.py new file mode 100644 index 00000000..321f8c83 --- /dev/null +++ b/tailbone/views/tempmon/dashboard.py @@ -0,0 +1,162 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2020 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Tempmon "Dashboard" View +""" + +from __future__ import unicode_literals, absolute_import + +import datetime + +from rattail.time import localtime, make_utc +from rattail_tempmon.db import model as tempmon + +from webhelpers2.html import tags + +from tailbone.views import View +from tailbone.db import TempmonSession + + +class TempmonDashboardView(View): + """ + Dashboard view for tempmon + """ + session_key = 'tempmon.dashboard.appliance_uuid' + + def dashboard(self): + use_buefy = self.get_use_buefy() + + if self.request.method == 'POST': + appliance = None + uuid = self.request.POST.get('appliance_uuid') + if uuid: + appliance = TempmonSession.query(tempmon.Appliance).get(uuid) + if appliance: + self.request.session[self.session_key] = appliance.uuid + if not appliance: + self.request.session.flash("Appliance could not be found: {}".format(uuid), 'error') + raise self.redirect(self.request.current_route_url()) + + selected_uuid = self.request.params.get('appliance_uuid') + selected_appliance = None + if not selected_uuid: + selected_uuid = self.request.session.get(self.session_key) + if not selected_uuid: + # must declare the "first" appliance selected + selected_appliance = TempmonSession.query(tempmon.Appliance)\ + .order_by(tempmon.Appliance.name)\ + .first() + if selected_appliance: + selected_uuid = selected_appliance.uuid + self.request.session[self.session_key] = selected_uuid + + if not selected_appliance and selected_uuid: + selected_appliance = TempmonSession.query(tempmon.Appliance)\ + .get(selected_uuid) + + appliances = TempmonSession.query(tempmon.Appliance)\ + .order_by(tempmon.Appliance.name)\ + .all() + appliance_options = tags.Options([ + tags.Option(appliance.name, appliance.uuid) + for appliance in appliances]) + + if use_buefy: + appliance_select = None + raise NotImplementedError + else: + appliance_select = tags.select('appliance_uuid', selected_uuid, appliance_options) + + return { + 'index_url': self.request.route_url('tempmon.appliances'), + 'index_title': "TempMon Appliances", + 'use_buefy': use_buefy, + 'appliance_select': appliance_select, + 'appliance': selected_appliance, + } + + def readings(self): + + # track down the requested appliance + uuid = self.request.params.get('appliance_uuid') + if not uuid: + return {'error': "Must specify valid appliance_uuid"} + appliance = TempmonSession.query(tempmon.Appliance).get(uuid) + if not appliance: + return {'error': "Must specify valid appliance_uuid"} + + # remember which appliance was shown last + self.request.session[self.session_key] = appliance.uuid + + # fetch all "current" (recent) readings for all connected probes + probes = [] + cutoff = make_utc() - datetime.timedelta(seconds=7200) # 2 hours ago + for probe in appliance.probes: + probes.append({ + 'uuid': probe.uuid, + 'description': probe.description, + 'location': probe.location, + 'enabled': str(probe.enabled) if probe.enabled else None, + 'readings': self.get_probe_readings(probe, cutoff), + }) + return {'probes': probes} + + def get_probe_readings(self, probe, cutoff): + + # figure out which readings we need to graph + readings = TempmonSession.query(tempmon.Reading)\ + .filter(tempmon.Reading.probe == probe)\ + .filter(tempmon.Reading.taken >= cutoff)\ + .order_by(tempmon.Reading.taken)\ + .all() + + # convert readings to data for scatter plot + return [{ + 'x': localtime(self.rattail_config, reading.taken, from_utc=True).isoformat(), + 'y': float(reading.degrees_f), + } for reading in readings] + + @classmethod + def defaults(cls, config): + cls._defaults(config) + + @classmethod + def _defaults(cls, config): + + # dashboard + config.add_tailbone_permission('tempmon.appliances', 'tempmon.appliances.dashboard', + "View the Tempmon Appliance \"Dashboard\" page") + config.add_route('tempmon.dashboard', '/tempmon/dashboard', + request_method=('GET', 'POST')) + config.add_view(cls, attr='dashboard', route_name='tempmon.dashboard', + renderer='/tempmon/dashboard.mako') + + # readings + config.add_route('tempmon.dashboard.readings', '/tempmon/dashboard/readings', + request_method='GET') + config.add_view(cls, attr='readings', route_name='tempmon.dashboard.readings', + renderer='json') + + +def includeme(config): + TempmonDashboardView.defaults(config) From d9f6a7201eba325e93aea62b38f2da3240ab7646 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 4 Apr 2020 20:51:49 -0500 Subject: [PATCH 0120/1681] Let config totally disable the old/legacy jQuery mobile app --- tailbone/config.py | 5 +++++ tailbone/views/auth.py | 14 +++++++----- tailbone/views/batch/core.py | 16 ++++++++----- tailbone/views/batch/inventory.py | 8 ++++--- tailbone/views/common.py | 18 +++++++++------ tailbone/views/core.py | 11 ++++++++- tailbone/views/datasync.py | 11 +++++---- tailbone/views/master.py | 31 +++++++++++++------------- tailbone/views/products.py | 7 ++++-- tailbone/views/purchasing/batch.py | 4 +++- tailbone/views/purchasing/receiving.py | 10 +++++---- 11 files changed, 87 insertions(+), 48 deletions(-) diff --git a/tailbone/config.py b/tailbone/config.py index 5553924e..875bc25b 100644 --- a/tailbone/config.py +++ b/tailbone/config.py @@ -51,3 +51,8 @@ class ConfigExtension(BaseExtension): # provide default theme selection config.setdefault('tailbone', 'themes', 'default, falafel') config.setdefault('tailbone', 'themes.expose_picker', 'true') + + +def legacy_mobile_enabled(config): + return config.getbool('tailbone', 'legacy_mobile.enabled', + default=True) diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index 2dd37e2c..4765e8e8 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -206,6 +206,8 @@ class AuthenticationView(View): @classmethod def defaults(cls, config): + rattail_config = config.registry.settings.get('rattail_config') + legacy_mobile = cls.legacy_mobile_enabled(rattail_config) # forbidden config.add_forbidden_view(cls, attr='forbidden') @@ -213,14 +215,16 @@ class AuthenticationView(View): # login config.add_route('login', '/login') config.add_view(cls, attr='login', route_name='login', renderer='/login.mako') - config.add_route('mobile.login', '/mobile/login') - config.add_view(cls, attr='mobile_login', route_name='mobile.login', renderer='/mobile/login.mako') + if legacy_mobile: + config.add_route('mobile.login', '/mobile/login') + config.add_view(cls, attr='mobile_login', route_name='mobile.login', renderer='/mobile/login.mako') # logout config.add_route('logout', '/logout') config.add_view(cls, attr='logout', route_name='logout') - config.add_route('mobile.logout', '/mobile/logout') - config.add_view(cls, attr='mobile_logout', route_name='mobile.logout') + if legacy_mobile: + config.add_route('mobile.logout', '/mobile/logout') + config.add_view(cls, attr='mobile_logout', route_name='mobile.logout') # no-op config.add_route('noop', '/noop') diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index e3e14ee8..fff98730 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -1489,12 +1489,14 @@ class BatchMasterView(MasterView): @classmethod def _batch_defaults(cls, config): + rattail_config = config.registry.settings.get('rattail_config') model_key = cls.get_model_key() route_prefix = cls.get_route_prefix() url_prefix = cls.get_url_prefix() permission_prefix = cls.get_permission_prefix() model_title = cls.get_model_title() model_title_plural = cls.get_model_title_plural() + legacy_mobile = cls.legacy_mobile_enabled(rattail_config) # TODO: currently must do this here (in addition to `_defaults()` or # else the perm group label will not display correctly... @@ -1538,14 +1540,16 @@ class BatchMasterView(MasterView): permission='{}.edit'.format(permission_prefix)) # mobile mark complete - config.add_route('mobile.{}.mark_complete'.format(route_prefix), '/mobile{}/{{{}}}/mark-complete'.format(url_prefix, model_key)) - config.add_view(cls, attr='mobile_mark_complete', route_name='mobile.{}.mark_complete'.format(route_prefix), - permission='{}.edit'.format(permission_prefix)) + if legacy_mobile: + config.add_route('mobile.{}.mark_complete'.format(route_prefix), '/mobile{}/{{{}}}/mark-complete'.format(url_prefix, model_key)) + config.add_view(cls, attr='mobile_mark_complete', route_name='mobile.{}.mark_complete'.format(route_prefix), + permission='{}.edit'.format(permission_prefix)) # mobile mark pending - config.add_route('mobile.{}.mark_pending'.format(route_prefix), '/mobile{}/{{{}}}/mark-pending'.format(url_prefix, model_key)) - config.add_view(cls, attr='mobile_mark_pending', route_name='mobile.{}.mark_pending'.format(route_prefix), - permission='{}.edit'.format(permission_prefix)) + if legacy_mobile: + config.add_route('mobile.{}.mark_pending'.format(route_prefix), '/mobile{}/{{{}}}/mark-pending'.format(url_prefix, model_key)) + config.add_view(cls, attr='mobile_mark_pending', route_name='mobile.{}.mark_pending'.format(route_prefix), + permission='{}.edit'.format(permission_prefix)) # refresh multiple batches (results) if cls.results_refreshable: diff --git a/tailbone/views/batch/inventory.py b/tailbone/views/batch/inventory.py index 843b7061..fd4b9b07 100644 --- a/tailbone/views/batch/inventory.py +++ b/tailbone/views/batch/inventory.py @@ -634,6 +634,7 @@ class InventoryBatchView(BatchMasterView): route_prefix = cls.get_route_prefix() url_prefix = cls.get_url_prefix() permission_prefix = cls.get_permission_prefix() + legacy_mobile = cls.legacy_mobile_enabled(rattail_config) # we need batch handler to determine available permissions factory = cls.get_handler_factory(rattail_config) @@ -655,9 +656,10 @@ class InventoryBatchView(BatchMasterView): renderer='json', permission='{}.create_row'.format(permission_prefix)) # mobile - make new row from UPC - config.add_route('mobile.{}.row_from_upc'.format(route_prefix), '/mobile{}/{{{}}}/row-from-upc'.format(url_prefix, model_key)) - config.add_view(cls, attr='mobile_row_from_upc', route_name='mobile.{}.row_from_upc'.format(route_prefix), - permission='{}.create_row'.format(permission_prefix)) + if legacy_mobile: + config.add_route('mobile.{}.row_from_upc'.format(route_prefix), '/mobile{}/{{{}}}/row-from-upc'.format(url_prefix, model_key)) + config.add_view(cls, attr='mobile_row_from_upc', route_name='mobile.{}.row_from_upc'.format(route_prefix), + permission='{}.create_row'.format(permission_prefix)) # TODO: this is a stopgap measure to fix an obvious bug, which exists when the diff --git a/tailbone/views/common.py b/tailbone/views/common.py index dd02e614..a118de7e 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -189,6 +189,7 @@ class CommonView(View): @classmethod def _defaults(cls, config): rattail_config = config.registry.settings.get('rattail_config') + legacy_mobile = cls.legacy_mobile_enabled(rattail_config) # auto-correct URLs which require trailing slash config.add_notfound_view(cls, attr='notfound', append_slash=True) @@ -203,8 +204,9 @@ class CommonView(View): # home config.add_route('home', '/') config.add_view(cls, attr='home', route_name='home', renderer='/home.mako') - config.add_route('mobile.home', '/mobile/') - config.add_view(cls, attr='mobile_home', route_name='mobile.home', renderer='/mobile/home.mako') + if legacy_mobile: + config.add_route('mobile.home', '/mobile/') + config.add_view(cls, attr='mobile_home', route_name='mobile.home', renderer='/mobile/home.mako') # robots.txt config.add_route('robots.txt', '/robots.txt') @@ -213,8 +215,9 @@ class CommonView(View): # about config.add_route('about', '/about') config.add_view(cls, attr='about', route_name='about', renderer='/about.mako') - config.add_route('mobile.about', '/mobile/about') - config.add_view(cls, attr='about', route_name='mobile.about', renderer='/mobile/about.mako') + if legacy_mobile: + config.add_route('mobile.about', '/mobile/about') + config.add_view(cls, attr='about', route_name='mobile.about', renderer='/mobile/about.mako') # change db engine config.add_tailbone_permission('common', 'common.change_db_engine', @@ -234,9 +237,10 @@ class CommonView(View): config.add_route('feedback', '/feedback', request_method='POST') config.add_view(cls, attr='feedback', route_name='feedback', renderer='json', permission='common.feedback') - config.add_route('mobile.feedback', '/mobile/feedback', request_method='POST') - config.add_view(cls, attr='mobile_feedback', route_name='mobile.feedback', - renderer='json', permission='common.feedback') + if legacy_mobile: + config.add_route('mobile.feedback', '/mobile/feedback', request_method='POST') + config.add_view(cls, attr='mobile_feedback', route_name='mobile.feedback', + renderer='json', permission='common.feedback') # consume batch ID config.add_tailbone_permission('common', 'common.consume_batch_id', diff --git a/tailbone/views/core.py b/tailbone/views/core.py index 7e7a7cee..79a6c797 100644 --- a/tailbone/views/core.py +++ b/tailbone/views/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -41,6 +41,7 @@ from tailbone.db import Session from tailbone.auth import logout_user from tailbone.progress import SessionProgress from tailbone.util import should_use_buefy +from tailbone.config import legacy_mobile_enabled class View(object): @@ -88,6 +89,14 @@ class View(object): """ return should_use_buefy(self.request) + @classmethod + def legacy_mobile_enabled(cls, rattail_config): + """ + Returns the boolean setting indicating whether the old / "legacy" + (jQuery) mobile app/site should be exposed. + """ + return legacy_mobile_enabled(rattail_config) + def late_login_user(self): """ Returns the :class:`rattail:rattail.db.model.User` instance diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py index 688a479a..309b3bc2 100644 --- a/tailbone/views/datasync.py +++ b/tailbone/views/datasync.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -94,6 +94,8 @@ class DataSyncChangesView(MasterView): @classmethod def defaults(cls, config): + rattail_config = config.registry.settings.get('rattail_config') + legacy_mobile = cls.legacy_mobile_enabled(rattail_config) # fix permission group title config.add_tailbone_permission_group('datasync', label="DataSync") @@ -104,9 +106,10 @@ class DataSyncChangesView(MasterView): config.add_route('datasync.restart', '/datasync/restart') config.add_view(cls, attr='restart', route_name='datasync.restart', permission='datasync.restart') # mobile - config.add_route('datasync.mobile', '/mobile/datasync/') - config.add_view(cls, attr='mobile_index', route_name='datasync.mobile', - permission='datasync.restart', renderer='/mobile/datasync.mako') + if legacy_mobile: + config.add_route('datasync.mobile', '/mobile/datasync/') + config.add_view(cls, attr='mobile_index', route_name='datasync.mobile', + permission='datasync.restart', renderer='/mobile/datasync.mako') cls._defaults(config) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 4a16927c..d7bc8bdb 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -3593,6 +3593,7 @@ class MasterView(View): model_title_plural = cls.get_model_title_plural() if cls.has_rows: row_model_title = cls.get_row_model_title() + legacy_mobile = cls.legacy_mobile_enabled(rattail_config) config.add_tailbone_permission_group(permission_prefix, model_title_plural, overwrite=False) @@ -3603,7 +3604,7 @@ class MasterView(View): config.add_route(route_prefix, '{}/'.format(url_prefix)) config.add_view(cls, attr='index', route_name=route_prefix, permission='{}.list'.format(permission_prefix)) - if cls.supports_mobile: + if legacy_mobile and cls.supports_mobile: config.add_route('mobile.{}'.format(route_prefix), '/mobile{}/'.format(url_prefix)) config.add_view(cls, attr='mobile_index', route_name='mobile.{}'.format(route_prefix), permission='{}.list'.format(permission_prefix)) @@ -3632,14 +3633,14 @@ class MasterView(View): permission='{}.quickie'.format(permission_prefix)) # create - if cls.creatable or cls.mobile_creatable: + if cls.creatable or (legacy_mobile and cls.mobile_creatable): config.add_tailbone_permission(permission_prefix, '{}.create'.format(permission_prefix), "Create new {}".format(model_title)) if cls.creatable: config.add_route('{}.create'.format(route_prefix), '{}/new'.format(url_prefix)) config.add_view(cls, attr='create', route_name='{}.create'.format(route_prefix), permission='{}.create'.format(permission_prefix)) - if cls.mobile_creatable: + if legacy_mobile and cls.mobile_creatable: config.add_route('mobile.{}.create'.format(route_prefix), '/mobile{}/new'.format(url_prefix)) config.add_view(cls, attr='mobile_create', route_name='mobile.{}.create'.format(route_prefix), permission='{}.create'.format(permission_prefix)) @@ -3709,7 +3710,7 @@ class MasterView(View): config.add_route('{}.view'.format(route_prefix), instance_url_prefix) config.add_view(cls, attr='view', route_name='{}.view'.format(route_prefix), permission='{}.view'.format(permission_prefix)) - if cls.supports_mobile: + if legacy_mobile and cls.supports_mobile: config.add_route('mobile.{}.view'.format(route_prefix), '/mobile{}'.format(instance_url_prefix)) config.add_view(cls, attr='mobile_view', route_name='mobile.{}.view'.format(route_prefix), permission='{}.view'.format(permission_prefix)) @@ -3762,27 +3763,27 @@ class MasterView(View): "Download associated data for {}".format(model_title)) # edit - if cls.editable or cls.mobile_editable: + if cls.editable or (legacy_mobile and cls.mobile_editable): config.add_tailbone_permission(permission_prefix, '{}.edit'.format(permission_prefix), "Edit {}".format(model_title)) if cls.editable: config.add_route('{}.edit'.format(route_prefix), '{}/edit'.format(instance_url_prefix)) config.add_view(cls, attr='edit', route_name='{}.edit'.format(route_prefix), permission='{}.edit'.format(permission_prefix)) - if cls.mobile_editable: + if legacy_mobile and cls.mobile_editable: config.add_route('mobile.{}.edit'.format(route_prefix), '/mobile{}/edit'.format(instance_url_prefix)) config.add_view(cls, attr='mobile_edit', route_name='mobile.{}.edit'.format(route_prefix), permission='{}.edit'.format(permission_prefix)) # execute - if cls.executable or cls.mobile_executable: + if cls.executable or (legacy_mobile and cls.mobile_executable): config.add_tailbone_permission(permission_prefix, '{}.execute'.format(permission_prefix), "Execute {}".format(model_title)) if cls.executable: config.add_route('{}.execute'.format(route_prefix), '{}/execute'.format(instance_url_prefix)) config.add_view(cls, attr='execute', route_name='{}.execute'.format(route_prefix), permission='{}.execute'.format(permission_prefix)) - if cls.mobile_executable: + if legacy_mobile and cls.mobile_executable: config.add_route('mobile.{}.execute'.format(route_prefix), '/mobile{}/execute'.format(instance_url_prefix)) config.add_view(cls, attr='mobile_execute', route_name='mobile.{}.execute'.format(route_prefix), permission='{}.execute'.format(permission_prefix)) @@ -3820,14 +3821,14 @@ class MasterView(View): # create row if cls.has_rows: - if cls.rows_creatable or cls.mobile_rows_creatable: + if cls.rows_creatable or (legacy_mobile and cls.mobile_rows_creatable): config.add_tailbone_permission(permission_prefix, '{}.create_row'.format(permission_prefix), "Create new {} rows".format(model_title)) if cls.rows_creatable: config.add_route('{}.create_row'.format(route_prefix), '{}/new-row'.format(instance_url_prefix)) config.add_view(cls, attr='create_row', route_name='{}.create_row'.format(route_prefix), permission='{}.create_row'.format(permission_prefix)) - if cls.mobile_rows_creatable: + if legacy_mobile and cls.mobile_rows_creatable: config.add_route('mobile.{}.create_row'.format(route_prefix), '/mobile{}/new-row'.format(instance_url_prefix)) config.add_view(cls, attr='mobile_create_row', route_name='mobile.{}.create_row'.format(route_prefix), permission='{}.create_row'.format(permission_prefix)) @@ -3842,35 +3843,35 @@ class MasterView(View): config.add_route('{}.view_row'.format(route_prefix), '{}/{{uuid}}/rows/{{row_uuid}}'.format(url_prefix)) config.add_view(cls, attr='view_row', route_name='{}.view_row'.format(route_prefix), permission='{}.view'.format(permission_prefix)) - if cls.mobile_rows_viewable: + if legacy_mobile and cls.mobile_rows_viewable: config.add_route('mobile.{}.view_row'.format(route_prefix), '/mobile{}/{{uuid}}/rows/{{row_uuid}}'.format(url_prefix)) config.add_view(cls, attr='mobile_view_row', route_name='mobile.{}.view_row'.format(route_prefix), permission='{}.view'.format(permission_prefix)) # edit row if cls.has_rows: - if cls.rows_editable or cls.mobile_rows_editable: + if cls.rows_editable or (legacy_mobile and cls.mobile_rows_editable): config.add_tailbone_permission(permission_prefix, '{}.edit_row'.format(permission_prefix), "Edit individual {} rows".format(model_title)) if cls.rows_editable: config.add_route('{}.edit_row'.format(route_prefix), '{}/{{uuid}}/rows/{{row_uuid}}/edit'.format(url_prefix)) config.add_view(cls, attr='edit_row', route_name='{}.edit_row'.format(route_prefix), permission='{}.edit_row'.format(permission_prefix)) - if cls.mobile_rows_editable: + if legacy_mobile and cls.mobile_rows_editable: config.add_route('mobile.{}.edit_row'.format(route_prefix), '/mobile{}/{{uuid}}/rows/{{row_uuid}}/edit'.format(url_prefix)) config.add_view(cls, attr='mobile_edit_row', route_name='mobile.{}.edit_row'.format(route_prefix), permission='{}.edit_row'.format(permission_prefix)) # delete row if cls.has_rows: - if cls.rows_deletable or cls.mobile_rows_deletable: + if cls.rows_deletable or (legacy_mobile and cls.mobile_rows_deletable): config.add_tailbone_permission(permission_prefix, '{}.delete_row'.format(permission_prefix), "Delete individual {} rows".format(model_title)) if cls.rows_deletable: config.add_route('{}.delete_row'.format(route_prefix), '{}/{{uuid}}/rows/{{row_uuid}}/delete'.format(url_prefix)) config.add_view(cls, attr='delete_row', route_name='{}.delete_row'.format(route_prefix), permission='{}.delete_row'.format(permission_prefix)) - if cls.mobile_rows_deletable: + if legacy_mobile and cls.mobile_rows_deletable: config.add_route('mobile.{}.delete_row'.format(route_prefix), '/mobile{}/{{uuid}}/rows/{{row_uuid}}/delete'.format(url_prefix)) config.add_view(cls, attr='mobile_delete_row', route_name='mobile.{}.delete_row'.format(route_prefix), permission='{}.delete_row'.format(permission_prefix)) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index bfe01fc1..8a2c3d0c 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -1651,11 +1651,13 @@ class ProductsView(MasterView): @classmethod def _product_defaults(cls, config): + rattail_config = config.registry.settings.get('rattail_config') route_prefix = cls.get_route_prefix() url_prefix = cls.get_url_prefix() template_prefix = cls.get_template_prefix() permission_prefix = cls.get_permission_prefix() model_title = cls.get_model_title() + legacy_mobile = cls.legacy_mobile_enabled(rattail_config) # print labels config.add_tailbone_permission('products', 'products.print_labels', @@ -1683,8 +1685,9 @@ class ProductsView(MasterView): config.add_view(cls, attr='image', route_name='products.image') # mobile quick lookup - config.add_route('mobile.products.quick_lookup', '/mobile/products/quick-lookup') - config.add_view(cls, attr='mobile_quick_lookup', route_name='mobile.products.quick_lookup') + if legacy_mobile: + config.add_route('mobile.products.quick_lookup', '/mobile/products/quick-lookup') + config.add_view(cls, attr='mobile_quick_lookup', route_name='mobile.products.quick_lookup') class ProductsAutocomplete(AutocompleteView): diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index 96f0d18c..97a002d3 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -975,11 +975,13 @@ class PurchasingBatchView(BatchMasterView): @classmethod def _purchasing_defaults(cls, config): + rattail_config = config.registry.settings.get('rattail_config') route_prefix = cls.get_route_prefix() url_prefix = cls.get_url_prefix() permission_prefix = cls.get_permission_prefix() model_key = cls.get_model_key() model_title = cls.get_model_title() + legacy_mobile = cls.legacy_mobile_enabled(rattail_config) # eligible purchases (AJAX) config.add_route('{}.eligible_purchases'.format(route_prefix), '{}/eligible-purchases'.format(url_prefix)) @@ -987,7 +989,7 @@ class PurchasingBatchView(BatchMasterView): renderer='json', permission='{}.view'.format(permission_prefix)) # add new product - if cls.supports_new_product: + if legacy_mobile and cls.supports_new_product: config.add_tailbone_permission(permission_prefix, '{}.new_product'.format(permission_prefix), "Create new Product when adding row to {}".format(model_title)) config.add_route('mobile.{}.new_product'.format(route_prefix), '{}/{{{}}}/new-product'.format(url_prefix, model_key)) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 8a8ca0dd..17d2eddf 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -1804,14 +1804,16 @@ class ReceivingBatchView(PurchasingBatchView): instance_url_prefix = cls.get_instance_url_prefix() model_key = cls.get_model_key() permission_prefix = cls.get_permission_prefix() + legacy_mobile = cls.legacy_mobile_enabled(rattail_config) # row-level receiving config.add_route('{}.receive_row'.format(route_prefix), '{}/{{uuid}}/rows/{{row_uuid}}/receive'.format(url_prefix)) config.add_view(cls, attr='receive_row', route_name='{}.receive_row'.format(route_prefix), permission='{}.edit_row'.format(permission_prefix)) - config.add_route('mobile.{}.receive_row'.format(route_prefix), '/mobile{}/{{uuid}}/rows/{{row_uuid}}/receive'.format(url_prefix)) - config.add_view(cls, attr='mobile_receive_row', route_name='mobile.{}.receive_row'.format(route_prefix), - permission='{}.edit_row'.format(permission_prefix)) + if legacy_mobile: + config.add_route('mobile.{}.receive_row'.format(route_prefix), '/mobile{}/{{uuid}}/rows/{{row_uuid}}/receive'.format(url_prefix)) + config.add_view(cls, attr='mobile_receive_row', route_name='mobile.{}.receive_row'.format(route_prefix), + permission='{}.edit_row'.format(permission_prefix)) # declare credit for row config.add_route('{}.declare_credit'.format(route_prefix), '{}/{{uuid}}/rows/{{row_uuid}}/declare-credit'.format(url_prefix)) From f2b5e2302a0eb4ea86412c8bcf99281ce4d4c06a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 4 Apr 2020 21:44:01 -0500 Subject: [PATCH 0121/1681] Delete some unwanted tests; delay import for tempmon session view config can now depend on rattail config, and tests don't like that... but they didn't really do anything that useful anyway i think --- tailbone/views/tempmon/core.py | 7 +++---- tests/test_root.py | 11 ----------- tests/test_views.py | 11 ----------- 3 files changed, 3 insertions(+), 26 deletions(-) delete mode 100644 tests/test_root.py delete mode 100644 tests/test_views.py diff --git a/tailbone/views/tempmon/core.py b/tailbone/views/tempmon/core.py index 03c4b9f1..6665f50e 100644 --- a/tailbone/views/tempmon/core.py +++ b/tailbone/views/tempmon/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -26,8 +26,6 @@ Common stuff for tempmon views from __future__ import unicode_literals, absolute_import -from rattail_tempmon.db import Session as RawTempmonSession - from webhelpers2.html import HTML from tailbone import views, grids @@ -41,7 +39,8 @@ class MasterView(views.MasterView): Session = TempmonSession def get_bulk_delete_session(self): - return RawTempmonSession() + from rattail_tempmon.db import Session + return Session() def render_probes(self, obj, field): """ diff --git a/tests/test_root.py b/tests/test_root.py deleted file mode 100644 index 40363b88..00000000 --- a/tests/test_root.py +++ /dev/null @@ -1,11 +0,0 @@ - -from . import TestCase - - -class RootTests(TestCase): - """ - Test root module. - """ - - def test_includeme(self): - self.config.include('tailbone') diff --git a/tests/test_views.py b/tests/test_views.py deleted file mode 100644 index 648bdf7d..00000000 --- a/tests/test_views.py +++ /dev/null @@ -1,11 +0,0 @@ - -from . import TestCase - - -class ViewTests(TestCase): - """ - Test root views module. - """ - - def test_includeme(self): - self.config.include('tailbone.views') From cc96d9877b2fdbea2a3eb3bd7f4cda42975ca5ce Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 6 Apr 2020 13:12:38 -0500 Subject: [PATCH 0122/1681] Defer fetching price, cost history when viewing product details user can ask for that history if they need it, but it's too expensive to always fetch by default for initial page load --- tailbone/templates/page.mako | 3 +- tailbone/templates/products/view.mako | 123 ++++++++++++++++++++++++-- tailbone/views/products.py | 105 ++++++++++++++++++++-- 3 files changed, 213 insertions(+), 18 deletions(-) diff --git a/tailbone/templates/page.mako b/tailbone/templates/page.mako index 1dbd333b..2d8227d4 100644 --- a/tailbone/templates/page.mako +++ b/tailbone/templates/page.mako @@ -32,7 +32,8 @@ let ThisPage = { template: '#this-page-template', - methods: {} + computed: {}, + methods: {}, } let ThisPageData = { diff --git a/tailbone/templates/products/view.mako b/tailbone/templates/products/view.mako index 99f555ae..c8f2a5ec 100644 --- a/tailbone/templates/products/view.mako +++ b/tailbone/templates/products/view.mako @@ -305,7 +305,7 @@ <p class="panel-heading"> Vendor Sources % if request.rattail_config.versioning_enabled() and master.has_perm('versions'): - <a href="#" @click.prevent="showingCostHistory = true"> + <a href="#" @click.prevent="showCostHistory()"> (view cost history) </a> % endif @@ -380,7 +380,7 @@ </p> </header> <section class="modal-card-body"> - ${regular_price_history_grid.render_buefy_table_element(data_prop='regularPriceHistoryData')|n} + ${regular_price_history_grid.render_buefy_table_element(data_prop='regularPriceHistoryData', loading='regularPriceHistoryLoading')|n} </section> <footer class="modal-card-foot"> <b-button @click="showingPriceHistory_regular = false"> @@ -399,7 +399,7 @@ </p> </header> <section class="modal-card-body"> - ${current_price_history_grid.render_buefy_table_element(data_prop='currentPriceHistoryData')|n} + ${current_price_history_grid.render_buefy_table_element(data_prop='currentPriceHistoryData', loading='currentPriceHistoryLoading')|n} </section> <footer class="modal-card-foot"> <b-button @click="showingPriceHistory_current = false"> @@ -418,7 +418,7 @@ </p> </header> <section class="modal-card-body"> - ${suggested_price_history_grid.render_buefy_table_element(data_prop='suggestedPriceHistoryData')|n} + ${suggested_price_history_grid.render_buefy_table_element(data_prop='suggestedPriceHistoryData', loading='suggestedPriceHistoryLoading')|n} </section> <footer class="modal-card-foot"> <b-button @click="showingPriceHistory_suggested = false"> @@ -437,7 +437,7 @@ </p> </header> <section class="modal-card-body"> - ${cost_history_grid.render_buefy_table_element(data_prop='costHistoryData')|n} + ${cost_history_grid.render_buefy_table_element(data_prop='costHistoryData', loading='costHistoryLoading')|n} </section> <footer class="modal-card-foot"> <b-button @click="showingCostHistory = false"> @@ -539,16 +539,121 @@ <script type="text/javascript"> ThisPageData.showingPriceHistory_regular = false - ThisPageData.regularPriceHistoryData = ${json.dumps(regular_price_history_grid.get_buefy_data()['data'])|n} + ThisPageData.regularPriceHistoryDataRaw = ${json.dumps(regular_price_history_grid.get_buefy_data()['data'])|n} + ThisPageData.regularPriceHistoryLoading = false + + ThisPage.computed.regularPriceHistoryData = function() { + let data = [] + this.regularPriceHistoryDataRaw.forEach(raw => { + data.push({ + price: raw.price_display, + since: raw.since, + changed: raw.changed_display_html, + changed_by: raw.changed_by_display, + }) + }) + return data + } + + ThisPage.methods.showPriceHistory_regular = function() { + this.showingPriceHistory_regular = true + this.regularPriceHistoryLoading = true + + let url = '${url("products.price_history", uuid=instance.uuid)}' + let params = {'type': 'regular'} + this.$http.get(url, {params: params}).then(response => { + this.regularPriceHistoryDataRaw = response.data + this.regularPriceHistoryLoading = false + }) + } ThisPageData.showingPriceHistory_current = false - ThisPageData.currentPriceHistoryData = ${json.dumps(current_price_history_grid.get_buefy_data()['data'])|n} + ThisPageData.currentPriceHistoryDataRaw = ${json.dumps(current_price_history_grid.get_buefy_data()['data'])|n} + ThisPageData.currentPriceHistoryLoading = false + + ThisPage.computed.currentPriceHistoryData = function() { + let data = [] + this.currentPriceHistoryDataRaw.forEach(raw => { + data.push({ + price: raw.price_display, + price_type: raw.price_type, + since: raw.since, + changed: raw.changed_display_html, + changed_by: raw.changed_by_display, + }) + }) + return data + } + + ThisPage.methods.showPriceHistory_current = function() { + this.showingPriceHistory_current = true + this.currentPriceHistoryLoading = true + + let url = '${url("products.price_history", uuid=instance.uuid)}' + let params = {'type': 'current'} + this.$http.get(url, {params: params}).then(response => { + this.currentPriceHistoryDataRaw = response.data + this.currentPriceHistoryLoading = false + }) + } ThisPageData.showingPriceHistory_suggested = false - ThisPageData.suggestedPriceHistoryData = ${json.dumps(suggested_price_history_grid.get_buefy_data()['data'])|n} + ThisPageData.suggestedPriceHistoryDataRaw = ${json.dumps(suggested_price_history_grid.get_buefy_data()['data'])|n} + ThisPageData.suggestedPriceHistoryLoading = false + + ThisPage.computed.suggestedPriceHistoryData = function() { + let data = [] + this.suggestedPriceHistoryDataRaw.forEach(raw => { + data.push({ + price: raw.price_display, + since: raw.since, + changed: raw.changed_display_html, + changed_by: raw.changed_by_display, + }) + }) + return data + } + + ThisPage.methods.showPriceHistory_suggested = function() { + this.showingPriceHistory_suggested = true + this.suggestedPriceHistoryLoading = true + + let url = '${url("products.price_history", uuid=instance.uuid)}' + let params = {'type': 'suggested'} + this.$http.get(url, {params: params}).then(response => { + this.suggestedPriceHistoryDataRaw = response.data + this.suggestedPriceHistoryLoading = false + }) + } ThisPageData.showingCostHistory = false - ThisPageData.costHistoryData = ${json.dumps(cost_history_grid.get_buefy_data()['data'])|n} + ThisPageData.costHistoryDataRaw = ${json.dumps(cost_history_grid.get_buefy_data()['data'])|n} + ThisPageData.costHistoryLoading = false + + ThisPage.computed.costHistoryData = function() { + let data = [] + this.costHistoryDataRaw.forEach(raw => { + data.push({ + cost: raw.cost_display, + vendor: raw.vendor, + since: raw.since, + changed: raw.changed_display_html, + changed_by: raw.changed_by_display, + }) + }) + return data + } + + ThisPage.methods.showCostHistory = function() { + this.showingCostHistory = true + this.costHistoryLoading = true + + let url = '${url("products.cost_history", uuid=instance.uuid)}' + this.$http.get(url).then(response => { + this.costHistoryDataRaw = response.data + this.costHistoryLoading = false + }) + } </script> % endif diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 8a2c3d0c..aaf70774 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -483,7 +483,7 @@ class ProductsView(MasterView): return text if self.get_use_buefy(): - kwargs = {'@click.prevent': 'showingPriceHistory_{} = true'.format(typ)} + kwargs = {'@click.prevent': 'showPriceHistory_{}()'.format(typ)} else: kwargs = {'id': 'view-{}-price-history'.format(typ)} history = tags.link_to("(view history)", '#', **kwargs) @@ -494,10 +494,17 @@ class ProductsView(MasterView): br = HTML.tag('br') return HTML.tag('div', c=[text, br, history]) + def show_price_effective_dates(self): + if not self.rattail_config.versioning_enabled(): + return False + return self.rattail_config.getbool( + 'tailbone', 'products.show_effective_price_dates', + default=True) + def render_regular_price(self, product, field): text = self.render_price(product, field) - if text and self.rattail_config.versioning_enabled(): + if text and self.show_price_effective_dates(): history = self.get_regular_price_history(product) if history: date = localtime(self.rattail_config, history[0]['changed'], from_utc=True).date() @@ -508,7 +515,7 @@ class ProductsView(MasterView): def render_current_price(self, product, field): text = self.render_price(product, field) - if text and self.rattail_config.versioning_enabled(): + if text and self.show_price_effective_dates(): history = self.get_current_price_history(product) if history: date = localtime(self.rattail_config, history[0]['changed'], from_utc=True).date() @@ -526,7 +533,7 @@ class ProductsView(MasterView): def render_suggested_price(self, product, column): text = self.render_price(product, column) - if text and self.rattail_config.versioning_enabled(): + if text and self.show_price_effective_dates(): history = self.get_suggested_price_history(product) if history: date = localtime(self.rattail_config, history[0]['changed'], from_utc=True).date() @@ -973,8 +980,63 @@ class ProductsView(MasterView): return "" return pretty_quantity(value) + def price_history(self): + """ + AJAX view for fetching various types of price history for a product. + """ + product = self.get_instance() + + typ = self.request.params.get('type', 'regular') + assert typ in ('regular', 'current', 'suggested') + + getter = getattr(self, 'get_{}_price_history'.format(typ)) + data = getter(product) + + # make some data JSON-friendly + jsdata = [] + for history in data: + history = dict(history) + price = history['price'] + history['price'] = float(price) + history['price_display'] = "${:0.2f}".format(price) + changed = localtime(self.rattail_config, history['changed'], from_utc=True) + history['changed'] = six.text_type(changed) + history['changed_display_html'] = raw_datetime(self.rattail_config, changed) + user = history.pop('changed_by') + history['changed_by_uuid'] = user.uuid + history['changed_by_display'] = six.text_type(user) + jsdata.append(history) + return jsdata + + def cost_history(self): + """ + AJAX view for fetching cost history for a product. + """ + product = self.get_instance() + data = self.get_cost_history(product) + + # make some data JSON-friendly + jsdata = [] + for history in data: + history = dict(history) + cost = history['cost'] + if cost is not None: + history['cost'] = float(cost) + history['cost_display'] = "${:0.2f}".format(cost) + else: + history['cost_display'] = None + changed = localtime(self.rattail_config, history['changed'], from_utc=True) + history['changed'] = six.text_type(changed) + history['changed_display_html'] = raw_datetime(self.rattail_config, changed) + user = history.pop('changed_by') + history['changed_by_uuid'] = user.uuid + history['changed_by_display'] = six.text_type(user) + jsdata.append(history) + return jsdata + def template_kwargs_view(self, **kwargs): product = kwargs['instance'] + use_buefy = self.get_use_buefy() # TODO: pretty sure this is no longer needed? guess we'll find out # kwargs['image'] = False @@ -998,7 +1060,10 @@ class ProductsView(MasterView): if self.rattail_config.versioning_enabled() and self.has_perm('versions'): # regular price - data = self.get_regular_price_history(product) + if use_buefy: + data = [] # defer fetching until user asks for it + else: + data = self.get_regular_price_history(product) grid = grids.Grid('products.regular_price_history', data, request=self.request, columns=[ @@ -1012,7 +1077,10 @@ class ProductsView(MasterView): kwargs['regular_price_history_grid'] = grid # current price - data = self.get_current_price_history(product) + if use_buefy: + data = [] # defer fetching until user asks for it + else: + data = self.get_current_price_history(product) grid = grids.Grid('products.current_price_history', data, request=self.request, columns=[ @@ -1030,7 +1098,10 @@ class ProductsView(MasterView): kwargs['current_price_history_grid'] = grid # suggested price - data = self.get_suggested_price_history(product) + if use_buefy: + data = [] # defer fetching until user asks for it + else: + data = self.get_suggested_price_history(product) grid = grids.Grid('products.suggested_price_history', data, request=self.request, columns=[ @@ -1044,7 +1115,10 @@ class ProductsView(MasterView): kwargs['suggested_price_history_grid'] = grid # cost history - data = self.get_cost_history(product) + if use_buefy: + data = [] # defer fetching until user asks for it + else: + data = self.get_cost_history(product) grid = grids.Grid('products.cost_history', data, request=self.request, columns=[ @@ -1654,6 +1728,7 @@ class ProductsView(MasterView): rattail_config = config.registry.settings.get('rattail_config') route_prefix = cls.get_route_prefix() url_prefix = cls.get_url_prefix() + instance_url_prefix = cls.get_instance_url_prefix() template_prefix = cls.get_template_prefix() permission_prefix = cls.get_permission_prefix() model_title = cls.get_model_title() @@ -1684,6 +1759,20 @@ class ProductsView(MasterView): config.add_route('products.image', '/products/{uuid}/image') config.add_view(cls, attr='image', route_name='products.image') + # price history + config.add_route('{}.price_history'.format(route_prefix), '{}/price-history'.format(instance_url_prefix), + request_method='GET') + config.add_view(cls, attr='price_history', route_name='{}.price_history'.format(route_prefix), + renderer='json', + permission='{}.versions'.format(permission_prefix)) + + # cost history + config.add_route('{}.cost_history'.format(route_prefix), '{}/cost-history'.format(instance_url_prefix), + request_method='GET') + config.add_view(cls, attr='cost_history', route_name='{}.cost_history'.format(route_prefix), + renderer='json', + permission='{}.versions'.format(permission_prefix)) + # mobile quick lookup if legacy_mobile: config.add_route('mobile.products.quick_lookup', '/mobile/products/quick-lookup') From 4c3b1891081d9345a552aabe67e69bad95ad9565 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 6 Apr 2020 13:20:44 -0500 Subject: [PATCH 0123/1681] Update changelog --- CHANGES.rst | 44 ++++++++++++++++++++++++++++++++++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index ee769424..9bc14faa 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,50 @@ CHANGELOG ========= +0.8.91 (2020-04-06) +------------------- + +* Add "danger" style for "delete" grid row action. + +* Misc. API improvements for sake of mobile receiving. + +* Use proper cornice service registration, for API batch execute etc. + +* Add common permission for sending user feedback. + +* Fix the "change password" form per Buefy theme. + +* Expose the ``Role.notes`` field for view/edit. + +* Add "local only" column to Users grid. + +* Fix row status filter for Import/Export batches. + +* Add "generic" ``render_id_str()`` method to MasterView. + +* Stop raising an error if view doesn't define row grid columns. + +* Add helper function, ``get_csrf_token()``. + +* Add support for "choice" widget, for report params. + +* Allow bulk-delete, merge for Brands table. + +* Move inventory batch view to its proper location. + +* Allow bulk-delete for Inventory Batches. + +* Move "most" inventory batch logic out of view, to underlying handler. + +* Add initial API views for inventory batches. + +* Add basic dashboard page for TempMon. + +* Let config totally disable the old/legacy jQuery mobile app. + +* Defer fetching price, cost history when viewing product details. + + 0.8.90 (2020-03-18) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index b8155370..45837dbe 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.90' +__version__ = '0.8.91' From 3a6ced388a46c9d340cdbed4c97c6706a63b6241 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 7 Apr 2020 13:44:13 -0500 Subject: [PATCH 0124/1681] Allow the home page to include quickie search make it easier for any "non-master" view to do so --- tailbone/views/common.py | 11 ++++++++++- tailbone/views/core.py | 18 ++++++++++++++++++ tailbone/views/master.py | 8 +------- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/tailbone/views/common.py b/tailbone/views/common.py index a118de7e..1ceb9e3f 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -65,7 +65,16 @@ class CommonView(View): image_url = self.rattail_config.get( 'tailbone', 'main_image_url', default=self.request.static_url('tailbone:static/img/home_logo.png')) - return {'image_url': image_url, 'use_buefy': self.get_use_buefy()} + + context = { + 'image_url': image_url, + 'use_buefy': self.get_use_buefy(), + } + + if self.expose_quickie_search: + context['quickie'] = self.get_quickie_context() + + return context def robots_txt(self): """ diff --git a/tailbone/views/core.py b/tailbone/views/core.py index 79a6c797..a3152a8b 100644 --- a/tailbone/views/core.py +++ b/tailbone/views/core.py @@ -31,6 +31,7 @@ import os import six from rattail.db import model +from rattail.core import Object from rattail.util import progress_loop from pyramid import httpexceptions @@ -48,6 +49,8 @@ class View(object): """ Base class for all class-based views. """ + # quickie (search) + expose_quickie_search = False def __init__(self, request, context=None): self.request = request @@ -154,3 +157,18 @@ class View(object): filename = filename.encode('ascii', 'replace') response.content_disposition = str('attachment; filename="{}"'.format(filename)) return response + + def get_quickie_context(self): + return Object( + url=self.get_quickie_url(), + perm=self.get_quickie_perm(), + placeholder=self.get_quickie_placeholder()) + + def get_quickie_url(self): + raise NotImplementedError + + def get_quickie_perm(self): + raise NotImplementedError + + def get_quickie_placeholder(self): + pass diff --git a/tailbone/views/master.py b/tailbone/views/master.py index d7bc8bdb..a263ffce 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -41,7 +41,6 @@ from sqlalchemy_utils.functions import get_primary_keys, get_columns from rattail.db import model, Session as RattailSession from rattail.db.continuum import model_transaction_query -from rattail.core import Object from rattail.util import prettify, OrderedDict, simple_error from rattail.time import localtime from rattail.threads import Thread @@ -105,7 +104,6 @@ class MasterView(View): # quickie (search) supports_quickie_search = False - expose_quickie_search = False # set to True to declare model as "contact" is_contact = False @@ -2327,11 +2325,7 @@ class MasterView(View): } if self.expose_quickie_search: - context['quickie'] = Object( - url=self.get_quickie_url(), - perm=self.get_quickie_perm(), - placeholder=self.get_quickie_placeholder(), - ) + context['quickie'] = self.get_quickie_context() if self.grid_index: context['grid_count'] = self.grid_count From f0224144b7b9de989a9f7ad62b9a3a0688399825 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 7 Apr 2020 21:19:48 -0500 Subject: [PATCH 0125/1681] 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 9bc14faa..4369cc48 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.92 (2020-04-07) +------------------- + +* Allow the home page to include quickie search. + + 0.8.91 (2020-04-06) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 45837dbe..e0bfa17b 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.91' +__version__ = '0.8.92' From 5f2dd31485859d0e244567bcfc6a7da5ebd925e6 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 14 May 2020 21:53:41 -0500 Subject: [PATCH 0126/1681] Parse pip requirements file ourselves, instead of using their internals that problem just kept getting worse, so i stole this solution partly from: https://github.com/AngellusMortis/django_microsoft_auth/commit/77879cf3414e6d23ae79d8576a88e17d699accc9 --- tailbone/views/upgrades.py | 49 ++++++++++++++------------------------ 1 file changed, 18 insertions(+), 31 deletions(-) diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index 2a37709b..54605efb 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.py @@ -33,21 +33,7 @@ import logging import six from sqlalchemy import orm -# TODO: pip has declared these to be "not public API" so we should find another way.. -try: - # this works for now, with pip 20.0 - from pip._internal.network.session import PipSession - from pip._internal.req import parse_requirements -except ImportError: - try: - # this works for now, with pip 10.0.1 - from pip._internal.download import PipSession - from pip._internal.req import parse_requirements - except ImportError: - # this should work with pip < 10.0 - from pip.download import PipSession - from pip.req import parse_requirements - +from rattail.core import Object from rattail.db import model, Session as RattailSession from rattail.time import make_utc from rattail.threads import Thread @@ -345,24 +331,25 @@ class UpgradeView(MasterView): def parse_requirements(self, upgrade, type_): packages = {} path = self.rattail_config.upgrade_filepath(upgrade.uuid, filename='requirements.{}.txt'.format(type_)) - session = PipSession() - for req in parse_requirements(path, session=session): - version = self.version_from_requirement(req) - packages[req.name] = version + with open(path, 'rt') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#'): + req = self.parse_requirement(line) + if req: + packages[req.name] = req.version + else: + log.warning("could not parse req from line: %s", line) return packages - def version_from_requirement(self, req): - if req.specifier: - match = re.match(r'^==(.*)$', six.text_type(req.specifier)) - if match: - return match.group(1) - return six.text_type(req.specifier) - elif req.link: - match = re.match(r'^.*@(.*)#egg=.*$', six.text_type(req.link)) - if match: - return match.group(1) - return six.text_type(req.link) - return "" + def parse_requirement(self, line): + match = re.match(r'^.*@(.*)#egg=(.*)$', line) + if match: + return Object(name=match.group(2), version=match.group(1)) + + match = re.match(r'^(.*)==(.*)$', line) + if match: + return Object(name=match.group(1), version=match.group(2)) def download_path(self, upgrade, filename): return self.rattail_config.upgrade_filepath(upgrade.uuid, filename=filename) From 2ac2a98727225049f30d0212fddad633fbe83df8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 14 May 2020 22:07:34 -0500 Subject: [PATCH 0127/1681] Don't auto-include "Guest" role when finding roles w/ permission X otherwise "all" roles are returned when checking for a perm which Guest role does have granted --- tailbone/views/roles.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py index 48ef827c..adac7fb5 100644 --- a/tailbone/views/roles.py +++ b/tailbone/views/roles.py @@ -271,7 +271,7 @@ class RolesView(PrincipalMasterView): .options(orm.joinedload(model.Role._permissions)) roles = [] for role in all_roles: - if has_permission(session, role, permission): + if has_permission(session, role, permission, include_guest=False): roles.append(role) return roles From b13cae11facad40cbe171e3f7a0b11dac30bd5ac Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 15 May 2020 10:56:56 -0500 Subject: [PATCH 0128/1681] 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 4369cc48..63bbb31a 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.93 (2020-05-15) +------------------- + +* Parse pip requirements file ourselves, instead of using their internals. + +* Don't auto-include "Guest" role when finding roles w/ permission X. + + 0.8.92 (2020-04-07) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index e0bfa17b..104ff9ff 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.92' +__version__ = '0.8.93' From 2139fea3d069aeee316ba34ed048f6e23894fd88 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 18 May 2020 14:42:02 -0500 Subject: [PATCH 0129/1681] Expose "shelved" field for pricing batches --- 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 61cf1ea7..34586ea1 100644 --- a/tailbone/views/batch/pricing.py +++ b/tailbone/views/batch/pricing.py @@ -81,6 +81,7 @@ class PricingBatchView(BatchMasterView): 'created', 'created_by', 'rowcount', + 'shelved', 'executed', 'executed_by', ] From b5f9c8e358a9551fe3fa8cff96cdbcaafd0129e7 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 19 May 2020 12:42:07 -0500 Subject: [PATCH 0130/1681] Sort available reports by name, if handler doesn't specify also add basic support for "decimal" params --- tailbone/templates/reports/choose.mako | 3 ++- tailbone/views/reports.py | 10 ++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/tailbone/templates/reports/choose.mako b/tailbone/templates/reports/choose.mako index f3caf20c..58c9ee22 100644 --- a/tailbone/templates/reports/choose.mako +++ b/tailbone/templates/reports/choose.mako @@ -97,7 +97,8 @@ <p>Please select the type of report you wish to generate.</p> <div class="report-selection"> - % for key, report in reports.items(): + % for key in sorted_reports: + <% report = reports[key] %> <h3>${h.link_to(report.name, url('generate_specific_report', type_key=key))}</h3> <p>${report.__doc__}</p> % endfor diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index ace0a7aa..6f6b1660 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -38,7 +38,7 @@ from rattail.files import resource_path from rattail.time import localtime from rattail.reporting import get_report_handler from rattail.threads import Thread -from rattail.util import simple_error +from rattail.util import simple_error, OrderedDict import colander from deform import widget as dfwidget @@ -297,9 +297,13 @@ class GenerateReport(View): # handler is responsible for determining which report types are valid reports = self.handler.get_reports() + if isinstance(reports, OrderedDict): + sorted_reports = list(reports) + else: + sorted_reports = sorted(reports, key=lambda k: reports[k].name) # make form to accept user choice of report type - schema = NewReport().bind(valid_report_types=list(reports)) + schema = NewReport().bind(valid_report_types=sorted_reports) form = forms.Form(schema=schema, request=self.request, use_buefy=use_buefy) form.submit_label = "Continue" form.cancel_url = self.request.route_url('report_output') @@ -325,6 +329,7 @@ class GenerateReport(View): 'form': form, 'dform': form.make_deform_form(), 'reports': reports, + 'sorted_reports': sorted_reports, 'report_descriptions': dict([(r.type_key, r.__doc__) for r in reports.values()]), 'use_form': self.rattail_config.getbool('tailbone', 'reporting.choosing_uses_form', @@ -346,6 +351,7 @@ class GenerateReport(View): NODE_TYPES = { bool: colander.Boolean, datetime.date: colander.Date, + 'decimal': colander.Decimal, } schema = colander.Schema() From 3bb0c8468b719ea6eff0ab19e3d93d3fa4015529 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 20 May 2020 15:53:49 -0500 Subject: [PATCH 0131/1681] 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 63bbb31a..6d39c595 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.94 (2020-05-20) +------------------- + +* Expose "shelved" field for pricing batches. + +* Sort available reports by name, if handler doesn't specify. + + 0.8.93 (2020-05-15) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 104ff9ff..8632ab1b 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.93' +__version__ = '0.8.94' From 8683e2a4c2abb6a7b0c7c82640decea8f349ad01 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 20 May 2020 16:15:05 -0500 Subject: [PATCH 0132/1681] Cap version for 'cornice' dependency their 5.0 release drops support for python 2.x but we can't do that yet --- setup.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 59c3ef9e..2e0315b3 100644 --- a/setup.py +++ b/setup.py @@ -73,9 +73,11 @@ requires = [ # (still, probably a better idea is to refactor so we can use 0.9) 'webhelpers2_grid==0.1', # 0.1 + # TODO: remove version cap once we can drop support for python 2.x + 'cornice<5.0', # 3.4.2 4.0.1 + 'colander', # 1.7.0 'ColanderAlchemy', # 0.3.3 - 'cornice', # 3.4.2 'deform', # 2.0.4 'humanize', # 0.5.1 'Mako', # 0.6.2 From a8a79ee326b440dcc06e02a2c9d9cb622ee33e76 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 20 May 2020 19:19:06 -0500 Subject: [PATCH 0133/1681] Let each grid component have a custom name, if needed --- tailbone/grids/core.py | 11 +- tailbone/static/js/tailbone.buefy.grid.js | 173 -------------------- tailbone/templates/grids/buefy.mako | 183 +++++++++++++++++++++- tailbone/templates/master/index.mako | 46 +++--- tailbone/templates/master/view.mako | 2 + 5 files changed, 213 insertions(+), 202 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 2593ed4c..60934879 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -75,7 +75,7 @@ class Grid(object): pageable=False, default_pagesize=20, default_page=1, checkboxes=False, checked=None, check_handler=None, check_all_handler=None, main_actions=[], more_actions=[], delete_speedbump=False, - ajax_data_url=None, + ajax_data_url=None, component='tailbone-grid', **kwargs): self.key = key @@ -139,8 +139,14 @@ class Grid(object): else: self.ajax_data_url = '' + self.component = component self._whgrid_kwargs = kwargs + @property + def component_studly(self): + words = self.component.split('-') + return ''.join([word.capitalize() for word in words]) + def make_columns(self): """ Return a default list of columns, based on :attr:`model_class`. @@ -1237,8 +1243,9 @@ class Grid(object): results['checked_rows'] = checked # TODO: this seems a bit hacky, but is required for now to # initialize things on the client side... + var = '{}CurrentData'.format(self.component_studly) results['checked_rows_code'] = '[{}]'.format( - ', '.join(['TailboneGridCurrentData[{}]'.format(i) for i in checked])) + ', '.join(['{}[{}]'.format(var, i) for i in checked])) if self.pageable and self.pager is not None: results['total_items'] = self.pager.item_count diff --git a/tailbone/static/js/tailbone.buefy.grid.js b/tailbone/static/js/tailbone.buefy.grid.js index 45f6581d..f4ebf170 100644 --- a/tailbone/static/js/tailbone.buefy.grid.js +++ b/tailbone/static/js/tailbone.buefy.grid.js @@ -88,176 +88,3 @@ const GridFilter = { } Vue.component('grid-filter', GridFilter) - - -let TailboneGrid = { - template: '#tailbone-grid-template', - - props: { - csrftoken: String, - }, - - computed: { - // note, can use this with v-model for hidden 'uuids' fields - selected_uuids: function() { - return this.checkedRowUUIDs().join(',') - }, - }, - - methods: { - - getRowClass(row, index) { - return this.rowStatusMap[index] - }, - - loadAsyncData(params) { - - if (params === undefined) { - params = [ - 'partial=true', - `sortkey=${this.sortField}`, - `sortdir=${this.sortOrder}`, - `pagesize=${this.perPage}`, - `page=${this.page}` - ].join('&') - } - - this.loading = true - this.$http.get(`${this.ajaxDataUrl}?${params}`).then(({ data }) => { - TailboneGridCurrentData = data.data - this.data = TailboneGridCurrentData - this.rowStatusMap = data.row_status_map - this.total = data.total_items - this.firstItem = data.first_item - this.lastItem = data.last_item - this.loading = false - this.checkedRows = this.locateCheckedRows(data.checked_rows) - }) - .catch((error) => { - this.data = [] - this.total = 0 - this.loading = false - throw error - }) - }, - - locateCheckedRows(checked) { - let rows = [] - if (checked) { - for (let i = 0; i < this.data.length; i++) { - if (checked.includes(i)) { - rows.push(this.data[i]) - } - } - } - return rows - }, - - onPageChange(page) { - this.page = page - this.loadAsyncData() - }, - - onSort(field, order) { - this.sortField = field - this.sortOrder = order - // always reset to first page when changing sort options - // TODO: i mean..right? would we ever not want that? - this.page = 1 - this.loadAsyncData() - }, - - resetView() { - this.loading = true - location.href = '?reset-to-default-filters=true' - }, - - addFilter(filter_key) { - - // reset dropdown so user again sees "Add Filter" placeholder - this.$nextTick(function() { - this.selectedFilter = null - }) - - // show corresponding grid filter - this.filters[filter_key].visible = true - this.filters[filter_key].active = true - - // track down the component - var gridFilter = null - for (var gf of this.$refs.gridFilters) { - if (gf.filter.key == filter_key) { - gridFilter = gf - break - } - } - - // tell component to focus the value field, ASAP - this.$nextTick(function() { - gridFilter.focusValue() - }) - - }, - - applyFilters(params) { - if (params === undefined) { - params = [] - } - - params.push('partial=true') - params.push('filter=true') - - for (var key in this.filters) { - var filter = this.filters[key] - if (filter.active) { - params.push(key + '=' + encodeURIComponent(filter.value)) - params.push(key + '.verb=' + encodeURIComponent(filter.verb)) - } else { - filter.visible = false - } - } - - this.loadAsyncData(params.join('&')) - }, - - clearFilters() { - - // explicitly deactivate all filters - for (var key in this.filters) { - this.filters[key].active = false - } - - // then just "apply" as normal - this.applyFilters() - }, - - saveDefaults() { - - // apply current filters as normal, but add special directive - const params = ['save-current-filters-as-defaults=true'] - this.applyFilters(params) - }, - - deleteObject(event) { - // we let parent component/app deal with this, in whatever way makes sense... - // TODO: should we ever provide anything besides the URL for this? - this.$emit('deleteActionClicked', event.target.href) - }, - - checkedRowUUIDs() { - let uuids = [] - for (let row of this.$data.checkedRows) { - uuids.push(row.uuid) - } - return uuids - }, - - allRowUUIDs() { - let uuids = [] - for (let row of this.data) { - uuids.push(row.uuid) - } - return uuids - }, - } -} diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako index 1d47310b..f1bfadf1 100644 --- a/tailbone/templates/grids/buefy.mako +++ b/tailbone/templates/grids/buefy.mako @@ -85,7 +85,7 @@ </script> -<script type="text/x-template" id="tailbone-grid-template"> +<script type="text/x-template" id="${grid.component}-template"> <div> <div style="display: flex; justify-content: space-between; margin-bottom: 0.5em;"> @@ -236,14 +236,14 @@ <script type="text/javascript"> - let TailboneGridCurrentData = ${json.dumps(grid_data['data'])|n} + let ${grid.component_studly}CurrentData = ${json.dumps(grid_data['data'])|n} - let TailboneGridData = { + let ${grid.component_studly}Data = { loading: false, selectedFilter: null, ajaxDataUrl: ${json.dumps(grid.ajax_data_url)|n}, - data: TailboneGridCurrentData, + data: ${grid.component_studly}CurrentData, rowStatusMap: ${json.dumps(grid_data['row_status_map'])|n}, checkable: ${json.dumps(grid.checkboxes)|n}, @@ -267,4 +267,179 @@ selectedFilter: null, } + let ${grid.component_studly} = { + template: '#${grid.component}-template', + + props: { + csrftoken: String, + }, + + computed: { + // note, can use this with v-model for hidden 'uuids' fields + selected_uuids: function() { + return this.checkedRowUUIDs().join(',') + }, + }, + + methods: { + + getRowClass(row, index) { + return this.rowStatusMap[index] + }, + + loadAsyncData(params, callback) { + + if (params === undefined || params === null) { + params = [ + 'partial=true', + `sortkey=${'$'}{this.sortField}`, + `sortdir=${'$'}{this.sortOrder}`, + `pagesize=${'$'}{this.perPage}`, + `page=${'$'}{this.page}` + ].join('&') + } + + this.loading = true + this.$http.get(`${'$'}{this.ajaxDataUrl}?${'$'}{params}`).then(({ data }) => { + ${grid.component_studly}CurrentData = data.data + this.data = ${grid.component_studly}CurrentData + this.rowStatusMap = data.row_status_map + this.total = data.total_items + this.firstItem = data.first_item + this.lastItem = data.last_item + this.loading = false + this.checkedRows = this.locateCheckedRows(data.checked_rows) + if (callback) { + callback() + } + }) + .catch((error) => { + this.data = [] + this.total = 0 + this.loading = false + throw error + }) + }, + + locateCheckedRows(checked) { + let rows = [] + if (checked) { + for (let i = 0; i < this.data.length; i++) { + if (checked.includes(i)) { + rows.push(this.data[i]) + } + } + } + return rows + }, + + onPageChange(page) { + this.page = page + this.loadAsyncData() + }, + + onSort(field, order) { + this.sortField = field + this.sortOrder = order + // always reset to first page when changing sort options + // TODO: i mean..right? would we ever not want that? + this.page = 1 + this.loadAsyncData() + }, + + resetView() { + this.loading = true + location.href = '?reset-to-default-filters=true' + }, + + addFilter(filter_key) { + + // reset dropdown so user again sees "Add Filter" placeholder + this.$nextTick(function() { + this.selectedFilter = null + }) + + // show corresponding grid filter + this.filters[filter_key].visible = true + this.filters[filter_key].active = true + + // track down the component + var gridFilter = null + for (var gf of this.$refs.gridFilters) { + if (gf.filter.key == filter_key) { + gridFilter = gf + break + } + } + + // tell component to focus the value field, ASAP + this.$nextTick(function() { + gridFilter.focusValue() + }) + + }, + + applyFilters(params) { + if (params === undefined) { + params = [] + } + + params.push('partial=true') + params.push('filter=true') + + for (var key in this.filters) { + var filter = this.filters[key] + if (filter.active) { + params.push(key + '=' + encodeURIComponent(filter.value)) + params.push(key + '.verb=' + encodeURIComponent(filter.verb)) + } else { + filter.visible = false + } + } + + this.loadAsyncData(params.join('&')) + }, + + clearFilters() { + + // explicitly deactivate all filters + for (var key in this.filters) { + this.filters[key].active = false + } + + // then just "apply" as normal + this.applyFilters() + }, + + saveDefaults() { + + // apply current filters as normal, but add special directive + const params = ['save-current-filters-as-defaults=true'] + this.applyFilters(params) + }, + + deleteObject(event) { + // we let parent component/app deal with this, in whatever way makes sense... + // TODO: should we ever provide anything besides the URL for this? + this.$emit('deleteActionClicked', event.target.href) + }, + + checkedRowUUIDs() { + let uuids = [] + for (let row of this.$data.checkedRows) { + uuids.push(row.uuid) + } + return uuids + }, + + allRowUUIDs() { + let uuids = [] + for (let row of this.data) { + uuids.push(row.uuid) + } + return uuids + }, + } + } + </script> diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako index cae42362..8826c096 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -256,12 +256,12 @@ </%def> <%def name="page_content()"> - <tailbone-grid :csrftoken="csrftoken" + <${grid.component} :csrftoken="csrftoken" % if master.deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple': @deleteActionClicked="deleteObject" % endif > - </tailbone-grid> + </${grid.component}> % if master.deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple': ${h.form('#', ref='deleteObjectForm')} ${h.csrf_token(request)} @@ -273,9 +273,9 @@ ${parent.make_this_page_component()} <script type="text/javascript"> - TailboneGrid.data = function() { return TailboneGridData } + ${grid.component_studly}.data = function() { return ${grid.component_studly}Data } - Vue.component('tailbone-grid', TailboneGrid) + Vue.component('${grid.component}', ${grid.component_studly}) </script> </%def> @@ -309,10 +309,10 @@ ## enable / disable selected objects % if master.supports_set_enabled_toggle and master.has_perm('enable_disable_set'): - TailboneGridData.enableSelectedSubmitting = false - TailboneGridData.enableSelectedText = "Enable Selected" + ${grid.component_studly}Data.enableSelectedSubmitting = false + ${grid.component_studly}Data.enableSelectedText = "Enable Selected" - TailboneGrid.computed.enableSelectedDisabled = function() { + ${grid.component_studly}.computed.enableSelectedDisabled = function() { if (this.enableSelectedSubmitting) { return true } @@ -322,7 +322,7 @@ return false } - TailboneGrid.methods.enableSelectedSubmit = function() { + ${grid.component_studly}.methods.enableSelectedSubmit = function() { let uuids = this.checkedRowUUIDs() if (!uuids.length) { alert("You must first select one or more objects to disable.") @@ -337,10 +337,10 @@ this.$refs.enable_selected_form.submit() } - TailboneGridData.disableSelectedSubmitting = false - TailboneGridData.disableSelectedText = "Disable Selected" + ${grid.component_studly}Data.disableSelectedSubmitting = false + ${grid.component_studly}Data.disableSelectedText = "Disable Selected" - TailboneGrid.computed.disableSelectedDisabled = function() { + ${grid.component_studly}.computed.disableSelectedDisabled = function() { if (this.disableSelectedSubmitting) { return true } @@ -350,7 +350,7 @@ return false } - TailboneGrid.methods.disableSelectedSubmit = function() { + ${grid.component_studly}.methods.disableSelectedSubmit = function() { let uuids = this.checkedRowUUIDs() if (!uuids.length) { alert("You must first select one or more objects to disable.") @@ -370,10 +370,10 @@ ## delete selected objects % if master.set_deletable and master.has_perm('delete_set'): - TailboneGridData.deleteSelectedSubmitting = false - TailboneGridData.deleteSelectedText = "Delete Selected" + ${grid.component_studly}Data.deleteSelectedSubmitting = false + ${grid.component_studly}Data.deleteSelectedText = "Delete Selected" - TailboneGrid.computed.deleteSelectedDisabled = function() { + ${grid.component_studly}.computed.deleteSelectedDisabled = function() { if (this.deleteSelectedSubmitting) { return true } @@ -383,7 +383,7 @@ return false } - TailboneGrid.methods.deleteSelectedSubmit = function() { + ${grid.component_studly}.methods.deleteSelectedSubmit = function() { let uuids = this.checkedRowUUIDs() if (!uuids.length) { alert("You must first select one or more objects to disable.") @@ -401,10 +401,10 @@ % if master.bulk_deletable and master.has_perm('bulk_delete'): - TailboneGridData.deleteResultsSubmitting = false - TailboneGridData.deleteResultsText = "Delete Results" + ${grid.component_studly}Data.deleteResultsSubmitting = false + ${grid.component_studly}Data.deleteResultsText = "Delete Results" - TailboneGrid.computed.deleteResultsDisabled = function() { + ${grid.component_studly}.computed.deleteResultsDisabled = function() { if (this.deleteResultsSubmitting) { return true } @@ -414,7 +414,7 @@ return false } - TailboneGrid.methods.deleteResultsSubmit = function() { + ${grid.component_studly}.methods.deleteResultsSubmit = function() { // TODO: show "plural model title" here? if (!confirm("You are about to delete " + this.total.toLocaleString('en') + " objects.\n\nAre you sure?")) { return @@ -429,10 +429,10 @@ % if master.mergeable and master.has_perm('merge'): - TailboneGridData.mergeFormButtonText = "Merge 2 ${model_title_plural}" - TailboneGridData.mergeFormSubmitting = false + ${grid.component_studly}Data.mergeFormButtonText = "Merge 2 ${model_title_plural}" + ${grid.component_studly}Data.mergeFormSubmitting = false - TailboneGrid.methods.submitMergeForm = function() { + ${grid.component_studly}.methods.submitMergeForm = function() { this.mergeFormSubmitting = true this.mergeFormButtonText = "Working, please wait..." } diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako index 1f4b59ee..d07e1cc9 100644 --- a/tailbone/templates/master/view.mako +++ b/tailbone/templates/master/view.mako @@ -102,6 +102,7 @@ </%def> <%def name="make_this_page_component()"> + % if master.has_rows: <script type="text/javascript"> TailboneGrid.data = function() { return TailboneGridData } @@ -109,6 +110,7 @@ Vue.component('tailbone-grid', TailboneGrid) </script> + % endif ${parent.make_this_page_component()} </%def> From abea50427e6ea6cf12489bb17dc3082410b580c7 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 27 May 2020 15:25:52 -0500 Subject: [PATCH 0134/1681] 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 6d39c595..40f97a70 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.95 (2020-05-27) +------------------- + +* Cap version for 'cornice' dependency. + +* Let each grid component have a custom name, if needed. + + 0.8.94 (2020-05-20) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 8632ab1b..4400f970 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.94' +__version__ = '0.8.95' From 31df41283c6a57386fc7bc2d29d0fa728c33f90d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 12 Jun 2020 18:40:10 -0500 Subject: [PATCH 0135/1681] Don't allow edit/delete of rows, if master view says so also fix "back to parent" link when viewing row --- tailbone/templates/master/view_row.mako | 2 +- tailbone/views/master.py | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/tailbone/templates/master/view_row.mako b/tailbone/templates/master/view_row.mako index f8aa2acb..66756c3e 100644 --- a/tailbone/templates/master/view_row.mako +++ b/tailbone/templates/master/view_row.mako @@ -8,7 +8,7 @@ </%def> <%def name="context_menu_items()"> - <li>${h.link_to("Back to {}".format(parent_model_title), index_url)}</li> + <li>${h.link_to("Back to {}".format(parent_model_title), instance_url)}</li> % if master.rows_editable and instance_editable and request.has_perm('{}.edit'.format(permission_prefix)): <li>${h.link_to("Edit this {}".format(model_title), action_url('edit', instance))}</li> % endif diff --git a/tailbone/views/master.py b/tailbone/views/master.py index a263ffce..b445bf3e 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -3324,6 +3324,9 @@ class MasterView(View): """ self.editing = True row = self.get_row_instance() + if not self.row_editable(row): + raise self.redirect(self.get_row_action_url('view', row)) + form = self.make_row_form(row) if self.request.method == 'POST': @@ -3407,9 +3410,10 @@ class MasterView(View): """ Desktop view which can "delete" a sub-row from the parent. """ - row = self.Session.query(self.model_row_class).get(self.request.matchdict['row_uuid']) - if not row: - raise self.notfound() + row = self.get_row_instance() + if not self.row_deletable(row): + raise self.redirect(self.get_row_action_url('view', row)) + self.delete_row_object(row) return self.redirect(self.get_action_url('view', self.get_parent(row))) From dc81e5b5c53f77d32a750b9e7625f99c2616429d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 17 Jun 2020 12:45:00 -0500 Subject: [PATCH 0136/1681] 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 40f97a70..e29156b0 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.96 (2020-06-17) +------------------- + +* Don't allow edit/delete of rows, if master view says so. + + 0.8.95 (2020-05-27) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 4400f970..ff4d0a2f 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.95' +__version__ = '0.8.96' From 6463df7224211bd1d82cfc08cbc8c8fba2c7f290 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 22 Jun 2020 14:59:17 -0500 Subject: [PATCH 0137/1681] Add dropdown, autohide magic when editing Role permissions only for Buefy theme though --- tailbone/templates/deform/permissions.pt | 59 +++++++++++++++++++++++- tailbone/templates/roles/edit.mako | 11 +++++ tailbone/views/roles.py | 5 +- 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/deform/permissions.pt b/tailbone/templates/deform/permissions.pt index bafa7563..f5cbeef4 100644 --- a/tailbone/templates/deform/permissions.pt +++ b/tailbone/templates/deform/permissions.pt @@ -1,6 +1,10 @@ <div tal:define="oid oid|field.oid; - true_val true_val|field.widget.true_val;" + true_val true_val|field.widget.true_val; + use_buefy use_buefy|0;" tal:omit-tag=""> + + <div tal:condition="not use_buefy" + tal:omit-tag=""> ${field.start_mapping()} <div class="permissions-outer"> @@ -29,4 +33,57 @@ </div> ${field.end_mapping()} + </div> + + <div tal:condition="use_buefy"> + ${field.start_mapping()} + + <div class="level"> + <div class="level-left"> + <div class="level-item"> + showing group: + </div> + <div class="level-item"> + <!-- TODO: should make this v-model dynamic --> + <b-select v-model="showingPermissionGroup"> + <option value="">(all)</option> + <tal:loop tal:repeat="groupkey sorted(permissions, key=lambda k: permissions[k]['label'].lower())"> + <option tal:attributes="value groupkey"> + ${permissions[groupkey]['label']} + </option> + </tal:loop> + </b-select> + </div> + </div> + </div> + + <tal:loop tal:repeat="groupkey sorted(permissions, key=lambda k: permissions[k]['label'].lower())"> + <div tal:define="perms permissions[groupkey]['perms'];" + class="permissions-group"> + <!-- TODO: should use more dynamic v-model name --> + <div class="card" + tal:attributes="v-show string: !showingPermissionGroup || showingPermissionGroup == '${permissions[groupkey]['key']}';"> + <header class="card-header"> + <p class="card-header-title">${permissions[groupkey]['label']}</p> + </header> + <div class="card-content"> + <tal:loop tal:repeat="key sorted(perms, key=lambda p: perms[p]['label'].lower())"> + <div class="perm"> + <label> + <input type="checkbox" + name="${key}" + id="${oid}-${key}" + value="${true_val}" + tal:attributes="checked python:field.widget.get_checked_value(cstruct, key);" /> + ${perms[key]['label']} + </label> + </div> + </tal:loop> + </div><!-- card-content --> + </div><!-- card --> + </div><!-- permissions-group --> + </tal:loop> + + ${field.end_mapping()} + </div> </div> diff --git a/tailbone/templates/roles/edit.mako b/tailbone/templates/roles/edit.mako index 6f89b44d..67f63013 100644 --- a/tailbone/templates/roles/edit.mako +++ b/tailbone/templates/roles/edit.mako @@ -6,4 +6,15 @@ ${h.stylesheet_link(request.static_url('tailbone:static/css/perms.css'))} </%def> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + // TODO: this variable name should be more dynamic (?) since this is + // connected to (and only here b/c of) the permissions field + TailboneFormData.showingPermissionGroup = '' + + </script> +</%def> + ${parent.body()} diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py index adac7fb5..d0dd8967 100644 --- a/tailbone/views/roles.py +++ b/tailbone/views/roles.py @@ -137,6 +137,7 @@ class RolesView(PrincipalMasterView): def configure_form(self, f): super(RolesView, self).configure_form(f) role = f.model_instance + use_buefy = self.get_use_buefy() # name f.set_validator('name', self.unique_name) @@ -148,7 +149,9 @@ class RolesView(PrincipalMasterView): self.tailbone_permissions = self.get_available_permissions() f.set_renderer('permissions', PermissionsRenderer(permissions=self.tailbone_permissions)) f.set_node('permissions', colander.Set()) - f.set_widget('permissions', PermissionsWidget(permissions=self.tailbone_permissions)) + f.set_widget('permissions', PermissionsWidget( + permissions=self.tailbone_permissions, + use_buefy=use_buefy)) if self.editing: granted = [] for groupkey in self.tailbone_permissions: From e5f08313690526d5f6dc498dffb6c4d9bc35d5f5 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 22 Jun 2020 16:00:33 -0500 Subject: [PATCH 0138/1681] Add ability to download roles / permissions matrix as Excel file --- tailbone/templates/roles/index.mako | 12 +++++ tailbone/views/roles.py | 77 +++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 tailbone/templates/roles/index.mako diff --git a/tailbone/templates/roles/index.mako b/tailbone/templates/roles/index.mako new file mode 100644 index 00000000..857e5f6a --- /dev/null +++ b/tailbone/templates/roles/index.mako @@ -0,0 +1,12 @@ +## -*- coding: utf-8; -*- +<%inherit file="/principal/index.mako" /> + +<%def name="context_menu_items()"> + ${parent.context_menu_items()} + % if master.has_perm('download_permissions_matrix'): + <li>${h.link_to("Download Permissions Matrix", url('roles.download_permissions_matrix'))}</li> + % endif +</%def> + + +${parent.body()} diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py index d0dd8967..613576e6 100644 --- a/tailbone/views/roles.py +++ b/tailbone/views/roles.py @@ -26,12 +26,16 @@ Role Views from __future__ import unicode_literals, absolute_import +import os + import six from sqlalchemy import orm +from openpyxl.styles import Font, PatternFill from rattail.db import model from rattail.db.auth import (has_permission, grant_permission, revoke_permission, administrator_role, guest_role, authenticated_role) +from rattail.excel import ExcelWriter import colander from deform import widget as dfwidget @@ -278,6 +282,69 @@ class RolesView(PrincipalMasterView): roles.append(role) return roles + def download_permissions_matrix(self): + """ + View which renders the complete role / permissions matrix data into an + Excel spreadsheet, and returns that file. + """ + roles = self.Session.query(model.Role)\ + .order_by(model.Role.name)\ + .all() + + permissions = self.get_available_permissions() + + # prep the excel writer + path = os.path.join(self.rattail_config.workdir(), + 'permissions-matrix.xlsx') + writer = ExcelWriter(path, None) + sheet = writer.sheet + + # write header + sheet.cell(row=1, column=1, value="") + for i, role in enumerate(roles, 2): + sheet.cell(row=1, column=i, value=role.name) + + # font and fill pattern for permission group rows + bold = Font(bold=True) + group_fill = PatternFill(patternType='solid', + fgColor='d9d9d9', + bgColor='d9d9d9') + + # now we'll write the rows + writing_row = 2 + for groupkey in sorted(permissions, key=lambda k: permissions[k]['label'].lower()): + group = permissions[groupkey] + + # group label is bold, with fill pattern + cell = sheet.cell(row=writing_row, column=1, value=group['label']) + cell.font = bold + cell.fill = group_fill + + # continue fill pattern for rest of group row + for col, role in enumerate(roles, 2): + cell = sheet.cell(row=writing_row, column=col) + cell.fill = group_fill + + # okay, that row is done + writing_row += 1 + + # now we list each perm in the group + perms = group['perms'] + for key in sorted(perms, key=lambda p: perms[p]['label'].lower()): + sheet.cell(row=writing_row, column=1, value=perms[key]['label']) + + # and show an 'X' for any role which has this perm + for col, role in enumerate(roles, 2): + if has_permission(self.Session(), role, key, include_guest=False): + sheet.cell(row=writing_row, column=col, value="X") + + writing_row += 1 + + writer.auto_resize() + writer.auto_freeze() + writer.save() + return self.file_response(path) + @classmethod def defaults(cls, config): cls._principal_defaults(config) @@ -286,6 +353,8 @@ class RolesView(PrincipalMasterView): @classmethod def _role_defaults(cls, config): + route_prefix = cls.get_route_prefix() + url_prefix = cls.get_url_prefix() permission_prefix = cls.get_permission_prefix() # extra permissions for editing built-in roles etc. @@ -296,6 +365,14 @@ class RolesView(PrincipalMasterView): config.add_tailbone_permission(permission_prefix, '{}.edit_my'.format(permission_prefix), "Edit Role(s) to which current user belongs") + # download permissions matrix + config.add_tailbone_permission(permission_prefix, '{}.download_permissions_matrix'.format(permission_prefix), + "Download complete Role/Permissions matrix") + config.add_route('{}.download_permissions_matrix'.format(route_prefix), '{}/permissions-matrix'.format(url_prefix), + request_method='GET') + config.add_view(cls, attr='download_permissions_matrix', route_name='{}.download_permissions_matrix'.format(route_prefix), + permission='{}.download_permissions_matrix'.format(permission_prefix)) + class PermissionsWidget(dfwidget.Widget): template = 'permissions' From bb11263badddf201fa4eb75832718b518ee18c80 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 22 Jun 2020 16:21:45 -0500 Subject: [PATCH 0139/1681] Tweak how we freeze column for role/perm matrix --- tailbone/views/roles.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py index 613576e6..e30c38be 100644 --- a/tailbone/views/roles.py +++ b/tailbone/views/roles.py @@ -341,7 +341,7 @@ class RolesView(PrincipalMasterView): writing_row += 1 writer.auto_resize() - writer.auto_freeze() + writer.auto_freeze(column=2) writer.save() return self.file_response(path) From c7c3dea6b23f18e0a21e23803d257311ece11df0 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 22 Jun 2020 18:26:43 -0500 Subject: [PATCH 0140/1681] Improve support for composite key in master view --- tailbone/views/master.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index b445bf3e..04cd30de 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2475,7 +2475,10 @@ class MasterView(View): return cls.get_route_prefix() def get_row_grid_key(self): - return '{}.{}'.format(self.get_grid_key(), self.request.matchdict[self.get_model_key()]) + model_key = self.get_model_key(as_tuple=True) + key = '.'.join([self.get_grid_key()] + + [self.request.matchdict[k] for k in model_key]) + return key def get_grid_actions(self): main, more = self.get_main_actions(), self.get_more_actions() @@ -2875,10 +2878,12 @@ class MasterView(View): query = self.Session.query(self.get_model_class()) for i, model_key in enumerate(model_keys): key = self.request.matchdict[model_key] + if self.key_is_integer(model_key): + key = int(key) query = query.filter(getattr(self.model_class, model_key) == key) try: obj = query.one() - except NoResultFound: + except orm.exc.NoResultFound: raise self.notfound() # pretend global object doesn't exist, unless access allowed @@ -2889,6 +2894,18 @@ class MasterView(View): return obj + def key_is_integer(self, model_key): + + # inspect model class to determine if model_key is numeric + cls = self.get_model_class(error=False) + if cls: + attr = getattr(cls, model_key) + if isinstance(attr.type, sa.Integer): + return True + + # do not assume integer by default + return False + def get_instance_title(self, instance): """ Return a "pretty" title for the instance, to be used in the page title etc. From c1a2bb978c3831461aeedda298cae967d304df66 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 24 Jun 2020 10:53:43 -0500 Subject: [PATCH 0141/1681] Use byte string filters for row grid too if master view needs them at all, chances are they should apply to row grid as well as main grid --- tailbone/views/master.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 04cd30de..163fe91e 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -468,6 +468,7 @@ class MasterView(View): 'model_class': self.model_row_class, 'width': 'full', 'filterable': self.rows_filterable, + 'use_byte_string_filters': self.use_byte_string_filters, 'sortable': self.rows_sortable, 'pageable': self.rows_pageable, 'default_pagesize': self.rows_default_pagesize, From e943a1cd44307071bef631e53af7932600d4b77e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 24 Jun 2020 11:36:58 -0500 Subject: [PATCH 0142/1681] Convert mako directories to list, if it's a string so we can push a new path to it, for sake of theme --- tailbone/app.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tailbone/app.py b/tailbone/app.py index 810d502c..44d9976f 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -29,10 +29,11 @@ from __future__ import unicode_literals, absolute_import import os import warnings +import six import sqlalchemy as sa from sqlalchemy.orm import sessionmaker, scoped_session -from rattail.config import make_config +from rattail.config import make_config, parse_list from rattail.exceptions import ConfigurationError from rattail.db.types import GPCType @@ -161,8 +162,13 @@ def establish_theme(settings): theme = get_effective_theme(rattail_config) settings['tailbone.theme'] = theme + directories = settings['mako.directories'] + if isinstance(directories, six.string_types): + directories = parse_list(directories) + path = get_theme_template_path(rattail_config) - settings['mako.directories'].insert(0, path) + directories.insert(0, path) + settings['mako.directories'] = directories def configure_postgresql(pyramid_config): From bea671987c3bac8201ce7d8c418245d37a0da367 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 24 Jun 2020 12:07:46 -0500 Subject: [PATCH 0143/1681] 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 e29156b0..e33a7134 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,20 @@ CHANGELOG ========= +0.8.97 (2020-06-24) +------------------- + +* Add dropdown, autohide magic when editing Role permissions. + +* Add ability to download roles / permissions matrix as Excel file. + +* Improve support for composite key in master view. + +* Use byte string filters for row grid too. + +* Convert mako directories to list, if it's a string. + + 0.8.96 (2020-06-17) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index ff4d0a2f..1137fc33 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.96' +__version__ = '0.8.97' From aac9bad7ec46a1f1b1066352bdb135b988816039 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 29 Jun 2020 13:07:49 -0500 Subject: [PATCH 0144/1681] Freeze version for 'Chameleon' dependency pending the fix, which should come w/ next 'deform' release --- setup.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/setup.py b/setup.py index 2e0315b3..5731414c 100644 --- a/setup.py +++ b/setup.py @@ -76,6 +76,11 @@ requires = [ # TODO: remove version cap once we can drop support for python 2.x 'cornice<5.0', # 3.4.2 4.0.1 + # TODO: remove this cap once deform releases a new version + # cf. https://github.com/malthe/chameleon/issues/318 + # and https://github.com/Pylons/deform/pull/418 + 'Chameleon<3.8.0', # 3.7.4 + 'colander', # 1.7.0 'ColanderAlchemy', # 0.3.3 'deform', # 2.0.4 From 66bf11e89326477b5364b2db93baa5fd57725dfe Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 29 Jun 2020 16:57:05 -0500 Subject: [PATCH 0145/1681] Tweak field label for `Product.item_id` --- tailbone/views/products.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index aaf70774..6585fb81 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -87,6 +87,7 @@ class ProductsView(MasterView): results_downloadable_xlsx = True labels = { + 'item_id': "Item ID", 'upc': "UPC", 'status_code': "Status", 'tax1': "Tax 1", From 6577b3752fbc63d8643a95b7418d5b1cf3823139 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 2 Jul 2020 12:42:42 -0500 Subject: [PATCH 0146/1681] Avoid latest SQLAlchemy-Utils when running tests for python2.7 --- tox.ini | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tox.ini b/tox.ini index 18df2205..1218fec2 100644 --- a/tox.ini +++ b/tox.ini @@ -12,6 +12,15 @@ commands = pip install --upgrade --upgrade-strategy eager Tailbone rattail[auth,bouncer] rattail-tempmon nosetests {posargs} +[testenv:py27] +# TODO: this is only here to avoid latest SA-Utils on python2.7 +deps = + coverage + fixture + mock + nose + SQLAlchemy-Utils<0.36.7 + [testenv:coverage] basepython = python commands = From 4f2f192783c59ffcf218f5bc15640ed7fe37650d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 3 Jul 2020 19:14:30 -0500 Subject: [PATCH 0147/1681] Revert "Freeze version for 'Chameleon' dependency" This reverts commit aac9bad7ec46a1f1b1066352bdb135b988816039. all should be good now, per new 'deform' release --- setup.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/setup.py b/setup.py index 5731414c..2e0315b3 100644 --- a/setup.py +++ b/setup.py @@ -76,11 +76,6 @@ requires = [ # TODO: remove version cap once we can drop support for python 2.x 'cornice<5.0', # 3.4.2 4.0.1 - # TODO: remove this cap once deform releases a new version - # cf. https://github.com/malthe/chameleon/issues/318 - # and https://github.com/Pylons/deform/pull/418 - 'Chameleon<3.8.0', # 3.7.4 - 'colander', # 1.7.0 'ColanderAlchemy', # 0.3.3 'deform', # 2.0.4 From 793d80f0929843ff00c79c8b91352456cee3e279 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 4 Jul 2020 11:44:09 -0500 Subject: [PATCH 0148/1681] Make field list explicit for Department views --- tailbone/views/departments.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tailbone/views/departments.py b/tailbone/views/departments.py index 7b31fd31..c1369543 100644 --- a/tailbone/views/departments.py +++ b/tailbone/views/departments.py @@ -52,6 +52,14 @@ class DepartmentsView(MasterView): 'exempt_from_gross_sales', ] + form_fields = [ + 'number', + 'name', + 'product', + 'personnel', + 'exempt_from_gross_sales', + ] + def configure_grid(self, g): super(DepartmentsView, self).configure_grid(g) g.filters['name'].default_active = True From ca64d52021a37dc70d002016d49295993b005606 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 5 Jul 2020 00:21:00 -0500 Subject: [PATCH 0149/1681] Make field list explicit for Store views --- tailbone/views/stores.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tailbone/views/stores.py b/tailbone/views/stores.py index b46fa5f4..fa94f92e 100644 --- a/tailbone/views/stores.py +++ b/tailbone/views/stores.py @@ -50,6 +50,14 @@ class StoresView(MasterView): 'email', ] + form_fields = [ + 'id', + 'name', + 'phone', + 'email', + 'database_key', + ] + labels = { 'id': "ID", 'phone': "Phone Number", From 0dfe52a42d26ad2816fbab78cfe1afb68dc0a37a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 7 Jul 2020 19:23:52 -0500 Subject: [PATCH 0150/1681] Don't allow "execute results" for any batches by default custom app must always explicitly opt-in to that feature --- tailbone/views/batch/inventory.py | 1 - tailbone/views/batch/labels.py | 3 +-- tailbone/views/handheld.py | 3 +-- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/tailbone/views/batch/inventory.py b/tailbone/views/batch/inventory.py index fd4b9b07..26123707 100644 --- a/tailbone/views/batch/inventory.py +++ b/tailbone/views/batch/inventory.py @@ -63,7 +63,6 @@ class InventoryBatchView(BatchMasterView): index_title = "Inventory" rows_creatable = True bulk_deletable = True - results_executable = True mobile_creatable = True mobile_rows_creatable = True diff --git a/tailbone/views/batch/labels.py b/tailbone/views/batch/labels.py index 7aed7b5a..8aeab62b 100644 --- a/tailbone/views/batch/labels.py +++ b/tailbone/views/batch/labels.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -53,7 +53,6 @@ class LabelBatchView(BatchMasterView): rows_editable = True rows_bulk_deletable = True cloneable = True - results_executable = True row_grid_columns = [ 'sequence', diff --git a/tailbone/views/handheld.py b/tailbone/views/handheld.py index 1db59662..66cd480c 100644 --- a/tailbone/views/handheld.py +++ b/tailbone/views/handheld.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -64,7 +64,6 @@ class HandheldBatchView(FileBatchMasterView): url_prefix = '/batch/handheld' execution_options_schema = ExecutionOptions editable = False - results_executable = True model_row_class = model.HandheldBatchRow rows_creatable = False From 3819dd94698d6b6733ab3866093605b041545fab Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 15 Jul 2020 22:05:57 -0500 Subject: [PATCH 0151/1681] Fix pagination sync issue with buefy grid tables --- tailbone/templates/grids/buefy.mako | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako index f1bfadf1..699161c8 100644 --- a/tailbone/templates/grids/buefy.mako +++ b/tailbone/templates/grids/buefy.mako @@ -334,7 +334,7 @@ }, onPageChange(page) { - this.page = page + this.currentPage = page this.loadAsyncData() }, @@ -343,7 +343,7 @@ this.sortOrder = order // always reset to first page when changing sort options // TODO: i mean..right? would we ever not want that? - this.page = 1 + this.currentPage = 1 this.loadAsyncData() }, From 925e5e0731f87d26fe90a5d36f83641c8cabb337 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 16 Jul 2020 19:43:33 -0500 Subject: [PATCH 0152/1681] Fix permissions wiget bug when creating new role --- tailbone/templates/roles/create.mako | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tailbone/templates/roles/create.mako b/tailbone/templates/roles/create.mako index 235765f9..625b2675 100644 --- a/tailbone/templates/roles/create.mako +++ b/tailbone/templates/roles/create.mako @@ -6,4 +6,15 @@ ${h.stylesheet_link(request.static_url('tailbone:static/css/perms.css'))} </%def> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + // TODO: this variable name should be more dynamic (?) since this is + // connected to (and only here b/c of) the permissions field + TailboneFormData.showingPermissionGroup = '' + + </script> +</%def> + ${parent.body()} From 4c3112b85bdf994cadb24ce0a2b93e85b29ef879 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 19 Jul 2020 18:43:31 -0500 Subject: [PATCH 0153/1681] Fix another pagination bug with buefy grid tables hopefully this gets it all working right...ugh --- tailbone/templates/grids/buefy.mako | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako index 699161c8..ac814a8a 100644 --- a/tailbone/templates/grids/buefy.mako +++ b/tailbone/templates/grids/buefy.mako @@ -147,15 +147,12 @@ backend-sorting @sort="onSort" - ## % if grid.pageable: - ## paginated :paginated="paginated" :per-page="perPage" :current-page="currentPage" backend-pagination :total="total" @page-change="onPageChange" - ## % endif ## TODO: should let grid (or master view) decide how to set these? icon-pack="fas" @@ -295,7 +292,7 @@ `sortkey=${'$'}{this.sortField}`, `sortdir=${'$'}{this.sortOrder}`, `pagesize=${'$'}{this.perPage}`, - `page=${'$'}{this.page}` + `page=${'$'}{this.currentPage}` ].join('&') } From 0798102ba56eb69156a6dd7b9e1fb359b9357627 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 22 Jul 2020 19:53:35 -0500 Subject: [PATCH 0154/1681] Tweak "coalesce" logic for merging field data --- tailbone/views/master.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 163fe91e..daac610b 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2128,7 +2128,9 @@ class MasterView(View): def get_merge_resulting_data(self, remove, keep): result = dict(keep) for field in self.get_merge_coalesce_fields(): - if remove[field] and not keep[field]: + if remove[field] is not None and keep[field] is None: + result[field] = remove[field] + elif remove[field] and not keep[field]: result[field] = remove[field] for field in self.get_merge_additive_fields(): if isinstance(keep[field], (list, tuple)): From d196044d117076058bb9f707fb22950361a67fd6 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 26 Jul 2020 14:02:28 -0500 Subject: [PATCH 0155/1681] 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 e33a7134..39f5a0d5 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,24 @@ CHANGELOG ========= +0.8.98 (2020-07-26) +------------------- + +* Tweak field label for ``Product.item_id``. + +* Make field list explicit for Department views. + +* Make field list explicit for Store views. + +* Don't allow "execute results" for any batches by default. + +* Fix pagination sync issue with buefy grid tables. + +* Fix permissions wiget bug when creating new role. + +* Tweak "coalesce" logic for merging field data. + + 0.8.97 (2020-06-24) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 1137fc33..ed3946e6 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.97' +__version__ = '0.8.98' From e0ce7e8505c29fd9ac4a4cbe54e38147f17c58a2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 28 Jul 2020 21:19:47 -0500 Subject: [PATCH 0156/1681] Add `self.cloning` convenience indicator for master view --- tailbone/views/master.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index daac610b..7f52d9c4 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -123,6 +123,7 @@ class MasterView(View): editing = False deleting = False executing = False + cloning = False has_pk_fields = False has_image = False has_thumbnail = False @@ -1129,6 +1130,7 @@ class MasterView(View): View for cloning an object's data into a new object. """ self.viewing = True + self.cloning = True instance = self.get_instance() form = self.make_form(instance) self.configure_clone_form(form) From cf8072e402cf983b5cd4bcfcb316dd4ea30d508b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 29 Jul 2020 21:58:31 -0500 Subject: [PATCH 0157/1681] Use handler `do_delete()` method when deleting a batch even though it seems we have 2 calls to `session.delete(batch)` now, but things are still working..fingers crossed --- tailbone/views/batch/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index fff98730..66f0c382 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -746,7 +746,7 @@ class BatchMasterView(MasterView): """ Delete all data (files etc.) for the batch. """ - self.handler.delete(batch) + self.handler.do_delete(batch) super(BatchMasterView, self).delete_instance(batch) def get_fallback_templates(self, template, mobile=False): From dfeb14e7a87673815f0e88d4160135d4d67c5b87 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 29 Jul 2020 21:59:49 -0500 Subject: [PATCH 0158/1681] 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 39f5a0d5..e68bb48c 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.99 (2020-07-29) +------------------- + +* Add ``self.cloning`` convenience indicator for master view. + +* Use handler ``do_delete()`` method when deleting a batch. + + 0.8.98 (2020-07-26) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index ed3946e6..4218b25a 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.98' +__version__ = '0.8.99' From 8ea379bbff29c570b701b98bbaec74cc99d34b64 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 30 Jul 2020 16:38:03 -0500 Subject: [PATCH 0159/1681] Add more customization hooks for making grid actions in master view --- tailbone/views/master.py | 45 +++++++++++++++++++++++++++++----------- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 7f52d9c4..d4850ef4 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -1139,7 +1139,7 @@ class MasterView(View): self.request.session.flash("{} has been cloned: {}".format( self.get_model_title(), self.get_instance_title(instance))) self.request.session.flash("(NOTE, you are now viewing the clone!)") - return self.redirect(self.get_action_url('view', cloned)) + return self.redirect_after_clone(cloned) return self.render_to_response('clone', { 'instance': instance, 'instance_title': self.get_instance_title(instance), @@ -1167,6 +1167,9 @@ class MasterView(View): self.Session.flush() return cloned + def redirect_after_clone(self, instance, mobile=False): + return self.redirect(self.get_action_url('view', instance, mobile=mobile)) + def touch(self): """ View for "touching" an object so as to trigger datasync logic for it. @@ -2522,13 +2525,16 @@ class MasterView(View): Return a list of 'main' actions for the grid. """ actions = [] - use_buefy = self.get_use_buefy() if self.viewable and self.has_perm('view'): - url = self.get_view_index_url if self.use_index_links else None - icon = 'eye' if use_buefy else 'zoomin' - actions.append(self.make_action('view', icon=icon, url=url)) + actions.append(self.make_grid_action_view()) return actions + def make_grid_action_view(self): + use_buefy = self.get_use_buefy() + url = self.get_view_index_url if self.use_index_links else None + icon = 'eye' if use_buefy else 'zoomin' + return self.make_action('view', icon=icon, url=url) + def get_view_index_url(self, row, i): route = '{}.view_index'.format(self.get_route_prefix()) return '{}?index={}'.format(self.request.route_url(route), self.first_visible_grid_index + i - 1) @@ -2538,27 +2544,42 @@ class MasterView(View): Return a list of 'more' actions for the grid. """ actions = [] - use_buefy = self.get_use_buefy() # Edit if self.editable and self.has_perm('edit'): - icon = 'edit' if use_buefy else 'pencil' - actions.append(self.make_action('edit', icon=icon, url=self.default_edit_url)) + actions.append(self.make_grid_action_edit()) # Delete if self.deletable and self.has_perm('delete'): - kwargs = {} - if use_buefy and self.delete_confirm == 'simple': - kwargs['click_handler'] = 'deleteObject' - actions.append(self.make_action('delete', icon='trash', url=self.default_delete_url, **kwargs)) + actions.append(self.make_grid_action_delete()) return actions + def make_grid_action_edit(self): + use_buefy = self.get_use_buefy() + icon = 'edit' if use_buefy else 'pencil' + return self.make_action('edit', icon=icon, url=self.default_edit_url) + + def make_grid_action_clone(self): + return self.make_action('clone', icon='object-ungroup', + url=self.default_clone_url) + + def make_grid_action_delete(self): + use_buefy = self.get_use_buefy() + kwargs = {} + if use_buefy and self.delete_confirm == 'simple': + kwargs['click_handler'] = 'deleteObject' + return self.make_action('delete', icon='trash', url=self.default_delete_url, **kwargs) + def default_edit_url(self, row, i=None): if self.editable_instance(row): return self.request.route_url('{}.edit'.format(self.get_route_prefix()), **self.get_action_route_kwargs(row)) + def default_clone_url(self, row, i=None): + return self.request.route_url('{}.clone'.format(self.get_route_prefix()), + **self.get_action_route_kwargs(row)) + def default_delete_url(self, row, i=None): if self.deletable_instance(row): return self.request.route_url('{}.delete'.format(self.get_route_prefix()), From 6bd049e0bbbf6d93e9d43787be97b399097fe19c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 30 Jul 2020 16:39:44 -0500 Subject: [PATCH 0160/1681] 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 e68bb48c..6457e598 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.100 (2020-07-30) +-------------------- + +* Add more customization hooks for making grid actions in master view. + + 0.8.99 (2020-07-29) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 4218b25a..0745e4ce 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.99' +__version__ = '0.8.100' From 9a2a6bbc9f7ae979c0cbbc9260923b70b1d93c28 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 1 Aug 2020 22:18:54 -0500 Subject: [PATCH 0161/1681] Fix missing scrollbar when version diff table is too wide for screen at least, this seems to fix. not sure if/why we shouldn't apply this style globally always, but playing it safe for now --- tailbone/templates/master/view_version.mako | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tailbone/templates/master/view_version.mako b/tailbone/templates/master/view_version.mako index 71b51d39..13c87ae6 100644 --- a/tailbone/templates/master/view_version.mako +++ b/tailbone/templates/master/view_version.mako @@ -3,6 +3,17 @@ <%def name="title()">changes @ ver ${transaction.id}</%def> +<%def name="extra_styles()"> + ${parent.extra_styles()} + <style type="text/css"> + + .this-page-content { + overflow: auto; + } + + </style> +</%def> + <%def name="page_content()"> ## TODO: this was basically copied from Revel diff template..need to abstract From 493785591cc6f6b5a037216c3e8004397370ad0d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 2 Aug 2020 15:27:10 -0500 Subject: [PATCH 0162/1681] Add basic web views for "new customer order" batches --- tailbone/views/custorders/__init__.py | 5 +-- tailbone/views/custorders/batch.py | 41 +++++++++++++++++++++++++ tailbone/views/custorders/creating.py | 44 +++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 tailbone/views/custorders/batch.py create mode 100644 tailbone/views/custorders/creating.py diff --git a/tailbone/views/custorders/__init__.py b/tailbone/views/custorders/__init__.py index d2b4c5ed..78a3d3ab 100644 --- a/tailbone/views/custorders/__init__.py +++ b/tailbone/views/custorders/__init__.py @@ -1,8 +1,8 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -28,5 +28,6 @@ from __future__ import unicode_literals, absolute_import def includeme(config): + config.include('tailbone.views.custorders.creating') config.include('tailbone.views.custorders.orders') config.include('tailbone.views.custorders.items') diff --git a/tailbone/views/custorders/batch.py b/tailbone/views/custorders/batch.py new file mode 100644 index 00000000..a49be977 --- /dev/null +++ b/tailbone/views/custorders/batch.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2020 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Base class for customer order batch views +""" + +from __future__ import unicode_literals, absolute_import + +from rattail.db import model + +from tailbone.views.batch import BatchMasterView + + +class CustomerOrderBatchView(BatchMasterView): + """ + Master view base class, for customer order batches. The views for the + various mode/workflow batches will derive from this. + """ + model_class = model.CustomerOrderBatch + model_row_class = model.CustomerOrderBatchRow + default_handler_spec = 'rattail.batch.custorder:CustomerOrderBatchHandler' diff --git a/tailbone/views/custorders/creating.py b/tailbone/views/custorders/creating.py new file mode 100644 index 00000000..29dc5b35 --- /dev/null +++ b/tailbone/views/custorders/creating.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2020 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Views for 'creating' customer order batches +""" + +from __future__ import unicode_literals, absolute_import + +from tailbone.views.custorders.batch import CustomerOrderBatchView + + +class CreateCustomerOrderBatchView(CustomerOrderBatchView): + """ + Master view for "creating customer order" batches. + """ + route_prefix = 'new_custorders' + url_prefix = '/new-customer-orders' + model_title = "New Customer Order" + model_title_plural = "New Customer Orders" + creatable = False + + +def includeme(config): + CreateCustomerOrderBatchView.defaults(config) From c32f47ba9533fe4c27625629fe6b5f27a4377873 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 2 Aug 2020 19:13:40 -0500 Subject: [PATCH 0163/1681] Tweak the buefy autocomplete component a bit to better support staying in sync w/ data on the caller/parent side --- .../static/js/tailbone.buefy.autocomplete.js | 35 +++++++++++++++---- tailbone/templates/autocomplete.mako | 2 +- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/tailbone/static/js/tailbone.buefy.autocomplete.js b/tailbone/static/js/tailbone.buefy.autocomplete.js index 669b3c1f..fc64a073 100644 --- a/tailbone/static/js/tailbone.buefy.autocomplete.js +++ b/tailbone/static/js/tailbone.buefy.autocomplete.js @@ -22,23 +22,42 @@ const TailboneAutocomplete = { data: [], selected: selected, isFetching: false, - autocompleteValue: this.value, } }, + watch: { + value(to, from) { + if (from && !to) { + this.clearSelection(false) + } + }, + }, + methods: { - clearSelection() { + clearSelection(focus) { + if (focus === undefined) { + focus = true + } this.selected = null - this.autocompleteValue = null - this.$nextTick(function() { - this.$refs.autocomplete.focus() - }) + this.value = null + if (focus) { + this.$nextTick(function() { + this.$refs.autocomplete.focus() + }) + } // TODO: should emit event for caller logic (can they cancel?) // $('#' + oid + '-textbox').trigger('autocompletevaluecleared'); }, + getDisplayText() { + if (this.selected) { + return this.selected.label + } + return "" + }, + // TODO: should we allow custom callback? or is event enough? // function (oid) { // $('#' + oid + '-textbox').on('autocompletevaluecleared', function() { @@ -62,7 +81,9 @@ const TailboneAutocomplete = { // } itemSelected(value) { - this.$emit('input', value) + if (this.selected || !value) { + this.$emit('input', value) + } }, // TODO: buefy example uses `debounce()` here and perhaps we should too? diff --git a/tailbone/templates/autocomplete.mako b/tailbone/templates/autocomplete.mako index 7ec61f4c..c9de4507 100644 --- a/tailbone/templates/autocomplete.mako +++ b/tailbone/templates/autocomplete.mako @@ -65,7 +65,7 @@ <b-autocomplete ref="autocomplete" :name="name" v-show="!selected" - v-model="autocompleteValue" + v-model="value" :data="data" @typing="getAsyncData" @select="selectionMade" From 808e73720221bff292e7a13d255815bb15220de7 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 2 Aug 2020 20:59:16 -0500 Subject: [PATCH 0164/1681] Add basic/unfinished "new customer order" page/feature so far creates the order batch, and can set some customer info --- tailbone/templates/custorders/create.mako | 426 ++++++++++++++++++++++ tailbone/views/custorders/batch.py | 81 ++++ tailbone/views/custorders/creating.py | 5 + tailbone/views/custorders/orders.py | 129 ++++++- 4 files changed, 639 insertions(+), 2 deletions(-) create mode 100644 tailbone/templates/custorders/create.mako diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako new file mode 100644 index 00000000..24245b7a --- /dev/null +++ b/tailbone/templates/custorders/create.mako @@ -0,0 +1,426 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/create.mako" /> + +<%def name="extra_styles()"> + ${parent.extra_styles()} + % if use_buefy: + <style type="text/css"> + .this-page-content { + flex-grow: 1; + } + </style> + % endif +</%def> + +<%def name="page_content()"> + <br /> + % if use_buefy: + <customer-order-creator></customer-order-creator> + % else: + <p>Sorry, but this page is not supported by your current theme configuration.</p> + % endif +</%def> + +<%def name="order_form_buttons()"> + <div class="buttons"> + <b-button type="is-primary" + @click="submitOrder()" + icon-pack="fas" + icon-left="fas fa-upload"> + Submit this Order + </b-button> + <b-button @click="startOverEntirely()" + icon-pack="fas" + icon-left="fas fa-redo"> + Start Over Entirely + </b-button> + <b-button @click="cancelOrder()" + type="is-danger" + icon-pack="fas" + icon-left="fas fa-trash"> + Cancel this Order + </b-button> + </div> +</%def> + +<%def name="render_this_page_template()"> + ${parent.render_this_page_template()} + + <script type="text/x-template" id="customer-order-creator-template"> + <div> + + ${self.order_form_buttons()} + + <b-collapse class="panel" :class="customerPanelType" + :open.sync="customerPanelOpen"> + + <div slot="trigger" + slot-scope="props" + class="panel-heading" + role="button"> + <b-icon pack="fas" + ## TODO: this icon toggling should work, according to + ## Buefy docs, but i could not ever get it to work. + ## what am i missing? + ## https://buefy.org/documentation/collapse/ + ## :icon="props.open ? 'caret-down' : 'caret-right'"> + ## (for now we just always show caret-right instead) + icon="caret-right"> + </b-icon> + <strong v-html="customerPanelHeader"></strong> + </div> + + <div class="panel-block"> + <div style="width: 100%;"> + + <div style="display: flex; flex-direction: row;"> + <div style="flex-grow: 1; margin-right: 1rem;"> + <b-notification :type="customerStatusType" + position="is-bottom-right" + :closable="false"> + {{ customerStatusText }} + </b-notification> + </div> + <!-- <div class="buttons"> --> + <!-- <b-button @click="startOverCustomer()" --> + <!-- icon-pack="fas" --> + <!-- icon-left="fas fa-redo"> --> + <!-- Start Over --> + <!-- </b-button> --> + <!-- </div> --> + </div> + + <br /> + <div class="field"> + <b-radio v-model="customerIsKnown" + :native-value="true"> + Customer is already in the system. + </b-radio> + </div> + + <div v-show="customerIsKnown"> + <b-field label="Customer Name" horizontal> + <tailbone-autocomplete + ref="customerAutocomplete" + v-model="customerUUID" + :initial-label="customerDisplay" + serviceUrl="${url('customers.autocomplete')}" + @input="customerChanged"> + </tailbone-autocomplete> + </b-field> + <b-field label="Phone Number" horizontal> + <b-input v-model="phoneNumberEntry" + @input="phoneNumberChanged" + @keydown.native="phoneNumberKeyDown"> + </b-input> + <b-button v-if="!phoneNumberSaved" + type="is-primary" + icon-pack="fas" + icon-left="fas fa-save" + @click="setCustomerData()"> + Please save when finished editing + </b-button> + <!-- <tailbone-autocomplete --> + <!-- serviceUrl="${url('customers.autocomplete.phone')}"> --> + <!-- </tailbone-autocomplete> --> + </b-field> + </div> + + <br /> + <div class="field"> + <b-radio v-model="customerIsKnown" disabled + :native-value="false"> + Customer is not yet in the system. + </b-radio> + </div> + + <div v-if="!customerIsKnown"> + <b-field label="Customer Name" horizontal> + <b-input v-model="customerName"></b-input> + </b-field> + <b-field label="Phone Number" horizontal> + <b-input v-model="phoneNumber"></b-input> + </b-field> + </div> + + </div> + </div> <!-- panel-block --> + </b-collapse> + + <b-collapse class="panel" + open> + + <div slot="trigger" + slot-scope="props" + class="panel-heading" + role="button"> + <b-icon pack="fas" + ## TODO: this icon toggling should work, according to + ## Buefy docs, but i could not ever get it to work. + ## what am i missing? + ## https://buefy.org/documentation/collapse/ + ## :icon="props.open ? 'caret-down' : 'caret-right'"> + ## (for now we just always show caret-right instead) + icon="caret-right"> + </b-icon> + <strong>Items</strong> + </div> + + <div class="panel-block"> + <div> + TODO: items go here + </div> + </div> + </b-collapse> + + ${self.order_form_buttons()} + + ${h.form(request.current_route_url(), ref='batchActionForm')} + ${h.csrf_token(request)} + ${h.hidden('action', **{'v-model': 'batchAction'})} + ${h.end_form()} + + </div> + </script> +</%def> + +<%def name="make_this_page_component()"> + ${parent.make_this_page_component()} + <script type="text/javascript"> + + const CustomerOrderCreator = { + template: '#customer-order-creator-template', + data() { + return { + batchAction: null, + + customerPanelOpen: true, + customerIsKnown: true, + customerUUID: ${json.dumps(batch.customer_uuid)|n}, + customerDisplay: ${json.dumps(six.text_type(batch.customer or ''))|n}, + customerEntry: null, + phoneNumberEntry: ${json.dumps(batch.phone_number)|n}, + phoneNumberSaved: true, + customerName: null, + phoneNumber: null, + + ## 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}, + } + }, + computed: { + customerPanelHeader() { + let text = "Customer" + + if (this.customerIsKnown) { + if (this.customerUUID) { + if (this.$refs.customerAutocomplete) { + text = "Customer: " + this.$refs.customerAutocomplete.getDisplayText() + } else { + text = "Customer: " + this.customerDisplay + } + } + } else { + if (this.customerName) { + text = "Customer: " + this.customerName + } + } + + if (!this.customerPanelOpen) { + text += ' <p class="' + this.customerHeaderClass + '" style="display: inline-block; float: right;">' + this.customerStatusText + '</p>' + } + + return text + }, + customerHeaderClass() { + if (!this.customerPanelOpen) { + if (this.customerStatusType == 'is-danger') { + return 'has-text-danger' + } else if (this.customerStatusType == 'is-warning') { + return 'has-text-warning' + } + } + }, + customerPanelType() { + if (!this.customerPanelOpen) { + return this.customerStatusType + } + }, + customerStatusType() { + return this.customerStatusTypeAndText.type + }, + customerStatusText() { + return this.customerStatusTypeAndText.text + }, + customerStatusTypeAndText() { + let phoneNumber = null + if (this.customerIsKnown) { + if (!this.customerUUID) { + return { + type: 'is-danger', + text: "Please identify the customer.", + } + } + if (!this.phoneNumberEntry) { + return { + type: 'is-warning', + text: "Please provide a phone number for the customer.", + } + } + phoneNumber = this.phoneNumberEntry + } else { // customer is not known + if (!this.customerName) { + return { + type: 'is-danger', + text: "Please identify the customer.", + } + } + if (!this.phoneNumber) { + return { + type: 'is-warning', + text: "Please provide a phone number for the customer.", + } + } + phoneNumber = this.phoneNumber + } + + let phoneDigits = phoneNumber.replace(/\D/g, '') + if (!phoneDigits.length || (phoneDigits.length != 7 && phoneDigits.length != 10)) { + return { + type: 'is-warning', + text: "The phone number does not appear to be valid.", + } + } + + if (!this.customerIsKnown) { + return { + type: 'is-warning', + text: "Will create a new customer record.", + } + } + + return { + type: null, + text: "Everything seems to be okay here.", + } + }, + }, + methods: { + + startOverEntirely() { + let msg = "Are you sure you want to start over entirely?\n\n" + + "This will totally delete this order and start a new one." + if (!confirm(msg)) { + return + } + this.batchAction = 'start_over_entirely' + this.$nextTick(function() { + this.$refs.batchActionForm.submit() + }) + }, + + // startOverCustomer(confirmed) { + // if (!confirmed) { + // let msg = "Are you sure you want to start over for the customer data?" + // if (!confirm(msg)) { + // return + // } + // } + // this.customerIsKnown = true + // this.customerUUID = null + // // this.customerEntry = null + // this.phoneNumberEntry = null + // this.customerName = null + // this.phoneNumber = null + // }, + + // startOverItem(confirmed) { + // if (!confirmed) { + // let msg = "Are you sure you want to start over for the item data?" + // if (!confirm(msg)) { + // return + // } + // } + // // TODO: reset things + // }, + + cancelOrder() { + let msg = "Are you sure you want to cancel?\n\n" + + "This will totally delete the current order." + if (!confirm(msg)) { + return + } + this.batchAction = 'delete_batch' + this.$nextTick(function() { + this.$refs.batchActionForm.submit() + }) + }, + + submitBatchData(params, callback) { + let url = ${json.dumps(request.current_route_url())|n} + + let headers = { + ## TODO: should find a better way to handle CSRF token + 'X-CSRF-TOKEN': this.csrftoken, + } + + ## TODO: should find a better way to handle CSRF token + this.$http.post(url, params, {headers: headers}).then((response) => { + if (callback) { + callback(response) + } + }) + }, + + setCustomerData() { + let params = { + action: 'set_customer_data', + customer_uuid: this.customerUUID, + phone_number: this.phoneNumberEntry, + } + let that = this + this.submitBatchData(params, function(response) { + that.phoneNumberSaved = true + }) + }, + + submitOrder() { + alert("okay then!") + }, + + customerChanged(uuid) { + if (!uuid) { + this.phoneNumberEntry = null + this.setCustomerData() + } else { + let params = { + action: 'get_customer_info', + uuid: this.customerUUID, + } + let that = this + this.submitBatchData(params, function(response) { + that.phoneNumberEntry = response.data.phone_number + that.setCustomerData() + }) + } + }, + + phoneNumberChanged(value) { + this.phoneNumberSaved = false + }, + + phoneNumberKeyDown(event) { + if (event.which == 13) { // Enter + this.setCustomerData() + } + }, + }, + } + + Vue.component('customer-order-creator', CustomerOrderCreator) + + </script> +</%def> + + +${parent.body()} diff --git a/tailbone/views/custorders/batch.py b/tailbone/views/custorders/batch.py index a49be977..c8b6280f 100644 --- a/tailbone/views/custorders/batch.py +++ b/tailbone/views/custorders/batch.py @@ -26,8 +26,13 @@ Base class for customer order batch views from __future__ import unicode_literals, absolute_import +import six + from rattail.db import model +import colander + +from tailbone import forms from tailbone.views.batch import BatchMasterView @@ -39,3 +44,79 @@ class CustomerOrderBatchView(BatchMasterView): model_class = model.CustomerOrderBatch model_row_class = model.CustomerOrderBatchRow default_handler_spec = 'rattail.batch.custorder:CustomerOrderBatchHandler' + + grid_columns = [ + 'id', + 'customer', + 'rows', + 'created', + 'created_by', + ] + + form_fields = [ + 'id', + 'customer', + 'person', + 'phone_number', + 'email_address', + 'created', + 'created_by', + 'rows', + 'status_code', + ] + + def configure_grid(self, g): + super(CustomerOrderBatchView, self).configure_grid(g) + + g.set_link('customer') + g.set_link('created') + g.set_link('created_by') + + def configure_form(self, f): + super(CustomerOrderBatchView, self).configure_form(f) + order = f.model_instance + model = self.rattail_config.get_model() + + # readonly fields + f.set_readonly('rows') + f.set_readonly('status_code') + + # customer + if 'customer' in f.fields and self.editing: + f.replace('customer', 'customer_uuid') + f.set_node('customer_uuid', colander.String(), missing=colander.null) + customer_display = "" + if self.request.method == 'POST': + if self.request.POST.get('customer_uuid'): + customer = self.Session.query(model.Customer)\ + .get(self.request.POST['customer_uuid']) + if customer: + customer_display = six.text_type(customer) + elif self.editing: + customer_display = six.text_type(order.customer or "") + customers_url = self.request.route_url('customers.autocomplete') + f.set_widget('customer_uuid', forms.widgets.JQueryAutocompleteWidget( + field_display=customer_display, service_url=customers_url)) + f.set_label('customer_uuid', "Customer") + else: + f.set_renderer('customer', self.render_customer) + + # person + if 'person' in f.fields and self.editing: + f.replace('person', 'person_uuid') + f.set_node('person_uuid', colander.String(), missing=colander.null) + person_display = "" + if self.request.method == 'POST': + if self.request.POST.get('person_uuid'): + person = self.Session.query(model.Person)\ + .get(self.request.POST['person_uuid']) + if person: + person_display = six.text_type(person) + elif self.editing: + person_display = six.text_type(order.person or "") + people_url = self.request.route_url('people.autocomplete') + f.set_widget('person_uuid', forms.widgets.JQueryAutocompleteWidget( + field_display=person_display, service_url=people_url)) + f.set_label('person_uuid', "Person") + else: + f.set_renderer('person', self.render_person) diff --git a/tailbone/views/custorders/creating.py b/tailbone/views/custorders/creating.py index 29dc5b35..c14448eb 100644 --- a/tailbone/views/custorders/creating.py +++ b/tailbone/views/custorders/creating.py @@ -22,6 +22,11 @@ ################################################################################ """ Views for 'creating' customer order batches + +Note that this provides only the "direct" or "raw" table views for these +batches. This does *not* provide a way to create a new batch; you should see +:meth:`tailbone.views.custorders.orders.CustomerOrdersView.create()` for that +logic. """ from __future__ import unicode_literals, absolute_import diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index dee21f15..9ffc06c8 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -43,7 +43,6 @@ class CustomerOrdersView(MasterView): """ model_class = model.CustomerOrder route_prefix = 'custorders' - creatable = False editable = False deletable = False @@ -59,6 +58,8 @@ class CustomerOrdersView(MasterView): 'id', 'customer', 'person', + 'phone_number', + 'email_address', 'created', 'status_code', ] @@ -115,6 +116,130 @@ class CustomerOrdersView(MasterView): url = self.request.route_url('people.view', uuid=person.uuid) return tags.link_to(text, url) + def create(self, form=None, template='create'): + """ + View for creating a new customer order. Note that it does so by way of + maintaining a "new customer order" batch, until the user finally + submits the order, at which point the batch is converted to a proper + order. + """ + batch = self.get_current_batch() + + if self.request.method == 'POST': + + # first we check for traditional form post + action = self.request.POST.get('action') + post_actions = [ + 'start_over_entirely', + 'delete_batch', + ] + if action in post_actions: + return getattr(self, action)(batch) + + # okay then, we'll assume newer JSON-style post params + data = dict(self.request.json_body) + action = data.get('action') + json_actions = [ + 'get_customer_info', + 'set_customer_data', + 'submit_new_order', + ] + if action in json_actions: + result = getattr(self, action)(batch, data) + return self.json_response(result) + + context = {'batch': batch} + return self.render_to_response(template, context) + + def get_current_batch(self): + user = self.request.user + if not user: + raise RuntimeError("this feature requires a user to be logged in") + + try: + # there should be at most *one* new batch per user + batch = self.Session.query(model.CustomerOrderBatch)\ + .filter(model.CustomerOrderBatch.mode == self.enum.CUSTORDER_BATCH_MODE_CREATING)\ + .filter(model.CustomerOrderBatch.created_by == user)\ + .one() + + except orm.exc.NoResultFound: + # no batch yet for this user, so make one + batch = model.CustomerOrderBatch() + batch.mode = self.enum.CUSTORDER_BATCH_MODE_CREATING + batch.created_by = user + self.Session.add(batch) + self.Session.flush() + + return batch + + def start_over_entirely(self, batch): + # just delete current batch outright + # TODO: should use self.handler.do_delete() instead? + self.Session.delete(batch) + self.Session.flush() + + # send user back to normal "create" page; a new batch will be generated + # for them automatically + route_prefix = self.get_route_prefix() + url = self.request.route_url('{}.create'.format(route_prefix)) + return self.redirect(url) + + def delete_batch(self, batch): + # just delete current batch outright + # TODO: should use self.handler.do_delete() instead? + self.Session.delete(batch) + self.Session.flush() + + # set flash msg just to be more obvious + 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) + return self.redirect(url) + + def get_customer_info(self, batch, data): + uuid = data.get('uuid') + if not uuid: + return {'error': "Must specify a customer UUID"} + + customer = self.Session.query(model.Customer).get(uuid) + if not customer: + return {'error': "Customer not found"} + + return self.info_for_customer(batch, data, customer) + + def info_for_customer(self, batch, data, customer): + phone = customer.first_phone() + email = customer.first_email() + return { + 'uuid': customer.uuid, + 'phone_number': phone.number if phone else None, + 'email_address': email.address if email else None, + } + + def set_customer_data(self, batch, data): + if 'customer_uuid' in data: + batch.customer_uuid = data['customer_uuid'] + if 'person_uuid' in data: + batch.person_uuid = data['person_uuid'] + elif batch.customer_uuid: + self.Session.flush() + batch.person = batch.customer.first_person() + else: # no customer set + batch.person_uuid = None + if 'phone_number' in data: + batch.phone_number = data['phone_number'] + if 'email_address' in data: + batch.email_address = data['email_address'] + self.Session.flush() + return {'success': True} + + def submit_new_order(self, batch, data): + # TODO + return {'success': True} + def includeme(config): CustomerOrdersView.defaults(config) From 7d158e58b5f6420ce5c9130ed9493fa754966384 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 6 Aug 2020 02:04:17 -0500 Subject: [PATCH 0165/1681] Add `protected_usernames()` config function --- tailbone/config.py | 3 +++ tailbone/views/users.py | 16 ++++++++-------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/tailbone/config.py b/tailbone/config.py index 875bc25b..ebd8899e 100644 --- a/tailbone/config.py +++ b/tailbone/config.py @@ -56,3 +56,6 @@ class ConfigExtension(BaseExtension): def legacy_mobile_enabled(config): return config.getbool('tailbone', 'legacy_mobile.enabled', default=True) + +def protected_usernames(config): + return config.getlist('tailbone', 'protected_usernames') diff --git a/tailbone/views/users.py b/tailbone/views/users.py index 79e2590c..93869d5d 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -42,6 +42,7 @@ from tailbone import forms from tailbone.db import Session from tailbone.views import MasterView from tailbone.views.principal import PrincipalMasterView, PermissionsRenderer +from tailbone.config import protected_usernames class UsersView(PrincipalMasterView): @@ -139,9 +140,9 @@ class UsersView(PrincipalMasterView): user is "root". But if the given user is not protected, this simply returns ``True``. """ - if self.user_is_protected(user): - return self.request.is_root - return True + if self.request.is_root: + return True + return not self.user_is_protected(user) def deletable_instance(self, user): """ @@ -149,9 +150,9 @@ class UsersView(PrincipalMasterView): user is "root". But if the given user is not protected, this simply returns ``True``. """ - if self.user_is_protected(user): - return self.request.is_root - return True + if self.request.is_root: + return True + return not self.user_is_protected(user) def user_is_protected(self, user): """ @@ -165,8 +166,7 @@ class UsersView(PrincipalMasterView): "root", otherwise will return ``False``. """ if not hasattr(self, 'protected_usernames'): - self.protected_usernames = self.rattail_config.getlist( - 'tailbone', 'protected_usernames') + self.protected_usernames = protected_usernames(self.rattail_config) if self.protected_usernames and user.username in self.protected_usernames: return True return False From 437157440308179ef6171fc26155fad2876b5a72 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 9 Aug 2020 14:03:28 -0500 Subject: [PATCH 0166/1681] Add `model` to global template context, plus `h.maxlen()` sometimes it's nice to just add a `maxlength="100"` or whatever to an input tag within some random template. that should "just be possible" with no extra effort --- tailbone/helpers.py | 1 + tailbone/subscribers.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/tailbone/helpers.py b/tailbone/helpers.py index 3a3d8365..46a30dec 100644 --- a/tailbone/helpers.py +++ b/tailbone/helpers.py @@ -32,6 +32,7 @@ from decimal import Decimal from rattail.time import localtime, make_utc from rattail.util import (pretty_quantity, pretty_hours, hours_as_decimal, OrderedDict) +from rattail.db.util import maxlen from webhelpers2.html import * from webhelpers2.html.tags import * diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 90930e60..af88f7a7 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -99,6 +99,7 @@ def before_render(event): renderer_globals['url'] = request.route_url renderer_globals['rattail'] = rattail renderer_globals['tailbone'] = tailbone + renderer_globals['model'] = request.rattail_config.get_model() renderer_globals['enum'] = request.rattail_config.get_enum() renderer_globals['six'] = six renderer_globals['json'] = json From 163134326aaab45620a71d72795ba33d5b35fec9 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 9 Aug 2020 14:32:16 -0500 Subject: [PATCH 0167/1681] Coalesce on `User.active` when merging --- 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 93869d5d..078e99ca 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -86,6 +86,7 @@ class UsersView(PrincipalMasterView): merge_coalesce_fields = [ 'person_uuid', 'person_name', + 'active', ] merge_fields = merge_additive_fields + [ 'uuid', @@ -93,7 +94,6 @@ class UsersView(PrincipalMasterView): 'person_uuid', 'person_name', 'role_count', - 'active', ] def query(self, session): From ca31af196f6b456baf01914f48f687ec0d9415eb Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 9 Aug 2020 14:39:31 -0500 Subject: [PATCH 0168/1681] Expose user reference(s) for employees --- tailbone/views/employees.py | 23 +++++++++++++++++++++-- tailbone/views/master.py | 12 ++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/tailbone/views/employees.py b/tailbone/views/employees.py index 488e1f65..dac93e67 100644 --- a/tailbone/views/employees.py +++ b/tailbone/views/employees.py @@ -60,6 +60,7 @@ class EmployeesView(MasterView): 'phone', 'email', 'status', + 'username', ] form_fields = [ @@ -73,6 +74,7 @@ class EmployeesView(MasterView): 'full_time', 'full_time_start', 'id', + 'users', 'stores', 'departments', ] @@ -90,15 +92,25 @@ class EmployeesView(MasterView): factory=grids.filters.AlchemyPhoneNumberFilter) g.set_sorter('phone', model.EmployeePhoneNumber.number) + # email g.joiners['email'] = lambda q: q.outerjoin(model.EmployeeEmailAddress, sa.and_( model.EmployeeEmailAddress.parent_uuid == model.Employee.uuid, model.EmployeeEmailAddress.preference == 1)) + g.filters['email'] = g.make_filter('email', model.EmployeeEmailAddress.address, + label="Email Address") + # first/last name g.filters['first_name'] = g.make_filter('first_name', model.Person.first_name) g.filters['last_name'] = g.make_filter('last_name', model.Person.last_name) - g.filters['email'] = g.make_filter('email', model.EmployeeEmailAddress.address, - label="Email Address") + # username + if self.request.has_perm('users.view'): + g.set_joiner('username', lambda q: q.outerjoin(model.User)) + g.set_filter('username', model.User.username) + g.set_sorter('username', model.User.username) + g.set_renderer('username', self.grid_render_username) + else: + g.hide_column('username') # id if self.request.has_perm('{}.edit'.format(route_prefix)): @@ -142,6 +154,12 @@ class EmployeesView(MasterView): q = q.filter(model.Employee.status == self.enum.EMPLOYEE_STATUS_CURRENT) return q + def grid_render_username(self, employee, field): + person = employee.person if employee else None + if not person: + return "" + return ", ".join([u.username for u in person.users]) + def grid_extra_class(self, employee, i): if employee.status == self.enum.EMPLOYEE_STATUS_FORMER: return 'warning' @@ -161,6 +179,7 @@ class EmployeesView(MasterView): employee = f.model_instance f.set_renderer('person', self.render_person) + f.set_renderer('users', self.render_users) f.set_renderer('stores', self.render_stores) f.set_label('stores', "Stores") # TODO: should not be necessary diff --git a/tailbone/views/master.py b/tailbone/views/master.py index d4850ef4..10bd5c26 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -947,6 +947,18 @@ class MasterView(View): url = self.request.route_url('users.view', uuid=user.uuid) return tags.link_to(text, url) + def render_users(self, obj, field): + users = obj.users + if not users: + return "" + + items = [] + for user in users: + text = user.username + url = self.request.route_url('users.view', uuid=user.uuid) + items.append(HTML.tag('li', c=[tags.link_to(text, url)])) + return HTML.tag('ul', c=items) + def render_customer(self, obj, field): customer = getattr(obj, field) if not customer: From b4ea1489a7075b562767f41fb1faa3ef4ec999cf Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 9 Aug 2020 15:06:41 -0500 Subject: [PATCH 0169/1681] Update changelog --- CHANGES.rst | 20 ++++++++++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 6457e598..1516feb7 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,26 @@ CHANGELOG ========= +0.8.101 (2020-08-09) +-------------------- + +* Fix missing scrollbar when version diff table is too wide for screen. + +* Add basic web views for "new customer order" batches. + +* Tweak the buefy autocomplete component a bit. + +* Add basic/unfinished "new customer order" page/feature. + +* Add ``protected_usernames()`` config function. + +* Add ``model`` to global template context, plus ``h.maxlen()``. + +* Coalesce on ``User.active`` when merging. + +* Expose user reference(s) for employees. + + 0.8.100 (2020-07-30) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 0745e4ce..c5157649 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.100' +__version__ = '0.8.101' From d0e7f7dda2039cc2fc2cef0c022663cb03e62b96 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 9 Aug 2020 15:50:25 -0500 Subject: [PATCH 0170/1681] Improve rendering of `true_margin` column for pricing batch row grid --- tailbone/views/batch/pricing.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tailbone/views/batch/pricing.py b/tailbone/views/batch/pricing.py index 34586ea1..88063d00 100644 --- a/tailbone/views/batch/pricing.py +++ b/tailbone/views/batch/pricing.py @@ -26,6 +26,8 @@ Views for pricing batches from __future__ import unicode_literals, absolute_import +import six + from rattail.db import model from rattail.time import localtime @@ -203,12 +205,26 @@ class PricingBatchView(BatchMasterView): g.set_renderer('current_price', self.render_current_price) + g.set_renderer('true_margin', self.render_true_margin) + def render_vendor_id(self, row, field): vendor_id = row.vendor.id if row.vendor else None if not vendor_id: return "" return vendor_id + def render_true_margin(self, row, field): + margin = row.true_margin + if margin: + margin = six.text_type(margin) + else: + margin = HTML.literal(' ') + if row.old_true_margin is not None: + title = "WAS: {}".format(row.old_true_margin) + else: + title = "WAS: NULL" + return HTML.tag('span', title=title, c=[margin]) + def row_grid_extra_class(self, row, i): extra_class = None From dca890f16900a183ca561b751d1845b4ee3c6e05 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 10 Aug 2020 19:37:29 -0500 Subject: [PATCH 0171/1681] 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 1516feb7..d238ac5a 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.102 (2020-08-10) +-------------------- + +* Improve rendering of ``true_margin`` column for pricing batch row grid. + + 0.8.101 (2020-08-09) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index c5157649..9acd832b 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.101' +__version__ = '0.8.102' From aac0e7d35c9011b3602d2525756b96a04313f9fb Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 11 Aug 2020 18:28:03 -0500 Subject: [PATCH 0172/1681] Tweak config methods for customer master view --- tailbone/views/customers.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index cdb44429..a5cf963a 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -385,14 +385,17 @@ class CustomersView(MasterView): @classmethod def defaults(cls, config): + cls._defaults(config) + cls._customer_defaults(config) + + @classmethod + def _customer_defaults(cls, config): route_prefix = cls.get_route_prefix() url_prefix = cls.get_url_prefix() permission_prefix = cls.get_permission_prefix() model_key = cls.get_model_key() model_title = cls.get_model_title() - cls._defaults(config) - # detach person if cls.people_detachable: config.add_tailbone_permission(permission_prefix, '{}.detach_person'.format(permission_prefix), From 7924502b657a5de7076e5a23307241e830bb0fc0 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 13 Aug 2020 12:55:17 -0500 Subject: [PATCH 0173/1681] 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 d238ac5a..728a3483 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.103 (2020-08-13) +-------------------- + +* Tweak config methods for customer master view. + + 0.8.102 (2020-08-10) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 9acd832b..ce6cf316 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.102' +__version__ = '0.8.103' From a038f2a98dcb7f2f2263eddbe3d6cf0ee2e1d7b5 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 16 Aug 2020 16:57:06 -0500 Subject: [PATCH 0174/1681] Make "download row results" a bit more generic to handle non-native table/rows, w/ non-uuid key --- tailbone/templates/master/view.mako | 4 ++-- tailbone/views/master.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako index d07e1cc9..94454bd9 100644 --- a/tailbone/templates/master/view.mako +++ b/tailbone/templates/master/view.mako @@ -72,8 +72,8 @@ % if master.has_rows and master.rows_downloadable_csv and request.has_perm('{}.row_results_csv'.format(permission_prefix)): <li>${h.link_to("Download row results as CSV", url('{}.row_results_csv'.format(route_prefix), uuid=instance.uuid))}</li> % endif - % if master.has_rows and master.rows_downloadable_xlsx and request.has_perm('{}.row_results_xlsx'.format(permission_prefix)): - <li>${h.link_to("Download row results as XLSX", url('{}.row_results_xlsx'.format(route_prefix), uuid=instance.uuid))}</li> + % if master.has_rows and master.rows_downloadable_xlsx and master.has_perm('row_results_xlsx'): + <li>${h.link_to("Download row results as XLSX", master.get_action_url('row_results_xlsx', instance))}</li> % endif </%def> diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 10bd5c26..827e5500 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -3868,7 +3868,7 @@ class MasterView(View): if cls.has_rows and cls.rows_downloadable_xlsx: config.add_tailbone_permission(permission_prefix, '{}.row_results_xlsx'.format(permission_prefix), "Download {} results as XLSX".format(row_model_title)) - config.add_route('{}.row_results_xlsx'.format(route_prefix), '{}/{{uuid}}/rows-xlsx'.format(url_prefix)) + config.add_route('{}.row_results_xlsx'.format(route_prefix), '{}/rows-xlsx'.format(instance_url_prefix)) config.add_view(cls, attr='row_results_xlsx', route_name='{}.row_results_xlsx'.format(route_prefix), permission='{}.row_results_xlsx'.format(permission_prefix)) From b5028ab2d0e8a240657b6b274befe9e8efc64e30 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 17 Aug 2020 21:38:12 -0500 Subject: [PATCH 0175/1681] Add pagination to price, cost history grids for product view --- tailbone/grids/core.py | 3 +++ tailbone/templates/grids/b-table.mako | 4 ++++ tailbone/templates/products/view.mako | 8 ++++---- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 60934879..d475370c 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -985,6 +985,9 @@ class Grid(object): context['empty_labels'] = empty_labels if 'grid_columns' not in context: context['grid_columns'] = self.get_buefy_columns() + context.setdefault('paginated', False) + if context['paginated']: + context.setdefault('per_page', 20) # locate the 'view' action # TODO: this should be easier, and/or moved elsewhere? diff --git a/tailbone/templates/grids/b-table.mako b/tailbone/templates/grids/b-table.mako index 42e82273..8608b456 100644 --- a/tailbone/templates/grids/b-table.mako +++ b/tailbone/templates/grids/b-table.mako @@ -5,6 +5,10 @@ striped hoverable narrowed + % if paginated: + paginated + per-page="${per_page}" + % endif % if vshow is not Undefined and vshow: v-show="${vshow}" % endif diff --git a/tailbone/templates/products/view.mako b/tailbone/templates/products/view.mako index c8f2a5ec..23b6d2ca 100644 --- a/tailbone/templates/products/view.mako +++ b/tailbone/templates/products/view.mako @@ -380,7 +380,7 @@ </p> </header> <section class="modal-card-body"> - ${regular_price_history_grid.render_buefy_table_element(data_prop='regularPriceHistoryData', loading='regularPriceHistoryLoading')|n} + ${regular_price_history_grid.render_buefy_table_element(data_prop='regularPriceHistoryData', loading='regularPriceHistoryLoading', paginated=True, per_page=10)|n} </section> <footer class="modal-card-foot"> <b-button @click="showingPriceHistory_regular = false"> @@ -399,7 +399,7 @@ </p> </header> <section class="modal-card-body"> - ${current_price_history_grid.render_buefy_table_element(data_prop='currentPriceHistoryData', loading='currentPriceHistoryLoading')|n} + ${current_price_history_grid.render_buefy_table_element(data_prop='currentPriceHistoryData', loading='currentPriceHistoryLoading', paginated=True, per_page=10)|n} </section> <footer class="modal-card-foot"> <b-button @click="showingPriceHistory_current = false"> @@ -418,7 +418,7 @@ </p> </header> <section class="modal-card-body"> - ${suggested_price_history_grid.render_buefy_table_element(data_prop='suggestedPriceHistoryData', loading='suggestedPriceHistoryLoading')|n} + ${suggested_price_history_grid.render_buefy_table_element(data_prop='suggestedPriceHistoryData', loading='suggestedPriceHistoryLoading', paginated=True, per_page=10)|n} </section> <footer class="modal-card-foot"> <b-button @click="showingPriceHistory_suggested = false"> @@ -437,7 +437,7 @@ </p> </header> <section class="modal-card-body"> - ${cost_history_grid.render_buefy_table_element(data_prop='costHistoryData', loading='costHistoryLoading')|n} + ${cost_history_grid.render_buefy_table_element(data_prop='costHistoryData', loading='costHistoryLoading', paginated=True, per_page=10)|n} </section> <footer class="modal-card-foot"> <b-button @click="showingCostHistory = false"> From 96185d17bd07a12a2dae556f12a28d1fac9178ce Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 17 Aug 2020 21:56:09 -0500 Subject: [PATCH 0176/1681] 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 728a3483..b83ae927 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.104 (2020-08-17) +-------------------- + +* Make "download row results" a bit more generic. + +* Add pagination to price, cost history grids for product view. + + 0.8.103 (2020-08-13) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index ce6cf316..0c3caf56 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.103' +__version__ = '0.8.104' From cfa9c95814bb1633d74c2f1ef54840ba52423869 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 19 Aug 2020 17:16:54 -0500 Subject: [PATCH 0177/1681] Tweaks for export views, to make more generic --- tailbone/views/exports.py | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/tailbone/views/exports.py b/tailbone/views/exports.py index 793810f2..c136359a 100644 --- a/tailbone/views/exports.py +++ b/tailbone/views/exports.py @@ -27,6 +27,7 @@ Master class for generic export history views from __future__ import unicode_literals, absolute_import import os +import shutil import six @@ -46,6 +47,7 @@ class ExportMasterView(MasterView): creatable = False editable = False downloadable = False + delete_export_files = False grid_columns = [ 'id', @@ -65,8 +67,11 @@ class ExportMasterView(MasterView): if hasattr(self, 'export_key'): return self.export_key + cls = self.get_model_class() + return cls.export_key + def get_file_path(self, export, makedirs=False): - return self.rattail_config.export_filepath(self.export_key, + return self.rattail_config.export_filepath(self.get_export_key(), export.uuid, export.filename, makedirs=makedirs) @@ -89,6 +94,7 @@ class ExportMasterView(MasterView): g.set_label('created_by', "Created by") g.set_link('id') + g.set_link('filename') def render_id(self, export, field): return export.id_str @@ -129,6 +135,13 @@ class ExportMasterView(MasterView): else: f.set_readonly('record_count') + # filename + if self.editing: + f.remove_field('filename') + else: + f.set_readonly('filename') + f.set_renderer('filename', self.render_downloadable_file) + def objectify(self, form, data=None): obj = super(ExportMasterView, self).objectify(form, data=data) if self.creating: @@ -169,3 +182,17 @@ class ExportMasterView(MasterView): response.headers[b'Content-Length'] = six.binary_type(os.path.getsize(path)) response.headers[b'Content-Disposition'] = b'attachment; filename="{}"'.format(export.filename) return response + + def delete_instance(self, export): + """ + Delete the export's files as well as the export itself. + """ + # delete files for the export, if applicable + if self.delete_export_files: + path = self.get_file_path(export) + dirname = os.path.dirname(path) + if os.path.exists(dirname): + shutil.rmtree(dirname) + + # continue w/ normal deletion + super(ExportMasterView, self).delete_instance(export) From 9620fc5a83ab0daf84fa9a7f8b6982723dc763dc Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 20 Aug 2020 17:51:00 -0500 Subject: [PATCH 0178/1681] Add config for "global" help URL --- tailbone/config.py | 6 ++++++ tailbone/views/common.py | 2 ++ tailbone/views/master.py | 6 +++++- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/tailbone/config.py b/tailbone/config.py index ebd8899e..29359e06 100644 --- a/tailbone/config.py +++ b/tailbone/config.py @@ -53,9 +53,15 @@ class ConfigExtension(BaseExtension): config.setdefault('tailbone', 'themes.expose_picker', 'true') +def global_help_url(config): + return config.get('tailbone', 'global_help_url') + + def legacy_mobile_enabled(config): return config.getbool('tailbone', 'legacy_mobile.enabled', default=True) + def protected_usernames(config): return config.getlist('tailbone', 'protected_usernames') + diff --git a/tailbone/views/common.py b/tailbone/views/common.py index 1ceb9e3f..39e938b6 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -44,6 +44,7 @@ from tailbone.forms.common import Feedback from tailbone.db import Session from tailbone.views import View from tailbone.util import set_app_theme +from tailbone.config import global_help_url class CommonView(View): @@ -69,6 +70,7 @@ class CommonView(View): context = { 'image_url': image_url, 'use_buefy': self.get_use_buefy(), + 'help_url': global_help_url(self.rattail_config), } if self.expose_quickie_search: diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 827e5500..4210f62e 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -58,6 +58,7 @@ from webhelpers2.html import HTML, tags from tailbone import forms, grids, diffs from tailbone.views import View +from tailbone.config import global_help_url log = logging.getLogger(__name__) @@ -2318,7 +2319,10 @@ class MasterView(View): so if you like you can return a different help URL depending on which type of CRUD view is in effect, etc. """ - return self.help_url + if self.help_url: + return self.help_url + + return global_help_url(self.rattail_config) def render_to_response(self, template, data, mobile=False): """ From 374f20ff1a9fbb292e9b708610f3ba57bb8f8b0d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 20 Aug 2020 17:51:21 -0500 Subject: [PATCH 0179/1681] Remove `<section>` tag around "no results" for minimal b-table --- tailbone/templates/grids/b-table.mako | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/tailbone/templates/grids/b-table.mako b/tailbone/templates/grids/b-table.mako index 8608b456..728d285d 100644 --- a/tailbone/templates/grids/b-table.mako +++ b/tailbone/templates/grids/b-table.mako @@ -61,18 +61,16 @@ </template> <template slot="empty"> - <section class="section"> - <div class="content has-text-grey has-text-centered"> - <p> - <b-icon - pack="fas" - icon="fas fa-sad-tear" - size="is-large"> - </b-icon> - </p> - <p>Nothing here.</p> - </div> - </section> + <div class="content has-text-grey has-text-centered"> + <p> + <b-icon + pack="fas" + icon="fas fa-sad-tear" + size="is-large"> + </b-icon> + </p> + <p>Nothing here.</p> + </div> </template> % if show_footer is not Undefined and show_footer: From 7a01cb8873ed6bc9064a5dafb2449454dcc464fd Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 20 Aug 2020 17:51:59 -0500 Subject: [PATCH 0180/1681] Allow for unknown/missing "changed by" user for product price history --- tailbone/views/products.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 6585fb81..8b2b8575 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -1004,8 +1004,8 @@ class ProductsView(MasterView): history['changed'] = six.text_type(changed) history['changed_display_html'] = raw_datetime(self.rattail_config, changed) user = history.pop('changed_by') - history['changed_by_uuid'] = user.uuid - history['changed_by_display'] = six.text_type(user) + history['changed_by_uuid'] = user.uuid if user else None + history['changed_by_display'] = six.text_type(user or "??") jsdata.append(history) return jsdata From 58362ae858cc51d613c66aea588f55ba523abf5a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 20 Aug 2020 17:56:19 -0500 Subject: [PATCH 0181/1681] Add buefy theme support for ordering worksheet --- .../static/js/tailbone.buefy.numericinput.js | 10 + tailbone/templates/batch/view.mako | 11 +- tailbone/templates/batch/worksheet.mako | 10 +- tailbone/templates/ordering/worksheet.mako | 264 +++++++++++++----- tailbone/views/batch/core.py | 3 +- tailbone/views/purchasing/batch.py | 5 +- tailbone/views/purchasing/ordering.py | 63 ++++- 7 files changed, 282 insertions(+), 84 deletions(-) diff --git a/tailbone/static/js/tailbone.buefy.numericinput.js b/tailbone/static/js/tailbone.buefy.numericinput.js index 706c79a8..47a5e610 100644 --- a/tailbone/static/js/tailbone.buefy.numericinput.js +++ b/tailbone/static/js/tailbone.buefy.numericinput.js @@ -4,6 +4,8 @@ const NumericInput = { '<b-input', ':name="name"', ':value="value"', + '@focus="focus"', + '@blur="blur"', '@keydown.native="keyDown"', '@input="valueChanged"', '>', @@ -18,6 +20,14 @@ const NumericInput = { methods: { + focus(event) { + this.$emit('focus', event) + }, + + blur(event) { + this.$emit('blur', event) + }, + keyDown(event) { // by default we only allow numeric keys, and general navigation // keys, but we might also allow Enter key diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako index 1f1fd4f6..d1855eb2 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -55,8 +55,15 @@ </%def> <%def name="leading_buttons()"> - % if master.has_worksheet and master.allow_worksheet(batch) and request.has_perm('{}.worksheet'.format(permission_prefix)): - <button type="button" class="load-worksheet">Edit as Worksheet</button> + % if master.has_worksheet and master.allow_worksheet(batch) and master.has_perm('worksheet'): + % if use_buefy: + <once-button tag="a" + href="${url('{}.worksheet'.format(route_prefix), uuid=batch.uuid)}" + text="Edit as Worksheet"> + </once-button> + % else: + <button type="button" class="load-worksheet">Edit as Worksheet</button> + % endif % endif </%def> diff --git a/tailbone/templates/batch/worksheet.mako b/tailbone/templates/batch/worksheet.mako index 4f91ea3d..cf19a0e0 100644 --- a/tailbone/templates/batch/worksheet.mako +++ b/tailbone/templates/batch/worksheet.mako @@ -1,8 +1,9 @@ ## -*- coding: utf-8; -*- -<%inherit file="/base.mako" /> +<%inherit file="/page.mako" /> <%def name="extra_javascript()"> ${parent.extra_javascript()} + % if not use_buefy: <script type="text/javascript"> $(function() { @@ -17,6 +18,7 @@ }); </script> + % endif </%def> <%def name="extra_styles()"> @@ -41,5 +43,9 @@ <%def name="worksheet_grid()"></%def> +<%def name="page_content()"> + ${self.worksheet_grid()} +</%def> -${self.worksheet_grid()} + +${parent.body()} diff --git a/tailbone/templates/ordering/worksheet.mako b/tailbone/templates/ordering/worksheet.mako index 7e5dbe79..97b1b51b 100644 --- a/tailbone/templates/ordering/worksheet.mako +++ b/tailbone/templates/ordering/worksheet.mako @@ -1,10 +1,11 @@ ## -*- coding: utf-8; -*- -<%inherit file="/base.mako" /> +<%inherit file="/batch/worksheet.mako" /> <%def name="title()">Ordering Worksheet</%def> <%def name="extra_javascript()"> ${parent.extra_javascript()} + % if not use_buefy: ${h.javascript_link(request.static_url('tailbone:static/js/numeric.js'))} <script type="text/javascript"> @@ -56,6 +57,7 @@ }); </script> + % endif </%def> <%def name="extra_styles()"> @@ -112,71 +114,10 @@ </style> </%def> - <%def name="context_menu_items()"> <li>${h.link_to("Back to {}".format(model_title), url('ordering.view', uuid=batch.uuid))}</li> </%def> - -############################## -## page body -############################## - -<ul id="context-menu"> - ${self.context_menu_items()} -</ul> - -<div class="form-wrapper"> - - <div class="field-wrapper"> - <label>Vendor</label> - <div class="field">${h.link_to(vendor, url('vendors.view', uuid=vendor.uuid))}</div> - </div> - - <div class="field-wrapper"> - <label>Vendor Email</label> - <div class="field">${vendor.email or ''}</div> - </div> - - <div class="field-wrapper"> - <label>Vendor Fax</label> - <div class="field">${vendor.fax_number or ''}</div> - </div> - - <div class="field-wrapper"> - <label>Vendor Contact</label> - <div class="field">${vendor.contact or ''}</div> - </div> - - <div class="field-wrapper"> - <label>Vendor Phone</label> - <div class="field">${vendor.phone or ''}</div> - </div> - - ${self.extra_vendor_fields()} - - <div class="field-wrapper po-total"> - <label>PO Total</label> - ## TODO: should not fall back to po_total - <div class="field">$${'{:0,.2f}'.format(batch.po_total_calculated or batch.po_total or 0)}</div> - </div> - -</div><!-- form-wrapper --> - -${self.order_form_grid()} - -${h.form(url('ordering.worksheet_update', uuid=batch.uuid), id='item-update-form', style='display: none;')} -${h.csrf_token(request)} -${h.hidden('product_uuid')} -${h.hidden('cases_ordered')} -${h.hidden('units_ordered')} -${h.end_form()} - - -############################## -## methods -############################## - <%def name="extra_vendor_fields()"></%def> <%def name="extra_count()">0</%def> @@ -189,7 +130,7 @@ ${h.end_form()} <div class="grid"> <table class="order-form"> <% column_count = 8 + len(header_columns) + (0 if ignore_cases else 1) + int(capture(self.extra_count)) %> - % for department in sorted(departments.values(), key=lambda d: d.name if d else ''): + % for department in sorted(six.itervalues(departments), key=lambda d: d.name if d else ''): <thead> <tr> <th class="department" colspan="${column_count}">Department @@ -200,7 +141,7 @@ ${h.end_form()} % endif </th> </tr> - % for subdepartment in sorted(department._order_subdepartments.values(), key=lambda s: s.name if s else ''): + % for subdepartment in sorted(six.itervalues(department._order_subdepartments), key=lambda s: s.name if s else ''): <tr> <th class="subdepartment" colspan="${column_count}">Subdepartment % if subdepartment.number or subdepartment.name: @@ -245,7 +186,11 @@ ${h.end_form()} </thead> <tbody> % for i, cost in enumerate(subdepartment._order_costs, 1): - <tr data-uuid="${cost.product_uuid}" class="${'even' if i % 2 == 0 else 'odd'}"> + <tr data-uuid="${cost.product_uuid}" class="${'even' if i % 2 == 0 else 'odd'}" + % if use_buefy: + :class="{active: activeUUID == '${cost.uuid}'}" + % endif + > ${self.order_form_row(cost)} % for data in history: <td class="scratch_pad"> @@ -271,14 +216,34 @@ ${h.end_form()} % endfor % if not ignore_cases: <td class="current-order"> - ${h.text('cases_ordered_{}'.format(cost.uuid), value=int(cost._batchrow.cases_ordered or 0) if cost._batchrow else None)} + % if use_buefy: + <numeric-input v-model="worksheet.cost_${cost.uuid}_cases" + @focus="activeUUID = '${cost.uuid}'; $event.target.select()" + @blur="activeUUID = null" + @keydown.native="inputKeydown($event, '${cost.uuid}', '${cost.product_uuid}')"> + </numeric-input> + % else: + ${h.text('cases_ordered_{}'.format(cost.uuid), value=int(cost._batchrow.cases_ordered or 0) if cost._batchrow else None)} + % endif </td> % endif <td class="current-order"> - ${h.text('units_ordered_{}'.format(cost.uuid), value=int(cost._batchrow.units_ordered or 0) if cost._batchrow else None)} + % if use_buefy: + <numeric-input v-model="worksheet.cost_${cost.uuid}_units" + @focus="activeUUID = '${cost.uuid}'; $event.target.select()" + @blur="activeUUID = null" + @keydown.native="inputKeydown($event, '${cost.uuid}', '${cost.product_uuid}')"> + </numeric-input> + % else: + ${h.text('units_ordered_{}'.format(cost.uuid), value=int(cost._batchrow.units_ordered or 0) if cost._batchrow else None)} + % endif </td> ## TODO: should not fall back to po_total - <td class="po-total">${'${:0,.2f}'.format(cost._batchrow.po_total_calculated or cost._batchrow.po_total or 0) if cost._batchrow else ''}</td> + % if use_buefy: + <td class="po-total">{{ worksheet.cost_${cost.uuid}_total_display }}</td> + % else: + <td class="po-total">${'${:0,.2f}'.format(cost._batchrow.po_total_calculated or cost._batchrow.po_total or 0) if cost._batchrow else ''}</td> + % endif ${self.extra_td(cost)} </tr> % endfor @@ -302,3 +267,166 @@ ${h.end_form()} % endif </td> </%def> + +<%def name="page_content()"> + % if use_buefy: + <ordering-worksheet></ordering-worksheet> + % else: + <div class="form-wrapper"> + + <div class="field-wrapper"> + <label>Vendor</label> + <div class="field">${h.link_to(vendor, url('vendors.view', uuid=vendor.uuid))}</div> + </div> + + <div class="field-wrapper"> + <label>Vendor Email</label> + <div class="field">${vendor.email or ''}</div> + </div> + + <div class="field-wrapper"> + <label>Vendor Fax</label> + <div class="field">${vendor.fax_number or ''}</div> + </div> + + <div class="field-wrapper"> + <label>Vendor Contact</label> + <div class="field">${vendor.contact or ''}</div> + </div> + + <div class="field-wrapper"> + <label>Vendor Phone</label> + <div class="field">${vendor.phone or ''}</div> + </div> + + ${self.extra_vendor_fields()} + + <div class="field-wrapper po-total"> + <label>PO Total</label> + ## TODO: should not fall back to po_total + <div class="field">$${'{:0,.2f}'.format(batch.po_total_calculated or batch.po_total or 0)}</div> + </div> + + </div><!-- form-wrapper --> + + ${self.order_form_grid()} + + ${h.form(url('ordering.worksheet_update', uuid=batch.uuid), id='item-update-form', style='display: none;')} + ${h.csrf_token(request)} + ${h.hidden('product_uuid')} + ${h.hidden('cases_ordered')} + ${h.hidden('units_ordered')} + ${h.end_form()} + % endif +</%def> + +<%def name="render_this_page_template()"> + ${parent.render_this_page_template()} + + <script type="text/x-template" id="ordering-worksheet-template"> + <div> + <div class="form-wrapper"> + <div class="form"> + + <b-field horizontal label="Vendor"> + ${h.link_to(vendor, url('vendors.view', uuid=vendor.uuid))} + </b-field> + + <b-field horizontal label="Vendor Email"> + <span>${vendor.email or ''}</span> + </b-field> + + <b-field horizontal label="Vendor Fax"> + <span>${vendor.fax_number or ''}</span> + </b-field> + + <b-field horizontal label="Vendor Contact"> + <span>${vendor.contact or ''}</span> + </b-field> + + <b-field horizontal label="Vendor Phone"> + <span>${vendor.phone or ''}</span> + </b-field> + + ${self.extra_vendor_fields()} + + <b-field horizontal label="PO Total"> + <span>{{ poTotalDisplay }}</span> + </b-field> + + </div> <!-- form --> + </div><!-- form-wrapper --> + + ${self.order_form_grid()} + </div> + </script> +</%def> + +<%def name="make_this_page_component()"> + ${parent.make_this_page_component()} + <script type="text/javascript"> + + const OrderingWorksheet = { + template: '#ordering-worksheet-template', + data() { + return { + worksheet: ${json.dumps(worksheet_data)|n}, + activeUUID: null, + poTotalDisplay: "$${'{:0,.2f}'.format(batch.po_total_calculated or batch.po_total or 0)}", + submitting: false, + + ## TODO: should find a better way to handle CSRF token + csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}, + } + }, + methods: { + + inputKeydown(event, cost_uuid, product_uuid) { + if (event.which == 13) { + if (!this.submitting) { + this.submitting = true + + let url = '${url('ordering.worksheet_update', uuid=batch.uuid)}' + + let params = { + product_uuid: product_uuid, + % if not ignore_cases: + cases_ordered: this.worksheet['cost_' + cost_uuid + '_cases'], + % endif + units_ordered: this.worksheet['cost_' + cost_uuid + '_units'], + } + + let headers = { + ## TODO: should find a better way to handle CSRF token + 'X-CSRF-TOKEN': this.csrftoken, + } + + ## TODO: should find a better way to handle CSRF token + this.$http.post(url, params, {headers: headers}).then(response => { + if (response.data.error) { + alert(response.data.error) + } else { + this.worksheet['cost_' + cost_uuid + '_cases'] = response.data.row_cases_ordered + this.worksheet['cost_' + cost_uuid + '_units'] = response.data.row_units_ordered + this.worksheet['cost_' + cost_uuid + '_total_display'] = response.data.row_po_total_display + this.poTotalDisplay = response.data.batch_po_total_display + } + this.submitting = false + }) + } + } + }, + }, + } + + Vue.component('ordering-worksheet', OrderingWorksheet) + + </script> +</%def> + + +############################## +## page body +############################## + +${parent.body()} diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 66f0c382..b5d0915b 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -1514,7 +1514,8 @@ class BatchMasterView(MasterView): config.add_route('{}.worksheet'.format(route_prefix), '{}/{{{}}}/worksheet'.format(url_prefix, model_key)) config.add_view(cls, attr='worksheet', route_name='{}.worksheet'.format(route_prefix), permission='{}.worksheet'.format(permission_prefix)) - config.add_route('{}.worksheet_update'.format(route_prefix), '{}/{{{}}}/worksheet/update'.format(url_prefix, model_key)) + config.add_route('{}.worksheet_update'.format(route_prefix), '{}/{{{}}}/worksheet/update'.format(url_prefix, model_key), + request_method='POST') config.add_view(cls, attr='worksheet_update', route_name='{}.worksheet_update'.format(route_prefix), renderer='json', permission='{}.worksheet'.format(permission_prefix)) diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index 97a002d3..c777b5ef 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -328,8 +328,9 @@ class PurchasingBatchView(BatchMasterView): buyer_display = six.text_type(buyer) elif self.creating: buyer = self.request.user.employee - buyer_display = six.text_type(buyer) - f.set_default('buyer_uuid', buyer.uuid) + if buyer: + buyer_display = six.text_type(buyer) + f.set_default('buyer_uuid', buyer.uuid) elif self.editing: buyer_display = six.text_type(batch.buyer or '') buyers_url = self.request.route_url('employees.autocomplete') diff --git a/tailbone/views/purchasing/ordering.py b/tailbone/views/purchasing/ordering.py index 77e631f5..ef37b3b3 100644 --- a/tailbone/views/purchasing/ordering.py +++ b/tailbone/views/purchasing/ordering.py @@ -27,6 +27,7 @@ Views for 'ordering' (purchasing) batches from __future__ import unicode_literals, absolute_import import os +import json import six import openpyxl @@ -245,6 +246,11 @@ class OrderingBatchView(PurchasingBatchView): order_date = batch.date_ordered if not order_date: order_date = localtime(self.rattail_config).date() + + buefy_data = None + if self.get_use_buefy(): + buefy_data = self.get_worksheet_buefy_data(departments) + return self.render_to_response('worksheet', { 'batch': batch, 'order_date': order_date, @@ -257,8 +263,32 @@ class OrderingBatchView(PurchasingBatchView): 'get_upc': lambda p: p.upc.pretty() if p.upc else '', 'header_columns': self.order_form_header_columns, 'ignore_cases': not self.handler.allow_cases(), + 'worksheet_data': buefy_data, }) + def get_worksheet_buefy_data(self, departments): + data = {} + for department in six.itervalues(departments): + for subdepartment in six.itervalues(department._order_subdepartments): + for i, cost in enumerate(subdepartment._order_costs, 1): + cases = int(cost._batchrow.cases_ordered or 0) if cost._batchrow else None + units = int(cost._batchrow.units_ordered or 0) if cost._batchrow else None + key = 'cost_{}'.format(cost.uuid) + data['{}_cases'.format(key)] = cases + data['{}_units'.format(key)] = units + + total = 0 + row = cost._batchrow + if row: + total = row.po_total_calculated or row.po_total or 0 + if not (total or cases or units): + display = '' + else: + display = '${:0,.2f}'.format(total) + data['{}_total_display'.format(key)] = display + + return data + def worksheet_update(self): """ Handles AJAX requests to update the order quantities for some row @@ -282,21 +312,34 @@ class OrderingBatchView(PurchasingBatchView): """ batch = self.get_instance() - cases_ordered = self.request.POST.get('cases_ordered', '0') - if not cases_ordered or not cases_ordered.isdigit(): - return {'error': "Invalid value for cases ordered: {}".format(cases_ordered)} - cases_ordered = int(cases_ordered) + try: + data = self.request.json_body + except json.JSONDecodeError: + data = self.request.POST + + cases_ordered = data.get('cases_ordered') + if cases_ordered is None: + cases_ordered = 0 + elif not isinstance(cases_ordered, int): + if cases_ordered == '': + cases_ordered = 0 + else: + cases_ordered = int(cases_ordered) if cases_ordered >= 100000: # TODO: really this depends on underlying column return {'error': "Invalid value for cases ordered: {}".format(cases_ordered)} - units_ordered = self.request.POST.get('units_ordered', '0') - if not units_ordered or not units_ordered.isdigit(): - return {'error': "Invalid value for units ordered: {}".format(units_ordered)} - units_ordered = int(units_ordered) + units_ordered = data.get('units_ordered') + if units_ordered is None: + units_ordered = 0 + elif not isinstance(units_ordered, int): + if units_ordered == '': + units_ordered = 0 + else: + units_ordered = int(units_ordered) if units_ordered >= 100000: # TODO: really this depends on underlying column return {'error': "Invalid value for units ordered: {}".format(units_ordered)} - uuid = self.request.POST.get('product_uuid') + uuid = data.get('product_uuid') product = self.Session.query(model.Product).get(uuid) if uuid else None if not product: return {'error': "Product not found"} @@ -336,8 +379,10 @@ class OrderingBatchView(PurchasingBatchView): 'row_units_ordered': int(row.units_ordered or 0) if row else None, 'row_po_total': '${:0,.2f}'.format(row.po_total or 0) if row else None, 'row_po_total_calculated': '${:0,.2f}'.format(row.po_total_calculated or 0) if row else None, + 'row_po_total_display': '${:0,.2f}'.format(row.po_total_calculated or row.po_total or 0) if row else None, 'batch_po_total': '${:0,.2f}'.format(batch.po_total or 0), 'batch_po_total_calculated': '${:0,.2f}'.format(batch.po_total_calculated or 0), + 'batch_po_total_display': '${:0,.2f}'.format(batch.po_total_calculated or batch.po_total or 0), } def render_mobile_listitem(self, batch, i): From 7f8271e215ff1f323d1bcf538ae6e07fa5ee420b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 21 Aug 2020 12:28:01 -0500 Subject: [PATCH 0182/1681] Don't require department by default, for new purchasing batch --- tailbone/views/purchasing/batch.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index c777b5ef..4f7a14e6 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -309,7 +309,9 @@ class PurchasingBatchView(BatchMasterView): f.set_node('department_uuid', colander.String()) dept_options = self.get_department_options() dept_values = [(v, k) for k, v in dept_options] + dept_values.insert(0, ('', "(unspecified)")) f.set_widget('department_uuid', dfwidget.SelectWidget(values=dept_values)) + f.set_required('department_uuid', False) f.set_label('department_uuid', "Department") else: f.set_readonly('department') From 32b98ae818a18fb624d44b862fa490e57a343f94 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 21 Aug 2020 13:18:08 -0500 Subject: [PATCH 0183/1681] 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 b83ae927..29e1128b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,22 @@ CHANGELOG ========= +0.8.105 (2020-08-21) +-------------------- + +* Tweaks for export views, to make more generic. + +* Add config for "global" help URL. + +* Remove ``<section>`` tag around "no results" for minimal b-table. + +* Allow for unknown/missing "changed by" user for product price history. + +* Add buefy theme support for ordering worksheet. + +* Don't require department by default, for new purchasing batch. + + 0.8.104 (2020-08-17) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 0c3caf56..30e62aa6 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.104' +__version__ = '0.8.105' From 7d8c57170f6b90562372f78bc5200d73c584c857 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 21 Aug 2020 17:42:01 -0500 Subject: [PATCH 0184/1681] Add progress for generating "results as XLSX" file to download --- tailbone/templates/master/index.mako | 17 ++++ tailbone/views/master.py | 115 ++++++++++++++++++++++----- tailbone/views/progress.py | 19 ++++- 3 files changed, 126 insertions(+), 25 deletions(-) diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako index 8826c096..bf90de2e 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -295,6 +295,13 @@ ${parent.modify_this_page_vars()} <script type="text/javascript"> + ## TODO: stop checking for buefy here once we only have the one session.pop() + % if use_buefy and request.session.pop('{}.results_xlsx.generated'.format(route_prefix), False): + ThisPage.mounted = function() { + location.href = '${url('{}.results_xlsx_download'.format(route_prefix))}'; + } + % endif + ## delete single object % if master.deletable and master.has_perm('delete') and master.delete_confirm == 'simple': ThisPage.methods.deleteObject = function(url) { @@ -453,4 +460,14 @@ ${h.csrf_token(request)} ${h.end_form()} % endif + + ## TODO: can stop checking for buefy above once this legacy chunk is gone + % if request.session.pop('{}.results_xlsx.generated'.format(route_prefix), False): + <script type="text/javascript"> + $(function() { + location.href = '${url('{}.results_xlsx_download'.format(route_prefix))}'; + }); + </script> + % endif + % endif diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 4210f62e..2bf62498 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2704,32 +2704,100 @@ class MasterView(View): Download current list results as XLSX. """ results = self.get_effective_data() - fields = self.get_xlsx_fields() - path = temp_path(suffix='.xlsx') - writer = ExcelWriter(path, fields, sheet_title=self.get_model_title_plural()) - writer.write_header() - rows = [] - for obj in results: - data = self.get_xlsx_row(obj, fields) - row = [data[field] for field in fields] - rows.append(row) + # start thread to actually do work / generate progress data + route_prefix = self.get_route_prefix() + key = '{}.results_xlsx'.format(route_prefix) + progress = self.make_progress(key) + thread = Thread(target=self.results_xlsx_thread, + args=(results, self.request.user.uuid, progress)) + thread.start() - writer.write_rows(rows) - writer.auto_freeze() - writer.auto_filter() - writer.auto_resize() - writer.save() + # send user to progress page + return self.render_progress(progress, { + 'cancel_url': self.get_index_url(), + 'cancel_msg': "XLSX download was canceled.", + }) - response = self.request.response - with open(path, 'rb') as f: - response.body = f.read() - os.remove(path) + def results_xlsx_session(self): + return RattailSession() - response.content_length = len(response.body) - response.content_type = str('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') - response.content_disposition = str('attachment; filename={}.xlsx').format(self.get_grid_key()) - return response + def results_xlsx_thread(self, results, user_uuid, progress): + """ + Thread target, responsible for actually generating the Excel file which + is to be presented for download. + """ + route_prefix = self.get_route_prefix() + session = self.results_xlsx_session() + try: + + # create folder(s) for output; make sure file doesn't exist + path = os.path.join(self.rattail_config.datadir(), 'downloads', + 'results-xlsx', route_prefix, + user_uuid[:2], user_uuid[2:]) + if not os.path.exists(path): + os.makedirs(path) + path = os.path.join(path, '{}.xlsx'.format(route_prefix)) + if os.path.exists(path): + os.remove(path) + + results = results.with_session(session).all() + fields = self.get_xlsx_fields() + writer = ExcelWriter(path, fields, sheet_title=self.get_model_title_plural()) + writer.write_header() + + rows = [] + def write(obj, i): + data = self.get_xlsx_row(obj, fields) + row = [data[field] for field in fields] + rows.append(row) + + self.progress_loop(write, results, progress, + message="Collecting data for Excel") + + def finalize(x, i): + writer.write_rows(rows) + writer.auto_freeze() + writer.auto_filter() + writer.auto_resize() + writer.save() + + self.progress_loop(finalize, [1], progress, + message="Writing Excel file to disk") + + except Exception as error: + msg = "generating XLSX file for download failed!" + log.warning(msg, exc_info=True) + session.rollback() + session.close() + if progress: + progress.session.load() + progress.session['error'] = True + progress.session['error_msg'] = "{}: {}".format( + msg, simple_error(error)) + progress.session.save() + return + + session.commit() + session.close() + + if progress: + progress.session.load() + progress.session['complete'] = True + progress.session['success_url'] = self.get_index_url() + progress.session['extra_session_bits'] = { + '{}.results_xlsx.generated'.format(route_prefix): True, + } + progress.session.save() + + def results_xlsx_download(self): + route_prefix = self.get_route_prefix() + user_uuid = self.request.user.uuid + path = os.path.join(self.rattail_config.datadir(), 'downloads', + 'results-xlsx', route_prefix, + user_uuid[:2], user_uuid[2:], + '{}.xlsx'.format(route_prefix)) + return self.file_response(path) def get_xlsx_fields(self): """ @@ -3679,6 +3747,9 @@ class MasterView(View): config.add_route('{}.results_xlsx'.format(route_prefix), '{}/xlsx'.format(url_prefix)) config.add_view(cls, attr='results_xlsx', route_name='{}.results_xlsx'.format(route_prefix), permission='{}.results_xlsx'.format(permission_prefix)) + config.add_route('{}.results_xlsx_download'.format(route_prefix), '{}/xlsx/download'.format(url_prefix)) + config.add_view(cls, attr='results_xlsx_download', route_name='{}.results_xlsx_download'.format(route_prefix), + permission='{}.results_xlsx'.format(permission_prefix)) # quickie (search) if cls.supports_quickie_search: diff --git a/tailbone/views/progress.py b/tailbone/views/progress.py index e3cbef01..5d06f158 100644 --- a/tailbone/views/progress.py +++ b/tailbone/views/progress.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -26,18 +26,31 @@ Progress Views from __future__ import unicode_literals, absolute_import +import six + from tailbone.progress import get_progress_session def progress(request): key = request.matchdict['key'] - session = get_progress_session(request, key, type=request.GET.get('sessiontype')) + session = get_progress_session(request, key, + type=request.GET.get('sessiontype')) + if session.get('complete'): + msg = session.get('success_msg') if msg: request.session.flash(msg) + + bits = session.get('extra_session_bits') + if bits: + for key, value in six.iteritems(bits): + request.session[key] = value + elif session.get('error'): - request.session.flash(session.get('error_msg', "An unspecified error occurred."), 'error') + msg = session.get('error_msg', "An unspecified error occurred.") + request.session.flash(msg, 'error') + return session From 1b7612ffb00d5f24b4813fc47a88facec28a03c8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 21 Aug 2020 18:28:36 -0500 Subject: [PATCH 0185/1681] Add progress for generating "results as CSV" file to download --- tailbone/templates/master/index.mako | 12 +++ tailbone/views/master.py | 108 ++++++++++++++++++++++----- 2 files changed, 101 insertions(+), 19 deletions(-) diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako index bf90de2e..2c27d5a9 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -296,6 +296,11 @@ <script type="text/javascript"> ## TODO: stop checking for buefy here once we only have the one session.pop() + % if use_buefy and request.session.pop('{}.results_csv.generated'.format(route_prefix), False): + ThisPage.mounted = function() { + location.href = '${url('{}.results_csv_download'.format(route_prefix))}'; + } + % endif % if use_buefy and request.session.pop('{}.results_xlsx.generated'.format(route_prefix), False): ThisPage.mounted = function() { location.href = '${url('{}.results_xlsx_download'.format(route_prefix))}'; @@ -462,6 +467,13 @@ % endif ## TODO: can stop checking for buefy above once this legacy chunk is gone + % if request.session.pop('{}.results_csv.generated'.format(route_prefix), False): + <script type="text/javascript"> + $(function() { + location.href = '${url('{}.results_csv_download'.format(route_prefix))}'; + }); + </script> + % endif % if request.session.pop('{}.results_xlsx.generated'.format(route_prefix), False): <script type="text/javascript"> $(function() { diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 2bf62498..6ebeecac 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2677,27 +2677,94 @@ class MasterView(View): def results_csv(self): """ - Download current list results as CSV + Download current list results as CSV. """ results = self.get_effective_data() - fields = self.get_csv_fields() - data = six.StringIO() - writer = UnicodeDictWriter(data, fields) - writer.writeheader() - for obj in results: - writer.writerow(self.get_csv_row(obj, fields)) - response = self.request.response - if six.PY3: - response.text = data.getvalue() - response.content_type = 'text/csv' - response.content_disposition = 'attachment; filename={}.csv'.format(self.get_grid_key()) - else: - response.body = data.getvalue() - response.content_type = b'text/csv' - response.content_disposition = b'attachment; filename={}.csv'.format(self.get_grid_key()) - data.close() - response.content_length = len(response.body) - return response + + # start thread to actually do work / generate progress data + route_prefix = self.get_route_prefix() + key = '{}.results_csv'.format(route_prefix) + progress = self.make_progress(key) + thread = Thread(target=self.results_csv_thread, + args=(results, self.request.user.uuid, progress)) + thread.start() + + # send user to progress page + return self.render_progress(progress, { + 'cancel_url': self.get_index_url(), + 'cancel_msg': "CSV download was canceled.", + }) + + def results_csv_session(self): + return RattailSession() + + def results_csv_thread(self, results, user_uuid, progress): + """ + Thread target, responsible for actually generating the CSV file which + is to be presented for download. + """ + route_prefix = self.get_route_prefix() + session = self.results_csv_session() + try: + + # create folder(s) for output; make sure file doesn't exist + path = os.path.join(self.rattail_config.datadir(), 'downloads', + 'results-csv', route_prefix, + user_uuid[:2], user_uuid[2:]) + if not os.path.exists(path): + os.makedirs(path) + path = os.path.join(path, '{}.csv'.format(route_prefix)) + if os.path.exists(path): + os.remove(path) + + results = results.with_session(session).all() + fields = self.get_csv_fields() + + csv_file = open(path, 'wt') + writer = UnicodeDictWriter(csv_file, fields) + writer.writeheader() + + def write(obj, i): + writer.writerow(self.get_csv_row(obj, fields)) + + self.progress_loop(write, results, progress, + message="Collecting data for CSV") + csv_file.close() + session.commit() + + except Exception as error: + msg = "generating CSV file for download failed!" + log.warning(msg, exc_info=True) + session.rollback() + session.close() + if progress: + progress.session.load() + progress.session['error'] = True + progress.session['error_msg'] = "{}: {}".format( + msg, simple_error(error)) + progress.session.save() + return + + finally: + session.close() + + if progress: + progress.session.load() + progress.session['complete'] = True + progress.session['success_url'] = self.get_index_url() + progress.session['extra_session_bits'] = { + '{}.results_csv.generated'.format(route_prefix): True, + } + progress.session.save() + + def results_csv_download(self): + route_prefix = self.get_route_prefix() + user_uuid = self.request.user.uuid + path = os.path.join(self.rattail_config.datadir(), 'downloads', + 'results-csv', route_prefix, + user_uuid[:2], user_uuid[2:], + '{}.csv'.format(route_prefix)) + return self.file_response(path) def results_xlsx(self): """ @@ -3740,6 +3807,9 @@ class MasterView(View): config.add_route('{}.results_csv'.format(route_prefix), '{}/csv'.format(url_prefix)) config.add_view(cls, attr='results_csv', route_name='{}.results_csv'.format(route_prefix), permission='{}.results_csv'.format(permission_prefix)) + config.add_route('{}.results_csv_download'.format(route_prefix), '{}/csv/download'.format(url_prefix)) + config.add_view(cls, attr='results_csv_download', route_name='{}.results_csv_download'.format(route_prefix), + permission='{}.results_csv'.format(permission_prefix)) if cls.results_downloadable_xlsx: config.add_tailbone_permission(permission_prefix, '{}.results_xlsx'.format(permission_prefix), From 43472c7eb6eedce261c2060f8d1040d87a5e0730 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 21 Aug 2020 18:35:27 -0500 Subject: [PATCH 0186/1681] Use utf8 encoding when downloading results as CSV --- tailbone/views/master.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 6ebeecac..aea4be67 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -27,6 +27,7 @@ Model Master View from __future__ import unicode_literals, absolute_import import os +import csv import datetime import tempfile import logging @@ -2720,8 +2721,13 @@ class MasterView(View): results = results.with_session(session).all() fields = self.get_csv_fields() - csv_file = open(path, 'wt') - writer = UnicodeDictWriter(csv_file, fields) + if six.PY2: + csv_file = open(path, 'wb') + writer = UnicodeDictWriter(csv_file, fields, encoding='utf_8') + else: # PY3 + csv_file = open(path, 'wt', encoding='utf_8') + writer = csv.DictWriter(csv_file, fields) + writer.writeheader() def write(obj, i): From 922cbe445113d46c03186003fbc21a30b6507f74 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 22 Aug 2020 12:25:02 -0500 Subject: [PATCH 0187/1681] Add new/flexible "download results" feature --- tailbone/helpers.py | 1 + tailbone/templates/master/index.mako | 247 +++++++++++++++++++++ tailbone/views/master.py | 308 ++++++++++++++++++++++++++- 3 files changed, 553 insertions(+), 3 deletions(-) diff --git a/tailbone/helpers.py b/tailbone/helpers.py index 46a30dec..14282c43 100644 --- a/tailbone/helpers.py +++ b/tailbone/helpers.py @@ -26,6 +26,7 @@ Template Context Helpers from __future__ import unicode_literals, absolute_import +import os import datetime from decimal import Decimal diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako index 2c27d5a9..6ff9c4fe 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -143,6 +143,155 @@ <%def name="grid_tools()"> + ## download search results + % if master.results_downloadable and master.has_perm('download_results'): + % if use_buefy: + <b-button type="is-primary" + icon-pack="fas" + icon-left="fas fa-download" + @click="showDownloadResultsDialog = true" + :disabled="!total"> + Download Results + </b-button> + + ${h.form(url('{}.download_results'.format(route_prefix)), ref='download_results_form')} + ${h.csrf_token(request)} + <input type="hidden" name="fmt" :value="downloadResultsFormat" /> + <input type="hidden" name="fields" :value="downloadResultsFieldsIncluded" /> + ${h.end_form()} + + <b-modal :active.sync="showDownloadResultsDialog"> + <div class="card"> + + <div class="card-content"> + <p> + There are + <span class="is-size-4 has-text-weight-bold"> + {{ total.toLocaleString('en') }} ${model_title_plural} + </span> + matching your current filters. + </p> + <p> + You may download this set as a single data file if you like. + </p> + <br /> + + <b-notification type="is-warning" :closable="false" + v-if="downloadResultsFormat == 'xlsx' && total >= 1000"> + Excel downloads for large data sets can take a long time to + generate, and bog down the server in the meantime. You are + encouraged to choose CSV for a large data set, even though + the end result (file size) may be larger with CSV. + </b-notification> + + <div style="display: flex;"> + + <div style="flex-grow: 1;"> + <b-field horizontal label="Format"> + <b-select v-model="downloadResultsFormat"> + % for key, label in six.iteritems(master.download_results_supported_formats()): + <option value="${key}">${label}</option> + % endfor + </b-select> + </b-field> + </div> + + <div> + <div v-show="downloadResultsFieldsMode != 'choose'" + class="has-text-right"> + <p v-if="downloadResultsFieldsMode == 'default'"> + Will use DEFAULT fields. + </p> + <p v-if="downloadResultsFieldsMode == 'all'"> + Will use ALL fields. + </p> + </div> + <br /> + <div class="buttons"> + <b-button type="is-primary" + v-show="downloadResultsFieldsMode != 'default'" + @click="downloadResultsUseDefaultFields()"> + Use Default Fields + </b-button> + <b-button type="is-primary" + v-show="downloadResultsFieldsMode != 'all'" + @click="downloadResultsUseAllFields()"> + Use All Fields + </b-button> + <b-button type="is-primary" + v-show="downloadResultsFieldsMode != 'choose'" + @click="downloadResultsFieldsMode = 'choose'"> + Choose Fields + </b-button> + </div> + </div> + + </div> + <br /> + + <div v-show="downloadResultsFieldsMode == 'choose'"> + <div style="display: flex;"> + <div> + <b-field label="Excluded Fields"> + <b-select multiple native-size="8" + expanded + ref="downloadResultsExcludedFields"> + <option v-for="field in downloadResultsFieldsAvailable" + v-if="!downloadResultsFieldsIncluded.includes(field)" + :key="field" + :value="field"> + {{ field }} + </option> + </b-select> + </b-field> + </div> + <div> + <br /><br /> + <b-button style="margin: 0.5rem;" + @click="downloadResultsExcludeFields()"> + < + </b-button> + <br /> + <b-button style="margin: 0.5rem;" + @click="downloadResultsIncludeFields()"> + > + </b-button> + </div> + <div> + <b-field label="Included Fields"> + <b-select multiple native-size="8" + expanded + ref="downloadResultsIncludedFields"> + <option v-for="field in downloadResultsFieldsIncluded" + :key="field" + :value="field"> + {{ field }} + </option> + </b-select> + </b-field> + </div> + </div> + </div> + + </div> <!-- card-content --> + + <footer class="modal-card-foot"> + <b-button @click="showDownloadResultsDialog = false"> + Cancel + </b-button> + <once-button type="is-primary" + @click="downloadResultsSubmit()" + icon-pack="fas" + icon-left="fas fa-download" + :disabled="!downloadResultsFieldsIncluded.length" + text="Download Results"> + </once-button> + </footer> + </div> + </b-modal> + % endif + % endif + ## merge 2 objects % if master.mergeable and request.has_perm('{}.merge'.format(permission_prefix)): @@ -256,6 +405,14 @@ </%def> <%def name="page_content()"> + + % if download_results_path: + <b-notification type="is-info"> + Your download should start automatically, or you can + ${h.link_to("click here", '{}?filename={}'.format(url('{}.download_results'.format(route_prefix)), h.os.path.basename(download_results_path)))} + </b-notification> + % endif + <${grid.component} :csrftoken="csrftoken" % if master.deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple': @deleteActionClicked="deleteObject" @@ -295,6 +452,19 @@ ${parent.modify_this_page_vars()} <script type="text/javascript"> + ## maybe auto-redirect to download latest results file + % if download_results_path and use_buefy: + ThisPage.methods.downloadResultsRedirect = function() { + location.href = '${url('{}.download_results'.format(route_prefix))}?filename=${h.os.path.basename(download_results_path)}'; + } + ThisPage.mounted = function() { + // we give this 1 second before attempting the redirect; otherwise + // the FontAwesome icons do not seem to load properly. so this way + // the page should fully render before redirecting + window.setTimeout(this.downloadResultsRedirect, 1000) + } + % endif + ## TODO: stop checking for buefy here once we only have the one session.pop() % if use_buefy and request.session.pop('{}.results_csv.generated'.format(route_prefix), False): ThisPage.mounted = function() { @@ -318,6 +488,83 @@ } % endif + ## download results + % if master.results_downloadable and master.has_perm('download_results'): + + ${grid.component_studly}Data.downloadResultsFormat = '${master.download_results_default_format()}' + ${grid.component_studly}Data.showDownloadResultsDialog = false + ${grid.component_studly}Data.downloadResultsFieldsMode = 'default' + ${grid.component_studly}Data.downloadResultsFieldsAvailable = ${json.dumps(download_results_fields_available)|n} + ${grid.component_studly}Data.downloadResultsFieldsDefault = ${json.dumps(download_results_fields_default)|n} + ${grid.component_studly}Data.downloadResultsFieldsIncluded = ${json.dumps(download_results_fields_default)|n} + + ${grid.component_studly}.computed.downloadResultsFieldsExcluded = function() { + let excluded = [] + this.downloadResultsFieldsAvailable.forEach(field => { + if (!this.downloadResultsFieldsIncluded.includes(field)) { + excluded.push(field) + } + }, this) + return excluded + } + + ${grid.component_studly}.methods.downloadResultsExcludeFields = function() { + let selected = this.$refs.downloadResultsIncludedFields.selected + if (!selected) { + return + } + selected = Array.from(selected) + selected.forEach(field => { + + // de-select the entry within "included" field input + let index = this.$refs.downloadResultsIncludedFields.selected.indexOf(field) + if (index > -1) { + this.$refs.downloadResultsIncludedFields.selected.splice(index, 1) + } + + // remove field from official "included" list + index = this.downloadResultsFieldsIncluded.indexOf(field) + if (index > -1) { + this.downloadResultsFieldsIncluded.splice(index, 1) + } + }, this) + } + + ${grid.component_studly}.methods.downloadResultsIncludeFields = function() { + let selected = this.$refs.downloadResultsExcludedFields.selected + if (!selected) { + return + } + selected = Array.from(selected) + selected.forEach(field => { + + // de-select the entry within "excluded" field input + let index = this.$refs.downloadResultsExcludedFields.selected.indexOf(field) + if (index > -1) { + this.$refs.downloadResultsExcludedFields.selected.splice(index, 1) + } + + // add field to official "included" list + this.downloadResultsFieldsIncluded.push(field) + + }, this) + } + + ${grid.component_studly}.methods.downloadResultsUseDefaultFields = function() { + this.downloadResultsFieldsIncluded = Array.from(this.downloadResultsFieldsDefault) + this.downloadResultsFieldsMode = 'default' + } + + ${grid.component_studly}.methods.downloadResultsUseAllFields = function() { + this.downloadResultsFieldsIncluded = Array.from(this.downloadResultsFieldsAvailable) + this.downloadResultsFieldsMode = 'all' + } + + ${grid.component_studly}.methods.downloadResultsSubmit = function() { + this.$refs.download_results_form.submit() + } + % endif + ## enable / disable selected objects % if master.supports_set_enabled_toggle and master.has_perm('enable_disable_set'): diff --git a/tailbone/views/master.py b/tailbone/views/master.py index aea4be67..c2720d6e 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -76,8 +76,12 @@ class MasterView(View): # set to True in order to encode search values as utf-8 use_byte_string_filters = False + # set to True if all timestamps are "local" instead of UTC + has_local_times = False + listable = True sortable = True + results_downloadable = False results_downloadable_csv = False results_downloadable_xlsx = False creatable = True @@ -191,6 +195,22 @@ class MasterView(View): from tailbone.db import Session return Session + def make_isolated_session(self): + """ + This method should return a newly-created SQLAlchemy Session instance. + The use case here is primarily for secondary threads, which may be + employed for long-running processes such as executing a batch. The + session returned should *not* have any web hooks to auto-commit with + the request/response cycle etc. It should just be a plain old session, + "isolated" from the rest of the web app in a sense. + + So whereas ``self.Session`` by default will return a reference to + ``tailbone.db.Session``, which is a "scoped" session wrapper specific + to the current thread (one per request), this method should instead + return e.g. a new independent ``rattail.db.Session`` instance. + """ + return RattailSession() + @classmethod def get_grid_factory(cls): """ @@ -327,6 +347,15 @@ class MasterView(View): context = { 'grid': grid, } + + if self.results_downloadable and self.has_perm('download_results'): + route_prefix = self.get_route_prefix() + context['download_results_path'] = self.request.session.pop( + '{}.results.generated'.format(route_prefix), None) + available = self.download_results_fields_available() + context['download_results_fields_available'] = available + context['download_results_fields_default'] = self.download_results_fields_default(available) + return self.render_to_response('index', context) def make_grid(self, factory=None, key=None, data=None, columns=None, **kwargs): @@ -1900,7 +1929,7 @@ class MasterView(View): message="Deleting objects") def get_bulk_delete_session(self): - return RattailSession() + return self.make_isolated_session() def bulk_delete_thread(self, objects, progress): """ @@ -2676,6 +2705,264 @@ class MasterView(View): """ return False + def download_results_path(self, user_uuid, filename=None, + typ='results', makedirs=False): + """ + Returns an absolute path for the "results" data file, specific to the + given user UUID. + """ + route_prefix = self.get_route_prefix() + path = os.path.join(self.rattail_config.datadir(), 'downloads', + typ, route_prefix, + user_uuid[:2], user_uuid[2:]) + if makedirs and not os.path.exists(path): + os.makedirs(path) + + if filename: + path = os.path.join(path, filename) + return path + + def download_results_filename(self, fmt): + """ + Must return an appropriate "download results" filename for the given + format. E.g. ``'products.csv'`` + """ + route_prefix = self.get_route_prefix() + if fmt == 'csv': + return '{}.csv'.format(route_prefix) + if fmt == 'xlsx': + return '{}.xlsx'.format(route_prefix) + + def download_results_supported_formats(self): + # TODO: default formats should be configurable? + return OrderedDict([ + ('xlsx', "Excel (XLSX)"), + ('csv', "CSV"), + ]) + + def download_results_default_format(self): + # TODO: default format should be configurable + return 'xlsx' + + def download_results(self): + """ + View for saving current (filtered) data results into a file, and + downloading that file. + """ + route_prefix = self.get_route_prefix() + user_uuid = self.request.user.uuid + + # POST means generate a new results file for download + if self.request.method == 'POST': + + # make sure a valid format was requested + supported = self.download_results_supported_formats() + if not supported: + self.request.session.flash("There are no supported download formats!", + 'error') + return self.redirect(self.get_index_url()) + fmt = self.request.POST.get('fmt') + if not fmt: + fmt = self.download_results_default_format() or list(supported)[0] + if fmt not in supported: + self.request.session.flash("Unsupported download format: {}".format(fmt), + 'error') + return self.redirect(self.get_index_url()) + + # parse field list if one was given + fields = self.request.POST.get('fields') + if fields: + fields = fields.split(',') + + # start thread to actually do work / report progress + key = '{}.download_results'.format(route_prefix) + progress = self.make_progress(key) + results = self.get_effective_data() + thread = Thread(target=self.download_results_thread, + args=(results, fmt, fields, user_uuid, progress)) + thread.start() + + # show user the progress page + return self.render_progress(progress, { + 'cancel_url': self.get_index_url(), + 'cancel_msg': "Download was canceled.", + }) + + # not POST, so just download a file (if specified) + filename = self.request.GET.get('filename') + if not filename: + return self.redirect(self.get_index_url()) + path = self.download_results_path(user_uuid, filename) + return self.file_response(path) + + def download_results_thread(self, results, fmt, fields, user_uuid, progress): + """ + Thread target, which invokes :meth:`download_results_generate()` to + officially generate the data file which is then to be downloaded. + """ + route_prefix = self.get_route_prefix() + session = self.make_isolated_session() + try: + + # create folder(s) for output; make sure file doesn't exist + filename = self.download_results_filename(fmt) + path = self.download_results_path(user_uuid, filename, makedirs=True) + if os.path.exists(path): + os.remove(path) + + # generate file for download + results = results.with_session(session).all() + self.download_results_setup(fields, progress=progress) + self.download_results_generate(session, results, path, fmt, fields, + progress=progress) + + session.commit() + + except Exception as error: + msg = "failed to generate results file for download!" + log.warning(msg, exc_info=True) + session.rollback() + if progress: + progress.session.load() + progress.session['error'] = True + progress.session['error_msg'] = "{}: {}".format( + msg, simple_error(error)) + progress.session.save() + return + + finally: + session.close() + + if progress: + progress.session.load() + progress.session['complete'] = True + progress.session['success_url'] = self.get_index_url() + progress.session['extra_session_bits'] = { + '{}.results.generated'.format(route_prefix): path, + } + progress.session.save() + + def download_results_setup(self, fields, progress=None): + """ + Perform any up-front caching or other setup required, just prior to + generating a new results data file for download. + """ + + def download_results_generate(self, session, results, path, fmt, fields, progress=None): + """ + This method is responsible for actually generating the data file for a + "download results" operation, according to the given params. + """ + if fmt == 'csv': + + if six.PY2: + csv_file = open(path, 'wb') + writer = UnicodeDictWriter(csv_file, fields, encoding='utf_8') + else: # PY3 + csv_file = open(path, 'wt', encoding='utf_8') + writer = csv.DictWriter(csv_file, fields) + writer.writeheader() + + def write(obj, i): + data = self.download_results_normalize(obj, fields, fmt=fmt) + row = self.download_results_coerce_csv(data, fields) + writer.writerow(row) + + self.progress_loop(write, results, progress, + message="Writing data to CSV file") + csv_file.close() + + elif fmt == 'xlsx': + + writer = ExcelWriter(path, fields, + sheet_title=self.get_model_title_plural()) + writer.write_header() + + xlrows = [] + def write(obj, i): + data = self.download_results_normalize(obj, fields, fmt=fmt) + row = self.download_results_coerce_xlsx(data, fields) + xlrow = [row[field] for field in fields] + xlrows.append(xlrow) + + self.progress_loop(write, results, progress, + message="Collecting data for Excel") + + def finalize(x, i): + writer.write_rows(xlrows) + writer.auto_freeze() + writer.auto_filter() + writer.auto_resize() + writer.save() + + self.progress_loop(finalize, [1], progress, + message="Writing Excel file to disk") + + def download_results_fields_available(self, **kwargs): + """ + Return the list of fields which are *available* to be written to + download file. Default field list will be constructed from the + underlying table columns. + """ + fields = [] + mapper = orm.class_mapper(self.model_class) + for prop in mapper.iterate_properties: + if isinstance(prop, orm.ColumnProperty): + fields.append(prop.key) + return fields + + def download_results_fields_default(self, fields, **kwargs): + """ + Return the default list of fields to be written to download file. + Unless you override, all "available" fields will be included by + default. + """ + return fields + + def download_results_normalize(self, obj, fields, **kwargs): + """ + Normalize the given object into a data dict, for use when writing to + the results file for download. + """ + data = {} + for field in fields: + value = getattr(obj, field, None) + + # make timestamps zone-aware + if isinstance(value, datetime.datetime): + value = localtime(self.rattail_config, value, + from_utc=not self.has_local_times) + + data[field] = value + + return data + + def download_results_coerce_csv(self, data, fields, **kwargs): + """ + Coerce the given data dict record, to a "row" dict suitable for use + when writing directly to CSV file. Each value in the dict should be a + string type. + """ + csvrow = dict(data) + for field in fields: + value = csvrow.get(field) + + if value is None: + value = '' + else: + value = six.text_type(value) + + csvrow[field] = value + + return csvrow + + def download_results_coerce_xlsx(self, data, fields, **kwargs): + """ + Coerce the given data dict record, to a "row" dict suitable for use + when writing directly to XLSX file. + """ + return data + def results_csv(self): """ Download current list results as CSV. @@ -2697,7 +2984,7 @@ class MasterView(View): }) def results_csv_session(self): - return RattailSession() + return self.make_isolated_session() def results_csv_thread(self, results, user_uuid, progress): """ @@ -2793,7 +3080,7 @@ class MasterView(View): }) def results_xlsx_session(self): - return RattailSession() + return self.make_isolated_session() def results_xlsx_thread(self, results, user_uuid, progress): """ @@ -3807,6 +4094,20 @@ class MasterView(View): config.add_view(cls, attr='mobile_index', route_name='mobile.{}'.format(route_prefix), permission='{}.list'.format(permission_prefix)) + # download results + # this is the "new" more flexible approach, but we only want to + # enable it if the class declares it, *and* does *not* declare the + # older style(s). that way each class must explicitly choose + # *only* the new style in order to use it + if cls.results_downloadable and not ( + cls.results_downloadable_csv or cls.results_downloadable_xlsx): + config.add_tailbone_permission(permission_prefix, '{}.download_results'.format(permission_prefix), + "Download search results for {}".format(model_title_plural)) + config.add_route('{}.download_results'.format(route_prefix), '{}/download-results'.format(url_prefix)) + config.add_view(cls, attr='download_results', route_name='{}.download_results'.format(route_prefix), + permission='{}.download_results'.format(permission_prefix)) + + # download results as CSV (deprecated) if cls.results_downloadable_csv: config.add_tailbone_permission(permission_prefix, '{}.results_csv'.format(permission_prefix), "Download {} as CSV".format(model_title_plural)) @@ -3817,6 +4118,7 @@ class MasterView(View): config.add_view(cls, attr='results_csv_download', route_name='{}.results_csv_download'.format(route_prefix), permission='{}.results_csv'.format(permission_prefix)) + # download results as XLSX (deprecated) if cls.results_downloadable_xlsx: config.add_tailbone_permission(permission_prefix, '{}.results_xlsx'.format(permission_prefix), "Download {} as XLSX".format(model_title_plural)) From 5af26a57f6652f2e1ac7824b7ee99472b8c33b40 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 22 Aug 2020 16:11:29 -0500 Subject: [PATCH 0188/1681] Improve layout for "download results" modal --- tailbone/templates/master/index.mako | 98 ++++++++++++++-------------- 1 file changed, 49 insertions(+), 49 deletions(-) diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako index 6ff9c4fe..a2078907 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -184,9 +184,9 @@ the end result (file size) may be larger with CSV. </b-notification> - <div style="display: flex;"> + <div style="display: flex; justify-content: space-between"> - <div style="flex-grow: 1;"> + <div> <b-field horizontal label="Format"> <b-select v-model="downloadResultsFormat"> % for key, label in six.iteritems(master.download_results_supported_formats()): @@ -197,6 +197,7 @@ </div> <div> + <div v-show="downloadResultsFieldsMode != 'choose'" class="has-text-right"> <p v-if="downloadResultsFieldsMode == 'default'"> @@ -205,9 +206,10 @@ <p v-if="downloadResultsFieldsMode == 'all'"> Will use ALL fields. </p> + <br /> </div> - <br /> - <div class="buttons"> + + <div class="buttons is-right"> <b-button type="is-primary" v-show="downloadResultsFieldsMode != 'default'" @click="downloadResultsUseDefaultFields()"> @@ -224,55 +226,53 @@ Choose Fields </b-button> </div> - </div> - </div> - <br /> + <div v-show="downloadResultsFieldsMode == 'choose'"> + <div style="display: flex;"> + <div> + <b-field label="Excluded Fields"> + <b-select multiple native-size="8" + expanded + ref="downloadResultsExcludedFields"> + <option v-for="field in downloadResultsFieldsAvailable" + v-if="!downloadResultsFieldsIncluded.includes(field)" + :key="field" + :value="field"> + {{ field }} + </option> + </b-select> + </b-field> + </div> + <div> + <br /><br /> + <b-button style="margin: 0.5rem;" + @click="downloadResultsExcludeFields()"> + < + </b-button> + <br /> + <b-button style="margin: 0.5rem;" + @click="downloadResultsIncludeFields()"> + > + </b-button> + </div> + <div> + <b-field label="Included Fields"> + <b-select multiple native-size="8" + expanded + ref="downloadResultsIncludedFields"> + <option v-for="field in downloadResultsFieldsIncluded" + :key="field" + :value="field"> + {{ field }} + </option> + </b-select> + </b-field> + </div> + </div> + </div> - <div v-show="downloadResultsFieldsMode == 'choose'"> - <div style="display: flex;"> - <div> - <b-field label="Excluded Fields"> - <b-select multiple native-size="8" - expanded - ref="downloadResultsExcludedFields"> - <option v-for="field in downloadResultsFieldsAvailable" - v-if="!downloadResultsFieldsIncluded.includes(field)" - :key="field" - :value="field"> - {{ field }} - </option> - </b-select> - </b-field> - </div> - <div> - <br /><br /> - <b-button style="margin: 0.5rem;" - @click="downloadResultsExcludeFields()"> - < - </b-button> - <br /> - <b-button style="margin: 0.5rem;" - @click="downloadResultsIncludeFields()"> - > - </b-button> - </div> - <div> - <b-field label="Included Fields"> - <b-select multiple native-size="8" - expanded - ref="downloadResultsIncludedFields"> - <option v-for="field in downloadResultsFieldsIncluded" - :key="field" - :value="field"> - {{ field }} - </option> - </b-select> - </b-field> - </div> </div> </div> - </div> <!-- card-content --> <footer class="modal-card-foot"> From 026dc6309c1c667dd642c8ebdc5f708ef2d71297 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 23 Aug 2020 11:23:22 -0500 Subject: [PATCH 0189/1681] Fix "execute results" batch template logic for Buefy themes --- tailbone/templates/batch/index.mako | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/tailbone/templates/batch/index.mako b/tailbone/templates/batch/index.mako index 21e3d7aa..5070c46e 100644 --- a/tailbone/templates/batch/index.mako +++ b/tailbone/templates/batch/index.mako @@ -97,6 +97,8 @@ % if use_buefy: <b-button type="is-primary" @click="executeResults()" + icon-pack="fas" + icon-left="arrow-circle-right" :disabled="!total"> Execute Results </b-button> @@ -114,7 +116,11 @@ Please be advised, you are about to execute {{ total }} batches! </p> <br /> - <tailbone-form ref="executeResultsForm"></tailbone-form> + <div class="form-wrapper"> + <div class="form"> + <${execute_form.component} ref="executeResultsForm"></${execute_form.component}> + </div> + </div> </section> <footer class="modal-card-foot"> @@ -123,7 +129,8 @@ </b-button> <once-button type="is-primary" @click="submitExecuteResults()" - text="Execute"> + icon-left="arrow-circle-right" + :text="'Execute ' + total + ' Batches'"> </once-button> </footer> @@ -141,8 +148,8 @@ % if master.results_executable and master.has_perm('execute_multiple'): <script type="text/javascript"> - TailboneForm.methods.submit = function() { - this.$refs.actualForm.submit() + ${execute_form.component_studly}.methods.submit = function() { + this.$refs.actualExecuteForm.submit() } TailboneGridData.hasExecutionOptions = ${json.dumps(master.has_execution_options(batch))|n} @@ -181,9 +188,9 @@ % if master.results_executable and master.has_perm('execute_multiple'): <script type="text/javascript"> - TailboneForm.data = function() { return TailboneFormData } + ${execute_form.component_studly}.data = function() { return ${execute_form.component_studly}Data } - Vue.component('tailbone-form', TailboneForm) + Vue.component('${execute_form.component}', ${execute_form.component_studly}) </script> % endif @@ -192,7 +199,7 @@ <%def name="render_this_page_template()"> ${parent.render_this_page_template()} % if master.results_executable and master.has_perm('execute_multiple'): - ${execute_form.render_deform(form_kwargs={'ref': 'actualForm'}, buttons=False)|n} + ${execute_form.render_deform(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)|n} % endif </%def> From 7a0f975b3164df427421e0ef7e5f9fb7dfaddd9e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 23 Aug 2020 11:26:09 -0500 Subject: [PATCH 0190/1681] Fix spacing between components in "grid tools" section --- tailbone/templates/grids/buefy.mako | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako index ac814a8a..8d075356 100644 --- a/tailbone/templates/grids/buefy.mako +++ b/tailbone/templates/grids/buefy.mako @@ -113,7 +113,7 @@ <div class="grid-tools-wrapper"> % if tools: - <div class="grid-tools field is-grouped is-pulled-right"> + <div class="grid-tools field buttons is-grouped is-pulled-right"> ## TODO: stop using |n filter ${tools|n} </div> From d2d632092b40d54e8161b4ef41adc8b406893cc7 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 23 Aug 2020 13:46:32 -0500 Subject: [PATCH 0191/1681] Add support for batch execution options in Buefy themes i.e. from "view batch" page --- tailbone/templates/batch/view.mako | 130 ++++++++++++++++------------- tailbone/views/batch/core.py | 8 +- 2 files changed, 79 insertions(+), 59 deletions(-) diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako index d1855eb2..c04ec8c4 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -16,7 +16,7 @@ location.href = '${url('{}.worksheet'.format(route_prefix), uuid=batch.uuid)}'; }); % endif - % if master.batch_refreshable(batch) and request.has_perm('{}.refresh'.format(permission_prefix)): + % if master.batch_refreshable(batch) and master.has_perm('refresh'): $('#refresh-data').click(function() { $(this) .button('option', 'disabled', true) @@ -32,7 +32,15 @@ <%def name="extra_styles()"> ${parent.extra_styles()} - % if not use_buefy: + % if use_buefy: + <style type="text/css"> + + .modal-card-body label { + white-space: nowrap; + } + + </style> + % else: <style type="text/css"> .grid-wrapper { @@ -68,7 +76,7 @@ </%def> <%def name="refresh_button()"> - % if master.batch_refreshable(batch) and request.has_perm('{}.refresh'.format(permission_prefix)): + % if master.batch_refreshable(batch) and master.has_perm('refresh'): % if use_buefy: ## TODO: this should surely use a POST request? <once-button tag="a" @@ -81,30 +89,6 @@ % endif </%def> -<%def name="execute_submit_button()"> - <b-button type="is-primary" - % if master.has_execution_options(batch): - @click="executeBatch" - % else: - native-type="submit" - % endif - % if not execute_enabled: - disabled - % elif not master.has_execution_options(batch): - :disabled="executeFormSubmitting" - % endif - % if why_not_execute: - title="${why_not_execute}" - % endif - > - % if master.has_execution_options(batch): - ${execute_title} - % else: - {{ executeFormButtonText }} - % endif - </b-button> -</%def> - <%def name="object_helpers()"> ${self.render_status_breakdown()} ${self.render_execute_helper()} @@ -147,14 +131,49 @@ by ${batch.executed_by} </p> % elif master.handler.executable(batch): - % if request.has_perm('{}.execute'.format(permission_prefix)): + % if master.has_perm('execute'): <p>Batch has not yet been executed.</p> % if use_buefy: - % if master.has_execution_options(batch): - <p>TODO: must implement execution with options</p> - % else: - <execute-form></execute-form> - % endif + <br /> + <b-button type="is-primary" + % if not execute_enabled: + disabled + % if why_not_execute: + title="${why_not_execute}" + % endif + % endif + @click="showExecutionDialog = true" + icon-pack="fas" + icon-left="arrow-circle-right"> + ${execute_title} + </b-button> + + <b-modal has-modal-card + :active.sync="showExecutionDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Execute ${model_title}</p> + </header> + + <section class="modal-card-body"> + <${execute_form.component} ref="executeBatchForm"></${execute_form.component}> + </section> + + <footer class="modal-card-foot"> + <b-button @click="showExecutionDialog = false"> + Cancel + </b-button> + <once-button type="is-primary" + @click="submitExecuteBatch()" + icon-left="arrow-circle-right" + text="Execute Batch"> + </once-button> + </footer> + + </div> + </b-modal> + % else: ## no buefy, do legacy thing <button type="button" @@ -188,7 +207,7 @@ <%def name="render_this_page()"> ${parent.render_this_page()} % if not use_buefy: - % if master.handler.executable(batch) and request.has_perm('{}.execute'.format(permission_prefix)): + % if master.handler.executable(batch) and master.has_perm('execute'): <div id="execution-options-dialog" style="display: none;"> ${execute_form.render_deform(form_kwargs={'name': 'batch-execution'}, buttons=False)|n} </div> @@ -198,9 +217,9 @@ <%def name="render_this_page_template()"> ${parent.render_this_page_template()} - % if use_buefy and master.handler.executable(batch) and request.has_perm('{}.execute'.format(permission_prefix)): + % if use_buefy and master.handler.executable(batch) and master.has_perm('execute'): ## TODO: stop using |n filter - ${execute_form.render_deform(buttons=capture(execute_submit_button), form_kwargs={'@submit': 'submitExecuteForm'})|n} + ${execute_form.render_deform(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)|n} % endif </%def> @@ -210,34 +229,29 @@ ThisPageData.statusBreakdownData = ${json.dumps(status_breakdown_grid.get_buefy_data()['data'])|n} + % if not batch.executed and master.has_perm('execute'): + + ThisPageData.showExecutionDialog = false + + ThisPage.methods.submitExecuteBatch = function() { + this.$refs.executeBatchForm.submit() + } + + ${execute_form.component_studly}.methods.submit = function() { + this.$refs.actualExecuteForm.submit() + } + + % endif </script> - - % if not batch.executed and request.has_perm('{}.execute'.format(permission_prefix)): - <script type="text/javascript"> - - ${execute_form.component_studly}Data.executeFormButtonText = "${execute_title}" - ${execute_form.component_studly}Data.executeFormSubmitting = false - - ${execute_form.component_studly}.methods.executeBatch = function() { - alert("TODO: implement options dialog for batch execution") - } - - ${execute_form.component_studly}.methods.submitExecuteForm = function() { - this.executeFormSubmitting = true - this.executeFormButtonText = "Executing, please wait..." - } - - </script> - % endif </%def> -<%def name="finalize_this_page_vars()"> - ${parent.finalize_this_page_vars()} - % if not batch.executed and request.has_perm('{}.execute'.format(permission_prefix)): +<%def name="make_this_page_component()"> + ${parent.make_this_page_component()} + % if not batch.executed and master.has_perm('execute'): <script type="text/javascript"> + ## ExecuteForm ${execute_form.component_studly}.data = function() { return ${execute_form.component_studly}Data } - Vue.component('${execute_form.component}', ${execute_form.component_studly}) </script> diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index b5d0915b..c8f055f3 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -49,6 +49,7 @@ from rattail.progress import SocketProgress import colander import deform +from deform import widget as dfwidget from pyramid.renderers import render_to_response from pyramid.response import FileResponse from webhelpers2.html import HTML, tags @@ -800,6 +801,7 @@ class BatchMasterView(MasterView): """ defaults = {} route_prefix = self.get_route_prefix() + use_buefy = self.get_use_buefy() if self.has_execution_options(batch): if batch is None: @@ -818,10 +820,14 @@ class BatchMasterView(MasterView): labels = kwargs.setdefault('labels', {}) labels[field.name] = field.title + # auto-convert select widgets for buefy theme + if use_buefy and isinstance(field.widget, forms.widgets.PlainSelectWidget): + field.widget = dfwidget.SelectWidget(values=field.widget.values) + else: schema = colander.Schema() - kwargs['use_buefy'] = self.get_use_buefy() + kwargs['use_buefy'] = use_buefy kwargs['component'] = 'execute-form' return forms.Form(schema=schema, request=self.request, defaults=defaults, **kwargs) From 72177e8ab567a1739cbf87e3b5a32d01978a19cb Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 2 Sep 2020 11:30:02 -0500 Subject: [PATCH 0192/1681] Improve auto-handling of "local" timestamps for non-Rattail DBs where timestamps are local instead of UTC --- tailbone/forms/core.py | 9 +++++++-- tailbone/grids/core.py | 11 ++++++++--- tailbone/views/master.py | 12 ++++++++++++ 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index cf7dd49e..7441e4ab 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -340,7 +340,8 @@ class Form(object): auto_disable_cancel = True def __init__(self, fields=None, schema=None, request=None, mobile=False, readonly=False, readonly_fields=[], - model_instance=None, model_class=None, appstruct=UNSPECIFIED, nodes={}, enums={}, labels={}, renderers=None, + model_instance=None, model_class=None, appstruct=UNSPECIFIED, nodes={}, enums={}, labels={}, + assume_local_times=False, renderers=None, hidden={}, widgets={}, defaults={}, validators={}, required={}, helptext={}, focus_spec=None, action_url=None, cancel_url=None, use_buefy=None, component='tailbone-form'): @@ -364,6 +365,7 @@ class Form(object): self.nodes = nodes or {} self.enums = enums or {} self.labels = labels or {} + self.assume_local_times = assume_local_times if renderers is None and self.model_class: self.renderers = self.make_renderers() else: @@ -430,7 +432,10 @@ class Form(object): if len(prop.columns) == 1: column = prop.columns[0] if isinstance(column.type, sa.DateTime): - renderers[prop.key] = self.render_datetime + if self.assume_local_times: + renderers[prop.key] = self.render_datetime_local + else: + renderers[prop.key] = self.render_datetime elif isinstance(column.type, sa.Boolean): renderers[prop.key] = self.render_boolean diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index d475370c..442568de 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -69,7 +69,8 @@ class Grid(object): def __init__(self, key, data, columns=None, width='auto', request=None, mobile=False, model_class=None, model_title=None, model_title_plural=None, - enums={}, labels={}, renderers={}, extra_row_class=None, linked_columns=[], url='#', + enums={}, labels={}, assume_local_times=False, renderers={}, + extra_row_class=None, linked_columns=[], url='#', joiners={}, filterable=False, filters={}, use_byte_string_filters=False, sortable=False, sorters={}, default_sortkey=None, default_sortdir='asc', pageable=False, default_pagesize=20, default_page=1, @@ -102,6 +103,7 @@ class Grid(object): self.enums = enums or {} self.labels = labels or {} + self.assume_local_times = assume_local_times self.renderers = self.make_default_renderers(renderers or {}) self.extra_row_class = extra_row_class self.linked_columns = linked_columns or [] @@ -407,7 +409,10 @@ class Grid(object): return self.render_boolean if isinstance(coltype, sa.DateTime): - return self.render_datetime + if self.assume_local_times: + return self.render_datetime_local + else: + return self.render_datetime if isinstance(coltype, GPCType): return self.render_gpc diff --git a/tailbone/views/master.py b/tailbone/views/master.py index c2720d6e..6b6c8305 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -424,6 +424,7 @@ class MasterView(View): 'url': lambda obj: self.get_action_url('view', obj), 'checkboxes': checkboxes, 'checked': self.checked, + 'assume_local_times': self.has_local_times, } if 'main_actions' not in kwargs and 'more_actions' not in kwargs: main, more = self.get_grid_actions() @@ -2961,6 +2962,16 @@ class MasterView(View): Coerce the given data dict record, to a "row" dict suitable for use when writing directly to XLSX file. """ + data = dict(data) + for key in data: + value = data[key] + + # make timestamps local, "zone-naive" + if isinstance(value, datetime.datetime): + value = localtime(self.rattail_config, value, tzinfo=False) + + data[key] = value + return data def results_csv(self): @@ -3504,6 +3515,7 @@ class MasterView(View): 'model_class': getattr(self, 'model_class', None), 'action_url': self.request.current_route_url(_query=None), 'use_buefy': self.get_use_buefy(), + 'assume_local_times': self.has_local_times, } if self.creating: kwargs.setdefault('cancel_url', self.get_index_url()) From 527bc04998e6a387bd75f472dd868de93de903ac Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 2 Sep 2020 13:38:18 -0500 Subject: [PATCH 0193/1681] Expose `Product.average_weight` field --- tailbone/templates/products/view.mako | 1 + tailbone/views/products.py | 1 + 2 files changed, 2 insertions(+) diff --git a/tailbone/templates/products/view.mako b/tailbone/templates/products/view.mako index 23b6d2ca..b3fddacc 100644 --- a/tailbone/templates/products/view.mako +++ b/tailbone/templates/products/view.mako @@ -99,6 +99,7 @@ ${form.render_field_readonly('size')} ${form.render_field_readonly('unit_size')} ${form.render_field_readonly('unit_of_measure')} + ${form.render_field_readonly('average_weight')} ${form.render_field_readonly('case_size')} % if instance.is_pack_item(): ${form.render_field_readonly('pack_size')} diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 8b2b8575..1cac0c19 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -124,6 +124,7 @@ class ProductsView(MasterView): 'default_pack', 'case_size', 'weighed', + 'average_weight', 'department', 'subdepartment', 'category', From 24516b81cb3a98b6c386cb5c6f4720b0be76548a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 2 Sep 2020 13:44:13 -0500 Subject: [PATCH 0194/1681] 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 29e1128b..e3ff8171 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,24 @@ CHANGELOG ========= +0.8.106 (2020-09-02) +-------------------- + +* Add progress for generating "results as CSV/XLSX" file to download. + +* Use utf8 encoding when downloading results as CSV. + +* Add new/flexible "download results" feature. + +* Fix spacing between components in "grid tools" section. + +* Add support for batch execution options in Buefy themes. + +* Improve auto-handling of "local" timestamps. + +* Expose ``Product.average_weight`` field. + + 0.8.105 (2020-08-21) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 30e62aa6..270801a0 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.105' +__version__ = '0.8.106' From fdcf23f65f91c22936ba0984a7d27a16ed1980e2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 4 Sep 2020 20:30:33 -0500 Subject: [PATCH 0195/1681] Stop including 'complete' filter by default for purchasing batches --- tailbone/views/purchasing/batch.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index 4f7a14e6..e8bc5ba7 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -203,9 +203,12 @@ class PurchasingBatchView(BatchMasterView): default_active=True, default_verb='contains') g.sorters['buyer'] = g.make_sorter(model.Person.display_name) - if self.request.has_perm('{}.execute'.format(self.get_permission_prefix())): - g.filters['complete'].default_active = True - g.filters['complete'].default_verb = 'is_true' + # TODO: we used to include the 'complete' filter by default, but it + # seems to likely be confusing for newcomers, so it is no longer + # default. not sure if there are any other implications...? + # if self.request.has_perm('{}.execute'.format(self.get_permission_prefix())): + # g.filters['complete'].default_active = True + # g.filters['complete'].default_verb = 'is_true' # invoice_total g.set_type('invoice_total', 'currency') From 1283a794df3087c1155f161f1a5c827e53921a4f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 5 Sep 2020 17:48:34 -0500 Subject: [PATCH 0196/1681] Overhaul project changelog links for upgrade pkg diff table --- tailbone/views/upgrades.py | 70 +++++++++++++++++++++++++++----------- 1 file changed, 51 insertions(+), 19 deletions(-) diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index 54605efb..b469a7be 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.py @@ -288,30 +288,62 @@ class UpgradeView(MasterView): commit_hash_pattern = re.compile(r'^.{40}$') - def get_changelog_url(self, project, old_version, new_version): + def get_changelog_projects(self): projects = { - 'rattail': 'rattail', - 'Tailbone': 'tailbone', - 'pyCatapult': 'pycatapult', - 'rattail-catapult': 'rattail-catapult', - 'rattail-tempmon': 'rattail-tempmon', - 'tailbone-catapult': 'tailbone-catapult', + 'rattail': { + 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/rattail/changelog/{new_version}/?size=10', + 'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail/files/{new_version}/CHANGES.rst', + }, + 'Tailbone': { + 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone/changelog/{new_version}/?size=10', + 'release_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone/files/{new_version}/CHANGES.rst', + }, + 'pyCOREPOS': { + 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/pycorepos/changelog/{new_version}/?size=10', + 'release_url': 'https://kallithea.rattailproject.org/rattail-project/pycorepos/files/{new_version}/CHANGES.rst', + }, + 'rattail_corepos': { + 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-corepos/changelog/{new_version}/?size=10', + 'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-corepos/files/{new_version}/CHANGES.rst', + }, + 'tailbone_corepos': { + 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-corepos/changelog/{new_version}/?size=10', + 'release_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-corepos/files/{new_version}/CHANGES.rst', + }, + 'onager': { + 'commit_url': 'https://kallithea.rattailproject.org/rattail-restricted/onager/changelog/{new_version}/?size=10', + 'release_url': 'https://kallithea.rattailproject.org/rattail-restricted/onager/files/{new_version}/CHANGES.rst', + }, + 'rattail-onager': { + 'commit_url': 'https://kallithea.rattailproject.org/rattail-restricted/rattail-onager/changelog/{new_version}/?size=10', + 'release_url': 'https://kallithea.rattailproject.org/rattail-restricted/rattail-onager/files/{new_version}/CHANGELOG.md', + }, + 'rattail_tempmon': { + 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-tempmon/changelog/{new_version}/?size=10', + 'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-tempmon/files/{new_version}/CHANGES.rst', + }, + 'tailbone-onager': { + 'commit_url': 'https://kallithea.rattailproject.org/rattail-restricted/tailbone-onager/changelog/{new_version}/?size=10', + 'release_url': 'https://kallithea.rattailproject.org/rattail-restricted/tailbone-onager/files/{new_version}/CHANGELOG.md', + }, } - if project not in projects: + return projects + + def get_changelog_url(self, project, old_version, new_version): + projects = self.get_changelog_projects() + + project_name = project + if project_name not in projects: + # cannot generate a changelog URL for unknown project return + + project = projects[project_name] + if self.commit_hash_pattern.match(new_version): - if new_version == old_version: - return 'https://rattailproject.org/trac/log/{}/?rev={}&limit=100'.format( - projects[project], new_version) - else: - return 'https://rattailproject.org/trac/log/{}/?rev={}&stop_rev={}&limit=100'.format( - projects[project], new_version, old_version) + return project['commit_url'].format(new_version=new_version, old_version=old_version) + elif re.match(r'^\d+\.\d+\.\d+$', new_version): - return 'https://rattailproject.org/trac/browser/{}/CHANGES.rst?rev=v{}'.format( - projects[project], new_version) - else: - return 'https://rattailproject.org/trac/browser/{}/CHANGES.rst'.format( - projects[project]) + return project['release_url'].format(new_version=new_version, old_version=old_version) def render_diff_field(self, field, diff): old_version = diff.old_value(field) From bd19d7c2312f60923a16172a12093547164c9071 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 6 Sep 2020 12:27:02 -0500 Subject: [PATCH 0197/1681] Add view for generating new project from template this was copied as-is from titeship --- tailbone/templates/generate_project.mako | 9 ++ tailbone/views/projects.py | 176 +++++++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 tailbone/templates/generate_project.mako create mode 100644 tailbone/views/projects.py diff --git a/tailbone/templates/generate_project.mako b/tailbone/templates/generate_project.mako new file mode 100644 index 00000000..9ae13d59 --- /dev/null +++ b/tailbone/templates/generate_project.mako @@ -0,0 +1,9 @@ +## -*- coding: utf-8; -*- +<%inherit file="/form.mako" /> + +<%def name="title()">Generate Project</%def> + +<%def name="content_title()"></%def> + + +${parent.body()} diff --git a/tailbone/views/projects.py b/tailbone/views/projects.py new file mode 100644 index 00000000..8bf9e7e8 --- /dev/null +++ b/tailbone/views/projects.py @@ -0,0 +1,176 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2020 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Project views +""" + +from __future__ import unicode_literals, absolute_import + +import os +import zipfile +# from collections import OrderedDict + +import colander + +from tailbone import forms +from tailbone.views import View + + +class GenerateProject(colander.MappingSchema): + """ + Base schema for the "generate project" form + """ + name = colander.SchemaNode(colander.String()) + + slug = colander.SchemaNode(colander.String()) + + organization = colander.SchemaNode(colander.String()) + + python_project_name = colander.SchemaNode(colander.String()) + + python_name = colander.SchemaNode(colander.String()) + + has_db = colander.SchemaNode(colander.Boolean()) + + extends_db = colander.SchemaNode(colander.Boolean()) + + has_batch_schema = colander.SchemaNode(colander.Boolean()) + + has_web = colander.SchemaNode(colander.Boolean()) + + has_web_api = colander.SchemaNode(colander.Boolean()) + + has_datasync = colander.SchemaNode(colander.Boolean()) + + # has_filemon = colander.SchemaNode(colander.Boolean()) + + # has_tempmon = colander.SchemaNode(colander.Boolean()) + + # has_bouncer = colander.SchemaNode(colander.Boolean()) + + integrates_catapult = colander.SchemaNode(colander.Boolean()) + + integrates_corepos = colander.SchemaNode(colander.Boolean()) + + # integrates_instacart = colander.SchemaNode(colander.Boolean()) + + integrates_locsms = colander.SchemaNode(colander.Boolean()) + + # integrates_mailchimp = colander.SchemaNode(colander.Boolean()) + + uses_fabric = colander.SchemaNode(colander.Boolean()) + + +class GenerateProjectView(View): + """ + View for generating new project source code + """ + + def __init__(self, request): + super(GenerateProjectView, self).__init__(request) + self.handler = self.get_handler() + + def get_handler(self): + from rattail.projects.handler import RattailProjectHandler + return RattailProjectHandler(self.rattail_config) + + def __call__(self): + use_buefy = self.get_use_buefy() + + # choices = OrderedDict([ + # ('has_db', {'prompt': "Does project need its own Rattail DB?", + # 'type': 'bool'}), + # ]) + + form = forms.Form(schema=GenerateProject(), + request=self.request, use_buefy=use_buefy) + form.submit_label = "Generate Project" + form.auto_disable = False + form.auto_disable_save = False + if form.validate(newstyle=True): + zipped = self.generate_project(form) + return self.file_response(zipped) + # self.request.session.flash("New project was generated: {}".format(form.validated['name'])) + # return self.redirect(self.request.current_route_url()) + + form.set_label('python_name', "Python Package Name") + form.set_label('has_db', "Has Rattail DB") + form.set_label('extends_db', "Extends Rattail DB Schema") + form.set_label('has_batch_schema', "Uses Rattail Batch Schema") + form.set_label('has_web', "Has Tailbone Web App") + form.set_label('has_web_api', "Has Tailbone Web API") + form.set_label('has_datasync', "Has DataSync Service") + # form.set_label('has_filemon', "Has FileMon Service") + # form.set_label('has_tempmon', "Has TempMon Service") + # form.set_label('has_bouncer', "Has Bouncer Service") + form.set_label('integrates_catapult', "Integrates w/ Catapult") + form.set_label('integrates_corepos', "Integrates w/ CORE-POS") + # form.set_label('integrates_instacart', "Integrates w/ Instacart") + form.set_label('integrates_locsms', "Integrates w/ LOC SMS") + # form.set_label('integrates_mailchimp', "Integrates w/ Mailchimp") + + # TODO! + form.set_default('name', 'Okay-Then') + form.set_default('slug', 'okay-then') + form.set_default('organization', 'Acme') + form.set_default('python_project_name', 'Acme-Okay-Then') + form.set_default('python_name', 'okay_then') + form.set_default('has_db', True) + form.set_default('has_web', True) + + return { + 'index_title': "Generate Project", + 'handler': self.handler, + # 'choices': choices, + 'form': form, + 'use_buefy': use_buefy, + } + + def generate_project(self, form): + options = form.validated + slug = options['slug'] + path = self.handler.generate_project(slug, options) + + zipped = '{}.zip'.format(path) + with zipfile.ZipFile(zipped, 'w', zipfile.ZIP_DEFLATED) as z: + self.zipdir(z, path, slug) + return zipped + + def zipdir(self, zipf, path, slug): + for root, dirs, files in os.walk(path): + relative_root = os.path.join(slug, root[len(path)+1:]) + for fname in files: + zipf.write(os.path.join(root, fname), + arcname=os.path.join(relative_root, fname)) + + @classmethod + def defaults(cls, config): + config.add_tailbone_permission('common', 'common.generate_project', + "Generate new project source code") + config.add_route('generate_project', '/generate-project') + config.add_view(cls, route_name='generate_project', + renderer='/generate_project.mako') + + +def includeme(config): + GenerateProjectView.defaults(config) From cebe2f8adc45944bc5613b30ead4cfd3d6db5624 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 6 Sep 2020 13:54:11 -0500 Subject: [PATCH 0198/1681] Add basic/incomplete support for generating new 'byjove' project just wanted to get the placeholder in here for now --- tailbone/templates/generate_project.mako | 155 ++++++++++++++++++++++- tailbone/views/projects.py | 26 +++- 2 files changed, 175 insertions(+), 6 deletions(-) diff --git a/tailbone/templates/generate_project.mako b/tailbone/templates/generate_project.mako index 9ae13d59..93da4df6 100644 --- a/tailbone/templates/generate_project.mako +++ b/tailbone/templates/generate_project.mako @@ -1,9 +1,162 @@ ## -*- coding: utf-8; -*- -<%inherit file="/form.mako" /> +<%inherit file="/page.mako" /> <%def name="title()">Generate Project</%def> <%def name="content_title()"></%def> +<%def name="page_content()"> + <b-field horizontal label="Project Type"> + <b-select v-model="projectType"> + <option value="rattail">rattail</option> + <option value="byjove">byjove</option> + </b-select> + </b-field> + + <div class="card" v-if="projectType == 'rattail'"> + <header class="card-header"> + <p class="card-header-title">New 'rattail' Project</p> + </header> + <div class="card-content"> + <div class="content"> + ${h.form(request.current_route_url(), ref='rattailForm')} + ${h.csrf_token(request)} + ${h.hidden('project_type', value='rattail')} + + <b-field horizontal label="Name"> + <b-input name="name" v-model="rattail.name"></b-input> + </b-field> + + <b-field horizontal label="Slug"> + <b-input name="slug" v-model="rattail.slug"></b-input> + </b-field> + + <b-field horizontal label="Organization"> + <b-input name="organization" v-model="rattail.organization"></b-input> + </b-field> + + <b-field horizontal label="Python Project Name"> + <b-input name="python_project_name" v-model="rattail.python_project_name"></b-input> + </b-field> + + <b-field horizontal label="Python Package Name"> + <b-input name="python_name" v-model="rattail.python_package_name"></b-input> + </b-field> + + <b-field horizontal label="Has Rattail DB"> + <b-checkbox name="has_db" v-model="rattail.has_rattail_db"></b-checkbox> + </b-field> + + <b-field horizontal label="Extends Rattail DB Schema"> + <b-checkbox name="extends_db" v-model="rattail.extends_rattail_db_schema"></b-checkbox> + </b-field> + + <b-field horizontal label="Uses Rattail Batch Schema"> + <b-checkbox name="has_batch_schema" v-model="rattail.uses_rattail_batch_schema"></b-checkbox> + </b-field> + + <b-field horizontal label="Has Tailbone Web App"> + <b-checkbox name="has_web" v-model="rattail.has_tailbone_web_app"></b-checkbox> + </b-field> + + <b-field horizontal label="Has Tailbone Web API"> + <b-checkbox name="has_web_api" v-model="rattail.has_tailbone_web_api"></b-checkbox> + </b-field> + + <b-field horizontal label="Has DataSync Service"> + <b-checkbox name="has_datasync" v-model="rattail.has_datasync_service"></b-checkbox> + </b-field> + + <b-field horizontal label="Integrates w/ Catapult"> + <b-checkbox name="integrates_catapult" v-model="rattail.integrates_with_catapult"></b-checkbox> + </b-field> + + <b-field horizontal label="Integrates w/ CORE-POS"> + <b-checkbox name="integrates_corepos" v-model="rattail.integrates_with_corepos"></b-checkbox> + </b-field> + + <b-field horizontal label="Integrates w/ LOC SMS"> + <b-checkbox name="integrates_corepos" v-model="rattail.integrates_with_locsms"></b-checkbox> + </b-field> + + <b-field horizontal label="Uses Fabric"> + <b-checkbox name="uses_fabric" v-model="rattail.uses_fabric"></b-checkbox> + </b-field> + + ${h.end_form()} + </div> + </div> + </div> + + <div class="card" v-if="projectType == 'byjove'"> + <header class="card-header"> + <p class="card-header-title">New 'byjove' Project</p> + </header> + <div class="card-content"> + <div class="content"> + ${h.form(request.current_route_url(), ref='byjoveForm')} + ${h.csrf_token(request)} + ${h.hidden('project_type', value='byjove')} + + <b-field horizontal label="Name"> + <b-input name="name" v-model="byjove.name"></b-input> + </b-field> + + <b-field horizontal label="Slug"> + <b-input name="slug" v-model="byjove.slug"></b-input> + </b-field> + + ${h.end_form()} + </div> + </div> + </div> + + <br /> + <div class="buttons" style="padding-left: 8rem;"> + <b-button type="is-primary" + @click="submitProjectForm()"> + Generate Project + </b-button> + </div> + +</%def> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + ThisPageData.projectType = 'rattail' + + ThisPageData.rattail = { + name: "Okay-Then", + slug: "okay-then", + organization: "Acme", + python_project_name: "Acme-Okay-Then", + python_package_name: "okay_then", + has_rattail_db: true, + extends_rattail_db_schema: false, + uses_rattail_batch_schema: false, + has_tailbone_web_app: true, + has_tailbone_web_api: false, + has_datasync_service: false, + integrates_with_catapult: false, + integrates_with_corepos: false, + integrates_with_locsms: false, + uses_fabric: false, + } + + ThisPageData.byjove = { + name: "Okay-Then-Mobile", + slug: "okay-then-mobile", + } + + ThisPage.methods.submitProjectForm = function() { + let form = this.$refs[this.projectType + 'Form'] + form.submit() + } + + </script> +</%def> + ${parent.body()} diff --git a/tailbone/views/projects.py b/tailbone/views/projects.py index 8bf9e7e8..df6d072c 100644 --- a/tailbone/views/projects.py +++ b/tailbone/views/projects.py @@ -81,6 +81,15 @@ class GenerateProject(colander.MappingSchema): uses_fabric = colander.SchemaNode(colander.Boolean()) +class GenerateByjoveProject(colander.MappingSchema): + """ + Schema for generating a new 'byjove' project + """ + name = colander.SchemaNode(colander.String()) + + slug = colander.SchemaNode(colander.String()) + + class GenerateProjectView(View): """ View for generating new project source code @@ -102,13 +111,20 @@ class GenerateProjectView(View): # 'type': 'bool'}), # ]) - form = forms.Form(schema=GenerateProject(), - request=self.request, use_buefy=use_buefy) + project_type = 'rattail' + if self.request.method == 'POST': + project_type = self.request.POST.get('project_type', 'rattail') + if project_type not in ('rattail', 'byjove'): + raise ValueError("Unknown project type: {}".format(project_type)) + + schema = GenerateByjoveProject if project_type == 'byjove' else GenerateProject + form = forms.Form(schema=schema(), request=self.request, + use_buefy=use_buefy) form.submit_label = "Generate Project" form.auto_disable = False form.auto_disable_save = False if form.validate(newstyle=True): - zipped = self.generate_project(form) + zipped = self.generate_project(project_type, form) return self.file_response(zipped) # self.request.session.flash("New project was generated: {}".format(form.validated['name'])) # return self.redirect(self.request.current_route_url()) @@ -146,10 +162,10 @@ class GenerateProjectView(View): 'use_buefy': use_buefy, } - def generate_project(self, form): + def generate_project(self, project_type, form): options = form.validated slug = options['slug'] - path = self.handler.generate_project(slug, options) + path = self.handler.generate_project(project_type, slug, options) zipped = '{}.zip'.format(path) with zipfile.ZipFile(zipped, 'w', zipfile.ZIP_DEFLATED) as z: From cf613ab34a1f644ae6776608b39e3acca0899f48 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 6 Sep 2020 14:47:14 -0500 Subject: [PATCH 0199/1681] Split "new project" forms into multiple sections --- tailbone/templates/generate_project.mako | 246 +++++++++++++++-------- tailbone/views/projects.py | 29 --- 2 files changed, 164 insertions(+), 111 deletions(-) diff --git a/tailbone/templates/generate_project.mako b/tailbone/templates/generate_project.mako index 93da4df6..419f12c7 100644 --- a/tailbone/templates/generate_project.mako +++ b/tailbone/templates/generate_project.mako @@ -13,102 +13,184 @@ </b-select> </b-field> - <div class="card" v-if="projectType == 'rattail'"> - <header class="card-header"> - <p class="card-header-title">New 'rattail' Project</p> - </header> - <div class="card-content"> - <div class="content"> - ${h.form(request.current_route_url(), ref='rattailForm')} - ${h.csrf_token(request)} - ${h.hidden('project_type', value='rattail')} + <div v-if="projectType == 'rattail'"> + ${h.form(request.current_route_url(), ref='rattailForm')} + ${h.csrf_token(request)} + ${h.hidden('project_type', value='rattail')} + <br /> + <div class="card"> + <header class="card-header"> + <p class="card-header-title">Naming</p> + </header> + <div class="card-content"> + <div class="content"> - <b-field horizontal label="Name"> - <b-input name="name" v-model="rattail.name"></b-input> - </b-field> + <b-field horizontal label="Name"> + <b-input name="name" v-model="rattail.name"></b-input> + </b-field> - <b-field horizontal label="Slug"> - <b-input name="slug" v-model="rattail.slug"></b-input> - </b-field> + <b-field horizontal label="Slug"> + <b-input name="slug" v-model="rattail.slug"></b-input> + </b-field> - <b-field horizontal label="Organization"> - <b-input name="organization" v-model="rattail.organization"></b-input> - </b-field> + <b-field horizontal label="Organization"> + <b-input name="organization" v-model="rattail.organization"></b-input> + </b-field> - <b-field horizontal label="Python Project Name"> - <b-input name="python_project_name" v-model="rattail.python_project_name"></b-input> - </b-field> + <b-field horizontal label="Python Project Name"> + <b-input name="python_project_name" v-model="rattail.python_project_name"></b-input> + </b-field> - <b-field horizontal label="Python Package Name"> - <b-input name="python_name" v-model="rattail.python_package_name"></b-input> - </b-field> + <b-field horizontal label="Python Package Name"> + <b-input name="python_name" v-model="rattail.python_package_name"></b-input> + </b-field> - <b-field horizontal label="Has Rattail DB"> - <b-checkbox name="has_db" v-model="rattail.has_rattail_db"></b-checkbox> - </b-field> - - <b-field horizontal label="Extends Rattail DB Schema"> - <b-checkbox name="extends_db" v-model="rattail.extends_rattail_db_schema"></b-checkbox> - </b-field> - - <b-field horizontal label="Uses Rattail Batch Schema"> - <b-checkbox name="has_batch_schema" v-model="rattail.uses_rattail_batch_schema"></b-checkbox> - </b-field> - - <b-field horizontal label="Has Tailbone Web App"> - <b-checkbox name="has_web" v-model="rattail.has_tailbone_web_app"></b-checkbox> - </b-field> - - <b-field horizontal label="Has Tailbone Web API"> - <b-checkbox name="has_web_api" v-model="rattail.has_tailbone_web_api"></b-checkbox> - </b-field> - - <b-field horizontal label="Has DataSync Service"> - <b-checkbox name="has_datasync" v-model="rattail.has_datasync_service"></b-checkbox> - </b-field> - - <b-field horizontal label="Integrates w/ Catapult"> - <b-checkbox name="integrates_catapult" v-model="rattail.integrates_with_catapult"></b-checkbox> - </b-field> - - <b-field horizontal label="Integrates w/ CORE-POS"> - <b-checkbox name="integrates_corepos" v-model="rattail.integrates_with_corepos"></b-checkbox> - </b-field> - - <b-field horizontal label="Integrates w/ LOC SMS"> - <b-checkbox name="integrates_corepos" v-model="rattail.integrates_with_locsms"></b-checkbox> - </b-field> - - <b-field horizontal label="Uses Fabric"> - <b-checkbox name="uses_fabric" v-model="rattail.uses_fabric"></b-checkbox> - </b-field> - - ${h.end_form()} + </div> </div> </div> + <br /> + <div class="card"> + <header class="card-header"> + <p class="card-header-title">Database</p> + </header> + <div class="card-content"> + <div class="content"> + + <b-field horizontal label="Has Rattail DB"> + <b-checkbox name="has_db" + v-model="rattail.has_rattail_db" + native-value="true"> + </b-checkbox> + </b-field> + + <b-field horizontal label="Extends Rattail DB Schema"> + <b-checkbox name="extends_db" + v-model="rattail.extends_rattail_db_schema" + native-value="true"> + </b-checkbox> + </b-field> + + <b-field horizontal label="Uses Rattail Batch Schema"> + <b-checkbox name="has_batch_schema" + v-model="rattail.uses_rattail_batch_schema" + native-value="true"> + </b-checkbox> + </b-field> + + </div> + </div> + </div> + <br /> + <div class="card"> + <header class="card-header"> + <p class="card-header-title">Web App</p> + </header> + <div class="card-content"> + <div class="content"> + + <b-field horizontal label="Has Tailbone Web App"> + <b-checkbox name="has_web" + v-model="rattail.has_tailbone_web_app" + native-value="true"> + </b-checkbox> + </b-field> + + <b-field horizontal label="Has Tailbone Web API"> + <b-checkbox name="has_web_api" + v-model="rattail.has_tailbone_web_api" + native-value="true"> + </b-checkbox> + </b-field> + + </div> + </div> + </div> + <br /> + <div class="card"> + <header class="card-header"> + <p class="card-header-title">Integrations</p> + </header> + <div class="card-content"> + <div class="content"> + + <b-field horizontal label="Integrates w/ Catapult"> + <b-checkbox name="integrates_catapult" + v-model="rattail.integrates_with_catapult" + native-value="true"> + </b-checkbox> + </b-field> + + <b-field horizontal label="Integrates w/ CORE-POS"> + <b-checkbox name="integrates_corepos" + v-model="rattail.integrates_with_corepos" + native-value="true"> + </b-checkbox> + </b-field> + + <b-field horizontal label="Integrates w/ LOC SMS"> + <b-checkbox name="integrates_corepos" + v-model="rattail.integrates_with_locsms" + native-value="true"> + </b-checkbox> + </b-field> + + <b-field horizontal label="Has DataSync Service"> + <b-checkbox name="has_datasync" + v-model="rattail.has_datasync_service" + native-value="true"> + </b-checkbox> + </b-field> + + </div> + </div> + </div> + <br /> + <div class="card"> + <header class="card-header"> + <p class="card-header-title">Deployment</p> + </header> + <div class="card-content"> + <div class="content"> + + <b-field horizontal label="Uses Fabric"> + <b-checkbox name="uses_fabric" + v-model="rattail.uses_fabric" + native-value="true"> + </b-checkbox> + </b-field> + + </div> + </div> + </div> + ${h.end_form()} </div> - <div class="card" v-if="projectType == 'byjove'"> - <header class="card-header"> - <p class="card-header-title">New 'byjove' Project</p> - </header> - <div class="card-content"> - <div class="content"> - ${h.form(request.current_route_url(), ref='byjoveForm')} - ${h.csrf_token(request)} - ${h.hidden('project_type', value='byjove')} + <div v-if="projectType == 'byjove'"> + ${h.form(request.current_route_url(), ref='byjoveForm')} + ${h.csrf_token(request)} + ${h.hidden('project_type', value='byjove')} - <b-field horizontal label="Name"> - <b-input name="name" v-model="byjove.name"></b-input> - </b-field> + <br /> + <div class="card"> + <header class="card-header"> + <p class="card-header-title">Naming</p> + </header> + <div class="card-content"> + <div class="content"> - <b-field horizontal label="Slug"> - <b-input name="slug" v-model="byjove.slug"></b-input> - </b-field> + <b-field horizontal label="Name"> + <b-input name="name" v-model="byjove.name"></b-input> + </b-field> - ${h.end_form()} + <b-field horizontal label="Slug"> + <b-input name="slug" v-model="byjove.slug"></b-input> + </b-field> + + </div> </div> </div> + + ${h.end_form()} </div> <br /> @@ -134,7 +216,7 @@ python_project_name: "Acme-Okay-Then", python_package_name: "okay_then", has_rattail_db: true, - extends_rattail_db_schema: false, + extends_rattail_db_schema: true, uses_rattail_batch_schema: false, has_tailbone_web_app: true, has_tailbone_web_api: false, diff --git a/tailbone/views/projects.py b/tailbone/views/projects.py index df6d072c..8060de4d 100644 --- a/tailbone/views/projects.py +++ b/tailbone/views/projects.py @@ -120,45 +120,16 @@ class GenerateProjectView(View): schema = GenerateByjoveProject if project_type == 'byjove' else GenerateProject form = forms.Form(schema=schema(), request=self.request, use_buefy=use_buefy) - form.submit_label = "Generate Project" - form.auto_disable = False - form.auto_disable_save = False if form.validate(newstyle=True): zipped = self.generate_project(project_type, form) return self.file_response(zipped) # self.request.session.flash("New project was generated: {}".format(form.validated['name'])) # return self.redirect(self.request.current_route_url()) - form.set_label('python_name', "Python Package Name") - form.set_label('has_db', "Has Rattail DB") - form.set_label('extends_db', "Extends Rattail DB Schema") - form.set_label('has_batch_schema', "Uses Rattail Batch Schema") - form.set_label('has_web', "Has Tailbone Web App") - form.set_label('has_web_api', "Has Tailbone Web API") - form.set_label('has_datasync', "Has DataSync Service") - # form.set_label('has_filemon', "Has FileMon Service") - # form.set_label('has_tempmon', "Has TempMon Service") - # form.set_label('has_bouncer', "Has Bouncer Service") - form.set_label('integrates_catapult', "Integrates w/ Catapult") - form.set_label('integrates_corepos', "Integrates w/ CORE-POS") - # form.set_label('integrates_instacart', "Integrates w/ Instacart") - form.set_label('integrates_locsms', "Integrates w/ LOC SMS") - # form.set_label('integrates_mailchimp', "Integrates w/ Mailchimp") - - # TODO! - form.set_default('name', 'Okay-Then') - form.set_default('slug', 'okay-then') - form.set_default('organization', 'Acme') - form.set_default('python_project_name', 'Acme-Okay-Then') - form.set_default('python_name', 'okay_then') - form.set_default('has_db', True) - form.set_default('has_web', True) - return { 'index_title': "Generate Project", 'handler': self.handler, # 'choices': choices, - 'form': form, 'use_buefy': use_buefy, } From f8d9b0803c2aaa2763a01fd7f302ca4eae47a1d6 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 6 Sep 2020 15:33:19 -0500 Subject: [PATCH 0200/1681] Add some help text to new project form, etc. --- tailbone/templates/generate_project.mako | 45 ++++++++++++++++-------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/tailbone/templates/generate_project.mako b/tailbone/templates/generate_project.mako index 419f12c7..a4936e69 100644 --- a/tailbone/templates/generate_project.mako +++ b/tailbone/templates/generate_project.mako @@ -25,23 +25,28 @@ <div class="card-content"> <div class="content"> - <b-field horizontal label="Name"> + <b-field horizontal label="Name" + message="The "canonical" name generally used to refer to this project"> <b-input name="name" v-model="rattail.name"></b-input> </b-field> - <b-field horizontal label="Slug"> + <b-field horizontal label="Slug" + message="Used for e.g. naming the project source code folder"> <b-input name="slug" v-model="rattail.slug"></b-input> </b-field> - <b-field horizontal label="Organization"> + <b-field horizontal label="Organization" + message="For use with "branding" etc."> <b-input name="organization" v-model="rattail.organization"></b-input> </b-field> - <b-field horizontal label="Python Project Name"> + <b-field horizontal label="Package Name for PyPI" + message="It's a good idea to use org name as namespace prefix here"> <b-input name="python_project_name" v-model="rattail.python_project_name"></b-input> </b-field> - <b-field horizontal label="Python Package Name"> + <b-field horizontal label="Package Name in Python" + :message="`For example, ~/src/${'$'}{rattail.slug}/${'$'}{rattail.python_package_name}/__init__.py`"> <b-input name="python_name" v-model="rattail.python_package_name"></b-input> </b-field> @@ -56,21 +61,25 @@ <div class="card-content"> <div class="content"> - <b-field horizontal label="Has Rattail DB"> + <b-field horizontal label="Has Rattail DB" + message="Note that a DB is required for the Web App"> <b-checkbox name="has_db" v-model="rattail.has_rattail_db" native-value="true"> </b-checkbox> </b-field> - <b-field horizontal label="Extends Rattail DB Schema"> + <b-field horizontal label="Extends Rattail DB Schema" + message="For adding custom tables/columns to the core schema"> <b-checkbox name="extends_db" v-model="rattail.extends_rattail_db_schema" native-value="true"> </b-checkbox> </b-field> - <b-field horizontal label="Uses Rattail Batch Schema"> + <b-field horizontal label="Uses Rattail Batch Schema" + v-show="false" + message="Needed for "dynamic" (e.g. import/export) batches"> <b-checkbox name="has_batch_schema" v-model="rattail.uses_rattail_batch_schema" native-value="true"> @@ -95,7 +104,9 @@ </b-checkbox> </b-field> - <b-field horizontal label="Has Tailbone Web API"> + <b-field horizontal label="Has Tailbone Web API" + v-show="false" + message="Needed for e.g. Vue.js SPA mobile apps"> <b-checkbox name="has_web_api" v-model="rattail.has_tailbone_web_api" native-value="true"> @@ -113,28 +124,32 @@ <div class="card-content"> <div class="content"> - <b-field horizontal label="Integrates w/ Catapult"> + <b-field horizontal label="Integrates w/ Catapult" + message="Add schema, import/export logic etc. for ECRS Catapult"> <b-checkbox name="integrates_catapult" v-model="rattail.integrates_with_catapult" native-value="true"> </b-checkbox> </b-field> - <b-field horizontal label="Integrates w/ CORE-POS"> + <b-field horizontal label="Integrates w/ CORE-POS" + v-show="false"> <b-checkbox name="integrates_corepos" v-model="rattail.integrates_with_corepos" native-value="true"> </b-checkbox> </b-field> - <b-field horizontal label="Integrates w/ LOC SMS"> + <b-field horizontal label="Integrates w/ LOC SMS" + v-show="false"> <b-checkbox name="integrates_corepos" v-model="rattail.integrates_with_locsms" native-value="true"> </b-checkbox> </b-field> - <b-field horizontal label="Has DataSync Service"> + <b-field horizontal label="Has DataSync Service" + v-show="false"> <b-checkbox name="has_datasync" v-model="rattail.has_datasync_service" native-value="true"> @@ -212,7 +227,7 @@ ThisPageData.rattail = { name: "Okay-Then", slug: "okay-then", - organization: "Acme", + organization: "Acme Foods", python_project_name: "Acme-Okay-Then", python_package_name: "okay_then", has_rattail_db: true, @@ -224,7 +239,7 @@ integrates_with_catapult: false, integrates_with_corepos: false, integrates_with_locsms: false, - uses_fabric: false, + uses_fabric: true, } ThisPageData.byjove = { From 7df5838bc0aa78d646bd615bc1a305169760e123 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 8 Sep 2020 19:01:34 -0500 Subject: [PATCH 0201/1681] Require permission to generate a new project --- tailbone/views/projects.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/projects.py b/tailbone/views/projects.py index 8060de4d..6994a513 100644 --- a/tailbone/views/projects.py +++ b/tailbone/views/projects.py @@ -156,6 +156,7 @@ class GenerateProjectView(View): "Generate new project source code") config.add_route('generate_project', '/generate-project') config.add_view(cls, route_name='generate_project', + permission='common.generate_project', renderer='/generate_project.mako') From 3eb929aa13af0b839ecbb881b7a16e3ea80dee02 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 8 Sep 2020 19:05:36 -0500 Subject: [PATCH 0202/1681] Hide the 'byjove' option for generating new project until we actually support it --- tailbone/templates/generate_project.mako | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/templates/generate_project.mako b/tailbone/templates/generate_project.mako index a4936e69..9dd9aabb 100644 --- a/tailbone/templates/generate_project.mako +++ b/tailbone/templates/generate_project.mako @@ -9,7 +9,7 @@ <b-field horizontal label="Project Type"> <b-select v-model="projectType"> <option value="rattail">rattail</option> - <option value="byjove">byjove</option> + ## <option value="byjove">byjove</option> </b-select> </b-field> From e6da1152caa2ea79871072df9eef90a2ead03467 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 14 Sep 2020 13:10:12 -0500 Subject: [PATCH 0203/1681] 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 e3ff8171..e6952f8d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.8.107 (2020-09-14) +-------------------- + +* Stop including 'complete' filter by default for purchasing batches. + +* Overhaul project changelog links for upgrade pkg diff table. + +* Add support/views for generating new custom projects, via handler. + + 0.8.106 (2020-09-02) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 270801a0..5787f15f 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.106' +__version__ = '0.8.107' From 32cfe58601b8f6312ad2cdc869b4215a20217cf7 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 15 Sep 2020 09:33:27 -0500 Subject: [PATCH 0204/1681] Allow custom props for TailboneForm component --- tailbone/templates/forms/deform_buefy.mako | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/templates/forms/deform_buefy.mako b/tailbone/templates/forms/deform_buefy.mako index 3d60decc..7bd20139 100644 --- a/tailbone/templates/forms/deform_buefy.mako +++ b/tailbone/templates/forms/deform_buefy.mako @@ -87,6 +87,7 @@ let ${form.component_studly} = { template: '#${form.component}-template', components: {}, + props: {}, methods: { ## TODO: deprecate / remove the latter option here From dd2b634ed2afb1bcebb35d08990dca73a277f98b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 15 Sep 2020 18:34:00 -0500 Subject: [PATCH 0205/1681] Remove some custom field labels for Vendor should use `labels` dict if really needed, but they don't seem to be --- tailbone/views/vendors/core.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tailbone/views/vendors/core.py b/tailbone/views/vendors/core.py index 7f9c064e..4a46a075 100644 --- a/tailbone/views/vendors/core.py +++ b/tailbone/views/vendors/core.py @@ -89,10 +89,6 @@ class VendorsView(MasterView): super(VendorsView, self).configure_form(f) vendor = f.model_instance - f.set_label('lead_time_days', "Lead Time in Days") - - f.set_label('order_interval', "Order Interval in Days") - # default_phone f.set_renderer('default_phone', self.render_default_phone) if not self.creating and vendor.phones: From 652e951f89a232f6c73e89424099cb0c5789752f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 16 Sep 2020 16:34:15 -0500 Subject: [PATCH 0206/1681] Add support for generating new 'fabric' project --- tailbone/templates/generate_project.mako | 92 ++++++++++++++++++++++++ tailbone/views/projects.py | 29 +++++++- 2 files changed, 119 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/generate_project.mako b/tailbone/templates/generate_project.mako index 9dd9aabb..136a0f99 100644 --- a/tailbone/templates/generate_project.mako +++ b/tailbone/templates/generate_project.mako @@ -10,6 +10,7 @@ <b-select v-model="projectType"> <option value="rattail">rattail</option> ## <option value="byjove">byjove</option> + <option value="fabric">fabric</option> </b-select> </b-field> @@ -208,6 +209,87 @@ ${h.end_form()} </div> + <div v-if="projectType == 'fabric'"> + ${h.form(request.current_route_url(), ref='fabricForm')} + ${h.csrf_token(request)} + ${h.hidden('project_type', value='fabric')} + + <br /> + <div class="card"> + <header class="card-header"> + <p class="card-header-title">Naming</p> + </header> + <div class="card-content"> + <div class="content"> + + <b-field horizontal label="Name"> + <b-input name="name" v-model="fabric.name"></b-input> + </b-field> + + <b-field horizontal label="Slug"> + <b-input name="slug" v-model="fabric.slug"></b-input> + </b-field> + + <b-field horizontal label="Organization" + message="For use with "branding" etc."> + <b-input name="organization" v-model="fabric.organization"></b-input> + </b-field> + + <b-field horizontal label="Package Name for PyPI" + message="It's a good idea to use org name as namespace prefix here"> + <b-input name="python_project_name" v-model="fabric.python_project_name"></b-input> + </b-field> + + <b-field horizontal label="Package Name in Python" + :message="`For example, ~/src/${'$'}{fabric.slug}/${'$'}{fabric.python_package_name}/__init__.py`"> + <b-input name="python_name" v-model="fabric.python_package_name"></b-input> + </b-field> + + </div> + </div> + </div> + + <br /> + <div class="card"> + <header class="card-header"> + <p class="card-header-title">Misc.</p> + </header> + <div class="card-content"> + <div class="content"> + + <b-field horizontal label="Time Zone" + message="for possible values see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List"> + <b-input name="timezone" v-model="fabric.timezone"></b-input> + </b-field> + + </div> + </div> + </div> + + <br /> + <div class="card"> + <header class="card-header"> + <p class="card-header-title">Theo</p> + </header> + <div class="card-content"> + <div class="content"> + + <b-field horizontal label="Integrates With"> + <b-select name="integrates_with" v-model="fabric.integrates_with"> + <option value="">(nothing)</option> + <option value="catapult">Catapult</option> + <option value="corepos">CORE-POS</option> + ## <option value="locsms">LOC SMS</option> + </b-select> + </b-field> + + </div> + </div> + </div> + + ${h.end_form()} + </div> + <br /> <div class="buttons" style="padding-left: 8rem;"> <b-button type="is-primary" @@ -247,6 +329,16 @@ slug: "okay-then-mobile", } + ThisPageData.fabric = { + name: "AcmeFab", + slug: "acmefab", + organization: "Acme Foods", + python_project_name: "Acme-Fabric", + python_package_name: "acmefab", + timezone: 'America/Chicago', + integrates_with: '', + } + ThisPage.methods.submitProjectForm = function() { let form = this.$refs[this.projectType + 'Form'] form.submit() diff --git a/tailbone/views/projects.py b/tailbone/views/projects.py index 6994a513..9297afe0 100644 --- a/tailbone/views/projects.py +++ b/tailbone/views/projects.py @@ -90,6 +90,26 @@ class GenerateByjoveProject(colander.MappingSchema): slug = colander.SchemaNode(colander.String()) +class GenerateFabricProject(colander.MappingSchema): + """ + Schema for generating a new 'fabric' project + """ + name = colander.SchemaNode(colander.String()) + + slug = colander.SchemaNode(colander.String()) + + organization = colander.SchemaNode(colander.String()) + + python_project_name = colander.SchemaNode(colander.String()) + + python_name = colander.SchemaNode(colander.String()) + + timezone = colander.SchemaNode(colander.String()) + + integrates_with = colander.SchemaNode(colander.String(), + missing=colander.null) + + class GenerateProjectView(View): """ View for generating new project source code @@ -114,10 +134,15 @@ class GenerateProjectView(View): project_type = 'rattail' if self.request.method == 'POST': project_type = self.request.POST.get('project_type', 'rattail') - if project_type not in ('rattail', 'byjove'): + if project_type not in ('rattail', 'byjove', 'fabric'): raise ValueError("Unknown project type: {}".format(project_type)) - schema = GenerateByjoveProject if project_type == 'byjove' else GenerateProject + if project_type == 'byjove': + schema = GenerateByjoveProject + elif project_type == 'fabric': + schema = GenerateFabricProject + else: + schema = GenerateProject form = forms.Form(schema=schema(), request=self.request, use_buefy=use_buefy) if form.validate(newstyle=True): From cc5d0ed3c68761e16a72e164cfa912c77a0d928a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 16 Sep 2020 22:22:37 -0500 Subject: [PATCH 0207/1681] Tweak option label for Catapult when generating project --- tailbone/templates/generate_project.mako | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/templates/generate_project.mako b/tailbone/templates/generate_project.mako index 136a0f99..750213df 100644 --- a/tailbone/templates/generate_project.mako +++ b/tailbone/templates/generate_project.mako @@ -277,7 +277,7 @@ <b-field horizontal label="Integrates With"> <b-select name="integrates_with" v-model="fabric.integrates_with"> <option value="">(nothing)</option> - <option value="catapult">Catapult</option> + <option value="catapult">ECRS Catapult</option> <option value="corepos">CORE-POS</option> ## <option value="locsms">LOC SMS</option> </b-select> From 3ac8ca90ce437c47734ae6aa797906f01279d307 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 16 Sep 2020 22:23:28 -0500 Subject: [PATCH 0208/1681] 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 e6952f8d..860ed6dc 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.8.108 (2020-09-16) +-------------------- + +* Allow custom props for TailboneForm component. + +* Remove some custom field labels for Vendor. + +* Add support for generating new 'fabric' project. + + 0.8.107 (2020-09-14) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 5787f15f..3a76aeed 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.107' +__version__ = '0.8.108' From 37a60592f632759bcc33068827552bc29bcbdcde Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 17 Sep 2020 13:54:43 -0500 Subject: [PATCH 0209/1681] Add 'warning' class for 'delete' action in b-table grid --- tailbone/templates/grids/b-table.mako | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/templates/grids/b-table.mako b/tailbone/templates/grids/b-table.mako index 728d285d..ee257819 100644 --- a/tailbone/templates/grids/b-table.mako +++ b/tailbone/templates/grids/b-table.mako @@ -47,6 +47,7 @@ <b-table-column field="actions" label="Actions"> % for action in grid.main_actions: <a :href="props.row._action_url_${action.key}" + class="grid-action${' has-text-danger' if action.key == 'delete' else ''}" % if action.click_handler: @click.prevent="${action.click_handler}" % endif From 711ed947a386b4a20dff7421db6ec67c2f91b135 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 18 Sep 2020 12:17:04 -0500 Subject: [PATCH 0210/1681] Add "worksheet file" pattern for editing batches lets user download a worksheet, edit, then upload back to update the batch --- tailbone/forms/core.py | 3 + tailbone/templates/batch/view.mako | 159 ++++++++++++++++++++++++++++- tailbone/templates/form.mako | 2 +- tailbone/views/batch/core.py | 103 ++++++++++++++++++- tailbone/views/core.py | 9 +- 5 files changed, 262 insertions(+), 14 deletions(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 7441e4ab..8f0cbfff 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -623,6 +623,9 @@ class Form(object): if 'required' in kwargs and not kwargs['required']: kw['missing'] = colander.null self.set_node(key, colander.SchemaNode(deform.FileData(), **kw)) + # must explicitly replace node, if we already have a schema + if self.schema: + self.schema[key] = self.nodes[key] else: raise ValueError("unknown type for '{}' field: {}".format(key, type_)) diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako index c04ec8c4..b39ea376 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -10,7 +10,7 @@ var has_execution_options = ${'true' if master.has_execution_options(batch) else 'false'}; $(function() { - % if master.has_worksheet: + % if master.has_worksheet and master.allow_worksheet(batch) and master.has_perm('worksheet'): $('.load-worksheet').click(function() { disable_button(this); location.href = '${url('{}.worksheet'.format(route_prefix), uuid=batch.uuid)}'; @@ -24,6 +24,36 @@ location.href = '${url('{}.refresh'.format(route_prefix), uuid=batch.uuid)}'; }); % endif + % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'): + $('.upload-worksheet').click(function() { + $('#upload-worksheet-dialog').dialog({ + title: "Upload Worksheet", + width: 600, + modal: true, + buttons: [ + { + text: "Upload & Update Batch", + click: function(event) { + var form = $('form[name="upload-worksheet"]'); + var field = form.find('input[type="file"]').get(0); + if (!field.value) { + alert("Please choose a file to upload."); + return + } + disable_button(dialog_button(event)); + form.submit(); + } + }, + { + text: "Cancel", + click: function() { + $(this).dialog('close'); + } + } + ] + }); + }); + % endif }); </script> @@ -59,6 +89,7 @@ <div class="buttons"> ${self.leading_buttons()} ${refresh_button()} + ${self.trailing_buttons()} </div> </%def> @@ -81,7 +112,8 @@ ## TODO: this should surely use a POST request? <once-button tag="a" href="${url('{}.refresh'.format(route_prefix), uuid=batch.uuid)}" - text="Refresh Data"> + text="Refresh Data" + icon-left="fas fa-redo"> </once-button> % else: <button type="button" class="button" id="refresh-data">Refresh Data</button> @@ -89,6 +121,28 @@ % endif </%def> +<%def name="trailing_buttons()"> + % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'): + % if use_buefy: + <b-button tag="a" + href="${master.get_action_url('download_worksheet', batch)}" + icon-pack="fas" + icon-left="fas fa-download"> + Download Worksheet + </b-button> + <b-button type="is-primary" + icon-pack="fas" + icon-left="fas fa-upload" + @click="$emit('show-upload')"> + Upload Worksheet + </b-button> + % else: + ${h.link_to("Download Worksheet", master.get_action_url('download_worksheet', batch), class_='button')} + <button type="button" class="upload-worksheet">Upload Worksheet</button> + % endif + % endif +</%def> + <%def name="object_helpers()"> ${self.render_status_breakdown()} ${self.render_execute_helper()} @@ -206,6 +260,54 @@ <%def name="render_this_page()"> ${parent.render_this_page()} + + % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'): + % if use_buefy: + <b-modal has-modal-card + :active.sync="showUploadDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Upload Worksheet</p> + </header> + + <section class="modal-card-body"> + <p> + This will <span class="has-text-weight-bold">update</span> + the batch data with the worksheet file you provide. + Please be certain to use the right one! + </p> + <br /> + <${upload_worksheet_form.component} ref="uploadForm"> + </${upload_worksheet_form.component}> + </section> + + <footer class="modal-card-foot"> + <b-button @click="showUploadDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + @click="submitUpload()" + icon-pack="fas" + icon-left="fas fa-upload" + :disabled="uploadButtonDisabled"> + {{ uploadButtonText }} + </b-button> + </footer> + + </div> + </b-modal> + % else: + <div id="upload-worksheet-dialog" style="display: none;"> + <p> + This will <strong>update</strong> the batch data with the worksheet + file you provide. Please be certain to use the right one! + </p> + ${upload_worksheet_form.render_deform(buttons=False, form_kwargs={'name': 'upload-worksheet'})|n} + </div> + % endif + % endif + % if not use_buefy: % if master.handler.executable(batch) and master.has_perm('execute'): <div id="execution-options-dialog" style="display: none;"> @@ -213,22 +315,59 @@ </div> % endif % endif + </%def> <%def name="render_this_page_template()"> ${parent.render_this_page_template()} - % if use_buefy and master.handler.executable(batch) and master.has_perm('execute'): - ## TODO: stop using |n filter - ${execute_form.render_deform(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)|n} + % if use_buefy: + % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'): + ${upload_worksheet_form.render_deform(buttons=False, form_kwargs={'ref': 'actualUploadForm'})|n} + % endif + % if master.handler.executable(batch) and master.has_perm('execute'): + ${execute_form.render_deform(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)|n} + % endif % endif </%def> +<%def name="render_buefy_form()"> + <div class="form"> + <${form.component} @show-upload="showUploadDialog = true"> + </${form.component}> + </div> +</%def> + <%def name="modify_this_page_vars()"> ${parent.modify_this_page_vars()} <script type="text/javascript"> ThisPageData.statusBreakdownData = ${json.dumps(status_breakdown_grid.get_buefy_data()['data'])|n} + % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'): + + ThisPageData.showUploadDialog = false + ThisPageData.uploadButtonText = "Upload & Update Batch" + ThisPageData.uploadButtonDisabled = false + + ThisPage.methods.submitUpload = function() { + let form = this.$refs.uploadForm + let value = form.field_model_worksheet_file + if (!value) { + alert("Please choose a file to upload.") + return + } + this.uploadButtonDisabled = true + this.uploadButtonText = "Working, please wait..." + form.submit() + } + + ${upload_worksheet_form.component_studly}.methods.submit = function() { + this.$refs.actualUploadForm.submit() + } + + ## end 'external_worksheet' + % endif + % if not batch.executed and master.has_perm('execute'): ThisPageData.showExecutionDialog = false @@ -247,6 +386,16 @@ <%def name="make_this_page_component()"> ${parent.make_this_page_component()} + % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'): + <script type="text/javascript"> + + ## UploadForm + ${upload_worksheet_form.component_studly}.data = function() { return ${upload_worksheet_form.component_studly}Data } + Vue.component('${upload_worksheet_form.component}', ${upload_worksheet_form.component_studly}) + + </script> + % endif + % if not batch.executed and master.has_perm('execute'): <script type="text/javascript"> diff --git a/tailbone/templates/form.mako b/tailbone/templates/form.mako index 291c2741..5b11face 100644 --- a/tailbone/templates/form.mako +++ b/tailbone/templates/form.mako @@ -11,7 +11,7 @@ <%def name="render_buefy_form()"> <div class="form"> - <tailbone-form></tailbone-form> + <${form.component}></${form.component}> </div> </%def> diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index c8f055f3..2f3a2f25 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -87,6 +87,7 @@ class BatchMasterView(MasterView): mobile_filterable = True mobile_rows_viewable = True has_worksheet = False + has_worksheet_file = False grid_columns = [ 'id', @@ -168,6 +169,10 @@ class BatchMasterView(MasterView): batch = kwargs['instance'] kwargs['batch'] = batch kwargs['handler'] = self.handler + + if self.has_worksheet_file and self.allow_worksheet(batch) and self.has_perm('worksheet'): + kwargs['upload_worksheet_form'] = self.make_upload_worksheet_form(batch) + kwargs['execute_title'] = self.get_execute_title(batch) kwargs['execute_enabled'] = self.instance_executable(batch) if kwargs['mobile']: @@ -181,6 +186,7 @@ class BatchMasterView(MasterView): kwargs['execute_form'] = self.make_execute_form(batch, action_url=url) else: kwargs['why_not_execute'] = self.handler.why_not_execute(batch) + kwargs['status_breakdown'] = self.make_status_breakdown(batch) if use_buefy: data = [{'title': title, 'count': count} @@ -190,6 +196,71 @@ class BatchMasterView(MasterView): data, ['title', 'count']) return kwargs + def make_upload_worksheet_form(self, batch): + action_url = self.get_action_url('upload_worksheet', batch) + use_buefy = self.get_use_buefy() + form = forms.Form(schema=UploadWorksheet(), + request=self.request, + action_url=action_url, + use_buefy=use_buefy, + component='upload-worksheet-form') + form.set_type('worksheet_file', 'file') + # TODO: must set these to avoid some default Buefy code + form.auto_disable = False + form.auto_disable_save = False + return form + + def download_worksheet(self): + batch = self.get_instance() + path = self.handler.write_worksheet(batch) + root, ext = os.path.splitext(path) + # we present a more descriptive filename for download + filename = '{}.worksheet.{}{}'.format(batch.batch_key, batch.id_str, ext) + return self.file_response(path, filename=filename) + + def upload_worksheet(self): + batch = self.get_instance() + form = self.make_upload_worksheet_form(batch) + if self.validate_form(form): + uploads = self.normalize_uploads(form) + path = uploads['worksheet_file']['temp_path'] + return self.handler_action(batch, 'update_from_worksheet', path=path) + self.request.session.flash("Upload form did not validate!", 'error') + return self.redirect(self.get_action_url('view', batch)) + + def update_from_worksheet_thread(self, batch_uuid, user_uuid, progress, path=None): + """ + Thread target for updating a batch from worksheet. + """ + session = self.make_isolated_session() + batch = session.query(self.model_class).get(batch_uuid) + try: + self.handler.update_from_worksheet(batch, path, progress=progress) + + except Exception as error: + session.rollback() + log.exception("upload/update failed for '{}' batch: {}".format(self.batch_key, batch)) + session.close() + if progress: + progress.session.load() + progress.session['error'] = True + progress.session['error_msg'] = "Upload processing failed: {}".format( + simple_error(error)) + progress.session.save() + + else: + session.commit() + success_msg = "Batch has been updated: {}".format(batch) + success_url = self.get_action_url('view', batch) + session.close() + + if progress: + progress.session.load() + progress.session['complete'] = True + progress.session['success_msg'] = success_msg + progress.session['success_url'] = success_url + progress.session.save() + def make_status_breakdown(self, batch, rows=None, status_enum=None): """ Returns a simple list of 2-tuples, each of which has the status display @@ -1375,11 +1446,11 @@ class BatchMasterView(MasterView): # If no error, check result flag (false means user canceled). else: + success_msg = None if result: session.commit() - # TODO: this doesn't always work...? - self.request.session.flash("{} has been executed: {}".format( - self.get_model_title(), batch.id_str)) + success_msg = "{} has been executed: {}".format( + self.get_model_title(), batch.id_str) else: session.rollback() @@ -1391,6 +1462,8 @@ class BatchMasterView(MasterView): progress.session.load() progress.session['complete'] = True progress.session['success_url'] = success_url + if success_msg: + progress.session['success_msg'] = success_msg progress.session.save() def get_execute_success_url(self, batch, result, **kwargs): @@ -1499,6 +1572,7 @@ class BatchMasterView(MasterView): model_key = cls.get_model_key() route_prefix = cls.get_route_prefix() url_prefix = cls.get_url_prefix() + instance_url_prefix = cls.get_instance_url_prefix() permission_prefix = cls.get_permission_prefix() model_title = cls.get_model_title() model_title_plural = cls.get_model_title_plural() @@ -1514,9 +1588,10 @@ class BatchMasterView(MasterView): permission='{}.create'.format(permission_prefix)) # worksheet - if cls.has_worksheet: + if cls.has_worksheet or cls.has_worksheet_file: config.add_tailbone_permission(permission_prefix, '{}.worksheet'.format(permission_prefix), "Edit {} data as worksheet".format(model_title)) + if cls.has_worksheet: config.add_route('{}.worksheet'.format(route_prefix), '{}/{{{}}}/worksheet'.format(url_prefix, model_key)) config.add_view(cls, attr='worksheet', route_name='{}.worksheet'.format(route_prefix), permission='{}.worksheet'.format(permission_prefix)) @@ -1525,6 +1600,20 @@ class BatchMasterView(MasterView): config.add_view(cls, attr='worksheet_update', route_name='{}.worksheet_update'.format(route_prefix), renderer='json', permission='{}.worksheet'.format(permission_prefix)) + # worksheet file + if cls.has_worksheet_file: + + # download worksheet + config.add_route('{}.download_worksheet'.format(route_prefix), '{}/download-worksheet'.format(instance_url_prefix)) + config.add_view(cls, attr='download_worksheet', route_name='{}.download_worksheet'.format(route_prefix), + permission='{}.worksheet'.format(permission_prefix)) + + # upload worksheet + config.add_route('{}.upload_worksheet'.format(route_prefix), '{}/upload-worksheet'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='upload_worksheet', route_name='{}.upload_worksheet'.format(route_prefix), + permission='{}.worksheet'.format(permission_prefix)) + # refresh batch data if cls.refreshable: config.add_route('{}.refresh'.format(route_prefix), '{}/{{uuid}}/refresh'.format(url_prefix)) @@ -1611,6 +1700,12 @@ class FileBatchMasterView(BatchMasterView): f.set_renderer('filename', self.render_downloadable_file) +class UploadWorksheet(colander.Schema): + + # this node is actually "replaced" when form is configured + worksheet_file = colander.SchemaNode(colander.String()) + + class ToggleComplete(colander.MappingSchema): complete = colander.SchemaNode(colander.Boolean()) diff --git a/tailbone/views/core.py b/tailbone/views/core.py index a3152a8b..e04aa7fa 100644 --- a/tailbone/views/core.py +++ b/tailbone/views/core.py @@ -144,7 +144,7 @@ class View(object): return render_to_response('json', data, request=self.request) - def file_response(self, path): + def file_response(self, path, filename=None): """ Returns a generic FileResponse from the given path """ @@ -152,9 +152,10 @@ class View(object): return self.notfound() response = FileResponse(path, request=self.request) response.content_length = os.path.getsize(path) - filename = os.path.basename(path) - if six.PY2: - filename = filename.encode('ascii', 'replace') + if not filename: + filename = os.path.basename(path) + if six.PY2: + filename = filename.encode('ascii', 'replace') response.content_disposition = str('attachment; filename="{}"'.format(filename)) return response From 149ae4b71c5f0db21177658b5c3502b4b498a150 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 20 Sep 2020 16:32:44 -0500 Subject: [PATCH 0211/1681] Avoid unhelpful error when perm check happens for "re-created" DB user kind of an edge case, should only apply to dev --- tailbone/auth.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tailbone/auth.py b/tailbone/auth.py index 9db292ad..338fac55 100644 --- a/tailbone/auth.py +++ b/tailbone/auth.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -101,7 +101,10 @@ class TailboneAuthorizationPolicy(object): if context.request.user and context.request.user.uuid == userid: return context.request.has_perm(permission) else: - assert False # should no longer happen..right? + # this is pretty rare, but can happen in dev after + # re-creating the database, which means new user uuids. + # TODO: the odds of this query returning a user in that + # case, are probably nil, and we should just skip this bit? user = Session.query(model.User).get(userid) if user: if has_permission(Session(), user, permission): From d146514c399a630cf91f72869f09dbd5ac0a5f39 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 20 Sep 2020 17:41:04 -0500 Subject: [PATCH 0212/1681] Prompt user if they try to send email preview w/ no address --- tailbone/templates/settings/email/view.mako | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/settings/email/view.mako b/tailbone/templates/settings/email/view.mako index e7568398..be4f5774 100644 --- a/tailbone/templates/settings/email/view.mako +++ b/tailbone/templates/settings/email/view.mako @@ -92,7 +92,7 @@ </div> <div class="control"> - <input name="recipient" type="email" class="input" value="${request.user.email_address or ''}" /> + <b-input name="recipient" v-model="userEmailAddress"></b-input> </div> <div class="control"> @@ -119,10 +119,16 @@ return { previewFormButtonText: "Send Preview Email", previewFormSubmitting: false, + userEmailAddress: ${json.dumps(request.user.email_address)|n}, } }, methods: { - submitPreviewForm() { + submitPreviewForm(event) { + if (!this.userEmailAddress) { + alert("Please provide an email address.") + event.preventDefault() + return + } this.previewFormSubmitting = true this.previewFormButtonText = "Working, please wait..." } From 2d2924503782466a99b948cd082efc59cd885779 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 20 Sep 2020 18:01:23 -0500 Subject: [PATCH 0213/1681] Don't expose "timezone" for input when generating 'fabric' project static default is good enough for that --- tailbone/templates/generate_project.mako | 18 ------------------ tailbone/views/projects.py | 2 -- 2 files changed, 20 deletions(-) diff --git a/tailbone/templates/generate_project.mako b/tailbone/templates/generate_project.mako index 750213df..53e5db54 100644 --- a/tailbone/templates/generate_project.mako +++ b/tailbone/templates/generate_project.mako @@ -249,23 +249,6 @@ </div> </div> - <br /> - <div class="card"> - <header class="card-header"> - <p class="card-header-title">Misc.</p> - </header> - <div class="card-content"> - <div class="content"> - - <b-field horizontal label="Time Zone" - message="for possible values see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List"> - <b-input name="timezone" v-model="fabric.timezone"></b-input> - </b-field> - - </div> - </div> - </div> - <br /> <div class="card"> <header class="card-header"> @@ -335,7 +318,6 @@ organization: "Acme Foods", python_project_name: "Acme-Fabric", python_package_name: "acmefab", - timezone: 'America/Chicago', integrates_with: '', } diff --git a/tailbone/views/projects.py b/tailbone/views/projects.py index 9297afe0..1770e021 100644 --- a/tailbone/views/projects.py +++ b/tailbone/views/projects.py @@ -104,8 +104,6 @@ class GenerateFabricProject(colander.MappingSchema): python_name = colander.SchemaNode(colander.String()) - timezone = colander.SchemaNode(colander.String()) - integrates_with = colander.SchemaNode(colander.String(), missing=colander.null) From f37a9963f68ce910b22017dd199e83d2a1c941ad Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 20 Sep 2020 18:04:44 -0500 Subject: [PATCH 0214/1681] Add some more field hints when generating 'fabric' project --- tailbone/templates/generate_project.mako | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tailbone/templates/generate_project.mako b/tailbone/templates/generate_project.mako index 53e5db54..dc4a2f06 100644 --- a/tailbone/templates/generate_project.mako +++ b/tailbone/templates/generate_project.mako @@ -222,11 +222,13 @@ <div class="card-content"> <div class="content"> - <b-field horizontal label="Name"> + <b-field horizontal label="Name" + message="The "canonical" name generally used to refer to this project"> <b-input name="name" v-model="fabric.name"></b-input> </b-field> - <b-field horizontal label="Slug"> + <b-field horizontal label="Slug" + message="Used for e.g. naming the project source code folder"> <b-input name="slug" v-model="fabric.slug"></b-input> </b-field> @@ -257,7 +259,8 @@ <div class="card-content"> <div class="content"> - <b-field horizontal label="Integrates With"> + <b-field horizontal label="Integrates With" + message="Which POS system should Theo integrate with, if any"> <b-select name="integrates_with" v-model="fabric.integrates_with"> <option value="">(nothing)</option> <option value="catapult">ECRS Catapult</option> From 4b4faae0095bcebbb6da206a68adbac285df92fe Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 20 Sep 2020 19:55:33 -0500 Subject: [PATCH 0215/1681] Show node title in header, for home page --- tailbone/views/common.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/common.py b/tailbone/views/common.py index 39e938b6..09525140 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -71,6 +71,7 @@ class CommonView(View): 'image_url': image_url, 'use_buefy': self.get_use_buefy(), 'help_url': global_help_url(self.rattail_config), + 'index_title': self.rattail_config.node_title(), } if self.expose_quickie_search: From 6709d97abcac6093b1529174a7b35806a32c2493 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 20 Sep 2020 19:57:27 -0500 Subject: [PATCH 0216/1681] Only show node title in home page header, for buefy themes it's just redundant for the old jquery theme --- tailbone/views/common.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tailbone/views/common.py b/tailbone/views/common.py index 09525140..2535f1d7 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -67,12 +67,14 @@ class CommonView(View): 'tailbone', 'main_image_url', default=self.request.static_url('tailbone:static/img/home_logo.png')) + use_buefy = self.get_use_buefy() context = { 'image_url': image_url, - 'use_buefy': self.get_use_buefy(), + 'use_buefy': use_buefy, 'help_url': global_help_url(self.rattail_config), - 'index_title': self.rattail_config.node_title(), } + if use_buefy: + context['index_title'] = self.rattail_config.node_title() if self.expose_quickie_search: context['quickie'] = self.get_quickie_context() From af11511d24a2365246eb71b1356ec3a7a60693c1 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 20 Sep 2020 23:35:07 -0500 Subject: [PATCH 0217/1681] Remove unwanted columns for default Products grid --- tailbone/views/products.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 1cac0c19..cce79659 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -102,9 +102,6 @@ class ProductsView(MasterView): 'size', 'department', 'vendor', - 'cost', - 'true_cost', - 'true_margin', 'regular_price', 'current_price', ] From 77fa2a78d474378504c7ea2411db267a6b7c532b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 22 Sep 2020 19:40:47 -0500 Subject: [PATCH 0218/1681] Update changelog --- CHANGES.rst | 20 ++++++++++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 860ed6dc..d462e851 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,26 @@ CHANGELOG ========= +0.8.109 (2020-09-22) +-------------------- + +* Add 'warning' class for 'delete' action in b-table grid. + +* Add "worksheet file" pattern for editing batches. + +* Avoid unhelpful error when perm check happens for "re-created" DB user. + +* Prompt user if they try to send email preview w/ no address. + +* Don't expose "timezone" for input when generating 'fabric' project. + +* Add some more field hints when generating 'fabric' project. + +* Show node title in header, for home page. + +* Remove unwanted columns for default Products grid. + + 0.8.108 (2020-09-16) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 3a76aeed..00954084 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.108' +__version__ = '0.8.109' From 746db72046603ccef5b25ac0d3377bfb8120e1b6 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 23 Sep 2020 16:28:54 -0500 Subject: [PATCH 0219/1681] Add `user_is_protected()` method to core View class also, don't allow "protected" users to change their own password --- tailbone/views/auth.py | 4 ++++ tailbone/views/core.py | 16 +++++++++++++++- tailbone/views/users.py | 18 ------------------ 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index 4765e8e8..5eb0cc53 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -172,6 +172,10 @@ class AuthenticationView(View): if not self.request.user: return self.redirect(self.request.route_url('home')) + if self.user_is_protected(self.request.user): + self.request.session.flash("Cannot change password for user: {}".format(self.request.user)) + return self.redirect(self.request.get_referrer()) + use_buefy = self.get_use_buefy() schema = ChangePassword().bind(user=self.request.user) form = forms.Form(schema=schema, request=self.request, use_buefy=use_buefy) diff --git a/tailbone/views/core.py b/tailbone/views/core.py index e04aa7fa..9b6a5d38 100644 --- a/tailbone/views/core.py +++ b/tailbone/views/core.py @@ -42,7 +42,7 @@ from tailbone.db import Session from tailbone.auth import logout_user from tailbone.progress import SessionProgress from tailbone.util import should_use_buefy -from tailbone.config import legacy_mobile_enabled +from tailbone.config import legacy_mobile_enabled, protected_usernames class View(object): @@ -110,6 +110,20 @@ class View(object): if uuid: return Session.query(model.User).get(uuid) + def user_is_protected(self, user): + """ + This logic will consult the settings for a list of "protected" + usernames, which should require root privileges to edit. If the given + ``user`` object is represented in this list, it is considered to be + protected and this method will return ``True``; otherwise it returns + ``False``. + """ + if not hasattr(self, 'protected_usernames'): + self.protected_usernames = protected_usernames(self.rattail_config) + if self.protected_usernames and user.username in self.protected_usernames: + return True + return False + def redirect(self, url, **kwargs): """ Convenience method to return a HTTP 302 response. diff --git a/tailbone/views/users.py b/tailbone/views/users.py index 078e99ca..310967eb 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -42,7 +42,6 @@ from tailbone import forms from tailbone.db import Session from tailbone.views import MasterView from tailbone.views.principal import PrincipalMasterView, PermissionsRenderer -from tailbone.config import protected_usernames class UsersView(PrincipalMasterView): @@ -154,23 +153,6 @@ class UsersView(PrincipalMasterView): return True return not self.user_is_protected(user) - def user_is_protected(self, user): - """ - This logic will consult the settings, for a list of "protected" - usernames, which should require root privileges to edit. If no setting - is found, or the given ``user`` is not represented in the setting, then - edit is allowed. - - But if there is a setting, and the ``user`` is represented in it, then - this method will return ``True`` only if the "current" app user is - "root", otherwise will return ``False``. - """ - if not hasattr(self, 'protected_usernames'): - self.protected_usernames = protected_usernames(self.rattail_config) - if self.protected_usernames and user.username in self.protected_usernames: - return True - return False - def unique_username(self, node, value): query = self.Session.query(model.User)\ .filter(model.User.username == value) From 24cc4b427272f362628d3b5317acfe500418343e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 23 Sep 2020 16:39:44 -0500 Subject: [PATCH 0220/1681] Change how we protect certain person, employee records --- tailbone/views/employees.py | 18 ++++++++++++------ tailbone/views/people.py | 18 ++++++++++++------ 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/tailbone/views/employees.py b/tailbone/views/employees.py index dac93e67..d7cad068 100644 --- a/tailbone/views/employees.py +++ b/tailbone/views/employees.py @@ -164,15 +164,21 @@ class EmployeesView(MasterView): if employee.status == self.enum.EMPLOYEE_STATUS_FORMER: return 'warning' + def is_employee_protected(self, employee): + for user in employee.person.users: + if self.user_is_protected(user): + return True + return False + def editable_instance(self, employee): - if self.rattail_config.demo(): - return not bool(employee.user and employee.user.username == 'chuck') - return True + if self.request.is_root: + return True + return not self.is_employee_protected(employee) def deletable_instance(self, employee): - if self.rattail_config.demo(): - return not bool(employee.user and employee.user.username == 'chuck') - return True + if self.request.is_root: + return True + return not self.is_employee_protected(employee) def configure_form(self, f): super(EmployeesView, self).configure_form(f) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index f21a88b6..17f7fb67 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -145,15 +145,21 @@ class PeopleView(MasterView): return instance.person raise HTTPNotFound + def is_person_protected(self, person): + for user in person.users: + if self.user_is_protected(user): + return True + return False + def editable_instance(self, person): - if self.rattail_config.demo(): - return not bool(person.user and person.user.username == 'chuck') - return True + if self.request.is_root: + return True + return not self.is_person_protected(person) def deletable_instance(self, person): - if self.rattail_config.demo(): - return not bool(person.user and person.user.username == 'chuck') - return True + if self.request.is_root: + return True + return not self.is_person_protected(person) def delete_instance(self, person): """ From 2d699b3e43c33ab83bd6b8c860488bb8b71a7586 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 23 Sep 2020 18:32:53 -0500 Subject: [PATCH 0221/1681] Add global help URL to login template --- tailbone/views/auth.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index 5eb0cc53..ef041f99 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -37,6 +37,7 @@ from tailbone import forms from tailbone.db import Session from tailbone.views import View from tailbone.auth import login_user, logout_user +from tailbone.config import global_help_url class UserLogin(colander.MappingSchema): @@ -128,6 +129,7 @@ class AuthenticationView(View): 'referrer': referrer, 'image_url': image_url, 'use_buefy': use_buefy, + 'help_url': global_help_url(self.rattail_config), } def authenticate_user(self, username, password): From c79b63e270a941a8e233ae71f0905c3633021d21 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 23 Sep 2020 20:42:43 -0500 Subject: [PATCH 0222/1681] Fix bug when fetching partial versions data grid e.g. when requesting new page of data --- tailbone/views/master.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 6b6c8305..cae51b89 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -1243,6 +1243,7 @@ class MasterView(View): instance = self.get_instance() instance_title = self.get_instance_title(instance) grid = self.make_version_grid(instance=instance) + use_buefy = self.get_use_buefy() # return grid only, if partial page was requested if self.request.params.get('partial'): From 9dc9bd162f24fc54cd3dea6c25c67d03feaba8e8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 24 Sep 2020 13:54:46 -0500 Subject: [PATCH 0223/1681] 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 d462e851..9025959b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,18 @@ CHANGELOG ========= +0.8.110 (2020-09-24) +-------------------- + +* Add ``user_is_protected()`` method to core View class. + +* Change how we protect certain person, employee records. + +* Add global help URL to login template. + +* Fix bug when fetching partial versions data grid. + + 0.8.109 (2020-09-22) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 00954084..9e0de214 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.109' +__version__ = '0.8.110' From 5b05f9426f2d6950763ce6b346b2e6eef57dccc9 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 25 Sep 2020 16:04:32 -0500 Subject: [PATCH 0224/1681] Allow alternate engine to act as 'default' when multiple are available --- tailbone/views/master.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index cae51b89..729948d2 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2471,12 +2471,22 @@ class MasterView(View): return ['/mobile/master/{}.mako'.format(template)] return ['/master/{}.mako'.format(template)] + def get_default_engine_dbkey(self): + """ + Returns the "default" engine dbkey. + """ + return self.rattail_config.get( + 'tailbone', + 'engines.{}.pretend_default'.format(self.engine_type_key), + default='default') + def get_current_engine_dbkey(self): """ Returns the "current" engine's dbkey, for the current user. """ + default = self.get_default_engine_dbkey() return self.request.session.get('tailbone.engines.{}.current'.format(self.engine_type_key), - 'default') + default) def template_kwargs(self, **kwargs): """ From 20c31cbb076eb676c7e8105e626bf22ac8634133 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 25 Sep 2020 16:05:07 -0500 Subject: [PATCH 0225/1681] Fix grid bug when paginator is not involved --- tailbone/grids/core.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 442568de..a6672270 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -1194,8 +1194,16 @@ class Grid(object): status_map = {} checked = [] + # we check for 'all' method and if so, assume we have a Query; + # otherwise we assume it's something we can use len() with, which could + # be a list or a Paginator + if hasattr(raw_data, 'all'): + count = raw_data.count() + else: + count = len(raw_data) + # iterate over data rows - for i in range(len(raw_data)): + for i in range(count): rowobj = raw_data[i] row = {} @@ -1262,6 +1270,8 @@ class Grid(object): results['pages'] = self.pager.page_count results['first_item'] = self.pager.first_item results['last_item'] = self.pager.last_item + else: + results['total_items'] = count return results From 18b9f43eaae4585b11554b0063f70505eb04c663 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 25 Sep 2020 17:55:39 -0500 Subject: [PATCH 0226/1681] 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 9025959b..a1db102b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.111 (2020-09-25) +-------------------- + +* Allow alternate engine to act as 'default' when multiple are available. + +* Fix grid bug when paginator is not involved. + + 0.8.110 (2020-09-24) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 9e0de214..cfd6d60d 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.110' +__version__ = '0.8.111' From 37a05155e5f8640af1a10e829aefea57c8c2c401 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 25 Sep 2020 23:23:01 -0500 Subject: [PATCH 0227/1681] Add support for "list" type of app settings (w/ textarea) --- tailbone/templates/appsettings.mako | 6 ++++++ tailbone/views/settings.py | 20 +++++++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/tailbone/templates/appsettings.mako b/tailbone/templates/appsettings.mako index 36783c69..888a5b2a 100644 --- a/tailbone/templates/appsettings.mako +++ b/tailbone/templates/appsettings.mako @@ -108,6 +108,12 @@ v-model="setting.value" value="true" /> + <b-input v-else-if="setting.data_type == 'list'" + type="textarea" + :name="setting.field_name" + v-model="setting.value"> + </b-input> + <b-select v-else-if="setting.choices" :name="setting.field_name" :id="setting.field_name" diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index 83afb2ff..ae580337 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -226,6 +226,9 @@ class AppSettingsView(View): def get_setting_value(self, setting): if setting.data_type is bool: return self.rattail_config.getbool(setting.namespace, setting.name) + if setting.data_type is list: + return '\n'.join( + self.rattail_config.getlist(setting.namespace, setting.name)) return self.rattail_config.get(setting.namespace, setting.name) def save_setting_value(self, setting, value): @@ -234,10 +237,25 @@ class AppSettingsView(View): legacy_name = '{}.{}'.format(setting.namespace, setting.name) if setting.data_type is bool: value = 'true' if value else 'false' + elif setting.data_type is list: + entries = [self.clean_list_entry(entry) + for entry in value.split('\n')] + value = ', '.join(entries) else: value = six.text_type(value) api.save_setting(Session(), legacy_name, value) + def clean_list_entry(self, value): + value = value.strip() + if '"' in value and "'" in value: + raise NotImplementedError("don't know how to handle escaping 2 " + "different types of quotes!") + if '"' in value: + return "'{}'".format(value) + if "'" in value: + return '"{}"'.format(value) + return value + @classmethod def defaults(cls, config): config.add_route('appsettings', '/settings/app/') From bcb4bda7e6282c4bf9e0e93cefb7b242bf46b4b1 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 26 Sep 2020 15:00:42 -0500 Subject: [PATCH 0228/1681] Fix bug in App Settings when list value is "missing" --- tailbone/views/settings.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index ae580337..94a71853 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -228,7 +228,8 @@ class AppSettingsView(View): return self.rattail_config.getbool(setting.namespace, setting.name) if setting.data_type is list: return '\n'.join( - self.rattail_config.getlist(setting.namespace, setting.name)) + self.rattail_config.getlist(setting.namespace, setting.name, + default=[])) return self.rattail_config.get(setting.namespace, setting.name) def save_setting_value(self, setting, value): From e0d1e3982449b6f89bf3a1dbc63afa952aedf99a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 28 Sep 2020 12:45:46 -0500 Subject: [PATCH 0229/1681] Add feature to "download rows for results" in master index view --- tailbone/templates/master/index.mako | 94 ++++++++ tailbone/views/master.py | 312 ++++++++++++++++++++++++++- 2 files changed, 404 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako index a2078907..7dfe741e 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -18,6 +18,15 @@ <script type="text/javascript"> $(function() { + % if download_results_rows_path: + function downloadResultsRowsRedirect() { + location.href = '${url('{}.download_results_rows'.format(route_prefix))}?filename=${h.os.path.basename(download_results_rows_path)}'; + } + // we give this 1 second before attempting the redirect; so this + // way the page should fully render before redirecting + window.setTimeout(downloadResultsRowsRedirect, 1000); + % endif + $('.grid-wrapper').gridwrapper(); % if master.deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple': @@ -55,6 +64,20 @@ % endif + % if master.has_rows and master.results_rows_downloadable: + + $('#download-row-results-button').click(function() { + if (confirm("This will generate an Excel file which contains " + + "not the results themselves, but the *rows* for " + + "each.\n\nAre you sure you want this?")) { + disable_button(this); + var form = $(this).parents('form'); + form.submit(); + } + }); + + % endif + % if master.bulk_deletable and request.has_perm('{}.bulk_delete'.format(permission_prefix)): $('form[name="bulk-delete"] button').click(function() { @@ -292,6 +315,29 @@ % endif % endif + ## download rows for search results + % if master.has_rows and master.results_rows_downloadable: + % if use_buefy: + <b-button type="is-primary" + icon-pack="fas" + icon-left="fas fa-download" + @click="downloadResultsRows()" + :disabled="downloadResultsRowsButtonDisabled"> + {{ downloadResultsRowsButtonText }} + </b-button> + ${h.form(url('{}.download_results_rows'.format(route_prefix)), ref='downloadResultsRowsForm')} + ${h.csrf_token(request)} + ${h.end_form()} + % else: + ${h.form(url('{}.download_results_rows'.format(route_prefix)))} + ${h.csrf_token(request)} + <button type="button" id="download-row-results-button"> + Download Rows for Results + </button> + ${h.end_form()} + % endif + % endif + ## merge 2 objects % if master.mergeable and request.has_perm('{}.merge'.format(permission_prefix)): @@ -413,6 +459,13 @@ </b-notification> % endif + % if download_results_rows_path: + <b-notification type="is-info"> + Your download should start automatically, or you can + ${h.link_to("click here", '{}?filename={}'.format(url('{}.download_results_rows'.format(route_prefix)), h.os.path.basename(download_results_rows_path)))} + </b-notification> + % endif + <${grid.component} :csrftoken="csrftoken" % if master.deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple': @deleteActionClicked="deleteObject" @@ -465,6 +518,19 @@ } % endif + ## maybe auto-redirect to download latest "rows for results" file + % if download_results_rows_path and use_buefy: + ThisPage.methods.downloadResultsRowsRedirect = function() { + location.href = '${url('{}.download_results_rows'.format(route_prefix))}?filename=${h.os.path.basename(download_results_rows_path)}'; + } + ThisPage.mounted = function() { + // we give this 1 second before attempting the redirect; otherwise + // the FontAwesome icons do not seem to load properly. so this way + // the page should fully render before redirecting + window.setTimeout(this.downloadResultsRowsRedirect, 1000) + } + % endif + ## TODO: stop checking for buefy here once we only have the one session.pop() % if use_buefy and request.session.pop('{}.results_csv.generated'.format(route_prefix), False): ThisPage.mounted = function() { @@ -565,6 +631,23 @@ } % endif + ## download rows for results + % if master.has_rows and master.results_rows_downloadable and master.has_perm('download_results_rows'): + + ${grid.component_studly}Data.downloadResultsRowsButtonDisabled = false + ${grid.component_studly}Data.downloadResultsRowsButtonText = "Download Rows for Results" + + ${grid.component_studly}.methods.downloadResultsRows = function() { + if (confirm("This will generate an Excel file which contains " + + "not the results themselves, but the *rows* for " + + "each.\n\nAre you sure you want this?")) { + this.downloadResultsRowsButtonDisabled = true + this.downloadResultsRowsButtonText = "Working, please wait..." + this.$refs.downloadResultsRowsForm.submit() + } + } + % endif + ## enable / disable selected objects % if master.supports_set_enabled_toggle and master.has_perm('enable_disable_set'): @@ -705,6 +788,17 @@ % else: ## no buefy, so do the traditional thing + + % if download_results_rows_path: + <div class="flash-messages"> + <div class="ui-state-highlight ui-corner-all"> + <span style="float: left; margin-right: .3em;" class="ui-icon ui-icon-info"></span> + Your download should start automatically, or you can + ${h.link_to("click here", '{}?filename={}'.format(url('{}.download_results_rows'.format(route_prefix)), h.os.path.basename(download_results_rows_path)))} + </div> + </div> + % endif + ${grid.render_complete(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())|n} % if master.deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple': diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 729948d2..d48618e8 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -84,6 +84,7 @@ class MasterView(View): results_downloadable = False results_downloadable_csv = False results_downloadable_xlsx = False + results_rows_downloadable = False creatable = True show_create_link = True viewable = True @@ -356,6 +357,14 @@ class MasterView(View): context['download_results_fields_available'] = available context['download_results_fields_default'] = self.download_results_fields_default(available) + if self.has_rows and self.results_rows_downloadable and self.has_perm('download_results_rows'): + route_prefix = self.get_route_prefix() + context['download_results_rows_path'] = self.request.session.pop( + '{}.results_rows.generated'.format(route_prefix), None) + available = self.download_results_fields_available() + context['download_results_rows_fields_available'] = available + context['download_results_rows_fields_default'] = self.download_results_rows_fields_default(available) + return self.render_to_response('index', context) def make_grid(self, factory=None, key=None, data=None, columns=None, **kwargs): @@ -2877,8 +2886,8 @@ class MasterView(View): def write(obj, i): data = self.download_results_normalize(obj, fields, fmt=fmt) - row = self.download_results_coerce_csv(data, fields) - writer.writerow(row) + csvrow = self.download_results_coerce_csv(data, fields) + writer.writerow(csvrow) self.progress_loop(write, results, progress, message="Writing data to CSV file") @@ -3201,6 +3210,297 @@ class MasterView(View): row[field] = getattr(obj, field, None) return row + def download_results_rows_supported_formats(self): + # TODO: default formats should be configurable? + return OrderedDict([ + ('xlsx', "Excel (XLSX)"), + ('csv', "CSV"), + ]) + + def download_results_rows_default_format(self): + # TODO: default format should be configurable + return 'xlsx' + + def download_results_rows(self): + """ + View for saving *rows* of current (filtered) data results into a file, + and downloading that file. + """ + route_prefix = self.get_route_prefix() + user_uuid = self.request.user.uuid + + # POST means generate a new results file for download + if self.request.method == 'POST': + + # make sure a valid format was requested + supported = self.download_results_rows_supported_formats() + if not supported: + self.request.session.flash("There are no supported download formats!", + 'error') + return self.redirect(self.get_index_url()) + fmt = self.request.POST.get('fmt') + if not fmt: + fmt = self.download_results_rows_default_format() or list(supported)[0] + if fmt not in supported: + self.request.session.flash("Unsupported download format: {}".format(fmt), + 'error') + return self.redirect(self.get_index_url()) + + # parse field list if one was given + fields = self.request.POST.get('fields') + if fields: + fields = fields.split(',') + if not fields: + if fmt == 'csv': + fields = self.get_row_csv_fields() + elif fmt == 'xlsx': + fields = self.get_row_xlsx_fields() + else: + self.request.session.flash("No fields were specified", 'error') + return self.redirect(self.get_index_url()) + + # start thread to actually do work / report progress + key = '{}.download_results_rows'.format(route_prefix) + progress = self.make_progress(key) + results = self.get_effective_data() + thread = Thread(target=self.download_results_rows_thread, + args=(results, fmt, fields, user_uuid, progress)) + thread.start() + + # show user the progress page + return self.render_progress(progress, { + 'cancel_url': self.get_index_url(), + 'cancel_msg': "Download was canceled.", + }) + + # not POST, so just download a file (if specified) + filename = self.request.GET.get('filename') + if not filename: + return self.redirect(self.get_index_url()) + path = self.download_results_rows_path(user_uuid, filename) + return self.file_response(path) + + def download_results_rows_filename(self, fmt): + """ + Must return an appropriate "download results" filename for the given + format. E.g. ``'products.csv'`` + """ + route_prefix = self.get_route_prefix() + if fmt == 'csv': + return '{}.rows.csv'.format(route_prefix) + if fmt == 'xlsx': + return '{}.rows.xlsx'.format(route_prefix) + + def download_results_rows_path(self, user_uuid, filename=None, + typ='results', makedirs=False): + """ + Returns an absolute path for the "results" data file, specific to the + given user UUID. + """ + route_prefix = self.get_route_prefix() + path = os.path.join(self.rattail_config.datadir(), 'downloads', + typ, route_prefix, + user_uuid[:2], user_uuid[2:]) + if makedirs and not os.path.exists(path): + os.makedirs(path) + + if filename: + path = os.path.join(path, filename) + return path + + def download_results_rows_fields_available(self, **kwargs): + """ + Return the list of fields which are *available* to be written to + download file. Default field list will be constructed from the + underlying table columns. + """ + fields = [] + mapper = orm.class_mapper(self.model_class) + for prop in mapper.iterate_properties: + if isinstance(prop, orm.ColumnProperty): + fields.append(prop.key) + return fields + + def download_results_rows_fields_default(self, fields, **kwargs): + """ + Return the default list of fields to be written to download file. + Unless you override, all "available" fields will be included by + default. + """ + return fields + + def download_results_rows_thread(self, results, fmt, fields, user_uuid, progress): + """ + Thread target, which invokes :meth:`download_results_generate()` to + officially generate the data file which is then to be downloaded. + """ + route_prefix = self.get_route_prefix() + session = self.make_isolated_session() + try: + + # create folder(s) for output; make sure file doesn't exist + filename = self.download_results_rows_filename(fmt) + path = self.download_results_rows_path(user_uuid, filename, makedirs=True) + if os.path.exists(path): + os.remove(path) + + # generate file for download + results = results.with_session(session).all() + self.download_results_rows_setup(fields, progress=progress) + self.download_results_rows_generate(session, results, path, fmt, fields, + progress=progress) + + session.commit() + + except Exception as error: + msg = "failed to generate results file for download!" + log.warning(msg, exc_info=True) + session.rollback() + if progress: + progress.session.load() + progress.session['error'] = True + progress.session['error_msg'] = "{}: {}".format( + msg, simple_error(error)) + progress.session.save() + return + + finally: + session.close() + + if progress: + progress.session.load() + progress.session['complete'] = True + progress.session['success_url'] = self.get_index_url() + progress.session['extra_session_bits'] = { + '{}.results_rows.generated'.format(route_prefix): path, + } + progress.session.save() + + def download_results_rows_setup(self, fields, progress=None): + """ + Perform any up-front caching or other setup required, just prior to + generating a new results data file for download. + """ + + def download_results_rows_generate(self, session, results, path, fmt, fields, progress=None): + """ + This method is responsible for actually generating the data file for a + "download rows for results" operation, according to the given params. + """ + # we really are concerned with "rows of results" here, so let's just + # replace the 'results' list with a list of rows + original_results = results + results = [] + + def collect(obj, i): + results.extend(self.get_row_data(obj).all()) + + self.progress_loop(collect, original_results, progress, + message="Collecting data for {}".format(self.get_row_model_title_plural())) + + if fmt == 'csv': + + if six.PY2: + csv_file = open(path, 'wb') + writer = UnicodeDictWriter(csv_file, fields, encoding='utf_8') + else: # PY3 + csv_file = open(path, 'wt', encoding='utf_8') + writer = csv.DictWriter(csv_file, fields) + writer.writeheader() + + def write(obj, i): + data = self.download_results_rows_normalize(obj, fields, fmt=fmt) + csvrow = self.download_results_rows_coerce_csv(data, fields) + writer.writerow(csvrow) + + self.progress_loop(write, results, progress, + message="Writing data to CSV file") + csv_file.close() + + elif fmt == 'xlsx': + + writer = ExcelWriter(path, fields, + sheet_title=self.get_row_model_title_plural()) + writer.write_header() + + xlrows = [] + def write(obj, i): + data = self.download_results_rows_normalize(obj, fields, fmt=fmt) + row = self.download_results_rows_coerce_xlsx(data, fields) + xlrow = [row[field] for field in fields] + xlrows.append(xlrow) + + self.progress_loop(write, results, progress, + message="Collecting data for Excel") + + def finalize(x, i): + writer.write_rows(xlrows) + writer.auto_freeze() + writer.auto_filter() + writer.auto_resize() + writer.save() + + self.progress_loop(finalize, [1], progress, + message="Writing Excel file to disk") + + def download_results_rows_normalize(self, row, fields, **kwargs): + """ + Normalize the given row object into a data dict, for use when writing + to the results file for download. + """ + data = {} + for field in fields: + value = getattr(row, field, None) + + # make timestamps zone-aware + if isinstance(value, datetime.datetime): + value = localtime(self.rattail_config, value, + from_utc=not self.has_local_times) + + data[field] = value + + return data + + def download_results_rows_coerce_csv(self, data, fields, **kwargs): + """ + Coerce the given data dict record, to a "row" dict suitable for use + when writing directly to CSV file. Each value in the dict should be a + string type. + """ + csvrow = dict(data) + for field in fields: + value = csvrow.get(field) + + if value is None: + value = '' + else: + value = six.text_type(value) + + csvrow[field] = value + + return csvrow + + def download_results_rows_coerce_xlsx(self, data, fields, **kwargs): + """ + Coerce the given data dict record, to a "row" dict suitable for use + when writing directly to XLSX file. + """ + data = dict(data) + for key in data: + value = data[key] + + # convert GPC to pretty string + if isinstance(value, GPC): + value = value.pretty() + + # make timestamps local, "zone-naive" + elif isinstance(value, datetime.datetime): + value = localtime(self.rattail_config, value, tzinfo=False) + + data[key] = value + + return data + def row_results_xlsx(self): """ Download current *row* results as XLSX. @@ -4152,6 +4452,14 @@ class MasterView(View): config.add_view(cls, attr='results_xlsx_download', route_name='{}.results_xlsx_download'.format(route_prefix), permission='{}.results_xlsx'.format(permission_prefix)) + # download rows for results + if cls.has_rows and cls.results_rows_downloadable: + config.add_tailbone_permission(permission_prefix, '{}.download_results_rows'.format(permission_prefix), + "Download *rows* for {} search results".format(model_title)) + config.add_route('{}.download_results_rows'.format(route_prefix), '{}/download-rows-for-results'.format(url_prefix)) + config.add_view(cls, attr='download_results_rows', route_name='{}.download_results_rows'.format(route_prefix), + permission='{}.download_results_rows'.format(permission_prefix)) + # quickie (search) if cls.supports_quickie_search: config.add_tailbone_permission(permission_prefix, '{}.quickie'.format(permission_prefix), From dc1f613bc2a068bdaaf0a52be2455d23adfb8cb8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 28 Sep 2020 13:23:01 -0500 Subject: [PATCH 0230/1681] Fix "refresh results" for batches, in Buefy theme --- tailbone/templates/batch/index.mako | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/tailbone/templates/batch/index.mako b/tailbone/templates/batch/index.mako index 5070c46e..8d54facc 100644 --- a/tailbone/templates/batch/index.mako +++ b/tailbone/templates/batch/index.mako @@ -81,10 +81,15 @@ % if master.results_refreshable and master.has_perm('refresh'): % if use_buefy: <b-button type="is-primary" - disabled - title="TODO: need to implement this for new theme"> - Refresh Results + :disabled="refreshResultsButtonDisabled" + icon-pack="fas" + icon-left="fas fa-redo" + @click="refreshResults()"> + {{ refreshResultsButtonText }} </b-button> + ${h.form(url('{}.refresh_results'.format(route_prefix)), ref='refreshResultsForm')} + ${h.csrf_token(request)} + ${h.end_form()} % else: <button type="button" id="refresh-results-button"> Refresh Results @@ -148,6 +153,15 @@ % if master.results_executable and master.has_perm('execute_multiple'): <script type="text/javascript"> + TailboneGridData.refreshResultsButtonText = "Refresh Results" + TailboneGridData.refreshResultsButtonDisabled = false + + TailboneGrid.methods.refreshResults = function() { + this.refreshResultsButtonDisabled = true + this.refreshResultsButtonText = "Working, please wait..." + this.$refs.refreshResultsForm.submit() + } + ${execute_form.component_studly}.methods.submit = function() { this.$refs.actualExecuteForm.submit() } From 9af7e382190d3d6d2fbf361d08ca3935fcd40f25 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 29 Sep 2020 18:07:10 -0500 Subject: [PATCH 0231/1681] 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 a1db102b..8f306636 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.8.112 (2020-09-29) +-------------------- + +* Add support for "list" type of app settings (w/ textarea). + +* Add feature to "download rows for results" in master index view. + +* Fix "refresh results" for batches, in Buefy theme. + + 0.8.111 (2020-09-25) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index cfd6d60d..95830513 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.111' +__version__ = '0.8.112' From d80844c1ed1a4f213f16e61d845cad0ccd73b93f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 13 Oct 2020 16:56:05 -0500 Subject: [PATCH 0232/1681] Tweak how global DB session is created no need to specify "record changes" flag here --- tailbone/db.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/db.py b/tailbone/db.py index 1cbf61ec..f7b106fe 100644 --- a/tailbone/db.py +++ b/tailbone/db.py @@ -35,7 +35,7 @@ from rattail.db import SessionBase from rattail.db.continuum import versioning_manager -Session = scoped_session(sessionmaker(class_=SessionBase, rattail_config=None, rattail_record_changes=False, expire_on_commit=False)) +Session = scoped_session(sessionmaker(class_=SessionBase, rattail_config=None, expire_on_commit=False)) # not necessarily used, but here if you need it TempmonSession = scoped_session(sessionmaker()) From ee3d32d60ad1458a0f0c9ad4501360a5a900443f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 13 Oct 2020 16:59:35 -0500 Subject: [PATCH 0233/1681] 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 8f306636..e4297cd8 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.113 (2020-10-13) +-------------------- + +* Tweak how global DB session is created. + + 0.8.112 (2020-09-29) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 95830513..ab1fd5b3 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.112' +__version__ = '0.8.113' From 3cd5fa7f4a428426f90ad0644da0f47bd7d9b66c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 23 Oct 2020 22:08:43 -0500 Subject: [PATCH 0234/1681] Misc. tweaks to vendor catalog views for sake of titeship --- tailbone/templates/batch/vendorcatalog/index.mako | 11 +++++++++++ tailbone/views/batch/vendorcatalog.py | 8 -------- tailbone/views/master.py | 5 ++++- 3 files changed, 15 insertions(+), 9 deletions(-) create mode 100644 tailbone/templates/batch/vendorcatalog/index.mako diff --git a/tailbone/templates/batch/vendorcatalog/index.mako b/tailbone/templates/batch/vendorcatalog/index.mako new file mode 100644 index 00000000..70412b39 --- /dev/null +++ b/tailbone/templates/batch/vendorcatalog/index.mako @@ -0,0 +1,11 @@ +## -*- coding: utf-8; -*- +<%inherit file="/batch/index.mako" /> + +<%def name="context_menu_items()"> + ${parent.context_menu_items()} + % if request.has_perm('vendors.list'): + <li>${h.link_to("View Vendors", url('vendors'))}</li> + % endif +</%def> + +${parent.body()} diff --git a/tailbone/views/batch/vendorcatalog.py b/tailbone/views/batch/vendorcatalog.py index f4692e5d..945b3758 100644 --- a/tailbone/views/batch/vendorcatalog.py +++ b/tailbone/views/batch/vendorcatalog.py @@ -186,14 +186,6 @@ class VendorCatalogsView(FileBatchMasterView): if not self.creating: f.set_readonly('effective') - def render_vendor(self, batch, field): - vendor = batch.vendor - if not vendor: - return "" - text = "({}) {}".format(vendor.id, vendor.name) - url = self.request.route_url('vendors.view', uuid=vendor.uuid) - return tags.link_to(text, url) - def get_batch_kwargs(self, batch): kwargs = super(VendorCatalogsView, self).get_batch_kwargs(batch) kwargs['parser_key'] = batch.parser_key diff --git a/tailbone/views/master.py b/tailbone/views/master.py index d48618e8..8c0ee61d 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -928,7 +928,10 @@ class MasterView(View): vendor = getattr(obj, field) if not vendor: return "" - text = "({}) {}".format(vendor.id, vendor.name) + if vendor.id: + text = "({}) {}".format(vendor.id, vendor.name) + else: + text = six.text_type(vendor) url = self.request.route_url('vendors.view', uuid=vendor.uuid) return tags.link_to(text, url) From c87a452471a09c195607d3de1a0037ad387cadbd Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 25 Nov 2020 18:49:02 -0600 Subject: [PATCH 0235/1681] Tweak how an "enum" grid filter is initialized wasn't working quite right for Buefy theme --- tailbone/grids/filters.py | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py index 54643c94..9d08c881 100644 --- a/tailbone/grids/filters.py +++ b/tailbone/grids/filters.py @@ -161,7 +161,7 @@ class GridFilter(object): if value_renderer: self.set_value_renderer(value_renderer) elif value_enum: - self.set_value_renderer(EnumValueRenderer(value_enum)) + self.set_choices(value_enum) else: self.set_value_renderer(self.value_renderer_factory) self.default_active = default_active @@ -189,13 +189,13 @@ class GridFilter(object): return verbs return ['equal', 'not_equal', 'is_null', 'is_not_null', 'is_any'] - def set_choices(self, choices): + def normalize_choices(self, choices): """ - Set the value choices for the filter, post-construction. Note that - this also will set the value renderer to one which supports choices. + Normalize a set of "choices" to a format suitable for use with the + filter. - :param choices: A collection of "choices" for the filter. This must be - in one of the following formats: + :param choices: A collection of "choices" in one of the following + formats: * simple list, each value of which should be a string, which is assumed to be able to serve as both key and value (ordering of @@ -205,20 +205,30 @@ class GridFilter(object): * OrderedDict, keys and values of which will define the choices (ordering of choices will be preserved) """ - # first must normalize choices if isinstance(choices, OrderedDict): normalized = choices + elif isinstance(choices, dict): normalized = OrderedDict([ (key, choices[key]) for key in sorted(choices)]) + elif isinstance(choices, list): normalized = OrderedDict([ (key, key) for key in choices]) - # store normalized choices, and set renderer - self.choices = normalized + return normalized + + def set_choices(self, choices): + """ + Set the value choices for the filter. Note that this also will set the + value renderer to one which supports choices. + + :param choices: A collection of "choices" which will be normalized by + way of :meth:`normalize_choices()`. + """ + self.choices = self.normalize_choices(choices) self.set_value_renderer(ChoiceValueRenderer(self.choices)) def set_value_renderer(self, renderer): From e5d58503275d17904626c2dba84fb8ea7e875f5a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 26 Nov 2020 12:35:57 -0600 Subject: [PATCH 0236/1681] Add "generic" Employee tab feature, for profile view i.e. this now exposes a way to begin/end employment status for a person, and invokes the "employment handler" accordingly --- .../templates/people/view_profile_buefy.mako | 532 ++++++++++++++---- tailbone/views/core.py | 8 + tailbone/views/people.py | 128 +++++ 3 files changed, 571 insertions(+), 97 deletions(-) diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako index c3017aa0..1108256d 100644 --- a/tailbone/templates/people/view_profile_buefy.mako +++ b/tailbone/templates/people/view_profile_buefy.mako @@ -2,7 +2,8 @@ <%inherit file="/master/view.mako" /> <%def name="page_content()"> - <profile-info></profile-info> + <profile-info @change-content-title="changeContentTitle"> + </profile-info> </%def> <%def name="render_this_page()"> @@ -179,9 +180,207 @@ % endif </%def> -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} +<%def name="render_employee_tab_template()"> + <script type="text/x-template" id="employee-tab-template"> + <div> + <div style="display: flex; justify-content: space-between;"> + <div style="flex-grow: 1;"> + + <div v-if="employee.exists"> + + <b-field horizontal label="Employee ID"> + <span>{{ employee.id }}</span> + </b-field> + + <b-field horizontal label="Employee Status"> + <span>{{ employee.current ? "current" : "former" }}</span> + </b-field> + + <b-field horizontal label="Start Date"> + <span>{{ employee.startDate }}</span> + </b-field> + + <b-field horizontal label="End Date"> + <span>{{ employee.endDate }}</span> + </b-field> + + <br /> + <p><strong>Employee History</strong></p> + <br /> + + <b-table :data="employeeHistory.data"> + <template slot-scope="props"> + + <b-table-column field="start_date" label="Start Date"> + {{ props.row.start_date }} + </b-table-column> + + <b-table-column field="end_date" label="End Date"> + {{ props.row.end_date }} + </b-table-column> + + % if request.has_perm('people_profile.edit_employee_history'): + <b-table-column field="actions" label="Actions"> + <a href="#" @click.prevent="editEmployeeHistory(props.row)"> + <i class="fas fa-edit"></i> + Edit + </a> + </b-table-column> + % endif + + </template> + </b-table> + + </div> + + <p v-if="!employee.exists"> + ${person} has never been an employee. + </p> + + </div> + + <div> + <div class="buttons"> + + % if request.has_perm('people_profile.toggle_employee'): + + <b-button v-if="!employee.current" + type="is-primary" + @click="showStartEmployee()"> + ${person} is now an Employee + </b-button> + + <b-button v-if="employee.current" + type="is-primary" + @click="showStopEmployeeDialog = true"> + ${person} is no longer an Employee + </b-button> + + <b-modal has-modal-card + :active.sync="showStartEmployeeDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Employee Start</p> + </header> + + <section class="modal-card-body"> + <b-field label="Employee Number"> + <b-input v-model="employeeID"></b-input> + </b-field> + <b-field label="Start Date"> + <tailbone-datepicker v-model="employeeStartDate"></tailbone-datepicker> + </b-field> + </section> + + <footer class="modal-card-foot"> + <b-button @click="showStartEmployeeDialog = false"> + Cancel + </b-button> + <once-button type="is-primary" + @click="startEmployee()" + :disabled="!employeeStartDate" + text="Save"> + </once-button> + </footer> + </div> + </b-modal> + + <b-modal has-modal-card + :active.sync="showStopEmployeeDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Employee End</p> + </header> + + <section class="modal-card-body"> + <b-field label="End Date" + :type="employeeEndDate ? null : 'is-danger'"> + <tailbone-datepicker v-model="employeeEndDate"></tailbone-datepicker> + </b-field> + <b-field label="Revoke Internal App Access"> + <b-checkbox v-model="employeeRevokeAccess"> + </b-checkbox> + </b-field> + </section> + + <footer class="modal-card-foot"> + <b-button @click="showStopEmployeeDialog = false"> + Cancel + </b-button> + <once-button type="is-primary" + @click="endEmployee()" + :disabled="!employeeEndDate" + text="Save"> + </once-button> + </footer> + </div> + </b-modal> + % endif + + % if request.has_perm('people_profile.edit_employee_history'): + <b-modal has-modal-card + :active.sync="showEditEmployeeHistoryDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Edit Employee History</p> + </header> + + <section class="modal-card-body"> + <b-field label="Start Date"> + <tailbone-datepicker v-model="employeeHistoryStartDate"></tailbone-datepicker> + </b-field> + <b-field label="End Date"> + <tailbone-datepicker v-model="employeeHistoryEndDate" + :disabled="!employeeHistoryEndDateRequired"> + </tailbone-datepicker> + </b-field> + </section> + + <footer class="modal-card-foot"> + <b-button @click="showEditEmployeeHistoryDialog = false"> + Cancel + </b-button> + <once-button type="is-primary" + @click="saveEmployeeHistory()" + :disabled="!employeeHistoryStartDate || (employeeHistoryEndDateRequired && !employeeHistoryEndDate)" + text="Save"> + </once-button> + </footer> + </div> + </b-modal> + % endif + + % if request.has_perm('employees.view'): + <b-button v-if="employee.viewURL" + tag="a" :href="employee.viewURL"> + View Employee + </b-button> + % endif + + </div> + </div> + + </div> + </div> + </script> +</%def> + +<%def name="render_employee_tab()"> + <b-tab-item label="Employee" + icon-pack="fas" + :icon="employee.current ? 'check' : null"> + <employee-tab :employee="employee" + :employee-history="employeeHistory" + @change-content-title="changeContentTitle"> + </employee-tab> + </b-tab-item> +</%def> + +<%def name="render_profile_info_template()"> <script type="text/x-template" id="profile-info-template"> <div> <b-tabs v-model="activeTab" type="is-boxed"> @@ -322,98 +521,7 @@ ${self.render_member_tab()} - <b-tab-item label="Employee" ${'icon="check" icon-pack="fas"' if employee else ''|n}> - - % if employee: - <div style="display: flex; justify-content: space-between;"> - - <div> - - <div class="field-wrapper id"> - <div class="field-row"> - <label>ID</label> - <div class="field"> - ${employee.id or ''} - </div> - </div> - </div> - - <div class="field-wrapper display_name"> - <div class="field-row"> - <label>Display Name</label> - <div class="field"> - ${employee.display_name or ''} - </div> - </div> - </div> - - <div class="field-wrapper status"> - <div class="field-row"> - <label>Status</label> - <div class="field"> - ${enum.EMPLOYEE_STATUS.get(employee.status, '')} - </div> - </div> - </div> - - % if employee.phones: - % for phone in employee.phones: - <div class="field-wrapper"> - <div class="field-row"> - <label>Phone Number</label> - <div class="field"> - ${phone.number} (type: ${phone.type}) - </div> - </div> - </div> - % endfor - % else: - <div class="field-wrapper"> - <div class="field-row"> - <label>Phone Number</label> - <div class="field"> - (none on file) - </div> - </div> - </div> - % endif - - % if employee.emails: - % for email in employee.emails: - <div class="field-wrapper"> - <div class="field-row"> - <label>Email Address</label> - <div class="field"> - ${email.address} (type: ${email.type}) - </div> - </div> - </div> - % endfor - % else: - <div class="field-wrapper"> - <div class="field-row"> - <label>Email Address</label> - <div class="field"> - (none on file) - </div> - </div> - </div> - % endif - - </div> - - <div> - % if request.has_perm('employees.view'): - ${h.link_to("View Employee", url('employees.view', uuid=employee.uuid), class_='button')} - % endif - </div> - - </div> - - % else: - <p>${person} has never been an employee.</p> - % endif - </b-tab-item><!-- Employee --> + ${self.render_employee_tab()} <b-tab-item label="User" ${'icon="check" icon-pack="fas"' if person.users else ''|n}> % if person.users: @@ -477,8 +585,213 @@ </script> </%def> -<%def name="make_this_page_component()"> - ${parent.make_this_page_component()} +<%def name="render_this_page_template()"> + ${parent.render_this_page_template()} + ${self.render_employee_tab_template()} + ${self.render_profile_info_template()} +</%def> + +<%def name="set_employee_data()"> + <script type="text/javascript"> + + let EmployeeData = { + exists: ${json.dumps(bool(employee))|n}, + viewURL: ${json.dumps(employee_view_url)|n}, + current: ${json.dumps(bool(employee and employee.status == enum.EMPLOYEE_STATUS_CURRENT))|n}, + startDate: ${json.dumps(six.text_type(employee_history.start_date) if employee_history else None)|n}, + endDate: ${json.dumps(six.text_type(employee_history.end_date) if employee_history and employee_history.end_date else None)|n}, + id: ${json.dumps(employee.id if employee else None)|n}, + } + + let EmployeeHistoryData = { + data: ${json.dumps(employee_history_data)|n}, + } + + </script> +</%def> + +<%def name="declare_employee_tab_vars()"> + <script type="text/javascript"> + + let EmployeeTab = { + template: '#employee-tab-template', + props: { + employee: Object, + employeeHistory: Object, + }, + data() { + return { + showStartEmployeeDialog: false, + employeeID: null, + employeeStartDate: null, + showStopEmployeeDialog: false, + employeeEndDate: null, + employeeRevokeAccess: false, + showEditEmployeeHistoryDialog: false, + employeeHistoryUUID: null, + employeeHistoryStartDate: null, + employeeHistoryEndDate: null, + employeeHistoryEndDateRequired: false, + + ## TODO: should find a better way to handle CSRF token + csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}, + } + }, + + methods: { + + changeContentTitle(newTitle) { + this.$emit('change-content-title', newTitle) + }, + + % if request.has_perm('people_profile.toggle_employee'): + + showStartEmployee() { + this.employeeID = this.employee.id + this.showStartEmployeeDialog = true + }, + + startEmployee() { + + let url = '${url('people.profile_start_employee', uuid=person.uuid)}' + + let params = { + id: this.employeeID, + start_date: this.employeeStartDate, + } + + let headers = { + ## TODO: should find a better way to handle CSRF token + 'X-CSRF-TOKEN': this.csrftoken, + } + + ## TODO: should find a better way to handle CSRF token + this.$http.post(url, params, {headers: headers}).then(({ data }) => { + if (data.success) { + this.startEmployeeSuccess(data) + } else { + this.$buefy.toast.open({ + message: "Save failed: " + data.error, + type: 'is-danger', + duration: 4000, // 4 seconds + }) + } + }, response => { + alert("Unexpected error occurred!") + }) + }, + + startEmployeeSuccess(data) { + this.employee.exists = true + this.employee.id = data.employee_id + this.employee.viewURL = data.employee_view_url + this.employee.current = true + this.employee.startDate = data.start_date + this.employee.endDate = null + this.employeeHistory.data = data.employee_history_data + // this.customerNumber = data.customer_number + this.employeeEndDate = null + this.$emit('change-content-title', data.dynamic_content_title) + // this.posTabStale = true + this.showStartEmployeeDialog = false + }, + + endEmployee() { + + let url = '${url('people.profile_end_employee', uuid=person.uuid)}' + + let params = { + end_date: this.employeeEndDate, + revoke_access: this.employeeRevokeAccess, + } + + let headers = { + ## TODO: should find a better way to handle CSRF token + 'X-CSRF-TOKEN': this.csrftoken, + } + + ## TODO: should find a better way to handle CSRF token + this.$http.post(url, params, {headers: headers}).then(({ data }) => { + if (data.success) { + this.endEmployeeSuccess(data) + } else { + this.$buefy.toast.open({ + message: "Save failed: " + data.error, + type: 'is-danger', + duration: 4000, // 4 seconds + }) + } + }, response => { + alert("Unexpected error occurred!") + }) + }, + + endEmployeeSuccess(data) { + this.employee.current = false + this.employee.endDate = data.end_date + this.employeeHistory.data = data.employee_history_data + this.employeeStartDate = null + this.$emit('change-content-title', data.dynamic_content_title) + // this.memberTabStale = true + // this.posTabStale = true + this.showStopEmployeeDialog = false + }, + + % endif + + % if request.has_perm('people_profile.edit_employee_history'): + + editEmployeeHistory(row) { + this.employeeHistoryUUID = row.uuid + this.employeeHistoryStartDate = row.start_date + this.employeeHistoryEndDate = row.end_date + this.employeeHistoryEndDateRequired = !!row.end_date + this.showEditEmployeeHistoryDialog = true + }, + + saveEmployeeHistory() { + + let url = '${url('people.profile_edit_employee_history', uuid=person.uuid)}' + + let params = { + uuid: this.employeeHistoryUUID, + start_date: this.employeeHistoryStartDate, + end_date: this.employeeHistoryEndDate, + } + + let headers = { + ## TODO: should find a better way to handle CSRF token + 'X-CSRF-TOKEN': this.csrftoken, + } + + ## TODO: should find a better way to handle CSRF token + this.$http.post(url, params, {headers: headers}).then(({ data }) => { + if (data.success) { + this.employee.startDate = data.start_date + this.employee.endDate = data.end_date + this.employeeHistory.data = data.employee_history_data + } + this.showEditEmployeeHistoryDialog = false + }) + }, + + % endif + }, + } + + </script> +</%def> + +<%def name="make_employee_tab_component()"> + ${self.declare_employee_tab_vars()} + <script type="text/javascript"> + + Vue.component('employee-tab', EmployeeTab) + + </script> +</%def> + +<%def name="make_profile_info_component()"> <script type="text/javascript"> const ProfileInfo = { @@ -489,8 +802,15 @@ person: ${json.dumps(person_data)|n}, customers: ${json.dumps(customers_data)|n}, members: ${json.dumps(members_data)|n}, + employee: EmployeeData, + employeeHistory: EmployeeHistoryData, } }, + methods: { + changeContentTitle(newTitle) { + this.$emit('change-content-title', newTitle) + }, + }, } Vue.component('profile-info', ProfileInfo) @@ -498,5 +818,23 @@ </script> </%def> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + ThisPage.methods.changeContentTitle = function(newTitle) { + this.$emit('change-content-title', newTitle) + } + + </script> +</%def> + +<%def name="make_this_page_component()"> + ${parent.make_this_page_component()} + ${self.set_employee_data()} + ${self.make_employee_tab_component()} + ${self.make_profile_info_component()} +</%def> + ${parent.body()} diff --git a/tailbone/views/core.py b/tailbone/views/core.py index 9b6a5d38..aa662cf9 100644 --- a/tailbone/views/core.py +++ b/tailbone/views/core.py @@ -76,6 +76,14 @@ class View(object): """ return getattr(self.request, 'rattail_config', None) + def get_rattail_app(self): + """ + Returns the Rattail ``AppHandler`` instance, creating it if necessary. + """ + if not hasattr(self, 'rattail_app'): + self.rattail_app = self.rattail_config.get_app() + return self.rattail_app + def forbidden(self): """ Convenience method, to raise a HTTP 403 Forbidden exception. diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 17f7fb67..969693d7 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -26,6 +26,8 @@ Person Views from __future__ import unicode_literals, absolute_import +import datetime + import six import sqlalchemy as sa from sqlalchemy import orm @@ -432,6 +434,15 @@ class PeopleView(MasterView): 'view_profile_url': profile_url, } + def get_context_employee(self, employee): + """ + Return a dict of context data for the given employee. + """ + app = self.get_rattail_app() + handler = app.get_employment_handler() + context = handler.get_context_employee(employee) + return context + def get_context_employee_history(self, employee): data = [] if employee: @@ -443,6 +454,104 @@ class PeopleView(MasterView): }) return data + def ensure_customer(self, person): + """ + Return the `Customer` record for the given person, establishing it + first if necessary. + """ + app = self.get_rattail_app() + handler = app.get_clientele_handler() + customer = handler.ensure_customer(person) + return customer + + def profile_start_employee(self): + """ + View which will cause the person to start being an employee. + """ + person = self.get_instance() + app = self.get_rattail_app() + handler = app.get_employment_handler() + + reason = handler.why_not_begin_employment(person) + if reason: + return {'error': reason} + + data = self.request.json_body + start_date = datetime.datetime.strptime(data['start_date'], '%Y-%m-%d').date() + employee = handler.begin_employment(person, start_date, + employee_id=data['id']) + return self.profile_start_employee_result(employee, start_date) + + def profile_start_employee_result(self, employee, start_date): + return { + 'success': True, + 'employee': self.get_context_employee(employee), + 'employee_view_url': self.request.route_url('employees.view', uuid=employee.uuid), + 'start_date': six.text_type(start_date), + 'employee_history_data': self.get_context_employee_history(employee), + } + + def profile_end_employee(self): + """ + View which will cause the person to stop being an employee. + """ + person = self.get_instance() + app = self.get_rattail_app() + handler = app.get_employment_handler() + + reason = handler.why_not_end_employment(person) + if reason: + return {'error': reason} + + data = dict(self.request.json_body) + end_date = datetime.datetime.strptime(data['end_date'], '%Y-%m-%d').date() + employee = handler.get_employee(person) + handler.end_employment(employee, end_date, + revoke_access=data.get('revoke_access')) + return self.profile_end_employee_result(employee, end_date) + + def profile_end_employee_result(self, employee, end_date): + return { + 'success': True, + 'employee': self.get_context_employee(employee), + 'employee_view_url': self.request.route_url('employees.view', uuid=employee.uuid), + 'end_date': six.text_type(end_date), + 'employee_history_data': self.get_context_employee_history(employee), + } + + def profile_edit_employee_history(self): + """ + AJAX view for updating an employee history record. + """ + person = self.get_instance() + employee = person.employee + + uuid = self.request.json_body['uuid'] + history = self.Session.query(model.EmployeeHistory).get(uuid) + if not history or history not in employee.history: + return {'error': "Must specify a valid Employee History record for this Person."} + + # all history records have a start date, so always update that + start_date = self.request.json_body['start_date'] + start_date = datetime.datetime.strptime(start_date, '%Y-%m-%d').date() + history.start_date = start_date + + # only update end_date if history already had one + if history.end_date: + end_date = self.request.json_body['end_date'] + end_date = datetime.datetime.strptime(end_date, '%Y-%m-%d').date() + history.end_date = end_date + + self.Session.flush() + current_history = employee.get_current_history() + + return { + 'success': True, + 'start_date': six.text_type(current_history.start_date), + 'end_date': six.text_type(current_history.end_date or ''), + 'employee_history_data': self.get_context_employee_history(employee), + } + def make_note_form(self, mode, person): schema = NoteSchema().bind(session=self.Session(), person_uuid=person.uuid) @@ -545,6 +654,7 @@ class PeopleView(MasterView): permission_prefix = cls.get_permission_prefix() route_prefix = cls.get_route_prefix() url_prefix = cls.get_url_prefix() + instance_url_prefix = cls.get_instance_url_prefix() model_key = cls.get_model_key() model_title = cls.get_model_title() @@ -564,6 +674,24 @@ class PeopleView(MasterView): config.add_view(cls, attr='view_profile', route_name='{}.view_profile'.format(route_prefix), permission='{}.view_profile'.format(permission_prefix)) + # profile - start employee + config.add_route('{}.profile_start_employee'.format(route_prefix), '{}/profile/start-employee'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='profile_start_employee', route_name='{}.profile_start_employee'.format(route_prefix), + permission='people_profile.toggle_employee', renderer='json') + + # profile - end employee + config.add_route('{}.profile_end_employee'.format(route_prefix), '{}/profile/end-employee'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='profile_end_employee', route_name='{}.profile_end_employee'.format(route_prefix), + permission='people_profile.toggle_employee', renderer='json') + + # profile - edit employee history + config.add_route('{}.profile_edit_employee_history'.format(route_prefix), '{}/profile/edit-employee-history'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='profile_edit_employee_history', route_name='{}.profile_edit_employee_history'.format(route_prefix), + permission='people_profile.edit_employee_history', renderer='json') + # manage notes from profile view if cls.manage_notes_from_profile_view: From 04ba14fcd7b71260c44001f0d7d2064fe65c8afa Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 1 Dec 2020 20:05:19 -0600 Subject: [PATCH 0237/1681] 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 e4297cd8..2c95d63c 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.8.114 (2020-12-01) +-------------------- + +* Misc. tweaks to vendor catalog views. + +* Tweak how an "enum" grid filter is initialized. + +* Add "generic" Employee tab feature, for profile view. + + 0.8.113 (2020-10-13) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index ab1fd5b3..6777768b 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.113' +__version__ = '0.8.114' From 2ad0223e9ac805202cc62c4557e0e092dc689841 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 2 Dec 2020 14:03:19 -0600 Subject: [PATCH 0238/1681] Add the "Employee Status" filter to People grid --- tailbone/views/people.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 969693d7..183c63a5 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -121,6 +121,10 @@ class PeopleView(MasterView): g.filters['last_name'].default_active = True g.filters['last_name'].default_verb = 'contains' + g.set_joiner('employee_status', lambda q: q.outerjoin(model.Employee)) + g.set_filter('employee_status', model.Employee.status, + value_enum=self.enum.EMPLOYEE_STATUS) + g.sorters['email'] = lambda q, d: q.order_by(getattr(model.PersonEmailAddress.address, d)()) g.sorters['phone'] = lambda q, d: q.order_by(getattr(model.PersonPhoneNumber.number, d)()) From 0220e401cd68256c2c0231284f566885cd7b07ba Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 4 Dec 2020 15:26:21 -0600 Subject: [PATCH 0239/1681] Add "is empty" and related verbs, for "string" type grid filters --- tailbone/grids/filters.py | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py index 9d08c881..02ca9130 100644 --- a/tailbone/grids/filters.py +++ b/tailbone/grids/filters.py @@ -134,19 +134,33 @@ class GridFilter(object): 'greater_equal': "greater than or equal to", 'less_than': "less than", 'less_equal': "less than or equal to", + 'is_empty': "is empty", + 'is_not_empty': "is not empty", 'is_null': "is null", 'is_not_null': "is not null", 'is_true': "is true", 'is_false': "is false", 'is_false_null': "is false or null", + 'is_empty_or_null': "is either empty or null", 'contains': "contains", 'does_not_contain': "does not contain", 'is_me': "is me", 'is_not_me': "is not me", } - valueless_verbs = ['is_any', 'is_null', 'is_not_null', 'is_true', 'is_false', - 'is_false_null', 'is_me', 'is_not_me'] + valueless_verbs = [ + 'is_any', + 'is_empty', + 'is_not_empty', + 'is_null', + 'is_not_null', + 'is_true', + 'is_false', + 'is_false_null', + 'is_empty_or_null', + 'is_me', + 'is_not_me', + ] value_renderer_factory = DefaultValueRenderer data_type = 'string' # default, but will be set from value renderer @@ -380,7 +394,11 @@ class AlchemyStringFilter(AlchemyGridFilter): Expose contains / does-not-contain verbs in addition to core. """ return ['contains', 'does_not_contain', - 'equal', 'not_equal', 'is_null', 'is_not_null', 'is_any'] + 'equal', 'not_equal', + 'is_empty', 'is_not_empty', + 'is_null', 'is_not_null', + 'is_empty_or_null', + 'is_any'] def filter_contains(self, query, value): """ @@ -408,6 +426,17 @@ class AlchemyStringFilter(AlchemyGridFilter): for v in value.split()]), )) + def filter_is_empty(self, query, value): + return query.filter(sa.func.trim(self.column) == self.encode_value('')) + + def filter_is_not_empty(self, query, value): + return query.filter(sa.func.trim(self.column) != self.encode_value('')) + + def filter_is_empty_or_null(self, query, value): + return query.filter( + sa.or_( + sa.func.trim(self.column) == self.encode_value(''), + self.column == None)) class AlchemyEmptyStringFilter(AlchemyStringFilter): """ From a204e78e3a24a3ede414e76f043c250d5a168031 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 4 Dec 2020 15:26:50 -0600 Subject: [PATCH 0240/1681] Assume composite PK when fetching instance for master view i.e. stop trying a simple get() which would assume not only a simple PK, but also assumes the PK is same as defined by the class mapper. in some cases it may be helpful to use a different PK from what mapper defines --- tailbone/views/master.py | 33 ++++++++++++++------------------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 8c0ee61d..62d25cea 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -3658,28 +3658,23 @@ class MasterView(View): doing a database lookup. If the instance cannot be found, raises 404. """ model_keys = self.get_model_key(as_tuple=True) + query = self.Session.query(self.get_model_class()) - # if just one primary key, simple get() will work - if len(model_keys) == 1: - model_key = model_keys[0] + def filtr(query, model_key): key = self.request.matchdict[model_key] + if self.key_is_integer(model_key): + key = int(key) + query = query.filter(getattr(self.model_class, model_key) == key) + return query - obj = self.Session.query(self.get_model_class()).get(key) - if not obj: - raise self.notfound() - - else: # composite key; fetch accordingly - # TODO: should perhaps use filter() instead of get() here? - query = self.Session.query(self.get_model_class()) - for i, model_key in enumerate(model_keys): - key = self.request.matchdict[model_key] - if self.key_is_integer(model_key): - key = int(key) - query = query.filter(getattr(self.model_class, model_key) == key) - try: - obj = query.one() - except orm.exc.NoResultFound: - raise self.notfound() + # filter query by composite key. we use filter() instead of a simple + # get() here in case view uses a "pseudo-PK" + for i, model_key in enumerate(model_keys): + query = filtr(query, model_key) + try: + obj = query.one() + except orm.exc.NoResultFound: + raise self.notfound() # pretend global object doesn't exist, unless access allowed if self.secure_global_objects: From 3ae47ba1e59fea899657b20d5025666150ee539b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 4 Dec 2020 17:50:56 -0600 Subject: [PATCH 0241/1681] 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 2c95d63c..649069b8 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.8.115 (2020-12-04) +-------------------- + +* Add the "Employee Status" filter to People grid. + +* Add "is empty" and related verbs, for "string" type grid filters. + +* Assume composite PK when fetching instance for master view. + + 0.8.114 (2020-12-01) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 6777768b..17c987d7 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.114' +__version__ = '0.8.115' From efbc6df19905947df3dce7053c62089c30c7369e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 4 Dec 2020 18:06:53 -0600 Subject: [PATCH 0242/1681] Tweak tox test config for py27 to make buildbot happy... --- tox.ini | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 1218fec2..fea73355 100644 --- a/tox.ini +++ b/tox.ini @@ -13,12 +13,13 @@ commands = nosetests {posargs} [testenv:py27] -# TODO: this is only here to avoid latest SA-Utils on python2.7 +# TODO: this is only here to avoid "latest" packages which break us on python2.7 deps = coverage fixture mock nose + SQLAlchemy<1.3 SQLAlchemy-Utils<0.36.7 [testenv:coverage] From 2d8d4659b35ddca249d47f44716f33976184261a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 4 Dec 2020 18:27:47 -0600 Subject: [PATCH 0243/1681] Use python3 when building coverage, docs targest via tox at least i think that's what this does..hopefully it works --- tox.ini | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index fea73355..0d1278d4 100644 --- a/tox.ini +++ b/tox.ini @@ -23,14 +23,14 @@ deps = SQLAlchemy-Utils<0.36.7 [testenv:coverage] -basepython = python +basepython = python3 commands = pip install --upgrade pip pip install --upgrade --upgrade-strategy eager Tailbone rattail[auth,bouncer] rattail-tempmon nosetests {posargs:--with-coverage --cover-html-dir={envtmpdir}/coverage} [testenv:docs] -basepython = python +basepython = python3 deps = Sphinx sphinx-rtd-theme From 3250347df1e924b7d29b5bbd708bc64bd342bc09 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 4 Dec 2020 18:46:58 -0600 Subject: [PATCH 0244/1681] Add sqlalchemy version cap for tox coverage, docs this is of course just kicking the can down the road a bit for now...really need to get the latest zope.sqlalchemy instead... --- tox.ini | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tox.ini b/tox.ini index 0d1278d4..a83b1694 100644 --- a/tox.ini +++ b/tox.ini @@ -24,6 +24,13 @@ deps = [testenv:coverage] basepython = python3 +# TODO: capping sqlalchemy for now, to avoid issues w/ zope.sqlalchemy +deps = + coverage + fixture + mock + nose + SQLAlchemy<1.4 commands = pip install --upgrade pip pip install --upgrade --upgrade-strategy eager Tailbone rattail[auth,bouncer] rattail-tempmon @@ -31,9 +38,11 @@ commands = [testenv:docs] basepython = python3 +# TODO: capping sqlalchemy for now, to avoid issues w/ zope.sqlalchemy deps = Sphinx sphinx-rtd-theme + SQLAlchemy<1.4 changedir = docs commands = pip install --upgrade pip From ac5139b7c4d51c62838137ff61c9de8dbd6ea2c9 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 6 Dec 2020 19:36:23 -0600 Subject: [PATCH 0245/1681] Add basic views for IFPS PLU Codes --- tailbone/templates/ifps-plu-codes/index.mako | 10 +++ tailbone/views/ifps.py | 91 ++++++++++++++++++++ tailbone/views/master.py | 12 +++ 3 files changed, 113 insertions(+) create mode 100644 tailbone/templates/ifps-plu-codes/index.mako create mode 100644 tailbone/views/ifps.py diff --git a/tailbone/templates/ifps-plu-codes/index.mako b/tailbone/templates/ifps-plu-codes/index.mako new file mode 100644 index 00000000..3f014343 --- /dev/null +++ b/tailbone/templates/ifps-plu-codes/index.mako @@ -0,0 +1,10 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/index.mako" /> + +<%def name="context_menu_items()"> + ${parent.context_menu_items()} + <li>${h.link_to("Go to IFPS Website", 'https://www.ifpsglobal.com/PLU-Codes/PLU-codes-Search', target='_blank')}</li> +</%def> + + +${parent.body()} diff --git a/tailbone/views/ifps.py b/tailbone/views/ifps.py new file mode 100644 index 00000000..030c7e66 --- /dev/null +++ b/tailbone/views/ifps.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2020 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +IFPS Views +""" + +from __future__ import unicode_literals, absolute_import + +from rattail.db import model + +from tailbone.views import MasterView + + +class IFPS_PLUView(MasterView): + """ + Master view for the Department class. + """ + model_class = model.IFPS_PLU + route_prefix = 'ifps_plus' + url_prefix = '/ifps-plu-codes' + results_downloadable = True + has_versions = True + + labels = { + 'plu': "PLU", + 'gpc': "GPC", + 'aka': "AKA", + 'measurements_north_america': "Measurements (North America)", + 'measurements_rest_of_world': "Measurements (rest of world)", + } + + grid_columns = [ + 'plu', + 'category', + 'commodity', + 'variety', + 'size', + 'botanical_name', + 'revision_date', + ] + + def configure_grid(self, g): + super(IFPS_PLUView, self).configure_grid(g) + + g.filters['plu'].default_active = True + g.filters['plu'].default_verb = 'equal' + + g.set_sort_defaults('plu') + + # variety + # this is actually a TEXT field, so potentially large + g.set_renderer('variety', self.render_truncated_value) + + g.set_link('plu') + g.set_link('commodity') + g.set_link('variety') + + def configure_form(self, f): + super(IFPS_PLUView, self).configure_form(f) + + if self.creating: + f.remove('revision_date', + 'date_added') + else: + f.set_readonly('revision_date') + f.set_readonly('date_added') + + + +def includeme(config): + IFPS_PLUView.defaults(config) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 62d25cea..15734212 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -887,6 +887,18 @@ class MasterView(View): 'importer_host_title': importer_host_title, }) + def render_truncated_value(self, obj, field): + """ + Simple renderer which truncates the (string) value to 100 chars. + """ + value = getattr(obj, field) + if value is None: + return "" + value = six.text_type(value) + if len(value) > 100: + value = value[:100] + '...' + return value + def render_id_str(self, obj, field): """ Render the ``id_str`` attribute value for the given object. From 42eb72422dc258974a058daf440a808060e46d53 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 7 Dec 2020 11:40:26 -0600 Subject: [PATCH 0246/1681] Add very basic support for merging 2 People this is not very complete, but was enough for what i needed at the moment. almost seems like incomplete feature may be worse than none at all? but then again some sort of default starting point is nice i guess... --- tailbone/views/people.py | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 183c63a5..ac4f2237 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -98,6 +98,18 @@ class PeopleView(MasterView): 'users', ] + mergeable = True + merge_additive_fields = [ + 'usernames', + 'member_uuids', + ] + merge_fields = merge_additive_fields + [ + 'uuid', + 'first_name', + 'last_name', + 'display_name', + ] + def configure_grid(self, g): super(PeopleView, self).configure_grid(g) @@ -337,6 +349,34 @@ class PeopleView(MasterView): (model.VendorContact, 'person_uuid'), ] + def get_merge_data(self, person): + return { + 'uuid': person.uuid, + 'first_name': person.first_name, + 'last_name': person.last_name, + 'display_name': person.display_name, + 'usernames': [u.username for u in person.users], + 'member_uuids': [m.uuid for m in person.members], + } + + def merge_objects(self, removing, keeping): + """ + Execute a merge operation on the two given person records. + """ + # move Member records to final Person + for member in list(removing.members): + removing.members.remove(member) + keeping.members.append(member) + + # move User records to final Person + for user in list(removing.users): + removing.users.remove(user) + keeping.users.append(user) + + # delete unwanted Person + self.Session.delete(removing) + self.Session.flush() + def view_profile(self): """ View which exposes the "full profile" for a given person, i.e. all From 8ff590e43fd9ff0306e8cf0bd990570a185eea0e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 7 Dec 2020 19:01:43 -0600 Subject: [PATCH 0247/1681] Expose "commodity" filter by default, for IFPS PLU codes --- tailbone/views/ifps.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tailbone/views/ifps.py b/tailbone/views/ifps.py index 030c7e66..be3bb0f8 100644 --- a/tailbone/views/ifps.py +++ b/tailbone/views/ifps.py @@ -65,6 +65,9 @@ class IFPS_PLUView(MasterView): g.filters['plu'].default_active = True g.filters['plu'].default_verb = 'equal' + g.filters['commodity'].default_active = True + g.filters['commodity'].default_verb = 'contains' + g.set_sort_defaults('plu') # variety From 95dd8d83dc7af0aadf4d630fe3dd3646312bb181 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 14 Dec 2020 15:13:17 -0600 Subject: [PATCH 0248/1681] Hopefully temporary version cap for deform getting the following error w/ v2.0.15: ``` File "/srv/envs/XXX/lib/python3.7/site-packages/deform/field.py", line 452, in get_widget_requirements requirements = [req for req in self.widget.requirements] + [ TypeError: 'NoneType' object is not iterable ``` --- setup.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 2e0315b3..0fa089df 100644 --- a/setup.py +++ b/setup.py @@ -76,9 +76,11 @@ requires = [ # TODO: remove version cap once we can drop support for python 2.x 'cornice<5.0', # 3.4.2 4.0.1 + # TODO: remove once their bug is fixed? idk what this is about yet... + 'deform<2.0.15', # 2.0.14 + 'colander', # 1.7.0 'ColanderAlchemy', # 0.3.3 - 'deform', # 2.0.4 'humanize', # 0.5.1 'Mako', # 0.6.2 'openpyxl', # 2.4.7 From 058677adecb684fc4f6379f1079ebbb21d96ff17 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 15 Dec 2020 19:08:24 -0600 Subject: [PATCH 0249/1681] Tweak spacing for header logo + title, in falafel theme those were just too close together, this should fix. nb. i am unclear if everything in layout.css is actually being used..? --- tailbone/static/themes/falafel/css/layout.css | 6 ++++++ tailbone/templates/themes/falafel/base.mako | 4 +++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/tailbone/static/themes/falafel/css/layout.css b/tailbone/static/themes/falafel/css/layout.css index b22b6f97..b4fdccec 100644 --- a/tailbone/static/themes/falafel/css/layout.css +++ b/tailbone/static/themes/falafel/css/layout.css @@ -21,6 +21,12 @@ body { * header ******************************/ +/* this is the one in the very top left of screen, next to logo and linked to +the home page */ +#global-header-title { + margin-left: 0.3rem; +} + header .level { /* TODO: not sure what this 60px was supposed to do? but it broke the */ /* styles for the feedback dialog, so disabled it is. diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index 713d9547..46faf30e 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -175,7 +175,9 @@ <div class="navbar-brand"> <a class="navbar-item" href="${url('home')}"> ${base_meta.header_logo()} - ${base_meta.global_title()} + <div id="global-header-title"> + ${base_meta.global_title()} + </div> </a> <a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false"> <span aria-hidden="true"></span> From 20f3d001c431226c49290f6862f38c82af895834 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 15 Dec 2020 20:08:02 -0600 Subject: [PATCH 0250/1681] 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 649069b8..30661805 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.8.116 (2020-12-15) +-------------------- + +* Add basic views for IFPS PLU Codes. + +* Add very basic support for merging 2 People. + +* Tweak spacing for header logo + title, in falafel theme. + + 0.8.115 (2020-12-04) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 17c987d7..59781bda 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.115' +__version__ = '0.8.116' From a80167282178c94f54434d80522b27f396c4ee76 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 16 Dec 2020 12:47:45 -0600 Subject: [PATCH 0251/1681] Improve error handling for feedback form also make sure the message doesn't self-destruct when closing the dialog --- .../themes/falafel/js/tailbone.feedback.js | 19 ++++++++++++++----- tailbone/templates/themes/falafel/base.mako | 6 ++++-- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/tailbone/static/themes/falafel/js/tailbone.feedback.js b/tailbone/static/themes/falafel/js/tailbone.feedback.js index a3cd2af2..9b1d9c4f 100644 --- a/tailbone/static/themes/falafel/js/tailbone.feedback.js +++ b/tailbone/static/themes/falafel/js/tailbone.feedback.js @@ -1,11 +1,10 @@ let FeedbackForm = { - props: ['action'], + props: ['action', 'message'], template: '#feedback-template', methods: { showFeedback() { - this.message = '' this.showDialog = true this.$nextTick(function() { this.$refs.textarea.focus() @@ -30,10 +29,21 @@ let FeedbackForm = { if (data.ok) { alert("Message successfully sent.\n\nThank you for your feedback.") this.showDialog = false + // clear out message, in case they need to send another + this.message = "" } else { - alert("Sorry! Your message could not be sent.\n\n" - + "Please try to contact the site admin some other way.") + this.$buefy.toast.open({ + message: "Failed to send feedback: " + data.error, + type: 'is-danger', + duration: 4000, // 4 seconds + }) } + }, response => { + this.$buefy.toast.open({ + message: "Failed to send feedback! (unknown server error)", + type: 'is-danger', + duration: 4000, // 4 seconds + }) }) }, } @@ -43,6 +53,5 @@ let FeedbackFormData = { referrer: null, userUUID: null, userName: null, - message: '', showDialog: false, } diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index 46faf30e..95c5e817 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -334,7 +334,8 @@ ## Feedback Button / Dialog % if request.has_perm('common.feedback'): <feedback-form - action="${url('feedback')}"> + action="${url('feedback')}" + :message="feedbackMessage"> </feedback-form> % endif @@ -512,7 +513,8 @@ } let WholePageData = { - contentTitleHTML: ${json.dumps(capture(self.content_title))|n} + contentTitleHTML: ${json.dumps(capture(self.content_title))|n}, + feedbackMessage: "", } </script> From cc833c52b6e10741f934b44aad8c10b041de86d6 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 16 Dec 2020 14:28:41 -0600 Subject: [PATCH 0252/1681] Add common "form poster" logic, to make CSRF token/header names configurable also refactor the Feedback logic to use it --- tailbone/app.py | 9 ++-- tailbone/config.py | 9 +++- .../themes/falafel/js/tailbone.feedback.js | 30 +++---------- tailbone/subscribers.py | 2 + tailbone/templates/formposter.mako | 42 +++++++++++++++++++ tailbone/templates/themes/falafel/base.mako | 3 +- 6 files changed, 66 insertions(+), 29 deletions(-) create mode 100644 tailbone/templates/formposter.mako diff --git a/tailbone/app.py b/tailbone/app.py index 44d9976f..bbb6d295 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -43,7 +43,7 @@ from zope.sqlalchemy import register import tailbone.db from tailbone.auth import TailboneAuthorizationPolicy - +from tailbone.config import csrf_token_name, csrf_header_name from tailbone.util import get_effective_theme, get_theme_template_path @@ -130,9 +130,12 @@ def make_pyramid_config(settings, configure_csrf=True): config.set_authorization_policy(TailboneAuthorizationPolicy()) config.set_authentication_policy(SessionAuthenticationPolicy()) - # always require CSRF token protection + # maybe require CSRF token protection if configure_csrf: - config.set_default_csrf_options(require_csrf=True, token='_csrf') + rattail_config = settings['rattail_config'] + config.set_default_csrf_options(require_csrf=True, + token=csrf_token_name(rattail_config), + header=csrf_header_name(rattail_config)) # Bring in some Pyramid goodies. config.include('tailbone.beaker') diff --git a/tailbone/config.py b/tailbone/config.py index 29359e06..6be175ae 100644 --- a/tailbone/config.py +++ b/tailbone/config.py @@ -53,6 +53,14 @@ class ConfigExtension(BaseExtension): config.setdefault('tailbone', 'themes.expose_picker', 'true') +def csrf_token_name(config): + return config.get('tailbone', 'csrf_token_name', default='_csrf') + + +def csrf_header_name(config): + return config.get('tailbone', 'csrf_header_name', default='X-CSRF-TOKEN') + + def global_help_url(config): return config.get('tailbone', 'global_help_url') @@ -64,4 +72,3 @@ def legacy_mobile_enabled(config): def protected_usernames(config): return config.getlist('tailbone', 'protected_usernames') - diff --git a/tailbone/static/themes/falafel/js/tailbone.feedback.js b/tailbone/static/themes/falafel/js/tailbone.feedback.js index 9b1d9c4f..e83b59ed 100644 --- a/tailbone/static/themes/falafel/js/tailbone.feedback.js +++ b/tailbone/static/themes/falafel/js/tailbone.feedback.js @@ -2,6 +2,7 @@ let FeedbackForm = { props: ['action', 'message'], template: '#feedback-template', + mixins: [FormPosterMixin], methods: { showFeedback() { @@ -20,30 +21,11 @@ let FeedbackForm = { message: this.message.trim(), } - let headers = { - // TODO: should find a better way to handle CSRF token - 'X-CSRF-TOKEN': this.csrftoken, - } - - this.$http.post(this.action, params, {headers: headers}).then(({ data }) => { - if (data.ok) { - alert("Message successfully sent.\n\nThank you for your feedback.") - this.showDialog = false - // clear out message, in case they need to send another - this.message = "" - } else { - this.$buefy.toast.open({ - message: "Failed to send feedback: " + data.error, - type: 'is-danger', - duration: 4000, // 4 seconds - }) - } - }, response => { - this.$buefy.toast.open({ - message: "Failed to send feedback! (unknown server error)", - type: 'is-danger', - duration: 4000, // 4 seconds - }) + this.submitForm(this.action, params, response => { + alert("Message successfully sent.\n\nThank you for your feedback.") + this.showDialog = false + // clear out message, in case they need to send another + this.message = "" }) }, } diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index af88f7a7..3deb9c1e 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -42,6 +42,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.menus import make_simple_menus @@ -106,6 +107,7 @@ def before_render(event): renderer_globals['datetime'] = datetime renderer_globals['colander'] = colander renderer_globals['deform'] = deform + renderer_globals['csrf_header_name'] = csrf_header_name(request.rattail_config) # theme - we only want do this for classic web app, *not* API # TODO: so, clearly we need a better way to distinguish the two diff --git a/tailbone/templates/formposter.mako b/tailbone/templates/formposter.mako new file mode 100644 index 00000000..47c6ffd3 --- /dev/null +++ b/tailbone/templates/formposter.mako @@ -0,0 +1,42 @@ +## -*- coding: utf-8; -*- + +<%def name="declare_formposter_mixin()"> + <script type="text/javascript"> + + let FormPosterMixin = { + methods: { + + submitForm(action, params, success) { + + let csrftoken = ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n} + + let headers = { + '${csrf_header_name}': csrftoken, + } + + this.$http.post(action, params, {headers: headers}).then(response => { + + if (response.data.ok) { + success(response) + + } else { + this.$buefy.toast.open({ + message: "Failed to send feedback: " + response.data.error, + type: 'is-danger', + duration: 4000, // 4 seconds + }) + } + + }, response => { + this.$buefy.toast.open({ + message: "Failed to submit form! (unknown server error)", + type: 'is-danger', + duration: 4000, // 4 seconds + }) + }) + }, + }, + } + + </script> +</%def> diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index 95c5e817..75bb914c 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -2,6 +2,7 @@ <%namespace file="/grids/nav.mako" import="grid_index_nav" /> <%namespace file="/autocomplete.mako" import="tailbone_autocomplete_template" /> <%namespace name="base_meta" file="/base_meta.mako" /> +<%namespace file="/formposter.mako" import="declare_formposter_mixin" /> <!DOCTYPE html> <html lang="en"> <head> @@ -487,6 +488,7 @@ </%def> <%def name="declare_whole_page_vars()"> + ${declare_formposter_mixin()} ${h.javascript_link(request.static_url('tailbone:static/themes/falafel/js/tailbone.feedback.js') + '?ver={}'.format(tailbone.__version__))} <script type="text/javascript"> @@ -523,7 +525,6 @@ <%def name="modify_whole_page_vars()"> <script type="text/javascript"> - FeedbackFormData.csrftoken = ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n} FeedbackFormData.referrer = location.href % if request.user: From 6a0bcdaa82e86248277bfc5ae2d92a5b47cc6c51 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 16 Dec 2020 14:53:17 -0600 Subject: [PATCH 0253/1681] 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 30661805..9ec31ef9 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.117 (2020-12-16) +-------------------- + +* Add common "form poster" logic, to make CSRF token/header names configurable. + +* Refactor the feedback form to use common form poster logic. + + 0.8.116 (2020-12-15) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 59781bda..cc842e83 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.116' +__version__ = '0.8.117' From 9c026c1dd93cc7cc8f96cbb8230d1c0b07f52c98 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 2 Jan 2021 18:48:45 -0600 Subject: [PATCH 0254/1681] Show node title in header for Login, About pages --- tailbone/views/auth.py | 5 ++++- tailbone/views/common.py | 8 ++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index ef041f99..2924c821 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -124,13 +124,16 @@ class AuthenticationView(View): 'tailbone', 'main_image_url', default=self.request.static_url('tailbone:static/img/home_logo.png')) - return { + context = { 'form': form, 'referrer': referrer, 'image_url': image_url, 'use_buefy': use_buefy, 'help_url': global_help_url(self.rattail_config), } + if use_buefy: + context['index_title'] = self.rattail_config.node_title() + return context def authenticate_user(self, username, password): return authenticate_user(Session(), username, password) diff --git a/tailbone/views/common.py b/tailbone/views/common.py index 2535f1d7..05ac8e5a 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -112,12 +112,16 @@ class CommonView(View): """ Generic view to show "about project" info page. """ - return { + use_buefy = self.get_use_buefy() + context = { 'project_title': self.project_title, 'project_version': self.project_version, 'packages': self.get_packages(), - 'use_buefy': self.get_use_buefy(), + 'use_buefy': use_buefy, } + if use_buefy: + context['index_title'] = self.rattail_config.node_title() + return context def get_packages(self): """ From 483a47ed4320d234a266e08f6a4672196ccb58d1 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 2 Jan 2021 18:49:20 -0600 Subject: [PATCH 0255/1681] Allow changing protected user password when acting as root --- tailbone/views/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index 2924c821..df4ecffa 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -177,7 +177,7 @@ class AuthenticationView(View): if not self.request.user: return self.redirect(self.request.route_url('home')) - if self.user_is_protected(self.request.user): + if self.user_is_protected(self.request.user) and not self.request.is_root: self.request.session.flash("Cannot change password for user: {}".format(self.request.user)) return self.redirect(self.request.get_referrer()) From ad859d4bef691a7d577408d5d2830234f4cb4d77 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 4 Jan 2021 13:22:44 -0600 Subject: [PATCH 0256/1681] Allow specifying the size of a file, for `readable_size()` method sometimes the file bytes are stored in DB instead of on disk --- tailbone/views/master.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 15734212..ea911bd0 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -3772,16 +3772,17 @@ class MasterView(View): return tags.link_to(content, url) return content - def readable_size(self, path): + def readable_size(self, path, size=None): # TODO: this was shamelessly copied from FormAlchemy ... - length = self.get_size(path) - if length == 0: + if size is None: + size = self.get_size(path) + if size == 0: return '0 KB' - if length <= 1024: + if size <= 1024: return '1 KB' - if length > 1048576: - return '%0.02f MB' % (length / 1048576.0) - return '%0.02f KB' % (length / 1024.0) + if size > 1048576: + return '%0.02f MB' % (size / 1048576.0) + return '%0.02f KB' % (size / 1024.0) def get_size(self, path): try: From e548b72323ba2ea8a6f75abdc635dd9103b0d7ea Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 5 Jan 2021 18:19:27 -0600 Subject: [PATCH 0257/1681] Fix some deform template comments --- tailbone/templates/deform/checkbox_dynamic.pt | 2 +- tailbone/templates/deform/date_jquery.pt | 2 +- tailbone/templates/deform/file_upload.pt | 2 +- tailbone/templates/deform/select_dynamic.pt | 2 +- tailbone/templates/deform/time_jquery.pt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tailbone/templates/deform/checkbox_dynamic.pt b/tailbone/templates/deform/checkbox_dynamic.pt index 45ebb576..c5d4d795 100644 --- a/tailbone/templates/deform/checkbox_dynamic.pt +++ b/tailbone/templates/deform/checkbox_dynamic.pt @@ -1,4 +1,4 @@ -<!-- -*- mode: html; -*- --> +<!--! -*- mode: html; -*- --> <b-checkbox tal:define="name name|field.name; oid oid|field.oid; true_val true_val|field.widget.true_val; diff --git a/tailbone/templates/deform/date_jquery.pt b/tailbone/templates/deform/date_jquery.pt index 08176aee..0539b99a 100644 --- a/tailbone/templates/deform/date_jquery.pt +++ b/tailbone/templates/deform/date_jquery.pt @@ -1,4 +1,4 @@ -<!-- -*- mode: html; -*- --> +<!--! -*- mode: html; -*- --> <div tal:define="css_class css_class|field.widget.css_class; oid oid|field.oid; field_name field_name|field.name; diff --git a/tailbone/templates/deform/file_upload.pt b/tailbone/templates/deform/file_upload.pt index 1471199b..3cb83a5d 100644 --- a/tailbone/templates/deform/file_upload.pt +++ b/tailbone/templates/deform/file_upload.pt @@ -1,4 +1,4 @@ -<!-- -*- mode: html; -*- --> +<!--! -*- mode: html; -*- --> <tal:block tal:define="oid oid|field.oid; css_class css_class|field.widget.css_class; style style|field.widget.style; diff --git a/tailbone/templates/deform/select_dynamic.pt b/tailbone/templates/deform/select_dynamic.pt index 4ce3b01c..5d1de2f6 100644 --- a/tailbone/templates/deform/select_dynamic.pt +++ b/tailbone/templates/deform/select_dynamic.pt @@ -1,4 +1,4 @@ -<!-- -*- mode: html; -*- --> +<!--! -*- mode: html; -*- --> <div tal:define=" name name|field.name; oid oid|field.oid; diff --git a/tailbone/templates/deform/time_jquery.pt b/tailbone/templates/deform/time_jquery.pt index 4e8cdfe7..1575b3fa 100644 --- a/tailbone/templates/deform/time_jquery.pt +++ b/tailbone/templates/deform/time_jquery.pt @@ -1,4 +1,4 @@ -<!-- -*- mode: html; -*- --> +<!--! -*- mode: html; -*- --> <span tal:define="size size|field.widget.size; css_class css_class|field.widget.css_class; oid oid|field.oid; From fd1342c605cae77e7c8bccad12bac206df0ed586 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 5 Jan 2021 18:53:00 -0600 Subject: [PATCH 0258/1681] Try to show existing filename, for upload widget --- tailbone/forms/core.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 8f0cbfff..ebad3f74 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -786,7 +786,10 @@ class Form(object): the overall response, to be interpreted on the client side. """ if isinstance(field.schema.typ, deform.FileData): - # TODO: don't recall why "always null" here? + # TODO: we used to always/only return 'null' here but hopefully + # this also works, to show existing filename when present + if field.cstruct and field.cstruct['filename']: + return json.dumps({'name': field.cstruct['filename']}) return 'null' if isinstance(field.schema.typ, colander.Set): From 4d8e29c892db5e625aa2a8367507bf999927e3a9 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 6 Jan 2021 13:12:27 -0600 Subject: [PATCH 0259/1681] Add basic support for "download" and "rawbytes" API views --- tailbone/api/master2.py | 68 +++++++++++++++++++++++++++++++++++++++-- tailbone/views/core.py | 15 ++++----- 2 files changed, 74 insertions(+), 9 deletions(-) diff --git a/tailbone/api/master2.py b/tailbone/api/master2.py index a062343f..18fb3af0 100644 --- a/tailbone/api/master2.py +++ b/tailbone/api/master2.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -26,6 +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 from tailbone.api import APIMasterView @@ -41,6 +42,8 @@ class APIMasterView2(APIMasterView): editable = True deletable = True supports_autocomplete = False + supports_download = False + supports_rawbytes = False @classmethod def establish_method(cls, method_name): @@ -85,6 +88,48 @@ class APIMasterView2(APIMasterView): 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) @@ -137,5 +182,24 @@ class APIMasterView2(APIMasterView): if cls.supports_autocomplete: autocomplete = Service(name='{}.autocomplete'.format(route_prefix), path='{}/autocomplete'.format(collection_url_prefix)) - autocomplete.add_view('GET', 'autocomplete', klass=cls) + 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/views/core.py b/tailbone/views/core.py index aa662cf9..7d75e723 100644 --- a/tailbone/views/core.py +++ b/tailbone/views/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -166,7 +166,7 @@ class View(object): return render_to_response('json', data, request=self.request) - def file_response(self, path, filename=None): + def file_response(self, path, filename=None, attachment=True): """ Returns a generic FileResponse from the given path """ @@ -174,11 +174,12 @@ class View(object): return self.notfound() response = FileResponse(path, request=self.request) response.content_length = os.path.getsize(path) - if not filename: - filename = os.path.basename(path) - if six.PY2: - filename = filename.encode('ascii', 'replace') - response.content_disposition = str('attachment; filename="{}"'.format(filename)) + if attachment: + if not filename: + filename = os.path.basename(path) + if six.PY2: + filename = filename.encode('ascii', 'replace') + response.content_disposition = str('attachment; filename="{}"'.format(filename)) return response def get_quickie_context(self): From 758d5e6f4cec21047144209ad7c79cfa707b3757 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 10 Jan 2021 21:24:59 -0600 Subject: [PATCH 0260/1681] 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 9ec31ef9..fd7a2fe4 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,20 @@ CHANGELOG ========= +0.8.118 (2021-01-10) +-------------------- + +* Show node title in header for Login, About pages. + +* Allow changing protected user password when acting as root. + +* Allow specifying the size of a file, for ``readable_size()`` method. + +* Try to show existing filename, for upload widget. + +* Add basic support for "download" and "rawbytes" API views. + + 0.8.117 (2020-12-16) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index cc842e83..b0dd2a81 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.117' +__version__ = '0.8.118' From 5e9264bbefcc704c7f317a010b5f35b2d411001e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 14 Jan 2021 12:10:35 -0600 Subject: [PATCH 0261/1681] Don't create new person for new user, if one was selected --- tailbone/views/users.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tailbone/views/users.py b/tailbone/views/users.py index 310967eb..127d3491 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -274,15 +274,20 @@ class UsersView(PrincipalMasterView): names['last'] = data['last_name_'] if 'display_name_' in form: names['display'] = data['display_name_'] + # we will not have a person reference yet, when creating new user. if + # that is the case, go ahead and load it, if specified. + if self.creating and user.person_uuid: + self.Session.add(user) + self.Session.flush() # note, do *not* create new person unless name(s) provided if not user.person and any([n for n in names.values()]): user.person = model.Person() if user.person: - if 'first' in names: + if names.get('first'): user.person.first_name = names['first'] - if 'last' in names: + if names.get('last'): user.person.last_name = names['last'] - if 'display' in names: + if names.get('display'): user.person.display_name = names['display'] # force "local only" flag unless global access granted From db7d0211330fb4c2a422bfcb6f2f3e1fd4f3de2f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 14 Jan 2021 12:47:37 -0600 Subject: [PATCH 0262/1681] Allow newer zope.sqlalchemy package not sure of any real benefit, but could not find any reason to cap at such an old version, so let's relax it --- setup.py | 6 ++++-- tox.ini | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index 0fa089df..4a7badf7 100644 --- a/setup.py +++ b/setup.py @@ -64,8 +64,10 @@ requires = [ # # package # low high - # TODO: why do we need to cap this? breaks tailbone.db zope stuff somehow - 'zope.sqlalchemy<1.0', # 0.7 0.7.7 + # TODO: previously was capping this to pre-1.0 although i'm not sure why. + # however the 1.2 release has some breaking changes which require refactor. + # cf. https://pypi.org/project/zope.sqlalchemy/#id3 + 'zope.sqlalchemy<1.2', # 0.7 1.1 # TODO: apparently they jumped from 0.1 to 0.9 and that broke us... # (0.1 was released on 2014-09-14 and then 0.9 came out on 2018-09-27) diff --git a/tox.ini b/tox.ini index a83b1694..3a0faeca 100644 --- a/tox.ini +++ b/tox.ini @@ -19,7 +19,7 @@ deps = fixture mock nose - SQLAlchemy<1.3 + SQLAlchemy<1.4 SQLAlchemy-Utils<0.36.7 [testenv:coverage] From a3cbb248924be12d2de6338137401e7ad2677727 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 16 Jan 2021 14:13:34 -0600 Subject: [PATCH 0263/1681] Add variant transaction logic per zope.sqlalchemy 1.1 changes without this we can't use zope.sqlalchemy 1.1 due to error --- tailbone/db.py | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/tailbone/db.py b/tailbone/db.py index f7b106fe..ae919e49 100644 --- a/tailbone/db.py +++ b/tailbone/db.py @@ -1,8 +1,8 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -30,6 +30,7 @@ import sqlalchemy as sa from zope.sqlalchemy import datamanager import sqlalchemy_continuum as continuum from sqlalchemy.orm import sessionmaker, scoped_session +from pkg_resources import get_distribution, parse_version from rattail.db import SessionBase from rattail.db.continuum import versioning_manager @@ -44,6 +45,11 @@ TrainwreckSession = scoped_session(sessionmaker()) # empty dict for now, this must populated on app startup (if needed) ExtraTrainwreckSessions = {} +# some of the logic below may need to vary somewhat, based on which version of +# zope.sqlalchemy we have installed +zope_sqlalchemy_version = get_distribution('zope.sqlalchemy').version +zope_sqlalchemy_version_parsed = parse_version(zope_sqlalchemy_version) + class TailboneSessionDataManager(datamanager.SessionDataManager): """Integrate a top level sqlalchemy session transaction into a zope transaction @@ -85,12 +91,24 @@ def join_transaction(session, initial_state=datamanager.STATUS_ACTIVE, transacti This function is copied from upstream, and tweaked so that our custom :class:`TailboneSessionDataManager` will be used. """ - if datamanager._SESSION_STATE.get(id(session), None) is None: - if session.twophase: - DataManager = datamanager.TwoPhaseSessionDataManager - else: - DataManager = TailboneSessionDataManager - DataManager(session, initial_state, transaction_manager, keep_session=keep_session) + # the upstream internals of this function has changed a little over time. + # unfortunately for us, that means we must include each variant here. + + if zope_sqlalchemy_version_parsed >= parse_version('1.1'): # 1.1+ + if datamanager._SESSION_STATE.get(session, None) is None: + if session.twophase: + DataManager = datamanager.TwoPhaseSessionDataManager + else: + DataManager = TailboneSessionDataManager + DataManager(session, initial_state, transaction_manager, keep_session=keep_session) + + else: # pre-1.1 + if datamanager._SESSION_STATE.get(id(session), None) is None: + if session.twophase: + DataManager = datamanager.TwoPhaseSessionDataManager + else: + DataManager = TailboneSessionDataManager + DataManager(session, initial_state, transaction_manager, keep_session=keep_session) class ZopeTransactionExtension(datamanager.ZopeTransactionExtension): From ce629c91bbd458c767275ef6fc0e9edb0de34368 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 17 Jan 2021 11:15:24 -0600 Subject: [PATCH 0264/1681] Add CSS styles for 'codehilite' a la Pygments this is in anticipation for displaying syntax-highlighted code snippets from markdown source. this CSS file was generated according to instructions at https://python-markdown.github.io/extensions/code_hilite/ --- tailbone/static/css/codehilite.css | 74 +++++++++++++++++++++ tailbone/templates/themes/falafel/base.mako | 2 + 2 files changed, 76 insertions(+) create mode 100644 tailbone/static/css/codehilite.css diff --git a/tailbone/static/css/codehilite.css b/tailbone/static/css/codehilite.css new file mode 100644 index 00000000..a0992759 --- /dev/null +++ b/tailbone/static/css/codehilite.css @@ -0,0 +1,74 @@ +pre { line-height: 125%; } +td.linenos pre { color: #000000; background-color: #f0f0f0; padding-left: 5px; padding-right: 5px; } +span.linenos { color: #000000; background-color: #f0f0f0; padding-left: 5px; padding-right: 5px; } +td.linenos pre.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } +span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } +.codehilite .hll { background-color: #ffffcc } +.codehilite { background: #f8f8f8; } +.codehilite .c { color: #408080; font-style: italic } /* Comment */ +.codehilite .err { border: 1px solid #FF0000 } /* Error */ +.codehilite .k { color: #008000; font-weight: bold } /* Keyword */ +.codehilite .o { color: #666666 } /* Operator */ +.codehilite .ch { color: #408080; font-style: italic } /* Comment.Hashbang */ +.codehilite .cm { color: #408080; font-style: italic } /* Comment.Multiline */ +.codehilite .cp { color: #BC7A00 } /* Comment.Preproc */ +.codehilite .cpf { color: #408080; font-style: italic } /* Comment.PreprocFile */ +.codehilite .c1 { color: #408080; font-style: italic } /* Comment.Single */ +.codehilite .cs { color: #408080; font-style: italic } /* Comment.Special */ +.codehilite .gd { color: #A00000 } /* Generic.Deleted */ +.codehilite .ge { font-style: italic } /* Generic.Emph */ +.codehilite .gr { color: #FF0000 } /* Generic.Error */ +.codehilite .gh { color: #000080; font-weight: bold } /* Generic.Heading */ +.codehilite .gi { color: #00A000 } /* Generic.Inserted */ +.codehilite .go { color: #888888 } /* Generic.Output */ +.codehilite .gp { color: #000080; font-weight: bold } /* Generic.Prompt */ +.codehilite .gs { font-weight: bold } /* Generic.Strong */ +.codehilite .gu { color: #800080; font-weight: bold } /* Generic.Subheading */ +.codehilite .gt { color: #0044DD } /* Generic.Traceback */ +.codehilite .kc { color: #008000; font-weight: bold } /* Keyword.Constant */ +.codehilite .kd { color: #008000; font-weight: bold } /* Keyword.Declaration */ +.codehilite .kn { color: #008000; font-weight: bold } /* Keyword.Namespace */ +.codehilite .kp { color: #008000 } /* Keyword.Pseudo */ +.codehilite .kr { color: #008000; font-weight: bold } /* Keyword.Reserved */ +.codehilite .kt { color: #B00040 } /* Keyword.Type */ +.codehilite .m { color: #666666 } /* Literal.Number */ +.codehilite .s { color: #BA2121 } /* Literal.String */ +.codehilite .na { color: #7D9029 } /* Name.Attribute */ +.codehilite .nb { color: #008000 } /* Name.Builtin */ +.codehilite .nc { color: #0000FF; font-weight: bold } /* Name.Class */ +.codehilite .no { color: #880000 } /* Name.Constant */ +.codehilite .nd { color: #AA22FF } /* Name.Decorator */ +.codehilite .ni { color: #999999; font-weight: bold } /* Name.Entity */ +.codehilite .ne { color: #D2413A; font-weight: bold } /* Name.Exception */ +.codehilite .nf { color: #0000FF } /* Name.Function */ +.codehilite .nl { color: #A0A000 } /* Name.Label */ +.codehilite .nn { color: #0000FF; font-weight: bold } /* Name.Namespace */ +.codehilite .nt { color: #008000; font-weight: bold } /* Name.Tag */ +.codehilite .nv { color: #19177C } /* Name.Variable */ +.codehilite .ow { color: #AA22FF; font-weight: bold } /* Operator.Word */ +.codehilite .w { color: #bbbbbb } /* Text.Whitespace */ +.codehilite .mb { color: #666666 } /* Literal.Number.Bin */ +.codehilite .mf { color: #666666 } /* Literal.Number.Float */ +.codehilite .mh { color: #666666 } /* Literal.Number.Hex */ +.codehilite .mi { color: #666666 } /* Literal.Number.Integer */ +.codehilite .mo { color: #666666 } /* Literal.Number.Oct */ +.codehilite .sa { color: #BA2121 } /* Literal.String.Affix */ +.codehilite .sb { color: #BA2121 } /* Literal.String.Backtick */ +.codehilite .sc { color: #BA2121 } /* Literal.String.Char */ +.codehilite .dl { color: #BA2121 } /* Literal.String.Delimiter */ +.codehilite .sd { color: #BA2121; font-style: italic } /* Literal.String.Doc */ +.codehilite .s2 { color: #BA2121 } /* Literal.String.Double */ +.codehilite .se { color: #BB6622; font-weight: bold } /* Literal.String.Escape */ +.codehilite .sh { color: #BA2121 } /* Literal.String.Heredoc */ +.codehilite .si { color: #BB6688; font-weight: bold } /* Literal.String.Interpol */ +.codehilite .sx { color: #008000 } /* Literal.String.Other */ +.codehilite .sr { color: #BB6688 } /* Literal.String.Regex */ +.codehilite .s1 { color: #BA2121 } /* Literal.String.Single */ +.codehilite .ss { color: #19177C } /* Literal.String.Symbol */ +.codehilite .bp { color: #008000 } /* Name.Builtin.Pseudo */ +.codehilite .fm { color: #0000FF } /* Name.Function.Magic */ +.codehilite .vc { color: #19177C } /* Name.Variable.Class */ +.codehilite .vg { color: #19177C } /* Name.Variable.Global */ +.codehilite .vi { color: #19177C } /* Name.Variable.Instance */ +.codehilite .vm { color: #19177C } /* Name.Variable.Magic */ +.codehilite .il { color: #666666 } /* Literal.Number.Integer.Long */ diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index 75bb914c..4a7e1083 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -140,6 +140,8 @@ ${h.stylesheet_link(request.static_url('tailbone:static/themes/falafel/css/forms.css') + '?ver={}'.format(tailbone.__version__))} ${h.stylesheet_link(request.static_url('tailbone:static/css/diffs.css') + '?ver={}'.format(tailbone.__version__))} + ${h.stylesheet_link(request.static_url('tailbone:static/css/codehilite.css') + '?ver={}'.format(tailbone.__version__))} + <style type="text/css"> .filters .filter-fieldname { min-width: ${filter_fieldname_width}; From ca602ff8452247d1732b8067c42d4b5dc6a17cb8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 17 Jan 2021 12:08:33 -0600 Subject: [PATCH 0265/1681] Add feature to generate new features... at least that's the idea. guess we'll see where this goes --- setup.py | 1 + tailbone/templates/generate_feature.mako | 219 +++++++++++++++++++++++ tailbone/views/features.py | 116 ++++++++++++ 3 files changed, 336 insertions(+) create mode 100644 tailbone/templates/generate_feature.mako create mode 100644 tailbone/views/features.py diff --git a/setup.py b/setup.py index 4a7badf7..692c5c10 100644 --- a/setup.py +++ b/setup.py @@ -85,6 +85,7 @@ requires = [ 'ColanderAlchemy', # 0.3.3 'humanize', # 0.5.1 'Mako', # 0.6.2 + 'markdown', # 3.3.3 'openpyxl', # 2.4.7 'paginate', # 0.5.6 'paginate_sqlalchemy', # 0.2.0 diff --git a/tailbone/templates/generate_feature.mako b/tailbone/templates/generate_feature.mako new file mode 100644 index 00000000..658aab12 --- /dev/null +++ b/tailbone/templates/generate_feature.mako @@ -0,0 +1,219 @@ +## -*- coding: utf-8; -*- +<%inherit file="/page.mako" /> + +<%def name="title()">Generate Feature</%def> + +<%def name="content_title()"></%def> + +<%def name="extra_styles()"> + ${parent.extra_styles()} + <style type="text/css"> + + .content.result p { + margin-bottom: 1rem; + } + + .content.result .codehilite { + margin-bottom: 2rem; + } + + </style> +</%def> + +<%def name="page_content()"> + + <b-field horizontal label="App Prefix" + message="Unique naming prefix for the app."> + <b-input v-model="app.app_prefix" + @input="appPrefixChanged"> + </b-input> + </b-field> + + <b-field horizontal label="App CapPrefix" + message="Unique naming prefix for the app, in CapWords style."> + <b-input v-model="app.app_cap_prefix"></b-input> + </b-field> + + <b-field horizontal label="Feature Type"> + <b-select v-model="featureType"> + <option value="new-report">New Report</option> + <option value="new-table">New Table</option> + </b-select> + </b-field> + + <div v-if="featureType == 'new-table'"> + ${h.form(request.current_route_url(), ref='new-table-form')} + ${h.csrf_token(request)} + ${h.hidden('feature_type', value='new-table')} + ${h.hidden('app_prefix', **{'v-model': 'app.app_prefix'})} + ${h.hidden('app_cap_prefix', **{'v-model': 'app.app_cap_prefix'})} + + <br /> + <div class="card"> + <header class="card-header"> + <p class="card-header-title">New Table</p> + </header> + <div class="card-content"> + <div class="content"> + + <b-field horizontal label="Table Name" + :message="`Name for the table within the DB. With prefix this becomes: ${'$'}{app.app_prefix}_${'$'}{new_table.table_name}`"> + <b-input name="table_name" + v-model="new_table.table_name" + @input="tableNameChanged"> + </b-input> + </b-field> + + <b-field horizontal label="Model Name" + :message="`Model name for the table, in CapWords style. With prefix this becomes: ${'$'}{app.app_cap_prefix}${'$'}{new_table.model_name}`"> + <b-input name="model_name" v-model="new_table.model_name"></b-input> + </b-field> + + <b-field horizontal label="Model Title" + message="Human-friendly singular model title."> + <b-input name="model_title" v-model="new_table.model_title"></b-input> + </b-field> + + <b-field horizontal label="Plural Model Title" + message="Human-friendly plural model title."> + <b-input name="model_title_plural" v-model="new_table.model_title_plural"></b-input> + </b-field> + + <b-field horizontal label="Description" + message="Description of what a record in this table represents."> + <b-input name="description" v-model="new_table.description"></b-input> + </b-field> + + <b-field horizontal label="Versioned" + message="Whether to record version data for this table."> + <b-checkbox name="versioned" + v-model="new_table.versioned" + native-value="true"> + {{ new_table.versioned }} + </b-checkbox> + </b-field> + + </div> + </div> + </div> + + ${h.end_form()} + </div> + + <div v-if="featureType == 'new-report'"> + ${h.form(request.current_route_url(), ref='new-report-form')} + ${h.csrf_token(request)} + ${h.hidden('feature_type', value='new-report')} + ${h.hidden('app_prefix', **{'v-model': 'app.app_prefix'})} + ${h.hidden('app_cap_prefix', **{'v-model': 'app.app_cap_prefix'})} + + <br /> + <div class="card"> + <header class="card-header"> + <p class="card-header-title">New Report</p> + </header> + <div class="card-content"> + <div class="content"> + + <b-field horizontal label="Name" + message="Human-friendly name for the report."> + <b-input name="name" v-model="new_report.name"></b-input> + </b-field> + + <b-field horizontal label="Description" + message="Description of the report."> + <b-input name="description" v-model="new_report.description"></b-input> + </b-field> + + </div> + </div> + </div> + + ${h.end_form()} + </div> + + <br /> + <div class="buttons" style="padding-left: 8rem;"> + <once-button type="is-primary" + @click="submitFeatureForm()" + text="Generate Feature"> + </once-button> + </div> + + <div class="card" + v-if="resultGenerated"> + <header class="card-header"> + <p class="card-header-title"> + <a name="instructions" href="#instructions">Please follow these instructions carefully.</a> + </p> + </header> + <div class="card-content"> + <div class="content result">${rendered_result or ""|n}</div> + </div> + </div> + +</%def> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + ThisPageData.featureType = ${json.dumps(feature_type)|n} + ThisPageData.resultGenerated = ${json.dumps(bool(result))|n} + + % if result: + ThisPage.mounted = function() { + location.href = '#instructions' + } + % endif + + ThisPageData.app = { + <% dform = app_form.make_deform_form() %> + % for field in dform: + ${field.name}: ${app_form.get_vuejs_model_value(field)|n}, + % endfor + } + + % for key, form in six.iteritems(feature_forms): + <% safekey = key.replace('-', '_') %> + ThisPageData.${safekey} = { + <% dform = feature_forms[key].make_deform_form() %> + % for field in dform: + ${field.name}: ${feature_forms[key].get_vuejs_model_value(field)|n}, + % endfor + } + % endfor + + ThisPage.methods.appPrefixChanged = function(prefix) { + let words = prefix.split('_') + let capitalized = [] + words.forEach(word => { + capitalized.push(word[0].toUpperCase() + word.substr(1)) + }) + + this.app.app_cap_prefix = capitalized.join('') + } + + ThisPage.methods.tableNameChanged = function(name) { + let words = name.split('_') + let capitalized = [] + words.forEach(word => { + capitalized.push(word[0].toUpperCase() + word.substr(1)) + }) + + this.new_table.model_name = capitalized.join('') + this.new_table.model_title = capitalized.join(' ') + this.new_table.model_title_plural = capitalized.join(' ') + 's' + this.new_table.description = `Represents a ${'$'}{this.new_table.model_title}.` + } + + ThisPage.methods.submitFeatureForm = function() { + let form = this.$refs[this.featureType + '-form'] + form.submit() + } + + </script> +</%def> + + +${parent.body()} diff --git a/tailbone/views/features.py b/tailbone/views/features.py new file mode 100644 index 00000000..f2919fbc --- /dev/null +++ b/tailbone/views/features.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2021 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Feature views +""" + +from __future__ import unicode_literals, absolute_import + +import six +import colander +import markdown + +from tailbone import forms +from tailbone.views import View + + +class GenerateFeatureView(View): + """ + View for generating new feature source code + """ + + def __init__(self, request): + super(GenerateFeatureView, self).__init__(request) + self.handler = self.get_handler() + + def get_handler(self): + app = self.get_rattail_app() + handler = app.get_feature_handler() + return handler + + def __call__(self): + use_buefy = self.get_use_buefy() + + schema = self.handler.make_schema() + app_form = forms.Form(schema=schema, request=self.request, + use_buefy=use_buefy) + for key, value in six.iteritems(self.handler.get_defaults()): + app_form.set_default(key, value) + + feature_forms = {} + for feature in self.handler.iter_features(): + schema = feature.make_schema() + form = forms.Form(schema=schema, request=self.request, + use_buefy=use_buefy) + for key, value in six.iteritems(feature.get_defaults()): + form.set_default(key, value) + feature_forms[feature.feature_key] = form + + result = rendered_result = None + feature_type = 'new-table' + if self.request.method == 'POST': + if app_form.validate(newstyle=True): + + feature_type = self.request.POST['feature_type'] + feature = self.handler.get_feature(feature_type) + if not feature: + raise ValueError("Unknown feature type: {}".format(feature_type)) + + feature_form = feature_forms[feature.feature_key] + if feature_form.validate(newstyle=True): + context = dict(app_form.validated) + context.update(feature_form.validated) + result = self.handler.do_generate(feature, **context) + rendered_result = self.render_result(result) + + context = { + 'index_title': "Generate Feature", + 'handler': self.handler, + 'use_buefy': use_buefy, + 'app_form': app_form, + 'feature_type': feature_type, + 'feature_forms': feature_forms, + 'result': result, + 'rendered_result': rendered_result, + } + + return context + + def render_result(self, result): + return markdown.markdown(result, extensions=['fenced_code', + 'codehilite']) + + @classmethod + def defaults(cls, config): + + # generate feature + config.add_tailbone_permission('common', 'common.generate_feature', + "Generate new feature source code") + config.add_route('generate_feature', '/generate-feature') + config.add_view(cls, route_name='generate_feature', + permission='common.generate_feature', + renderer='/generate_feature.mako') + + +def includeme(config): + GenerateFeatureView.defaults(config) From 850b6f71dd10e889d48310706784afd5add8988d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 18 Jan 2021 00:32:30 -0600 Subject: [PATCH 0266/1681] Add basic support for defining columns when generating new table feature --- tailbone/templates/generate_feature.mako | 158 +++++++++++++++++++++++ 1 file changed, 158 insertions(+) diff --git a/tailbone/templates/generate_feature.mako b/tailbone/templates/generate_feature.mako index 658aab12..acd1db2f 100644 --- a/tailbone/templates/generate_feature.mako +++ b/tailbone/templates/generate_feature.mako @@ -47,6 +47,7 @@ ${h.hidden('feature_type', value='new-table')} ${h.hidden('app_prefix', **{'v-model': 'app.app_prefix'})} ${h.hidden('app_cap_prefix', **{'v-model': 'app.app_cap_prefix'})} + ${h.hidden('columns', **{':value': 'JSON.stringify(new_table.columns)'})} <br /> <div class="card"> @@ -93,6 +94,118 @@ </b-checkbox> </b-field> + <b-field horizontal label="Columns"> + <div class="control"> + + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="fas fa-plus" + @click="addColumn()"> + New Column + </b-button> + </div> + </div> + <div class="level-right"> + <div class="level-item"> + <b-button type="is-danger" + icon-pack="fas" + icon-left="fas fa-trash" + @click="new_table.columns = []" + :disabled="!new_table.columns.length"> + Delete All + </b-button> + </div> + </div> + </div> + + <b-table + :data="new_table.columns"> + <template slot-scope="props"> + + <b-table-column field="name" label="Name"> + {{ props.row.name }} + </b-table-column> + + <b-table-column field="data_type" label="Data Type"> + {{ props.row.data_type }} + </b-table-column> + + <b-table-column field="nullable" label="Nullable"> + {{ props.row.nullable }} + </b-table-column> + + <b-table-column field="description" label="Description"> + {{ props.row.description }} + </b-table-column> + + <b-table-column field="actions" label="Actions"> + <a href="#" class="grid-action" + @click.prevent="editColumnRow(props.row)"> + <i class="fas fa-edit"></i> + Edit + </a> + + + <a href="#" class="grid-action has-text-danger" + @click.prevent="deleteColumn(props.index)"> + <i class="fas fa-trash"></i> + Delete + </a> + + </b-table-column> + + </template> + </b-table> + + <b-modal has-modal-card + :active.sync="showingEditColumn"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Edit Column</p> + </header> + + <section class="modal-card-body"> + + <b-field label="Name"> + <b-input v-model="editingColumnName"></b-input> + </b-field> + + <b-field label="Data Type"> + <b-input v-model="editingColumnDataType"></b-input> + </b-field> + + <b-field label="Nullable"> + <b-checkbox v-model="editingColumnNullable" + native-value="true"> + {{ editingColumnNullable }} + </b-checkbox> + </b-field> + + <b-field label="Description"> + <b-input v-model="editingColumnDescription"></b-input> + </b-field> + + </section> + + <footer class="modal-card-foot"> + <b-button @click="showingEditColumn = false"> + Cancel + </b-button> + <b-button type="is-primary" + @click="saveColumn()"> + Save + </b-button> + </footer> + </div> + </b-modal> + + </div> + </b-field> + </div> </div> </div> @@ -207,8 +320,53 @@ this.new_table.description = `Represents a ${'$'}{this.new_table.model_title}.` } + ThisPageData.showingEditColumn = false + ThisPageData.editingColumn = null + ThisPageData.editingColumnName = null + ThisPageData.editingColumnDataType = null + ThisPageData.editingColumnNullable = null + ThisPageData.editingColumnDescription = null + + ThisPage.methods.addColumn = function(column) { + this.editingColumn = null + this.editingColumnName = null + this.editingColumnDataType = null + this.editingColumnNullable = true + this.editingColumnDescription = null + this.showingEditColumn = true + } + + ThisPage.methods.editColumnRow = function(column) { + this.editingColumn = column + this.editingColumnName = column.name + this.editingColumnDataType = column.data_type + this.editingColumnNullable = column.nullable + this.editingColumnDescription = column.description + this.showingEditColumn = true + } + + ThisPage.methods.saveColumn = function() { + if (this.editingColumn) { + column = this.editingColumn + } else { + column = {} + this.new_table.columns.push(column) + } + column.name = this.editingColumnName + column.data_type = this.editingColumnDataType + column.nullable = this.editingColumnNullable + column.description = this.editingColumnDescription + this.showingEditColumn = false + } + + ThisPage.methods.deleteColumn = function(index) { + this.new_table.columns.splice(index, 1) + } + ThisPage.methods.submitFeatureForm = function() { let form = this.$refs[this.featureType + '-form'] + // TODO: why do we have to set this? hidden field value is blank?! + form['feature_type'].value = this.featureType form.submit() } From af99ca790512f65a236fa45b451f87cb6892e9ed Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 19 Jan 2021 11:25:02 -0600 Subject: [PATCH 0267/1681] Make 'new-report' the default feature to be generated --- tailbone/views/features.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/features.py b/tailbone/views/features.py index f2919fbc..f9c2b5c7 100644 --- a/tailbone/views/features.py +++ b/tailbone/views/features.py @@ -67,7 +67,7 @@ class GenerateFeatureView(View): feature_forms[feature.feature_key] = form result = rendered_result = None - feature_type = 'new-table' + feature_type = 'new-report' if self.request.method == 'POST': if app_form.validate(newstyle=True): From f480c046f63197743165d70ecc1479a2e1b7c4c3 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 19 Jan 2021 12:18:56 -0600 Subject: [PATCH 0268/1681] Add views for "delete product" batch --- tailbone/views/batch/delproduct.py | 82 ++++++++++++++++++++++++++++++ tailbone/views/products.py | 22 ++++++-- 2 files changed, 99 insertions(+), 5 deletions(-) create mode 100644 tailbone/views/batch/delproduct.py diff --git a/tailbone/views/batch/delproduct.py b/tailbone/views/batch/delproduct.py new file mode 100644 index 00000000..775e2e79 --- /dev/null +++ b/tailbone/views/batch/delproduct.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2021 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Views for "delete product" batches +""" + +from __future__ import unicode_literals, absolute_import + +from rattail.db import model + +from tailbone.views.batch import BatchMasterView + + +class DeleteProductBatchView(BatchMasterView): + """ + Master view for delete product batches. + """ + model_class = model.DeleteProductBatch + model_row_class = model.DeleteProductBatchRow + default_handler_spec = 'rattail.batch.delproduct:DeleteProductBatchHandler' + route_prefix = 'batch.delproduct' + url_prefix = '/batches/delproduct' + template_prefix = '/batch/delproduct' + creatable = False + bulk_deletable = True + rows_bulk_deletable = True + + row_grid_columns = [ + 'sequence', + 'upc', + 'brand_name', + 'description', + 'size', + 'department_name', + 'subdepartment_name', + 'status_code', + ] + + row_form_fields = [ + 'sequence', + 'product', + 'upc', + 'brand_name', + 'description', + 'size', + 'department_number', + 'department_name', + 'subdepartment_number', + 'subdepartment_name', + 'status_code', + 'status_text', + ] + + def row_grid_extra_class(self, row, i): + if row.status_code == row.STATUS_PRODUCT_NOT_FOUND: + return 'warning' + if row.status_code == row.STATUS_DEPARTMENT_NOT_ALLOWED: + return 'notice' + + +def includeme(config): + DeleteProductBatchView.defaults(config) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index cce79659..34887021 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -1583,10 +1583,20 @@ class ProductsView(MasterView): return {'product': data} def get_supported_batches(self): - return { - 'labels': 'rattail.batch.labels:LabelBatchHandler', - 'pricing': 'rattail.batch.pricing:PricingBatchHandler', - } + 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'), + }), + ('delproduct', { + 'spec': self.rattail_config.get('rattail.batch', 'delproduct.handler', + default='rattail.batch.delproduct:DeleteProductBatchHandler'), + }), + ]) def make_batch(self): """ @@ -1716,6 +1726,8 @@ class ProductsView(MasterView): return self.request.route_url('labels.batch.view', uuid=batch.uuid) if batch.batch_key == 'pricing': return self.request.route_url('batch.pricing.view', uuid=batch.uuid) + if batch.batch_key == 'delproduct': + return self.request.route_url('batch.delproduct.view', uuid=batch.uuid) @classmethod def defaults(cls, config): From 59167278d4df7c6eed36a589b23a693e8b2976e0 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 20 Jan 2021 20:29:07 -0600 Subject: [PATCH 0269/1681] Set `self.model` when constructing new View --- tailbone/views/core.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/core.py b/tailbone/views/core.py index 7d75e723..69f9974c 100644 --- a/tailbone/views/core.py +++ b/tailbone/views/core.py @@ -68,6 +68,7 @@ class View(object): config = self.rattail_config if config: self.enum = config.get_enum() + self.model = config.get_model() @property def rattail_config(self): From 523ea6e0df6f747860c3ceaeea195e084ef40566 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 20 Jan 2021 20:29:17 -0600 Subject: [PATCH 0270/1681] Add some generic render methods to MasterView --- tailbone/views/master.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index ea911bd0..79f537e0 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -905,6 +905,19 @@ class MasterView(View): """ return obj.id_str + def render_as_is(self, obj, field): + return getattr(obj, field) + + def render_url(self, obj, field): + url = getattr(obj, field) + if url: + return tags.link_to(url, url, target='_blank') + + def render_html(self, obj, field): + html = getattr(obj, field) + if html: + return HTML.literal(html) + def render_default_phone(self, obj, field): """ Render the "default" (first) phone number for the given contact. From 0035a4129a786ff5aefc782d70705bfb1e6ba63a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 21 Jan 2021 17:37:17 -0600 Subject: [PATCH 0271/1681] Add custom `base.css` for falafel theme this copies from bobcat/base.css and just adds margin-bottom for p tag. this was done b/c in certain Buefy dialogs etc. the p tags are too close together. not sure if this change breaks anything else yet... --- tailbone/static/themes/falafel/css/base.css | 23 +++++++++++++++++++++ tailbone/templates/themes/falafel/base.mako | 2 +- 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 tailbone/static/themes/falafel/css/base.css diff --git a/tailbone/static/themes/falafel/css/base.css b/tailbone/static/themes/falafel/css/base.css new file mode 100644 index 00000000..10370009 --- /dev/null +++ b/tailbone/static/themes/falafel/css/base.css @@ -0,0 +1,23 @@ + +/****************************** + * general + ******************************/ + +p { + margin-bottom: 1rem; +} + + +/****************************** + * tweaks for root user + ******************************/ + +.navbar .navbar-end .navbar-link.root-user, +.navbar .navbar-end .navbar-link.root-user:hover, +.navbar .navbar-end .navbar-link.root-user.is_active, +.navbar .navbar-end .navbar-item.root-user, +.navbar .navbar-end .navbar-item.root-user:hover, +.navbar .navbar-end .navbar-item.root-user.is_active { + background-color: red; + font-weight: bold; +} diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index 4a7e1083..8bee2119 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -129,7 +129,7 @@ ${self.buefy_styles()} - ${h.stylesheet_link(request.static_url('tailbone:static/themes/bobcat/css/base.css') + '?ver={}'.format(tailbone.__version__))} + ${h.stylesheet_link(request.static_url('tailbone:static/themes/falafel/css/base.css') + '?ver={}'.format(tailbone.__version__))} ${h.stylesheet_link(request.static_url('tailbone:static/themes/falafel/css/layout.css') + '?ver={}'.format(tailbone.__version__))} ${h.stylesheet_link(request.static_url('tailbone:static/css/grids.css') + '?ver={}'.format(tailbone.__version__))} ${h.stylesheet_link(request.static_url('tailbone:static/themes/falafel/css/grids.css') + '?ver={}'.format(tailbone.__version__))} From dde6195f38d214ea353fe58c2833fb9ba08d8cc2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 21 Jan 2021 17:39:16 -0600 Subject: [PATCH 0272/1681] Add master view for Units of Measure mapping table w/ support for "collect from wild" tool --- .../templates/units-of-measure/index.mako | 66 +++++++++ tailbone/views/uoms.py | 126 ++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 tailbone/templates/units-of-measure/index.mako create mode 100644 tailbone/views/uoms.py diff --git a/tailbone/templates/units-of-measure/index.mako b/tailbone/templates/units-of-measure/index.mako new file mode 100644 index 00000000..b29bad66 --- /dev/null +++ b/tailbone/templates/units-of-measure/index.mako @@ -0,0 +1,66 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/index.mako" /> + +<%def name="grid_tools()"> + ${parent.grid_tools()} + + <b-button type="is-primary" + icon-pack="fas" + icon-left="fas fa-shopping-basket" + @click="showingCollectWildDialog = true"> + Collect from the Wild + </b-button> + + ${h.form(url('{}.collect_wild_uoms'.format(route_prefix)), ref='collect-wild-uoms-form')} + ${h.csrf_token(request)} + ${h.end_form()} + + <b-modal has-modal-card + :active.sync="showingCollectWildDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Collect from the Wild</p> + </header> + + <section class="modal-card-body"> + <p> + This tool will query some database(s) in order to discover all UOM + abbreviations which currently exist in your product data. + </p> + <p> + Depending on how it has to go about that, this could take a minute or + two. Please be patient when running it. + </p> + </section> + + <footer class="modal-card-foot"> + <b-button @click="showingCollectWildDialog = false"> + Cancel + </b-button> + <once-button type="is-primary" + @click="collectFromWild()" + icon-left="shopping-basket" + text="Collect from the Wild"> + </once-button> + </footer> + + </div> + </b-modal> +</%def> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + TailboneGridData.showingCollectWildDialog = false + + TailboneGrid.methods.collectFromWild = function() { + this.$refs['collect-wild-uoms-form'].submit() + } + + </script> +</%def> + + +${parent.body()} diff --git a/tailbone/views/uoms.py b/tailbone/views/uoms.py new file mode 100644 index 00000000..11f80779 --- /dev/null +++ b/tailbone/views/uoms.py @@ -0,0 +1,126 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2021 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +UOM Views +""" + +from __future__ import unicode_literals, absolute_import + +from rattail.db import model + +from tailbone.views import MasterView + + +class UnitOfMeasureView(MasterView): + """ + Master view for the UOM mappings. + """ + model_class = model.UnitOfMeasure + route_prefix = 'uoms' + url_prefix = '/units-of-measure' + bulk_deletable = True + has_versions = True + + labels = { + 'sil_code': "SIL Code", + } + + grid_columns = [ + 'abbreviation', + 'sil_code', + 'description', + 'notes', + ] + + form_fields = [ + 'abbreviation', + 'sil_code', + 'description', + 'notes', + ] + + def configure_grid(self, g): + super(UnitOfMeasureView, self).configure_grid(g) + + g.set_renderer('description', self.render_description) + + g.set_sort_defaults('abbreviation') + + g.set_link('abbreviation') + g.set_link('description') + + def configure_form(self, f): + super(UnitOfMeasureView, self).configure_form(f) + + f.set_renderer('description', self.render_description) + f.set_type('notes', 'text') + + if not self.creating: + f.set_readonly('abbreviation') + + if self.creating or self.editing: + f.remove('description') + f.set_enum('sil_code', self.enum.UNIT_OF_MEASURE) + + def redirect_after_create(self, uom, **kwargs): + return self.redirect(self.get_index_url()) + + def redirect_after_edit(self, uom, **kwargs): + return self.redirect(self.get_index_url()) + + def render_description(self, uom, field): + code = uom.sil_code + if code: + if code in self.enum.UNIT_OF_MEASURE: + return self.enum.UNIT_OF_MEASURE[code] + return "(unknown code)" + + def collect_wild_uoms(self): + app = self.get_rattail_app() + handler = app.get_products_handler() + uoms = handler.collect_wild_uoms() + self.request.session.flash("All abbreviations from the wild have been added. Now please map each to a SIL code.") + return self.redirect(self.get_index_url()) + + @classmethod + def defaults(cls, config): + cls._uom_defaults(config) + cls._defaults(config) + + @classmethod + def _uom_defaults(cls, config): + route_prefix = cls.get_route_prefix() + url_prefix = cls.get_url_prefix() + permission_prefix = cls.get_permission_prefix() + + # collect wild uoms + config.add_tailbone_permission(permission_prefix, '{}.collect_wild_uoms'.format(permission_prefix), + "Collect UoM abbreviations from the wild") + config.add_route('{}.collect_wild_uoms'.format(route_prefix), '{}/collect-wild-uoms'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='collect_wild_uoms', route_name='{}.collect_wild_uoms'.format(route_prefix), + permission='{}.collect_wild_uoms'.format(permission_prefix)) + + +def includeme(config): + UnitOfMeasureView.defaults(config) From 649ac12cdd3185dc81c0831593e03bbc14fc6764 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 21 Jan 2021 17:48:09 -0600 Subject: [PATCH 0273/1681] Add woocommerce package links for sake of upgrade diff view --- tailbone/views/upgrades.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index b469a7be..33a99e59 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.py @@ -326,6 +326,14 @@ class UpgradeView(MasterView): 'commit_url': 'https://kallithea.rattailproject.org/rattail-restricted/tailbone-onager/changelog/{new_version}/?size=10', 'release_url': 'https://kallithea.rattailproject.org/rattail-restricted/tailbone-onager/files/{new_version}/CHANGELOG.md', }, + 'rattail-woocommerce': { + 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-woocommerce/changelog/{new_version}/?size=10', + 'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-woocommerce/files/{new_version}/CHANGES.rst', + }, + 'tailbone-woocommerce': { + 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-woocommerce/changelog/{new_version}/?size=10', + 'release_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-woocommerce/files/{new_version}/CHANGES.rst', + }, } return projects From a327dfab7c77e9a7209d8c36fc09c2896859dce0 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 23 Jan 2021 14:11:05 -0600 Subject: [PATCH 0274/1681] Add basic web API app, for simple use cases plus some functions which make it easier to customize --- setup.py | 3 +- tailbone/webapi.py | 91 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 tailbone/webapi.py diff --git a/setup.py b/setup.py index 692c5c10..0e40a324 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -169,6 +169,7 @@ setup( 'paste.app_factory': [ 'main = tailbone.app:main', + 'webapi = tailbone.webapi:main', ], 'rattail.config.extensions': [ diff --git a/tailbone/webapi.py b/tailbone/webapi.py new file mode 100644 index 00000000..10c3460b --- /dev/null +++ b/tailbone/webapi.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2021 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Tailbone Web API +""" + +from __future__ import unicode_literals, absolute_import + +from pyramid.config import Configurator +from pyramid.authentication import SessionAuthenticationPolicy + +from tailbone import app +from tailbone.auth import TailboneAuthorizationPolicy + + +def make_rattail_config(settings): + """ + Make a Rattail config object from the given settings. + """ + rattail_config = app.make_rattail_config(settings) + return rattail_config + + +def make_pyramid_config(settings): + """ + Make a Pyramid config object from the given settings. + """ + pyramid_config = Configurator(settings=settings, root_factory=app.Root) + + # configure user authorization / authentication + pyramid_config.set_authorization_policy(TailboneAuthorizationPolicy()) + pyramid_config.set_authentication_policy(SessionAuthenticationPolicy()) + + # always require CSRF token protection + pyramid_config.set_default_csrf_options(require_csrf=True, + token='_csrf', + header='X-XSRF-TOKEN') + + # bring in some Pyramid goodies + pyramid_config.include('tailbone.beaker') + pyramid_config.include('pyramid_tm') + pyramid_config.include('cornice') + + # bring in the pyramid_retry logic, if available + # TODO: pretty soon we can require this package, hopefully.. + try: + import pyramid_retry + except ImportError: + pass + else: + pyramid_config.include('pyramid_retry') + + # add some permissions magic + pyramid_config.add_directive('add_tailbone_permission_group', 'tailbone.auth.add_permission_group') + pyramid_config.add_directive('add_tailbone_permission', 'tailbone.auth.add_permission') + + return pyramid_config + + +def main(global_config, **settings): + """ + This function returns a Pyramid WSGI application. + """ + rattail_config = make_rattail_config(settings) + pyramid_config = make_pyramid_config(settings) + + # bring in some Tailbone + pyramid_config.include('tailbone.subscribers') + pyramid_config.include('tailbone.api') + + return pyramid_config.make_wsgi_app() From b55ecc3898601d4939df15997f0d6adabe97b721 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 23 Jan 2021 21:08:00 -0600 Subject: [PATCH 0275/1681] Tweak label style, per recent `base.css` change --- tailbone/templates/themes/falafel/base.mako | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index 8bee2119..b496a954 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -273,7 +273,7 @@ % if expose_db_picker is not Undefined and expose_db_picker: <div class="level-item"> - <p>DB:</p> + <p style="margin-bottom: 0;">DB:</p> </div> <div class="level-item"> ${h.form(url('change_db_engine'), ref='dbPickerForm')} From 475ab3013f125858e45c15b95f6aa54f6f6d14ae Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 25 Jan 2021 11:43:35 -0600 Subject: [PATCH 0276/1681] Update changelog --- CHANGES.rst | 28 ++++++++++++++++++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index fd7a2fe4..d8a0051b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,34 @@ CHANGELOG ========= +0.8.119 (2021-01-25) +-------------------- + +* Don't create new person for new user, if one was selected. + +* Allow newer zope.sqlalchemy package. + +* Add variant transaction logic per zope.sqlalchemy 1.1 changes. + +* Add CSS styles for 'codehilite' a la Pygments. + +* Add feature to generate new features... + +* Add views for "delete product" batch. + +* Set ``self.model`` when constructing new View. + +* Add some generic render methods to MasterView. + +* Add custom ``base.css`` for falafel theme. + +* Add master view for Units of Measure mapping table. + +* Add woocommerce package links for sake of upgrade diff view. + +* Add basic web API app, for simple use cases. + + 0.8.118 (2021-01-10) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index b0dd2a81..a4fa189f 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.118' +__version__ = '0.8.119' From 480d878db86f8c009ebe07b1d7fd21f60048ac65 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 26 Jan 2021 20:10:05 -0600 Subject: [PATCH 0277/1681] Initial support for adding items to, executing customer order batch --- .../static/js/tailbone.buefy.autocomplete.js | 8 +- tailbone/templates/autocomplete.mako | 6 +- tailbone/templates/custorders/create.mako | 414 +++++++++++++++++- tailbone/views/custorders/batch.py | 60 ++- tailbone/views/custorders/orders.py | 247 ++++++++++- tailbone/views/products.py | 3 + 6 files changed, 701 insertions(+), 37 deletions(-) diff --git a/tailbone/static/js/tailbone.buefy.autocomplete.js b/tailbone/static/js/tailbone.buefy.autocomplete.js index fc64a073..0d61ca79 100644 --- a/tailbone/static/js/tailbone.buefy.autocomplete.js +++ b/tailbone/static/js/tailbone.buefy.autocomplete.js @@ -8,6 +8,8 @@ const TailboneAutocomplete = { serviceUrl: String, value: String, initialLabel: String, + assignedValue: String, + assignedLabel: String, }, data() { @@ -43,7 +45,7 @@ const TailboneAutocomplete = { this.value = null if (focus) { this.$nextTick(function() { - this.$refs.autocomplete.focus() + this.focus() }) } @@ -51,6 +53,10 @@ const TailboneAutocomplete = { // $('#' + oid + '-textbox').trigger('autocompletevaluecleared'); }, + focus() { + this.$refs.autocomplete.focus() + }, + getDisplayText() { if (this.selected) { return this.selected.label diff --git a/tailbone/templates/autocomplete.mako b/tailbone/templates/autocomplete.mako index c9de4507..0ab9f49c 100644 --- a/tailbone/templates/autocomplete.mako +++ b/tailbone/templates/autocomplete.mako @@ -64,7 +64,7 @@ <b-autocomplete ref="autocomplete" :name="name" - v-show="!selected" + v-show="!assignedValue && !selected" v-model="value" :data="data" @typing="getAsyncData" @@ -76,10 +76,10 @@ </template> </b-autocomplete> - <b-button v-if="selected" + <b-button v-if="assignedValue || selected" style="width: 100%; justify-content: left;" @click="clearSelection()"> - {{ selected.label }} (click to change) + {{ assignedLabel || selected.label }} (click to change) </b-button> </div> diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index 24245b7a..88c902e9 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -22,24 +22,32 @@ </%def> <%def name="order_form_buttons()"> - <div class="buttons"> - <b-button type="is-primary" - @click="submitOrder()" - icon-pack="fas" - icon-left="fas fa-upload"> - Submit this Order - </b-button> - <b-button @click="startOverEntirely()" - icon-pack="fas" - icon-left="fas fa-redo"> - Start Over Entirely - </b-button> - <b-button @click="cancelOrder()" - type="is-danger" - icon-pack="fas" - icon-left="fas fa-trash"> - Cancel this Order - </b-button> + <div class="level"> + <div class="level-left"> + </div> + <div class="level-right"> + <div class="level-item"> + <div class="buttons"> + <b-button type="is-primary" + @click="submitOrder()" + icon-pack="fas" + icon-left="fas fa-upload"> + Submit this Order + </b-button> + <b-button @click="startOverEntirely()" + icon-pack="fas" + icon-left="fas fa-redo"> + Start Over Entirely + </b-button> + <b-button @click="cancelOrder()" + type="is-danger" + icon-pack="fas" + icon-left="fas fa-trash"> + Cancel this Order + </b-button> + </div> + </div> + </div> </div> </%def> @@ -163,12 +171,157 @@ ## (for now we just always show caret-right instead) icon="caret-right"> </b-icon> - <strong>Items</strong> + <strong v-html="itemsPanelHeader"></strong> </div> <div class="panel-block"> <div> - TODO: items go here + <b-button type="is-primary" + icon-pack="fas" + icon-left="fas fa-plus" + @click="showAddItemDialog()"> + Add Item + </b-button> + <b-modal :active.sync="showingItemDialog"> + <div class="card"> + <div class="card-content"> + + <b-tabs type="is-boxed is-toggle" + :animated="false"> + + <b-tab-item label="Product"> + + <div class="field"> + <b-radio v-model="productIsKnown" + :native-value="true"> + Product is already in the system. + </b-radio> + </div> + + <div v-show="productIsKnown"> + + <b-field grouped> + <b-field label="Description" horizontal expanded> + <tailbone-autocomplete + ref="productDescriptionAutocomplete" + v-model="productUUID" + :assigned-value="productUUID" + :assigned-label="productDisplay" + serviceUrl="${url('products.autocomplete')}" + @input="productChanged"> + </tailbone-autocomplete> + </b-field> + </b-field> + + <b-field grouped> + <b-field label="UPC" horizontal expanded> + <b-input v-if="!productUUID" + v-model="productUPC" + ref="productUPCInput"> + </b-input> + <b-button v-if="!productUUID" + @click="fetchProductByUPC()"> + Fetch + </b-button> + <b-button v-if="productUUID" + @click="clearProduct(true)"> + {{ productUPC }} (click to change) + </b-button> + </b-field> + </b-field> + + </div> + + <div class="field"> + <b-radio v-model="productIsKnown" disabled + :native-value="false"> + Product is not yet in the system. + </b-radio> + </div> + + </b-tab-item> + <b-tab-item label="Quantity"> + + <b-field grouped> + + <b-field label="Quantity" horizontal> + <b-input v-model="productQuantity"></b-input> + </b-field> + + <b-select v-model="productUOM"> + <option v-for="choice in productUnitChoices" + :key="choice.key" + :value="choice.key" + v-html="choice.value"> + </option> + </b-select> + + </b-field> + </b-tab-item> + </b-tabs> + + <div class="buttons"> + <b-button @click="showingItemDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + icon-pack="fas" + icon-left="fas fa-save" + @click="itemDialogSave()"> + {{ itemDialogSaveButtonText }} + </b-button> + </div> + + </div> + </div> + </b-modal> + + <b-table + :data="items"> + <template slot-scope="props"> + + <b-table-column field="product_upc_pretty" label="UPC"> + {{ props.row.product_upc_pretty }} + </b-table-column> + + <b-table-column field="product_brand" label="Brand"> + {{ props.row.product_brand }} + </b-table-column> + + <b-table-column field="product_description" label="Description"> + {{ props.row.product_description }} + </b-table-column> + + <b-table-column field="product_size" label="Size"> + {{ props.row.product_size }} + </b-table-column> + + <b-table-column field="order_quantity_display" label="Quantity"> + <span v-html="props.row.order_quantity_display"></span> + </b-table-column> + + <b-table-column field="total_price_display" label="Total"> + {{ props.row.total_price_display }} + </b-table-column> + + <b-table-column field="actions" label="Actions"> + <a href="#" class="grid-action" + @click.prevent="showEditItemDialog(props.index)"> + <i class="fas fa-edit"></i> + Edit + </a> + + + <a href="#" class="grid-action has-text-danger" + @click.prevent="deleteItem(props.index)"> + <i class="fas fa-trash"></i> + Delete + </a> + + </b-table-column> + + </template> + </b-table> </div> </div> </b-collapse> @@ -191,10 +344,20 @@ const CustomerOrderCreator = { 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}' + return { batchAction: null, + batchTotalPriceDisplay: ${json.dumps(normalized_batch['total_price_display'])|n}, - customerPanelOpen: true, + customerPanelOpen: false, customerIsKnown: true, customerUUID: ${json.dumps(batch.customer_uuid)|n}, customerDisplay: ${json.dumps(six.text_type(batch.customer or ''))|n}, @@ -204,6 +367,20 @@ customerName: null, phoneNumber: null, + items: ${json.dumps(order_items)|n}, + editingItem: null, + showingItemDialog: false, + productIsKnown: true, + productUUID: null, + productDisplay: null, + productUPC: null, + productQuantity: null, + defaultUnitChoices: defaultUnitChoices, + productUnitChoices: defaultUnitChoices, + defaultUOM: defaultUOM, + productUOM: defaultUOM, + productCaseSize: null, + ## 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}, } @@ -301,9 +478,28 @@ return { type: null, - text: "Everything seems to be okay here.", + text: "Customer info looks okay.", } }, + + itemsPanelHeader() { + let text = "Items" + + if (this.items.length) { + text = "Items: " + this.items.length.toString() + " for " + this.batchTotalPriceDisplay + } + + return text + }, + + itemDialogSaveButtonText() { + return this.editingItem ? "Update Item" : "Add Item" + }, + }, + mounted() { + if (this.customerStatusType) { + this.customerPanelOpen = true + } }, methods: { @@ -369,6 +565,12 @@ if (callback) { callback(response) } + }, response => { + this.$buefy.toast.open({ + message: "Unexpected error occurred", + type: 'is-danger', + duration: 2000, // 2 seconds + }) }) }, @@ -385,7 +587,24 @@ }, submitOrder() { - alert("okay then!") + let params = { + action: 'submit_new_order', + } + this.submitBatchData(params, response => { + if (response.data.error) { + this.$buefy.toast.open({ + message: "Submit failed: " + response.data.error, + type: 'is-danger', + duration: 2000, // 2 seconds + }) + } else { + if (response.data.next_url) { + location.href = response.data.next_url + } else { + location.reload() + } + } + }) }, customerChanged(uuid) { @@ -414,6 +633,155 @@ this.setCustomerData() } }, + + showAddItemDialog() { + this.editingItem = null + this.productIsKnown = true + this.productUUID = null + this.productDisplay = null + this.productUPC = null + this.productQuantity = 1 + this.productUnitChoices = this.defaultUnitChoices + this.productUOM = this.defaultUOM + this.showingItemDialog = true + this.$nextTick(() => { + this.$refs.productDescriptionAutocomplete.focus() + }) + }, + + showEditItemDialog(index) { + row = this.items[index] + this.editingItem = row + this.productIsKnown = true // TODO + this.productUUID = row.product_uuid + this.productDisplay = row.product_full_description + this.productUPC = row.product_upc_pretty || row.product_upc + this.productQuantity = row.order_quantity + this.productUnitChoices = row.order_uom_choices + this.productUOM = row.order_uom + + this.showingItemDialog = true + }, + + deleteItem(index) { + if (!confirm("Are you sure you want to delete this item?")) { + return + } + + let params = { + action: 'delete_item', + uuid: this.items[index].uuid, + } + this.submitBatchData(params, response => { + if (response.data.error) { + this.$buefy.toast.open({ + message: "Delete failed: " + response.data.error, + type: 'is-warning', + duration: 2000, // 2 seconds + }) + } else { + this.items.splice(index, 1) + this.batchTotalPriceDisplay = response.data.batch.total_price_display + } + }) + }, + + clearProduct(autofocus) { + this.productUUID = null + this.productDisplay = null + this.productUPC = null + this.productUnitChoices = this.defaultUnitChoices + if (autofocus) { + this.$nextTick(() => { + this.$refs.productUPCInput.focus() + }) + } + }, + + fetchProductByUPC() { + let params = { + action: 'find_product_by_upc', + upc: this.productUPC, + } + this.submitBatchData(params, response => { + if (response.data.error) { + this.$buefy.toast.open({ + message: "Fetch failed: " + response.data.error, + type: 'is-warning', + duration: 2000, // 2 seconds + }) + } else { + this.productUUID = response.data.uuid + this.productUPC = response.data.upc_pretty + this.productDisplay = response.data.full_description + } + }) + }, + + productChanged(uuid) { + if (uuid) { + this.productUUID = uuid + let params = { + action: 'get_product_info', + uuid: this.productUUID, + } + this.submitBatchData(params, response => { + this.productUPC = response.data.upc_pretty + this.productDisplay = response.data.full_description + this.productUnitChoices = response.data.uom_choices + + let found = false + for (let uom of this.productUnitChoices) { + if (this.productUOM == uom.key) { + found = true + break + } + } + if (!found) { + this.productUOM = this.productUnitChoices[0].key + } + }) + } else { + this.clearProduct() + } + }, + + itemDialogSave() { + + let params = { + product_is_known: this.productIsKnown, + product_uuid: this.productUUID, + order_quantity: this.productQuantity, + order_uom: this.productUOM, + } + + if (this.editingItem) { + params.action = 'update_item' + params.uuid = this.editingItem.uuid + } else { + params.action = 'add_item' + } + + this.submitBatchData(params, response => { + + if (params.action == 'add_item') { + this.items.push(response.data.row) + + } else { // update_item + // must update each value separately, instead of + // overwriting the item record, or else display will + // not update properly + for (let [key, value] of Object.entries(response.data.row)) { + this.editingItem[key] = value + } + } + + // also update the batch total price + this.batchTotalPriceDisplay = response.data.batch.total_price_display + + this.showingItemDialog = false + }) + }, }, } diff --git a/tailbone/views/custorders/batch.py b/tailbone/views/custorders/batch.py index c8b6280f..bfbb5c02 100644 --- a/tailbone/views/custorders/batch.py +++ b/tailbone/views/custorders/batch.py @@ -48,7 +48,8 @@ class CustomerOrderBatchView(BatchMasterView): grid_columns = [ 'id', 'customer', - 'rows', + 'rowcount', + 'total_price', 'created', 'created_by', ] @@ -61,13 +62,35 @@ class CustomerOrderBatchView(BatchMasterView): 'email_address', 'created', 'created_by', - 'rows', + 'rowcount', + 'total_price', + ] + + row_labels = { + 'product_upc': "UPC", + 'product_brand': "Brand", + 'product_description': "Description", + 'product_size': "Size", + 'order_uom': "Order UOM", + } + + row_grid_columns = [ + 'sequence', + 'product_upc', + 'product_brand', + 'product_description', + 'product_size', + 'order_quantity', + 'order_uom', + 'total_price', 'status_code', ] def configure_grid(self, g): super(CustomerOrderBatchView, self).configure_grid(g) + g.set_type('total_price', 'currency') + g.set_link('customer') g.set_link('created') g.set_link('created_by') @@ -120,3 +143,36 @@ class CustomerOrderBatchView(BatchMasterView): f.set_label('person_uuid', "Person") else: f.set_renderer('person', self.render_person) + + f.set_type('total_price', 'currency') + + def row_grid_extra_class(self, row, i): + if row.status_code == row.STATUS_PRODUCT_NOT_FOUND: + return 'warning' + + def configure_row_grid(self, g): + super(CustomerOrderBatchView, self).configure_row_grid(g) + + g.set_type('case_quantity', 'quantity') + g.set_type('cases_ordered', 'quantity') + g.set_type('units_ordered', 'quantity') + g.set_type('order_quantity', 'quantity') + g.set_enum('order_uom', self.enum.UNIT_OF_MEASURE) + g.set_type('unit_price', 'currency') + g.set_type('total_price', 'currency') + + g.set_link('product_upc') + g.set_link('product_description') + + def configure_row_form(self, f): + super(CustomerOrderBatchView, self).configure_row_form(f) + + f.set_renderer('product', self.render_product) + + f.set_type('case_quantity', 'quantity') + f.set_type('cases_ordered', 'quantity') + f.set_type('units_ordered', 'quantity') + f.set_type('order_quantity', 'quantity') + f.set_enum('order_uom', self.enum.UNIT_OF_MEASURE) + f.set_type('unit_price', 'currency') + f.set_type('total_price', 'currency') diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index 9ffc06c8..420ac892 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -26,10 +26,15 @@ Customer Order Views from __future__ import unicode_literals, absolute_import +import decimal + import six from sqlalchemy import orm -from rattail.db import model +from rattail import pod +from rattail.db import api, model +from rattail.util import pretty_quantity +from rattail.batch import get_batch_handler from webhelpers2.html import tags @@ -123,6 +128,10 @@ class CustomerOrdersView(MasterView): submits the order, at which point the batch is converted to a proper order. """ + self.handler = get_batch_handler( + self.rattail_config, 'custorder', + default='rattail.batch.custorder:CustomerOrderBatchHandler') + batch = self.get_current_batch() if self.request.method == 'POST': @@ -142,13 +151,22 @@ class CustomerOrdersView(MasterView): json_actions = [ 'get_customer_info', 'set_customer_data', + 'find_product_by_upc', + 'get_product_info', + 'add_item', + 'update_item', + 'delete_item', 'submit_new_order', ] if action in json_actions: result = getattr(self, action)(batch, data) return self.json_response(result) - context = {'batch': batch} + items = [self.normalize_row(row) + for row in batch.active_rows()] + context = {'batch': batch, + 'normalized_batch': self.normalize_batch(batch), + 'order_items': items} return self.render_to_response(template, context) def get_current_batch(self): @@ -161,13 +179,15 @@ class CustomerOrdersView(MasterView): batch = self.Session.query(model.CustomerOrderBatch)\ .filter(model.CustomerOrderBatch.mode == self.enum.CUSTORDER_BATCH_MODE_CREATING)\ .filter(model.CustomerOrderBatch.created_by == user)\ + .filter(model.CustomerOrderBatch.executed == None)\ .one() except orm.exc.NoResultFound: # no batch yet for this user, so make one - batch = model.CustomerOrderBatch() - batch.mode = self.enum.CUSTORDER_BATCH_MODE_CREATING - batch.created_by = user + + batch = self.handler.make_batch( + self.Session(), created_by=user, + mode=self.enum.CUSTORDER_BATCH_MODE_CREATING) self.Session.add(batch) self.Session.flush() @@ -236,9 +256,220 @@ class CustomerOrdersView(MasterView): self.Session.flush() return {'success': True} + def find_product_by_upc(self, batch, data): + upc = data.get('upc') + if not upc: + return {'error': "Must specify a product UPC"} + + product = api.get_product_by_upc(self.Session(), upc) + if not product: + return {'error': "Product not found"} + + return self.info_for_product(batch, data, product) + + def get_product_info(self, batch, data): + uuid = data.get('uuid') + if not uuid: + return {'error': "Must specify a product UUID"} + + product = self.Session.query(model.Product).get(uuid) + if not product: + return {'error': "Product not found"} + + return self.info_for_product(batch, data, product) + + def uom_choices_for_product(self, product): + choices = [] + + # Each + if not product or not product.weighed: + unit_name = self.enum.UNIT_OF_MEASURE[self.enum.UNIT_OF_MEASURE_EACH] + choices.append({'key': self.enum.UNIT_OF_MEASURE_EACH, + 'value': unit_name}) + + # Pound + if not product or product.weighed: + unit_name = self.enum.UNIT_OF_MEASURE[self.enum.UNIT_OF_MEASURE_POUND] + choices.append({ + 'key': self.enum.UNIT_OF_MEASURE_POUND, + 'value': unit_name, + }) + + # Case + case_text = None + if product.case_size is None: + case_text = "{} (× ?? {})".format( + self.enum.UNIT_OF_MEASURE[self.enum.UNIT_OF_MEASURE_CASE], + unit_name) + elif product.case_size > 1: + case_text = "{} (× {} {})".format( + self.enum.UNIT_OF_MEASURE[self.enum.UNIT_OF_MEASURE_CASE], + pretty_quantity(product.case_size), + unit_name) + if case_text: + choices.append({'key': self.enum.UNIT_OF_MEASURE_CASE, + 'value': case_text}) + + return choices + + def info_for_product(self, batch, data, product): + return { + 'uuid': product.uuid, + 'upc': six.text_type(product.upc), + 'upc_pretty': product.upc.pretty(), + 'full_description': product.full_description, + 'image_url': pod.get_image_url(self.rattail_config, product.upc), + 'uom_choices': self.uom_choices_for_product(product), + } + + def normalize_batch(self, batch): + return { + 'uuid': batch.uuid, + 'total_price': six.text_type(batch.total_price or 0), + 'total_price_display': "${:0.2f}".format(batch.total_price or 0), + 'status_code': batch.status_code, + 'status_text': batch.status_text, + } + + def normalize_row(self, row): + data = { + 'uuid': row.uuid, + 'sequence': row.sequence, + 'item_entry': row.item_entry, + 'product_uuid': row.product_uuid, + 'product_upc': six.text_type(row.product_upc or ''), + 'product_upc_pretty': row.product_upc.pretty() if row.product_upc else None, + 'product_brand': row.product_brand, + 'product_description': row.product_description, + 'product_size': row.product_size, + 'product_full_description': row.product.full_description if row.product else row.product_description, + 'product_weighed': row.product_weighed, + + 'case_quantity': pretty_quantity(row.case_quantity), + 'cases_ordered': pretty_quantity(row.cases_ordered), + 'units_ordered': pretty_quantity(row.units_ordered), + 'order_quantity': pretty_quantity(row.order_quantity), + 'order_uom': row.order_uom, + 'order_uom_choices': self.uom_choices_for_product(row.product), + + 'unit_price': six.text_type(row.unit_price) if row.unit_price is not None else None, + 'unit_price_display': "${:0.2f}".format(row.unit_price) if row.unit_price is not None else None, + 'total_price': six.text_type(row.total_price) if row.total_price is not None else None, + 'total_price_display': "${:0.2f}".format(row.total_price) if row.total_price is not None else None, + + 'status_code': row.status_code, + 'status_text': row.status_text, + } + + unit_uom = self.enum.UNIT_OF_MEASURE_POUND if data['product_weighed'] else self.enum.UNIT_OF_MEASURE_EACH + if row.order_uom == self.enum.UNIT_OF_MEASURE_CASE: + data.update({ + 'order_quantity_display': "{} {} (× {} {} = {} {})".format( + data['order_quantity'], + self.enum.UNIT_OF_MEASURE[self.enum.UNIT_OF_MEASURE_CASE], + data['case_quantity'], + self.enum.UNIT_OF_MEASURE[unit_uom], + pretty_quantity(row.order_quantity * row.case_quantity), + self.enum.UNIT_OF_MEASURE[unit_uom]), + }) + else: + data.update({ + 'order_quantity_display': "{} {}".format( + pretty_quantity(row.order_quantity), + self.enum.UNIT_OF_MEASURE[unit_uom]), + }) + + return data + + def add_item(self, batch, data): + if data.get('product_is_known'): + + uuid = data.get('product_uuid') + if not uuid: + return {'error': "Must specify a product UUID"} + + product = self.Session.query(model.Product).get(uuid) + if not product: + return {'error': "Product not found"} + + row = self.handler.make_row() + row.item_entry = product.uuid + row.product = product + row.order_quantity = decimal.Decimal(data.get('order_quantity') or '0') + row.order_uom = data.get('order_uom') + self.handler.add_row(batch, row) + self.Session.flush() + self.Session.refresh(row) + + else: # product is not known + raise NotImplementedError # TODO + + return {'batch': self.normalize_batch(batch), + 'row': self.normalize_row(row)} + + def update_item(self, batch, data): + uuid = data.get('uuid') + if not uuid: + return {'error': "Must specify a row UUID"} + + row = self.Session.query(model.CustomerOrderBatchRow).get(uuid) + if not row: + return {'error': "Row not found"} + + if row not in batch.active_rows(): + return {'error': "Row is not active for the batch"} + + if data.get('product_is_known'): + + uuid = data.get('product_uuid') + if not uuid: + return {'error': "Must specify a product UUID"} + + product = self.Session.query(model.Product).get(uuid) + if not product: + return {'error': "Product not found"} + + row.item_entry = product.uuid + row.product = product + row.order_quantity = decimal.Decimal(data.get('order_quantity') or '0') + row.order_uom = data.get('order_uom') + self.handler.refresh_row(row) + self.Session.flush() + self.Session.refresh(row) + + else: # product is not known + raise NotImplementedError # TODO + + return {'batch': self.normalize_batch(batch), + 'row': self.normalize_row(row)} + + def delete_item(self, batch, data): + + uuid = data.get('uuid') + if not uuid: + return {'error': "Must specify a row UUID"} + + row = self.Session.query(model.CustomerOrderBatchRow).get(uuid) + if not row: + return {'error': "Row not found"} + + if row not in batch.active_rows(): + return {'error': "Row is not active for this batch"} + + self.handler.do_remove_row(row) + return {'ok': True, + 'batch': self.normalize_batch(batch)} + def submit_new_order(self, batch, data): - # TODO - return {'success': True} + result = self.handler.do_execute(batch, self.request.user) + 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': next_url} def includeme(config): diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 34887021..5ec4b9c5 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -862,6 +862,9 @@ class ProductsView(MasterView): else: f.set_readonly('brand') + # case_size + f.set_type('case_size', 'quantity') + # status_code f.set_label('status_code', "Status") From d1d64ec96c6321603f86b68215b7fe15f7768a16 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 27 Jan 2021 08:50:20 -0600 Subject: [PATCH 0278/1681] Fix some UOM bugs for new customer order --- tailbone/templates/custorders/create.mako | 29 +++++++++++++---------- tailbone/views/custorders/orders.py | 9 +++++-- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index 88c902e9..866312c9 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -698,6 +698,21 @@ } }, + setProductUnitChoices(choices) { + this.productUnitChoices = choices + + let found = false + for (let uom of choices) { + if (this.productUOM == uom.key) { + found = true + break + } + } + if (!found) { + this.productUOM = choices[0].key + } + }, + fetchProductByUPC() { let params = { action: 'find_product_by_upc', @@ -714,6 +729,7 @@ this.productUUID = response.data.uuid this.productUPC = response.data.upc_pretty this.productDisplay = response.data.full_description + this.setProductUnitChoices(response.data.uom_choices) } }) }, @@ -728,18 +744,7 @@ this.submitBatchData(params, response => { this.productUPC = response.data.upc_pretty this.productDisplay = response.data.full_description - this.productUnitChoices = response.data.uom_choices - - let found = false - for (let uom of this.productUnitChoices) { - if (this.productUOM == uom.key) { - found = true - break - } - } - if (!found) { - this.productUOM = this.productUnitChoices[0].key - } + this.setProductUnitChoices(response.data.uom_choices) }) } else { this.clearProduct() diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index 420ac892..68ae24c1 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -363,13 +363,18 @@ class CustomerOrdersView(MasterView): unit_uom = self.enum.UNIT_OF_MEASURE_POUND if data['product_weighed'] else self.enum.UNIT_OF_MEASURE_EACH if row.order_uom == self.enum.UNIT_OF_MEASURE_CASE: + if row.case_quantity is None: + case_qty = unit_qty = '??' + else: + case_qty = data['case_quantity'] + unit_qty = pretty_quantity(row.order_quantity * row.case_quantity) data.update({ 'order_quantity_display': "{} {} (× {} {} = {} {})".format( data['order_quantity'], self.enum.UNIT_OF_MEASURE[self.enum.UNIT_OF_MEASURE_CASE], - data['case_quantity'], + case_qty, self.enum.UNIT_OF_MEASURE[unit_uom], - pretty_quantity(row.order_quantity * row.case_quantity), + unit_qty, self.enum.UNIT_OF_MEASURE[unit_uom]), }) else: From a927827e3310546763594b2505f45d51c5a2d810 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 27 Jan 2021 08:52:38 -0600 Subject: [PATCH 0279/1681] Add changelog link for Theo, in upgrade package diff --- tailbone/views/upgrades.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index 33a99e59..3261229e 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.py @@ -334,6 +334,10 @@ class UpgradeView(MasterView): 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-woocommerce/changelog/{new_version}/?size=10', 'release_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-woocommerce/files/{new_version}/CHANGES.rst', }, + 'tailbone-theo': { + 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/theo/changelog/{new_version}/?size=10', + 'release_url': 'https://kallithea.rattailproject.org/rattail-project/theo/files/{new_version}/CHANGES.rst', + }, } return projects From 5e27ceedcee5416df98468ad3fcae1db5210bb7f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 27 Jan 2021 08:56:38 -0600 Subject: [PATCH 0280/1681] Hide "collect from wild" button for UOMs unless user has permission --- tailbone/templates/units-of-measure/index.mako | 4 ++++ tailbone/views/uoms.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/tailbone/templates/units-of-measure/index.mako b/tailbone/templates/units-of-measure/index.mako index b29bad66..fb3a3219 100644 --- a/tailbone/templates/units-of-measure/index.mako +++ b/tailbone/templates/units-of-measure/index.mako @@ -4,6 +4,7 @@ <%def name="grid_tools()"> ${parent.grid_tools()} + % if master.has_perm('collect_wild_uoms'): <b-button type="is-primary" icon-pack="fas" icon-left="fas fa-shopping-basket" @@ -47,10 +48,12 @@ </div> </b-modal> + % endif </%def> <%def name="modify_this_page_vars()"> ${parent.modify_this_page_vars()} + % if master.has_perm('collect_wild_uoms'): <script type="text/javascript"> TailboneGridData.showingCollectWildDialog = false @@ -60,6 +63,7 @@ } </script> + % endif </%def> diff --git a/tailbone/views/uoms.py b/tailbone/views/uoms.py index 11f80779..964401f1 100644 --- a/tailbone/views/uoms.py +++ b/tailbone/views/uoms.py @@ -112,6 +112,10 @@ class UnitOfMeasureView(MasterView): 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() + + # fix perm group name + config.add_tailbone_permission_group(permission_prefix, model_title_plural, overwrite=False) # collect wild uoms config.add_tailbone_permission(permission_prefix, '{}.collect_wild_uoms'.format(permission_prefix), From 40b4596df4fcf45421e468b9297b7951eb4322b6 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 27 Jan 2021 09:01:42 -0600 Subject: [PATCH 0281/1681] Hopefully fix package links for upgrade diff why in the F doesn't a hyphen work for this? --- tailbone/views/upgrades.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index 3261229e..e817d9a1 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.py @@ -326,15 +326,15 @@ class UpgradeView(MasterView): 'commit_url': 'https://kallithea.rattailproject.org/rattail-restricted/tailbone-onager/changelog/{new_version}/?size=10', 'release_url': 'https://kallithea.rattailproject.org/rattail-restricted/tailbone-onager/files/{new_version}/CHANGELOG.md', }, - 'rattail-woocommerce': { + 'rattail_woocommerce': { 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-woocommerce/changelog/{new_version}/?size=10', 'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-woocommerce/files/{new_version}/CHANGES.rst', }, - 'tailbone-woocommerce': { + 'tailbone_woocommerce': { 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-woocommerce/changelog/{new_version}/?size=10', 'release_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-woocommerce/files/{new_version}/CHANGES.rst', }, - 'tailbone-theo': { + 'tailbone_theo': { 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/theo/changelog/{new_version}/?size=10', 'release_url': 'https://kallithea.rattailproject.org/rattail-project/theo/files/{new_version}/CHANGES.rst', }, From 797a65e9c891bb0374792f49e3aeeb184ecb36a2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 27 Jan 2021 14:16:11 -0600 Subject: [PATCH 0282/1681] 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 d8a0051b..0aa996f6 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.8.120 (2021-01-27) +-------------------- + +* Initial support for adding items to, executing customer order batch. + +* Add changelog link for Theo, in upgrade package diff. + +* Hide "collect from wild" button for UOMs unless user has permission. + + 0.8.119 (2021-01-25) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index a4fa189f..6afb710d 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.119' +__version__ = '0.8.120' From b3867d9c89d91948182160d6c68e698ce338066c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 27 Jan 2021 22:24:23 -0600 Subject: [PATCH 0283/1681] Tweak how vendor link is rendered for readonly field --- tailbone/views/master.py | 7 ++++--- tailbone/views/purchasing/batch.py | 10 +--------- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 79f537e0..be67eaa5 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -953,8 +953,9 @@ class MasterView(View): vendor = getattr(obj, field) if not vendor: return "" - if vendor.id: - text = "({}) {}".format(vendor.id, vendor.name) + short = vendor.id or vendor.abbreviation + if short: + text = "({}) {}".format(short, vendor.name) else: text = six.text_type(vendor) url = self.request.route_url('vendors.view', uuid=vendor.uuid) diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index e8bc5ba7..d83beb6f 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -409,14 +409,6 @@ class PurchasingBatchView(BatchMasterView): url = self.request.route_url('purchases.view', uuid=purchase.uuid) return tags.link_to(text, url) - def render_vendor(self, batch, field): - vendor = batch.vendor - if not vendor: - return "" - text = "({}) {}".format(vendor.id, vendor.name) - url = self.request.route_url('vendors.view', uuid=vendor.uuid) - return tags.link_to(text, url) - def render_vendor_email(self, batch, field): if batch.vendor.email: return batch.vendor.email.address From fb7a5725194e855c8bf47c10d30522ff5a58f1f2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 28 Jan 2021 14:34:18 -0600 Subject: [PATCH 0284/1681] Use "People Handler" to update names, when editing person or user --- tailbone/views/people.py | 33 ++++++++++++++++++++++++++++++++- tailbone/views/users.py | 19 ++++++++----------- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index ac4f2237..9deb1cda 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -179,6 +179,37 @@ class PeopleView(MasterView): return True return not self.is_person_protected(person) + def objectify(self, form, data=None): + if data is None: + data = form.validated + + # do normal create/update + person = super(PeopleView, self).objectify(form, data) + + # collect data from all name fields + names = {} + if 'first_name' in form: + names['first'] = data['first_name'] + if 'middle_name' in form: + names['middle'] = data['middle_name'] + if 'last_name' in form: + names['last'] = data['last_name'] + if 'display_name' in form: + names['full'] = data['display_name'] + + # TODO: why do we find colander.null values in data at this point? + # ugh, for now we must convert them + for key in names: + if names[key] is colander.null: + names[key] = None + + # do explicit name update w/ common handler logic + app = self.get_rattail_app() + handler = app.get_people_handler() + handler.update_names(person, **names) + + return person + def delete_instance(self, person): """ Supplements the default logic as follows: diff --git a/tailbone/views/users.py b/tailbone/views/users.py index 127d3491..228381c9 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -268,12 +268,12 @@ class UsersView(PrincipalMasterView): # create/update person as needed names = {} - if 'first_name_' in form: + if 'first_name_' in form and data['first_name_']: names['first'] = data['first_name_'] - if 'last_name_' in form: + if 'last_name_' in form and data['last_name_']: names['last'] = data['last_name_'] - if 'display_name_' in form: - names['display'] = data['display_name_'] + if 'display_name_' in form and data['display_name_']: + names['full'] = data['display_name_'] # we will not have a person reference yet, when creating new user. if # that is the case, go ahead and load it, if specified. if self.creating and user.person_uuid: @@ -283,12 +283,9 @@ class UsersView(PrincipalMasterView): if not user.person and any([n for n in names.values()]): user.person = model.Person() if user.person: - if names.get('first'): - user.person.first_name = names['first'] - if names.get('last'): - user.person.last_name = names['last'] - if names.get('display'): - user.person.display_name = names['display'] + app = self.get_rattail_app() + handler = app.get_people_handler() + handler.update_names(user.person, **names) # force "local only" flag unless global access granted if self.secure_global_objects: From 3ad19d05e5e5126618d922ea25e0cd1aaf0f8a4f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 28 Jan 2021 14:56:13 -0600 Subject: [PATCH 0285/1681] 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 0aa996f6..c13d9e63 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.121 (2021-01-28) +-------------------- + +* Tweak how vendor link is rendered for readonly field. + +* Use "People Handler" to update names, when editing person or user. + + 0.8.120 (2021-01-27) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 6afb710d..236425b0 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.120' +__version__ = '0.8.121' From 719e7c844130b67e340b0cbf03f496bab55c3675 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 28 Jan 2021 16:32:25 -0600 Subject: [PATCH 0286/1681] Normalize naming of all traditional master views such names should never use plural forms. for now what plural forms were previously in use, should still work. ideally can remove those at some point --- tailbone/views/batch/vendorcatalog.py | 19 ++++++++++------- tailbone/views/batch/vendorinvoice.py | 17 ++++++++------- tailbone/views/bouncer.py | 13 +++++++----- tailbone/views/brands.py | 11 ++++++---- tailbone/views/categories.py | 17 ++++++++------- tailbone/views/customergroups.py | 11 ++++++---- tailbone/views/customers.py | 17 ++++++++------- tailbone/views/custorders/items.py | 15 ++++++++------ tailbone/views/custorders/orders.py | 11 ++++++---- tailbone/views/datasync.py | 11 ++++++---- tailbone/views/departments.py | 13 +++++++----- tailbone/views/depositlinks.py | 11 ++++++---- tailbone/views/email.py | 13 +++++++----- tailbone/views/employees.py | 15 ++++++++------ tailbone/views/families.py | 11 ++++++---- tailbone/views/inventory.py | 13 +++++++----- tailbone/views/labels/profiles.py | 13 +++++++----- tailbone/views/messages.py | 25 ++++++++++++---------- tailbone/views/people.py | 17 ++++++++------- tailbone/views/products.py | 21 +++++++++++-------- tailbone/views/reportcodes.py | 11 ++++++---- tailbone/views/roles.py | 15 ++++++++------ tailbone/views/settings.py | 13 +++++++----- tailbone/views/shifts/core.py | 26 ++++++++++++++--------- tailbone/views/stores.py | 13 +++++++----- tailbone/views/subdepartments.py | 13 +++++++----- tailbone/views/tables.py | 9 +++++--- tailbone/views/taxes.py | 11 ++++++---- tailbone/views/users.py | 30 ++++++++++++++++----------- tailbone/views/vendors/core.py | 15 ++++++++------ 30 files changed, 273 insertions(+), 177 deletions(-) diff --git a/tailbone/views/batch/vendorcatalog.py b/tailbone/views/batch/vendorcatalog.py index 945b3758..cc9374f1 100644 --- a/tailbone/views/batch/vendorcatalog.py +++ b/tailbone/views/batch/vendorcatalog.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -45,7 +45,7 @@ from tailbone.views.batch import FileBatchMasterView log = logging.getLogger(__name__) -class VendorCatalogsView(FileBatchMasterView): +class VendorCatalogView(FileBatchMasterView): """ Master view for vendor catalog batches. """ @@ -132,7 +132,7 @@ class VendorCatalogsView(FileBatchMasterView): return self.parsers def configure_grid(self, g): - super(VendorCatalogsView, self).configure_grid(g) + super(VendorCatalogView, self).configure_grid(g) g.joiners['vendor'] = lambda q: q.join(model.Vendor) g.filters['vendor'] = g.make_filter('vendor', model.Vendor.name, default_active=True, default_verb='contains') @@ -145,7 +145,7 @@ class VendorCatalogsView(FileBatchMasterView): return six.text_type(batch.vendor) def configure_form(self, f): - super(VendorCatalogsView, self).configure_form(f) + super(VendorCatalogView, self).configure_form(f) # vendor f.set_renderer('vendor', self.render_vendor) @@ -187,7 +187,7 @@ class VendorCatalogsView(FileBatchMasterView): f.set_readonly('effective') def get_batch_kwargs(self, batch): - kwargs = super(VendorCatalogsView, self).get_batch_kwargs(batch) + kwargs = super(VendorCatalogView, self).get_batch_kwargs(batch) kwargs['parser_key'] = batch.parser_key if batch.vendor: kwargs['vendor'] = batch.vendor @@ -197,7 +197,7 @@ class VendorCatalogsView(FileBatchMasterView): return kwargs def configure_row_grid(self, g): - super(VendorCatalogsView, self).configure_row_grid(g) + super(VendorCatalogView, self).configure_row_grid(g) batch = self.get_instance() # starts @@ -230,7 +230,7 @@ class VendorCatalogsView(FileBatchMasterView): return 'notice' def configure_row_form(self, f): - super(VendorCatalogsView, self).configure_row_form(f) + super(VendorCatalogView, self).configure_row_form(f) f.set_renderer('product', self.render_product) f.set_type('discount_percent', 'percent') @@ -251,6 +251,9 @@ class VendorCatalogsView(FileBatchMasterView): kwargs['parsers'] = parsers return kwargs +# TODO: deprecate / remove this +VendorCatalogsView = VendorCatalogView + def includeme(config): - VendorCatalogsView.defaults(config) + VendorCatalogView.defaults(config) diff --git a/tailbone/views/batch/vendorinvoice.py b/tailbone/views/batch/vendorinvoice.py index a6777504..c16a7a6a 100644 --- a/tailbone/views/batch/vendorinvoice.py +++ b/tailbone/views/batch/vendorinvoice.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -38,7 +38,7 @@ from deform import widget as dfwidget from tailbone.views.batch import FileBatchMasterView -class VendorInvoicesView(FileBatchMasterView): +class VendorInvoiceView(FileBatchMasterView): """ Master view for vendor invoice batches. """ @@ -91,7 +91,7 @@ class VendorInvoicesView(FileBatchMasterView): return six.text_type(batch.vendor) def configure_grid(self, g): - super(VendorInvoicesView, self).configure_grid(g) + super(VendorInvoiceView, self).configure_grid(g) # vendor g.set_joiner('vendor', lambda q: q.join(model.Vendor)) @@ -117,7 +117,7 @@ class VendorInvoicesView(FileBatchMasterView): g.set_link('executed', False) def configure_form(self, f): - super(VendorInvoicesView, self).configure_form(f) + super(VendorInvoiceView, self).configure_form(f) # vendor if self.creating: @@ -166,7 +166,7 @@ class VendorInvoicesView(FileBatchMasterView): # raise formalchemy.ValidationError(unicode(error)) def get_batch_kwargs(self, batch): - kwargs = super(VendorInvoicesView, self).get_batch_kwargs(batch) + kwargs = super(VendorInvoiceView, self).get_batch_kwargs(batch) kwargs['parser_key'] = batch.parser_key return kwargs @@ -180,7 +180,7 @@ class VendorInvoicesView(FileBatchMasterView): return True def configure_row_grid(self, g): - super(VendorInvoicesView, self).configure_row_grid(g) + super(VendorInvoiceView, self).configure_row_grid(g) g.set_label('upc', "UPC") g.set_label('brand_name', "Brand") g.set_label('shipped_cases', "Cases") @@ -197,6 +197,9 @@ class VendorInvoicesView(FileBatchMasterView): row.STATUS_UNIT_COST_DIFFERS): return 'notice' +# TODO: deprecate / remove this +VendorInvoicesView = VendorInvoiceView + def includeme(config): - VendorInvoicesView.defaults(config) + VendorInvoiceView.defaults(config) diff --git a/tailbone/views/bouncer.py b/tailbone/views/bouncer.py index ac995aca..314f0eb6 100644 --- a/tailbone/views/bouncer.py +++ b/tailbone/views/bouncer.py @@ -41,7 +41,7 @@ from webhelpers2.html import HTML, tags from tailbone.views import MasterView -class EmailBouncesView(MasterView): +class EmailBounceView(MasterView): """ Master view for email bounces. """ @@ -66,14 +66,14 @@ class EmailBouncesView(MasterView): ] def __init__(self, request): - super(EmailBouncesView, self).__init__(request) + super(EmailBounceView, self).__init__(request) self.handler_options = sorted(get_profile_keys(self.rattail_config)) def get_handler(self, bounce): return get_handler(self.rattail_config, bounce.config_key) def configure_grid(self, g): - super(EmailBouncesView, self).configure_grid(g) + super(EmailBounceView, self).configure_grid(g) g.filters['config_key'].set_choices(self.handler_options) g.filters['config_key'].default_active = True @@ -93,7 +93,7 @@ class EmailBouncesView(MasterView): g.set_link('intended_recipient_address') def configure_form(self, f): - super(EmailBouncesView, self).configure_form(f) + super(EmailBounceView, self).configure_form(f) bounce = f.model_instance f.set_renderer('message', self.render_message_file) f.set_renderer('links', self.render_links) @@ -207,6 +207,9 @@ class EmailBouncesView(MasterView): cls._defaults(config) +# TODO: deprecate / remove this +EmailBouncesView = EmailBounceView + def includeme(config): - EmailBouncesView.defaults(config) + EmailBounceView.defaults(config) diff --git a/tailbone/views/brands.py b/tailbone/views/brands.py index b66c3f42..69b74a82 100644 --- a/tailbone/views/brands.py +++ b/tailbone/views/brands.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -31,7 +31,7 @@ from rattail.db import model from tailbone.views import MasterView, AutocompleteView -class BrandsView(MasterView): +class BrandView(MasterView): """ Master view for the Brand class. """ @@ -59,7 +59,7 @@ class BrandsView(MasterView): ] def configure_grid(self, g): - super(BrandsView, self).configure_grid(g) + super(BrandView, self).configure_grid(g) # name g.filters['name'].default_active = True @@ -90,6 +90,9 @@ class BrandsView(MasterView): self.Session.flush() self.Session.delete(removing) +# TODO: deprecate / remove this +BrandsView = BrandView + class BrandsAutocomplete(AutocompleteView): @@ -104,4 +107,4 @@ def includeme(config): config.add_view(BrandsAutocomplete, route_name='brands.autocomplete', renderer='json', permission='brands.list') - BrandsView.defaults(config) + BrandView.defaults(config) diff --git a/tailbone/views/categories.py b/tailbone/views/categories.py index 649ecfeb..229d60ef 100644 --- a/tailbone/views/categories.py +++ b/tailbone/views/categories.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -32,7 +32,7 @@ from tailbone import forms from tailbone.views import MasterView -class CategoriesView(MasterView): +class CategoryView(MasterView): """ Master view for the Category class. """ @@ -55,7 +55,7 @@ class CategoriesView(MasterView): ] def configure_grid(self, g): - super(CategoriesView, self).configure_grid(g) + super(CategoryView, self).configure_grid(g) g.filters['name'].default_active = True g.filters['name'].default_verb = 'contains' @@ -68,7 +68,7 @@ class CategoriesView(MasterView): g.set_link('name') def get_xlsx_fields(self): - fields = super(CategoriesView, self).get_xlsx_fields() + fields = super(CategoryView, self).get_xlsx_fields() fields.extend([ 'department_number', 'department_name', @@ -76,7 +76,7 @@ class CategoriesView(MasterView): return fields def get_xlsx_row(self, category, fields): - row = super(CategoriesView, self).get_xlsx_row(category, fields) + row = super(CategoryView, self).get_xlsx_row(category, fields) dept = category.department if dept: row['department_number'] = dept.number @@ -87,7 +87,7 @@ class CategoriesView(MasterView): return row def configure_form(self, f): - super(CategoriesView, self).configure_form(f) + super(CategoryView, self).configure_form(f) # department if self.creating or self.editing: @@ -106,6 +106,9 @@ class CategoriesView(MasterView): return [(dept.uuid, "{} {}".format(dept.number, dept.name)) for dept in departments] +# TODO: deprecate / remove this +CategoriesView = CategoryView + def includeme(config): - CategoriesView.defaults(config) + CategoryView.defaults(config) diff --git a/tailbone/views/customergroups.py b/tailbone/views/customergroups.py index 5c6892d5..02138346 100644 --- a/tailbone/views/customergroups.py +++ b/tailbone/views/customergroups.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -32,7 +32,7 @@ from tailbone.db import Session from tailbone.views import MasterView -class CustomerGroupsView(MasterView): +class CustomerGroupView(MasterView): """ Master view for the CustomerGroup class. """ @@ -54,7 +54,7 @@ class CustomerGroupsView(MasterView): ] def configure_grid(self, g): - super(CustomerGroupsView, self).configure_grid(g) + super(CustomerGroupView, self).configure_grid(g) g.filters['name'].default_active = True g.filters['name'].default_verb = 'contains' g.set_sort_defaults('name') @@ -76,6 +76,9 @@ class CustomerGroupsView(MasterView): cls._defaults(config) +# TODO: deprecate / remove this +CustomerGroupsView = CustomerGroupView + def includeme(config): - CustomerGroupsView.defaults(config) + CustomerGroupView.defaults(config) diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index a5cf963a..3d8e78d1 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -43,7 +43,7 @@ from tailbone.views import MasterView, AutocompleteView from rattail.db import model -class CustomersView(MasterView): +class CustomerView(MasterView): """ Master view for the Customer class. """ @@ -110,7 +110,7 @@ class CustomersView(MasterView): ] def configure_grid(self, g): - super(CustomersView, self).configure_grid(g) + super(CustomerView, self).configure_grid(g) # name g.filters['name'].default_active = True @@ -160,7 +160,7 @@ class CustomersView(MasterView): def get_instance(self): try: - instance = super(CustomersView, self).get_instance() + instance = super(CustomerView, self).get_instance() except HTTPNotFound: pass else: @@ -189,7 +189,7 @@ class CustomersView(MasterView): raise HTTPNotFound def configure_common_form(self, f): - super(CustomersView, self).configure_common_form(f) + super(CustomerView, self).configure_common_form(f) customer = f.model_instance permission_prefix = self.get_permission_prefix() @@ -252,7 +252,7 @@ class CustomersView(MasterView): f.set_readonly('groups') def configure_form(self, f): - super(CustomersView, self).configure_form(f) + super(CustomerView, self).configure_form(f) customer = f.model_instance permission_prefix = self.get_permission_prefix() @@ -406,6 +406,9 @@ class CustomersView(MasterView): config.add_view(cls, attr='detach_person', route_name='{}.detach_person'.format(route_prefix), permission='{}.detach_person'.format(permission_prefix)) +# TODO: deprecate / remove this +CustomersView = CustomerView + # # TODO: this is referenced by some custom apps, but should be moved?? # def unique_id(value, field): @@ -492,4 +495,4 @@ def includeme(config): config.add_view(customer_info, route_name='customer.info', renderer='json', permission='customers.view') - CustomersView.defaults(config) + CustomerView.defaults(config) diff --git a/tailbone/views/custorders/items.py b/tailbone/views/custorders/items.py index da56e7de..2fb19225 100644 --- a/tailbone/views/custorders/items.py +++ b/tailbone/views/custorders/items.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -40,7 +40,7 @@ from tailbone.views import MasterView from tailbone.util import raw_datetime -class CustomerOrderItemsView(MasterView): +class CustomerOrderItemView(MasterView): """ Master view for customer order items """ @@ -100,7 +100,7 @@ class CustomerOrderItemsView(MasterView): .joinedload(model.CustomerOrder.person)) def configure_grid(self, g): - super(CustomerOrderItemsView, self).configure_grid(g) + super(CustomerOrderItemView, self).configure_grid(g) g.set_joiner('person', lambda q: q.outerjoin(model.Person)) @@ -134,7 +134,7 @@ class CustomerOrderItemsView(MasterView): return raw_datetime(self.rattail_config, value) def configure_form(self, f): - super(CustomerOrderItemsView, self).configure_form(f) + super(CustomerOrderItemView, self).configure_form(f) # order f.set_renderer('order', self.render_order) @@ -176,12 +176,15 @@ class CustomerOrderItemsView(MasterView): model.CustomerOrderItemEvent.type_code) def configure_row_grid(self, g): - super(CustomerOrderItemsView, self).configure_row_grid(g) + super(CustomerOrderItemView, self).configure_row_grid(g) g.set_label('occurred', "When") g.set_label('type_code', "What") # TODO: enum renderer g.set_label('user', "Who") g.set_label('note', "Notes") +# TODO: deprecate / remove this +CustomerOrderItemsView = CustomerOrderItemView + def includeme(config): - CustomerOrderItemsView.defaults(config) + CustomerOrderItemView.defaults(config) diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index 68ae24c1..b51402cd 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -42,7 +42,7 @@ from tailbone.db import Session from tailbone.views import MasterView -class CustomerOrdersView(MasterView): +class CustomerOrderView(MasterView): """ Master view for customer orders """ @@ -74,7 +74,7 @@ class CustomerOrdersView(MasterView): .options(orm.joinedload(model.CustomerOrder.customer)) def configure_grid(self, g): - super(CustomerOrdersView, self).configure_grid(g) + super(CustomerOrderView, self).configure_grid(g) g.set_joiner('customer', lambda q: q.outerjoin(model.Customer)) g.set_joiner('person', lambda q: q.outerjoin(model.Person)) @@ -98,7 +98,7 @@ class CustomerOrdersView(MasterView): g.set_label('id', "ID") def configure_form(self, f): - super(CustomerOrdersView, self).configure_form(f) + super(CustomerOrderView, self).configure_form(f) # id f.set_readonly('id') @@ -476,6 +476,9 @@ class CustomerOrdersView(MasterView): return {'ok': True, 'next_url': next_url} +# TODO: deprecate / remove this +CustomerOrdersView = CustomerOrderView + def includeme(config): - CustomerOrdersView.defaults(config) + CustomerOrderView.defaults(config) diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py index 309b3bc2..d3b9b3b8 100644 --- a/tailbone/views/datasync.py +++ b/tailbone/views/datasync.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -37,7 +37,7 @@ from tailbone.views import MasterView log = logging.getLogger(__name__) -class DataSyncChangesView(MasterView): +class DataSyncChangeView(MasterView): """ Master view for the DataSyncChange model. """ @@ -64,7 +64,7 @@ class DataSyncChangesView(MasterView): ] def configure_grid(self, g): - super(DataSyncChangesView, self).configure_grid(g) + super(DataSyncChangeView, self).configure_grid(g) # batch_sequence g.set_label('batch_sequence', "Batch Seq.") @@ -113,6 +113,9 @@ class DataSyncChangesView(MasterView): cls._defaults(config) +# TODO: deprecate / remove this +DataSyncChangesView = DataSyncChangeView + def includeme(config): - DataSyncChangesView.defaults(config) + DataSyncChangeView.defaults(config) diff --git a/tailbone/views/departments.py b/tailbone/views/departments.py index c1369543..cc793ede 100644 --- a/tailbone/views/departments.py +++ b/tailbone/views/departments.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -36,7 +36,7 @@ from tailbone import grids from tailbone.views import MasterView, AutocompleteView -class DepartmentsView(MasterView): +class DepartmentView(MasterView): """ Master view for the Department class. """ @@ -61,7 +61,7 @@ class DepartmentsView(MasterView): ] def configure_grid(self, g): - super(DepartmentsView, self).configure_grid(g) + super(DepartmentView, self).configure_grid(g) g.filters['name'].default_active = True g.filters['name'].default_verb = 'contains' g.set_sort_defaults('number') @@ -71,7 +71,7 @@ class DepartmentsView(MasterView): g.set_link('name') def configure_form(self, f): - super(DepartmentsView, self).configure_form(f) + super(DepartmentView, self).configure_form(f) f.remove_field('subdepartments') f.remove_field('employees') f.set_type('product', 'boolean') @@ -144,6 +144,9 @@ class DepartmentsView(MasterView): cls._defaults(config) +# TODO: deprecate / remove this +DepartmentsView = DepartmentView + class DepartmentsAutocomplete(AutocompleteView): @@ -158,4 +161,4 @@ def includeme(config): config.add_view(DepartmentsAutocomplete, route_name='departments.autocomplete', renderer='json', permission='departments.list') - DepartmentsView.defaults(config) + DepartmentView.defaults(config) diff --git a/tailbone/views/depositlinks.py b/tailbone/views/depositlinks.py index db28e3f6..42f83460 100644 --- a/tailbone/views/depositlinks.py +++ b/tailbone/views/depositlinks.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -31,7 +31,7 @@ from rattail.db import model from tailbone.views import MasterView -class DepositLinksView(MasterView): +class DepositLinkView(MasterView): """ Master view for deposit links. """ @@ -52,7 +52,7 @@ class DepositLinksView(MasterView): ] def configure_grid(self, g): - super(DepositLinksView, self).configure_grid(g) + super(DepositLinkView, self).configure_grid(g) g.filters['description'].default_active = True g.filters['description'].default_verb = 'contains' g.set_sort_defaults('code') @@ -60,6 +60,9 @@ class DepositLinksView(MasterView): g.set_link('code') g.set_link('description') +# TODO: deprecate / remove this +DepositLinksView = DepositLinkView + def includeme(config): - DepositLinksView.defaults(config) + DepositLinkView.defaults(config) diff --git a/tailbone/views/email.py b/tailbone/views/email.py index 1349d4cc..2201f8f3 100644 --- a/tailbone/views/email.py +++ b/tailbone/views/email.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -40,7 +40,7 @@ from tailbone.db import Session from tailbone.views import View, MasterView -class ProfilesView(MasterView): +class EmailSettingView(MasterView): """ Master view for email admin (settings/preview). """ @@ -76,7 +76,7 @@ class ProfilesView(MasterView): ] def __init__(self, request): - super(ProfilesView, self).__init__(request) + super(EmailSettingView, self).__init__(request) self.handler = self.get_handler() def get_handler(self): @@ -159,7 +159,7 @@ class ProfilesView(MasterView): return True def configure_form(self, f): - super(ProfilesView, self).configure_form(f) + super(EmailSettingView, self).configure_form(f) profile = f.model_instance['_email'] # key @@ -222,6 +222,9 @@ class ProfilesView(MasterView): kwargs['email'] = self.handler.get_email(key) return kwargs +# TODO: deprecate / remove this +ProfilesView = EmailSettingView + class RecipientsType(colander.String): """ @@ -396,6 +399,6 @@ class EmailAttemptView(MasterView): def includeme(config): - ProfilesView.defaults(config) + EmailSettingView.defaults(config) EmailPreview.defaults(config) EmailAttemptView.defaults(config) diff --git a/tailbone/views/employees.py b/tailbone/views/employees.py index d7cad068..ea430a94 100644 --- a/tailbone/views/employees.py +++ b/tailbone/views/employees.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -40,7 +40,7 @@ from tailbone.db import Session from tailbone.views import MasterView, AutocompleteView -class EmployeesView(MasterView): +class EmployeeView(MasterView): """ Master view for the Employee class. """ @@ -80,7 +80,7 @@ class EmployeesView(MasterView): ] def configure_grid(self, g): - super(EmployeesView, self).configure_grid(g) + super(EmployeeView, self).configure_grid(g) route_prefix = self.get_route_prefix() # phone @@ -181,7 +181,7 @@ class EmployeesView(MasterView): return not self.is_employee_protected(employee) def configure_form(self, f): - super(EmployeesView, self).configure_form(f) + super(EmployeeView, self).configure_form(f) employee = f.model_instance f.set_renderer('person', self.render_person) @@ -230,7 +230,7 @@ class EmployeesView(MasterView): def objectify(self, form, data=None): if data is None: data = form.validated - employee = super(EmployeesView, self).objectify(form, data) + employee = super(EmployeeView, self).objectify(form, data) self.update_stores(employee, data) self.update_departments(employee, data) return employee @@ -304,6 +304,9 @@ class EmployeesView(MasterView): (model.EmployeeDepartment, 'employee_uuid'), ] +# TODO: deprecate / remove this +EmployeesView = EmployeeView + class EmployeesAutocomplete(AutocompleteView): """ @@ -328,4 +331,4 @@ def includeme(config): config.add_view(EmployeesAutocomplete, route_name='employees.autocomplete', renderer='json', permission='employees.list') - EmployeesView.defaults(config) + EmployeeView.defaults(config) diff --git a/tailbone/views/families.py b/tailbone/views/families.py index 997255b3..7bbdc966 100644 --- a/tailbone/views/families.py +++ b/tailbone/views/families.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -31,7 +31,7 @@ from rattail.db import model from tailbone.views import MasterView -class FamiliesView(MasterView): +class FamilyView(MasterView): """ Master view for the Family class. """ @@ -52,11 +52,14 @@ class FamiliesView(MasterView): ] def configure_grid(self, g): - super(FamiliesView, self).configure_grid(g) + super(FamilyView, self).configure_grid(g) g.filters['name'].default_active = True g.filters['name'].default_verb = 'contains' g.set_sort_defaults('code') +# TODO: deprecate / remove this +FamiliesView = FamilyView + def includeme(config): - FamiliesView.defaults(config) + FamilyView.defaults(config) diff --git a/tailbone/views/inventory.py b/tailbone/views/inventory.py index 7e4ed33f..7cf5d8d0 100644 --- a/tailbone/views/inventory.py +++ b/tailbone/views/inventory.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -33,7 +33,7 @@ import colander from tailbone.views import MasterView -class InventoryAdjustmentReasonsView(MasterView): +class InventoryAdjustmentReasonView(MasterView): """ Master view for inventory adjustment reasons. """ @@ -48,11 +48,11 @@ class InventoryAdjustmentReasonsView(MasterView): ] def configure_grid(self, g): - super(InventoryAdjustmentReasonsView, self).configure_grid(g) + super(InventoryAdjustmentReasonView, self).configure_grid(g) g.set_sort_defaults('code') def configure_form(self, f): - super(InventoryAdjustmentReasonsView, self).configure_form(f) + super(InventoryAdjustmentReasonView, self).configure_form(f) # code f.set_validator('code', self.unique_code) @@ -66,6 +66,9 @@ class InventoryAdjustmentReasonsView(MasterView): if query.count(): raise colander.Invalid(node, "Code must be unique") +# TODO: deprecate / remove this +InventoryAdjustmentReasonsView = InventoryAdjustmentReasonView + def includeme(config): - InventoryAdjustmentReasonsView.defaults(config) + InventoryAdjustmentReasonView.defaults(config) diff --git a/tailbone/views/labels/profiles.py b/tailbone/views/labels/profiles.py index 3fb5dd34..3dfe07ab 100644 --- a/tailbone/views/labels/profiles.py +++ b/tailbone/views/labels/profiles.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -34,7 +34,7 @@ from tailbone import forms from tailbone.views import MasterView -class ProfilesView(MasterView): +class LabelProfileView(MasterView): """ Master view for the LabelProfile model. """ @@ -63,14 +63,14 @@ class ProfilesView(MasterView): ] def configure_grid(self, g): - super(ProfilesView, self).configure_grid(g) + super(LabelProfileView, self).configure_grid(g) g.set_sort_defaults('ordinal') g.set_type('visible', 'boolean') g.set_link('code') g.set_link('description') def configure_form(self, f): - super(ProfilesView, self).configure_form(f) + super(LabelProfileView, self).configure_form(f) # format f.set_type('format', 'codeblock') @@ -167,6 +167,9 @@ class ProfilesView(MasterView): config.add_view(cls, attr='printer_settings', route_name='{}.printer_settings'.format(route_prefix), permission='{}.edit'.format(permission_prefix)) +# TODO: deprecate / remove this +ProfilesView = LabelProfileView + def includeme(config): - ProfilesView.defaults(config) + LabelProfileView.defaults(config) diff --git a/tailbone/views/messages.py b/tailbone/views/messages.py index 5f814569..f4c1648c 100644 --- a/tailbone/views/messages.py +++ b/tailbone/views/messages.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -42,7 +42,7 @@ from tailbone.views import MasterView from tailbone.util import raw_datetime -class MessagesView(MasterView): +class MessageView(MasterView): """ Base class for message views. """ @@ -87,12 +87,12 @@ class MessagesView(MasterView): def index(self): if not self.request.user: raise httpexceptions.HTTPForbidden - return super(MessagesView, self).index() + return super(MessageView, self).index() def get_instance(self): if not self.request.user: raise httpexceptions.HTTPForbidden - message = super(MessagesView, self).get_instance() + message = super(MessageView, self).get_instance() if not self.associated_with(message): raise httpexceptions.HTTPForbidden return message @@ -204,7 +204,7 @@ class MessagesView(MasterView): # TODO!! # def make_form(self, instance, **kwargs): - # form = super(MessagesView, self).make_form(instance, **kwargs) + # form = super(MessageView, self).make_form(instance, **kwargs) # if self.creating: # form.id = 'new-message' # form.cancel_url = self.request.get_referrer(default=self.request.route_url('messages.inbox')) @@ -212,7 +212,7 @@ class MessagesView(MasterView): # return form def configure_form(self, f): - super(MessagesView, self).configure_form(f) + super(MessageView, self).configure_form(f) use_buefy = self.get_use_buefy() f.submit_label = "Send Message" @@ -293,7 +293,7 @@ class MessagesView(MasterView): def objectify(self, form, data=None): if data is None: data = form.validated - message = super(MessagesView, self).objectify(form, data) + message = super(MessageView, self).objectify(form, data) if self.creating: if self.request.user: @@ -469,8 +469,11 @@ class MessagesView(MasterView): cls._defaults(config) +# TODO: deprecate / remove this +MessagesView = MessageView -class InboxView(MessagesView): + +class InboxView(MessageView): """ Inbox message view. """ @@ -486,7 +489,7 @@ class InboxView(MessagesView): return q.filter(model.MessageRecipient.status == self.enum.MESSAGE_STATUS_INBOX) -class ArchiveView(MessagesView): +class ArchiveView(MessageView): """ Archived message view. """ @@ -502,7 +505,7 @@ class ArchiveView(MessagesView): return q.filter(model.MessageRecipient.status == self.enum.MESSAGE_STATUS_ARCHIVE) -class SentView(MessagesView): +class SentView(MessageView): """ Sent messages view. """ @@ -585,4 +588,4 @@ def includeme(config): config.add_view(SentView, attr='index', route_name='messages.sent', permission='messages.list') - MessagesView.defaults(config) + MessageView.defaults(config) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 9deb1cda..54c84d82 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -44,7 +44,7 @@ from tailbone import forms, grids from tailbone.views import MasterView, AutocompleteView -class PeopleView(MasterView): +class PersonView(MasterView): """ Master view for the Person class. """ @@ -111,7 +111,7 @@ class PeopleView(MasterView): ] def configure_grid(self, g): - super(PeopleView, self).configure_grid(g) + super(PersonView, self).configure_grid(g) g.joiners['email'] = lambda q: q.outerjoin(model.PersonEmailAddress, sa.and_( model.PersonEmailAddress.parent_uuid == model.Person.uuid, @@ -184,7 +184,7 @@ class PeopleView(MasterView): data = form.validated # do normal create/update - person = super(PeopleView, self).objectify(form, data) + person = super(PersonView, self).objectify(form, data) # collect data from all name fields names = {} @@ -227,7 +227,7 @@ class PeopleView(MasterView): customer._people.reorder() # continue with normal logic - super(PeopleView, self).delete_instance(person) + super(PersonView, self).delete_instance(person) def touch_instance(self, person): """ @@ -237,7 +237,7 @@ class PeopleView(MasterView): contact info record associated with them. """ # touch person, as per usual - super(PeopleView, self).touch_instance(person) + super(PersonView, self).touch_instance(person) def touch(obj): change = model.Change() @@ -259,7 +259,7 @@ class PeopleView(MasterView): touch(address) def configure_common_form(self, f): - super(PeopleView, self).configure_common_form(f) + super(PersonView, self).configure_common_form(f) person = f.model_instance f.set_label('display_name', "Full Name") @@ -800,6 +800,9 @@ class PeopleView(MasterView): config.add_view(cls, attr='make_user', route_name='{}.make_user'.format(route_prefix), permission='users.create') +# TODO: deprecate / remove this +PeopleView = PersonView + class PeopleAutocomplete(AutocompleteView): @@ -918,5 +921,5 @@ def includeme(config): config.add_view(PeopleEmployeesAutocomplete, route_name='people.autocomplete.employees', renderer='json', permission='people.list') - PeopleView.defaults(config) + PersonView.defaults(config) PersonNoteView.defaults(config) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 5ec4b9c5..526a9160 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -77,7 +77,7 @@ log = logging.getLogger(__name__) # return query -class ProductsView(MasterView): +class ProductView(MasterView): """ Master view for the Product class. """ @@ -171,7 +171,7 @@ class ProductsView(MasterView): CurrentPrice = orm.aliased(model.ProductPrice) def __init__(self, request): - super(ProductsView, self).__init__(request) + super(ProductView, self).__init__(request) self.print_labels = request.rattail_config.getbool('tailbone', 'products.print_labels', default=False) def query(self, session): @@ -201,7 +201,7 @@ class ProductsView(MasterView): return query def configure_grid(self, g): - super(ProductsView, self).configure_grid(g) + super(ProductView, self).configure_grid(g) def join_vendor(q): return q.outerjoin(self.ProductVendorCost, @@ -342,7 +342,7 @@ class ProductsView(MasterView): g.set_label('vendor', "Pref. Vendor") def configure_common_form(self, f): - super(ProductsView, self).configure_common_form(f) + super(ProductView, self).configure_common_form(f) product = f.model_instance # upc @@ -594,7 +594,7 @@ class ProductsView(MasterView): return ' '.join(classes) def get_xlsx_fields(self): - fields = super(ProductsView, self).get_xlsx_fields() + fields = super(ProductView, self).get_xlsx_fields() i = fields.index('department_uuid') fields.insert(i + 1, 'department_number') @@ -640,7 +640,7 @@ class ProductsView(MasterView): return fields def get_xlsx_row(self, product, fields): - row = super(ProductsView, self).get_xlsx_row(product, fields) + row = super(ProductView, self).get_xlsx_row(product, fields) if 'upc' in fields and isinstance(row['upc'], GPC): row['upc'] = row['upc'].pretty() @@ -710,7 +710,7 @@ class ProductsView(MasterView): raise httpexceptions.HTTPNotFound() def configure_form(self, f): - super(ProductsView, self).configure_form(f) + super(ProductView, self).configure_form(f) product = f.model_instance # department @@ -880,7 +880,7 @@ class ProductsView(MasterView): def objectify(self, form, data=None): if data is None: data = form.validated - product = super(ProductsView, self).objectify(form, data=data) + product = super(ProductView, self).objectify(form, data=data) # regular_price_amount if (self.creating or self.editing) and 'regular_price_amount' in form.fields: @@ -1792,6 +1792,9 @@ class ProductsView(MasterView): config.add_route('mobile.products.quick_lookup', '/mobile/products/quick-lookup') config.add_view(cls, attr='mobile_quick_lookup', route_name='mobile.products.quick_lookup') +# TODO: deprecate / remove this +ProductsView = ProductView + class ProductsAutocomplete(AutocompleteView): """ @@ -1853,4 +1856,4 @@ def includeme(config): config.add_view(print_labels, route_name='products.print_labels', renderer='json', permission='products.print_labels') - ProductsView.defaults(config) + ProductView.defaults(config) diff --git a/tailbone/views/reportcodes.py b/tailbone/views/reportcodes.py index 63044c3b..ba55db01 100644 --- a/tailbone/views/reportcodes.py +++ b/tailbone/views/reportcodes.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -31,7 +31,7 @@ from rattail.db import model from tailbone.views import MasterView -class ReportCodesView(MasterView): +class ReportCodeView(MasterView): """ Master view for the ReportCode class. """ @@ -51,13 +51,16 @@ class ReportCodesView(MasterView): ] def configure_grid(self, g): - super(ReportCodesView, self).configure_grid(g) + super(ReportCodeView, self).configure_grid(g) g.filters['name'].default_active = True g.filters['name'].default_verb = 'contains' g.set_sort_defaults('code') g.set_link('code') g.set_link('name') +# TODO: deprecate / remove this +ReportCodesView = ReportCodeView + def includeme(config): - ReportCodesView.defaults(config) + ReportCodeView.defaults(config) diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py index e30c38be..9b44dcdf 100644 --- a/tailbone/views/roles.py +++ b/tailbone/views/roles.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -46,7 +46,7 @@ from tailbone.db import Session from tailbone.views.principal import PrincipalMasterView, PermissionsRenderer -class RolesView(PrincipalMasterView): +class RoleView(PrincipalMasterView): """ Master view for the Role model. """ @@ -67,7 +67,7 @@ class RolesView(PrincipalMasterView): ] def configure_grid(self, g): - super(RolesView, self).configure_grid(g) + super(RoleView, self).configure_grid(g) # name g.filters['name'].default_active = True @@ -139,7 +139,7 @@ class RolesView(PrincipalMasterView): raise colander.Invalid(node, "Name must be unique") def configure_form(self, f): - super(RolesView, self).configure_form(f) + super(RoleView, self).configure_form(f) role = f.model_instance use_buefy = self.get_use_buefy() @@ -226,7 +226,7 @@ class RolesView(PrincipalMasterView): """ if data is None: data = form.validated - role = super(RolesView, self).objectify(form, data) + role = super(RoleView, self).objectify(form, data) self.update_permissions(role, data['permissions']) return role @@ -373,6 +373,9 @@ class RolesView(PrincipalMasterView): config.add_view(cls, attr='download_permissions_matrix', route_name='{}.download_permissions_matrix'.format(route_prefix), permission='{}.download_permissions_matrix'.format(permission_prefix)) +# TODO: deprecate / remove this +RolesView = RoleView + class PermissionsWidget(dfwidget.Widget): template = 'permissions' @@ -396,4 +399,4 @@ class PermissionsWidget(dfwidget.Widget): def includeme(config): - RolesView.defaults(config) + RoleView.defaults(config) diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index 94a71853..ed63b857 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -43,7 +43,7 @@ from tailbone.db import Session from tailbone.views import MasterView, View -class SettingsView(MasterView): +class SettingView(MasterView): """ Master view for the settings model. """ @@ -59,14 +59,14 @@ class SettingsView(MasterView): ] def configure_grid(self, g): - super(SettingsView, self).configure_grid(g) + super(SettingView, self).configure_grid(g) g.filters['name'].default_active = True g.filters['name'].default_verb = 'contains' g.set_sort_defaults('name') g.set_link('name') def configure_form(self, f): - super(SettingsView, self).configure_form(f) + super(SettingView, self).configure_form(f) if self.creating: f.set_validator('name', self.unique_name) @@ -85,6 +85,9 @@ class SettingsView(MasterView): return not bool(self.feedback.match(setting.name)) return True +# TODO: deprecate / remove this +SettingsView = SettingView + class AppSettingsForm(forms.Form): @@ -267,4 +270,4 @@ class AppSettingsView(View): def includeme(config): AppSettingsView.defaults(config) - SettingsView.defaults(config) + SettingView.defaults(config) diff --git a/tailbone/views/shifts/core.py b/tailbone/views/shifts/core.py index 10ceba0b..b33ae0f0 100644 --- a/tailbone/views/shifts/core.py +++ b/tailbone/views/shifts/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -48,7 +48,7 @@ def render_shift_length(shift, field): return HTML.tag('span', title="{} hrs".format(hours_as_decimal(length)), c=[pretty_hours(length)]) -class ScheduledShiftsView(MasterView): +class ScheduledShiftView(MasterView): """ Master view for employee scheduled shifts. """ @@ -83,12 +83,15 @@ class ScheduledShiftsView(MasterView): g.set_label('employee', "Employee Name") def configure_form(self, f): - super(ScheduledShiftsView, self).configure_form(f) + super(ScheduledShiftView, self).configure_form(f) f.set_renderer('length', render_shift_length) +# TODO: deprecate / remove this +ScheduledShiftsView = ScheduledShiftView -class WorkedShiftsView(MasterView): + +class WorkedShiftView(MasterView): """ Master view for employee worked shifts. """ @@ -114,7 +117,7 @@ class WorkedShiftsView(MasterView): ] def configure_grid(self, g): - super(WorkedShiftsView, self).configure_grid(g) + super(WorkedShiftView, self).configure_grid(g) g.joiners['employee'] = lambda q: q.join(model.Employee).join(model.Person) g.filters['employee'] = g.make_filter('employee', model.Person.display_name) @@ -146,7 +149,7 @@ class WorkedShiftsView(MasterView): return "WorkedShift: {}, {}".format(shift.employee, date) def configure_form(self, f): - super(WorkedShiftsView, self).configure_form(f) + super(WorkedShiftView, self).configure_form(f) f.set_readonly('employee') f.set_renderer('employee', self.render_employee) @@ -164,7 +167,7 @@ class WorkedShiftsView(MasterView): return tags.link_to(text, url) def get_xlsx_fields(self): - fields = super(WorkedShiftsView, self).get_xlsx_fields() + fields = super(WorkedShiftView, self).get_xlsx_fields() # add employee name i = fields.index('employee_uuid') @@ -176,7 +179,7 @@ class WorkedShiftsView(MasterView): return fields def get_xlsx_row(self, shift, fields): - row = super(WorkedShiftsView, self).get_xlsx_row(shift, fields) + row = super(WorkedShiftView, self).get_xlsx_row(shift, fields) # localize start and end times (Excel requires time with no zone) if shift.punch_in: @@ -200,7 +203,10 @@ class WorkedShiftsView(MasterView): return row +# TODO: deprecate / remove this +WorkedShiftsView = WorkedShiftView + def includeme(config): - ScheduledShiftsView.defaults(config) - WorkedShiftsView.defaults(config) + ScheduledShiftView.defaults(config) + WorkedShiftView.defaults(config) diff --git a/tailbone/views/stores.py b/tailbone/views/stores.py index fa94f92e..a4c4d549 100644 --- a/tailbone/views/stores.py +++ b/tailbone/views/stores.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -36,7 +36,7 @@ from tailbone import grids from tailbone.views import MasterView -class StoresView(MasterView): +class StoreView(MasterView): """ Master view for the Store class. """ @@ -65,7 +65,7 @@ class StoresView(MasterView): } def configure_grid(self, g): - super(StoresView, self).configure_grid(g) + super(StoreView, self).configure_grid(g) g.set_joiner('email', lambda q: q.outerjoin(model.StoreEmailAddress, sa.and_( model.StoreEmailAddress.parent_uuid == model.Store.uuid, @@ -88,7 +88,7 @@ class StoresView(MasterView): g.set_link('name') def configure_form(self, f): - super(StoresView, self).configure_form(f) + super(StoreView, self).configure_form(f) f.remove_field('employees') f.remove_field('phones') @@ -107,6 +107,9 @@ class StoresView(MasterView): (model.StoreEmailAddress, 'parent_uuid'), ] +# TODO: deprecate / remove this +StoresView = StoreView + def includeme(config): - StoresView.defaults(config) + StoreView.defaults(config) diff --git a/tailbone/views/subdepartments.py b/tailbone/views/subdepartments.py index 1e65d56f..0efcbeed 100644 --- a/tailbone/views/subdepartments.py +++ b/tailbone/views/subdepartments.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -32,7 +32,7 @@ from tailbone.db import Session from tailbone.views import MasterView -class SubdepartmentsView(MasterView): +class SubdepartmentView(MasterView): """ Master view for the Subdepartment class. """ @@ -58,7 +58,7 @@ class SubdepartmentsView(MasterView): ] def configure_grid(self, g): - super(SubdepartmentsView, self).configure_grid(g) + super(SubdepartmentView, self).configure_grid(g) # name g.filters['name'].default_active = True @@ -74,7 +74,7 @@ class SubdepartmentsView(MasterView): g.set_link('name') def configure_form(self, f): - super(SubdepartmentsView, self).configure_form(f) + super(SubdepartmentView, self).configure_form(f) f.remove_field('products') # TODO: figure out this dang department situation.. @@ -98,6 +98,9 @@ class SubdepartmentsView(MasterView): Session.delete(removing) +# TODO: deprecate / remove this +SubdepartmentsView = SubdepartmentView + def includeme(config): - SubdepartmentsView.defaults(config) + SubdepartmentView.defaults(config) diff --git a/tailbone/views/tables.py b/tailbone/views/tables.py index 78363f66..df03a692 100644 --- a/tailbone/views/tables.py +++ b/tailbone/views/tables.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -29,7 +29,7 @@ from __future__ import unicode_literals, absolute_import from tailbone.views import MasterView -class TablesView(MasterView): +class TableView(MasterView): """ Master view for tables """ @@ -70,6 +70,9 @@ class TablesView(MasterView): g.sorters['row_count'] = g.make_simple_sorter('row_count') g.set_sort_defaults('name') +# TODO: deprecate / remove this +TablesView = TableView + def includeme(config): - TablesView.defaults(config) + TableView.defaults(config) diff --git a/tailbone/views/taxes.py b/tailbone/views/taxes.py index 99177dea..96a404c7 100644 --- a/tailbone/views/taxes.py +++ b/tailbone/views/taxes.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -31,7 +31,7 @@ from rattail.db import model from tailbone.views import MasterView -class TaxesView(MasterView): +class TaxView(MasterView): """ Master view for taxes. """ @@ -53,13 +53,16 @@ class TaxesView(MasterView): ] def configure_grid(self, g): - super(TaxesView, self).configure_grid(g) + super(TaxView, self).configure_grid(g) g.filters['description'].default_active = True g.filters['description'].default_verb = 'contains' g.set_sort_defaults('code') g.set_link('code') g.set_link('description') +# TODO: deprecate / remove this +TaxesView = TaxView + def includeme(config): - TaxesView.defaults(config) + TaxView.defaults(config) diff --git a/tailbone/views/users.py b/tailbone/views/users.py index 228381c9..30937c91 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -44,7 +44,7 @@ from tailbone.views import MasterView from tailbone.views.principal import PrincipalMasterView, PermissionsRenderer -class UsersView(PrincipalMasterView): +class UserView(PrincipalMasterView): """ Master view for the User model. """ @@ -96,7 +96,7 @@ class UsersView(PrincipalMasterView): ] def query(self, session): - query = super(UsersView, self).query(session) + query = super(UserView, self).query(session) # bring in the related Person(s) query = query.outerjoin(model.Person)\ @@ -105,7 +105,7 @@ class UsersView(PrincipalMasterView): return query def configure_grid(self, g): - super(UsersView, self).configure_grid(g) + super(UserView, self).configure_grid(g) del g.filters['salt'] g.filters['username'].default_active = True @@ -163,7 +163,7 @@ class UsersView(PrincipalMasterView): raise colander.Invalid(node, "Username must be unique") def configure_form(self, f): - super(UsersView, self).configure_form(f) + super(UserView, self).configure_form(f) user = f.model_instance # username @@ -264,7 +264,7 @@ class UsersView(PrincipalMasterView): # create/update user as per normal if data is None: data = form.validated - user = super(UsersView, self).objectify(form, data) + user = super(UserView, self).objectify(form, data) # create/update person as needed names = {} @@ -356,7 +356,7 @@ class UsersView(PrincipalMasterView): .filter(model.UserEvent.user == user) def configure_row_grid(self, g): - super(UsersView, self).configure_row_grid(g) + super(UserView, self).configure_row_grid(g) g.width = 'half' g.filterable = False g.set_sort_defaults('occurred', 'desc') @@ -397,7 +397,7 @@ class UsersView(PrincipalMasterView): } def get_merge_resulting_data(self, remove, keep): - result = super(UsersView, self).get_merge_resulting_data(remove, keep) + result = super(UserView, self).get_merge_resulting_data(remove, keep) result['role_count'] = len(set(remove['_roles'] + keep['_roles'])) return result @@ -428,8 +428,11 @@ class UsersView(PrincipalMasterView): config.add_tailbone_permission(permission_prefix, '{}.edit_roles'.format(permission_prefix), "Edit the Roles to which a {} belongs".format(model_title)) +# TODO: deprecate / remove this +UsersView = UserView -class UserEventsView(MasterView): + +class UserEventView(MasterView): """ Master view for all user events """ @@ -448,11 +451,11 @@ class UserEventsView(MasterView): ] def get_data(self, session=None): - query = super(UserEventsView, self).get_data(session=session) + query = super(UserEventView, self).get_data(session=session) return query.join(model.User) def configure_grid(self, g): - super(UserEventsView, self).configure_grid(g) + super(UserEventView, self).configure_grid(g) g.set_joiner('person', lambda q: q.outerjoin(model.Person)) g.set_sorter('user', model.User.username) g.set_sorter('person', model.Person.display_name) @@ -473,7 +476,10 @@ class UserEventsView(MasterView): if event.user.person: return event.user.person.display_name +# TODO: deprecate / remove this +UserEventsView = UserEventView + def includeme(config): - UsersView.defaults(config) - UserEventsView.defaults(config) + UserView.defaults(config) + UserEventView.defaults(config) diff --git a/tailbone/views/vendors/core.py b/tailbone/views/vendors/core.py index 4a46a075..52b3e5a6 100644 --- a/tailbone/views/vendors/core.py +++ b/tailbone/views/vendors/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -35,7 +35,7 @@ from webhelpers2.html import tags from tailbone.views import MasterView, AutocompleteView -class VendorsView(MasterView): +class VendorView(MasterView): """ Master view for the Vendor class. """ @@ -72,7 +72,7 @@ class VendorsView(MasterView): ] def configure_grid(self, g): - super(VendorsView, self).configure_grid(g) + super(VendorView, self).configure_grid(g) g.filters['name'].default_active = True g.filters['name'].default_verb = 'contains' @@ -86,7 +86,7 @@ class VendorsView(MasterView): g.set_link('abbreviation') def configure_form(self, f): - super(VendorsView, self).configure_form(f) + super(VendorView, self).configure_form(f) vendor = f.model_instance # default_phone @@ -114,7 +114,7 @@ class VendorsView(MasterView): def objectify(self, form, data=None): if data is None: data = form.validated - vendor = super(VendorsView, self).objectify(form, data) + vendor = super(VendorView, self).objectify(form, data) vendor = self.objectify_contact(vendor, data) if 'orders_email' in data: @@ -164,6 +164,9 @@ class VendorsView(MasterView): (model.VendorContact, 'vendor_uuid'), ] +# TODO: deprecate / remove this +VendorsView = VendorView + class VendorsAutocomplete(AutocompleteView): @@ -178,4 +181,4 @@ def includeme(config): config.add_view(VendorsAutocomplete, route_name='vendors.autocomplete', renderer='json', permission='vendors.list') - VendorsView.defaults(config) + VendorView.defaults(config) From 1a181479719256a97ff562dfeee5d3fde2af1e48 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 28 Jan 2021 17:18:45 -0600 Subject: [PATCH 0287/1681] Normalize naming of all traditional master views whoops, missed one.. --- tailbone/views/vendors/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tailbone/views/vendors/__init__.py b/tailbone/views/vendors/__init__.py index 51b528f2..885ec712 100644 --- a/tailbone/views/vendors/__init__.py +++ b/tailbone/views/vendors/__init__.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -26,7 +26,9 @@ Views pertaining to vendors from __future__ import unicode_literals, absolute_import -from .core import VendorsView, VendorsAutocomplete +from .core import VendorView, VendorsAutocomplete +# TODO: deprecate / remove this +from .core import VendorsView def includeme(config): From e1e3301fc1c3416e8dcfe2d9a5061cbda4b09e3a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 30 Jan 2021 13:12:04 -0600 Subject: [PATCH 0288/1681] Undo recent `base.css` changes for `<p>` tags turns out i should be doing `<p class="block">` when i want spacing --- tailbone/static/themes/falafel/css/base.css | 9 --------- tailbone/templates/themes/falafel/base.mako | 2 +- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/tailbone/static/themes/falafel/css/base.css b/tailbone/static/themes/falafel/css/base.css index 10370009..0fa02dbb 100644 --- a/tailbone/static/themes/falafel/css/base.css +++ b/tailbone/static/themes/falafel/css/base.css @@ -1,13 +1,4 @@ -/****************************** - * general - ******************************/ - -p { - margin-bottom: 1rem; -} - - /****************************** * tweaks for root user ******************************/ diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index b496a954..8bee2119 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -273,7 +273,7 @@ % if expose_db_picker is not Undefined and expose_db_picker: <div class="level-item"> - <p style="margin-bottom: 0;">DB:</p> + <p>DB:</p> </div> <div class="level-item"> ${h.form(url('change_db_engine'), ref='dbPickerForm')} From fac00e6ecd4018e2b18c824ed39964d9fdd5ed37 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 30 Jan 2021 13:17:08 -0600 Subject: [PATCH 0289/1681] Misc. improvements for ordering batches, purchases also we now show handler's description when executing batch --- tailbone/templates/batch/view.mako | 6 ++++ tailbone/templates/forms/deform_buefy.mako | 32 +++++++++++-------- tailbone/templates/ordering/create.mako | 2 ++ tailbone/templates/receiving/create.mako | 2 ++ tailbone/views/purchases/core.py | 36 ++++++++++++++++------ tailbone/views/purchases/credits.py | 12 ++++++-- tailbone/views/purchasing/batch.py | 10 +++--- tailbone/views/purchasing/ordering.py | 11 +++++-- 8 files changed, 77 insertions(+), 34 deletions(-) diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako index b39ea376..d0a1ca21 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -211,6 +211,12 @@ </header> <section class="modal-card-body"> + <p class="block has-text-weight-bold"> + What will actually happen when this batch is executed? + </p> + <p class="block"> + ${handler.describe_execution(batch) or "TODO: handler does not provide a description for this batch"} + </p> <${execute_form.component} ref="executeBatchForm"></${execute_form.component}> </section> diff --git a/tailbone/templates/forms/deform_buefy.mako b/tailbone/templates/forms/deform_buefy.mako index 7bd20139..514d520d 100644 --- a/tailbone/templates/forms/deform_buefy.mako +++ b/tailbone/templates/forms/deform_buefy.mako @@ -1,6 +1,7 @@ ## -*- coding: utf-8; -*- <script type="text/x-template" id="${form.component}-template"> + <div> % if not form.readonly: ${h.form(form.action_url, id=dform.formid, method='post', enctype='multipart/form-data', **form_kwargs)} @@ -18,19 +19,24 @@ % elif field in dform: <% field = dform[field] %> - <b-field horizontal - label="${form.get_label(field.name)}" - ## TODO: is this class="file" really needed? - % if isinstance(field.schema.typ, deform.FileData): - class="file" - % endif - % if field.error: - type="is-danger" - :message='${form.messages_json(field.error.messages())|n}' - % endif - > - ${field.serialize(use_buefy=True)|n} - </b-field> + % if form.field_visible(field.name): + <b-field horizontal + label="${form.get_label(field.name)}" + ## TODO: is this class="file" really needed? + % if isinstance(field.schema.typ, deform.FileData): + class="file" + % endif + % if field.error: + type="is-danger" + :message='${form.messages_json(field.error.messages())|n}' + % endif + > + ${field.serialize(use_buefy=True)|n} + </b-field> + % else: + ## hidden field + ${field.serialize()|n} + % endif % endif % endfor diff --git a/tailbone/templates/ordering/create.mako b/tailbone/templates/ordering/create.mako index aeedf523..ff0fc836 100644 --- a/tailbone/templates/ordering/create.mako +++ b/tailbone/templates/ordering/create.mako @@ -3,6 +3,7 @@ <%def name="extra_javascript()"> ${parent.extra_javascript()} + % if not use_buefy: ${self.func_show_mode()} <script type="text/javascript"> @@ -56,6 +57,7 @@ }); </script> + % endif </%def> <%def name="func_show_mode()"> diff --git a/tailbone/templates/receiving/create.mako b/tailbone/templates/receiving/create.mako index a8055188..1763b50f 100644 --- a/tailbone/templates/receiving/create.mako +++ b/tailbone/templates/receiving/create.mako @@ -3,6 +3,7 @@ <%def name="extra_javascript()"> ${parent.extra_javascript()} + % if not use_buefy: ${self.func_show_batch_type()} <script type="text/javascript"> @@ -26,6 +27,7 @@ }); </script> + % endif </%def> <%def name="func_show_batch_type()"> diff --git a/tailbone/views/purchases/core.py b/tailbone/views/purchases/core.py index fbb77bc9..2c31e904 100644 --- a/tailbone/views/purchases/core.py +++ b/tailbone/views/purchases/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -48,8 +48,12 @@ class PurchaseView(MasterView): model_row_class = model.PurchaseItem row_model_title = 'Purchase Item' + labels = { + 'id': "ID", + } + grid_columns = [ - 'store', + 'id', 'vendor', 'department', 'buyer', @@ -62,6 +66,7 @@ class PurchaseView(MasterView): ] form_fields = [ + 'id', 'store', 'vendor', 'department', @@ -162,6 +167,9 @@ class PurchaseView(MasterView): default_active=True, default_verb='contains') g.sorters['buyer'] = g.make_sorter(model.Person.display_name) + # id + g.set_renderer('id', self.render_id_str) + # date_ordered g.filters['date_ordered'].default_active = True g.filters['date_ordered'].default_verb = 'equal' @@ -182,6 +190,9 @@ class PurchaseView(MasterView): g.set_type('po_total', 'currency') g.set_type('invoice_total', 'currency') g.set_label('invoice_number', "Invoice No.") + + g.set_link('id') + g.set_link('vendor') g.set_link('date_ordered') g.set_link('po_total') g.set_link('date_received') @@ -190,6 +201,8 @@ class PurchaseView(MasterView): def configure_form(self, f): super(PurchaseView, self).configure_form(f) + f.set_renderer('id', self.render_id_str) + f.set_renderer('store', self.render_store) f.set_renderer('vendor', self.render_vendor) f.set_renderer('department', self.render_department) @@ -220,14 +233,6 @@ class PurchaseView(MasterView): url = self.request.route_url('stores.view', uuid=store.uuid) return tags.link_to(text, url) - def render_vendor(self, purchase, field): - vendor = purchase.vendor - if not vendor: - return "" - text = "({}) {}".format(vendor.id, vendor.name) - url = self.request.route_url('vendors.view', uuid=vendor.uuid) - return tags.link_to(text, url) - def render_department(self, purchase, field): department = purchase.department if not department: @@ -316,6 +321,17 @@ class PurchaseView(MasterView): def configure_row_form(self, f): super(PurchaseView, self).configure_row_form(f) + # quantity fields + f.set_type('case_quantity', 'quantity') + f.set_type('cases_ordered', 'quantity') + f.set_type('units_ordered', 'quantity') + f.set_type('cases_received', 'quantity') + f.set_type('units_received', 'quantity') + f.set_type('cases_damaged', 'quantity') + f.set_type('units_damaged', 'quantity') + f.set_type('cases_expired', 'quantity') + f.set_type('units_expired', 'quantity') + # currency fields f.set_type('po_unit_cost', 'currency') f.set_type('po_total', 'currency') diff --git a/tailbone/views/purchases/credits.py b/tailbone/views/purchases/credits.py index dafe5d5e..79530fe2 100644 --- a/tailbone/views/purchases/credits.py +++ b/tailbone/views/purchases/credits.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -180,11 +180,19 @@ class PurchaseCreditView(MasterView): @classmethod def defaults(cls, config): + cls._purchase_credit_defaults(config) + cls._defaults(config) + + @classmethod + def _purchase_credit_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() + # fix perm group name + config.add_tailbone_permission_group(permission_prefix, model_title_plural, overwrite=False) + # change status config.add_tailbone_permission(permission_prefix, '{}.change_status'.format(permission_prefix), "Change status for {}".format(model_title_plural)) @@ -192,8 +200,6 @@ class PurchaseCreditView(MasterView): config.add_view(cls, attr='change_status', route_name='{}.change_status'.format(route_prefix), permission='{}.change_status'.format(permission_prefix)) - cls._defaults(config) - def includeme(config): PurchaseCreditView.defaults(config) diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index d83beb6f..ebfcf8ce 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -281,9 +281,9 @@ class PurchasingBatchView(BatchMasterView): if self.creating: f.replace('vendor', 'vendor_uuid') f.set_label('vendor_uuid', "Vendor") - widget_type = self.rattail_config.get('tailbone', 'default_widget.vendor', - default='autocomplete') - if widget_type == 'autocomplete': + use_autocomplete = self.rattail_config.getbool( + 'rattail', 'vendor.use_autocomplete', default=True) + if use_autocomplete: vendor_display = "" if self.request.method == 'POST': if self.request.POST.get('vendor_uuid'): @@ -293,14 +293,12 @@ class PurchasingBatchView(BatchMasterView): vendors_url = self.request.route_url('vendors.autocomplete') f.set_widget('vendor_uuid', forms.widgets.JQueryAutocompleteWidget( field_display=vendor_display, service_url=vendors_url)) - elif widget_type == 'dropdown': + else: vendors = self.Session.query(model.Vendor)\ .order_by(model.Vendor.id) vendor_values = [(vendor.uuid, "({}) {}".format(vendor.id, vendor.name)) for vendor in vendors] f.set_widget('vendor_uuid', dfwidget.SelectWidget(values=vendor_values)) - else: - raise NotImplementedError("Unsupported vendor widget type: {}".format(widget_type)) elif self.editing: f.set_readonly('vendor') diff --git a/tailbone/views/purchasing/ordering.py b/tailbone/views/purchasing/ordering.py index ef37b3b3..2a7a9b11 100644 --- a/tailbone/views/purchasing/ordering.py +++ b/tailbone/views/purchasing/ordering.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -155,9 +155,11 @@ class OrderingBatchView(PurchasingBatchView): def configure_form(self, f): super(OrderingBatchView, self).configure_form(f) + batch = f.model_instance # purchase - f.remove_field('purchase') + if self.creating or not batch.executed or not batch.purchase: + f.remove_field('purchase') def get_batch_kwargs(self, batch, mobile=False): kwargs = super(OrderingBatchView, self).get_batch_kwargs(batch, mobile=mobile) @@ -468,6 +470,11 @@ class OrderingBatchView(PurchasingBatchView): return self.file_response(path) + def get_execute_success_url(self, batch, result, **kwargs): + if isinstance(result, model.Purchase): + return self.request.route_url('purchases.view', uuid=result.uuid) + return super(OrderingBatchView, self).get_execute_success_url(batch, result, **kwargs) + @classmethod def _ordering_defaults(cls, config): route_prefix = cls.get_route_prefix() From 708641a8f1c0b51ad2782ed4b43ae7a4d45cbc34 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 30 Jan 2021 15:52:47 -0600 Subject: [PATCH 0290/1681] Purge things for legacy (jquery) mobile, and unused template themes gosh it feels good to get rid of this stuff... fingers crossed that nothing was broken, but am thinking it's safe --- docs/structure.rst | 1 - tailbone/config.py | 7 +- tailbone/forms/core.py | 5 +- tailbone/grids/__init__.py | 3 +- tailbone/grids/core.py | 20 +- tailbone/grids/filters.py | 14 +- tailbone/grids/mobile.py | 53 -- tailbone/static/css/mobile.css | 57 -- .../static/js/jquery.ui.tailbone.mobile.js | 81 -- tailbone/static/js/tailbone.mobile.js | 308 -------- .../static/js/tailbone.mobile.receiving.js | 92 --- tailbone/static/themes/bobcat/css/base.css | 114 --- tailbone/static/themes/bobcat/css/forms.css | 141 ---- tailbone/static/themes/bobcat/css/layout.css | 208 ------ tailbone/static/themes/dodo/css/admin.css | 84 --- tailbone/static/themes/dodo/css/base.css | 11 - tailbone/static/themes/dodo/js/bulma.js | 12 - tailbone/static/themes/falafel/css/forms.css | 33 + tailbone/subscribers.py | 6 +- tailbone/templates/forms/deform.mako | 6 +- tailbone/templates/forms/deform_buefy.mako | 18 +- tailbone/templates/mobile/about.mako | 13 - tailbone/templates/mobile/base.mako | 208 ------ .../mobile/base_internal_toolbars.mako | 20 - tailbone/templates/mobile/batch/execute.mako | 10 - .../mobile/batch/inventory/create.mako | 6 - .../mobile/batch/inventory/index.mako | 6 - .../mobile/batch/inventory/view.mako | 24 - .../mobile/batch/inventory/view_row.mako | 63 -- tailbone/templates/mobile/batch/view.mako | 32 - tailbone/templates/mobile/batch/view_row.mako | 4 - tailbone/templates/mobile/datasync.mako | 9 - tailbone/templates/mobile/grids/complete.mako | 7 - .../mobile/grids/filters_simple.mako | 15 - tailbone/templates/mobile/grids/grid.mako | 36 - tailbone/templates/mobile/home.mako | 12 - tailbone/templates/mobile/keypad.mako | 41 - tailbone/templates/mobile/login.mako | 7 - tailbone/templates/mobile/master/create.mako | 8 - .../templates/mobile/master/create_row.mako | 6 - tailbone/templates/mobile/master/edit.mako | 10 - .../templates/mobile/master/edit_row.mako | 17 - tailbone/templates/mobile/master/index.mako | 17 - tailbone/templates/mobile/master/view.mako | 48 -- .../templates/mobile/master/view_row.mako | 19 - .../templates/mobile/ordering/create.mako | 31 - .../templates/mobile/ordering/create_row.mako | 6 - .../mobile/ordering/new_product.mako | 6 - tailbone/templates/mobile/products/index.mako | 17 - .../templates/mobile/receiving/create.mako | 85 --- .../mobile/receiving/receive_row.mako | 151 ---- .../templates/mobile/receiving/view_row.mako | 151 ---- tailbone/templates/themes/bobcat/base.mako | 311 -------- tailbone/templates/themes/dodo/base.mako | 231 ------ tailbone/templates/themes/falafel/base.mako | 1 - tailbone/views/auth.py | 28 +- tailbone/views/batch/core.py | 205 +---- tailbone/views/batch/inventory.py | 177 +---- tailbone/views/batch/pricing.py | 6 +- tailbone/views/common.py | 29 +- tailbone/views/core.py | 10 +- tailbone/views/customers.py | 22 +- tailbone/views/datasync.py | 10 - tailbone/views/master.py | 702 +----------------- tailbone/views/people.py | 17 +- tailbone/views/principal.py | 6 +- tailbone/views/products.py | 44 +- tailbone/views/purchasing/batch.py | 146 +--- tailbone/views/purchasing/ordering.py | 78 +- tailbone/views/purchasing/receiving.py | 700 ++--------------- 70 files changed, 196 insertions(+), 4886 deletions(-) delete mode 100644 tailbone/grids/mobile.py delete mode 100644 tailbone/static/css/mobile.css delete mode 100644 tailbone/static/js/jquery.ui.tailbone.mobile.js delete mode 100644 tailbone/static/js/tailbone.mobile.js delete mode 100644 tailbone/static/js/tailbone.mobile.receiving.js delete mode 100644 tailbone/static/themes/bobcat/css/base.css delete mode 100644 tailbone/static/themes/bobcat/css/forms.css delete mode 100644 tailbone/static/themes/bobcat/css/layout.css delete mode 100644 tailbone/static/themes/dodo/css/admin.css delete mode 100644 tailbone/static/themes/dodo/css/base.css delete mode 100644 tailbone/static/themes/dodo/js/bulma.js delete mode 100644 tailbone/templates/mobile/about.mako delete mode 100644 tailbone/templates/mobile/base.mako delete mode 100644 tailbone/templates/mobile/base_internal_toolbars.mako delete mode 100644 tailbone/templates/mobile/batch/execute.mako delete mode 100644 tailbone/templates/mobile/batch/inventory/create.mako delete mode 100644 tailbone/templates/mobile/batch/inventory/index.mako delete mode 100644 tailbone/templates/mobile/batch/inventory/view.mako delete mode 100644 tailbone/templates/mobile/batch/inventory/view_row.mako delete mode 100644 tailbone/templates/mobile/batch/view.mako delete mode 100644 tailbone/templates/mobile/batch/view_row.mako delete mode 100644 tailbone/templates/mobile/datasync.mako delete mode 100644 tailbone/templates/mobile/grids/complete.mako delete mode 100644 tailbone/templates/mobile/grids/filters_simple.mako delete mode 100644 tailbone/templates/mobile/grids/grid.mako delete mode 100644 tailbone/templates/mobile/home.mako delete mode 100644 tailbone/templates/mobile/keypad.mako delete mode 100644 tailbone/templates/mobile/login.mako delete mode 100644 tailbone/templates/mobile/master/create.mako delete mode 100644 tailbone/templates/mobile/master/create_row.mako delete mode 100644 tailbone/templates/mobile/master/edit.mako delete mode 100644 tailbone/templates/mobile/master/edit_row.mako delete mode 100644 tailbone/templates/mobile/master/index.mako delete mode 100644 tailbone/templates/mobile/master/view.mako delete mode 100644 tailbone/templates/mobile/master/view_row.mako delete mode 100644 tailbone/templates/mobile/ordering/create.mako delete mode 100644 tailbone/templates/mobile/ordering/create_row.mako delete mode 100644 tailbone/templates/mobile/ordering/new_product.mako delete mode 100644 tailbone/templates/mobile/products/index.mako delete mode 100644 tailbone/templates/mobile/receiving/create.mako delete mode 100644 tailbone/templates/mobile/receiving/receive_row.mako delete mode 100644 tailbone/templates/mobile/receiving/view_row.mako delete mode 100644 tailbone/templates/themes/bobcat/base.mako delete mode 100644 tailbone/templates/themes/dodo/base.mako diff --git a/docs/structure.rst b/docs/structure.rst index b741475e..5585f71a 100644 --- a/docs/structure.rst +++ b/docs/structure.rst @@ -117,7 +117,6 @@ of course supply the web app layer. │ │ │ └── foobatch/ │ │ ├── customers/ │ │ ├── menu.mako - │ │ ├── mobile/ │ │ └── products/ │ └── views/ │ ├── __init__.py diff --git a/tailbone/config.py b/tailbone/config.py index 6be175ae..90799016 100644 --- a/tailbone/config.py +++ b/tailbone/config.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -65,10 +65,5 @@ def global_help_url(config): return config.get('tailbone', 'global_help_url') -def legacy_mobile_enabled(config): - return config.getbool('tailbone', 'legacy_mobile.enabled', - default=True) - - def protected_usernames(config): return config.getlist('tailbone', 'protected_usernames') diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index ebad3f74..d35b8a35 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -339,7 +339,7 @@ class Form(object): auto_disable_save = True auto_disable_cancel = True - def __init__(self, fields=None, schema=None, request=None, mobile=False, readonly=False, readonly_fields=[], + 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, hidden={}, widgets={}, defaults={}, validators={}, required={}, helptext={}, focus_spec=None, @@ -352,7 +352,6 @@ class Form(object): if self.fields is None and self.schema: self.set_fields([f.name for f in self.schema]) self.request = request - self.mobile = mobile self.readonly = readonly self.readonly_fields = set(readonly_fields or []) self.model_instance = model_instance diff --git a/tailbone/grids/__init__.py b/tailbone/grids/__init__.py index 0d4970c8..7db22b26 100644 --- a/tailbone/grids/__init__.py +++ b/tailbone/grids/__init__.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -28,4 +28,3 @@ from __future__ import unicode_literals, absolute_import from . import filters from .core import Grid, GridAction -from .mobile import MobileGrid diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index a6672270..dde02d19 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -67,7 +67,7 @@ class Grid(object): Core grid class. In sore need of documentation. """ - def __init__(self, key, data, columns=None, width='auto', request=None, mobile=False, + def __init__(self, key, data, columns=None, width='auto', request=None, model_class=None, model_title=None, model_title_plural=None, enums={}, labels={}, assume_local_times=False, renderers={}, extra_row_class=None, linked_columns=[], url='#', @@ -84,7 +84,6 @@ class Grid(object): self.columns = FieldList(columns) if columns is not None else None self.width = width self.request = request - self.mobile = mobile self.model_class = model_class if self.model_class and self.columns is None: self.columns = self.make_columns() @@ -341,7 +340,6 @@ class Grid(object): def make_webhelpers_grid(self): kwargs = dict(self._whgrid_kwargs) kwargs['request'] = self.request - kwargs['mobile'] = self.mobile kwargs['url'] = self.make_url columns = list(self.columns) @@ -1302,17 +1300,11 @@ class CustomWebhelpersGrid(webhelpers2_grid.Grid): """ def __init__(self, itemlist, columns, **kwargs): - self.mobile = kwargs.pop('mobile', False) self.renderers = kwargs.pop('renderers', {}) self.linked_columns = kwargs.pop('linked_columns', []) self.extra_record_class = kwargs.pop('extra_record_class', None) super(CustomWebhelpersGrid, self).__init__(itemlist, columns, **kwargs) - def default_header_record_format(self, headers): - if self.mobile: - return HTML('') - return super(CustomWebhelpersGrid, self).default_header_record_format(headers) - def generate_header_link(self, column_number, column, label_text): # display column header as simple no-op link; client-side JS takes care @@ -1329,8 +1321,6 @@ class CustomWebhelpersGrid(webhelpers2_grid.Grid): label_text) def default_record_format(self, i, record, columns): - if self.mobile: - return columns kwargs = { 'class_': self.get_record_class(i, record, columns), } @@ -1359,12 +1349,6 @@ class CustomWebhelpersGrid(webhelpers2_grid.Grid): def default_column_format(self, column_number, i, record, column_name): value = self.get_column_value(column_number, i, record, column_name) - if self.mobile: - url = self.url_generator(record, i) - attrs = {} - if hasattr(record, 'uuid'): - attrs['data_uuid'] = record.uuid - return HTML.tag('li', tags.link_to(value, url), **attrs) if self.linked_columns and column_name in self.linked_columns and ( value is not None and value != ''): url = self.url_generator(record, i) diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py index 02ca9130..0aa5046d 100644 --- a/tailbone/grids/filters.py +++ b/tailbone/grids/filters.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -294,18 +294,6 @@ class GridFilter(object): return self.value_renderer.render(value=value, **kwargs) -class MobileFilter(GridFilter): - """ - Base class for mobile grid filters. - """ - default_verbs = ['equal'] - - def __init__(self, key, **kwargs): - kwargs.setdefault('default_active', True) - kwargs.setdefault('default_verb', 'equal') - super(MobileFilter, self).__init__(key, **kwargs) - - class AlchemyGridFilter(GridFilter): """ Base class for SQLAlchemy grid filters. diff --git a/tailbone/grids/mobile.py b/tailbone/grids/mobile.py deleted file mode 100644 index dc6a04b9..00000000 --- a/tailbone/grids/mobile.py +++ /dev/null @@ -1,53 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU General Public License as published by the Free Software -# Foundation, either version 3 of the License, or (at your option) any later -# version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -# details. -# -# You should have received a copy of the GNU General Public License along with -# Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ -""" -Mobile Grids -""" - -from __future__ import unicode_literals, absolute_import - -from pyramid.renderers import render - -from .core import Grid - - -class MobileGrid(Grid): - """ - Base class for all mobile grids - """ - - def render_filters(self, template='/mobile/grids/filters_simple.mako', **kwargs): - context = kwargs - context['request'] = self.request - context['grid'] = self - return render(template, context) - - def render_grid(self, template='/mobile/grids/grid.mako', **kwargs): - context = kwargs - context['grid'] = self - return render(template, context) - - def render_complete(self, template='/mobile/grids/complete.mako', **kwargs): - context = kwargs - context['grid'] = self - return render(template, context) diff --git a/tailbone/static/css/mobile.css b/tailbone/static/css/mobile.css deleted file mode 100644 index 9ebfbc8b..00000000 --- a/tailbone/static/css/mobile.css +++ /dev/null @@ -1,57 +0,0 @@ - -/**************************************** - * Global styles for mobile templates - ****************************************/ - -/* main user menu button when root */ -[data-role="header"] a.root-user, -[data-role="header"] a.root-user:hover { - background-color: red; -} - -/* become/stop root menu links */ -#usermenu .root-user a { - background-color: red; -} - -/* normal flash messages */ -.flash { - color: green; - margin-bottom: 1em; -} - -/* error flash messages */ -.error, -.error-messages { - color: red; - margin-bottom: 1em; -} - -/* receiving warning flash messages */ -.receiving-warning { - color: red; -} - -.replacement-header { - display: none; -} - -.field-wrapper.with-error { - background-color: #ddcccc; - border: 2px solid #dd6666; - margin-bottom: 1em; -} - -.field-wrapper label { - font-weight: bold; - margin-top: 1em; -} - -.field-error .error-msg { - color: Red; -} - -/* make sure space comes between simple filter and "grid" list */ -.simple-filter { - margin-bottom: 1.5em; -} diff --git a/tailbone/static/js/jquery.ui.tailbone.mobile.js b/tailbone/static/js/jquery.ui.tailbone.mobile.js deleted file mode 100644 index 79eecb9a..00000000 --- a/tailbone/static/js/jquery.ui.tailbone.mobile.js +++ /dev/null @@ -1,81 +0,0 @@ - -/****************************************** - * jQuery Mobile plugins for Tailbone - *****************************************/ - -/****************************************** - * mobile autocomplete - *****************************************/ - -(function($) { - - $.widget('tailbone.mobileautocomplete', { - - _create: function() { - var that = this; - - // snag some element references - this.search = this.element.find('.ui-input-search'); - this.hidden_field = this.element.find('input[type="hidden"]'); - this.text_field = this.element.find('input[type="text"]'); - this.ul = this.element.find('ul'); - this.button = this.element.find('button'); - - // establish our autocomplete URL - this.url = this.options.url || this.element.data('url'); - - // NOTE: much of this code was copied from the jquery mobile demo site - // https://demos.jquerymobile.com/1.4.5/listview-autocomplete-remote/ - this.ul.on('filterablebeforefilter', function(e, data) { - - var $input = $( data.input ), - value = $input.val(), - html = ""; - that.ul.html( "" ); - if ( value && value.length > 2 ) { - that.ul.html( "<li><div class='ui-loader'><span class='ui-icon ui-icon-loading'></span></div></li>" ); - that.ul.listview( "refresh" ); - $.ajax({ - url: that.url, - data: { - term: $input.val() - } - }) - .then( function ( response ) { - $.each( response, function ( i, val ) { - html += '<li data-uuid="' + val.value + '">' + val.label + "</li>"; - }); - that.ul.html( html ); - that.ul.listview( "refresh" ); - that.ul.trigger( "updatelayout"); - }); - } - - }); - - // when user clicks autocomplete result, hide search etc. - this.ul.on('click', 'li', function() { - var $li = $(this); - var uuid = $li.data('uuid'); - that.search.hide(); - that.hidden_field.val(uuid); - that.button.text($li.text()).show(); - that.ul.hide(); - that.element.trigger('autocompleteitemselected', uuid); - }); - - // when user clicks "change" button, show search etc. - this.button.click(function() { - that.button.hide(); - that.ul.empty().show(); - that.hidden_field.val(''); - that.search.show(); - that.text_field.focus(); - that.element.trigger('autocompleteitemcleared'); - }); - - } - - }); - -})( jQuery ); diff --git a/tailbone/static/js/tailbone.mobile.js b/tailbone/static/js/tailbone.mobile.js deleted file mode 100644 index 432f3170..00000000 --- a/tailbone/static/js/tailbone.mobile.js +++ /dev/null @@ -1,308 +0,0 @@ - -/************************************************************ - * - * tailbone.mobile.js - * - * Global logic for mobile app - * - ************************************************************/ - - -$(function() { - - // must init header/footer toolbars since ours are "external" - $('[data-role="header"], [data-role="footer"]').toolbar({theme: 'a'}); -}); - - -$(document).on('pagecontainerchange', function(event, ui) { - - // in some cases (i.e. when no user is logged in) we may want the (external) - // header toolbar button to change between pages. here's how we do that. - // note however that we do this *always* even when not technically needed - var link = $('[data-role="header"] a:first'); - var newlink = ui.toPage.find('.replacement-header a'); - link.text(newlink.text()); - link.attr('href', newlink.attr('href')); - link.removeClass('ui-icon-home ui-icon-user'); - link.addClass(newlink.attr('class')); -}); - - -$(document).on('click', '#feedback-button', function() { - - // prepare and display 'feedback' popup dialog - var popup = $('.ui-page-active #feedback-popup'); - popup.find('.referrer .field').html(location.href); - popup.find('.referrer input').val(location.href); - popup.find('.user_name input').val(''); - popup.find('.message textarea').val(''); - popup.data('feedback-sent', false); - popup.popup('open'); -}); - - -$(document).on('click', '#feedback-popup .submit', function() { - - // send message when 'feedback' submit button pressed - var popup = $('.ui-page-active #feedback-popup'); - var form = popup.find('form'); - $.post(form.attr('action'), form.serializeArray(), function(data) { - if (data.ok) { - - // mark "feedback sent" flag, for popupafterclose - popup.data('feedback-sent', true); - popup.popup('close'); - } - }); - -}); - - -$(document).on('click', '#feedback-form-buttons .cancel', function() { - - // close 'feedback' popup when user clicks Cancel - var popup = $('.ui-page-active #feedback-popup'); - popup.popup('close'); -}); - - -$(document).on('popupafterclose', '#feedback-popup', function() { - - // thank the user for their feedback, after msg is sent - if ($(this).data('feedback-sent')) { - var popup = $('.ui-page-active #feedback-thanks'); - popup.popup('open'); - } -}); - - -$(document).on('pagecreate', function() { - - // setup any autocomplete fields - $('.field.autocomplete').mobileautocomplete(); - -}); - - -// submit "quick row" form upon autocomplete selection -$(document).on('autocompleteitemselected', function(event, uuid) { - var field = $(event.target); - if (field.hasClass('quick-row')) { - var form = field.parents('form:first'); - form.find('[name="quick_entry"]').val(uuid); - form.submit(); - } -}); - - -/** - * Automatically set focus to certain fields, on various pages - * TODO: should be letting the form declare a "focus spec" instead, to avoid - * hard-coding these field names below! - */ -function setfocus() { - var el = null; - var queries = [ - '#username', - '#new-purchasing-batch-vendor-text', - '#new-receiving-batch-vendor-text', - ]; - $.each(queries, function(i, query) { - el = $(query); - if (el.is(':visible')) { - el.focus(); - return false; - } - }); -} - - -$(document).on('pageshow', function() { - - setfocus(); - - // if current page has form, which has declared a "focus spec", then try to - // set focus accordingly - var form = $('.ui-page-active form'); - if (form) { - var spec = form.data('focus'); - if (spec) { - var input = $(spec); - if (input) { - if (input.is(':visible')) { - input.focus(); - } - } - } - } - -}); - - -// handle radio button value change for "simple" grid filter -$(document).on('change', '.simple-filter .ui-radio', function() { - $(this).parents('form:first').submit(); -}); - - -// vendor validation for new purchasing batch -$(document).on('click', 'form[name="new-purchasing-batch"] input[type="submit"]', function() { - var $form = $(this).parents('form'); - if (! $form.find('[name="vendor"]').val()) { - alert("Please select a vendor"); - $form.find('[name="new-purchasing-batch-vendor-text"]').focus(); - return false; - } -}); - - -// disable datasync restart button when clicked -$(document).on('click', '#datasync-restart', function() { - $(this).button('disable'); -}); - - -// TODO: this should go away in favor of quick_row approach -// handle global keypress on product batch "row" page, for sake of scanner wedge -var product_batch_routes = [ - 'mobile.batch.inventory.view', -]; -$(document).on('keypress', function(event) { - var current_route = $('.ui-page-active [role="main"]').data('route'); - for (var route of product_batch_routes) { - if (current_route == route) { - var upc = $('.ui-page-active #upc-search'); - if (upc.length) { - if (upc.is(':focus')) { - if (event.which == 13) { - if (upc.val()) { - $.mobile.navigate(upc.data('url') + '?upc=' + upc.val()); - } - } - } else { - if (event.which >= 48 && event.which <= 57) { // numeric (qwerty) - upc.val(upc.val() + event.key); - // TODO: these codes are correct for 'keydown' but apparently not 'keypress' ? - // } else if (event.which >= 96 && event.which <= 105) { // numeric (10-key) - // upc.val(upc.val() + event.key); - } else if (event.which == 13) { - if (upc.val()) { - $.mobile.navigate(upc.data('url') + '?upc=' + upc.val()); - } - } - return false; - } - } - } - } -}); - - -// handle various keypress events for quick entry forms -$(document).on('keypress', function(event) { - var quick_entry = $('.ui-page-active #quick_entry'); - if (quick_entry.length) { - - // if user hits enter with quick row input focused, submit form - if (quick_entry.is(':focus')) { - if (event.which == 13) { // ENTER - if (quick_entry.val()) { - var form = quick_entry.parents('form:first'); - form.submit(); - return false; - } - } - - } else { // quick row input not focused - - // mimic keyboard wedge if we're so instructed - if (quick_entry.data('wedge')) { - - if (event.which >= 48 && event.which <= 57) { // numeric (qwerty) - if (!event.altKey && !event.ctrlKey && !event.metaKey) { - quick_entry.val(quick_entry.val() + event.key); - return false; - } - - // TODO: these codes are correct for 'keydown' but apparently not 'keypress' ? - // } else if (event.which >= 96 && event.which <= 105) { // numeric (10-key) - // upc.val(upc.val() + event.key); - - } else if (event.which == 13) { // ENTER - // submit form when ENTER is received via keyboard "wedge" - if (quick_entry.val()) { - var form = quick_entry.parents('form:first'); - form.submit(); - return false; - } - } - } - } - } -}); - - -// when numeric keypad button is clicked, update quantity accordingly -$(document).on('click', '.quantity-keypad-thingy .keypad-button', function() { - var keypad = $(this).parents('.quantity-keypad-thingy'); - var quantity = keypad.find('.keypad-quantity'); - var value = quantity.text(); - var key = $(this).text(); - var changed = keypad.data('changed'); - if (key == 'Del') { - if (value.length == 1) { - quantity.text('0'); - } else { - quantity.text(value.substring(0, value.length - 1)); - } - changed = true; - } else if (key == '.') { - if (value.indexOf('.') == -1) { - if (changed) { - quantity.text(value + '.'); - } else { - quantity.text('0.'); - changed = true; - } - } - } else { - if (value == '0') { - quantity.text(key); - changed = true; - } else if (changed) { - quantity.text(value + key); - } else { - quantity.text(key); - changed = true; - } - } - if (changed) { - keypad.data('changed', true); - } -}); - - -// show/hide expiration date per receiving mode selection -$(document).on('change', 'fieldset.receiving-mode input[name="mode"]', function() { - var mode = $(this).val(); - if (mode == 'expired') { - $('#expiration-row').show(); - } else { - $('#expiration-row').hide(); - } -}); - - -// handle inventory save button -$(document).on('click', '.inventory-actions button.save', function() { - var form = $(this).parents('form:first'); - var uom = form.find('[name="keypad-uom"]:checked').val(); - var qty = form.find('.keypad-quantity').text(); - if (uom == 'CS') { - form.find('input[name="cases"]').val(qty); - } else { // units - form.find('input[name="units"]').val(qty); - } - form.submit(); -}); diff --git a/tailbone/static/js/tailbone.mobile.receiving.js b/tailbone/static/js/tailbone.mobile.receiving.js deleted file mode 100644 index d46740ac..00000000 --- a/tailbone/static/js/tailbone.mobile.receiving.js +++ /dev/null @@ -1,92 +0,0 @@ - -/************************************************************ - * - * tailbone.mobile.receiving.js - * - * Global logic for mobile receiving feature - * - ************************************************************/ - - -// toggle visibility of "Receive" type buttons based on whether vendor is set -$(document).on('autocompleteitemselected', 'form[name="new-receiving-batch"] .vendor', function(event, uuid) { - $('#new-receiving-types').show(); -}); -$(document).on('autocompleteitemcleared', 'form[name="new-receiving-batch"] .vendor', function(event) { - $('#new-receiving-types').hide(); -}); -$(document).on('change', 'form[name="new-receiving-batch"] select[name="vendor"]', function(event) { - if ($(this).val()) { - $('#new-receiving-types').show(); - } else { - $('#new-receiving-types').hide(); - } -}); - - -// submit new receiving batch form when user clicks "Receive" type button -$(document).on('click', 'form[name="new-receiving-batch"] .start-receiving', function() { - var form = $(this).parents('form'); - form.find('input[name="workflow"]').val($(this).data('workflow')); - form.submit(); -}); - - -// submit new receiving batch form when user clicks Purchase Order option -$(document).on('click', 'form[name="new-receiving-batch"] [data-role="listview"] a', function() { - var form = $(this).parents('form'); - var key = $(this).parents('li').data('key'); - form.find('[name="workflow"]').val('from_po'); - form.find('.purchase-order-field').val(key); - form.submit(); - return false; -}); - - -// handle receiving action buttons -$(document).on('click', 'form.receiving-update .receiving-actions button', function() { - var action = $(this).data('action'); - var form = $(this).parents('form:first'); - var uom = form.find('[name="keypad-uom"]:checked').val(); - var mode = form.find('[name="mode"]:checked').val(); - var qty = form.find('.keypad-quantity').text(); - if (action == 'add' || action == 'subtract') { - if (qty != '0') { - if (action == 'subtract') { - qty = '-' + qty; - } - - if (uom == 'CS') { - form.find('[name="cases"]').val(qty); - } else { // units - form.find('[name="units"]').val(qty); - } - - if (action == 'add' && mode == 'expired') { - var expiry = form.find('input[name="expiration_date"]'); - if (! /^\d{4}-\d{2}-\d{2}$/.test(expiry.val())) { - alert("Please enter a valid expiration date."); - expiry.focus(); - return; - } - } - - form.submit(); - } - } -}); - - -// quick-receive (1 case or unit) -$(document).on('click', 'form.receiving-update .quick-receive', function() { - var form = $(this).parents('form:first'); - form.find('[name="mode"]').val('received'); - var quantity = $(this).data('quantity'); - if ($(this).data('uom') == 'CS') { - form.find('[name="cases"]').val(quantity); - } else { - form.find('[name="units"]').val(quantity); - } - form.find('input[name="quick_receive"]').val('true'); - form.submit(); -}); diff --git a/tailbone/static/themes/bobcat/css/base.css b/tailbone/static/themes/bobcat/css/base.css deleted file mode 100644 index 758ea304..00000000 --- a/tailbone/static/themes/bobcat/css/base.css +++ /dev/null @@ -1,114 +0,0 @@ - -/* /\****************************** */ -/* * General */ -/* ******************************\/ */ - -/* * { */ -/* margin: 0px; */ -/* } */ - -/* body { */ -/* font-family: Verdana, Arial, sans-serif; */ -/* font-size: 11pt; */ -/* } */ - -/* a { */ -/* color: #0972a5; */ -/* text-decoration: none; */ -/* } */ - -/* a:hover { */ -/* text-decoration: underline; */ -/* } */ - -/* h1 { */ -/* margin-bottom: 15px; */ -/* } */ - -/* h2 { */ -/* font-size: 12pt; */ -/* margin: 20px auto 10px auto; */ -/* } */ - -/* li { */ -/* line-height: 2em; */ -/* } */ - -/* p { */ -/* margin-bottom: 5px; */ -/* } */ - -/* .left { */ -/* float: left; */ -/* text-align: left; */ -/* } */ - -/* .right { */ -/* text-align: right; */ -/* } */ - -/* .wrapper { */ -/* overflow: auto; */ -/* } */ - -/* div.buttons { */ -/* clear: both; */ -/* margin-top: 10px; */ -/* } */ - -/* div.dialog { */ -/* display: none; */ -/* } */ - -/* div.flash-message { */ -/* background-color: #dddddd; */ -/* margin-bottom: 8px; */ -/* padding: 3px; */ -/* } */ - -/* div.flash-messages div.ui-state-highlight { */ -/* padding: .3em; */ -/* margin-bottom: 8px; */ -/* } */ - -/* div.error-messages div.ui-state-error { */ -/* padding: .3em; */ -/* margin-bottom: 8px; */ -/* } */ - -/* .flash-messages, */ -/* .error-messages { */ -/* margin: 0.5em 0 0 0; */ -/* } */ - -/* ul.error { */ -/* color: #dd6666; */ -/* font-weight: bold; */ -/* padding: 0px; */ -/* } */ - -/* ul.error li { */ -/* list-style-type: none; */ -/* } */ - -/* /\****************************** */ -/* * jQuery UI tweaks */ -/* ******************************\/ */ - -/* ul.ui-menu { */ -/* max-height: 30em; */ -/* } */ - -/****************************** - * tweaks for root user - ******************************/ - -.navbar .navbar-end .navbar-link.root-user, -.navbar .navbar-end .navbar-link.root-user:hover, -.navbar .navbar-end .navbar-link.root-user.is_active, -.navbar .navbar-end .navbar-item.root-user, -.navbar .navbar-end .navbar-item.root-user:hover, -.navbar .navbar-end .navbar-item.root-user.is_active { - background-color: red; - font-weight: bold; -} diff --git a/tailbone/static/themes/bobcat/css/forms.css b/tailbone/static/themes/bobcat/css/forms.css deleted file mode 100644 index 3ae22da3..00000000 --- a/tailbone/static/themes/bobcat/css/forms.css +++ /dev/null @@ -1,141 +0,0 @@ - -/* /\****************************** */ -/* * Form Wrapper */ -/* ******************************\/ */ - -/* div.form-wrapper { */ -/* overflow: auto; */ -/* } */ - - -/****************************** - * context menu - ******************************/ - -/* #context-menu { */ -/* /\* background-color: #ddcccc; *\/ */ -/* /\* background-color: green; *\/ */ -/* float: right; */ -/* /\* list-style-type: none; *\/ */ -/* /\* margin: 0px; *\/ */ -/* text-align: right; */ -/* } */ - -/* div.form-wrapper ul.context-menu li { */ -/* line-height: 2em; */ -/* } */ - - -/* /\****************************** */ -/* * "object helper" panel */ -/* ******************************\/ */ - -/* .object-helper { */ -/* border: 1px solid black; */ -/* float: right; */ -/* margin-top: 1em; */ -/* padding: 1em; */ -/* width: 20em; */ -/* } */ - -/* .object-helper-content { */ -/* margin-top: 1em; */ -/* } */ - - -/****************************** - * forms - ******************************/ - -/* div.form, */ -/* div.fieldset-form, */ -/* div.fieldset { */ -/* clear: left; */ -/* float: left; */ -/* margin-top: 10px; */ -/* } */ - -/* TODO: replace this with bulma equivalent */ -.form { - padding-left: 5em; -} - - -/****************************** - * fieldsets - ******************************/ - -/* TODO: replace this with bulma equivalent */ -.field-wrapper { - clear: both; - min-height: 30px; - overflow: auto; - margin: 15px; -} - -/* .field-wrapper.with-error { */ -/* background-color: #ddcccc; */ -/* border: 2px solid #dd6666; */ -/* padding-bottom: 1em; */ -/* } */ - -/* TODO: replace this with bulma equivalent */ -.field-wrapper .field-row { - display: table-row; -} - -/* TODO: replace this with bulma equivalent */ -.field-wrapper label { - display: table-cell; - vertical-align: top; - width: 18em; - font-weight: bold; - padding-top: 2px; - white-space: nowrap; -} - -/* .field-wrapper.with-error label { */ -/* padding-left: 1em; */ -/* } */ - -/* .field-wrapper .field-error { */ -/* padding: 1em 0 0.5em 1em; */ -/* } */ - -/* .field-wrapper .field-error .error-msg { */ -/* color: #dd6666; */ -/* font-weight: bold; */ -/* } */ - -/* TODO: replace this with bulma equivalent */ -.field-wrapper .field { - display: table-cell; - line-height: 25px; -} - -/* .field-wrapper .field input[type=text], */ -/* .field-wrapper .field input[type=password], */ -/* .field-wrapper .field select, */ -/* .field-wrapper .field textarea { */ -/* width: 320px; */ -/* } */ - -/* label input[type="checkbox"], */ -/* label input[type="radio"] { */ -/* margin-right: 0.5em; */ -/* } */ - -/* .field ul { */ -/* margin: 0px; */ -/* padding-left: 15px; */ -/* } */ - - -/* /\****************************** */ -/* * Buttons */ -/* ******************************\/ */ - -/* div.buttons { */ -/* clear: both; */ -/* margin: 10px 0px; */ -/* } */ diff --git a/tailbone/static/themes/bobcat/css/layout.css b/tailbone/static/themes/bobcat/css/layout.css deleted file mode 100644 index 1c490cbe..00000000 --- a/tailbone/static/themes/bobcat/css/layout.css +++ /dev/null @@ -1,208 +0,0 @@ - -/****************************** - * main layout - ******************************/ - -body { - display: flex; - flex-direction: column; - min-height: 100vh; -} - -.content-wrapper { - display: flex; - flex: 1; - flex-direction: column; - justify-content: space-between; -} - - -/****************************** - * header - ******************************/ - -header .level { - /* height: 60px; */ - line-height: 60px; - padding-left: 0.5em; - padding-right: 0.5em; -} - -header .level #header-logo { - display: inline-block; -} - -header .level .global-title, -header .level-left .global-title { - font-size: 2em; - font-weight: bold; -} - -header .level #current-context, -header .level-left #current-context { - font-size: 2em; - font-weight: bold; -} - -header .level #current-context span, -header .level-left #current-context span { - margin-right: 10px; -} - -header .level .theme-picker { - display: inline-flex; -} - -/* header .global .grid-nav { */ -/* display: inline-block; */ -/* font-size: 16px; */ -/* font-weight: bold; */ -/* line-height: 60px; */ -/* margin-left: 5em; */ -/* } */ - -/* header .global .grid-nav .ui-button, */ -/* header .global .grid-nav span.viewing { */ -/* margin-left: 1em; */ -/* } */ - -#content-title h1 { - font-size: 2em; -} - -/* /\****************************** */ -/* * Logo */ -/* ******************************\/ */ - -/* #logo { */ -/* display: block; */ -/* margin: 40px auto; */ -/* } */ - - -/****************************** - * content - ******************************/ - -#page-body { - padding: 0.4em; -} - -/* body > #body-wrapper { */ -/* margin: 0px; */ -/* position: relative; */ -/* } */ - -/* .content-wrapper { */ -/* height: 100%; */ -/* padding-bottom: 30px; */ -/* } */ - -/* #scrollpane { */ -/* height: 100%; */ -/* } */ - -/* #scrollpane .inner-content { */ -/* padding: 0 0.5em 0.5em 0.5em; */ -/* } */ - - -/****************************** - * context menu - ******************************/ - -#context-menu { - text-align: right; - white-space: nowrap; -} - -/****************************** - * "object helper" panel - ******************************/ - -.object-helper { - border: 1px solid black; - margin: 1em; - padding: 1em; - min-width: 20em; -} - -.object-helper-content { - margin-top: 1em; -} - -/* /\****************************** */ -/* * Panels */ -/* ******************************\/ */ - -/* .panel-wrapper { */ -/* float: left; */ -/* margin-right: 15px; */ -/* width: 40%; */ -/* } */ - -/* .panel, */ -/* .panel-grid { */ -/* border-left: 1px solid Black; */ -/* margin-bottom: 15px; */ -/* } */ - -/* .panel { */ -/* border-bottom: 1px solid Black; */ -/* border-right: 1px solid Black; */ -/* padding: 0px; */ -/* } */ - -/* .panel h2, */ -/* .panel-grid h2 { */ -/* border-bottom: 1px solid Black; */ -/* border-top: 1px solid Black; */ -/* padding: 5px; */ -/* margin: 0px; */ -/* } */ - -/* .panel-grid h2 { */ -/* border-right: 1px solid Black; */ -/* } */ - -/* .panel-body { */ -/* overflow: auto; */ -/* padding: 5px; */ -/* } */ - -/****************************** - * feedback - ******************************/ - -#feedback-dialog { - display: none; -} - -#feedback-dialog p { - margin-top: 1em; -} - -#feedback-dialog .red { - color: red; - font-weight: bold; -} - -#feedback-dialog .field-wrapper { - margin-top: 1em; - padding: 0; -} - -#feedback-dialog .field { - margin-bottom: 0; - margin-top: 0.5em; -} - -#feedback-dialog .referrer .field { - clear: both; - float: none; - margin-top: 1em; -} - -#feedback-dialog textarea { - width: auto; -} diff --git a/tailbone/static/themes/dodo/css/admin.css b/tailbone/static/themes/dodo/css/admin.css deleted file mode 100644 index a362b64f..00000000 --- a/tailbone/static/themes/dodo/css/admin.css +++ /dev/null @@ -1,84 +0,0 @@ -/* copied from https://github.com/dansup/bulma-templates/blob/master/css/admin.css */ - -html, body { - font-family: 'Open Sans', serif; - font-size: 16px; - line-height: 1.5; - height: 100%; - background: #ECF0F3; -} -nav.navbar { - border-top: 4px solid #276cda; - margin-bottom: 1rem; -} -.navbar-item.brand-text { - font-weight: 300; -} -.navbar-item, .navbar-link { - font-size: 14px; - font-weight: 700; -} -.columns { - width: 100%; - height: 100%; - margin-left: 0; -} -.menu-label { - color: #8F99A3; - letter-spacing: 1.3; - font-weight: 700; -} -.menu-list a { - color: #0F1D38; - font-size: 14px; - font-weight: 700; -} -.menu-list a:hover { - background-color: transparent; - color: #276cda; -} -.menu-list a.is-active { - background-color: transparent; - color: #276cda; - font-weight: 700; -} -.card { - box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.18); - margin-bottom: 2rem; -} -.card-header-title { - color: #8F99A3; - font-weight: 400; -} -.info-tiles { - margin: 1rem 0; -} -.info-tiles .subtitle { - font-weight: 300; - color: #8F99A3; -} -.hero.welcome.is-info { - background: #36D1DC; - background: -webkit-linear-gradient(to right, #5B86E5, #36D1DC); - background: linear-gradient(to right, #5B86E5, #36D1DC); -} -.hero.welcome .title, .hero.welcome .subtitle { - color: hsl(192, 17%, 99%); -} -.card .content { - font-size: 14px; -} -.card-footer-item { - font-size: 14px; - font-weight: 700; - color: #8F99A3; -} -.card-footer-item:hover { -} -.card-table .table { - margin-bottom: 0; -} -.events-card .card-table { - max-height: 250px; - overflow-y: scroll; -} \ No newline at end of file diff --git a/tailbone/static/themes/dodo/css/base.css b/tailbone/static/themes/dodo/css/base.css deleted file mode 100644 index 27f44c9f..00000000 --- a/tailbone/static/themes/dodo/css/base.css +++ /dev/null @@ -1,11 +0,0 @@ - -/****************************** - * tweaks for root user - ******************************/ - -.navbar .navbar-menu .navbar-link.root-user, -.navbar .navbar-menu .navbar-item.root-user, -.navbar.is-white .navbar-item.has-dropdown.is-active .navbar-link.root-user, -.navbar.is-white .navbar-item.has-dropdown:hover .navbar-link.root-user { - background-color: red; -} diff --git a/tailbone/static/themes/dodo/js/bulma.js b/tailbone/static/themes/dodo/js/bulma.js deleted file mode 100644 index a2e2dc9a..00000000 --- a/tailbone/static/themes/dodo/js/bulma.js +++ /dev/null @@ -1,12 +0,0 @@ -// copied from https://github.com/dansup/bulma-templates/blob/master/js/bulma.js - -// The following code is based off a toggle menu by @Bradcomp -// source: https://gist.github.com/Bradcomp/a9ef2ef322a8e8017443b626208999c1 -(function() { - var burger = document.querySelector('.burger'); - var menu = document.querySelector('#'+burger.dataset.target); - burger.addEventListener('click', function() { - burger.classList.toggle('is-active'); - menu.classList.toggle('is-active'); - }); -})(); diff --git a/tailbone/static/themes/falafel/css/forms.css b/tailbone/static/themes/falafel/css/forms.css index b5b10c74..de4b1ebe 100644 --- a/tailbone/static/themes/falafel/css/forms.css +++ b/tailbone/static/themes/falafel/css/forms.css @@ -26,3 +26,36 @@ .form-wrapper .form .field.is-horizontal .field-body .select select { width: 100%; } + +/****************************** + * field-wrappers + ******************************/ + +/* TODO: replace this with bulma equivalent */ +.field-wrapper { + clear: both; + min-height: 30px; + overflow: auto; + margin: 15px; +} + +/* TODO: replace this with bulma equivalent */ +.field-wrapper .field-row { + display: table-row; +} + +/* TODO: replace this with bulma equivalent */ +.field-wrapper label { + display: table-cell; + vertical-align: top; + width: 18em; + font-weight: bold; + padding-top: 2px; + white-space: nowrap; +} + +/* TODO: replace this with bulma equivalent */ +.field-wrapper .field { + display: table-cell; + line-height: 25px; +} diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 3deb9c1e..69aa29e5 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -208,7 +208,7 @@ def context_found(event): return False request.has_any_perm = has_any_perm - def get_referrer(default=None, mobile=False): + def get_referrer(default=None, **kwargs): if request.params.get('referrer'): return request.params['referrer'] if request.session.get('referrer'): @@ -218,8 +218,6 @@ def context_found(event): or not referrer.startswith(request.host_url)): if default: referrer = default - elif mobile: - referrer = request.route_url('mobile.home') else: referrer = request.route_url('home') return referrer diff --git a/tailbone/templates/forms/deform.mako b/tailbone/templates/forms/deform.mako index d6c99953..ede55f12 100644 --- a/tailbone/templates/forms/deform.mako +++ b/tailbone/templates/forms/deform.mako @@ -89,11 +89,7 @@ ${h.csrf_token(request)} <input type="reset" value="Reset" class="button" /> % endif % if getattr(form, 'show_cancel', True): - % if form.mobile: - ${h.link_to("Cancel", form.cancel_url, class_='ui-btn ui-corner-all')} - % else: - ${h.link_to("Cancel", form.cancel_url, class_='cancel button{}'.format(' autodisable' if form.auto_disable_cancel else ''))} - % endif + ${h.link_to("Cancel", form.cancel_url, class_='cancel button{}'.format(' autodisable' if form.auto_disable_cancel else ''))} % endif </div> % endif diff --git a/tailbone/templates/forms/deform_buefy.mako b/tailbone/templates/forms/deform_buefy.mako index 514d520d..71684f1d 100644 --- a/tailbone/templates/forms/deform_buefy.mako +++ b/tailbone/templates/forms/deform_buefy.mako @@ -65,18 +65,14 @@ <input type="reset" value="Reset" class="button" /> % endif % if getattr(form, 'show_cancel', True): - % if form.mobile: - ${h.link_to("Cancel", form.cancel_url, class_='ui-btn ui-corner-all')} + % if form.auto_disable_cancel: + <once-button tag="a" href="${form.cancel_url or request.get_referrer()}" + text="Cancel"> + </once-button> % else: - % if form.auto_disable_cancel: - <once-button tag="a" href="${form.cancel_url or request.get_referrer()}" - text="Cancel"> - </once-button> - % else: - <b-button tag="a" href="${form.cancel_url or request.get_referrer()}"> - Cancel - </b-button> - % endif + <b-button tag="a" href="${form.cancel_url or request.get_referrer()}"> + Cancel + </b-button> % endif % endif </div> diff --git a/tailbone/templates/mobile/about.mako b/tailbone/templates/mobile/about.mako deleted file mode 100644 index bfa55379..00000000 --- a/tailbone/templates/mobile/about.mako +++ /dev/null @@ -1,13 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/mobile/base.mako" /> -<%namespace name="base_meta" file="/base_meta.mako" /> - -<%def name="title()">About ${base_meta.app_title()}</%def> - -<h2>${project_title} ${project_version}</h2> - -% for name, version in packages.items(): - <h3>${name} ${version}</h3> -% endfor - -<p>Please see <a href="https://rattailproject.org/">rattailproject.org</a> for more info.</p> diff --git a/tailbone/templates/mobile/base.mako b/tailbone/templates/mobile/base.mako deleted file mode 100644 index c05c2100..00000000 --- a/tailbone/templates/mobile/base.mako +++ /dev/null @@ -1,208 +0,0 @@ -## -*- coding: utf-8 -*- -<%namespace name="base_meta" file="/base_meta.mako" /> -<!DOCTYPE html> -<html> - <head> - <meta http-equiv="content-type" content="text/html; charset=UTF-8" /> - <title>${base_meta.global_title()} » ${self.title()}</title> - <meta name="viewport" content="width=device-width, initial-scale=1" /> - - ${self.jquery()} - ${h.javascript_link(request.static_url('tailbone:static/js/jquery.ui.tailbone.mobile.js') + '?ver={}'.format(tailbone.__version__))} - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.mobile.js') + '?ver={}'.format(tailbone.__version__))} - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.mobile.receiving.js') + '?ver={}'.format(tailbone.__version__))} - ${self.extra_javascript()} - - ## since jquery mobile will "utterly cache" the first page which is loaded - ## by the client, we must make sure that is always the home page. so if - ## user tries to e.g. "refresh" some other page, redirect to home page - % if request.matched_route.name != 'mobile.home' and request.rattail_config.getbool('tailbone', 'mobile.force_home', default=True): - <script type="text/javascript"> - location.href = '${request.route_url('mobile.home')}'; - </script> - % endif - - % if request.rattail_config.getbool('tailbone', 'mobile.flash.autodismiss', default=True): - <script type="text/javascript"> - $(document).on('pageshow', function() { - ## TODO: seems like this should be better somehow... - // remove all flash messages after 2.5 seconds - window.setTimeout(function() { $('.flash, .error').remove(); }, 2500); - }); - </script> - % endif - - ${self.jquery_theme()} - ${h.stylesheet_link(request.static_url('tailbone:static/css/mobile.css') + '?ver={}'.format(tailbone.__version__))} - % if not request.rattail_config.production(): - <style type="text/css"> - .ui-page-theme-a { background-image: url(${request.static_url('tailbone:static/img/testing.png')}); } - </style> - % endif - ${self.extra_styles()} - - </head> - ${self.mobile_body()} -</html> - -<%def name="mobile_body()"> - <body> - - ## note that our toolbars are *external* (in jqm-speak) by default - - ${self.mobile_header()} - - <div data-role="page" data-url="${self.page_url()}"> - - ${self.mobile_usermenu()} - - ${self.mobile_page_body()} - - </div><!-- page --> - - ${self.mobile_footer()} - - </body> -</%def> - -<%def name="page_url()">${request.current_route_url()}</%def> - -<%def name="page_title()">${self.title()}</%def> - -<%def name="jquery()"> - ${h.javascript_link('https://code.jquery.com/jquery-1.12.4.min.js')} - ${h.javascript_link('https://code.jquery.com/mobile/1.4.5/jquery.mobile-1.4.5.min.js')} -</%def> - -<%def name="extra_javascript()"></%def> - -<%def name="jquery_theme()"> - ${h.stylesheet_link('https://code.jquery.com/mobile/1.4.5/jquery.mobile-1.4.5.min.css')} -</%def> - -<%def name="extra_styles()"></%def> - -<%def name="mobile_header()"> - <div data-role="header"> - ${self.mobile_header_link()} - <h1>${base_meta.global_title()}</h1> - ${self.mobile_header_feedback()} - </div> -</%def> - -<%def name="mobile_header_link()"> - <% classes = 'ui-btn-left ui-btn ui-btn-inline ui-mini ui-corner-all ui-btn-icon-left ' %> - % if request.user: - ${h.link_to(request.user.get_short_name(), '#usermenu', data_role='button', data_icon='user', - class_=' root-user' if request.is_root else '')} - % elif request.matched_route.name in ('mobile.login', 'mobile.about'): - ${h.link_to("Home", url('mobile.home'), data_role='button', data_icon='home')} - % else: - ${h.link_to("Login", url('mobile.login'), data_role='button', data_icon='user')} - % endif -</%def> - -<%def name="mobile_header_feedback()"> - ${h.link_to("Feedback", '#', id='feedback-button', data_role='button', data_icon='recycle')} -</%def> - -<%def name="mobile_usermenu()"> - <div id="usermenu" data-role="panel" data-display="overlay"> - <ul data-role="listview"> - <li data-icon="home">${h.link_to("Home", url('mobile.home'))}</li> - % if request.has_perm('datasync.restart'): - <li>${h.link_to("DataSync", url('datasync.mobile'))}</li> - % endif - % if request.is_root: - <li class="root-user" data-icon="forbidden">${h.link_to("Stop being root", url('stop_root'), **{'data-ajax': 'false'})}</li> - % elif request.is_admin: - <li class="root-user" data-icon="forbidden">${h.link_to("Become root", url('become_root'), **{'data-ajax': 'false'})}</li> - % endif - <li data-icon="lock">${h.link_to("Logout", url('mobile.logout'), **{'data-ajax': 'false'})}</li> - <li data-icon="info">${h.link_to("About {}".format(capture(base_meta.app_title)), url('mobile.about'))}</li> - </ul> - </div> -</%def> - -<%def name="mobile_page_body()"> - <div role="main" class="ui-content" data-route="${request.matched_route.name}"> - - % if request.session.peek_flash('error'): - % for error in request.session.pop_flash('error'): - <div class="error">${error}</div> - % endfor - % endif - - % if request.session.peek_flash(): - % for msg in request.session.pop_flash(): - <div class="flash">${msg|n}</div> - % endfor - % endif - - <h2>${self.page_title()}</h2> - - ${self.body()} - - <div data-role="popup" data-overlay-theme="b" id="feedback-popup" class="ui-content"> - <a href="#" data-rel="back" data-role="button" data-theme="a" data-icon="delete" data-iconpos="notext" class="ui-btn-right">Close</a> - ${self.mobile_feedback_form()} - </div> - - <div data-role="popup" data-overlay-theme="b" id="feedback-thanks" class="ui-content"> - Thank you for your feedback. - </div> - - <div class="replacement-header"> - ${self.mobile_header_link()} - </div> - - </div> -</%def> - -<%def name="mobile_footer()"> - <div data-role="footer"> - <h4>powered by ${h.link_to("Rattail", url('mobile.about'))}</h4> - </div> -</%def> - -<%def name="mobile_feedback_form()"> - ${h.form(url('mobile.feedback'))} - ${h.csrf_token(request)} - ${h.hidden('user', value=request.user.uuid if request.user else None)} - - <p> - Questions, suggestions, comments, complaints, etc. <span class="red">regarding this website</span> - are welcome and may be submitted below. - </p> - - <div class="field-wrapper referrer"> - <label for="referrer">Referring URL</label> - <div class="field"></div> - ${h.hidden('referrer')} - </div> - - % if request.user: - ${h.hidden('user_name', value=six.text_type(request.user))} - % else: - <div class="field-wrapper user_name"> - <label for="user_name">Your Name</label> - <div class="field"> - ${h.text('user_name')} - </div> - </div> - % endif - - <div class="field-wrapper message"> - <label for="message">Message</label> - <div class="field"> - ${h.textarea('message', cols=45, rows=15)} - </div> - </div> - - <div class="buttons" id="feedback-form-buttons"> - <button type="button" data-inline="true" class="submit" data-theme="b">Send Note</button> - <button type="button" data-inline="true" class="cancel">Cancel</button> - </div> - - ${h.end_form()} -</%def> diff --git a/tailbone/templates/mobile/base_internal_toolbars.mako b/tailbone/templates/mobile/base_internal_toolbars.mako deleted file mode 100644 index 107ca928..00000000 --- a/tailbone/templates/mobile/base_internal_toolbars.mako +++ /dev/null @@ -1,20 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="tailbone:templates/mobile/base.mako" /> - -<%def name="mobile_body()"> - <body> - - <div data-role="page" data-url="${self.page_url()}"${' data-rel="dialog"' if dialog else ''|n}> - - ${self.mobile_usermenu()} - - ${self.mobile_header()} - - ${self.mobile_page_body()} - - ${self.mobile_footer()} - - </div><!-- page --> - - </body> -</%def> diff --git a/tailbone/templates/mobile/batch/execute.mako b/tailbone/templates/mobile/batch/execute.mako deleted file mode 100644 index a6c7c6ef..00000000 --- a/tailbone/templates/mobile/batch/execute.mako +++ /dev/null @@ -1,10 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/mobile/base.mako" /> - -<%def name="title()">${index_title} » ${instance_title} » Execute</%def> - -<%def name="page_title()">${h.link_to(index_title, index_url)} » ${h.link_to(instance_title, instance_url)} » Execute</%def> - -<div class="form-wrapper"> - ${form.render()|n} -</div><!-- form-wrapper --> diff --git a/tailbone/templates/mobile/batch/inventory/create.mako b/tailbone/templates/mobile/batch/inventory/create.mako deleted file mode 100644 index 99c8106d..00000000 --- a/tailbone/templates/mobile/batch/inventory/create.mako +++ /dev/null @@ -1,6 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/mobile/master/create.mako" /> - -<%def name="title()">${h.link_to("Inventory", url('mobile.batch.inventory'))} » New Batch</%def> - -${parent.body()} diff --git a/tailbone/templates/mobile/batch/inventory/index.mako b/tailbone/templates/mobile/batch/inventory/index.mako deleted file mode 100644 index 29038208..00000000 --- a/tailbone/templates/mobile/batch/inventory/index.mako +++ /dev/null @@ -1,6 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/mobile/master/index.mako" /> - -<%def name="title()">Inventory</%def> - -${parent.body()} diff --git a/tailbone/templates/mobile/batch/inventory/view.mako b/tailbone/templates/mobile/batch/inventory/view.mako deleted file mode 100644 index 2c8f785c..00000000 --- a/tailbone/templates/mobile/batch/inventory/view.mako +++ /dev/null @@ -1,24 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/mobile/batch/view.mako" /> - -<%def name="title()">${h.link_to("Inventory", url('mobile.batch.inventory'))} » ${batch.id_str}</%def> - -${form.render()|n} - -% if not batch.executed and not batch.complete: - <br /> - ${h.text('upc-search', class_='inventory-upc-search', placeholder="Enter UPC", autocomplete='off', **{'data-type': 'search', 'data-url': url('mobile.batch.inventory.row_from_upc', uuid=batch.uuid)})} -% endif - -% if master.has_rows: - <br /> - ${grid.render_complete()|n} -% endif - -% if not batch.executed and not batch.complete: - <br /> - ${h.form(request.route_url('mobile.batch.inventory.mark_complete', uuid=batch.uuid))} - ${h.csrf_token(request)} - ${h.hidden('mark-complete', value='true')} - <button type="submit">Mark Batch as Complete</button> -% endif diff --git a/tailbone/templates/mobile/batch/inventory/view_row.mako b/tailbone/templates/mobile/batch/inventory/view_row.mako deleted file mode 100644 index bfb06dcf..00000000 --- a/tailbone/templates/mobile/batch/inventory/view_row.mako +++ /dev/null @@ -1,63 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/mobile/batch/view_row.mako" /> -<%namespace file="/mobile/keypad.mako" import="keypad" /> - -## TODO: this is broken for actual page (header) title -<%def name="title()">${h.link_to("Inventory", url('mobile.batch.inventory'))} » ${h.link_to(batch.id_str, url('mobile.batch.inventory.view', uuid=batch.uuid))} » ${row.upc.pretty()}</%def> - -<div class="ui-grid-a"> - <div class="ui-block-a"> - % if instance.product: - <h3>${row.brand_name or ""}</h3> - <h3>${row.description} ${row.size}</h3> - <h3>${h.pretty_quantity(row.case_quantity)} ${unit_uom} per CS</h3> - % else: - <h3>${row.description}</h3> - % endif - </div> - <div class="ui-block-b"> - ${h.image(product_image_url, "product image")} - </div> -</div> - -<p> - currently: - % if uom == 'CS': - ${h.pretty_quantity(row.cases or 0)} - % else: - ${h.pretty_quantity(row.units or 0)} - % endif - ${uom} -</p> - -% if not batch.executed and not batch.complete: - - ${h.form(request.current_route_url())} - ${h.csrf_token(request)} - ${h.hidden('row', value=row.uuid)} - % if allow_cases: - ${h.hidden('cases')} - % endif - ${h.hidden('units')} - - <% - quantity = 1 - if allow_cases: - if row.cases is not None: - quantity = row.cases - elif row.units is not None: - quantity = row.units - elif row.units is not None: - quantity = row.units - %> - ${keypad(unit_uom, uom, quantity=quantity, allow_cases=allow_cases)} - - <fieldset data-role="controlgroup" data-type="horizontal" class="inventory-actions"> - <button type="button" class="ui-btn-inline ui-corner-all save">Save</button> - <button type="button" class="ui-btn-inline ui-corner-all delete" disabled="disabled">Delete</button> - ${h.link_to("Cancel", url('mobile.batch.inventory.view', uuid=batch.uuid), class_='ui-btn ui-btn-inline ui-corner-all')} - </fieldset> - - ${h.end_form()} - -% endif diff --git a/tailbone/templates/mobile/batch/view.mako b/tailbone/templates/mobile/batch/view.mako deleted file mode 100644 index ff0bcc38..00000000 --- a/tailbone/templates/mobile/batch/view.mako +++ /dev/null @@ -1,32 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/mobile/master/view.mako" /> - -${parent.body()} - -% if not batch.executed: - % if request.has_perm('{}.edit'.format(permission_prefix)): - % if batch.complete: - ${h.form(url('mobile.{}.mark_pending'.format(route_prefix), uuid=batch.uuid))} - ${h.csrf_token(request)} - ${h.hidden('mark-pending', value='true')} - ${h.submit('submit', "Mark Batch as Pending")} - ${h.end_form()} - % else: - ${h.form(url('mobile.{}.mark_complete'.format(route_prefix), uuid=batch.uuid))} - ${h.csrf_token(request)} - ${h.hidden('mark-complete', value='true')} - ${h.submit('submit', "Mark Batch as Complete")} - ${h.end_form()} - % endif - % endif - % if batch.complete and master.mobile_executable and request.has_perm('{}.execute'.format(permission_prefix)): - % if master.has_execution_options(batch): - ${h.link_to("Execute Batch", url('mobile.{}.execute'.format(route_prefix), uuid=batch.uuid), class_='ui-btn ui-corner-all')} - % else: - ${h.form(url('mobile.{}.execute'.format(route_prefix), uuid=batch.uuid))} - ${h.csrf_token(request)} - ${h.submit('submit', "Execute Batch")} - ${h.end_form()} - % endif - % endif -% endif diff --git a/tailbone/templates/mobile/batch/view_row.mako b/tailbone/templates/mobile/batch/view_row.mako deleted file mode 100644 index ad729169..00000000 --- a/tailbone/templates/mobile/batch/view_row.mako +++ /dev/null @@ -1,4 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/mobile/master/view_row.mako" /> - -${parent.body()} diff --git a/tailbone/templates/mobile/datasync.mako b/tailbone/templates/mobile/datasync.mako deleted file mode 100644 index 2f21a2a2..00000000 --- a/tailbone/templates/mobile/datasync.mako +++ /dev/null @@ -1,9 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/mobile/base.mako" /> - -<%def name="title()">DataSync</%def> - -${h.form(url('datasync.restart'))} -${h.csrf_token(request)} -${h.submit('restart', "Restart DataSync Daemon", id='datasync-restart')} -${h.end_form()} diff --git a/tailbone/templates/mobile/grids/complete.mako b/tailbone/templates/mobile/grids/complete.mako deleted file mode 100644 index ebb58334..00000000 --- a/tailbone/templates/mobile/grids/complete.mako +++ /dev/null @@ -1,7 +0,0 @@ -## -*- coding: utf-8; -*- - -% if grid.filterable: - ${grid.render_filters()|n} -% endif - -${grid.render_grid()|n} diff --git a/tailbone/templates/mobile/grids/filters_simple.mako b/tailbone/templates/mobile/grids/filters_simple.mako deleted file mode 100644 index 1286d99a..00000000 --- a/tailbone/templates/mobile/grids/filters_simple.mako +++ /dev/null @@ -1,15 +0,0 @@ -## -*- coding: utf-8; -*- -<div class="simple-filter"> - ${h.form(request.current_route_url(_query=None), method='get')} - - % for filtr in grid.iter_filters(): - ${h.hidden('{}.verb'.format(filtr.key), value=filtr.verb)} - <fieldset data-role="controlgroup" data-type="horizontal"> - % for value, label in filtr.iter_choices(): - ${h.radio(filtr.key, value=value, label=label, checked=value == filtr.value)} - % endfor - </fieldset> - % endfor - - ${h.end_form()} -</div><!-- simple-filter --> diff --git a/tailbone/templates/mobile/grids/grid.mako b/tailbone/templates/mobile/grids/grid.mako deleted file mode 100644 index b7b029b5..00000000 --- a/tailbone/templates/mobile/grids/grid.mako +++ /dev/null @@ -1,36 +0,0 @@ -## -*- coding: utf-8; -*- - -<ul data-role="listview"> - ${grid.make_webhelpers_grid()} -</ul> - -## <table data-role="table" class="ui-responsive table-stroke"> -## <thead> -## <tr> -## % for column in grid.iter_visible_columns(): -## ${grid.column_header(column)} -## % endfor -## </tr> -## </thead> -## <tbody> -## % for i, row in enumerate(grid.iter_rows(), 1): -## <tr> -## % for column in grid.iter_visible_columns(): -## <td>${grid.render_cell(row, column)}</td> -## % endfor -## </tr> -## % endfor -## </tbody> -## </table> - -% if grid.pageable and grid.pager: - <br /> - <div data-role="controlgroup" data-type="horizontal"> - ${grid.pager.pager('$link_first $link_previous $link_next $link_last', - symbol_first='<< first', symbol_last='last >>', - symbol_previous='< prev', symbol_next='next >', - link_attr={'class': 'ui-btn ui-corner-all'}, - curpage_attr={'class': 'ui-btn ui-corner-all'}, - dotdot_attr={'class': 'ui-btn ui-corner-all'})|n} - </div> -% endif diff --git a/tailbone/templates/mobile/home.mako b/tailbone/templates/mobile/home.mako deleted file mode 100644 index 1daafd86..00000000 --- a/tailbone/templates/mobile/home.mako +++ /dev/null @@ -1,12 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/mobile/base.mako" /> -<%namespace name="base_meta" file="/base_meta.mako" /> - -<%def name="title()">Home</%def> - -<%def name="page_title()"></%def> - -<div style="text-align: center;"> - ${h.image(image_url, "{} logo".format(capture(base_meta.app_title)), id='logo', width=300)} - <h3>Welcome to ${base_meta.app_title()}</h3> -</div> diff --git a/tailbone/templates/mobile/keypad.mako b/tailbone/templates/mobile/keypad.mako deleted file mode 100644 index 38cb03da..00000000 --- a/tailbone/templates/mobile/keypad.mako +++ /dev/null @@ -1,41 +0,0 @@ -## -*- coding: utf-8; -*- - -<%def name="keypad(unit_uom, selected_uom, quantity=1, allow_cases=True)"> - <div class="quantity-keypad-thingy" data-changed="false"> - - <table> - <tbody> - <tr> - <td>${h.link_to("7", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td> - <td>${h.link_to("8", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td> - <td>${h.link_to("9", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td> - </tr> - <tr> - <td>${h.link_to("4", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td> - <td>${h.link_to("5", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td> - <td>${h.link_to("6", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td> - </tr> - <tr> - <td>${h.link_to("1", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td> - <td>${h.link_to("2", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td> - <td>${h.link_to("3", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td> - </tr> - <tr> - <td>${h.link_to("0", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td> - <td>${h.link_to(".", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td> - <td>${h.link_to("Del", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td> - </tr> - </tbody> - </table> - - <fieldset data-role="controlgroup" data-type="horizontal"> - <button type="button" class="ui-btn-active keypad-quantity">${h.pretty_quantity(1 if quantity is None else quantity)}</button> - <button type="button" disabled="disabled"> </button> - % if allow_cases: - ${h.radio('keypad-uom', value='CS', checked=selected_uom == 'CS', label="CS")} - % endif - ${h.radio('keypad-uom', value=unit_uom, checked=selected_uom == unit_uom, label=unit_uom)} - </fieldset> - - </div> -</%def> diff --git a/tailbone/templates/mobile/login.mako b/tailbone/templates/mobile/login.mako deleted file mode 100644 index 5a5efb9f..00000000 --- a/tailbone/templates/mobile/login.mako +++ /dev/null @@ -1,7 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/mobile/base.mako" /> -<%namespace file="/login.mako" import="login_form" /> - -<%def name="title()">Login</%def> - -${login_form()} diff --git a/tailbone/templates/mobile/master/create.mako b/tailbone/templates/mobile/master/create.mako deleted file mode 100644 index 9bcca732..00000000 --- a/tailbone/templates/mobile/master/create.mako +++ /dev/null @@ -1,8 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/mobile/base.mako" /> - -<%def name="title()">New ${model_title}</%def> - -<div class="form-wrapper"> - ${form.render()|n} -</div><!-- form-wrapper --> diff --git a/tailbone/templates/mobile/master/create_row.mako b/tailbone/templates/mobile/master/create_row.mako deleted file mode 100644 index 7b5dae0c..00000000 --- a/tailbone/templates/mobile/master/create_row.mako +++ /dev/null @@ -1,6 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/mobile/master/create.mako" /> - -<%def name="title()">New ${model_title} Row</%def> - -${parent.body()} diff --git a/tailbone/templates/mobile/master/edit.mako b/tailbone/templates/mobile/master/edit.mako deleted file mode 100644 index 3c13a8e4..00000000 --- a/tailbone/templates/mobile/master/edit.mako +++ /dev/null @@ -1,10 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/mobile/base.mako" /> - -<%def name="title()">${index_title} » ${instance_title} » Edit</%def> - -<%def name="page_title()">${h.link_to(index_title, index_url)} » ${h.link_to(instance_title, instance_url)} » Edit</%def> - -<div class="form-wrapper"> - ${form.render()|n} -</div><!-- form-wrapper --> diff --git a/tailbone/templates/mobile/master/edit_row.mako b/tailbone/templates/mobile/master/edit_row.mako deleted file mode 100644 index 93eb12e3..00000000 --- a/tailbone/templates/mobile/master/edit_row.mako +++ /dev/null @@ -1,17 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/mobile/master/edit.mako" /> - -<%def name="title()">${index_title} » ${parent_title} » ${instance_title} » Edit</%def> - -<%def name="page_title()">${h.link_to(index_title, index_url)} » ${h.link_to(parent_title, parent_url)} » ${h.link_to(instance_title, instance_url)} » Edit</%def> - -<div class="form-wrapper"> - ${form.render()|n} -</div><!-- form-wrapper --> - -% if master.mobile_rows_deletable and request.has_perm('{}.delete_row'.format(permission_prefix)): - ${h.form(url('mobile.{}.delete_row'.format(route_prefix), uuid=parent_instance.uuid, row_uuid=row.uuid))} - ${h.csrf_token(request)} - ${h.submit('submit', "Delete this Row")} - ${h.end_form()} -% endif diff --git a/tailbone/templates/mobile/master/index.mako b/tailbone/templates/mobile/master/index.mako deleted file mode 100644 index f54ac2ae..00000000 --- a/tailbone/templates/mobile/master/index.mako +++ /dev/null @@ -1,17 +0,0 @@ -## -*- coding: utf-8; -*- -## ############################################################################## -## -## Default master 'index' template for mobile. Features a somewhat abbreviated -## data table and (hopefully) exposes a way to filter and sort the data, etc. -## -## ############################################################################## -<%inherit file="/mobile/base.mako" /> - -<%def name="title()">${index_title}</%def> - -% if master.mobile_creatable and request.has_perm('{}.create'.format(permission_prefix)): - ${h.link_to("New {}".format(model_title), url('mobile.{}.create'.format(route_prefix)), class_='ui-btn ui-corner-all')} - <br /> -% endif - -${grid.render_complete()|n} diff --git a/tailbone/templates/mobile/master/view.mako b/tailbone/templates/mobile/master/view.mako deleted file mode 100644 index 9f00d8af..00000000 --- a/tailbone/templates/mobile/master/view.mako +++ /dev/null @@ -1,48 +0,0 @@ -## -*- coding: utf-8; -*- -## ############################################################################## -## -## Default master 'view' template for mobile. Features a basic field list, and -## links to edit/delete the object when appropriate. -## -## ############################################################################## -<%inherit file="/mobile/base.mako" /> - -<%def name="title()">${index_title} » ${instance_title}</%def> - -<%def name="page_title()">${h.link_to(index_title, index_url)} » ${instance_title}</%def> - -${form.render()|n} - -% if master.has_rows: - - % if master.mobile_rows_creatable and master.rows_creatable_for(instance): - ## TODO: this seems like a poor choice of names? what are we really testing for here? - % if master.mobile_rows_creatable_via_browse: - <% add_title = "Add Record" if add_item_title is Undefined else add_item_title %> - ${h.link_to(add_title, url('mobile.{}.create_row'.format(route_prefix), uuid=instance.uuid), class_='ui-btn ui-corner-all')} - % endif - % endif - % if master.mobile_rows_quickable and master.rows_quickable_for(instance): - <% placeholder = '' if quick_entry_placeholder is Undefined else quick_entry_placeholder %> - ${h.form(url('mobile.{}.quick_row'.format(route_prefix), uuid=instance.uuid))} - ${h.csrf_token(request)} - % if quick_row_autocomplete: - <div class="field autocomplete quick-row" data-url="${quick_row_autocomplete_url}"> - ${h.hidden('quick_entry')} - ${h.text('quick_row_autocomplete_text', placeholder=placeholder, autocomplete='off', data_type='search')} - <ul data-role="listview" data-inset="true" data-filter="true" data-input="#quick_row_autocomplete_text"></ul> - <button type="button" style="display: none;">Change</button> - </div> - % else: - ${h.text('quick_entry', placeholder=placeholder, autocomplete='off', **{'data-type': 'search', 'data-url': url('mobile.{}.quick_row'.format(route_prefix), uuid=instance.uuid), 'data-wedge': 'true' if quick_row_keyboard_wedge else 'false'})} - % endif - ${h.end_form()} - % endif - - <br /> - ${grid.render_complete()|n} -% endif - -% if master.mobile_editable and instance_editable and request.has_perm('{}.edit'.format(permission_prefix)): - ${h.link_to("Edit This", url('mobile.{}.edit'.format(route_prefix), uuid=instance.uuid), class_='ui-btn ui-corner-all')} -% endif diff --git a/tailbone/templates/mobile/master/view_row.mako b/tailbone/templates/mobile/master/view_row.mako deleted file mode 100644 index 29a014e8..00000000 --- a/tailbone/templates/mobile/master/view_row.mako +++ /dev/null @@ -1,19 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/mobile/master/view.mako" /> - -<%def name="title()">${index_title} » ${parent_title} » ${instance_title}</%def> - -<%def name="page_title()">${h.link_to(index_title, index_url)} » ${h.link_to(parent_title, parent_url)} » ${instance_title}</%def> - -${form.render()|n} - -% if master.mobile_rows_editable and instance_editable and request.has_perm('{}.edit_row'.format(permission_prefix)): - ${h.link_to("Edit", url('mobile.{}.edit_row'.format(route_prefix), uuid=instance.batch_uuid, row_uuid=instance.uuid), class_='ui-btn')} -% endif - -% if master.mobile_rows_deletable and master.row_deletable(row) and request.has_perm('{}.delete_row'.format(permission_prefix)): - ${h.form(url('mobile.{}.delete_row'.format(route_prefix), uuid=parent_instance.uuid, row_uuid=row.uuid))} - ${h.csrf_token(request)} - ${h.submit('submit', "Delete this Row")} - ${h.end_form()} -% endif diff --git a/tailbone/templates/mobile/ordering/create.mako b/tailbone/templates/mobile/ordering/create.mako deleted file mode 100644 index ae292269..00000000 --- a/tailbone/templates/mobile/ordering/create.mako +++ /dev/null @@ -1,31 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/mobile/base.mako" /> - -<%def name="title()">${index_title} » New Batch</%def> - -<%def name="page_title()">${h.link_to(index_title, index_url)} » New Batch</%def> - -${h.form(request.current_route_url(), class_='ui-filterable', name='new-purchasing-batch')} -${h.csrf_token(request)} - -<div class="field-wrapper vendor"> - % if vendor_use_autocomplete: - <div class="field autocomplete" data-url="${url('vendors.autocomplete')}"> - ${h.hidden('vendor')} - ${h.text('new-purchasing-batch-vendor-text', placeholder="Vendor name", autocomplete='off', data_type='search')} - <ul data-role="listview" data-inset="true" data-filter="true" data-input="#new-purchasing-batch-vendor-text"></ul> - <button type="button" style="display: none;">Change Vendor</button> - </div> - % else: - <div class="field-row"> - <label for="vendor">Vendor</label> - <div class="field"> - ${h.select('vendor', None, vendor_options)} - </div> - </div> - % endif -</div> - -<br /> -${h.submit('submit', "Make Batch")} -${h.end_form()} diff --git a/tailbone/templates/mobile/ordering/create_row.mako b/tailbone/templates/mobile/ordering/create_row.mako deleted file mode 100644 index 79d83630..00000000 --- a/tailbone/templates/mobile/ordering/create_row.mako +++ /dev/null @@ -1,6 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/mobile/master/create_row.mako" /> - -<%def name="page_title()">${h.link_to(index_title, index_url)} » ${h.link_to(instance_title, instance_url)} » Add Item</%def> - -${parent.body()} diff --git a/tailbone/templates/mobile/ordering/new_product.mako b/tailbone/templates/mobile/ordering/new_product.mako deleted file mode 100644 index 79d83630..00000000 --- a/tailbone/templates/mobile/ordering/new_product.mako +++ /dev/null @@ -1,6 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/mobile/master/create_row.mako" /> - -<%def name="page_title()">${h.link_to(index_title, index_url)} » ${h.link_to(instance_title, instance_url)} » Add Item</%def> - -${parent.body()} diff --git a/tailbone/templates/mobile/products/index.mako b/tailbone/templates/mobile/products/index.mako deleted file mode 100644 index 01cb8320..00000000 --- a/tailbone/templates/mobile/products/index.mako +++ /dev/null @@ -1,17 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/mobile/master/index.mako" /> - -% if master.mobile_creatable and request.has_perm('{}.create'.format(permission_prefix)): - ${h.link_to("New {}".format(model_title), url('mobile.{}.create'.format(route_prefix)), class_='ui-btn ui-corner-all')} -% endif - -% if quick_lookup: - - ${h.form(url('mobile.{}.quick_lookup'.format(route_prefix)))} - ${h.csrf_token(request)} - ${h.text('quick_entry', placeholder=placeholder, autocomplete='off', **{'data-type': 'search', 'data-url': url('mobile.{}.quick_lookup'.format(route_prefix)), 'data-wedge': 'true' if quick_lookup_keyboard_wedge else 'false'})} - ${h.end_form()} - -% else: ## not quick_only - ${grid.render_complete()|n} -% endif diff --git a/tailbone/templates/mobile/receiving/create.mako b/tailbone/templates/mobile/receiving/create.mako deleted file mode 100644 index 97cb132d..00000000 --- a/tailbone/templates/mobile/receiving/create.mako +++ /dev/null @@ -1,85 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/mobile/base.mako" /> - -<%def name="title()">Receiving » New Batch</%def> - -<%def name="page_title()">${h.link_to("Receiving", url('mobile.receiving'))} » New Batch</%def> - -${h.form(form.action_url, class_='ui-filterable', name='new-receiving-batch')} -${h.csrf_token(request)} - -% if phase == 1: - - % if vendor_use_autocomplete: - <div class="field-wrapper vendor"> - <div class="field autocomplete" data-url="${url('vendors.autocomplete')}"> - ${h.hidden('vendor')} - ${h.text('new-receiving-batch-vendor-text', placeholder="Vendor name", autocomplete='off', **{'data-type': 'search'})} - <ul data-role="listview" data-inset="true" data-filter="true" data-input="#new-receiving-batch-vendor-text"></ul> - <button type="button" style="display: none;">Change Vendor</button> - </div> - </div> - % else: - <div class="field-row"> - <label for="vendor">Vendor</label> - <div class="field"> - ${h.select('vendor', None, vendor_options)} - </div> - </div> - % endif - - <br /> - - <div id="new-receiving-types" style="display: none;"> - - ${h.hidden('workflow')} - ${h.hidden('phase', value='1')} - - % if master.allow_from_po: - <button type="button" class="start-receiving" data-workflow="from_po">Receive from PO</button> - % endif - - % if master.allow_from_scratch: - <button type="button" class="start-receiving" data-workflow="from_scratch">Receive from Scratch</button> - % endif - - % if master.allow_truck_dump: - <button type="button" class="start-receiving" data-workflow="truck_dump">Receive Truck Dump</button> - % endif - - </div> - -% else: ## phase 2 - - ${h.hidden('workflow')} - ${h.hidden('phase', value='2')} - - <div class="field-wrapper vendor"> - <label>Vendor</label> - <div class="field"> - ${h.hidden('vendor', value=vendor.uuid)} - ${vendor} - </div> - </div> - - % if purchases: - ${h.hidden(purchase_order_fieldname, class_='purchase-order-field')} - <p>Please choose a Purchase Order to receive:</p> - <ul data-role="listview" data-inset="true"> - % for key, purchase in purchases: - <li data-key="${key}">${h.link_to(purchase, '#')}</li> - % endfor - </ul> - % else: - <p>(no eligible purchases found)</p> - % endif - - % if master.allow_from_scratch: - <button type="button" class="start-receiving" data-workflow="from_scratch">Receive from Scratch</button> - % endif - - ${h.link_to("Cancel", url('mobile.{}'.format(route_prefix)), class_='ui-btn ui-corner-all')} - -% endif - -${h.end_form()} diff --git a/tailbone/templates/mobile/receiving/receive_row.mako b/tailbone/templates/mobile/receiving/receive_row.mako deleted file mode 100644 index 7987e9de..00000000 --- a/tailbone/templates/mobile/receiving/receive_row.mako +++ /dev/null @@ -1,151 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/mobile/master/view_row.mako" /> -<%namespace file="/mobile/keypad.mako" import="keypad" /> - -<%def name="title()">Receiving » ${batch.id_str} » ${master.render_product_key_value(row)}</%def> - -<%def name="page_title()">${h.link_to("Receiving", url('mobile.receiving'))} » ${h.link_to(batch.id_str, url('mobile.receiving.view', uuid=batch.uuid))} » ${master.render_product_key_value(row)}</%def> - - -<div${' class="ui-grid-a"' if product_image_url else ''|n}> - <div class="ui-block-a"${'' if instance.product else ' style="background-color: red;"'|n}> - % if instance.product: - <h3>${instance.brand_name or ""}</h3> - <h3>${instance.description} ${instance.size or ''}</h3> - % if allow_cases: - <h3>1 CS = ${h.pretty_quantity(row.case_quantity or 1)} ${unit_uom}</h3> - % endif - % else: - <h3>${instance.description}</h3> - % endif - </div> - % if product_image_url: - <div class="ui-block-b"> - ${h.image(product_image_url, "product image")} - </div> - % endif -</div> - -<table${'' if instance.product else ' style="background-color: red;"'|n}> - <tbody> - % if batch.order_quantities_known: - <tr> - <td>shipped</td> - <td> - % if allow_cases: - ${h.pretty_quantity(row.cases_shipped or 0)} / - % endif - ${h.pretty_quantity(row.units_shipped or 0)} - </td> - </tr> - % endif - <tr> - <td>received</td> - <td> - % if allow_cases: - ${h.pretty_quantity(row.cases_received or 0)} / - % endif - ${h.pretty_quantity(row.units_received or 0)} - </td> - </tr> - <tr> - <td>damaged</td> - <td> - % if allow_cases: - ${h.pretty_quantity(row.cases_damaged or 0)} / - % endif - ${h.pretty_quantity(row.units_damaged or 0)} - </td> - </tr> - % if allow_expired: - <tr> - <td>expired</td> - <td> - % if allow_cases: - ${h.pretty_quantity(row.cases_expired or 0)} / - % endif - ${h.pretty_quantity(row.units_expired or 0)} - </td> - </tr> - % endif - </tbody> -</table> - -% if request.session.peek_flash('receiving-warning'): - % for error in request.session.pop_flash('receiving-warning'): - <div class="receiving-warning">${error}</div> - % endfor -% endif - -% if not batch.executed and not batch.complete: - - ${h.form(request.current_route_url(), class_='receiving-update')} - ${h.csrf_token(request)} - ${h.hidden('row', value=row.uuid)} - ${h.hidden('cases')} - ${h.hidden('units')} - - ## only show quick-receive if we have an identifiable product - % if quick_receive and instance.product: - % if quick_receive_all: - <button type="button" class="quick-receive" data-quantity="${quick_receive_quantity}" data-uom="${quick_receive_uom}">${quick_receive_text}</button> - % elif allow_cases: - <button type="button" class="quick-receive" data-quantity="1" data-uom="CS">Receive 1 CS</button> - <div> - ## TODO: probably should make these optional / configurable - <button type="button" class="quick-receive ui-btn ui-btn-inline ui-corner-all" data-quantity="1" data-uom="EA">1 EA</button> - <button type="button" class="quick-receive ui-btn ui-btn-inline ui-corner-all" data-quantity="3" data-uom="EA">3 EA</button> - <button type="button" class="quick-receive ui-btn ui-btn-inline ui-corner-all" data-quantity="6" data-uom="EA">6 EA</button> - </div> - <br /> - % else: - <button type="button" class="quick-receive" data-quantity="1" data-uom="${unit_uom}">Receive 1 ${unit_uom}</button> - % endif - % endif - - ${keypad(unit_uom, uom, allow_cases=allow_cases)} - - <table> - <tbody> - <tr> - <td> - <fieldset data-role="controlgroup" data-type="horizontal" class="receiving-mode"> - ${h.radio('mode', value='received', label="received", checked=True)} - ${h.radio('mode', value='damaged', label="damaged")} - % if allow_expired: - ${h.radio('mode', value='expired', label="expired")} - % endif - </fieldset> - </td> - </tr> - <tr id="expiration-row" style="display: none;"> - <td> - <div style="padding:10px 20px;"> - <label for="expiration_date">Expiration Date</label> - <input name="expiration_date" type="date" value="" placeholder="YYYY-MM-DD" /> - </div> - </td> - </tr> - <tr> - <td> - <fieldset data-role="controlgroup" data-type="horizontal" class="receiving-actions"> - <button type="button" data-action="add" class="ui-btn-inline ui-corner-all">Add</button> - <button type="button" data-action="subtract" class="ui-btn-inline ui-corner-all">Subtract</button> - ## <button type="button" data-action="clear" class="ui-btn-inline ui-corner-all ui-state-disabled">Clear</button> - </fieldset> - </td> - </tr> - </tbody> - </table> - - ${h.hidden('quick_receive', value='false')} - ${h.end_form()} - - % if master.mobile_rows_deletable and master.row_deletable(row) and request.has_perm('{}.delete_row'.format(permission_prefix)): - ${h.form(url('mobile.{}.delete_row'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid), class_='receiving-update')} - ${h.csrf_token(request)} - ${h.submit('submit', "Delete this Row")} - ${h.end_form()} - % endif - -% endif diff --git a/tailbone/templates/mobile/receiving/view_row.mako b/tailbone/templates/mobile/receiving/view_row.mako deleted file mode 100644 index 53d8820f..00000000 --- a/tailbone/templates/mobile/receiving/view_row.mako +++ /dev/null @@ -1,151 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/mobile/master/view_row.mako" /> -<%namespace file="/mobile/keypad.mako" import="keypad" /> - -<%def name="title()">Receiving » ${batch.id_str} » ${master.render_product_key_value(row)}</%def> - -<%def name="page_title()">${h.link_to("Receiving", url('mobile.receiving'))} » ${h.link_to(batch.id_str, url('mobile.receiving.view', uuid=batch.uuid))} » ${master.render_product_key_value(row)}</%def> - - -<div${' class="ui-grid-a"' if product_image_url else ''|n}> - <div class="ui-block-a"${'' if instance.product else ' style="background-color: red;"'|n}> - % if instance.product: - <h3>${instance.brand_name or ""}</h3> - <h3>${instance.description} ${instance.size or ''}</h3> - % if allow_cases: - <h3>1 CS = ${h.pretty_quantity(row.case_quantity or 1)} ${unit_uom}</h3> - % endif - % else: - <h3>${instance.description}</h3> - % endif - </div> - % if product_image_url: - <div class="ui-block-b"> - ${h.image(product_image_url, "product image")} - </div> - % endif -</div> - -<table${'' if instance.product else ' style="background-color: red;"'|n}> - <tbody> - % if batch.order_quantities_known: - <tr> - <td>ordered</td> - <td> - % if allow_cases: - ${h.pretty_quantity(row.cases_ordered or 0)} / - % endif - ${h.pretty_quantity(row.units_ordered or 0)} - </td> - </tr> - % endif - <tr> - <td>received</td> - <td> - % if allow_cases: - ${h.pretty_quantity(row.cases_received or 0)} / - % endif - ${h.pretty_quantity(row.units_received or 0)} - </td> - </tr> - <tr> - <td>damaged</td> - <td> - % if allow_cases: - ${h.pretty_quantity(row.cases_damaged or 0)} / - % endif - ${h.pretty_quantity(row.units_damaged or 0)} - </td> - </tr> - % if allow_expired: - <tr> - <td>expired</td> - <td> - % if allow_cases: - ${h.pretty_quantity(row.cases_expired or 0)} / - % endif - ${h.pretty_quantity(row.units_expired or 0)} - </td> - </tr> - % endif - </tbody> -</table> - -% if request.session.peek_flash('receiving-warning'): - % for error in request.session.pop_flash('receiving-warning'): - <div class="receiving-warning">${error}</div> - % endfor -% endif - -% if not batch.executed and not batch.complete: - - ${h.form(request.current_route_url(), class_='receiving-update')} - ${h.csrf_token(request)} - ${h.hidden('row', value=row.uuid)} - ${h.hidden('cases')} - ${h.hidden('units')} - - ## only show quick-receive if we have an identifiable product - % if quick_receive and instance.product: - % if quick_receive_all: - <button type="button" class="quick-receive" data-quantity="${quick_receive_quantity}" data-uom="${quick_receive_uom}">${quick_receive_text}</button> - % elif allow_cases: - <button type="button" class="quick-receive" data-quantity="1" data-uom="CS">Receive 1 CS</button> - <div> - ## TODO: probably should make these optional / configurable - <button type="button" class="quick-receive ui-btn ui-btn-inline ui-corner-all" data-quantity="1" data-uom="EA">1 EA</button> - <button type="button" class="quick-receive ui-btn ui-btn-inline ui-corner-all" data-quantity="3" data-uom="EA">3 EA</button> - <button type="button" class="quick-receive ui-btn ui-btn-inline ui-corner-all" data-quantity="6" data-uom="EA">6 EA</button> - </div> - <br /> - % else: - <button type="button" class="quick-receive" data-quantity="1" data-uom="${unit_uom}">Receive 1 ${unit_uom}</button> - % endif - % endif - - ${keypad(unit_uom, uom, allow_cases=allow_cases)} - - <table> - <tbody> - <tr> - <td> - <fieldset data-role="controlgroup" data-type="horizontal" class="receiving-mode"> - ${h.radio('mode', value='received', label="received", checked=True)} - ${h.radio('mode', value='damaged', label="damaged")} - % if allow_expired: - ${h.radio('mode', value='expired', label="expired")} - % endif - </fieldset> - </td> - </tr> - <tr id="expiration-row" style="display: none;"> - <td> - <div style="padding:10px 20px;"> - <label for="expiration_date">Expiration Date</label> - <input name="expiration_date" type="date" value="" placeholder="YYYY-MM-DD" /> - </div> - </td> - </tr> - <tr> - <td> - <fieldset data-role="controlgroup" data-type="horizontal" class="receiving-actions"> - <button type="button" data-action="add" class="ui-btn-inline ui-corner-all">Add</button> - <button type="button" data-action="subtract" class="ui-btn-inline ui-corner-all">Subtract</button> - ## <button type="button" data-action="clear" class="ui-btn-inline ui-corner-all ui-state-disabled">Clear</button> - </fieldset> - </td> - </tr> - </tbody> - </table> - - ${h.hidden('quick_receive', value='false')} - ${h.end_form()} - - % if master.mobile_rows_deletable and master.row_deletable(row) and request.has_perm('{}.delete_row'.format(permission_prefix)): - ${h.form(url('mobile.{}.delete_row'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid), class_='receiving-update')} - ${h.csrf_token(request)} - ${h.submit('submit', "Delete this Row")} - ${h.end_form()} - % endif - -% endif diff --git a/tailbone/templates/themes/bobcat/base.mako b/tailbone/templates/themes/bobcat/base.mako deleted file mode 100644 index d67b390f..00000000 --- a/tailbone/templates/themes/bobcat/base.mako +++ /dev/null @@ -1,311 +0,0 @@ -## -*- coding: utf-8; -*- -<%namespace file="/grids/nav.mako" import="grid_index_nav" /> -<%namespace file="/feedback_dialog.mako" import="feedback_dialog" /> -<%namespace name="base_meta" file="/base_meta.mako" /> -<!DOCTYPE html> -<html lang="en"> - <head> - <meta http-equiv="content-type" content="text/html; charset=UTF-8" /> - <title>${base_meta.global_title()} » ${capture(self.title)|n}</title> - ${base_meta.favicon()} - ${self.header_core()} - - % if background_color: - <style type="text/css"> - body, .navbar, .footer { - background-color: ${background_color}; - } - </style> - % endif - - % if not request.rattail_config.production(): - <style type="text/css"> - body, .navbar, .footer { - background-image: url(${request.static_url('tailbone:static/img/testing.png')}); - } - </style> - % endif - - ${self.head_tags()} - </head> - - <body> - <header> - - <nav class="navbar" role="navigation" aria-label="main navigation"> - <div class="navbar-menu"> - <div class="navbar-start"> - - % for topitem in menus: - % if topitem.is_link: - ${h.link_to(topitem.title, topitem.url, target=topitem.target, class_='navbar-item')} - % else: - <div class="navbar-item has-dropdown is-hoverable"> - <a class="navbar-link">${topitem.title}</a> - <div class="navbar-dropdown"> - % for subitem in topitem.items: - % if subitem.is_sep: - <hr class="navbar-divider"> - % else: - ${h.link_to(subitem.title, subitem.url, class_='navbar-item', target=subitem.target)} - % endif - % endfor - </div> - </div> - % endif - % endfor - - </div><!-- navbar-start --> - <div class="navbar-end"> - - ## User Menu - % if request.user: - <div class="navbar-item has-dropdown is-hoverable"> - % if messaging_enabled: - <a class="navbar-link ${'root-user' if request.is_root else ''}">${request.user}${" ({})".format(inbox_count) if inbox_count else ''}</a> - % else: - <a class="navbar-link ${'root-user' if request.is_root else ''}">${request.user}</a> - % endif - <div class="navbar-dropdown"> - % if request.is_root: - ${h.link_to("Stop being root", url('stop_root'), class_='navbar-item root-user')} - % elif request.is_admin: - ${h.link_to("Become root", url('become_root'), class_='navbar-item root-user')} - % endif - % if messaging_enabled: - ${h.link_to("Messages{}".format(" ({})".format(inbox_count) if inbox_count else ''), url('messages.inbox'), class_='navbar-item')} - % endif - ${h.link_to("Change Password", url('change_password'), class_='navbar-item')} - ${h.link_to("Logout", url('logout'), class_='navbar-item')} - </div> - </div> - % else: - ${h.link_to("Login", url('login'), class_='navbar-item')} - % endif - - </div><!-- navbar-end --> - </div> - </nav> - - <nav class="level"> - <div class="level-left"> - - ## App Logo / Name - <div class="level-item"> - <a class="home" href="${url('home')}"> - <div id="header-logo">${base_meta.header_logo()}</div> - <span class="global-title">${base_meta.global_title()}</span> - </a> - </div> - - ## Current Context - <div id="current-context" class="level-item"> - % if master: - <span>»</span> - % if master.listing: - <span>${index_title}</span> - % else: - ${h.link_to(index_title, index_url)} - % if parent_url is not Undefined: - <span>»</span> - ${h.link_to(parent_title, parent_url)} - % elif instance_url is not Undefined: - <span>»</span> - ${h.link_to(instance_title, instance_url)} - % endif - % if master.viewing and grid_index: - ${grid_index_nav()} - % endif - % endif - % elif index_title: - <span>»</span> - <span>${index_title}</span> - % endif - </div> - - </div><!-- level-left --> - <div class="level-right"> - - ## Theme Picker - % if expose_theme_picker and request.has_perm('common.change_app_theme'): - <div class="level-item"> - ${h.form(url('change_theme'), method="post")} - ${h.csrf_token(request)} - Theme: - <div class="theme-picker"> - <div class="select"> - ${h.select('theme', theme, options=theme_picker_options, id='theme-picker')} - </div> - </div> - ${h.end_form()} - </div> - % endif - - ## Help Button - % if help_url is not Undefined and help_url: - <div class="level-item"> - ${h.link_to("Help", help_url, target='_blank', class_='button')} - </div> - % endif - - ## Feedback Button - <div class="level-item"> - <button type="button" class="button is-primary" id="feedback">Feedback</button> - </div> - - </div><!-- level-right --> - </nav><!-- level --> - </header> - - ## Page Title - <section id="content-title" class="hero is-primary"> - <div class="container"> - % if capture(self.content_title): - - % if show_prev_next is not Undefined and show_prev_next: - <div style="float: right;"> - % if prev_url: - ${h.link_to("« Older", prev_url, class_='button autodisable')} - % else: - ${h.link_to("« Older", '#', class_='button', disabled='disabled')} - % endif - % if next_url: - ${h.link_to("Newer »", next_url, class_='button autodisable')} - % else: - ${h.link_to("Newer »", '#', class_='button', disabled='disabled')} - % endif - </div> - % endif - - <h1 class="title">${self.content_title()}</h1> - % endif - </div> - </section> - - <div class="content-wrapper"> - - ## Page Body - <section id="page-body"> - - % if request.session.peek_flash('error'): - % for error in request.session.pop_flash('error'): - <div class="notification is-warning"> - <!-- <button class="delete"></button> --> - ${error} - </div> - % endfor - % endif - - % if request.session.peek_flash(): - % for msg in request.session.pop_flash(): - <div class="notification is-info"> - <!-- <button class="delete"></button> --> - ${msg} - </div> - % endfor - % endif - - ${self.body()} - </section> - - ## Feedback Dialog - ${feedback_dialog()} - - ## Footer - <footer class="footer"> - <div class="content"> - ${base_meta.footer()} - </div> - </footer> - - </div><!-- content-wrapper --> - - </body> -</html> - -<%def name="title()"></%def> - -<%def name="content_title()"> - ${self.title()} -</%def> - -<%def name="header_core()"> - - ${self.core_javascript()} - ${self.extra_javascript()} - ${self.core_styles()} - ${self.extra_styles()} - - ## TODO: should this be elsewhere / more customizable? - % if dform is not Undefined: - <% resources = dform.get_widget_resources() %> - % for path in resources['js']: - ${h.javascript_link(request.static_url(path))} - % endfor - % for path in resources['css']: - ${h.stylesheet_link(request.static_url(path))} - % endfor - % endif -</%def> - -<%def name="core_javascript()"> - ${self.jquery()} - ${h.javascript_link(request.static_url('tailbone:static/js/lib/jquery.loadmask.min.js'))} - ${h.javascript_link(request.static_url('tailbone:static/js/lib/jquery.ui.timepicker.js'))} - <script type="text/javascript"> - var session_timeout = ${request.get_session_timeout() or 'null'}; - var logout_url = '${request.route_url('logout')}'; - var noop_url = '${request.route_url('noop')}'; - % if expose_theme_picker and request.has_perm('common.change_app_theme'): - $(function() { - $('#theme-picker').change(function() { - $(this).parents('form:first').submit(); - }); - }); - % endif - </script> - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.js') + '?ver={}'.format(tailbone.__version__))} - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.feedback.js') + '?ver={}'.format(tailbone.__version__))} - ${h.javascript_link(request.static_url('tailbone:static/js/jquery.ui.tailbone.js') + '?ver={}'.format(tailbone.__version__))} -</%def> - -<%def name="jquery()"> - ${h.javascript_link('https://code.jquery.com/jquery-1.12.4.min.js')} - ${h.javascript_link('https://code.jquery.com/ui/{}/jquery-ui.min.js'.format(request.rattail_config.get('tailbone', 'jquery_ui.version', default='1.11.4')))} -</%def> - -<%def name="extra_javascript()"></%def> - -<%def name="core_styles()"> - - ${h.stylesheet_link('https://cdnjs.cloudflare.com/ajax/libs/bulma/0.7.2/css/bulma.min.css')} - - ${self.jquery_theme()} - ${h.stylesheet_link(request.static_url('tailbone:static/css/jquery.loadmask.css'))} - ${h.stylesheet_link(request.static_url('tailbone:static/css/jquery.ui.timepicker.css'))} - ${h.stylesheet_link(request.static_url('tailbone:static/css/jquery.ui.tailbone.css') + '?ver={}'.format(tailbone.__version__))} - - ${h.stylesheet_link(request.static_url('tailbone:static/themes/bobcat/css/base.css') + '?ver={}'.format(tailbone.__version__))} - ${h.stylesheet_link(request.static_url('tailbone:static/themes/bobcat/css/layout.css') + '?ver={}'.format(tailbone.__version__))} - ${h.stylesheet_link(request.static_url('tailbone:static/css/grids.css') + '?ver={}'.format(tailbone.__version__))} - ${h.stylesheet_link(request.static_url('tailbone:static/css/filters.css') + '?ver={}'.format(tailbone.__version__))} - ${h.stylesheet_link(request.static_url('tailbone:static/themes/bobcat/css/forms.css') + '?ver={}'.format(tailbone.__version__))} - ${h.stylesheet_link(request.static_url('tailbone:static/css/diffs.css') + '?ver={}'.format(tailbone.__version__))} -</%def> - -<%def name="jquery_theme()"> - ${h.stylesheet_link('https://code.jquery.com/ui/1.11.4/themes/dark-hive/jquery-ui.css')} -</%def> - -<%def name="extra_styles()"></%def> - -<%def name="head_tags()"></%def> - -<%def name="wtfield(form, name, **kwargs)"> - <div class="field-wrapper${' error' if form[name].errors else ''}"> - <label for="${name}">${form[name].label}</label> - <div class="field"> - ${form[name](**kwargs)} - </div> - </div> -</%def> diff --git a/tailbone/templates/themes/dodo/base.mako b/tailbone/templates/themes/dodo/base.mako deleted file mode 100644 index a5cfa3ec..00000000 --- a/tailbone/templates/themes/dodo/base.mako +++ /dev/null @@ -1,231 +0,0 @@ -## -*- coding: utf-8; -*- -## largely copied from https://github.com/dansup/bulma-templates/blob/master/templates/admin.html -## <%namespace file="/feedback_dialog.mako" import="feedback_dialog" /> -<%namespace name="base_meta" file="/base_meta.mako" /> -<!DOCTYPE html> -<html lang="en"> -<head> - <meta charset="utf-8"> - ## <meta http-equiv="content-type" content="text/html; charset=UTF-8" /> - <meta name="viewport" content="width=device-width, initial-scale=1"> - <title>${base_meta.global_title()} » ${capture(self.title)|n}</title> - - <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css"> - <link href="https://fonts.googleapis.com/css?family=Open+Sans:300,400,700" rel="stylesheet"> - <!-- Bulma Version 0.7.4--> - <link rel="stylesheet" href="https://unpkg.com/bulma@0.7.4/css/bulma.min.css" /> - ${h.stylesheet_link(request.static_url('tailbone:static/themes/dodo/css/admin.css') + '?ver={}'.format(tailbone.__version__))} - - ${h.stylesheet_link(request.static_url('tailbone:static/themes/dodo/css/base.css') + '?ver={}'.format(tailbone.__version__))} - - % if background_color: - <style type="text/css"> - html, body { - background-color: ${background_color}; - } - </style> - % endif - - % if not request.rattail_config.production(): - <style type="text/css"> - html, body, body > .navbar { - background-image: url(${request.static_url('tailbone:static/img/testing.png')}); - } - </style> - % endif - - ${h.javascript_link('https://code.jquery.com/jquery-1.12.4.min.js')} - <script type="text/javascript"> - var session_timeout = ${request.get_session_timeout() or 'null'}; - var logout_url = '${request.route_url('logout')}'; - var noop_url = '${request.route_url('noop')}'; - % if expose_theme_picker and request.has_perm('common.change_app_theme'): - $(function() { - $('#theme-picker').change(function() { - $(this).parents('form:first').submit(); - }); - }); - % endif - </script> - - -</head> - -<body> - - <!-- START NAV --> - <nav class="navbar is-white"> - <div class="container"> - <div class="navbar-brand"> - <a class="navbar-item brand-text" href="${url('home')}"> - ${base_meta.header_logo()} - ${base_meta.global_title()} - </a> - - <div class="navbar-burger burger" data-target="navMenu"> - <span></span> - <span></span> - <span></span> - </div> - </div> - <div id="navMenu" class="navbar-menu"> - <div class="navbar-start"> - - ## User Menu - % if request.user: - <div class="navbar-item has-dropdown is-hoverable"> - % if messaging_enabled: - <a class="navbar-link ${'root-user' if request.is_root else ''}">${request.user}${" ({})".format(inbox_count) if inbox_count else ''}</a> - % else: - <a class="navbar-link ${'root-user' if request.is_root else ''}">${request.user}</a> - % endif - <div class="navbar-dropdown"> - % if request.is_root: - ${h.link_to("Stop being root", url('stop_root'), class_='navbar-item root-user')} - % elif request.is_admin: - ${h.link_to("Become root", url('become_root'), class_='navbar-item root-user')} - % endif - % if messaging_enabled: - ${h.link_to("Messages{}".format(" ({})".format(inbox_count) if inbox_count else ''), url('messages.inbox'), class_='navbar-item')} - % endif - ${h.link_to("Change Password", url('change_password'), class_='navbar-item')} - ${h.link_to("Logout", url('logout'), class_='navbar-item')} - </div> - </div> - % else: - ${h.link_to("Login", url('login'), class_='navbar-item')} - % endif - - </div><!-- navbar-start --> - - <div class="navbar-end"> - - ## Theme Picker - % if expose_theme_picker and request.has_perm('common.change_app_theme'): - <div class="level-item"> - ${h.form(url('change_theme'), method="post")} - ${h.csrf_token(request)} - <div class="columns is-vcentered"> - <div class="column"> - Theme: - </div> - <div class="column theme-picker"> - <div class="select"> - ${h.select('theme', theme, options=theme_picker_options, id='theme-picker')} - </div> - </div> - </div> - ${h.end_form()} - </div> - % endif - - </div><!-- navbar-end --> - - </div><!-- navbar-menu --> - </div> - </nav> - <!-- END NAV --> - <div class="container"> - <div class="columns"> - <div class="column is-3 "> - <aside class="menu is-hidden-mobile"> - - % for topitem in menus: - % if topitem.is_link: - ${h.link_to(topitem.title, topitem.url, target=topitem.target, class_='navbar-item')} - % else: - <p class="menu-label">${topitem.title}</p> - <ul class="menu-list"> - % for subitem in topitem.items: - % if not subitem.is_sep: - <li>${h.link_to(subitem.title, subitem.url, target=subitem.target)}</li> - % endif - % endfor - </ul> - % endif - % endfor - - </aside> - </div> - <div class="column is-9"> - <nav class="breadcrumb" aria-label="breadcrumbs"> - <ul> - - ## Current Context - % if master: - % if master.listing: - <li>${index_title}</li> - % else: - <li>${h.link_to(index_title, index_url)}</li> - % if parent_url is not Undefined: - <li>${h.link_to(parent_title, parent_url)}</li> - % elif instance_url is not Undefined: - <li>${h.link_to(instance_title, instance_url)}</li> - % endif -## % if master.viewing and grid_index: -## ${grid_index_nav()} -## % endif - % endif - % elif index_title: - <li>${index_title}</li> - % endif - - % if capture(self.content_title): - -## % if show_prev_next is not Undefined and show_prev_next: -## <div style="float: right;"> -## % if prev_url: -## ${h.link_to("« Older", prev_url, class_='button autodisable')} -## % else: -## ${h.link_to("« Older", '#', class_='button', disabled='disabled')} -## % endif -## % if next_url: -## ${h.link_to("Newer »", next_url, class_='button autodisable')} -## % else: -## ${h.link_to("Newer »", '#', class_='button', disabled='disabled')} -## % endif -## </div> -## % endif - - <li class="is-active"><a href="#" aria-current="page">${self.content_title()}</a></li> - % endif - - </ul> - </nav> - <section id="page-body"> - - % if request.session.peek_flash('error'): - % for error in request.session.pop_flash('error'): - <div class="notification is-warning"> - <!-- <button class="delete"></button> --> - ${error} - </div> - % endfor - % endif - - % if request.session.peek_flash(): - % for msg in request.session.pop_flash(): - <div class="notification is-info"> - <!-- <button class="delete"></button> --> - ${msg} - </div> - % endfor - % endif - - ${self.body()} - - </section> - - </div> - </div> - </div> - ${h.javascript_link(request.static_url('tailbone:static/themes/dodo/js/bulma.js'), async='true')} -</body> - -</html> - -<%def name="title()"></%def> - -<%def name="content_title()"> - ${self.title()} -</%def> diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index 8bee2119..b3e19fd8 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -136,7 +136,6 @@ ${h.stylesheet_link(request.static_url('tailbone:static/themes/falafel/css/grids.rowstatus.css') + '?ver={}'.format(tailbone.__version__))} ## ${h.stylesheet_link(request.static_url('tailbone:static/css/filters.css') + '?ver={}'.format(tailbone.__version__))} ${h.stylesheet_link(request.static_url('tailbone:static/themes/falafel/css/filters.css') + '?ver={}'.format(tailbone.__version__))} - ${h.stylesheet_link(request.static_url('tailbone:static/themes/bobcat/css/forms.css') + '?ver={}'.format(tailbone.__version__))} ${h.stylesheet_link(request.static_url('tailbone:static/themes/falafel/css/forms.css') + '?ver={}'.format(tailbone.__version__))} ${h.stylesheet_link(request.static_url('tailbone:static/css/diffs.css') + '?ver={}'.format(tailbone.__version__))} diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index df4ecffa..51b27f14 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -87,12 +87,11 @@ class AuthenticationView(View): self.request.session.flash(msg, allow_duplicate=False) return self.redirect(next_url) - def login(self, mobile=False): + def login(self, **kwargs): """ The login view, responsible for displaying and handling the login form. """ - home = 'mobile.home' if mobile else 'home' - referrer = self.request.get_referrer(default=self.request.route_url(home)) + referrer = self.request.get_referrer(default=self.request.route_url('home')) # redirect if already logged in if self.request.user: @@ -138,10 +137,7 @@ class AuthenticationView(View): def authenticate_user(self, username, password): return authenticate_user(Session(), username, password) - def mobile_login(self): - return self.login(mobile=True) - - def logout(self, mobile=False): + def logout(self, **kwargs): """ View responsible for logging out the current user. @@ -153,17 +149,12 @@ class AuthenticationView(View): # redirect to home page after login, if so configured if self.rattail_config.getbool('tailbone', 'home_after_logout', default=False): - home = 'mobile.home' if mobile else 'home' - return self.redirect(self.request.route_url(home), headers=headers) + return self.redirect(self.request.route_url('home'), headers=headers) # otherwise redirect to referrer, with 'login' page as fallback - login = 'mobile.login' if mobile else 'login' - referrer = self.request.get_referrer(default=self.request.route_url(login)) + referrer = self.request.get_referrer(default=self.request.route_url('login')) return self.redirect(referrer, headers=headers) - def mobile_logout(self): - return self.logout(mobile=True) - def noop(self): """ View to serve as "no-op" / ping action to reset current user's session timer @@ -216,7 +207,6 @@ class AuthenticationView(View): @classmethod def defaults(cls, config): rattail_config = config.registry.settings.get('rattail_config') - legacy_mobile = cls.legacy_mobile_enabled(rattail_config) # forbidden config.add_forbidden_view(cls, attr='forbidden') @@ -224,16 +214,10 @@ class AuthenticationView(View): # login config.add_route('login', '/login') config.add_view(cls, attr='login', route_name='login', renderer='/login.mako') - if legacy_mobile: - config.add_route('mobile.login', '/mobile/login') - config.add_view(cls, attr='mobile_login', route_name='mobile.login', renderer='/mobile/login.mako') # logout config.add_route('logout', '/logout') config.add_view(cls, attr='logout', route_name='logout') - if legacy_mobile: - config.add_route('mobile.logout', '/mobile/logout') - config.add_view(cls, attr='mobile_logout', route_name='mobile.logout') # no-op config.add_route('noop', '/noop') diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 2f3a2f25..d48a7913 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -83,9 +83,6 @@ class BatchMasterView(MasterView): executable = True results_refreshable = False results_executable = False - supports_mobile = True - mobile_filterable = True - mobile_rows_viewable = True has_worksheet = False has_worksheet_file = False @@ -175,12 +172,6 @@ class BatchMasterView(MasterView): kwargs['execute_title'] = self.get_execute_title(batch) kwargs['execute_enabled'] = self.instance_executable(batch) - if kwargs['mobile']: - if self.mobile_rows_creatable: - kwargs.setdefault('add_item_title', "Add Item") - if self.mobile_rows_quickable: - kwargs.setdefault('quick_entry_placeholder', "Enter {}".format( - self.rattail_config.product_key_title())) if kwargs['execute_enabled']: url = self.get_action_url('execute', batch) kwargs['execute_form'] = self.make_execute_form(batch, action_url=url) @@ -337,18 +328,6 @@ class BatchMasterView(MasterView): return "{} {}".format(batch.id_str, batch.description) return batch.id_str - def get_mobile_data(self, session=None): - return super(BatchMasterView, self).get_mobile_data(session=session)\ - .order_by(self.model_class.id.desc()) - - def make_mobile_filters(self): - """ - Returns a set of filters for the mobile grid. - """ - filters = grids.filters.GridFilterSet() - filters['status'] = MobileBatchStatusFilter(self.model_class, 'status', default_value='pending') - return filters - def configure_form(self, f): super(BatchMasterView, self).configure_form(f) @@ -488,28 +467,6 @@ class BatchMasterView(MasterView): url = self.request.route_url('users.view', uuid=user.uuid) return tags.link_to(title, url) - def configure_mobile_form(self, f): - super(BatchMasterView, self).configure_mobile_form(f) - batch = f.model_instance - - if self.creating: - f.remove_fields('id', - 'rowcount', - 'created', - 'created_by', - 'cognized', - 'cognized_by', - 'executed', - 'executed_by', - 'purge') - - else: # not creating - if not batch.executed: - f.remove_fields('executed', - 'executed_by') - if not batch.complete: - f.remove_field('complete') - def save_create_form(self, form): uploads = self.normalize_uploads(form) self.before_create(form) @@ -547,28 +504,7 @@ class BatchMasterView(MasterView): os.remove(upload['temp_path']) os.rmdir(upload['tempdir']) - def save_mobile_create_form(self, form): - self.before_create(form) - session = self.Session() - with session.no_autoflush: - - # transfer form data to batch instance - batch = self.objectify(form, self.form_deserialized) - - # current user is batch creator - batch.created_by = self.request.user - - # TODO: is this still necessary with colander? - # destroy initial batch and re-make using handler - kwargs = self.get_batch_kwargs(batch) - if batch in session: - session.expunge(batch) - batch = self.handler.make_batch(session, **kwargs) - - session.flush() - return batch - - def get_batch_kwargs(self, batch, mobile=False): + def get_batch_kwargs(self, batch, **kwargs): """ Return a kwargs dict for use with ``self.handler.make_batch()``, using the given batch as a template. @@ -599,13 +535,13 @@ class BatchMasterView(MasterView): """ return True - def redirect_after_create(self, batch, mobile=False): + def redirect_after_create(self, batch, **kwargs): if self.handler.should_populate(batch): - return self.redirect(self.get_action_url('prefill', batch, mobile=mobile)) + return self.redirect(self.get_action_url('prefill', batch)) elif self.refresh_after_create: - return self.redirect(self.get_action_url('refresh', batch, mobile=mobile)) + return self.redirect(self.get_action_url('refresh', batch)) else: - return self.redirect(self.get_action_url('view', batch, mobile=mobile)) + return self.redirect(self.get_action_url('view', batch)) def template_kwargs_edit(self, **kwargs): batch = kwargs['instance'] @@ -631,16 +567,6 @@ class BatchMasterView(MasterView): def mark_batch_incomplete(self, batch): self.handler.mark_incomplete(batch) - def mobile_mark_complete(self): - batch = self.get_instance() - self.mark_batch_complete(batch) - return self.redirect(self.get_index_url(mobile=True)) - - def mobile_mark_pending(self): - batch = self.get_instance() - self.mark_batch_incomplete(batch) - return self.redirect(self.get_action_url('view', batch, mobile=True)) - def rows_creatable_for(self, batch): """ Only allow creating new rows on a batch if it hasn't yet been executed @@ -703,16 +629,6 @@ class BatchMasterView(MasterView): return self.redirect(self.get_action_url('view', batch)) return super(BatchMasterView, self).create_row() - def mobile_create_row(self): - """ - Only allow creating a new row if the batch hasn't yet been executed. - """ - batch = self.get_instance() - if batch.executed: - self.request.session.flash("You cannot add new rows to a batch which has been executed") - return self.redirect(self.get_action_url('view', batch, mobile=True)) - return super(BatchMasterView, self).mobile_create_row() - def save_create_row_form(self, form): batch = self.get_instance() row = self.objectify(form, self.form_deserialized) @@ -739,19 +655,6 @@ class BatchMasterView(MasterView): # status text f.set_readonly('status_text') - def configure_mobile_row_form(self, f): - super(BatchMasterView, self).configure_mobile_row_form(f) - - # sequence - f.set_readonly('sequence') - - # status_code - if self.model_row_class: - f.set_enum('status_code', self.model_row_class.STATUS) - f.set_renderer('status_code', self.render_row_status) - f.set_readonly('status_code') - f.set_label('status_code', "Status") - def make_default_row_grid_tools(self, batch): if self.rows_creatable and not batch.executed and not batch.complete: permission_prefix = self.get_permission_prefix() @@ -803,9 +706,6 @@ class BatchMasterView(MasterView): def make_row_grid_tools(self, batch): return (self.make_default_row_grid_tools(batch) or '') + (self.make_batch_row_grid_tools(batch) or '') - def sort_mobile_row_data(self, query): - return query.order_by(self.model_row_class.sequence) - def redirect_after_edit(self, batch): """ If refresh flag is set, do that; otherwise go (back) to view/edit page. @@ -821,12 +721,7 @@ class BatchMasterView(MasterView): self.handler.do_delete(batch) super(BatchMasterView, self).delete_instance(batch) - def get_fallback_templates(self, template, mobile=False): - if mobile: - return [ - '/mobile/batch/{}.mako'.format(template), - '/mobile/master/{}.mako'.format(template), - ] + def get_fallback_templates(self, template, **kwargs): return [ '/batch/{}.mako'.format(template), '/master/{}.mako'.format(template), @@ -1374,49 +1269,6 @@ class BatchMasterView(MasterView): self.request.session.flash("Invalid request: {}".format(form.make_deform_form().error), 'error') return self.redirect(self.get_action_url('view', batch)) - def mobile_execute(self): - """ - Mobile view which can prompt user for execution options if applicable, - and/or execute a batch. For now this is done in a "blocking" fashion, - i.e. no progress bar. - """ - batch = self.get_instance() - model_title = self.get_model_title() - instance_title = self.get_instance_title(batch) - view_url = self.get_action_url('view', batch, mobile=True) - self.executing = True - form = self.make_execute_form(batch) - if form.validate(newstyle=True): - kwargs = dict(form.validated) - - # cache options to use as defaults next time - for key, value in form.validated.items(): - self.request.session['batch.{}.execute_option.{}'.format(batch.batch_key, key)] = value - - try: - result = self.handler.execute(batch, user=self.request.user, **kwargs) - except Exception as err: - log.exception("failed to execute %s %s", model_title, batch.id_str) - self.request.session.flash(self.execute_error_message(err), 'error') - else: - if result: - batch.executed = datetime.datetime.utcnow() - batch.executed_by = self.request.user - self.request.session.flash("{} was executed: {}".format(model_title, instance_title)) - else: - log.error("not sure why, but failed to execute %s %s: %s", model_title, batch.id_str, batch) - self.request.session.flash("Failed to execute {}: {}".format(model_title, err), 'error') - return self.redirect(view_url) - - form.mobile = True - form.submit_label = "Execute" - form.cancel_url = view_url - return self.render_to_response('execute', { - 'form': form, - 'instance_title': instance_title, - 'instance_url': view_url, - }, mobile=True) - def execute_error_message(self, error): return "Batch execution failed: {}".format(simple_error(error)) @@ -1576,7 +1428,6 @@ class BatchMasterView(MasterView): permission_prefix = cls.get_permission_prefix() model_title = cls.get_model_title() model_title_plural = cls.get_model_title_plural() - legacy_mobile = cls.legacy_mobile_enabled(rattail_config) # TODO: currently must do this here (in addition to `_defaults()` or # else the perm group label will not display correctly... @@ -1635,18 +1486,6 @@ class BatchMasterView(MasterView): config.add_view(cls, attr='toggle_complete', route_name='{}.toggle_complete'.format(route_prefix), permission='{}.edit'.format(permission_prefix)) - # mobile mark complete - if legacy_mobile: - config.add_route('mobile.{}.mark_complete'.format(route_prefix), '/mobile{}/{{{}}}/mark-complete'.format(url_prefix, model_key)) - config.add_view(cls, attr='mobile_mark_complete', route_name='mobile.{}.mark_complete'.format(route_prefix), - permission='{}.edit'.format(permission_prefix)) - - # mobile mark pending - if legacy_mobile: - config.add_route('mobile.{}.mark_pending'.format(route_prefix), '/mobile{}/{{{}}}/mark-pending'.format(url_prefix, model_key)) - config.add_view(cls, attr='mobile_mark_pending', route_name='mobile.{}.mark_pending'.format(route_prefix), - permission='{}.edit'.format(permission_prefix)) - # refresh multiple batches (results) if cls.results_refreshable: config.add_route('{}.refresh_results'.format(route_prefix), '{}/refresh-results'.format(url_prefix), @@ -1709,33 +1548,3 @@ class UploadWorksheet(colander.Schema): class ToggleComplete(colander.MappingSchema): complete = colander.SchemaNode(colander.Boolean()) - - -class MobileBatchStatusFilter(grids.filters.MobileFilter): - - value_choices = ['pending', 'complete', 'executed', 'all'] - - def __init__(self, model_class, key, **kwargs): - self.model_class = model_class - super(MobileBatchStatusFilter, self).__init__(key, **kwargs) - - def filter_equal(self, query, value): - - if value == 'pending': - return query.filter(self.model_class.executed == None)\ - .filter(sa.or_( - self.model_class.complete == None, - self.model_class.complete == False)) - - if value == 'complete': - return query.filter(self.model_class.executed == None)\ - .filter(self.model_class.complete == True) - - if value == 'executed': - return query.filter(self.model_class.executed != None) - - return query - - def iter_choices(self): - for value in self.value_choices: - yield value, prettify(value) diff --git a/tailbone/views/batch/inventory.py b/tailbone/views/batch/inventory.py index 26123707..adf91561 100644 --- a/tailbone/views/batch/inventory.py +++ b/tailbone/views/batch/inventory.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -35,7 +35,6 @@ import six from rattail import pod from rattail.db import model from rattail.db.util import make_full_description -from rattail.time import localtime from rattail.gpc import GPC from rattail.util import pretty_quantity, OrderedDict @@ -63,8 +62,6 @@ class InventoryBatchView(BatchMasterView): index_title = "Inventory" rows_creatable = True bulk_deletable = True - mobile_creatable = True - mobile_rows_creatable = True # set to True for the UI to "prefer" case amounts, as opposed to unit prefer_cases = False @@ -101,15 +98,6 @@ class InventoryBatchView(BatchMasterView): 'executed_by', ] - mobile_form_fields = [ - 'mode', - 'reason_code', - 'rowcount', - 'complete', - 'executed', - 'executed_by', - ] - model_row_class = model.InventoryBatchRow rows_editable = True @@ -160,13 +148,6 @@ class InventoryBatchView(BatchMasterView): # total_cost g.set_type('total_cost', 'currency') - def render_mobile_listitem(self, batch, i): - return "({}) {} rows - {}, {}".format( - batch.id_str, - "?" if batch.rowcount is None else batch.rowcount, - batch.created_by, - localtime(self.request.rattail_config, batch.created, from_utc=True).strftime('%Y-%m-%d')) - def mutable_batch(self, batch): return not batch.executed and not batch.complete and batch.mode != self.enum.INVENTORY_MODE_ZERO_ALL @@ -397,56 +378,6 @@ class InventoryBatchView(BatchMasterView): data['image_url'] = pod.get_image_url(self.rattail_config, product.upc) return data - def configure_mobile_form(self, f): - super(InventoryBatchView, self).configure_mobile_form(f) - batch = f.model_instance - - # mode - modes = self.get_available_modes() - f.set_enum('mode', modes) - mode_values = [(k, v) for k, v in sorted(modes.items())] - f.set_widget('mode', forms.widgets.PlainSelectWidget(values=mode_values)) - - # complete - if self.creating or batch.executed or not batch.complete: - f.remove_field('complete') - - # rowcount - if self.viewing and not batch.executed and not batch.complete: - f.remove_field('rowcount') - - # TODO: this view can create new rows, with only a GET query. that should - # probably be changed to require POST; for now we just require the "create - # batch row" perm and call it good.. - def mobile_row_from_upc(self): - """ - Locate and/or create a row within the batch, according to the given - product UPC, then redirect to the row view page. - """ - batch = self.get_instance() - row = None - raw_entry = self.request.GET.get('upc', '') - entry = raw_entry.strip() - entry = re.sub(r'\D', '', entry) - if entry: - - if len(entry) <= 14: - row = self.add_row_for_upc(batch, entry, warn_if_present=True) - if not row: - self.request.session.flash("Product not found: {}".format(entry), 'error') - return self.redirect(self.get_action_url('view', batch, mobile=True)) - - else: - self.request.session.flash("UPC has too many digits ({}): {}".format(len(entry), entry), 'error') - return self.redirect(self.get_action_url('view', batch, mobile=True)) - - else: - self.request.session.flash("Product not found: {}".format(raw_entry), 'error') - return self.redirect(self.get_action_url('view', batch, mobile=True)) - - self.Session.flush() - return self.redirect(self.mobile_row_route_url('view', uuid=row.batch_uuid, row_uuid=row.uuid)) - def add_row_for_upc(self, batch, entry, warn_if_present=False): """ Add a row to the batch for the given UPC, if applicable. @@ -467,76 +398,13 @@ class InventoryBatchView(BatchMasterView): kwargs['product_image_url'] = pod.get_image_url(self.rattail_config, row.upc) return kwargs - def get_batch_kwargs(self, batch, mobile=False): - kwargs = super(InventoryBatchView, self).get_batch_kwargs(batch, mobile=False) + def get_batch_kwargs(self, batch, **kwargs): + kwargs = super(InventoryBatchView, self).get_batch_kwargs(batch, **kwargs) kwargs['mode'] = batch.mode kwargs['complete'] = False kwargs['reason_code'] = batch.reason_code return kwargs - def get_mobile_row_data(self, batch): - # we want newest on top, for inventory batch rows - return self.get_row_data(batch)\ - .order_by(self.model_row_class.sequence.desc()) - - # TODO: ugh, the hackiness. needs a refactor fo sho - def mobile_view_row(self): - """ - Mobile view for inventory batch rows. Note that this also handles - updating a row...ugh. - """ - self.viewing = True - row = self.get_row_instance() - batch = self.get_parent(row) - form = self.make_mobile_row_form(row) - - allow_cases = self.allow_cases(batch) - unit_uom = 'LB' if row.product and row.product.weighed else 'EA' - if row.cases and allow_cases: - uom = 'CS' - elif row.units: - uom = unit_uom - elif row.case_quantity and allow_cases and self.prefer_cases: - uom = 'CS' - else: - uom = unit_uom - - context = { - 'row': row, - 'batch': batch, - 'instance': row, - 'instance_title': self.get_row_instance_title(row), - 'parent_model_title': self.get_model_title(), - 'parent_title': self.get_instance_title(batch), - 'parent_url': self.get_action_url('view', batch, mobile=True), - 'product_image_url': pod.get_image_url(self.rattail_config, row.upc), - 'form': form, - 'allow_cases': allow_cases, - 'unit_uom': unit_uom, - 'uom': uom, - } - - if self.request.has_perm('{}.edit_row'.format(self.get_permission_prefix())): - schema = InventoryForm().bind(session=self.Session()) - update_form = forms.Form(schema=schema, request=self.request) - if update_form.validate(newstyle=True): - row = self.Session.query(model.InventoryBatchRow).get(update_form.validated['row']) - cases = update_form.validated['cases'] - units = update_form.validated['units'] - if cases is not colander.null: - row.cases = cases - row.units = None - elif units is not colander.null: - row.cases = None - row.units = units - else: - raise NotImplementedError - self.handler.refresh_row(row) - route_prefix = self.get_route_prefix() - return self.redirect(self.request.route_url('mobile.{}.view'.format(route_prefix), uuid=batch.uuid)) - - return self.render_to_response('view_row', context, mobile=True) - def get_row_instance_title(self, row): if row.upc: return row.upc.pretty() @@ -569,12 +437,6 @@ class InventoryBatchView(BatchMasterView): if row.status_code == row.STATUS_PRODUCT_NOT_FOUND: return 'warning' - def render_mobile_row_listitem(self, row, i): - description = row.product.full_description if row.product else row.description - unit_uom = 'LB' if row.product and row.product.weighed else 'EA' - qty = "{} {}".format(pretty_quantity(row.cases or row.units), 'CS' if row.cases else unit_uom) - return "({}) {} - {}".format(row.upc.pretty(), description, qty) - def configure_row_form(self, f): super(InventoryBatchView, self).configure_row_form(f) row = f.model_instance @@ -633,7 +495,6 @@ class InventoryBatchView(BatchMasterView): route_prefix = cls.get_route_prefix() url_prefix = cls.get_url_prefix() permission_prefix = cls.get_permission_prefix() - legacy_mobile = cls.legacy_mobile_enabled(rattail_config) # we need batch handler to determine available permissions factory = cls.get_handler_factory(rattail_config) @@ -654,38 +515,6 @@ class InventoryBatchView(BatchMasterView): config.add_view(cls, attr='desktop_lookup', route_name='{}.desktop_lookup'.format(route_prefix), renderer='json', permission='{}.create_row'.format(permission_prefix)) - # mobile - make new row from UPC - if legacy_mobile: - config.add_route('mobile.{}.row_from_upc'.format(route_prefix), '/mobile{}/{{{}}}/row-from-upc'.format(url_prefix, model_key)) - config.add_view(cls, attr='mobile_row_from_upc', route_name='mobile.{}.row_from_upc'.format(route_prefix), - permission='{}.create_row'.format(permission_prefix)) - - -# TODO: this is a stopgap measure to fix an obvious bug, which exists when the -# session is not provided by the view at runtime (i.e. when it was instead -# being provided by the type instance, which was created upon app startup). -@colander.deferred -def valid_inventory_batch_row(node, kw): - session = kw['session'] - def validate(node, value): - row = session.query(model.InventoryBatchRow).get(value) - if not row: - raise colander.Invalid(node, "Batch row not found") - if row.batch.executed: - raise colander.Invalid(node, "Batch has already been executed") - return row.uuid - return validate - - -class InventoryForm(colander.MappingSchema): - - row = colander.SchemaNode(colander.String(), - validator=valid_inventory_batch_row) - - cases = colander.SchemaNode(colander.Decimal(), missing=colander.null) - - units = colander.SchemaNode(colander.Decimal(), missing=colander.null) - # TODO: this is a stopgap measure to fix an obvious bug, which exists when the # session is not provided by the view at runtime (i.e. when it was instead diff --git a/tailbone/views/batch/pricing.py b/tailbone/views/batch/pricing.py index 88063d00..6a8c56f2 100644 --- a/tailbone/views/batch/pricing.py +++ b/tailbone/views/batch/pricing.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -171,8 +171,8 @@ class PricingBatchView(BatchMasterView): if self.request.POST.get('auto_generate_from_srp_breach') == 'true': f.set_required('input_filename', False) - def get_batch_kwargs(self, batch, mobile=False): - kwargs = super(PricingBatchView, self).get_batch_kwargs(batch, mobile=mobile) + def get_batch_kwargs(self, batch, **kwargs): + kwargs = super(PricingBatchView, self).get_batch_kwargs(batch, **kwargs) kwargs['min_diff_threshold'] = batch.min_diff_threshold kwargs['min_diff_percent'] = batch.min_diff_percent kwargs['calculate_for_manual'] = batch.calculate_for_manual diff --git a/tailbone/views/common.py b/tailbone/views/common.py index 05ac8e5a..37b2c4a4 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -55,11 +55,11 @@ class CommonView(View): project_version = tailbone.__version__ robots_txt_path = resource_path('tailbone.static:robots.txt') - def home(self, mobile=False): + def home(self, **kwargs): """ Home page view. """ - if not mobile and not self.request.user: + if not self.request.user: if self.rattail_config.getbool('tailbone', 'login_is_home', default=True): raise self.redirect(self.request.route_url('login')) @@ -96,12 +96,6 @@ class CommonView(View): response.content_type = b'text/plain' return response - def mobile_home(self): - """ - Home page view for mobile. - """ - return self.home(mobile=True) - def exception(self): """ Generic exception view @@ -179,12 +173,6 @@ class CommonView(View): return {'ok': True} return {'error': "Form did not validate!"} - def mobile_feedback(self): - """ - Generic view to handle the user feedback form on mobile. - """ - return self.feedback() - def consume_batch_id(self): """ Consume next batch ID from the PG sequence, and display via flash message. @@ -207,7 +195,6 @@ class CommonView(View): @classmethod def _defaults(cls, config): rattail_config = config.registry.settings.get('rattail_config') - legacy_mobile = cls.legacy_mobile_enabled(rattail_config) # auto-correct URLs which require trailing slash config.add_notfound_view(cls, attr='notfound', append_slash=True) @@ -222,9 +209,6 @@ class CommonView(View): # home config.add_route('home', '/') config.add_view(cls, attr='home', route_name='home', renderer='/home.mako') - if legacy_mobile: - config.add_route('mobile.home', '/mobile/') - config.add_view(cls, attr='mobile_home', route_name='mobile.home', renderer='/mobile/home.mako') # robots.txt config.add_route('robots.txt', '/robots.txt') @@ -233,9 +217,6 @@ class CommonView(View): # about config.add_route('about', '/about') config.add_view(cls, attr='about', route_name='about', renderer='/about.mako') - if legacy_mobile: - config.add_route('mobile.about', '/mobile/about') - config.add_view(cls, attr='about', route_name='mobile.about', renderer='/mobile/about.mako') # change db engine config.add_tailbone_permission('common', 'common.change_db_engine', @@ -255,10 +236,6 @@ class CommonView(View): config.add_route('feedback', '/feedback', request_method='POST') config.add_view(cls, attr='feedback', route_name='feedback', renderer='json', permission='common.feedback') - if legacy_mobile: - config.add_route('mobile.feedback', '/mobile/feedback', request_method='POST') - config.add_view(cls, attr='mobile_feedback', route_name='mobile.feedback', - renderer='json', permission='common.feedback') # consume batch ID config.add_tailbone_permission('common', 'common.consume_batch_id', diff --git a/tailbone/views/core.py b/tailbone/views/core.py index 69f9974c..bcb5b01b 100644 --- a/tailbone/views/core.py +++ b/tailbone/views/core.py @@ -42,7 +42,7 @@ from tailbone.db import Session from tailbone.auth import logout_user from tailbone.progress import SessionProgress from tailbone.util import should_use_buefy -from tailbone.config import legacy_mobile_enabled, protected_usernames +from tailbone.config import protected_usernames class View(object): @@ -101,14 +101,6 @@ class View(object): """ return should_use_buefy(self.request) - @classmethod - def legacy_mobile_enabled(cls, rattail_config): - """ - Returns the boolean setting indicating whether the old / "legacy" - (jQuery) mobile app/site should be exposed. - """ - return legacy_mobile_enabled(rattail_config) - def late_login_user(self): """ Returns the :class:`rattail:rattail.db.model.User` instance diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index 3d8e78d1..723beb5a 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -50,7 +50,6 @@ class CustomerView(MasterView): model_class = model.Customer is_contact = True has_versions = True - supports_mobile = True people_detachable = True touchable = True @@ -95,20 +94,6 @@ class CustomerView(MasterView): 'members', ] - mobile_form_fields = [ - 'id', - 'name', - 'default_phone', - 'default_email', - 'default_address', - 'email_preference', - 'wholesale', - 'active_in_pos', - 'active_in_pos_sticky', - 'people', - 'groups', - ] - def configure_grid(self, g): super(CustomerView, self).configure_grid(g) @@ -154,10 +139,6 @@ class CustomerView(MasterView): g.set_link('person') g.set_link('email') - def get_mobile_data(self, session=None): - # TODO: hacky! - return self.get_data(session=session).order_by(model.Customer.name) - def get_instance(self): try: instance = super(CustomerView, self).get_instance() @@ -303,8 +284,7 @@ class CustomerView(MasterView): items = [] for person in people: text = six.text_type(person) - route = '{}people.view'.format('mobile.' if self.mobile else '') - url = self.request.route_url(route, uuid=person.uuid) + url = self.request.route_url('people.view', uuid=person.uuid) link = tags.link_to(text, url) items.append(HTML.tag('li', c=[link])) return HTML.tag('ul', c=items) diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py index d3b9b3b8..f4434447 100644 --- a/tailbone/views/datasync.py +++ b/tailbone/views/datasync.py @@ -89,27 +89,17 @@ class DataSyncChangeView(MasterView): 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'))) - def mobile_index(self): - return {} - @classmethod def defaults(cls, config): rattail_config = config.registry.settings.get('rattail_config') - legacy_mobile = cls.legacy_mobile_enabled(rattail_config) # fix permission group title config.add_tailbone_permission_group('datasync', label="DataSync") # restart datasync config.add_tailbone_permission('datasync', 'datasync.restart', label="Restart DataSync Daemon") - # desktop config.add_route('datasync.restart', '/datasync/restart') config.add_view(cls, attr='restart', route_name='datasync.restart', permission='datasync.restart') - # mobile - if legacy_mobile: - config.add_route('datasync.mobile', '/mobile/datasync/') - config.add_view(cls, attr='mobile_index', route_name='datasync.mobile', - permission='datasync.restart', renderer='/mobile/datasync.mako') cls._defaults(config) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index be67eaa5..68130a2f 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -115,14 +115,6 @@ class MasterView(View): # set to True to declare model as "contact" is_contact = False - supports_mobile = False - mobile_creatable = False - mobile_editable = False - mobile_pageable = True - mobile_filterable = False - mobile_executable = False - - mobile = False listing = False creating = False creates_multiple = False @@ -170,14 +162,6 @@ class MasterView(View): rows_downloadable_csv = False rows_downloadable_xlsx = False - mobile_rows_creatable = False - mobile_rows_creatable_via_browse = False - mobile_rows_quickable = False - mobile_rows_filterable = False - mobile_rows_viewable = False - mobile_rows_editable = False - mobile_rows_deletable = False - row_labels = {} @property @@ -236,24 +220,6 @@ class MasterView(View): """ return getattr(cls, 'version_grid_factory', grids.Grid) - @classmethod - def get_mobile_grid_factory(cls): - """ - Must return a callable to be used when creating new mobile grid - instances. Instead of overriding this, you can set - :attr:`mobile_grid_factory`. Default factory is :class:`MobileGrid`. - """ - return getattr(cls, 'mobile_grid_factory', grids.MobileGrid) - - @classmethod - def get_mobile_row_grid_factory(cls): - """ - Must return a callable to be used when creating new mobile row grid - instances. Instead of overriding this, you can set - :attr:`mobile_row_grid_factory`. Default factory is :class:`MobileGrid`. - """ - return getattr(cls, 'mobile_row_grid_factory', grids.MobileGrid) - def set_labels(self, obj): labels = self.collect_labels() for key, label in six.iteritems(labels): @@ -624,163 +590,6 @@ class MasterView(View): def render_version_comment(self, transaction, column): return transaction.meta.get('comment', "") - def mobile_index(self): - """ - Mobile "home" page for the data model - """ - self.mobile = True - self.listing = True - grid = self.make_mobile_grid() - return self.render_to_response('index', {'grid': grid}, mobile=True) - - @classmethod - def get_mobile_grid_key(cls): - """ - Must return a unique "config key" for the mobile grid, for sort/filter - purposes etc. (It need only be unique among *mobile* grids.) Instead - of overriding this, you can set :attr:`mobile_grid_key`. Default is - the value returned by :meth:`get_route_prefix()`. - """ - if hasattr(cls, 'mobile_grid_key'): - return cls.mobile_grid_key - return 'mobile.{}'.format(cls.get_route_prefix()) - - def make_mobile_grid(self, factory=None, key=None, data=None, columns=None, **kwargs): - """ - Creates a new mobile grid instance - """ - if factory is None: - factory = self.get_mobile_grid_factory() - if key is None: - key = self.get_mobile_grid_key() - if data is None: - data = self.get_mobile_data(session=kwargs.get('session')) - if columns is None: - columns = self.get_mobile_grid_columns() - - kwargs.setdefault('request', self.request) - kwargs.setdefault('mobile', True) - kwargs = self.make_mobile_grid_kwargs(**kwargs) - grid = factory(key, data, columns, **kwargs) - self.configure_mobile_grid(grid) - grid.load_settings() - return grid - - def get_mobile_grid_columns(self): - if hasattr(self, 'mobile_grid_columns'): - return self.mobile_grid_columns - # TODO - return ['listitem'] - - def get_mobile_data(self, session=None): - """ - Must return the "raw" / full data set for the mobile grid. This data - should *not* yet be sorted or filtered in any way; that happens later. - Default is the value returned by :meth:`get_data()`, in which case all - records visible in the traditional view, are visible in mobile too. - """ - return self.get_data(session=session) - - def make_mobile_grid_kwargs(self, **kwargs): - """ - Must return a dictionary of kwargs to be passed to the factory when - creating new mobile grid instances. - """ - defaults = { - 'model_class': getattr(self, 'model_class', None), - 'pageable': self.mobile_pageable, - 'sortable': False, - 'filterable': self.mobile_filterable, - 'renderers': self.make_mobile_grid_renderers(), - 'url': lambda obj: self.get_action_url('view', obj, mobile=True), - } - # TODO: this seems wrong.. - if self.mobile_filterable: - defaults['filters'] = self.make_mobile_filters() - defaults.update(kwargs) - return defaults - - def make_mobile_grid_renderers(self): - return { - 'listitem': self.render_mobile_listitem, - } - - def render_mobile_listitem(self, obj, i): - return obj - - def configure_mobile_grid(self, grid): - pass - - def make_mobile_row_grid(self, factory=None, key=None, data=None, columns=None, **kwargs): - """ - Make a new (configured) rows grid instance for mobile. - """ - instance = kwargs.pop('instance', self.get_instance()) - - if factory is None: - factory = self.get_mobile_row_grid_factory() - if key is None: - key = 'mobile.{}.{}'.format(self.get_grid_key(), self.request.matchdict[self.get_model_key()]) - if data is None: - data = self.get_mobile_row_data(instance) - if columns is None: - columns = self.get_mobile_row_grid_columns() - - kwargs.setdefault('request', self.request) - kwargs.setdefault('mobile', True) - kwargs = self.make_mobile_row_grid_kwargs(**kwargs) - grid = factory(key, data, columns, **kwargs) - self.configure_mobile_row_grid(grid) - grid.load_settings() - return grid - - def get_mobile_row_grid_columns(self): - if hasattr(self, 'mobile_row_grid_columns'): - return self.mobile_row_grid_columns - # TODO - return ['listitem'] - - def make_mobile_row_grid_kwargs(self, **kwargs): - """ - Must return a dictionary of kwargs to be passed to the factory when - creating new mobile *row* grid instances. - """ - defaults = { - 'model_class': self.model_row_class, - # TODO - 'pageable': self.pageable, - 'sortable': False, - 'filterable': self.mobile_rows_filterable, - 'renderers': self.make_mobile_row_grid_renderers(), - 'url': lambda obj: self.get_row_action_url('view', obj, mobile=True), - } - # TODO: this seems wrong.. - if self.mobile_rows_filterable: - defaults['filters'] = self.make_mobile_row_filters() - defaults.update(kwargs) - return defaults - - def make_mobile_row_grid_renderers(self): - return { - 'listitem': self.render_mobile_row_listitem, - } - - def configure_mobile_row_grid(self, grid): - pass - - def make_mobile_filters(self): - """ - Returns a set of filters for the mobile grid, if applicable. - """ - - def make_mobile_row_filters(self): - """ - Returns a set of filters for the mobile row grid, if applicable. - """ - - def render_mobile_row_listitem(self, obj, i): - return obj - def create(self, form=None, template='create'): """ View for creating a new model record. @@ -800,22 +609,6 @@ class MasterView(View): context['dform'] = form.make_deform_form() return self.render_to_response(template, context) - def mobile_create(self): - """ - Mobile view for creating a new primary object - """ - self.mobile = True - self.creating = True - form = self.make_mobile_form(self.get_model_class()) - if self.request.method == 'POST': - if self.validate_mobile_form(form): - # let save_create_form() return alternate object if necessary - obj = self.save_mobile_create_form(form) - self.after_create(obj) - self.flash_after_create(obj) - return self.redirect_after_create(obj, mobile=True) - return self.render_to_response('create', {'form': form}, mobile=True) - def save_create_form(self, form): uploads = self.normalize_uploads(form) self.before_create(form) @@ -1044,19 +837,10 @@ class MasterView(View): self.request.session.flash("{} has been created: {}".format( self.get_model_title(), self.get_instance_title(obj))) - def save_mobile_create_form(self, form): - self.before_create(form) - with self.Session.no_autoflush: - obj = self.objectify(form, self.form_deserialized) - self.before_create_flush(obj, form) - self.Session.add(obj) - self.Session.flush() - return obj - - def redirect_after_create(self, instance, mobile=False): + def redirect_after_create(self, instance, **kwargs): if self.populatable and self.should_populate(instance): - return self.redirect(self.get_action_url('populate', instance, mobile=mobile)) - return self.redirect(self.get_action_url('view', instance, mobile=mobile)) + return self.redirect(self.get_action_url('populate', instance)) + return self.redirect(self.get_action_url('view', instance)) def should_populate(self, obj): return True @@ -1249,8 +1033,8 @@ class MasterView(View): self.Session.flush() return cloned - def redirect_after_clone(self, instance, mobile=False): - return self.redirect(self.get_action_url('view', instance, mobile=mobile)) + def redirect_after_clone(self, instance, **kwargs): + return self.redirect(self.get_action_url('view', instance)) def touch(self): """ @@ -1414,75 +1198,6 @@ class MasterView(View): versions.extend(query.all()) return versions - def mobile_view(self): - """ - Mobile view for displaying a single object's details - """ - self.mobile = True - self.viewing = True - instance = self.get_instance() - form = self.make_mobile_form(instance) - - context = { - 'instance': instance, - 'instance_title': self.get_instance_title(instance), - 'instance_editable': self.editable_instance(instance), - # 'instance_deletable': self.deletable_instance(instance), - 'form': form, - } - if self.has_rows: - context['model_row_class'] = self.model_row_class - context['grid'] = self.make_mobile_row_grid(instance=instance) - return self.render_to_response('view', context, mobile=True) - - def make_mobile_form(self, instance=None, factory=None, fields=None, schema=None, **kwargs): - """ - Creates a new mobile form for the given model class/instance. - """ - if factory is None: - factory = self.get_mobile_form_factory() - if fields is None: - fields = self.get_mobile_form_fields() - if schema is None: - schema = self.make_mobile_form_schema() - - if not self.creating: - kwargs['model_instance'] = instance - kwargs = self.make_mobile_form_kwargs(**kwargs) - form = factory(fields, schema, **kwargs) - self.configure_mobile_form(form) - return form - - def get_mobile_form_fields(self): - if hasattr(self, 'mobile_form_fields'): - return self.mobile_form_fields - # TODO - # raise NotImplementedError - - def make_mobile_form_schema(self): - if not self.model_class: - # TODO - raise NotImplementedError - - def make_mobile_form_kwargs(self, **kwargs): - """ - Return a dictionary of kwargs to be passed to the factory when creating - new mobile forms. - """ - defaults = { - 'request': self.request, - 'readonly': self.viewing, - 'model_class': getattr(self, 'model_class', None), - 'action_url': self.request.current_route_url(_query=None), - } - if self.creating: - defaults['cancel_url'] = self.get_index_url(mobile=True) - else: - instance = kwargs['model_instance'] - defaults['cancel_url'] = self.get_action_url('view', instance, mobile=True) - defaults.update(kwargs) - return defaults - def configure_common_form(self, form): """ Configure the form in whatever way is deemed "common" - i.e. where @@ -1491,6 +1206,8 @@ class MasterView(View): By default this removes the 'uuid' field (if present), sets any primary key fields to be readonly (if we have a :attr:`model_class` and are in edit mode), and sets labels as defined by the master class hierarchy. + + TODO: this logic should be moved back into configure_form() """ form.remove_field('uuid') @@ -1516,62 +1233,29 @@ class MasterView(View): # is the safer option and would help prevent unwanted mistakes form.set_default('local_only', True) - def configure_mobile_form(self, form): - """ - Configure the main "mobile" form for the view's data model. - """ - self.configure_common_form(form) - - def validate_mobile_form(self, form): - if form.validate(newstyle=True): - # TODO: deprecate / remove self.form_deserialized - self.form_deserialized = form.validated - return True - else: - return False - - def make_mobile_row_form(self, instance=None, factory=None, fields=None, schema=None, **kwargs): - """ - Creates a new mobile form for the given model class/instance. - """ - if factory is None: - factory = self.get_mobile_row_form_factory() - if fields is None: - fields = self.get_mobile_row_form_fields() - if schema is None: - schema = self.make_mobile_row_form_schema() - - if not self.creating: - kwargs['model_instance'] = instance - kwargs = self.make_mobile_row_form_kwargs(**kwargs) - form = factory(fields, schema, **kwargs) - self.configure_mobile_row_form(form) - return form - - def make_quick_row_form(self, instance=None, factory=None, fields=None, schema=None, mobile=False, **kwargs): + def make_quick_row_form(self, instance=None, factory=None, fields=None, schema=None, **kwargs): """ Creates a "quick" form for adding a new row to the given instance. """ if factory is None: - factory = self.get_quick_row_form_factory(mobile=mobile) + factory = self.get_quick_row_form_factory() if fields is None: - fields = self.get_quick_row_form_fields(mobile=mobile) + fields = self.get_quick_row_form_fields() if schema is None: - schema = self.make_quick_row_form_schema(mobile=mobile) + schema = self.make_quick_row_form_schema() - kwargs['mobile'] = mobile kwargs = self.make_quick_row_form_kwargs(**kwargs) form = factory(fields, schema, **kwargs) - self.configure_quick_row_form(form, mobile=mobile) + self.configure_quick_row_form(form) return form - def get_quick_row_form_factory(self, mobile=False): + def get_quick_row_form_factory(self, **kwargs): return forms.Form - def get_quick_row_form_fields(self, mobile=False): + def get_quick_row_form_fields(self, **kwargs): pass - def make_quick_row_form_schema(self, mobile=False): + def make_quick_row_form_schema(self, **kwargs): schema = colander.MappingSchema() schema.add(colander.SchemaNode(colander.String(), name='quick_entry')) return schema @@ -1585,102 +1269,12 @@ class MasterView(View): defaults.update(kwargs) return defaults - def configure_quick_row_form(self, form, mobile=False): + def configure_quick_row_form(self, form, **kwargs): pass - def get_mobile_row_form_fields(self): - if hasattr(self, 'mobile_row_form_fields'): - return self.mobile_row_form_fields - # TODO - # raise NotImplementedError - - def make_mobile_row_form_schema(self): - if not self.model_row_class: - # TODO - raise NotImplementedError - - def make_mobile_row_form_kwargs(self, **kwargs): - """ - Return a dictionary of kwargs to be passed to the factory when creating - new mobile row forms. - """ - defaults = { - 'request': self.request, - 'mobile': True, - 'readonly': self.viewing, - 'model_class': getattr(self, 'model_row_class', None), - 'action_url': self.request.current_route_url(_query=None), - } - if self.creating: - defaults['cancel_url'] = self.request.get_referrer() - else: - instance = kwargs['model_instance'] - defaults['cancel_url'] = self.get_row_action_url('view', instance, mobile=True) - defaults.update(kwargs) - return defaults - - def configure_mobile_row_form(self, form): - """ - Configure the mobile row form. - """ - # TODO: is any of this stuff from configure_form() needed? - # if self.editing: - # model_class = self.get_model_class(error=False) - # if model_class: - # mapper = orm.class_mapper(model_class) - # for key in mapper.primary_key: - # for field in form.fields: - # if field == key.name: - # form.set_readonly(field) - # break - # form.remove_field('uuid') - - self.set_row_labels(form) - - def validate_mobile_row_form(self, form): - controls = self.request.POST.items() - try: - self.form_deserialized = form.validate(controls) - except deform.ValidationFailure: - return False - return True - def validate_quick_row_form(self, form): return form.validate(newstyle=True) - def get_mobile_row_data(self, parent): - query = self.get_row_data(parent) - return self.sort_mobile_row_data(query) - - def sort_mobile_row_data(self, query): - return query - - def mobile_row_route_url(self, route_name, **kwargs): - route_name = 'mobile.{}.{}_row'.format(self.get_route_prefix(), route_name) - return self.request.route_url(route_name, **kwargs) - - def mobile_view_row(self): - """ - Mobile view for row items - """ - self.mobile = True - self.viewing = True - row = self.get_row_instance() - parent = self.get_parent(row) - form = self.make_mobile_row_form(row) - context = { - 'row': row, - 'parent_instance': parent, - 'parent_title': self.get_instance_title(parent), - 'parent_url': self.get_action_url('view', parent, mobile=True), - 'instance': row, - 'instance_title': self.get_row_instance_title(row), - 'instance_editable': self.row_editable(row), - 'parent_model_title': self.get_model_title(), - 'form': form, - } - return self.render_to_response('view_row', context, mobile=True) - def make_default_row_grid_tools(self, obj): if self.rows_creatable: link = tags.link_to("Create a new {}".format(self.get_row_model_title()), @@ -1851,61 +1445,16 @@ class MasterView(View): context['dform'] = form.make_deform_form() return self.render_to_response('edit', context) - def mobile_edit(self): - """ - Mobile view for editing an existing model record. - """ - self.mobile = True - self.editing = True - obj = self.get_instance() - - if not self.editable_instance(obj): - msg = "Edit is not permitted for {}: {}".format( - self.get_model_title(), - self.get_instance_title(obj)) - self.request.session.flash(msg, 'error') - return self.redirect(self.get_action_url('view', obj)) - - form = self.make_mobile_form(obj) - - if self.request.method == 'POST': - if self.validate_mobile_form(form): - - # note that save_form() may return alternate object - obj = self.save_mobile_edit_form(form) - - msg = "{} has been updated: {}".format( - self.get_model_title(), - self.get_instance_title(obj)) - self.request.session.flash(msg) - return self.redirect_after_edit(obj, mobile=True) - - context = { - 'instance': obj, - 'instance_title': self.get_instance_title(obj), - 'instance_deletable': self.deletable_instance(obj), - 'instance_url': self.get_action_url('view', obj, mobile=True), - 'form': form, - } - if hasattr(form, 'make_deform_form'): - context['dform'] = form.make_deform_form() - return self.render_to_response('edit', context, mobile=True) - def save_edit_form(self, form): - if not self.mobile: - uploads = self.normalize_uploads(form) + uploads = self.normalize_uploads(form) obj = self.objectify(form) - if not self.mobile: - self.process_uploads(obj, form, uploads) + self.process_uploads(obj, form, uploads) self.after_edit(obj) self.Session.flush() return obj - def save_mobile_edit_form(self, form): - return self.save_edit_form(form) - - def redirect_after_edit(self, instance, mobile=False): - return self.redirect(self.get_action_url('view', instance, mobile=mobile)) + def redirect_after_edit(self, instance, **kwargs): + return self.redirect(self.get_action_url('view', instance)) def delete(self): """ @@ -2350,13 +1899,11 @@ class MasterView(View): """ return getattr(cls, 'permission_prefix', cls.get_route_prefix()) - def get_index_url(self, mobile=False, **kwargs): + def get_index_url(self, **kwargs): """ Returns the master view's index URL. """ route = self.get_route_prefix() - if mobile: - route = 'mobile.{}'.format(route) return self.request.route_url(route, **kwargs) @classmethod @@ -2366,15 +1913,13 @@ class MasterView(View): """ return getattr(cls, 'index_title', cls.get_model_title_plural()) - def get_action_url(self, action, instance, mobile=False, **kwargs): + def get_action_url(self, action, instance, **kwargs): """ Generate a URL for the given action on the given instance """ kw = self.get_action_route_kwargs(instance) kw.update(kwargs) route_prefix = self.get_route_prefix() - if mobile: - route_prefix = 'mobile.{}'.format(route_prefix) return self.request.route_url('{}.{}'.format(route_prefix, action), **kw) def get_help_url(self): @@ -2394,7 +1939,7 @@ class MasterView(View): return global_help_url(self.rattail_config) - def render_to_response(self, template, data, mobile=False): + def render_to_response(self, template, data, **kwargs): """ Return a response with the given template rendered with the given data. Note that ``template`` must only be a "key" (e.g. 'index' or 'view'). @@ -2405,13 +1950,12 @@ class MasterView(View): context = { 'master': self, 'use_buefy': self.get_use_buefy(), - 'mobile': mobile, 'model_title': self.get_model_title(), 'model_title_plural': self.get_model_title_plural(), 'route_prefix': self.get_route_prefix(), 'permission_prefix': self.get_permission_prefix(), 'index_title': self.get_index_title(), - 'index_url': self.get_index_url(mobile=mobile), + 'index_url': self.get_index_url(), 'action_url': self.get_action_url, 'grid_index': self.grid_index, 'help_url': self.get_help_url(), @@ -2430,34 +1974,20 @@ class MasterView(View): context['row_model_title_plural'] = self.get_row_model_title_plural() context['row_action_url'] = self.get_row_action_url - if mobile and self.viewing and self.mobile_rows_quickable: - - # quick row does *not* mimic keyboard wedge by default, but can - context['quick_row_keyboard_wedge'] = False - - # quick row does *not* use autocomplete by default, but can - context['quick_row_autocomplete'] = False - context['quick_row_autocomplete_url'] = '#' - context.update(data) context.update(self.template_kwargs(**context)) if hasattr(self, 'template_kwargs_{}'.format(template)): context.update(getattr(self, 'template_kwargs_{}'.format(template))(**context)) - if mobile and hasattr(self, 'mobile_template_kwargs_{}'.format(template)): - context.update(getattr(self, 'mobile_template_kwargs_{}'.format(template))(**context)) # First try the template path most specific to the view. - if mobile: - mako_path = '/mobile{}/{}.mako'.format(self.get_template_prefix(), template) - else: - mako_path = '{}/{}.mako'.format(self.get_template_prefix(), template) + mako_path = '{}/{}.mako'.format(self.get_template_prefix(), template) try: return render_to_response(mako_path, context, request=self.request) except IOError: # Failing that, try one or more fallback templates. - for fallback in self.get_fallback_templates(template, mobile=mobile): + for fallback in self.get_fallback_templates(template): try: return render_to_response(fallback, context, request=self.request) except IOError: @@ -2504,9 +2034,7 @@ class MasterView(View): return render('{}/{}.mako'.format(self.get_template_prefix(), template), context, request=self.request) - def get_fallback_templates(self, template, mobile=False): - if mobile: - return ['/mobile/master/{}.mako'.format(template)] + def get_fallback_templates(self, template, **kwargs): return ['/master/{}.mako'.format(template)] def get_default_engine_dbkey(self): @@ -3736,14 +3264,6 @@ class MasterView(View): """ return getattr(cls, 'form_factory', forms.Form) - @classmethod - def get_mobile_form_factory(cls): - """ - Returns the factory or class which is to be used when creating new - mobile forms. - """ - return getattr(cls, 'mobile_form_factory', forms.Form) - @classmethod def get_row_form_factory(cls): """ @@ -3752,14 +3272,6 @@ class MasterView(View): """ return getattr(cls, 'row_form_factory', forms.Form) - @classmethod - def get_mobile_row_form_factory(cls): - """ - Returns the factory or class which is to be used when creating new - mobile row forms. - """ - return getattr(cls, 'mobile_row_form_factory', forms.Form) - def download_path(self, obj, filename): """ Should return absolute path on disk, for the given object and filename. @@ -4055,49 +3567,8 @@ class MasterView(View): def after_create_row(self, row_object): pass - def redirect_after_create_row(self, row, mobile=False): - return self.redirect(self.get_row_action_url('view', row, mobile=mobile)) - - def mobile_create_row(self): - """ - Mobile view for creating a new row object - """ - self.mobile = True - self.creating = True - parent = self.get_instance() - instance_url = self.get_action_url('view', parent, mobile=True) - form = self.make_mobile_row_form(self.model_row_class, cancel_url=instance_url) - if self.request.method == 'POST': - if self.validate_mobile_row_form(form): - self.before_create_row(form) - # let save() return alternate object if necessary - obj = self.save_create_row_form(form) - self.after_create_row(obj) - return self.redirect_after_create_row(obj, mobile=True) - return self.render_to_response('create_row', { - 'instance_title': self.get_instance_title(parent), - 'instance_url': instance_url, - 'parent_object': parent, - 'form': form, - }, mobile=True) - - def mobile_quick_row(self): - """ - Mobile view for "quick" location or creation of a row object - """ - parent = self.get_instance() - parent_url = self.get_action_url('view', parent, mobile=True) - form = self.make_quick_row_form(self.model_row_class, mobile=True, cancel_url=parent_url) - if self.request.method == 'POST': - if self.validate_quick_row_form(form): - row = self.save_quick_row_form(form) - if not row: - self.request.session.flash("Could not locate/create row for entry: " - "{}".format(form.validated['quick_entry']), - 'error') - return self.redirect(parent_url) - return self.redirect_after_quick_row(row, mobile=True) - return self.redirect(parent_url) + def redirect_after_create_row(self, row, **kwargs): + return self.redirect(self.get_row_action_url('view', row)) def save_quick_row_form(self, form): raise NotImplementedError("You must define `{}:{}.save_quick_row_form()` " @@ -4105,8 +3576,8 @@ class MasterView(View): self.__class__.__module__, self.__class__.__name__)) - def redirect_after_quick_row(self, row, mobile=False): - return self.redirect(self.get_row_action_url('edit', row, mobile=mobile)) + def redirect_after_quick_row(self, row, **kwargs): + return self.redirect(self.get_row_action_url('edit', row)) def view_row(self): """ @@ -4182,34 +3653,6 @@ class MasterView(View): 'dform': form.make_deform_form(), }) - def mobile_edit_row(self): - """ - Mobile view for editing a row object - """ - self.mobile = True - self.editing = True - row = self.get_row_instance() - instance_url = self.get_row_action_url('view', row, mobile=True) - form = self.make_mobile_row_form(row) - - if self.request.method == 'POST': - if self.validate_mobile_row_form(form): - self.save_edit_row_form(form) - return self.redirect_after_edit_row(row, mobile=True) - - parent = self.get_parent(row) - return self.render_to_response('edit_row', { - 'row': row, - 'instance': row, - 'parent_instance': parent, - 'instance_title': self.get_row_instance_title(row), - 'instance_url': instance_url, - 'instance_deletable': self.row_deletable(row), - 'parent_title': self.get_instance_title(parent), - 'parent_url': self.get_action_url('view', parent, mobile=True), - 'form': form}, - mobile=True) - def save_edit_row_form(self, form): obj = self.objectify(form, self.form_deserialized) self.after_edit_row(obj) @@ -4224,8 +3667,8 @@ class MasterView(View): Event hook, called just after an existing row object is saved. """ - def redirect_after_edit_row(self, row, mobile=False): - return self.redirect(self.get_row_action_url('view', row, mobile=mobile)) + def redirect_after_edit_row(self, row, **kwargs): + return self.redirect(self.get_row_action_url('view', row)) def row_deletable(self, row): """ @@ -4252,22 +3695,6 @@ class MasterView(View): self.delete_row_object(row) return self.redirect(self.get_action_url('view', self.get_parent(row))) - def mobile_delete_row(self): - """ - Mobile view which can "delete" a sub-row from the parent. - """ - if self.request.method == 'POST': - parent = self.get_instance() - row = self.get_row_instance() - if self.get_parent(row) is not parent: - raise RuntimeError("Can only delete rows which belong to current object") - - self.delete_row_object(row) - return self.redirect(self.get_action_url('view', parent, mobile=True)) - - self.session.flash("Must POST to delete a row", 'error') - return self.redirect(self.request.get_referrer(mobile=True)) - def get_parent(self, row): raise NotImplementedError @@ -4357,13 +3784,11 @@ class MasterView(View): return True return False - def get_row_action_url(self, action, row, mobile=False): + def get_row_action_url(self, action, row, **kwargs): """ Generate a URL for the given action on the given row. """ route_name = '{}.{}_row'.format(self.get_route_prefix(), action) - if mobile: - route_name = 'mobile.{}'.format(route_name) return self.request.route_url(route_name, **self.get_row_action_route_kwargs(row)) def get_row_action_route_kwargs(self, row): @@ -4426,7 +3851,6 @@ class MasterView(View): model_title_plural = cls.get_model_title_plural() if cls.has_rows: row_model_title = cls.get_row_model_title() - legacy_mobile = cls.legacy_mobile_enabled(rattail_config) config.add_tailbone_permission_group(permission_prefix, model_title_plural, overwrite=False) @@ -4437,10 +3861,6 @@ class MasterView(View): config.add_route(route_prefix, '{}/'.format(url_prefix)) config.add_view(cls, attr='index', route_name=route_prefix, permission='{}.list'.format(permission_prefix)) - if legacy_mobile and cls.supports_mobile: - config.add_route('mobile.{}'.format(route_prefix), '/mobile{}/'.format(url_prefix)) - config.add_view(cls, attr='mobile_index', route_name='mobile.{}'.format(route_prefix), - permission='{}.list'.format(permission_prefix)) # download results # this is the "new" more flexible approach, but we only want to @@ -4495,17 +3915,12 @@ class MasterView(View): permission='{}.quickie'.format(permission_prefix)) # create - if cls.creatable or (legacy_mobile and cls.mobile_creatable): + if cls.creatable: config.add_tailbone_permission(permission_prefix, '{}.create'.format(permission_prefix), "Create new {}".format(model_title)) - if cls.creatable: config.add_route('{}.create'.format(route_prefix), '{}/new'.format(url_prefix)) config.add_view(cls, attr='create', route_name='{}.create'.format(route_prefix), permission='{}.create'.format(permission_prefix)) - if legacy_mobile and cls.mobile_creatable: - config.add_route('mobile.{}.create'.format(route_prefix), '/mobile{}/new'.format(url_prefix)) - config.add_view(cls, attr='mobile_create', route_name='mobile.{}.create'.format(route_prefix), - permission='{}.create'.format(permission_prefix)) # populate new object if cls.populatable: @@ -4572,10 +3987,6 @@ class MasterView(View): config.add_route('{}.view'.format(route_prefix), instance_url_prefix) config.add_view(cls, attr='view', route_name='{}.view'.format(route_prefix), permission='{}.view'.format(permission_prefix)) - if legacy_mobile and cls.supports_mobile: - config.add_route('mobile.{}.view'.format(route_prefix), '/mobile{}'.format(instance_url_prefix)) - config.add_view(cls, attr='mobile_view', route_name='mobile.{}.view'.format(route_prefix), - permission='{}.view'.format(permission_prefix)) # version history if cls.has_versions and rattail_config and rattail_config.versioning_enabled(): @@ -4625,30 +4036,20 @@ class MasterView(View): "Download associated data for {}".format(model_title)) # edit - if cls.editable or (legacy_mobile and cls.mobile_editable): + if cls.editable: config.add_tailbone_permission(permission_prefix, '{}.edit'.format(permission_prefix), "Edit {}".format(model_title)) - if cls.editable: config.add_route('{}.edit'.format(route_prefix), '{}/edit'.format(instance_url_prefix)) config.add_view(cls, attr='edit', route_name='{}.edit'.format(route_prefix), permission='{}.edit'.format(permission_prefix)) - if legacy_mobile and cls.mobile_editable: - config.add_route('mobile.{}.edit'.format(route_prefix), '/mobile{}/edit'.format(instance_url_prefix)) - config.add_view(cls, attr='mobile_edit', route_name='mobile.{}.edit'.format(route_prefix), - permission='{}.edit'.format(permission_prefix)) # execute - if cls.executable or (legacy_mobile and cls.mobile_executable): + if cls.executable: config.add_tailbone_permission(permission_prefix, '{}.execute'.format(permission_prefix), "Execute {}".format(model_title)) - if cls.executable: config.add_route('{}.execute'.format(route_prefix), '{}/execute'.format(instance_url_prefix)) config.add_view(cls, attr='execute', route_name='{}.execute'.format(route_prefix), permission='{}.execute'.format(permission_prefix)) - if legacy_mobile and cls.mobile_executable: - config.add_route('mobile.{}.execute'.format(route_prefix), '/mobile{}/execute'.format(instance_url_prefix)) - config.add_view(cls, attr='mobile_execute', route_name='mobile.{}.execute'.format(route_prefix), - permission='{}.execute'.format(permission_prefix)) # delete if cls.deletable: @@ -4683,21 +4084,12 @@ class MasterView(View): # create row if cls.has_rows: - if cls.rows_creatable or (legacy_mobile and cls.mobile_rows_creatable): + if cls.rows_creatable: config.add_tailbone_permission(permission_prefix, '{}.create_row'.format(permission_prefix), "Create new {} rows".format(model_title)) - if cls.rows_creatable: config.add_route('{}.create_row'.format(route_prefix), '{}/new-row'.format(instance_url_prefix)) config.add_view(cls, attr='create_row', route_name='{}.create_row'.format(route_prefix), permission='{}.create_row'.format(permission_prefix)) - if legacy_mobile and cls.mobile_rows_creatable: - config.add_route('mobile.{}.create_row'.format(route_prefix), '/mobile{}/new-row'.format(instance_url_prefix)) - config.add_view(cls, attr='mobile_create_row', route_name='mobile.{}.create_row'.format(route_prefix), - permission='{}.create_row'.format(permission_prefix)) - if cls.mobile_rows_quickable: - config.add_route('mobile.{}.quick_row'.format(route_prefix), '/mobile{}/quick-row'.format(instance_url_prefix)) - config.add_view(cls, attr='mobile_quick_row', route_name='mobile.{}.quick_row'.format(route_prefix), - permission='{}.create_row'.format(permission_prefix)) # view row if cls.has_rows: @@ -4705,35 +4097,21 @@ class MasterView(View): config.add_route('{}.view_row'.format(route_prefix), '{}/{{uuid}}/rows/{{row_uuid}}'.format(url_prefix)) config.add_view(cls, attr='view_row', route_name='{}.view_row'.format(route_prefix), permission='{}.view'.format(permission_prefix)) - if legacy_mobile and cls.mobile_rows_viewable: - config.add_route('mobile.{}.view_row'.format(route_prefix), '/mobile{}/{{uuid}}/rows/{{row_uuid}}'.format(url_prefix)) - config.add_view(cls, attr='mobile_view_row', route_name='mobile.{}.view_row'.format(route_prefix), - permission='{}.view'.format(permission_prefix)) # edit row if cls.has_rows: - if cls.rows_editable or (legacy_mobile and cls.mobile_rows_editable): + if cls.rows_editable: config.add_tailbone_permission(permission_prefix, '{}.edit_row'.format(permission_prefix), "Edit individual {} rows".format(model_title)) - if cls.rows_editable: config.add_route('{}.edit_row'.format(route_prefix), '{}/{{uuid}}/rows/{{row_uuid}}/edit'.format(url_prefix)) config.add_view(cls, attr='edit_row', route_name='{}.edit_row'.format(route_prefix), permission='{}.edit_row'.format(permission_prefix)) - if legacy_mobile and cls.mobile_rows_editable: - config.add_route('mobile.{}.edit_row'.format(route_prefix), '/mobile{}/{{uuid}}/rows/{{row_uuid}}/edit'.format(url_prefix)) - config.add_view(cls, attr='mobile_edit_row', route_name='mobile.{}.edit_row'.format(route_prefix), - permission='{}.edit_row'.format(permission_prefix)) # delete row if cls.has_rows: - if cls.rows_deletable or (legacy_mobile and cls.mobile_rows_deletable): + if cls.rows_deletable: config.add_tailbone_permission(permission_prefix, '{}.delete_row'.format(permission_prefix), "Delete individual {} rows".format(model_title)) - if cls.rows_deletable: config.add_route('{}.delete_row'.format(route_prefix), '{}/{{uuid}}/rows/{{row_uuid}}/delete'.format(url_prefix)) config.add_view(cls, attr='delete_row', route_name='{}.delete_row'.format(route_prefix), permission='{}.delete_row'.format(permission_prefix)) - if legacy_mobile and cls.mobile_rows_deletable: - config.add_route('mobile.{}.delete_row'.format(route_prefix), '/mobile{}/{{uuid}}/rows/{{row_uuid}}/delete'.format(url_prefix)) - config.add_view(cls, attr='mobile_delete_row', route_name='mobile.{}.delete_row'.format(route_prefix), - permission='{}.delete_row'.format(permission_prefix)) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 54c84d82..de970119 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -53,7 +53,6 @@ class PersonView(MasterView): route_prefix = 'people' touchable = True has_versions = True - supports_mobile = True bulk_deletable = True is_contact = True manage_notes_from_profile_view = False @@ -85,19 +84,6 @@ class PersonView(MasterView): 'users', ] - mobile_form_fields = [ - 'first_name', - 'middle_name', - 'last_name', - 'display_name', - 'phone', - 'email', - 'address', - 'employee', - 'customers', - 'users', - ] - mergeable = True merge_additive_fields = [ 'usernames', @@ -331,8 +317,7 @@ class PersonView(MasterView): text = "(#{}) {}".format(customer.number, text) elif customer.id: text = "({}) {}".format(customer.id, text) - route = '{}customers.view'.format('mobile.' if self.mobile else '') - url = self.request.route_url(route, uuid=customer.uuid) + url = self.request.route_url('customers.view', uuid=customer.uuid) items.append(HTML.tag('li', c=[tags.link_to(text, url)])) return HTML.tag('ul', c=items) diff --git a/tailbone/views/principal.py b/tailbone/views/principal.py index e22e2554..b3d032ab 100644 --- a/tailbone/views/principal.py +++ b/tailbone/views/principal.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -44,10 +44,10 @@ class PrincipalMasterView(MasterView): Master view base class for security principal models, i.e. User and Role. """ - def get_fallback_templates(self, template, mobile=False): + def get_fallback_templates(self, template, **kwargs): return [ '/principal/{}.mako'.format(template), - ] + super(PrincipalMasterView, self).get_fallback_templates(template, mobile=mobile) + ] + super(PrincipalMasterView, self).get_fallback_templates(template, **kwargs) def perm_sortkey(self, item): key, value = item diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 526a9160..e7afa49a 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -82,7 +82,6 @@ class ProductView(MasterView): Master view for the Product class. """ model_class = model.Product - supports_mobile = True has_versions = True results_downloadable_xlsx = True @@ -157,8 +156,6 @@ class ProductView(MasterView): 'inventory_on_order', ] - mobile_form_fields = form_fields - # These aliases enable the grid queries to filter products which may be # purchased from *any* vendor, and yet sort by only the "preferred" vendor # (since that's what shows up in the grid column). @@ -936,7 +933,7 @@ class ProductView(MasterView): else: code = pack.item_id text = "({}) {}".format(code, pack.full_description) - url = self.get_action_url('view', pack, mobile=self.mobile) + url = self.get_action_url('view', pack) links.append(tags.link_to(text, url)) items = [HTML.tag('li', c=[link]) for link in links] @@ -955,7 +952,7 @@ class ProductView(MasterView): code = unit.item_id text = "({}) {}".format(code, unit.full_description) - url = self.get_action_url('view', unit, mobile=self.mobile) + url = self.get_action_url('view', unit) return tags.link_to(text, url) def render_current_price_ends(self, product, field): @@ -1494,37 +1491,6 @@ class ProductView(MasterView): 'instance_title': self.get_instance_title(instance), 'form': form}) - def mobile_index(self): - """ - Mobile "home" page for products - """ - self.mobile = True - context = { - 'quick_lookup': False, - 'placeholder': "Enter {}".format(self.rattail_config.product_key_title()), - 'quick_lookup_keyboard_wedge': True, - } - if self.rattail_config.getbool('rattail', 'products.mobile.quick_lookup', default=False): - context['quick_lookup'] = True - else: - self.listing = True - grid = self.make_mobile_grid() - context['grid'] = grid - return self.render_to_response('index', context, mobile=True) - - def mobile_quick_lookup(self): - entry = self.request.POST['quick_entry'].strip() - provided = GPC(entry, calc_check_digit=False) - product = api.get_product_by_upc(self.Session(), provided) - if not product: - checked = GPC(entry, calc_check_digit='upc') - product = api.get_product_by_upc(self.Session(), checked) - if not product: - product = api.get_product_by_code(self.Session(), entry) - if not product: - raise self.notfound() - return self.redirect(self.get_action_url('view', product, mobile=True)) - def get_version_child_classes(self): return [ (model.ProductCode, 'product_uuid'), @@ -1746,7 +1712,6 @@ class ProductView(MasterView): template_prefix = cls.get_template_prefix() permission_prefix = cls.get_permission_prefix() model_title = cls.get_model_title() - legacy_mobile = cls.legacy_mobile_enabled(rattail_config) # print labels config.add_tailbone_permission('products', 'products.print_labels', @@ -1787,11 +1752,6 @@ class ProductView(MasterView): renderer='json', permission='{}.versions'.format(permission_prefix)) - # mobile quick lookup - if legacy_mobile: - config.add_route('mobile.products.quick_lookup', '/mobile/products/quick-lookup') - config.add_view(cls, attr='mobile_quick_lookup', route_name='mobile.products.quick_lookup') - # TODO: deprecate / remove this ProductsView = ProductView diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index ebfcf8ce..1ca8c21c 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -150,34 +150,6 @@ class PurchasingBatchView(BatchMasterView): 'credits', ] - mobile_row_form_fields = [ - 'upc', - 'item_id', - 'product', - 'brand_name', - 'description', - 'size', - 'case_quantity', - 'cases_ordered', - 'units_ordered', - 'cases_received', - 'units_received', - 'cases_damaged', - 'units_damaged', - 'cases_expired', - 'units_expired', - 'cases_mispick', - 'units_mispick', - # 'po_line_number', - 'po_unit_cost', - 'po_total', - # 'invoice_line_number', - 'invoice_unit_cost', - 'invoice_total', - 'status_code', - # 'credits', - ] - @property def batch_mode(self): raise NotImplementedError("Please define `batch_mode` for your purchasing batch view") @@ -518,8 +490,8 @@ class PurchasingBatchView(BatchMasterView): total = purchase.invoice_total return '{} for ${:0,.2f} ({})'.format(date, total or 0, purchase.department or purchase.buyer) - def get_batch_kwargs(self, batch, mobile=False): - kwargs = super(PurchasingBatchView, self).get_batch_kwargs(batch, mobile=mobile) + def get_batch_kwargs(self, batch, **kwargs): + kwargs = super(PurchasingBatchView, self).get_batch_kwargs(batch, **kwargs) kwargs['mode'] = self.batch_mode kwargs['truck_dump'] = batch.truck_dump kwargs['invoice_parser_key'] = batch.invoice_parser_key @@ -596,9 +568,6 @@ class PurchasingBatchView(BatchMasterView): # query = super(PurchasingBatchView, self).get_row_data(batch) # return query.options(orm.joinedload(model.PurchaseBatchRow.credits)) - def sort_mobile_row_data(self, query): - return query.order_by(model.PurchaseBatchRow.modified.desc()) - def configure_row_grid(self, g): super(PurchasingBatchView, self).configure_row_grid(g) @@ -760,104 +729,6 @@ class PurchasingBatchView(BatchMasterView): g.set_type('credit_total', 'currency') return HTML.literal(g.render_grid()) - def configure_mobile_row_form(self, f): - super(PurchasingBatchView, self).configure_mobile_row_form(f) - # row = f.model_instance - # if self.creating: - # batch = self.get_instance() - # else: - # batch = self.get_parent(row) - - # # readonly fields - # f.set_readonly('case_quantity') - # f.set_readonly('credits') - - # quantity fields - f.set_type('case_quantity', 'quantity') - f.set_type('cases_ordered', 'quantity') - f.set_type('units_ordered', 'quantity') - f.set_type('cases_received', 'quantity') - f.set_type('units_received', 'quantity') - f.set_type('cases_damaged', 'quantity') - f.set_type('units_damaged', 'quantity') - f.set_type('cases_expired', 'quantity') - f.set_type('units_expired', 'quantity') - f.set_type('cases_mispick', 'quantity') - f.set_type('units_mispick', 'quantity') - - # currency fields - f.set_type('po_unit_cost', 'currency') - f.set_type('po_total', 'currency') - f.set_type('po_total_calculated', 'currency') - f.set_type('invoice_unit_cost', 'currency') - f.set_type('invoice_total', 'currency') - f.set_type('invoice_total_calculated', 'currency') - - # if self.creating: - # f.remove_fields( - # 'upc', - # 'product', - # 'po_total', - # 'invoice_total', - # ) - # if self.batch_mode == self.enum.PURCHASE_BATCH_MODE_ORDERING: - # f.remove_fields('cases_received', - # 'units_received') - # elif self.batch_mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING: - # f.remove_fields('cases_ordered', - # 'units_ordered') - - # elif self.editing: - # f.set_readonly('upc') - # f.set_readonly('product') - # f.remove_fields('po_total', - # 'invoice_total', - # 'status_code') - - # elif self.viewing: - # if row.product: - # f.remove_fields('brand_name', - # 'description', - # 'size') - # else: - # f.remove_field('product') - - def mobile_new_product(self): - """ - View which allows user to create a new Product and add a row for it to - the Purchasing Batch. - """ - batch = self.get_instance() - batch_url = self.get_action_url('view', batch, mobile=True) - form = forms.Form(schema=self.make_new_product_schema(), - request=self.request, - mobile=True, - cancel_url=batch_url) - - if form.validate(newstyle=True): - product = model.Product() - product.item_id = form.validated['item_id'] - product.description = form.validated['description'] - row = self.model_row_class() - row.product = product - self.handler.add_row(batch, row) - self.Session.flush() - return self.redirect(self.get_row_action_url('edit', row, mobile=True)) - - return self.render_to_response('new_product', { - 'form': form, - 'dform': form.make_deform_form(), - 'instance_title': self.get_instance_title(batch), - 'instance_url': batch_url, - }, mobile=True) - - def make_new_product_schema(self): - """ - Must return a ``colander.Schema`` instance for use with the form in the - :meth:`mobile_new_product()` view. - """ - return NewProduct() - # def item_lookup(self, value, field=None): # """ # Try to locate a single product using ``value`` as a lookup code. @@ -956,9 +827,9 @@ class PurchasingBatchView(BatchMasterView): # return self.redirect(self.request.current_route_url()) # TODO: seems like this should be master behavior, controlled by setting? - def redirect_after_edit_row(self, row, mobile=False): + def redirect_after_edit_row(self, row, **kwargs): parent = self.get_parent(row) - return self.redirect(self.get_action_url('view', parent, mobile=mobile)) + return self.redirect(self.get_action_url('view', parent)) # def get_execute_success_url(self, batch, result, **kwargs): # # if batch execution yielded a Purchase, redirect to it @@ -977,21 +848,12 @@ class PurchasingBatchView(BatchMasterView): permission_prefix = cls.get_permission_prefix() model_key = cls.get_model_key() model_title = cls.get_model_title() - legacy_mobile = cls.legacy_mobile_enabled(rattail_config) # eligible purchases (AJAX) config.add_route('{}.eligible_purchases'.format(route_prefix), '{}/eligible-purchases'.format(url_prefix)) config.add_view(cls, attr='eligible_purchases', route_name='{}.eligible_purchases'.format(route_prefix), renderer='json', permission='{}.view'.format(permission_prefix)) - # add new product - if legacy_mobile and cls.supports_new_product: - config.add_tailbone_permission(permission_prefix, '{}.new_product'.format(permission_prefix), - "Create new Product when adding row to {}".format(model_title)) - config.add_route('mobile.{}.new_product'.format(route_prefix), '{}/{{{}}}/new-product'.format(url_prefix, model_key)) - config.add_view(cls, attr='mobile_new_product', route_name='mobile.{}.new_product'.format(route_prefix), - permission='{}.new_product'.format(permission_prefix)) - @classmethod def defaults(cls, config): diff --git a/tailbone/views/purchasing/ordering.py b/tailbone/views/purchasing/ordering.py index 2a7a9b11..43955263 100644 --- a/tailbone/views/purchasing/ordering.py +++ b/tailbone/views/purchasing/ordering.py @@ -51,12 +51,7 @@ class OrderingBatchView(PurchasingBatchView): model_title = "Ordering Batch" model_title_plural = "Ordering Batches" index_title = "Ordering" - mobile_creatable = True rows_editable = True - mobile_rows_creatable = True - mobile_rows_quickable = True - mobile_rows_editable = True - mobile_rows_deletable = True has_worksheet = True labels = { @@ -86,21 +81,6 @@ class OrderingBatchView(PurchasingBatchView): 'executed_by', ] - mobile_form_fields = [ - 'vendor', - 'department', - 'date_ordered', - 'po_number', - 'po_total', - 'created', - 'created_by', - 'notes', - 'status_code', - 'complete', - 'executed', - 'executed_by', - ] - row_labels = { 'po_total_calculated': "PO Total", } @@ -161,8 +141,8 @@ class OrderingBatchView(PurchasingBatchView): if self.creating or not batch.executed or not batch.purchase: f.remove_field('purchase') - def get_batch_kwargs(self, batch, mobile=False): - kwargs = super(OrderingBatchView, self).get_batch_kwargs(batch, mobile=mobile) + def get_batch_kwargs(self, batch, **kwargs): + kwargs = super(OrderingBatchView, self).get_batch_kwargs(batch, **kwargs) kwargs['ship_method'] = batch.ship_method kwargs['notes_to_vendor'] = batch.notes_to_vendor return kwargs @@ -387,60 +367,6 @@ class OrderingBatchView(PurchasingBatchView): 'batch_po_total_display': '${:0,.2f}'.format(batch.po_total_calculated or batch.po_total or 0), } - def render_mobile_listitem(self, batch, i): - return "({}) {} on {} for ${:0,.2f}".format(batch.id_str, batch.vendor, - batch.date_ordered, batch.po_total or 0) - - def mobile_create(self): - """ - Mobile view for creating a new ordering batch - """ - mode = self.batch_mode - data = {'mode': mode} - - vendor = None - if self.request.method == 'POST' and self.request.POST.get('vendor'): - vendor = self.Session.query(model.Vendor).get(self.request.POST['vendor']) - if vendor: - - # fetch first to avoid flush below - store = self.rattail_config.get_store(self.Session()) - - batch = self.model_class() - batch.mode = mode - batch.vendor = vendor - batch.store = store - batch.buyer = self.request.user.employee - batch.created_by = self.request.user - batch.po_total = 0 - kwargs = self.get_batch_kwargs(batch, mobile=True) - batch = self.handler.make_batch(self.Session(), **kwargs) - if self.handler.should_populate(batch): - self.handler.populate(batch) - return self.redirect(self.request.route_url('mobile.ordering.view', uuid=batch.uuid)) - - data['index_title'] = self.get_index_title() - data['index_url'] = self.get_index_url(mobile=True) - data['mode_title'] = self.enum.PURCHASE_BATCH_MODE[mode].capitalize() - - data['vendor_use_autocomplete'] = self.rattail_config.getbool( - 'rattail', 'vendor.use_autocomplete', default=True) - if not data['vendor_use_autocomplete']: - vendors = self.Session.query(model.Vendor)\ - .order_by(model.Vendor.name) - options = [(tags.Option(vendor.name, vendor.uuid)) - for vendor in vendors] - options.insert(0, tags.Option("(please choose)", '')) - data['vendor_options'] = options - - return self.render_to_response('create', data, mobile=True) - - def configure_mobile_row_form(self, f): - super(OrderingBatchView, self).configure_mobile_row_form(f) - if self.editing: - # TODO: probably should take `allow_cases` into account here... - f.focus_spec = '[name="units_ordered"]' - def download_excel(self): """ Download ordering batch as Excel spreadsheet. diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 17d2eddf..ff82595e 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -48,78 +48,11 @@ from webhelpers2.html import tags, HTML from tailbone import forms, grids from tailbone.views.purchasing import PurchasingBatchView -from tailbone.forms.receiving import ReceiveRow as MobileReceivingForm log = logging.getLogger(__name__) -class MobileItemStatusFilter(grids.filters.MobileFilter): - - value_choices = ['incomplete', 'unexpected', 'damaged', 'expired', 'all'] - - def filter_equal(self, query, value): - - # NOTE: this is only relevant for truck dump or "from scratch" - if value == 'received': - return query.filter(sa.or_( - model.PurchaseBatchRow.cases_received != 0, - model.PurchaseBatchRow.units_received != 0)) - - if value == 'incomplete': - # looking for any rows with "ordered" quantity, but where the - # status does *not* signify a "settled" row so to speak - # TODO: would be nice if we had a simple flag to leverage? - return query.filter(sa.or_(model.PurchaseBatchRow.cases_ordered != 0, - model.PurchaseBatchRow.units_ordered != 0))\ - .filter(~model.PurchaseBatchRow.status_code.in_(( - model.PurchaseBatchRow.STATUS_OK, - model.PurchaseBatchRow.STATUS_PRODUCT_NOT_FOUND, - model.PurchaseBatchRow.STATUS_CASE_QUANTITY_DIFFERS))) - - if value == 'invalid': - return query.filter(model.PurchaseBatchRow.status_code.in_(( - model.PurchaseBatchRow.STATUS_PRODUCT_NOT_FOUND, - model.PurchaseBatchRow.STATUS_COST_NOT_FOUND, - model.PurchaseBatchRow.STATUS_CASE_QUANTITY_UNKNOWN, - model.PurchaseBatchRow.STATUS_CASE_QUANTITY_DIFFERS, - ))) - - if value == 'unexpected': - # looking for any rows which have "received" quantity but which - # do *not* have any "ordered" quantity - return query.filter(sa.and_( - sa.or_( - model.PurchaseBatchRow.cases_ordered == None, - model.PurchaseBatchRow.cases_ordered == 0), - sa.or_( - model.PurchaseBatchRow.units_ordered == None, - model.PurchaseBatchRow.units_ordered == 0), - sa.or_( - model.PurchaseBatchRow.cases_received != 0, - model.PurchaseBatchRow.units_received != 0, - model.PurchaseBatchRow.cases_damaged != 0, - model.PurchaseBatchRow.units_damaged != 0, - model.PurchaseBatchRow.cases_expired != 0, - model.PurchaseBatchRow.units_expired != 0))) - - if value == 'damaged': - return query.filter(sa.or_( - model.PurchaseBatchRow.cases_damaged != 0, - model.PurchaseBatchRow.units_damaged != 0)) - - if value == 'expired': - return query.filter(sa.or_( - model.PurchaseBatchRow.cases_expired != 0, - model.PurchaseBatchRow.units_expired != 0)) - - return query - - def iter_choices(self): - for value in self.value_choices: - yield value, prettify(value) - - class ReceivingBatchView(PurchasingBatchView): """ Master view for receiving batches @@ -132,11 +65,6 @@ class ReceivingBatchView(PurchasingBatchView): downloadable = True bulk_deletable = True rows_editable = True - mobile_creatable = True - mobile_rows_filterable = True - mobile_rows_creatable = True - mobile_rows_quickable = True - mobile_rows_deletable = True allow_from_po = False allow_from_scratch = True @@ -207,11 +135,6 @@ class ReceivingBatchView(PurchasingBatchView): 'executed_by', ] - mobile_form_fields = [ - 'vendor', - 'department', - ] - row_grid_columns = [ 'sequence', 'upc', @@ -295,20 +218,9 @@ class ReceivingBatchView(PurchasingBatchView): if batch.executed or batch.complete: return False - # can "always" delete rows from truck dump parent... + # can always delete rows from truck dump parent if batch.is_truck_dump_parent(): - - # ...but only on desktop! - if not self.mobile: - return True - - # ...for mobile we only allow deletion of rows which did *not* come - # from a child batch, i.e. can delete ad-hoc rows only - # TODO: should have a better way to detect this; for now we rely on - # the fact that only rows from an invoice or similar would have - # order quantities - if not (row.cases_ordered or row.units_ordered): - return True + return True # can always delete rows from truck dump child elif batch.is_truck_dump_child(): @@ -466,33 +378,32 @@ class ReceivingBatchView(PurchasingBatchView): kwargs['batch_vendor_map'] = vmap return kwargs - def get_batch_kwargs(self, batch, mobile=False): - kwargs = super(ReceivingBatchView, self).get_batch_kwargs(batch, mobile=mobile) - if not mobile: - batch_type = self.request.POST['batch_type'] - if batch_type == 'from_scratch': - kwargs.pop('truck_dump_batch', None) - kwargs.pop('truck_dump_batch_uuid', None) - elif batch_type == 'truck_dump_children_first': - kwargs['truck_dump'] = True - kwargs['truck_dump_children_first'] = True - kwargs['order_quantities_known'] = True - # TODO: this makes sense in some cases, but all? - # (should just omit that field when not relevant) - kwargs['date_ordered'] = None - elif batch_type == 'truck_dump_children_last': - kwargs['truck_dump'] = True - kwargs['truck_dump_ready'] = True - # TODO: this makes sense in some cases, but all? - # (should just omit that field when not relevant) - kwargs['date_ordered'] = None - elif batch_type.startswith('truck_dump_child'): - truck_dump = self.get_instance() - kwargs['store'] = truck_dump.store - kwargs['vendor'] = truck_dump.vendor - kwargs['truck_dump_batch'] = truck_dump - else: - raise NotImplementedError + def get_batch_kwargs(self, batch, **kwargs): + kwargs = super(ReceivingBatchView, self).get_batch_kwargs(batch, **kwargs) + batch_type = self.request.POST['batch_type'] + if batch_type == 'from_scratch': + kwargs.pop('truck_dump_batch', None) + kwargs.pop('truck_dump_batch_uuid', None) + elif batch_type == 'truck_dump_children_first': + kwargs['truck_dump'] = True + kwargs['truck_dump_children_first'] = True + kwargs['order_quantities_known'] = True + # TODO: this makes sense in some cases, but all? + # (should just omit that field when not relevant) + kwargs['date_ordered'] = None + elif batch_type == 'truck_dump_children_last': + kwargs['truck_dump'] = True + kwargs['truck_dump_ready'] = True + # TODO: this makes sense in some cases, but all? + # (should just omit that field when not relevant) + kwargs['date_ordered'] = None + elif batch_type.startswith('truck_dump_child'): + truck_dump = self.get_instance() + kwargs['store'] = truck_dump.store + kwargs['vendor'] = truck_dump.vendor + kwargs['truck_dump_batch'] = truck_dump + else: + raise NotImplementedError return kwargs def department_for_purchase(self, purchase): @@ -608,140 +519,6 @@ class ReceivingBatchView(PurchasingBatchView): url = self.request.route_url('receiving.view', uuid=truck_dump.uuid) return tags.link_to(text, url) - def render_mobile_listitem(self, batch, i): - title = "({}) {} for ${:0,.2f} - {}, {}".format( - batch.id_str, - batch.vendor, - batch.invoice_total or batch.po_total or 0, - batch.department, - batch.created_by) - return title - - def make_mobile_row_filters(self): - """ - Returns a set of filters for the mobile row grid. - """ - batch = self.get_instance() - filters = grids.filters.GridFilterSet() - - # visible filter options will depend on whether batch came from purchase - if batch.order_quantities_known: - value_choices = ['incomplete', 'unexpected', 'damaged', 'expired', 'invalid', 'all'] - default_status = 'incomplete' - else: - value_choices = ['received', 'damaged', 'expired', 'invalid', 'all'] - default_status = 'all' - - # remove 'expired' filter option if not relevant - if 'expired' in value_choices and not self.handler.allow_expired_credits(): - value_choices.remove('expired') - - filters['status'] = MobileItemStatusFilter('status', - value_choices=value_choices, - default_value=default_status) - return filters - - def mobile_create(self): - """ - Mobile view for creating a new receiving batch - """ - mode = self.batch_mode - data = {'mode': mode} - phase = 1 - - schema = MobileNewReceivingBatch().bind(session=self.Session()) - form = forms.Form(schema=schema, request=self.request) - if form.validate(newstyle=True): - phase = form.validated['phase'] - - if form.validated['workflow'] == 'from_scratch': - if not self.allow_from_scratch: - raise NotImplementedError("Requested workflow not supported: from_scratch") - batch = self.model_class() - batch.store = self.rattail_config.get_store(self.Session()) - batch.mode = mode - batch.vendor = self.Session.query(model.Vendor).get(form.validated['vendor']) - batch.created_by = self.request.user - batch.date_received = localtime(self.rattail_config).date() - kwargs = self.get_batch_kwargs(batch, mobile=True) - batch = self.handler.make_batch(self.Session(), **kwargs) - return self.redirect(self.get_action_url('view', batch, mobile=True)) - - elif form.validated['workflow'] == 'truck_dump': - if not self.allow_truck_dump: - raise NotImplementedError("Requested workflow not supported: truck_dump") - batch = self.model_class() - batch.store = self.rattail_config.get_store(self.Session()) - batch.mode = mode - batch.truck_dump = True - batch.vendor = self.Session.query(model.Vendor).get(form.validated['vendor']) - batch.created_by = self.request.user - batch.date_received = localtime(self.rattail_config).date() - kwargs = self.get_batch_kwargs(batch, mobile=True) - batch = self.handler.make_batch(self.Session(), **kwargs) - return self.redirect(self.get_action_url('view', batch, mobile=True)) - - elif form.validated['workflow'] == 'from_po': - if not self.allow_from_po: - raise NotImplementedError("Requested workflow not supported: from_po") - - vendor = self.Session.query(model.Vendor).get(form.validated['vendor']) - data['vendor'] = vendor - - schema = self.make_mobile_receiving_from_po_schema() - po_form = forms.Form(schema=schema, request=self.request) - if phase == 2: - if po_form.validate(newstyle=True): - batch = self.model_class() - batch.store = self.rattail_config.get_store(self.Session()) - batch.mode = mode - batch.vendor = vendor - batch.buyer = self.request.user.employee - batch.created_by = self.request.user - batch.date_received = localtime(self.rattail_config).date() - self.assign_purchase_order(batch, po_form) - kwargs = self.get_batch_kwargs(batch, mobile=True) - batch = self.handler.make_batch(self.Session(), **kwargs) - if self.handler.should_populate(batch): - self.handler.populate(batch) - return self.redirect(self.get_action_url('view', batch, mobile=True)) - - else: - phase = 2 - - else: - raise NotImplementedError("Requested workflow not supported: {}".format(form.validated['workflow'])) - - data['form'] = form - data['dform'] = form.make_deform_form() - data['mode_title'] = self.enum.PURCHASE_BATCH_MODE[mode].capitalize() - data['phase'] = phase - - if phase == 1: - data['vendor_use_autocomplete'] = self.rattail_config.getbool( - 'rattail', 'vendor.use_autocomplete', default=True) - if not data['vendor_use_autocomplete']: - vendors = self.Session.query(model.Vendor)\ - .order_by(model.Vendor.name) - options = [(tags.Option(vendor.name, vendor.uuid)) - for vendor in vendors] - options.insert(0, tags.Option("(please choose)", '')) - data['vendor_options'] = options - - elif phase == 2: - purchases = self.eligible_purchases(vendor.uuid, mode=mode) - data['purchases'] = [(p['key'], p['display']) for p in purchases['purchases']] - data['purchase_order_fieldname'] = self.purchase_order_fieldname - - return self.render_to_response('create', data, mobile=True) - - def make_mobile_receiving_from_po_schema(self): - schema = colander.MappingSchema() - schema.add(colander.SchemaNode(colander.String(), - name=self.purchase_order_fieldname, - validator=self.validate_purchase)) - return schema.bind(session=self.Session()) - @staticmethod @colander.deferred def validate_purchase(node, kw): @@ -766,20 +543,6 @@ class ReceivingBatchView(PurchasingBatchView): if department: batch.department_uuid = department.uuid - def configure_mobile_form(self, f): - super(ReceivingBatchView, self).configure_mobile_form(f) - batch = f.model_instance - - # truck_dump - if not self.creating: - if not batch.is_truck_dump_parent(): - f.remove_field('truck_dump') - - # department - if not self.creating: - if batch.is_truck_dump_parent(): - f.remove_field('department') - def configure_row_grid(self, g): super(ReceivingBatchView, self).configure_row_grid(g) g.set_label('department_name', "Department") @@ -858,7 +621,7 @@ class ReceivingBatchView(PurchasingBatchView): if row.product and row.product.is_pack_item(): return self.get_row_action_url('transform_unit', row) - def receive_row(self, mobile=False): + def receive_row(self, **kwargs): """ Primary desktop view for row-level receiving. """ @@ -866,7 +629,6 @@ class ReceivingBatchView(PurchasingBatchView): # tries to pave the way for shared logic, i.e. where the latter would # simply invoke this method and return the result. however we're not # there yet...for now it's only tested for desktop - self.mobile = mobile self.viewing = True row = self.get_row_instance() batch = row.batch @@ -890,23 +652,14 @@ class ReceivingBatchView(PurchasingBatchView): 'quick_receive_all': False, } - if mobile: - context['quick_receive'] = self.rattail_config.getbool('rattail.batch', 'purchase.mobile_quick_receive', - default=True) - if batch.order_quantities_known: - context['quick_receive_all'] = self.rattail_config.getbool('rattail.batch', 'purchase.mobile_quick_receive_all', - default=False) - schema = ReceiveRowForm().bind(session=self.Session()) form = forms.Form(schema=schema, request=self.request) - form.cancel_url = self.get_row_action_url('view', row, mobile=mobile) + form.cancel_url = self.get_row_action_url('view', row) form.set_widget('mode', forms.widgets.JQuerySelectWidget(values=[(m, m) for m in possible_modes])) form.set_widget('quantity', forms.widgets.CasesUnitsWidget(amount_required=True, one_amount_only=True)) form.set_type('expiration_date', 'date_jquery') - - if not mobile: - form.remove_field('quick_receive') + form.remove_field('quick_receive') if form.validate(newstyle=True): @@ -921,20 +674,17 @@ class ReceivingBatchView(PurchasingBatchView): # whether or not it was 'CS' since the unit_uom can vary # TODO: should this be done for desktop too somehow? sticky_case = None - if mobile and not form.validated['quick_receive']: - cases = form.validated['cases'] - units = form.validated['units'] - if cases and not units: - sticky_case = True - elif units and not cases: - sticky_case = False + # if mobile and not form.validated['quick_receive']: + # cases = form.validated['cases'] + # units = form.validated['units'] + # if cases and not units: + # sticky_case = True + # elif units and not cases: + # sticky_case = False if sticky_case is not None: self.request.session['tailbone.mobile.receiving.sticky_uom_is_case'] = sticky_case - if mobile: - return self.redirect(self.get_action_url('view', batch, mobile=True)) - else: - return self.redirect(self.get_row_action_url('view', row)) + return self.redirect(self.get_row_action_url('view', row)) # unit_uom can vary by product context['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA' @@ -968,9 +718,9 @@ class ReceivingBatchView(PurchasingBatchView): # effective uom can vary in a few ways...the basic default is 'CS' if # self.default_uom_is_case is true, otherwise whatever unit_uom is. sticky_case = None - if mobile: - # TODO: should do this for desktop also, but rename the session variable - sticky_case = self.request.session.get('tailbone.mobile.receiving.sticky_uom_is_case') + # if mobile: + # # TODO: should do this for desktop also, but rename the session variable + # sticky_case = self.request.session.get('tailbone.mobile.receiving.sticky_uom_is_case') if sticky_case is None: context['uom'] = 'CS' if self.default_uom_is_case else context['unit_uom'] elif sticky_case: @@ -980,37 +730,37 @@ class ReceivingBatchView(PurchasingBatchView): if context['uom'] == 'CS' and row.units_ordered and not row.cases_ordered: context['uom'] = context['unit_uom'] - # TODO: should do this for desktop in addition to mobile? - if mobile and batch.order_quantities_known and not row.cases_ordered and not row.units_ordered: - warn = True - if batch.is_truck_dump_parent() and row.product: - uuids = [child.uuid for child in batch.truck_dump_children] - if uuids: - count = self.Session.query(model.PurchaseBatchRow)\ - .filter(model.PurchaseBatchRow.batch_uuid.in_(uuids))\ - .filter(model.PurchaseBatchRow.product == row.product)\ - .count() - if count: - warn = False - if warn: - self.request.session.flash("This item was NOT on the original purchase order.", 'receiving-warning') + # # TODO: should do this for desktop in addition to mobile? + # if mobile and batch.order_quantities_known and not row.cases_ordered and not row.units_ordered: + # warn = True + # if batch.is_truck_dump_parent() and row.product: + # uuids = [child.uuid for child in batch.truck_dump_children] + # if uuids: + # count = self.Session.query(model.PurchaseBatchRow)\ + # .filter(model.PurchaseBatchRow.batch_uuid.in_(uuids))\ + # .filter(model.PurchaseBatchRow.product == row.product)\ + # .count() + # if count: + # warn = False + # if warn: + # self.request.session.flash("This item was NOT on the original purchase order.", 'receiving-warning') - # TODO: should do this for desktop in addition to mobile? - if mobile: - # maybe alert user if they've already received some of this product - alert_received = self.rattail_config.getbool('tailbone', 'receiving.alert_already_received', - default=False) - if alert_received: - 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)) - self.request.session.flash(msg, 'receiving-warning') + # # TODO: should do this for desktop in addition to mobile? + # if mobile: + # # maybe alert user if they've already received some of this product + # alert_received = self.rattail_config.getbool('tailbone', 'receiving.alert_already_received', + # default=False) + # if alert_received: + # 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)) + # self.request.session.flash(msg, 'receiving-warning') context['form'] = form context['dform'] = form.make_deform_form() - context['parent_url'] = self.get_action_url('view', batch, mobile=mobile) + context['parent_url'] = self.get_action_url('view', batch) context['parent_title'] = self.get_instance_title(batch) - return self.render_to_response('receive_row', context, mobile=mobile) + return self.render_to_response('receive_row', context) def declare_credit(self): """ @@ -1418,8 +1168,8 @@ class ReceivingBatchView(PurchasingBatchView): self.Session.flush() return row - def redirect_after_edit_row(self, row, mobile=False): - return self.redirect(self.get_row_action_url('view', row, mobile=mobile)) + def redirect_after_edit_row(self, row, **kwargs): + return self.redirect(self.get_row_action_url('view', row)) def update_row_cost(self): """ @@ -1463,287 +1213,16 @@ class ReceivingBatchView(PurchasingBatchView): }, } - def render_mobile_row_listitem(self, row, i): - key = self.render_product_key_value(row) - description = row.product.full_description if row.product else row.description - return "({}) {}".format(key, description) - - def make_mobile_row_grid_kwargs(self, **kwargs): - kwargs = super(ReceivingBatchView, self).make_mobile_row_grid_kwargs(**kwargs) - - # use custom `receive_row` instead of `view_row` - # TODO: should still use `view_row` in some cases? e.g. executed batch - kwargs['url'] = lambda obj: self.get_row_action_url('receive', obj, mobile=True) - - return kwargs - def save_quick_row_form(self, form): batch = self.get_instance() entry = form.validated['quick_entry'] row = self.handler.quick_entry(self.Session(), batch, entry) return row - def redirect_after_quick_row(self, row, mobile=False): - if mobile: - return self.redirect(self.get_row_action_url('receive', row, mobile=mobile)) - return super(ReceivingBatchView, self).redirect_after_quick_row(row, mobile=mobile) - def get_row_image_url(self, row): if self.rattail_config.getbool('rattail.batch', 'purchase.mobile_images', default=True): return pod.get_image_url(self.rattail_config, row.upc) - def get_mobile_data(self, session=None): - query = super(ReceivingBatchView, self).get_mobile_data(session=session) - - # do not expose truck dump child batches on mobile - # TODO: is there any case where we *would* want to? - query = query.filter(model.PurchaseBatch.truck_dump_batch == None) - - return query - - def mobile_view_row(self): - """ - Mobile view for receiving batch row items. Note that this also handles - updating a row. - """ - self.mobile = True - self.viewing = True - row = self.get_row_instance() - batch = row.batch - permission_prefix = self.get_permission_prefix() - form = self.make_mobile_row_form(row) - context = { - 'row': row, - 'batch': batch, - 'parent_instance': batch, - 'instance': row, - 'instance_title': self.get_row_instance_title(row), - 'parent_model_title': self.get_model_title(), - 'product_image_url': self.get_row_image_url(row), - 'form': form, - 'allow_expired': self.handler.allow_expired_credits(), - 'allow_cases': self.handler.allow_cases(), - 'quick_receive': False, - 'quick_receive_all': False, - } - - context['quick_receive'] = self.rattail_config.getbool('rattail.batch', 'purchase.mobile_quick_receive', - default=True) - if batch.order_quantities_known: - context['quick_receive_all'] = self.rattail_config.getbool('rattail.batch', 'purchase.mobile_quick_receive_all', - default=False) - - if self.request.has_perm('{}.create_row'.format(permission_prefix)): - schema = MobileReceivingForm().bind(session=self.Session()) - update_form = forms.Form(schema=schema, request=self.request) - # TODO: this seems hacky, but avoids "complex" date value parsing - update_form.set_widget('expiration_date', dfwidget.TextInputWidget()) - if update_form.validate(newstyle=True): - row = self.Session.query(model.PurchaseBatchRow).get(update_form.validated['row']) - mode = update_form.validated['mode'] - cases = update_form.validated['cases'] - units = update_form.validated['units'] - - # handler takes care of the row receiving logic for us - kwargs = dict(update_form.validated) - del kwargs['row'] - self.handler.receive_row(row, **kwargs) - - # keep track of last-used uom, although we just track - # whether or not it was 'CS' since the unit_uom can vary - sticky_case = None - if not update_form.validated['quick_receive']: - if cases and not units: - sticky_case = True - elif units and not cases: - sticky_case = False - if sticky_case is not None: - self.request.session['tailbone.mobile.receiving.sticky_uom_is_case'] = sticky_case - - return self.redirect(self.get_action_url('view', batch, mobile=True)) - - # unit_uom can vary by product - context['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA' - - if context['quick_receive'] and context['quick_receive_all']: - if context['allow_cases']: - context['quick_receive_uom'] = 'CS' - raise NotImplementedError("TODO: add CS support for quick_receive_all") - else: - context['quick_receive_uom'] = context['unit_uom'] - accounted_for = self.handler.get_units_accounted_for(row) - remainder = self.handler.get_units_ordered(row) - accounted_for - - if accounted_for: - # some product accounted for; button should receive "remainder" only - if remainder: - remainder = pretty_quantity(remainder) - context['quick_receive_quantity'] = remainder - context['quick_receive_text'] = "Receive Remainder ({} {})".format(remainder, context['unit_uom']) - else: - # unless there is no remainder, in which case disable it - context['quick_receive'] = False - - else: # nothing yet accounted for, button should receive "all" - if not remainder: - raise ValueError("why is remainder empty?") - remainder = pretty_quantity(remainder) - context['quick_receive_quantity'] = remainder - context['quick_receive_text'] = "Receive ALL ({} {})".format(remainder, context['unit_uom']) - - # effective uom can vary in a few ways...the basic default is 'CS' if - # self.default_uom_is_case is true, otherwise whatever unit_uom is. - sticky_case = self.request.session.get('tailbone.mobile.receiving.sticky_uom_is_case') - if sticky_case is None: - context['uom'] = 'CS' if self.default_uom_is_case else context['unit_uom'] - elif sticky_case: - context['uom'] = 'CS' - else: - context['uom'] = context['unit_uom'] - if context['uom'] == 'CS' and row.units_ordered and not row.cases_ordered: - context['uom'] = context['unit_uom'] - - if batch.order_quantities_known and not row.cases_ordered and not row.units_ordered: - warn = True - if batch.is_truck_dump_parent() and row.product: - uuids = [child.uuid for child in batch.truck_dump_children] - if uuids: - count = self.Session.query(model.PurchaseBatchRow)\ - .filter(model.PurchaseBatchRow.batch_uuid.in_(uuids))\ - .filter(model.PurchaseBatchRow.product == row.product)\ - .count() - if count: - warn = False - if warn: - self.request.session.flash("This item was NOT on the original purchase order.", 'receiving-warning') - return self.render_to_response('view_row', context, mobile=True) - - def mobile_receive_row(self): - """ - Mobile view for row-level receiving. - """ - self.mobile = True - self.viewing = True - row = self.get_row_instance() - batch = row.batch - permission_prefix = self.get_permission_prefix() - form = self.make_mobile_row_form(row) - context = { - 'row': row, - 'batch': batch, - 'parent_instance': batch, - 'instance': row, - 'instance_title': self.get_row_instance_title(row), - 'parent_model_title': self.get_model_title(), - 'product_image_url': self.get_row_image_url(row), - 'form': form, - 'allow_expired': self.handler.allow_expired_credits(), - 'allow_cases': self.handler.allow_cases(), - 'quick_receive': False, - 'quick_receive_all': False, - } - - context['quick_receive'] = self.rattail_config.getbool('rattail.batch', 'purchase.mobile_quick_receive', - default=True) - if batch.order_quantities_known: - context['quick_receive_all'] = self.rattail_config.getbool('rattail.batch', 'purchase.mobile_quick_receive_all', - default=False) - - if self.request.has_perm('{}.create_row'.format(permission_prefix)): - schema = MobileReceivingForm().bind(session=self.Session()) - update_form = forms.Form(schema=schema, request=self.request) - # TODO: this seems hacky, but avoids "complex" date value parsing - update_form.set_widget('expiration_date', dfwidget.TextInputWidget()) - if update_form.validate(newstyle=True): - row = self.Session.query(model.PurchaseBatchRow).get(update_form.validated['row']) - mode = update_form.validated['mode'] - cases = update_form.validated['cases'] - units = update_form.validated['units'] - - # handler takes care of the row receiving logic for us - kwargs = dict(update_form.validated) - del kwargs['row'] - self.handler.receive_row(row, **kwargs) - - # keep track of last-used uom, although we just track - # whether or not it was 'CS' since the unit_uom can vary - sticky_case = None - if not update_form.validated['quick_receive']: - if cases and not units: - sticky_case = True - elif units and not cases: - sticky_case = False - if sticky_case is not None: - self.request.session['tailbone.mobile.receiving.sticky_uom_is_case'] = sticky_case - - return self.redirect(self.get_action_url('view', batch, mobile=True)) - - # unit_uom can vary by product - context['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA' - - if context['quick_receive'] and context['quick_receive_all']: - if context['allow_cases']: - context['quick_receive_uom'] = 'CS' - raise NotImplementedError("TODO: add CS support for quick_receive_all") - else: - context['quick_receive_uom'] = context['unit_uom'] - accounted_for = self.handler.get_units_accounted_for(row) - remainder = self.handler.get_units_ordered(row) - accounted_for - - if accounted_for: - # some product accounted for; button should receive "remainder" only - if remainder: - remainder = pretty_quantity(remainder) - context['quick_receive_quantity'] = remainder - context['quick_receive_text'] = "Receive Remainder ({} {})".format(remainder, context['unit_uom']) - else: - # unless there is no remainder, in which case disable it - context['quick_receive'] = False - - else: # nothing yet accounted for, button should receive "all" - if not remainder: - raise ValueError("why is remainder empty?") - remainder = pretty_quantity(remainder) - context['quick_receive_quantity'] = remainder - context['quick_receive_text'] = "Receive ALL ({} {})".format(remainder, context['unit_uom']) - - # effective uom can vary in a few ways...the basic default is 'CS' if - # self.default_uom_is_case is true, otherwise whatever unit_uom is. - sticky_case = self.request.session.get('tailbone.mobile.receiving.sticky_uom_is_case') - if sticky_case is None: - context['uom'] = 'CS' if self.default_uom_is_case else context['unit_uom'] - elif sticky_case: - context['uom'] = 'CS' - else: - context['uom'] = context['unit_uom'] - if context['uom'] == 'CS' and row.units_ordered and not row.cases_ordered: - context['uom'] = context['unit_uom'] - - if batch.order_quantities_known and not row.cases_ordered and not row.units_ordered: - warn = True - if batch.is_truck_dump_parent() and row.product: - uuids = [child.uuid for child in batch.truck_dump_children] - if uuids: - count = self.Session.query(model.PurchaseBatchRow)\ - .filter(model.PurchaseBatchRow.batch_uuid.in_(uuids))\ - .filter(model.PurchaseBatchRow.product == row.product)\ - .count() - if count: - warn = False - if warn: - self.request.session.flash("This item was NOT on the original purchase order.", 'receiving-warning') - - # maybe alert user if they've already received some of this product - alert_received = self.rattail_config.getbool('tailbone', 'receiving.alert_already_received', - default=False) - if alert_received: - 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)) - self.request.session.flash(msg, 'receiving-warning') - - return self.render_to_response('receive_row', context, mobile=True) - def auto_receive(self): """ View which can "auto-receive" all items in the batch. Meant only as a @@ -1804,16 +1283,11 @@ class ReceivingBatchView(PurchasingBatchView): instance_url_prefix = cls.get_instance_url_prefix() model_key = cls.get_model_key() permission_prefix = cls.get_permission_prefix() - legacy_mobile = cls.legacy_mobile_enabled(rattail_config) # row-level receiving config.add_route('{}.receive_row'.format(route_prefix), '{}/{{uuid}}/rows/{{row_uuid}}/receive'.format(url_prefix)) config.add_view(cls, attr='receive_row', route_name='{}.receive_row'.format(route_prefix), permission='{}.edit_row'.format(permission_prefix)) - if legacy_mobile: - config.add_route('mobile.{}.receive_row'.format(route_prefix), '/mobile{}/{{uuid}}/rows/{{row_uuid}}/receive'.format(url_prefix)) - config.add_view(cls, attr='mobile_receive_row', route_name='mobile.{}.receive_row'.format(route_prefix), - permission='{}.edit_row'.format(permission_prefix)) # declare credit for row config.add_route('{}.declare_credit'.format(route_prefix), '{}/{{uuid}}/rows/{{row_uuid}}/declare-credit'.format(url_prefix)) @@ -1854,40 +1328,6 @@ class ReceivingBatchView(PurchasingBatchView): cls._defaults(config) -# TODO: this is a stopgap measure to fix an obvious bug, which exists when the -# session is not provided by the view at runtime (i.e. when it was instead -# being provided by the type instance, which was created upon app startup). -@colander.deferred -def valid_vendor(node, kw): - session = kw['session'] - def validate(node, value): - vendor = session.query(model.Vendor).get(value) - if not vendor: - raise colander.Invalid(node, "Vendor not found") - return vendor.uuid - return validate - - -class MobileNewReceivingBatch(colander.MappingSchema): - - vendor = colander.SchemaNode(colander.String(), - validator=valid_vendor) - - workflow = colander.SchemaNode(colander.String(), - validator=colander.OneOf([ - 'from_po', - 'from_scratch', - 'truck_dump', - ])) - - phase = colander.SchemaNode(colander.Int()) - - -class MobileNewReceivingFromPO(colander.MappingSchema): - - purchase = colander.SchemaNode(colander.String()) - - class ReceiveRowForm(colander.MappingSchema): mode = colander.SchemaNode(colander.String(), From ff2e39f67a50c56b92fa03f365c52a4c1e700479 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 30 Jan 2021 16:56:30 -0600 Subject: [PATCH 0291/1681] Make handler responsible for possible receiving modes --- tailbone/templates/receiving/create.mako | 2 +- tailbone/templates/receiving/view.mako | 4 +- tailbone/views/purchasing/receiving.py | 47 +++++++++++------------- 3 files changed, 24 insertions(+), 29 deletions(-) diff --git a/tailbone/templates/receiving/create.mako b/tailbone/templates/receiving/create.mako index 1763b50f..c31cb849 100644 --- a/tailbone/templates/receiving/create.mako +++ b/tailbone/templates/receiving/create.mako @@ -7,7 +7,7 @@ ${self.func_show_batch_type()} <script type="text/javascript"> - % if master.allow_truck_dump: + % if master.handler.allow_truck_dump_receiving(): var batch_vendor_map = ${json.dumps(batch_vendor_map)|n}; % endif diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako index 4bdf5862..82e51f6b 100644 --- a/tailbone/templates/receiving/view.mako +++ b/tailbone/templates/receiving/view.mako @@ -286,7 +286,7 @@ <%def name="object_helpers()"> ${parent.object_helpers()} ## TODO: for now this is a truck-dump-only feature? maybe should change that - % if not request.rattail_config.production() and master.allow_truck_dump: + % if not request.rattail_config.production() and master.handler.allow_truck_dump_receiving(): % if not batch.executed and not batch.complete and request.has_perm('admin'): % if (batch.is_truck_dump_parent() and batch.truck_dump_children_first) or not batch.is_truck_dump_related(): <div class="object-helper"> @@ -306,7 +306,7 @@ ${parent.body()} -% if master.allow_truck_dump and request.has_perm('{}.edit_row'.format(permission_prefix)): +% if 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/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index ff82595e..99f84409 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -66,10 +66,6 @@ class ReceivingBatchView(PurchasingBatchView): bulk_deletable = True rows_editable = True - allow_from_po = False - allow_from_scratch = True - allow_truck_dump = False - default_uom_is_case = True purchase_order_fieldname = 'purchase' @@ -246,15 +242,16 @@ class ReceivingBatchView(PurchasingBatchView): def configure_form(self, f): super(ReceivingBatchView, self).configure_form(f) batch = f.model_instance + allow_truck_dump = self.handler.allow_truck_dump_receiving() # batch_type if self.creating: batch_types = OrderedDict() - if self.allow_from_scratch: + if self.handler.allow_receiving_from_scratch(): batch_types['from_scratch'] = "From Scratch" - if self.allow_from_po: + if self.handler.allow_receiving_from_purchase_order(): batch_types['from_po'] = "From PO" - if self.allow_truck_dump: + if allow_truck_dump: batch_types['truck_dump_children_first'] = "Truck Dump (children FIRST)" batch_types['truck_dump_children_last'] = "Truck Dump (children LAST)" f.set_enum('batch_type', batch_types) @@ -262,7 +259,7 @@ class ReceivingBatchView(PurchasingBatchView): f.remove_field('batch_type') # truck_dump* - if self.allow_truck_dump: + if allow_truck_dump: # truck_dump if self.creating or not batch.is_truck_dump_parent(): @@ -367,7 +364,7 @@ class ReceivingBatchView(PurchasingBatchView): def template_kwargs_create(self, **kwargs): kwargs = super(ReceivingBatchView, self).template_kwargs_create(**kwargs) - if self.allow_truck_dump: + if self.handler.allow_truck_dump_receiving(): vmap = {} batches = self.Session.query(model.PurchaseBatch)\ .filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING)\ @@ -1300,24 +1297,22 @@ class ReceivingBatchView(PurchasingBatchView): permission='{}.edit_row'.format(permission_prefix), renderer='json') - if cls.allow_truck_dump: + # add TD child batch, from invoice file + config.add_route('{}.add_child_from_invoice'.format(route_prefix), '{}/{{{}}}/add-child-from-invoice'.format(url_prefix, model_key)) + config.add_view(cls, attr='add_child_from_invoice', route_name='{}.add_child_from_invoice'.format(route_prefix), + permission='{}.create'.format(permission_prefix)) - # add TD child batch, from invoice file - config.add_route('{}.add_child_from_invoice'.format(route_prefix), '{}/{{{}}}/add-child-from-invoice'.format(url_prefix, model_key)) - config.add_view(cls, attr='add_child_from_invoice', route_name='{}.add_child_from_invoice'.format(route_prefix), - permission='{}.create'.format(permission_prefix)) + # transform TD parent row from "pack" to "unit" item + config.add_route('{}.transform_unit_row'.format(route_prefix), '{}/{{{}}}/transform-unit'.format(url_prefix, model_key)) + config.add_view(cls, attr='transform_unit_row', route_name='{}.transform_unit_row'.format(route_prefix), + permission='{}.edit_row'.format(permission_prefix), renderer='json') - # transform TD parent row from "pack" to "unit" item - config.add_route('{}.transform_unit_row'.format(route_prefix), '{}/{{{}}}/transform-unit'.format(url_prefix, model_key)) - config.add_view(cls, attr='transform_unit_row', route_name='{}.transform_unit_row'.format(route_prefix), - permission='{}.edit_row'.format(permission_prefix), renderer='json') - - # auto-receive all items - if not rattail_config.production(): - config.add_route('{}.auto_receive'.format(route_prefix), '{}/{{{}}}/auto-receive'.format(url_prefix, model_key), - request_method='POST') - config.add_view(cls, attr='auto_receive', route_name='{}.auto_receive'.format(route_prefix), - permission='admin') + # auto-receive all items + if not rattail_config.production(): + config.add_route('{}.auto_receive'.format(route_prefix), '{}/{{{}}}/auto-receive'.format(url_prefix, model_key), + request_method='POST') + config.add_view(cls, attr='auto_receive', route_name='{}.auto_receive'.format(route_prefix), + permission='admin') @classmethod From a2b7f882bceecb56636bea453fdce05947ab7848 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 30 Jan 2021 19:54:38 -0600 Subject: [PATCH 0292/1681] Split "new receiving batch" process into 2 steps: choose, create so that the form used to create the batch can be made custom per-workflow, and it won't have to think about any other workflows since we just use one form at a time for that --- tailbone/views/purchasing/receiving.py | 193 ++++++++++++++++++++++--- 1 file changed, 172 insertions(+), 21 deletions(-) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 99f84409..04d0d624 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -93,7 +93,8 @@ class ReceivingBatchView(PurchasingBatchView): form_fields = [ 'id', - 'batch_type', + 'batch_type', # TODO: ideally would get rid of this one + 'receiving_workflow', 'store', 'vendor', 'description', @@ -202,6 +203,79 @@ class ReceivingBatchView(PurchasingBatchView): def batch_mode(self): return self.enum.PURCHASE_BATCH_MODE_RECEIVING + def create(self, form=None, **kwargs): + """ + Custom view for creating a new receiving batch. We split the process + into two steps, 1) choose and 2) create. This is because the specific + form details for creating a batch will depend on which "type" of batch + creation is to be done, and it's much easier to keep conditional logic + for that in the server instead of client-side etc. + """ + route_prefix = self.get_route_prefix() + workflows = self.handler.supported_receiving_workflows() + valid_workflows = [workflow['workflow_key'] + for workflow in workflows] + + # if user has already identified their desired workflow, then we can + # just farm out to the default logic. we will of course configure our + # form differently, based on workflow, but this create() method at + # least will not need customization for that. + if 'workflow_key' in self.request.matchdict: + + # however we do have one more thing to check - the workflow + # requested must of course be valid! + workflow_key = self.request.matchdict['workflow_key'] + if workflow_key not in valid_workflows: + self.request.session.flash( + "Not a supported workflow: {}".format(workflow_key), + 'error') + raise self.redirect(self.request.route_url('{}.create'.format(route_prefix))) + + # okay now do the normal thing, per workflow + return super(ReceivingBatchView, self).create(**kwargs) + + # on the other hand, if caller provided a form, that means we are in + # the middle of some other custom workflow, e.g. "add child to truck + # dump parent" or some such. in which case we also defer to the normal + # logic, so as to not interfere with that. + if form: + return super(ReceivingBatchView, self).create(form=form, **kwargs) + + # okay, at this point we need the user to select a workflow... + self.creating = True + use_buefy = self.get_use_buefy() + context = {} + + # form to accept user choice of workflow + schema = NewBatchType().bind(valid_workflows=valid_workflows) + form = forms.Form(schema=schema, request=self.request, + use_buefy=use_buefy) + values = [(workflow['workflow_key'], workflow['display']) + for workflow in workflows] + if use_buefy: + # if workflows: + # form.set_default('workflow', workflows[0]['workflow_key']) + form.set_widget('workflow', + dfwidget.SelectWidget(values=values)) + else: + form.set_widget('workflow', + forms.widgets.JQuerySelectWidget(values=values)) + form.submit_label = "Continue" + form.cancel_url = self.get_index_url() + + # if form validates, that means user has chosen a creation type, so we + # just redirect to the appropriate "new batch of type X" page + if form.validate(newstyle=True): + workflow_key = form.validated['workflow'] + url = self.request.route_url('{}.create_type'.format(route_prefix), + workflow_key=workflow_key) + raise self.redirect(url) + + context['form'] = form + if hasattr(form, 'make_deform_form'): + context['dform'] = form.make_deform_form() + return self.render_to_response('create', context) + def row_deletable(self, row): batch = row.batch @@ -243,18 +317,25 @@ class ReceivingBatchView(PurchasingBatchView): super(ReceivingBatchView, self).configure_form(f) batch = f.model_instance allow_truck_dump = self.handler.allow_truck_dump_receiving() + workflow = self.request.matchdict.get('workflow_key') + route_prefix = self.get_route_prefix() + + # cancel should take us back to choosing a workflow, when creating + if self.creating and workflow: + f.cancel_url = self.request.route_url('{}.create'.format(route_prefix)) + + # receiving_workflow + if self.creating and workflow: + f.set_readonly('receiving_workflow') + f.set_renderer('receiving_workflow', self.render_receiving_workflow) + else: + f.remove('receiving_workflow') # batch_type if self.creating: - batch_types = OrderedDict() - if self.handler.allow_receiving_from_scratch(): - batch_types['from_scratch'] = "From Scratch" - if self.handler.allow_receiving_from_purchase_order(): - batch_types['from_po'] = "From PO" - if allow_truck_dump: - batch_types['truck_dump_children_first'] = "Truck Dump (children FIRST)" - batch_types['truck_dump_children_last'] = "Truck Dump (children LAST)" - f.set_enum('batch_type', batch_types) + f.set_widget('batch_type', dfwidget.HiddenWidget()) + f.set_default('batch_type', workflow) + f.set_hidden('batch_type') else: f.remove_field('batch_type') @@ -361,6 +442,44 @@ class ReceivingBatchView(PurchasingBatchView): # invoice totals f.set_label('invoice_total', "Invoice Total (Orig.)") f.set_label('invoice_total_calculated', "Invoice Total (Calc.)") + if self.creating: + f.remove('invoice_total_calculated') + + # receiving_complete + if self.creating: + f.remove('receiving_complete') + + # now that all fields are setup, we get rid of some, based on workflow + if self.creating: + + if workflow == 'from_scratch': + f.remove('truck_dump_batch_uuid', + 'invoice_file', + 'invoice_parser_key') + + elif workflow == 'truck_dump_children_first': + f.remove('truck_dump_batch_uuid', + 'invoice_file', + 'invoice_parser_key', + 'date_ordered', + 'po_number', + 'invoice_date', + 'invoice_number') + + elif workflow == 'truck_dump_children_last': + f.remove('truck_dump_batch_uuid', + 'invoice_file', + 'invoice_parser_key', + 'date_ordered', + 'po_number', + 'invoice_date', + 'invoice_number') + + def render_receiving_workflow(self, batch, field): + key = self.request.matchdict['workflow_key'] + info = self.handler.receiving_workflow_info(key) + if info: + return info['display'] def template_kwargs_create(self, **kwargs): kwargs = super(ReceivingBatchView, self).template_kwargs_create(**kwargs) @@ -488,6 +607,9 @@ class ReceivingBatchView(PurchasingBatchView): self.configure_form(f) + # cancel should go back to truck dump parent + f.cancel_url = self.get_action_url('view', truck_dump) + f.set_fields([ 'batch_type', 'truck_dump_parent', @@ -502,6 +624,7 @@ class ReceivingBatchView(PurchasingBatchView): # batch_type f.set_widget('batch_type', forms.widgets.ReadonlyWidget()) f.set_default('batch_type', 'truck_dump_child_from_invoice') + f.set_hidden('batch_type', False) # truck_dump_batch_uuid f.set_readonly('truck_dump_parent') @@ -1272,6 +1395,13 @@ class ReceivingBatchView(PurchasingBatchView): progress.session['success_url'] = success_url progress.session.save() + @classmethod + def defaults(cls, config): + cls._receiving_defaults(config) + cls._purchasing_defaults(config) + cls._batch_defaults(config) + cls._defaults(config) + @classmethod def _receiving_defaults(cls, config): rattail_config = config.registry.settings.get('rattail_config') @@ -1281,13 +1411,18 @@ class ReceivingBatchView(PurchasingBatchView): model_key = cls.get_model_key() permission_prefix = cls.get_permission_prefix() + # new receiving batch using workflow X + config.add_route('{}.create_type'.format(route_prefix), '{}/new/{{workflow_key}}'.format(url_prefix)) + config.add_view(cls, attr='create', route_name='{}.create_type'.format(route_prefix), + permission='{}.create'.format(permission_prefix)) + # row-level receiving - config.add_route('{}.receive_row'.format(route_prefix), '{}/{{uuid}}/rows/{{row_uuid}}/receive'.format(url_prefix)) + config.add_route('{}.receive_row'.format(route_prefix), '{}/rows/{{row_uuid}}/receive'.format(instance_url_prefix)) config.add_view(cls, attr='receive_row', route_name='{}.receive_row'.format(route_prefix), permission='{}.edit_row'.format(permission_prefix)) # declare credit for row - config.add_route('{}.declare_credit'.format(route_prefix), '{}/{{uuid}}/rows/{{row_uuid}}/declare-credit'.format(url_prefix)) + config.add_route('{}.declare_credit'.format(route_prefix), '{}/rows/{{row_uuid}}/declare-credit'.format(instance_url_prefix)) config.add_view(cls, attr='declare_credit', route_name='{}.declare_credit'.format(route_prefix), permission='{}.edit_row'.format(permission_prefix)) @@ -1298,29 +1433,45 @@ class ReceivingBatchView(PurchasingBatchView): renderer='json') # add TD child batch, from invoice file - config.add_route('{}.add_child_from_invoice'.format(route_prefix), '{}/{{{}}}/add-child-from-invoice'.format(url_prefix, model_key)) + config.add_route('{}.add_child_from_invoice'.format(route_prefix), '{}/add-child-from-invoice'.format(instance_url_prefix)) config.add_view(cls, attr='add_child_from_invoice', route_name='{}.add_child_from_invoice'.format(route_prefix), permission='{}.create'.format(permission_prefix)) # transform TD parent row from "pack" to "unit" item - config.add_route('{}.transform_unit_row'.format(route_prefix), '{}/{{{}}}/transform-unit'.format(url_prefix, model_key)) + config.add_route('{}.transform_unit_row'.format(route_prefix), '{}/transform-unit'.format(instance_url_prefix)) config.add_view(cls, attr='transform_unit_row', route_name='{}.transform_unit_row'.format(route_prefix), permission='{}.edit_row'.format(permission_prefix), renderer='json') # auto-receive all items if not rattail_config.production(): - config.add_route('{}.auto_receive'.format(route_prefix), '{}/{{{}}}/auto-receive'.format(url_prefix, model_key), + config.add_route('{}.auto_receive'.format(route_prefix), '{}/auto-receive'.format(instance_url_prefix), request_method='POST') config.add_view(cls, attr='auto_receive', route_name='{}.auto_receive'.format(route_prefix), permission='admin') - @classmethod - def defaults(cls, config): - cls._receiving_defaults(config) - cls._purchasing_defaults(config) - cls._batch_defaults(config) - cls._defaults(config) +@colander.deferred +def valid_workflow(node, kw): + """ + Deferred validator for ``workflow`` field, for new batches. + """ + valid_workflows = kw['valid_workflows'] + + def validate(node, value): + # we just need to provide possible values, and let stock validator + # handle the rest + oneof = colander.OneOf(valid_workflows) + return oneof(node, value) + + return validate + + +class NewBatchType(colander.Schema): + """ + Schema for choosing which "type" of new receiving batch should be created. + """ + workflow = colander.SchemaNode(colander.String(), + validator=valid_workflow) class ReceiveRowForm(colander.MappingSchema): From 801c56f06e9d7282eecf33b04c6825dd795ba486 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 31 Jan 2021 12:10:44 -0600 Subject: [PATCH 0293/1681] More tweaks for receiving batch workflows now first step requires choice of vendor and workflow. supports receiving from PO at least for native use case. --- tailbone/api/batch/receiving.py | 10 +-- tailbone/templates/batch/view.mako | 6 +- tailbone/templates/receiving/view.mako | 18 +++- tailbone/views/purchasing/receiving.py | 118 +++++++++++++++++++++---- 4 files changed, 118 insertions(+), 34 deletions(-) diff --git a/tailbone/api/batch/receiving.py b/tailbone/api/batch/receiving.py index 71a8bcba..22632b80 100644 --- a/tailbone/api/batch/receiving.py +++ b/tailbone/api/batch/receiving.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -126,13 +126,7 @@ class ReceivingBatchViews(APIBatchView): } def render_eligible_purchase(self, purchase): - if purchase.status == self.enum.PURCHASE_STATUS_ORDERED: - date = purchase.date_ordered - total = purchase.po_total - elif purchase.status == self.enum.PURCHASE_STATUS_RECEIVED: - date = purchase.date_received - total = purchase.invoice_total - return '{} for ${:0,.2f} ({})'.format(date, total or 0, purchase.department or purchase.buyer) + return self.handler.render_eligible_purchase(purchase) @classmethod def defaults(cls, config): diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako index d0a1ca21..bfc22833 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -212,7 +212,7 @@ <section class="modal-card-body"> <p class="block has-text-weight-bold"> - What will actually happen when this batch is executed? + What will happen when this batch is executed? </p> <p class="block"> ${handler.describe_execution(batch) or "TODO: handler does not provide a description for this batch"} @@ -374,7 +374,7 @@ ## end 'external_worksheet' % endif - % if not batch.executed and master.has_perm('execute'): + % if execute_enabled and master.has_perm('execute'): ThisPageData.showExecutionDialog = false @@ -402,7 +402,7 @@ </script> % endif - % if not batch.executed and master.has_perm('execute'): + % if execute_enabled and master.has_perm('execute'): <script type="text/javascript"> ## ExecuteForm diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako index 82e51f6b..32c327fe 100644 --- a/tailbone/templates/receiving/view.mako +++ b/tailbone/templates/receiving/view.mako @@ -292,10 +292,20 @@ <div class="object-helper"> <h3>Development Tools</h3> <div class="object-helper-content"> - ${h.form(url('{}.auto_receive'.format(route_prefix), uuid=batch.uuid), class_='autodisable')} - ${h.csrf_token(request)} - ${h.submit('submit', "Auto-Receive All Items")} - ${h.end_form()} + % if use_buefy: + ${h.form(url('{}.auto_receive'.format(route_prefix), uuid=batch.uuid), ref='auto_receive_all_form')} + ${h.csrf_token(request)} + <once-button type="is-primary" + @click="$refs.auto_receive_all_form.submit()" + text="Auto-Receive All Items"> + </once-button> + ${h.end_form()} + % else: + ${h.form(url('{}.auto_receive'.format(route_prefix), uuid=batch.uuid), class_='autodisable')} + ${h.csrf_token(request)} + ${h.submit('submit', "Auto-Receive All Items")} + ${h.end_form()} + % endif </div> </div> % endif diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 04d0d624..0d5606b1 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -94,9 +94,9 @@ class ReceivingBatchView(PurchasingBatchView): form_fields = [ 'id', 'batch_type', # TODO: ideally would get rid of this one - 'receiving_workflow', 'store', 'vendor', + 'receiving_workflow', 'description', 'truck_dump', 'truck_dump_children_first', @@ -220,7 +220,7 @@ class ReceivingBatchView(PurchasingBatchView): # just farm out to the default logic. we will of course configure our # form differently, based on workflow, but this create() method at # least will not need customization for that. - if 'workflow_key' in self.request.matchdict: + if self.request.matched_route.name.endswith('create_workflow'): # however we do have one more thing to check - the workflow # requested must of course be valid! @@ -241,25 +241,49 @@ class ReceivingBatchView(PurchasingBatchView): if form: return super(ReceivingBatchView, self).create(form=form, **kwargs) - # okay, at this point we need the user to select a workflow... + # okay, at this point we need the user to select a vendor and workflow self.creating = True use_buefy = self.get_use_buefy() context = {} - # form to accept user choice of workflow - schema = NewBatchType().bind(valid_workflows=valid_workflows) + # form to accept user choice of vendor/workflow + schema = NewReceivingBatch().bind(valid_workflows=valid_workflows) form = forms.Form(schema=schema, request=self.request, use_buefy=use_buefy) + + # configure vendor field + use_autocomplete = self.rattail_config.getbool( + 'rattail', 'vendor.use_autocomplete', default=True) + if use_autocomplete: + 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)) + else: + 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)) + + # configure workflow field values = [(workflow['workflow_key'], workflow['display']) for workflow in workflows] if use_buefy: - # if workflows: - # form.set_default('workflow', workflows[0]['workflow_key']) form.set_widget('workflow', dfwidget.SelectWidget(values=values)) else: form.set_widget('workflow', forms.widgets.JQuerySelectWidget(values=values)) + form.submit_label = "Continue" form.cancel_url = self.get_index_url() @@ -267,8 +291,10 @@ class ReceivingBatchView(PurchasingBatchView): # just redirect to the appropriate "new batch of type X" page if form.validate(newstyle=True): workflow_key = form.validated['workflow'] - url = self.request.route_url('{}.create_type'.format(route_prefix), - workflow_key=workflow_key) + vendor_uuid = form.validated['vendor'] + url = self.request.route_url('{}.create_workflow'.format(route_prefix), + workflow_key=workflow_key, + vendor_uuid=vendor_uuid) raise self.redirect(url) context['form'] = form @@ -315,13 +341,24 @@ class ReceivingBatchView(PurchasingBatchView): def configure_form(self, f): super(ReceivingBatchView, self).configure_form(f) + model = self.model batch = f.model_instance allow_truck_dump = self.handler.allow_truck_dump_receiving() workflow = self.request.matchdict.get('workflow_key') route_prefix = self.get_route_prefix() + use_buefy = self.get_use_buefy() - # cancel should take us back to choosing a workflow, when creating + # tweak some things if we are in "step 2" of creating new batch if self.creating and workflow: + + # display vendor but do not allow changing + vendor = self.Session.query(model.Vendor).get( + self.request.matchdict['vendor_uuid']) + assert vendor + f.set_readonly('vendor_uuid') + f.set_default('vendor_uuid', six.text_type(vendor)) + + # cancel should take us back to choosing a workflow f.cancel_url = self.request.route_url('{}.create'.format(route_prefix)) # receiving_workflow @@ -415,7 +452,10 @@ class ReceivingBatchView(PurchasingBatchView): parsers = sorted(iter_invoice_parsers(), key=lambda p: p.display) parser_values = [(p.key, p.display) for p in parsers] parser_values.insert(0, ('', "(please choose)")) - f.set_widget('invoice_parser_key', forms.widgets.JQuerySelectWidget(values=parser_values)) + if use_buefy: + f.set_widget('invoice_parser_key', dfwidget.SelectWidget(values=parser_values)) + else: + f.set_widget('invoice_parser_key', forms.widgets.JQuerySelectWidget(values=parser_values)) else: f.remove_field('invoice_parser_key') @@ -428,7 +468,18 @@ class ReceivingBatchView(PurchasingBatchView): f.set_widget('store_uuid', dfwidget.HiddenWidget()) # purchase - if self.creating: + if (self.creating and workflow == 'from_po' + and self.purchase_order_fieldname == 'purchase'): + if use_buefy: + f.replace('purchase', 'purchase_uuid') + purchases = self.handler.get_eligible_purchases( + vendor, self.enum.PURCHASE_BATCH_MODE_RECEIVING) + values = [(p.uuid, self.handler.render_eligible_purchase(p)) + for p in purchases] + f.set_widget('purchase_uuid', dfwidget.SelectWidget(values=values)) + f.set_label('purchase_uuid', "Purchase Order") + f.set_required('purchase_uuid') + else: f.remove_field('purchase') # department @@ -449,14 +500,24 @@ class ReceivingBatchView(PurchasingBatchView): if self.creating: f.remove('receiving_complete') - # now that all fields are setup, we get rid of some, based on workflow - if self.creating: + # now that all fields are setup, some final tweaks based on workflow + if self.creating and workflow: if workflow == 'from_scratch': f.remove('truck_dump_batch_uuid', 'invoice_file', 'invoice_parser_key') + elif workflow == 'from_invoice': + f.remove('truck_dump_batch_uuid') + f.set_required('invoice_file') + f.set_required('invoice_parser_key') + + elif workflow == 'from_po': + f.remove('truck_dump_batch_uuid', + 'invoice_file', + 'invoice_parser_key') + elif workflow == 'truck_dump_children_first': f.remove('truck_dump_batch_uuid', 'invoice_file', @@ -497,9 +558,20 @@ class ReceivingBatchView(PurchasingBatchView): def get_batch_kwargs(self, batch, **kwargs): kwargs = super(ReceivingBatchView, self).get_batch_kwargs(batch, **kwargs) batch_type = self.request.POST['batch_type'] + + # must pull vendor from URL if it was not in form data + if 'vendor_uuid' not in kwargs and 'vendor' not in kwargs: + if 'vendor_uuid' in self.request.matchdict: + kwargs['vendor_uuid'] = self.request.matchdict['vendor_uuid'] + if batch_type == 'from_scratch': kwargs.pop('truck_dump_batch', None) kwargs.pop('truck_dump_batch_uuid', None) + elif batch_type == 'from_invoice': + pass + elif batch_type == 'from_po': + # TODO: how to best handle this field? this doesn't seem flexible + kwargs['purchase_key'] = batch.purchase_uuid elif batch_type == 'truck_dump_children_first': kwargs['truck_dump'] = True kwargs['truck_dump_children_first'] = True @@ -750,6 +822,7 @@ class ReceivingBatchView(PurchasingBatchView): # simply invoke this method and return the result. however we're not # there yet...for now it's only tested for desktop self.viewing = True + use_buefy = self.get_use_buefy() row = self.get_row_instance() batch = row.batch permission_prefix = self.get_permission_prefix() @@ -773,9 +846,13 @@ class ReceivingBatchView(PurchasingBatchView): } schema = ReceiveRowForm().bind(session=self.Session()) - form = forms.Form(schema=schema, request=self.request) + form = forms.Form(schema=schema, request=self.request, use_buefy=use_buefy) form.cancel_url = self.get_row_action_url('view', row) - form.set_widget('mode', forms.widgets.JQuerySelectWidget(values=[(m, m) for m in possible_modes])) + mode_values = [(mode, mode) for mode in possible_modes] + if use_buefy: + form.set_widget('mode', dfwidget.SelectWidget(values=mode_values)) + else: + form.set_widget('mode', forms.widgets.JQuerySelectWidget(values=mode_values)) form.set_widget('quantity', forms.widgets.CasesUnitsWidget(amount_required=True, one_amount_only=True)) form.set_type('expiration_date', 'date_jquery') @@ -1412,8 +1489,8 @@ class ReceivingBatchView(PurchasingBatchView): permission_prefix = cls.get_permission_prefix() # new receiving batch using workflow X - config.add_route('{}.create_type'.format(route_prefix), '{}/new/{{workflow_key}}'.format(url_prefix)) - config.add_view(cls, attr='create', route_name='{}.create_type'.format(route_prefix), + config.add_route('{}.create_workflow'.format(route_prefix), '{}/new/{{workflow_key}}/{{vendor_uuid}}'.format(url_prefix)) + config.add_view(cls, attr='create', route_name='{}.create_workflow'.format(route_prefix), permission='{}.create'.format(permission_prefix)) # row-level receiving @@ -1466,10 +1543,13 @@ def valid_workflow(node, kw): return validate -class NewBatchType(colander.Schema): +class NewReceivingBatch(colander.Schema): """ Schema for choosing which "type" of new receiving batch should be created. """ + vendor = colander.SchemaNode(colander.String(), + label="Vendor") + workflow = colander.SchemaNode(colander.String(), validator=valid_workflow) From 329e75ee822f7b2f796ede10a1dc14b7b28e1532 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 31 Jan 2021 20:46:29 -0600 Subject: [PATCH 0294/1681] Add initial "scanning" feature for Ordering Batches --- tailbone/api/batch/receiving.py | 8 +- .../static/js/tailbone.buefy.numericinput.js | 23 +- tailbone/templates/batch/view.mako | 11 +- tailbone/templates/ordering/view.mako | 407 ++++++++++++++++++ tailbone/views/batch/core.py | 4 +- tailbone/views/purchasing/ordering.py | 132 +++++- 6 files changed, 553 insertions(+), 32 deletions(-) diff --git a/tailbone/api/batch/receiving.py b/tailbone/api/batch/receiving.py index 22632b80..37bb00b5 100644 --- a/tailbone/api/batch/receiving.py +++ b/tailbone/api/batch/receiving.py @@ -31,7 +31,6 @@ import logging import six import humanize -from rattail import pod from rattail.db import model from rattail.time import make_utc from rattail.util import pretty_quantity @@ -268,9 +267,12 @@ class ReceivingBatchRowViews(APIBatchRowView): return filters def normalize(self, row): - batch = row.batch data = super(ReceivingBatchRowViews, self).normalize(row) + batch = row.batch + app = self.get_rattail_app() + prodder = app.get_products_handler() + data['product_uuid'] = row.product_uuid data['item_id'] = row.item_id data['upc'] = six.text_type(row.upc) @@ -282,7 +284,7 @@ class ReceivingBatchRowViews(APIBatchRowView): # only provide image url if so configured if self.rattail_config.getbool('rattail.batch', 'purchase.mobile_images', default=True): - data['image_url'] = pod.get_image_url(self.rattail_config, row.upc) if row.upc else None + data['image_url'] = prodder.get_image_url(product=row.product, upc=row.upc) # unit_uom can vary by product data['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA' diff --git a/tailbone/static/js/tailbone.buefy.numericinput.js b/tailbone/static/js/tailbone.buefy.numericinput.js index 47a5e610..3fc0d74f 100644 --- a/tailbone/static/js/tailbone.buefy.numericinput.js +++ b/tailbone/static/js/tailbone.buefy.numericinput.js @@ -4,8 +4,14 @@ const NumericInput = { '<b-input', ':name="name"', ':value="value"', - '@focus="focus"', - '@blur="blur"', + 'ref="input"', + ':placeholder="placeholder"', + ':size="size"', + ':icon-pack="iconPack"', + ':icon="icon"', + ':disabled="disabled"', + '@focus="notifyFocus"', + '@blur="notifyBlur"', '@keydown.native="keyDown"', '@input="valueChanged"', '>', @@ -15,16 +21,25 @@ const NumericInput = { props: { name: String, value: String, + placeholder: String, + iconPack: String, + icon: String, + size: String, + disabled: Boolean, allowEnter: Boolean }, methods: { - focus(event) { + focus() { + this.$refs.input.focus() + }, + + notifyFocus(event) { this.$emit('focus', event) }, - blur(event) { + notifyBlur(event) { this.$emit('blur', event) }, diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako index bfc22833..d1a640ed 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -96,8 +96,9 @@ <%def name="leading_buttons()"> % if master.has_worksheet and master.allow_worksheet(batch) and master.has_perm('worksheet'): % if use_buefy: - <once-button tag="a" - href="${url('{}.worksheet'.format(route_prefix), uuid=batch.uuid)}" + <once-button type="is-primary" + tag="a" href="${url('{}.worksheet'.format(route_prefix), uuid=batch.uuid)}" + icon-left="edit" text="Edit as Worksheet"> </once-button> % else: @@ -110,10 +111,10 @@ % if master.batch_refreshable(batch) and master.has_perm('refresh'): % if use_buefy: ## TODO: this should surely use a POST request? - <once-button tag="a" - href="${url('{}.refresh'.format(route_prefix), uuid=batch.uuid)}" + <once-button type="is-primary" + tag="a" href="${url('{}.refresh'.format(route_prefix), uuid=batch.uuid)}" text="Refresh Data" - icon-left="fas fa-redo"> + icon-left="redo"> </once-button> % else: <button type="button" class="button" id="refresh-data">Refresh Data</button> diff --git a/tailbone/templates/ordering/view.mako b/tailbone/templates/ordering/view.mako index 9d2b7247..f0e6380a 100644 --- a/tailbone/templates/ordering/view.mako +++ b/tailbone/templates/ordering/view.mako @@ -13,4 +13,411 @@ % endif </%def> +<%def name="render_row_grid_tools()"> + ${parent.render_row_grid_tools()} + % if not batch.executed and not batch.complete and master.has_perm('edit_row'): + <ordering-scanner numeric-only> + </ordering-scanner> + % endif +</%def> + +<%def name="render_this_page_template()"> + ${parent.render_this_page_template()} + % if not batch.executed and not batch.complete and master.has_perm('edit_row'): + <script type="text/x-template" id="ordering-scanner-template"> + <div> + <b-button type="is-primary" + icon-pack="fas" + icon-left="fas fa-play" + @click="startScanning()"> + Start Scanning + </b-button> + <b-modal :active.sync="showScanningDialog" + :can-cancel="false"> + <div class="card"> + <div class="card-content"> + <section style="min-height: 400px;"> + <div class="columns"> + + <div class="column"> + <b-field grouped> + + <numeric-input v-if="numericOnly" + v-model="itemEntry" + allow-enter + placeholder="Enter UPC" + icon-pack="fas" + icon="fas fa-search" + ref="itemEntryInput" + :disabled="currentRow" + @keydown.native="itemEntryKeydown"> + </numeric-input> + + <b-input v-if="!numericOnly" + v-model="itemEntry" + placeholder="Enter UPC" + icon-pack="fas" + icon="fas fa-search" + ref="itemEntryInput" + :disabled="currentRow"> + </b-input> + + <b-button @click="fetchEntry()" + :disabled="currentRow"> + Fetch + </b-button> + + </b-field> + + <div v-if="currentRow"> + <b-field grouped> + + <b-field label="${enum.UNIT_OF_MEASURE[enum.UNIT_OF_MEASURE_CASE]}" horizontal> + <numeric-input v-model="currentRow.cases_ordered" + ref="casesInput" + @keydown.native="casesKeydown" + style="width: 60px; margin-right: 1rem;"> + </numeric-input> + </b-field> + + <b-field :label="currentRow.unit_of_measure_display" horizontal> + <numeric-input v-model="currentRow.units_ordered" + ref="unitsInput" + @keydown.native="unitsKeydown" + style="width: 60px;"> + </numeric-input> + </b-field> + + </b-field> + + <p class="block has-text-weight-bold"> + 1 ${enum.UNIT_OF_MEASURE[enum.UNIT_OF_MEASURE_CASE]} + = {{ currentRow.case_quantity || '??' }} + {{ currentRow.unit_of_measure_display }} + </p> + + <p class="block has-text-weight-bold"> + {{ currentRow.po_case_cost_display || '$?.??' }} + per ${enum.UNIT_OF_MEASURE[enum.UNIT_OF_MEASURE_CASE]}; + {{ currentRow.po_unit_cost_display || '$?.??' }} + per {{ currentRow.unit_of_measure_display }} + </p> + + <p class="block has-text-weight-bold"> + Total is + {{ totalCostDisplay }} + </p> + + <div class="buttons"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="fas fa-save" + @click="saveCurrentRow()"> + Save + </b-button> + <b-button @click="cancelCurrentRow()"> + Cancel + </b-button> + </div> + </div> + + </div> + + <div class="column is-three-fifths"> + <div v-if="currentRow"> + + <b-field label="UPC" horizontal> + {{ currentRow.upc_display }} + </b-field> + + <b-field label="Brand" horizontal> + {{ currentRow.brand_name }} + </b-field> + + <b-field label="Description" horizontal> + {{ currentRow.description }} + </b-field> + + <b-field label="Size" horizontal> + {{ currentRow.size }} + </b-field> + + <b-field label="Reg. Price" horizontal> + {{ currentRow.product_price_display }} + </b-field> + + <div class="buttons"> + <img :src="currentRow.image_url"></img> + <b-button v-if="currentRow.product_url" + type="is-primary" + tag="a" :href="currentRow.product_url" + target="_blank"> + View Full Product + </b-button> + </div> + </div> + </div> + + </div> <!-- columns --> + </section> + + <div class="level"> + <div class="level-left"> + </div> + <div class="level-right"> + <div class="level-item buttons"> + <once-button type="is-primary" + @click="stopScanning()" + text="Stop Scanning" + icon-left="stop" + :disabled="currentRow" + :title="currentRow ? 'Please save or cancel first' : null"> + </once-button> + </div> + </div> + </div> + + </div> <!-- card-content --> + </div> + </b-modal> + </div> + </script> + % endif +</%def> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + % if not batch.executed and not batch.complete and master.has_perm('edit_row'): + <script type="text/javascript"> + + let OrderingScanner = { + template: '#ordering-scanner-template', + props: { + numericOnly: Boolean, + }, + data() { + return { + showScanningDialog: false, + itemEntry: null, + fetching: false, + currentRow: null, + saving: false, + + ## TODO: should find a better way to handle CSRF token + csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}, + } + }, + computed: { + + totalUnits() { + let cases = parseFloat(this.currentRow.cases_ordered || 0) + let units = parseFloat(this.currentRow.units_ordered || 0) + if (cases) { + units += cases * (this.currentRow.case_quantity || 1) + } + return units + }, + + totalUnitsDisplay() { + let cases = parseFloat(this.currentRow.cases_ordered || 0) + let units = parseFloat(this.currentRow.units_ordered || 0) + let casesTotal = "" + if (cases) { + casesTotal = cases.toString() + " ${enum.UNIT_OF_MEASURE[enum.UNIT_OF_MEASURE_CASE]}" + } + let unitsTotal = "" + if (units) { + unitsTotal = units.toString() + " " + this.currentRow.unit_of_measure_display + } + if (casesTotal.length && unitsTotal.length) { + return casesTotal + " + " + unitsTotal + } else if (casesTotal.length) { + return casesTotal + } else if (unitsTotal.length) { + return unitsTotal + } + return "??" + }, + + totalCost() { + if (this.currentRow.po_case_cost === null + && this.currentRow.po_unit_cost === null) { + return null + } + let cases = parseFloat(this.currentRow.cases_ordered || 0) + let units = parseFloat(this.currentRow.units_ordered || 0) + let total = cases * this.currentRow.po_case_cost + total += units * this.currentRow.po_unit_cost + return total + }, + + totalCostDisplay() { + if (this.totalCost === null) { + return '$?.??' + } + return '$' + this.totalCost.toFixed(2) + }, + }, + methods: { + + startScanning() { + this.showScanningDialog = true + this.$nextTick(() => { + this.$refs.itemEntryInput.focus() + }) + }, + + itemEntryKeydown(event) { + if (event.which == 13) { + this.fetchEntry() + } + }, + + fetchEntry() { + if (this.fetching) { + return + } + if (!this.itemEntry) { + return + } + + this.fetching = true + + let url = '${url('{}.scanning_entry'.format(route_prefix), uuid=batch.uuid)}' + + let params = { + entry: this.itemEntry, + } + + let headers = { + ## TODO: should find a better way to handle CSRF token + 'X-CSRF-TOKEN': this.csrftoken, + } + + ## TODO: should find a better way to handle CSRF token + this.$http.post(url, params, {headers: headers}).then(({ data }) => { + if (data.error) { + this.$buefy.toast.open({ + message: "Fetch failed: " + data.error, + type: 'is-danger', + duration: 4000, // 4 seconds + }) + } else { + this.currentRow = data.row + this.$nextTick(() => { + this.$refs.casesInput.focus() + }) + } + this.fetching = false + }, response => { + this.$buefy.toast.open({ + message: "Fetch failed: (unknown error)", + type: 'is-danger', + duration: 4000, // 4 seconds + }) + this.fetching = false + }) + }, + + casesKeydown(event) { + if (event.which == 13) { + this.$refs.unitsInput.focus() + } else if (event.which == 27) { + this.cancelCurrentRow() + } + }, + + unitsKeydown(event) { + if (event.which == 13) { + this.saveCurrentRow() + } else if (event.which == 27) { + this.cancelCurrentRow() + } + }, + + saveCurrentRow() { + if (this.saving) { + return + } + + this.saving = true + + let url = '${url('{}.scanning_update'.format(route_prefix), uuid=batch.uuid)}' + + let params = { + row_uuid: this.currentRow.uuid, + cases_ordered: this.currentRow.cases_ordered, + units_ordered: this.currentRow.units_ordered, + } + + let headers = { + ## TODO: should find a better way to handle CSRF token + 'X-CSRF-TOKEN': this.csrftoken, + } + + ## TODO: should find a better way to handle CSRF token + this.$http.post(url, params, {headers: headers}).then(({ data }) => { + if (data.error) { + this.$buefy.toast.open({ + message: "Save failed: " + data.error, + type: 'is-danger', + duration: 4000, // 4 seconds + }) + } else { + this.$buefy.toast.open({ + message: "Item was saved", + type: 'is-success', + }) + this.itemEntry = null + this.currentRow = null + this.$nextTick(() => { + this.$refs.itemEntryInput.focus() + }) + } + this.saving = false + }, response => { + this.$buefy.toast.open({ + message: "Save failed: (unknown error)", + type: 'is-danger', + duration: 4000, // 4 seconds + }) + this.saving = false + }) + + }, + + cancelCurrentRow() { + this.itemEntry = null + this.currentRow = null + this.$buefy.toast.open({ + message: "Edit was cancelled", + type: 'is-warning', + }) + this.$nextTick(() => { + this.$refs.itemEntryInput.focus() + }) + }, + + stopScanning() { + location.reload() + }, + } + } + + </script> + % endif +</%def> + +<%def name="make_this_page_component()"> + ${parent.make_this_page_component()} + % if not batch.executed and not batch.complete and master.has_perm('edit_row'): + <script type="text/javascript"> + + Vue.component('ordering-scanner', OrderingScanner) + + </script> + % endif +</%def> + + ${parent.body()} diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index d48a7913..3a07f0a8 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -412,10 +412,10 @@ class BatchMasterView(MasterView): return text if batch.complete: - label = "Mark as NOT Complete" + label = "Mark Incomplete" value = 'false' else: - label = "Mark as Complete" + label = "Mark Complete" value = 'true' kwargs = {} diff --git a/tailbone/views/purchasing/ordering.py b/tailbone/views/purchasing/ordering.py index 43955263..e184fd3f 100644 --- a/tailbone/views/purchasing/ordering.py +++ b/tailbone/views/purchasing/ordering.py @@ -169,6 +169,89 @@ class OrderingBatchView(PurchasingBatchView): if field not in editable_fields: f.set_readonly(field) + def scanning_entry(self): + """ + AJAX view to handle user entry/fetch input for "scanning" feature. + """ + data = self.request.json_body + app = self.get_rattail_app() + prodder = app.get_products_handler() + + batch = self.get_instance() + entry = data['entry'] + row = self.handler.quick_entry(self.Session(), batch, entry) + + uom = self.enum.UNIT_OF_MEASURE_EACH + if row.product and row.product.weighed: + uom = self.enum.UNIT_OF_MEASURE_POUND + + cases_ordered = None + if row.cases_ordered: + cases_ordered = float(row.cases_ordered) + + units_ordered = None + if row.units_ordered: + units_ordered = float(row.units_ordered) + + po_case_cost = None + if row.po_unit_cost is not None: + po_case_cost = row.po_unit_cost * (row.case_quantity or 1) + + product_url = None + if row.product_uuid: + product_url = self.request.route_url('products.view', uuid=row.product_uuid) + + product_price = None + if row.product and row.product.regular_price: + product_price = row.product.regular_price.price + + product_price_display = None + if product_price is not None: + product_price_display = app.render_currency(product_price) + + return { + 'ok': True, + 'entry': entry, + 'row': { + 'uuid': row.uuid, + 'item_id': row.item_id, + 'upc_display': row.upc.pretty() if row.upc else None, + 'brand_name': row.brand_name, + 'description': row.description, + 'size': row.size, + 'unit_of_measure_display': self.enum.UNIT_OF_MEASURE[uom], + 'case_quantity': float(row.case_quantity) if row.case_quantity is not None else None, + 'cases_ordered': cases_ordered, + 'units_ordered': units_ordered, + 'po_unit_cost': float(row.po_unit_cost) if row.po_unit_cost is not None else None, + 'po_unit_cost_display': app.render_currency(row.po_unit_cost), + 'po_case_cost': float(po_case_cost) if po_case_cost is not None else None, + 'po_case_cost_display': app.render_currency(po_case_cost), + 'image_url': prodder.get_image_url(upc=row.upc), + 'product_url': product_url, + 'product_price_display': product_price_display, + }, + } + + def scanning_update(self): + """ + AJAX view to handle row data updates for "scanning" feature. + """ + data = self.request.json_body + batch = self.get_instance() + assert batch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING + assert not (batch.executed or batch.complete) + + uuid = data.get('row_uuid') + row = self.Session.query(self.model_row_class).get(uuid) if uuid else None + if not row: + return {'error': "Row not found"} + if row.batch is not batch or row.removed: + return {'error': "Row is not active for batch"} + + self.handler.update_row_quantity(row, **data) + return {'ok': True} + def worksheet(self): """ View for editing batch row data as an order form worksheet. @@ -401,24 +484,6 @@ class OrderingBatchView(PurchasingBatchView): return self.request.route_url('purchases.view', uuid=result.uuid) return super(OrderingBatchView, self).get_execute_success_url(batch, result, **kwargs) - @classmethod - def _ordering_defaults(cls, config): - route_prefix = cls.get_route_prefix() - url_prefix = cls.get_url_prefix() - permission_prefix = cls.get_permission_prefix() - model_title = cls.get_model_title() - model_title_plural = cls.get_model_title_plural() - - # fix permission group label - config.add_tailbone_permission_group(permission_prefix, model_title_plural) - - # download as Excel - config.add_route('{}.download_excel'.format(route_prefix), '{}/{{uuid}}/excel'.format(url_prefix)) - config.add_view(cls, attr='download_excel', route_name='{}.download_excel'.format(route_prefix), - permission='{}.download_excel'.format(permission_prefix)) - config.add_tailbone_permission(permission_prefix, '{}.download_excel'.format(permission_prefix), - "Download {} as Excel".format(model_title)) - @classmethod def defaults(cls, config): cls._ordering_defaults(config) @@ -426,6 +491,37 @@ class OrderingBatchView(PurchasingBatchView): cls._batch_defaults(config) cls._defaults(config) + @classmethod + def _ordering_defaults(cls, config): + route_prefix = cls.get_route_prefix() + instance_url_prefix = cls.get_instance_url_prefix() + permission_prefix = cls.get_permission_prefix() + model_title = cls.get_model_title() + model_title_plural = cls.get_model_title_plural() + + # fix permission group label + config.add_tailbone_permission_group(permission_prefix, model_title_plural, + overwrite=False) + + # scanning entry + config.add_route('{}.scanning_entry'.format(route_prefix), '{}/scanning-entry'.format(instance_url_prefix)) + config.add_view(cls, attr='scanning_entry', route_name='{}.scanning_entry'.format(route_prefix), + permission='{}.edit_row'.format(permission_prefix), + renderer='json') + + # scanning update + config.add_route('{}.scanning_update'.format(route_prefix), '{}/scanning-update'.format(instance_url_prefix)) + config.add_view(cls, attr='scanning_update', route_name='{}.scanning_update'.format(route_prefix), + permission='{}.edit_row'.format(permission_prefix), + renderer='json') + + # download as Excel + config.add_route('{}.download_excel'.format(route_prefix), '{}/excel'.format(instance_url_prefix)) + config.add_view(cls, attr='download_excel', route_name='{}.download_excel'.format(route_prefix), + permission='{}.download_excel'.format(permission_prefix)) + config.add_tailbone_permission(permission_prefix, '{}.download_excel'.format(permission_prefix), + "Download {} as Excel".format(model_title)) + def includeme(config): OrderingBatchView.defaults(config) From fe80028c07e5453679fc49a356c87b4b3ea57490 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 1 Feb 2021 11:57:12 -0600 Subject: [PATCH 0295/1681] Add support for "nested" menu items some menus were just getting too long, so this gives us a way to collapse certain items, which user can expand as needed --- tailbone/menus.py | 131 +++++++++++++----- tailbone/static/themes/falafel/css/layout.css | 5 + tailbone/templates/themes/falafel/base.mako | 41 +++++- 3 files changed, 139 insertions(+), 38 deletions(-) diff --git a/tailbone/menus.py b/tailbone/menus.py index f28574bf..2402e768 100644 --- a/tailbone/menus.py +++ b/tailbone/menus.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -33,6 +33,7 @@ from rattail.util import import_module_path class MenuGroup(Object): title = None items = None + is_menu = True is_link = False @@ -41,10 +42,19 @@ class MenuItem(Object): url = None target = None is_link = True + is_menu = False + is_sep = False + + +class MenuItemMenu(Object): + title = None + items = None + is_menu = True is_sep = False class MenuSeparator(object): + is_menu = False is_sep = True @@ -61,55 +71,108 @@ def make_simple_menus(request): # collect "simple" menus definition, but must refine that somewhat to # produce our final menus raw_menus = menus_module.simple_menus(request) + mark_allowed(request, raw_menus) final_menus = [] for topitem in raw_menus: - if topitem.get('type') == 'link': - final_menus.append( - MenuItem(title=topitem['title'], - url=topitem['url'], - target=topitem.get('target'))) + if topitem['allowed']: - else: # assuming 'menu' type + if topitem.get('type') == 'link': + final_menus.append(make_menu_entry(topitem)) - # figure out which ones the user has permission to access - allowed = [] - for item in topitem['items']: + else: # assuming 'menu' type - if item.get('type') == 'sep': - allowed.append(item) - - if item.get('perm'): - if request.has_perm(item['perm']): - allowed.append(item) - else: - allowed.append(item) - - if allowed: - - # user must have access to something; construct items for the menu menu_items = [] - for item in allowed: + for item in topitem['items']: + if not item['allowed']: + continue - # separator - if item.get('type') == 'sep': + # nested submenu + if item.get('type') == 'menu': + submenu_items = [] + for subitem in item['items']: + if subitem['allowed']: + submenu_items.append(make_menu_entry(subitem)) + menu_items.append(MenuItemMenu( + title=item['title'], + items=submenu_items)) + + elif item.get('type') == 'sep': + # we only want to add a sep, *if* we already have some + # menu items (i.e. there is something to separate) + # *and* the last menu item is not a sep (avoid doubles) if menu_items and not menu_items[-1].is_sep: - menu_items.append(MenuSeparator()) + menu_items.append(make_menu_entry(item)) - # menu item - else: - menu_items.append( - MenuItem(title=item['title'], - url=item['url'], - target=item.get('target'))) + else: # standard menu item + menu_items.append(make_menu_entry(item)) # remove final separator if present if menu_items and menu_items[-1].is_sep: menu_items.pop() # only add if we wound up with something + assert menu_items if menu_items: - final_menus.append( - MenuGroup(title=topitem['title'], items=menu_items)) + final_menus.append(MenuGroup( + title=topitem['title'], + items=menu_items)) return final_menus + + +def make_menu_entry(item): + """ + Convert a simple menu entry dict, into a proper menu-related object, for + use in constructing final menu. + """ + # separator + if item.get('type') == 'sep': + return MenuSeparator() + + # standard menu item + return MenuItem( + title=item['title'], + url=item['url'], + target=item.get('target')) + + +def is_allowed(request, item): + """ + Logic to determine if a given menu item is "allowed" for current user. + """ + perm = item.get('perm') + if perm: + return request.has_perm(perm) + return True + + +def mark_allowed(request, menus): + """ + Traverse the menu set, and mark each item as "allowed" (or not) based on + current user permissions. + """ + for topitem in menus: + + if topitem.get('type', 'menu') == 'menu': + topitem['allowed'] = False + + for item in topitem['items']: + + if item.get('type') == 'menu': + for subitem in item['items']: + subitem['allowed'] = is_allowed(request, subitem) + + item['allowed'] = False + for subitem in item['items']: + if subitem['allowed'] and subitem.get('type') != 'sep': + item['allowed'] = True + break + + else: + item['allowed'] = is_allowed(request, item) + + for item in topitem['items']: + if item['allowed'] and item.get('type') != 'sep': + topitem['allowed'] = True + break diff --git a/tailbone/static/themes/falafel/css/layout.css b/tailbone/static/themes/falafel/css/layout.css index b4fdccec..20fcf36e 100644 --- a/tailbone/static/themes/falafel/css/layout.css +++ b/tailbone/static/themes/falafel/css/layout.css @@ -46,6 +46,11 @@ header .level-left .global-title { font-weight: bold; } +/* indent nested menu items a bit */ +header .navbar-item.nested { + padding-left: 2.5rem; +} + header .level #current-context, header .level-left #current-context { font-size: 2em; diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index b3e19fd8..e4996a27 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -198,11 +198,28 @@ <div class="navbar-item has-dropdown is-hoverable"> <a class="navbar-link">${topitem.title}</a> <div class="navbar-dropdown"> - % for subitem in topitem.items: - % if subitem.is_sep: - <hr class="navbar-divider"> + % for item in topitem.items: + % if item.is_menu: + <% item_hash = id(item) %> + <% toggle = 'menu_{}_shown'.format(item_hash) %> + <div> + <a class="navbar-link" @click.prevent="toggleNestedMenu('${item_hash}')"> + ${item.title} + </a> + </div> + % for subitem in item.items: + % if subitem.is_sep: + <hr class="navbar-divider" v-show="${toggle}"> + % else: + ${h.link_to("{}".format(subitem.title), subitem.url, class_='navbar-item nested', target=subitem.target, **{'v-show': toggle})} + % endif + % endfor % else: - ${h.link_to(subitem.title, subitem.url, class_='navbar-item', target=subitem.target)} + % if item.is_sep: + <hr class="navbar-divider"> + % else: + ${h.link_to(item.title, item.url, class_='navbar-item', target=item.target)} + % endif % endif % endfor </div> @@ -512,6 +529,11 @@ this.$refs.themePickerForm.submit() }, % endif + + toggleNestedMenu(hash) { + const key = 'menu_' + hash + '_shown' + this[key] = !this[key] + }, }, } @@ -520,6 +542,17 @@ feedbackMessage: "", } + ## declare nested menu visibility toggle flags + % for topitem in menus: + % if topitem.is_menu: + % for item in topitem.items: + % if item.is_menu: + WholePageData.menu_${id(item)}_shown = false + % endif + % endfor + % endif + % endfor + </script> </%def> From 8e9c66c0ea05eddaa21205d505a092582c5b0a2c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 1 Feb 2021 13:58:02 -0600 Subject: [PATCH 0296/1681] Add icon for Help button --- tailbone/templates/themes/falafel/base.mako | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index e4996a27..9a7f8bce 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -346,7 +346,12 @@ ## Help Button % if help_url is not Undefined and help_url: <div class="level-item"> - ${h.link_to("Help", help_url, target='_blank', class_='button')} + <b-button tag="a" href="${help_url}" + target="_blank" + icon-pack="fas" + icon-left="fas fa-question-circle"> + Help + </b-button> </div> % endif From 1cdb11c88cbe77f5e2b1302db92cc22ac4e9c545 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 1 Feb 2021 13:59:37 -0600 Subject: [PATCH 0297/1681] Update changelog --- CHANGES.rst | 22 ++++++++++++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index c13d9e63..60989a3a 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,28 @@ CHANGELOG ========= +0.8.122 (2021-02-01) +-------------------- + +* Normalize naming of all traditional master views. + +* Undo recent ``base.css`` changes for ``<p>`` tags. + +* Misc. improvements for ordering batches, purchases. + +* Purge things for legacy (jquery) mobile, and unused template themes. + +* Make handler responsible for possible receiving modes. + +* Split "new receiving batch" process into 2 steps: choose, create. + +* Add initial "scanning" feature for Ordering Batches. + +* Add support for "nested" menu items. + +* Add icon for Help button. + + 0.8.121 (2021-01-28) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 236425b0..ab0a1242 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.121' +__version__ = '0.8.122' From 0209957defdf877f98d648494bc37c1a15a3640d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 1 Feb 2021 17:15:39 -0600 Subject: [PATCH 0298/1681] Fix config defaults for PurchaseView so can customize that more easily --- tailbone/views/purchases/core.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tailbone/views/purchases/core.py b/tailbone/views/purchases/core.py index 2c31e904..2cd28be8 100644 --- a/tailbone/views/purchases/core.py +++ b/tailbone/views/purchases/core.py @@ -355,21 +355,23 @@ class PurchaseView(MasterView): @classmethod def defaults(cls, config): + cls._purchase_defaults(config) + cls._defaults(config) + + @classmethod + def _purchase_defaults(cls, config): route_prefix = cls.get_route_prefix() url_prefix = cls.get_url_prefix() permission_prefix = cls.get_permission_prefix() model_key = cls.get_model_key() model_title = cls.get_model_title() - cls._defaults(config) - # receiving worksheet config.add_tailbone_permission(permission_prefix, '{}.receiving_worksheet'.format(permission_prefix), "Print receiving worksheet for {}".format(model_title)) config.add_route('{}.receiving_worksheet'.format(route_prefix), '{}/{{{}}}/receiving-worksheet'.format(url_prefix, model_key)) config.add_view(cls, attr='receiving_worksheet', route_name='{}.receiving_worksheet'.format(route_prefix), permission='{}.receiving_worksheet'.format(permission_prefix)) - def includeme(config): From e3bf7f2bb267ad07aba8597ca49b39fbff467a20 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 2 Feb 2021 10:57:58 -0600 Subject: [PATCH 0299/1681] Add stub methods for `MasterView.template_kwargs_view()` etc. otherwise subclass has to consider, can i call super() or not? it still does for some other views, but at least create/view/edit are common enough that it should always be able to call super() without concern for those --- tailbone/views/master.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 68130a2f..a0e3a3b0 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2077,6 +2077,24 @@ class MasterView(View): return kwargs + def template_kwargs_create(self, **kwargs): + """ + Method stub, so subclass can always invoke super() for it. + """ + return kwargs + + def template_kwargs_view(self, **kwargs): + """ + Method stub, so subclass can always invoke super() for it. + """ + return kwargs + + def template_kwargs_edit(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 9b76e233544ddeaaa557a285ca4d71f5b573e4e5 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 2 Feb 2021 13:28:56 -0600 Subject: [PATCH 0300/1681] Update references to vendor catalog batches per table/model rename --- tailbone/views/batch/vendorcatalog.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tailbone/views/batch/vendorcatalog.py b/tailbone/views/batch/vendorcatalog.py index cc9374f1..25eddd68 100644 --- a/tailbone/views/batch/vendorcatalog.py +++ b/tailbone/views/batch/vendorcatalog.py @@ -49,9 +49,10 @@ class VendorCatalogView(FileBatchMasterView): """ Master view for vendor catalog batches. """ - model_class = model.VendorCatalog - model_row_class = model.VendorCatalogRow + model_class = model.VendorCatalogBatch + model_row_class = model.VendorCatalogBatchRow default_handler_spec = 'rattail.batch.vendorcatalog:VendorCatalogHandler' + route_prefix = 'vendorcatalogs' url_prefix = '/vendors/catalogs' template_prefix = '/batch/vendorcatalog' editable = False From 0128690da88ee7ee1b7e9fb5be70540c47f7cd28 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 2 Feb 2021 13:45:53 -0600 Subject: [PATCH 0301/1681] Update references to vendor invoice batches per table/model rename --- tailbone/views/batch/vendorinvoice.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tailbone/views/batch/vendorinvoice.py b/tailbone/views/batch/vendorinvoice.py index c16a7a6a..bd030666 100644 --- a/tailbone/views/batch/vendorinvoice.py +++ b/tailbone/views/batch/vendorinvoice.py @@ -42,9 +42,10 @@ class VendorInvoiceView(FileBatchMasterView): """ Master view for vendor invoice batches. """ - model_class = model.VendorInvoice - model_row_class = model.VendorInvoiceRow + model_class = model.VendorInvoiceBatch + model_row_class = model.VendorInvoiceBatchRow default_handler_spec = 'rattail.batch.vendorinvoice:VendorInvoiceHandler' + route_prefix = 'vendorinvoices' url_prefix = '/vendors/invoices' grid_columns = [ From f93fd7aefa90f45d665e71138d6fc9241b0cc438 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 2 Feb 2021 14:48:34 -0600 Subject: [PATCH 0302/1681] Fix display of handheld batch links, when viewing label batch --- tailbone/views/batch/labels.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tailbone/views/batch/labels.py b/tailbone/views/batch/labels.py index 8aeab62b..5015ffdc 100644 --- a/tailbone/views/batch/labels.py +++ b/tailbone/views/batch/labels.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -138,11 +138,11 @@ class LabelBatchView(BatchMasterView): f.set_label('label_profile_uuid', "Label Profile") def render_handheld_batches(self, label_batch, field): - items = '' + items = [] for handheld in label_batch._handhelds: text = handheld.handheld.id_str url = self.request.route_url('batch.handheld.view', uuid=handheld.handheld_uuid) - items += HTML.tag('li', c=tags.link_to(text, url)) + items.append(HTML.tag('li', c=[tags.link_to(text, url)])) return HTML.tag('ul', c=items) def configure_row_grid(self, g): From 63350469d0eb8b3d12d10c78ab9208a19c033d9b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 2 Feb 2021 18:58:46 -0600 Subject: [PATCH 0303/1681] Prevent updates to batch rows, if batch is immutable probably need a lot more support for this elsewhere; this is all i needed for the moment though.. --- tailbone/api/batch/core.py | 7 ++++++- tailbone/api/batch/ordering.py | 5 ++++- tailbone/api/master.py | 8 ++++++-- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/tailbone/api/batch/core.py b/tailbone/api/batch/core.py index 1200f703..a2f44596 100644 --- a/tailbone/api/batch/core.py +++ b/tailbone/api/batch/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -117,6 +117,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), } def create_object(self, data): @@ -268,6 +269,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), 'sequence': row.sequence, 'status_code': row.status_code, 'status_display': row.STATUS.get(row.status_code, six.text_type(row.status_code)), @@ -280,6 +282,9 @@ 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): + return {'error': "Batch is not mutable"} + # update row per usual row = super(APIBatchRowView, self).update_object(row, data) diff --git a/tailbone/api/batch/ordering.py b/tailbone/api/batch/ordering.py index 031bccdf..21de8da0 100644 --- a/tailbone/api/batch/ordering.py +++ b/tailbone/api/batch/ordering.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -267,6 +267,9 @@ class OrderingBatchRowViews(APIBatchRowView): Note that the "normal" logic for this method is not invoked at all. """ + if not self.handler.is_mutable(row.batch): + return {'error': "Batch is not mutable"} + self.handler.update_row_quantity(row, **data) return row diff --git a/tailbone/api/master.py b/tailbone/api/master.py index f215bee1..775292bc 100644 --- a/tailbone/api/master.py +++ b/tailbone/api/master.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -345,8 +345,12 @@ class APIMasterView(APIView): # assume our data comes only from request JSON body data = self.request.json_body - # update and return data for object + # try to update data for object, returning error as necessary obj = self.update_object(obj, data) + if isinstance(obj, dict) and 'error' in obj: + return {'error': obj['error']} + + # return data for object self.Session.flush() return self._get(obj) From 562d7b48bc0420b399bab5c0822bb88b3c2647b7 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 4 Feb 2021 11:04:00 -0600 Subject: [PATCH 0304/1681] 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 60989a3a..eb919210 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,20 @@ CHANGELOG ========= +0.8.123 (2021-02-04) +-------------------- + +* Fix config defaults for PurchaseView. + +* Add stub methods for ``MasterView.template_kwargs_view()`` etc. + +* Update references to vendor catalog batches etc. + +* Fix display of handheld batch links, when viewing label batch. + +* Prevent updates to batch rows, if batch is immutable. + + 0.8.122 (2021-02-01) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index ab0a1242..5e5bd121 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.122' +__version__ = '0.8.123' From 8f69b07ee2085287697d61e87bfe4a9fb2aabae2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 4 Feb 2021 16:44:40 -0600 Subject: [PATCH 0305/1681] Fix bug when editing a Person --- tailbone/views/people.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index de970119..6e72fe1f 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -180,7 +180,7 @@ class PersonView(MasterView): names['middle'] = data['middle_name'] if 'last_name' in form: names['last'] = data['last_name'] - if 'display_name' in form: + if 'display_name' in form and 'display_name' not in form.readonly_fields: names['full'] = data['display_name'] # TODO: why do we find colander.null values in data at this point? From 85403dfa5ea8a7afd42f1bc3d4f6d60baf2d1caa Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 4 Feb 2021 16:45:24 -0600 Subject: [PATCH 0306/1681] 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 eb919210..77fc38cf 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.124 (2021-02-04) +-------------------- + +* Fix bug when editing a Person. + + 0.8.123 (2021-02-04) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 5e5bd121..c5d2b24f 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.123' +__version__ = '0.8.124' From cc2308c3992e064441fdcf6040ba20f532c0403d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 9 Feb 2021 12:19:26 -0600 Subject: [PATCH 0307/1681] Fix some permission bugs when showing batch tools etc. --- tailbone/templates/batch/index.mako | 7 ++++++- tailbone/templates/master/index.mako | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/batch/index.mako b/tailbone/templates/batch/index.mako index 8d54facc..89358567 100644 --- a/tailbone/templates/batch/index.mako +++ b/tailbone/templates/batch/index.mako @@ -150,7 +150,7 @@ <%def name="modify_this_page_vars()"> ${parent.modify_this_page_vars()} - % if master.results_executable and master.has_perm('execute_multiple'): + % if master.results_refreshable and master.has_perm('refresh'): <script type="text/javascript"> TailboneGridData.refreshResultsButtonText = "Refresh Results" @@ -162,6 +162,11 @@ this.$refs.refreshResultsForm.submit() } + </script> + % endif + % if master.results_executable and master.has_perm('execute_multiple'): + <script type="text/javascript"> + ${execute_form.component_studly}.methods.submit = function() { this.$refs.actualExecuteForm.submit() } diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako index 7dfe741e..d1389a47 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -316,7 +316,7 @@ % endif ## download rows for search results - % if master.has_rows and master.results_rows_downloadable: + % if master.has_rows and master.results_rows_downloadable and master.has_perm('download_results_rows'): % if use_buefy: <b-button type="is-primary" icon-pack="fas" From 5969515f2579b47b408c17559a59b3f96676989c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 9 Feb 2021 14:21:07 -0600 Subject: [PATCH 0308/1681] Render batch execution description as markdown --- tailbone/templates/batch/view.mako | 15 +++++++++++---- tailbone/views/batch/core.py | 5 +++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako index d1a640ed..36b9b633 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -69,6 +69,10 @@ white-space: nowrap; } + .markdown p { + margin-bottom: 1.5rem; + } + </style> % else: <style type="text/css"> @@ -203,6 +207,7 @@ ${execute_title} </b-button> + % if execute_enabled: <b-modal has-modal-card :active.sync="showExecutionDialog"> <div class="modal-card"> @@ -215,10 +220,11 @@ <p class="block has-text-weight-bold"> What will happen when this batch is executed? </p> - <p class="block"> - ${handler.describe_execution(batch) or "TODO: handler does not provide a description for this batch"} - </p> - <${execute_form.component} ref="executeBatchForm"></${execute_form.component}> + <div class="markdown"> + ${execution_described|n} + </div> + <${execute_form.component} ref="executeBatchForm"> + </${execute_form.component}> </section> <footer class="modal-card-foot"> @@ -234,6 +240,7 @@ </div> </b-modal> + % endif % else: ## no buefy, do legacy thing diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 3a07f0a8..d178d60a 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -38,6 +38,7 @@ from six import StringIO import json import six +import markdown import sqlalchemy as sa from sqlalchemy import orm @@ -175,6 +176,10 @@ class BatchMasterView(MasterView): if kwargs['execute_enabled']: url = self.get_action_url('execute', batch) kwargs['execute_form'] = self.make_execute_form(batch, action_url=url) + description = (self.handler.describe_execution(batch) + or "TODO: handler does not provide a description for this batch") + kwargs['execution_described'] = markdown.markdown( + description, extensions=['fenced_code', 'codehilite']) else: kwargs['why_not_execute'] = self.handler.why_not_execute(batch) From e462e41ae193877be54bc8a2910440dcc3c278d3 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 9 Feb 2021 14:22:07 -0600 Subject: [PATCH 0309/1681] Cleanup default display for vendor catalog batches expose description, notes etc. --- tailbone/views/batch/vendorcatalog.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tailbone/views/batch/vendorcatalog.py b/tailbone/views/batch/vendorcatalog.py index 25eddd68..f7b5b15a 100644 --- a/tailbone/views/batch/vendorcatalog.py +++ b/tailbone/views/batch/vendorcatalog.py @@ -61,20 +61,21 @@ class VendorCatalogView(FileBatchMasterView): grid_columns = [ 'id', 'vendor', - 'effective', + 'description', 'filename', 'rowcount', 'created', - 'created_by', 'executed', ] form_fields = [ 'id', + 'description', 'vendor', 'filename', 'future', 'effective', + 'notes', 'created', 'created_by', 'rowcount', @@ -142,9 +143,6 @@ class VendorCatalogView(FileBatchMasterView): g.set_link('vendor') g.set_link('filename') - def get_instance_title(self, batch): - return six.text_type(batch.vendor) - def configure_form(self, f): super(VendorCatalogView, self).configure_form(f) @@ -176,6 +174,8 @@ class VendorCatalogView(FileBatchMasterView): 'parser_key', 'vendor_uuid', 'future', + 'description', + 'notes', ]) parser_values = [(p.key, p.display) for p in self.get_parsers()] @@ -233,6 +233,7 @@ class VendorCatalogView(FileBatchMasterView): def configure_row_form(self, f): super(VendorCatalogView, self).configure_row_form(f) f.set_renderer('product', self.render_product) + f.set_type('upc', 'gpc') f.set_type('discount_percent', 'percent') def template_kwargs_create(self, **kwargs): From f58b06531698737634b6247679b8c2309f6bea22 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 9 Feb 2021 14:24:05 -0600 Subject: [PATCH 0310/1681] Make errors more obvious, when running batch commands as subprocess admin still must consult logs to determine cause, but at least UI won't hang --- tailbone/views/batch/core.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index d178d60a..299f0a10 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -988,13 +988,14 @@ class BatchMasterView(MasterView): except Exception as error: log.warning("%s of '%s' batch failed: %s", handler_action, self.handler.batch_key, batch_uuid, exc_info=True) - # TODO: write error info to socket - - # if progress: - # progress.session.load() - # progress.session['error'] = True - # progress.session['error_msg'] = "Batch population failed: {} - {}".format(error.__class__.__name__, error) - # progress.session.save() + # TODO: write minimal error info to socket + if progress: + progress.session.load() + progress.session['error'] = True + progress.session['error_msg'] = ( + "{} of '{}' batch failed (see logs for more info)").format( + handler_action, self.handler.batch_key) + progress.session.save() return From eaf929474ff4257d5bc83fc93a75def55a633f2f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 10 Feb 2021 11:35:05 -0600 Subject: [PATCH 0311/1681] Add styles for field labels in profile view --- tailbone/templates/people/view_profile_buefy.mako | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako index 1108256d..31779e89 100644 --- a/tailbone/templates/people/view_profile_buefy.mako +++ b/tailbone/templates/people/view_profile_buefy.mako @@ -1,6 +1,16 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> +<%def name="extra_styles()"> + ${parent.extra_styles()} + <style type="text/css"> + .field.is-horizontal .field-label .label { + white-space: nowrap; + min-width: 10rem; + } + </style> +</%def> + <%def name="page_content()"> <profile-info @change-content-title="changeContentTitle"> </profile-info> From a23eb3f32dccaf58607785f814d7e40b6bcd876b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 10 Feb 2021 11:53:40 -0600 Subject: [PATCH 0312/1681] 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 77fc38cf..f3028f24 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,20 @@ CHANGELOG ========= +0.8.125 (2021-02-10) +-------------------- + +* Fix some permission bugs when showing batch tools etc. + +* Render batch execution description as markdown. + +* Cleanup default display for vendor catalog batches. + +* Make errors more obvious, when running batch commands as subprocess. + +* Add styles for field labels in profile view. + + 0.8.124 (2021-02-04) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index c5d2b24f..309e446b 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.124' +__version__ = '0.8.125' From 1420a33649abdf9de05e626d0a62a1869cdd0a47 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 11 Feb 2021 15:57:18 -0600 Subject: [PATCH 0313/1681] Allow customization of main Buefy CSS styles, for falafel theme --- tailbone/templates/themes/falafel/base.mako | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index 9a7f8bce..099fca16 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -153,8 +153,14 @@ </%def> <%def name="buefy_styles()"> - ## Buefy 0.7.4 - ${h.stylesheet_link('https://unpkg.com/buefy@0.7.4/dist/buefy.min.css')} + <% buefy_css = request.rattail_config.get('tailbone', 'theme.falafel.buefy_css') %> + % if buefy_css: + ## custom Buefy CSS + ${h.stylesheet_link(buefy_css)} + % else: + ## Buefy 0.7.4 + ${h.stylesheet_link('https://unpkg.com/buefy@0.7.4/dist/buefy.min.css')} + % endif </%def> ## TODO: this is only being referenced by the progress template i think? From 89f0336af94c66bb31ec1d0c7a1926b8306a86b3 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 12 Feb 2021 13:57:54 -0600 Subject: [PATCH 0314/1681] Add special "contains any of" verb for string-based grid filters --- tailbone/grids/core.py | 4 +++ tailbone/grids/filters.py | 39 +++++++++++++++++++++++ tailbone/static/js/tailbone.buefy.grid.js | 17 ++++++++++ tailbone/templates/grids/buefy.mako | 16 ++++++++-- 4 files changed, 73 insertions(+), 3 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index dde02d19..5c8e1c87 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -1044,6 +1044,9 @@ class Grid(object): valueless = [v for v in filtr.valueless_verbs if v in filtr.verbs] + multiple_values = [v for v in filtr.multiple_value_verbs + if v in filtr.verbs] + choices = [] choice_labels = {} if filtr.choices: @@ -1060,6 +1063,7 @@ class Grid(object): 'visible': filtr.active, 'verbs': filtr.verbs, 'valueless_verbs': valueless, + 'multiple_value_verbs': multiple_values, 'verb_labels': filtr.verb_labels, 'verb': filtr.verb or filtr.default_verb or filtr.verbs[0], 'value': six.text_type(filtr.value) if filtr.value is not None else "", diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py index 0aa5046d..a8914b79 100644 --- a/tailbone/grids/filters.py +++ b/tailbone/grids/filters.py @@ -144,6 +144,7 @@ class GridFilter(object): 'is_empty_or_null': "is either empty or null", 'contains': "contains", 'does_not_contain': "does not contain", + 'contains_any_of': "contains any of", 'is_me': "is me", 'is_not_me': "is not me", } @@ -162,6 +163,10 @@ class GridFilter(object): 'is_not_me', ] + multiple_value_verbs = [ + 'contains_any_of', + ] + value_renderer_factory = DefaultValueRenderer data_type = 'string' # default, but will be set from value renderer choices = {} @@ -382,6 +387,7 @@ class AlchemyStringFilter(AlchemyGridFilter): Expose contains / does-not-contain verbs in addition to core. """ return ['contains', 'does_not_contain', + 'contains_any_of', 'equal', 'not_equal', 'is_empty', 'is_not_empty', 'is_null', 'is_not_null', @@ -414,6 +420,39 @@ class AlchemyStringFilter(AlchemyGridFilter): for v in value.split()]), )) + def filter_contains_any_of(self, query, value): + """ + This filter expects "multiple values" separated by newline character, + and will add an "OR" condition with each value being checked via + "ILIKE". For instance if the user submits a "value" like this: + + .. code-block:: none + + foo bar + baz + + This will result in SQL condition like this: + + .. code-block:: sql + + (name ILIKE '%foo%' AND name ILIKE '%bar%') OR name ILIKE '%baz%' + """ + if not value: + return query + + values = value.split('\n') + values = [value for value in values if value] + if not values: + return query + + conditions = [] + for value in values: + conditions.append(sa.and_( + *[self.column.ilike(self.encode_value('%{}%'.format(v))) + for v in value.split()])) + + return query.filter(sa.or_(*conditions)) + def filter_is_empty(self, query, value): return query.filter(sa.func.trim(self.column) == self.encode_value('')) diff --git a/tailbone/static/js/tailbone.buefy.grid.js b/tailbone/static/js/tailbone.buefy.grid.js index f4ebf170..a4139bc6 100644 --- a/tailbone/static/js/tailbone.buefy.grid.js +++ b/tailbone/static/js/tailbone.buefy.grid.js @@ -80,6 +80,23 @@ const GridFilter = { return true }, + multiValuedVerb() { + /* this returns true if the filter's current verb should expose a multi-value input */ + + // if filter has no "multi-value" verbs then we safely assume false + if (!this.filter.multiple_value_verbs) { + return false + } + + // if filter *does* have multi-value verbs, see if "current" is one + if (this.filter.multiple_value_verbs.includes(this.filter.verb)) { + return true + } + + // current verb is not multi-value + return false + }, + focusValue: function() { this.$refs.valueInput.focus() // this.$refs.valueInput.select() diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako index 8d075356..5bd0e619 100644 --- a/tailbone/templates/grids/buefy.mako +++ b/tailbone/templates/grids/buefy.mako @@ -27,7 +27,8 @@ <script type="text/x-template" id="grid-filter-template"> <div class="level filter" v-show="filter.visible"> - <div class="level-left"> + <div class="level-left" + style="align-items: start;"> <div class="level-item filter-fieldname"> @@ -40,7 +41,9 @@ </div> - <b-field grouped v-show="filter.active" class="level-item"> + <b-field grouped v-show="filter.active" + class="level-item" + style="align-items: start;"> <b-select v-model="filter.verb" @input="focusValue()" @@ -72,7 +75,14 @@ </option> </b-select> - <b-input v-if="filter.data_type == 'string'" + <b-input v-if="filter.data_type == 'string' && !multiValuedVerb()" + v-model="filter.value" + v-show="valuedVerb()" + ref="valueInput"> + </b-input> + + <b-input v-if="filter.data_type == 'string' && multiValuedVerb()" + type="textarea" v-model="filter.value" v-show="valuedVerb()" ref="valueInput"> From 34623a73072b19170918d8743bba260d30e00ea9 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 12 Feb 2021 14:05:44 -0600 Subject: [PATCH 0315/1681] Add special "equal to any of" verb for UPC-related grid filters --- tailbone/grids/filters.py | 45 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py index a8914b79..df594839 100644 --- a/tailbone/grids/filters.py +++ b/tailbone/grids/filters.py @@ -130,6 +130,7 @@ class GridFilter(object): 'is_any': "is any", 'equal': "equal to", 'not_equal': "not equal to", + 'equal_any_of': "equal to any of", 'greater_than': "greater than", 'greater_equal': "greater than or equal to", 'less_than': "less than", @@ -164,6 +165,7 @@ class GridFilter(object): ] multiple_value_verbs = [ + 'equal_any_of', 'contains_any_of', ] @@ -919,7 +921,7 @@ class AlchemyGPCFilter(AlchemyGridFilter): """ GPC filter for SQLAlchemy. """ - default_verbs = ['equal', 'not_equal'] + default_verbs = ['equal', 'not_equal', 'equal_any_of'] def filter_equal(self, query, value): """ @@ -961,6 +963,47 @@ class AlchemyGPCFilter(AlchemyGridFilter): except ValueError: return query + def filter_equal_any_of(self, query, value): + """ + This filter expects "multiple values" separated by newline character, + and will add an "OR" condition with each value being checked via + "ILIKE". For instance if the user submits a "value" like this: + + .. code-block:: none + + 07430500132 + 07430500116 + + This will result in SQL condition like this: + + .. code-block:: sql + + (upc IN (7430500132, 74305001321)) OR (upc IN (7430500116, 74305001161)) + """ + if not value: + return query + + values = value.split('\n') + values = [value for value in values if value] + if not values: + return query + + conditions = [] + for value in values: + try: + clause = self.column.in_(( + GPC(value), + GPC(value, calc_check_digit='upc'))) + except ValueError: + pass + else: + conditions.append(clause) + + if not conditions: + return query + + return query.filter(sa.or_(*conditions)) + class AlchemyPhoneNumberFilter(AlchemyStringFilter): """ From ff904d840f37ee69ae447c5345dfdf336ba73148 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 13 Feb 2021 12:29:43 -0600 Subject: [PATCH 0316/1681] Tweaks per "delete products" batch --- tailbone/views/batch/delproduct.py | 4 +++- tailbone/views/departments.py | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/tailbone/views/batch/delproduct.py b/tailbone/views/batch/delproduct.py index 775e2e79..bcb0795b 100644 --- a/tailbone/views/batch/delproduct.py +++ b/tailbone/views/batch/delproduct.py @@ -53,6 +53,7 @@ class DeleteProductBatchView(BatchMasterView): 'size', 'department_name', 'subdepartment_name', + 'present_in_scale', 'status_code', ] @@ -67,6 +68,7 @@ class DeleteProductBatchView(BatchMasterView): 'department_name', 'subdepartment_number', 'subdepartment_name', + 'present_in_scale', 'status_code', 'status_text', ] @@ -74,7 +76,7 @@ class DeleteProductBatchView(BatchMasterView): def row_grid_extra_class(self, row, i): if row.status_code == row.STATUS_PRODUCT_NOT_FOUND: return 'warning' - if row.status_code == row.STATUS_DEPARTMENT_NOT_ALLOWED: + if row.status_code == row.STATUS_DELETE_NOT_ALLOWED: return 'notice' diff --git a/tailbone/views/departments.py b/tailbone/views/departments.py index cc793ede..d242b51d 100644 --- a/tailbone/views/departments.py +++ b/tailbone/views/departments.py @@ -58,6 +58,7 @@ class DepartmentView(MasterView): 'product', 'personnel', 'exempt_from_gross_sales', + 'allow_product_deletions', ] def configure_grid(self, g): From 793022b92fe8ccdb7eba4aa129a5ae8f36d8d6e5 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 15 Feb 2021 12:57:35 -0600 Subject: [PATCH 0317/1681] Misc. tweaks for vendor catalog batch per rattail changes, in particular for sake of Corporal, to allow for non-native vendor and product associations --- tailbone/helpers.py | 6 ++- .../templates/batch/vendorcatalog/index.mako | 2 +- tailbone/util.py | 14 ++++++- tailbone/views/batch/vendorcatalog.py | 37 +++++++++++-------- 4 files changed, 39 insertions(+), 20 deletions(-) diff --git a/tailbone/helpers.py b/tailbone/helpers.py index 14282c43..a3d07f79 100644 --- a/tailbone/helpers.py +++ b/tailbone/helpers.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -38,7 +38,9 @@ from rattail.db.util import maxlen from webhelpers2.html import * from webhelpers2.html.tags import * -from tailbone.util import csrf_token, get_csrf_token, pretty_datetime, raw_datetime +from tailbone.util import (csrf_token, get_csrf_token, + pretty_datetime, raw_datetime, + route_exists) def pretty_date(date): diff --git a/tailbone/templates/batch/vendorcatalog/index.mako b/tailbone/templates/batch/vendorcatalog/index.mako index 70412b39..fa6e4a5a 100644 --- a/tailbone/templates/batch/vendorcatalog/index.mako +++ b/tailbone/templates/batch/vendorcatalog/index.mako @@ -3,7 +3,7 @@ <%def name="context_menu_items()"> ${parent.context_menu_items()} - % if request.has_perm('vendors.list'): + % if h.route_exists(request, 'vendors') and request.has_perm('vendors.list'): <li>${h.link_to("View Vendors", url('vendors'))}</li> % endif </%def> diff --git a/tailbone/util.py b/tailbone/util.py index 08ffd4cd..c0ab4e3e 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -37,6 +37,7 @@ from rattail.files import resource_path import colander from pyramid.renderers import get_renderer +from pyramid.interfaces import IRoutesMapper from webhelpers2.html import HTML, tags @@ -239,3 +240,14 @@ def email_address_is_valid(address): except colander.Invalid: return False return True + + +def route_exists(request, route_name): + """ + Checks for existence of the given route name, within the running app + config. Returns boolean indicating whether it exists. + """ + reg = request.registry + mapper = reg.getUtility(IRoutesMapper) + route = mapper.get_route(route_name) + return bool(route) diff --git a/tailbone/views/batch/vendorcatalog.py b/tailbone/views/batch/vendorcatalog.py index f7b5b15a..adcf5dff 100644 --- a/tailbone/views/batch/vendorcatalog.py +++ b/tailbone/views/batch/vendorcatalog.py @@ -58,6 +58,10 @@ class VendorCatalogView(FileBatchMasterView): editable = False rows_bulk_deletable = True + labels = { + 'vendor_id': "Vendor ID", + } + grid_columns = [ 'id', 'vendor', @@ -107,6 +111,7 @@ class VendorCatalogView(FileBatchMasterView): 'brand_name', 'description', 'size', + 'is_preferred_vendor', 'old_vendor_code', 'vendor_code', 'old_case_size', @@ -148,7 +153,7 @@ class VendorCatalogView(FileBatchMasterView): # vendor f.set_renderer('vendor', self.render_vendor) - if self.creating: + if self.creating and 'vendor' in f: f.replace('vendor', 'vendor_uuid') f.set_node('vendor_uuid', colander.String()) vendor_display = "" @@ -167,24 +172,19 @@ class VendorCatalogView(FileBatchMasterView): # filename f.set_label('filename', "Catalog File") + # parser_key if self.creating: - - f.set_fields([ - 'filename', - 'parser_key', - 'vendor_uuid', - 'future', - 'description', - 'notes', - ]) - - parser_values = [(p.key, p.display) for p in self.get_parsers()] - parser_values.insert(0, ('', "(please choose)")) - f.set_widget('parser_key', dfwidget.SelectWidget(values=parser_values)) + if 'parser_key' not in f: + f.insert_after('filename', 'parser_key') + values = [(p.key, p.display) for p in self.get_parsers()] + values.insert(0, ('', "(please choose)")) + f.set_widget('parser_key', dfwidget.SelectWidget(values=values)) f.set_label('parser_key', "File Type") # effective - if not self.creating: + if self.creating: + f.remove('effective') + else: f.set_readonly('effective') def get_batch_kwargs(self, batch): @@ -194,6 +194,10 @@ class VendorCatalogView(FileBatchMasterView): kwargs['vendor'] = batch.vendor elif batch.vendor_uuid: kwargs['vendor_uuid'] = batch.vendor_uuid + if batch.vendor_id: + kwargs['vendor_id'] = batch.vendor_id + if batch.vendor_name: + kwargs['vendor_name'] = batch.vendor_name kwargs['future'] = batch.future return kwargs @@ -227,7 +231,8 @@ class VendorCatalogView(FileBatchMasterView): row.STATUS_UPDATE_COST, # TODO: deprecate/remove this one row.STATUS_CHANGE_VENDOR_ITEM_CODE, row.STATUS_CHANGE_CASE_SIZE, - row.STATUS_CHANGE_COST): + row.STATUS_CHANGE_COST, + row.STATUS_CHANGE_PRODUCT): return 'notice' def configure_row_form(self, f): From 9ad64ba5e12a769cc21f0174d26b7cf0ea6d0da0 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 17 Feb 2021 20:18:45 -0600 Subject: [PATCH 0318/1681] Add support for "default" trainwreck model per rattail changes --- tailbone/views/trainwreck/__init__.py | 29 +++++++++++++ .../{trainwreck.py => trainwreck/base.py} | 10 ++++- tailbone/views/trainwreck/defaults.py | 43 +++++++++++++++++++ 3 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 tailbone/views/trainwreck/__init__.py rename tailbone/views/{trainwreck.py => trainwreck/base.py} (96%) create mode 100644 tailbone/views/trainwreck/defaults.py diff --git a/tailbone/views/trainwreck/__init__.py b/tailbone/views/trainwreck/__init__.py new file mode 100644 index 00000000..33662c67 --- /dev/null +++ b/tailbone/views/trainwreck/__init__.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2021 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Trainwreck Views +""" + +from __future__ import unicode_literals, absolute_import + +from .base import TransactionView diff --git a/tailbone/views/trainwreck.py b/tailbone/views/trainwreck/base.py similarity index 96% rename from tailbone/views/trainwreck.py rename to tailbone/views/trainwreck/base.py index a21fd8ef..85e77761 100644 --- a/tailbone/views/trainwreck.py +++ b/tailbone/views/trainwreck/base.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -162,6 +162,10 @@ class TransactionView(MasterView): g.set_link('customer_name') g.set_link('total') + def grid_extra_class(self, transaction, i): + if transaction.void: + return 'warning' + def configure_form(self, f): super(TransactionView, self).configure_form(f) @@ -200,6 +204,10 @@ class TransactionView(MasterView): g.set_type('tax', 'currency') g.set_type('total', 'currency') + def row_grid_extra_class(self, row, i): + if row.void: + return 'warning' + def configure_row_form(self, f): super(TransactionView, self).configure_row_form(f) diff --git a/tailbone/views/trainwreck/defaults.py b/tailbone/views/trainwreck/defaults.py new file mode 100644 index 00000000..68b08a42 --- /dev/null +++ b/tailbone/views/trainwreck/defaults.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2021 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Trainwreck "default" views (i.e. assuming "default" schema) +""" + +from __future__ import unicode_literals, absolute_import + +from rattail.trainwreck.db.model import defaults as trainwreck + +from tailbone.views.trainwreck import base + + +class TransactionView(base.TransactionView): + """ + Master view for Trainwreck transactions + """ + model_class = trainwreck.Transaction + model_row_class = trainwreck.TransactionItem + + +def includeme(config): + TransactionView.defaults(config) From 26d7ab080f6a0b8b1cab947a52c3f313c347bb48 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 18 Feb 2021 11:51:05 -0600 Subject: [PATCH 0319/1681] 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 f3028f24..3ca68cbf 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,22 @@ CHANGELOG ========= +0.8.126 (2021-02-18) +-------------------- + +* Allow customization of main Buefy CSS styles, for falafel theme. + +* Add special "contains any of" verb for string-based grid filters. + +* Add special "equal to any of" verb for UPC-related grid filters. + +* Tweaks per "delete products" batch. + +* Misc. tweaks for vendor catalog batch. + +* Add support for "default" trainwreck model. + + 0.8.125 (2021-02-10) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 309e446b..f55de358 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.125' +__version__ = '0.8.126' From 89bb0aa56d952aa3450bfc747c92be8335875035 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 18 Feb 2021 20:02:53 -0600 Subject: [PATCH 0320/1681] Use end time as default filter, sort for Trainwreck --- tailbone/views/trainwreck/base.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tailbone/views/trainwreck/base.py b/tailbone/views/trainwreck/base.py index 85e77761..927cb79f 100644 --- a/tailbone/views/trainwreck/base.py +++ b/tailbone/views/trainwreck/base.py @@ -60,7 +60,6 @@ class TransactionView(MasterView): grid_columns = [ 'start_time', 'end_time', - 'upload_time', 'system', 'terminal_id', 'receipt_number', @@ -143,10 +142,11 @@ class TransactionView(MasterView): super(TransactionView, self).configure_grid(g) g.filters['receipt_number'].default_active = True g.filters['receipt_number'].default_verb = 'equal' - g.filters['upload_time'].default_active = True - g.filters['upload_time'].default_verb = 'equal' - g.filters['upload_time'].default_value = six.text_type(localtime(self.rattail_config).date()) - g.set_sort_defaults('upload_time', 'desc') + + g.filters['end_time'].default_active = True + g.filters['end_time'].default_verb = 'equal' + g.filters['end_time'].default_value = six.text_type(localtime(self.rattail_config).date()) + g.set_sort_defaults('end_time', 'desc') g.set_enum('system', self.enum.TRAINWRECK_SYSTEM) g.set_type('total', 'currency') From 216807503adf02d06d8d56f5a5305489c697a890 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 20 Feb 2021 08:45:15 -0600 Subject: [PATCH 0321/1681] Avoid encoding values as string, for integer grid filters grid filter for Catapult Transaction "Status" was not working right b/c that is an integer in the db, but we were passing encoded string value to SA / query --- tailbone/grids/filters.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py index df594839..4ce1dc22 100644 --- a/tailbone/grids/filters.py +++ b/tailbone/grids/filters.py @@ -591,6 +591,11 @@ class AlchemyIntegerFilter(AlchemyNumericFilter): return True return False + def encode_value(self, value): + # ensure we pass integer value to sqlalchemy, so it does not try to + # encode it as a string etc. + return int(value) + class AlchemyBooleanFilter(AlchemyGridFilter): """ From abfe8bc648fa742961184ca5c4b36bd56a33e587 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 24 Feb 2021 17:53:48 -0600 Subject: [PATCH 0322/1681] Fix message recipients for Reply / Reply-All, with Buefy themes --- tailbone/views/messages.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tailbone/views/messages.py b/tailbone/views/messages.py index f4c1648c..7371e2e9 100644 --- a/tailbone/views/messages.py +++ b/tailbone/views/messages.py @@ -277,11 +277,17 @@ class MessageView(MasterView): value = [r[0] for r in value] if old_message.sender is not self.request.user and old_message.sender.active: value.insert(0, old_message.sender_uuid) - f.set_default('set_recipients', ','.join(value)) + if use_buefy: + f.set_default('set_recipients', value) + else: + f.set_default('set_recipients', ','.join(value)) # Just a normal reply, to sender only. elif self.filter_reply_recipient(old_message.sender): - f.set_default('set_recipients', old_message.sender.uuid) + if use_buefy: + f.set_default('set_recipients', [old_message.sender.uuid]) + else: + f.set_default('set_recipients', old_message.sender.uuid) # TODO? # # Set focus to message body instead of recipients, when replying. From 637c249c3647ff2b40528bf73263a9012cac0b24 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 26 Feb 2021 21:49:58 -0600 Subject: [PATCH 0323/1681] Handle row click as if checkbox was clicked, for checkable grid should be more convenient since the checkbox is a rather small target as compared to the row itself. this also brings in newer Buefy 0.8.6 b/c it includes "shift+click" behavior for the checkbox: - https://github.com/buefy/buefy/issues/535 - https://github.com/buefy/buefy/pull/1894 --- tailbone/templates/grids/buefy.mako | 15 +++++++++++++++ tailbone/templates/themes/falafel/base.mako | 4 +--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako index 5bd0e619..00b9ce9e 100644 --- a/tailbone/templates/grids/buefy.mako +++ b/tailbone/templates/grids/buefy.mako @@ -143,6 +143,7 @@ :checkable="checkable" % if grid.checkboxes: :checked-rows.sync="checkedRows" + @click="rowClick" % endif % if grid.check_handler: @check="${grid.check_handler}" @@ -446,6 +447,20 @@ } return uuids }, + + // when a user clicks a row, handle as if they clicked checkbox. + // note that this method is only used if table is "checkable" + rowClick(row) { + let i = this.checkedRows.indexOf(row) + if (i >= 0) { + this.checkedRows.splice(i, 1) + } else { + this.checkedRows.push(row) + } + % if grid.check_handler: + this.${grid.check_handler}(this.checkedRows, row) + % endif + }, } } diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index 099fca16..28ff4f6e 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -114,9 +114,7 @@ </%def> <%def name="buefy()"> - ## Buefy (last known good @ 0.8.2) - ## ${h.javascript_link('https://unpkg.com/buefy/dist/buefy.min.js')} - ${h.javascript_link('https://unpkg.com/buefy@0.8.2/dist/buefy.min.js')} + ${h.javascript_link('https://unpkg.com/buefy@0.8.6/dist/buefy.min.js')} </%def> <%def name="fontawesome()"> From ba790823edcb0746328865810a941a7ba1d67a1b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 1 Mar 2021 17:34:24 -0600 Subject: [PATCH 0324/1681] Highlight delete product batch rows with "pending customer orders" status --- tailbone/views/batch/delproduct.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tailbone/views/batch/delproduct.py b/tailbone/views/batch/delproduct.py index bcb0795b..1912c48e 100644 --- a/tailbone/views/batch/delproduct.py +++ b/tailbone/views/batch/delproduct.py @@ -76,7 +76,8 @@ class DeleteProductBatchView(BatchMasterView): def row_grid_extra_class(self, row, i): if row.status_code == row.STATUS_PRODUCT_NOT_FOUND: return 'warning' - if row.status_code == row.STATUS_DELETE_NOT_ALLOWED: + if row.status_code in (row.STATUS_DELETE_NOT_ALLOWED, + row.STATUS_PENDING_CUSTOMER_ORDERS): return 'notice' From 492546d0f601353b4cce9c2dad870751d033544d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 2 Mar 2021 09:26:36 -0600 Subject: [PATCH 0325/1681] Add hover text for subdepartment name, in pricing batch row grid --- tailbone/views/batch/pricing.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tailbone/views/batch/pricing.py b/tailbone/views/batch/pricing.py index 6a8c56f2..57a97a62 100644 --- a/tailbone/views/batch/pricing.py +++ b/tailbone/views/batch/pricing.py @@ -199,6 +199,8 @@ class PricingBatchView(BatchMasterView): g.set_filter('vendor_id', model.Vendor.id) g.set_renderer('vendor_id', self.render_vendor_id) + g.set_renderer('subdepartment_number', self.render_subdepartment_number) + g.set_type('old_price', 'currency') g.set_type('new_price', 'currency') g.set_type('price_diff', 'currency') @@ -213,6 +215,13 @@ class PricingBatchView(BatchMasterView): return "" return vendor_id + def render_subdepartment_number(self, row, field): + if row.subdepartment_number: + if row.subdepartment_name: + return HTML.tag('span', title=row.subdepartment_name, + c=six.text_type(row.subdepartment_number)) + return row.subdepartment_number + def render_true_margin(self, row, field): margin = row.true_margin if margin: From a933fc836f471ca3228ac64602475220ef229e4c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 2 Mar 2021 09:30:41 -0600 Subject: [PATCH 0326/1681] 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 3ca68cbf..ab2ebbcc 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,22 @@ CHANGELOG ========= +0.8.127 (2021-03-02) +-------------------- + +* Use end time as default filter, sort for Trainwreck. + +* Avoid encoding values as string, for integer grid filters. + +* Fix message recipients for Reply / Reply-All, with Buefy themes. + +* Handle row click as if checkbox was clicked, for checkable grid. + +* Highlight delete product batch rows with "pending customer orders" status. + +* Add hover text for subdepartment name, in pricing batch row grid. + + 0.8.126 (2021-02-18) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index f55de358..58c3676d 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.126' +__version__ = '0.8.127' From 241747b967ce34ae891c7a1e09a021f0226eea0b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 5 Mar 2021 12:02:32 -0600 Subject: [PATCH 0327/1681] Allow per-user stylesheet for Buefy themes there is not yet a way for user to select from available options though --- tailbone/subscribers.py | 11 +++++++++++ tailbone/templates/themes/falafel/base.mako | 1 - 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 69aa29e5..b0834496 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -44,6 +44,7 @@ from tailbone import helpers from tailbone.db import Session from tailbone.config import csrf_header_name from tailbone.menus import make_simple_menus +from tailbone.util import should_use_buefy def new_request(event): @@ -145,6 +146,16 @@ def before_render(event): renderer_globals['background_color'] = request.rattail_config.get( 'tailbone', 'background_color') + # maybe set custom stylesheet for Buefy themes + if should_use_buefy(request): + css = None + if request.user: + css = request.rattail_config.get('tailbone.{}'.format(request.user.uuid), + 'buefy_css') + if not css: + css = request.rattail_config.get('tailbone', 'theme.falafel.buefy_css') + renderer_globals['buefy_css'] = css + # here we globally declare widths for grid filter pseudo-columns widths = request.rattail_config.get('tailbone', 'grids.filters.column_widths') if widths: diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index 28ff4f6e..24b533d1 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -151,7 +151,6 @@ </%def> <%def name="buefy_styles()"> - <% buefy_css = request.rattail_config.get('tailbone', 'theme.falafel.buefy_css') %> % if buefy_css: ## custom Buefy CSS ${h.stylesheet_link(buefy_css)} From 97e1700cf9ba7103dbd9f21b2c1d2798afdbecfa Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 5 Mar 2021 12:50:59 -0600 Subject: [PATCH 0328/1681] Expose `date_created` for delete product batches --- tailbone/views/batch/delproduct.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tailbone/views/batch/delproduct.py b/tailbone/views/batch/delproduct.py index 1912c48e..ebe30df3 100644 --- a/tailbone/views/batch/delproduct.py +++ b/tailbone/views/batch/delproduct.py @@ -54,6 +54,7 @@ class DeleteProductBatchView(BatchMasterView): 'department_name', 'subdepartment_name', 'present_in_scale', + 'date_created', 'status_code', ] @@ -69,6 +70,7 @@ class DeleteProductBatchView(BatchMasterView): 'subdepartment_number', 'subdepartment_name', 'present_in_scale', + 'date_created', 'status_code', 'status_text', ] From 059b24fac75511a8d7edbc864065f709aae1ced1 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 5 Mar 2021 12:21:45 -0600 Subject: [PATCH 0329/1681] 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 ab2ebbcc..41cb4d3e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.128 (2021-03-05) +-------------------- + +* Allow per-user stylesheet for Buefy themes. + +* Expose ``date_created`` for delete product batches. + + 0.8.127 (2021-03-02) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 58c3676d..9e65ff42 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.127' +__version__ = '0.8.128' From 7532dc511734034fecf9a8a88d4fb610d8bc74d0 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 9 Mar 2021 11:44:56 -0600 Subject: [PATCH 0330/1681] Add support for `inactivity_months` field for delete product batch --- tailbone/views/batch/delproduct.py | 13 +++++++ tailbone/views/products.py | 55 +++++++++++++++++++----------- 2 files changed, 49 insertions(+), 19 deletions(-) diff --git a/tailbone/views/batch/delproduct.py b/tailbone/views/batch/delproduct.py index ebe30df3..e181ec49 100644 --- a/tailbone/views/batch/delproduct.py +++ b/tailbone/views/batch/delproduct.py @@ -45,6 +45,19 @@ class DeleteProductBatchView(BatchMasterView): bulk_deletable = True rows_bulk_deletable = True + form_fields = [ + 'id', + 'description', + 'notes', + 'inactivity_months', + 'created', + 'created_by', + 'rowcount', + 'status_code', + 'executed', + 'executed_by', + ] + row_grid_columns = [ 'sequence', 'upc', diff --git a/tailbone/views/products.py b/tailbone/views/products.py index e7afa49a..d929a589 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -1608,6 +1608,7 @@ class ProductView(MasterView): if self.request.method == 'POST': if form.validate(newstyle=True): data = form.validated + fully_validated = True # collect general params batch_key = data['batch_type'] @@ -1617,27 +1618,32 @@ class ProductView(MasterView): # collect batch-type-specific params pform = params_forms.get(batch_key) - if pform and pform.validate(newstyle=True): - pdata = pform.validated - for field in pform.schema: - param_name = pform.schema[field.name].param_name - params[param_name] = pdata[field.name] + if pform: + if pform.validate(newstyle=True): + pdata = pform.validated + for field in pform.schema: + param_name = pform.schema[field.name].param_name + params[param_name] = pdata[field.name] + else: + fully_validated = False - # TODO: should this be done elsewhere? - for name in params: - if params[name] is colander.null: - params[name] = None + if fully_validated: - handler = supported[batch_key] - products = self.get_products_for_batch(batch_key) - progress = self.make_progress('products.batch') - thread = Thread(target=self.make_batch_thread, - args=(handler, self.request.user.uuid, products, params, progress)) - thread.start() - return self.render_progress(progress, { - 'cancel_url': self.get_index_url(), - 'cancel_msg': "Batch creation was canceled.", - }) + # TODO: should this be done elsewhere? + for name in params: + if params[name] is colander.null: + params[name] = None + + handler = supported[batch_key] + products = self.get_products_for_batch(batch_key) + progress = self.make_progress('products.batch') + thread = Thread(target=self.make_batch_thread, + args=(handler, self.request.user.uuid, products, params, progress)) + thread.start() + return self.render_progress(progress, { + 'cancel_url': self.get_index_url(), + 'cancel_msg': "Batch creation was canceled.", + }) return self.render_to_response('batch', { 'form': form, @@ -1668,6 +1674,17 @@ class ProductView(MasterView): colander.SchemaNode(colander.Boolean(), name='calculate_for_manual'), ) + def make_batch_params_schema_delproduct(self): + """ + Return params schema for making a "delete products" batch. + """ + return colander.SchemaNode( + colander.Mapping(), + colander.SchemaNode(colander.Integer(), name='inactivity_months', + # TODO: probably should be configurable + default=18), + ) + def make_batch_thread(self, handler, user_uuid, products, params, progress): """ Threat target for making a batch from current products query. From 70c5e36ccbeffc2ac38355caabc3e6bc76487743 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 9 Mar 2021 19:22:05 -0600 Subject: [PATCH 0331/1681] Expose new fields for Trainwreck --- tailbone/views/trainwreck/base.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tailbone/views/trainwreck/base.py b/tailbone/views/trainwreck/base.py index 927cb79f..60a0f873 100644 --- a/tailbone/views/trainwreck/base.py +++ b/tailbone/views/trainwreck/base.py @@ -54,6 +54,7 @@ class TransactionView(MasterView): SessionExtras = ExtraTrainwreckSessions labels = { + 'store_id': "Store", 'cashback': "Cash Back", } @@ -61,6 +62,7 @@ class TransactionView(MasterView): 'start_time', 'end_time', 'system', + 'store_id', 'terminal_id', 'receipt_number', 'cashier_name', @@ -72,8 +74,10 @@ class TransactionView(MasterView): form_fields = [ 'system', 'system_id', + 'store_id', 'terminal_id', 'receipt_number', + 'effective_date', 'start_time', 'end_time', 'upload_time', @@ -83,6 +87,7 @@ class TransactionView(MasterView): 'customer_name', 'shopper_id', 'shopper_name', + 'shopper_level_number', 'subtotal', 'discounted_subtotal', 'tax', @@ -98,6 +103,7 @@ class TransactionView(MasterView): row_labels = { 'item_id': "Item ID", 'department_number': "Dept. No.", + 'subdepartment_number': "Subdept. No.", } row_grid_columns = [ @@ -105,6 +111,7 @@ class TransactionView(MasterView): 'item_type', 'item_scancode', 'department_number', + 'subdepartment_number', 'description', 'unit_quantity', 'subtotal', @@ -120,6 +127,8 @@ class TransactionView(MasterView): 'item_id', 'department_number', 'department_name', + 'subdepartment_number', + 'subdepartment_name', 'description', 'unit_quantity', 'subtotal', @@ -188,8 +197,7 @@ class TransactionView(MasterView): def get_row_data(self, transaction): return self.Session.query(self.model_row_class)\ - .filter(self.model_row_class.transaction == transaction)\ - .order_by(self.model_row_class.sequence) + .filter(self.model_row_class.transaction == transaction) def get_parent(self, item): return item.transaction From e4e0d81f6e52a3b3df0bb6f74e60d17ed9bf0ea1 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 11 Mar 2021 08:47:27 -0600 Subject: [PATCH 0332/1681] Fix enum display for customer order status --- tailbone/views/custorders/orders.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index b51402cd..29d5b7a3 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -91,6 +91,8 @@ class CustomerOrderView(MasterView): g.set_sorter('customer', model.Customer.name) g.set_sorter('person', model.Person.display_name) + g.set_enum('status_code', self.enum.CUSTORDER_STATUS) + g.set_sort_defaults('created', 'desc') # TODO: enum choices renderer From e19119194d19d90c5816080aada9bb78aae63845 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 11 Mar 2021 11:49:18 -0600 Subject: [PATCH 0333/1681] 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 41cb4d3e..92a83cc2 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.8.129 (2021-03-11) +-------------------- + +* Add support for ``inactivity_months`` field for delete product batch. + +* Expose new fields for Trainwreck. + +* Fix enum display for customer order status. + + 0.8.128 (2021-03-05) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 9e65ff42..d5dc16de 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.128' +__version__ = '0.8.129' From ee65d08d8111fbbce5e2b2a83fe5f458906fce41 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 19 Mar 2021 10:38:56 -0500 Subject: [PATCH 0334/1681] Catch and show error, if one happens when making batch from product query --- tailbone/views/products.py | 39 ++++++++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index d929a589..2642b4db 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -40,7 +40,7 @@ from rattail.db import model, api, auth, Session as RattailSession from rattail.gpc import GPC from rattail.threads import Thread from rattail.exceptions import LabelPrintingError -from rattail.util import load_object, pretty_quantity, OrderedDict +from rattail.util import load_object, pretty_quantity, OrderedDict, simple_error from rattail.batch import get_batch_handler from rattail.time import localtime, make_utc @@ -1693,19 +1693,34 @@ class ProductView(MasterView): user = session.query(model.User).get(user_uuid) assert user params['created_by'] = user - batch = handler.make_batch(session, **params) - batch.products = products.with_session(session).all() - handler.do_populate(batch, user, progress=progress) + try: + batch = handler.make_batch(session, **params) + batch.products = products.with_session(session).all() + handler.do_populate(batch, user, progress=progress) - session.commit() - session.refresh(batch) - session.close() + except Exception as error: + session.rollback() + log.exception("failed to make '%s' batch with params: %s", + handler.batch_key, params) + session.close() + if progress: + progress.session.load() + progress.session['error'] = True + progress.session['error_msg'] = "Failed to make '{}' batch: {}".format( + handler.batch_key, simple_error(error)) + progress.session.save() - progress.session.load() - progress.session['complete'] = True - progress.session['success_url'] = self.get_batch_view_url(batch) - progress.session['success_msg'] = 'Batch has been created: {}'.format(batch) - progress.session.save() + else: + session.commit() + session.refresh(batch) + session.close() + + if progress: + progress.session.load() + progress.session['complete'] = True + progress.session['success_url'] = self.get_batch_view_url(batch) + progress.session['success_msg'] = 'Batch has been created: {}'.format(batch) + progress.session.save() def get_batch_view_url(self, batch): if batch.batch_key == 'labels': From 2332cae09bf5ae52b526aa1d16dc00d82149f246 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 19 Mar 2021 10:39:25 -0500 Subject: [PATCH 0335/1681] Expose the new `Store.archived` flag --- tailbone/views/stores.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tailbone/views/stores.py b/tailbone/views/stores.py index a4c4d549..b3107a83 100644 --- a/tailbone/views/stores.py +++ b/tailbone/views/stores.py @@ -56,6 +56,7 @@ class StoreView(MasterView): 'phone', 'email', 'database_key', + 'archived', ] labels = { @@ -80,6 +81,10 @@ class StoreView(MasterView): g.filters['name'].default_active = True g.filters['name'].default_verb = 'contains' + # archived + g.filters['archived'].default_active = True + g.filters['archived'].default_verb = 'is_false_null' + g.set_sorter('phone', model.StorePhoneNumber.number) g.set_sorter('email', model.StoreEmailAddress.address) g.set_sort_defaults('id') @@ -87,6 +92,10 @@ class StoreView(MasterView): g.set_link('id') g.set_link('name') + def grid_extra_class(self, store, i): + if store.archived: + return 'warning' + def configure_form(self, f): super(StoreView, self).configure_form(f) From 4cf61a92cf3ae7f9b58600e2bb6f0fb50679f6ec Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 30 Mar 2021 11:50:20 -0500 Subject: [PATCH 0336/1681] 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 92a83cc2..f2eca7f6 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.130 (2021-03-30) +-------------------- + +* Catch and show error, if one happens when making batch from product query. + +* Expose the new ``Store.archived`` flag. + + 0.8.129 (2021-03-11) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index d5dc16de..83a3291b 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.129' +__version__ = '0.8.130' From 6c5377fadc1f8c9db5d383079442b979e96ea427 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 7 Apr 2021 12:29:33 -0500 Subject: [PATCH 0337/1681] Show current price date range as hover text, for products grid --- tailbone/views/products.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 2642b4db..3a27de06 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -296,6 +296,7 @@ class ProductView(MasterView): g.set_filter('regular_price', self.RegularPrice.price, label="Regular Price") g.set_label('current_price', "Cur. Price") + g.set_renderer('current_price', self.render_current_price_for_grid) g.set_joiner('current_price', lambda q: q.outerjoin( self.CurrentPrice, self.CurrentPrice.uuid == model.Product.current_price_uuid)) g.set_sorter('current_price', self.CurrentPrice.price) @@ -326,7 +327,6 @@ class ProductView(MasterView): g.set_type('upc', 'gpc') g.set_renderer('regular_price', self.render_price) - g.set_renderer('current_price', self.render_price) g.set_renderer('on_hand', self.render_on_hand) g.set_renderer('on_order', self.render_on_order) @@ -472,6 +472,30 @@ class ProductView(MasterView): return "$ {:0.2f} / {}".format(price.pack_price, price.pack_multiple) return "" + def render_current_price_for_grid(self, product, field): + text = self.render_price(product, field) + + price = product.current_price + if price: + app = self.get_rattail_app() + + if price.starts: + starts = localtime(self.rattail_config, price.starts, from_utc=True) + starts = app.render_date(starts.date()) + else: + starts = "??" + + if price.ends: + ends = localtime(self.rattail_config, price.ends, from_utc=True) + ends = app.render_date(ends.date()) + else: + ends = "??" + + return HTML.tag('span', c=text, + title="{} thru {}".format(starts, ends)) + + return text + def add_price_history_link(self, text, typ): if not self.rattail_config.versioning_enabled(): return text From c48371ca2abc06a7a76bafc7dcee0b53a22f48d3 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 7 Apr 2021 17:04:52 -0500 Subject: [PATCH 0338/1681] Make it easier to extend "common" API views --- tailbone/api/common.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tailbone/api/common.py b/tailbone/api/common.py index 0552b68d..c2823ff9 100644 --- a/tailbone/api/common.py +++ b/tailbone/api/common.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -111,6 +111,10 @@ class CommonView(APIView): @classmethod def defaults(cls, config): + cls._common_defaults(config) + + @classmethod + def _common_defaults(cls, config): # about about = Service(name='about', path='/about') From 2d754097574806371b349c3ec66f1948d06b21c8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 12 Apr 2021 11:36:24 -0500 Subject: [PATCH 0339/1681] Accept any decimal numbers for API inventory batch counts i.e. don't assume integer values --- tailbone/api/batch/inventory.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tailbone/api/batch/inventory.py b/tailbone/api/batch/inventory.py index 40ab8ef6..a798c58e 100644 --- a/tailbone/api/batch/inventory.py +++ b/tailbone/api/batch/inventory.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -26,6 +26,8 @@ Tailbone Web API - Inventory Batches from __future__ import unicode_literals, absolute_import +import decimal + import six from rattail import pod @@ -157,17 +159,19 @@ class InventoryBatchRowViews(APIBatchRowView): Converts certain fields within the data, to proper "native" types. """ + data = dict(data) + # convert some data types as needed if 'cases' in data: if data['cases'] == '': data['cases'] = None elif data['cases']: - data['cases'] = int(data['cases']) + data['cases'] = decimal.Decimal(data['cases']) if 'units' in data: if data['units'] == '': data['units'] = None elif data['units']: - data['units'] = int(data['units']) + data['units'] = decimal.Decimal(data['units']) # update row per usual row = super(InventoryBatchRowViews, self).update_object(row, data) From 60fe7cf29c2a78a50a64ff2906cbbf1306575548 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 12 Apr 2021 11:52:54 -0500 Subject: [PATCH 0340/1681] 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 f2eca7f6..d7610610 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.8.131 (2021-04-12) +-------------------- + +* Show current price date range as hover text, for products grid. + +* Make it easier to extend "common" API views. + +* Accept any decimal numbers for API inventory batch counts. + + 0.8.130 (2021-03-30) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 83a3291b..b41bd4ce 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.130' +__version__ = '0.8.131' From 661d536e9d0679ec8b3b3f64fa4bdeeee285f312 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 27 Apr 2021 18:31:11 -0500 Subject: [PATCH 0341/1681] Highlight "has inventory" rows for delete item batch also pass list of such rows to template context --- tailbone/views/batch/delproduct.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tailbone/views/batch/delproduct.py b/tailbone/views/batch/delproduct.py index e181ec49..845de3db 100644 --- a/tailbone/views/batch/delproduct.py +++ b/tailbone/views/batch/delproduct.py @@ -88,10 +88,20 @@ class DeleteProductBatchView(BatchMasterView): 'status_text', ] + def template_kwargs_view(self, **kwargs): + kwargs = super(DeleteProductBatchView, self).template_kwargs_view(**kwargs) + batch = kwargs['batch'] + + kwargs['rows_with_inventory'] = [row for row in batch.active_rows() + if row.status_code == row.STATUS_HAS_INVENTORY] + + return kwargs + def row_grid_extra_class(self, row, i): if row.status_code == row.STATUS_PRODUCT_NOT_FOUND: return 'warning' if row.status_code in (row.STATUS_DELETE_NOT_ALLOWED, + row.STATUS_HAS_INVENTORY, row.STATUS_PENDING_CUSTOMER_ORDERS): return 'notice' From 544f05a5a80961af13b3d9d14ffce44e3854cd5e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 28 Apr 2021 14:05:48 -0500 Subject: [PATCH 0342/1681] Add csrftoken to TailboneForm js ugh..for now at least --- tailbone/templates/forms/deform_buefy.mako | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tailbone/templates/forms/deform_buefy.mako b/tailbone/templates/forms/deform_buefy.mako index 71684f1d..0f1ae184 100644 --- a/tailbone/templates/forms/deform_buefy.mako +++ b/tailbone/templates/forms/deform_buefy.mako @@ -104,6 +104,9 @@ let ${form.component_studly}Data = { + ## TODO: should find a better way to handle CSRF token + csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}, + ## TODO: ugh, this seems pretty hacky. need to declare some data models ## for various field components to bind to... % if not form.readonly: From eede391be8966239bef1533471da21a14f346411 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 28 Apr 2021 19:07:15 -0500 Subject: [PATCH 0343/1681] Freeze pyramid version at 1.x we need to get to python3 before can get latest cornice, and until then we can't get latest pyramid either --- setup.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 0e40a324..e24e3f98 100644 --- a/setup.py +++ b/setup.py @@ -81,6 +81,9 @@ requires = [ # TODO: remove once their bug is fixed? idk what this is about yet... 'deform<2.0.15', # 2.0.14 + # TODO: cornice<5 requires pyramid<2 (see above) + 'pyramid<2', # 1.3b2 1.10.8 + 'colander', # 1.7.0 'ColanderAlchemy', # 0.3.3 'humanize', # 0.5.1 @@ -91,7 +94,6 @@ requires = [ 'paginate_sqlalchemy', # 0.2.0 'passlib', # 1.7.1 'Pillow', # 5.3.0 - 'pyramid', # 1.3b2 'pyramid_beaker>=0.6', # 0.6.1 'pyramid_deform', # 0.2 'pyramid_exclog', # 0.6 From 91db10b10c351331010f6a93fe4fd5378a03eeac Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 28 Apr 2021 19:07:42 -0500 Subject: [PATCH 0344/1681] Tweak tox config a bit per broken tests --- tox.ini | 28 ++++++++-------------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/tox.ini b/tox.ini index 3a0faeca..2ac683e2 100644 --- a/tox.ini +++ b/tox.ini @@ -9,42 +9,30 @@ deps = nose commands = pip install --upgrade pip - pip install --upgrade --upgrade-strategy eager Tailbone rattail[auth,bouncer] rattail-tempmon + pip install --upgrade --upgrade-strategy eager Tailbone rattail[auth,bouncer,db] rattail-tempmon nosetests {posargs} [testenv:py27] -# TODO: this is only here to avoid "latest" packages which break us on python2.7 -deps = - coverage - fixture - mock - nose - SQLAlchemy<1.4 - SQLAlchemy-Utils<0.36.7 +# TODO: this only adds the sa-utils restriction, per python2 +commands = + pip install --upgrade pip + pip install --upgrade --upgrade-strategy eager Tailbone rattail[auth,bouncer,db] rattail-tempmon SQLAlchemy-Utils<0.36.7 + nosetests {posargs} [testenv:coverage] basepython = python3 -# TODO: capping sqlalchemy for now, to avoid issues w/ zope.sqlalchemy -deps = - coverage - fixture - mock - nose - SQLAlchemy<1.4 commands = pip install --upgrade pip - pip install --upgrade --upgrade-strategy eager Tailbone rattail[auth,bouncer] rattail-tempmon + pip install --upgrade --upgrade-strategy eager Tailbone rattail[auth,bouncer,db] rattail-tempmon nosetests {posargs:--with-coverage --cover-html-dir={envtmpdir}/coverage} [testenv:docs] basepython = python3 -# TODO: capping sqlalchemy for now, to avoid issues w/ zope.sqlalchemy deps = Sphinx sphinx-rtd-theme - SQLAlchemy<1.4 changedir = docs commands = pip install --upgrade pip - pip install --upgrade --upgrade-strategy eager Tailbone rattail[auth,bouncer] rattail-tempmon + pip install --upgrade --upgrade-strategy eager Tailbone rattail[auth,bouncer,db] rattail-tempmon sphinx-build -b html -d {envtmpdir}/doctrees -W -T . {envtmpdir}/docs From 00615bea97fcf1804ee262d6940951f4ffa62126 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 3 May 2021 12:36:41 -0500 Subject: [PATCH 0345/1681] 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 d7610610..ad872b75 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.8.132 (2021-05-03) +-------------------- + +* Highlight "has inventory" rows for delete item batch. + +* Add csrftoken to TailboneForm js. + +* Freeze pyramid version at 1.x. + + 0.8.131 (2021-04-12) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index b41bd4ce..2c05624c 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.131' +__version__ = '0.8.132' From 949b9d64bf2d627980dcd37a8e8bb5aa32ec9e61 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 14 May 2021 12:13:23 -0500 Subject: [PATCH 0346/1681] Allow customization of rendering version diff values --- tailbone/templates/master/view_version.mako | 14 ++++++++++---- tailbone/views/master.py | 8 ++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/tailbone/templates/master/view_version.mako b/tailbone/templates/master/view_version.mako index 13c87ae6..5dbcd15d 100644 --- a/tailbone/templates/master/view_version.mako +++ b/tailbone/templates/master/view_version.mako @@ -11,6 +11,10 @@ overflow: auto; } + .versions-wrapper { + margin-left: 2rem; + } + </style> </%def> @@ -45,6 +49,7 @@ </div><!-- form-wrapper --> +<div class="versions-wrapper"> % for version in versions: <h2>${title_for_version(version)}</h2> @@ -62,7 +67,7 @@ % for field in fields_for_version(version): <tr> <td class="field">${field}</td> - <td class="value old-value">${repr(getattr(version.previous, field))}</td> + <td class="value old-value">${render_old_value(version, field)}</td> <td class="value new-value"> </td> </tr> % endfor @@ -81,8 +86,8 @@ % for field in fields_for_version(version): <tr${' class="diff"' if getattr(version, field) != getattr(version.previous, field) else ''|n}> <td class="field">${field}</td> - <td class="value old-value">${repr(getattr(version.previous, field))}</td> - <td class="value new-value">${repr(getattr(version, field))}</td> + <td class="value old-value">${render_old_value(version, field)}</td> + <td class="value new-value">${render_new_value(version, field, 'dirty')}</td> </tr> % endfor </tbody> @@ -101,7 +106,7 @@ <tr> <td class="field">${field}</td> <td class="value old-value"> </td> - <td class="value new-value">${repr(getattr(version, field))}</td> + <td class="value new-value">${render_new_value(version, field, 'new')}</td> </tr> % endfor </tbody> @@ -109,6 +114,7 @@ % endif % endfor +</div> </%def> diff --git a/tailbone/views/master.py b/tailbone/views/master.py index a0e3a3b0..b21051cb 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -1169,6 +1169,8 @@ class MasterView(View): 'title_for_version': self.title_for_version, 'fields_for_version': self.fields_for_version, 'continuum': continuum, + 'render_old_value': self.render_version_old_field_value, + 'render_new_value': self.render_version_new_field_value, }) def title_for_version(self, version): @@ -1198,6 +1200,12 @@ class MasterView(View): versions.extend(query.all()) return versions + def render_version_old_field_value(self, version, field): + return repr(getattr(version.previous, field)) + + def render_version_new_field_value(self, version, field, typ): + return repr(getattr(version, field)) + def configure_common_form(self, form): """ Configure the form in whatever way is deemed "common" - i.e. where From d1a35a4d58920f626c5cae29d074b737e34827e5 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 18 May 2021 12:36:46 -0500 Subject: [PATCH 0347/1681] Allow direct creation of new label batches now technically this is allowed on desktop, but probably makes more sense on mobile via api --- tailbone/api/batch/labels.py | 4 +++- tailbone/views/batch/labels.py | 10 ++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/tailbone/api/batch/labels.py b/tailbone/api/batch/labels.py index 0648a0c9..11a3d20d 100644 --- a/tailbone/api/batch/labels.py +++ b/tailbone/api/batch/labels.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -61,7 +61,9 @@ class LabelBatchRowViews(APIBatchRowView): data['item_id'] = row.item_id data['upc'] = six.text_type(row.upc) data['upc_pretty'] = row.upc.pretty() if row.upc else None + data['brand_name'] = row.brand_name data['description'] = row.description + data['size'] = row.size data['full_description'] = row.product.full_description if row.product else row.description return data diff --git a/tailbone/views/batch/labels.py b/tailbone/views/batch/labels.py index 5015ffdc..c52a5a67 100644 --- a/tailbone/views/batch/labels.py +++ b/tailbone/views/batch/labels.py @@ -48,7 +48,6 @@ class LabelBatchView(BatchMasterView): route_prefix = 'labels.batch' url_prefix = '/labels/batches' template_prefix = '/batch/labels' - creatable = False bulk_deletable = True rows_editable = True rows_bulk_deletable = True @@ -116,12 +115,15 @@ class LabelBatchView(BatchMasterView): super(LabelBatchView, self).configure_form(f) # handheld_batches - f.set_readonly('handheld_batches') - f.set_renderer('handheld_batches', self.render_handheld_batches) - if self.viewing or self.deleting: + if self.creating: + f.remove('handheld_batches') + else: batch = self.get_instance() if not batch._handhelds: f.remove_field('handheld_batches') + else: + f.set_readonly('handheld_batches') + f.set_renderer('handheld_batches', self.render_handheld_batches) # label profile if self.creating or self.editing: From 31941c00bfb8a7f101ba1e8c1a9643149e2a12f9 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 24 May 2021 16:21:08 -0500 Subject: [PATCH 0348/1681] Allow generating project which integrates w/ LOC SMS --- tailbone/templates/generate_project.mako | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/generate_project.mako b/tailbone/templates/generate_project.mako index dc4a2f06..51f404ee 100644 --- a/tailbone/templates/generate_project.mako +++ b/tailbone/templates/generate_project.mako @@ -142,8 +142,8 @@ </b-field> <b-field horizontal label="Integrates w/ LOC SMS" - v-show="false"> - <b-checkbox name="integrates_corepos" + message="Add schema, import/export logic etc. for LOC SMS"> + <b-checkbox name="integrates_locsms" v-model="rattail.integrates_with_locsms" native-value="true"> </b-checkbox> From add4337d115c1c43f95253f2c0b19d458476ebfd Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 11 Jun 2021 13:34:40 -0500 Subject: [PATCH 0349/1681] 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 ad872b75..622d2d91 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.8.133 (2021-06-11) +-------------------- + +* Allow customization of rendering version diff values. + +* Allow direct creation of new label batches. + +* Allow generating project which integrates w/ LOC SMS. + + 0.8.132 (2021-05-03) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 2c05624c..5929fdec 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.132' +__version__ = '0.8.133' From b2bda5e31d01b9db1aba2c57786b48c0623525ae Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 15 Jun 2021 15:51:11 -0500 Subject: [PATCH 0350/1681] Allow config to set favicon and header image it already could set "main" image, shown in home and login pages --- tailbone/templates/base_meta.mako | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/base_meta.mako b/tailbone/templates/base_meta.mako index ec097e5d..568782b7 100644 --- a/tailbone/templates/base_meta.mako +++ b/tailbone/templates/base_meta.mako @@ -5,10 +5,12 @@ <%def name="global_title()">${"[STAGE] " if not request.rattail_config.production() else ''}${self.app_title()}</%def> <%def name="favicon()"> - <link rel="icon" type="image/x-icon" href="${request.static_url('tailbone:static/img/rattail.ico')}" /> + <link rel="icon" type="image/x-icon" href="${request.rattail_config.get('tailbone', 'favicon_url', default=request.static_url('tailbone:static/img/rattail.ico'))}" /> </%def> -<%def name="header_logo()"></%def> +<%def name="header_logo()"> + ${h.image(request.rattail_config.get('tailbone', 'header_image_url', default=request.static_url('tailbone:static/img/rattail.ico')), "Header Logo", style="height: 49px;")} +</%def> <%def name="footer()"> <p class="has-text-centered"> From a1d6403b1b448d47af47507e5961123745fb2c92 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 15 Jun 2021 15:51:57 -0500 Subject: [PATCH 0351/1681] 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 622d2d91..79f84049 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.134 (2021-06-15) +-------------------- + +* Allow config to set favicon and header image. + + 0.8.133 (2021-06-11) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 5929fdec..4ed5a5a8 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.133' +__version__ = '0.8.134' From 2e561f1a4af292a243f83bf5128ef756b617951e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 15 Jun 2021 21:34:22 -0500 Subject: [PATCH 0352/1681] Add 'v' prefix for release package diff links at least i think that is needed... --- tailbone/views/upgrades.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index e817d9a1..0484dabc 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.py @@ -292,51 +292,51 @@ class UpgradeView(MasterView): projects = { 'rattail': { 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/rattail/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail/files/{new_version}/CHANGES.rst', + 'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail/files/v{new_version}/CHANGES.rst', }, 'Tailbone': { 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone/files/{new_version}/CHANGES.rst', + 'release_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone/files/v{new_version}/CHANGES.rst', }, 'pyCOREPOS': { 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/pycorepos/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-project/pycorepos/files/{new_version}/CHANGES.rst', + 'release_url': 'https://kallithea.rattailproject.org/rattail-project/pycorepos/files/v{new_version}/CHANGES.rst', }, 'rattail_corepos': { 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-corepos/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-corepos/files/{new_version}/CHANGES.rst', + 'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-corepos/files/v{new_version}/CHANGES.rst', }, 'tailbone_corepos': { 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-corepos/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-corepos/files/{new_version}/CHANGES.rst', + 'release_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-corepos/files/v{new_version}/CHANGES.rst', }, 'onager': { 'commit_url': 'https://kallithea.rattailproject.org/rattail-restricted/onager/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-restricted/onager/files/{new_version}/CHANGES.rst', + 'release_url': 'https://kallithea.rattailproject.org/rattail-restricted/onager/files/v{new_version}/CHANGES.rst', }, 'rattail-onager': { 'commit_url': 'https://kallithea.rattailproject.org/rattail-restricted/rattail-onager/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-restricted/rattail-onager/files/{new_version}/CHANGELOG.md', + 'release_url': 'https://kallithea.rattailproject.org/rattail-restricted/rattail-onager/files/v{new_version}/CHANGELOG.md', }, 'rattail_tempmon': { 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-tempmon/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-tempmon/files/{new_version}/CHANGES.rst', + 'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-tempmon/files/v{new_version}/CHANGES.rst', }, 'tailbone-onager': { 'commit_url': 'https://kallithea.rattailproject.org/rattail-restricted/tailbone-onager/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-restricted/tailbone-onager/files/{new_version}/CHANGELOG.md', + 'release_url': 'https://kallithea.rattailproject.org/rattail-restricted/tailbone-onager/files/v{new_version}/CHANGELOG.md', }, 'rattail_woocommerce': { 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-woocommerce/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-woocommerce/files/{new_version}/CHANGES.rst', + 'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-woocommerce/files/v{new_version}/CHANGES.rst', }, 'tailbone_woocommerce': { 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-woocommerce/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-woocommerce/files/{new_version}/CHANGES.rst', + 'release_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-woocommerce/files/v{new_version}/CHANGES.rst', }, 'tailbone_theo': { 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/theo/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-project/theo/files/{new_version}/CHANGES.rst', + 'release_url': 'https://kallithea.rattailproject.org/rattail-project/theo/files/v{new_version}/CHANGES.rst', }, } return projects From 5cdd09020d055221205046d7e968d2b9bd84a8cb Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 15 Jun 2021 21:35:58 -0500 Subject: [PATCH 0353/1681] 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 79f84049..b466ba2f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.135 (2021-06-15) +-------------------- + +* Add 'v' prefix for release package diff links. + + 0.8.134 (2021-06-15) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 4ed5a5a8..dceab07e 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.134' +__version__ = '0.8.135' From 35aab87fdc32744730df5c0173ee18a373218770 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 18 Jun 2021 17:39:14 -0500 Subject: [PATCH 0354/1681] Include "is/not null" filters for GPC fields --- tailbone/grids/filters.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py index 4ce1dc22..06c4e7db 100644 --- a/tailbone/grids/filters.py +++ b/tailbone/grids/filters.py @@ -926,7 +926,8 @@ class AlchemyGPCFilter(AlchemyGridFilter): """ GPC filter for SQLAlchemy. """ - default_verbs = ['equal', 'not_equal', 'equal_any_of'] + default_verbs = ['equal', 'not_equal', 'equal_any_of', + 'is_null', 'is_not_null'] def filter_equal(self, query, value): """ From fb156d2e290632a067f2a07fd539b587981dea75 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 18 Jun 2021 17:53:27 -0500 Subject: [PATCH 0355/1681] 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 b466ba2f..1528bf60 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.136 (2021-06-18) +-------------------- + +* Include "is/not null" filters for GPC fields. + + 0.8.135 (2021-06-15) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index dceab07e..231280b2 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.135' +__version__ = '0.8.136' From 8eee4a1cf09891989bb9163fa7efa60ad340eea4 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 15 Jul 2021 13:29:31 -0500 Subject: [PATCH 0356/1681] Set UPC renderer for delproduct batch row --- tailbone/views/batch/core.py | 10 ++++++++++ tailbone/views/batch/delproduct.py | 7 +++++++ tailbone/views/batch/inventory.py | 10 ---------- tailbone/views/handheld.py | 10 ---------- 4 files changed, 17 insertions(+), 20 deletions(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 299f0a10..07b7ff68 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -615,6 +615,16 @@ class BatchMasterView(MasterView): def get_row_status_enum(self): return self.model_row_class.STATUS + def render_upc(self, row, field): + upc = row.upc + if not upc: + return "" + text = upc.pretty() + if row.product_uuid: + url = self.request.route_url('products.view', uuid=row.product_uuid) + return tags.link_to(text, url) + return text + def render_row_status(self, row, column): code = row.status_code if code is None: diff --git a/tailbone/views/batch/delproduct.py b/tailbone/views/batch/delproduct.py index 845de3db..287fb3e3 100644 --- a/tailbone/views/batch/delproduct.py +++ b/tailbone/views/batch/delproduct.py @@ -105,6 +105,13 @@ class DeleteProductBatchView(BatchMasterView): row.STATUS_PENDING_CUSTOMER_ORDERS): return 'notice' + def configure_row_form(self, f): + super(DeleteProductBatchView, self).configure_row_form(f) + row = f.model_instance + + # upc + f.set_renderer('upc', self.render_upc) + def includeme(config): DeleteProductBatchView.defaults(config) diff --git a/tailbone/views/batch/inventory.py b/tailbone/views/batch/inventory.py index adf91561..f8699725 100644 --- a/tailbone/views/batch/inventory.py +++ b/tailbone/views/batch/inventory.py @@ -471,16 +471,6 @@ class InventoryBatchView(BatchMasterView): if not self.allow_cases(row.batch): f.set_readonly('cases') - def render_upc(self, row, field): - upc = row.upc - if not upc: - return "" - text = upc.pretty() - if row.product_uuid: - url = self.request.route_url('products.view', uuid=row.product_uuid) - return tags.link_to(text, url) - return text - @classmethod def defaults(cls, config): cls._batch_defaults(config) diff --git a/tailbone/views/handheld.py b/tailbone/views/handheld.py index 66cd480c..b0392c13 100644 --- a/tailbone/views/handheld.py +++ b/tailbone/views/handheld.py @@ -187,16 +187,6 @@ class HandheldBatchView(FileBatchMasterView): # upc f.set_renderer('upc', self.render_upc) - def render_upc(self, row, field): - upc = row.upc - if not upc: - return "" - text = upc.pretty() - if row.product_uuid: - url = self.request.route_url('products.view', uuid=row.product_uuid) - return tags.link_to(text, url) - return text - 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) From 4addedef6ece47bf6028d738a2eb850a6ba8ec97 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 15 Jul 2021 14:13:01 -0500 Subject: [PATCH 0357/1681] Expose `pack_size` for delproduct batch --- tailbone/views/batch/delproduct.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tailbone/views/batch/delproduct.py b/tailbone/views/batch/delproduct.py index 287fb3e3..60561e96 100644 --- a/tailbone/views/batch/delproduct.py +++ b/tailbone/views/batch/delproduct.py @@ -64,6 +64,7 @@ class DeleteProductBatchView(BatchMasterView): 'brand_name', 'description', 'size', + 'pack_size', 'department_name', 'subdepartment_name', 'present_in_scale', @@ -78,6 +79,7 @@ class DeleteProductBatchView(BatchMasterView): 'brand_name', 'description', 'size', + 'pack_size', 'department_number', 'department_name', 'subdepartment_number', @@ -105,6 +107,12 @@ class DeleteProductBatchView(BatchMasterView): row.STATUS_PENDING_CUSTOMER_ORDERS): return 'notice' + def configure_row_grid(self, g): + super(DeleteProductBatchView, self).configure_row_grid(g) + + # pack_size + g.set_type('pack_size', 'quantity') + def configure_row_form(self, f): super(DeleteProductBatchView, self).configure_row_form(f) row = f.model_instance @@ -112,6 +120,9 @@ class DeleteProductBatchView(BatchMasterView): # upc f.set_renderer('upc', self.render_upc) + # pack_size + f.set_type('pack_size', 'quantity') + def includeme(config): DeleteProductBatchView.defaults(config) From 8884d28306ad1fb62da516f4a049073db81ffea5 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 15 Jul 2021 14:15:19 -0500 Subject: [PATCH 0358/1681] 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 1528bf60..27be20df 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.137 (2021-07-15) +-------------------- + +* Set UPC renderer for delproduct batch row. + +* Expose ``pack_size`` for delproduct batch. + + 0.8.136 (2021-06-18) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 231280b2..0fe1d51b 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.136' +__version__ = '0.8.137' From 90af8f91b88c6f8e82bfafaf452e923d2ae43224 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 2 Aug 2021 18:26:15 -0500 Subject: [PATCH 0359/1681] Let feedback forms define their own email key so multiple recipient options may be presented to user, e.g. in public frontend --- tailbone/api/common.py | 3 ++- tailbone/forms/common.py | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/tailbone/api/common.py b/tailbone/api/common.py index c2823ff9..81458c01 100644 --- a/tailbone/api/common.py +++ b/tailbone/api/common.py @@ -104,7 +104,8 @@ class CommonView(APIView): data['user_url'] = '#' # TODO: could get from config? data['client_ip'] = self.request.client_addr - send_email(self.rattail_config, self.feedback_email_key, data=data) + email_key = data['email_key'] or self.feedback_email_key + send_email(self.rattail_config, email_key, data=data) return {'ok': True} return {'error': "Form did not validate!"} diff --git a/tailbone/forms/common.py b/tailbone/forms/common.py index 9cc145dd..26934479 100644 --- a/tailbone/forms/common.py +++ b/tailbone/forms/common.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -46,6 +46,9 @@ class Feedback(colander.Schema): """ Form schema for user feedback. """ + email_key = colander.SchemaNode(colander.String(), + missing=colander.null) + referrer = colander.SchemaNode(colander.String()) user = colander.SchemaNode(colander.String(), From a10de791a1c04f9abad52db0367f2e51193c8fe8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 4 Aug 2021 13:01:09 -0500 Subject: [PATCH 0360/1681] 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 27be20df..7976b45d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.138 (2021-08-04) +-------------------- + +* Let feedback forms define their own email key. + + 0.8.137 (2021-07-15) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 0fe1d51b..10c12322 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.137' +__version__ = '0.8.138' From 5836099746eec80be94eead28435b36f99e09a9c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 16 Aug 2021 19:29:48 -0500 Subject: [PATCH 0361/1681] Tweak how email preview is sent, and attempt "to" is displayed latter only have been changed for the grid view. preview now is sent "properly" via the configured mail handler, which also means that an attempt may be recorded (whereas previously it would not be) --- tailbone/views/email.py | 52 +++++++++++++++++++++++++++-------------- 1 file changed, 34 insertions(+), 18 deletions(-) diff --git a/tailbone/views/email.py b/tailbone/views/email.py index 2201f8f3..58a0320b 100644 --- a/tailbone/views/email.py +++ b/tailbone/views/email.py @@ -26,6 +26,8 @@ Email Views from __future__ import unicode_literals, absolute_import +import re + import six from rattail import mail @@ -80,8 +82,8 @@ class EmailSettingView(MasterView): self.handler = self.get_handler() def get_handler(self): - # TODO: should let config override which handler we use - return mail.EmailHandler(self.rattail_config) + app = self.get_rattail_app() + return app.get_mail_handler() def get_data(self, session=None): data = [] @@ -277,8 +279,8 @@ class EmailPreview(View): self.handler = self.get_handler() def get_handler(self): - # TODO: should let config override which handler we use - return mail.EmailHandler(self.rattail_config) + app = self.get_rattail_app() + return app.get_mail_handler() def __call__(self): @@ -303,22 +305,15 @@ class EmailPreview(View): if key: email = self.handler.get_email(key) data = email.obtain_sample_data(self.request) - msg = email.make_message(data) - subject = msg['Subject'] - del msg['Subject'] - msg['Subject'] = "[preview] {0}".format(subject) + self.handler.send_message(email, data, + subject_prefix="[PREVIEW] ", + to=[recipient], + cc=None, bcc=None) - del msg['To'] - del msg['Cc'] - del msg['Bcc'] - msg['To'] = recipient - - # TODO: should refactor this to use email handler - sent = mail.deliver_message(self.rattail_config, key, msg) - - self.request.session.flash("Preview for '{}' was {}emailed to {}".format( - key, '' if sent else '(NOT) ', recipient)) + self.request.session.flash( + "Preview for '{}' was emailed to {}".format( + key, recipient)) def preview_template(self, key, type_): email = self.handler.get_email(key) @@ -385,12 +380,33 @@ class EmailAttemptView(MasterView): # status_code g.set_enum('status_code', self.enum.EMAIL_ATTEMPT) + # to + g.set_renderer('to', self.render_to_short) + # links g.set_link('key') g.set_link('sender') g.set_link('subject') g.set_link('to') + to_pattern = re.compile(r'^\{(.*)\}$') + + def render_to_short(self, attempt, column): + value = attempt.to + if not value: + return + + match = self.to_pattern.match(value) + if match: + recips = parse_list(match.group(1)) + if len(recips) > 2: + recips = recips[:2] + recips.append('...') + recips = [HTML.escape(r) for r in recips] + return ', '.join(recips) + + return value + def configure_form(self, f): super(EmailAttemptView, self).configure_form(f) From cf32d4235e7ee233ec79d6f07b79f5fd12f27815 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 18 Aug 2021 19:16:59 -0500 Subject: [PATCH 0362/1681] Move "merge 2 people" logic into People Handler view now delegates to handler, which lives in the rattail package --- tailbone/views/people.py | 66 ++++++++++++++++++---------------------- 1 file changed, 29 insertions(+), 37 deletions(-) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 6e72fe1f..b8e06ced 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -85,16 +85,13 @@ class PersonView(MasterView): ] mergeable = True - merge_additive_fields = [ - 'usernames', - 'member_uuids', - ] - merge_fields = merge_additive_fields + [ - 'uuid', - 'first_name', - 'last_name', - 'display_name', - ] + + def __init__(self, request): + super(PersonView, self).__init__(request) + + # always get a reference to the People Handler + app = self.get_rattail_app() + self.handler = app.get_people_handler() def configure_grid(self, g): super(PersonView, self).configure_grid(g) @@ -190,9 +187,7 @@ class PersonView(MasterView): names[key] = None # do explicit name update w/ common handler logic - app = self.get_rattail_app() - handler = app.get_people_handler() - handler.update_names(person, **names) + self.handler.update_names(person, **names) return person @@ -365,33 +360,30 @@ 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 { - 'uuid': person.uuid, - 'first_name': person.first_name, - 'last_name': person.last_name, - 'display_name': person.display_name, - 'usernames': [u.username for u in person.users], - 'member_uuids': [m.uuid for m in person.members], - } + 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): - """ - Execute a merge operation on the two given person records. - """ - # move Member records to final Person - for member in list(removing.members): - removing.members.remove(member) - keeping.members.append(member) - - # move User records to final Person - for user in list(removing.users): - removing.users.remove(user) - keeping.users.append(user) - - # delete unwanted Person - self.Session.delete(removing) - self.Session.flush() + self.handler.perform_merge(removing, keeping) def view_profile(self): """ From ac133ce8304585c33a2af1876c8fda33fd089626 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 19 Aug 2021 18:00:51 -0500 Subject: [PATCH 0363/1681] Expose "merge request tracking" feature for People data more to come i'm sure, but this covers the basics --- tailbone/templates/people/index.mako | 105 ++++++++++++++ .../templates/people/merge-requests/view.mako | 40 ++++++ tailbone/views/master.py | 2 +- tailbone/views/people.py | 135 +++++++++++++++++- 4 files changed, 279 insertions(+), 3 deletions(-) create mode 100644 tailbone/templates/people/index.mako create mode 100644 tailbone/templates/people/merge-requests/view.mako diff --git a/tailbone/templates/people/index.mako b/tailbone/templates/people/index.mako new file mode 100644 index 00000000..377063b8 --- /dev/null +++ b/tailbone/templates/people/index.mako @@ -0,0 +1,105 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/index.mako" /> + +<%def name="grid_tools()"> + + % if master.mergeable and master.has_perm('request_merge'): + % if use_buefy: + <b-button @click="showMergeRequest()" + icon-pack="fas" + icon-left="object-ungroup" + :disabled="checkedRows.length != 2"> + Request Merge + </b-button> + <b-modal has-modal-card + :active.sync="mergeRequestShowDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Request Merge of 2 People</p> + </header> + + <section class="modal-card-body"> + <b-table :data="mergeRequestRows" + striped hoverable> + <template slot-scope="props"> + <b-table-column field="customer_number" + label="Customer #"> + <span v-html="props.row.customer_number"></span> + </b-table-column> + <b-table-column field="first_name" + label="First Name"> + <span v-html="props.row.first_name"></span> + </b-table-column> + <b-table-column field="last_name" + label="Last Name"> + <span v-html="props.row.last_name"></span> + </b-table-column> + </template> + </b-table> + </section> + + <footer class="modal-card-foot"> + <b-button @click="mergeRequestShowDialog = false"> + Cancel + </b-button> + ${h.form(url('{}.request_merge'.format(route_prefix)), **{'@submit': 'submitMergeRequest'})} + ${h.csrf_token(request)} + ${h.hidden('removing_uuid', **{':value': 'mergeRequestRemovingUUID'})} + ${h.hidden('keeping_uuid', **{':value': 'mergeRequestKeepingUUID'})} + <b-button type="is-primary" + native-type="submit" + :disabled="mergeRequestSubmitting"> + {{ mergeRequestSubmitText }} + </b-button> + ${h.end_form()} + </footer> + </div> + </b-modal> + % endif + % endif + + ${parent.grid_tools()} +</%def> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + % if master.mergeable and master.has_perm('request_merge'): + + ${grid.component_studly}Data.mergeRequestShowDialog = false + ${grid.component_studly}Data.mergeRequestRows = [] + ${grid.component_studly}Data.mergeRequestSubmitText = "Submit Merge Request" + ${grid.component_studly}Data.mergeRequestSubmitting = false + + ${grid.component_studly}.computed.mergeRequestRemovingUUID = function() { + if (this.mergeRequestRows.length) { + return this.mergeRequestRows[0].uuid + } + return null + } + + ${grid.component_studly}.computed.mergeRequestKeepingUUID = function() { + if (this.mergeRequestRows.length) { + return this.mergeRequestRows[1].uuid + } + return null + } + + ${grid.component_studly}.methods.showMergeRequest = function() { + this.mergeRequestRows = this.checkedRows + this.mergeRequestShowDialog = true + } + + ${grid.component_studly}.methods.submitMergeRequest = function() { + this.mergeRequestSubmitting = true + this.mergeRequestSubmitText = "Working, please wait..." + } + + % endif + + </script> +</%def> + +${parent.body()} diff --git a/tailbone/templates/people/merge-requests/view.mako b/tailbone/templates/people/merge-requests/view.mako new file mode 100644 index 00000000..5dcbea03 --- /dev/null +++ b/tailbone/templates/people/merge-requests/view.mako @@ -0,0 +1,40 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> + +<%def name="page_content()"> + ${parent.page_content()} + % if not instance.merged and request.has_perm('people.merge'): + % if use_buefy: + ${h.form(url('people.merge'), **{'@submit': 'submitMergeForm'})} + ${h.csrf_token(request)} + ${h.hidden('uuids', value=','.join([instance.removing_uuid, instance.keeping_uuid]))} + <b-button type="is-primary" + native-type="submit" + :disabled="mergeFormSubmitting" + icon-pack="fas" + icon-left="object-ungroup"> + {{ mergeFormButtonText }} + </b-button> + ${h.end_form()} + % endif + % endif +</%def> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + % if not instance.merged and request.has_perm('people.merge'): + <script type="text/javascript"> + + ThisPageData.mergeFormButtonText = "Perform Merge" + ThisPageData.mergeFormSubmitting = false + + ThisPage.methods.submitMergeForm = function() { + this.mergeFormButtonText = "Working, please wait..." + this.mergeFormSubmitting = true + } + + </script> + % endif +</%def> + +${parent.body()} diff --git a/tailbone/views/master.py b/tailbone/views/master.py index b21051cb..c6f0253d 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -378,7 +378,7 @@ class MasterView(View): Return a dictionary of kwargs to be passed to the factory when creating new grid instances. """ - checkboxes = self.checkboxes + checkboxes = kwargs.get('checkboxes', self.checkboxes) if not checkboxes and self.mergeable and self.has_perm('merge'): checkboxes = True if not checkboxes and self.supports_set_enabled_toggle and self.has_perm('enable_disable_set'): diff --git a/tailbone/views/people.py b/tailbone/views/people.py index b8e06ced..5b4064b3 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -33,7 +33,7 @@ import sqlalchemy as sa from sqlalchemy import orm from rattail.db import model, api -from rattail.time import localtime +from rattail.time import localtime, make_utc from rattail.util import OrderedDict import colander @@ -68,6 +68,7 @@ class PersonView(MasterView): 'last_name', 'phone', 'email', + 'merge_requested', ] form_fields = [ @@ -93,6 +94,15 @@ class PersonView(MasterView): app = self.get_rattail_app() self.handler = app.get_people_handler() + def make_grid_kwargs(self, **kwargs): + kwargs = super(PersonView, self).make_grid_kwargs(**kwargs) + + # turn on checkboxes if user can create a merge reqeust + if self.mergeable and self.has_perm('request_merge'): + kwargs['checkboxes'] = True + + return kwargs + def configure_grid(self, g): super(PersonView, self).configure_grid(g) @@ -123,6 +133,9 @@ class PersonView(MasterView): g.sorters['email'] = lambda q, d: q.order_by(getattr(model.PersonEmailAddress.address, d)()) g.sorters['phone'] = lambda q, d: q.order_by(getattr(model.PersonPhoneNumber.number, d)()) + g.set_label('merge_requested', "MR") + g.set_renderer('merge_requested', self.render_merge_requested) + g.set_sort_defaults('display_name') g.set_label('display_name', "Full Name") @@ -134,6 +147,23 @@ class PersonView(MasterView): g.set_link('first_name') g.set_link('last_name') + def render_merge_requested(self, person, field): + model = self.model + merge_request = self.Session.query(model.MergePeopleRequest)\ + .filter(sa.or_( + model.MergePeopleRequest.removing_uuid == person.uuid, + model.MergePeopleRequest.keeping_uuid == person.uuid))\ + .filter(model.MergePeopleRequest.merged == None)\ + .first() + if merge_request: + use_buefy = self.get_use_buefy() + if use_buefy: + return HTML.tag('span', + class_='has-text-danger has-text-weight-bold', + title="A merge has been requested for this person.", + c="MR") + return "MR" + def get_instance(self): # TODO: I don't recall why this fallback check for a vendor contact # exists here, but leaving it intact for now. @@ -383,7 +413,7 @@ class PersonView(MasterView): raise Exception(reason) def merge_objects(self, removing, keeping): - self.handler.perform_merge(removing, keeping) + self.handler.perform_merge(removing, keeping, user=self.request.user) def view_profile(self): """ @@ -696,6 +726,18 @@ class PersonView(MasterView): self.request.session.flash("User has been created: {}".format(user.username)) return self.redirect(self.request.route_url('users.view', uuid=user.uuid)) + def request_merge(self): + """ + Create a new merge request for the given 2 people. + """ + merge = self.model.MergePeopleRequest() + merge.removing_uuid = self.request.POST['removing_uuid'] + merge.keeping_uuid = self.request.POST['keeping_uuid'] + merge.requested_by = self.request.user + merge.requested = make_utc() + self.Session.add(merge) + return self.redirect(self.get_index_url()) + @classmethod def defaults(cls, config): cls._people_defaults(config) @@ -709,6 +751,7 @@ class PersonView(MasterView): instance_url_prefix = cls.get_instance_url_prefix() model_key = cls.get_model_key() model_title = cls.get_model_title() + model_title_plural = cls.get_model_title_plural() # "profile" perms # TODO: should let view class (or config) determine which of these are available @@ -777,6 +820,15 @@ class PersonView(MasterView): config.add_view(cls, attr='make_user', route_name='{}.make_user'.format(route_prefix), permission='users.create') + # merge requests + if cls.mergeable: + config.add_tailbone_permission(permission_prefix, '{}.request_merge'.format(permission_prefix), + "Request merge for 2 {}".format(model_title_plural)) + config.add_route('{}.request_merge'.format(route_prefix), '{}/request-merge'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='request_merge', route_name='{}.request_merge'.format(route_prefix), + permission='{}.request_merge'.format(permission_prefix)) + # TODO: deprecate / remove this PeopleView = PersonView @@ -888,6 +940,84 @@ class NoteSchema(colander.Schema): note_text = colander.SchemaNode(colander.String(), missing='') +class MergePeopleRequestView(MasterView): + """ + Master view for the MergePeopleRequest class. + """ + model_class = model.MergePeopleRequest + route_prefix = 'people_merge_requests' + url_prefix = '/people/merge-requests' + creatable = False + editable = False + + labels = { + 'removing_uuid': "Removing", + 'keeping_uuid': "Keeping", + } + + grid_columns = [ + 'removing_uuid', + 'keeping_uuid', + 'requested', + 'requested_by', + 'merged', + 'merged_by', + ] + + form_fields = [ + 'removing_uuid', + 'keeping_uuid', + 'requested', + 'requested_by', + 'merged', + 'merged_by', + ] + + def configure_grid(self, g): + super(MergePeopleRequestView, self).configure_grid(g) + + g.set_renderer('removing_uuid', self.render_referenced_person_name) + g.set_renderer('keeping_uuid', self.render_referenced_person_name) + + g.filters['merged'].default_active = True + g.filters['merged'].default_verb = 'is_null' + + g.set_sort_defaults('requested', 'desc') + + g.set_link('removing_uuid') + g.set_link('keeping_uuid') + + def render_referenced_person_name(self, merge_request, field): + uuid = getattr(merge_request, field) + person = self.Session.query(self.model.Person).get(uuid) + if person: + return six.text_type(person) + return "(person not found)" + + def get_instance_title(self, merge_request): + model = self.model + removing = self.Session.query(model.Person).get(merge_request.removing_uuid) + keeping = self.Session.query(model.Person).get(merge_request.keeping_uuid) + return "{} -> {}".format( + removing or "(not found)", + keeping or "(not found)") + + def configure_form(self, f): + super(MergePeopleRequestView, self).configure_form(f) + + f.set_renderer('removing_uuid', self.render_referenced_person) + f.set_renderer('keeping_uuid', self.render_referenced_person) + + def render_referenced_person(self, merge_request, field): + uuid = getattr(merge_request, field) + person = self.Session.query(self.model.Person).get(uuid) + if person: + text = six.text_type(person) + url = self.request.route_url('people.view', uuid=person.uuid) + return tags.link_to(text, url) + return "(person not found)" + + def includeme(config): # autocomplete @@ -900,3 +1030,4 @@ def includeme(config): PersonView.defaults(config) PersonNoteView.defaults(config) + MergePeopleRequestView.defaults(config) From a881b310bc96610598a91e5ea8e0e187ca4e4ada Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 23 Aug 2021 14:25:08 -0500 Subject: [PATCH 0364/1681] Allow customization of row 'view' action url --- tailbone/views/master.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index c6f0253d..6d5a3d96 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -490,9 +490,8 @@ class MasterView(View): # view action if self.rows_viewable: - view = lambda r, i: self.get_row_action_url('view', r) icon = 'eye' if use_buefy else 'zoomin' - actions.append(self.make_action('view', icon=icon, url=view)) + actions.append(self.make_action('view', icon=icon, url=self.row_view_action_url)) # edit action if self.rows_editable and self.has_perm('edit_row'): @@ -1344,6 +1343,9 @@ class MasterView(View): """ return True + def row_view_action_url(self, row, i): + return self.get_row_action_url('view', row) + def row_edit_action_url(self, row, i): if self.row_editable(row): return self.get_row_action_url('edit', row) From 3cf4c0f8e40d89cae2ec2a998cf029630006bd63 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 23 Aug 2021 19:26:50 -0500 Subject: [PATCH 0365/1681] Require explicit opt-in for "clicking grid row checks box" feature sometimes it makes sense *not* to enable that, in which case disabled probably should be the default --- tailbone/grids/core.py | 2 ++ tailbone/templates/grids/buefy.mako | 2 ++ tailbone/views/master.py | 5 +++++ 3 files changed, 9 insertions(+) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 5c8e1c87..f6df3375 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -75,6 +75,7 @@ class Grid(object): sortable=False, sorters={}, default_sortkey=None, default_sortdir='asc', pageable=False, default_pagesize=20, default_page=1, checkboxes=False, checked=None, check_handler=None, check_all_handler=None, + clicking_row_checks_box=False, main_actions=[], more_actions=[], delete_speedbump=False, ajax_data_url=None, component='tailbone-grid', **kwargs): @@ -128,6 +129,7 @@ class Grid(object): self.checked = lambda item: False self.check_handler = check_handler self.check_all_handler = check_all_handler + self.clicking_row_checks_box = clicking_row_checks_box self.main_actions = main_actions or [] self.more_actions = more_actions or [] diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako index 00b9ce9e..6eaff11a 100644 --- a/tailbone/templates/grids/buefy.mako +++ b/tailbone/templates/grids/buefy.mako @@ -143,8 +143,10 @@ :checkable="checkable" % if grid.checkboxes: :checked-rows.sync="checkedRows" + % if grid.clicking_row_checks_box: @click="rowClick" % endif + % endif % if grid.check_handler: @check="${grid.check_handler}" % endif diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 6d5a3d96..a1d6da79 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -73,6 +73,10 @@ class MasterView(View): pageable = True checkboxes = False + # set to True to allow user to click "anywhere" in a row in order + # to toggle its checkbox + clicking_row_checks_box = False + # set to True in order to encode search values as utf-8 use_byte_string_filters = False @@ -399,6 +403,7 @@ class MasterView(View): 'url': lambda obj: self.get_action_url('view', obj), 'checkboxes': checkboxes, 'checked': self.checked, + 'clicking_row_checks_box': self.clicking_row_checks_box, 'assume_local_times': self.has_local_times, } if 'main_actions' not in kwargs and 'more_actions' not in kwargs: From c3079fe899a5c54b77042fe7a0b7742204dd0ab8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 24 Aug 2021 09:39:45 -0500 Subject: [PATCH 0366/1681] Add `before_render_index()` customization hook for MasterView --- tailbone/views/master.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index a1d6da79..f2634328 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -335,8 +335,17 @@ class MasterView(View): context['download_results_rows_fields_available'] = available context['download_results_rows_fields_default'] = self.download_results_rows_fields_default(available) + self.before_render_index() return self.render_to_response('index', context) + def before_render_index(self): + """ + Perform any needed logic just prior to rendering the index + response. Note that this logic is invoked only when rendering + the main index page, but *not* invoked when refreshing partial + grid contents etc. + """ + def make_grid(self, factory=None, key=None, data=None, columns=None, **kwargs): """ Creates a new grid instance From 445862d48d5c07306c314faa27159c1e59f5761c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 26 Aug 2021 11:55:09 -0500 Subject: [PATCH 0367/1681] 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 7976b45d..069c6c94 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,22 @@ CHANGELOG ========= +0.8.139 (2021-08-26) +-------------------- + +* Tweak how email preview is sent, and attempt "to" is displayed. + +* Move "merge 2 people" logic into People Handler. + +* Expose "merge request tracking" feature for People data. + +* Allow customization of row 'view' action url. + +* Require explicit opt-in for "clicking grid row checks box" feature. + +* Add ``before_render_index()`` customization hook for MasterView. + + 0.8.138 (2021-08-04) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 10c12322..565c21e5 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.138' +__version__ = '0.8.139' From 897bb177bc649966283fda0c79dad1cd244cb32f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 28 Aug 2021 14:24:56 -0500 Subject: [PATCH 0368/1681] Make it easier to override rendering grid component in master/index was needed so i could pass extra event handlers to it --- tailbone/templates/master/index.mako | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako index d1389a47..8e855422 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -466,17 +466,22 @@ </b-notification> % endif + ${self.render_grid_component()} + + % if master.deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple': + ${h.form('#', ref='deleteObjectForm')} + ${h.csrf_token(request)} + ${h.end_form()} + % endif +</%def> + +<%def name="render_grid_component()"> <${grid.component} :csrftoken="csrftoken" % if master.deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple': @deleteActionClicked="deleteObject" % endif > </${grid.component}> - % if master.deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple': - ${h.form('#', ref='deleteObjectForm')} - ${h.csrf_token(request)} - ${h.end_form()} - % endif </%def> <%def name="make_this_page_component()"> From fe584f193fca3fdcfaeef35623e034ec998ac5ea Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 28 Aug 2021 18:45:31 -0500 Subject: [PATCH 0369/1681] Always show all grid actions...for now we don't have a great way to accommodate too many actions; ideally could hide some in a drawer, but for now we just show them all for simplicity... --- tailbone/templates/grids/buefy.mako | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako index 6eaff11a..90e8121b 100644 --- a/tailbone/templates/grids/buefy.mako +++ b/tailbone/templates/grids/buefy.mako @@ -187,7 +187,9 @@ % if grid.main_actions or grid.more_actions: <b-table-column field="actions" label="Actions"> - % for action in grid.main_actions: + ## TODO: we do not currently differentiate for "main vs. more" + ## here, but ideally we would tuck "more" away in a drawer etc. + % for action in grid.main_actions + grid.more_actions: <a v-if="props.row._action_url_${action.key}" :href="props.row._action_url_${action.key}" class="grid-action${' has-text-danger' if action.key == 'delete' else ''}" From 4d742bacb1381413cc0cf348889b306469fb1314 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 29 Aug 2021 10:28:36 -0500 Subject: [PATCH 0370/1681] Allow grid columns to be *invisible* (but still present in grid) this can be useful when you need contextual data for a given row, for sake of front-end UI features, but do not want to actually show the extra data column(s) --- tailbone/grids/core.py | 29 ++++++++++++++++++++++++++++- tailbone/templates/grids/buefy.mako | 5 ++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index f6df3375..c476c426 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -69,7 +69,7 @@ class Grid(object): def __init__(self, key, data, columns=None, width='auto', request=None, model_class=None, model_title=None, model_title_plural=None, - enums={}, labels={}, assume_local_times=False, renderers={}, + enums={}, labels={}, assume_local_times=False, renderers={}, invisible=[], extra_row_class=None, linked_columns=[], url='#', joiners={}, filterable=False, filters={}, use_byte_string_filters=False, sortable=False, sorters={}, default_sortkey=None, default_sortdir='asc', @@ -105,6 +105,7 @@ class Grid(object): self.labels = labels or {} self.assume_local_times = assume_local_times self.renderers = self.make_default_renderers(renderers or {}) + self.invisible = invisible or [] self.extra_row_class = extra_row_class self.linked_columns = linked_columns or [] self.url = url @@ -161,13 +162,38 @@ class Grid(object): return [prop.key for prop in mapper.iterate_properties] def hide_column(self, key): + """ + This *removes* a column from the grid, altogether. + + This method should really be renamed to ``remove_column()`` + instead. + """ if key in self.columns: self.columns.remove(key) def hide_columns(self, *keys): + """ + This *removes* columns from the grid, altogether. + + This method should really be renamed to ``remove_columns()`` + instead. + """ for key in keys: self.hide_column(key) + def set_invisible(self, key, invisible=True): + """ + Mark the given column as "invisible" (but do not remove it). + + Use :meth:`hide_column()` if you actually want to remove it. + """ + if invisible: + if key not in self.invisible: + self.invisible.append(key) + else: + if key in self.invisible: + self.invisible.remove(key) + def append(self, field): self.columns.append(field) @@ -1185,6 +1211,7 @@ class Grid(object): 'field': name, 'label': self.get_label(name), 'sortable': self.sortable and name in self.sorters, + 'visible': name not in self.invisible, }) return columns diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako index 90e8121b..b55ff30e 100644 --- a/tailbone/templates/grids/buefy.mako +++ b/tailbone/templates/grids/buefy.mako @@ -176,7 +176,10 @@ <template slot-scope="props"> % for column in grid_columns: - <b-table-column field="${column['field']}" label="${column['label']}" ${'sortable' if column['sortable'] else ''}> + <b-table-column field="${column['field']}" + label="${column['label']}" + :sortable="${json.dumps(column['sortable'])}" + :visible="${json.dumps(column['visible'])}"> % if grid.is_linked(column['field']): <a :href="props.row._action_url_view" v-html="props.row.${column['field']}"></a> % else: From c2ea1be83fd61ae2efee9d3a30f6e539bf10bcdc Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 29 Aug 2021 16:38:30 -0500 Subject: [PATCH 0371/1681] Improve UI, customization hooks for new custorder batch still not done yet, but a savepoint --- tailbone/templates/custorders/create.mako | 28 ++++++++++++++++++++++- tailbone/views/custorders/batch.py | 4 +++- tailbone/views/custorders/orders.py | 27 +++++++++++++++------- 3 files changed, 49 insertions(+), 10 deletions(-) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index 866312c9..7cab2d0b 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -217,9 +217,13 @@ <b-field label="UPC" horizontal expanded> <b-input v-if="!productUUID" v-model="productUPC" - ref="productUPCInput"> + ref="productUPCInput" + @keydown.native="productUPCKeyDown"> </b-input> <b-button v-if="!productUUID" + type="is-primary" + icon-pack="fas" + icon-left="search" @click="fetchProductByUPC()"> Fetch </b-button> @@ -228,6 +232,14 @@ {{ productUPC }} (click to change) </b-button> </b-field> + <b-button v-if="productUUID" + type="is-primary" + tag="a" target="_blank" + :href="'${request.route_url('products')}/' + productUUID" + icon-pack="fas" + icon-left="external-link-alt"> + View Product + </b-button> </b-field> </div> @@ -296,6 +308,10 @@ {{ props.row.product_size }} </b-table-column> + <b-table-column field="department_display" label="Department"> + {{ props.row.department_display }} + </b-table-column> + <b-table-column field="order_quantity_display" label="Quantity"> <span v-html="props.row.order_quantity_display"></span> </b-table-column> @@ -304,6 +320,10 @@ {{ props.row.total_price_display }} </b-table-column> + <b-table-column field="vendor_display" label="Vendor"> + {{ props.row.vendor_display }} + </b-table-column> + <b-table-column field="actions" label="Actions"> <a href="#" class="grid-action" @click.prevent="showEditItemDialog(props.index)"> @@ -734,6 +754,12 @@ }) }, + productUPCKeyDown(event) { + if (event.which == 13) { // Enter + this.fetchProductByUPC() + } + }, + productChanged(uuid) { if (uuid) { this.productUUID = uuid diff --git a/tailbone/views/custorders/batch.py b/tailbone/views/custorders/batch.py index bfbb5c02..9f685671 100644 --- a/tailbone/views/custorders/batch.py +++ b/tailbone/views/custorders/batch.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -52,6 +52,8 @@ class CustomerOrderBatchView(BatchMasterView): 'total_price', 'created', 'created_by', + 'executed', + 'executed_by', ] form_fields = [ diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index 29d5b7a3..e2be9ff5 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -32,7 +32,7 @@ import six from sqlalchemy import orm from rattail import pod -from rattail.db import api, model +from rattail.db import model from rattail.util import pretty_quantity from rattail.batch import get_batch_handler @@ -263,7 +263,8 @@ class CustomerOrderView(MasterView): if not upc: return {'error': "Must specify a product UPC"} - product = api.get_product_by_upc(self.Session(), upc) + product = self.handler.locate_product_for_entry( + self.Session(), upc, product_key='upc') if not product: return {'error': "Product not found"} @@ -299,14 +300,15 @@ class CustomerOrderView(MasterView): # Case case_text = None - if product.case_size is None: + case_size = self.handler.get_case_size_for_product(product) + if case_size is None: case_text = "{} (× ?? {})".format( self.enum.UNIT_OF_MEASURE[self.enum.UNIT_OF_MEASURE_CASE], unit_name) - elif product.case_size > 1: + elif case_size > 1: case_text = "{} (× {} {})".format( self.enum.UNIT_OF_MEASURE[self.enum.UNIT_OF_MEASURE_CASE], - pretty_quantity(product.case_size), + pretty_quantity(case_size), unit_name) if case_text: choices.append({'key': self.enum.UNIT_OF_MEASURE_CASE, @@ -334,6 +336,9 @@ class CustomerOrderView(MasterView): } def normalize_row(self, row): + product = row.product + department = product.department if product else None + cost = product.cost if product else None data = { 'uuid': row.uuid, 'sequence': row.sequence, @@ -344,7 +349,7 @@ class CustomerOrderView(MasterView): 'product_brand': row.product_brand, 'product_description': row.product_description, 'product_size': row.product_size, - 'product_full_description': row.product.full_description if row.product else row.product_description, + 'product_full_description': product.full_description if product else row.product_description, 'product_weighed': row.product_weighed, 'case_quantity': pretty_quantity(row.case_quantity), @@ -352,7 +357,10 @@ class CustomerOrderView(MasterView): 'units_ordered': pretty_quantity(row.units_ordered), 'order_quantity': pretty_quantity(row.order_quantity), 'order_uom': row.order_uom, - 'order_uom_choices': self.uom_choices_for_product(row.product), + 'order_uom_choices': self.uom_choices_for_product(product), + + 'department_display': department.name if department else None, + 'vendor_display': cost.vendor.name if cost else None, 'unit_price': six.text_type(row.unit_price) if row.unit_price is not None else None, 'unit_price_display': "${:0.2f}".format(row.unit_price) if row.unit_price is not None else None, @@ -468,7 +476,7 @@ class CustomerOrderView(MasterView): 'batch': self.normalize_batch(batch)} def submit_new_order(self, batch, data): - result = self.handler.do_execute(batch, self.request.user) + result = self.execute_new_order_batch(batch, data) if not result: return {'error': "Batch failed to execute"} @@ -478,6 +486,9 @@ class CustomerOrderView(MasterView): return {'ok': True, 'next_url': next_url} + def execute_new_order_batch(self, batch, data): + return self.handler.do_execute(batch, self.request.user) + # TODO: deprecate / remove this CustomerOrdersView = CustomerOrderView From 54f1a52ed0e164ec8276cf1627ee6fc1048cc6f9 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 29 Aug 2021 19:52:44 -0500 Subject: [PATCH 0372/1681] Add hover text for vendor ID column of pricing batch row grid --- tailbone/views/batch/pricing.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tailbone/views/batch/pricing.py b/tailbone/views/batch/pricing.py index 57a97a62..1f054e61 100644 --- a/tailbone/views/batch/pricing.py +++ b/tailbone/views/batch/pricing.py @@ -210,10 +210,11 @@ class PricingBatchView(BatchMasterView): g.set_renderer('true_margin', self.render_true_margin) def render_vendor_id(self, row, field): - vendor_id = row.vendor.id if row.vendor else None - if not vendor_id: - return "" - return vendor_id + vendor = row.vendor + if not vendor: + return + text = vendor.id or "(no id)" + return HTML.tag('span', c=text, title=vendor.name) def render_subdepartment_number(self, row, field): if row.subdepartment_number: From 560575e53f796cedc96059ea3ac56358e75bf61d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 31 Aug 2021 22:04:37 -0500 Subject: [PATCH 0373/1681] Fix size of roles multi-select when editing user i.e. for buefy themes --- tailbone/templates/deform/select.pt | 1 + tailbone/views/users.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/tailbone/templates/deform/select.pt b/tailbone/templates/deform/select.pt index 8bdc0c7d..4d09f16f 100644 --- a/tailbone/templates/deform/select.pt +++ b/tailbone/templates/deform/select.pt @@ -66,6 +66,7 @@ placeholder '(please choose)'; class string: form-control ${css_class or ''}; :multiple str(multiple).lower(); + native-size size; style style; v-model vmodel; @input input_handler;"> diff --git a/tailbone/views/users.py b/tailbone/views/users.py index 30937c91..6c8000ad 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -217,6 +217,8 @@ class UserView(PrincipalMasterView): size = len(roles) if size < 3: size = 3 + elif size > 20: + size = 20 f.set_widget('roles', dfwidget.SelectWidget(multiple=True, size=size, values=role_values)) From 8169160b572275c8433411ce82d8ed4542520f28 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 31 Aug 2021 22:05:02 -0500 Subject: [PATCH 0374/1681] Allow "touch" action for employees --- tailbone/views/employees.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/employees.py b/tailbone/views/employees.py index ea430a94..ae59e3da 100644 --- a/tailbone/views/employees.py +++ b/tailbone/views/employees.py @@ -46,6 +46,7 @@ class EmployeeView(MasterView): """ model_class = model.Employee has_versions = True + touchable = True labels = { 'id': "ID", From d671b18215bb26a686e96518cea4200c451b8f33 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 1 Sep 2021 12:20:45 -0500 Subject: [PATCH 0375/1681] 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 069c6c94..7021da36 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,24 @@ CHANGELOG ========= +0.8.140 (2021-09-01) +-------------------- + +* Make it easier to override rendering grid component in master/index. + +* Always show all grid actions...for now. + +* Allow grid columns to be *invisible* (but still present in grid). + +* Improve UI, customization hooks for new custorder batch. + +* Add hover text for vendor ID column of pricing batch row grid. + +* Fix size of roles multi-select when editing user. + +* Allow "touch" action for employees. + + 0.8.139 (2021-08-26) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 565c21e5..33a6b511 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.139' +__version__ = '0.8.140' From fa700d53adf4788cf9cac6d52ae4b0f9315d4ada Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 3 Sep 2021 16:26:15 -0500 Subject: [PATCH 0376/1681] Add /people API endpoint; allow for "native sort" --- tailbone/api/customers.py | 1 + tailbone/api/master.py | 4 +++ tailbone/api/people.py | 56 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+) create mode 100644 tailbone/api/people.py diff --git a/tailbone/api/customers.py b/tailbone/api/customers.py index 2e0a9d4c..fdd2c18e 100644 --- a/tailbone/api/customers.py +++ b/tailbone/api/customers.py @@ -46,6 +46,7 @@ class CustomerView(APIMasterView): 'uuid': customer.uuid, '_str': six.text_type(customer), 'id': customer.id, + 'number': customer.number, 'name': customer.name, } diff --git a/tailbone/api/master.py b/tailbone/api/master.py index 775292bc..27030f5b 100644 --- a/tailbone/api/master.py +++ b/tailbone/api/master.py @@ -129,6 +129,10 @@ class APIMasterView(APIView): def make_sort_spec(self): + # we prefer a "native sort" + if self.request.GET.has_key('nativeSort'): + return json.loads(self.request.GET.getone('nativeSort')) + # these params are based on 'vuetable-2' # https://www.vuetable.com/guide/sorting.html#initial-sorting-order if 'sort' in self.request.params: diff --git a/tailbone/api/people.py b/tailbone/api/people.py new file mode 100644 index 00000000..bb8dd883 --- /dev/null +++ b/tailbone/api/people.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2021 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Tailbone Web API - Person Views +""" + +from __future__ import unicode_literals, absolute_import + +import six + +from rattail.db import model + +from tailbone.api import APIMasterView2 as APIMasterView + + +class PersonView(APIMasterView): + """ + API views for Person data + """ + model_class = model.Person + permission_prefix = 'people' + collection_url_prefix = '/people' + object_url_prefix = '/person' + + def normalize(self, person): + return { + 'uuid': person.uuid, + '_str': six.text_type(person), + 'first_name': person.first_name, + 'last_name': person.last_name, + 'display_name': person.display_name, + } + + +def includeme(config): + PersonView.defaults(config) From 4474f30718a4ae024ed16236a7f02873db4e6d66 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 3 Sep 2021 18:26:55 -0500 Subject: [PATCH 0377/1681] Allow override of "create" permission in API --- tailbone/api/master2.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tailbone/api/master2.py b/tailbone/api/master2.py index 18fb3af0..7f62489e 100644 --- a/tailbone/api/master2.py +++ b/tailbone/api/master2.py @@ -151,7 +151,11 @@ class APIMasterView2(APIMasterView): # create if cls.creatable: cls.establish_method('collection_post') - resource.add_view(cls.collection_post, permission='{}.create'.format(permission_prefix)) + 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: From 82e730c18ee0ceb936d9caae056519dc4f8c2343 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 8 Sep 2021 14:33:40 -0500 Subject: [PATCH 0378/1681] Add the `Grid.remove()` method, deprecate `hide_column()` etc. this is more clear, and aligns with how Form works --- tailbone/grids/core.py | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index c476c426..d52e0fa8 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -27,9 +27,10 @@ Core Grid Classes from __future__ import unicode_literals, absolute_import import datetime -from six.moves import urllib +import warnings import six +from six.moves import urllib import sqlalchemy as sa from sqlalchemy import orm @@ -161,25 +162,32 @@ class Grid(object): mapper = orm.class_mapper(self.model_class) return [prop.key for prop in mapper.iterate_properties] + def remove(self, *keys): + """ + This *removes* some column(s) from the grid, altogether. + """ + for key in keys: + if key in self.columns: + self.columns.remove(key) + def hide_column(self, key): """ This *removes* a column from the grid, altogether. - This method should really be renamed to ``remove_column()`` - instead. + This method is deprecated; use :meth:`remove()` instead. """ - if key in self.columns: - self.columns.remove(key) + warnings.warn("Grid.hide_column() is deprecated; please use " + "Grid.remove() instead.", + DeprecationWarning) + self.remove(key) def hide_columns(self, *keys): """ This *removes* columns from the grid, altogether. - This method should really be renamed to ``remove_columns()`` - instead. + This method is deprecated; use :meth:`remove()` instead. """ - for key in keys: - self.hide_column(key) + self.remove(*keys) def set_invisible(self, key, invisible=True): """ From 97bdc3f7852ba66ed39a42e269aaf8d9027c2625 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 9 Sep 2021 12:00:13 -0500 Subject: [PATCH 0379/1681] Improve error handling for purchase batch so error will display in browser when applicable --- tailbone/views/purchasing/ordering.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tailbone/views/purchasing/ordering.py b/tailbone/views/purchasing/ordering.py index e184fd3f..69e361ed 100644 --- a/tailbone/views/purchasing/ordering.py +++ b/tailbone/views/purchasing/ordering.py @@ -428,8 +428,11 @@ class OrderingBatchView(PurchasingBatchView): self.handler.add_row(batch, row) # update row quantities - self.handler.update_row_quantity(row, cases_ordered=cases_ordered, - units_ordered=units_ordered) + try: + self.handler.update_row_quantity(row, cases_ordered=cases_ordered, + units_ordered=units_ordered) + except Exception as error: + return {'error': six.text_type(error)} else: # empty order quantities From 1ce60821bde8e55a95cad14f77361c0c23397429 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 9 Sep 2021 16:23:27 -0500 Subject: [PATCH 0380/1681] 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 7021da36..263bda2e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,18 @@ CHANGELOG ========= +0.8.141 (2021-09-09) +-------------------- + +* Add /people API endpoint; allow for "native sort". + +* Allow override of "create" permission in API. + +* Add the ``Grid.remove()`` method, deprecate ``hide_column()`` etc. + +* Improve error handling for purchase batch. + + 0.8.140 (2021-09-01) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 33a6b511..1d4f88a2 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.140' +__version__ = '0.8.141' From 83c354b983140c3669a883ab8959054d4d88d2ae Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 9 Sep 2021 17:07:46 -0500 Subject: [PATCH 0381/1681] Set quantity type when viewing vendor lead times, order intervals --- tailbone/views/vendors/core.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tailbone/views/vendors/core.py b/tailbone/views/vendors/core.py index 52b3e5a6..7a6f4eca 100644 --- a/tailbone/views/vendors/core.py +++ b/tailbone/views/vendors/core.py @@ -89,6 +89,9 @@ class VendorView(MasterView): super(VendorView, self).configure_form(f) vendor = f.model_instance + f.set_type('lead_time_days', 'quantity') + f.set_type('order_interval_days', 'quantity') + # default_phone f.set_renderer('default_phone', self.render_default_phone) if not self.creating and vendor.phones: From 177286533da3923e8ca97c3d89d0fc077572b953 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 9 Sep 2021 17:22:00 -0500 Subject: [PATCH 0382/1681] 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 263bda2e..c97fef0e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.142 (2021-09-09) +-------------------- + +* Set quantity type when viewing vendor lead times, order intervals. + + 0.8.141 (2021-09-09) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 1d4f88a2..64e90ff0 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.141' +__version__ = '0.8.142' From 25c1ae3c415103f03cfbf3c66771f9967a76ff77 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 9 Sep 2021 19:15:08 -0500 Subject: [PATCH 0383/1681] Add way to customize product autocomplete for new custorder --- tailbone/templates/custorders/create.mako | 2 +- tailbone/views/custorders/orders.py | 49 ++++++++++++++++++++--- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index 7cab2d0b..c035b3c0 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -207,7 +207,7 @@ v-model="productUUID" :assigned-value="productUUID" :assigned-label="productDisplay" - serviceUrl="${url('products.autocomplete')}" + serviceUrl="${product_autocomplete_url}" @input="productChanged"> </tailbone-autocomplete> </b-field> diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index e2be9ff5..34e0fbaa 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -123,6 +123,11 @@ class CustomerOrderView(MasterView): url = self.request.route_url('people.view', uuid=person.uuid) return tags.link_to(text, url) + def get_batch_handler(self): + return get_batch_handler( + self.rattail_config, 'custorder', + default='rattail.batch.custorder:CustomerOrderBatchHandler') + def create(self, form=None, template='create'): """ View for creating a new customer order. Note that it does so by way of @@ -130,10 +135,7 @@ class CustomerOrderView(MasterView): submits the order, at which point the batch is converted to a proper order. """ - self.handler = get_batch_handler( - self.rattail_config, 'custorder', - default='rattail.batch.custorder:CustomerOrderBatchHandler') - + self.handler = self.get_batch_handler() batch = self.get_current_batch() if self.request.method == 'POST': @@ -166,9 +168,17 @@ class CustomerOrderView(MasterView): items = [self.normalize_row(row) for row in batch.active_rows()] + + if self.handler.has_custom_product_autocomplete: + route_prefix = self.get_route_prefix() + autocomplete = '{}.product_autocomplete'.format(route_prefix) + else: + autocomplete = 'products.autocomplete' + context = {'batch': batch, 'normalized_batch': self.normalize_batch(batch), - 'order_items': items} + 'order_items': items, + 'product_autocomplete_url': self.request.route_url(autocomplete)} return self.render_to_response(template, context) def get_current_batch(self): @@ -258,6 +268,15 @@ class CustomerOrderView(MasterView): self.Session.flush() return {'success': True} + def product_autocomplete(self): + """ + Custom product autocomplete logic, which invokes the handler. + """ + self.handler = self.get_batch_handler() + term = self.request.GET['term'] + return self.handler.custom_product_autocomplete(self.Session(), term, + user=self.request.user) + def find_product_by_upc(self, batch, data): upc = data.get('upc') if not upc: @@ -489,6 +508,26 @@ class CustomerOrderView(MasterView): def execute_new_order_batch(self, batch, data): return self.handler.do_execute(batch, self.request.user) + @classmethod + def defaults(cls, config): + cls._order_defaults(config) + cls._defaults(config) + + @classmethod + def _order_defaults(cls, config): + route_prefix = cls.get_route_prefix() + url_prefix = cls.get_url_prefix() + + # custom product autocomplete + config.add_route('{}.product_autocomplete'.format(route_prefix), + '{}/product-autocomplete'.format(url_prefix), + request_method='GET') + config.add_view(cls, attr='product_autocomplete', + route_name='{}.product_autocomplete'.format(route_prefix), + renderer='json', + permission='products.list') + + # TODO: deprecate / remove this CustomerOrdersView = CustomerOrderView From 7e0713e22bd4f765b2e36c43bdaa9123cdeac3c0 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 12 Sep 2021 19:14:52 -0500 Subject: [PATCH 0384/1681] 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 c97fef0e..cdace26a 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.143 (2021-09-12) +-------------------- + +* Add way to customize product autocomplete for new custorder. + + 0.8.142 (2021-09-09) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 64e90ff0..2cc15640 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.142' +__version__ = '0.8.143' From 884b1e02a7bc2442845d00387ef5dac1ec09c2e7 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 15 Sep 2021 19:01:53 -0500 Subject: [PATCH 0385/1681] Invoke handler when request is made to merge 2 people --- tailbone/views/people.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 5b4064b3..0613d3d7 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -33,7 +33,7 @@ import sqlalchemy as sa from sqlalchemy import orm from rattail.db import model, api -from rattail.time import localtime, make_utc +from rattail.time import localtime from rattail.util import OrderedDict import colander @@ -730,12 +730,9 @@ class PersonView(MasterView): """ Create a new merge request for the given 2 people. """ - merge = self.model.MergePeopleRequest() - merge.removing_uuid = self.request.POST['removing_uuid'] - merge.keeping_uuid = self.request.POST['keeping_uuid'] - merge.requested_by = self.request.user - merge.requested = make_utc() - self.Session.add(merge) + self.handler.request_merge(self.request.user, + self.request.POST['removing_uuid'], + self.request.POST['keeping_uuid']) return self.redirect(self.get_index_url()) @classmethod From 2188e91fae5e366d86477396c034c2461fa21b3b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 16 Sep 2021 11:10:21 -0500 Subject: [PATCH 0386/1681] 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 cdace26a..95773484 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.144 (2021-09-16) +-------------------- + +* Invoke handler when request is made to merge 2 people. + + 0.8.143 (2021-09-12) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 2cc15640..49ad6884 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.143' +__version__ = '0.8.144' From d295cf04afb3ab37564cd033bb06a01d71b24a65 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 19 Sep 2021 18:36:25 -0500 Subject: [PATCH 0387/1681] Allow setting the "exclusive" sequence of grid filters i.e. let caller specify that any not included, should be omitted --- tailbone/grids/core.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index d52e0fa8..a4d3cc92 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -28,6 +28,7 @@ from __future__ import unicode_literals, absolute_import import datetime import warnings +import logging import six from six.moves import urllib @@ -49,6 +50,9 @@ from tailbone.db import Session from tailbone.util import raw_datetime +log = logging.getLogger(__name__) + + class FieldList(list): """ Convenience wrapper for a field list. @@ -1047,7 +1051,7 @@ class Grid(object): return render(template, context) - def set_filters_sequence(self, filters): + def set_filters_sequence(self, filters, only=False): """ Explicitly set the sequence for grid filters, using the sequence provided. If the grid currently has more filters than are mentioned in @@ -1055,12 +1059,21 @@ class Grid(object): tacked on at the end. :param filters: Sequence of filter keys, i.e. field names. + + :param only: If true, then *only* those filters specified will + be kept, and all others discarded. If false then any + filters not specified will still be tacked onto the end, in + alphabetical order. """ new_filters = gridfilters.GridFilterSet() for field in filters: - new_filters[field] = self.filters.pop(field) - for field in self.filters: - new_filters[field] = self.filters[field] + if field in self.filters: + new_filters[field] = self.filters.pop(field) + else: + log.warning("field '%s' is not in current filter set", field) + if not only: + for field in sorted(self.filters): + new_filters[field] = self.filters[field] self.filters = new_filters def get_filters_sequence(self): From 8af247a7f633adba0aa117e1299c9d7e2a8905a5 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 19 Sep 2021 19:08:53 -0500 Subject: [PATCH 0388/1681] 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 95773484..0cf4a503 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.145 (2021-09-19) +-------------------- + +* Allow setting the "exclusive" sequence of grid filters. + + 0.8.144 (2021-09-16) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 49ad6884..a837239c 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.144' +__version__ = '0.8.145' From d0a7a241b449b3bbcdaa9016e1f4ce43370d6646 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 21 Sep 2021 13:49:51 -0500 Subject: [PATCH 0389/1681] Misc. improvements for customer order views --- tailbone/templates/custorders/create.mako | 34 +++++++--- tailbone/views/custorders/batch.py | 1 + tailbone/views/custorders/items.py | 52 +++++++++++++-- tailbone/views/custorders/orders.py | 79 +++++++++++++++++++++-- 4 files changed, 143 insertions(+), 23 deletions(-) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index c035b3c0..f0e5d5b3 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -30,9 +30,10 @@ <div class="buttons"> <b-button type="is-primary" @click="submitOrder()" + :disabled="submittingOrder" icon-pack="fas" icon-left="fas fa-upload"> - Submit this Order + {{ submitOrderButtonText }} </b-button> <b-button @click="startOverEntirely()" icon-pack="fas" @@ -176,12 +177,14 @@ <div class="panel-block"> <div> - <b-button type="is-primary" - icon-pack="fas" - icon-left="fas fa-plus" - @click="showAddItemDialog()"> - Add Item - </b-button> + <div class="buttons"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="fas fa-plus" + @click="showAddItemDialog()"> + Add Item + </b-button> + </div> <b-modal :active.sync="showingItemDialog"> <div class="card"> <div class="card-content"> @@ -288,8 +291,8 @@ </div> </b-modal> - <b-table - :data="items"> + <b-table v-if="items.length" + :data="items"> <template slot-scope="props"> <b-table-column field="product_upc_pretty" label="UPC"> @@ -403,6 +406,8 @@ ## TODO: should find a better way to handle CSRF token csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}, + + submittingOrder: false, } }, computed: { @@ -515,6 +520,13 @@ itemDialogSaveButtonText() { return this.editingItem ? "Update Item" : "Add Item" }, + + submitOrderButtonText() { + if (this.submittingOrder) { + return "Working, please wait..." + } + return "Submit this Order" + }, }, mounted() { if (this.customerStatusType) { @@ -607,9 +619,12 @@ }, submitOrder() { + this.submittingOrder = true + let params = { action: 'submit_new_order', } + this.submitBatchData(params, response => { if (response.data.error) { this.$buefy.toast.open({ @@ -617,6 +632,7 @@ type: 'is-danger', duration: 2000, // 2 seconds }) + this.submittingOrder = false } else { if (response.data.next_url) { location.href = response.data.next_url diff --git a/tailbone/views/custorders/batch.py b/tailbone/views/custorders/batch.py index 9f685671..c9a4a4b6 100644 --- a/tailbone/views/custorders/batch.py +++ b/tailbone/views/custorders/batch.py @@ -84,6 +84,7 @@ class CustomerOrderBatchView(BatchMasterView): 'product_size', 'order_quantity', 'order_uom', + 'case_quantity', 'total_price', 'status_code', ] diff --git a/tailbone/views/custorders/items.py b/tailbone/views/custorders/items.py index 2fb19225..8756d538 100644 --- a/tailbone/views/custorders/items.py +++ b/tailbone/views/custorders/items.py @@ -51,14 +51,20 @@ class CustomerOrderItemView(MasterView): editable = False deletable = False + labels = { + 'order_id': "Order ID", + 'order_uom': "Order UOM", + } + grid_columns = [ + 'order_id', 'person', 'product_brand', 'product_description', 'product_size', + 'order_quantity', + 'order_uom', 'case_quantity', - 'cases_ordered', - 'units_ordered', 'order_created', 'status_code', ] @@ -79,14 +85,16 @@ class CustomerOrderItemView(MasterView): ] form_fields = [ + 'order', + 'sequence', 'person', 'product', 'product_brand', 'product_description', 'product_size', + 'order_quantity', + 'order_uom', 'case_quantity', - 'cases_ordered', - 'units_ordered', 'unit_price', 'total_price', 'paid_amount', @@ -102,6 +110,8 @@ class CustomerOrderItemView(MasterView): def configure_grid(self, g): super(CustomerOrderItemView, self).configure_grid(g) + g.set_renderer('order_id', self.render_order_id) + g.set_joiner('person', lambda q: q.outerjoin(model.Person)) g.filters['person'] = g.make_filter('person', model.Person.display_name, @@ -116,18 +126,34 @@ class CustomerOrderItemView(MasterView): g.set_type('cases_ordered', 'quantity') g.set_type('units_ordered', 'quantity') g.set_type('total_price', 'currency') + g.set_type('order_quantity', 'quantity') - g.set_renderer('person', self.render_person) + g.set_enum('order_uom', self.enum.UNIT_OF_MEASURE) + + g.set_renderer('person', self.render_person_text) g.set_renderer('order_created', self.render_order_created) + g.set_enum('status_code', self.enum.CUSTORDER_ITEM_STATUS) + g.set_label('person', "Person Name") g.set_label('product_brand', "Brand") g.set_label('product_description', "Description") g.set_label('product_size', "Size") g.set_label('status_code', "Status") - def render_person(self, item, column): - return item.order.person + g.set_link('order_id') + g.set_link('person') + g.set_link('product_brand') + g.set_link('product_description') + + def render_order_id(self, item, field): + return item.order.id + + def render_person_text(self, item, field): + person = item.order.person + if person: + text = six.text_type(person) + return text def render_order_created(self, item, column): value = localtime(self.rattail_config, item.order.created, from_utc=True) @@ -149,6 +175,9 @@ class CustomerOrderItemView(MasterView): f.set_type('case_quantity', 'quantity') f.set_type('cases_ordered', 'quantity') f.set_type('units_ordered', 'quantity') + f.set_type('order_quantity', 'quantity') + + f.set_enum('order_uom', self.enum.UNIT_OF_MEASURE) # currency fields f.set_type('unit_price', 'currency') @@ -158,6 +187,8 @@ class CustomerOrderItemView(MasterView): # person f.set_renderer('person', self.render_person) + f.set_enum('status_code', self.enum.CUSTORDER_ITEM_STATUS) + # label overrides f.set_label('status_code', "Status") @@ -169,6 +200,13 @@ class CustomerOrderItemView(MasterView): url = self.request.route_url('custorders.view', uuid=order.uuid) return tags.link_to(text, url) + def render_person(self, item, field): + person = item.order.person + if person: + text = six.text_type(person) + url = self.request.route_url('people.view', uuid=person.uuid) + return tags.link_to(text, url) + def get_row_data(self, item): return self.Session.query(model.CustomerOrderItemEvent)\ .filter(model.CustomerOrderItemEvent.item == item)\ diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index 34e0fbaa..304ccc37 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -49,7 +49,6 @@ class CustomerOrderView(MasterView): model_class = model.CustomerOrder route_prefix = 'custorders' editable = False - deletable = False grid_columns = [ 'id', @@ -69,6 +68,26 @@ class CustomerOrderView(MasterView): 'status_code', ] + has_rows = True + model_row_class = model.CustomerOrderItem + rows_viewable = False + + row_labels = { + 'order_uom': "Order UOM", + } + + row_grid_columns = [ + 'sequence', + 'product_brand', + 'product_description', + 'product_size', + 'order_quantity', + 'order_uom', + 'case_quantity', + 'total_price', + 'status_code', + ] + def query(self, session): return session.query(model.CustomerOrder)\ .options(orm.joinedload(model.CustomerOrder.customer)) @@ -99,22 +118,24 @@ class CustomerOrderView(MasterView): g.set_label('status_code', "Status") g.set_label('id', "ID") + g.set_link('id') + g.set_link('customer') + g.set_link('person') + def configure_form(self, f): super(CustomerOrderView, self).configure_form(f) - # id f.set_readonly('id') f.set_label('id', "ID") - # person + f.set_renderer('customer', self.render_customer) f.set_renderer('person', self.render_person) - # created - f.set_readonly('created') - - # label overrides + f.set_enum('status_code', self.enum.CUSTORDER_STATUS) f.set_label('status_code', "Status") + f.set_readonly('created') + def render_person(self, order, field): person = order.person if not person: @@ -123,6 +144,50 @@ class CustomerOrderView(MasterView): url = self.request.route_url('people.view', uuid=person.uuid) return tags.link_to(text, url) + def get_row_data(self, order): + return self.Session.query(model.CustomerOrderItem)\ + .filter(model.CustomerOrderItem.order == order) + + def get_parent(self, item): + return item.order + + def make_row_grid_kwargs(self, **kwargs): + kwargs = super(CustomerOrderView, self).make_row_grid_kwargs(**kwargs) + + assert not kwargs['main_actions'] + kwargs['main_actions'].append( + self.make_action('view', icon='eye', url=self.row_view_action_url)) + + return kwargs + + def row_view_action_url(self, item, i): + if self.request.has_perm('custorders.items.view'): + return self.request.route_url('custorders.items.view', uuid=item.uuid) + + def configure_row_grid(self, g): + super(CustomerOrderView, self).configure_row_grid(g) + + g.set_type('case_quantity', 'quantity') + g.set_type('order_quantity', 'quantity') + g.set_type('cases_ordered', 'quantity') + g.set_type('units_ordered', 'quantity') + g.set_type('total_price', 'currency') + + g.set_enum('order_uom', self.enum.UNIT_OF_MEASURE) + g.set_enum('status_code', self.enum.CUSTORDER_ITEM_STATUS) + + g.set_label('sequence', "Seq.") + g.filters['sequence'].label = "Sequence" + g.set_label('product_brand', "Brand") + g.set_label('product_description', "Description") + g.set_label('product_size', "Size") + g.set_label('status_code', "Status") + + g.set_sort_defaults('sequence') + + g.set_link('product_brand') + g.set_link('product_description') + def get_batch_handler(self): return get_batch_handler( self.rattail_config, 'custorder', From b229b409b0dc97b2ee8ce40ba85fcdd42ce3bdd1 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 21 Sep 2021 13:52:49 -0500 Subject: [PATCH 0390/1681] 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 0cf4a503..9dd757df 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.146 (2021-09-21) +-------------------- + +* Misc. improvements for customer order views. + + 0.8.145 (2021-09-19) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index a837239c..21d1117a 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.145' +__version__ = '0.8.146' From 87d8322b85d41316b0d7054fa222a8d7b678c2cc Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 22 Sep 2021 16:42:49 -0500 Subject: [PATCH 0391/1681] Add way to override grid action label rendering so that custom HTML can be embedded in there somehow.. --- tailbone/grids/core.py | 11 +++++++++++ tailbone/templates/grids/buefy.mako | 2 +- tailbone/views/master.py | 6 ++++-- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index a4d3cc92..1e6787fa 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -1434,6 +1434,17 @@ class GridAction(object): return self.url(row, i) return self.url + def render_label(self): + """ + Render the label "text" within the actions column of a grid + row. Most actions have a static label that never varies, but + you can override this to add e.g. HTML content. Note that the + return value will be treated / rendered as HTML whether or not + it contains any, so perhaps be careful that it is trusted + content. + """ + return self.label + class URLMaker(object): """ diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako index b55ff30e..63deddc9 100644 --- a/tailbone/templates/grids/buefy.mako +++ b/tailbone/templates/grids/buefy.mako @@ -201,7 +201,7 @@ % endif > <i class="fas fa-${action.icon}"></i> - ${action.label} + ${action.render_label()|n} </a> % endfor diff --git a/tailbone/views/master.py b/tailbone/views/master.py index f2634328..a4210ba2 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2251,14 +2251,16 @@ class MasterView(View): return self.request.route_url('{}.delete'.format(self.get_route_prefix()), **self.get_action_route_kwargs(row)) - def make_action(self, key, url=None, **kwargs): + def make_action(self, key, url=None, factory=None, **kwargs): """ Make a new :class:`GridAction` instance for the current grid. """ if url is None: route = '{}.{}'.format(self.get_route_prefix(), key) url = lambda r, i: self.request.route_url(route, **self.get_action_route_kwargs(r)) - return grids.GridAction(key, url=url, **kwargs) + if not factory: + factory = grids.GridAction + return factory(key, url=url, **kwargs) def get_action_route_kwargs(self, row): """ From af8bd246a9740b4c623fb9c5d228df2ef0f40af8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 22 Sep 2021 16:50:17 -0500 Subject: [PATCH 0392/1681] 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 9dd757df..37c42156 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.147 (2021-09-22) +-------------------- + +* Add way to override grid action label rendering. + + 0.8.146 (2021-09-21) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 21d1117a..57a2ebb3 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.146' +__version__ = '0.8.147' From 9365dd7b1a9dedee293cbe591f81a5d738dc67a2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 22 Sep 2021 18:29:30 -0500 Subject: [PATCH 0393/1681] Add way to update Employee ID from profile view --- .../templates/people/view_profile_buefy.mako | 108 +++++++++++++++++- tailbone/views/employees.py | 7 +- tailbone/views/people.py | 28 +++++ 3 files changed, 141 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako index 31779e89..7b413621 100644 --- a/tailbone/templates/people/view_profile_buefy.mako +++ b/tailbone/templates/people/view_profile_buefy.mako @@ -200,7 +200,51 @@ <div v-if="employee.exists"> <b-field horizontal label="Employee ID"> - <span>{{ employee.id }}</span> + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <span>{{ employee.id }}</span> + </div> + % if request.has_perm('employees.edit'): + <div class="level-item"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="edit" + @click="initEditEmployeeID()"> + Edit ID + </b-button> + <b-modal has-modal-card + :active.sync="showEditEmployeeIDDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Employee ID</p> + </header> + + <section class="modal-card-body"> + <b-field label="Employee ID"> + <b-input v-model="newEmployeeID"></b-input> + </b-field> + </section> + + <footer class="modal-card-foot"> + <b-button @click="showEditEmployeeIDDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + icon-pack="fas" + icon-left="save" + :disabled="updatingEmployeeID" + @click="updateEmployeeID()"> + {{ editEmployeeIDSaveButtonText }} + </b-button> + </footer> + </div> + </b-modal> + </div> + % endif + </div> + </div> </b-field> <b-field horizontal label="Employee Status"> @@ -643,17 +687,79 @@ employeeHistoryEndDate: null, employeeHistoryEndDateRequired: false, + % if request.has_perm('employees.edit'): + showEditEmployeeIDDialog: false, + newEmployeeID: null, + updatingEmployeeID: false, + % endif + ## 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}, } }, + computed: { + + % if request.has_perm('employees.edit'): + + editEmployeeIDSaveButtonText() { + if (this.updatingEmployeeID) { + return "Working, please wait..." + } + return "Save" + }, + + % endif + }, + methods: { changeContentTitle(newTitle) { this.$emit('change-content-title', newTitle) }, + % if request.has_perm('employees.edit'): + + initEditEmployeeID() { + this.newEmployeeID = this.employee.id + this.updatingEmployeeID = false + this.showEditEmployeeIDDialog = true + }, + + updateEmployeeID() { + this.updatingEmployeeID = true + + let url = '${url('people.profile_update_employee_id', uuid=instance.uuid)}' + + let params = { + 'employee_id': this.newEmployeeID, + } + + let headers = { + ## TODO: should find a better way to handle CSRF token + 'X-CSRF-TOKEN': this.csrftoken, + } + + ## TODO: should find a better way to handle CSRF token + this.$http.post(url, params, {headers: headers}).then(({ data }) => { + if (data.success) { + this.employee.id = data.employee.id + this.showEditEmployeeIDDialog = false + this.updatingEmployeeID = false + } else { + this.$buefy.toast.open({ + message: "Save failed: " + data.error, + type: 'is-danger', + duration: 4000, // 4 seconds + }) + } + }, response => { + alert("Unexpected error occurred!") + }) + }, + + % endif + % if request.has_perm('people_profile.toggle_employee'): showStartEmployee() { diff --git a/tailbone/views/employees.py b/tailbone/views/employees.py index ae59e3da..aa97b9b7 100644 --- a/tailbone/views/employees.py +++ b/tailbone/views/employees.py @@ -186,7 +186,12 @@ class EmployeeView(MasterView): employee = f.model_instance f.set_renderer('person', self.render_person) - f.set_renderer('users', self.render_users) + + if self.creating or self.editing: + f.remove('users') + else: + f.set_readonly('users') + f.set_renderer('users', self.render_users) f.set_renderer('stores', self.render_stores) f.set_label('stores', "Stores") # TODO: should not be necessary diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 0613d3d7..a393df99 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -634,6 +634,25 @@ class PersonView(MasterView): 'employee_history_data': self.get_context_employee_history(employee), } + def profile_update_employee_id(self): + """ + View to update an employee's ID value. + """ + app = self.get_rattail_app() + employment = app.get_employment_handler() + + person = self.get_instance() + employee = employment.get_employee(person) + + data = self.request.json_body + employee.id = data['employee_id'] + self.Session.flush() + + return { + 'success': True, + 'employee': self.get_context_employee(employee), + } + def make_note_form(self, mode, person): schema = NoteSchema().bind(session=self.Session(), person_uuid=person.uuid) @@ -784,6 +803,15 @@ class PersonView(MasterView): config.add_view(cls, attr='profile_edit_employee_history', route_name='{}.profile_edit_employee_history'.format(route_prefix), permission='people_profile.edit_employee_history', renderer='json') + # profile - update employee ID + config.add_route('{}.profile_update_employee_id'.format(route_prefix), + '{}/profile/update-employee-id'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='profile_update_employee_id', + route_name='{}.profile_update_employee_id'.format(route_prefix), + renderer='json', + permission='employees.edit') + # manage notes from profile view if cls.manage_notes_from_profile_view: From e6a92c566777aa5544ab90e33da97aebe9111b2d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 22 Sep 2021 18:30:39 -0500 Subject: [PATCH 0394/1681] 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 37c42156..d6ba609c 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.148 (2021-09-22) +-------------------- + +* Add way to update Employee ID from profile view. + + 0.8.147 (2021-09-22) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 57a2ebb3..8aa73dee 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.147' +__version__ = '0.8.148' From fbd12c7dfcc8cb3fe055c4a2dcf94c142ed560a6 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 24 Sep 2021 17:17:19 -0400 Subject: [PATCH 0395/1681] Improve default autocomplete query logic, w/ multiple ILIKE e.g. to search for customer first and/or last name --- tailbone/views/autocomplete.py | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/tailbone/views/autocomplete.py b/tailbone/views/autocomplete.py index 96bbd36b..f2a12d0e 100644 --- a/tailbone/views/autocomplete.py +++ b/tailbone/views/autocomplete.py @@ -1,8 +1,8 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -26,6 +26,8 @@ Autocomplete View from __future__ import unicode_literals, absolute_import +import sqlalchemy as sa + from tailbone.views.core import View from tailbone.db import Session @@ -45,11 +47,24 @@ class AutocompleteView(View): return q def make_query(self, term): - q = Session.query(self.mapped_class) - q = self.filter_query(q) - q = q.filter(getattr(self.mapped_class, self.fieldname).ilike('%%%s%%' % term)) - q = q.order_by(getattr(self.mapped_class, self.fieldname)) - return q + """ + Make and return the "complete" query for the given search term. + """ + # we are querying one table (and column) primarily + query = Session.query(self.mapped_class) + column = getattr(self.mapped_class, self.fieldname) + + # filter according to business logic, if applicable + query = self.filter_query(query) + + # filter according to search term(s) + criteria = [column.ilike('%{}%'.format(word)) + for word in term.split()] + query = query.filter(sa.and_(*criteria)) + + # sort results by something meaningful + query = query.order_by(column) + return query def query(self, term): return self.make_query(term) From 57cb787b3077a60e7ec18542d80e38db44bb03f2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 24 Sep 2021 17:28:14 -0400 Subject: [PATCH 0396/1681] Add placeholder to customer lookup for new order also hide phone field unless customer is identified --- .../static/js/tailbone.buefy.autocomplete.js | 1 + tailbone/templates/autocomplete.mako | 1 + tailbone/templates/custorders/create.mako | 17 +++++++++-------- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/tailbone/static/js/tailbone.buefy.autocomplete.js b/tailbone/static/js/tailbone.buefy.autocomplete.js index 0d61ca79..2b442416 100644 --- a/tailbone/static/js/tailbone.buefy.autocomplete.js +++ b/tailbone/static/js/tailbone.buefy.autocomplete.js @@ -10,6 +10,7 @@ const TailboneAutocomplete = { initialLabel: String, assignedValue: String, assignedLabel: String, + placeholder: String, }, data() { diff --git a/tailbone/templates/autocomplete.mako b/tailbone/templates/autocomplete.mako index 0ab9f49c..05339e12 100644 --- a/tailbone/templates/autocomplete.mako +++ b/tailbone/templates/autocomplete.mako @@ -66,6 +66,7 @@ :name="name" v-show="!assignedValue && !selected" v-model="value" + :placeholder="placeholder" :data="data" @typing="getAsyncData" @select="selectionMade" diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index f0e5d5b3..55797ed6 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -108,16 +108,17 @@ </div> <div v-show="customerIsKnown"> - <b-field label="Customer Name" horizontal> - <tailbone-autocomplete - ref="customerAutocomplete" - v-model="customerUUID" - :initial-label="customerDisplay" - serviceUrl="${url('customers.autocomplete')}" - @input="customerChanged"> + <b-field label="Customer" horizontal> + <tailbone-autocomplete ref="customerAutocomplete" + v-model="customerUUID" + placeholder="Enter name or phone number" + :initial-label="customerDisplay" + serviceUrl="${url('customers.autocomplete')}" + @input="customerChanged"> </tailbone-autocomplete> </b-field> - <b-field label="Phone Number" horizontal> + <b-field label="Phone Number" horizontal + v-show="customerUUID"> <b-input v-model="phoneNumberEntry" @input="phoneNumberChanged" @keydown.native="phoneNumberKeyDown"> From 3b6b1aa5b668c8ed034fa504b67cd3842de22092 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 24 Sep 2021 18:09:24 -0400 Subject: [PATCH 0397/1681] Invoke handler for customer autocomplete when making new custorder --- .../static/js/tailbone.buefy.autocomplete.js | 5 +++- tailbone/templates/autocomplete.mako | 2 +- tailbone/templates/custorders/create.mako | 2 +- tailbone/views/custorders/orders.py | 24 ++++++++++++++++--- 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/tailbone/static/js/tailbone.buefy.autocomplete.js b/tailbone/static/js/tailbone.buefy.autocomplete.js index 2b442416..eb36fa74 100644 --- a/tailbone/static/js/tailbone.buefy.autocomplete.js +++ b/tailbone/static/js/tailbone.buefy.autocomplete.js @@ -59,8 +59,11 @@ const TailboneAutocomplete = { }, getDisplayText() { + if (this.assignedLabel) { + return this.assignedLabel + } if (this.selected) { - return this.selected.label + return this.selected.display || this.selected.label } return "" }, diff --git a/tailbone/templates/autocomplete.mako b/tailbone/templates/autocomplete.mako index 05339e12..e7aad900 100644 --- a/tailbone/templates/autocomplete.mako +++ b/tailbone/templates/autocomplete.mako @@ -80,7 +80,7 @@ <b-button v-if="assignedValue || selected" style="width: 100%; justify-content: left;" @click="clearSelection()"> - {{ assignedLabel || selected.label }} (click to change) + {{ getDisplayText() }} (click to change) </b-button> </div> diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index 55797ed6..61df8552 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -113,7 +113,7 @@ v-model="customerUUID" placeholder="Enter name or phone number" :initial-label="customerDisplay" - serviceUrl="${url('customers.autocomplete')}" + serviceUrl="${url('{}.customer_autocomplete'.format(route_prefix))}" @input="customerChanged"> </tailbone-autocomplete> </b-field> diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index 304ccc37..4553a19b 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -236,14 +236,14 @@ class CustomerOrderView(MasterView): if self.handler.has_custom_product_autocomplete: route_prefix = self.get_route_prefix() - autocomplete = '{}.product_autocomplete'.format(route_prefix) + product_autocomplete = '{}.product_autocomplete'.format(route_prefix) else: - autocomplete = 'products.autocomplete' + product_autocomplete = 'products.autocomplete' context = {'batch': batch, 'normalized_batch': self.normalize_batch(batch), 'order_items': items, - 'product_autocomplete_url': self.request.route_url(autocomplete)} + 'product_autocomplete_url': self.request.route_url(product_autocomplete)} return self.render_to_response(template, context) def get_current_batch(self): @@ -296,6 +296,15 @@ class CustomerOrderView(MasterView): url = self.request.route_url(route_prefix) return self.redirect(url) + def customer_autocomplete(self): + """ + Custom customer autocomplete logic, which invokes the handler. + """ + self.handler = self.get_batch_handler() + term = self.request.GET['term'] + return self.handler.customer_autocomplete(self.Session(), term, + user=self.request.user) + def get_customer_info(self, batch, data): uuid = data.get('uuid') if not uuid: @@ -583,6 +592,15 @@ class CustomerOrderView(MasterView): route_prefix = cls.get_route_prefix() url_prefix = cls.get_url_prefix() + # customer autocomplete + config.add_route('{}.customer_autocomplete'.format(route_prefix), + '{}/customer-autocomplete'.format(url_prefix), + request_method='GET') + config.add_view(cls, attr='customer_autocomplete', + route_name='{}.customer_autocomplete'.format(route_prefix), + renderer='json', + permission='customers.list') + # custom product autocomplete config.add_route('{}.product_autocomplete'.format(route_prefix), '{}/product-autocomplete'.format(url_prefix), From ec5ff8a788e20faf8cd6dbed4ee93e1bece0c850 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 24 Sep 2021 19:16:23 -0400 Subject: [PATCH 0398/1681] Improve "employees" list when viewing a department, for buefy themes --- tailbone/templates/departments/view.mako | 11 +++- tailbone/views/departments.py | 73 ++++++++++++++++++++---- 2 files changed, 72 insertions(+), 12 deletions(-) diff --git a/tailbone/templates/departments/view.mako b/tailbone/templates/departments/view.mako index f3887af7..20c2a266 100644 --- a/tailbone/templates/departments/view.mako +++ b/tailbone/templates/departments/view.mako @@ -3,7 +3,7 @@ <%def name="page_content()"> ${parent.page_content()} - + % if not use_buefy: <h2>Employees</h2> % if employees: @@ -12,7 +12,16 @@ % else: <p>No employees are assigned to this department.</p> % endif + % endif </%def> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + ${form.component_studly}Data.employeesData = ${json.dumps(employees_data)|n} + + </script> +</%def> ${parent.body()} diff --git a/tailbone/views/departments.py b/tailbone/views/departments.py index d242b51d..d66a3f22 100644 --- a/tailbone/views/departments.py +++ b/tailbone/views/departments.py @@ -31,6 +31,7 @@ import six from rattail.db import model from deform import widget as dfwidget +from webhelpers2.html import HTML from tailbone import grids from tailbone.views import MasterView, AutocompleteView @@ -59,6 +60,7 @@ class DepartmentView(MasterView): 'personnel', 'exempt_from_gross_sales', 'allow_product_deletions', + 'employees', ] def configure_grid(self, g): @@ -73,23 +75,72 @@ class DepartmentView(MasterView): def configure_form(self, f): super(DepartmentView, self).configure_form(f) + use_buefy = self.get_use_buefy() + f.remove_field('subdepartments') - f.remove_field('employees') + + if not use_buefy or self.creating or self.editing: + f.remove('employees') + else: + f.set_renderer('employees', self.render_employees) + f.set_type('product', 'boolean') f.set_type('personnel', 'boolean') + def render_employees(self, department, field): + route_prefix = self.get_route_prefix() + permission_prefix = self.get_permission_prefix() + + factory = self.get_grid_factory() + g = factory( + key='{}.employees'.format(route_prefix), + data=[], + columns=[ + 'first_name', + 'last_name', + ], + sortable=True, + sorters={'first_name': True, 'last_name': True}, + ) + + if self.request.has_perm('employees.view'): + g.main_actions.append(self.make_action('view', icon='eye')) + if self.request.has_perm('employees.edit'): + g.main_actions.append(self.make_action('edit', icon='edit')) + + return HTML.literal( + g.render_buefy_table_element(data_prop='employeesData')) + def template_kwargs_view(self, **kwargs): + kwargs = super(DepartmentView, self).template_kwargs_view(**kwargs) + use_buefy = self.get_use_buefy() department = kwargs['instance'] - if department.employees: - employees = sorted(department.employees, key=six.text_type) - actions = [ - grids.GridAction('view', icon='zoomin', - url=lambda r, i: self.request.route_url('employees.view', uuid=r.uuid)) - ] - kwargs['employees'] = grids.Grid(None, employees, ['display_name'], request=self.request, - model_class=model.Employee, main_actions=actions) - else: - kwargs['employees'] = None + department_employees = sorted(department.employees, key=six.text_type) + + if use_buefy: + employees = [] + for employee in department_employees: + person = employee.person + employees.append({ + 'uuid': employee.uuid, + 'first_name': person.first_name, + 'last_name': person.last_name, + '_action_url_view': self.request.route_url('employees.view', uuid=employee.uuid), + '_action_url_edit': self.request.route_url('employees.edit', uuid=employee.uuid), + }) + kwargs['employees_data'] = employees + + else: # not buefy + if department.employees: + actions = [ + grids.GridAction('view', icon='zoomin', + url=lambda r, i: self.request.route_url('employees.view', uuid=r.uuid)) + ] + kwargs['employees'] = grids.Grid(None, department_employees, ['display_name'], request=self.request, + model_class=model.Employee, main_actions=actions) + else: + kwargs['employees'] = None + return kwargs def before_delete(self, department): From 0dc9793772dfcd33d8a1f5f4f454e0c2690eac55 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 25 Sep 2021 15:27:43 -0400 Subject: [PATCH 0399/1681] Add products row grid for misc. org table views --- tailbone/views/brands.py | 43 +++++++++++++++++++++++++++ tailbone/views/departments.py | 43 +++++++++++++++++++++++++++ tailbone/views/families.py | 48 ++++++++++++++++++++++++++++++ tailbone/views/products.py | 26 +++++------------ tailbone/views/reportcodes.py | 44 ++++++++++++++++++++++++++++ tailbone/views/subdepartments.py | 50 ++++++++++++++++++++++++++++++++ 6 files changed, 236 insertions(+), 18 deletions(-) diff --git a/tailbone/views/brands.py b/tailbone/views/brands.py index 69b74a82..29cd6adc 100644 --- a/tailbone/views/brands.py +++ b/tailbone/views/brands.py @@ -58,6 +58,23 @@ class BrandView(MasterView): 'confirmed', ] + has_rows = True + model_row_class = model.Product + + row_labels = { + 'upc': "UPC", + } + + row_grid_columns = [ + 'upc', + 'description', + 'size', + 'department', + 'vendor', + 'regular_price', + 'current_price', + ] + def configure_grid(self, g): super(BrandView, self).configure_grid(g) @@ -70,6 +87,32 @@ class BrandView(MasterView): # confirmed g.set_type('confirmed', 'boolean') + def get_row_data(self, brand): + return self.Session.query(model.Product)\ + .filter(model.Product.brand == brand) + + def get_parent(self, product): + return product.brand + + def configure_row_grid(self, g): + super(BrandView, self).configure_row_grid(g) + + app = self.get_rattail_app() + self.handler = app.get_products_handler() + g.set_renderer('regular_price', self.render_price) + g.set_renderer('current_price', self.render_price) + + g.set_sort_defaults('upc') + + def render_price(self, product, field): + if not product.not_for_sale: + price = product[field] + if price: + return self.handler.render_price(price) + + def row_view_action_url(self, product, i): + return self.request.route_url('products.view', uuid=product.uuid) + def get_merge_data(self, brand): product_count = self.Session.query(model.Product)\ .filter(model.Product.brand == brand)\ diff --git a/tailbone/views/departments.py b/tailbone/views/departments.py index d66a3f22..1d3c36c6 100644 --- a/tailbone/views/departments.py +++ b/tailbone/views/departments.py @@ -63,6 +63,23 @@ class DepartmentView(MasterView): 'employees', ] + has_rows = True + model_row_class = model.Product + + row_labels = { + 'upc': "UPC", + } + + row_grid_columns = [ + 'upc', + 'brand', + 'description', + 'size', + 'vendor', + 'regular_price', + 'current_price', + ] + def configure_grid(self, g): super(DepartmentView, self).configure_grid(g) g.filters['name'].default_active = True @@ -156,6 +173,32 @@ class DepartmentView(MasterView): count, department), 'error') raise self.redirect(self.get_action_url('view', department)) + def get_row_data(self, department): + return self.Session.query(model.Product)\ + .filter(model.Product.department == department) + + def get_parent(self, product): + return product.department + + def configure_row_grid(self, g): + super(DepartmentView, self).configure_row_grid(g) + + app = self.get_rattail_app() + self.handler = app.get_products_handler() + g.set_renderer('regular_price', self.render_price) + g.set_renderer('current_price', self.render_price) + + g.set_sort_defaults('upc') + + def render_price(self, product, field): + if not product.not_for_sale: + price = product[field] + if price: + return self.handler.render_price(price) + + def row_view_action_url(self, product, i): + return self.request.route_url('products.view', uuid=product.uuid) + def list_by_vendor(self): """ View list of departments by vendor diff --git a/tailbone/views/families.py b/tailbone/views/families.py index 7bbdc966..b2a5ebe3 100644 --- a/tailbone/views/families.py +++ b/tailbone/views/families.py @@ -51,12 +51,60 @@ class FamilyView(MasterView): 'name', ] + has_rows = True + model_row_class = model.Product + + row_labels = { + 'upc': "UPC", + } + + row_grid_columns = [ + 'upc', + 'brand', + 'description', + 'size', + 'department', + 'vendor', + 'regular_price', + 'current_price', + ] + def configure_grid(self, g): super(FamilyView, self).configure_grid(g) g.filters['name'].default_active = True g.filters['name'].default_verb = 'contains' + g.set_sort_defaults('code') + g.set_link('code') + g.set_link('name') + + def get_row_data(self, family): + return self.Session.query(model.Product)\ + .filter(model.Product.family == family) + + def get_parent(self, product): + return product.family + + def configure_row_grid(self, g): + super(FamilyView, self).configure_row_grid(g) + + app = self.get_rattail_app() + self.handler = app.get_products_handler() + g.set_renderer('regular_price', self.render_price) + g.set_renderer('current_price', self.render_price) + + g.set_sort_defaults('upc') + + def render_price(self, product, field): + if not product.not_for_sale: + price = product[field] + if price: + return self.handler.render_price(price) + + def row_view_action_url(self, product, i): + return self.request.route_url('products.view', uuid=product.uuid) + # TODO: deprecate / remove this FamiliesView = FamilyView diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 3a27de06..8efab65c 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -171,6 +171,9 @@ class ProductView(MasterView): super(ProductView, self).__init__(request) self.print_labels = request.rattail_config.getbool('tailbone', 'products.print_labels', default=False) + app = self.get_rattail_app() + self.handler = app.get_products_handler() + def query(self, session): user = self.request.user if user and user not in session: @@ -453,24 +456,11 @@ class ProductView(MasterView): return "" return "${:0.2f}".format(cost.unit_cost) - def render_price(self, product, column): - price = product[column] - if price: - if not product.not_for_sale: - if price.price is not None and price.pack_price is not None: - if price.multiple > 1: - return HTML.literal("$ {:0.2f} / {} ($ {:0.2f} / {})".format( - price.price, price.multiple, - price.pack_price, price.pack_multiple)) - return HTML.literal("$ {:0.2f} ($ {:0.2f} / {})".format( - price.price, price.pack_price, price.pack_multiple)) - if price.price is not None: - if price.multiple is not None and price.multiple > 1: - return "$ {:0.2f} / {}".format(price.price, price.multiple) - return "$ {:0.2f}".format(price.price) - if price.pack_price is not None: - return "$ {:0.2f} / {}".format(price.pack_price, price.pack_multiple) - return "" + def render_price(self, product, field): + if not product.not_for_sale: + price = product[field] + if price: + return self.handler.render_price(price) def render_current_price_for_grid(self, product, field): text = self.render_price(product, field) diff --git a/tailbone/views/reportcodes.py b/tailbone/views/reportcodes.py index ba55db01..ee9a009e 100644 --- a/tailbone/views/reportcodes.py +++ b/tailbone/views/reportcodes.py @@ -50,6 +50,24 @@ class ReportCodeView(MasterView): 'name', ] + has_rows = True + model_row_class = model.Product + + row_labels = { + 'upc': "UPC", + } + + row_grid_columns = [ + 'upc', + 'brand', + 'description', + 'size', + 'department', + 'vendor', + 'regular_price', + 'current_price', + ] + def configure_grid(self, g): super(ReportCodeView, self).configure_grid(g) g.filters['name'].default_active = True @@ -58,6 +76,32 @@ class ReportCodeView(MasterView): g.set_link('code') g.set_link('name') + def get_row_data(self, reportcode): + return self.Session.query(model.Product)\ + .filter(model.Product.report_code == reportcode) + + def get_parent(self, product): + return product.report_code + + def configure_row_grid(self, g): + super(ReportCodeView, self).configure_row_grid(g) + + app = self.get_rattail_app() + self.handler = app.get_products_handler() + g.set_renderer('regular_price', self.render_price) + g.set_renderer('current_price', self.render_price) + + g.set_sort_defaults('upc') + + def render_price(self, product, field): + if not product.not_for_sale: + price = product[field] + if price: + return self.handler.render_price(price) + + def row_view_action_url(self, product, i): + return self.request.route_url('products.view', uuid=product.uuid) + # TODO: deprecate / remove this ReportCodesView = ReportCodeView diff --git a/tailbone/views/subdepartments.py b/tailbone/views/subdepartments.py index 0efcbeed..b94b0f1b 100644 --- a/tailbone/views/subdepartments.py +++ b/tailbone/views/subdepartments.py @@ -46,6 +46,12 @@ class SubdepartmentView(MasterView): 'department', ] + form_fields = [ + 'number', + 'name', + 'department', + ] + mergeable = True merge_additive_fields = [ 'product_count', @@ -57,6 +63,23 @@ class SubdepartmentView(MasterView): 'department_number', ] + has_rows = True + model_row_class = model.Product + + row_labels = { + 'upc': "UPC", + } + + row_grid_columns = [ + 'upc', + 'brand', + 'description', + 'size', + 'vendor', + 'regular_price', + 'current_price', + ] + def configure_grid(self, g): super(SubdepartmentView, self).configure_grid(g) @@ -98,6 +121,33 @@ class SubdepartmentView(MasterView): Session.delete(removing) + def get_row_data(self, subdepartment): + return self.Session.query(model.Product)\ + .filter(model.Product.subdepartment == subdepartment) + + def get_parent(self, product): + return product.subdepartment + + def configure_row_grid(self, g): + super(SubdepartmentView, self).configure_row_grid(g) + + app = self.get_rattail_app() + self.handler = app.get_products_handler() + g.set_renderer('regular_price', self.render_price) + g.set_renderer('current_price', self.render_price) + + g.set_sort_defaults('upc') + + def render_price(self, product, field): + if not product.not_for_sale: + price = product[field] + if price: + return self.handler.render_price(price) + + def row_view_action_url(self, product, i): + return self.request.route_url('products.view', uuid=product.uuid) + + # TODO: deprecate / remove this SubdepartmentsView = SubdepartmentView From 9fe1d4c596d1ef42e8cfc267042d9e2b341f33ed Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 25 Sep 2021 15:34:29 -0400 Subject: [PATCH 0400/1681] 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 d6ba609c..9491e489 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,20 @@ CHANGELOG ========= +0.8.149 (2021-09-25) +-------------------- + +* Improve default autocomplete query logic, w/ multiple ILIKE. + +* Add placeholder to customer lookup for new order. + +* Invoke handler for customer autocomplete when making new custorder. + +* Improve "employees" list when viewing a department, for buefy themes. + +* Add products row grid for misc. org table views. + + 0.8.148 (2021-09-22) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 8aa73dee..cd6d0a01 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.148' +__version__ = '0.8.149' From 3ece3303db9c79d3748058ed98740a9f9a12717c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 25 Sep 2021 18:54:33 -0400 Subject: [PATCH 0401/1681] Refactor several "field grids" per Buefy theme e.g. the Users field when viewing a Role, and Vendor Sources panel when viewing a Product --- tailbone/forms/core.py | 25 ++++++-- tailbone/grids/core.py | 3 +- tailbone/templates/customers/view.mako | 25 ++++++++ tailbone/templates/grids/b-table.mako | 7 +++ tailbone/templates/products/view.mako | 11 ++++ tailbone/templates/roles/view.mako | 22 ++++++- tailbone/views/customers.py | 80 +++++++++++++++++++++++--- tailbone/views/products.py | 80 ++++++++++++++++++++++++++ tailbone/views/roles.py | 63 +++++++++++++++++++- 9 files changed, 299 insertions(+), 17 deletions(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index d35b8a35..2267b8dc 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -615,6 +615,9 @@ class Form(object): elif type_ == 'text': self.set_renderer(key, self.render_pre_sans_serif) self.set_widget(key, dfwidget.TextAreaWidget(cols=80, rows=8)) + elif type_ == 'text_wrapped': + self.set_renderer(key, self.render_pre_sans_serif_wrapped) + self.set_widget(key, dfwidget.TextAreaWidget(cols=80, rows=8)) elif type_ == 'file': tmpstore = SessionFileUploadTempStore(self.request) kw = {'widget': dfwidget.FileUploadWidget(tmpstore), @@ -914,14 +917,26 @@ class Form(object): return "" return HTML.tag('pre', value) - def render_pre_sans_serif(self, record, field_name): + def render_pre_sans_serif(self, record, field_name, wrapped=False): value = self.obtain_value(record, field_name) if value is None: return "" - # this uses a Bulma helper class, for which we also add custom styles - # to our "default" base.css (for jquery theme) - return HTML.tag('pre', class_='is-family-sans-serif', - c=value) + + kwargs = { + 'c': value, + # this uses a Bulma helper class, for which we also add + # custom styles to our "default" base.css (for jquery + # theme) + 'class_': 'is-family-sans-serif', + } + + if wrapped: + kwargs['style'] = 'white-space: pre-wrap;' + + return HTML.tag('pre', **kwargs) + + def render_pre_sans_serif_wrapped(self, record, field_name): + return self.render_pre_sans_serif(record, field_name, wrapped=True) def obtain_value(self, record, field_name): if record: diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 1e6787fa..f918fad4 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -1418,12 +1418,13 @@ class GridAction(object): """ def __init__(self, key, label=None, url='#', icon=None, target=None, - click_handler=None): + link_class=None, click_handler=None): self.key = key self.label = label or prettify(key) self.icon = icon self.url = url self.target = target + self.link_class = link_class self.click_handler = click_handler def get_url(self, row, i): diff --git a/tailbone/templates/customers/view.mako b/tailbone/templates/customers/view.mako index 6c9de1ce..81e05aaa 100644 --- a/tailbone/templates/customers/view.mako +++ b/tailbone/templates/customers/view.mako @@ -26,4 +26,29 @@ % endif </%def> +<%def name="render_buefy_form()"> + <div class="form"> + <tailbone-form @detach-person="detachPerson"> + </tailbone-form> + </div> +</%def> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + ${form.component_studly}Data.peopleData = ${json.dumps(people_data)|n} + + ThisPage.methods.detachPerson = function(url) { + ## TODO: this should require POST, but we will add that once + ## we can assume a Buefy theme is present, to avoid having to + ## implement the logic in old jquery... + if (confirm("Are you sure you want to detach this person from this customer account?")) { + location.href = url + } + } + + </script> +</%def> + ${parent.body()} diff --git a/tailbone/templates/grids/b-table.mako b/tailbone/templates/grids/b-table.mako index ee257819..7ff33e73 100644 --- a/tailbone/templates/grids/b-table.mako +++ b/tailbone/templates/grids/b-table.mako @@ -15,6 +15,9 @@ % if loading is not Undefined and loading: :loading="${loading}" % endif + % if grid.default_sortkey: + :default-sort="['${grid.default_sortkey}', '${grid.default_sortdir}']" + % endif > <template slot-scope="props"> @@ -47,7 +50,11 @@ <b-table-column field="actions" label="Actions"> % for action in grid.main_actions: <a :href="props.row._action_url_${action.key}" + % if action.link_class: + class="${action.link_class}" + % else: class="grid-action${' has-text-danger' if action.key == 'delete' else ''}" + % endif % if action.click_handler: @click.prevent="${action.click_handler}" % endif diff --git a/tailbone/templates/products/view.mako b/tailbone/templates/products/view.mako index b3fddacc..08aa348a 100644 --- a/tailbone/templates/products/view.mako +++ b/tailbone/templates/products/view.mako @@ -231,6 +231,9 @@ </%def> <%def name="lookup_codes_grid()"> + % if use_buefy: + ${lookup_codes['grid'].render_buefy_table_element(data_prop='lookupCodesData')|n} + % else: <div class="grid full no-border"> <table> <thead> @@ -247,6 +250,7 @@ </tbody> </table> </div> + % endif </%def> <%def name="lookup_codes_panel()"> @@ -266,6 +270,9 @@ </%def> <%def name="sources_grid()"> + % if use_buefy: + ${vendor_sources['grid'].render_buefy_table_element(data_prop='vendorSourcesData')|n} + % else: <div class="grid full no-border"> <table> <thead> @@ -298,6 +305,7 @@ </tbody> </table> </div> + % endif </%def> <%def name="sources_panel()"> @@ -627,6 +635,9 @@ }) } + ThisPageData.vendorSourcesData = ${json.dumps(vendor_sources['data'])|n} + ThisPageData.lookupCodesData = ${json.dumps(lookup_codes['data'])|n} + ThisPageData.showingCostHistory = false ThisPageData.costHistoryDataRaw = ${json.dumps(cost_history_grid.get_buefy_data()['data'])|n} ThisPageData.costHistoryLoading = false diff --git a/tailbone/templates/roles/view.mako b/tailbone/templates/roles/view.mako index ab3b49df..c5a5b78d 100644 --- a/tailbone/templates/roles/view.mako +++ b/tailbone/templates/roles/view.mako @@ -8,7 +8,7 @@ <%def name="page_content()"> ${parent.page_content()} - + % if not use_buefy: <h2>Users</h2> % if instance is guest_role: @@ -21,7 +21,27 @@ % else: <p>There are no users assigned to this role.</p> % endif + % endif </%def> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + % if users_data is not Undefined: + ${form.component_studly}Data.usersData = ${json.dumps(users_data)|n} + % endif + + ThisPage.methods.detachPerson = function(url) { + ## TODO: this should require POST, but we will add that once + ## we can assume a Buefy theme is present, to avoid having to + ## implement the logic in old jquery... + if (confirm("Are you sure you want to detach this person from this customer account?")) { + location.href = url + } + } + + </script> +</%def> ${parent.body()} diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index 723beb5a..7132a767 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -173,6 +173,7 @@ class CustomerView(MasterView): super(CustomerView, self).configure_common_form(f) customer = f.model_instance permission_prefix = self.get_permission_prefix() + use_buefy = self.get_use_buefy() f.set_renderer('default_email', self.render_default_email) if not self.creating and customer.emails: @@ -217,13 +218,15 @@ class CustomerView(MasterView): f.set_renderer('person', self.form_render_person) # people - if self.creating: - f.remove_field('people') - elif self.viewing and self.request.has_perm('{}.detach_person'.format(permission_prefix)): - f.set_renderer('people', self.render_people_removable) + if self.viewing: + if use_buefy: + f.set_renderer('people', self.render_people_buefy) + elif self.has_perm('detach_person'): + f.set_renderer('people', self.render_people_removable) + else: + f.set_renderer('people', self.render_people) else: - f.set_renderer('people', self.render_people) - f.set_readonly('people') + f.remove('people') # groups if self.creating: @@ -245,7 +248,30 @@ class CustomerView(MasterView): f.set_readonly('members') def template_kwargs_view(self, **kwargs): + kwargs = super(CustomerView, self).template_kwargs_view(**kwargs) + kwargs['show_profiles_helper'] = self.show_profiles_helper + + use_buefy = self.get_use_buefy() + if use_buefy: + customer = kwargs['instance'] + people = [] + for person in customer.people: + people.append({ + 'uuid': person.uuid, + 'full_name': person.display_name, + 'first_name': person.first_name, + 'last_name': person.last_name, + '_action_url_view': self.request.route_url('people.view', + uuid=person.uuid), + '_action_url_edit': self.request.route_url('people.edit', + uuid=person.uuid), + '_action_url_detach': self.request.route_url('customers.detach_person', + uuid=customer.uuid, + person_uuid=person.uuid), + }) + kwargs['people_data'] = people + return kwargs def unique_id(self, node, value): @@ -318,6 +344,35 @@ class CustomerView(MasterView): main_actions=actions) return HTML.literal(g.render_grid()) + def render_people_buefy(self, customer, field): + route_prefix = self.get_route_prefix() + permission_prefix = self.get_permission_prefix() + + factory = self.get_grid_factory() + g = factory( + key='{}.people'.format(route_prefix), + data=[], + columns=[ + 'full_name', + 'first_name', + 'last_name', + ], + sortable=True, + sorters={'full_name': True, 'first_name': True, 'last_name': True}, + ) + + if self.request.has_perm('people.view'): + g.main_actions.append(self.make_action('view', icon='eye')) + if self.request.has_perm('people.edit'): + g.main_actions.append(self.make_action('edit', icon='edit')) + if self.has_perm('detach_person'): + g.main_actions.append(self.make_action('detach', icon='minus-circle', + link_class='has-text-warning', + click_handler="$emit('detach-person', props.row._action_url_detach)")) + + return HTML.literal( + g.render_buefy_table_element(data_prop='peopleData')) + def render_groups(self, customer, field): groups = customer.groups if not groups: @@ -372,18 +427,25 @@ class CustomerView(MasterView): def _customer_defaults(cls, config): route_prefix = cls.get_route_prefix() url_prefix = cls.get_url_prefix() + instance_url_prefix = cls.get_instance_url_prefix() permission_prefix = cls.get_permission_prefix() model_key = cls.get_model_key() model_title = cls.get_model_title() # detach person if cls.people_detachable: - config.add_tailbone_permission(permission_prefix, '{}.detach_person'.format(permission_prefix), + config.add_tailbone_permission(permission_prefix, + '{}.detach_person'.format(permission_prefix), "Detach a Person from a {}".format(model_title)) - config.add_route('{}.detach_person'.format(route_prefix), '{}/{{{}}}/detach-person/{{person_uuid}}'.format(url_prefix, model_key), + # TODO: this should require POST, but we'll add that once + # we can assume a Buefy theme is present, to avoid having + # to implement the logic in old jquery... + config.add_route('{}.detach_person'.format(route_prefix), + '{}/detach-person/{{person_uuid}}'.format(instance_url_prefix), # request_method='POST', ) - config.add_view(cls, attr='detach_person', route_name='{}.detach_person'.format(route_prefix), + config.add_view(cls, attr='detach_person', + route_name='{}.detach_person'.format(route_prefix), permission='{}.detach_person'.format(permission_prefix)) # TODO: deprecate / remove this diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 8efab65c..9ae3e19e 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -1152,8 +1152,88 @@ class ProductView(MasterView): kwargs['costs_label_vendor'] = "Vendor" kwargs['costs_label_code'] = "Order Code" kwargs['costs_label_case_size'] = "Case Size" + + if use_buefy: + kwargs['vendor_sources'] = self.get_context_vendor_sources(product) + kwargs['lookup_codes'] = self.get_context_lookup_codes(product) + return kwargs + def get_context_vendor_sources(self, product): + app = self.get_rattail_app() + route_prefix = self.get_route_prefix() + + factory = self.get_grid_factory() + g = factory( + key='{}.vendor_sources'.format(route_prefix), + data=[], + columns=[ + 'preferred', + 'vendor', + 'vendor_item_code', + 'case_size', + 'case_cost', + 'unit_cost', + 'status', + ], + labels={ + 'preferred': "Pref.", + 'vendor_item_code': "Order Code", + }, + ) + + sources = [] + link_vendor = self.request.has_perm('vendors.view') + for cost in product.costs: + + source = { + 'uuid': cost.uuid, + 'preferred': "X" if cost.preference == 1 else None, + 'vendor_item_code': cost.code, + 'case_size': app.render_quantity(cost.case_size), + 'case_cost': app.render_currency(cost.case_cost), + 'unit_cost': app.render_currency(cost.unit_cost, scale=4), + 'status': "discontinued" if cost.discontinued else "available", + } + + text = six.text_type(cost.vendor) + if link_vendor: + url = self.request.route_url('vendors.view', uuid=cost.vendor.uuid) + source['vendor'] = tags.link_to(text, url) + else: + source['vendor'] = text + + sources.append(source) + + return {'grid': g, 'data': sources} + + def get_context_lookup_codes(self, product): + route_prefix = self.get_route_prefix() + + factory = self.get_grid_factory() + g = factory( + key='{}.lookup_codes'.format(route_prefix), + data=[], + columns=[ + 'sequence', + 'code', + ], + labels={ + 'sequence': "Seq.", + }, + ) + + lookup_codes = [] + for code in product._codes: + + lookup_codes.append({ + 'uuid': code.uuid, + 'sequence': code.ordinal, + 'code': code.code, + }) + + return {'grid': g, 'data': lookup_codes} + def get_regular_price_history(self, product): """ Returns a sequence of "records" which corresponds to the given diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py index 9b44dcdf..3cd62571 100644 --- a/tailbone/views/roles.py +++ b/tailbone/views/roles.py @@ -63,6 +63,7 @@ class RoleView(PrincipalMasterView): 'name', 'session_timeout', 'notes', + 'users', 'permissions', ] @@ -147,7 +148,13 @@ class RoleView(PrincipalMasterView): f.set_validator('name', self.unique_name) # notes - f.set_type('notes', 'text') + f.set_type('notes', 'text_wrapped') + + # users + if use_buefy and self.viewing or self.deleting: + f.set_renderer('users', self.render_users) + else: + f.remove('users') # permissions self.tailbone_permissions = self.get_available_permissions() @@ -171,6 +178,40 @@ class RoleView(PrincipalMasterView): if self.editing and role is guest_role(self.Session()): f.set_readonly('session_timeout') + def render_users(self, role, field): + + if role is guest_role(self.Session()): + return ("The guest role is implied for all anonymous users, " + "i.e. when not logged in.") + + if role is authenticated_role(self.Session()): + return ("The authenticated role is implied for all users, " + "but only when logged in.") + + route_prefix = self.get_route_prefix() + permission_prefix = self.get_permission_prefix() + factory = self.get_grid_factory() + g = factory( + key='{}.users'.format(route_prefix), + data=[], + columns=[ + 'full_name', + 'username', + 'active', + ], + sortable=True, + sorters={'full_name': True, 'username': True, 'active': True}, + default_sortkey='full_name', + ) + + if self.request.has_perm('users.view'): + g.main_actions.append(self.make_action('view', icon='eye')) + if self.request.has_perm('users.edit'): + g.main_actions.append(self.make_action('edit', icon='edit')) + + return HTML.literal( + g.render_buefy_table_element(data_prop='usersData')) + def get_available_permissions(self): """ Should return a dictionary with all "available" permissions. The @@ -259,8 +300,28 @@ class RoleView(PrincipalMasterView): main_actions=actions) else: kwargs['users'] = None + kwargs['guest_role'] = guest_role(self.Session()) kwargs['authenticated_role'] = authenticated_role(self.Session()) + + use_buefy = self.get_use_buefy() + if use_buefy: + role = kwargs['instance'] + if role not in (kwargs['guest_role'], kwargs['authenticated_role']): + users_data = [] + for user in role.users: + users_data.append({ + 'uuid': user.uuid, + 'full_name': user.display_name, + 'username': user.username, + 'active': "Yes" if user.active else "No", + '_action_url_view': self.request.route_url('users.view', + uuid=user.uuid), + '_action_url_edit': self.request.route_url('users.edit', + uuid=user.uuid), + }) + kwargs['users_data'] = users_data + return kwargs def before_delete(self, role): From 8095f2c9eabaddc1465277e872ac4fd70b9dcc9a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 25 Sep 2021 18:55:53 -0400 Subject: [PATCH 0402/1681] Display the Store field for Customer Orders --- tailbone/views/custorders/batch.py | 3 +++ tailbone/views/custorders/orders.py | 11 ++++++++++- tailbone/views/master.py | 7 +++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/tailbone/views/custorders/batch.py b/tailbone/views/custorders/batch.py index c9a4a4b6..18ff9a00 100644 --- a/tailbone/views/custorders/batch.py +++ b/tailbone/views/custorders/batch.py @@ -58,6 +58,7 @@ class CustomerOrderBatchView(BatchMasterView): form_fields = [ 'id', + 'store', 'customer', 'person', 'phone_number', @@ -107,6 +108,8 @@ class CustomerOrderBatchView(BatchMasterView): f.set_readonly('rows') f.set_readonly('status_code') + f.set_renderer('store', self.render_store) + # customer if 'customer' in f.fields and self.editing: f.replace('customer', 'customer_uuid') diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index 4553a19b..36414a89 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -60,12 +60,15 @@ class CustomerOrderView(MasterView): form_fields = [ 'id', + 'store', 'customer', 'person', 'phone_number', 'email_address', - 'created', + 'total_price', 'status_code', + 'created', + 'created_by', ] has_rows = True @@ -128,14 +131,20 @@ class CustomerOrderView(MasterView): f.set_readonly('id') f.set_label('id', "ID") + f.set_renderer('store', self.render_store) f.set_renderer('customer', self.render_customer) f.set_renderer('person', self.render_person) + f.set_type('total_price', 'currency') + f.set_enum('status_code', self.enum.CUSTORDER_STATUS) f.set_label('status_code', "Status") f.set_readonly('created') + f.set_readonly('created_by') + f.set_renderer('created_by', self.render_user) + def render_person(self, order, field): person = order.person if not person: diff --git a/tailbone/views/master.py b/tailbone/views/master.py index a4210ba2..d238a4bb 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -747,6 +747,13 @@ class MasterView(View): return obj.upc.pretty() if obj.upc else '' return getattr(obj, product_key) + def render_store(self, obj, field): + store = getattr(obj, field) + if store: + text = "({}) {}".format(store.id, store.name) + url = self.request.route_url('stores.view', uuid=store.uuid) + return tags.link_to(text, url) + def render_product(self, obj, field): product = getattr(obj, field) if not product: From 12310da09e7f043a2d3d4b0358f00b7b40c24fad Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 26 Sep 2021 17:26:11 -0400 Subject: [PATCH 0403/1681] 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 9491e489..0c7c9666 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.150 (2021-09-26) +-------------------- + +* Refactor several "field grids" per Buefy theme. + +* Display the Store field for Customer Orders. + + 0.8.149 (2021-09-25) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index cd6d0a01..caa3fd66 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.149' +__version__ = '0.8.150' From a52b5ec3808d4b462e78daf56c6ac3306db46616 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 27 Sep 2021 09:22:06 -0400 Subject: [PATCH 0404/1681] Overhaul new custorder so contact may be either Person or Customer also make the handler responsible for (un)assigning contact --- tailbone/templates/custorders/create.mako | 156 ++++++++++++---------- tailbone/views/custorders/orders.py | 137 +++++++++++++++---- 2 files changed, 202 insertions(+), 91 deletions(-) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index 61df8552..f1ecfb9f 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -101,50 +101,63 @@ <br /> <div class="field"> - <b-radio v-model="customerIsKnown" + <b-radio v-model="contactIsKnown" :native-value="true"> Customer is already in the system. </b-radio> </div> - <div v-show="customerIsKnown"> + <div v-show="contactIsKnown"> <b-field label="Customer" horizontal> - <tailbone-autocomplete ref="customerAutocomplete" - v-model="customerUUID" + <tailbone-autocomplete ref="contactAutocomplete" + v-model="contactUUID" placeholder="Enter name or phone number" - :initial-label="customerDisplay" + :initial-label="contactDisplay" + % if new_order_requires_customer: serviceUrl="${url('{}.customer_autocomplete'.format(route_prefix))}" - @input="customerChanged"> + % else: + serviceUrl="${url('{}.person_autocomplete'.format(route_prefix))}" + % endif + @input="contactChanged"> </tailbone-autocomplete> + <b-button v-if="contactUUID && contactProfileURL" + type="is-primary" + tag="a" target="_blank" + :href="contactProfileURL" + icon-pack="fas" + icon-left="external-link-alt"> + View Profile + </b-button> </b-field> <b-field label="Phone Number" horizontal - v-show="customerUUID"> - <b-input v-model="phoneNumberEntry" - @input="phoneNumberChanged" - @keydown.native="phoneNumberKeyDown"> - </b-input> - <b-button v-if="!phoneNumberSaved" - type="is-primary" - icon-pack="fas" - icon-left="fas fa-save" - @click="setCustomerData()"> - Please save when finished editing - </b-button> - <!-- <tailbone-autocomplete --> - <!-- serviceUrl="${url('customers.autocomplete.phone')}"> --> - <!-- </tailbone-autocomplete> --> + v-show="contactUUID"> + {{ phoneNumberEntry }} +## <b-input v-model="phoneNumberEntry" +## @input="phoneNumberChanged" +## @keydown.native="phoneNumberKeyDown"> +## </b-input> +## <b-button v-if="!phoneNumberSaved" +## type="is-primary" +## icon-pack="fas" +## icon-left="fas fa-save" +## @click="setContactData()"> +## Please save when finished editing +## </b-button> +## <!-- <tailbone-autocomplete --> +## <!-- serviceUrl="${url('customers.autocomplete.phone')}"> --> +## <!-- </tailbone-autocomplete> --> </b-field> </div> <br /> <div class="field"> - <b-radio v-model="customerIsKnown" disabled + <b-radio v-model="contactIsKnown" disabled :native-value="false"> Customer is not yet in the system. </b-radio> </div> - <div v-if="!customerIsKnown"> + <div v-if="!contactIsKnown"> <b-field label="Customer Name" horizontal> <b-input v-model="customerName"></b-input> </b-field> @@ -382,10 +395,11 @@ batchTotalPriceDisplay: ${json.dumps(normalized_batch['total_price_display'])|n}, customerPanelOpen: false, - customerIsKnown: true, - customerUUID: ${json.dumps(batch.customer_uuid)|n}, - customerDisplay: ${json.dumps(six.text_type(batch.customer or ''))|n}, + contactIsKnown: true, + contactUUID: ${json.dumps(batch.customer_uuid)|n}, + contactDisplay: ${json.dumps(six.text_type(batch.customer or ''))|n}, customerEntry: null, + contactProfileURL: ${json.dumps(contact_profile_url)|n}, phoneNumberEntry: ${json.dumps(batch.phone_number)|n}, phoneNumberSaved: true, customerName: null, @@ -415,12 +429,12 @@ customerPanelHeader() { let text = "Customer" - if (this.customerIsKnown) { - if (this.customerUUID) { - if (this.$refs.customerAutocomplete) { - text = "Customer: " + this.$refs.customerAutocomplete.getDisplayText() + if (this.contactIsKnown) { + if (this.contactUUID) { + if (this.$refs.contactAutocomplete) { + text = "Customer: " + this.$refs.contactAutocomplete.getDisplayText() } else { - text = "Customer: " + this.customerDisplay + text = "Customer: " + this.contactDisplay } } } else { @@ -457,8 +471,8 @@ }, customerStatusTypeAndText() { let phoneNumber = null - if (this.customerIsKnown) { - if (!this.customerUUID) { + if (this.contactIsKnown) { + if (!this.contactUUID) { return { type: 'is-danger', text: "Please identify the customer.", @@ -495,7 +509,7 @@ } } - if (!this.customerIsKnown) { + if (!this.contactIsKnown) { return { type: 'is-warning', text: "Will create a new customer record.", @@ -555,8 +569,8 @@ // return // } // } - // this.customerIsKnown = true - // this.customerUUID = null + // this.contactIsKnown = true + // this.contactUUID = null // // this.customerEntry = null // this.phoneNumberEntry = null // this.customerName = null @@ -607,17 +621,17 @@ }) }, - setCustomerData() { - let params = { - action: 'set_customer_data', - customer_uuid: this.customerUUID, - phone_number: this.phoneNumberEntry, - } - let that = this - this.submitBatchData(params, function(response) { - that.phoneNumberSaved = true - }) - }, + // setContactData() { + // let params = { + // action: 'set_customer_data', + // customer_uuid: this.contactUUID, + // phone_number: this.phoneNumberEntry, + // } + // let that = this + // this.submitBatchData(params, function(response) { + // that.phoneNumberSaved = true + // }) + // }, submitOrder() { this.submittingOrder = true @@ -644,32 +658,40 @@ }) }, - customerChanged(uuid) { + contactChanged(uuid) { + let params if (!uuid) { - this.phoneNumberEntry = null - this.setCustomerData() - } else { - let params = { - action: 'get_customer_info', - uuid: this.customerUUID, + params = { + action: 'unassign_contact', + } + } else { + params = { + action: 'assign_contact', + uuid: this.contactUUID, } - let that = this - this.submitBatchData(params, function(response) { - that.phoneNumberEntry = response.data.phone_number - that.setCustomerData() - }) } + let that = this + this.submitBatchData(params, function(response) { + console.log(response.data) + % if new_order_requires_customer: + that.contactUUID = response.data.customer_uuid + % else: + that.contactUUID = response.data.person_uuid + % endif + that.phoneNumberEntry = response.data.phone_number + that.contactProfileURL = response.data.contact_profile_url + }) }, - phoneNumberChanged(value) { - this.phoneNumberSaved = false - }, + // phoneNumberChanged(value) { + // this.phoneNumberSaved = false + // }, - phoneNumberKeyDown(event) { - if (event.which == 13) { // Enter - this.setCustomerData() - } - }, + // phoneNumberKeyDown(event) { + // if (event.which == 13) { // Enter + // this.setContactData() + // } + // }, showAddItemDialog() { this.editingItem = null diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index 36414a89..4bab7740 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -227,8 +227,10 @@ class CustomerOrderView(MasterView): data = dict(self.request.json_body) action = data.get('action') json_actions = [ + 'assign_contact', + 'unassign_contact', 'get_customer_info', - 'set_customer_data', + # 'set_customer_data', 'find_product_by_upc', 'get_product_info', 'add_item', @@ -251,8 +253,17 @@ class CustomerOrderView(MasterView): context = {'batch': batch, 'normalized_batch': self.normalize_batch(batch), + 'new_order_requires_customer': self.handler.new_order_requires_customer(), + 'contact_profile_url': None, 'order_items': items, 'product_autocomplete_url': self.request.route_url(product_autocomplete)} + + # maybe add profile URL + if batch.person_uuid: + if self.request.has_perm('people.view_profile'): + context['contact_profile_url'] = self.request.route_url( + 'people.view_profile', uuid=batch.person_uuid) + return self.render_to_response(template, context) def get_current_batch(self): @@ -307,13 +318,22 @@ class CustomerOrderView(MasterView): def customer_autocomplete(self): """ - Custom customer autocomplete logic, which invokes the handler. + Customer autocomplete logic, which invokes the handler. """ self.handler = self.get_batch_handler() term = self.request.GET['term'] return self.handler.customer_autocomplete(self.Session(), term, user=self.request.user) + def person_autocomplete(self): + """ + Person autocomplete logic, which invokes the handler. + """ + self.handler = self.get_batch_handler() + term = self.request.GET['term'] + return self.handler.person_autocomplete(self.Session(), term, + user=self.request.user) + def get_customer_info(self, batch, data): uuid = data.get('uuid') if not uuid: @@ -326,30 +346,90 @@ class CustomerOrderView(MasterView): return self.info_for_customer(batch, data, customer) def info_for_customer(self, batch, data, customer): - phone = customer.first_phone() - email = customer.first_email() - return { - 'uuid': customer.uuid, - 'phone_number': phone.number if phone else None, - 'email_address': email.address if email else None, + + # most info comes from handler + info = self.handler.get_customer_info(batch) + + # maybe add profile URL + if info['person_uuid']: + if self.request.has_perm('people.view_profile'): + info['contact_profile_url'] = self.request.route_url( + 'people.view_profile', uuid=info['person_uuid']), + + return info + + def assign_contact(self, batch, data): + kwargs = {} + + # this will either be a Person or Customer UUID + uuid = data['uuid'] + + if self.handler.new_order_requires_customer(): + + customer = self.Session.query(model.Customer).get(uuid) + if not customer: + return {'error': "Customer not found"} + kwargs['customer'] = customer + + else: + + person = self.Session.query(model.Person).get(uuid) + if not person: + return {'error': "Person not found"} + kwargs['person'] = person + + # invoke handler to assign contact + try: + self.handler.assign_contact(batch, **kwargs) + except ValueError as error: + return {'error': six.text_type(error)} + + context = { + 'success': True, + 'customer_uuid': batch.customer_uuid, + 'person_uuid': batch.person_uuid, + 'phone_number': batch.phone_number, + 'email_address': batch.email_address, } - def set_customer_data(self, batch, data): - if 'customer_uuid' in data: - batch.customer_uuid = data['customer_uuid'] - if 'person_uuid' in data: - batch.person_uuid = data['person_uuid'] - elif batch.customer_uuid: - self.Session.flush() - batch.person = batch.customer.first_person() - else: # no customer set - batch.person_uuid = None - if 'phone_number' in data: - batch.phone_number = data['phone_number'] - if 'email_address' in data: - batch.email_address = data['email_address'] - self.Session.flush() - return {'success': True} + # maybe add profile URL + if batch.person_uuid: + if self.request.has_perm('people.view_profile'): + context['contact_profile_url'] = self.request.route_url( + 'people.view_profile', uuid=batch.person_uuid) + + return context + + def unassign_contact(self, batch, data): + self.handler.unassign_contact(batch) + + context = { + 'success': True, + 'customer_uuid': batch.customer_uuid, + 'person_uuid': batch.person_uuid, + 'phone_number': batch.phone_number, + 'email_address': batch.email_address, + 'contact_profile_url': None, + } + + return context + + # def set_customer_data(self, batch, data): + # if 'customer_uuid' in data: + # batch.customer_uuid = data['customer_uuid'] + # if 'person_uuid' in data: + # batch.person_uuid = data['person_uuid'] + # elif batch.customer_uuid: + # self.Session.flush() + # batch.person = batch.customer.first_person() + # else: # no customer set + # batch.person_uuid = None + # if 'phone_number' in data: + # batch.phone_number = data['phone_number'] + # if 'email_address' in data: + # batch.email_address = data['email_address'] + # self.Session.flush() + # return {'success': True} def product_autocomplete(self): """ @@ -601,6 +681,15 @@ class CustomerOrderView(MasterView): route_prefix = cls.get_route_prefix() url_prefix = cls.get_url_prefix() + # person autocomplete + config.add_route('{}.person_autocomplete'.format(route_prefix), + '{}/person-autocomplete'.format(url_prefix), + request_method='GET') + config.add_view(cls, attr='person_autocomplete', + route_name='{}.person_autocomplete'.format(route_prefix), + renderer='json', + permission='people.list') + # customer autocomplete config.add_route('{}.customer_autocomplete'.format(route_prefix), '{}/customer-autocomplete'.format(url_prefix), From 65ac7e0c1572a58498a13a34b052b3e2763b51e4 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 27 Sep 2021 09:46:31 -0400 Subject: [PATCH 0405/1681] Add a dropdown of choices to the Department filter for Products grid --- tailbone/views/products.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 9ae3e19e..4a52f682 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -202,6 +202,7 @@ class ProductView(MasterView): def configure_grid(self, g): super(ProductView, self).configure_grid(g) + app = self.get_rattail_app() def join_vendor(q): return q.outerjoin(self.ProductVendorCost, @@ -230,8 +231,18 @@ class ProductView(MasterView): ProductCostCodeAny.product_uuid == model.Product.uuid) g.joiners['brand'] = lambda q: q.outerjoin(model.Brand) - g.joiners['department'] = lambda q: q.outerjoin(model.Department, - model.Department.uuid == model.Product.department_uuid) + + # department + g.set_joiner('department', lambda q: q.outerjoin(model.Department)) + g.set_sorter('department', model.Department.name) + department_choices = app.cache_model(self.Session(), model.Department, + order_by=model.Department.name, + normalizer=lambda d: d.name) + g.set_filter('department', model.Department.uuid, + value_enum=department_choices, + verbs=['equal', 'not_equal', 'is_null', 'is_not_null', 'is_any'], + default_active=True, default_verb='equal') + g.joiners['subdepartment'] = lambda q: q.outerjoin(model.Subdepartment, model.Subdepartment.uuid == model.Product.subdepartment_uuid) g.joiners['code'] = lambda q: q.outerjoin(model.ProductCode) @@ -241,7 +252,6 @@ class ProductView(MasterView): g.joiners['vendor_code_any'] = join_vendor_code_any g.sorters['brand'] = g.make_sorter(model.Brand.name) - g.sorters['department'] = g.make_sorter(model.Department.name) g.sorters['subdepartment'] = g.make_sorter(model.Subdepartment.name) g.sorters['vendor'] = g.make_sorter(model.Vendor.name) @@ -274,8 +284,6 @@ class ProductView(MasterView): g.filters['description'].default_verb = 'contains' g.filters['brand'] = g.make_filter('brand', model.Brand.name, default_active=True, default_verb='contains') - g.filters['department'] = g.make_filter('department', model.Department.name, - default_active=True, default_verb='contains') g.filters['subdepartment'] = g.make_filter('subdepartment', model.Subdepartment.name) g.filters['code'] = g.make_filter('code', model.ProductCode.code) g.filters['vendor'] = g.make_filter('vendor', model.Vendor.name) From 7c6c2f7deda6dc18c9b968e92cece10d2a808f6f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 27 Sep 2021 09:54:34 -0400 Subject: [PATCH 0406/1681] 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 0c7c9666..a821635a 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.151 (2021-09-27) +-------------------- + +* Overhaul new custorder so contact may be either Person or Customer. + +* Add a dropdown of choices to the Department filter for Products grid. + + 0.8.150 (2021-09-26) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index caa3fd66..68b64bc5 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.150' +__version__ = '0.8.151' From ab517d1199b9ab633d58ecd964f819c960d16331 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 27 Sep 2021 13:25:02 -0400 Subject: [PATCH 0407/1681] Allow changing status, adding notes for customer order items --- tailbone/templates/custorders/items/view.mako | 317 ++++++++++++++++++ tailbone/views/custorders/items.py | 239 ++++++++++++- tailbone/views/custorders/orders.py | 11 +- 3 files changed, 555 insertions(+), 12 deletions(-) create mode 100644 tailbone/templates/custorders/items/view.mako diff --git a/tailbone/templates/custorders/items/view.mako b/tailbone/templates/custorders/items/view.mako new file mode 100644 index 00000000..533d8f18 --- /dev/null +++ b/tailbone/templates/custorders/items/view.mako @@ -0,0 +1,317 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> + +<%def name="render_buefy_form()"> + <div class="form"> + <${form.component} ref="mainForm" + % if master.has_perm('change_status'): + @change-status="showChangeStatus" + % endif + % if master.has_perm('add_note'): + @add-note="showAddNote" + % endif + > + </${form.component}> + </div> +</%def> + +<%def name="page_content()"> + ${parent.page_content()} + + % if master.has_perm('change_status'): + <b-modal :active.sync="showChangeStatusDialog"> + <div class="card"> + <div class="card-content"> + <div class="level"> + <div class="level-left"> + + <div class="level-item"> + Current status is: + </div> + + <div class="level-item has-text-weight-bold"> + {{ orderItemStatuses[oldStatusCode] }} + </div> + + <div class="level-item" + style="margin-left: 5rem;"> + New status will be: + </div> + + <b-field class="level-item" + :type="newStatusCode ? null : 'is-danger'"> + <b-select v-model="newStatusCode"> + <option v-for="item in orderItemStatusOptions" + :key="item.key" + :value="item.key"> + {{ item.label }} + </option> + </b-select> + </b-field> + + </div> + </div> + + <div v-if="changeStatusGridData.length"> + + <p class="block"> + Please indicate any other item(s) to which the new + status should be applied: + </p> + + <b-table :data="changeStatusGridData" + checkable + :checked-rows.sync="changeStatusCheckedRows" + narrowed + class="is-size-7"> + <template slot-scope="props"> + <b-table-column field="product_brand" label="Brand"> + <span v-html="props.row.product_brand"></span> + </b-table-column> + <b-table-column field="product_description" label="Product"> + <span v-html="props.row.product_description"></span> + </b-table-column> + <!-- <b-table-column field="quantity" label="Quantity"> --> + <!-- <span v-html="props.row.quantity"></span> --> + <!-- </b-table-column> --> + <b-table-column field="product_case_quantity" label="cPack"> + <span v-html="props.row.product_case_quantity"></span> + </b-table-column> + <b-table-column field="order_quantity" label="oQty"> + <span v-html="props.row.order_quantity"></span> + </b-table-column> + <b-table-column field="order_uom" label="UOM"> + <span v-html="props.row.order_uom"></span> + </b-table-column> + <b-table-column field="department_name" label="Department"> + <span v-html="props.row.department_name"></span> + </b-table-column> + <b-table-column field="product_barcode" label="Product Barcode"> + <span v-html="props.row.product_barcode"></span> + </b-table-column> + <b-table-column field="unit_price" label="Unit $"> + <span v-html="props.row.unit_price"></span> + </b-table-column> + <b-table-column field="total_price" label="Total $"> + <span v-html="props.row.total_price"></span> + </b-table-column> + <b-table-column field="order_date" label="Order Date"> + <span v-html="props.row.order_date"></span> + </b-table-column> + <b-table-column field="status_code" label="Status"> + <span v-html="props.row.status_code"></span> + </b-table-column> + <!-- <b-table-column field="flagged" label="Flagged"> --> + <!-- <span v-html="props.row.flagged"></span> --> + <!-- </b-table-column> --> + </template> + </b-table> + + <br /> + </div> + + <p> + Please provide a note<span v-if="changeStatusGridData.length"> + (will be applied to all selected items)</span>: + </p> + <b-input v-model="newStatusNote" + type="textarea" rows="2"> + </b-input> + + <br /> + + <div class="buttons"> + <b-button type="is-primary" + :disabled="changeStatusSaveDisabled" + icon-pack="fas" + icon-left="save" + @click="statusChangeSave()"> + {{ changeStatusSubmitText }} + </b-button> + <b-button @click="cancelStatusChange"> + Cancel + </b-button> + </div> + + </div> + </div> + </b-modal> + ${h.form(master.get_action_url('change_status', instance), ref='changeStatusForm')} + ${h.csrf_token(request)} + ${h.hidden('new_status_code', **{'v-model': 'newStatusCode'})} + ${h.hidden('uuids', **{':value': 'changeStatusCheckedRows.map((row) => {return row.uuid}).join()'})} + ${h.hidden('note', **{':value': 'newStatusNote'})} + ${h.end_form()} + % endif + + % if master.has_perm('add_note'): + <b-modal has-modal-card + :active.sync="showAddNoteDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Add Note</p> + </header> + + <section class="modal-card-body"> + <b-field> + <b-input type="textarea" rows="8" + v-model="newNoteText" + ref="newNoteTextArea"> + </b-input> + </b-field> + <b-field> + <b-checkbox v-model="newNoteApplyAll"> + Apply to all items on this order + </b-checkbox> + </b-field> + </section> + + <footer class="modal-card-foot"> + <b-button type="is-primary" + @click="addNoteSave()" + :disabled="addNoteSaveDisabled" + icon-pack="fas" + icon-left="save"> + {{ addNoteSubmitText }} + </b-button> + <b-button @click="showAddNoteDialog = false"> + Cancel + </b-button> + </footer> + </div> + </b-modal> + % endif +</%def> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + ${form.component_studly}Data.notesData = ${json.dumps(notes_data)|n} + + % if master.has_perm('change_status'): + + ThisPageData.orderItemStatuses = ${json.dumps(enum.CUSTORDER_ITEM_STATUS)|n} + ThisPageData.orderItemStatusOptions = ${json.dumps([dict(key=k, label=v) for k, v in six.iteritems(enum.CUSTORDER_ITEM_STATUS)])|n} + + ThisPageData.oldStatusCode = ${instance.status_code} + + ThisPageData.showChangeStatusDialog = false + ThisPageData.newStatusCode = null + ThisPageData.changeStatusGridData = ${json.dumps(other_order_items_data)|n} + ThisPageData.changeStatusCheckedRows = [] + ThisPageData.newStatusNote = null + ThisPageData.changeStatusSubmitText = "Update Status" + ThisPageData.changeStatusSubmitting = false + + ThisPage.computed.changeStatusSaveDisabled = function() { + if (!this.newStatusCode) { + return true + } + if (this.changeStatusSubmitting) { + return true + } + return false + } + + ThisPage.methods.showChangeStatus = function() { + this.newStatusCode = null + // clear out any checked rows + this.changeStatusCheckedRows.length = 0 + this.newStatusNote = null + this.showChangeStatusDialog = true + } + + ThisPage.methods.cancelStatusChange = function() { + this.showChangeStatusDialog = false + } + + ThisPage.methods.statusChangeSave = function() { + if (this.newStatusCode == this.oldStatusCode) { + alert("You chose the same status it already had...") + return + } + + this.changeStatusSubmitting = true + this.changeStatusSubmitText = "Working, please wait..." + this.$refs.changeStatusForm.submit() + } + + % endif + + % if master.has_perm('add_note'): + + ThisPageData.showAddNoteDialog = false + ThisPageData.newNoteText = null + ThisPageData.newNoteApplyAll = false + ThisPageData.addNoteSubmitting = false + ThisPageData.addNoteSubmitText = "Save Note" + + ThisPage.computed.addNoteSaveDisabled = function() { + if (!this.newNoteText) { + return true + } + if (this.addNoteSubmitting) { + return true + } + return false + } + + ThisPage.methods.showAddNote = function() { + this.newNoteText = null + this.newNoteApplyAll = false + this.showAddNoteDialog = true + this.$nextTick(() => { + this.$refs.newNoteTextArea.focus() + }) + } + + ThisPage.methods.addNoteSave = function() { + this.addNoteSubmitting = true + this.addNoteSubmitText = "Working, please wait..." + + let url = '${url('{}.add_note'.format(route_prefix), uuid=instance.uuid)}' + + let params = { + note: this.newNoteText, + apply_all: this.newNoteApplyAll, + } + + let headers = { + ## TODO: should find a better way to handle CSRF token + 'X-CSRF-TOKEN': this.csrftoken, + } + + ## TODO: should find a better way to handle CSRF token + this.$http.post(url, params, {headers: headers}).then(({ data }) => { + if (data.success) { + this.$refs.mainForm.notesData = data.notes + this.showAddNoteDialog = false + } else { + this.$buefy.toast.open({ + message: "Save failed: " + (data.error || "(unknown error)"), + type: 'is-danger', + duration: 4000, // 4 seconds + }) + } + this.addNoteSubmitting = false + this.addNoteSubmitText = "Save Note" + }).catch((error) => { + // TODO: should handle this better somehow..? + this.$buefy.toast.open({ + message: "Save failed: (unknown error)", + type: 'is-danger', + duration: 4000, // 4 seconds + }) + this.addNoteSubmitting = false + this.addNoteSubmitText = "Save Note" + }) + } + + % endif + + </script> +</%def> + +${parent.body()} diff --git a/tailbone/views/custorders/items.py b/tailbone/views/custorders/items.py index 8756d538..2dcd43a5 100644 --- a/tailbone/views/custorders/items.py +++ b/tailbone/views/custorders/items.py @@ -34,7 +34,7 @@ from sqlalchemy import orm from rattail.db import model from rattail.time import localtime -from webhelpers2.html import tags +from webhelpers2.html import HTML, tags from tailbone.views import MasterView from tailbone.util import raw_datetime @@ -54,6 +54,7 @@ class CustomerOrderItemView(MasterView): labels = { 'order_id': "Order ID", 'order_uom': "Order UOM", + 'status_code': "Status", } grid_columns = [ @@ -99,6 +100,7 @@ class CustomerOrderItemView(MasterView): 'total_price', 'paid_amount', 'status_code', + 'notes', ] def query(self, session): @@ -139,7 +141,6 @@ class CustomerOrderItemView(MasterView): g.set_label('product_brand', "Brand") g.set_label('product_description', "Description") g.set_label('product_size', "Size") - g.set_label('status_code', "Status") g.set_link('order_id') g.set_link('person') @@ -161,6 +162,7 @@ class CustomerOrderItemView(MasterView): def configure_form(self, f): super(CustomerOrderItemView, self).configure_form(f) + use_buefy = self.get_use_buefy() # order f.set_renderer('order', self.render_order) @@ -187,10 +189,193 @@ class CustomerOrderItemView(MasterView): # person f.set_renderer('person', self.render_person) - f.set_enum('status_code', self.enum.CUSTORDER_ITEM_STATUS) + # status_code + f.set_renderer('status_code', self.render_status_code) - # label overrides - f.set_label('status_code', "Status") + # notes + if use_buefy: + f.set_renderer('notes', self.render_notes) + else: + f.remove('notes') + + def render_status_code(self, item, field): + use_buefy = self.get_use_buefy() + text = self.enum.CUSTORDER_ITEM_STATUS[item.status_code] + items = [HTML.tag('span', c=[text])] + + if use_buefy and self.has_perm('change_status'): + button = HTML.tag('b-button', type='is-primary', c="Change Status", + style='margin-left: 1rem;', + icon_pack='fas', icon_left='edit', + **{'@click': "$emit('change-status')"}) + items.append(button) + + left = HTML.tag('div', class_='level-left', c=items) + outer = HTML.tag('div', class_='level', c=[left]) + return outer + + def render_notes(self, item, field): + route_prefix = self.get_route_prefix() + + factory = self.get_grid_factory() + g = factory( + key='{}.notes'.format(route_prefix), + data=[], + columns=[ + 'text', + 'created_by', + 'created', + ], + labels={ + 'text': "Note", + }, + ) + + table = HTML.literal( + g.render_buefy_table_element(data_prop='notesData')) + elements = [table] + + if self.has_perm('add_note'): + button = HTML.tag('b-button', type='is-primary', c="Add Note", + class_='is-pulled-right', + icon_pack='fas', icon_left='plus', + **{'@click': "$emit('add-note')"}) + button_wrapper = HTML.tag('div', c=[button], + style='margin-top: 0.5rem;') + elements.append(button_wrapper) + + return HTML.tag('div', + style='display: flex; flex-direction: column;', + c=elements) + + def template_kwargs_view(self, **kwargs): + kwargs = super(CustomerOrderItemView, self).template_kwargs_view(**kwargs) + app = self.get_rattail_app() + item = kwargs['instance'] + + # fetch notes for current item + kwargs['notes_data'] = self.get_context_notes(item) + + # fetch "other" order items, siblings of current one + order = item.order + other_items = self.Session.query(model.CustomerOrderItem)\ + .filter(model.CustomerOrderItem.order == order)\ + .filter(model.CustomerOrderItem.uuid != item.uuid)\ + .all() + other_data = [] + for other in other_items: + + order_date = None + if order.created: + order_date = localtime(self.rattail_config, order.created, from_utc=True).date() + + other_data.append({ + 'uuid': other.uuid, + 'brand_name': other.product_brand, + 'product_description': other.product_description, + 'product_case_quantity': app.render_quantity(other.case_quantity), + 'order_quantity': app.render_quantity(other.order_quantity), + 'order_uom': self.enum.UNIT_OF_MEASURE[other.order_uom], + 'department_name': other.department_name, + 'product_barcode': other.product_upc.pretty() if other.product_upc else None, + 'unit_price': app.render_currency(other.unit_price), + 'total_price': app.render_currency(other.total_price), + 'order_date': app.render_date(order_date), + 'status_code': self.enum.CUSTORDER_ITEM_STATUS[other.status_code], + }) + kwargs['other_order_items_data'] = other_data + + return kwargs + + def get_context_notes(self, item): + notes = [] + for note in reversed(item.notes): + created = localtime(self.rattail_config, note.created, from_utc=True) + notes.append({ + 'created': raw_datetime(self.rattail_config, created), + 'created_by': note.created_by.display_name, + 'text': note.text, + }) + return notes + + def change_status(self): + """ + View for changing status of one or more order items. + """ + order_item = self.get_instance() + redirect = self.redirect(self.get_action_url('view', order_item)) + + # validate new status + new_status_code = int(self.request.POST['new_status_code']) + if new_status_code not in self.enum.CUSTORDER_ITEM_STATUS: + self.request.session.flash("Invalid status code", 'error') + return redirect + + # locate order items to which new status will be applied + order_items = [order_item] + uuids = self.request.POST['uuids'] + if uuids: + for uuid in uuids.split(','): + item = self.Session.query(model.CustomerOrderItem).get(uuid) + if item: + order_items.append(item) + + # locate user responsible for change + user = self.request.user + + # maybe grab extra user-provided note to attach + extra_note = self.request.POST.get('note') + + # apply new status to order item(s) + for item in order_items: + if item.status_code != new_status_code: + + # attach event + note = "status changed from \"{}\" to \"{}\"".format( + self.enum.CUSTORDER_ITEM_STATUS[item.status_code], + self.enum.CUSTORDER_ITEM_STATUS[new_status_code]) + if extra_note: + note = "{} - NOTE: {}".format(note, extra_note) + item.events.append(model.CustomerOrderItemEvent( + type_code=self.enum.CUSTORDER_ITEM_EVENT_STATUS_CHANGE, + user=user, note=note)) + + # change status + item.status_code = new_status_code + + self.request.session.flash("Status has been updated to: {}".format( + self.enum.CUSTORDER_ITEM_STATUS[new_status_code])) + return redirect + + def add_note(self): + """ + View for adding a new note to current order item, optinally + also adding it to all other items under the parent order. + """ + order_item = self.get_instance() + data = self.request.json_body + new_note = data['note'] + apply_all = data['apply_all'] == True + user = self.request.user + + if apply_all: + order_items = order_item.order.items + else: + order_items = [order_item] + + for item in order_items: + item.notes.append(model.CustomerOrderItemNote( + created_by=user, text=new_note)) + + # # attach event + # item.events.append(model.CustomerOrderItemEvent( + # type_code=self.enum.CUSTORDER_ITEM_EVENT_ADDED_NOTE, + # user=user, note=new_note)) + + self.Session.flush() + self.Session.refresh(order_item) + return {'success': True, + 'notes': self.get_context_notes(order_item)} def render_order(self, item, field): order = item.order @@ -210,16 +395,58 @@ class CustomerOrderItemView(MasterView): def get_row_data(self, item): return self.Session.query(model.CustomerOrderItemEvent)\ .filter(model.CustomerOrderItemEvent.item == item)\ - .order_by(model.CustomerOrderItemEvent.occurred, + .order_by(model.CustomerOrderItemEvent.occurred.desc(), model.CustomerOrderItemEvent.type_code) def configure_row_grid(self, g): super(CustomerOrderItemView, self).configure_row_grid(g) + + g.set_enum('type_code', self.enum.CUSTORDER_ITEM_EVENT) + g.set_label('occurred', "When") g.set_label('type_code', "What") # TODO: enum renderer g.set_label('user', "Who") g.set_label('note', "Notes") + @classmethod + def defaults(cls, config): + cls._order_item_defaults(config) + cls._defaults(config) + + @classmethod + def _order_item_defaults(cls, config): + route_prefix = cls.get_route_prefix() + instance_url_prefix = cls.get_instance_url_prefix() + permission_prefix = cls.get_permission_prefix() + model_title_plural = cls.get_model_title_plural() + + # fix permission group name + config.add_tailbone_permission_group(permission_prefix, model_title_plural) + + # change status + config.add_tailbone_permission(permission_prefix, + '{}.change_status'.format(permission_prefix), + "Change status for 1 or more {}".format(model_title_plural)) + config.add_route('{}.change_status'.format(route_prefix), + '{}/change-status'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='change_status', + route_name='{}.change_status'.format(route_prefix), + permission='{}.change_status'.format(permission_prefix)) + + # add note + config.add_tailbone_permission(permission_prefix, + '{}.add_note'.format(permission_prefix), + "Add arbitrary notes for {}".format(model_title_plural)) + config.add_route('{}.add_note'.format(route_prefix), + '{}/add-note'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='add_note', + route_name='{}.add_note'.format(route_prefix), + renderer='json', + permission='{}.add_note'.format(permission_prefix)) + + # TODO: deprecate / remove this CustomerOrderItemsView = CustomerOrderItemView diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index 4bab7740..4ae9666f 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -50,6 +50,11 @@ class CustomerOrderView(MasterView): route_prefix = 'custorders' editable = False + labels = { + 'id': "ID", + 'status_code': "Status", + } + grid_columns = [ 'id', 'customer', @@ -117,10 +122,6 @@ class CustomerOrderView(MasterView): g.set_sort_defaults('created', 'desc') - # TODO: enum choices renderer - g.set_label('status_code', "Status") - g.set_label('id', "ID") - g.set_link('id') g.set_link('customer') g.set_link('person') @@ -129,7 +130,6 @@ class CustomerOrderView(MasterView): super(CustomerOrderView, self).configure_form(f) f.set_readonly('id') - f.set_label('id', "ID") f.set_renderer('store', self.render_store) f.set_renderer('customer', self.render_customer) @@ -138,7 +138,6 @@ class CustomerOrderView(MasterView): f.set_type('total_price', 'currency') f.set_enum('status_code', self.enum.CUSTORDER_STATUS) - f.set_label('status_code', "Status") f.set_readonly('created') From 82074a37bae9718cb5be9f994cc6cb4bb5dab607 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 27 Sep 2021 13:28:26 -0400 Subject: [PATCH 0408/1681] 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 a821635a..edf4e8e4 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.152 (2021-09-27) +-------------------- + +* Allow changing status, adding notes for customer order items. + + 0.8.151 (2021-09-27) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 68b64bc5..c017ef55 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.151' +__version__ = '0.8.152' From ad6562558df85b6f42a8f5ba355c5f1dbc2497fd Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 27 Sep 2021 18:04:07 -0400 Subject: [PATCH 0409/1681] Improve phone/email handling when making new custorder still needs more improvement, but this is a start --- tailbone/templates/custorders/create.mako | 289 ++++++++++++++++++---- tailbone/views/custorders/orders.py | 40 +-- 2 files changed, 259 insertions(+), 70 deletions(-) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index f1ecfb9f..0d4576bb 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -107,19 +107,24 @@ </b-radio> </div> - <div v-show="contactIsKnown"> - <b-field label="Customer" horizontal> - <tailbone-autocomplete ref="contactAutocomplete" - v-model="contactUUID" - placeholder="Enter name or phone number" - :initial-label="contactDisplay" - % if new_order_requires_customer: - serviceUrl="${url('{}.customer_autocomplete'.format(route_prefix))}" - % else: - serviceUrl="${url('{}.person_autocomplete'.format(route_prefix))}" - % endif - @input="contactChanged"> - </tailbone-autocomplete> + <div v-show="contactIsKnown" + style="padding-left: 10rem;"> + + <b-field label="Customer" grouped> + <b-field style="margin-left: 1rem;"" + :expanded="!contactUUID"> + <tailbone-autocomplete ref="contactAutocomplete" + v-model="contactUUID" + placeholder="Enter name or phone number" + :initial-label="contactDisplay" + % if new_order_requires_customer: + serviceUrl="${url('{}.customer_autocomplete'.format(route_prefix))}" + % else: + serviceUrl="${url('{}.person_autocomplete'.format(route_prefix))}" + % endif + @input="contactChanged"> + </tailbone-autocomplete> + </b-field> <b-button v-if="contactUUID && contactProfileURL" type="is-primary" tag="a" target="_blank" @@ -129,23 +134,111 @@ View Profile </b-button> </b-field> - <b-field label="Phone Number" horizontal - v-show="contactUUID"> - {{ phoneNumberEntry }} -## <b-input v-model="phoneNumberEntry" -## @input="phoneNumberChanged" -## @keydown.native="phoneNumberKeyDown"> -## </b-input> -## <b-button v-if="!phoneNumberSaved" -## type="is-primary" -## icon-pack="fas" -## icon-left="fas fa-save" -## @click="setContactData()"> -## Please save when finished editing -## </b-button> -## <!-- <tailbone-autocomplete --> -## <!-- serviceUrl="${url('customers.autocomplete.phone')}"> --> -## <!-- </tailbone-autocomplete> --> + + <b-field grouped v-show="contactUUID" + style="margin-top: 2rem;"> + + <b-field label="Phone Number" + style="margin-right: 3rem;"> + <div class="level"> + <div class="level-left"> + <div class="level-item"> + {{ orderPhoneNumber }} + </div> + <div class="level-item"> + <b-button type="is-primary" + @click="editPhoneNumberInit()" + icon-pack="fas" + icon-left="edit"> + Edit + </b-button> + + <b-modal has-modal-card + :active.sync="editPhoneNumberShowDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Edit Phone Number</p> + </header> + + <section class="modal-card-body"> + <b-field label="Phone Number" + :type="editPhoneNumberValue ? null : 'is-danger'"> + <b-input v-model="editPhoneNumberValue" + ref="editPhoneNumberInput"> + </b-input> + </b-field> + </section> + + <footer class="modal-card-foot"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="save" + :disabled="editPhoneNumberSaveDisabled" + @click="editPhoneNumberSave()"> + {{ editPhoneNumberSaveText }} + </b-button> + <b-button @click="editPhoneNumberShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </b-modal> + + </div> + </div> + </div> + </b-field> + + <b-field label="Email Address"> + <div class="level"> + <div class="level-left"> + <div class="level-item"> + {{ orderEmailAddress }} + </div> + <div class="level-item"> + <b-button type="is-primary" + @click="editEmailAddressInit()" + icon-pack="fas" + icon-left="edit"> + Edit + </b-button> + <b-modal has-modal-card + :active.sync="editEmailAddressShowDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Edit Email Address</p> + </header> + + <section class="modal-card-body"> + <b-field label="Email Address" + :type="editEmailAddressValue ? null : 'is-danger'"> + <b-input v-model="editEmailAddressValue" + ref="editEmailAddressInput"> + </b-input> + </b-field> + </section> + + <footer class="modal-card-foot"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="save" + :disabled="editEmailAddressSaveDisabled" + @click="editEmailAddressSave()"> + {{ editEmailAddressSaveText }} + </b-button> + <b-button @click="editEmailAddressShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </b-modal> + </div> + </div> + </div> + </b-field> + </b-field> </div> @@ -400,10 +493,20 @@ contactDisplay: ${json.dumps(six.text_type(batch.customer or ''))|n}, customerEntry: null, contactProfileURL: ${json.dumps(contact_profile_url)|n}, - phoneNumberEntry: ${json.dumps(batch.phone_number)|n}, + ## phoneNumberEntry: ${json.dumps(batch.phone_number)|n}, + orderPhoneNumber: ${json.dumps(batch.phone_number)|n}, phoneNumberSaved: true, customerName: null, phoneNumber: null, + orderEmailAddress: ${json.dumps(batch.email_address)|n}, + + editPhoneNumberShowDialog: false, + editPhoneNumberValue: null, + editPhoneNumberSaving: false, + + editEmailAddressShowDialog: false, + editEmailAddressValue: null, + editEmailAddressSaving: false, items: ${json.dumps(order_items)|n}, editingItem: null, @@ -478,13 +581,13 @@ text: "Please identify the customer.", } } - if (!this.phoneNumberEntry) { + if (!this.orderPhoneNumber) { return { type: 'is-warning', text: "Please provide a phone number for the customer.", } } - phoneNumber = this.phoneNumberEntry + phoneNumber = this.orderPhoneNumber } else { // customer is not known if (!this.customerName) { return { @@ -522,6 +625,40 @@ } }, + editPhoneNumberSaveDisabled() { + if (this.editPhoneNumberSaving) { + return true + } + if (!this.editPhoneNumberValue) { + return true + } + return false + }, + + editPhoneNumberSaveText() { + if (this.editPhoneNumberSaving) { + return "Working, please wait..." + } + return "Save" + }, + + editEmailAddressSaveDisabled() { + if (this.editEmailAddressSaving) { + return true + } + if (!this.editEmailAddressValue) { + return true + } + return false + }, + + editEmailAddressSaveText() { + if (this.editEmailAddressSaving) { + return "Working, please wait..." + } + return "Save" + }, + itemsPanelHeader() { let text = "Items" @@ -621,18 +758,6 @@ }) }, - // setContactData() { - // let params = { - // action: 'set_customer_data', - // customer_uuid: this.contactUUID, - // phone_number: this.phoneNumberEntry, - // } - // let that = this - // this.submitBatchData(params, function(response) { - // that.phoneNumberSaved = true - // }) - // }, - submitOrder() { this.submittingOrder = true @@ -678,22 +803,78 @@ % else: that.contactUUID = response.data.person_uuid % endif - that.phoneNumberEntry = response.data.phone_number + that.orderPhoneNumber = response.data.phone_number + that.orderEmailAddress = response.data.email_address that.contactProfileURL = response.data.contact_profile_url }) }, - // phoneNumberChanged(value) { - // this.phoneNumberSaved = false - // }, + editPhoneNumberInit() { + this.editPhoneNumberValue = this.orderPhoneNumber + this.editPhoneNumberShowDialog = true + this.$nextTick(() => { + this.$refs.editPhoneNumberInput.focus() + }) + }, - // phoneNumberKeyDown(event) { - // if (event.which == 13) { // Enter - // this.setContactData() - // } - // }, + editPhoneNumberSave() { + this.editPhoneNumberSaving = true + + let params = { + action: 'update_phone_number', + phone_number: this.editPhoneNumberValue, + } + + this.submitBatchData(params, response => { + if (response.data.success) { + this.orderPhoneNumber = response.data.phone_number + this.editPhoneNumberShowDialog = false + } else { + this.$buefy.toast.open({ + message: "Save failed: " + response.data.error, + type: 'is-danger', + duration: 2000, // 2 seconds + }) + } + this.editPhoneNumberSaving = false + }) + + }, + + editEmailAddressInit() { + this.editEmailAddressValue = this.orderEmailAddress + this.editEmailAddressShowDialog = true + this.$nextTick(() => { + this.$refs.editEmailAddressInput.focus() + }) + }, + + editEmailAddressSave() { + this.editEmailAddressSaving = true + + let params = { + action: 'update_email_address', + email_address: this.editEmailAddressValue, + } + + this.submitBatchData(params, response => { + if (response.data.success) { + this.orderEmailAddress = response.data.email_address + this.editEmailAddressShowDialog = false + } else { + this.$buefy.toast.open({ + message: "Save failed: " + response.data.error, + type: 'is-danger', + duration: 2000, // 2 seconds + }) + } + this.editEmailAddressSaving = false + }) + + }, showAddItemDialog() { + this.customerPanelOpen = false this.editingItem = null this.productIsKnown = true this.productUUID = null diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index 4ae9666f..151f4572 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -228,6 +228,8 @@ class CustomerOrderView(MasterView): json_actions = [ 'assign_contact', 'unassign_contact', + 'update_phone_number', + 'update_email_address', 'get_customer_info', # 'set_customer_data', 'find_product_by_upc', @@ -413,22 +415,28 @@ class CustomerOrderView(MasterView): return context - # def set_customer_data(self, batch, data): - # if 'customer_uuid' in data: - # batch.customer_uuid = data['customer_uuid'] - # if 'person_uuid' in data: - # batch.person_uuid = data['person_uuid'] - # elif batch.customer_uuid: - # self.Session.flush() - # batch.person = batch.customer.first_person() - # else: # no customer set - # batch.person_uuid = None - # if 'phone_number' in data: - # batch.phone_number = data['phone_number'] - # if 'email_address' in data: - # batch.email_address = data['email_address'] - # self.Session.flush() - # return {'success': True} + def update_phone_number(self, batch, data): + app = self.get_rattail_app() + + batch.phone_number = app.format_phone_number(data['phone_number']) + self.Session.flush() + self.Session.refresh(batch) + + return { + 'success': True, + 'phone_number': batch.phone_number, + } + + def update_email_address(self, batch, data): + + batch.email_address = data['email_address'] + self.Session.flush() + self.Session.refresh(batch) + + return { + 'success': True, + 'email_address': batch.email_address, + } def product_autocomplete(self): """ From a6c89d799845572a036988e2e87152f1d30ce2d4 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 28 Sep 2021 16:10:04 -0400 Subject: [PATCH 0410/1681] Show "missing" msg if no email, for new custorder --- tailbone/templates/custorders/create.mako | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index 0d4576bb..95bd1498 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -194,7 +194,13 @@ <div class="level"> <div class="level-left"> <div class="level-item"> - {{ orderEmailAddress }} + <span v-if="orderEmailAddress"> + {{ orderEmailAddress }} + </span> + <span v-if="!orderEmailAddress" + class="has-text-danger"> + (no valid email on file) + </span> </div> <div class="level-item"> <b-button type="is-primary" From 03a569d9a3d83e96e55669ff20098c73c27e1b79 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 28 Sep 2021 16:12:33 -0400 Subject: [PATCH 0411/1681] Avoid "detach person" logic if not supported by view class --- tailbone/views/customers.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index 7132a767..f2e5f2dd 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -221,7 +221,7 @@ class CustomerView(MasterView): if self.viewing: if use_buefy: f.set_renderer('people', self.render_people_buefy) - elif self.has_perm('detach_person'): + elif self.people_detachable and self.has_perm('detach_person'): f.set_renderer('people', self.render_people_removable) else: f.set_renderer('people', self.render_people) @@ -257,7 +257,7 @@ class CustomerView(MasterView): customer = kwargs['instance'] people = [] for person in customer.people: - people.append({ + data = { 'uuid': person.uuid, 'full_name': person.display_name, 'first_name': person.first_name, @@ -266,10 +266,13 @@ class CustomerView(MasterView): uuid=person.uuid), '_action_url_edit': self.request.route_url('people.edit', uuid=person.uuid), - '_action_url_detach': self.request.route_url('customers.detach_person', - uuid=customer.uuid, - person_uuid=person.uuid), - }) + } + if self.people_detachable and self.has_perm('detach_person'): + data['_action_url_detach'] = self.request.route_url( + 'customers.detach_person', + uuid=customer.uuid, + person_uuid=person.uuid) + people.append(data) kwargs['people_data'] = people return kwargs @@ -365,7 +368,7 @@ class CustomerView(MasterView): g.main_actions.append(self.make_action('view', icon='eye')) if self.request.has_perm('people.edit'): g.main_actions.append(self.make_action('edit', icon='edit')) - if self.has_perm('detach_person'): + if self.people_detachable and self.has_perm('detach_person'): g.main_actions.append(self.make_action('detach', icon='minus-circle', link_class='has-text-warning', click_handler="$emit('detach-person', props.row._action_url_detach)")) From ed705ff86778840a70e49f1a34e3c3166d2ed36f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 28 Sep 2021 16:15:38 -0400 Subject: [PATCH 0412/1681] 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 edf4e8e4..b0b01131 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.153 (2021-09-28) +-------------------- + +* Improve phone/email handling when making new custorder. + +* Avoid "detach person" logic if not supported by view class. + + 0.8.152 (2021-09-27) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index c017ef55..acaa2fdc 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.152' +__version__ = '0.8.153' From bbfffd45fcbf681f36b4e055ca6f6f59f8b0bd3c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 29 Sep 2021 17:27:20 -0400 Subject: [PATCH 0413/1681] Initial (basic) views for invoice costing batches still a bit of feature preview at the moment, but maybe is mostly done? --- tailbone/views/purchasing/__init__.py | 3 +- tailbone/views/purchasing/batch.py | 42 ++- tailbone/views/purchasing/costing.py | 346 +++++++++++++++++++++++++ tailbone/views/purchasing/receiving.py | 41 +-- 4 files changed, 393 insertions(+), 39 deletions(-) create mode 100644 tailbone/views/purchasing/costing.py diff --git a/tailbone/views/purchasing/__init__.py b/tailbone/views/purchasing/__init__.py index 8f80b456..09d62909 100644 --- a/tailbone/views/purchasing/__init__.py +++ b/tailbone/views/purchasing/__init__.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -32,3 +32,4 @@ from .batch import PurchasingBatchView def includeme(config): config.include('tailbone.views.purchasing.ordering') config.include('tailbone.views.purchasing.receiving') + config.include('tailbone.views.purchasing.costing') diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index 1ca8c21c..1d42f08d 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -30,6 +30,7 @@ import six from rattail.db import model, api from rattail.time import localtime +from rattail.vendors.invoices import iter_invoice_parsers import colander from deform import widget as dfwidget @@ -220,6 +221,7 @@ class PurchasingBatchView(BatchMasterView): super(PurchasingBatchView, self).configure_form(f) batch = f.model_instance today = localtime(self.rattail_config).date() + use_buefy = self.get_use_buefy() # mode f.set_enum('mode', self.enum.PURCHASE_BATCH_MODE) @@ -313,6 +315,25 @@ class PurchasingBatchView(BatchMasterView): field_display=buyer_display, service_url=buyers_url)) f.set_label('buyer_uuid', "Buyer") + # invoice_file + if self.creating: + f.set_type('invoice_file', 'file', required=False) + else: + f.set_readonly('invoice_file') + f.set_renderer('invoice_file', self.render_downloadable_file) + + # invoice_parser_key + if self.creating: + parsers = sorted(iter_invoice_parsers(), key=lambda p: p.display) + parser_values = [(p.key, p.display) for p in parsers] + parser_values.insert(0, ('', "(please choose)")) + if use_buefy: + f.set_widget('invoice_parser_key', dfwidget.SelectWidget(values=parser_values)) + else: + f.set_widget('invoice_parser_key', forms.widgets.JQuerySelectWidget(values=parser_values)) + else: + f.remove_field('invoice_parser_key') + # date_ordered f.set_type('date_ordered', 'date_jquery') if self.creating: @@ -582,8 +603,11 @@ class PurchasingBatchView(BatchMasterView): g.set_type('po_total_calculated', 'currency') g.set_type('credits', 'boolean') - # we only want the grid column to have abbreviated label, but *not* the filter + # we only want the grid columns to have abbreviated labels, + # but *not* the filters # TODO: would be nice to somehow make this simpler + g.set_label('department_name', "Department") + g.filters['department_name'].label = "Department Name" g.set_label('cases_ordered', "Cases Ord.") g.filters['cases_ordered'].label = "Cases Ordered" g.set_label('units_ordered', "Units Ord.") @@ -597,6 +621,16 @@ class PurchasingBatchView(BatchMasterView): g.set_label('units_received', "Units Rec.") g.filters['units_received'].label = "Units Received" + # catalog_unit_cost + g.set_renderer('catalog_unit_cost', self.render_row_grid_cost) + g.set_label('catalog_unit_cost', "Catalog Cost") + g.filters['catalog_unit_cost'].label = "Catalog Unit Cost" + + # invoice_unit_cost + g.set_renderer('invoice_unit_cost', self.render_row_grid_cost) + g.set_label('invoice_unit_cost', "Invoice Cost") + g.filters['invoice_unit_cost'].label = "Invoice Unit Cost" + # invoice_total g.set_type('invoice_total', 'currency') g.set_label('invoice_total', "Total") @@ -608,6 +642,12 @@ class PurchasingBatchView(BatchMasterView): g.set_label('po_total', "Total") g.set_label('credits', "Credits?") + def render_row_grid_cost(self, row, field): + cost = getattr(row, field) + if cost is None: + return "" + return "{:0,.3f}".format(cost) + def make_row_grid_tools(self, batch): return self.make_default_row_grid_tools(batch) diff --git a/tailbone/views/purchasing/costing.py b/tailbone/views/purchasing/costing.py new file mode 100644 index 00000000..0f07d77d --- /dev/null +++ b/tailbone/views/purchasing/costing.py @@ -0,0 +1,346 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2021 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Views for 'costing' (purchasing) batches +""" + +from __future__ import unicode_literals, absolute_import + +import six + +import colander +from deform import widget as dfwidget + +from tailbone import forms +from tailbone.views.purchasing import PurchasingBatchView + + +class CostingBatchView(PurchasingBatchView): + """ + Master view for costing batches + """ + route_prefix = 'invoice_costing' + url_prefix = '/invoice-costing' + model_title = "Invoice Costing Batch" + model_title_plural = "Invoice Costing Batches" + index_title = "Invoice Costing" + downloadable = True + bulk_deletable = True + + purchase_order_fieldname = 'purchase' + + labels = { + 'invoice_parser_key': "Invoice Parser", + } + + grid_columns = [ + 'id', + 'vendor', + 'description', + 'department', + 'buyer', + 'date_ordered', + 'created', + 'created_by', + 'rowcount', + 'invoice_total', + 'status_code', + 'executed', + ] + + form_fields = [ + 'id', + 'store', + 'buyer', + 'vendor', + 'costing_workflow', + 'invoice_file', + 'invoice_parser_key', + 'department', + 'purchase', + 'vendor_email', + 'vendor_fax', + 'vendor_contact', + 'vendor_phone', + 'date_ordered', + 'date_received', + 'po_number', + 'po_total', + 'invoice_date', + 'invoice_number', + 'invoice_total', + 'invoice_total_calculated', + 'notes', + 'created', + 'created_by', + 'status_code', + 'complete', + 'executed', + 'executed_by', + ] + + row_grid_columns = [ + 'sequence', + 'upc', + # 'item_id', + 'vendor_code', + 'brand_name', + 'description', + 'size', + 'department_name', + 'cases_shipped', + 'units_shipped', + 'cases_received', + 'units_received', + 'catalog_unit_cost', + 'invoice_unit_cost', + # 'invoice_total_calculated', + 'invoice_total', + 'status_code', + ] + + @property + def batch_mode(self): + return self.enum.PURCHASE_BATCH_MODE_COSTING + + def create(self, form=None, **kwargs): + """ + Custom view for creating a new costing batch. We split the + process into two steps, 1) choose workflow and 2) create + batch. This is because the specific form details for creating + a batch will depend on which "type" of batch creation is to be + done, and it's much easier to keep conditional logic for that + in the server instead of client-side etc. + + See also + :meth:`tailbone.views.purchasing.receiving:ReceivingBatchView.create()` + which uses similar logic. + """ + route_prefix = self.get_route_prefix() + workflows = self.handler.supported_costing_workflows() + valid_workflows = [workflow['workflow_key'] + for workflow in workflows] + + # if user has already identified their desired workflow, then we can + # just farm out to the default logic. we will of course configure our + # form differently, based on workflow, but this create() method at + # least will not need customization for that. + if self.request.matched_route.name.endswith('create_workflow'): + + # however we do have one more thing to check - the workflow + # requested must of course be valid! + workflow_key = self.request.matchdict['workflow_key'] + if workflow_key not in valid_workflows: + self.request.session.flash( + "Not a supported workflow: {}".format(workflow_key), + 'error') + raise self.redirect(self.request.route_url('{}.create'.format(route_prefix))) + + # okay now do the normal thing, per workflow + return super(CostingBatchView, self).create(**kwargs) + + # okay, at this point we need the user to select a vendor and workflow + self.creating = True + use_buefy = self.get_use_buefy() + model = self.model + context = {} + + # form to accept user choice of vendor/workflow + schema = NewCostingBatch().bind(valid_workflows=valid_workflows) + form = forms.Form(schema=schema, request=self.request, + use_buefy=use_buefy) + if len(valid_workflows) == 1: + form.set_default('workflow', valid_workflows[0]) + + # configure vendor field + use_autocomplete = self.rattail_config.getbool( + 'rattail', 'vendor.use_autocomplete', default=True) + if use_autocomplete: + 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)) + else: + 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)) + + # configure workflow field + values = [(workflow['workflow_key'], workflow['display']) + for workflow in workflows] + if use_buefy: + form.set_widget('workflow', + dfwidget.SelectWidget(values=values)) + else: + form.set_widget('workflow', + forms.widgets.JQuerySelectWidget(values=values)) + + form.submit_label = "Continue" + form.cancel_url = self.get_index_url() + + # if form validates, that means user has chosen a creation type, so we + # just redirect to the appropriate "new batch of type X" page + if form.validate(newstyle=True): + workflow_key = form.validated['workflow'] + vendor_uuid = form.validated['vendor'] + url = self.request.route_url('{}.create_workflow'.format(route_prefix), + workflow_key=workflow_key, + vendor_uuid=vendor_uuid) + raise self.redirect(url) + + context['form'] = form + if hasattr(form, 'make_deform_form'): + context['dform'] = form.make_deform_form() + return self.render_to_response('create', context) + + def configure_form(self, f): + super(CostingBatchView, self).configure_form(f) + route_prefix = self.get_route_prefix() + use_buefy = self.get_use_buefy() + model = self.model + workflow = self.request.matchdict.get('workflow_key') + + if self.creating: + f.set_fields([ + 'vendor_uuid', + 'costing_workflow', + 'invoice_file', + 'invoice_parser_key', + 'purchase', + ]) + f.set_required('invoice_file') + + # tweak some things if we are in "step 2" of creating new batch + if self.creating and workflow: + + # display vendor but do not allow changing + vendor = self.Session.query(model.Vendor).get( + self.request.matchdict['vendor_uuid']) + assert vendor + + f.set_hidden('vendor_uuid') + f.set_default('vendor_uuid', vendor.uuid) + f.set_widget('vendor_uuid', dfwidget.HiddenWidget()) + + f.insert_after('vendor_uuid', 'vendor_name') + f.set_readonly('vendor_name') + f.set_default('vendor_name', vendor.name) + f.set_label('vendor_name', "Vendor") + + # cancel should take us back to choosing a workflow + f.cancel_url = self.request.route_url('{}.create'.format(route_prefix)) + + # costing_workflow + if self.creating and workflow: + f.set_readonly('costing_workflow') + f.set_renderer('costing_workflow', self.render_costing_workflow) + else: + f.remove('costing_workflow') + + # batch_type + if self.creating: + f.set_widget('batch_type', dfwidget.HiddenWidget()) + f.set_default('batch_type', workflow) + f.set_hidden('batch_type') + else: + f.remove_field('batch_type') + + # purchase + if (self.creating and workflow == 'invoice_with_po' + and self.purchase_order_fieldname == 'purchase'): + if use_buefy: + f.replace('purchase', 'purchase_uuid') + purchases = self.handler.get_eligible_purchases( + vendor, self.enum.PURCHASE_BATCH_MODE_COSTING) + values = [(p.uuid, self.handler.render_eligible_purchase(p)) + for p in purchases] + f.set_widget('purchase_uuid', dfwidget.SelectWidget(values=values)) + f.set_label('purchase_uuid', "Purchase Order") + f.set_required('purchase_uuid') + + def render_costing_workflow(self, batch, field): + key = self.request.matchdict['workflow_key'] + info = self.handler.costing_workflow_info(key) + if info: + return info['display'] + + @classmethod + def defaults(cls, config): + cls._costing_defaults(config) + cls._purchasing_defaults(config) + cls._batch_defaults(config) + cls._defaults(config) + + @classmethod + def _costing_defaults(cls, config): + route_prefix = cls.get_route_prefix() + url_prefix = cls.get_url_prefix() + permission_prefix = cls.get_permission_prefix() + + # new costing batch using workflow X + config.add_route('{}.create_workflow'.format(route_prefix), + '{}/new/{{workflow_key}}/{{vendor_uuid}}'.format(url_prefix)) + config.add_view(cls, attr='create', + route_name='{}.create_workflow'.format(route_prefix), + permission='{}.create'.format(permission_prefix)) + + +@colander.deferred +def valid_workflow(node, kw): + """ + Deferred validator for ``workflow`` field, for new batches. + """ + valid_workflows = kw['valid_workflows'] + + def validate(node, value): + # we just need to provide possible values, and let stock + # validator handle the rest + oneof = colander.OneOf(valid_workflows) + return oneof(node, value) + + return validate + + +class NewCostingBatch(colander.Schema): + """ + Schema for choosing which "type" of new receiving batch should be created. + """ + vendor = colander.SchemaNode(colander.String(), + label="Vendor") + + workflow = colander.SchemaNode(colander.String(), + validator=valid_workflow) + + +def includeme(config): + CostingBatchView.defaults(config) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 0d5606b1..0af4afe7 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -38,7 +38,6 @@ from rattail import pod from rattail.db import model, Session as RattailSession from rattail.time import localtime, make_utc from rattail.util import pretty_quantity, prettify, OrderedDict, simple_error -from rattail.vendors.invoices import iter_invoice_parsers, require_invoice_parser from rattail.threads import Thread import colander @@ -210,6 +209,10 @@ class ReceivingBatchView(PurchasingBatchView): form details for creating a batch will depend on which "type" of batch creation is to be done, and it's much easier to keep conditional logic for that in the server instead of client-side etc. + + See also + :meth:`tailbone.views.purchasing.costing:CostingBatchView.create()` + which uses similar logic. """ route_prefix = self.get_route_prefix() workflows = self.handler.supported_receiving_workflows() @@ -440,25 +443,6 @@ class ReceivingBatchView(PurchasingBatchView): 'truck_dump_status', 'truck_dump_batch') - # invoice_file - if self.creating: - f.set_type('invoice_file', 'file', required=False) - else: - f.set_readonly('invoice_file') - f.set_renderer('invoice_file', self.render_downloadable_file) - - # invoice_parser_key - if self.creating: - parsers = sorted(iter_invoice_parsers(), key=lambda p: p.display) - parser_values = [(p.key, p.display) for p in parsers] - parser_values.insert(0, ('', "(please choose)")) - if use_buefy: - f.set_widget('invoice_parser_key', dfwidget.SelectWidget(values=parser_values)) - else: - f.set_widget('invoice_parser_key', forms.widgets.JQuerySelectWidget(values=parser_values)) - else: - f.remove_field('invoice_parser_key') - # store if self.creating: store = self.rattail_config.get_store(self.Session()) @@ -737,22 +721,11 @@ class ReceivingBatchView(PurchasingBatchView): def configure_row_grid(self, g): super(ReceivingBatchView, self).configure_row_grid(g) - g.set_label('department_name', "Department") # vendor_code g.filters['vendor_code'].default_active = True g.filters['vendor_code'].default_verb = 'contains' - # catalog_unit_cost - g.set_renderer('catalog_unit_cost', self.render_row_grid_cost) - g.set_label('catalog_unit_cost', "Catalog Cost") - g.filters['catalog_unit_cost'].label = "Catalog Unit Cost" - - # invoice_unit_cost - g.set_renderer('invoice_unit_cost', self.render_row_grid_cost) - g.set_label('invoice_unit_cost', "Invoice Cost") - g.filters['invoice_unit_cost'].label = "Invoice Unit Cost" - # credits # note that sorting by credits involves a subquery with group by clause. # seems likely there may be a better way? but this seems to work fine @@ -800,12 +773,6 @@ class ReceivingBatchView(PurchasingBatchView): return css_class - def render_row_grid_cost(self, row, field): - cost = getattr(row, field) - if cost is None: - return "" - return "{:0,.3f}".format(cost) - def transform_unit_url(self, row, i): # grid action is shown only when we return a URL here if self.row_editable(row): From b2e2b2e85ec80594b4d2783d081c2731e55c4d40 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 30 Sep 2021 16:17:55 -0400 Subject: [PATCH 0414/1681] Fix one broken test; remove another ugh what are these tests even accomplishing.. --- tests/views/test_autocomplete.py | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/tests/views/test_autocomplete.py b/tests/views/test_autocomplete.py index dc630af4..717a2621 100644 --- a/tests/views/test_autocomplete.py +++ b/tests/views/test_autocomplete.py @@ -1,6 +1,7 @@ from mock import Mock from pyramid import testing +import sqlalchemy as sa from .. import TestCase, mock_query from tailbone.views import autocomplete @@ -77,19 +78,9 @@ class SampleAutocompleteViewTests(TestCase): def test_make_query(self): view = self.view() - view.mapped_class.thing.ilike.return_value = 'whatever' + whatever = sa.text('whatever') + view.mapped_class.thing.ilike.return_value = whatever self.assertTrue(view.make_query('test') is self.query) view.mapped_class.thing.ilike.assert_called_with('%test%') - self.query.filter.assert_called_with('whatever') + self.query.filter.assert_called_with(whatever) self.query.order_by.assert_called_with(view.mapped_class.thing) - - def test_call(self): - self.query.all.return_value = [ - Mock(uuid='1', thing='first'), - Mock(uuid='2', thing='second'), - ] - view = self.view(params={'term': 'bogus'}) - self.assertEqual(view(), [ - {'label': 'first', 'value': '1'}, - {'label': 'second', 'value': '2'}, - ]) From e0dff55ffa6aa3e66fad2624efbf948f85dfc64e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 30 Sep 2021 16:34:56 -0400 Subject: [PATCH 0415/1681] 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 b0b01131..79cff825 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.154 (2021-09-30) +-------------------- + +* Initial (basic) views for invoice costing batches. + + 0.8.153 (2021-09-28) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index acaa2fdc..a199919d 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.153' +__version__ = '0.8.154' From a7f4b2e6ef4059491610513c54861b1b1203926a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 30 Sep 2021 19:26:57 -0400 Subject: [PATCH 0416/1681] Refactor autocomplete view logic to leverage new "autocompleters" finally! this cleans up some view config and AFAIK there is no loss in functionality etc. --- tailbone/views/__init__.py | 5 +- tailbone/views/autocomplete.py | 95 ------------------------------ tailbone/views/brands.py | 18 +----- tailbone/views/customers.py | 54 +---------------- tailbone/views/departments.py | 19 +----- tailbone/views/employees.py | 29 +-------- tailbone/views/master.py | 49 +++++++++++++++ tailbone/views/people.py | 31 +--------- tailbone/views/products.py | 32 +--------- tailbone/views/vendors/__init__.py | 4 +- tailbone/views/vendors/core.py | 18 +----- tests/views/test_autocomplete.py | 86 --------------------------- 12 files changed, 65 insertions(+), 375 deletions(-) delete mode 100644 tailbone/views/autocomplete.py delete mode 100644 tests/views/test_autocomplete.py diff --git a/tailbone/views/__init__.py b/tailbone/views/__init__.py index 135e45b9..6b6ebc19 100644 --- a/tailbone/views/__init__.py +++ b/tailbone/views/__init__.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -29,9 +29,6 @@ from __future__ import unicode_literals, absolute_import from .core import View from .master import MasterView -# TODO: deprecate / remove some of this -from .autocomplete import AutocompleteView - def includeme(config): diff --git a/tailbone/views/autocomplete.py b/tailbone/views/autocomplete.py deleted file mode 100644 index f2a12d0e..00000000 --- a/tailbone/views/autocomplete.py +++ /dev/null @@ -1,95 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2021 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU General Public License as published by the Free Software -# Foundation, either version 3 of the License, or (at your option) any later -# version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -# details. -# -# You should have received a copy of the GNU General Public License along with -# Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ -""" -Autocomplete View -""" - -from __future__ import unicode_literals, absolute_import - -import sqlalchemy as sa - -from tailbone.views.core import View -from tailbone.db import Session - - -class AutocompleteView(View): - """ - Base class for generic autocomplete views. - """ - - def prepare_term(self, term): - """ - If necessary, massage the incoming search term for use with the query. - """ - return term - - def filter_query(self, q): - return q - - def make_query(self, term): - """ - Make and return the "complete" query for the given search term. - """ - # we are querying one table (and column) primarily - query = Session.query(self.mapped_class) - column = getattr(self.mapped_class, self.fieldname) - - # filter according to business logic, if applicable - query = self.filter_query(query) - - # filter according to search term(s) - criteria = [column.ilike('%{}%'.format(word)) - for word in term.split()] - query = query.filter(sa.and_(*criteria)) - - # sort results by something meaningful - query = query.order_by(column) - return query - - def query(self, term): - return self.make_query(term) - - def display(self, instance): - return getattr(instance, self.fieldname) - - def value(self, instance): - """ - Determine the data value for a query result instance. - """ - return instance.uuid - - def get_data(self, term): - return self.query(term).all() - - def __call__(self): - """ - View implementation. - """ - term = self.request.params.get(u'term') or u'' - term = term.strip() - if term: - term = self.prepare_term(term) - if not term: - return [] - results = self.get_data(term) - return [{'label': self.display(x), 'value': self.value(x)} for x in results] diff --git a/tailbone/views/brands.py b/tailbone/views/brands.py index 29cd6adc..b73060a3 100644 --- a/tailbone/views/brands.py +++ b/tailbone/views/brands.py @@ -28,7 +28,7 @@ from __future__ import unicode_literals, absolute_import from rattail.db import model -from tailbone.views import MasterView, AutocompleteView +from tailbone.views import MasterView class BrandView(MasterView): @@ -38,6 +38,7 @@ class BrandView(MasterView): model_class = model.Brand has_versions = True bulk_deletable = True + supports_autocomplete = True mergeable = True merge_additive_fields = [ @@ -133,21 +134,6 @@ class BrandView(MasterView): self.Session.flush() self.Session.delete(removing) -# TODO: deprecate / remove this -BrandsView = BrandView - - -class BrandsAutocomplete(AutocompleteView): - - mapped_class = model.Brand - fieldname = 'name' - def includeme(config): - - # autocomplete - config.add_route('brands.autocomplete', '/brands/autocomplete') - config.add_view(BrandsAutocomplete, route_name='brands.autocomplete', - renderer='json', permission='brands.list') - BrandView.defaults(config) diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index f2e5f2dd..27b19e94 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -26,11 +26,8 @@ Customer Views from __future__ import unicode_literals, absolute_import -import re - import six import sqlalchemy as sa -from sqlalchemy import orm import colander from pyramid.httpexceptions import HTTPNotFound @@ -38,7 +35,7 @@ from webhelpers2.html import HTML, tags from tailbone import grids from tailbone.db import Session -from tailbone.views import MasterView, AutocompleteView +from tailbone.views import MasterView from rattail.db import model @@ -52,6 +49,7 @@ class CustomerView(MasterView): has_versions = True people_detachable = True touchable = True + supports_autocomplete = True # whether to show "view full profile" helper for customer view show_profiles_helper = True @@ -451,9 +449,6 @@ class CustomerView(MasterView): route_name='{}.detach_person'.format(route_prefix), permission='{}.detach_person'.format(permission_prefix)) -# TODO: deprecate / remove this -CustomersView = CustomerView - # # TODO: this is referenced by some custom apps, but should be moved?? # def unique_id(value, field): @@ -473,43 +468,6 @@ def unique_id(node, value): raise colander.Invalid(node, "Customer ID must be unique") -class CustomerNameAutocomplete(AutocompleteView): - """ - Autocomplete view which operates on customer name. - """ - mapped_class = model.Customer - fieldname = 'name' - - -class CustomerPhoneAutocomplete(AutocompleteView): - """ - Autocomplete view which operates on customer phone number. - - .. note:: - As currently implemented, this view will only work with a PostgreSQL - database. It normalizes the user's search term and the database values - to numeric digits only (i.e. removes special characters from each) in - order to be able to perform smarter matching. However normalizing the - database value currently uses the PG SQL ``regexp_replace()`` function. - """ - invalid_pattern = re.compile(r'\D') - - def prepare_term(self, term): - return self.invalid_pattern.sub('', term) - - def query(self, term): - return Session.query(model.CustomerPhoneNumber)\ - .filter(sa.func.regexp_replace(model.CustomerPhoneNumber.number, r'\D', '', 'g').like('%{0}%'.format(term)))\ - .order_by(model.CustomerPhoneNumber.number)\ - .options(orm.joinedload(model.CustomerPhoneNumber.customer)) - - def display(self, phone): - return "{0} {1}".format(phone.number, phone.customer) - - def value(self, phone): - return phone.customer.uuid - - def customer_info(request): """ View which returns simple dictionary of info for a particular customer. @@ -527,14 +485,6 @@ def customer_info(request): def includeme(config): - # autocomplete - config.add_route('customers.autocomplete', '/customers/autocomplete') - config.add_view(CustomerNameAutocomplete, route_name='customers.autocomplete', - renderer='json', permission='customers.list') - config.add_route('customers.autocomplete.phone', '/customers/autocomplete/phone') - config.add_view(CustomerPhoneAutocomplete, route_name='customers.autocomplete.phone', - renderer='json', permission='customers.list') - # info config.add_route('customer.info', '/customers/info') config.add_view(customer_info, route_name='customer.info', diff --git a/tailbone/views/departments.py b/tailbone/views/departments.py index 1d3c36c6..8c841f6b 100644 --- a/tailbone/views/departments.py +++ b/tailbone/views/departments.py @@ -30,11 +30,10 @@ import six from rattail.db import model -from deform import widget as dfwidget from webhelpers2.html import HTML from tailbone import grids -from tailbone.views import MasterView, AutocompleteView +from tailbone.views import MasterView class DepartmentView(MasterView): @@ -44,6 +43,7 @@ class DepartmentView(MasterView): model_class = model.Department touchable = True has_versions = True + supports_autocomplete = True grid_columns = [ 'number', @@ -239,21 +239,6 @@ class DepartmentView(MasterView): cls._defaults(config) -# TODO: deprecate / remove this -DepartmentsView = DepartmentView - - -class DepartmentsAutocomplete(AutocompleteView): - - mapped_class = model.Department - fieldname = 'name' - def includeme(config): - - # autocomplete - config.add_route('departments.autocomplete', '/departments/autocomplete') - config.add_view(DepartmentsAutocomplete, route_name='departments.autocomplete', - renderer='json', permission='departments.list') - DepartmentView.defaults(config) diff --git a/tailbone/views/employees.py b/tailbone/views/employees.py index aa97b9b7..3ad331ab 100644 --- a/tailbone/views/employees.py +++ b/tailbone/views/employees.py @@ -36,8 +36,7 @@ from deform import widget as dfwidget from webhelpers2.html import tags, HTML from tailbone import grids -from tailbone.db import Session -from tailbone.views import MasterView, AutocompleteView +from tailbone.views import MasterView class EmployeeView(MasterView): @@ -47,6 +46,7 @@ class EmployeeView(MasterView): model_class = model.Employee has_versions = True touchable = True + supports_autocomplete = True labels = { 'id': "ID", @@ -310,31 +310,6 @@ class EmployeeView(MasterView): (model.EmployeeDepartment, 'employee_uuid'), ] -# TODO: deprecate / remove this -EmployeesView = EmployeeView - - -class EmployeesAutocomplete(AutocompleteView): - """ - Autocomplete view for the Employee model, but restricted to return only - results for current employees. - """ - mapped_class = model.Person - fieldname = 'display_name' - - def filter_query(self, q): - return q.join(model.Employee)\ - .filter(model.Employee.status == self.enum.EMPLOYEE_STATUS_CURRENT) - - def value(self, person): - return person.employee.uuid - def includeme(config): - - # autocomplete - config.add_route('employees.autocomplete', '/employees/autocomplete') - config.add_view(EmployeesAutocomplete, route_name='employees.autocomplete', - renderer='json', permission='employees.list') - EmployeeView.defaults(config) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index d238a4bb..ce7fcca7 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -97,6 +97,7 @@ class MasterView(View): delete_confirm = 'full' bulk_deletable = False set_deletable = False + supports_autocomplete = False supports_set_enabled_toggle = False populatable = False mergeable = False @@ -3573,6 +3574,35 @@ class MasterView(View): return self.after_delete_url return self.get_index_url() + ############################## + # Autocomplete Stuff + ############################## + + def autocomplete(self): + """ + View which accepts a single ``term`` param, and returns a list + of autocomplete results to match. + """ + app = self.get_rattail_app() + key = self.get_autocompleter_key() + # url may include key, for more specific autocompleter + if 'key' in self.request.matchdict: + key = '{}.{}'.format(key, self.request.matchdict['key']) + autocompleter = app.get_autocompleter(key) + + term = self.request.params.get('term', '') + return autocompleter.autocomplete(self.Session(), term) + + def get_autocompleter_key(self): + """ + Must return the "key" to be used when locating the + Autocompleter object, for use with autocomplete view. + """ + if hasattr(self, 'autocompleter_key'): + if self.autocompleter_key: + return self.autocompleter_key + return self.get_route_prefix() + ############################## # Associated Rows Stuff ############################## @@ -3965,6 +3995,25 @@ class MasterView(View): config.add_view(cls, attr='quickie', route_name='{}.quickie'.format(route_prefix), permission='{}.quickie'.format(permission_prefix)) + # autocomplete + if cls.supports_autocomplete: + + # default + config.add_route('{}.autocomplete'.format(route_prefix), + '{}/autocomplete'.format(url_prefix)) + config.add_view(cls, attr='autocomplete', + route_name='{}.autocomplete'.format(route_prefix), + renderer='json', + permission='{}.list'.format(permission_prefix)) + + # special + config.add_route('{}.autocomplete_special'.format(route_prefix), + '{}/autocomplete/{{key}}'.format(url_prefix)) + config.add_view(cls, attr='autocomplete', + route_name='{}.autocomplete_special'.format(route_prefix), + renderer='json', + permission='{}.list'.format(permission_prefix)) + # create if cls.creatable: config.add_tailbone_permission(permission_prefix, '{}.create'.format(permission_prefix), diff --git a/tailbone/views/people.py b/tailbone/views/people.py index a393df99..a5adb399 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -41,7 +41,7 @@ from pyramid.httpexceptions import HTTPFound, HTTPNotFound from webhelpers2.html import HTML, tags from tailbone import forms, grids -from tailbone.views import MasterView, AutocompleteView +from tailbone.views import MasterView class PersonView(MasterView): @@ -56,6 +56,7 @@ class PersonView(MasterView): bulk_deletable = True is_contact = True manage_notes_from_profile_view = False + supports_autocomplete = True labels = { 'default_phone': "Phone Number", @@ -854,25 +855,6 @@ class PersonView(MasterView): config.add_view(cls, attr='request_merge', route_name='{}.request_merge'.format(route_prefix), permission='{}.request_merge'.format(permission_prefix)) -# TODO: deprecate / remove this -PeopleView = PersonView - - -class PeopleAutocomplete(AutocompleteView): - - mapped_class = model.Person - fieldname = 'display_name' - - -class PeopleEmployeesAutocomplete(PeopleAutocomplete): - """ - Autocomplete view for the Person model, but restricted to return only - results for people who are employees. - """ - - def filter_query(self, q): - return q.join(model.Employee) - class PersonNoteView(MasterView): """ @@ -1044,15 +1026,6 @@ class MergePeopleRequestView(MasterView): def includeme(config): - - # autocomplete - config.add_route('people.autocomplete', '/people/autocomplete') - config.add_view(PeopleAutocomplete, route_name='people.autocomplete', - renderer='json', permission='people.list') - config.add_route('people.autocomplete.employees', '/people/autocomplete/employees') - config.add_view(PeopleEmployeesAutocomplete, route_name='people.autocomplete.employees', - renderer='json', permission='people.list') - PersonView.defaults(config) PersonNoteView.defaults(config) MergePeopleRequestView.defaults(config) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 4a52f682..3419ccfe 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -51,7 +51,7 @@ from webhelpers2.html import tags, HTML from tailbone import forms, grids from tailbone.db import Session -from tailbone.views import MasterView, AutocompleteView +from tailbone.views import MasterView from tailbone.util import raw_datetime @@ -84,6 +84,7 @@ class ProductView(MasterView): model_class = model.Product has_versions = True results_downloadable_xlsx = True + supports_autocomplete = True labels = { 'item_id': "Item ID", @@ -1886,31 +1887,6 @@ class ProductView(MasterView): renderer='json', permission='{}.versions'.format(permission_prefix)) -# TODO: deprecate / remove this -ProductsView = ProductView - - -class ProductsAutocomplete(AutocompleteView): - """ - Autocomplete view for products. - """ - mapped_class = model.Product - fieldname = 'description' - - def query(self, term): - q = Session.query(model.Product).outerjoin(model.Brand) - q = q.filter(sa.or_( - model.Brand.name.ilike('%{}%'.format(term)), - model.Product.description.ilike('%{}%'.format(term)))) - if not self.request.has_perm('products.view_deleted'): - q = q.filter(model.Product.deleted == False) - q = q.order_by(model.Brand.name, model.Product.description) - q = q.options(orm.joinedload(model.Product.brand)) - return q - - def display(self, product): - return product.full_description - def print_labels(request): profile = request.params.get('profile') @@ -1942,10 +1918,6 @@ def print_labels(request): def includeme(config): - config.add_route('products.autocomplete', '/products/autocomplete') - config.add_view(ProductsAutocomplete, route_name='products.autocomplete', - renderer='json', permission='products.list') - config.add_route('products.print_labels', '/products/labels') config.add_view(print_labels, route_name='products.print_labels', renderer='json', permission='products.print_labels') diff --git a/tailbone/views/vendors/__init__.py b/tailbone/views/vendors/__init__.py index 885ec712..6a31777c 100644 --- a/tailbone/views/vendors/__init__.py +++ b/tailbone/views/vendors/__init__.py @@ -26,9 +26,7 @@ Views pertaining to vendors from __future__ import unicode_literals, absolute_import -from .core import VendorView, VendorsAutocomplete -# TODO: deprecate / remove this -from .core import VendorsView +from .core import VendorView def includeme(config): diff --git a/tailbone/views/vendors/core.py b/tailbone/views/vendors/core.py index 7a6f4eca..ceac1c71 100644 --- a/tailbone/views/vendors/core.py +++ b/tailbone/views/vendors/core.py @@ -32,7 +32,7 @@ from rattail.db import model from webhelpers2.html import tags -from tailbone.views import MasterView, AutocompleteView +from tailbone.views import MasterView class VendorView(MasterView): @@ -42,6 +42,7 @@ class VendorView(MasterView): model_class = model.Vendor has_versions = True touchable = True + supports_autocomplete = True labels = { 'id': "ID", @@ -167,21 +168,6 @@ class VendorView(MasterView): (model.VendorContact, 'vendor_uuid'), ] -# TODO: deprecate / remove this -VendorsView = VendorView - - -class VendorsAutocomplete(AutocompleteView): - - mapped_class = model.Vendor - fieldname = 'name' - def includeme(config): - - # autocomplete - config.add_route('vendors.autocomplete', '/vendors/autocomplete') - config.add_view(VendorsAutocomplete, route_name='vendors.autocomplete', - renderer='json', permission='vendors.list') - VendorView.defaults(config) diff --git a/tests/views/test_autocomplete.py b/tests/views/test_autocomplete.py deleted file mode 100644 index 717a2621..00000000 --- a/tests/views/test_autocomplete.py +++ /dev/null @@ -1,86 +0,0 @@ - -from mock import Mock -from pyramid import testing -import sqlalchemy as sa - -from .. import TestCase, mock_query -from tailbone.views import autocomplete - - -class BareAutocompleteViewTests(TestCase): - - def view(self, **kwargs): - request = testing.DummyRequest(**kwargs) - return autocomplete.AutocompleteView(request) - - def test_attributes(self): - view = self.view() - self.assertRaises(AttributeError, getattr, view, 'mapped_class') - self.assertRaises(AttributeError, getattr, view, 'fieldname') - - def test_filter_query(self): - view = self.view() - query = Mock() - filtered = view.filter_query(query) - self.assertTrue(filtered is query) - - def test_make_query(self): - view = self.view() - # No mapped_class defined for view. - self.assertRaises(AttributeError, view.make_query, 'test') - - def test_query(self): - view = self.view() - query = Mock() - view.make_query = Mock(return_value=query) - filtered = view.query('test') - self.assertTrue(filtered is query) - - def test_display(self): - view = self.view() - instance = Mock() - # No fieldname defined for view. - self.assertRaises(AttributeError, view.display, instance) - - def test_call(self): - # Empty or missing query term yields empty list. - view = self.view(params={}) - self.assertEqual(view(), []) - view = self.view(params={'term': None}) - self.assertEqual(view(), []) - view = self.view(params={'term': ''}) - self.assertEqual(view(), []) - view = self.view(params={'term': '\t'}) - self.assertEqual(view(), []) - # No mapped_class defined for view. - view = self.view(params={'term': 'bogus'}) - self.assertRaises(AttributeError, view) - - -class SampleAutocompleteViewTests(TestCase): - - def setUp(self): - super(SampleAutocompleteViewTests, self).setUp() - self.Session_query = autocomplete.Session.query - self.query = mock_query() - autocomplete.Session.query = self.query - - def tearDown(self): - super(SampleAutocompleteViewTests, self).tearDown() - autocomplete.Session.query = self.Session_query - - def view(self, **kwargs): - request = testing.DummyRequest(**kwargs) - view = autocomplete.AutocompleteView(request) - view.mapped_class = Mock() - view.fieldname = 'thing' - return view - - def test_make_query(self): - view = self.view() - whatever = sa.text('whatever') - view.mapped_class.thing.ilike.return_value = whatever - self.assertTrue(view.make_query('test') is self.query) - view.mapped_class.thing.ilike.assert_called_with('%test%') - self.query.filter.assert_called_with(whatever) - self.query.order_by.assert_called_with(view.mapped_class.thing) From 272b0fd07186c9f2574e1127967e3c9aab5bb2c1 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 1 Oct 2021 18:38:24 -0400 Subject: [PATCH 0417/1681] 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 79cff825..d2a244ce 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.155 (2021-10-01) +-------------------- + +* Refactor autocomplete view logic to leverage new "autocompleters". + + 0.8.154 (2021-09-30) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index a199919d..a9451e5f 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.154' +__version__ = '0.8.155' From 711e526822ccbbc0fd3b6ea4a9c5a4f91ed4af13 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 3 Oct 2021 19:26:25 -0400 Subject: [PATCH 0418/1681] Show "contact notes" when creating new custorder --- tailbone/templates/custorders/create.mako | 256 ++++++++++++---------- tailbone/views/custorders/orders.py | 3 + 2 files changed, 138 insertions(+), 121 deletions(-) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index 95bd1498..bb06ca40 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -108,144 +108,157 @@ </div> <div v-show="contactIsKnown" - style="padding-left: 10rem;"> + style="padding-left: 10rem; display: flex;"> - <b-field label="Customer" grouped> - <b-field style="margin-left: 1rem;"" - :expanded="!contactUUID"> - <tailbone-autocomplete ref="contactAutocomplete" - v-model="contactUUID" - placeholder="Enter name or phone number" - :initial-label="contactDisplay" - % if new_order_requires_customer: - serviceUrl="${url('{}.customer_autocomplete'.format(route_prefix))}" - % else: - serviceUrl="${url('{}.person_autocomplete'.format(route_prefix))}" - % endif - @input="contactChanged"> - </tailbone-autocomplete> + <div :style="{'flex-grow': contactNotes.length ? 0 : 1}"> + + <b-field label="Customer" grouped> + <b-field style="margin-left: 1rem;"" + :expanded="!contactUUID"> + <tailbone-autocomplete ref="contactAutocomplete" + v-model="contactUUID" + placeholder="Enter name or phone number" + :initial-label="contactDisplay" + % if new_order_requires_customer: + serviceUrl="${url('{}.customer_autocomplete'.format(route_prefix))}" + % else: + serviceUrl="${url('{}.person_autocomplete'.format(route_prefix))}" + % endif + @input="contactChanged"> + </tailbone-autocomplete> + </b-field> + <b-button v-if="contactUUID && contactProfileURL" + type="is-primary" + tag="a" target="_blank" + :href="contactProfileURL" + icon-pack="fas" + icon-left="external-link-alt"> + View Profile + </b-button> </b-field> - <b-button v-if="contactUUID && contactProfileURL" - type="is-primary" - tag="a" target="_blank" - :href="contactProfileURL" - icon-pack="fas" - icon-left="external-link-alt"> - View Profile - </b-button> - </b-field> - <b-field grouped v-show="contactUUID" - style="margin-top: 2rem;"> + <b-field grouped v-show="contactUUID" + style="margin-top: 2rem;"> - <b-field label="Phone Number" - style="margin-right: 3rem;"> - <div class="level"> - <div class="level-left"> - <div class="level-item"> - {{ orderPhoneNumber }} - </div> - <div class="level-item"> - <b-button type="is-primary" - @click="editPhoneNumberInit()" - icon-pack="fas" - icon-left="edit"> - Edit - </b-button> + <b-field label="Phone Number" + style="margin-right: 3rem;"> + <div class="level"> + <div class="level-left"> + <div class="level-item"> + {{ orderPhoneNumber }} + </div> + <div class="level-item"> + <b-button type="is-primary" + @click="editPhoneNumberInit()" + icon-pack="fas" + icon-left="edit"> + Edit + </b-button> - <b-modal has-modal-card - :active.sync="editPhoneNumberShowDialog"> - <div class="modal-card"> + <b-modal has-modal-card + :active.sync="editPhoneNumberShowDialog"> + <div class="modal-card"> - <header class="modal-card-head"> - <p class="modal-card-title">Edit Phone Number</p> - </header> + <header class="modal-card-head"> + <p class="modal-card-title">Edit Phone Number</p> + </header> - <section class="modal-card-body"> - <b-field label="Phone Number" - :type="editPhoneNumberValue ? null : 'is-danger'"> - <b-input v-model="editPhoneNumberValue" - ref="editPhoneNumberInput"> - </b-input> - </b-field> - </section> + <section class="modal-card-body"> + <b-field label="Phone Number" + :type="editPhoneNumberValue ? null : 'is-danger'"> + <b-input v-model="editPhoneNumberValue" + ref="editPhoneNumberInput"> + </b-input> + </b-field> + </section> - <footer class="modal-card-foot"> - <b-button type="is-primary" - icon-pack="fas" - icon-left="save" - :disabled="editPhoneNumberSaveDisabled" - @click="editPhoneNumberSave()"> - {{ editPhoneNumberSaveText }} - </b-button> - <b-button @click="editPhoneNumberShowDialog = false"> - Cancel - </b-button> - </footer> - </div> - </b-modal> + <footer class="modal-card-foot"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="save" + :disabled="editPhoneNumberSaveDisabled" + @click="editPhoneNumberSave()"> + {{ editPhoneNumberSaveText }} + </b-button> + <b-button @click="editPhoneNumberShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </b-modal> + </div> </div> </div> - </div> - </b-field> + </b-field> - <b-field label="Email Address"> - <div class="level"> - <div class="level-left"> - <div class="level-item"> - <span v-if="orderEmailAddress"> - {{ orderEmailAddress }} - </span> - <span v-if="!orderEmailAddress" - class="has-text-danger"> - (no valid email on file) - </span> - </div> - <div class="level-item"> - <b-button type="is-primary" - @click="editEmailAddressInit()" - icon-pack="fas" - icon-left="edit"> - Edit - </b-button> - <b-modal has-modal-card - :active.sync="editEmailAddressShowDialog"> - <div class="modal-card"> + <b-field label="Email Address"> + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <span v-if="orderEmailAddress"> + {{ orderEmailAddress }} + </span> + <span v-if="!orderEmailAddress" + class="has-text-danger"> + (no valid email on file) + </span> + </div> + <div class="level-item"> + <b-button type="is-primary" + @click="editEmailAddressInit()" + icon-pack="fas" + icon-left="edit"> + Edit + </b-button> + <b-modal has-modal-card + :active.sync="editEmailAddressShowDialog"> + <div class="modal-card"> - <header class="modal-card-head"> - <p class="modal-card-title">Edit Email Address</p> - </header> + <header class="modal-card-head"> + <p class="modal-card-title">Edit Email Address</p> + </header> - <section class="modal-card-body"> - <b-field label="Email Address" - :type="editEmailAddressValue ? null : 'is-danger'"> - <b-input v-model="editEmailAddressValue" - ref="editEmailAddressInput"> - </b-input> - </b-field> - </section> + <section class="modal-card-body"> + <b-field label="Email Address" + :type="editEmailAddressValue ? null : 'is-danger'"> + <b-input v-model="editEmailAddressValue" + ref="editEmailAddressInput"> + </b-input> + </b-field> + </section> - <footer class="modal-card-foot"> - <b-button type="is-primary" - icon-pack="fas" - icon-left="save" - :disabled="editEmailAddressSaveDisabled" - @click="editEmailAddressSave()"> - {{ editEmailAddressSaveText }} - </b-button> - <b-button @click="editEmailAddressShowDialog = false"> - Cancel - </b-button> - </footer> - </div> - </b-modal> + <footer class="modal-card-foot"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="save" + :disabled="editEmailAddressSaveDisabled" + @click="editEmailAddressSave()"> + {{ editEmailAddressSaveText }} + </b-button> + <b-button @click="editEmailAddressShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </b-modal> + </div> </div> </div> - </div> - </b-field> + </b-field> - </b-field> + </b-field> + </div> + + <div v-show="contactNotes.length" + style="margin-left: 1rem;"> + <b-notification v-for="note in contactNotes" + :key="note" + type="is-warning" + :closable="false"> + {{ note }} + </b-notification> + </div> </div> <br /> @@ -505,6 +518,7 @@ customerName: null, phoneNumber: null, orderEmailAddress: ${json.dumps(batch.email_address)|n}, + contactNotes: ${json.dumps(contact_notes)|n}, editPhoneNumberShowDialog: false, editPhoneNumberValue: null, @@ -803,7 +817,6 @@ } let that = this this.submitBatchData(params, function(response) { - console.log(response.data) % if new_order_requires_customer: that.contactUUID = response.data.customer_uuid % else: @@ -812,6 +825,7 @@ that.orderPhoneNumber = response.data.phone_number that.orderEmailAddress = response.data.email_address that.contactProfileURL = response.data.contact_profile_url + that.contactNotes = response.data.contact_notes }) }, diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index 151f4572..885d9ec8 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -256,6 +256,7 @@ class CustomerOrderView(MasterView): 'normalized_batch': self.normalize_batch(batch), 'new_order_requires_customer': self.handler.new_order_requires_customer(), 'contact_profile_url': None, + 'contact_notes': self.handler.get_contact_notes(batch), 'order_items': items, 'product_autocomplete_url': self.request.route_url(product_autocomplete)} @@ -391,6 +392,7 @@ class CustomerOrderView(MasterView): 'person_uuid': batch.person_uuid, 'phone_number': batch.phone_number, 'email_address': batch.email_address, + 'contact_notes': self.handler.get_contact_notes(batch), } # maybe add profile URL @@ -411,6 +413,7 @@ class CustomerOrderView(MasterView): 'phone_number': batch.phone_number, 'email_address': batch.email_address, 'contact_profile_url': None, + 'contact_notes': self.handler.get_contact_notes(batch), } return context From 1884edb33429255a8da9a78f8d0e835c4f1b13b8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 4 Oct 2021 12:24:24 -0400 Subject: [PATCH 0419/1681] Improve phone editing for new custorder let user choose from existing phones, or add a new one. not yet implemented, they can check a box to add new phone to customer proper in addition to setting it for the order --- tailbone/templates/custorders/create.mako | 86 ++++++++++++++++++----- tailbone/views/custorders/orders.py | 1 + 2 files changed, 70 insertions(+), 17 deletions(-) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index bb06ca40..2e7b8195 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -164,12 +164,36 @@ </header> <section class="modal-card-body"> - <b-field label="Phone Number" - :type="editPhoneNumberValue ? null : 'is-danger'"> - <b-input v-model="editPhoneNumberValue" - ref="editPhoneNumberInput"> - </b-input> + + <b-field v-for="phone in contactPhones" + :key="phone.uuid"> + <b-radio v-model="existingPhoneUUID" + :native-value="phone.uuid"> + {{ phone.type }} {{ phone.number }} + <span v-if="phone.preferred" + class="is-italic"> + (preferred) + </span> + </b-radio> </b-field> + + <b-field> + <b-radio v-model="existingPhoneUUID" + :native-value="null"> + other + </b-radio> + </b-field> + + <b-field v-if="!existingPhoneUUID" + grouped> + <b-input v-model="otherPhoneNumber"> + </b-input> + <b-checkbox v-model="addOtherPhoneNumber" + disabled> + add this phone number to customer record + </b-checkbox> + </b-field> + </section> <footer class="modal-card-foot"> @@ -512,18 +536,20 @@ contactDisplay: ${json.dumps(six.text_type(batch.customer or ''))|n}, customerEntry: null, contactProfileURL: ${json.dumps(contact_profile_url)|n}, - ## phoneNumberEntry: ${json.dumps(batch.phone_number)|n}, + orderPhoneNumber: ${json.dumps(batch.phone_number)|n}, - phoneNumberSaved: true, + contactPhones: ${json.dumps(contact_phones)|n}, + existingPhoneUUID: null, + otherPhoneNumber: null, + addOtherPhoneNumber: false, + editPhoneNumberShowDialog: false, + editPhoneNumberSaving: false, + customerName: null, phoneNumber: null, orderEmailAddress: ${json.dumps(batch.email_address)|n}, contactNotes: ${json.dumps(contact_notes)|n}, - editPhoneNumberShowDialog: false, - editPhoneNumberValue: null, - editPhoneNumberSaving: false, - editEmailAddressShowDialog: false, editEmailAddressValue: null, editEmailAddressSaving: false, @@ -607,6 +633,12 @@ text: "Please provide a phone number for the customer.", } } + if (this.contactNotes.length) { + return { + type: 'is-warning', + text: "Please review notes below.", + } + } phoneNumber = this.orderPhoneNumber } else { // customer is not known if (!this.customerName) { @@ -649,7 +681,7 @@ if (this.editPhoneNumberSaving) { return true } - if (!this.editPhoneNumberValue) { + if (!this.existingPhoneUUID && !this.otherPhoneNumber) { return true } return false @@ -830,11 +862,17 @@ }, editPhoneNumberInit() { - this.editPhoneNumberValue = this.orderPhoneNumber + this.existingPhoneUUID = null + let normalOrderPhone = this.orderPhoneNumber.replace(/\D/g, '') + for (let phone of this.contactPhones) { + let normal = phone.number.replace(/\D/g, '') + if (normal == normalOrderPhone) { + this.existingPhoneUUID = phone.uuid + break + } + } + this.otherPhoneNumber = this.existingPhoneUUID ? this.orderPhoneNumber : null this.editPhoneNumberShowDialog = true - this.$nextTick(() => { - this.$refs.editPhoneNumberInput.focus() - }) }, editPhoneNumberSave() { @@ -842,7 +880,21 @@ let params = { action: 'update_phone_number', - phone_number: this.editPhoneNumberValue, + phone_number: null, + } + + if (this.existingPhoneUUID) { + for (let phone of this.contactPhones) { + if (phone.uuid == this.existingPhoneUUID) { + params.phone_number = phone.number + break + } + } + } + + if (!params.phone_number) { + params.phone_number = this.otherPhoneNumber + // params.add_phone_number = this.addOtherPhoneNumber } this.submitBatchData(params, response => { diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index 885d9ec8..dcd22a44 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -255,6 +255,7 @@ class CustomerOrderView(MasterView): context = {'batch': batch, 'normalized_batch': self.normalize_batch(batch), 'new_order_requires_customer': self.handler.new_order_requires_customer(), + 'contact_phones': self.handler.get_contact_phones(batch), 'contact_profile_url': None, 'contact_notes': self.handler.get_contact_notes(batch), 'order_items': items, From d4aef9ceacdcce6a07876697f916ce1011fffd27 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 4 Oct 2021 12:29:27 -0400 Subject: [PATCH 0420/1681] Fix contact phones data when new contact is assigned --- tailbone/templates/custorders/create.mako | 3 ++- tailbone/views/custorders/orders.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index 2e7b8195..d5c6c4c9 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -857,6 +857,7 @@ that.orderPhoneNumber = response.data.phone_number that.orderEmailAddress = response.data.email_address that.contactProfileURL = response.data.contact_profile_url + that.contactPhones = response.data.contact_phones that.contactNotes = response.data.contact_notes }) }, @@ -871,7 +872,7 @@ break } } - this.otherPhoneNumber = this.existingPhoneUUID ? this.orderPhoneNumber : null + this.otherPhoneNumber = this.existingPhoneUUID ? null : this.orderPhoneNumber this.editPhoneNumberShowDialog = true }, diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index dcd22a44..b9eac443 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -393,6 +393,7 @@ class CustomerOrderView(MasterView): 'person_uuid': batch.person_uuid, 'phone_number': batch.phone_number, 'email_address': batch.email_address, + 'contact_phones': self.handler.get_contact_phones(batch), 'contact_notes': self.handler.get_contact_notes(batch), } From 8e4079224fd9e689556daa0c0fd567996909f677 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 4 Oct 2021 12:39:30 -0400 Subject: [PATCH 0421/1681] Add button to refresh contact info for new custorder e.g. click that after changes are made in other screen / system --- tailbone/templates/custorders/create.mako | 32 +++++++++++++++++------ 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index d5c6c4c9..420ae1af 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -127,14 +127,22 @@ @input="contactChanged"> </tailbone-autocomplete> </b-field> - <b-button v-if="contactUUID && contactProfileURL" - type="is-primary" - tag="a" target="_blank" - :href="contactProfileURL" - icon-pack="fas" - icon-left="external-link-alt"> - View Profile - </b-button> + <div v-if="contactUUID" + class="buttons"> + <b-button @click="refreshContact" + icon-pack="fas" + icon-left="redo"> + Refresh + </b-button> + <b-button v-if="contactProfileURL" + type="is-primary" + tag="a" target="_blank" + :href="contactProfileURL" + icon-pack="fas" + icon-left="external-link-alt"> + View Profile + </b-button> + </div> </b-field> <b-field grouped v-show="contactUUID" @@ -532,7 +540,11 @@ customerPanelOpen: false, contactIsKnown: true, + % if new_order_requires_customer: contactUUID: ${json.dumps(batch.customer_uuid)|n}, + % else: + contactUUID: ${json.dumps(batch.person_uuid)|n}, + % endif contactDisplay: ${json.dumps(six.text_type(batch.customer or ''))|n}, customerEntry: null, contactProfileURL: ${json.dumps(contact_profile_url)|n}, @@ -862,6 +874,10 @@ }) }, + refreshContact() { + this.contactChanged(this.contactUUID) + }, + editPhoneNumberInit() { this.existingPhoneUUID = null let normalOrderPhone = this.orderPhoneNumber.replace(/\D/g, '') From 48864ab611535bfc3fd2ad7fa54da83dd6ebb62b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 4 Oct 2021 12:40:35 -0400 Subject: [PATCH 0422/1681] Put the View Profile button above Refresh --- tailbone/templates/custorders/create.mako | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index 420ae1af..79233493 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -129,11 +129,6 @@ </b-field> <div v-if="contactUUID" class="buttons"> - <b-button @click="refreshContact" - icon-pack="fas" - icon-left="redo"> - Refresh - </b-button> <b-button v-if="contactProfileURL" type="is-primary" tag="a" target="_blank" @@ -142,6 +137,11 @@ icon-left="external-link-alt"> View Profile </b-button> + <b-button @click="refreshContact" + icon-pack="fas" + icon-left="redo"> + Refresh + </b-button> </div> </b-field> From 6386b345169e90ecead749fda00881a34593e325 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 4 Oct 2021 21:21:34 -0400 Subject: [PATCH 0423/1681] Overhaul the "Personal" tab of profile view should be much more useful now.. er, at least for those who track contact info on the Person record, but not those who track on the Customer record.. --- .../templates/people/view_profile_buefy.mako | 1061 ++++++++++++++--- tailbone/views/people.py | 420 ++++++- 2 files changed, 1336 insertions(+), 145 deletions(-) diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako index 7b413621..114682bd 100644 --- a/tailbone/templates/people/view_profile_buefy.mako +++ b/tailbone/templates/people/view_profile_buefy.mako @@ -4,6 +4,9 @@ <%def name="extra_styles()"> ${parent.extra_styles()} <style type="text/css"> + .card.personal { + margin-bottom: 1rem; + } .field.is-horizontal .field-label .label { white-space: nowrap; min-width: 10rem; @@ -20,6 +23,476 @@ ${self.page_content()} </%def> +<%def name="render_personal_name_card()"> + <div class="card personal"> + <header class="card-header"> + <p class="card-header-title">Name</p> + </header> + <div class="card-content"> + <div class="content"> + <div style="display: flex; justify-content: space-between;"> + <div style="flex-grow: 1; margin-right: 1rem;"> + + <b-field horizontal label="First Name"> + <span>{{ person.first_name }}</span> + </b-field> + + <b-field horizontal label="Middle Name"> + <span>{{ person.middle_name }}</span> + </b-field> + + <b-field horizontal label="Last Name"> + <span>{{ person.last_name }}</span> + </b-field> + + </div> + % if request.has_perm('people_profile.edit_person'): + <div v-if="editNameAllowed()"> + <b-button type="is-primary" + @click="editNameInit()" + icon-pack="fas" + icon-left="edit"> + Edit Name + </b-button> + </div> + <b-modal has-modal-card + :active.sync="editNameShowDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Edit Name</p> + </header> + + <section class="modal-card-body"> + <b-field label="First Name"> + <b-input v-model.trim="personFirstName"></b-input> + </b-field> + <b-field label="Middle Name"> + <b-input v-model.trim="personMiddleName"></b-input> + </b-field> + <b-field label="Last Name"> + <b-input v-model.trim="personLastName"></b-input> + </b-field> + </section> + + <footer class="modal-card-foot"> + <once-button type="is-primary" + @click="editNameSave()" + :disabled="editNameSaveDisabled" + icon-left="save" + text="Save"> + </once-button> + <b-button @click="editNameShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </b-modal> + % endif + </div> + </div> + </div> + </div> +</%def> + +<%def name="render_personal_address_card()"> + <div class="card personal"> + <header class="card-header"> + <p class="card-header-title">Address</p> + </header> + <div class="card-content"> + <div class="content"> + <div style="display: flex; justify-content: space-between;"> + <div style="flex-grow: 1; margin-right: 1rem;"> + + <b-field horizontal label="Street 1"> + <span>{{ person.address ? person.address.street : null }}</span> + </b-field> + + <b-field horizontal label="Street 2"> + <span>{{ person.address ? person.address.street2 : null }}</span> + </b-field> + + <b-field horizontal label="City"> + <span>{{ person.address ? person.address.city : null }}</span> + </b-field> + + <b-field horizontal label="State"> + <span>{{ person.address ? person.address.state : null }}</span> + </b-field> + + <b-field horizontal label="Zipcode"> + <span>{{ person.address ? person.address.zipcode : null }}</span> + </b-field> + + <b-field v-if="person.address && person.address.invalid" + horizontal label="Invalid" + class="has-text-danger"> + <span>Yes</span> + </b-field> + + </div> + % if request.has_perm('people_profile.edit_person'): + <b-button type="is-primary" + @click="editAddressInit()" + icon-pack="fas" + icon-left="edit"> + Edit Address + </b-button> + <b-modal has-modal-card + :active.sync="editAddressShowDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Edit Address</p> + </header> + + <section class="modal-card-body"> + + <b-field label="Street 1" expanded> + <b-input v-model.trim="personStreet1" + :maxlength="maxLengths.address_street || null"> + </b-input> + </b-field> + + <b-field label="Street 2" expanded> + <b-input v-model.trim="personStreet2" + :maxlength="maxLengths.address_street2 || null"> + </b-input> + </b-field> + + <b-field label="Zipcode"> + <b-input v-model.trim="personZipcode" + :maxlength="maxLengths.address_zipcode || null"> + </b-input> + </b-field> + + <b-field grouped> + <b-field label="City"> + <b-input v-model.trim="personCity" + :maxlength="maxLengths.address_city || null"> + </b-input> + </b-field> + <b-field label="State"> + <b-input v-model.trim="personState" + :maxlength="maxLengths.address_state || null"> + </b-input> + </b-field> + </b-field> + + <b-field label="Invalid"> + <b-checkbox v-model="personInvalidAddress" + type="is-danger"> + </b-checkbox> + </b-field> + + </section> + + <footer class="modal-card-foot"> + <once-button type="is-primary" + @click="editAddressSave()" + :disabled="editAddressSaveDisabled" + icon-left="save" + text="Save"> + </once-button> + <b-button @click="editAddressShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </b-modal> + % endif + </div> + </div> + </div> + </div> +</%def> + +<%def name="render_personal_phone_card()"> + <div class="card personal"> + <header class="card-header"> + <p class="card-header-title">Phone(s)</p> + </header> + <div class="card-content"> + <div class="content"> + + <b-notification v-if="person.invalid_phone_number" + type="is-warning" + has-icon icon-pack="fas" + :closable="false"> + We appear to have an invalid phone number on file for this person. + </b-notification> + + % if request.has_perm('people_profile.edit_person'): + <div class="is-pulled-right"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="plus" + @click="addPhoneInit()"> + Add Phone + </b-button> + </div> + <b-modal has-modal-card + :active.sync="editPhoneShowDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title"> + {{ phoneUUID ? "Edit Phone" : "Add Phone" }} + </p> + </header> + + <section class="modal-card-body"> + <b-field grouped> + + <b-field label="Type" expanded> + <b-select v-model="phoneType" expanded> + <option v-for="option in phoneTypeOptions" + :key="option.value" + :value="option.value"> + {{ option.label }} + </option> + </b-select> + </b-field> + + <b-field label="Number" expanded> + <b-input v-model.trim="phoneNumber" + ref="editPhoneInput"> + </b-input> + </b-field> + </b-field> + + <b-field label="Preferred?"> + <b-checkbox v-model="phonePreferred"> + </b-checkbox> + </b-field> + + </section> + + <footer class="modal-card-foot"> + <b-button type="is-primary" + @click="editPhoneSave()" + :disabled="editPhoneSaveDisabled" + icon-pack="fas" + icon-left="save"> + {{ editPhoneSaveText }} + </b-button> + <b-button @click="editPhoneShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </b-modal> + % endif + + <b-table :data="person.phones"> + <template slot-scope="props"> + + <b-table-column field="preference" label="Preferred"> + {{ props.row.preferred ? "Yes" : "" }} + </b-table-column> + + <b-table-column field="type" label="Type"> + {{ props.row.type }} + </b-table-column> + + <b-table-column field="number" label="Number"> + {{ props.row.number }} + </b-table-column> + + % if request.has_perm('people_profile.edit_person'): + <b-table-column label="Actions"> + <a href="#" @click.prevent="editPhoneInit(props.row)"> + <i class="fas fa-edit"></i> + Edit + </a> + <a href="#" @click.prevent="deletePhone(props.row)" + class="has-text-danger"> + <i class="fas fa-trash"></i> + Delete + </a> + <a href="#" @click.prevent="setPreferredPhone(props.row)" + v-if="!props.row.preferred"> + <i class="fas fa-star"></i> + Set Preferred + </a> + </b-table-column> + % endif + + </template> + </b-table> + + </div> + </div> + </div> +</%def> + +<%def name="render_personal_email_card()"> + <div class="card personal"> + <header class="card-header"> + <p class="card-header-title">Email(s)</p> + </header> + <div class="card-content"> + <div class="content"> + + % if request.has_perm('people_profile.edit_person'): + <div class="is-pulled-right"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="plus" + @click="addEmailInit()"> + Add Email + </b-button> + </div> + <b-modal has-modal-card + :active.sync="editEmailShowDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title"> + {{ emailUUID ? "Edit Email" : "Add Email" }} + </p> + </header> + + <section class="modal-card-body"> + <b-field grouped> + + <b-field label="Type" expanded> + <b-select v-model="emailType" expanded> + <option v-for="option in emailTypeOptions" + :key="option.value" + :value="option.value"> + {{ option.label }} + </option> + </b-select> + </b-field> + + <b-field label="Address" expanded> + <b-input v-model.trim="emailAddress" + ref="editEmailInput"> + </b-input> + </b-field> + + </b-field> + + <b-field v-if="!emailUUID" + label="Preferred?"> + <b-checkbox v-model="emailPreferred"> + </b-checkbox> + </b-field> + + <b-field v-if="emailUUID" + label="Invalid?"> + <b-checkbox v-model="emailInvalid" + :type="emailInvalid ? 'is-danger': null"> + </b-checkbox> + </b-field> + + </section> + + <footer class="modal-card-foot"> + <b-button type="is-primary" + @click="editEmailSave()" + :disabled="editEmailSaveDisabled" + icon-pack="fas" + icon-left="save"> + {{ editEmailSaveText }} + </b-button> + <b-button @click="editEmailShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </b-modal> + % endif + + <b-table :data="person.emails"> + <template slot-scope="props"> + + <b-table-column field="preference" label="Preferred"> + {{ props.row.preferred ? "Yes" : "" }} + </b-table-column> + + <b-table-column field="type" label="Type"> + {{ props.row.type }} + </b-table-column> + + <b-table-column field="address" label="Address"> + {{ props.row.address }} + </b-table-column> + + <b-table-column field="invalid" label="Invalid"> + <span v-if="props.row.invalid" class="has-text-danger">Yes</span> + </b-table-column> + + % if request.has_perm('people_profile.edit_person'): + <b-table-column label="Actions"> + <a href="#" @click.prevent="editEmailInit(props.row)"> + <i class="fas fa-edit"></i> + Edit + </a> + <a href="#" @click.prevent="deleteEmail(props.row)" + class="has-text-danger"> + <i class="fas fa-trash"></i> + Delete + </a> + <a href="#" @click.prevent="setPreferredEmail(props.row)" + v-if="!props.row.preferred"> + <i class="fas fa-star"></i> + Set Preferred + </a> + </b-table-column> + % endif + + </template> + </b-table> + + </div> + </div> + </div> +</%def> + +<%def name="render_personal_tab_template()"> + <script type="text/x-template" id="personal-tab-template"> + <div style="display: flex; justify-content: space-between;"> + + <div style="flex-grow: 1; margin-right: 1rem;"> + + ${self.render_personal_name_card()} + + ${self.render_personal_address_card()} + + ${self.render_personal_phone_card()} + + ${self.render_personal_email_card()} + + </div> + + <div> + % if request.has_perm('people.view'): + ${h.link_to("View Person", url('people.view', uuid=person.uuid), class_='button')} + % endif + </div> + + </div> + </script> +</%def> + +<%def name="render_personal_tab()"> + <b-tab-item label="Personal" + icon-pack="fas" + icon="check"> + <personal-tab :person="person" + :member="member" + :max-lengths="maxLengths" + :phone-type-options="phoneTypeOptions" + :email-type-options="emailTypeOptions" + @person-updated="personUpdated" + @change-content-title="changeContentTitle"> + </personal-tab> + </b-tab-item> +</%def> + <%def name="render_member_tab()"> <b-tab-item label="Member" icon-pack="fas" :icon="members.length ? 'check' : null"> @@ -439,137 +912,7 @@ <div> <b-tabs v-model="activeTab" type="is-boxed"> - <b-tab-item label="Personal" icon="check" icon-pack="fas"> - <div style="display: flex; justify-content: space-between;"> - - <div> - - <div class="field-wrapper first_name"> - <div class="field-row"> - <label>First Name</label> - <div class="field"> - ${person.first_name} - </div> - </div> - </div> - - <div class="field-wrapper middle_name"> - <div class="field-row"> - <label>Middle Name</label> - <div class="field"> - ${person.middle_name} - </div> - </div> - </div> - - <div class="field-wrapper last_name"> - <div class="field-row"> - <label>Last Name</label> - <div class="field"> - ${person.last_name} - </div> - </div> - </div> - - <div class="field-wrapper street"> - <div class="field-row"> - <label>Street 1</label> - <div class="field"> - ${person.address.street if person.address else ''} - </div> - </div> - </div> - - <div class="field-wrapper street2"> - <div class="field-row"> - <label>Street 2</label> - <div class="field"> - ${person.address.street2 if person.address else ''} - </div> - </div> - </div> - - <div class="field-wrapper city"> - <div class="field-row"> - <label>City</label> - <div class="field"> - ${person.address.city if person.address else ''} - </div> - </div> - </div> - - <div class="field-wrapper state"> - <div class="field-row"> - <label>State</label> - <div class="field"> - ${person.address.state if person.address else ''} - </div> - </div> - </div> - - <div class="field-wrapper zipcode"> - <div class="field-row"> - <label>Zipcode</label> - <div class="field"> - ${person.address.zipcode if person.address else ''} - </div> - </div> - </div> - - % if person.phones: - % for phone in person.phones: - <div class="field-wrapper"> - <div class="field-row"> - <label>Phone Number</label> - <div class="field"> - ${phone.number} (type: ${phone.type}) - </div> - </div> - </div> - % endfor - % else: - <div class="field-wrapper"> - <div class="field-row"> - <label>Phone Number</label> - <div class="field"> - (none on file) - </div> - </div> - </div> - % endif - - % if person.emails: - % for email in person.emails: - <div class="field-wrapper"> - <div class="field-row"> - <label>Email Address</label> - <div class="field"> - ${email.address} (type: ${email.type}) - </div> - </div> - </div> - % endfor - % else: - <div class="field-wrapper"> - <div class="field-row"> - <label>Email Address</label> - <div class="field"> - (none on file) - </div> - </div> - </div> - % endif - - </div> - - <div> - % if request.has_perm('people.view'): - ${h.link_to("View Person", url('people.view', uuid=person.uuid), class_='button')} - % endif - </div> - - </div> - </b-tab-item><!-- Personal --> + ${self.render_personal_tab()} ${self.render_customer_tab()} @@ -641,10 +984,384 @@ <%def name="render_this_page_template()"> ${parent.render_this_page_template()} + ${self.render_personal_tab_template()} ${self.render_employee_tab_template()} ${self.render_profile_info_template()} </%def> +<%def name="declare_personal_tab_vars()"> + <script type="text/javascript"> + + let PersonalTabData = { + + editNameShowDialog: false, + personFirstName: null, + personMiddleName: null, + personLastName: null, + + editAddressShowDialog: false, + personStreet1: null, + personStreet2: null, + personCity: null, + personState: null, + personZipcode: null, + personInvalidAddress: false, + + editPhoneShowDialog: false, + phoneUUID: null, + phoneType: null, + phoneNumber: null, + phonePreferred: false, + savingPhone: false, + + editEmailShowDialog: false, + emailUUID: null, + emailType: null, + emailAddress: null, + emailPreferred: null, + emailInvalid: false, + editEmailSaving: false, + } + + let PersonalTab = { + template: '#personal-tab-template', + mixins: [SubmitMixin], + props: { + person: Object, + member: Object, + phoneTypeOptions: Array, + emailTypeOptions: Array, + maxLengths: Object, + }, + computed: { + % if request.has_perm('people_profile.edit_person'): + editNameSaveDisabled: function() { + + // first and last name are required + if (!this.personFirstName || !this.personLastName) { + return true + } + + // otherwise don't disable; let user save + return false + }, + + editAddressSaveDisabled: function() { + + // TODO: should require anything here? + + // otherwise don't disable; let user save + return false + }, + + editPhoneSaveText() { + if (this.savingPhone) { + return "Working..." + } + return "Save" + }, + + editPhoneSaveDisabled: function() { + if (this.savingPhone) { + return true + } + + // phone type is required + if (!this.phoneType) { + return true + } + + // phone number is required + if (!this.phoneNumber) { + return true + } + + // otherwise don't disable; let user save + return false + }, + + editEmailSaveText() { + if (this.editEmailSaving) { + return "Working, please wait..." + } + return "Save" + }, + + editEmailSaveDisabled: function() { + + // disable if currently submitting form + if (this.editEmailSaving) { + return true + } + + // email type is required + if (!this.emailType) { + return true + } + + // email address is required + if (!this.emailAddress) { + return true + } + + // otherwise don't disable; let user save + return false + }, + % endif + }, + methods: { + + changeContentTitle(newTitle) { + this.$emit('change-content-title', newTitle) + }, + + % if request.has_perm('people_profile.edit_person'): + + editNameAllowed() { + return true + }, + + editNameInit() { + this.personFirstName = this.person.first_name + this.personMiddleName = this.person.middle_name + this.personLastName = this.person.last_name + this.editNameShowDialog = true + }, + + editNameSave() { + let url = '${url('people.profile_edit_name', uuid=person.uuid)}' + + let params = { + first_name: this.personFirstName, + middle_name: this.personMiddleName, + last_name: this.personLastName, + } + + let that = this + this.submitData(url, params, function(response) { + that.$emit('person-updated', response.data.person) + that.editNameShowDialog = false + // TODO: not sure this is standard upstream, or just in bespoke? + if (response.data.dynamic_content_title) { + that.$emit('change-content-title', response.data.dynamic_content_title) + } + }) + }, + + editAddressInit() { + let address = this.person.address + this.personStreet1 = address ? address.street : null + this.personStreet2 = address ? address.street2 : null + this.personCity = address ? address.city : null + this.personState = address ? address.state : null + this.personZipcode = address ? address.zipcode : null + this.personInvalidAddress = address ? address.invalid : false + this.editAddressShowDialog = true + }, + + editAddressSave() { + let url = '${url('people.profile_edit_address', uuid=person.uuid)}' + + let params = { + street: this.personStreet1, + street2: this.personStreet2, + city: this.personCity, + state: this.personState, + zipcode: this.personZipcode, + invalid: this.personInvalidAddress, + } + + let that = this + this.submitData(url, params, function(response) { + that.$emit('person-updated', response.data.person) + that.editAddressShowDialog = false + }) + }, + + addPhoneInit() { + this.editPhoneInit({ + uuid: null, + type: 'Home', + number: null, + preferred: false, + }) + }, + + editPhoneInit(phone) { + this.phoneUUID = phone.uuid + this.phoneType = phone.type + this.phoneNumber = phone.number + this.phonePreferred = phone.preferred + this.editPhoneShowDialog = true + this.$nextTick(function() { + this.$refs.editPhoneInput.focus() + }) + }, + + editPhoneSave() { + this.savingPhone = true + + let url + let params = { + phone_number: this.phoneNumber, + phone_type: this.phoneType, + phone_preferred: this.phonePreferred, + } + + if (this.phoneUUID) { + url = '${url('people.profile_update_phone', uuid=person.uuid)}' + params.phone_uuid = this.phoneUUID + } else { + url = '${url('people.profile_add_phone', uuid=person.uuid)}' + } + + let that = this + this.submitData(url, params, function(response) { + that.$emit('person-updated', response.data.person) + that.editPhoneShowDialog = false + that.savingPhone = false + }) + }, + + deletePhone(phone) { + let url = '${url('people.profile_delete_phone', uuid=person.uuid)}' + + let params = { + phone_uuid: phone.uuid, + } + + let that = this + this.submitData(url, params, function(response) { + that.$emit('person-updated', response.data.person) + that.$buefy.toast.open({ + message: "Phone number was deleted.", + type: 'is-info', + duration: 3000, // 3 seconds + }) + }) + }, + + setPreferredPhone(phone) { + let url = '${url('people.profile_set_preferred_phone', uuid=person.uuid)}' + + let params = { + phone_uuid: phone.uuid, + } + + let that = this + this.submitData(url, params, function(response) { + that.$emit('person-updated', response.data.person) + that.$buefy.toast.open({ + message: "Phone preference updated!", + type: 'is-info', + duration: 3000, // 3 seconds + }) + }) + }, + + addEmailInit() { + this.editEmailInit({ + uuid: null, + type: 'Home', + address: null, + invalid: false, + preferred: false, + }) + }, + + editEmailInit(email) { + this.emailUUID = email.uuid + this.emailType = email.type + this.emailAddress = email.address + this.emailInvalid = email.invalid + this.emailPreferred = email.preferred + this.editEmailShowDialog = true + this.$nextTick(function() { + this.$refs.editEmailInput.focus() + }) + }, + + editEmailSave() { + this.editEmailSaving = true + + let url = null + let params = { + email_address: this.emailAddress, + email_type: this.emailType, + } + + if (this.emailUUID) { + url = '${url('people.profile_update_email', uuid=person.uuid)}' + params.email_uuid = this.emailUUID + params.email_invalid = this.emailInvalid + } else { + url = '${url('people.profile_add_email', uuid=person.uuid)}' + params.email_preferred = this.emailPreferred + } + + let that = this + this.submitData(url, params, function(response) { + that.$emit('person-updated', response.data.person) + that.editEmailShowDialog = false + that.editEmailSaving = false + }, function(error) { + that.editEmailSaving = false + }) + }, + + deleteEmail(email) { + let url = '${url('people.profile_delete_email', uuid=person.uuid)}' + + let params = { + email_uuid: email.uuid, + } + + let that = this + this.submitData(url, params, function(response) { + that.$emit('person-updated', response.data.person) + that.$buefy.toast.open({ + message: "Email address was deleted.", + type: 'is-info', + duration: 3000, // 3 seconds + }) + }) + }, + + setPreferredEmail(email) { + let url = '${url('people.profile_set_preferred_email', uuid=person.uuid)}' + + let params = { + email_uuid: email.uuid, + } + + let that = this + this.submitData(url, params, function(response) { + that.$emit('person-updated', response.data.person) + that.$buefy.toast.open({ + message: "Email preference updated!", + type: 'is-info', + duration: 3000, // 3 seconds + }) + }) + }, + + % endif + }, + } + + </script> +</%def> + +<%def name="make_personal_tab_component()"> + ${self.declare_personal_tab_vars()} + <script type="text/javascript"> + + PersonalTab.data = function() { return PersonalTabData } + Vue.component('personal-tab', PersonalTab) + + </script> +</%def> + <%def name="set_employee_data()"> <script type="text/javascript"> @@ -907,28 +1624,44 @@ </script> </%def> -<%def name="make_profile_info_component()"> +<%def name="declare_profile_info_vars()"> <script type="text/javascript"> - const ProfileInfo = { + let ProfileInfoData = { + activeTab: 0, + person: ${json.dumps(person_data)|n}, + customers: ${json.dumps(customers_data)|n}, + member: null, // TODO + members: ${json.dumps(members_data)|n}, + employee: EmployeeData, + employeeHistory: EmployeeHistoryData, + phoneTypeOptions: ${json.dumps(phone_type_options)|n}, + emailTypeOptions: ${json.dumps(email_type_options)|n}, + maxLengths: ${json.dumps(max_lengths)|n}, + } + + let ProfileInfo = { template: '#profile-info-template', - data() { - return { - activeTab: 0, - person: ${json.dumps(person_data)|n}, - customers: ${json.dumps(customers_data)|n}, - members: ${json.dumps(members_data)|n}, - employee: EmployeeData, - employeeHistory: EmployeeHistoryData, - } - }, + computed: {}, methods: { + personUpdated(person) { + this.person = person + }, changeContentTitle(newTitle) { this.$emit('change-content-title', newTitle) }, }, } + </script> +</%def> + +<%def name="make_profile_info_component()"> + ${self.declare_profile_info_vars()} + <script type="text/javascript"> + + ProfileInfo.data = function() { return ProfileInfoData } + Vue.component('profile-info', ProfileInfo) </script> @@ -942,11 +1675,53 @@ this.$emit('change-content-title', newTitle) } + var SubmitMixin = { + data() { + return { + csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}, + } + }, + + methods: { + submitData(url, params, success, failure) { + let headers = { + 'X-CSRF-TOKEN': this.csrftoken, + } + this.$http.post(url, params, {headers: headers}).then((response) => { + if (response.data.success) { + if (success) { + success(response) + } + } else { + this.$buefy.toast.open({ + message: "Save failed: " + (response.data.error || "(unknown error)"), + type: 'is-danger', + duration: 4000, // 4 seconds + }) + if (failure) { + failure() + } + } + }).catch((error) => { + this.$buefy.toast.open({ + message: "Save failed: (unknown error)", + type: 'is-danger', + duration: 4000, // 4 seconds + }) + if (failure) { + failure() + } + }) + }, + }, + } + </script> </%def> <%def name="make_this_page_component()"> ${parent.make_this_page_component()} + ${self.make_personal_tab_component()} ${self.set_employee_data()} ${self.make_employee_tab_component()} ${self.make_profile_info_component()} diff --git a/tailbone/views/people.py b/tailbone/views/people.py index a5adb399..0967147d 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -27,14 +27,16 @@ Person Views from __future__ import unicode_literals, absolute_import import datetime +import logging import six import sqlalchemy as sa from sqlalchemy import orm from rattail.db import model, api +from rattail.db.util import maxlen from rattail.time import localtime -from rattail.util import OrderedDict +from rattail.util import OrderedDict, simple_error import colander from pyramid.httpexceptions import HTTPFound, HTTPNotFound @@ -44,6 +46,9 @@ from tailbone import forms, grids from tailbone.views import MasterView +log = logging.getLogger(__name__) + + class PersonView(MasterView): """ Master view for the Person class. @@ -430,6 +435,9 @@ class PersonView(MasterView): 'instance_title': self.get_instance_title(person), 'today': localtime(self.rattail_config).date(), 'person_data': self.get_context_person(person), + 'phone_type_options': self.get_phone_type_options(), + 'email_type_options': self.get_email_type_options(), + 'max_lengths': self.get_max_lengths(), 'customers_data': self.get_context_customers(person), 'members_data': self.get_context_members(person), 'employee': employee, @@ -442,17 +450,65 @@ class PersonView(MasterView): template = 'view_profile_buefy' if use_buefy else 'view_profile' return self.render_to_response(template, context) - def get_context_person(self, person): + def get_max_lengths(self): + model = self.model return { + 'address_street': maxlen(model.PersonMailingAddress.street), + 'address_street2': maxlen(model.PersonMailingAddress.street2), + 'address_city': maxlen(model.PersonMailingAddress.city), + 'address_state': maxlen(model.PersonMailingAddress.state), + 'address_zipcode': maxlen(model.PersonMailingAddress.zipcode), + } + + def get_phone_type_options(self): + """ + Returns a list of "phone type" options, for use in dropdown. + """ + # TODO: should probably define this list somewhere else + phone_types = [ + "Home", + "Mobile", + "Work", + "Other", + "Fax", + ] + return [{'value': typ, 'label': typ} + for typ in phone_types] + + def get_email_type_options(self): + """ + Returns a list of "email type" options, for use in dropdown. + """ + # TODO: should probably define this list somewhere else + email_types = [ + "Home", + "Work", + "Other", + ] + return [{'value': typ, 'label': typ} + for typ in email_types] + + def get_context_person(self, person): + + context = { 'uuid': person.uuid, 'first_name': person.first_name, + 'middle_name': person.middle_name, 'last_name': person.last_name, 'display_name': person.display_name, 'view_url': self.get_action_url('view', person), 'view_profile_url': self.get_action_url('view_profile', person), + 'phones': self.get_context_phones(person), + 'emails': self.get_context_emails(person), } + if person.address: + context['address'] = self.get_context_address(person.address) + + return context + def get_context_address(self, address): + person = address.person return { 'uuid': address.uuid, 'street': address.street, @@ -461,6 +517,7 @@ class PersonView(MasterView): 'state': address.state, 'zipcode': address.zipcode, 'display': six.text_type(address), + 'invalid': self.handler.address_is_invalid(person, address), } def get_context_customers(self, person): @@ -547,6 +604,270 @@ class PersonView(MasterView): customer = handler.ensure_customer(person) return customer + def profile_edit_name(self): + """ + View which allows a person's name to be updated. + """ + person = self.get_instance() + data = dict(self.request.json_body) + + self.handler.update_names(person, + first=data['first_name'], + middle=data['middle_name'], + last=data['last_name']) + + self.Session.flush() + return { + 'success': True, + 'person': self.get_context_person(person), + } + + def get_context_phones(self, person): + data = [] + for phone in person.phones: + data.append({ + 'uuid': phone.uuid, + 'type': phone.type, + 'number': phone.number, + 'preferred': phone.preferred, + 'preference': phone.preference, + }) + return data + + def profile_add_phone(self): + """ + View which adds a new phone number for the person. + """ + person = self.get_instance() + data = dict(self.request.json_body) + + try: + phone = self.handler.add_phone(person, data['phone_number'], + type=data['phone_type'], + preferred=data['phone_preferred']) + except Exception as error: + log.warning("failed to add phone", exc_info=True) + return {'error': simple_error(error)} + + self.Session.flush() + return { + 'success': True, + 'person': self.get_context_person(person), + } + + def profile_update_phone(self): + """ + View which updates a phone number for the person. + """ + person = self.get_instance() + data = dict(self.request.json_body) + + phone = self.Session.query(model.PersonPhoneNumber).get(data['phone_uuid']) + if not phone: + return {'error': "Phone not found."} + + kwargs = { + 'number': data['phone_number'], + 'type': data['phone_type'], + } + if 'phone_preferred' in data: + kwargs['preferred'] = data['phone_preferred'] + + try: + phone = self.handler.update_phone(person, phone, **kwargs) + except Exception as error: + log.warning("failed to update phone", exc_info=True) + return {'error': simple_error(error)} + + self.Session.flush() + return { + 'success': True, + 'person': self.get_context_person(person), + } + + def profile_delete_phone(self): + """ + View which allows a person's phone number to be deleted. + """ + person = self.get_instance() + data = dict(self.request.json_body) + + # validate phone + phone = self.Session.query(model.PersonPhoneNumber).get(data['phone_uuid']) + if not phone: + return {'error': "Phone not found."} + if phone not in person.phones: + return {'error': "Phone does not belong to this person."} + + # remove phone + person.remove_phone(phone) + + self.Session.flush() + return { + 'success': True, + 'person': self.get_context_person(person), + } + + def profile_set_preferred_phone(self): + """ + View which allows a person's "preferred" phone to be set. + """ + person = self.get_instance() + data = dict(self.request.json_body) + + # validate phone + phone = self.Session.query(model.PersonPhoneNumber).get(data['phone_uuid']) + if not phone: + return {'error': "Phone not found."} + if phone not in person.phones: + return {'error': "Phone does not belong to this person."} + + # update phone preference + person.set_primary_phone(phone) + + self.Session.flush() + return { + 'success': True, + 'person': self.get_context_person(person), + } + + def get_context_emails(self, person): + data = [] + for email in person.emails: + data.append({ + 'uuid': email.uuid, + 'type': email.type, + 'address': email.address, + 'invalid': email.invalid, + 'preferred': email.preferred, + 'preference': email.preference, + }) + return data + + def profile_add_email(self): + """ + View which adds a new email address for the person. + """ + person = self.get_instance() + data = dict(self.request.json_body) + + kwargs = { + 'type': data['email_type'], + 'invalid': False, + } + if 'email_preferred' in data: + kwargs['preferred'] = data['email_preferred'] + + try: + email = self.handler.add_email(person, data['email_address'], **kwargs) + except Exception as error: + log.warning("failed to add email", exc_info=True) + return {'error': simple_error(error)} + + self.Session.flush() + return { + 'success': True, + 'person': self.get_context_person(person), + } + + def profile_update_email(self): + """ + View which updates an email address for the person. + """ + person = self.get_instance() + data = dict(self.request.json_body) + + email = self.Session.query(model.PersonEmailAddress).get(data['email_uuid']) + if not email: + return {'error': "Email not found."} + + try: + email = self.handler.update_email(person, email, + address=data['email_address'], + type=data['email_type'], + invalid=data['email_invalid']) + except Exception as error: + log.warning("failed to add email", exc_info=True) + return {'error': simple_error(error)} + + self.Session.flush() + return { + 'success': True, + 'person': self.get_context_person(person), + } + + def profile_delete_email(self): + """ + View which allows a person's email address to be deleted. + """ + person = self.get_instance() + data = dict(self.request.json_body) + + # validate email + email = self.Session.query(model.PersonEmailAddress).get(data['email_uuid']) + if not email: + return {'error': "Email not found."} + if email not in person.emails: + return {'error': "Email does not belong to this person."} + + # remove email + person.remove_email(email) + + self.Session.flush() + + return { + 'success': True, + 'person': self.get_context_person(person), + } + + def profile_set_preferred_email(self): + """ + View which allows a person's "preferred" email to be set. + """ + person = self.get_instance() + data = dict(self.request.json_body) + + # validate email + email = self.Session.query(model.PersonEmailAddress).get(data['email_uuid']) + if not email: + return {'error': "Email not found."} + if email not in person.emails: + return {'error': "Email does not belong to this person."} + + # update email preference + person.set_primary_email(email) + + self.Session.flush() + return { + 'success': True, + 'person': self.get_context_person(person), + } + + def profile_edit_address(self): + """ + View which allows a person's mailing address to be updated. + """ + person = self.get_instance() + 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']) + + self.Session.flush() + return { + 'success': True, + 'person': self.get_context_person(person), + } + def profile_start_employee(self): """ View which will cause the person to start being an employee. @@ -786,6 +1107,101 @@ class PersonView(MasterView): config.add_view(cls, attr='view_profile', route_name='{}.view_profile'.format(route_prefix), permission='{}.view_profile'.format(permission_prefix)) + # profile - edit personal details + config.add_tailbone_permission('people_profile', + 'people_profile.edit_person', + "Edit the Personal details") + + # profile - edit name + config.add_route('{}.profile_edit_name'.format(route_prefix), + '{}/profile/edit-name'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='profile_edit_name', + route_name='{}.profile_edit_name'.format(route_prefix), + renderer='json', + permission='people_profile.edit_person') + + # profile - add phone + config.add_route('{}.profile_add_phone'.format(route_prefix), + '{}/profile/add-phone'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='profile_add_phone', + route_name='{}.profile_add_phone'.format(route_prefix), + renderer='json', + permission='people_profile.edit_person') + + # profile - update phone + config.add_route('{}.profile_update_phone'.format(route_prefix), + '{}/profile/update-phone'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='profile_update_phone', + route_name='{}.profile_update_phone'.format(route_prefix), + renderer='json', + permission='people_profile.edit_person') + + # profile - delete phone + config.add_route('{}.profile_delete_phone'.format(route_prefix), + '{}/profile/delete-phone'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='profile_delete_phone', + route_name='{}.profile_delete_phone'.format(route_prefix), + renderer='json', + permission='people_profile.edit_person') + + # profile - set preferred phone + config.add_route('{}.profile_set_preferred_phone'.format(route_prefix), + '{}/profile/set-preferred-phone'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='profile_set_preferred_phone', + route_name='{}.profile_set_preferred_phone'.format(route_prefix), + renderer='json', + permission='people_profile.edit_person') + + # profile - add email + config.add_route('{}.profile_add_email'.format(route_prefix), + '{}/profile/add-email'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='profile_add_email', + route_name='{}.profile_add_email'.format(route_prefix), + renderer='json', + permission='people_profile.edit_person') + + # profile - update email + config.add_route('{}.profile_update_email'.format(route_prefix), + '{}/profile/update-email'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='profile_update_email', + route_name='{}.profile_update_email'.format(route_prefix), + renderer='json', + permission='people_profile.edit_person') + + # profile - delete email + config.add_route('{}.profile_delete_email'.format(route_prefix), + '{}/profile/delete-email'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='profile_delete_email', + route_name='{}.profile_delete_email'.format(route_prefix), + renderer='json', + permission='people_profile.edit_person') + + # profile - set preferred email + config.add_route('{}.profile_set_preferred_email'.format(route_prefix), + '{}/profile/set-preferred-email'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='profile_set_preferred_email', + route_name='{}.profile_set_preferred_email'.format(route_prefix), + renderer='json', + permission='people_profile.edit_person') + + # profile - edit address + config.add_route('{}.profile_edit_address'.format(route_prefix), + '{}/profile/edit-address'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='profile_edit_address', + route_name='{}.profile_edit_address'.format(route_prefix), + renderer='json', + permission='people_profile.edit_person') + # profile - start employee config.add_route('{}.profile_start_employee'.format(route_prefix), '{}/profile/start-employee'.format(instance_url_prefix), request_method='POST') From e7fb1559f5472789f29b49e88aa7f39780ed7b8a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 5 Oct 2021 08:25:33 -0400 Subject: [PATCH 0424/1681] Refactor the Employee tab of profile view, per better patterns learned some things from the Personal tab overhaul --- .../templates/people/view_profile_buefy.mako | 380 +++++++----------- tailbone/views/people.py | 6 +- 2 files changed, 161 insertions(+), 225 deletions(-) diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako index 114682bd..9ef956a9 100644 --- a/tailbone/templates/people/view_profile_buefy.mako +++ b/tailbone/templates/people/view_profile_buefy.mako @@ -452,20 +452,19 @@ </div> </%def> +<%def name="render_personal_tab_cards()"> + ${self.render_personal_name_card()} + ${self.render_personal_address_card()} + ${self.render_personal_phone_card()} + ${self.render_personal_email_card()} +</%def> + <%def name="render_personal_tab_template()"> <script type="text/x-template" id="personal-tab-template"> <div style="display: flex; justify-content: space-between;"> <div style="flex-grow: 1; margin-right: 1rem;"> - - ${self.render_personal_name_card()} - - ${self.render_personal_address_card()} - - ${self.render_personal_phone_card()} - - ${self.render_personal_email_card()} - + ${self.render_personal_tab_cards()} </div> <div> @@ -670,7 +669,7 @@ <div style="flex-grow: 1;"> - <div v-if="employee.exists"> + <div v-if="employee.uuid"> <b-field horizontal label="Employee ID"> <div class="level"> @@ -721,22 +720,22 @@ </b-field> <b-field horizontal label="Employee Status"> - <span>{{ employee.current ? "current" : "former" }}</span> + <span>{{ employee.status_display }}</span> </b-field> <b-field horizontal label="Start Date"> - <span>{{ employee.startDate }}</span> + <span>{{ employee.start_date }}</span> </b-field> <b-field horizontal label="End Date"> - <span>{{ employee.endDate }}</span> + <span>{{ employee.end_date }}</span> </b-field> <br /> <p><strong>Employee History</strong></p> <br /> - <b-table :data="employeeHistory.data"> + <b-table :data="employeeHistory"> <template slot-scope="props"> <b-table-column field="start_date" label="Start Date"> @@ -761,7 +760,7 @@ </div> - <p v-if="!employee.exists"> + <p v-if="!employee.uuid"> ${person} has never been an employee. </p> @@ -774,7 +773,7 @@ <b-button v-if="!employee.current" type="is-primary" - @click="showStartEmployee()"> + @click="startEmployeeInit()"> ${person} is now an Employee </b-button> @@ -785,7 +784,7 @@ </b-button> <b-modal has-modal-card - :active.sync="showStartEmployeeDialog"> + :active.sync="startEmployeeShowDialog"> <div class="modal-card"> <header class="modal-card-head"> @@ -802,7 +801,7 @@ </section> <footer class="modal-card-foot"> - <b-button @click="showStartEmployeeDialog = false"> + <b-button @click="startEmployeeShowDialog = false"> Cancel </b-button> <once-button type="is-primary" @@ -882,8 +881,8 @@ % endif % if request.has_perm('employees.view'): - <b-button v-if="employee.viewURL" - tag="a" :href="employee.viewURL"> + <b-button v-if="employee.view_url" + tag="a" :href="employee.view_url"> View Employee </b-button> % endif @@ -902,81 +901,85 @@ :icon="employee.current ? 'check' : null"> <employee-tab :employee="employee" :employee-history="employeeHistory" + @employee-updated="employeeUpdated" + @employee-history-updated="employeeHistoryUpdated" @change-content-title="changeContentTitle"> </employee-tab> </b-tab-item> </%def> +<%def name="render_user_tab()"> + <b-tab-item label="User" ${'icon="check" icon-pack="fas"' if person.users else ''|n}> + % if person.users: + <p>${person} is associated with <strong>${len(person.users)}</strong> user account(s)</p> + <br /> + <div id="users-accordion"> + % for user in person.users: + + <b-collapse class="panel" + ## TODO: what's up with aria-id here? + ## aria-id="contentIdForA11y2" + > + + <div + slot="trigger" + class="panel-heading" + role="button" + ## TODO: what's up with aria-id here? + ## aria-controls="contentIdForA11y2" + > + <strong>${user.username}</strong> + </div> + + <div class="panel-block"> + + <div style="display: flex; justify-content: space-between; width: 100%;"> + + <div> + + <div class="field-wrapper id"> + <div class="field-row"> + <label>Username</label> + <div class="field"> + ${user.username} + </div> + </div> + </div> + + </div> + + <div> + % if request.has_perm('users.view'): + ${h.link_to("View User", url('users.view', uuid=user.uuid), class_='button')} + % endif + </div> + + </div> + + </div> + </b-collapse> + % endfor + </div> + + % else: + <p>${person} has never been a user.</p> + % endif + </b-tab-item><!-- User --> +</%def> + +<%def name="render_profile_tabs()"> + ${self.render_personal_tab()} + ${self.render_customer_tab()} + ${self.render_member_tab()} + ${self.render_employee_tab()} + ${self.render_user_tab()} +</%def> + <%def name="render_profile_info_template()"> <script type="text/x-template" id="profile-info-template"> <div> <b-tabs v-model="activeTab" type="is-boxed"> - - ${self.render_personal_tab()} - - ${self.render_customer_tab()} - - ${self.render_member_tab()} - - ${self.render_employee_tab()} - - <b-tab-item label="User" ${'icon="check" icon-pack="fas"' if person.users else ''|n}> - % if person.users: - <p>${person} is associated with <strong>${len(person.users)}</strong> user account(s)</p> - <br /> - <div id="users-accordion"> - % for user in person.users: - - <b-collapse class="panel" - ## TODO: what's up with aria-id here? - ## aria-id="contentIdForA11y2" - > - - <div - slot="trigger" - class="panel-heading" - role="button" - ## TODO: what's up with aria-id here? - ## aria-controls="contentIdForA11y2" - > - <strong>${user.username}</strong> - </div> - - <div class="panel-block"> - - <div style="display: flex; justify-content: space-between; width: 100%;"> - - <div> - - <div class="field-wrapper id"> - <div class="field-row"> - <label>Username</label> - <div class="field"> - ${user.username} - </div> - </div> - </div> - - </div> - - <div> - % if request.has_perm('users.view'): - ${h.link_to("View User", url('users.view', uuid=user.uuid), class_='button')} - % endif - </div> - - </div> - - </div> - </b-collapse> - % endfor - </div> - - % else: - <p>${person} has never been a user.</p> - % endif - </b-tab-item><!-- User --> - + ${self.render_profile_tabs()} </b-tabs> </div> </script> @@ -1362,58 +1365,37 @@ </script> </%def> -<%def name="set_employee_data()"> - <script type="text/javascript"> - - let EmployeeData = { - exists: ${json.dumps(bool(employee))|n}, - viewURL: ${json.dumps(employee_view_url)|n}, - current: ${json.dumps(bool(employee and employee.status == enum.EMPLOYEE_STATUS_CURRENT))|n}, - startDate: ${json.dumps(six.text_type(employee_history.start_date) if employee_history else None)|n}, - endDate: ${json.dumps(six.text_type(employee_history.end_date) if employee_history and employee_history.end_date else None)|n}, - id: ${json.dumps(employee.id if employee else None)|n}, - } - - let EmployeeHistoryData = { - data: ${json.dumps(employee_history_data)|n}, - } - - </script> -</%def> - <%def name="declare_employee_tab_vars()"> <script type="text/javascript"> + let EmployeeTabData = { + + startEmployeeShowDialog: false, + employeeID: null, + employeeStartDate: null, + showStopEmployeeDialog: false, + employeeEndDate: null, + employeeRevokeAccess: false, + showEditEmployeeHistoryDialog: false, + employeeHistoryUUID: null, + employeeHistoryStartDate: null, + employeeHistoryEndDate: null, + employeeHistoryEndDateRequired: false, + + % if request.has_perm('employees.edit'): + showEditEmployeeIDDialog: false, + newEmployeeID: null, + updatingEmployeeID: false, + % endif + } + let EmployeeTab = { template: '#employee-tab-template', + mixins: [SubmitMixin], props: { employee: Object, employeeHistory: Object, }, - data() { - return { - showStartEmployeeDialog: false, - employeeID: null, - employeeStartDate: null, - showStopEmployeeDialog: false, - employeeEndDate: null, - employeeRevokeAccess: false, - showEditEmployeeHistoryDialog: false, - employeeHistoryUUID: null, - employeeHistoryStartDate: null, - employeeHistoryEndDate: null, - employeeHistoryEndDateRequired: false, - - % if request.has_perm('employees.edit'): - showEditEmployeeIDDialog: false, - newEmployeeID: null, - updatingEmployeeID: false, - % endif - - ## 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}, - } - }, computed: { @@ -1452,26 +1434,11 @@ 'employee_id': this.newEmployeeID, } - let headers = { - ## TODO: should find a better way to handle CSRF token - 'X-CSRF-TOKEN': this.csrftoken, - } - - ## TODO: should find a better way to handle CSRF token - this.$http.post(url, params, {headers: headers}).then(({ data }) => { - if (data.success) { - this.employee.id = data.employee.id - this.showEditEmployeeIDDialog = false - this.updatingEmployeeID = false - } else { - this.$buefy.toast.open({ - message: "Save failed: " + data.error, - type: 'is-danger', - duration: 4000, // 4 seconds - }) - } - }, response => { - alert("Unexpected error occurred!") + let that = this + this.submitData(url, params, function(response) { + that.$emit('employee-updated', response.data.employee) + that.showEditEmployeeIDDialog = false + that.updatingEmployeeID = false }) }, @@ -1479,13 +1446,12 @@ % if request.has_perm('people_profile.toggle_employee'): - showStartEmployee() { - this.employeeID = this.employee.id - this.showStartEmployeeDialog = true + startEmployeeInit() { + this.employeeID = this.employee.id || null + this.startEmployeeShowDialog = true }, startEmployee() { - let url = '${url('people.profile_start_employee', uuid=person.uuid)}' let params = { @@ -1493,44 +1459,26 @@ start_date: this.employeeStartDate, } - let headers = { - ## TODO: should find a better way to handle CSRF token - 'X-CSRF-TOKEN': this.csrftoken, - } - - ## TODO: should find a better way to handle CSRF token - this.$http.post(url, params, {headers: headers}).then(({ data }) => { - if (data.success) { - this.startEmployeeSuccess(data) - } else { - this.$buefy.toast.open({ - message: "Save failed: " + data.error, - type: 'is-danger', - duration: 4000, // 4 seconds - }) - } - }, response => { - alert("Unexpected error occurred!") + let that = this + this.submitData(url, params, function(response) { + that.startEmployeeSuccess(response.data) }) }, startEmployeeSuccess(data) { - this.employee.exists = true - this.employee.id = data.employee_id - this.employee.viewURL = data.employee_view_url - this.employee.current = true - this.employee.startDate = data.start_date - this.employee.endDate = null - this.employeeHistory.data = data.employee_history_data - // this.customerNumber = data.customer_number - this.employeeEndDate = null + this.$emit('employee-updated', data.employee) + this.$emit('employee-history-updated', data.employee_history_data) this.$emit('change-content-title', data.dynamic_content_title) - // this.posTabStale = true - this.showStartEmployeeDialog = false + + // let derived component do more here if needed + this.startEmployeeSuccessExtra(data) + + this.startEmployeeShowDialog = false }, - endEmployee() { + startEmployeeSuccessExtra(data) {}, + endEmployee() { let url = '${url('people.profile_end_employee', uuid=person.uuid)}' let params = { @@ -1538,38 +1486,25 @@ revoke_access: this.employeeRevokeAccess, } - let headers = { - ## TODO: should find a better way to handle CSRF token - 'X-CSRF-TOKEN': this.csrftoken, - } - - ## TODO: should find a better way to handle CSRF token - this.$http.post(url, params, {headers: headers}).then(({ data }) => { - if (data.success) { - this.endEmployeeSuccess(data) - } else { - this.$buefy.toast.open({ - message: "Save failed: " + data.error, - type: 'is-danger', - duration: 4000, // 4 seconds - }) - } - }, response => { - alert("Unexpected error occurred!") + let that = this + this.submitData(url, params, function(response) { + that.endEmployeeSuccess(response.data) }) }, endEmployeeSuccess(data) { - this.employee.current = false - this.employee.endDate = data.end_date - this.employeeHistory.data = data.employee_history_data - this.employeeStartDate = null + this.$emit('employee-updated', data.employee) + this.$emit('employee-history-updated', data.employee_history_data) this.$emit('change-content-title', data.dynamic_content_title) - // this.memberTabStale = true - // this.posTabStale = true + + // let derived component do more here if needed + this.startEmployeeSuccessExtra(data) + this.showStopEmployeeDialog = false }, + endEmployeeSuccessExtra(data) {}, + % endif % if request.has_perm('people_profile.edit_employee_history'): @@ -1583,7 +1518,6 @@ }, saveEmployeeHistory() { - let url = '${url('people.profile_edit_employee_history', uuid=person.uuid)}' let params = { @@ -1592,19 +1526,11 @@ end_date: this.employeeHistoryEndDate, } - let headers = { - ## TODO: should find a better way to handle CSRF token - 'X-CSRF-TOKEN': this.csrftoken, - } - - ## TODO: should find a better way to handle CSRF token - this.$http.post(url, params, {headers: headers}).then(({ data }) => { - if (data.success) { - this.employee.startDate = data.start_date - this.employee.endDate = data.end_date - this.employeeHistory.data = data.employee_history_data - } - this.showEditEmployeeHistoryDialog = false + let that = this + this.submitData(url, params, function(response) { + that.$emit('employee-updated', response.data.employee) + that.$emit('employee-history-updated', response.data.employee_history_data) + that.showEditEmployeeHistoryDialog = false }) }, @@ -1619,6 +1545,7 @@ ${self.declare_employee_tab_vars()} <script type="text/javascript"> + EmployeeTab.data = function() { return EmployeeTabData } Vue.component('employee-tab', EmployeeTab) </script> @@ -1633,8 +1560,8 @@ customers: ${json.dumps(customers_data)|n}, member: null, // TODO members: ${json.dumps(members_data)|n}, - employee: EmployeeData, - employeeHistory: EmployeeHistoryData, + employee: ${json.dumps(employee_data)|n}, + employeeHistory: ${json.dumps(employee_history_data)|n}, phoneTypeOptions: ${json.dumps(phone_type_options)|n}, emailTypeOptions: ${json.dumps(email_type_options)|n}, maxLengths: ${json.dumps(max_lengths)|n}, @@ -1647,6 +1574,12 @@ personUpdated(person) { this.person = person }, + employeeUpdated(employee) { + this.employee = employee + }, + employeeHistoryUpdated(employeeHistory) { + this.employeeHistory = employeeHistory + }, changeContentTitle(newTitle) { this.$emit('change-content-title', newTitle) }, @@ -1722,7 +1655,6 @@ <%def name="make_this_page_component()"> ${parent.make_this_page_component()} ${self.make_personal_tab_component()} - ${self.set_employee_data()} ${self.make_employee_tab_component()} ${self.make_profile_info_component()} </%def> diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 0967147d..615cb238 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -441,6 +441,7 @@ class PersonView(MasterView): 'customers_data': self.get_context_customers(person), 'members_data': self.get_context_members(person), 'employee': employee, + 'employee_data': self.get_context_employee(employee) if employee else {}, 'employee_view_url': self.request.route_url('employees.view', uuid=employee.uuid) if employee else None, 'employee_history': employee.get_current_history() if employee else None, 'employee_history_data': self.get_context_employee_history(employee), @@ -581,6 +582,7 @@ class PersonView(MasterView): app = self.get_rattail_app() handler = app.get_employment_handler() context = handler.get_context_employee(employee) + context['view_url'] = self.request.route_url('employees.view', uuid=employee.uuid) return context def get_context_employee_history(self, employee): @@ -884,6 +886,7 @@ class PersonView(MasterView): start_date = datetime.datetime.strptime(data['start_date'], '%Y-%m-%d').date() employee = handler.begin_employment(person, start_date, employee_id=data['id']) + self.Session.flush() return self.profile_start_employee_result(employee, start_date) def profile_start_employee_result(self, employee, start_date): @@ -912,6 +915,7 @@ class PersonView(MasterView): employee = handler.get_employee(person) handler.end_employment(employee, end_date, revoke_access=data.get('revoke_access')) + self.Session.flush() return self.profile_end_employee_result(employee, end_date) def profile_end_employee_result(self, employee, end_date): @@ -948,9 +952,9 @@ class PersonView(MasterView): self.Session.flush() current_history = employee.get_current_history() - return { 'success': True, + 'employee': self.get_context_employee(employee), 'start_date': six.text_type(current_history.start_date), 'end_date': six.text_type(current_history.end_date or ''), 'employee_history_data': self.get_context_employee_history(employee), From d7c145ce395902a68948f95bdc263c7f446abd86 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 5 Oct 2021 10:43:17 -0400 Subject: [PATCH 0425/1681] 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 d2a244ce..c236aa46 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,20 @@ CHANGELOG ========= +0.8.156 (2021-10-05) +-------------------- + +* Show "contact notes" when creating new custorder. + +* Improve phone editing for new custorder. + +* Add button to refresh contact info for new custorder. + +* Overhaul the "Personal" tab of profile view. + +* Refactor the Employee tab of profile view, per better patterns. + + 0.8.155 (2021-10-01) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index a9451e5f..55b6bda7 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.155' +__version__ = '0.8.156' From def8ea7c15bf6a87b397bf6b68ca5907a5a3f3ec Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 5 Oct 2021 16:12:48 -0400 Subject: [PATCH 0426/1681] Some tweaks for invoice costing batch views --- tailbone/views/purchasing/batch.py | 6 +++++- tailbone/views/purchasing/costing.py | 26 ++++++++++++++++++++++++-- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index 1d42f08d..95c12bb1 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -660,7 +660,10 @@ class PurchasingBatchView(BatchMasterView): row.STATUS_ORDERED_RECEIVED_DIFFER, row.STATUS_TRUCKDUMP_UNCLAIMED, row.STATUS_TRUCKDUMP_PARTCLAIMED, - row.STATUS_OUT_OF_STOCK): + row.STATUS_OUT_OF_STOCK, + row.STATUS_ON_PO_NOT_INVOICE, + row.STATUS_ON_INVOICE_NOT_PO, + row.STATUS_COST_INCREASE): return 'notice' def configure_row_form(self, f): @@ -694,6 +697,7 @@ class PurchasingBatchView(BatchMasterView): f.set_type('po_total', 'currency') f.set_type('po_total_calculated', 'currency') f.set_type('invoice_unit_cost', 'currency') + f.set_type('catalog_unit_cost', 'currency') # upc f.set_type('upc', 'gpc') diff --git a/tailbone/views/purchasing/costing.py b/tailbone/views/purchasing/costing.py index 0f07d77d..149e0442 100644 --- a/tailbone/views/purchasing/costing.py +++ b/tailbone/views/purchasing/costing.py @@ -108,8 +108,6 @@ class CostingBatchView(PurchasingBatchView): 'description', 'size', 'department_name', - 'cases_shipped', - 'units_shipped', 'cases_received', 'units_received', 'catalog_unit_cost', @@ -119,6 +117,30 @@ class CostingBatchView(PurchasingBatchView): 'status_code', ] + row_form_fields = [ + 'sequence', + 'upc', + 'item_id', + 'product', + 'vendor_code', + 'brand_name', + 'description', + 'size', + 'department_name', + 'case_quantity', + 'cases_received', + 'units_received', + 'po_line_number', + 'po_unit_cost', + 'po_total', + 'invoice_line_number', + 'invoice_unit_cost', + 'invoice_total', + 'invoice_total_calculated', + 'catalog_unit_cost', + 'status_code', + ] + @property def batch_mode(self): return self.enum.PURCHASE_BATCH_MODE_COSTING From 9b6113a4c8be4480a8598efcb1d8d80515dfdcbf Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 5 Oct 2021 16:20:08 -0400 Subject: [PATCH 0427/1681] Show shipped quantities when viewing costing batch row for lines which came from invoice, we should know those quantities, but possibly *not* the received quantities, if e.g. the line item wasn't matched w/ PO --- tailbone/views/purchasing/costing.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tailbone/views/purchasing/costing.py b/tailbone/views/purchasing/costing.py index 149e0442..daf016a9 100644 --- a/tailbone/views/purchasing/costing.py +++ b/tailbone/views/purchasing/costing.py @@ -128,6 +128,8 @@ class CostingBatchView(PurchasingBatchView): 'size', 'department_name', 'case_quantity', + 'cases_shipped', + 'units_shipped', 'cases_received', 'units_received', 'po_line_number', From 0237d8c31a0554fedfe521706c66030068bede81 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 6 Oct 2021 12:32:13 -0400 Subject: [PATCH 0428/1681] Add "restrict contact info" feature for new custorder batch also add support for choosing from existing emails --- tailbone/templates/custorders/create.mako | 124 ++++++++++++++++------ tailbone/views/custorders/orders.py | 25 ++--- 2 files changed, 102 insertions(+), 47 deletions(-) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index 79233493..e718eb96 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -155,7 +155,8 @@ <div class="level-item"> {{ orderPhoneNumber }} </div> - <div class="level-item"> + <div class="level-item" + v-if="contactPhones.length > 1"> <b-button type="is-primary" @click="editPhoneNumberInit()" icon-pack="fas" @@ -185,22 +186,24 @@ </b-radio> </b-field> - <b-field> - <b-radio v-model="existingPhoneUUID" - :native-value="null"> - other - </b-radio> - </b-field> + % if not restrict_contact_info: + <b-field> + <b-radio v-model="existingPhoneUUID" + :native-value="null"> + other + </b-radio> + </b-field> - <b-field v-if="!existingPhoneUUID" - grouped> - <b-input v-model="otherPhoneNumber"> - </b-input> - <b-checkbox v-model="addOtherPhoneNumber" - disabled> - add this phone number to customer record - </b-checkbox> - </b-field> + <b-field v-if="!existingPhoneUUID" + grouped> + <b-input v-model="otherPhoneNumber"> + </b-input> + <b-checkbox v-model="addOtherPhoneNumber" + disabled> + add this phone number to customer record + </b-checkbox> + </b-field> + % endif </section> @@ -236,7 +239,8 @@ (no valid email on file) </span> </div> - <div class="level-item"> + <div class="level-item" + v-if="contactEmails.length > 1"> <b-button type="is-primary" @click="editEmailAddressInit()" icon-pack="fas" @@ -252,12 +256,38 @@ </header> <section class="modal-card-body"> - <b-field label="Email Address" - :type="editEmailAddressValue ? null : 'is-danger'"> - <b-input v-model="editEmailAddressValue" - ref="editEmailAddressInput"> - </b-input> + + <b-field v-for="email in contactEmails" + :key="email.uuid"> + <b-radio v-model="existingEmailUUID" + :native-value="email.uuid"> + {{ email.type }} {{ email.address }} + <span v-if="email.preferred" + class="is-italic"> + (preferred) + </span> + </b-radio> </b-field> + + % if not restrict_contact_info: + <b-field> + <b-radio v-model="existingEmailUUID" + :native-value="null"> + other + </b-radio> + </b-field> + + <b-field v-if="!existingEmailUUID" + grouped> + <b-input v-model="otherEmailAddress"> + </b-input> + <b-checkbox v-model="addOtherEmailAddress" + disabled> + add this email address to customer record + </b-checkbox> + </b-field> + % endif + </section> <footer class="modal-card-foot"> @@ -545,7 +575,7 @@ % else: contactUUID: ${json.dumps(batch.person_uuid)|n}, % endif - contactDisplay: ${json.dumps(six.text_type(batch.customer or ''))|n}, + contactDisplay: ${json.dumps(contact_display)|n}, customerEntry: null, contactProfileURL: ${json.dumps(contact_profile_url)|n}, @@ -557,15 +587,18 @@ editPhoneNumberShowDialog: false, editPhoneNumberSaving: false, + orderEmailAddress: ${json.dumps(batch.email_address)|n}, + contactEmails: ${json.dumps(contact_emails)|n}, + existingEmailUUID: null, + otherEmailAddress: null, + addOtherEmailAddress: false, + editEmailAddressShowDialog: false, + editEmailAddressSaving: false, + customerName: null, phoneNumber: null, - orderEmailAddress: ${json.dumps(batch.email_address)|n}, contactNotes: ${json.dumps(contact_notes)|n}, - editEmailAddressShowDialog: false, - editEmailAddressValue: null, - editEmailAddressSaving: false, - items: ${json.dumps(order_items)|n}, editingItem: null, showingItemDialog: false, @@ -710,7 +743,7 @@ if (this.editEmailAddressSaving) { return true } - if (!this.editEmailAddressValue) { + if (!this.existingEmailUUID && !this.otherEmailAddress) { return true } return false @@ -870,6 +903,7 @@ that.orderEmailAddress = response.data.email_address that.contactProfileURL = response.data.contact_profile_url that.contactPhones = response.data.contact_phones + that.contactEmails = response.data.contact_emails that.contactNotes = response.data.contact_notes }) }, @@ -931,11 +965,18 @@ }, editEmailAddressInit() { - this.editEmailAddressValue = this.orderEmailAddress + this.existingEmailUUID = null + let normalOrderEmail = (this.orderEmailAddress || '').toLowerCase() + for (let email of this.contactEmails) { + let normal = email.address.toLowerCase() + if (normal == normalOrderEmail) { + this.existingEmailUUID = email.uuid + break + } + } + this.otherEmailAddress = this.existingEmailUUID ? null : this.orderEmailAddress this.editEmailAddressShowDialog = true - this.$nextTick(() => { - this.$refs.editEmailAddressInput.focus() - }) + }, editEmailAddressSave() { @@ -943,7 +984,21 @@ let params = { action: 'update_email_address', - email_address: this.editEmailAddressValue, + email_address: null, + } + + if (this.existingEmailUUID) { + for (let email of this.contactEmails) { + if (email.uuid == this.existingEmailUUID) { + params.email_address = email.address + break + } + } + } + + if (!params.email_address) { + params.email_address = this.otherEmailAddress + // params.add_email_address = this.addOtherEmailAddress } this.submitBatchData(params, response => { @@ -959,7 +1014,6 @@ } this.editEmailAddressSaving = false }) - }, showAddItemDialog() { diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index b9eac443..85376e48 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -255,7 +255,10 @@ class CustomerOrderView(MasterView): context = {'batch': batch, 'normalized_batch': self.normalize_batch(batch), 'new_order_requires_customer': self.handler.new_order_requires_customer(), + 'restrict_contact_info': self.handler.should_restrict_contact_info(), + 'contact_display': self.handler.get_contact_display(batch), 'contact_phones': self.handler.get_contact_phones(batch), + 'contact_emails': self.handler.get_contact_emails(batch), 'contact_profile_url': None, 'contact_notes': self.handler.get_contact_notes(batch), 'order_items': items, @@ -387,13 +390,19 @@ class CustomerOrderView(MasterView): except ValueError as error: return {'error': six.text_type(error)} + self.Session.flush() + context = self.get_context_contact(batch) + context['success'] = True + return context + + def get_context_contact(self, batch): context = { - 'success': True, 'customer_uuid': batch.customer_uuid, 'person_uuid': batch.person_uuid, 'phone_number': batch.phone_number, 'email_address': batch.email_address, 'contact_phones': self.handler.get_contact_phones(batch), + 'contact_emails': self.handler.get_contact_emails(batch), 'contact_notes': self.handler.get_contact_notes(batch), } @@ -407,17 +416,9 @@ class CustomerOrderView(MasterView): def unassign_contact(self, batch, data): self.handler.unassign_contact(batch) - - context = { - 'success': True, - 'customer_uuid': batch.customer_uuid, - 'person_uuid': batch.person_uuid, - 'phone_number': batch.phone_number, - 'email_address': batch.email_address, - 'contact_profile_url': None, - 'contact_notes': self.handler.get_contact_notes(batch), - } - + self.Session.flush() + context = self.get_context_contact(batch) + context['success'] = True return context def update_phone_number(self, batch, data): From 2fa7857daf5562119e684a5ef7d520ffdf8c9c78 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 6 Oct 2021 12:43:38 -0400 Subject: [PATCH 0429/1681] Add "allow contact info choice" support for new custorder batch --- tailbone/templates/custorders/create.mako | 488 +++++++++++----------- tailbone/views/custorders/orders.py | 1 + 2 files changed, 254 insertions(+), 235 deletions(-) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index e718eb96..41bb23e9 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -155,74 +155,76 @@ <div class="level-item"> {{ orderPhoneNumber }} </div> - <div class="level-item" - v-if="contactPhones.length > 1"> - <b-button type="is-primary" - @click="editPhoneNumberInit()" - icon-pack="fas" - icon-left="edit"> - Edit - </b-button> + % if allow_contact_info_choice: + <div class="level-item" + v-if="contactPhones.length > 1"> + <b-button type="is-primary" + @click="editPhoneNumberInit()" + icon-pack="fas" + icon-left="edit"> + Edit + </b-button> - <b-modal has-modal-card - :active.sync="editPhoneNumberShowDialog"> - <div class="modal-card"> + <b-modal has-modal-card + :active.sync="editPhoneNumberShowDialog"> + <div class="modal-card"> - <header class="modal-card-head"> - <p class="modal-card-title">Edit Phone Number</p> - </header> + <header class="modal-card-head"> + <p class="modal-card-title">Edit Phone Number</p> + </header> - <section class="modal-card-body"> + <section class="modal-card-body"> - <b-field v-for="phone in contactPhones" - :key="phone.uuid"> - <b-radio v-model="existingPhoneUUID" - :native-value="phone.uuid"> - {{ phone.type }} {{ phone.number }} - <span v-if="phone.preferred" - class="is-italic"> - (preferred) - </span> - </b-radio> - </b-field> - - % if not restrict_contact_info: - <b-field> + <b-field v-for="phone in contactPhones" + :key="phone.uuid"> <b-radio v-model="existingPhoneUUID" - :native-value="null"> - other + :native-value="phone.uuid"> + {{ phone.type }} {{ phone.number }} + <span v-if="phone.preferred" + class="is-italic"> + (preferred) + </span> </b-radio> </b-field> - <b-field v-if="!existingPhoneUUID" - grouped> - <b-input v-model="otherPhoneNumber"> - </b-input> - <b-checkbox v-model="addOtherPhoneNumber" - disabled> - add this phone number to customer record - </b-checkbox> - </b-field> - % endif + % if not restrict_contact_info: + <b-field> + <b-radio v-model="existingPhoneUUID" + :native-value="null"> + other + </b-radio> + </b-field> - </section> + <b-field v-if="!existingPhoneUUID" + grouped> + <b-input v-model="otherPhoneNumber"> + </b-input> + <b-checkbox v-model="addOtherPhoneNumber" + disabled> + add this phone number to customer record + </b-checkbox> + </b-field> + % endif + + </section> + + <footer class="modal-card-foot"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="save" + :disabled="editPhoneNumberSaveDisabled" + @click="editPhoneNumberSave()"> + {{ editPhoneNumberSaveText }} + </b-button> + <b-button @click="editPhoneNumberShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </b-modal> - <footer class="modal-card-foot"> - <b-button type="is-primary" - icon-pack="fas" - icon-left="save" - :disabled="editPhoneNumberSaveDisabled" - @click="editPhoneNumberSave()"> - {{ editPhoneNumberSaveText }} - </b-button> - <b-button @click="editPhoneNumberShowDialog = false"> - Cancel - </b-button> - </footer> </div> - </b-modal> - - </div> + % endif </div> </div> </b-field> @@ -239,72 +241,74 @@ (no valid email on file) </span> </div> - <div class="level-item" - v-if="contactEmails.length > 1"> - <b-button type="is-primary" - @click="editEmailAddressInit()" - icon-pack="fas" - icon-left="edit"> - Edit - </b-button> - <b-modal has-modal-card - :active.sync="editEmailAddressShowDialog"> - <div class="modal-card"> + % if allow_contact_info_choice: + <div class="level-item" + v-if="contactEmails.length > 1"> + <b-button type="is-primary" + @click="editEmailAddressInit()" + icon-pack="fas" + icon-left="edit"> + Edit + </b-button> + <b-modal has-modal-card + :active.sync="editEmailAddressShowDialog"> + <div class="modal-card"> - <header class="modal-card-head"> - <p class="modal-card-title">Edit Email Address</p> - </header> + <header class="modal-card-head"> + <p class="modal-card-title">Edit Email Address</p> + </header> - <section class="modal-card-body"> + <section class="modal-card-body"> - <b-field v-for="email in contactEmails" - :key="email.uuid"> - <b-radio v-model="existingEmailUUID" - :native-value="email.uuid"> - {{ email.type }} {{ email.address }} - <span v-if="email.preferred" - class="is-italic"> - (preferred) - </span> - </b-radio> - </b-field> - - % if not restrict_contact_info: - <b-field> + <b-field v-for="email in contactEmails" + :key="email.uuid"> <b-radio v-model="existingEmailUUID" - :native-value="null"> - other + :native-value="email.uuid"> + {{ email.type }} {{ email.address }} + <span v-if="email.preferred" + class="is-italic"> + (preferred) + </span> </b-radio> </b-field> - <b-field v-if="!existingEmailUUID" - grouped> - <b-input v-model="otherEmailAddress"> - </b-input> - <b-checkbox v-model="addOtherEmailAddress" - disabled> - add this email address to customer record - </b-checkbox> - </b-field> - % endif + % if not restrict_contact_info: + <b-field> + <b-radio v-model="existingEmailUUID" + :native-value="null"> + other + </b-radio> + </b-field> - </section> + <b-field v-if="!existingEmailUUID" + grouped> + <b-input v-model="otherEmailAddress"> + </b-input> + <b-checkbox v-model="addOtherEmailAddress" + disabled> + add this email address to customer record + </b-checkbox> + </b-field> + % endif - <footer class="modal-card-foot"> - <b-button type="is-primary" - icon-pack="fas" - icon-left="save" - :disabled="editEmailAddressSaveDisabled" - @click="editEmailAddressSave()"> - {{ editEmailAddressSaveText }} - </b-button> - <b-button @click="editEmailAddressShowDialog = false"> - Cancel - </b-button> - </footer> + </section> + + <footer class="modal-card-foot"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="save" + :disabled="editEmailAddressSaveDisabled" + @click="editEmailAddressSave()"> + {{ editEmailAddressSaveText }} + </b-button> + <b-button @click="editEmailAddressShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </b-modal> </div> - </b-modal> - </div> + % endif </div> </div> </b-field> @@ -581,19 +585,25 @@ orderPhoneNumber: ${json.dumps(batch.phone_number)|n}, contactPhones: ${json.dumps(contact_phones)|n}, - existingPhoneUUID: null, - otherPhoneNumber: null, - addOtherPhoneNumber: false, - editPhoneNumberShowDialog: false, - editPhoneNumberSaving: false, orderEmailAddress: ${json.dumps(batch.email_address)|n}, contactEmails: ${json.dumps(contact_emails)|n}, - existingEmailUUID: null, - otherEmailAddress: null, - addOtherEmailAddress: false, - editEmailAddressShowDialog: false, - editEmailAddressSaving: false, + + % if allow_contact_info_choice: + + existingPhoneUUID: null, + otherPhoneNumber: null, + addOtherPhoneNumber: false, + editPhoneNumberShowDialog: false, + editPhoneNumberSaving: false, + + existingEmailUUID: null, + otherEmailAddress: null, + addOtherEmailAddress: false, + editEmailAddressShowDialog: false, + editEmailAddressSaving: false, + + % endif customerName: null, phoneNumber: null, @@ -722,39 +732,43 @@ } }, - editPhoneNumberSaveDisabled() { - if (this.editPhoneNumberSaving) { - return true - } - if (!this.existingPhoneUUID && !this.otherPhoneNumber) { - return true - } - return false - }, + % if allow_contact_info_choice: - editPhoneNumberSaveText() { - if (this.editPhoneNumberSaving) { - return "Working, please wait..." - } - return "Save" - }, + editPhoneNumberSaveDisabled() { + if (this.editPhoneNumberSaving) { + return true + } + if (!this.existingPhoneUUID && !this.otherPhoneNumber) { + return true + } + return false + }, - editEmailAddressSaveDisabled() { - if (this.editEmailAddressSaving) { - return true - } - if (!this.existingEmailUUID && !this.otherEmailAddress) { - return true - } - return false - }, + editPhoneNumberSaveText() { + if (this.editPhoneNumberSaving) { + return "Working, please wait..." + } + return "Save" + }, - editEmailAddressSaveText() { - if (this.editEmailAddressSaving) { - return "Working, please wait..." - } - return "Save" - }, + editEmailAddressSaveDisabled() { + if (this.editEmailAddressSaving) { + return true + } + if (!this.existingEmailUUID && !this.otherEmailAddress) { + return true + } + return false + }, + + editEmailAddressSaveText() { + if (this.editEmailAddressSaving) { + return "Working, please wait..." + } + return "Save" + }, + + % endif itemsPanelHeader() { let text = "Items" @@ -912,109 +926,113 @@ this.contactChanged(this.contactUUID) }, - editPhoneNumberInit() { - this.existingPhoneUUID = null - let normalOrderPhone = this.orderPhoneNumber.replace(/\D/g, '') - for (let phone of this.contactPhones) { - let normal = phone.number.replace(/\D/g, '') - if (normal == normalOrderPhone) { - this.existingPhoneUUID = phone.uuid - break - } - } - this.otherPhoneNumber = this.existingPhoneUUID ? null : this.orderPhoneNumber - this.editPhoneNumberShowDialog = true - }, + % if allow_contact_info_choice: - editPhoneNumberSave() { - this.editPhoneNumberSaving = true - - let params = { - action: 'update_phone_number', - phone_number: null, - } - - if (this.existingPhoneUUID) { + editPhoneNumberInit() { + this.existingPhoneUUID = null + let normalOrderPhone = this.orderPhoneNumber.replace(/\D/g, '') for (let phone of this.contactPhones) { - if (phone.uuid == this.existingPhoneUUID) { - params.phone_number = phone.number + let normal = phone.number.replace(/\D/g, '') + if (normal == normalOrderPhone) { + this.existingPhoneUUID = phone.uuid break } } - } + this.otherPhoneNumber = this.existingPhoneUUID ? null : this.orderPhoneNumber + this.editPhoneNumberShowDialog = true + }, - if (!params.phone_number) { - params.phone_number = this.otherPhoneNumber - // params.add_phone_number = this.addOtherPhoneNumber - } + editPhoneNumberSave() { + this.editPhoneNumberSaving = true - this.submitBatchData(params, response => { - if (response.data.success) { - this.orderPhoneNumber = response.data.phone_number - this.editPhoneNumberShowDialog = false - } else { - this.$buefy.toast.open({ - message: "Save failed: " + response.data.error, - type: 'is-danger', - duration: 2000, // 2 seconds - }) + let params = { + action: 'update_phone_number', + phone_number: null, } - this.editPhoneNumberSaving = false - }) - }, - - editEmailAddressInit() { - this.existingEmailUUID = null - let normalOrderEmail = (this.orderEmailAddress || '').toLowerCase() - for (let email of this.contactEmails) { - let normal = email.address.toLowerCase() - if (normal == normalOrderEmail) { - this.existingEmailUUID = email.uuid - break + if (this.existingPhoneUUID) { + for (let phone of this.contactPhones) { + if (phone.uuid == this.existingPhoneUUID) { + params.phone_number = phone.number + break + } + } } - } - this.otherEmailAddress = this.existingEmailUUID ? null : this.orderEmailAddress - this.editEmailAddressShowDialog = true - }, + if (!params.phone_number) { + params.phone_number = this.otherPhoneNumber + // params.add_phone_number = this.addOtherPhoneNumber + } - editEmailAddressSave() { - this.editEmailAddressSaving = true + this.submitBatchData(params, response => { + if (response.data.success) { + this.orderPhoneNumber = response.data.phone_number + this.editPhoneNumberShowDialog = false + } else { + this.$buefy.toast.open({ + message: "Save failed: " + response.data.error, + type: 'is-danger', + duration: 2000, // 2 seconds + }) + } + this.editPhoneNumberSaving = false + }) - let params = { - action: 'update_email_address', - email_address: null, - } + }, - if (this.existingEmailUUID) { + editEmailAddressInit() { + this.existingEmailUUID = null + let normalOrderEmail = (this.orderEmailAddress || '').toLowerCase() for (let email of this.contactEmails) { - if (email.uuid == this.existingEmailUUID) { - params.email_address = email.address + let normal = email.address.toLowerCase() + if (normal == normalOrderEmail) { + this.existingEmailUUID = email.uuid break } } - } + this.otherEmailAddress = this.existingEmailUUID ? null : this.orderEmailAddress + this.editEmailAddressShowDialog = true - if (!params.email_address) { - params.email_address = this.otherEmailAddress - // params.add_email_address = this.addOtherEmailAddress - } + }, - this.submitBatchData(params, response => { - if (response.data.success) { - this.orderEmailAddress = response.data.email_address - this.editEmailAddressShowDialog = false - } else { - this.$buefy.toast.open({ - message: "Save failed: " + response.data.error, - type: 'is-danger', - duration: 2000, // 2 seconds - }) + editEmailAddressSave() { + this.editEmailAddressSaving = true + + let params = { + action: 'update_email_address', + email_address: null, } - this.editEmailAddressSaving = false - }) - }, + + if (this.existingEmailUUID) { + for (let email of this.contactEmails) { + if (email.uuid == this.existingEmailUUID) { + params.email_address = email.address + break + } + } + } + + if (!params.email_address) { + params.email_address = this.otherEmailAddress + // params.add_email_address = this.addOtherEmailAddress + } + + this.submitBatchData(params, response => { + if (response.data.success) { + this.orderEmailAddress = response.data.email_address + this.editEmailAddressShowDialog = false + } else { + this.$buefy.toast.open({ + message: "Save failed: " + response.data.error, + type: 'is-danger', + duration: 2000, // 2 seconds + }) + } + this.editEmailAddressSaving = false + }) + }, + + % endif showAddItemDialog() { this.customerPanelOpen = false diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index 85376e48..9401a935 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -255,6 +255,7 @@ class CustomerOrderView(MasterView): context = {'batch': batch, 'normalized_batch': self.normalize_batch(batch), 'new_order_requires_customer': self.handler.new_order_requires_customer(), + 'allow_contact_info_choice': self.handler.allow_contact_info_choice(), 'restrict_contact_info': self.handler.should_restrict_contact_info(), 'contact_display': self.handler.get_contact_display(batch), 'contact_phones': self.handler.get_contact_phones(batch), From 9b40096bb7c68429c7975e85f13eb03ccd975c57 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 6 Oct 2021 14:49:13 -0400 Subject: [PATCH 0430/1681] Add "contact update request" workflow for new custorder batch if user checks "please add phone to customer record" etc. then this preference is stored in the batch params, and when batch is executed that will "happen" (which may just mean someone gets email about it) --- tailbone/templates/custorders/create.mako | 38 ++++++++++++++--------- tailbone/views/custorders/batch.py | 1 + tailbone/views/custorders/orders.py | 20 +++++++++--- 3 files changed, 41 insertions(+), 18 deletions(-) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index 41bb23e9..4716404d 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -127,8 +127,7 @@ @input="contactChanged"> </tailbone-autocomplete> </b-field> - <div v-if="contactUUID" - class="buttons"> + <div v-if="contactUUID"> <b-button v-if="contactProfileURL" type="is-primary" tag="a" target="_blank" @@ -137,6 +136,7 @@ icon-left="external-link-alt"> View Profile </b-button> + <b-button @click="refreshContact" icon-pack="fas" icon-left="redo"> @@ -157,7 +157,10 @@ </div> % if allow_contact_info_choice: <div class="level-item" - v-if="contactPhones.length > 1"> + % if restrict_contact_info: + v-if="contactPhones.length > 1" + % endif + > <b-button type="is-primary" @click="editPhoneNumberInit()" icon-pack="fas" @@ -199,8 +202,7 @@ grouped> <b-input v-model="otherPhoneNumber"> </b-input> - <b-checkbox v-model="addOtherPhoneNumber" - disabled> + <b-checkbox v-model="addOtherPhoneNumber"> add this phone number to customer record </b-checkbox> </b-field> @@ -243,7 +245,10 @@ </div> % if allow_contact_info_choice: <div class="level-item" - v-if="contactEmails.length > 1"> + % if restrict_contact_info: + v-if="contactEmails.length > 1" + % endif + > <b-button type="is-primary" @click="editEmailAddressInit()" icon-pack="fas" @@ -284,8 +289,7 @@ grouped> <b-input v-model="otherEmailAddress"> </b-input> - <b-checkbox v-model="addOtherEmailAddress" - disabled> + <b-checkbox v-model="addOtherEmailAddress"> add this email address to customer record </b-checkbox> </b-field> @@ -593,13 +597,13 @@ existingPhoneUUID: null, otherPhoneNumber: null, - addOtherPhoneNumber: false, + addOtherPhoneNumber: ${json.dumps(add_phone_number)|n}, editPhoneNumberShowDialog: false, editPhoneNumberSaving: false, existingEmailUUID: null, otherEmailAddress: null, - addOtherEmailAddress: false, + addOtherEmailAddress: ${json.dumps(add_email_address)|n}, editEmailAddressShowDialog: false, editEmailAddressSaving: false, @@ -915,6 +919,8 @@ % endif that.orderPhoneNumber = response.data.phone_number that.orderEmailAddress = response.data.email_address + that.addOtherPhoneNumber = response.data.add_phone_number + that.addOtherEmailAddres = response.data.add_email_address that.contactProfileURL = response.data.contact_profile_url that.contactPhones = response.data.contact_phones that.contactEmails = response.data.contact_emails @@ -959,9 +965,11 @@ } } - if (!params.phone_number) { + if (params.phone_number) { + params.add_phone_number = false + } else { params.phone_number = this.otherPhoneNumber - // params.add_phone_number = this.addOtherPhoneNumber + params.add_phone_number = this.addOtherPhoneNumber } this.submitBatchData(params, response => { @@ -1012,9 +1020,11 @@ } } - if (!params.email_address) { + if (params.email_address) { + params.add_email_address = false + } else { params.email_address = this.otherEmailAddress - // params.add_email_address = this.addOtherEmailAddress + params.add_email_address = this.addOtherEmailAddress } this.submitBatchData(params, response => { diff --git a/tailbone/views/custorders/batch.py b/tailbone/views/custorders/batch.py index 18ff9a00..1cced9de 100644 --- a/tailbone/views/custorders/batch.py +++ b/tailbone/views/custorders/batch.py @@ -63,6 +63,7 @@ class CustomerOrderBatchView(BatchMasterView): 'person', 'phone_number', 'email_address', + 'params', 'created', 'created_by', 'rowcount', diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index 9401a935..aa005dd1 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -260,6 +260,8 @@ class CustomerOrderView(MasterView): 'contact_display': self.handler.get_contact_display(batch), 'contact_phones': self.handler.get_contact_phones(batch), 'contact_emails': self.handler.get_contact_emails(batch), + 'add_phone_number': bool(batch.get_param('add_phone_number')), + 'add_email_address': bool(batch.get_param('add_email_address')), 'contact_profile_url': None, 'contact_notes': self.handler.get_contact_notes(batch), 'order_items': items, @@ -405,6 +407,8 @@ class CustomerOrderView(MasterView): 'contact_phones': self.handler.get_contact_phones(batch), 'contact_emails': self.handler.get_contact_emails(batch), 'contact_notes': self.handler.get_contact_notes(batch), + 'add_phone_number': bool(batch.get_param('add_phone_number')), + 'add_email_address': bool(batch.get_param('add_email_address')), } # maybe add profile URL @@ -426,9 +430,13 @@ class CustomerOrderView(MasterView): app = self.get_rattail_app() batch.phone_number = app.format_phone_number(data['phone_number']) - self.Session.flush() - self.Session.refresh(batch) + if data.get('add_phone_number'): + batch.set_param('add_phone_number', True) + else: + batch.clear_param('add_phone_number') + + self.Session.flush() return { 'success': True, 'phone_number': batch.phone_number, @@ -437,9 +445,13 @@ class CustomerOrderView(MasterView): def update_email_address(self, batch, data): batch.email_address = data['email_address'] - self.Session.flush() - self.Session.refresh(batch) + if data.get('add_email_address'): + batch.set_param('add_email_address', True) + else: + batch.clear_param('add_email_address') + + self.Session.flush() return { 'success': True, 'email_address': batch.email_address, From 25a019cc12e01722f4f9e04a3f3da18d564b659a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 6 Oct 2021 14:55:19 -0400 Subject: [PATCH 0431/1681] 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 c236aa46..5b7a9d5f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.8.157 (2021-10-06) +-------------------- + +* Some tweaks for invoice costing batch views. + +* Add "restrict contact info" features for new custorder batch. + +* Add "contact update request" workflow for new custorder batch. + + 0.8.156 (2021-10-05) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 55b6bda7..bba88b3e 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.156' +__version__ = '0.8.157' From d933dd2723c9b49bcb71f5b4d520dbe93fdfaef1 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 6 Oct 2021 18:22:29 -0400 Subject: [PATCH 0432/1681] Add support for "new customer" when creating new custorder --- tailbone/templates/custorders/create.mako | 164 +++++++++++++++++++--- tailbone/views/customers.py | 58 ++++++++ tailbone/views/custorders/batch.py | 13 ++ tailbone/views/custorders/orders.py | 83 ++++++++--- 4 files changed, 283 insertions(+), 35 deletions(-) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index 4716404d..d921779e 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -333,19 +333,84 @@ <br /> <div class="field"> - <b-radio v-model="contactIsKnown" disabled + <b-radio v-model="contactIsKnown" :native-value="false"> Customer is not yet in the system. </b-radio> </div> - <div v-if="!contactIsKnown"> - <b-field label="Customer Name" horizontal> - <b-input v-model="customerName"></b-input> - </b-field> - <b-field label="Phone Number" horizontal> - <b-input v-model="phoneNumber"></b-input> - </b-field> + <div v-if="!contactIsKnown" + style="padding-left: 10rem; display: flex;"> + <div> + <b-field label="Customer Name"> + <span>{{ newCustomerName }}</span> + </b-field> + <b-field grouped> + <b-field label="Phone Number"> + <span>{{ newCustomerPhone }}</span> + </b-field> + <b-field label="Email Address"> + <span>{{ newCustomerEmail }}</span> + </b-field> + </b-field> + </div> + + <div> + <b-button type="is-primary" + @click="editNewCustomerInit()" + icon-pack="fas" + icon-left="edit"> + Edit New Customer + </b-button> + </div> + + <div style="margin-left: 1rem;"> + <b-notification type="is-warning" + :closable="false"> + <p>Duplicate records can be difficult to clean up!</p> + <p>Please be sure the customer is not already in the system.</p> + </b-notification> + </div> + + <b-modal has-modal-card + :active.sync="editNewCustomerShowDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Edit New Customer</p> + </header> + + <section class="modal-card-body"> + <b-field label="Customer Name"> + <b-input v-model="editNewCustomerName" + ref="editNewCustomerInput"> + </b-input> + </b-field> + <b-field grouped> + <b-field label="Phone Number"> + <b-input v-model="editNewCustomerPhone"></b-input> + </b-field> + <b-field label="Email Address"> + <b-input v-model="editNewCustomerEmail"></b-input> + </b-field> + </b-field> + </section> + + <footer class="modal-card-foot"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="save" + :disabled="editNewCustomerSaveDisabled" + @click="editNewCustomerSave()"> + {{ editNewCustomerSaveText }} + </b-button> + <b-button @click="editNewCustomerShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </b-modal> + </div> </div> @@ -577,7 +642,7 @@ batchTotalPriceDisplay: ${json.dumps(normalized_batch['total_price_display'])|n}, customerPanelOpen: false, - contactIsKnown: true, + contactIsKnown: ${json.dumps(contact_is_known)|n}, % if new_order_requires_customer: contactUUID: ${json.dumps(batch.customer_uuid)|n}, % else: @@ -609,10 +674,17 @@ % endif - customerName: null, - phoneNumber: null, + newCustomerName: ${json.dumps(new_customer_name)|n}, + newCustomerPhone: ${json.dumps(new_customer_phone)|n}, + newCustomerEmail: ${json.dumps(new_customer_email)|n}, contactNotes: ${json.dumps(contact_notes)|n}, + editNewCustomerShowDialog: false, + editNewCustomerName: null, + editNewCustomerPhone: null, + editNewCustomerEmail: null, + editNewCustomerSaving: false, + items: ${json.dumps(order_items)|n}, editingItem: null, showingItemDialog: false, @@ -646,8 +718,8 @@ } } } else { - if (this.customerName) { - text = "Customer: " + this.customerName + if (this.newCustomerName) { + text = "Customer: " + this.newCustomerName } } @@ -700,19 +772,19 @@ } phoneNumber = this.orderPhoneNumber } else { // customer is not known - if (!this.customerName) { + if (!this.newCustomerName) { return { type: 'is-danger', text: "Please identify the customer.", } } - if (!this.phoneNumber) { + if (!this.newCustomerPhone) { return { type: 'is-warning', text: "Please provide a phone number for the customer.", } } - phoneNumber = this.phoneNumber + phoneNumber = this.newCustomerPhone } let phoneDigits = phoneNumber.replace(/\D/g, '') @@ -774,6 +846,26 @@ % endif + editNewCustomerSaveDisabled() { + if (this.editNewCustomerSaving) { + return true + } + if (!this.editNewCustomerName) { + return true + } + if (!(this.editNewCustomerPhone || this.editNewCustomerEmail)) { + return true + } + return false + }, + + editNewCustomerSaveText() { + if (this.editNewCustomerSaving) { + return "Working, please wait..." + } + return "Save" + }, + itemsPanelHeader() { let text = "Items" @@ -1044,6 +1136,46 @@ % endif + editNewCustomerInit() { + this.editNewCustomerName = this.newCustomerName + this.editNewCustomerPhone = this.newCustomerPhone + this.editNewCustomerEmail = this.newCustomerEmail + this.editNewCustomerShowDialog = true + this.$nextTick(() => { + this.$refs.editNewCustomerInput.focus() + }) + }, + + editNewCustomerSave() { + this.editNewCustomerSaving = true + + let params = { + action: 'update_pending_customer', + display_name: this.editNewCustomerName, + phone_number: this.editNewCustomerPhone, + email_address: this.editNewCustomerEmail, + } + + this.submitBatchData(params, response => { + if (response.data.success) { + this.newCustomerName = response.data.new_customer_name + this.newCustomerPhone = response.data.phone_number + this.orderPhoneNumber = response.data.phone_number + this.newCustomerEmail = response.data.email_address + this.orderEmailAddress = response.data.email_address + this.editNewCustomerShowDialog = false + } else { + this.$buefy.toast.open({ + message: "Save failed: " + (response.data.error || "(unknown error)"), + type: 'is-danger', + duration: 2000, // 2 seconds + }) + } + this.editNewCustomerSaving = false + }) + + }, + showAddItemDialog() { this.customerPanelOpen = false this.editingItem = null diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index 27b19e94..65618c1a 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -450,6 +450,63 @@ class CustomerView(MasterView): permission='{}.detach_person'.format(permission_prefix)) +class PendingCustomerView(MasterView): + """ + Master view for the Pending Customer class. + """ + model_class = model.PendingCustomer + route_prefix = 'pending_customers' + url_prefix = '/customers/pending' + + labels = { + 'id': "ID", + 'status_code': "Status", + } + + grid_columns = [ + 'id', + 'display_name', + 'first_name', + 'last_name', + 'phone_number', + 'email_address', + 'status_code', + ] + + form_fields = [ + 'id', + 'display_name', + 'first_name', + 'middle_name', + 'last_name', + 'phone_number', + 'phone_type', + 'email_address', + 'email_type', + 'address_street', + 'address_street2', + 'address_city', + 'address_state', + 'address_zipcode', + 'address_type', + 'status_code', + ] + + def configure_grid(self, g): + super(PendingCustomerView, self).configure_grid(g) + + g.set_enum('status_code', self.enum.PENDING_CUSTOMER_STATUS) + + g.set_sort_defaults('display_name') + g.set_link('id') + g.set_link('display_name') + + def configure_form(self, f): + super(PendingCustomerView, self).configure_form(f) + + f.set_enum('status_code', self.enum.PENDING_CUSTOMER_STATUS) + + # # TODO: this is referenced by some custom apps, but should be moved?? # def unique_id(value, field): # customer = field.parent.model @@ -491,3 +548,4 @@ def includeme(config): renderer='json', permission='customers.view') CustomerView.defaults(config) + PendingCustomerView.defaults(config) diff --git a/tailbone/views/custorders/batch.py b/tailbone/views/custorders/batch.py index 1cced9de..fb47f247 100644 --- a/tailbone/views/custorders/batch.py +++ b/tailbone/views/custorders/batch.py @@ -31,6 +31,7 @@ import six from rattail.db import model import colander +from webhelpers2.html import tags from tailbone import forms from tailbone.views.batch import BatchMasterView @@ -61,6 +62,7 @@ class CustomerOrderBatchView(BatchMasterView): 'store', 'customer', 'person', + 'pending_customer', 'phone_number', 'email_address', 'params', @@ -151,8 +153,19 @@ class CustomerOrderBatchView(BatchMasterView): else: f.set_renderer('person', self.render_person) + # pending_customer + f.set_renderer('pending_customer', self.render_pending_customer) + f.set_type('total_price', 'currency') + def render_pending_customer(self, batch, field): + pending = batch.pending_customer + if not pending: + return + text = six.text_type(pending) + url = self.request.route_url('pending_customers.view', uuid=pending.uuid) + return tags.link_to(text, url) + def row_grid_extra_class(self, row, i): if row.status_code == row.STATUS_PRODUCT_NOT_FOUND: return 'warning' diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index aa005dd1..1c6aed08 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -230,6 +230,7 @@ class CustomerOrderView(MasterView): 'unassign_contact', 'update_phone_number', 'update_email_address', + 'update_pending_customer', 'get_customer_info', # 'set_customer_data', 'find_product_by_upc', @@ -252,26 +253,17 @@ class CustomerOrderView(MasterView): else: product_autocomplete = 'products.autocomplete' - context = {'batch': batch, - 'normalized_batch': self.normalize_batch(batch), - 'new_order_requires_customer': self.handler.new_order_requires_customer(), - 'allow_contact_info_choice': self.handler.allow_contact_info_choice(), - 'restrict_contact_info': self.handler.should_restrict_contact_info(), - 'contact_display': self.handler.get_contact_display(batch), - 'contact_phones': self.handler.get_contact_phones(batch), - 'contact_emails': self.handler.get_contact_emails(batch), - 'add_phone_number': bool(batch.get_param('add_phone_number')), - 'add_email_address': bool(batch.get_param('add_email_address')), - 'contact_profile_url': None, - 'contact_notes': self.handler.get_contact_notes(batch), - 'order_items': items, - 'product_autocomplete_url': self.request.route_url(product_autocomplete)} + context = self.get_context_contact(batch) - # maybe add profile URL - if batch.person_uuid: - if self.request.has_perm('people.view_profile'): - context['contact_profile_url'] = self.request.route_url( - 'people.view_profile', uuid=batch.person_uuid) + context.update({ + 'batch': batch, + 'normalized_batch': self.normalize_batch(batch), + 'new_order_requires_customer': self.handler.new_order_requires_customer(), + 'allow_contact_info_choice': self.handler.allow_contact_info_choice(), + 'restrict_contact_info': self.handler.should_restrict_contact_info(), + 'order_items': items, + 'product_autocomplete_url': self.request.route_url(product_autocomplete), + }) return self.render_to_response(template, context) @@ -403,14 +395,38 @@ class CustomerOrderView(MasterView): 'customer_uuid': batch.customer_uuid, 'person_uuid': batch.person_uuid, 'phone_number': batch.phone_number, + 'contact_display': self.handler.get_contact_display(batch), 'email_address': batch.email_address, 'contact_phones': self.handler.get_contact_phones(batch), 'contact_emails': self.handler.get_contact_emails(batch), 'contact_notes': self.handler.get_contact_notes(batch), 'add_phone_number': bool(batch.get_param('add_phone_number')), 'add_email_address': bool(batch.get_param('add_email_address')), + 'contact_profile_url': None, + 'new_customer_name': None, + 'new_customer_phone': None, + 'new_customer_email': None, } + pending = batch.pending_customer + if pending: + context.update({ + 'new_customer_name': pending.display_name, + 'new_customer_phone': pending.phone_number, + 'new_customer_email': pending.email_address, + }) + + # figure out if "contact is known" from user's perspective. + # if we have a uuid then it's definitely known, otherwise if + # we have a pending customer then it's definitely *not* known, + # but if no pending customer yet then we can still "assume" it + # is known, by default, until user specifies otherwise. + contact = self.handler.get_contact(batch) + if contact: + context['contact_is_known'] = True + else: + context['contact_is_known'] = not bool(pending) + # maybe add profile URL if batch.person_uuid: if self.request.has_perm('people.view_profile'): @@ -457,6 +473,35 @@ class CustomerOrderView(MasterView): 'email_address': batch.email_address, } + def update_pending_customer(self, batch, data): + model = self.model + app = self.get_rattail_app() + + # clear out any contact it may have + self.handler.unassign_contact(batch) + + # create pending customer if needed + pending = batch.pending_customer + if not pending: + pending = model.PendingCustomer() + pending.user = self.request.user + pending.status_code = self.enum.PENDING_CUSTOMER_STATUS_PENDING + batch.pending_customer = pending + + # update pending customer info + pending.display_name = data['display_name'] + pending.phone_number = app.format_phone_number(data['phone_number']) + pending.email_address = data['email_address'] + + # also update the batch w/ contact info + batch.phone_number = pending.phone_number + batch.email_address = pending.email_address + + self.Session.flush() + context = self.get_context_contact(batch) + context['success'] = True + return context + def product_autocomplete(self): """ Custom product autocomplete logic, which invokes the handler. From c611eb378770c9b52d610274ee0d082158854016 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 6 Oct 2021 18:43:52 -0400 Subject: [PATCH 0433/1681] Clear out contact for custorder if user clicks "customer is unknown" also show pending customer reference when viewing proper custorder --- tailbone/templates/custorders/create.mako | 10 ++++++++++ tailbone/views/custorders/orders.py | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index d921779e..194ec600 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -892,6 +892,16 @@ this.customerPanelOpen = true } }, + watch: { + contactIsKnown: function(val) { + // if user has already specified a proper contact, then + // clicks the "contact is unknown" button, then we want + // to *clear out* the existing contact + if (!val && this.contactUUID) { + this.contactChanged(null) + } + }, + }, methods: { startOverEntirely() { diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index 1c6aed08..58f33b32 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -68,6 +68,7 @@ class CustomerOrderView(MasterView): 'store', 'customer', 'person', + 'pending_customer', 'phone_number', 'email_address', 'total_price', @@ -134,6 +135,7 @@ class CustomerOrderView(MasterView): f.set_renderer('store', self.render_store) f.set_renderer('customer', self.render_customer) f.set_renderer('person', self.render_person) + f.set_renderer('pending_customer', self.render_pending_customer) f.set_type('total_price', 'currency') @@ -152,6 +154,14 @@ class CustomerOrderView(MasterView): url = self.request.route_url('people.view', uuid=person.uuid) return tags.link_to(text, url) + def render_pending_customer(self, batch, field): + pending = batch.pending_customer + if not pending: + return + text = six.text_type(pending) + url = self.request.route_url('pending_customers.view', uuid=pending.uuid) + return tags.link_to(text, url) + def get_row_data(self, order): return self.Session.query(model.CustomerOrderItem)\ .filter(model.CustomerOrderItem.order == order) From 5e339bb7ead4efdb8de6d89b61348de9074d7c2a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 7 Oct 2021 12:33:52 -0400 Subject: [PATCH 0434/1681] Improve contact name handling for new custorder --- tailbone/templates/custorders/create.mako | 47 +++++++++++++++-------- tailbone/views/custorders/batch.py | 4 +- tailbone/views/custorders/orders.py | 13 ++++++- 3 files changed, 45 insertions(+), 19 deletions(-) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index 194ec600..390d6f34 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -342,8 +342,13 @@ <div v-if="!contactIsKnown" style="padding-left: 10rem; display: flex;"> <div> - <b-field label="Customer Name"> - <span>{{ newCustomerName }}</span> + <b-field grouped> + <b-field label="First Name"> + <span>{{ newCustomerFirstName }}</span> + </b-field> + <b-field label="Last Name"> + <span>{{ newCustomerLastName }}</span> + </b-field> </b-field> <b-field grouped> <b-field label="Phone Number"> @@ -381,10 +386,16 @@ </header> <section class="modal-card-body"> - <b-field label="Customer Name"> - <b-input v-model="editNewCustomerName" - ref="editNewCustomerInput"> - </b-input> + <b-field grouped> + <b-field label="First Name"> + <b-input v-model="editNewCustomerFirstName" + ref="editNewCustomerInput"> + </b-input> + </b-field> + <b-field label="Last Name"> + <b-input v-model="editNewCustomerLastName"> + </b-input> + </b-field> </b-field> <b-field grouped> <b-field label="Phone Number"> @@ -674,13 +685,15 @@ % endif - newCustomerName: ${json.dumps(new_customer_name)|n}, + newCustomerFirstName: ${json.dumps(new_customer_first_name)|n}, + newCustomerLastName: ${json.dumps(new_customer_last_name)|n}, newCustomerPhone: ${json.dumps(new_customer_phone)|n}, newCustomerEmail: ${json.dumps(new_customer_email)|n}, contactNotes: ${json.dumps(contact_notes)|n}, editNewCustomerShowDialog: false, - editNewCustomerName: null, + editNewCustomerFirstName: null, + editNewCustomerLastName: null, editNewCustomerPhone: null, editNewCustomerEmail: null, editNewCustomerSaving: false, @@ -718,8 +731,8 @@ } } } else { - if (this.newCustomerName) { - text = "Customer: " + this.newCustomerName + if (this.contactDisplay) { + text = "Customer: " + this.contactDisplay } } @@ -772,7 +785,7 @@ } phoneNumber = this.orderPhoneNumber } else { // customer is not known - if (!this.newCustomerName) { + if (!this.contactDisplay) { return { type: 'is-danger', text: "Please identify the customer.", @@ -850,7 +863,7 @@ if (this.editNewCustomerSaving) { return true } - if (!this.editNewCustomerName) { + if (!(this.editNewCustomerFirstName && this.editNewCustomerLastName)) { return true } if (!(this.editNewCustomerPhone || this.editNewCustomerEmail)) { @@ -1147,7 +1160,8 @@ % endif editNewCustomerInit() { - this.editNewCustomerName = this.newCustomerName + this.editNewCustomerFirstName = this.newCustomerFirstName + this.editNewCustomerLastName = this.newCustomerLastName this.editNewCustomerPhone = this.newCustomerPhone this.editNewCustomerEmail = this.newCustomerEmail this.editNewCustomerShowDialog = true @@ -1161,14 +1175,17 @@ let params = { action: 'update_pending_customer', - display_name: this.editNewCustomerName, + first_name: this.editNewCustomerFirstName, + last_name: this.editNewCustomerLastName, phone_number: this.editNewCustomerPhone, email_address: this.editNewCustomerEmail, } this.submitBatchData(params, response => { if (response.data.success) { - this.newCustomerName = response.data.new_customer_name + this.contactDisplay = response.data.new_customer_name + this.newCustomerFirstName = response.data.new_customer_first_name + this.newCustomerLastName = response.data.new_customer_last_name this.newCustomerPhone = response.data.phone_number this.orderPhoneNumber = response.data.phone_number this.newCustomerEmail = response.data.email_address diff --git a/tailbone/views/custorders/batch.py b/tailbone/views/custorders/batch.py index fb47f247..d70e5e77 100644 --- a/tailbone/views/custorders/batch.py +++ b/tailbone/views/custorders/batch.py @@ -48,7 +48,7 @@ class CustomerOrderBatchView(BatchMasterView): grid_columns = [ 'id', - 'customer', + 'contact_name', 'rowcount', 'total_price', 'created', @@ -98,7 +98,7 @@ class CustomerOrderBatchView(BatchMasterView): g.set_type('total_price', 'currency') - g.set_link('customer') + g.set_link('contact_name') g.set_link('created') g.set_link('created_by') diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index 58f33b32..4ce1335c 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -405,7 +405,7 @@ class CustomerOrderView(MasterView): 'customer_uuid': batch.customer_uuid, 'person_uuid': batch.person_uuid, 'phone_number': batch.phone_number, - 'contact_display': self.handler.get_contact_display(batch), + 'contact_display': batch.contact_name, 'email_address': batch.email_address, 'contact_phones': self.handler.get_contact_phones(batch), 'contact_emails': self.handler.get_contact_emails(batch), @@ -414,6 +414,8 @@ class CustomerOrderView(MasterView): 'add_email_address': bool(batch.get_param('add_email_address')), 'contact_profile_url': None, 'new_customer_name': None, + 'new_customer_first_name': None, + 'new_customer_last_name': None, 'new_customer_phone': None, 'new_customer_email': None, } @@ -421,6 +423,8 @@ class CustomerOrderView(MasterView): pending = batch.pending_customer if pending: context.update({ + 'new_customer_first_name': pending.first_name, + 'new_customer_last_name': pending.last_name, 'new_customer_name': pending.display_name, 'new_customer_phone': pending.phone_number, 'new_customer_email': pending.email_address, @@ -486,6 +490,7 @@ class CustomerOrderView(MasterView): def update_pending_customer(self, batch, data): model = self.model app = self.get_rattail_app() + people = app.get_people_handler() # clear out any contact it may have self.handler.unassign_contact(batch) @@ -499,11 +504,15 @@ class CustomerOrderView(MasterView): batch.pending_customer = pending # update pending customer info - pending.display_name = data['display_name'] + pending.first_name = data['first_name'] + pending.last_name = data['last_name'] + pending.display_name = people.normalize_full_name(pending.first_name, + pending.last_name) pending.phone_number = app.format_phone_number(data['phone_number']) pending.email_address = data['email_address'] # also update the batch w/ contact info + batch.contact_name = pending.display_name batch.phone_number = pending.phone_number batch.email_address = pending.email_address From 284078ff713d23147543ed2e3fbcc62c19f6732f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 7 Oct 2021 13:08:48 -0400 Subject: [PATCH 0435/1681] Delete pending customer if deleting custorder batch also invoke handler to update pending customer info for batch, so the handler can add validation, e.g. unique email address check --- tailbone/views/custorders/orders.py | 44 ++++++++++++----------------- 1 file changed, 18 insertions(+), 26 deletions(-) diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index 4ce1335c..a718486f 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -302,6 +302,13 @@ class CustomerOrderView(MasterView): return batch def start_over_entirely(self, batch): + + # delete pending customer if present + pending = batch.pending_customer + if pending: + batch.pending_customer = None + self.Session.delete(pending) + # just delete current batch outright # TODO: should use self.handler.do_delete() instead? self.Session.delete(batch) @@ -314,6 +321,13 @@ class CustomerOrderView(MasterView): return self.redirect(url) def delete_batch(self, batch): + + # delete pending customer if present + pending = batch.pending_customer + if pending: + batch.pending_customer = None + self.Session.delete(pending) + # just delete current batch outright # TODO: should use self.handler.do_delete() instead? self.Session.delete(batch) @@ -488,33 +502,11 @@ class CustomerOrderView(MasterView): } def update_pending_customer(self, batch, data): - model = self.model - app = self.get_rattail_app() - people = app.get_people_handler() - # clear out any contact it may have - self.handler.unassign_contact(batch) - - # create pending customer if needed - pending = batch.pending_customer - if not pending: - pending = model.PendingCustomer() - pending.user = self.request.user - pending.status_code = self.enum.PENDING_CUSTOMER_STATUS_PENDING - batch.pending_customer = pending - - # update pending customer info - pending.first_name = data['first_name'] - pending.last_name = data['last_name'] - pending.display_name = people.normalize_full_name(pending.first_name, - pending.last_name) - pending.phone_number = app.format_phone_number(data['phone_number']) - pending.email_address = data['email_address'] - - # also update the batch w/ contact info - batch.contact_name = pending.display_name - batch.phone_number = pending.phone_number - batch.email_address = pending.email_address + try: + self.handler.update_pending_customer(batch, self.request.user, data) + except Exception as error: + return {'error': six.text_type(error)} self.Session.flush() context = self.get_context_contact(batch) From b9b5a0e79bb51a9b612cb6439caead092bfaa250 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 7 Oct 2021 19:36:17 -0400 Subject: [PATCH 0436/1681] 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 5b7a9d5f..0d5e46e3 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.158 (2021-10-07) +-------------------- + +* Add support for "new customer" when creating new custorder. + +* Improve contact name handling for new custorder. + + 0.8.157 (2021-10-06) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index bba88b3e..b2a27308 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.157' +__version__ = '0.8.158' From a919bfb6c518b639cb738f67f75f53cdd6165323 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 7 Oct 2021 21:13:59 -0400 Subject: [PATCH 0437/1681] Simplify template context customization for view_profile_buefy --- tailbone/views/people.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 615cb238..3a54559d 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -451,6 +451,12 @@ class PersonView(MasterView): template = 'view_profile_buefy' if use_buefy else 'view_profile' return self.render_to_response(template, context) + def template_kwargs_view_profile_buefy(self, **kwargs): + """ + Method stub, so subclass can always invoke super() for it. + """ + return kwargs + def get_max_lengths(self): model = self.model return { From ce969306f776bd0d96df7824cabe1a7818f14761 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 10 Oct 2021 18:42:46 -0400 Subject: [PATCH 0438/1681] 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 0d5e46e3..ad1ae269 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.159 (2021-10-10) +-------------------- + +* Simplify template context customization for view_profile_buefy. + + 0.8.158 (2021-10-07) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index b2a27308..bf07a45e 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.158' +__version__ = '0.8.159' From 7fabef60047edac2b85fc52354e44f1d72a876b6 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 10 Oct 2021 20:08:52 -0400 Subject: [PATCH 0439/1681] Stop rounding case/unit cost fields to 2 places for purchase batch --- tailbone/views/purchasing/batch.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index 95c12bb1..c00267a9 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -693,11 +693,10 @@ class PurchasingBatchView(BatchMasterView): f.set_type('units_mispick', 'quantity') # currency fields - f.set_type('po_unit_cost', 'currency') + # nb. we only show "total" fields as currency, but not case or + # unit cost fields, b/c currency is rounded to 2 places f.set_type('po_total', 'currency') f.set_type('po_total_calculated', 'currency') - f.set_type('invoice_unit_cost', 'currency') - f.set_type('catalog_unit_cost', 'currency') # upc f.set_type('upc', 'gpc') From ffb33d00c8eb04b7e2d0587c12bc273aaa7d1431 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 10 Oct 2021 20:21:41 -0400 Subject: [PATCH 0440/1681] Fix some phone/email bugs for new custorder page --- tailbone/templates/custorders/create.mako | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index 390d6f34..5aed3c7b 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -153,7 +153,13 @@ <div class="level"> <div class="level-left"> <div class="level-item"> - {{ orderPhoneNumber }} + <span v-if="orderPhoneNumber"> + {{ orderPhoneNumber }} + </span> + <span v-if="!orderPhoneNumber" + class="has-text-danger"> + (no valid phone number on file) + </span> </div> % if allow_contact_info_choice: <div class="level-item" @@ -240,7 +246,7 @@ </span> <span v-if="!orderEmailAddress" class="has-text-danger"> - (no valid email on file) + (no valid email address on file) </span> </div> % if allow_contact_info_choice: @@ -1035,7 +1041,7 @@ that.orderPhoneNumber = response.data.phone_number that.orderEmailAddress = response.data.email_address that.addOtherPhoneNumber = response.data.add_phone_number - that.addOtherEmailAddres = response.data.add_email_address + that.addOtherEmailAddress = response.data.add_email_address that.contactProfileURL = response.data.contact_profile_url that.contactPhones = response.data.contact_phones that.contactEmails = response.data.contact_emails @@ -1051,7 +1057,7 @@ editPhoneNumberInit() { this.existingPhoneUUID = null - let normalOrderPhone = this.orderPhoneNumber.replace(/\D/g, '') + let normalOrderPhone = (this.orderPhoneNumber || '').replace(/\D/g, '') for (let phone of this.contactPhones) { let normal = phone.number.replace(/\D/g, '') if (normal == normalOrderPhone) { From 3e796e91642469b1f1735a61dddcd33c732eed23 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 10 Oct 2021 20:24:26 -0400 Subject: [PATCH 0441/1681] Fix bug when making context for mailing address sometimes those belong to a non-person, e.g. customer --- tailbone/views/people.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 3a54559d..8e8374c4 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -515,8 +515,7 @@ class PersonView(MasterView): return context def get_context_address(self, address): - person = address.person - return { + context = { 'uuid': address.uuid, 'street': address.street, 'street2': address.street2, @@ -524,9 +523,15 @@ class PersonView(MasterView): 'state': address.state, 'zipcode': address.zipcode, 'display': six.text_type(address), - 'invalid': self.handler.address_is_invalid(person, address), } + model = self.model + if isinstance(address, model.PersonMailingAddress): + person = address.person + context['invalid'] = self.handler.address_is_invalid(person, address) + + return context + def get_context_customers(self, person): data = [] for cp in person._customers: From 66bc775e148f2383d77b52f66ebd61cce18b16b8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 10 Oct 2021 20:43:27 -0400 Subject: [PATCH 0442/1681] Improve display, handling for "add contact info to customer record" for new custorders page. in particular, show this flag in main screen --- tailbone/templates/custorders/create.mako | 72 ++++++++++++++--------- tailbone/views/custorders/orders.py | 2 + 2 files changed, 47 insertions(+), 27 deletions(-) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index 5aed3c7b..f353c8fc 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -153,13 +153,19 @@ <div class="level"> <div class="level-left"> <div class="level-item"> - <span v-if="orderPhoneNumber"> - {{ orderPhoneNumber }} - </span> - <span v-if="!orderPhoneNumber" + <div v-if="orderPhoneNumber"> + <p> + {{ orderPhoneNumber }} + </p> + <p v-if="addOtherPhoneNumber" + class="is-size-7 is-italic"> + will be added to customer record + </p> + </div> + <p v-if="!orderPhoneNumber" class="has-text-danger"> (no valid phone number on file) - </span> + </p> </div> % if allow_contact_info_choice: <div class="level-item" @@ -206,9 +212,9 @@ <b-field v-if="!existingPhoneUUID" grouped> - <b-input v-model="otherPhoneNumber"> + <b-input v-model="editPhoneNumberOther"> </b-input> - <b-checkbox v-model="addOtherPhoneNumber"> + <b-checkbox v-model="editPhoneNumberAddOther"> add this phone number to customer record </b-checkbox> </b-field> @@ -241,9 +247,15 @@ <div class="level"> <div class="level-left"> <div class="level-item"> - <span v-if="orderEmailAddress"> - {{ orderEmailAddress }} - </span> + <div v-if="orderEmailAddress"> + <p> + {{ orderEmailAddress }} + </p> + <p v-if="addOtherEmailAddress" + class="is-size-7 is-italic"> + will be added to customer record + </p> + </div> <span v-if="!orderEmailAddress" class="has-text-danger"> (no valid email address on file) @@ -293,9 +305,9 @@ <b-field v-if="!existingEmailUUID" grouped> - <b-input v-model="otherEmailAddress"> + <b-input v-model="editEmailAddressOther"> </b-input> - <b-checkbox v-model="addOtherEmailAddress"> + <b-checkbox v-model="editEmailAddressAddOther"> add this email address to customer record </b-checkbox> </b-field> @@ -671,22 +683,25 @@ orderPhoneNumber: ${json.dumps(batch.phone_number)|n}, contactPhones: ${json.dumps(contact_phones)|n}, + addOtherPhoneNumber: ${json.dumps(add_phone_number)|n}, orderEmailAddress: ${json.dumps(batch.email_address)|n}, contactEmails: ${json.dumps(contact_emails)|n}, + addOtherEmailAddress: ${json.dumps(add_email_address)|n}, % if allow_contact_info_choice: - existingPhoneUUID: null, - otherPhoneNumber: null, - addOtherPhoneNumber: ${json.dumps(add_phone_number)|n}, editPhoneNumberShowDialog: false, + editPhoneNumberOther: null, + editPhoneNumberAddOther: false, + existingPhoneUUID: null, editPhoneNumberSaving: false, - existingEmailUUID: null, - otherEmailAddress: null, - addOtherEmailAddress: ${json.dumps(add_email_address)|n}, editEmailAddressShowDialog: false, + editEmailAddressOther: null, + editEmailAddressAddOther: false, + existingEmailUUID: null, + editEmailAddressOther: null, editEmailAddressSaving: false, % endif @@ -833,7 +848,7 @@ if (this.editPhoneNumberSaving) { return true } - if (!this.existingPhoneUUID && !this.otherPhoneNumber) { + if (!this.existingPhoneUUID && !this.editPhoneNumberOther) { return true } return false @@ -850,7 +865,7 @@ if (this.editEmailAddressSaving) { return true } - if (!this.existingEmailUUID && !this.otherEmailAddress) { + if (!this.existingEmailUUID && !this.editEmailAddressOther) { return true } return false @@ -1065,7 +1080,8 @@ break } } - this.otherPhoneNumber = this.existingPhoneUUID ? null : this.orderPhoneNumber + this.editPhoneNumberOther = this.existingPhoneUUID ? null : this.orderPhoneNumber + this.editPhoneNumberAddOther = this.addOtherPhoneNumber this.editPhoneNumberShowDialog = true }, @@ -1089,13 +1105,14 @@ if (params.phone_number) { params.add_phone_number = false } else { - params.phone_number = this.otherPhoneNumber - params.add_phone_number = this.addOtherPhoneNumber + params.phone_number = this.editPhoneNumberOther + params.add_phone_number = this.editPhoneNumberAddOther } this.submitBatchData(params, response => { if (response.data.success) { this.orderPhoneNumber = response.data.phone_number + this.addOtherPhoneNumber = response.data.add_phone_number this.editPhoneNumberShowDialog = false } else { this.$buefy.toast.open({ @@ -1119,9 +1136,9 @@ break } } - this.otherEmailAddress = this.existingEmailUUID ? null : this.orderEmailAddress + this.editEmailAddressOther = this.existingEmailUUID ? null : this.orderEmailAddress + this.editEmailAddressAddOther = this.addOtherEmailAddress this.editEmailAddressShowDialog = true - }, editEmailAddressSave() { @@ -1144,13 +1161,14 @@ if (params.email_address) { params.add_email_address = false } else { - params.email_address = this.otherEmailAddress - params.add_email_address = this.addOtherEmailAddress + params.email_address = this.editEmailAddressOther + params.add_email_address = this.editEmailAddressAddOther } this.submitBatchData(params, response => { if (response.data.success) { this.orderEmailAddress = response.data.email_address + this.addOtherEmailAddress = response.data.add_email_address this.editEmailAddressShowDialog = false } else { this.$buefy.toast.open({ diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index a718486f..a7129d68 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -484,6 +484,7 @@ class CustomerOrderView(MasterView): return { 'success': True, 'phone_number': batch.phone_number, + 'add_phone_number': bool(batch.get_param('add_phone_number')), } def update_email_address(self, batch, data): @@ -499,6 +500,7 @@ class CustomerOrderView(MasterView): return { 'success': True, 'email_address': batch.email_address, + 'add_email_address': bool(batch.get_param('add_email_address')), } def update_pending_customer(self, batch, data): From 20492410ad63631db47acccc0729794c7b005090 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 11 Oct 2021 21:58:18 -0400 Subject: [PATCH 0443/1681] 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 ad1ae269..7aaee0a8 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,18 @@ CHANGELOG ========= +0.8.160 (2021-10-11) +-------------------- + +* Stop rounding case/unit cost fields to 2 places for purchase batch. + +* Fix some phone/email bugs for new custorder page. + +* Fix bug when making context for mailing address. + +* Improve display, handling for "add contact info to customer record". + + 0.8.159 (2021-10-10) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index bf07a45e..a1da473b 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.159' +__version__ = '0.8.160' From aeace0c7cf2eb1d31fb8f9163340b587b922b1af Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 12 Oct 2021 14:17:10 -0400 Subject: [PATCH 0444/1681] Add `debounce()` wrapper for buefy autocomplete per docs, although was not very clear "which" debounce i needed, this one at least works without errors.. hoping this fixes some page performance issues when tailbone autocomplete component is present --- tailbone/static/js/debounce.js | 36 +++++++++++++++++++ .../static/js/tailbone.buefy.autocomplete.js | 10 +++--- tailbone/templates/themes/falafel/base.mako | 3 ++ 3 files changed, 44 insertions(+), 5 deletions(-) create mode 100644 tailbone/static/js/debounce.js diff --git a/tailbone/static/js/debounce.js b/tailbone/static/js/debounce.js new file mode 100644 index 00000000..8fea0eda --- /dev/null +++ b/tailbone/static/js/debounce.js @@ -0,0 +1,36 @@ + +// this code was politely stolen from +// https://vanillajstoolkit.com/helpers/debounce/ + +// its purpose is to help with Buefy autocomplete performance +// https://buefy.org/documentation/autocomplete/ + +/** + * Debounce functions for better performance + * (c) 2021 Chris Ferdinandi, MIT License, https://gomakethings.com + * @param {Function} fn The function to debounce + */ +function debounce (fn) { + + // Setup a timer + let timeout; + + // Return a function to run debounced + return function () { + + // Setup the arguments + let context = this; + let args = arguments; + + // If there's a timer, cancel it + if (timeout) { + window.cancelAnimationFrame(timeout); + } + + // Setup the new requestAnimationFrame() + timeout = window.requestAnimationFrame(function () { + fn.apply(context, args); + }); + + }; +} diff --git a/tailbone/static/js/tailbone.buefy.autocomplete.js b/tailbone/static/js/tailbone.buefy.autocomplete.js index eb36fa74..9b28f6b3 100644 --- a/tailbone/static/js/tailbone.buefy.autocomplete.js +++ b/tailbone/static/js/tailbone.buefy.autocomplete.js @@ -98,7 +98,7 @@ const TailboneAutocomplete = { // TODO: buefy example uses `debounce()` here and perhaps we should too? // https://buefy.org/documentation/autocomplete - getAsyncData: function (entry) { + getAsyncData: debounce(function (entry) { if (entry.length < 3) { this.data = [] return @@ -112,10 +112,10 @@ const TailboneAutocomplete = { this.data = [] throw error }) - .finally(() => { - this.isFetching = false - }) - }, + .finally(() => { + this.isFetching = false + }) + }), }, } diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index 24b533d1..bf8f5ee7 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -77,6 +77,9 @@ ## some commonly-useful logic for detecting (non-)numeric input ${h.javascript_link(request.static_url('tailbone:static/js/numeric.js') + '?ver={}'.format(tailbone.__version__))} + ## debounce, for better autocomplete performance + ${h.javascript_link(request.static_url('tailbone:static/js/debounce.js') + '?ver={}'.format(tailbone.__version__))} + ## Tailbone / Buefy stuff ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.datepicker.js') + '?ver={}'.format(tailbone.__version__))} ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.numericinput.js') + '?ver={}'.format(tailbone.__version__))} From e3cad91be0d8520762de6f750837c32f729dc0d3 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 12 Oct 2021 18:22:04 -0400 Subject: [PATCH 0445/1681] Leverage the auth handler for main user login --- tailbone/views/auth.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index 51b27f14..d071ace7 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -135,7 +135,9 @@ class AuthenticationView(View): return context def authenticate_user(self, username, password): - return authenticate_user(Session(), username, password) + app = self.get_rattail_app() + auth = app.get_auth_handler() + return auth.authenticate_user(Session(), username, password) def logout(self, **kwargs): """ From 1463c09385c60451222af3378cbd0f9fc165b29a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 13 Oct 2021 12:19:49 -0400 Subject: [PATCH 0446/1681] 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 7aaee0a8..d2dcaee4 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.161 (2021-10-13) +-------------------- + +* Add ``debounce()`` wrapper for buefy autocomplete. + +* Leverage the auth handler for main user login. + + 0.8.160 (2021-10-11) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index a1da473b..e20174fb 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.160' +__version__ = '0.8.161' From 80589cde2f6de69a6b4a855af6f19f8e02512cdc Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 13 Oct 2021 17:29:41 -0400 Subject: [PATCH 0447/1681] Cleanup form display a bit, for App Settings --- tailbone/templates/appsettings.mako | 37 ++++++++++++++++------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/tailbone/templates/appsettings.mako b/tailbone/templates/appsettings.mako index 888a5b2a..79b2d952 100644 --- a/tailbone/templates/appsettings.mako +++ b/tailbone/templates/appsettings.mako @@ -88,7 +88,9 @@ </header> <div class="card-content"> <div v-for="setting in group.settings" - :class="'field-wrapper' + (setting.error ? ' with-error' : '')"> + ## TODO: not sure how the error handling looks now? + ## :class="'field-wrapper' + (setting.error ? ' with-error' : '')" + > <div v-if="setting.error" class="field-error"> <span v-for="msg in setting.error_messages" @@ -97,16 +99,18 @@ </span> </div> - <div class="field-row"> - <label :for="setting.field_name">{{ setting.label }}</label> - <div class="field"> + <div style="margin-bottom: 2rem;"> - <input v-if="setting.data_type == 'bool'" - type="checkbox" - :name="setting.field_name" - :id="setting.field_name" - v-model="setting.value" - value="true" /> + <b-field horizontal + :label="setting.label"> + + <b-checkbox v-if="setting.data_type == 'bool'" + :name="setting.field_name" + :id="setting.field_name" + v-model="setting.value" + native-value="true"> + {{ setting.value || false }} + </b-checkbox> <b-input v-else-if="setting.data_type == 'list'" type="textarea" @@ -128,14 +132,15 @@ :name="setting.field_name" :id="setting.field_name" v-model="setting.value" /> - </div> + + </b-field> + + <span v-if="setting.helptext" class="instructions"> + {{ setting.helptext }} + </span> </div> - <span v-if="setting.helptext" class="instructions"> - {{ setting.helptext }} - </span> - - </div><!-- field-wrapper --> + </div> </div><!-- card-content --> </div><!-- card --> From 22aa55c24bf34757e340ceedea7446fd06540f01 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 14 Oct 2021 10:39:54 -0400 Subject: [PATCH 0448/1681] Invoke the auth handler to cache user permissions etc. various changes for sake of "synced" roles feature --- tailbone/auth.py | 11 +++++----- tailbone/subscribers.py | 14 +++++++++++-- tailbone/views/roles.py | 45 ++++++++++++++++++++++++++++++++++++----- tailbone/views/users.py | 16 +++++++++++++++ 4 files changed, 74 insertions(+), 12 deletions(-) diff --git a/tailbone/auth.py b/tailbone/auth.py index 338fac55..deda1ab7 100644 --- a/tailbone/auth.py +++ b/tailbone/auth.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -93,9 +93,6 @@ def set_session_timeout(request, timeout): class TailboneAuthorizationPolicy(object): def permits(self, context, principals, permission): - from rattail.db import model - from rattail.db.auth import has_permission - for userid in principals: if userid not in (Everyone, Authenticated): if context.request.user and context.request.user.uuid == userid: @@ -103,11 +100,15 @@ class TailboneAuthorizationPolicy(object): else: # this is pretty rare, but can happen in dev after # re-creating the database, which means new user uuids. + config = context.request.rattail_config + model = config.get_model() + app = config.get_app() + auth = app.get_auth_handler() # TODO: the odds of this query returning a user in that # case, are probably nil, and we should just skip this bit? user = Session.query(model.User).get(userid) if user: - if has_permission(Session(), user, permission): + if auth.has_permission(Session(), user, permission): return True if Everyone in principals: return has_permission(Session(), None, permission) diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index b0834496..5468df7f 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -32,7 +32,6 @@ import datetime import rattail from rattail.db import model -from rattail.db.auth import cache_permissions import colander import deform @@ -69,6 +68,7 @@ def new_request(event): """ request = event.request rattail_config = request.registry.settings.get('rattail_config') + # TODO: why would this ever be null? if rattail_config: request.rattail_config = rattail_config @@ -86,7 +86,17 @@ def new_request(event): request.is_admin = bool(request.user) and request.user.is_admin() request.is_root = request.is_admin and request.session.get('is_root', False) - request.tailbone_cached_permissions = cache_permissions(Session(), request.user) + if rattail_config: + app = rattail_config.get_app() + auth = app.get_auth_handler() + request.tailbone_cached_permissions = auth.cache_permissions( + Session(), request.user) + else: + # TODO: not sure why this would really work, or even be + # needed, if there was no rattail config? + from rattail.db.auth import cache_permissions + request.tailbone_cached_permissions = cache_permissions( + Session(), request.user) def before_render(event): diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py index 3cd62571..8dde78b8 100644 --- a/tailbone/views/roles.py +++ b/tailbone/views/roles.py @@ -52,10 +52,13 @@ class RoleView(PrincipalMasterView): """ model_class = model.Role has_versions = True + touchable = True grid_columns = [ 'name', 'session_timeout', + 'sync_me', + 'node_type', 'notes', ] @@ -63,6 +66,8 @@ class RoleView(PrincipalMasterView): 'name', 'session_timeout', 'notes', + 'sync_me', + 'node_type', 'users', 'permissions', ] @@ -93,6 +98,11 @@ class RoleView(PrincipalMasterView): We must prevent edit for certain built-in roles etc., depending on current user's permissions. """ + # role with node type specified, can only be edited from a + # node of the same type + if role.node_type and role.node_type != self.rattail_config.node_type(): + return False + # only "root" can edit Administrator if role is administrator_role(self.Session()): return self.request.is_root @@ -116,6 +126,11 @@ class RoleView(PrincipalMasterView): """ We must prevent deletion for all built-in roles. """ + # role with node type specified, can only be edited from a + # node of the same type + if role.node_type and role.node_type != self.rattail_config.node_type(): + return False + if role is administrator_role(self.Session()): return False if role is authenticated_role(self.Session()): @@ -147,6 +162,27 @@ class RoleView(PrincipalMasterView): # name f.set_validator('name', self.unique_name) + # session_timeout + f.set_renderer('session_timeout', self.render_session_timeout) + if self.editing and role is guest_role(self.Session()): + f.set_readonly('session_timeout') + + # sync_me, node_type + if not self.creating: + include = True + if role is administrator_role(self.Session()): + include = False + elif role is authenticated_role(self.Session()): + include = False + elif role is guest_role(self.Session()): + include = False + if not include: + f.remove('sync_me', 'node_type') + else: + if not self.has_perm('edit_node_sync'): + f.set_readonly('sync_me') + f.set_readonly('node_type') + # notes f.set_type('notes', 'text_wrapped') @@ -173,11 +209,6 @@ class RoleView(PrincipalMasterView): elif self.deleting: f.remove_field('permissions') - # session_timeout - f.set_renderer('session_timeout', self.render_session_timeout) - if self.editing and role is guest_role(self.Session()): - f.set_readonly('session_timeout') - def render_users(self, role, field): if role is guest_role(self.Session()): @@ -417,6 +448,7 @@ class RoleView(PrincipalMasterView): route_prefix = cls.get_route_prefix() url_prefix = cls.get_url_prefix() permission_prefix = cls.get_permission_prefix() + model_title = cls.get_model_title() # extra permissions for editing built-in roles etc. config.add_tailbone_permission(permission_prefix, '{}.edit_authenticated'.format(permission_prefix), @@ -425,6 +457,9 @@ class RoleView(PrincipalMasterView): "Edit the \"Guest\" Role") config.add_tailbone_permission(permission_prefix, '{}.edit_my'.format(permission_prefix), "Edit Role(s) to which current user belongs") + config.add_tailbone_permission(permission_prefix, + '{}.edit_node_sync'.format(permission_prefix), + "Edit the Node Type and Sync flags for a {}".format(model_title)) # download permissions matrix config.add_tailbone_permission(permission_prefix, '{}.download_permissions_matrix'.format(permission_prefix), diff --git a/tailbone/views/users.py b/tailbone/views/users.py index 6c8000ad..b30034b4 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -320,6 +320,14 @@ class UserView(PrincipalMasterView): if self.request.is_root or uuid != admin.uuid: user._roles.append(model.UserRole(role_uuid=uuid)) + # also record a change to the role, for datasync. + # this is done "just in case" the role is to be + # synced to all nodes + if self.Session().rattail_record_changes: + self.Session.add(model.Change(class_name='Role', + instance_uuid=uuid, + deleted=False)) + # remove any roles which were *not* specified, although must take care # not to remove admin role, unless acting as root for uuid in old_roles: @@ -328,6 +336,14 @@ class UserView(PrincipalMasterView): role = self.Session.query(model.Role).get(uuid) user.roles.remove(role) + # also record a change to the role, for datasync. + # this is done "just in case" the role is to be + # synced to all nodes + if self.Session().rattail_record_changes: + self.Session.add(model.Change(class_name='Role', + instance_uuid=uuid, + deleted=False)) + def render_person(self, user, field): person = user.person if not person: From d61fa7b6b916b6c55177cfa5d6bc4e7ae100f684 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 14 Oct 2021 12:12:10 -0400 Subject: [PATCH 0449/1681] 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 d2dcaee4..50f39f37 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.162 (2021-10-14) +-------------------- + +* Cleanup form display a bit, for App Settings. + +* Invoke the auth handler to cache user permissions etc. + + 0.8.161 (2021-10-13) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index e20174fb..8acdddff 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.161' +__version__ = '0.8.162' From dd6c9cc8cebddabf584bbae8395ef0d19a1a0428 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 14 Oct 2021 14:18:36 -0400 Subject: [PATCH 0450/1681] Misc. tweaks for users, roles --- tailbone/views/roles.py | 2 +- tailbone/views/users.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py index 8dde78b8..ded1f5cf 100644 --- a/tailbone/views/roles.py +++ b/tailbone/views/roles.py @@ -187,7 +187,7 @@ class RoleView(PrincipalMasterView): f.set_type('notes', 'text_wrapped') # users - if use_buefy and self.viewing or self.deleting: + if use_buefy and self.viewing: f.set_renderer('users', self.render_users) else: f.remove('users') diff --git a/tailbone/views/users.py b/tailbone/views/users.py index b30034b4..b0f6a5de 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -52,6 +52,7 @@ class UserView(PrincipalMasterView): has_rows = True model_row_class = model.UserEvent has_versions = True + touchable = True grid_columns = [ 'username', From 1b33c8a2b75429c67c38213a8701f3534f9a8e83 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 14 Oct 2021 14:22:07 -0400 Subject: [PATCH 0451/1681] 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 50f39f37..540b069d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.163 (2021-10-14) +-------------------- + +* Misc. tweaks for users, roles. + + 0.8.162 (2021-10-14) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 8acdddff..29fe3709 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.162' +__version__ = '0.8.163' From 53fc1508f3c5925ce3a6526efcad0604a9a8a5d2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 14 Oct 2021 17:49:12 -0400 Subject: [PATCH 0452/1681] Give custorder batch handler a couple ways to affect adding new items --- tailbone/views/custorders/orders.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index a7129d68..b7b583dc 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -530,10 +530,19 @@ class CustomerOrderView(MasterView): return {'error': "Must specify a product UPC"} product = self.handler.locate_product_for_entry( - self.Session(), upc, product_key='upc') + self.Session(), upc, product_key='upc', + # nb. let handler know "why" we're doing this, so that it + # can "modify" the result accordingly, i.e. return the + # appropriate item when a "different" scancode is entered + # by the user (e.g. PLU, and/or units vs. packs) + variation='new_custorder') if not product: return {'error': "Product not found"} + reason = self.handler.why_not_add_product(product, batch) + if reason: + return {'error': reason} + return self.info_for_product(batch, data, product) def get_product_info(self, batch, data): From 232a02b944ada5f9afafe428a07be938f93943df Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 14 Oct 2021 21:42:16 -0400 Subject: [PATCH 0453/1681] Refactor to leverage all existing methods of auth handler instead of importing and calling functions from core rattail --- tailbone/api/auth.py | 15 ++++++++++----- tailbone/auth.py | 11 ++++++----- tailbone/subscribers.py | 13 +++++++------ tailbone/views/auth.py | 7 +++++-- tailbone/views/principal.py | 10 ++++++---- tailbone/views/roles.py | 30 ++++++++++++++++++++++-------- tailbone/views/users.py | 11 ++++++++--- 7 files changed, 64 insertions(+), 33 deletions(-) diff --git a/tailbone/api/auth.py b/tailbone/api/auth.py index 16e48e82..80f8fac0 100644 --- a/tailbone/api/auth.py +++ b/tailbone/api/auth.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -26,7 +26,7 @@ Tailbone Web API - Auth Views from __future__ import unicode_literals, absolute_import -from rattail.db.auth import authenticate_user, set_user_password, cache_permissions +from rattail.db.auth import set_user_password from cornice import Service @@ -82,15 +82,20 @@ class AuthenticationView(APIView): if error: return {'error': error} + app = self.get_rattail_app() + auth = app.get_auth_handler() + login_user(self.request, user) return { 'ok': True, 'user': self.get_user_info(user), - 'permissions': list(cache_permissions(Session(), user)), + 'permissions': list(auth.cache_permissions(Session(), user)), } def authenticate_user(self, username, password): - return authenticate_user(Session(), username, password) + app = self.get_rattail_app() + auth = app.get_auth_handler() + return auth.authenticate_user(Session(), username, password) def why_cant_user_login(self, user): """ @@ -156,7 +161,7 @@ class AuthenticationView(APIView): data = self.request.json_body # first make sure "current" password is accurate - if not authenticate_user(Session(), self.request.user, data['current_password']): + if not self.authenticate_user(self.request.user, data['current_password']): return {'error': "The current/old password you provided is incorrect"} # okay then, set new password diff --git a/tailbone/auth.py b/tailbone/auth.py index deda1ab7..88fbab0b 100644 --- a/tailbone/auth.py +++ b/tailbone/auth.py @@ -93,6 +93,11 @@ def set_session_timeout(request, timeout): class TailboneAuthorizationPolicy(object): def permits(self, context, principals, permission): + config = context.request.rattail_config + model = config.get_model() + app = config.get_app() + auth = app.get_auth_handler() + for userid in principals: if userid not in (Everyone, Authenticated): if context.request.user and context.request.user.uuid == userid: @@ -100,10 +105,6 @@ class TailboneAuthorizationPolicy(object): else: # this is pretty rare, but can happen in dev after # re-creating the database, which means new user uuids. - config = context.request.rattail_config - model = config.get_model() - app = config.get_app() - auth = app.get_auth_handler() # TODO: the odds of this query returning a user in that # case, are probably nil, and we should just skip this bit? user = Session.query(model.User).get(userid) @@ -111,7 +112,7 @@ class TailboneAuthorizationPolicy(object): if auth.has_permission(Session(), user, permission): return True if Everyone in principals: - return has_permission(Session(), None, permission) + return auth.has_permission(Session(), None, permission) return False def principals_allowed_by_permission(self, context, permission): diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 5468df7f..6fbced82 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -91,12 +91,13 @@ def new_request(event): auth = app.get_auth_handler() request.tailbone_cached_permissions = auth.cache_permissions( Session(), request.user) - else: - # TODO: not sure why this would really work, or even be - # needed, if there was no rattail config? - from rattail.db.auth import cache_permissions - request.tailbone_cached_permissions = cache_permissions( - Session(), request.user) + # TODO: until we know otherwise, let's assume this is not needed + # else: + # # TODO: not sure why this would really work, or even be + # # needed, if there was no rattail config? + # from rattail.db.auth import cache_permissions + # request.tailbone_cached_permissions = cache_permissions( + # Session(), request.user) def before_render(event): diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index d071ace7..efe2794d 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -50,9 +50,12 @@ class UserLogin(colander.MappingSchema): @colander.deferred def current_password_correct(node, kw): + request = kw['request'] + app = request.rattail_config.get_app() + auth = app.get_auth_handler() user = kw['user'] def validate(node, value): - if not authenticate_user(Session(), user.username, value): + if not auth.authenticate_user(Session(), user.username, value): raise colander.Invalid(node, "The password is incorrect") return validate @@ -175,7 +178,7 @@ class AuthenticationView(View): return self.redirect(self.request.get_referrer()) use_buefy = self.get_use_buefy() - schema = ChangePassword().bind(user=self.request.user) + schema = ChangePassword().bind(user=self.request.user, request=self.request) form = forms.Form(schema=schema, request=self.request, use_buefy=use_buefy) if form.validate(newstyle=True): set_user_password(self.request.user, form.validated['new_password']) diff --git a/tailbone/views/principal.py b/tailbone/views/principal.py index b3d032ab..3fc5ce6b 100644 --- a/tailbone/views/principal.py +++ b/tailbone/views/principal.py @@ -28,7 +28,6 @@ from __future__ import unicode_literals, absolute_import import copy -from rattail.db.auth import has_permission from rattail.core import Object from rattail.util import OrderedDict @@ -144,6 +143,9 @@ class PermissionsRenderer(Object): return self.render() def render(self): + app = self.request.rattail_config.get_app() + auth = app.get_handler() + principal = self.principal html = '' for groupkey in sorted(self.permissions, key=lambda k: self.permissions[k]['label'].lower()): @@ -151,9 +153,9 @@ class PermissionsRenderer(Object): perms = self.permissions[groupkey]['perms'] rendered = False for key in sorted(perms, key=lambda p: perms[p]['label'].lower()): - checked = has_permission(Session(), principal, key, - include_guest=self.include_guest, - include_authenticated=self.include_authenticated) + checked = auth.has_permission(Session(), principal, key, + include_guest=self.include_guest, + include_authenticated=self.include_authenticated) if checked: label = perms[key]['label'] span = HTML.tag('span', c="[X]" if checked else "[ ]") diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py index ded1f5cf..2ce48f0d 100644 --- a/tailbone/views/roles.py +++ b/tailbone/views/roles.py @@ -33,8 +33,7 @@ from sqlalchemy import orm from openpyxl.styles import Font, PatternFill from rattail.db import model -from rattail.db.auth import (has_permission, grant_permission, revoke_permission, - administrator_role, guest_role, authenticated_role) +from rattail.db.auth import administrator_role, guest_role, authenticated_role from rattail.excel import ExcelWriter import colander @@ -158,6 +157,8 @@ class RoleView(PrincipalMasterView): super(RoleView, self).configure_form(f) role = f.model_instance use_buefy = self.get_use_buefy() + app = self.get_rattail_app() + auth = app.get_auth_handler() # name f.set_validator('name', self.unique_name) @@ -194,7 +195,8 @@ class RoleView(PrincipalMasterView): # permissions self.tailbone_permissions = self.get_available_permissions() - f.set_renderer('permissions', PermissionsRenderer(permissions=self.tailbone_permissions)) + f.set_renderer('permissions', PermissionsRenderer(request=self.request, + permissions=self.tailbone_permissions)) f.set_node('permissions', colander.Set()) f.set_widget('permissions', PermissionsWidget( permissions=self.tailbone_permissions, @@ -203,7 +205,9 @@ class RoleView(PrincipalMasterView): granted = [] for groupkey in self.tailbone_permissions: for key in self.tailbone_permissions[groupkey]['perms']: - if has_permission(self.Session(), role, key, include_guest=False, include_authenticated=False): + if auth.has_permission(self.Session(), role, key, + include_guest=False, + include_authenticated=False): granted.append(key) f.set_default('permissions', granted) elif self.deleting: @@ -309,13 +313,16 @@ class RoleView(PrincipalMasterView): permissions, but rather each "available" permission (depends on current user) will be examined individually, and updated as needed. """ + app = self.get_rattail_app() + auth = app.get_auth_handler() + available = self.tailbone_permissions for gkey, group in six.iteritems(available): for pkey, perm in six.iteritems(group['perms']): if pkey in permissions: - grant_permission(role, pkey) + auth.grant_permission(role, pkey) else: - revoke_permission(role, pkey) + auth.revoke_permission(role, pkey) def template_kwargs_view(self, **kwargs): role = kwargs['instance'] @@ -364,13 +371,16 @@ class RoleView(PrincipalMasterView): return self.redirect(self.request.get_referrer(default=self.request.route_url('roles'))) def find_principals_with_permission(self, session, permission): + app = self.get_rattail_app() + auth = app.get_auth_handler() + # TODO: this should search Permission table instead, and work backward to Role? all_roles = session.query(model.Role)\ .order_by(model.Role.name)\ .options(orm.joinedload(model.Role._permissions)) roles = [] for role in all_roles: - if has_permission(session, role, permission, include_guest=False): + if auth.has_permission(session, role, permission, include_guest=False): roles.append(role) return roles @@ -379,6 +389,9 @@ class RoleView(PrincipalMasterView): View which renders the complete role / permissions matrix data into an Excel spreadsheet, and returns that file. """ + app = self.get_rattail_app() + auth = app.get_auth_handler() + roles = self.Session.query(model.Role)\ .order_by(model.Role.name)\ .all() @@ -427,7 +440,8 @@ class RoleView(PrincipalMasterView): # and show an 'X' for any role which has this perm for col, role in enumerate(roles, 2): - if has_permission(self.Session(), role, key, include_guest=False): + if auth.has_permission(self.Session(), role, key, + include_guest=False): sheet.cell(row=writing_row, column=col, value="X") writing_row += 1 diff --git a/tailbone/views/users.py b/tailbone/views/users.py index b0f6a5de..7b8312bd 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -32,7 +32,8 @@ import six from sqlalchemy import orm from rattail.db import model -from rattail.db.auth import administrator_role, guest_role, authenticated_role, set_user_password, has_permission +from rattail.db.auth import (administrator_role, guest_role, + authenticated_role, set_user_password) import colander from deform import widget as dfwidget @@ -239,7 +240,8 @@ class UserView(PrincipalMasterView): if self.viewing: permissions = self.request.registry.settings.get('tailbone_permissions', {}) f.append('permissions') - f.set_renderer('permissions', PermissionsRenderer(permissions=permissions, + f.set_renderer('permissions', PermissionsRenderer(request=self.request, + permissions=permissions, include_guest=True, include_authenticated=True)) @@ -389,6 +391,9 @@ class UserView(PrincipalMasterView): ] def find_principals_with_permission(self, session, permission): + app = self.get_rattail_app() + auth = app.get_auth_handler() + # TODO: this should search Permission table instead, and work backward to User? all_users = session.query(model.User)\ .filter(model.User.active == True)\ @@ -398,7 +403,7 @@ class UserView(PrincipalMasterView): .joinedload(model.Role._permissions)) users = [] for user in all_users: - if has_permission(session, user, permission): + if auth.has_permission(session, user, permission): users.append(user) return users From 52fbe73893f3ed3a48709745092fd0c4e9cae91b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 16 Oct 2021 15:37:23 -0400 Subject: [PATCH 0454/1681] Overhaul the autocomplete component, for sake of new custorder turns out we had some issues with our understanding of how that all was supposed to work. this seems to be much cleaner and even semi-documented :) --- .../static/js/tailbone.buefy.autocomplete.js | 229 ++++++++++++------ tailbone/templates/autocomplete.mako | 5 +- tailbone/templates/custorders/create.mako | 18 +- 3 files changed, 166 insertions(+), 86 deletions(-) diff --git a/tailbone/static/js/tailbone.buefy.autocomplete.js b/tailbone/static/js/tailbone.buefy.autocomplete.js index 9b28f6b3..7969f35a 100644 --- a/tailbone/static/js/tailbone.buefy.autocomplete.js +++ b/tailbone/static/js/tailbone.buefy.autocomplete.js @@ -4,16 +4,54 @@ const TailboneAutocomplete = { template: '#tailbone-autocomplete-template', props: { + + // this is the "input" field name essentially. primarily is + // useful for "traditional" tailbone forms; it normally is not + // used otherwise. it is passed as-is to the buefy + // autocomplete component `name` prop name: String, + + // the url from which search results are to be obtained. the + // url should expect a GET request with a query string with a + // single `term` parameter, and return results as a JSON array + // containing objects with `value` and `label` properties. serviceUrl: String, + + // callers do not specify this directly but rather by way of + // the `v-model` directive. this component will emit `input` + // events when the value changes value: String, + + // callers may set an initial label if needed. this is useful + // in cases where the autocomplete needs to "already have a + // value" on page load. for instance when a user fills out + // the autocomplete field, but leaves other required fields + // blank and submits the form; page will re-load showing + // errors but the autocomplete field should remain "set" - + // normally it is only given a "value" (e.g. uuid) but this + // allows for the "label" to display correctly as well initialLabel: String, - assignedValue: String, + + // TODO: i am not sure this is needed? but current logic does + // handle it specially, so am leaving for now. if this prop + // is set by the caller, then the `assignedLabel` will *always* + // be shown for the button (when "selection" has been made) assignedLabel: String, + + // simple placeholder text for the input box placeholder: String, + + // TODO: pretty sure this can be ignored..? + // (should deprecate / remove if so) + assignedValue: String, }, data() { + + // we want to track the "currently selected option" - which + // should normally be `null` to begin with, unless we were + // given a value, in which case we use `initialLabel` to + // complete the option let selected = null if (this.value) { selected = { @@ -21,89 +59,55 @@ const TailboneAutocomplete = { label: this.initialLabel, } } - return { - data: [], - selected: selected, - isFetching: false, - } - }, - watch: { - value(to, from) { - if (from && !to) { - this.clearSelection(false) - } - }, + return { + + // this contains the search results; its contents may + // change over time as new searches happen. the + // "currently selected option" should be one of these, + // unless it is null + data: [], + + // this tracks our "currently selected option" - per above + selected: selected, + + // since we are wrapping a component which also makes use + // of the "value" paradigm, we must separate the concerns. + // so we use our own `value` prop to interact with the + // caller, but then we use this `buefyValue` data point to + // communicate with the buefy autocomplete component. + // note that `this.value` will always be either a uuid or + // null, whereas `this.buefyValue` may be raw text as + // entered by the user. + buefyValue: this.value, + + // // TODO: we are "setting" this at the appropriate time, + // // but not clear if that actually affects anything. + // // should we just remove it? + // isFetching: false, + } }, methods: { - clearSelection(focus) { - if (focus === undefined) { - focus = true - } - this.selected = null - this.value = null - if (focus) { - this.$nextTick(function() { - this.focus() - }) - } - - // TODO: should emit event for caller logic (can they cancel?) - // $('#' + oid + '-textbox').trigger('autocompletevaluecleared'); - }, - - focus() { - this.$refs.autocomplete.focus() - }, - - getDisplayText() { - if (this.assignedLabel) { - return this.assignedLabel - } - if (this.selected) { - return this.selected.display || this.selected.label - } - return "" - }, - - // TODO: should we allow custom callback? or is event enough? - // function (oid) { - // $('#' + oid + '-textbox').on('autocompletevaluecleared', function() { - // ${cleared_callback}(); - // }); - // } - - selectionMade(option) { - this.selected = option - - // TODO: should emit event for caller logic (can they cancel?) - // $('#' + oid + '-textbox').trigger('autocompletevalueselected', - // [ui.item.value, ui.item.label]); - }, - - // TODO: should we allow custom callback? or is event enough? - // function (oid) { - // $('#' + oid + '-textbox').on('autocompletevalueselected', function(event, uuid, label) { - // ${selected_callback}(uuid, label); - // }); - // } - - itemSelected(value) { - if (this.selected || !value) { - this.$emit('input', value) - } - }, - - // TODO: buefy example uses `debounce()` here and perhaps we should too? - // https://buefy.org/documentation/autocomplete + // fetch new search results from the server. this is invoked + // via the `@typing` event from buefy autocomplete component. + // the doc at https://buefy.org/documentation/autocomplete + // mentions `debounce` as being optional. at one point i + // thought it would fix a performance bug; not sure `debounce` + // helped but figured might as well leave it getAsyncData: debounce(function (entry) { + + // since the `@typing` event from buefy component does not + // "self-regulate" in any way, we a) use `debounce` above, + // but also b) skip the search unless we have at least 3 + // characters of input from user if (entry.length < 3) { this.data = [] return } - this.isFetching = true + + // and perform the search this.$http.get(this.serviceUrl + '?term=' + encodeURIComponent(entry)) .then(({ data }) => { this.data = data @@ -112,10 +116,81 @@ const TailboneAutocomplete = { this.data = [] throw error }) - .finally(() => { - this.isFetching = false - }) }), + + // this method is invoked via the `@select` event of the buefy + // autocomplete component. the `option` received will either + // be `null` or else a simple object with (at least) `value` + // and `label` properties + selectionMade(option) { + + // we want to keep track of the "currently selected + // option" so we can display its label etc. also this + // helps control the visibility of the autocomplete input + // field vs. the button which indicates the field has a + // value + this.selected = option + + // reset the internal value for buefy autocomplete + // component. note that this value will normally hold + // either the raw text entered by the user, or a uuid. we + // will not be needing either of those b/c they are not + // visible to user once selection is made, and if the + // selection is cleared we want user to start over anyway + this.buefyValue = null + + // here is where we alert callers to the new value + this.$emit('input', option ? option.value : null) + }, + + // clear the field of any value, i.e. set the "currently + // selected option" to null. this is invoked when you click + // the button, which is visible while the field has a value. + // but callers can invoke it directly as well. + clearSelection(focus) { + + // clear selection for the buefy autocomplete component + this.$refs.autocomplete.setSelected(null) + + // maybe set focus to our (autocomplete) component + if (focus) { + this.$nextTick(function() { + this.focus() + }) + } + }, + + // set focus to this component, which will just set focus to + // the buefy autocomplete component + focus() { + this.$refs.autocomplete.focus() + }, + + // this determines the "display text" for the button, which is + // shown when a selection has been made (or rather, when the + // field actually has a value) + getDisplayText() { + + // always use the "assigned" label if we have one + // TODO: where is this used? what is the use case? + if (this.assignedLabel) { + return this.assignedLabel + } + + // if we have a "currently selected option" then use its + // label. all search results / options have a `label` + // property as that is shown directly in the autocomplete + // dropdown. but if the option also has a `display` + // property then that is what we will show in the button. + // this way search results can show one thing in the + // search dropdown, and another in the button. + if (this.selected) { + return this.selected.display || this.selected.label + } + + // we have nothing to go on here.. + return "" + }, }, } diff --git a/tailbone/templates/autocomplete.mako b/tailbone/templates/autocomplete.mako index e7aad900..7961d07c 100644 --- a/tailbone/templates/autocomplete.mako +++ b/tailbone/templates/autocomplete.mako @@ -65,12 +65,11 @@ <b-autocomplete ref="autocomplete" :name="name" v-show="!assignedValue && !selected" - v-model="value" + v-model="buefyValue" :placeholder="placeholder" :data="data" @typing="getAsyncData" @select="selectionMade" - @input="itemSelected" keep-first> <template slot-scope="props"> {{ props.option.label }} @@ -79,7 +78,7 @@ <b-button v-if="assignedValue || selected" style="width: 100%; justify-content: left;" - @click="clearSelection()"> + @click="clearSelection(true)"> {{ getDisplayText() }} (click to change) </b-button> diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index f353c8fc..c342e318 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -113,7 +113,7 @@ <div :style="{'flex-grow': contactNotes.length ? 0 : 1}"> <b-field label="Customer" grouped> - <b-field style="margin-left: 1rem;"" + <b-field style="margin-left: 1rem;" :expanded="!contactUUID"> <tailbone-autocomplete ref="contactAutocomplete" v-model="contactUUID" @@ -927,12 +927,17 @@ } }, watch: { + contactIsKnown: function(val) { - // if user has already specified a proper contact, then - // clicks the "contact is unknown" button, then we want - // to *clear out* the existing contact - if (!val && this.contactUUID) { - this.contactChanged(null) + + // if user has already specified a proper contact, + // i.e. `contactUUID` is not null, *and* user has + // clicked the "contact is not yet in the system" + // button, i.e. `val` is false, then we want to *clear + // out* the existing contact selection. this is + // primarily to avoid any ambiguity. + if (this.contactUUID && !val) { + this.$refs.contactAutocomplete.clearSelection() } }, }, @@ -1053,6 +1058,7 @@ % else: that.contactUUID = response.data.person_uuid % endif + that.contactDisplay = response.data.contact_display that.orderPhoneNumber = response.data.phone_number that.orderEmailAddress = response.data.email_address that.addOtherPhoneNumber = response.data.add_phone_number From ab33b49218471b2b851730fa11240cd7f35b894f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 17 Oct 2021 17:28:28 -0400 Subject: [PATCH 0455/1681] Improve "refresh contact", show new fields in green for custorder only showing new "customer" fields in green so far --- tailbone/templates/custorders/create.mako | 46 ++++++++++++++++++----- tailbone/views/customers.py | 4 ++ tailbone/views/custorders/batch.py | 1 + 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index c342e318..2e89783c 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -139,8 +139,9 @@ <b-button @click="refreshContact" icon-pack="fas" - icon-left="redo"> - Refresh + icon-left="redo" + :disabled="refreshingContact"> + {{ refreshingContact ? "Refreshig" : "Refresh" }} </b-button> </div> </b-field> @@ -362,18 +363,26 @@ <div> <b-field grouped> <b-field label="First Name"> - <span>{{ newCustomerFirstName }}</span> + <span class="has-text-success"> + {{ newCustomerFirstName }} + </span> </b-field> <b-field label="Last Name"> - <span>{{ newCustomerLastName }}</span> + <span class="has-text-success"> + {{ newCustomerLastName }} + </span> </b-field> </b-field> <b-field grouped> <b-field label="Phone Number"> - <span>{{ newCustomerPhone }}</span> + <span class="has-text-success"> + {{ newCustomerPhone }} + </span> </b-field> <b-field label="Email Address"> - <span>{{ newCustomerEmail }}</span> + <span class="has-text-success"> + {{ newCustomerEmail }} + </span> </b-field> </b-field> </div> @@ -680,6 +689,7 @@ contactDisplay: ${json.dumps(contact_display)|n}, customerEntry: null, contactProfileURL: ${json.dumps(contact_profile_url)|n}, + refreshingContact: false, orderPhoneNumber: ${json.dumps(batch.phone_number)|n}, contactPhones: ${json.dumps(contact_phones)|n}, @@ -930,13 +940,20 @@ contactIsKnown: function(val) { + // when user clicks "contact is known" then we want to + // set focus to the autocomplete component + if (val) { + this.$nextTick(() => { + this.$refs.contactAutocomplete.focus() + }) + // if user has already specified a proper contact, // i.e. `contactUUID` is not null, *and* user has // clicked the "contact is not yet in the system" // button, i.e. `val` is false, then we want to *clear // out* the existing contact selection. this is // primarily to avoid any ambiguity. - if (this.contactUUID && !val) { + } else if (this.contactUUID) { this.$refs.contactAutocomplete.clearSelection() } }, @@ -1039,7 +1056,7 @@ }) }, - contactChanged(uuid) { + contactChanged(uuid, callback) { let params if (!uuid) { params = { @@ -1067,11 +1084,22 @@ that.contactPhones = response.data.contact_phones that.contactEmails = response.data.contact_emails that.contactNotes = response.data.contact_notes + if (callback) { + callback() + } }) }, refreshContact() { - this.contactChanged(this.contactUUID) + this.refreshingContact = true + this.contactChanged(this.contactUUID, () => { + this.refreshingContact = false + this.$buefy.toast.open({ + message: "Contact info has been refreshed.", + type: 'is-success', + duration: 3000, // 3 seconds + }) + }) }, % if allow_contact_info_choice: diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index 65618c1a..d29d69f2 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -490,6 +490,8 @@ class PendingCustomerView(MasterView): 'address_zipcode', 'address_type', 'status_code', + 'created', + 'user', ] def configure_grid(self, g): @@ -506,6 +508,8 @@ class PendingCustomerView(MasterView): f.set_enum('status_code', self.enum.PENDING_CUSTOMER_STATUS) + f.set_renderer('user', self.render_user) + # # TODO: this is referenced by some custom apps, but should be moved?? # def unique_id(value, field): diff --git a/tailbone/views/custorders/batch.py b/tailbone/views/custorders/batch.py index d70e5e77..ccbf492e 100644 --- a/tailbone/views/custorders/batch.py +++ b/tailbone/views/custorders/batch.py @@ -63,6 +63,7 @@ class CustomerOrderBatchView(BatchMasterView): 'customer', 'person', 'pending_customer', + 'contact_name', 'phone_number', 'email_address', 'params', From 87374d56476109004bad0cd513950d879e7e9ec4 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 17 Oct 2021 17:29:26 -0400 Subject: [PATCH 0456/1681] Fix auth handler reference bug --- tailbone/views/principal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/principal.py b/tailbone/views/principal.py index 3fc5ce6b..0012adc8 100644 --- a/tailbone/views/principal.py +++ b/tailbone/views/principal.py @@ -144,7 +144,7 @@ class PermissionsRenderer(Object): def render(self): app = self.request.rattail_config.get_app() - auth = app.get_handler() + auth = app.get_auth_handler() principal = self.principal html = '' From 93b752f436095ac0efb554fe2e49dd65b1312240 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 17 Oct 2021 18:07:57 -0400 Subject: [PATCH 0457/1681] Invoke handler when adding new item to custorder batch --- tailbone/views/custorders/orders.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index b7b583dc..0b72f377 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -682,14 +682,10 @@ class CustomerOrderView(MasterView): if not product: return {'error': "Product not found"} - row = self.handler.make_row() - row.item_entry = product.uuid - row.product = product - row.order_quantity = decimal.Decimal(data.get('order_quantity') or '0') - row.order_uom = data.get('order_uom') - self.handler.add_row(batch, row) + row = self.handler.add_product(batch, product, + decimal.Decimal(data.get('order_quantity') or '0'), + data.get('order_uom')) self.Session.flush() - self.Session.refresh(row) else: # product is not known raise NotImplementedError # TODO From 8b044dbb2202aa960b01c9a92537bef7d2cedd4f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 18 Oct 2021 18:28:28 -0500 Subject: [PATCH 0458/1681] Add basic "price needs confirmation" support for custorder --- tailbone/templates/custorders/create.mako | 34 +++++++- tailbone/templates/custorders/items/view.mako | 77 ++++++++++++++++ tailbone/views/custorders/items.py | 87 +++++++++++++++++-- tailbone/views/custorders/orders.py | 27 +++++- 4 files changed, 214 insertions(+), 11 deletions(-) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index 2e89783c..655caf2b 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -500,7 +500,8 @@ </b-radio> </div> - <div v-show="productIsKnown"> + <div v-show="productIsKnown" + style="padding-left: 5rem;"> <b-field grouped> <b-field label="Description" horizontal expanded> @@ -544,8 +545,28 @@ </b-button> </b-field> + <div v-if="productUUID"> + <b-field grouped + v-if="productUUID"> + <b-field label="Unit Price"> + <span :class="productPriceNeedsConfirmation ? 'has-background-warning' : ''"> + $4.20 / EA + </span> + </b-field> + <b-field label="Last Changed"> + <span>2021-01-01</span> + </b-field> + </b-field> + <b-checkbox v-model="productPriceNeedsConfirmation" + size="is-small"> + This price is questionable and should be confirmed + by someone before order proceeds. + </b-checkbox> + </div> + </div> + <br /> <div class="field"> <b-radio v-model="productIsKnown" disabled :native-value="false"> @@ -619,7 +640,9 @@ </b-table-column> <b-table-column field="total_price_display" label="Total"> - {{ props.row.total_price_display }} + <span :class="props.row.price_needs_confirmation ? 'has-background-warning' : ''"> + {{ props.row.total_price_display }} + </span> </b-table-column> <b-table-column field="vendor_display" label="Vendor"> @@ -742,6 +765,7 @@ defaultUOM: defaultUOM, productUOM: defaultUOM, productCaseSize: null, + productPriceNeedsConfirmation: false, ## TODO: should find a better way to handle CSRF token csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}, @@ -1271,6 +1295,7 @@ this.productQuantity = 1 this.productUnitChoices = this.defaultUnitChoices this.productUOM = this.defaultUOM + this.productPriceNeedsConfirmation = false this.showingItemDialog = true this.$nextTick(() => { this.$refs.productDescriptionAutocomplete.focus() @@ -1287,6 +1312,7 @@ this.productQuantity = row.order_quantity this.productUnitChoices = row.order_uom_choices this.productUOM = row.order_uom + this.productPriceNeedsConfirmation = row.price_needs_confirmation this.showingItemDialog = true }, @@ -1319,6 +1345,7 @@ this.productDisplay = null this.productUPC = null this.productUnitChoices = this.defaultUnitChoices + this.productPriceNeedsConfirmation = false if (autofocus) { this.$nextTick(() => { this.$refs.productUPCInput.focus() @@ -1358,6 +1385,7 @@ this.productUPC = response.data.upc_pretty this.productDisplay = response.data.full_description this.setProductUnitChoices(response.data.uom_choices) + this.productPriceNeedsConfirmation = false } }) }, @@ -1379,6 +1407,7 @@ this.productUPC = response.data.upc_pretty this.productDisplay = response.data.full_description this.setProductUnitChoices(response.data.uom_choices) + this.productPriceNeedsConfirmation = false }) } else { this.clearProduct() @@ -1392,6 +1421,7 @@ product_uuid: this.productUUID, order_quantity: this.productQuantity, order_uom: this.productUOM, + price_needs_confirmation: this.productPriceNeedsConfirmation, } if (this.editingItem) { diff --git a/tailbone/templates/custorders/items/view.mako b/tailbone/templates/custorders/items/view.mako index 533d8f18..030b0ade 100644 --- a/tailbone/templates/custorders/items/view.mako +++ b/tailbone/templates/custorders/items/view.mako @@ -4,6 +4,9 @@ <%def name="render_buefy_form()"> <div class="form"> <${form.component} ref="mainForm" + % if master.has_perm('confirm_price'): + @confirm-price="showConfirmPrice" + % endif % if master.has_perm('change_status'): @change-status="showChangeStatus" % endif @@ -18,6 +21,45 @@ <%def name="page_content()"> ${parent.page_content()} + % if master.has_perm('confirm_price'): + <b-modal has-modal-card + :active.sync="confirmPriceShowDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Confirm Price</p> + </header> + + <section class="modal-card-body"> + <p> + Please provide a note</span>: + </p> + <b-input v-model="confirmPriceNote" + ref="confirmPriceNoteField" + type="textarea" rows="2"> + </b-input> + </section> + + <footer class="modal-card-foot"> + <b-button type="is-primary" + @click="confirmPriceSave()" + :disabled="confirmPriceSaveDisabled" + icon-pack="fas" + icon-left="check"> + {{ confirmPriceSubmitText }} + </b-button> + <b-button @click="confirmPriceShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </b-modal> + ${h.form(master.get_action_url('confirm_price', instance), ref='confirmPriceForm')} + ${h.csrf_token(request)} + ${h.hidden('note', **{':value': 'confirmPriceNote'})} + ${h.end_form()} + % endif + % if master.has_perm('change_status'): <b-modal :active.sync="showChangeStatusDialog"> <div class="card"> @@ -190,6 +232,41 @@ ${form.component_studly}Data.notesData = ${json.dumps(notes_data)|n} + % if master.has_perm('confirm_price'): + + ThisPageData.confirmPriceShowDialog = false + ThisPageData.confirmPriceNote = null + ThisPageData.confirmPriceSubmitting = false + + ThisPage.computed.confirmPriceSaveDisabled = function() { + if (this.confirmPriceSubmitting) { + return true + } + return false + } + + ThisPage.computed.confirmPriceSubmitText = function() { + if (this.confirmPriceSubmitting) { + return "Working, please wait..." + } + return "Confirm Price" + } + + ThisPage.methods.showConfirmPrice = function() { + this.confirmPriceNote = null + this.confirmPriceShowDialog = true + this.$nextTick(() => { + this.$refs.confirmPriceNoteField.focus() + }) + } + + ThisPage.methods.confirmPriceSave = function() { + this.confirmPriceSubmitting = true + this.$refs.confirmPriceForm.submit() + } + + % endif + % if master.has_perm('change_status'): ThisPageData.orderItemStatuses = ${json.dumps(enum.CUSTORDER_ITEM_STATUS)|n} diff --git a/tailbone/views/custorders/items.py b/tailbone/views/custorders/items.py index 2dcd43a5..737b1c20 100644 --- a/tailbone/views/custorders/items.py +++ b/tailbone/views/custorders/items.py @@ -98,6 +98,7 @@ class CustomerOrderItemView(MasterView): 'case_quantity', 'unit_price', 'total_price', + 'price_needs_confirmation', 'paid_amount', 'status_code', 'notes', @@ -135,7 +136,7 @@ class CustomerOrderItemView(MasterView): g.set_renderer('person', self.render_person_text) g.set_renderer('order_created', self.render_order_created) - g.set_enum('status_code', self.enum.CUSTORDER_ITEM_STATUS) + g.set_renderer('status_code', self.render_status_code_column) g.set_label('person', "Person Name") g.set_label('product_brand', "Brand") @@ -160,6 +161,13 @@ class CustomerOrderItemView(MasterView): value = localtime(self.rattail_config, item.order.created, from_utc=True) return raw_datetime(self.rattail_config, value) + def render_status_code_column(self, item, field): + text = self.enum.CUSTORDER_ITEM_STATUS.get(item.status_code, + six.text_type(item.status_code)) + if item.status_text: + return HTML.tag('span', title=item.status_text, c=[text]) + return text + def configure_form(self, f): super(CustomerOrderItemView, self).configure_form(f) use_buefy = self.get_use_buefy() @@ -178,12 +186,12 @@ class CustomerOrderItemView(MasterView): f.set_type('cases_ordered', 'quantity') f.set_type('units_ordered', 'quantity') f.set_type('order_quantity', 'quantity') - f.set_enum('order_uom', self.enum.UNIT_OF_MEASURE) - # currency fields - f.set_type('unit_price', 'currency') - f.set_type('total_price', 'currency') + # price fields + f.set_renderer('unit_price', self.render_price_with_confirmation) + f.set_renderer('total_price', self.render_price_with_confirmation) + f.set_renderer('price_needs_confirmation', self.render_price_needs_confirmation) f.set_type('paid_amount', 'currency') # person @@ -198,9 +206,37 @@ class CustomerOrderItemView(MasterView): else: f.remove('notes') + def render_price_with_confirmation(self, item, field): + price = getattr(item, field) + app = self.get_rattail_app() + text = app.render_currency(price) + if item.price_needs_confirmation: + return HTML.tag('span', class_='has-background-warning', + c=[text]) + return text + + def render_price_needs_confirmation(self, item, field): + + value = item.price_needs_confirmation + text = "Yes" if value else "No" + items = [text] + + if value and self.has_perm('confirm_price'): + button = HTML.tag('b-button', type='is-primary', c="Confirm Price", + style='margin-left: 1rem;', + icon_pack='fas', icon_left='check', + **{'@click': "$emit('confirm-price')"}) + items.append(button) + + left = HTML.tag('div', class_='level-left', c=items) + outer = HTML.tag('div', class_='level', c=[left]) + return outer + def render_status_code(self, item, field): use_buefy = self.get_use_buefy() text = self.enum.CUSTORDER_ITEM_STATUS[item.status_code] + if item.status_text: + text = "{} ({})".format(text, item.status_text) items = [HTML.tag('span', c=[text])] if use_buefy and self.has_perm('change_status'): @@ -298,6 +334,32 @@ class CustomerOrderItemView(MasterView): }) return notes + def confirm_price(self): + """ + View for confirming price of an order item. + """ + item = self.get_instance() + redirect = self.redirect(self.get_action_url('view', item)) + + # locate user responsible for change + user = self.request.user + + # grab user-provided note to attach to event + note = self.request.POST.get('note') + + # declare item no longer in need of price confirmation + item.price_needs_confirmation = False + item.add_event(self.enum.CUSTORDER_ITEM_EVENT_PRICE_CONFIRMED, + user, note=note) + + # advance item to next status + if item.status_code == self.enum.CUSTORDER_ITEM_STATUS_INITIATED: + item.status_code = self.enum.CUSTORDER_ITEM_STATUS_READY + item.status_text = "price has been confirmed" + + self.request.session.flash("Price has been confirmed.") + return redirect + def change_status(self): """ View for changing status of one or more order items. @@ -342,6 +404,9 @@ class CustomerOrderItemView(MasterView): # change status item.status_code = new_status_code + # nb. must blank this out, b/c user cannot specify new + # text and the old text no longer applies + item.status_text = None self.request.session.flash("Status has been updated to: {}".format( self.enum.CUSTORDER_ITEM_STATUS[new_status_code])) @@ -418,11 +483,23 @@ class CustomerOrderItemView(MasterView): route_prefix = cls.get_route_prefix() instance_url_prefix = cls.get_instance_url_prefix() permission_prefix = cls.get_permission_prefix() + model_title = cls.get_model_title() model_title_plural = cls.get_model_title_plural() # fix permission group name config.add_tailbone_permission_group(permission_prefix, model_title_plural) + # confirm price + config.add_tailbone_permission(permission_prefix, + '{}.confirm_price'.format(permission_prefix), + "Confirm price for a {}".format(model_title)) + config.add_route('{}.confirm_price'.format(route_prefix), + '{}/confirm-price'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='confirm_price', + route_name='{}.confirm_price'.format(route_prefix), + permission='{}.confirm_price'.format(permission_prefix)) + # change status config.add_tailbone_permission(permission_prefix, '{}.change_status'.format(permission_prefix), diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index 0b72f377..0153126a 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -36,7 +36,7 @@ from rattail.db import model from rattail.util import pretty_quantity from rattail.batch import get_batch_handler -from webhelpers2.html import tags +from webhelpers2.html import tags, HTML from tailbone.db import Session from tailbone.views import MasterView @@ -189,10 +189,10 @@ class CustomerOrderView(MasterView): g.set_type('order_quantity', 'quantity') g.set_type('cases_ordered', 'quantity') g.set_type('units_ordered', 'quantity') - g.set_type('total_price', 'currency') + g.set_renderer('total_price', self.render_price_with_confirmation) g.set_enum('order_uom', self.enum.UNIT_OF_MEASURE) - g.set_enum('status_code', self.enum.CUSTORDER_ITEM_STATUS) + g.set_renderer('status_code', self.render_row_status_code) g.set_label('sequence', "Seq.") g.filters['sequence'].label = "Sequence" @@ -206,6 +206,22 @@ class CustomerOrderView(MasterView): g.set_link('product_brand') g.set_link('product_description') + def render_price_with_confirmation(self, item, field): + price = getattr(item, field) + app = self.get_rattail_app() + text = app.render_currency(price) + if item.price_needs_confirmation: + return HTML.tag('span', class_='has-background-warning', + c=[text]) + return text + + def render_row_status_code(self, item, field): + text = self.enum.CUSTORDER_ITEM_STATUS.get(item.status_code, + six.text_type(item.status_code)) + if item.status_text: + return HTML.tag('span', title=item.status_text, c=[text]) + return text + def get_batch_handler(self): return get_batch_handler( self.rattail_config, 'custorder', @@ -641,6 +657,7 @@ class CustomerOrderView(MasterView): 'unit_price_display': "${:0.2f}".format(row.unit_price) if row.unit_price is not None else None, 'total_price': six.text_type(row.total_price) if row.total_price is not None else None, 'total_price_display': "${:0.2f}".format(row.total_price) if row.total_price is not None else None, + 'price_needs_confirmation': row.price_needs_confirmation, 'status_code': row.status_code, 'status_text': row.status_text, @@ -684,7 +701,8 @@ class CustomerOrderView(MasterView): row = self.handler.add_product(batch, product, decimal.Decimal(data.get('order_quantity') or '0'), - data.get('order_uom')) + data.get('order_uom'), + price_needs_confirmation=data.get('price_needs_confirmation')) self.Session.flush() else: # product is not known @@ -719,6 +737,7 @@ class CustomerOrderView(MasterView): row.product = product row.order_quantity = decimal.Decimal(data.get('order_quantity') or '0') row.order_uom = data.get('order_uom') + row.price_needs_confirmation = data.get('price_needs_confirmation') self.handler.refresh_row(row) self.Session.flush() self.Session.refresh(row) From 8d16a5f1100d3a6d7ca5756e6807bb74010363d8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 19 Oct 2021 18:14:50 -0500 Subject: [PATCH 0459/1681] Clean up the product selection UI for new custorder still needs some work but this is much better, more like the customer selection now w/ "multi-faceted" autocomplete --- tailbone/templates/custorders/create.mako | 116 +++++++++------------- tailbone/views/custorders/orders.py | 87 +++++++++------- 2 files changed, 98 insertions(+), 105 deletions(-) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index 655caf2b..61c147f5 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -504,41 +504,22 @@ style="padding-left: 5rem;"> <b-field grouped> - <b-field label="Description" horizontal expanded> - <tailbone-autocomplete - ref="productDescriptionAutocomplete" - v-model="productUUID" - :assigned-value="productUUID" - :assigned-label="productDisplay" - serviceUrl="${product_autocomplete_url}" - @input="productChanged"> + <p class="label control"> + Product + </p> + <b-field :expanded="!productUUID"> + <tailbone-autocomplete ref="productAutocomplete" + v-model="productUUID" + placeholder="Enter UPC or brand, description etc." + :initial-label="productDisplay" + serviceUrl="${url('{}.product_autocomplete'.format(route_prefix))}" + @input="productChanged"> </tailbone-autocomplete> </b-field> - </b-field> - - <b-field grouped> - <b-field label="UPC" horizontal expanded> - <b-input v-if="!productUUID" - v-model="productUPC" - ref="productUPCInput" - @keydown.native="productUPCKeyDown"> - </b-input> - <b-button v-if="!productUUID" - type="is-primary" - icon-pack="fas" - icon-left="search" - @click="fetchProductByUPC()"> - Fetch - </b-button> - <b-button v-if="productUUID" - @click="clearProduct(true)"> - {{ productUPC }} (click to change) - </b-button> - </b-field> <b-button v-if="productUUID" type="is-primary" tag="a" target="_blank" - :href="'${request.route_url('products')}/' + productUUID" + :href="productURL" icon-pack="fas" icon-left="external-link-alt"> View Product @@ -546,18 +527,26 @@ </b-field> <div v-if="productUUID"> - <b-field grouped - v-if="productUUID"> + + <div class="is-pulled-right has-text-centered"> + <img :src="productImageURL" + style="height: 150px; width: 150px; "/> + <p>{{ productKey }}</p> + </div> + + <b-field grouped> <b-field label="Unit Price"> <span :class="productPriceNeedsConfirmation ? 'has-background-warning' : ''"> - $4.20 / EA + {{ productUnitPriceDisplay }} </span> </b-field> - <b-field label="Last Changed"> - <span>2021-01-01</span> - </b-field> + <!-- <b-field label="Last Changed"> --> + <!-- <span>2021-01-01</span> --> + <!-- </b-field> --> </b-field> + <b-checkbox v-model="productPriceNeedsConfirmation" + type="is-warning" size="is-small"> This price is questionable and should be confirmed by someone before order proceeds. @@ -759,6 +748,10 @@ productUUID: null, productDisplay: null, productUPC: null, + productKey: null, + productUnitPriceDisplay: null, + productURL: null, + productImageURL: null, productQuantity: null, defaultUnitChoices: defaultUnitChoices, productUnitChoices: defaultUnitChoices, @@ -1292,13 +1285,15 @@ this.productUUID = null this.productDisplay = null this.productUPC = null + this.productKey = null + this.productUnitPriceDisplay = null this.productQuantity = 1 this.productUnitChoices = this.defaultUnitChoices this.productUOM = this.defaultUOM this.productPriceNeedsConfirmation = false this.showingItemDialog = true this.$nextTick(() => { - this.$refs.productDescriptionAutocomplete.focus() + this.$refs.productAutocomplete.focus() }) }, @@ -1309,6 +1304,10 @@ this.productUUID = row.product_uuid this.productDisplay = row.product_full_description this.productUPC = row.product_upc_pretty || row.product_upc + this.productKey = row.product_key + this.productURL = row.product_url + this.productUnitPriceDisplay = row.unit_price_display + this.productImageURL = row.product_image_url this.productQuantity = row.order_quantity this.productUnitChoices = row.order_uom_choices this.productUOM = row.order_uom @@ -1340,17 +1339,16 @@ }) }, - clearProduct(autofocus) { + clearProduct() { this.productUUID = null this.productDisplay = null this.productUPC = null + this.productKey = null + this.productUnitPriceDisplay = null + this.productURL = null + this.productImageURL = null this.productUnitChoices = this.defaultUnitChoices this.productPriceNeedsConfirmation = false - if (autofocus) { - this.$nextTick(() => { - this.$refs.productUPCInput.focus() - }) - } }, setProductUnitChoices(choices) { @@ -1368,34 +1366,6 @@ } }, - fetchProductByUPC() { - let params = { - action: 'find_product_by_upc', - upc: this.productUPC, - } - this.submitBatchData(params, response => { - if (response.data.error) { - this.$buefy.toast.open({ - message: "Fetch failed: " + response.data.error, - type: 'is-warning', - duration: 2000, // 2 seconds - }) - } else { - this.productUUID = response.data.uuid - this.productUPC = response.data.upc_pretty - this.productDisplay = response.data.full_description - this.setProductUnitChoices(response.data.uom_choices) - this.productPriceNeedsConfirmation = false - } - }) - }, - - productUPCKeyDown(event) { - if (event.which == 13) { // Enter - this.fetchProductByUPC() - } - }, - productChanged(uuid) { if (uuid) { this.productUUID = uuid @@ -1405,7 +1375,11 @@ } this.submitBatchData(params, response => { this.productUPC = response.data.upc_pretty + this.productKey = response.data.key this.productDisplay = response.data.full_description + this.productUnitPriceDisplay = response.data.unit_price_display + this.productURL = response.data.url + this.productImageURL = response.data.image_url this.setProductUnitChoices(response.data.uom_choices) this.productPriceNeedsConfirmation = false }) diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index 0153126a..1d564f35 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -31,7 +31,6 @@ import decimal import six from sqlalchemy import orm -from rattail import pod from rattail.db import model from rattail.util import pretty_quantity from rattail.batch import get_batch_handler @@ -259,7 +258,6 @@ class CustomerOrderView(MasterView): 'update_pending_customer', 'get_customer_info', # 'set_customer_data', - 'find_product_by_upc', 'get_product_info', 'add_item', 'update_item', @@ -273,12 +271,6 @@ class CustomerOrderView(MasterView): items = [self.normalize_row(row) for row in batch.active_rows()] - if self.handler.has_custom_product_autocomplete: - route_prefix = self.get_route_prefix() - product_autocomplete = '{}.product_autocomplete'.format(route_prefix) - else: - product_autocomplete = 'products.autocomplete' - context = self.get_context_contact(batch) context.update({ @@ -288,7 +280,6 @@ class CustomerOrderView(MasterView): 'allow_contact_info_choice': self.handler.allow_contact_info_choice(), 'restrict_contact_info': self.handler.should_restrict_contact_info(), 'order_items': items, - 'product_autocomplete_url': self.request.route_url(product_autocomplete), }) return self.render_to_response(template, context) @@ -535,31 +526,18 @@ class CustomerOrderView(MasterView): """ Custom product autocomplete logic, which invokes the handler. """ - self.handler = self.get_batch_handler() term = self.request.GET['term'] - return self.handler.custom_product_autocomplete(self.Session(), term, - user=self.request.user) - def find_product_by_upc(self, batch, data): - upc = data.get('upc') - if not upc: - return {'error': "Must specify a product UPC"} + # if handler defines custom autocomplete, use that + handler = self.get_batch_handler() + if handler.has_custom_product_autocomplete: + return handler.custom_product_autocomplete(self.Session(), term, + user=self.request.user) - product = self.handler.locate_product_for_entry( - self.Session(), upc, product_key='upc', - # nb. let handler know "why" we're doing this, so that it - # can "modify" the result accordingly, i.e. return the - # appropriate item when a "different" scancode is entered - # by the user (e.g. PLU, and/or units vs. packs) - variation='new_custorder') - if not product: - return {'error': "Product not found"} - - reason = self.handler.why_not_add_product(product, batch) - if reason: - return {'error': reason} - - return self.info_for_product(batch, data, product) + # otherwise we use 'products.neworder' autocomplete + app = self.get_rattail_app() + autocomplete = app.get_autocompleter('products.neworder') + return autocomplete.autocomplete(self.Session(), term) def get_product_info(self, batch, data): uuid = data.get('uuid') @@ -608,15 +586,27 @@ class CustomerOrderView(MasterView): return choices def info_for_product(self, batch, data, product): - return { + app = self.get_rattail_app() + products = app.get_products_handler() + data = { 'uuid': product.uuid, 'upc': six.text_type(product.upc), 'upc_pretty': product.upc.pretty(), + 'unit_price_display': self.get_unit_price_display(product), 'full_description': product.full_description, - 'image_url': pod.get_image_url(self.rattail_config, product.upc), + 'url': self.request.route_url('products.view', uuid=product.uuid), + 'image_url': products.get_image_url(product), 'uom_choices': self.uom_choices_for_product(product), } + key = self.rattail_config.product_key() + if key == 'upc': + data['key'] = data['upc_pretty'] + else: + data['key'] = getattr(product, key, data['upc_pretty']) + + return data + def normalize_batch(self, batch): return { 'uuid': batch.uuid, @@ -626,7 +616,24 @@ class CustomerOrderView(MasterView): 'status_text': batch.status_text, } + def get_unit_price_display(self, obj): + """ + Returns a display string for the given object's unit price. + The object can be either a ``Product`` instance, or a batch + row. + """ + app = self.get_rattail_app() + model = self.model + if isinstance(obj, model.Product): + products = app.get_products_handler() + return products.render_price(obj.regular_price) + else: # row + return app.render_currency(obj.unit_price) + def normalize_row(self, row): + app = self.get_rattail_app() + products = app.get_products_handler() + product = row.product department = product.department if product else None cost = product.cost if product else None @@ -654,7 +661,7 @@ class CustomerOrderView(MasterView): 'vendor_display': cost.vendor.name if cost else None, 'unit_price': six.text_type(row.unit_price) if row.unit_price is not None else None, - 'unit_price_display': "${:0.2f}".format(row.unit_price) if row.unit_price is not None else None, + 'unit_price_display': self.get_unit_price_display(row), 'total_price': six.text_type(row.total_price) if row.total_price is not None else None, 'total_price_display': "${:0.2f}".format(row.total_price) if row.total_price is not None else None, 'price_needs_confirmation': row.price_needs_confirmation, @@ -663,6 +670,18 @@ class CustomerOrderView(MasterView): 'status_text': row.status_text, } + key = self.rattail_config.product_key() + if key == 'upc': + data['product_key'] = data['product_upc_pretty'] + else: + data['product_key'] = getattr(product, key, data['product_upc_pretty']) + + if row.product: + data.update({ + 'product_url': self.request.route_url('products.view', uuid=row.product.uuid), + 'product_image_url': products.get_image_url(row.product), + }) + unit_uom = self.enum.UNIT_OF_MEASURE_POUND if data['product_weighed'] else self.enum.UNIT_OF_MEASURE_EACH if row.order_uom == self.enum.UNIT_OF_MEASURE_CASE: if row.case_quantity is None: From 4a383709bdf0fc61aea68c67e676200bd59d8957 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 20 Oct 2021 16:15:19 -0500 Subject: [PATCH 0460/1681] 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 540b069d..38d1e8b4 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,24 @@ CHANGELOG ========= +0.8.164 (2021-10-20) +-------------------- + +* Give custorder batch handler a couple ways to affect adding new items. + +* Refactor to leverage all existing methods of auth handler. + +* Overhaul the autocomplete component, for sake of new custorder. + +* Improve "refresh contact", show new fields in green for custorder. + +* Invoke handler when adding new item to custorder batch. + +* Add basic "price needs confirmation" support for custorder. + +* Clean up the product selection UI for new custorder. + + 0.8.163 (2021-10-14) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 29fe3709..b68d6316 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.163' +__version__ = '0.8.164' From a553a266440edfc7f375e4a8b3438316b69d2f45 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 22 Oct 2021 21:04:39 -0500 Subject: [PATCH 0461/1681] Optionally set the `sticky-header` attribute for main buefy grids should affect the 'index' and 'view' (with rows) but i don't think any other pages will get this..? --- tailbone/templates/grids/buefy.mako | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako index 63deddc9..08cb2969 100644 --- a/tailbone/templates/grids/buefy.mako +++ b/tailbone/templates/grids/buefy.mako @@ -140,6 +140,14 @@ :loading="loading" :row-class="getRowClass" + ## TODO: this should be more configurable, maybe auto-detect based + ## on buefy version?? probably cannot do that, but this feature + ## is only supported with buefy 0.8.13 and newer + % if request.rattail_config.getbool('tailbone', 'sticky_headers'): + sticky-header + height="600px" + % endif + :checkable="checkable" % if grid.checkboxes: :checked-rows.sync="checkedRows" From 2d0a922cffed2df8650f6155de72fdc4469dc087 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 22 Oct 2021 21:24:08 -0500 Subject: [PATCH 0462/1681] Show case qty by default for costing batch rows --- tailbone/views/purchasing/costing.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tailbone/views/purchasing/costing.py b/tailbone/views/purchasing/costing.py index daf016a9..71b99cf2 100644 --- a/tailbone/views/purchasing/costing.py +++ b/tailbone/views/purchasing/costing.py @@ -110,6 +110,7 @@ class CostingBatchView(PurchasingBatchView): 'department_name', 'cases_received', 'units_received', + 'case_quantity', 'catalog_unit_cost', 'invoice_unit_cost', # 'invoice_total_calculated', @@ -318,6 +319,13 @@ class CostingBatchView(PurchasingBatchView): if info: return info['display'] + def configure_row_grid(self, g): + super(CostingBatchView, self).configure_row_grid(g) + + g.set_label('case_quantity', "Case Qty") + g.filters['case_quantity'].label = "Case Quantity" + g.set_type('case_quantity', 'quantity') + @classmethod def defaults(cls, config): cls._costing_defaults(config) From 2d87ce5c2981b083b08de779e0246874f4ce81b6 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 22 Oct 2021 21:53:46 -0500 Subject: [PATCH 0463/1681] Highlight the "did not receive" rows for purchase batch also add some row grid links --- tailbone/views/purchasing/batch.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index c00267a9..11a891c7 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -642,6 +642,10 @@ class PurchasingBatchView(BatchMasterView): g.set_label('po_total', "Total") g.set_label('credits', "Credits?") + g.set_link('upc') + g.set_link('vendor_code') + g.set_link('description') + def render_row_grid_cost(self, row, field): cost = getattr(row, field) if cost is None: @@ -663,7 +667,8 @@ class PurchasingBatchView(BatchMasterView): row.STATUS_OUT_OF_STOCK, row.STATUS_ON_PO_NOT_INVOICE, row.STATUS_ON_INVOICE_NOT_PO, - row.STATUS_COST_INCREASE): + row.STATUS_COST_INCREASE, + row.STATUS_DID_NOT_RECEIVE): return 'notice' def configure_row_form(self, f): From 4dfc29768cc4495899119ea7df9f2d7b4accaa50 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 28 Oct 2021 18:55:28 -0500 Subject: [PATCH 0464/1681] Improve validation for Person field of User form otherwise if user enters e.g. "John Doe" but does *not* select an autocomplete result, then "John Doe" will be submitted as-is to the server, which then tried to write that directly to ``users.person_uuid`` column in the DB, resulting in error --- tailbone/views/users.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tailbone/views/users.py b/tailbone/views/users.py index 7b8312bd..124d355a 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -164,6 +164,17 @@ class UserView(PrincipalMasterView): if query.count(): raise colander.Invalid(node, "Username must be unique") + def valid_person(self, node, value): + """ + Make sure ``value`` corresponds to an existing + ``Person.uuid``. + """ + if value: + model = self.model + person = self.Session.query(model.Person).get(value) + if not person: + raise colander.Invalid(node, "Person not found (you must *select* a record)") + def configure_form(self, f): super(UserView, self).configure_form(f) user = f.model_instance @@ -188,6 +199,7 @@ class UserView(PrincipalMasterView): people_url = self.request.route_url('people.autocomplete') f.set_widget('person_uuid', forms.widgets.JQueryAutocompleteWidget( field_display=person_display, service_url=people_url)) + f.set_validator('person_uuid', self.valid_person) f.set_label('person_uuid', "Person") # person name(s) From 7b5e2d17f3b356487f82415c581233681f1b2295 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 28 Oct 2021 19:00:56 -0500 Subject: [PATCH 0465/1681] Omit "edit" link unless user has perm, for Customer "people" subgrid --- tailbone/views/customers.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index d29d69f2..ff99f1af 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -262,9 +262,11 @@ class CustomerView(MasterView): 'last_name': person.last_name, '_action_url_view': self.request.route_url('people.view', uuid=person.uuid), - '_action_url_edit': self.request.route_url('people.edit', - uuid=person.uuid), } + if self.editable and self.request.has_perm('people.edit'): + data['_action_url_edit'] = self.request.route_url( + 'people.edit', + uuid=person.uuid) if self.people_detachable and self.has_perm('detach_person'): data['_action_url_detach'] = self.request.route_url( 'customers.detach_person', From 7651efff9d7e0d7329f5998a9505d32e47b9b181 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 31 Oct 2021 11:56:46 -0500 Subject: [PATCH 0466/1681] Highlight "cannot calculate price" rows for new product batch --- tailbone/views/batch/newproduct.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tailbone/views/batch/newproduct.py b/tailbone/views/batch/newproduct.py index b56d008e..e74ffcf6 100644 --- a/tailbone/views/batch/newproduct.py +++ b/tailbone/views/batch/newproduct.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -136,7 +136,8 @@ class NewProductBatchView(BatchMasterView): return 'warning' if row.status_code in (row.STATUS_CATEGORY_NOT_FOUND, row.STATUS_FAMILY_NOT_FOUND, - row.STATUS_REPORTCODE_NOT_FOUND): + row.STATUS_REPORTCODE_NOT_FOUND, + row.STATUS_CANNOT_CALCULATE_PRICE): return 'notice' def configure_row_form(self, f): From 209b4b4de3f2a267706ecf04adc22d0a62307629 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 2 Nov 2021 11:15:44 -0500 Subject: [PATCH 0467/1681] 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 38d1e8b4..516c7dec 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,22 @@ CHANGELOG ========= +0.8.165 (2021-11-02) +-------------------- + +* Optionally set the ``sticky-header`` attribute for main buefy grids. + +* Show case qty by default for costing batch rows. + +* Highlight the "did not receive" rows for purchase batch. + +* Improve validation for Person field of User form. + +* Omit "edit" link unless user has perm, for Customer "people" subgrid. + +* Highlight "cannot calculate price" rows for new product batch. + + 0.8.164 (2021-10-20) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index b68d6316..1f238d69 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.164' +__version__ = '0.8.165' From 9fef4c2601a536a0e62961419e895c728b27bebc Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 3 Nov 2021 16:47:55 -0500 Subject: [PATCH 0468/1681] Fix the Department filter for Products grid, for jquery themes ugh jquery --- tailbone/views/products.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 3419ccfe..cc6a47ec 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -204,6 +204,7 @@ class ProductView(MasterView): def configure_grid(self, g): super(ProductView, self).configure_grid(g) app = self.get_rattail_app() + use_buefy = self.get_use_buefy() def join_vendor(q): return q.outerjoin(self.ProductVendorCost, @@ -239,6 +240,12 @@ class ProductView(MasterView): department_choices = app.cache_model(self.Session(), model.Department, order_by=model.Department.name, normalizer=lambda d: d.name) + department_choices = OrderedDict([('', "(any)")] + + sorted(six.iteritems(department_choices), + key=lambda itm: itm[1])) + if not use_buefy: + department_choices = [tags.Option(name, uuid) + for uuid, name in six.iteritems(department_choices)] g.set_filter('department', model.Department.uuid, value_enum=department_choices, verbs=['equal', 'not_equal', 'is_null', 'is_not_null', 'is_any'], From a6b7056f2a34f1502460acfd72aa52341feef635 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 3 Nov 2021 16:49:04 -0500 Subject: [PATCH 0469/1681] 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 516c7dec..1c89676b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.166 (2021-11-03) +-------------------- + +* Fix the Department filter for Products grid, for jquery themes. + + 0.8.165 (2021-11-02) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 1f238d69..6de0b83d 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.165' +__version__ = '0.8.166' From 8a378317c0950d53727f05e0daf96926c60238ce Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 3 Nov 2021 18:15:13 -0500 Subject: [PATCH 0470/1681] Try to prevent caching for any /index (grid) page if this works, maybe also should do it for /view since that can have a rows grid? --- tailbone/views/master.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index ce7fcca7..46b652e8 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -3941,7 +3941,13 @@ class MasterView(View): "List / search {}".format(model_title_plural)) config.add_route(route_prefix, '{}/'.format(url_prefix)) config.add_view(cls, attr='index', route_name=route_prefix, - permission='{}.list'.format(permission_prefix)) + permission='{}.list'.format(permission_prefix), + # hopefully, instruct browser to never cache this page. + # on windows/chrome we are seeing some caching when e.g. + # user applies some filters, then views a record, then + # clicks back button, filters no longer are applied + # cf. https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/viewconfig.html#non-predicate-arguments + http_cache=0) # download results # this is the "new" more flexible approach, but we only want to From b0fa559760a577edda4569106f60587573b4770a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 3 Nov 2021 18:30:16 -0500 Subject: [PATCH 0471/1681] Fix product view page when user cannot view version history --- tailbone/templates/products/view.mako | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tailbone/templates/products/view.mako b/tailbone/templates/products/view.mako index 08aa348a..0d0e4e5f 100644 --- a/tailbone/templates/products/view.mako +++ b/tailbone/templates/products/view.mako @@ -544,8 +544,12 @@ <%def name="modify_this_page_vars()"> ${parent.modify_this_page_vars()} - % if request.rattail_config.versioning_enabled() and master.has_perm('versions'): - <script type="text/javascript"> + <script type="text/javascript"> + + ThisPageData.vendorSourcesData = ${json.dumps(vendor_sources['data'])|n} + ThisPageData.lookupCodesData = ${json.dumps(lookup_codes['data'])|n} + + % if request.rattail_config.versioning_enabled() and master.has_perm('versions'): ThisPageData.showingPriceHistory_regular = false ThisPageData.regularPriceHistoryDataRaw = ${json.dumps(regular_price_history_grid.get_buefy_data()['data'])|n} @@ -635,9 +639,6 @@ }) } - ThisPageData.vendorSourcesData = ${json.dumps(vendor_sources['data'])|n} - ThisPageData.lookupCodesData = ${json.dumps(lookup_codes['data'])|n} - ThisPageData.showingCostHistory = false ThisPageData.costHistoryDataRaw = ${json.dumps(cost_history_grid.get_buefy_data()['data'])|n} ThisPageData.costHistoryLoading = false @@ -667,8 +668,8 @@ }) } - </script> - % endif + % endif + </script> </%def> From 4d33e3dcbe981d73dcc7b562764ab5d5bf8ec360 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 3 Nov 2021 19:19:20 -0500 Subject: [PATCH 0472/1681] Move some custorder logic to handler; allow force-swap of product selection --- .../static/js/tailbone.buefy.autocomplete.js | 15 +++-- tailbone/templates/custorders/create.mako | 12 +++- tailbone/views/custorders/orders.py | 57 ++----------------- 3 files changed, 24 insertions(+), 60 deletions(-) diff --git a/tailbone/static/js/tailbone.buefy.autocomplete.js b/tailbone/static/js/tailbone.buefy.autocomplete.js index 7969f35a..53c41b40 100644 --- a/tailbone/static/js/tailbone.buefy.autocomplete.js +++ b/tailbone/static/js/tailbone.buefy.autocomplete.js @@ -32,10 +32,17 @@ const TailboneAutocomplete = { // allows for the "label" to display correctly as well initialLabel: String, - // TODO: i am not sure this is needed? but current logic does - // handle it specially, so am leaving for now. if this prop - // is set by the caller, then the `assignedLabel` will *always* - // be shown for the button (when "selection" has been made) + // while the `initialLabel` above is useful for setting the + // *initial* label (of course), it cannot be used to + // arbitrarily update the label during the component's life. + // if you do need to *update* the label after initial page + // load, then you should set `assignedLabel` instead. one + // place this happens is in /custorders/create page, where + // product autocomplete shows some results, and user clicks + // one, but then handler logic can forcibly "swap" the + // selection, causing *different* product data to come back + // from the server, and autocomplete label should be updated + // to match. this feels a bit awkward still but does work.. assignedLabel: String, // simple placeholder text for the input box diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index 61c147f5..c6bcbd64 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -511,7 +511,7 @@ <tailbone-autocomplete ref="productAutocomplete" v-model="productUUID" placeholder="Enter UPC or brand, description etc." - :initial-label="productDisplay" + :assigned-label="productDisplay" serviceUrl="${url('{}.product_autocomplete'.format(route_prefix))}" @input="productChanged"> </tailbone-autocomplete> @@ -1368,12 +1368,18 @@ productChanged(uuid) { if (uuid) { - this.productUUID = uuid let params = { action: 'get_product_info', - uuid: this.productUUID, + uuid: uuid, } + // nb. it is possible for the handler to "swap" + // the product selection, i.e. user chooses a "per + // LB" item but the handler only allows selling by + // the "case" item. so we do not assume the uuid + // received above is the correct one, but just use + // whatever came back from handler this.submitBatchData(params, response => { + this.productUUID = response.data.uuid this.productUPC = response.data.upc_pretty this.productKey = response.data.key this.productDisplay = response.data.full_description diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index 1d564f35..ce74bac2 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -551,61 +551,12 @@ class CustomerOrderView(MasterView): return self.info_for_product(batch, data, product) def uom_choices_for_product(self, product): - choices = [] - - # Each - if not product or not product.weighed: - unit_name = self.enum.UNIT_OF_MEASURE[self.enum.UNIT_OF_MEASURE_EACH] - choices.append({'key': self.enum.UNIT_OF_MEASURE_EACH, - 'value': unit_name}) - - # Pound - if not product or product.weighed: - unit_name = self.enum.UNIT_OF_MEASURE[self.enum.UNIT_OF_MEASURE_POUND] - choices.append({ - 'key': self.enum.UNIT_OF_MEASURE_POUND, - 'value': unit_name, - }) - - # Case - case_text = None - case_size = self.handler.get_case_size_for_product(product) - if case_size is None: - case_text = "{} (× ?? {})".format( - self.enum.UNIT_OF_MEASURE[self.enum.UNIT_OF_MEASURE_CASE], - unit_name) - elif case_size > 1: - case_text = "{} (× {} {})".format( - self.enum.UNIT_OF_MEASURE[self.enum.UNIT_OF_MEASURE_CASE], - pretty_quantity(case_size), - unit_name) - if case_text: - choices.append({'key': self.enum.UNIT_OF_MEASURE_CASE, - 'value': case_text}) - - return choices + return self.handler.uom_choices_for_product(product) def info_for_product(self, batch, data, product): - app = self.get_rattail_app() - products = app.get_products_handler() - data = { - 'uuid': product.uuid, - 'upc': six.text_type(product.upc), - 'upc_pretty': product.upc.pretty(), - 'unit_price_display': self.get_unit_price_display(product), - 'full_description': product.full_description, - 'url': self.request.route_url('products.view', uuid=product.uuid), - 'image_url': products.get_image_url(product), - 'uom_choices': self.uom_choices_for_product(product), - } - - key = self.rattail_config.product_key() - if key == 'upc': - data['key'] = data['upc_pretty'] - else: - data['key'] = getattr(product, key, data['upc_pretty']) - - return data + info = self.handler.get_product_info(batch, product) + info['url'] = self.request.route_url('products.view', uuid=info['uuid']) + return info def normalize_batch(self, batch): return { From 1bdb845032edae646bf3f3be5df462f562f5aa63 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 3 Nov 2021 20:20:22 -0500 Subject: [PATCH 0473/1681] Honor the "product price may be questionable" flag for new custorder i.e. don't expose the per-item flag unless *that* flag is set --- tailbone/templates/custorders/create.mako | 27 ++++++++++++++++++++++- tailbone/views/custorders/orders.py | 22 ++++++++++++++---- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index c6bcbd64..7d368bdf 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -545,12 +545,14 @@ <!-- </b-field> --> </b-field> + % if product_price_may_be_questionable: <b-checkbox v-model="productPriceNeedsConfirmation" type="is-warning" size="is-small"> This price is questionable and should be confirmed by someone before order proceeds. </b-checkbox> + % endif </div> </div> @@ -629,7 +631,11 @@ </b-table-column> <b-table-column field="total_price_display" label="Total"> - <span :class="props.row.price_needs_confirmation ? 'has-background-warning' : ''"> + <span + % if product_price_may_be_questionable: + :class="props.row.price_needs_confirmation ? 'has-background-warning' : ''" + % endif + > {{ props.row.total_price_display }} </span> </b-table-column> @@ -758,7 +764,10 @@ defaultUOM: defaultUOM, productUOM: defaultUOM, productCaseSize: null, + + % if product_price_may_be_questionable: productPriceNeedsConfirmation: false, + % endif ## 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}, @@ -1290,7 +1299,11 @@ this.productQuantity = 1 this.productUnitChoices = this.defaultUnitChoices this.productUOM = this.defaultUOM + + % if product_price_may_be_questionable: this.productPriceNeedsConfirmation = false + % endif + this.showingItemDialog = true this.$nextTick(() => { this.$refs.productAutocomplete.focus() @@ -1311,7 +1324,10 @@ this.productQuantity = row.order_quantity this.productUnitChoices = row.order_uom_choices this.productUOM = row.order_uom + + % if product_price_may_be_questionable: this.productPriceNeedsConfirmation = row.price_needs_confirmation + % endif this.showingItemDialog = true }, @@ -1348,7 +1364,10 @@ this.productURL = null this.productImageURL = null this.productUnitChoices = this.defaultUnitChoices + + % if product_price_may_be_questionable: this.productPriceNeedsConfirmation = false + % endif }, setProductUnitChoices(choices) { @@ -1387,7 +1406,10 @@ this.productURL = response.data.url this.productImageURL = response.data.image_url this.setProductUnitChoices(response.data.uom_choices) + + % if product_price_may_be_questionable: this.productPriceNeedsConfirmation = false + % endif }) } else { this.clearProduct() @@ -1401,7 +1423,10 @@ product_uuid: this.productUUID, order_quantity: this.productQuantity, order_uom: this.productUOM, + + % if product_price_may_be_questionable: price_needs_confirmation: this.productPriceNeedsConfirmation, + % endif } if (this.editingItem) { diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index ce74bac2..b3d44183 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -188,7 +188,11 @@ class CustomerOrderView(MasterView): g.set_type('order_quantity', 'quantity') g.set_type('cases_ordered', 'quantity') g.set_type('units_ordered', 'quantity') - g.set_renderer('total_price', self.render_price_with_confirmation) + + if self.handler.product_price_may_be_questionable(): + g.set_renderer('total_price', self.render_price_with_confirmation) + else: + g.set_type('total_price', 'currency') g.set_enum('order_uom', self.enum.UNIT_OF_MEASURE) g.set_renderer('status_code', self.render_row_status_code) @@ -277,6 +281,7 @@ class CustomerOrderView(MasterView): 'batch': batch, 'normalized_batch': self.normalize_batch(batch), 'new_order_requires_customer': self.handler.new_order_requires_customer(), + 'product_price_may_be_questionable': self.handler.product_price_may_be_questionable(), 'allow_contact_info_choice': self.handler.allow_contact_info_choice(), 'restrict_contact_info': self.handler.should_restrict_contact_info(), 'order_items': items, @@ -615,12 +620,14 @@ class CustomerOrderView(MasterView): 'unit_price_display': self.get_unit_price_display(row), 'total_price': six.text_type(row.total_price) if row.total_price is not None else None, 'total_price_display': "${:0.2f}".format(row.total_price) if row.total_price is not None else None, - 'price_needs_confirmation': row.price_needs_confirmation, 'status_code': row.status_code, 'status_text': row.status_text, } + if self.handler.product_price_may_be_questionable(): + data['price_needs_confirmation'] = row.price_needs_confirmation + key = self.rattail_config.product_key() if key == 'upc': data['product_key'] = data['product_upc_pretty'] @@ -669,10 +676,14 @@ class CustomerOrderView(MasterView): if not product: return {'error': "Product not found"} + kwargs = {} + if self.handler.product_price_may_be_questionable(): + kwargs['price_needs_confirmation'] = data.get('price_needs_confirmation') + row = self.handler.add_product(batch, product, decimal.Decimal(data.get('order_quantity') or '0'), data.get('order_uom'), - price_needs_confirmation=data.get('price_needs_confirmation')) + **kwargs) self.Session.flush() else: # product is not known @@ -707,7 +718,10 @@ class CustomerOrderView(MasterView): row.product = product row.order_quantity = decimal.Decimal(data.get('order_quantity') or '0') row.order_uom = data.get('order_uom') - row.price_needs_confirmation = data.get('price_needs_confirmation') + + if self.handler.product_price_may_be_questionable(): + row.price_needs_confirmation = data.get('price_needs_confirmation') + self.handler.refresh_row(row) self.Session.flush() self.Session.refresh(row) From 0758ca09e65bfc3e90ce26a713a4d61919e9323f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 3 Nov 2021 20:54:46 -0500 Subject: [PATCH 0474/1681] Show unit price in line items grid for new custorder maybe should change this to show "base price" (unit *or* case depending on the row uom) ? --- tailbone/templates/custorders/create.mako | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index 7d368bdf..3e409a34 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -630,6 +630,16 @@ <span v-html="props.row.order_quantity_display"></span> </b-table-column> + <b-table-column field="unit_price_display" label="Unit Price"> + <span + % if product_price_may_be_questionable: + :class="props.row.price_needs_confirmation ? 'has-background-warning' : ''" + % endif + > + {{ props.row.unit_price_display }} + </span> + </b-table-column> + <b-table-column field="total_price_display" label="Total"> <span % if product_price_may_be_questionable: From b34d88d704ca32326cac8ca76a4bee3797cc0a41 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 4 Nov 2021 21:20:42 -0500 Subject: [PATCH 0475/1681] Avoid exposing batch params when creating a batch not sure how this never came up until now..? --- tailbone/views/batch/core.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 07b7ff68..821628aa 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -341,6 +341,10 @@ class BatchMasterView(MasterView): f.set_renderer('id', self.render_id_str) f.set_label('id', "Batch ID") + # params + if self.creating: + f.remove('params') + # created f.set_readonly('created') f.set_readonly('created_by') From eb76d868ca9754b30770b1ddd3f122a7c58609b6 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 4 Nov 2021 21:25:32 -0500 Subject: [PATCH 0476/1681] 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 1c89676b..8fef5dea 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,22 @@ CHANGELOG ========= +0.8.167 (2021-11-04) +-------------------- + +* Try to prevent caching for any /index (grid) page. + +* Fix product view page when user cannot view version history. + +* Move some custorder logic to handler; allow force-swap of product selection. + +* Honor the "product price may be questionable" flag for new custorder. + +* Show unit price in line items grid for new custorder. + +* Avoid exposing batch params when creating a batch. + + 0.8.166 (2021-11-03) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 6de0b83d..44127df5 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.166' +__version__ = '0.8.167' From 2be1d121161d36305875f615c5d42cd3ed17baff Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 5 Nov 2021 15:11:07 -0500 Subject: [PATCH 0477/1681] Make separate method for writing results XLSX file so subclass can customize --- tailbone/views/master.py | 45 ++++++++++++++++++++++------------------ 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 46b652e8..1ab87bb6 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2722,6 +2722,29 @@ class MasterView(View): def results_xlsx_session(self): return self.make_isolated_session() + def results_write_xlsx(self, path, fields, results, session, progress=None): + writer = ExcelWriter(path, fields, sheet_title=self.get_model_title_plural()) + writer.write_header() + + rows = [] + def write(obj, i): + data = self.get_xlsx_row(obj, fields) + row = [data[field] for field in fields] + rows.append(row) + + self.progress_loop(write, results, progress, + message="Collecting data for Excel") + + def finalize(x, i): + writer.write_rows(rows) + writer.auto_freeze() + writer.auto_filter() + writer.auto_resize() + writer.save() + + self.progress_loop(finalize, [1], progress, + message="Writing Excel file to disk") + def results_xlsx_thread(self, results, user_uuid, progress): """ Thread target, responsible for actually generating the Excel file which @@ -2743,27 +2766,9 @@ class MasterView(View): results = results.with_session(session).all() fields = self.get_xlsx_fields() - writer = ExcelWriter(path, fields, sheet_title=self.get_model_title_plural()) - writer.write_header() - rows = [] - def write(obj, i): - data = self.get_xlsx_row(obj, fields) - row = [data[field] for field in fields] - rows.append(row) - - self.progress_loop(write, results, progress, - message="Collecting data for Excel") - - def finalize(x, i): - writer.write_rows(rows) - writer.auto_freeze() - writer.auto_filter() - writer.auto_resize() - writer.save() - - self.progress_loop(finalize, [1], progress, - message="Writing Excel file to disk") + # write output file + self.results_write_xlsx(path, fields, results, session, progress=progress) except Exception as error: msg = "generating XLSX file for download failed!" From df8778f85d33f9f1176e97c1d11842cf14757b64 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 5 Nov 2021 15:11:30 -0500 Subject: [PATCH 0478/1681] Add `render_brand()` method for MasterView --- tailbone/views/master.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 1ab87bb6..1eb7686a 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -791,6 +791,14 @@ class MasterView(View): url = self.request.route_url('subdepartments.view', uuid=subdepartment.uuid) return tags.link_to(text, url) + def render_brand(self, obj, field): + brand = getattr(obj, field) + if not brand: + return + text = brand.name + url = self.request.route_url('brands.view', uuid=brand.uuid) + return tags.link_to(text, url) + def render_category(self, obj, field): category = getattr(obj, field) if not category: From 5ff57ae7d223c3061409e76950982e97a74b6e77 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 5 Nov 2021 18:40:46 -0500 Subject: [PATCH 0479/1681] Add link to download generic template for vendor catalog batch also let config restrict which parsers are "supported" and auto-choose parser if there is only one --- .../templates/batch/vendorcatalog/index.mako | 3 +++ tailbone/views/batch/vendorcatalog.py | 22 ++++++++++++++++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/tailbone/templates/batch/vendorcatalog/index.mako b/tailbone/templates/batch/vendorcatalog/index.mako index fa6e4a5a..1fac1170 100644 --- a/tailbone/templates/batch/vendorcatalog/index.mako +++ b/tailbone/templates/batch/vendorcatalog/index.mako @@ -3,6 +3,9 @@ <%def name="context_menu_items()"> ${parent.context_menu_items()} + % if generic_template_url and master.has_perm('create'): + <li>${h.link_to("Download Generic Template", generic_template_url)}</li> + % endif % if h.route_exists(request, 'vendors') and request.has_perm('vendors.list'): <li>${h.link_to("View Vendors", url('vendors'))}</li> % endif diff --git a/tailbone/views/batch/vendorcatalog.py b/tailbone/views/batch/vendorcatalog.py index adcf5dff..7a1a4153 100644 --- a/tailbone/views/batch/vendorcatalog.py +++ b/tailbone/views/batch/vendorcatalog.py @@ -135,7 +135,13 @@ class VendorCatalogView(FileBatchMasterView): def get_parsers(self): if not hasattr(self, 'parsers'): - self.parsers = sorted(iter_catalog_parsers(), key=lambda p: p.display) + parsers = sorted(iter_catalog_parsers(), key=lambda p: p.display) + supported = self.rattail_config.getlist( + 'tailbone', 'batch.vendorcatalog.supported_parsers') + if supported: + parsers = [parser for parser in parsers + if parser.key in supported] + self.parsers = parsers return self.parsers def configure_grid(self, g): @@ -176,8 +182,13 @@ class VendorCatalogView(FileBatchMasterView): if self.creating: if 'parser_key' not in f: f.insert_after('filename', 'parser_key') - values = [(p.key, p.display) for p in self.get_parsers()] - values.insert(0, ('', "(please choose)")) + parsers = self.get_parsers() + values = [(p.key, p.display) for p in parsers] + if len(values) == 1: + f.set_default('parser_key', parsers[0].key) + use_buefy = self.get_use_buefy() + if not use_buefy: + values.insert(0, ('', "(please choose)")) f.set_widget('parser_key', dfwidget.SelectWidget(values=values)) f.set_label('parser_key', "File Type") @@ -241,6 +252,11 @@ class VendorCatalogView(FileBatchMasterView): f.set_type('upc', 'gpc') f.set_type('discount_percent', 'percent') + def template_kwargs_index(self, **kwargs): + url = self.rattail_config.get('tailbone', 'batch.vendorcatalog.generic_template_url') + kwargs['generic_template_url'] = url + return kwargs + def template_kwargs_create(self, **kwargs): parsers = self.get_parsers() for parser in parsers: From 28e908524962d942a5fa36e1e244e75ec237aaf2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 5 Nov 2021 18:45:45 -0500 Subject: [PATCH 0480/1681] 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 8fef5dea..5965aed3 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.8.168 (2021-11-05) +-------------------- + +* Make separate method for writing results XLSX file. + +* Add ``render_brand()`` method for MasterView. + +* Add link to download generic template for vendor catalog batch. + + 0.8.167 (2021-11-04) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 44127df5..35b813c7 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.167' +__version__ = '0.8.168' From 7a5ba0503ae64a4c0cde77b82e945935a6e15a07 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 6 Nov 2021 17:36:19 -0500 Subject: [PATCH 0481/1681] Use products handler to get image URL --- tailbone/views/products.py | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index cc6a47ec..3b6f45f0 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -1067,23 +1067,7 @@ class ProductView(MasterView): product = kwargs['instance'] use_buefy = self.get_use_buefy() - # TODO: pretty sure this is no longer needed? guess we'll find out - # kwargs['image'] = False - - # maybe provide image URL for product; we prefer image from our DB if - # present, but otherwise a "POD" image URL can be attempted. - if product.image: - kwargs['image_url'] = self.request.route_url('products.image', uuid=product.uuid) - - elif product.upc: - if self.rattail_config.getbool('tailbone', 'products.show_pod_image', default=False): - # here we try to give a URL to a so-called "POD" image for the product - kwargs['image_url'] = pod.get_image_url(self.rattail_config, product.upc) - kwargs['image_path'] = pod.get_image_path(self.rattail_config, product.upc) - - # maybe use "image not found" placeholder image - if not kwargs.get('image_url'): - kwargs['image_url'] = self.request.static_url('tailbone:static/img/product.png') + kwargs['image_url'] = self.handler.get_image_url(product) # add price history, if user has access if self.rattail_config.versioning_enabled() and self.has_perm('versions'): From 43bbc2a29ea0e2875e361064fad5a9df31487d73 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 6 Nov 2021 17:37:05 -0500 Subject: [PATCH 0482/1681] Show some more product attributes in custorder item selection popup --- tailbone/templates/custorders/create.mako | 76 ++++++++++++++++++++++- tailbone/views/custorders/orders.py | 5 ++ 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index 3e409a34..f8b7b9cb 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -531,18 +531,35 @@ <div class="is-pulled-right has-text-centered"> <img :src="productImageURL" style="height: 150px; width: 150px; "/> - <p>{{ productKey }}</p> + ## <p>{{ productKey }}</p> </div> <b-field grouped> + <b-field :label="productKeyLabel"> + <span>{{ productKey }}</span> + </b-field> + + <b-field label="Unit Size"> + <span>{{ productSize }}</span> + </b-field> + <b-field label="Unit Price"> - <span :class="productPriceNeedsConfirmation ? 'has-background-warning' : ''"> + <span + % if product_price_may_be_questionable: + :class="productPriceNeedsConfirmation ? 'has-background-warning' : ''" + % endif + > {{ productUnitPriceDisplay }} </span> </b-field> <!-- <b-field label="Last Changed"> --> <!-- <span>2021-01-01</span> --> <!-- </b-field> --> + + <b-field label="Case Size"> + <span>{{ productCaseQuantity }}</span> + </b-field> + </b-field> % if product_price_may_be_questionable: @@ -568,6 +585,12 @@ </b-tab-item> <b-tab-item label="Quantity"> + <b-field grouped> + <b-field label="Product" horizontal> + <span>{{ productDisplay }}</span> + </b-field> + </b-field> + <b-field grouped> <b-field label="Quantity" horizontal> @@ -583,6 +606,39 @@ </b-select> </b-field> + + <b-field grouped> + + <b-field label="Unit Size"> + <span>{{ productSize }}</span> + </b-field> + + <b-field label="Unit Price"> + <span + % if product_price_may_be_questionable: + :class="productPriceNeedsConfirmation ? 'has-background-warning' : ''" + % endif + > + {{ productUnitPriceDisplay }} + </span> + </b-field> + + <b-field label="Case Size"> + <span>{{ productCaseQuantity }}</span> + </b-field> + + <b-field label="Case Price"> + <span + % if product_price_may_be_questionable: + :class="productPriceNeedsConfirmation ? 'has-background-warning' : ''" + % endif + > + {{ productCasePriceDisplay }} + </span> + </b-field> + + </b-field> + </b-tab-item> </b-tabs> @@ -765,7 +821,11 @@ productDisplay: null, productUPC: null, productKey: null, + productKeyLabel: ${json.dumps(product_key_label)|n}, + productSize: null, + productCaseQuantity: null, productUnitPriceDisplay: null, + productCasePriceDisplay: null, productURL: null, productImageURL: null, productQuantity: null, @@ -1305,7 +1365,10 @@ this.productDisplay = null this.productUPC = null this.productKey = null + this.productSize = null + this.productCaseQuantity = null this.productUnitPriceDisplay = null + this.productCasePriceDisplay = null this.productQuantity = 1 this.productUnitChoices = this.defaultUnitChoices this.productUOM = this.defaultUOM @@ -1328,8 +1391,11 @@ this.productDisplay = row.product_full_description this.productUPC = row.product_upc_pretty || row.product_upc this.productKey = row.product_key + this.productSize = row.product_size + this.productCaseQuantity = row.case_quantity this.productURL = row.product_url this.productUnitPriceDisplay = row.unit_price_display + this.productCasePriceDisplay = row.case_price_display this.productImageURL = row.product_image_url this.productQuantity = row.order_quantity this.productUnitChoices = row.order_uom_choices @@ -1370,7 +1436,10 @@ this.productDisplay = null this.productUPC = null this.productKey = null + this.productSize = null + this.productCaseQuantity = null this.productUnitPriceDisplay = null + this.productCasePriceDisplay = null this.productURL = null this.productImageURL = null this.productUnitChoices = this.defaultUnitChoices @@ -1412,7 +1481,10 @@ this.productUPC = response.data.upc_pretty this.productKey = response.data.key this.productDisplay = response.data.full_description + this.productSize = response.data.size + this.productCaseQuantity = response.data.case_quantity this.productUnitPriceDisplay = response.data.unit_price_display + this.productCasePriceDisplay = response.data.case_price_display this.productURL = response.data.url this.productImageURL = response.data.image_url this.setProductUnitChoices(response.data.uom_choices) diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index b3d44183..9065c330 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -285,6 +285,7 @@ class CustomerOrderView(MasterView): 'allow_contact_info_choice': self.handler.allow_contact_info_choice(), 'restrict_contact_info': self.handler.should_restrict_contact_info(), 'order_items': items, + 'product_key_label': self.rattail_config.product_key_title(), }) return self.render_to_response(template, context) @@ -625,6 +626,10 @@ class CustomerOrderView(MasterView): 'status_text': row.status_text, } + case_price = self.handler.get_case_price_for_row(row) + data['case_price'] = six.text_type(case_price) if case_price is not None else None + data['case_price_display'] = app.render_currency(case_price) + if self.handler.product_price_may_be_questionable(): data['price_needs_confirmation'] = row.price_needs_confirmation From ddb05afe6b5e0e84fda69a645733ca23a87ce139 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 6 Nov 2021 17:56:35 -0500 Subject: [PATCH 0483/1681] Auto-select Quantity tab when editing item for new custorder also be a little smarter on error when user selects an item --- tailbone/templates/custorders/create.mako | 21 ++++++++++++++++++--- tailbone/views/custorders/orders.py | 10 +++++++--- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index f8b7b9cb..520ed0f4 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -489,6 +489,7 @@ <div class="card-content"> <b-tabs type="is-boxed is-toggle" + v-model="itemDialogTabIndex" :animated="false"> <b-tab-item label="Product"> @@ -816,6 +817,7 @@ items: ${json.dumps(order_items)|n}, editingItem: null, showingItemDialog: false, + itemDialogTabIndex: 0, productIsKnown: true, productUUID: null, productDisplay: null, @@ -1105,7 +1107,7 @@ }) }, - submitBatchData(params, callback) { + submitBatchData(params, success, failure) { let url = ${json.dumps(request.current_route_url())|n} let headers = { @@ -1115,8 +1117,17 @@ ## TODO: should find a better way to handle CSRF token this.$http.post(url, params, {headers: headers}).then((response) => { - if (callback) { - callback(response) + if (response.data.error) { + this.$buefy.toast.open({ + message: response.data.error, + type: 'is-danger', + duration: 2000, // 2 seconds + }) + if (failure) { + failure(response) + } + } else if (success) { + success(response) } }, response => { this.$buefy.toast.open({ @@ -1377,6 +1388,7 @@ this.productPriceNeedsConfirmation = false % endif + this.itemDialogTabIndex = 0 this.showingItemDialog = true this.$nextTick(() => { this.$refs.productAutocomplete.focus() @@ -1405,6 +1417,7 @@ this.productPriceNeedsConfirmation = row.price_needs_confirmation % endif + this.itemDialogTabIndex = 1 this.showingItemDialog = true }, @@ -1492,6 +1505,8 @@ % if product_price_may_be_questionable: this.productPriceNeedsConfirmation = false % endif + }, response => { + this.clearProduct() }) } else { this.clearProduct() diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index 9065c330..1cc36aca 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -560,9 +560,13 @@ class CustomerOrderView(MasterView): return self.handler.uom_choices_for_product(product) def info_for_product(self, batch, data, product): - info = self.handler.get_product_info(batch, product) - info['url'] = self.request.route_url('products.view', uuid=info['uuid']) - return info + try: + info = self.handler.get_product_info(batch, product) + except Exception as error: + return {'error': six.text_type(error)} + else: + info['url'] = self.request.route_url('products.view', uuid=info['uuid']) + return info def normalize_batch(self, batch): return { From 5d875bc731d855b05f9d4048f79357ab811ff0d3 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 6 Nov 2021 20:00:54 -0500 Subject: [PATCH 0484/1681] Let user "add past product" when making new custorder --- tailbone/templates/custorders/create.mako | 188 ++++++++++++++++++++-- tailbone/views/custorders/orders.py | 16 ++ 2 files changed, 188 insertions(+), 16 deletions(-) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index 520ed0f4..3daff955 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -483,7 +483,13 @@ @click="showAddItemDialog()"> Add Item </b-button> + <b-button icon-pack="fas" + icon-left="fas fa-plus" + @click="showAddPastItem()"> + Add Past Item + </b-button> </div> + <b-modal :active.sync="showingItemDialog"> <div class="card"> <div class="card-content"> @@ -586,28 +592,18 @@ </b-tab-item> <b-tab-item label="Quantity"> + <div class="is-pulled-right has-text-centered"> + <img :src="productImageURL" + style="height: 150px; width: 150px; "/> + ## <p>{{ productKey }}</p> + </div> + <b-field grouped> <b-field label="Product" horizontal> <span>{{ productDisplay }}</span> </b-field> </b-field> - <b-field grouped> - - <b-field label="Quantity" horizontal> - <b-input v-model="productQuantity"></b-input> - </b-field> - - <b-select v-model="productUOM"> - <option v-for="choice in productUnitChoices" - :key="choice.key" - :value="choice.key" - v-html="choice.value"> - </option> - </b-select> - - </b-field> - <b-field grouped> <b-field label="Unit Size"> @@ -640,6 +636,22 @@ </b-field> + <b-field grouped> + + <b-field label="Quantity" horizontal> + <b-input v-model="productQuantity"></b-input> + </b-field> + + <b-select v-model="productUOM"> + <option v-for="choice in productUnitChoices" + :key="choice.key" + :value="choice.key" + v-html="choice.value"> + </option> + </b-select> + + </b-field> + </b-tab-item> </b-tabs> @@ -659,6 +671,93 @@ </div> </b-modal> + <b-modal :active.sync="pastItemsShowDialog"> + <div class="card"> + <div class="card-content"> + + <b-table :data="pastItems" + icon-pack="fas" + :loading="pastItemsLoading" + :selected.sync="pastItemsSelected" + sortable + paginated + per-page="6"> + <template slot-scope="props"> + + <b-table-column :label="productKeyLabel" + field="key" + sortable> + {{ props.row.key }} + </b-table-column> + + <b-table-column label="Brand" + field="brand_name" + sortable> + {{ props.row.brand_name }} + </b-table-column> + + <b-table-column label="Description" + field="description" + sortable> + {{ props.row.description }} + </b-table-column> + + <b-table-column label="Size" + field="size" + sortable> + {{ props.row.size }} + </b-table-column> + + <b-table-column label="Unit Price" + field="unit_price" + sortable> + {{ props.row.unit_price_display }} + </b-table-column> + + <b-table-column label="Department" + field="department_name" + sortable> + {{ props.row.department_name }} + </b-table-column> + + <b-table-column label="Vendor" + field="vendor_name" + sortable> + {{ props.row.vendor_name }} + </b-table-column> + + </template> + <template slot="empty"> + <div class="content has-text-grey has-text-centered"> + <p> + <b-icon + pack="fas" + icon="fas fa-sad-tear" + size="is-large"> + </b-icon> + </p> + <p>Nothing here.</p> + </div> + </template> + </b-table> + + <div class="buttons"> + <b-button @click="pastItemsShowDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + icon-pack="fas" + icon-left="plus" + @click="pastItemsAddSelected()" + :disabled="!pastItemsSelected"> + Add Selected Item + </b-button> + </div> + + </div> + </div> + </b-modal> + <b-table v-if="items.length" :data="items"> <template slot-scope="props"> @@ -818,6 +917,10 @@ editingItem: null, showingItemDialog: false, itemDialogTabIndex: 0, + pastItemsShowDialog: false, + pastItemsLoading: false, + pastItems: [], + pastItemsSelected: null, productIsKnown: true, productUUID: null, productDisplay: null, @@ -1164,6 +1267,11 @@ }, contactChanged(uuid, callback) { + + // clear out the past items cache + this.pastItemsSelected = null + this.pastItems = [] + let params if (!uuid) { params = { @@ -1380,6 +1488,7 @@ this.productCaseQuantity = null this.productUnitPriceDisplay = null this.productCasePriceDisplay = null + this.productImageURL = '${request.static_url('tailbone:static/img/product.png')}' this.productQuantity = 1 this.productUnitChoices = this.defaultUnitChoices this.productUOM = this.defaultUOM @@ -1395,6 +1504,53 @@ }) }, + showAddPastItem() { + this.pastItemsSelected = null + + if (!this.pastItems.length) { + this.pastItemsLoading = true + let params = { + action: 'get_past_items', + } + this.submitBatchData(params, response => { + this.pastItems = response.data.past_items + this.pastItemsLoading = false + }) + } + + this.pastItemsShowDialog = true + }, + + pastItemsAddSelected() { + this.pastItemsShowDialog = false + + let selected = this.pastItemsSelected + this.editingItem = null + this.productIsKnown = true + this.productUUID = selected.uuid + this.productDisplay = selected.full_description + this.productUPC = selected.upc_pretty || selected.upc + this.productKey = selected.key + this.productSize = selected.size + this.productCaseQuantity = selected.case_quantity + this.productUnitPriceDisplay = selected.unit_price_display + this.productCasePriceDisplay = selected.case_price_display + this.productImageURL = selected.image_url + // TODO: this needs to come from handler i guess.. + // this.productURL = row.product_url + this.productQuantity = 1 + this.productUnitChoices = selected.uom_choices + // TODO: seems like the default should not be so generic? + this.productUOM = this.defaultUOM + + % if product_price_may_be_questionable: + this.productPriceNeedsConfirmation = false + % endif + + this.itemDialogTabIndex = 1 + this.showingItemDialog = true + }, + showEditItemDialog(index) { row = this.items[index] this.editingItem = row diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index 1cc36aca..a6304976 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -263,6 +263,7 @@ class CustomerOrderView(MasterView): 'get_customer_info', # 'set_customer_data', 'get_product_info', + 'get_past_items', 'add_item', 'update_item', 'delete_item', @@ -568,6 +569,21 @@ class CustomerOrderView(MasterView): info['url'] = self.request.route_url('products.view', uuid=info['uuid']) return info + def get_past_items(self, batch, data): + past_products = self.handler.get_past_products(batch) + past_items = [] + + for product in past_products: + try: + item = self.handler.get_product_info(batch, product) + except: + # nb. handler may raise error if product is "unsupported" + pass + else: + past_items.append(item) + + return {'past_items': past_items} + def normalize_batch(self, batch): return { 'uuid': batch.uuid, From 3990854d42f3c8bceded8664fdea78f30465e9d3 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 6 Nov 2021 20:31:55 -0500 Subject: [PATCH 0485/1681] Fix product URL for a new custorder scenario --- tailbone/templates/custorders/create.mako | 4 ++-- tailbone/views/custorders/orders.py | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index 3daff955..a78eed6c 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -527,6 +527,7 @@ type="is-primary" tag="a" target="_blank" :href="productURL" + :disabled="!productURL" icon-pack="fas" icon-left="external-link-alt"> View Product @@ -1536,8 +1537,7 @@ this.productUnitPriceDisplay = selected.unit_price_display this.productCasePriceDisplay = selected.case_price_display this.productImageURL = selected.image_url - // TODO: this needs to come from handler i guess.. - // this.productURL = row.product_url + this.productURL = selected.url this.productQuantity = 1 this.productUnitChoices = selected.uom_choices // TODO: seems like the default should not be so generic? diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index a6304976..79753c50 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -183,13 +183,17 @@ class CustomerOrderView(MasterView): def configure_row_grid(self, g): super(CustomerOrderView, self).configure_row_grid(g) + app = self.get_rattail_app() + handler = app.get_batch_handler( + 'custorder', + default='rattail.batch.custorder:CustomerOrderBatchHandler') g.set_type('case_quantity', 'quantity') g.set_type('order_quantity', 'quantity') g.set_type('cases_ordered', 'quantity') g.set_type('units_ordered', 'quantity') - if self.handler.product_price_may_be_questionable(): + if handler.product_price_may_be_questionable(): g.set_renderer('total_price', self.render_price_with_confirmation) else: g.set_type('total_price', 'currency') From 67c1adcc75a1502b3582072211796cb06f0d1eca Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 7 Nov 2021 14:12:06 -0600 Subject: [PATCH 0486/1681] Tweak how we fetch invoice parser per changes in rattail --- tailbone/views/batch/vendorinvoice.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/batch/vendorinvoice.py b/tailbone/views/batch/vendorinvoice.py index bd030666..9cfd5dc9 100644 --- a/tailbone/views/batch/vendorinvoice.py +++ b/tailbone/views/batch/vendorinvoice.py @@ -172,7 +172,7 @@ class VendorInvoiceView(FileBatchMasterView): return kwargs def init_batch(self, batch): - parser = require_invoice_parser(batch.parser_key) + parser = require_invoice_parser(self.rattail_config, batch.parser_key) vendor = api.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)) From 23d38604c4464759863a642fedd01b2cfc84a1b2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 7 Nov 2021 17:10:33 -0600 Subject: [PATCH 0487/1681] Let handler restrict available invoice parser options --- tailbone/views/purchasing/batch.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index 11a891c7..96fe2128 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -30,7 +30,6 @@ import six from rattail.db import model, api from rattail.time import localtime -from rattail.vendors.invoices import iter_invoice_parsers import colander from deform import widget as dfwidget @@ -219,6 +218,7 @@ class PurchasingBatchView(BatchMasterView): def configure_form(self, f): super(PurchasingBatchView, self).configure_form(f) + model = self.model batch = f.model_instance today = localtime(self.rattail_config).date() use_buefy = self.get_use_buefy() @@ -324,12 +324,23 @@ class PurchasingBatchView(BatchMasterView): # invoice_parser_key if self.creating: - parsers = sorted(iter_invoice_parsers(), key=lambda p: p.display) + kwargs = {} + + if 'vendor_uuid' in self.request.matchdict: + vendor = self.Session.query(model.Vendor).get( + self.request.matchdict['vendor_uuid']) + if vendor: + kwargs['vendor'] = vendor + + parsers = self.handler.get_supported_invoice_parsers(**kwargs) parser_values = [(p.key, p.display) for p in parsers] - parser_values.insert(0, ('', "(please choose)")) + if len(parsers) == 1: + f.set_default('invoice_parser_key', parsers[0].key) + if use_buefy: f.set_widget('invoice_parser_key', dfwidget.SelectWidget(values=parser_values)) else: + parser_values.insert(0, ('', "(please choose)")) f.set_widget('invoice_parser_key', forms.widgets.JQuerySelectWidget(values=parser_values)) else: f.remove_field('invoice_parser_key') From fec7c3b3eee89b94542ac10cb60c6325446d7832 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 7 Nov 2021 18:10:28 -0600 Subject: [PATCH 0488/1681] Cleanup grid columns for receiving batches --- tailbone/views/purchasing/receiving.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 0af4afe7..ead0abed 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -80,7 +80,6 @@ class ReceivingBatchView(PurchasingBatchView): 'truck_dump', 'description', 'department', - 'buyer', 'date_ordered', 'created', 'created_by', @@ -202,6 +201,12 @@ class ReceivingBatchView(PurchasingBatchView): def batch_mode(self): return self.enum.PURCHASE_BATCH_MODE_RECEIVING + def configure_grid(self, g): + super(ReceivingBatchView, self).configure_grid(g) + + if not self.handler.allow_truck_dump_receiving(): + g.remove('truck_dump') + def create(self, form=None, **kwargs): """ Custom view for creating a new receiving batch. We split the process From eb28fc2e3c6716d2db44a856316360bc3ef109b3 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 8 Nov 2021 13:15:10 -0600 Subject: [PATCH 0489/1681] Fall back to empty string for product regular price i think this avoids a bug when a product has no regular price but does have a current price --- tailbone/views/products.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 3b6f45f0..b1feecc3 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -479,7 +479,7 @@ class ProductView(MasterView): return self.handler.render_price(price) def render_current_price_for_grid(self, product, field): - text = self.render_price(product, field) + text = self.render_price(product, field) or "" price = product.current_price if price: From a12318246f647f2c09986b8b668d2a4a0a7d7df2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 8 Nov 2021 18:33:19 -0600 Subject: [PATCH 0490/1681] 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 5965aed3..3689c11c 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,24 @@ CHANGELOG ========= +0.8.169 (2021-11-08) +-------------------- + +* Use products handler to get image URL. + +* Show some more product attributes in custorder item selection popup. + +* Auto-select Quantity tab when editing item for new custorder. + +* Let user "add past product" when making new custorder. + +* Let handler restrict available invoice parser options. + +* Cleanup grid columns for receiving batches. + +* Fall back to empty string for product regular price. + + 0.8.168 (2021-11-05) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 35b813c7..78340043 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.168' +__version__ = '0.8.169' From 90cc8e5370ef4194ee2b0985a5f849a72794a3d9 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 8 Nov 2021 20:17:07 -0600 Subject: [PATCH 0491/1681] Fix dynamic content title for "view profile" page --- .../templates/people/view_profile_buefy.mako | 6 +++++- tailbone/views/people.py | 18 ++++++++++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako index 9ef956a9..766ca5f1 100644 --- a/tailbone/templates/people/view_profile_buefy.mako +++ b/tailbone/templates/people/view_profile_buefy.mako @@ -14,6 +14,10 @@ </style> </%def> +<%def name="content_title()"> + ${dynamic_content_title} +</%def> + <%def name="page_content()"> <profile-info @change-content-title="changeContentTitle"> </profile-info> @@ -1394,7 +1398,7 @@ mixins: [SubmitMixin], props: { employee: Object, - employeeHistory: Object, + employeeHistory: Array, }, computed: { diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 8e8374c4..3f055493 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -445,18 +445,27 @@ class PersonView(MasterView): 'employee_view_url': self.request.route_url('employees.view', uuid=employee.uuid) if employee else None, 'employee_history': employee.get_current_history() if employee else None, 'employee_history_data': self.get_context_employee_history(employee), + 'dynamic_content_title': self.get_context_content_title(person), } use_buefy = self.get_use_buefy() template = 'view_profile_buefy' if use_buefy else 'view_profile' return self.render_to_response(template, context) - def template_kwargs_view_profile_buefy(self, **kwargs): + def template_kwargs_view_profile(self, **kwargs): """ - Method stub, so subclass can always invoke super() for it. + Stub method so subclass can call `super()` for it. """ return kwargs + def template_kwargs_view_profile_buefy(self, **kwargs): + """ + Note that any subclass should not need to define this method. + It by default invokes :meth:`template_kwargs_view_profile()` + and returns that result. + """ + return self.template_kwargs_view_profile(**kwargs) + def get_max_lengths(self): model = self.model return { @@ -507,6 +516,7 @@ class PersonView(MasterView): 'view_profile_url': self.get_action_url('view_profile', person), 'phones': self.get_context_phones(person), 'emails': self.get_context_emails(person), + 'dynamic_content_title': self.get_context_content_title(person), } if person.address: @@ -514,6 +524,9 @@ class PersonView(MasterView): return context + def get_context_content_title(self, person): + return six.text_type(person) + def get_context_address(self, address): context = { 'uuid': address.uuid, @@ -633,6 +646,7 @@ class PersonView(MasterView): return { 'success': True, 'person': self.get_context_person(person), + 'dynamic_content_title': self.get_context_content_title(person), } def get_context_phones(self, person): From 85166d5beb8a81060c48cf5ed97f0c72f3d64383 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 9 Nov 2021 11:51:21 -0600 Subject: [PATCH 0492/1681] 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 3689c11c..622d0c0f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.170 (2021-11-09) +-------------------- + +* Fix dynamic content title for "view profile" page. + + 0.8.169 (2021-11-08) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 78340043..fab44e5f 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.169' +__version__ = '0.8.170' From e7871380a9454521926fb4386ec41fb61d5e210a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 9 Nov 2021 15:49:42 -0600 Subject: [PATCH 0493/1681] Add "true margin" to products XLSX export --- tailbone/views/products.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index b1feecc3..b2cb835e 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -663,6 +663,7 @@ class ProductView(MasterView): fields.append('vendor_name') fields.append('vendor_item_code') fields.append('unit_cost') + fields.append('true_margin') return fields @@ -724,6 +725,11 @@ class ProductView(MasterView): if 'unit_cost' in fields: row['unit_cost'] = product.cost.unit_cost if product.cost else None + if 'true_margin' in fields: + row['true_margin'] = None + if product.volatile and product.volatile.true_margin: + row['true_margin'] = product.volatile.true_margin + return row def get_instance(self): From 7630f504b00bd7ca498f2eb4d58d55cadf1b3f5b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 9 Nov 2021 17:20:53 -0600 Subject: [PATCH 0494/1681] Add initial VersionMasterView for those times when you just need to expose a version table directly --- tailbone/views/versions.py | 89 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 tailbone/views/versions.py diff --git a/tailbone/views/versions.py b/tailbone/views/versions.py new file mode 100644 index 00000000..6c370996 --- /dev/null +++ b/tailbone/views/versions.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2021 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Master view for version tables +""" + +from __future__ import unicode_literals, absolute_import + +import sqlalchemy_continuum as continuum + +from tailbone.views import MasterView +from tailbone.util import raw_datetime + + +class VersionMasterView(MasterView): + """ + Base class for version master views + """ + creatable = False + editable = False + deletable = False + + labels = { + 'transaction_issued_at': "Changed", + 'transaction_user': "Changed by", + 'transaction_id': "Transaction ID", + } + + grid_columns = [ + 'transaction_issued_at', + 'transaction_user', + 'version_parent', + 'transaction_id', + ] + + def query(self, session): + Transaction = continuum.transaction_class(self.true_model_class) + + query = session.query(self.model_class)\ + .join(Transaction, + Transaction.id == self.model_class.transaction_id) + + return query + + def configure_grid(self, g): + super(VersionMasterView, self).configure_grid(g) + Transaction = continuum.transaction_class(self.true_model_class) + + g.set_sorter('transaction_issued_at', Transaction.issued_at) + g.set_sorter('transaction_id', Transaction.id) + g.set_sort_defaults('transaction_issued_at', 'desc') + + g.set_renderer('transaction_issued_at', self.render_transaction_issued_at) + g.set_renderer('transaction_user', self.render_transaction_user) + g.set_renderer('transaction_id', self.render_transaction_id) + + g.set_link('transaction_issued_at') + g.set_link('transaction_user') + g.set_link('version_parent') + + def render_transaction_issued_at(self, version, field): + value = version.transaction.issued_at + return raw_datetime(self.rattail_config, value) + + def render_transaction_user(self, version, field): + return version.transaction.user + + def render_transaction_id(self, version, field): + return version.transaction.id From 5f9d311cdb24f3517cad30531ae1d70aede58836 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 10 Nov 2021 12:39:51 -0600 Subject: [PATCH 0495/1681] Add views for PendingProduct model; also DepartmentWidget --- tailbone/forms/widgets.py | 30 ++++++- tailbone/views/master.py | 4 +- tailbone/views/products.py | 160 +++++++++++++++++++++++++++++++++++++ 3 files changed, 192 insertions(+), 2 deletions(-) diff --git a/tailbone/forms/widgets.py b/tailbone/forms/widgets.py index d8976337..ad9d9c31 100644 --- a/tailbone/forms/widgets.py +++ b/tailbone/forms/widgets.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -36,6 +36,7 @@ import colander from deform import widget as dfwidget from webhelpers2.html import tags, HTML +from tailbone.db import Session from tailbone.forms.types import ProductQuantity @@ -278,3 +279,30 @@ class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget): tmpl_values = self.get_template_values(field, cstruct, kw) template = readonly and self.readonly_template or self.template return field.renderer(template, **tmpl_values) + + +class DepartmentWidget(dfwidget.SelectWidget): + """ + Custom select widget for a Department reference field. + + Constructor accepts the normal ``values`` kwarg but if not + provided then the widget will fetch department list from Rattail + DB. + + Constructor also accepts ``required`` kwarg, which defaults to + true unless specified. + """ + + def __init__(self, request, **kwargs): + + if 'values' not in kwargs: + model = request.rattail_config.get_model() + departments = Session.query(model.Department)\ + .order_by(model.Department.number) + values = [(dept.uuid, six.text_type(dept)) + for dept in departments] + if not kwargs.pop('required', True): + values.insert(0, ('', "(none)")) + kwargs['values'] = values + + super(DepartmentWidget, self).__init__(**kwargs) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 1eb7686a..f288ec34 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -739,9 +739,11 @@ class MasterView(View): if obj.emails: return obj.emails[0].address - def render_product_key_value(self, obj): + def render_product_key_value(self, obj, field=None): """ Render the "canonical" product key value for the given object. + + nb. the ``field`` kwarg is ignored if present """ product_key = self.rattail_config.product_key() if product_key == 'upc': diff --git a/tailbone/views/products.py b/tailbone/views/products.py index b2cb835e..351dd832 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -1885,6 +1885,165 @@ class ProductView(MasterView): permission='{}.versions'.format(permission_prefix)) +class PendingProductView(MasterView): + """ + Master view for the Pending Product class. + """ + model_class = model.PendingProduct + route_prefix = 'pending_products' + url_prefix = '/products/pending' + + labels = { + 'regular_price_amount': "Regular Price", + 'status_code': "Status", + 'user': "Created by", + } + + grid_columns = [ + '_product_key_', + 'department_name', + 'brand_name', + 'description', + 'size', + 'created', + 'user', + 'status_code', + ] + + form_fields = [ + '_product_key_', + 'department_name', + 'department', + 'brand_name', + 'brand', + 'description', + 'size', + 'case_size', + 'regular_price_amount', + 'special_order', + 'notes', + 'created', + 'user', + 'status_code', + ] + + def configure_grid(self, g): + super(PendingProductView, self).configure_grid(g) + + # product key + if '_product_key_' in g.columns: + key = self.rattail_config.product_key() + g.replace('_product_key_', key) + g.set_label(key, self.rattail_config.product_key_title(key)) + g.set_link(key) + + g.set_enum('status_code', self.enum.PENDING_PRODUCT_STATUS) + + g.set_sort_defaults('created', 'desc') + + g.set_link('description') + + def configure_form(self, f): + super(PendingProductView, self).configure_form(f) + model = self.model + pending = f.model_instance + + # product key + if '_product_key_' in f: + key = self.rattail_config.product_key() + f.replace('_product_key_', key) + f.set_label(key, self.rattail_config.product_key_title(key)) + f.set_renderer(key, self.render_product_key_value) + + # department + if self.creating or self.editing: + if 'department' in f: + f.remove('department_name') + f.replace('department', 'department_uuid') + f.set_widget('department_uuid', forms.widgets.DepartmentWidget(self.request, required=False)) + f.set_label('department_uuid', "Department") + else: + f.set_renderer('department', self.render_department) + if pending.department: + f.remove('department_name') + + # brand + if self.creating or self.editing: + f.remove('brand_name') + f.replace('brand', 'brand_uuid') + f.set_label('brand_uuid', "Brand") + + f.set_node('brand_uuid', colander.String(), missing=colander.null) + brand_display = "" + if self.request.method == 'POST': + if self.request.POST.get('brand_uuid'): + brand = self.Session.query(model.Brand).get(self.request.POST['brand_uuid']) + if brand: + brand_display = six.text_type(brand) + elif self.editing: + brand_display = six.text_type(pending.brand or '') + brands_url = self.request.route_url('brands.autocomplete') + f.set_widget('brand_uuid', forms.widgets.JQueryAutocompleteWidget( + field_display=brand_display, service_url=brands_url)) + else: + f.set_renderer('brand', self.render_brand) + if pending.brand: + f.remove('brand_name') + + # description + f.set_required('description') + + # case_size + f.set_type('case_size', 'quantity') + + # regular_price_amount + f.set_type('regular_price_amount', 'currency') + + # notes + f.set_type('notes', 'text') + + # created + if self.creating: + f.remove('created') + else: + f.set_readonly('created') + + # user + if self.creating: + f.remove('user') + else: + f.set_readonly('user') + f.set_renderer('user', self.render_user) + + # status_code + if self.creating: + f.remove('status_code') + else: + # f.set_readonly('status_code') + f.set_enum('status_code', self.enum.PENDING_PRODUCT_STATUS) + + def objectify(self, form, data=None): + if data is None: + data = form.validated + + pending = super(PendingProductView, self).objectify(form, data) + + if not pending.user: + pending.user = self.request.user + + self.Session.add(pending) + self.Session.flush() + self.Session.refresh(pending) + + if pending.department: + pending.department_name = pending.department.name + + if pending.brand: + pending.brand_name = pending.brand.name + + return pending + + def print_labels(request): profile = request.params.get('profile') profile = Session.query(model.LabelProfile).get(profile) if profile else None @@ -1920,3 +2079,4 @@ def includeme(config): renderer='json', permission='products.print_labels') ProductView.defaults(config) + PendingProductView.defaults(config) From 1ceb1e4434f1246fa3624043965563c28caf3fcf Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 11 Nov 2021 12:11:24 -0600 Subject: [PATCH 0496/1681] 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 622d0c0f..a579ad70 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.8.171 (2021-11-11) +-------------------- + +* Add "true margin" to products XLSX export. + +* Add initial ``VersionMasterView`` base class. + +* Add views for ``PendingProduct`` model; also ``DepartmentWidget``. + + 0.8.170 (2021-11-09) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index fab44e5f..11009583 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.170' +__version__ = '0.8.171' From f1fd003dca5b6125076cb1564d85e0fbe99d0925 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 11 Nov 2021 12:30:00 -0600 Subject: [PATCH 0497/1681] Add permission for viewing "all" employees previously we showed all if user had "edit" perm --- tailbone/views/employees.py | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/tailbone/views/employees.py b/tailbone/views/employees.py index 3ad331ab..febe521e 100644 --- a/tailbone/views/employees.py +++ b/tailbone/views/employees.py @@ -83,6 +83,7 @@ class EmployeeView(MasterView): def configure_grid(self, g): super(EmployeeView, self).configure_grid(g) route_prefix = self.get_route_prefix() + use_buefy = self.get_use_buefy() # phone g.set_joiner('phone', lambda q: q.outerjoin(model.EmployeePhoneNumber, sa.and_( @@ -114,21 +115,23 @@ class EmployeeView(MasterView): g.hide_column('username') # id - if self.request.has_perm('{}.edit'.format(route_prefix)): + if self.has_perm('edit'): g.set_link('id') else: - g.hide_column('id') + g.remove('id') del g.filters['id'] # status - if self.request.has_perm('{}.edit'.format(route_prefix)): + if self.has_perm('view_all'): g.set_enum('status', self.enum.EMPLOYEE_STATUS) g.filters['status'].default_active = True g.filters['status'].default_verb = 'equal' - # TODO: why must we set unicode string value here? - g.filters['status'].default_value = six.text_type(self.enum.EMPLOYEE_STATUS_CURRENT) + if use_buefy: + g.filters['status'].default_value = six.text_type(self.enum.EMPLOYEE_STATUS_CURRENT) + else: + g.filters['status'].default_value = self.enum.EMPLOYEE_STATUS_CURRENT else: - g.hide_column('status') + g.remove('status') del g.filters['status'] g.filters['first_name'].default_active = True @@ -151,7 +154,7 @@ class EmployeeView(MasterView): def query(self, session): q = session.query(model.Employee).join(model.Person) - if not self.request.has_perm('employees.edit'): + if not self.has_perm('view_all'): q = q.filter(model.Employee.status == self.enum.EMPLOYEE_STATUS_CURRENT) return q @@ -310,6 +313,21 @@ class EmployeeView(MasterView): (model.EmployeeDepartment, 'employee_uuid'), ] + @classmethod + def defaults(cls, config): + cls._defaults(config) + cls._employee_defaults(config) + + @classmethod + def _employee_defaults(cls, config): + permission_prefix = cls.get_permission_prefix() + model_title_plural = cls.get_model_title_plural() + + # view *all* employees + config.add_tailbone_permission(permission_prefix, + '{}.view_all'.format(permission_prefix), + "View *all* (not just current) {}".format(model_title_plural)) + def includeme(config): EmployeeView.defaults(config) From 6e15d59a843d0a1fa103bce35c4cde21d8718eb6 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 11 Nov 2021 12:31:42 -0600 Subject: [PATCH 0498/1681] 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 a579ad70..0499965e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.172 (2021-11-11) +-------------------- + +* Add permission for viewing "all" employees. + + 0.8.171 (2021-11-11) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 11009583..c36160ae 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.171' +__version__ = '0.8.172' From 3a10a4bcb7cfcfd675dd9ea680a5f42a4ccdf0fc Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 11 Nov 2021 13:37:10 -0600 Subject: [PATCH 0499/1681] Improve error handling when executing a custorder batch --- tailbone/templates/custorders/create.mako | 23 ++++++++++------------- tailbone/views/custorders/orders.py | 10 +++++++--- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index a78eed6c..cfbc4894 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -483,7 +483,8 @@ @click="showAddItemDialog()"> Add Item </b-button> - <b-button icon-pack="fas" + <b-button v-if="contactUUID" + icon-pack="fas" icon-left="fas fa-plus" @click="showAddPastItem()"> Add Past Item @@ -1239,6 +1240,9 @@ type: 'is-danger', duration: 2000, // 2 seconds }) + if (failure) { + failure(response) + } }) }, @@ -1250,20 +1254,13 @@ } this.submitBatchData(params, response => { - if (response.data.error) { - this.$buefy.toast.open({ - message: "Submit failed: " + response.data.error, - type: 'is-danger', - duration: 2000, // 2 seconds - }) - this.submittingOrder = false + if (response.data.next_url) { + location.href = response.data.next_url } else { - if (response.data.next_url) { - location.href = response.data.next_url - } else { - location.reload() - } + location.reload() } + }, response => { + this.submittingOrder = false }) }, diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index 79753c50..49280d96 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -779,9 +779,13 @@ class CustomerOrderView(MasterView): 'batch': self.normalize_batch(batch)} def submit_new_order(self, batch, data): - result = self.execute_new_order_batch(batch, data) - if not result: - return {'error': "Batch failed to execute"} + try: + result = self.execute_new_order_batch(batch, data) + except Exception as error: + return {'error': six.text_type(error)} + else: + if not result: + return {'error': "Batch failed to execute"} next_url = None if isinstance(result, model.CustomerOrder): From 426ba0ea344aa0bb352648e0f9117eed0a02f117 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 11 Nov 2021 17:42:59 -0600 Subject: [PATCH 0500/1681] Fix "download results" support for Products it is not enabled by default, but still should work when it is --- tailbone/templates/products/index.mako | 1 + tailbone/views/products.py | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/tailbone/templates/products/index.mako b/tailbone/templates/products/index.mako index 06b1e7e0..3f65cd68 100644 --- a/tailbone/templates/products/index.mako +++ b/tailbone/templates/products/index.mako @@ -76,6 +76,7 @@ </%def> <%def name="grid_tools()"> + ${parent.grid_tools()} % if label_profiles and request.has_perm('products.print_labels'): <table class="label-printing"> <thead> diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 351dd832..2f7bf02d 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -732,6 +732,16 @@ class ProductView(MasterView): return row + def download_results_normalize(self, product, fields, **kwargs): + data = super(ProductView, self).download_results_normalize( + product, fields, **kwargs) + + if 'upc' in data: + if isinstance(data['upc'], GPC): + data['upc'] = six.text_type(data['upc']) + + return data + def get_instance(self): key = self.request.matchdict['uuid'] product = Session.query(model.Product).get(key) From 901dacf038c81cff0bdc0a7214b119d7e69fa1e8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 11 Nov 2021 18:38:44 -0600 Subject: [PATCH 0501/1681] 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 0499965e..32eea392 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.173 (2021-11-11) +-------------------- + +* Improve error handling when executing a custorder batch. + +* Fix "download results" support for Products. + + 0.8.172 (2021-11-11) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index c36160ae..e081e8ca 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.172' +__version__ = '0.8.173' From a7b91b5b31acd691ae7871e7af792a8dd499beda Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 13 Nov 2021 15:05:45 -0600 Subject: [PATCH 0502/1681] Expose the "sync users" flag for Roles --- tailbone/views/roles.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py index 2ce48f0d..78389d5d 100644 --- a/tailbone/views/roles.py +++ b/tailbone/views/roles.py @@ -53,10 +53,15 @@ class RoleView(PrincipalMasterView): has_versions = True touchable = True + labels = { + 'sync_me': "Sync Attrs & Perms", + } + grid_columns = [ 'name', 'session_timeout', 'sync_me', + 'sync_users', 'node_type', 'notes', ] @@ -66,6 +71,7 @@ class RoleView(PrincipalMasterView): 'session_timeout', 'notes', 'sync_me', + 'sync_users', 'node_type', 'users', 'permissions', @@ -178,10 +184,11 @@ class RoleView(PrincipalMasterView): elif role is guest_role(self.Session()): include = False if not include: - f.remove('sync_me', 'node_type') + f.remove('sync_me', 'sync_users', 'node_type') else: if not self.has_perm('edit_node_sync'): f.set_readonly('sync_me') + f.set_readonly('sync_users') f.set_readonly('node_type') # notes From f385aab44a64865986055309fd65833ac8496af8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 14 Nov 2021 13:27:13 -0600 Subject: [PATCH 0503/1681] 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 32eea392..dab12a4f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.174 (2021-11-14) +-------------------- + +* Expose the "sync users" flag for Roles. + + 0.8.173 (2021-11-11) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index e081e8ca..586e661e 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.173' +__version__ = '0.8.174' From 0fa888efafba732f9de7450f795d3f072227d944 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 16 Nov 2021 17:23:56 -0600 Subject: [PATCH 0504/1681] Fix bug when product has empty suggested price --- tailbone/views/products.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 2f7bf02d..a59df32f 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -558,8 +558,10 @@ class ProductView(MasterView): def render_suggested_price(self, product, column): text = self.render_price(product, column) + if not text: + return - if text and self.show_price_effective_dates(): + if self.show_price_effective_dates(): history = self.get_suggested_price_history(product) if history: date = localtime(self.rattail_config, history[0]['changed'], from_utc=True).date() From b8f1b7bd84fc57ccd30269391503834bff88f1c2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 17 Nov 2021 14:57:10 -0600 Subject: [PATCH 0505/1681] Show ordered quantity when viewing costing batch row --- tailbone/views/purchasing/costing.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tailbone/views/purchasing/costing.py b/tailbone/views/purchasing/costing.py index 71b99cf2..d790fbc1 100644 --- a/tailbone/views/purchasing/costing.py +++ b/tailbone/views/purchasing/costing.py @@ -129,6 +129,8 @@ class CostingBatchView(PurchasingBatchView): 'size', 'department_name', 'case_quantity', + 'cases_ordered', + 'units_ordered', 'cases_shipped', 'units_shipped', 'cases_received', From e8828efae3b6a3bdf51fe13050535ca2e36ce1fb Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 17 Nov 2021 15:12:54 -0600 Subject: [PATCH 0506/1681] 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 dab12a4f..607d6443 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.175 (2021-11-17) +-------------------- + +* Fix bug when product has empty suggested price. + +* Show ordered quantity when viewing costing batch row. + + 0.8.174 (2021-11-14) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 586e661e..79b5759b 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.174' +__version__ = '0.8.175' From 03dad826636c520168acfbbfcf034e50e4262aba Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 25 Nov 2021 16:50:13 -0600 Subject: [PATCH 0507/1681] Add basic support for receiving from PO with invoice --- tailbone/views/purchasing/receiving.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index ead0abed..ccc97d9a 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -457,7 +457,7 @@ class ReceivingBatchView(PurchasingBatchView): f.set_widget('store_uuid', dfwidget.HiddenWidget()) # purchase - if (self.creating and workflow == 'from_po' + if (self.creating and workflow in ('from_po', 'from_po_with_invoice') and self.purchase_order_fieldname == 'purchase'): if use_buefy: f.replace('purchase', 'purchase_uuid') @@ -507,6 +507,11 @@ class ReceivingBatchView(PurchasingBatchView): 'invoice_file', 'invoice_parser_key') + elif workflow == 'from_po_with_invoice': + f.remove('truck_dump_batch_uuid') + f.set_required('invoice_file') + f.set_required('invoice_parser_key') + elif workflow == 'truck_dump_children_first': f.remove('truck_dump_batch_uuid', 'invoice_file', @@ -561,6 +566,9 @@ class ReceivingBatchView(PurchasingBatchView): elif batch_type == 'from_po': # TODO: how to best handle this field? this doesn't seem flexible kwargs['purchase_key'] = batch.purchase_uuid + elif batch_type == 'from_po_with_invoice': + # TODO: how to best handle this field? this doesn't seem flexible + kwargs['purchase_key'] = batch.purchase_uuid elif batch_type == 'truck_dump_children_first': kwargs['truck_dump'] = True kwargs['truck_dump_children_first'] = True From b9037111a4a8370f2b7f7b472e53247b7c37899d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 25 Nov 2021 18:56:28 -0600 Subject: [PATCH 0508/1681] Don't use multi-select for new report in buefy themes also let app handler fetch the report handler --- tailbone/views/reports.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index 6f6b1660..5d1ca5eb 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -36,7 +36,6 @@ import rattail from rattail.db import model, Session as RattailSession from rattail.files import resource_path from rattail.time import localtime -from rattail.reporting import get_report_handler from rattail.threads import Thread from rattail.util import simple_error, OrderedDict @@ -286,7 +285,8 @@ class GenerateReport(View): self.handler = self.get_handler() def get_handler(self): - return get_report_handler(self.rattail_config) + app = self.get_rattail_app() + return app.get_report_handler() def choose(self): """ @@ -313,7 +313,7 @@ class GenerateReport(View): values = [(r.type_key, r.name) for r in reports.values()] values.sort(key=lambda r: r[1]) if use_buefy: - form.set_widget('report_type', forms.widgets.CustomSelectWidget(values=values, size=10)) + form.set_widget('report_type', forms.widgets.CustomSelectWidget(values=values)) form.widgets['report_type'].set_template_values(input_handler='reportTypeChanged') else: form.set_widget('report_type', forms.widgets.PlainSelectWidget(values=values, size=10)) From ce354d5bc32b07a1316942a024491a8ca3d9bf77 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 25 Nov 2021 19:01:35 -0600 Subject: [PATCH 0509/1681] 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 607d6443..7287c0cd 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.176 (2021-11-25) +-------------------- + +* Add basic support for receiving from PO with invoice. + +* Don't use multi-select for new report in buefy themes. + + 0.8.175 (2021-11-17) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 79b5759b..939e3843 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.175' +__version__ = '0.8.176' From c1f91906134c91f9c1ae439220be369f94e017f2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 27 Nov 2021 19:08:15 -0600 Subject: [PATCH 0510/1681] Show current/sale pricing for products in new custorder page --- tailbone/templates/custorders/create.mako | 69 ++++++++++++++++++++--- 1 file changed, 61 insertions(+), 8 deletions(-) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index cfbc4894..98dafda4 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -552,6 +552,10 @@ <span>{{ productSize }}</span> </b-field> + <b-field label="Case Size"> + <span>{{ productCaseQuantity }}</span> + </b-field> + <b-field label="Unit Price"> <span % if product_price_may_be_questionable: @@ -565,8 +569,18 @@ <!-- <span>2021-01-01</span> --> <!-- </b-field> --> - <b-field label="Case Size"> - <span>{{ productCaseQuantity }}</span> + <b-field label="Sale Price" + v-if="productSalePriceDisplay"> + <span class="has-background-warning"> + {{ productSalePriceDisplay }} + </span> + </b-field> + + <b-field label="Sale Ends" + v-if="productSaleEndsDisplay"> + <span class="has-background-warning"> + {{ productSaleEndsDisplay }} + </span> </b-field> </b-field> @@ -622,6 +636,20 @@ </span> </b-field> + <b-field label="Sale Price" + v-if="productSalePriceDisplay"> + <span class="has-background-warning"> + {{ productSalePriceDisplay }} + </span> + </b-field> + + <b-field label="Sale Ends" + v-if="productSaleEndsDisplay"> + <span class="has-background-warning"> + {{ productSaleEndsDisplay }} + </span> + </b-field> + <b-field label="Case Size"> <span>{{ productCaseQuantity }}</span> </b-field> @@ -629,7 +657,9 @@ <b-field label="Case Price"> <span % if product_price_may_be_questionable: - :class="productPriceNeedsConfirmation ? 'has-background-warning' : ''" + :class="(productPriceNeedsConfirmation || productSalePriceDisplay) ? 'has-background-warning' : ''" + % else: + :class="productSalePriceDisplay ? 'has-background-warning' : ''" % endif > {{ productCasePriceDisplay }} @@ -702,11 +732,6 @@ field="description" sortable> {{ props.row.description }} - </b-table-column> - - <b-table-column label="Size" - field="size" - sortable> {{ props.row.size }} </b-table-column> @@ -716,6 +741,22 @@ {{ props.row.unit_price_display }} </b-table-column> + <b-table-column label="Sale Price" + field="sale_price" + sortable> + <span class="has-background-warning"> + {{ props.row.sale_price_display }} + </span> + </b-table-column> + + <b-table-column label="Sale Ends" + field="sale_ends" + sortable> + <span class="has-background-warning"> + {{ props.row.sale_ends_display }} + </span> + </b-table-column> + <b-table-column label="Department" field="department_name" sortable> @@ -933,6 +974,8 @@ productCaseQuantity: null, productUnitPriceDisplay: null, productCasePriceDisplay: null, + productSalePriceDisplay: null, + productSaleEndsDisplay: null, productURL: null, productImageURL: null, productQuantity: null, @@ -1486,6 +1529,8 @@ this.productCaseQuantity = null this.productUnitPriceDisplay = null this.productCasePriceDisplay = null + this.productSalePriceDisplay = null + this.productSaleEndsDisplay = null this.productImageURL = '${request.static_url('tailbone:static/img/product.png')}' this.productQuantity = 1 this.productUnitChoices = this.defaultUnitChoices @@ -1533,6 +1578,8 @@ this.productCaseQuantity = selected.case_quantity this.productUnitPriceDisplay = selected.unit_price_display this.productCasePriceDisplay = selected.case_price_display + this.productSalePriceDisplay = selected.sale_price_display + this.productSaleEndsDisplay = selected.sale_ends_display this.productImageURL = selected.image_url this.productURL = selected.url this.productQuantity = 1 @@ -1561,6 +1608,8 @@ this.productURL = row.product_url this.productUnitPriceDisplay = row.unit_price_display this.productCasePriceDisplay = row.case_price_display + this.productSalePriceDisplay = row.sale_price_display + this.productSaleEndsDisplay = row.sale_ends_display this.productImageURL = row.product_image_url this.productQuantity = row.order_quantity this.productUnitChoices = row.order_uom_choices @@ -1606,6 +1655,8 @@ this.productCaseQuantity = null this.productUnitPriceDisplay = null this.productCasePriceDisplay = null + this.productSalePriceDisplay = null + this.productSaleEndsDisplay = null this.productURL = null this.productImageURL = null this.productUnitChoices = this.defaultUnitChoices @@ -1651,6 +1702,8 @@ this.productCaseQuantity = response.data.case_quantity this.productUnitPriceDisplay = response.data.unit_price_display this.productCasePriceDisplay = response.data.case_price_display + this.productSalePriceDisplay = response.data.sale_price_display + this.productSaleEndsDisplay = response.data.sale_ends_display this.productURL = response.data.url this.productImageURL = response.data.image_url this.setProductUnitChoices(response.data.uom_choices) From dbd00291b33a393a3cc3aa711264240b93174fcd Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 27 Nov 2021 19:47:02 -0600 Subject: [PATCH 0511/1681] Add simple search filters for past items dialog in new custorder --- tailbone/templates/custorders/create.mako | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index 98dafda4..0071b61f 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -713,7 +713,8 @@ :selected.sync="pastItemsSelected" sortable paginated - per-page="6"> + per-page="5" + :debounce-search="1000"> <template slot-scope="props"> <b-table-column :label="productKeyLabel" @@ -724,13 +725,15 @@ <b-table-column label="Brand" field="brand_name" - sortable> + sortable + searchable> {{ props.row.brand_name }} </b-table-column> <b-table-column label="Description" field="description" - sortable> + sortable + searchable> {{ props.row.description }} {{ props.row.size }} </b-table-column> @@ -759,13 +762,15 @@ <b-table-column label="Department" field="department_name" - sortable> + sortable + searchable> {{ props.row.department_name }} </b-table-column> <b-table-column label="Vendor" field="vendor_name" - sortable> + sortable + searchable> {{ props.row.vendor_name }} </b-table-column> From bb0666b77d3609ab1f150676b26aa793d5aba66e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 28 Nov 2021 10:59:55 -0600 Subject: [PATCH 0512/1681] 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 7287c0cd..b2631aff 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.177 (2021-11-28) +-------------------- + +* Show current/sale pricing for products in new custorder page. + +* Add simple search filters for past items dialog in new custorder. + + 0.8.176 (2021-11-25) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 939e3843..29b86e26 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.176' +__version__ = '0.8.177' From 8aff5d519d773d5d7a37237ca5bc2de5fae5d4d3 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 29 Nov 2021 17:23:01 -0600 Subject: [PATCH 0513/1681] Add page for configuring datasync experimental! until proven worthy.. --- .../templates/datasync/changes/index.mako | 7 + tailbone/templates/datasync/configure.mako | 759 ++++++++++++++++++ tailbone/views/datasync.py | 203 ++++- 3 files changed, 954 insertions(+), 15 deletions(-) create mode 100644 tailbone/templates/datasync/configure.mako diff --git a/tailbone/templates/datasync/changes/index.mako b/tailbone/templates/datasync/changes/index.mako index cea3c969..c28076fe 100644 --- a/tailbone/templates/datasync/changes/index.mako +++ b/tailbone/templates/datasync/changes/index.mako @@ -1,6 +1,13 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/index.mako" /> +<%def name="context_menu_items()"> + ${parent.context_menu_items()} + % if master.has_perm('configure'): + ${h.link_to("Configure DataSync", url('datasync.configure'))} + % endif +</%def> + <%def name="grid_tools()"> ${parent.grid_tools()} diff --git a/tailbone/templates/datasync/configure.mako b/tailbone/templates/datasync/configure.mako new file mode 100644 index 00000000..331db610 --- /dev/null +++ b/tailbone/templates/datasync/configure.mako @@ -0,0 +1,759 @@ +## -*- coding: utf-8; -*- +<%inherit file="/page.mako" /> + +<%def name="title()">Configure DataSync</%def> + +<%def name="page_content()"> + <br /> + + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <p class="block"> + This tool lets you modify the DataSync configuration. + Before using it, + <a href="#" class="has-background-warning" + @click.prevent="showConfigFilesNote = !showConfigFilesNote"> + please see these notes. + </a> + </p> + </div> + + <div class="level-item buttons" + v-if="settingsNeedSaved"> + <b-button type="is-primary" + @click="saveSettings" + :disabled="savingSettings" + icon-pack="fas" + icon-left="save"> + {{ saveSettingsButtonText }} + </b-button> + <once-button tag="a" href="${request.current_route_url()}" + @click="undoChanges = true" + icon-left="undo" + text="Undo All Changes"> + </once-button> + </div> + </div> + + <div class="level-right"> + <div class="level-item"> + ${h.form(url('datasync.restart'), **{'@submit': 'submitRestartDatasyncForm'})} + ${h.csrf_token(request)} + <b-button type="is-primary" + native-type="submit" + @click="restartDatasync" + :disabled="restartingDatasync" + icon-pack="fas" + icon-left="redo"> + {{ restartDatasyncFormButtonText }} + </b-button> + ${h.end_form()} + </div> + </div> + </div> + + <b-notification type="is-warning" + :active.sync="showConfigFilesNote"> + ## TODO: should link to some ratman page here, yes? + <p class="block"> + This tool works by modifying settings in the DB. It + does <span class="is-italic">not</span> modify any config + files. If you intend to manage datasync config via files + only then you should + <span class="is-italic">not</span> use this tool! + </p> + <p class="block"> + 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 + <span class="is-family-code">[rattail.datasync]</span> entries + from your config file (and restart apps) so as to avoid + confusion. + </p> + <p class="block"> + Finally, you should know that this tool will + <span class="is-italic">overwrite</span> the entire + <span class="is-family-code">rattail.datasync</span> 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. + </p> + </b-notification> + + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <h3 class="is-size-3">Watcher Profiles</h3> + </div> + </div> + <div class="level-right"> + <div class="level-item"> + <b-button type="is-primary" + @click="newProfile()" + icon-pack="fas" + icon-left="plus"> + New Profile + </b-button> + </div> + <div class="level-item"> + <b-button @click="toggleDisabledProfiles()"> + {{ showDisabledProfiles ? "Hide" : "Show" }} Disabled + </b-button> + </div> + </div> + </div> + + <b-table :data="filteredProfilesData" + :row-class="(row, i) => row.enabled ? null : 'has-background-warning'"> + <template slot-scope="props"> + <b-table-column field="key" label="Watcher Key"> + {{ props.row.key }} + </b-table-column> + <b-table-column field="watcher_spec" label="Watcher Spec"> + {{ props.row.watcher_spec }} + </b-table-column> + <b-table-column field="watcher_dbkey" label="DB Key"> + {{ props.row.watcher_dbkey }} + </b-table-column> + <b-table-column field="watcher_delay" label="Loop Delay"> + {{ props.row.watcher_delay }} sec + </b-table-column> + <b-table-column field="watcher_retry_attempts" label="Attempts / Delay"> + {{ props.row.watcher_retry_attempts }} / {{ props.row.watcher_retry_delay }} sec + </b-table-column> + <b-table-column field="watcher_default_runas" label="Default Runas"> + {{ props.row.watcher_default_runas }} + </b-table-column> + <b-table-column label="Consumers"> + {{ consumerShortList(props.row) }} + </b-table-column> +## <b-table-column field="notes" label="Notes"> +## TODO +## ## {{ props.row.notes }} +## </b-table-column> + <b-table-column field="enabled" label="Enabled"> + {{ props.row.enabled ? "Yes" : "No" }} + </b-table-column> + <b-table-column label="Actions"> + <a href="#" + class="grid-action" + @click.prevent="editProfile(props.row)"> + <i class="fas fa-edit"></i> + Edit + </a> + + <a href="#" + class="grid-action has-text-danger" + @click.prevent="deleteProfile(props.row)"> + <i class="fas fa-trash"></i> + Delete + </a> + </b-table-column> + </template> + <template slot="empty"> + <section class="section"> + <div class="content has-text-grey has-text-centered"> + <p> + <b-icon + pack="fas" + icon="fas fa-sad-tear" + size="is-large"> + </b-icon> + </p> + <p>Nothing here.</p> + </div> + </section> + </template> + </b-table> + + <b-modal :active.sync="editProfileShowDialog"> + <div class="card"> + <div class="card-content"> + + <b-field grouped> + + <b-field label="Watcher Key" + :type="editingProfileKey ? null : 'is-danger'"> + <b-input v-model="editingProfileKey" + ref="watcherKeyInput"> + </b-input> + </b-field> + + <b-field label="Default Runas User"> + <b-input v-model="editingProfileWatcherDefaultRunas"> + </b-input> + </b-field> + + </b-field> + + <b-field grouped> + + <b-field label="Watcher Spec" + :type="editingProfileWatcherSpec ? null : 'is-danger'" + expanded> + <b-input v-model="editingProfileWatcherSpec"> + </b-input> + </b-field> + + <b-field label="DB Key"> + <b-input v-model="editingProfileWatcherDBKey"> + </b-input> + </b-field> + + </b-field> + + <b-field grouped> + + <b-field label="Loop Delay (seconds)"> + <b-input v-model="editingProfileWatcherDelay"> + </b-input> + </b-field> + + <b-field label="Attempts"> + <b-input v-model="editingProfileWatcherRetryAttempts"> + </b-input> + </b-field> + + <b-field label="Retry Delay (seconds)"> + <b-input v-model="editingProfileWatcherRetryDelay"> + </b-input> + </b-field> + + </b-field> + + <div style="display: flex;"> + + <div style="width: 40%;"> + + <b-field label="Watcher consumes its own changes" + v-if="!editingProfilePendingConsumers.length"> + <b-checkbox v-model="editingProfileWatcherConsumesSelf"> + {{ editingProfileWatcherConsumesSelf ? "Yes" : "No" }} + </b-checkbox> + </b-field> + + <b-table :data="editingProfilePendingConsumers" + v-if="!editingProfileWatcherConsumesSelf" + :row-class="(row, i) => row.enabled ? null : 'has-background-warning'"> + <template slot-scope="props"> + <b-table-column field="key" label="Consumer"> + {{ props.row.key }} + </b-table-column> + <b-table-column style="white-space: nowrap;"> + {{ props.row.consumer_delay }} / {{ props.row.consumer_retry_attempts }} / {{ props.row.consumer_retry_delay }} + </b-table-column> + <b-table-column label="Actions"> + <a href="#" + class="grid-action" + @click.prevent="editProfileConsumer(props.row)"> + <i class="fas fa-edit"></i> + Edit + </a> + + <a href="#" + class="grid-action has-text-danger" + @click.prevent="deleteProfileConsumer(props.row)"> + <i class="fas fa-trash"></i> + Delete + </a> + </b-table-column> + </template> + <template slot="empty"> + <section class="section"> + <div class="content has-text-grey has-text-centered"> + <p> + <b-icon + pack="fas" + icon="fas fa-sad-tear" + size="is-large"> + </b-icon> + </p> + <p>Nothing here.</p> + </div> + </section> + </template> + </b-table> + + </div> + + <div v-show="!editingConsumer && !editingProfileWatcherConsumesSelf" + style="padding-left: 1rem;"> + <b-button type="is-primary" + @click="newConsumer()" + icon-pack="fas" + icon-left="plus"> + New Consumer + </b-button> + </div> + + <div v-show="editingConsumer" + style="flex-grow: 1; padding-left: 1rem; padding-right: 1rem;"> + + <b-field grouped> + + <b-field label="Consumer Key" + :type="editingConsumerKey ? null : 'is-danger'"> + <b-input v-model="editingConsumerKey" + ref="consumerKeyInput"> + </b-input> + </b-field> + + <b-field label="Runas User"> + <b-input v-model="editingConsumerRunas"> + </b-input> + </b-field> + + </b-field> + + <b-field grouped> + + <b-field label="Consumer Spec" + expanded + :type="editingConsumerSpec ? null : 'is-danger'" + > + <b-input v-model="editingConsumerSpec"> + </b-input> + </b-field> + + <b-field label="DB Key"> + <b-input v-model="editingConsumerDBKey"> + </b-input> + </b-field> + + </b-field> + + <b-field grouped> + + <b-field label="Loop Delay"> + <b-input v-model="editingConsumerDelay" + style="width: 8rem;"> + </b-input> + </b-field> + + <b-field label="Attempts"> + <b-input v-model="editingConsumerRetryAttempts" + style="width: 8rem;"> + </b-input> + </b-field> + + <b-field label="Retry Delay"> + <b-input v-model="editingConsumerRetryDelay" + style="width: 8rem;"> + </b-input> + </b-field> + + </b-field> + + <b-field grouped> + + <b-button @click="editingConsumer = null" + class="control"> + Cancel + </b-button> + + <b-button type="is-primary" + @click="updateConsumer()" + :disabled="updateConsumerDisabled" + class="control"> + Update Consumer + </b-button> + + <b-field label="Enabled" horizontal + style="margin-left: 2rem;"> + <b-checkbox v-model="editingConsumerEnabled"> + {{ editingConsumerEnabled ? "Yes" : "No" }} + </b-checkbox> + </b-field> + + </b-field> + </div> + </div> + + <br /> + <b-field grouped> + + <b-button @click="editProfileShowDialog = false" + class="control"> + Cancel + </b-button> + + <b-button type="is-primary" + class="control" + @click="updateProfile()" + :disabled="updateProfileDisabled"> + Update Profile + </b-button> + + <b-field label="Enabled" horizontal + style="margin-left: 2rem;"> + <b-checkbox v-model="editingProfileEnabled"> + {{ editingProfileEnabled ? "Yes" : "No" }} + </b-checkbox> + </b-field> + + </b-field> + + </div> + </div> + </b-modal> + + <br /> + + <h3 class="is-size-3">Misc.</h3> + + <b-field grouped> + <b-field label="Restart Command" + message="This will run as '${system_user}' system user - please configure sudoers as needed. Typical command is like: sudo supervisorctl restart poser:poser_datasync" + expanded> + <b-input v-model="restartCommand" + @input="settingsNeedSaved = true"> + </b-input> + </b-field> + </b-field> + +</%def> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + ThisPageData.showConfigFilesNote = false + ThisPageData.profilesData = ${json.dumps(profiles_data)|n} + ThisPageData.showDisabledProfiles = false + + ThisPageData.editProfileShowDialog = false + ThisPageData.editingProfile = null + ThisPageData.editingProfileKey = null + ThisPageData.editingProfileWatcherSpec = null + ThisPageData.editingProfileWatcherDBKey = null + ThisPageData.editingProfileWatcherDelay = 1 + ThisPageData.editingProfileWatcherRetryAttempts = 1 + ThisPageData.editingProfileWatcherRetryDelay = 1 + ThisPageData.editingProfileWatcherDefaultRunas = null + ThisPageData.editingProfileWatcherConsumesSelf = false + ThisPageData.editingProfilePendingConsumers = [] + ThisPageData.editingProfileEnabled = true + + ThisPageData.editingConsumer = null + ThisPageData.editingConsumerKey = null + ThisPageData.editingConsumerSpec = null + ThisPageData.editingConsumerDBKey = null + ThisPageData.editingConsumerDelay = 1 + ThisPageData.editingConsumerRetryAttempts = 1 + ThisPageData.editingConsumerRetryDelay = 1 + ThisPageData.editingConsumerRunas = null + ThisPageData.editingConsumerEnabled = true + + ThisPageData.restartCommand = ${json.dumps(restart_command)|n} + + ThisPageData.settingsNeedSaved = false + ThisPageData.undoChanges = false + ThisPageData.savingSettings = false + + ThisPage.computed.filteredProfilesData = function() { + if (this.showDisabledProfiles) { + return this.profilesData + } + let data = [] + for (let row of this.profilesData) { + if (row.enabled) { + data.push(row) + } + } + return data + } + + ThisPage.computed.updateConsumerDisabled = function() { + if (!this.editingConsumerKey) { + return true + } + if (!this.editingConsumerSpec) { + return true + } + return false + } + + ThisPage.computed.updateProfileDisabled = function() { + if (this.editingConsumer) { + return true + } + if (!this.editingProfileKey) { + return true + } + if (!this.editingProfileWatcherSpec) { + return true + } + return false + } + + ThisPage.computed.saveSettingsButtonText = function() { + if (this.savingSettings) { + return "Working, please wait..." + } + return "Save All Settings" + } + + ThisPage.methods.toggleDisabledProfiles = function() { + this.showDisabledProfiles = !this.showDisabledProfiles + } + + ThisPage.methods.consumerShortList = function(row) { + let keys = [] + if (row.watcher_consumes_self) { + keys.push('self (watcher)') + } else { + for (let consumer of row.consumers_data) { + if (consumer.enabled) { + keys.push(consumer.key) + } + } + } + return keys.join(', ') + } + + ThisPage.methods.newProfile = function() { + this.editingProfile = {} + this.editingConsumer = null + + this.editingProfileKey = null + this.editingProfileWatcherSpec = null + this.editingProfileWatcherDBKey = null + this.editingProfileWatcherDelay = 1 + this.editingProfileWatcherRetryAttempts = 1 + this.editingProfileWatcherRetryDelay = 1 + this.editingProfileWatcherDefaultRunas = null + this.editingProfileWatcherConsumesSelf = false + this.editingProfileEnabled = true + this.editingProfilePendingConsumers = [] + + this.editProfileShowDialog = true + this.$nextTick(() => { + this.$refs.watcherKeyInput.focus() + }) + } + + ThisPage.methods.editProfile = function(row) { + this.editingProfile = row + this.editingConsumer = null + + this.editingProfileKey = row.key + this.editingProfileWatcherSpec = row.watcher_spec + this.editingProfileWatcherDBKey = row.watcher_dbkey + this.editingProfileWatcherDelay = row.watcher_delay + this.editingProfileWatcherRetryAttempts = row.watcher_retry_attempts + this.editingProfileWatcherRetryDelay = row.watcher_retry_delay + this.editingProfileWatcherDefaultRunas = row.watcher_default_runas + this.editingProfileWatcherConsumesSelf = row.watcher_consumes_self + this.editingProfileEnabled = row.enabled + + this.editingProfilePendingConsumers = [] + for (let consumer of row.consumers_data) { + let pending = { + original_key: consumer.key, + key: consumer.key, + consumer_spec: consumer.consumer_spec, + consumer_dbkey: consumer.consumer_dbkey, + consumer_delay: consumer.consumer_delay, + consumer_retry_attempts: consumer.consumer_retry_attempts, + consumer_retry_delay: consumer.consumer_retry_delay, + consumer_runas: consumer.consumer_runas, + enabled: consumer.enabled, + } + this.editingProfilePendingConsumers.push(pending) + } + + this.editProfileShowDialog = true + } + + ThisPage.methods.findOriginalConsumer = function(key) { + for (let consumer of this.editingProfile.consumers_data) { + if (consumer.key == key) { + return consumer + } + } + } + + ThisPage.methods.updateProfile = function() { + let row = this.editingProfile + + if (!row.key) { + row.consumers_data = [] + this.profilesData.push(row) + } + + row.key = this.editingProfileKey + row.watcher_spec = this.editingProfileWatcherSpec + row.watcher_dbkey = this.editingProfileWatcherDBKey + row.watcher_delay = this.editingProfileWatcherDelay + row.watcher_retry_attempts = this.editingProfileWatcherRetryAttempts + row.watcher_retry_delay = this.editingProfileWatcherRetryDelay + row.watcher_default_runas = this.editingProfileWatcherDefaultRunas + row.watcher_consumes_self = this.editingProfileWatcherConsumesSelf + row.enabled = this.editingProfileEnabled + + // track which keys still belong (persistent) + let persistent = [] + + // transfer pending data to profile consumers + for (let pending of this.editingProfilePendingConsumers) { + persistent.push(pending.key) + if (pending.original_key) { + let consumer = this.findOriginalConsumer(pending.original_key) + consumer.key = pending.key + consumer.consumer_spec = pending.consumer_spec + consumer.consumer_dbkey = pending.consumer_dbkey + consumer.consumer_delay = pending.consumer_delay + consumer.consumer_retry_attempts = pending.consumer_retry_attempts + consumer.consumer_retry_delay = pending.consumer_retry_delay + consumer.consumer_runas = pending.consumer_runas + consumer.enabled = pending.enabled + } else { + row.consumers_data.push(pending) + } + } + + // remove any consumers not being persisted + let remove = [] + for (let consumer of row.consumers_data) { + let i = persistent.indexOf(consumer.key) + if (i < 0) { + remove.push(consumer) + } + } + for (let consumer of remove) { + let i = row.consumers_data.indexOf(consumer) + row.consumers_data.splice(i, 1) + } + + this.settingsNeedSaved = true + this.editProfileShowDialog = false + } + + ThisPage.methods.deleteProfile = function(row) { + if (confirm("Are you sure you want to delete the '" + row.key + "' profile?")) { + let i = this.profilesData.indexOf(row) + this.profilesData.splice(i, 1) + this.settingsNeedSaved = true + } + } + + ThisPage.methods.newConsumer = function() { + this.editingConsumerKey = null + this.editingConsumerSpec = null + this.editingConsumerDBKey = null + this.editingConsumerDelay = 1 + this.editingConsumerRetryAttempts = 1 + this.editingConsumerRetryDelay = 1 + this.editingConsumerRunas = null + this.editingConsumerEnabled = true + this.editingConsumer = {} + this.$nextTick(() => { + this.$refs.consumerKeyInput.focus() + }) + } + + ThisPage.methods.editProfileConsumer = function(row) { + this.editingConsumerKey = row.key + this.editingConsumerSpec = row.consumer_spec + this.editingConsumerDBKey = row.consumer_dbkey + this.editingConsumerDelay = row.consumer_delay + this.editingConsumerRetryAttempts = row.consumer_retry_attempts + this.editingConsumerRetryDelay = row.consumer_retry_delay + this.editingConsumerRunas = row.consumer_runas + this.editingConsumerEnabled = row.enabled + this.editingConsumer = row + } + + ThisPage.methods.updateConsumer = function() { + let pending = this.editingConsumer + let isNew = !pending.key + + pending.key = this.editingConsumerKey + pending.consumer_spec = this.editingConsumerSpec + pending.consumer_dbkey = this.editingConsumerDBKey + pending.consumer_delay = this.editingConsumerDelay + pending.consumer_retry_attempts = this.editingConsumerRetryAttempts + pending.consumer_retry_delay = this.editingConsumerRetryDelay + pending.consumer_runas = this.editingConsumerRunas + pending.enabled = this.editingConsumerEnabled + + if (isNew) { + this.editingProfilePendingConsumers.push(pending) + } + this.editingConsumer = null + } + + ThisPage.methods.deleteProfileConsumer = function(row) { + if (confirm("Are you sure you want to delete the '" + row.key + "' consumer?")) { + let i = this.editingProfilePendingConsumers.indexOf(row) + this.editingProfilePendingConsumers.splice(i, 1) + } + } + + ThisPage.methods.saveSettings = function() { + this.savingSettings = true + let url = ${json.dumps(request.current_route_url())|n} + + let params = { + profiles: this.profilesData, + restart_command: this.restartCommand, + } + + let headers = { + 'X-CSRF-TOKEN': this.csrftoken, + } + + this.$http.post(url, params, {headers: headers}).then((response) => { + if (response.data.success) { + this.settingsNeedSaved = false + location.href = url // reload page + } else { + this.$buefy.toast.open({ + message: "Save failed: " + (response.data.error || "(unknown error)"), + type: 'is-danger', + duration: 4000, // 4 seconds + }) + } + }).catch((error) => { + this.$buefy.toast.open({ + message: "Save failed: (unknown error)", + type: 'is-danger', + duration: 4000, // 4 seconds + }) + }) + } + + // cf. https://stackoverflow.com/a/56551646 + ThisPage.methods.beforeWindowUnload = function(e) { + if (this.settingsNeedSaved && !this.undoChanges) { + e.preventDefault() + e.returnValue = '' + } + } + + ThisPage.created = function() { + window.addEventListener('beforeunload', this.beforeWindowUnload) + } + + % if request.has_perm('datasync.restart'): + ThisPageData.restartingDatasync = false + ThisPageData.restartDatasyncFormButtonText = "Restart Datasync" + ThisPage.methods.restartDatasync = function(e) { + if (this.settingsNeedSaved) { + alert("You have unsaved changes. Please save or undo them first.") + e.preventDefault() + } + } + ThisPage.methods.submitRestartDatasyncForm = function() { + this.restartingDatasync = true + this.restartDatasyncFormButtonText = "Restarting Datasync..." + } + % endif + + </script> +</%def> + + +${parent.body()} diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py index f4434447..86db1ce8 100644 --- a/tailbone/views/datasync.py +++ b/tailbone/views/datasync.py @@ -26,12 +26,18 @@ DataSync Views from __future__ import unicode_literals, absolute_import +import getpass import subprocess import logging +import sqlalchemy as sa + from rattail.db import model +from rattail.datasync.config import load_profiles +from rattail.datasync.util import get_lastrun from tailbone.views import MasterView +from tailbone.util import csrf_token log = logging.getLogger(__name__) @@ -78,30 +84,197 @@ class DataSyncChangeView(MasterView): return kwargs def restart(self): - # TODO: Add better validation (e.g. CSRF) here? - if self.request.method == 'POST': - cmd = self.rattail_config.getlist('tailbone', 'datasync.restart', default='/bin/sleep 3') # simulate by default - log.debug("attempting datasync restart with command: {}".format(cmd)) - result = subprocess.call(cmd) - if result == 0: - 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') + 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: + 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'))) + def configure(self): + """ + View for configuring the DataSync daemon. + """ + if self.request.method == 'POST': + data = self.request.json_body + self.save_settings(data) + self.request.session.flash("Settings have been saved. " + "You should probably restart DataSync now.") + return self.json_response({'success': True}) + + profiles = load_profiles(self.rattail_config, + include_disabled=True, + ignore_problems=True) + + profiles_data = [] + for profile in sorted(profiles.values(), key=lambda p: p.key): + data = { + 'key': profile.key, + 'watcher_spec': profile.watcher_spec, + 'watcher_dbkey': profile.watcher.dbkey, + 'watcher_delay': profile.watcher.delay, + 'watcher_retry_attempts': profile.watcher.retry_attempts, + 'watcher_retry_delay': profile.watcher.retry_delay, + 'watcher_default_runas': profile.watcher.default_runas, + 'watcher_consumes_self': profile.watcher.consumes_self, + # 'notes': None, # TODO + 'enabled': profile.enabled, + } + + consumers = [] + if profile.watcher.consumes_self: + pass + else: + for consumer in sorted(profile.consumers, key=lambda c: c.key): + consumers.append({ + 'key': consumer.key, + 'consumer_spec': consumer.spec, + 'consumer_dbkey': consumer.dbkey, + 'consumer_runas': getattr(consumer, 'runas', None), + 'consumer_delay': consumer.delay, + 'consumer_retry_attempts': consumer.retry_attempts, + 'consumer_retry_delay': consumer.retry_delay, + 'enabled': consumer.enabled, + }) + data['consumers_data'] = consumers + profiles_data.append(data) + + return { + 'master': self, + # TODO: really only buefy themes are supported here + 'use_buefy': self.get_use_buefy(), + 'index_title': "DataSync Changes", + 'index_url': self.get_index_url(), + 'profiles': profiles, + 'profiles_data': profiles_data, + 'restart_command': self.rattail_config.get('tailbone', 'datasync.restart'), + 'system_user': getpass.getuser(), + } + + def save_settings(self, data): + model = self.model + + # collect new settings + settings = [] + watch = [] + for profile in data['profiles']: + pkey = profile['key'] + if profile['enabled']: + watch.append(pkey) + 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']}, + ]) + consumers = [] + if profile['watcher_consumes_self']: + consumers = ['self'] + else: + 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)}, + ]) + settings.extend([ + {'name': 'rattail.datasync.watch', + 'value': ', '.join(watch)}, + {'name': 'tailbone.datasync.restart', + 'value': data['restart_command']}, + ]) + + # delete all current settings + self.delete_settings() + + # create all new settings + for setting in settings: + self.Session.add(model.Setting(name=setting['name'], + value=setting['value'])) + + def delete_settings(self): + model = self.model + + to_delete = [ + 'rattail.datasync.watch', + 'tailbone.datasync.restart', + ] + for setting in to_delete: + setting = self.Session.query(model.Setting).get(setting) + if setting: + self.Session.delete(setting) + + self.Session.query(model.Setting)\ + .filter(sa.or_( + model.Setting.name.like('rattail.datasync.%.watcher'), + model.Setting.name.like('rattail.datasync.%.watcher.db'), + model.Setting.name.like('rattail.datasync.%.watcher.delay'), + model.Setting.name.like('rattail.datasync.%.watcher.retry_attempts'), + model.Setting.name.like('rattail.datasync.%.watcher.retry_delay'), + model.Setting.name.like('rattail.datasync.%.consumers'), + model.Setting.name.like('rattail.datasync.%.consumers.runas'), + model.Setting.name.like('rattail.datasync.%.consumer.%')))\ + .delete(synchronize_session=False) + @classmethod def defaults(cls, config): - rattail_config = config.registry.settings.get('rattail_config') + cls._defaults(config) + cls._datasync_defaults(config) + + @classmethod + def _datasync_defaults(cls, config): + permission_prefix = cls.get_permission_prefix() # fix permission group title - config.add_tailbone_permission_group('datasync', label="DataSync") + config.add_tailbone_permission_group(permission_prefix, label="DataSync") # restart datasync - config.add_tailbone_permission('datasync', 'datasync.restart', label="Restart DataSync Daemon") - config.add_route('datasync.restart', '/datasync/restart') - config.add_view(cls, attr='restart', route_name='datasync.restart', permission='datasync.restart') + config.add_tailbone_permission(permission_prefix, + '{}.restart'.format(permission_prefix), + label="Restart the DataSync daemon") + config.add_route('datasync.restart', '/datasync/restart', + request_method='POST') + config.add_view(cls, attr='restart', + route_name='datasync.restart', + permission='{}.restart'.format(permission_prefix)) - cls._defaults(config) + # configure datasync + config.add_tailbone_permission(permission_prefix, + '{}.configure'.format(permission_prefix), + label="Configure the DataSync daemon") + config.add_route('datasync.configure', '/datasync/configure') + config.add_view(cls, attr='configure', + route_name='datasync.configure', + permission='{}.configure'.format(permission_prefix), + renderer='/datasync/configure.mako') # TODO: deprecate / remove this DataSyncChangesView = DataSyncChangeView From 4229798c7b80480c7bc94189916a5accba33a5f7 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 29 Nov 2021 19:28:07 -0600 Subject: [PATCH 0514/1681] Add button to remove all datasync settings from DB seems useful for someone testing, as prep to make the switch --- tailbone/templates/datasync/configure.mako | 55 ++++++++++++++++++++++ tailbone/views/datasync.py | 43 +++++------------ 2 files changed, 68 insertions(+), 30 deletions(-) diff --git a/tailbone/templates/datasync/configure.mako b/tailbone/templates/datasync/configure.mako index 331db610..161328f7 100644 --- a/tailbone/templates/datasync/configure.mako +++ b/tailbone/templates/datasync/configure.mako @@ -50,9 +50,57 @@ </b-button> ${h.end_form()} </div> + <div class="level-item"> + <b-button type="is-danger" + @click="purgeSettingsInit()" + icon-pack="fas" + icon-left="trash"> + Remove All Settings + </b-button> + </div> </div> </div> + <b-modal has-modal-card + :active.sync="purgeSettingsShowDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Remove All Settings</p> + </header> + + <section class="modal-card-body"> + <p class="block"> + If you like we can remove all DataSync settings from the DB. + </p> + <p class="block"> + Note that this tool normally removes all settings first, + every time you click "Save Settings". Here though you + can "just remove" and <span class="is-italic">not</span> + save the current settings. + </p> + </section> + + <footer class="modal-card-foot"> + <b-button @click="purgeSettingsShowDialog = false"> + Cancel + </b-button> + ${h.form(request.current_route_url())} + ${h.csrf_token(request)} + ${h.hidden('purge_settings', 'true')} + <b-button type="is-danger" + native-type="submit" + :disabled="purgingSettings" + icon-pack="fas" + icon-left="trash" + @click="purgingSettings = true"> + {{ purgingSettings ? "Working, please wait..." : "Remove All Settings" }} + </b-button> + ${h.end_form()} + </footer> + </div> + </b-modal> + <b-notification type="is-warning" :active.sync="showConfigFilesNote"> ## TODO: should link to some ratman page here, yes? @@ -448,6 +496,9 @@ ThisPageData.restartCommand = ${json.dumps(restart_command)|n} + ThisPageData.purgeSettingsShowDialog = false + ThisPageData.purgingSettings = false + ThisPageData.settingsNeedSaved = false ThisPageData.undoChanges = false ThisPageData.savingSettings = false @@ -692,6 +743,10 @@ } } + ThisPage.methods.purgeSettingsInit = function() { + this.purgeSettingsShowDialog = true + } + ThisPage.methods.saveSettings = function() { this.savingSettings = true let url = ${json.dumps(request.current_route_url())|n} diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py index 86db1ce8..0fe1e709 100644 --- a/tailbone/views/datasync.py +++ b/tailbone/views/datasync.py @@ -30,11 +30,9 @@ import getpass import subprocess import logging -import sqlalchemy as sa - from rattail.db import model from rattail.datasync.config import load_profiles -from rattail.datasync.util import get_lastrun +from rattail.datasync.util import get_lastrun, purge_datasync_settings from tailbone.views import MasterView from tailbone.util import csrf_token @@ -100,11 +98,17 @@ class DataSyncChangeView(MasterView): View for configuring the DataSync daemon. """ if self.request.method == 'POST': - data = self.request.json_body - self.save_settings(data) - self.request.session.flash("Settings have been saved. " - "You should probably restart DataSync now.") - return self.json_response({'success': True}) + # if self.request.is_xhr and not self.request.POST: + if self.request.POST.get('purge_settings'): + self.delete_settings() + self.request.session.flash("Settings have been removed.") + return self.redirect(self.request.current_route_url()) + else: + data = self.request.json_body + self.save_settings(data) + self.request.session.flash("Settings have been saved. " + "You should probably restart DataSync now.") + return self.json_response({'success': True}) profiles = load_profiles(self.rattail_config, include_disabled=True, @@ -221,28 +225,7 @@ class DataSyncChangeView(MasterView): value=setting['value'])) def delete_settings(self): - model = self.model - - to_delete = [ - 'rattail.datasync.watch', - 'tailbone.datasync.restart', - ] - for setting in to_delete: - setting = self.Session.query(model.Setting).get(setting) - if setting: - self.Session.delete(setting) - - self.Session.query(model.Setting)\ - .filter(sa.or_( - model.Setting.name.like('rattail.datasync.%.watcher'), - model.Setting.name.like('rattail.datasync.%.watcher.db'), - model.Setting.name.like('rattail.datasync.%.watcher.delay'), - model.Setting.name.like('rattail.datasync.%.watcher.retry_attempts'), - model.Setting.name.like('rattail.datasync.%.watcher.retry_delay'), - model.Setting.name.like('rattail.datasync.%.consumers'), - model.Setting.name.like('rattail.datasync.%.consumers.runas'), - model.Setting.name.like('rattail.datasync.%.consumer.%')))\ - .delete(synchronize_session=False) + purge_datasync_settings(self.rattail_config, self.Session()) @classmethod def defaults(cls, config): From 47f6c941ec35d2b9bcc426464bcead81db888ed5 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 29 Nov 2021 21:03:20 -0600 Subject: [PATCH 0515/1681] 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 b2631aff..46e27857 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.178 (2021-11-29) +-------------------- + +* Add page for configuring datasync. + + 0.8.177 (2021-11-28) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 29b86e26..08069e20 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.177' +__version__ = '0.8.178' From 760fbc57bc546db735ee02f1c2a7dd11643be9a8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 2 Dec 2021 14:40:51 -0600 Subject: [PATCH 0516/1681] Expose the Sale Price and TPR Price for product views in addition to Current Price --- tailbone/templates/products/view.mako | 4 ++ tailbone/views/products.py | 62 +++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/tailbone/templates/products/view.mako b/tailbone/templates/products/view.mako index 0d0e4e5f..d42c04b9 100644 --- a/tailbone/templates/products/view.mako +++ b/tailbone/templates/products/view.mako @@ -191,6 +191,10 @@ ${form.render_field_readonly('regular_price')} ${form.render_field_readonly('current_price')} ${form.render_field_readonly('current_price_ends')} + ${form.render_field_readonly('sale_price')} + ${form.render_field_readonly('sale_price_ends')} + ${form.render_field_readonly('tpr_price')} + ${form.render_field_readonly('tpr_price_ends')} ${form.render_field_readonly('suggested_price')} ${form.render_field_readonly('deposit_link')} ${form.render_field_readonly('tax')} diff --git a/tailbone/views/products.py b/tailbone/views/products.py index a59df32f..40414cf8 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -93,6 +93,8 @@ class ProductView(MasterView): 'tax1': "Tax 1", 'tax2': "Tax 2", 'tax3': "Tax 3", + 'tpr_price': "TPR Price", + 'tpr_price_ends': "TPR Price Ends", } grid_columns = [ @@ -131,6 +133,10 @@ class ProductView(MasterView): 'regular_price', 'current_price', 'current_price_ends', + 'sale_price', + 'sale_price_ends', + 'tpr_price', + 'tpr_price_ends', 'vendor', 'cost', 'deposit_link', @@ -167,6 +173,8 @@ class ProductView(MasterView): # same, but for prices RegularPrice = orm.aliased(model.ProductPrice) CurrentPrice = orm.aliased(model.ProductPrice) + SalePrice = orm.aliased(model.ProductPrice) + TPRPrice = orm.aliased(model.ProductPrice) def __init__(self, request): super(ProductView, self).__init__(request) @@ -321,6 +329,16 @@ class ProductView(MasterView): g.set_sorter('current_price', self.CurrentPrice.price) g.set_filter('current_price', self.CurrentPrice.price, label="Current Price") + # tpr_price + g.set_joiner('tpr_price', lambda q: q.outerjoin( + self.TPRPrice, self.TPRPrice.uuid == model.Product.tpr_price_uuid)) + g.set_filter('tpr_price', self.TPRPrice.price) + + # sale_price + g.set_joiner('sale_price', lambda q: q.outerjoin( + self.SalePrice, self.SalePrice.uuid == model.Product.sale_price_uuid)) + g.set_filter('sale_price', self.SalePrice.price) + # suggested_price g.set_renderer('suggested_price', self.render_grid_suggested_price) @@ -427,6 +445,34 @@ class ProductView(MasterView): f.set_readonly('current_price_ends') f.set_renderer('current_price_ends', self.render_current_price_ends) + # sale_price + if self.creating: + f.remove_field('sale_price') + else: + f.set_readonly('sale_price') + f.set_renderer('sale_price', self.render_price) + + # sale_price_ends + if self.creating: + f.remove_field('sale_price_ends') + else: + f.set_readonly('sale_price_ends') + f.set_renderer('sale_price_ends', self.render_sale_price_ends) + + # tpr_price + if self.creating: + f.remove_field('tpr_price') + else: + f.set_readonly('tpr_price') + f.set_renderer('tpr_price', self.render_price) + + # tpr_price_ends + if self.creating: + f.remove_field('tpr_price_ends') + else: + f.set_readonly('tpr_price_ends') + f.set_renderer('tpr_price_ends', self.render_tpr_price_ends) + # vendor if self.creating: f.remove_field('vendor') @@ -1011,6 +1057,22 @@ class ProductView(MasterView): return "" return raw_datetime(self.request.rattail_config, value) + def render_sale_price_ends(self, product, field): + if not product.sale_price: + return + ends = product.sale_price.ends + if not ends: + return + return raw_datetime(self.rattail_config, ends) + + def render_tpr_price_ends(self, product, field): + if not product.tpr_price: + return + ends = product.tpr_price.ends + if not ends: + return + return raw_datetime(self.rattail_config, ends) + def render_inventory_on_hand(self, product, field): if not product.inventory: return "" From 95da490f9a2d634a02898cd836ada5a71fb6cc15 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 3 Dec 2021 09:44:20 -0600 Subject: [PATCH 0517/1681] 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 46e27857..68c0ef39 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.179 (2021-12-03) +-------------------- + +* Expose the Sale Price and TPR Price for product views. + + 0.8.178 (2021-11-29) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 08069e20..ff41db5a 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.178' +__version__ = '0.8.179' From 282185c5af2f9b6411e2b430a5d3569c12f3bd3a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 5 Dec 2021 17:23:11 -0600 Subject: [PATCH 0518/1681] Add basic import/export handler views, tool to run jobs --- tailbone/forms/core.py | 1 + tailbone/templates/importing/index.mako | 12 + tailbone/templates/importing/runjob.mako | 85 ++++ tailbone/templates/importing/view.mako | 22 + tailbone/views/batch/core.py | 76 ---- tailbone/views/importing.py | 543 +++++++++++++++++++++++ tailbone/views/master.py | 82 +++- 7 files changed, 744 insertions(+), 77 deletions(-) create mode 100644 tailbone/templates/importing/index.mako create mode 100644 tailbone/templates/importing/runjob.mako create mode 100644 tailbone/templates/importing/view.mako create mode 100644 tailbone/views/importing.py diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 2267b8dc..060e1133 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -771,6 +771,7 @@ class Form(object): # TODO: deprecate / remove the latter option here if self.auto_disable_save or self.auto_disable: if self.use_buefy: + context['form_kwargs']['ref'] = self.component_studly context['form_kwargs']['@submit'] = 'submit{}'.format(self.component_studly) else: context['form_kwargs']['class_'] = 'autodisable' diff --git a/tailbone/templates/importing/index.mako b/tailbone/templates/importing/index.mako new file mode 100644 index 00000000..c2d9c6ec --- /dev/null +++ b/tailbone/templates/importing/index.mako @@ -0,0 +1,12 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/index.mako" /> + +<%def name="render_grid_component()"> + <p class="block"> + ${request.rattail_config.get_app().get_title()} can run import / export jobs for the following: + </p> + ${parent.render_grid_component()} +</%def> + + +${parent.body()} diff --git a/tailbone/templates/importing/runjob.mako b/tailbone/templates/importing/runjob.mako new file mode 100644 index 00000000..2b9642f6 --- /dev/null +++ b/tailbone/templates/importing/runjob.mako @@ -0,0 +1,85 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/form.mako" /> + +<%def name="extra_styles()"> + ${parent.extra_styles()} + <style type="text/css"> + + .tailbone-markdown p { + margin-bottom: 1.5rem; + margin-top: 1rem; + } + + </style> +</%def> + +<%def name="title()"> + Run ${handler.direction.capitalize()}: ${handler.get_generic_title()} +</%def> + +<%def name="context_menu_items()"> + ${parent.context_menu_items()} + % if master.has_perm('view'): + <li>${h.link_to("View this {}".format(model_title), action_url('view', handler_info))}</li> + % endif +</%def> + +<%def name="render_this_page()"> + % if 'rattail.importing.runjob.notes' in request.session: + <b-notification type="is-info tailbone-markdown"> + ${request.session['rattail.importing.runjob.notes']|n} + </b-notification> + <% del request.session['rattail.importing.runjob.notes'] %> + % endif + + ${parent.render_this_page()} +</%def> + +<%def name="render_form_buttons()"> + <br /> + ${h.hidden('runjob', **{':value': 'runJob'})} + <div class="buttons"> + <once-button tag="a" href="${form.cancel_url or request.get_referrer()}" + text="Cancel"> + </once-button> + <b-button type="is-primary" + @click="submitRun()" + :disabled="submittingRun" + icon-pack="fas" + icon-left="arrow-circle-right"> + {{ submittingRun ? "Working, please wait..." : "Run this ${handler.direction.capitalize()}" }} + </b-button> + <b-button @click="submitExplain()" + :disabled="submittingExplain" + icon-pack="fas" + icon-left="question-circle"> + {{ submittingExplain ? "Working, please wait..." : "Just show me the notes" }} + </b-button> + </div> +</%def> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + ${form.component_studly}Data.submittingRun = false + ${form.component_studly}Data.submittingExplain = false + ${form.component_studly}Data.runJob = false + + ${form.component_studly}.methods.submitRun = function() { + this.submittingRun = true + this.runJob = true + this.$nextTick(() => { + this.$refs.${form.component_studly}.submit() + }) + } + + ${form.component_studly}.methods.submitExplain = function() { + this.submittingExplain = true + this.$refs.${form.component_studly}.submit() + } + + </script> +</%def> + +${parent.body()} diff --git a/tailbone/templates/importing/view.mako b/tailbone/templates/importing/view.mako new file mode 100644 index 00000000..3a28737c --- /dev/null +++ b/tailbone/templates/importing/view.mako @@ -0,0 +1,22 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> + +<%def name="object_helpers()"> + ${parent.object_helpers()} + % if master.has_perm('runjob'): + <nav class="panel"> + <p class="panel-heading">Tools</p> + <div class="panel-block buttons"> + <once-button type="is-primary" + tag="a" href="${url('{}.runjob'.format(route_prefix), key=handler.get_key())}" + icon-pack="fas" + icon-left="arrow-circle-right" + text="Run ${handler.direction.capitalize()} Job"> + </once-button> + </div> + </nav> + % endif +</%def> + + +${parent.body()} diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 821628aa..90614079 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -64,10 +64,6 @@ from tailbone.util import csrf_token log = logging.getLogger(__name__) -class EverythingComplete(Exception): - pass - - class BatchMasterView(MasterView): """ Base class for all "batch master" views. @@ -872,78 +868,6 @@ class BatchMasterView(MasterView): 'cancel_msg': "{} of batch was canceled.".format(batch_action.capitalize()), }) - def progress_thread(self, sock, success_url, progress): - """ - This method is meant to be used as a thread target. Its job is to read - progress data from ``connection`` and update the session progress - accordingly. When a final "process complete" indication is read, the - socket will be closed and the thread will end. - """ - while True: - try: - self.process_progress(sock, progress) - except EverythingComplete: - break - - # close server socket - sock.close() - - # finalize session progress - progress.session.load() - progress.session['complete'] = True - if callable(success_url): - success_url = success_url() - progress.session['success_url'] = success_url - progress.session.save() - - def process_progress(self, sock, progress): - """ - This method will accept a client connection on the given socket, and - then update the given progress object according to data written by the - client. - """ - connection, client_address = sock.accept() - active_progress = None - - # TODO: make this configurable? - suffix = "\n\n.".encode('utf_8') - data = b'' - - # listen for progress info, update session progress as needed - while True: - - # accumulate data bytestring until we see the suffix - byte = connection.recv(1) - data += byte - if data.endswith(suffix): - - # strip suffix, interpret data as JSON - data = data[:-len(suffix)] - if six.PY3: - data = data.decode('utf_8') - data = json.loads(data) - - if data.get('everything_complete'): - if active_progress: - active_progress.finish() - raise EverythingComplete - - elif data.get('process_complete'): - active_progress.finish() - active_progress = None - break - - elif 'value' in data: - if not active_progress: - active_progress = progress(data['message'], data['maximum']) - active_progress.update(data['value']) - - # reset data buffer - data = b'' - - # close client connection - connection.close() - def launch_subprocess(self, port=None, username=None, command='rattail', command_args=None, subcommand=None, subcommand_args=None): diff --git a/tailbone/views/importing.py b/tailbone/views/importing.py new file mode 100644 index 00000000..23a039cd --- /dev/null +++ b/tailbone/views/importing.py @@ -0,0 +1,543 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2021 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +View for running arbitrary import/export jobs +""" + +from __future__ import unicode_literals, absolute_import + +import getpass +import socket +import sys +import logging +import subprocess +import time + +import json +import six + +from rattail.exceptions import ConfigurationError +from rattail.threads import Thread + +import colander +import markdown +from deform import widget as dfwidget +from webhelpers2.html import HTML + +from tailbone.views import MasterView + + +log = logging.getLogger(__name__) + + +class ImportingView(MasterView): + """ + View for running arbitrary import/export jobs + """ + normalized_model_name = 'importhandler' + model_title = "Import / Export Handler" + model_key = 'key' + route_prefix = 'importing' + url_prefix = '/importing' + index_title = "Importing / Exporting" + creatable = False + editable = False + deletable = False + filterable = False + pageable = False + + labels = { + 'host_title': "Data Source", + 'local_title': "Data Target", + } + + grid_columns = [ + 'host_title', + 'local_title', + 'handler_spec', + ] + + form_fields = [ + 'key', + 'local_key', + 'host_key', + 'handler_spec', + 'host_title', + 'local_title', + 'models', + ] + + runjob_form_fields = [ + 'handler_spec', + 'host_title', + 'local_title', + 'models', + 'create', + 'update', + 'delete', + # 'runas', + 'versioning', + 'dry_run', + 'warnings', + ] + + def get_data(self, session=None): + app = self.get_rattail_app() + data = [] + + for Handler in app.all_import_handlers(): + handler = Handler(self.rattail_config) + data.append(self.normalize(handler)) + + data.sort(key=lambda handler: (handler['host_title'], + handler['local_title'])) + return data + + def normalize(self, handler): + Handler = handler.__class__ + return { + '_handler': handler, + 'key': handler.get_key(), + 'generic_title': handler.get_generic_title(), + 'host_key': handler.host_key, + 'host_title': handler.get_generic_host_title(), + 'local_key': handler.local_key, + 'local_title': handler.get_generic_local_title(), + 'handler_spec': handler.get_spec(), + } + + def configure_grid(self, g): + super(ImportingView, self).configure_grid(g) + + g.set_link('host_title') + g.set_link('local_title') + + def get_instance(self): + """ + Fetch the current model instance by inspecting the route kwargs and + doing a database lookup. If the instance cannot be found, raises 404. + """ + key = self.request.matchdict['key'] + app = self.get_rattail_app() + for Handler in app.all_import_handlers(): + if Handler.get_key() == key: + return self.normalize(Handler(self.rattail_config)) + raise self.notfound() + + def get_instance_title(self, handler_info): + handler = handler_info['_handler'] + return handler.get_generic_title() + + def make_form_schema(self): + return ImportHandlerSchema() + + def make_form_kwargs(self, **kwargs): + kwargs = super(ImportingView, self).make_form_kwargs(**kwargs) + + # nb. this is set as sort of a hack, to prevent SA model + # inspection logic + kwargs['renderers'] = {} + + return kwargs + + def configure_form(self, f): + super(ImportingView, self).configure_form(f) + + f.set_renderer('models', self.render_models) + + def render_models(self, handler, field): + handler = handler['_handler'] + items = [] + for key in handler.get_importer_keys(): + items.append(HTML.tag('li', c=[key])) + return HTML.tag('ul', c=items) + + def template_kwargs_view(self, **kwargs): + kwargs = super(ImportingView, self).template_kwargs_view(**kwargs) + handler_info = kwargs['instance'] + kwargs['handler'] = handler_info['_handler'] + return kwargs + + def runjob(self): + """ + View for running an import / export job + """ + handler_info = self.get_instance() + handler = handler_info['_handler'] + form = self.make_runjob_form(handler_info) + + if self.request.method == 'POST': + if self.validate_form(form): + + self.cache_runjob_form_values(handler, form) + + try: + return self.do_runjob(handler_info, form) + except Exception as error: + self.request.session.flash(six.text_type(error), 'error') + return self.redirect(self.request.current_route_url()) + + return self.render_to_response('runjob', { + 'handler_info': handler_info, + 'handler': handler, + 'form': form, + }) + + def cache_runjob_form_values(self, handler, form): + handler_key = handler.get_key() + + def make_key(key): + return 'rattail.importing.{}.{}'.format(handler_key, key) + + for field in form.fields: + key = make_key(field) + self.request.session[key] = form.validated[field] + + def read_cached_runjob_values(self, handler, form): + handler_key = handler.get_key() + + def make_key(key): + return 'rattail.importing.{}.{}'.format(handler_key, key) + + for field in form.fields: + key = make_key(field) + if key in self.request.session: + form.set_default(field, self.request.session[key]) + + def make_runjob_form(self, handler_info, **kwargs): + """ + Creates a new form for the given model class/instance + """ + handler = handler_info['_handler'] + factory = self.get_form_factory() + fields = list(self.runjob_form_fields) + schema = RunJobSchema() + + kwargs = self.make_runjob_form_kwargs(handler_info, **kwargs) + form = factory(fields, schema, **kwargs) + self.configure_runjob_form(handler, form) + + self.read_cached_runjob_values(handler, form) + + return form + + def make_runjob_form_kwargs(self, handler_info, **kwargs): + route_prefix = self.get_route_prefix() + handler = handler_info['_handler'] + defaults = { + 'request': self.request, + 'use_buefy': self.get_use_buefy(), + 'model_instance': handler, + 'cancel_url': self.request.route_url('{}.view'.format(route_prefix), + key=handler.get_key()), + # nb. these next 2 are set as sort of a hack, to prevent + # SA model inspection logic + 'renderers': {}, + 'appstruct': handler_info, + } + defaults.update(kwargs) + return defaults + + def configure_runjob_form(self, handler, f): + self.set_labels(f) + + f.set_readonly('handler_spec') + f.set_renderer('handler_spec', lambda handler, field: handler.get_spec()) + + f.set_readonly('host_title') + f.set_readonly('local_title') + + keys = handler.get_importer_keys() + f.set_widget('models', dfwidget.SelectWidget(values=[(k, k) for k in keys], + multiple=True, + size=len(keys))) + # f.set_default('models', keys) + + f.set_default('create', True) + f.set_default('update', True) + f.set_default('delete', False) + # f.set_default('runas', self.rattail_config.get('rattail', 'runas.default') or '') + f.set_default('versioning', True) + f.set_default('dry_run', False) + f.set_default('warnings', False) + + def do_runjob(self, handler_info, form): + handler = handler_info['_handler'] + handler_key = handler.get_key() + + if self.request.POST.get('runjob') == 'true': + + # will invoke handler to run job + + # TODO: this socket progress business was lifted from + # tailbone.views.batch.core:BatchMasterView.handler_action + # should probably refactor to share somehow + + # make progress object + key = 'rattail.importing.{}'.format(handler_key) + progress = self.make_progress(key) + + # make socket for progress thread to listen to action thread + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(('127.0.0.1', 0)) + sock.listen(1) + port = sock.getsockname()[1] + + # launch thread to monitor progress + success_url = self.request.current_route_url() + thread = Thread(target=self.progress_thread, + args=(sock, success_url, progress)) + thread.start() + + true_cmd = self.make_runjob_cmd(handler, form, 'true', port=port) + + # launch thread to invoke handler + thread = Thread(target=self.do_runjob_thread, + args=(handler, true_cmd, port, progress)) + thread.start() + + return self.render_progress(progress, { + 'can_cancel': False, + 'cancel_url': self.request.current_route_url(), + }) + + else: # explain only + notes_cmd = self.make_runjob_cmd(handler, form, 'notes') + self.cache_runjob_notes(handler, notes_cmd) + + return self.redirect(self.request.current_route_url()) + + def do_runjob_thread(self, handler, cmd, port, progress): + + # invoke handler command via subprocess + try: + result = subprocess.run(cmd, check=True, capture_output=True) + output = result.stderr.decode('utf_8').strip() + + except Exception as error: + log.warning("failed to invoke handler cmd: %s", cmd, exc_info=True) + if progress: + progress.session.load() + progress.session['error'] = True + msg = """\ +{} failed! Here is the command I tried to run: + +``` +{} +``` + +And here is the STDERR output: + +``` +{} +``` +""".format(handler.direction.capitalize(), + ' '.join(cmd), + error.stderr.decode('utf_8').strip()) + msg = markdown.markdown(msg, extensions=['fenced_code']) + msg = HTML.literal(msg) + msg = HTML.tag('div', class_='tailbone-markdown', c=[msg]) + progress.session['error_msg'] = msg + progress.session.save() + + else: # success + + if progress: + progress.session.load() + msg = self.get_runjob_success_msg(handler, output) + progress.session['complete'] = True + progress.session['success_url'] = self.request.current_route_url() + progress.session['success_msg'] = msg + progress.session.save() + + suffix = "\n\n.".encode('utf_8') + cxn = socket.create_connection(('127.0.0.1', port)) + data = json.dumps({ + 'everything_complete': True, + }) + if six.PY3: + data = data.encode('utf_8') + cxn.send(data) + cxn.send(suffix) + cxn.close() + + def get_runjob_success_msg(self, handler, output): + notes = """\ +{} went okay, here is the output: + +``` +{} +``` +""".format(handler.direction.capitalize(), output) + + notes = markdown.markdown(notes, extensions=['fenced_code']) + notes = HTML.literal(notes) + return HTML.tag('div', class_='tailbone-markdown', c=[notes]) + + def make_runjob_cmd(self, handler, form, typ, port=None): + handler_key = handler.get_key() + + option = '{}.cmd'.format(handler_key) + cmd = self.rattail_config.getlist('rattail.importing', option) + if not cmd or len(cmd) != 2: + msg = ("Missing or invalid config; please set '{}' in the " + "[rattail.importing] section of your config file".format(option)) + raise ConfigurationError(msg) + + command, subcommand = cmd + + option = '{}.runas'.format(handler_key) + runas = self.rattail_config.require('rattail.importing', option) + + data = form.validated + + if typ == 'true': + cmd = [ + '{}/bin/{}'.format(sys.prefix, command), + '--config={}/app/quiet.conf'.format(sys.prefix), + '--progress', + '--progress-socket=127.0.0.1:{}'.format(port), + '--runas={}'.format(runas), + subcommand, + ] + else: + cmd = [ + 'sudo', '-u', getpass.getuser(), + 'bin/{}'.format(command), + '-c', 'app/quiet.conf', + '-P', + '--runas', runas, + subcommand, + ] + + cmd.extend(data['models']) + + if data['create']: + if typ == 'true': + cmd.append('--create') + else: + cmd.append('--no-create') + + if data['update']: + if typ == 'true': + cmd.append('--update') + else: + cmd.append('--no-update') + + if data['delete']: + cmd.append('--delete') + else: + if typ == 'true': + cmd.append('--no-delete') + + if data['versioning']: + if typ == 'true': + cmd.append('--versioning') + else: + cmd.append('--no-versioning') + + if data['dry_run']: + cmd.append('--dry-run') + + if data['warnings']: + cmd.append('--warnings') + + return cmd + + def cache_runjob_notes(self, handler, notes_cmd): + notes = """\ +You can run this {direction} job manually via command line: + +```sh +cd {prefix} +{cmd} +``` +""".format(direction=handler.direction, + prefix=sys.prefix, + cmd=' '.join(notes_cmd)) + + self.request.session['rattail.importing.runjob.notes'] = markdown.markdown( + notes, extensions=['fenced_code', 'codehilite']) + + @classmethod + def defaults(cls, config): + cls._defaults(config) + cls._importing_defaults(config) + + @classmethod + def _importing_defaults(cls, config): + route_prefix = cls.get_route_prefix() + permission_prefix = cls.get_permission_prefix() + instance_url_prefix = cls.get_instance_url_prefix() + + # run job + config.add_tailbone_permission(permission_prefix, + '{}.runjob'.format(permission_prefix), + "Run an arbitrary import / export job") + config.add_route('{}.runjob'.format(route_prefix), + '{}/runjob'.format(instance_url_prefix)) + config.add_view(cls, attr='runjob', + route_name='{}.runjob'.format(route_prefix), + permission='{}.runjob'.format(permission_prefix)) + + +class ImportHandlerSchema(colander.MappingSchema): + + host_key = colander.SchemaNode(colander.String()) + + local_key = colander.SchemaNode(colander.String()) + + host_title = colander.SchemaNode(colander.String()) + + local_title = colander.SchemaNode(colander.String()) + + handler_spec = colander.SchemaNode(colander.String()) + + +class RunJobSchema(colander.MappingSchema): + + handler_spec = colander.SchemaNode(colander.String()) + + host_title = colander.SchemaNode(colander.String()) + + local_title = colander.SchemaNode(colander.String()) + + models = colander.SchemaNode(colander.List()) + + create = colander.SchemaNode(colander.Bool()) + + update = colander.SchemaNode(colander.Bool()) + + delete = colander.SchemaNode(colander.Bool()) + + # runas = colander.SchemaNode(colander.String()) + + versioning = colander.SchemaNode(colander.Bool()) + + dry_run = colander.SchemaNode(colander.Bool()) + + warnings = colander.SchemaNode(colander.Bool()) + + +def includeme(config): + ImportingView.defaults(config) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index f288ec34..2a3189c4 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -32,6 +32,7 @@ import datetime import tempfile import logging +import json import six import sqlalchemy as sa from sqlalchemy import orm @@ -65,6 +66,10 @@ from tailbone.config import global_help_url log = logging.getLogger(__name__) +class EverythingComplete(Exception): + pass + + class MasterView(View): """ Base "master" view class. All model master views should derive from this. @@ -1743,6 +1748,78 @@ class MasterView(View): def get_execute_success_url(self, obj, **kwargs): return self.get_action_url('view', obj, **kwargs) + def progress_thread(self, sock, success_url, progress): + """ + This method is meant to be used as a thread target. Its job is to read + progress data from ``connection`` and update the session progress + accordingly. When a final "process complete" indication is read, the + socket will be closed and the thread will end. + """ + while True: + try: + self.process_progress(sock, progress) + except EverythingComplete: + break + + # close server socket + sock.close() + + # finalize session progress + progress.session.load() + progress.session['complete'] = True + if callable(success_url): + success_url = success_url() + progress.session['success_url'] = success_url + progress.session.save() + + def process_progress(self, sock, progress): + """ + This method will accept a client connection on the given socket, and + then update the given progress object according to data written by the + client. + """ + connection, client_address = sock.accept() + active_progress = None + + # TODO: make this configurable? + suffix = "\n\n.".encode('utf_8') + data = b'' + + # listen for progress info, update session progress as needed + while True: + + # accumulate data bytestring until we see the suffix + byte = connection.recv(1) + data += byte + if data.endswith(suffix): + + # strip suffix, interpret data as JSON + data = data[:-len(suffix)] + if six.PY3: + data = data.decode('utf_8') + data = json.loads(data) + + if data.get('everything_complete'): + if active_progress: + active_progress.finish() + raise EverythingComplete + + elif data.get('process_complete'): + active_progress.finish() + active_progress = None + break + + elif 'value' in data: + if not active_progress: + active_progress = progress(data['message'], data['maximum']) + active_progress.update(data['value']) + + # reset data buffer + data = b'' + + # close client connection + connection.close() + def get_merge_fields(self): if hasattr(self, 'merge_fields'): return self.merge_fields @@ -2287,7 +2364,10 @@ class MasterView(View): try: mapper = orm.object_mapper(row) except orm.exc.UnmappedInstanceError: - return {self.model_key: row[self.model_key]} + try: + return {self.model_key: row[self.model_key]} + except TypeError: + return {self.model_key: getattr(row, self.model_key)} else: pkeys = get_primary_keys(row) keys = list(pkeys) From cc4b2278e732335ad49d0919ea399687ef48986d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 6 Dec 2021 20:04:34 -0600 Subject: [PATCH 0519/1681] OMG a ridiculous commit to overhaul import handler config etc. - add `MasterView.configurable` concept, `/configure.mako` template - add new master view for DataSync Threads (needs content) - tweak view config for DataSync Changes accordingly - update the Configure DataSync page per `configurable` concept - add new Configure Import/Export page, per `configurable` - add basic views for Raw Permissions --- tailbone/templates/configure.mako | 175 ++++++++++++++++ .../templates/datasync/changes/index.mako | 4 +- tailbone/templates/datasync/configure.mako | 136 ++---------- tailbone/templates/datasync/index.mako | 19 ++ tailbone/templates/importing/configure.mako | 197 ++++++++++++++++++ tailbone/templates/master/index.mako | 3 + tailbone/views/datasync.py | 160 +++++++------- tailbone/views/importing.py | 156 +++++++++++--- tailbone/views/master.py | 65 +++++- tailbone/views/permissions.py | 58 ++++++ 10 files changed, 735 insertions(+), 238 deletions(-) create mode 100644 tailbone/templates/configure.mako create mode 100644 tailbone/templates/datasync/index.mako create mode 100644 tailbone/templates/importing/configure.mako create mode 100644 tailbone/views/permissions.py diff --git a/tailbone/templates/configure.mako b/tailbone/templates/configure.mako new file mode 100644 index 00000000..b0bfb14e --- /dev/null +++ b/tailbone/templates/configure.mako @@ -0,0 +1,175 @@ +## -*- coding: utf-8; -*- +<%inherit file="/page.mako" /> + +<%def name="title()">Configure ${config_title}</%def> + +<%def name="save_undo_buttons()"> + <div class="buttons" + v-if="settingsNeedSaved"> + <b-button type="is-primary" + @click="saveSettings" + :disabled="savingSettings" + icon-pack="fas" + icon-left="save"> + {{ savingSettings ? "Working, please wait..." : "Save All Settings" }} + </b-button> + <once-button tag="a" href="${request.current_route_url()}" + @click="undoChanges = true" + icon-left="undo" + text="Undo All Changes"> + </once-button> + </div> +</%def> + +<%def name="purge_button()"> + <b-button type="is-danger" + @click="purgeSettingsInit()" + icon-pack="fas" + icon-left="trash"> + Remove All Settings + </b-button> +</%def> + +<%def name="buttons_row()"> + <div class="level"> + <div class="level-left"> + + <div class="level-item"> + <p class="block"> + This tool lets you modify the ${config_title} configuration. + </p> + </div> + + <div class="level-item"> + ${self.save_undo_buttons()} + </div> + </div> + + <div class="level-right"> + <div class="level-item"> + ${self.purge_button()} + </div> + </div> + </div> +</%def> + +<%def name="page_content()"> + ${parent.page_content()} + + <br /> + + ${self.buttons_row()} + + <b-modal has-modal-card + :active.sync="purgeSettingsShowDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Remove All Settings</p> + </header> + + <section class="modal-card-body"> + <p class="block"> + If you like we can remove all ${config_title} + settings from the DB. + </p> + <p class="block"> + Note that the tool normally removes all settings first, + every time you click "Save Settings" - here though you can + "just remove and not save" the settings. + </p> + <p class="block"> + Note also that this will of course + <span class="is-italic">not</span> remove any settings from + your config files, so after removing from DB, + <span class="is-italic">only</span> your config file + settings should be in effect. + </p> + </section> + + <footer class="modal-card-foot"> + <b-button @click="purgeSettingsShowDialog = false"> + Cancel + </b-button> + ${h.form(request.current_route_url())} + ${h.csrf_token(request)} + ${h.hidden('remove_settings', 'true')} + <b-button type="is-danger" + native-type="submit" + :disabled="purgingSettings" + icon-pack="fas" + icon-left="trash" + @click="purgingSettings = true"> + {{ purgingSettings ? "Working, please wait..." : "Remove All Settings" }} + </b-button> + ${h.end_form()} + </footer> + </div> + </b-modal> +</%def> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + ThisPageData.purgeSettingsShowDialog = false + ThisPageData.purgingSettings = false + + ThisPageData.settingsNeedSaved = false + ThisPageData.undoChanges = false + ThisPageData.savingSettings = false + + ThisPage.methods.purgeSettingsInit = function() { + this.purgeSettingsShowDialog = true + } + + ThisPage.methods.settingsCollectParams = function() { + return {} + } + + ThisPage.methods.saveSettings = function() { + this.savingSettings = true + + let url = ${json.dumps(request.current_route_url())|n} + let params = this.settingsCollectParams() + let headers = { + 'X-CSRF-TOKEN': this.csrftoken, + } + + this.$http.post(url, params, {headers: headers}).then((response) => { + if (response.data.success) { + this.settingsNeedSaved = false + location.href = url // reload page + } else { + this.$buefy.toast.open({ + message: "Save failed: " + (response.data.error || "(unknown error)"), + type: 'is-danger', + duration: 4000, // 4 seconds + }) + } + }).catch((error) => { + this.$buefy.toast.open({ + message: "Save failed: (unknown error)", + type: 'is-danger', + duration: 4000, // 4 seconds + }) + }) + } + + // cf. https://stackoverflow.com/a/56551646 + ThisPage.methods.beforeWindowUnload = function(e) { + if (this.settingsNeedSaved && !this.undoChanges) { + e.preventDefault() + e.returnValue = '' + } + } + + ThisPage.created = function() { + window.addEventListener('beforeunload', this.beforeWindowUnload) + } + + </script> +</%def> + + +${parent.body()} diff --git a/tailbone/templates/datasync/changes/index.mako b/tailbone/templates/datasync/changes/index.mako index c28076fe..7a79010f 100644 --- a/tailbone/templates/datasync/changes/index.mako +++ b/tailbone/templates/datasync/changes/index.mako @@ -3,8 +3,8 @@ <%def name="context_menu_items()"> ${parent.context_menu_items()} - % if master.has_perm('configure'): - ${h.link_to("Configure DataSync", url('datasync.configure'))} + % if request.has_perm('datasync.list'): + <li>${h.link_to("View DataSync Threads", url('datasync'))}</li> % endif </%def> diff --git a/tailbone/templates/datasync/configure.mako b/tailbone/templates/datasync/configure.mako index 161328f7..0bed21e3 100644 --- a/tailbone/templates/datasync/configure.mako +++ b/tailbone/templates/datasync/configure.mako @@ -1,13 +1,10 @@ ## -*- coding: utf-8; -*- -<%inherit file="/page.mako" /> - -<%def name="title()">Configure DataSync</%def> - -<%def name="page_content()"> - <br /> +<%inherit file="/configure.mako" /> +<%def name="buttons_row()"> <div class="level"> <div class="level-left"> + <div class="level-item"> <p class="block"> This tool lets you modify the DataSync configuration. @@ -19,24 +16,13 @@ </p> </div> - <div class="level-item buttons" - v-if="settingsNeedSaved"> - <b-button type="is-primary" - @click="saveSettings" - :disabled="savingSettings" - icon-pack="fas" - icon-left="save"> - {{ saveSettingsButtonText }} - </b-button> - <once-button tag="a" href="${request.current_route_url()}" - @click="undoChanges = true" - icon-left="undo" - text="Undo All Changes"> - </once-button> + <div class="level-item"> + ${self.save_undo_buttons()} </div> </div> <div class="level-right"> + <div class="level-item"> ${h.form(url('datasync.restart'), **{'@submit': 'submitRestartDatasyncForm'})} ${h.csrf_token(request)} @@ -50,56 +36,16 @@ </b-button> ${h.end_form()} </div> + <div class="level-item"> - <b-button type="is-danger" - @click="purgeSettingsInit()" - icon-pack="fas" - icon-left="trash"> - Remove All Settings - </b-button> + ${self.purge_button()} </div> </div> </div> +</%def> - <b-modal has-modal-card - :active.sync="purgeSettingsShowDialog"> - <div class="modal-card"> - - <header class="modal-card-head"> - <p class="modal-card-title">Remove All Settings</p> - </header> - - <section class="modal-card-body"> - <p class="block"> - If you like we can remove all DataSync settings from the DB. - </p> - <p class="block"> - Note that this tool normally removes all settings first, - every time you click "Save Settings". Here though you - can "just remove" and <span class="is-italic">not</span> - save the current settings. - </p> - </section> - - <footer class="modal-card-foot"> - <b-button @click="purgeSettingsShowDialog = false"> - Cancel - </b-button> - ${h.form(request.current_route_url())} - ${h.csrf_token(request)} - ${h.hidden('purge_settings', 'true')} - <b-button type="is-danger" - native-type="submit" - :disabled="purgingSettings" - icon-pack="fas" - icon-left="trash" - @click="purgingSettings = true"> - {{ purgingSettings ? "Working, please wait..." : "Remove All Settings" }} - </b-button> - ${h.end_form()} - </footer> - </div> - </b-modal> +<%def name="page_content()"> + ${parent.page_content()} <b-notification type="is-warning" :active.sync="showConfigFilesNote"> @@ -496,13 +442,6 @@ ThisPageData.restartCommand = ${json.dumps(restart_command)|n} - ThisPageData.purgeSettingsShowDialog = false - ThisPageData.purgingSettings = false - - ThisPageData.settingsNeedSaved = false - ThisPageData.undoChanges = false - ThisPageData.savingSettings = false - ThisPage.computed.filteredProfilesData = function() { if (this.showDisabledProfiles) { return this.profilesData @@ -539,13 +478,6 @@ return false } - ThisPage.computed.saveSettingsButtonText = function() { - if (this.savingSettings) { - return "Working, please wait..." - } - return "Save All Settings" - } - ThisPage.methods.toggleDisabledProfiles = function() { this.showDisabledProfiles = !this.showDisabledProfiles } @@ -743,53 +675,11 @@ } } - ThisPage.methods.purgeSettingsInit = function() { - this.purgeSettingsShowDialog = true - } - - ThisPage.methods.saveSettings = function() { - this.savingSettings = true - let url = ${json.dumps(request.current_route_url())|n} - - let params = { + ThisPage.methods.settingsCollectParams = function() { + return { profiles: this.profilesData, restart_command: this.restartCommand, } - - let headers = { - 'X-CSRF-TOKEN': this.csrftoken, - } - - this.$http.post(url, params, {headers: headers}).then((response) => { - if (response.data.success) { - this.settingsNeedSaved = false - location.href = url // reload page - } else { - this.$buefy.toast.open({ - message: "Save failed: " + (response.data.error || "(unknown error)"), - type: 'is-danger', - duration: 4000, // 4 seconds - }) - } - }).catch((error) => { - this.$buefy.toast.open({ - message: "Save failed: (unknown error)", - type: 'is-danger', - duration: 4000, // 4 seconds - }) - }) - } - - // cf. https://stackoverflow.com/a/56551646 - ThisPage.methods.beforeWindowUnload = function(e) { - if (this.settingsNeedSaved && !this.undoChanges) { - e.preventDefault() - e.returnValue = '' - } - } - - ThisPage.created = function() { - window.addEventListener('beforeunload', this.beforeWindowUnload) } % if request.has_perm('datasync.restart'): diff --git a/tailbone/templates/datasync/index.mako b/tailbone/templates/datasync/index.mako new file mode 100644 index 00000000..fd7c39c6 --- /dev/null +++ b/tailbone/templates/datasync/index.mako @@ -0,0 +1,19 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/index.mako" /> + +<%def name="context_menu_items()"> + ${parent.context_menu_items()} + % if request.has_perm('datasync_changes.list'): + <li>${h.link_to("View DataSync Changes", url('datasyncchanges'))}</li> + % endif +</%def> + +<%def name="render_grid_component()"> + <b-notification :closable="false"> + TODO: this page coming soon... + </b-notification> + ${parent.render_grid_component()} +</%def> + + +${parent.body()} diff --git a/tailbone/templates/importing/configure.mako b/tailbone/templates/importing/configure.mako new file mode 100644 index 00000000..462a5215 --- /dev/null +++ b/tailbone/templates/importing/configure.mako @@ -0,0 +1,197 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="page_content()"> + ${parent.page_content()} + + <h3 class="is-size-3">Designated Handlers</h3> + + <b-table :data="handlersData" + narrowed + icon-pack="fas" + :default-sort="['host_title', 'asc']"> + <template slot-scope="props"> + <b-table-column field="host_title" label="Data Source" sortable> + {{ props.row.host_title }} + </b-table-column> + <b-table-column field="local_title" label="Data Target" sortable> + {{ props.row.local_title }} + </b-table-column> + <b-table-column field="direction" label="Direction" sortable> + {{ props.row.direction_display }} + </b-table-column> + <b-table-column field="handler_spec" label="Handler Spec" sortable> + {{ props.row.handler_spec }} + </b-table-column> + <b-table-column field="cmd" label="Command" sortable> + {{ props.row.command }} {{ props.row.subcommand }} + </b-table-column> + <b-table-column field="runas" label="Default Runas" sortable> + {{ props.row.default_runas }} + </b-table-column> + <b-table-column label="Actions"> + <a href="#" class="grid-action" + @click.prevent="editHandler(props.row)"> + <i class="fas fa-edit"></i> + Edit + </a> + </b-table-column> + </template> + <template slot="empty"> + <section class="section"> + <div class="content has-text-grey has-text-centered"> + <p> + <b-icon + pack="fas" + icon="fas fa-sad-tear" + size="is-large"> + </b-icon> + </p> + <p>Nothing here.</p> + </div> + </section> + </template> + </b-table> + + <b-modal :active.sync="editHandlerShowDialog"> + <div class="card"> + <div class="card-content"> + + <b-field :label="editingHandlerDirection" horizontal expanded> + {{ editingHandlerHostTitle }} -> {{ editingHandlerLocalTitle }} + </b-field> + + <b-field label="Handler Spec" + :type="editingHandlerSpec ? null : 'is-danger'"> + <b-select v-model="editingHandlerSpec"> + <option v-for="option in editingHandlerSpecOptions" + :key="option" + :value="option"> + {{ option }} + </option> + </b-select> + </b-field> + + <b-field grouped> + + <b-field label="Command" + :type="editingHandlerCommand ? null : 'is-danger'"> + <div class="level"> + <div class="level-left"> + <div class="level-item" style="margin-right: 0;"> + bin/ + </div> + <div class="level-item" style="margin-left: 0;"> + <b-input v-model="editingHandlerCommand"> + </b-input> + </div> + </div> + </div> + </b-field> + + <b-field label="Subcommand" + :type="editingHandlerSubcommand ? null : 'is-danger'"> + <b-input v-model="editingHandlerSubcommand"> + </b-input> + </b-field> + + <b-field label="Default Runas"> + <b-input v-model="editingHandlerRunas"> + </b-input> + </b-field> + + </b-field> + + <b-field grouped> + + <b-button @click="editHandlerShowDialog = false" + class="control"> + Cancel + </b-button> + + <b-button type="is-primary" + class="control" + @click="updateHandler()" + :disabled="updateHandlerDisabled"> + Update Handler + </b-button> + + </b-field> + + </div> + </div> + </b-modal> +</%def> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + ThisPageData.handlersData = ${json.dumps(handlers_data)|n} + + ThisPageData.editHandlerShowDialog = false + ThisPageData.editingHandler = null + ThisPageData.editingHandlerHostTitle = null + ThisPageData.editingHandlerLocalTitle = null + ThisPageData.editingHandlerDirection = 'import' + ThisPageData.editingHandlerSpec = null + ThisPageData.editingHandlerSpecOptions = [] + ThisPageData.editingHandlerCommand = null + ThisPageData.editingHandlerSubcommand = null + ThisPageData.editingHandlerRunas = null + + ThisPageData.settingsNeedSaved = false + ThisPageData.undoChanges = false + ThisPageData.savingSettings = false + + ThisPage.computed.updateHandlerDisabled = function() { + if (!this.editingHandlerSpec) { + return true + } + if (!this.editingHandlerCommand) { + return true + } + if (!this.editingHandlerSubcommand) { + return true + } + return false + } + + ThisPage.methods.editHandler = function(row) { + this.editingHandler = row + + this.editingHandlerHostTitle = row.host_title + this.editingHandlerLocalTitle = row.local_title + this.editingHandlerDirection = row.direction_display + this.editingHandlerSpec = row.handler_spec + this.editingHandlerSpecOptions = row.spec_options + this.editingHandlerCommand = row.command + this.editingHandlerSubcommand = row.subcommand + this.editingHandlerRunas = row.default_runas + + this.editHandlerShowDialog = true + } + + ThisPage.methods.updateHandler = function() { + let row = this.editingHandler + + row.handler_spec = this.editingHandlerSpec + row.command = this.editingHandlerCommand + row.subcommand = this.editingHandlerSubcommand + row.default_runas = this.editingHandlerRunas + + this.settingsNeedSaved = true + this.editHandlerShowDialog = false + } + + ThisPage.methods.settingsCollectParams = function() { + return { + handlers: this.handlersData, + } + } + + </script> +</%def> + + +${parent.body()} diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako index 8e855422..f58a59d1 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -162,6 +162,9 @@ <li>${h.link_to("Create a new {}".format(model_title), url('{}.create'.format(route_prefix)))}</li> % endif % endif + % if master.configurable and master.has_perm('configure'): + <li>${h.link_to("Configure {}".format(config_title), url('{}.configure'.format(route_prefix)))}</li> + % endif </%def> <%def name="grid_tools()"> diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py index 0fe1e709..cff9553f 100644 --- a/tailbone/views/datasync.py +++ b/tailbone/views/datasync.py @@ -32,54 +32,44 @@ import logging from rattail.db import model from rattail.datasync.config import load_profiles -from rattail.datasync.util import get_lastrun, purge_datasync_settings +from rattail.datasync.util import purge_datasync_settings from tailbone.views import MasterView -from tailbone.util import csrf_token log = logging.getLogger(__name__) -class DataSyncChangeView(MasterView): +class DataSyncThreadView(MasterView): """ - Master view for the DataSyncChange model. + Master view for DataSync itself. + + This should (eventually) show all running threads in the main + index view, with status for each, sort of akin to "dashboard". + For now it only serves the config view. """ - model_class = model.DataSyncChange - url_prefix = '/datasync/changes' - permission_prefix = 'datasync' + normalized_model_name = 'datasyncthread' + model_title = "DataSync Thread" + model_key = 'key' + route_prefix = 'datasync' + url_prefix = '/datasync' + viewable = False creatable = False editable = False - bulk_deletable = True + deletable = False + filterable = False + pageable = False - labels = { - 'batch_id': "Batch ID", - } + configurable = True + config_title = "DataSync" grid_columns = [ - 'source', - 'batch_id', - 'batch_sequence', - 'payload_type', - 'payload_key', - 'deletion', - 'obtained', - 'consumer', + 'key', ] - def configure_grid(self, g): - super(DataSyncChangeView, self).configure_grid(g) - - # batch_sequence - g.set_label('batch_sequence', "Batch Seq.") - g.filters['batch_sequence'].label = "Batch Sequence" - - g.set_sort_defaults('obtained') - g.set_type('obtained', 'datetime') - - def template_kwargs_index(self, **kwargs): - kwargs['allow_filemon_restart'] = bool(self.rattail_config.get('tailbone', 'filemon.restart')) - return kwargs + def get_data(self, session=None): + data = [] + return data def restart(self): cmd = self.rattail_config.getlist('tailbone', 'datasync.restart', @@ -93,23 +83,7 @@ class DataSyncChangeView(MasterView): 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'))) - def configure(self): - """ - View for configuring the DataSync daemon. - """ - if self.request.method == 'POST': - # if self.request.is_xhr and not self.request.POST: - if self.request.POST.get('purge_settings'): - self.delete_settings() - self.request.session.flash("Settings have been removed.") - return self.redirect(self.request.current_route_url()) - else: - data = self.request.json_body - self.save_settings(data) - self.request.session.flash("Settings have been saved. " - "You should probably restart DataSync now.") - return self.json_response({'success': True}) - + def configure_get_context(self): profiles = load_profiles(self.rattail_config, include_disabled=True, ignore_problems=True) @@ -148,27 +122,21 @@ class DataSyncChangeView(MasterView): profiles_data.append(data) return { - 'master': self, - # TODO: really only buefy themes are supported here - 'use_buefy': self.get_use_buefy(), - 'index_title': "DataSync Changes", - 'index_url': self.get_index_url(), 'profiles': profiles, 'profiles_data': profiles_data, 'restart_command': self.rattail_config.get('tailbone', 'datasync.restart'), 'system_user': getpass.getuser(), } - def save_settings(self, data): - model = self.model - - # collect new settings + def configure_gather_settings(self, data): settings = [] watch = [] + for profile in data['profiles']: pkey = profile['key'] if profile['enabled']: watch.append(pkey) + settings.extend([ {'name': 'rattail.datasync.{}.watcher'.format(pkey), 'value': profile['watcher_spec']}, @@ -183,10 +151,12 @@ class DataSyncChangeView(MasterView): {'name': 'rattail.datasync.{}.consumers.runas'.format(pkey), 'value': profile['watcher_default_runas']}, ]) + consumers = [] if profile['watcher_consumes_self']: consumers = ['self'] else: + for consumer in profile['consumers_data']: ckey = consumer['key'] if consumer['enabled']: @@ -205,10 +175,12 @@ class DataSyncChangeView(MasterView): {'name': 'rattail.datasync.{}.consumer.{}.runas'.format(pkey, ckey), 'value': consumer['consumer_runas']}, ]) + settings.extend([ {'name': 'rattail.datasync.{}.consumers'.format(pkey), 'value': ', '.join(consumers)}, ]) + settings.extend([ {'name': 'rattail.datasync.watch', 'value': ', '.join(watch)}, @@ -216,15 +188,9 @@ class DataSyncChangeView(MasterView): 'value': data['restart_command']}, ]) - # delete all current settings - self.delete_settings() + return settings - # create all new settings - for setting in settings: - self.Session.add(model.Setting(name=setting['name'], - value=setting['value'])) - - def delete_settings(self): + def configure_remove_settings(self): purge_datasync_settings(self.rattail_config, self.Session()) @classmethod @@ -235,33 +201,65 @@ class DataSyncChangeView(MasterView): @classmethod def _datasync_defaults(cls, config): permission_prefix = cls.get_permission_prefix() + route_prefix = cls.get_route_prefix() + url_prefix = cls.get_url_prefix() - # fix permission group title - config.add_tailbone_permission_group(permission_prefix, label="DataSync") - - # restart datasync + # restart config.add_tailbone_permission(permission_prefix, '{}.restart'.format(permission_prefix), label="Restart the DataSync daemon") - config.add_route('datasync.restart', '/datasync/restart', + config.add_route('{}.restart'.format(route_prefix), + '{}/restart'.format(url_prefix), request_method='POST') config.add_view(cls, attr='restart', - route_name='datasync.restart', + route_name='{}.restart'.format(route_prefix), permission='{}.restart'.format(permission_prefix)) - # configure datasync - config.add_tailbone_permission(permission_prefix, - '{}.configure'.format(permission_prefix), - label="Configure the DataSync daemon") - config.add_route('datasync.configure', '/datasync/configure') - config.add_view(cls, attr='configure', - route_name='datasync.configure', - permission='{}.configure'.format(permission_prefix), - renderer='/datasync/configure.mako') + +class DataSyncChangeView(MasterView): + """ + Master view for the DataSyncChange model. + """ + model_class = model.DataSyncChange + url_prefix = '/datasync/changes' + permission_prefix = 'datasync_changes' + creatable = False + editable = False + bulk_deletable = True + + labels = { + 'batch_id': "Batch ID", + } + + grid_columns = [ + 'source', + 'batch_id', + 'batch_sequence', + 'payload_type', + 'payload_key', + 'deletion', + 'obtained', + 'consumer', + ] + + def configure_grid(self, g): + super(DataSyncChangeView, self).configure_grid(g) + + # batch_sequence + g.set_label('batch_sequence', "Batch Seq.") + g.filters['batch_sequence'].label = "Batch Sequence" + + g.set_sort_defaults('obtained') + g.set_type('obtained', 'datetime') + + def template_kwargs_index(self, **kwargs): + kwargs['allow_filemon_restart'] = bool(self.rattail_config.get('tailbone', 'filemon.restart')) + return kwargs # TODO: deprecate / remove this DataSyncChangesView = DataSyncChangeView def includeme(config): + DataSyncThreadView.defaults(config) DataSyncChangeView.defaults(config) diff --git a/tailbone/views/importing.py b/tailbone/views/importing.py index 23a039cd..80f54c37 100644 --- a/tailbone/views/importing.py +++ b/tailbone/views/importing.py @@ -35,6 +35,7 @@ import time import json import six +import sqlalchemy as sa from rattail.exceptions import ConfigurationError from rattail.threads import Thread @@ -66,14 +67,19 @@ class ImportingView(MasterView): filterable = False pageable = False + configurable = True + config_title = "Import / Export" + labels = { 'host_title': "Data Source", 'local_title': "Data Target", + 'direction_display': "Direction", } grid_columns = [ 'host_title', 'local_title', + 'direction_display', 'handler_spec', ] @@ -84,6 +90,7 @@ class ImportingView(MasterView): 'handler_spec', 'host_title', 'local_title', + 'direction_display', 'models', ] @@ -105,18 +112,14 @@ class ImportingView(MasterView): app = self.get_rattail_app() data = [] - for Handler in app.all_import_handlers(): - handler = Handler(self.rattail_config) + for handler in app.get_designated_import_handlers( + ignore_errors=True, sort=True): data.append(self.normalize(handler)) - data.sort(key=lambda handler: (handler['host_title'], - handler['local_title'])) return data - def normalize(self, handler): - Handler = handler.__class__ - return { - '_handler': handler, + def normalize(self, handler, keep_handler=True): + data = { 'key': handler.get_key(), 'generic_title': handler.get_generic_title(), 'host_key': handler.host_key, @@ -124,7 +127,31 @@ class ImportingView(MasterView): 'local_key': handler.local_key, 'local_title': handler.get_generic_local_title(), 'handler_spec': handler.get_spec(), - } + 'direction': handler.direction, + 'direction_display': handler.direction.capitalize(), + } + + if keep_handler: + data['_handler'] = handler + + alternates = getattr(handler, 'alternate_handlers', None) + if alternates: + data['alternates'] = [] + for alternate in alternates: + data['alternates'].append(self.normalize( + alternate, keep_handler=keep_handler)) + + cmd = self.get_cmd_for_handler(handler, ignore_errors=True) + if cmd: + data['cmd'] = ' '.join(cmd) + data['command'] = cmd[0] + data['subcommand'] = cmd[1] + + runas = self.get_runas_for_handler(handler) + if runas: + data['default_runas'] = runas + + return data def configure_grid(self, g): super(ImportingView, self).configure_grid(g) @@ -139,9 +166,9 @@ class ImportingView(MasterView): """ key = self.request.matchdict['key'] app = self.get_rattail_app() - for Handler in app.all_import_handlers(): - if Handler.get_key() == key: - return self.normalize(Handler(self.rattail_config)) + handler = app.get_designated_import_handler(key, ignore_errors=True) + if handler: + return self.normalize(handler) raise self.notfound() def get_instance_title(self, handler_info): @@ -206,8 +233,8 @@ class ImportingView(MasterView): def cache_runjob_form_values(self, handler, form): handler_key = handler.get_key() - def make_key(key): - return 'rattail.importing.{}.{}'.format(handler_key, key) + def make_key(field): + return 'rattail.importing.{}.{}'.format(handler_key, field) for field in form.fields: key = make_key(field) @@ -216,8 +243,8 @@ class ImportingView(MasterView): def read_cached_runjob_values(self, handler, form): handler_key = handler.get_key() - def make_key(key): - return 'rattail.importing.{}.{}'.format(handler_key, key) + def make_key(field): + return 'rattail.importing.{}.{}'.format(handler_key, field) for field in form.fields: key = make_key(field) @@ -331,8 +358,10 @@ class ImportingView(MasterView): # invoke handler command via subprocess try: - result = subprocess.run(cmd, check=True, capture_output=True) - output = result.stderr.decode('utf_8').strip() + result = subprocess.run(cmd, check=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT) + output = result.stdout.decode('utf_8').strip() except Exception as error: log.warning("failed to invoke handler cmd: %s", cmd, exc_info=True) @@ -346,14 +375,14 @@ class ImportingView(MasterView): {} ``` -And here is the STDERR output: +And here is the output: ``` {} ``` """.format(handler.direction.capitalize(), ' '.join(cmd), - error.stderr.decode('utf_8').strip()) + error.stdout.decode('utf_8').strip()) msg = markdown.markdown(msg, extensions=['fenced_code']) msg = HTML.literal(msg) msg = HTML.tag('div', class_='tailbone-markdown', c=[msg]) @@ -394,21 +423,35 @@ And here is the STDERR output: notes = HTML.literal(notes) return HTML.tag('div', class_='tailbone-markdown', c=[notes]) - def make_runjob_cmd(self, handler, form, typ, port=None): + def get_cmd_for_handler(self, handler, ignore_errors=False): handler_key = handler.get_key() - option = '{}.cmd'.format(handler_key) - cmd = self.rattail_config.getlist('rattail.importing', option) + cmd = self.rattail_config.getlist('rattail.importing', + '{}.cmd'.format(handler_key)) if not cmd or len(cmd) != 2: - msg = ("Missing or invalid config; please set '{}' in the " - "[rattail.importing] section of your config file".format(option)) - raise ConfigurationError(msg) + cmd = self.rattail_config.getlist('rattail.importing', + '{}.default_cmd'.format(handler_key)) - command, subcommand = cmd + if not cmd or len(cmd) != 2: + msg = ("Missing or invalid config; please set '{}.default_cmd' in the " + "[rattail.importing] section of your config file".format(handler_key)) + if ignore_errors: + return + raise ConfigurationError(msg) - option = '{}.runas'.format(handler_key) - runas = self.rattail_config.require('rattail.importing', option) + return cmd + def get_runas_for_handler(self, handler): + handler_key = handler.get_key() + runas = self.rattail_config.get('rattail.importing', + '{}.runas'.format(handler_key)) + if runas: + return runas + return self.rattail_config.get('rattail', 'runas.default') + + def make_runjob_cmd(self, handler, form, typ, port=None): + command, subcommand = self.get_cmd_for_handler(handler) + runas = self.get_runas_for_handler(handler) data = form.validated if typ == 'true': @@ -460,7 +503,10 @@ And here is the STDERR output: cmd.append('--dry-run') if data['warnings']: - cmd.append('--warnings') + if typ == 'true': + cmd.append('--warnings') + else: + cmd.append('-W') return cmd @@ -479,6 +525,54 @@ cd {prefix} self.request.session['rattail.importing.runjob.notes'] = markdown.markdown( notes, extensions=['fenced_code', 'codehilite']) + def configure_get_context(self): + app = self.get_rattail_app() + handlers_data = [] + + for handler in app.get_designated_import_handlers( + with_alternates=True, + ignore_errors=True, sort=True): + + data = self.normalize(handler, keep_handler=False) + + data['spec_options'] = [handler.get_spec()] + for alternate in handler.alternate_handlers: + data['spec_options'].append(alternate.get_spec()) + data['spec_options'].sort() + + handlers_data.append(data) + + return { + 'handlers_data': handlers_data, + } + + def configure_gather_settings(self, data): + settings = [] + + for handler in data['handlers']: + key = handler['key'] + + settings.extend([ + {'name': 'rattail.importing.{}.handler'.format(key), + 'value': handler['handler_spec']}, + {'name': 'rattail.importing.{}.cmd'.format(key), + 'value': '{} {}'.format(handler['command'], + handler['subcommand'])}, + {'name': 'rattail.importing.{}.runas'.format(key), + 'value': handler['default_runas']}, + ]) + + return settings + + def configure_remove_settings(self): + 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) + @classmethod def defaults(cls, config): cls._defaults(config) @@ -493,7 +587,7 @@ cd {prefix} # run job config.add_tailbone_permission(permission_prefix, '{}.runjob'.format(permission_prefix), - "Run an arbitrary import / export job") + "Run an arbitrary Import / Export Job") config.add_route('{}.runjob'.format(route_prefix), '{}/runjob'.format(instance_url_prefix)) config.add_view(cls, attr='runjob', diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 2a3189c4..cdd958a0 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -114,6 +114,7 @@ class MasterView(View): execute_progress_initial_msg = None supports_prev_next = False supports_import_batch_from_file = False + configurable = False # set to True to add "View *global* Objects" permission, and # expose / leverage the ``local_only`` object flag @@ -2032,6 +2033,16 @@ class MasterView(View): """ return getattr(cls, 'index_title', cls.get_model_title_plural()) + @classmethod + def get_config_title(cls): + """ + Returns the view's "config title". + """ + if hasattr(cls, 'config_title'): + return cls.config_title + + return cls.get_model_title_plural() + def get_action_url(self, action, instance, **kwargs): """ Generate a URL for the given action on the given instance @@ -2075,6 +2086,7 @@ class MasterView(View): 'permission_prefix': self.get_permission_prefix(), 'index_title': self.get_index_title(), 'index_url': self.get_index_url(), + 'config_title': self.get_config_title(), 'action_url': self.get_action_url, 'grid_index': self.grid_index, 'help_url': self.get_help_url(), @@ -3982,7 +3994,46 @@ class MasterView(View): return diffs.Diff(old_data, new_data, **kwargs) ############################## - # Config Stuff + # Configuration Views + ############################## + + def configure(self): + """ + Generic view for configuring some aspect of the software. + """ + if self.request.method == 'POST': + if self.request.POST.get('remove_settings'): + self.configure_remove_settings() + self.request.session.flash("Settings have been removed.") + return self.redirect(self.request.current_route_url()) + else: + data = self.request.json_body + settings = self.configure_gather_settings(data) + self.configure_remove_settings() + self.configure_save_settings(settings) + self.request.session.flash("Settings have been saved.") + return self.json_response({'success': True}) + + context = self.configure_get_context() + return self.render_to_response('configure', context) + + def configure_get_context(self): + return {} + + def configure_gather_settings(self, data): + return [] + + def configure_remove_settings(self): + pass + + def configure_save_settings(self, settings): + model = self.model + for setting in settings: + self.Session.add(model.Setting(name=setting['name'], + value=setting['value'])) + + ############################## + # Pyramid View Config ############################## @classmethod @@ -4025,6 +4076,7 @@ class MasterView(View): model_key = cls.get_model_key() model_title = cls.get_model_title() model_title_plural = cls.get_model_title_plural() + config_title = cls.get_config_title() if cls.has_rows: row_model_title = cls.get_row_model_title() @@ -4087,6 +4139,17 @@ class MasterView(View): config.add_view(cls, attr='download_results_rows', route_name='{}.download_results_rows'.format(route_prefix), permission='{}.download_results_rows'.format(permission_prefix)) + # configure + if cls.configurable: + config.add_tailbone_permission(permission_prefix, + '{}.configure'.format(permission_prefix), + label="Configure {}".format(config_title)) + config.add_route('{}.configure'.format(route_prefix), + '{}/configure'.format(url_prefix)) + config.add_view(cls, attr='configure', + route_name='{}.configure'.format(route_prefix), + permission='{}.configure'.format(permission_prefix)) + # quickie (search) if cls.supports_quickie_search: config.add_tailbone_permission(permission_prefix, '{}.quickie'.format(permission_prefix), diff --git a/tailbone/views/permissions.py b/tailbone/views/permissions.py new file mode 100644 index 00000000..67f6e9b1 --- /dev/null +++ b/tailbone/views/permissions.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2021 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Raw Permission Views +""" + +from __future__ import unicode_literals, absolute_import + +from sqlalchemy import orm + +from rattail.db import model + +from tailbone.views import MasterView + + +class PermissionView(MasterView): + """ + Master view for the permissions model. + """ + model_class = model.Permission + model_title = "Raw Permission" + editable = False + bulk_deletable = True + + grid_columns = [ + 'role', + 'permission', + ] + + def query(self, session): + model = self.model + query = super(PermissionView, self).query(session) + query = query.options(orm.joinedload(model.Permission.role)) + return query + + +def includeme(config): + PermissionView.defaults(config) From 092f1cda0ce9d17191959d0f027340df23c1f6d1 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 6 Dec 2021 21:29:33 -0600 Subject: [PATCH 0520/1681] Honor "safe for web app" flags for import/export handlers --- tailbone/templates/importing/runjob.mako | 5 +++++ tailbone/views/importing.py | 9 ++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/tailbone/templates/importing/runjob.mako b/tailbone/templates/importing/runjob.mako index 2b9642f6..2bc2a4e9 100644 --- a/tailbone/templates/importing/runjob.mako +++ b/tailbone/templates/importing/runjob.mako @@ -44,7 +44,12 @@ </once-button> <b-button type="is-primary" @click="submitRun()" + % if handler.safe_for_web_app: :disabled="submittingRun" + % else: + disabled + title="Handler is not (yet) safe to run with this tool" + % endif icon-pack="fas" icon-left="arrow-circle-right"> {{ submittingRun ? "Working, please wait..." : "Run this ${handler.direction.capitalize()}" }} diff --git a/tailbone/views/importing.py b/tailbone/views/importing.py index 80f54c37..4d142cf3 100644 --- a/tailbone/views/importing.py +++ b/tailbone/views/importing.py @@ -129,6 +129,7 @@ class ImportingView(MasterView): 'handler_spec': handler.get_spec(), 'direction': handler.direction, 'direction_display': handler.direction.capitalize(), + 'safe_for_web_app': handler.safe_for_web_app, } if keep_handler: @@ -314,7 +315,13 @@ class ImportingView(MasterView): if self.request.POST.get('runjob') == 'true': - # will invoke handler to run job + # will invoke handler to run job.. + + # ..but only if it is safe to do so + if not handler.safe_for_web_app: + self.request.session.flash("Handler is not (yet) safe to run " + "with this tool", 'error') + return self.redirect(self.request.current_route_url()) # TODO: this socket progress business was lifted from # tailbone.views.batch.core:BatchMasterView.handler_action From 5a4abbb1636a50eef16e959c0aab48f33b0412cf Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 7 Dec 2021 11:28:23 -0600 Subject: [PATCH 0521/1681] When viewing report output, show params as proper buefy table plus couple of other random tweaks --- .../templates/reports/generated/view.mako | 12 +++++++ tailbone/views/datasync.py | 7 +++- tailbone/views/reports.py | 34 +++++++++++++++++-- tailbone/views/stores.py | 1 + 4 files changed, 50 insertions(+), 4 deletions(-) diff --git a/tailbone/templates/reports/generated/view.mako b/tailbone/templates/reports/generated/view.mako index c7d34efa..ce8ef38d 100644 --- a/tailbone/templates/reports/generated/view.mako +++ b/tailbone/templates/reports/generated/view.mako @@ -8,4 +8,16 @@ % endif </%def> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + % if params_data is not Undefined: + ${form.component_studly}Data.paramsData = ${json.dumps(params_data)|n} + % endif + + </script> +</%def> + + ${parent.body()} diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py index cff9553f..03be846e 100644 --- a/tailbone/views/datasync.py +++ b/tailbone/views/datasync.py @@ -224,7 +224,6 @@ class DataSyncChangeView(MasterView): url_prefix = '/datasync/changes' permission_prefix = 'datasync_changes' creatable = False - editable = False bulk_deletable = True labels = { @@ -256,6 +255,12 @@ class DataSyncChangeView(MasterView): kwargs['allow_filemon_restart'] = bool(self.rattail_config.get('tailbone', 'filemon.restart')) return kwargs + def configure_form(self, f): + super(DataSyncChangeView, self).configure_form(f) + + f.set_readonly('obtained') + + # TODO: deprecate / remove this DataSyncChangesView = DataSyncChangeView diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index 5d1ca5eb..204dc9df 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -45,7 +45,7 @@ from mako.template import Template from pyramid.response import Response from webhelpers2.html import HTML -from tailbone import forms, grids +from tailbone import forms from tailbone.db import Session from tailbone.views import View from tailbone.views.exports import ExportMasterView @@ -257,18 +257,39 @@ class ReportOutputView(ExportMasterView): params.sort(key=lambda param: param['key']) route_prefix = self.get_route_prefix() - g = grids.Grid( + factory = self.get_grid_factory() + g = factory( key='{}.params'.format(route_prefix), data=params, columns=['key', 'value'], + labels={'key': "Name"}, ) - return HTML.literal(g.render_grid()) + if self.get_use_buefy(): + return HTML.literal( + g.render_buefy_table_element(data_prop='paramsData')) + else: + return HTML.literal(g.render_grid()) def render_download(self, report, field): path = report.filepath(self.rattail_config) url = self.get_action_url('download', report) return self.render_file_field(path, url=url) + def template_kwargs_view(self, **kwargs): + use_buefy = self.get_use_buefy() + if use_buefy: + + report = kwargs['instance'] + params_data = [] + for name, value in report.params.items(): + params_data.append({ + 'name': name, + 'value': value, + }) + kwargs['params_data'] = params_data + + return kwargs + def download(self): report = self.get_instance() path = report.filepath(self.rattail_config) @@ -387,6 +408,13 @@ class GenerateReport(View): if param.type is datetime.date: form.set_type(param.name, 'date_jquery') + # auto-select default choice for fields which have only one + for param in report_params: + if param.type == 'choice' and param.required: + values = form.schema[param.name].widget.values + if len(values) == 1: + form.set_default(param.name, values[0][0]) + # if form validates, start generating new report output; show progress page if form.validate(newstyle=True): key = 'report_output.generate' diff --git a/tailbone/views/stores.py b/tailbone/views/stores.py index b3107a83..ef09e69b 100644 --- a/tailbone/views/stores.py +++ b/tailbone/views/stores.py @@ -42,6 +42,7 @@ class StoreView(MasterView): """ model_class = model.Store has_versions = True + touchable = True grid_columns = [ 'id', From a7c6380a3a1370b15277b2e99e20c8d8907b8cbe Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 7 Dec 2021 11:36:46 -0600 Subject: [PATCH 0522/1681] 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 68c0ef39..1060d7d0 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,24 @@ CHANGELOG ========= +0.8.180 (2021-12-07) +-------------------- + +* Add basic import/export handler views, tool to run jobs. + +* Overhaul import handler config etc.: + * add ``MasterView.configurable`` concept, ``/configure.mako`` template + * add new master view for DataSync Threads (needs content) + * tweak view config for DataSync Changes accordingly + * update the Configure DataSync page per ``configurable`` concept + * add new Configure Import/Export page, per ``configurable`` + * add basic views for Raw Permissions + +* Honor "safe for web app" flags for import/export handlers. + +* When viewing report output, show params as proper buefy table. + + 0.8.179 (2021-12-03) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index ff41db5a..b99f1145 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.179' +__version__ = '0.8.180' From 1353f6ed3c6235bfcdef347d546f2e518d6428b4 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 7 Dec 2021 12:09:43 -0600 Subject: [PATCH 0523/1681] Bugfix --- tailbone/views/reports.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index 204dc9df..30a9c6b6 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -283,7 +283,7 @@ class ReportOutputView(ExportMasterView): params_data = [] for name, value in report.params.items(): params_data.append({ - 'name': name, + 'key': name, 'value': value, }) kwargs['params_data'] = params_data From 095afcde24f44cc19040bee91ce20a1ab02381d9 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 7 Dec 2021 13:19:18 -0600 Subject: [PATCH 0524/1681] 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 1060d7d0..cdbbe8fe 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.181 (2021-12-07) +-------------------- + +* Bugfix. + + 0.8.180 (2021-12-07) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index b99f1145..35cbd36e 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.180' +__version__ = '0.8.181' From 6fc666e221d71e5f12192c9df37fd13c40137049 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 7 Dec 2021 16:18:56 -0600 Subject: [PATCH 0525/1681] Fix form ref bug, for batch execution --- tailbone/forms/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 060e1133..6b463f55 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -771,7 +771,7 @@ class Form(object): # TODO: deprecate / remove the latter option here if self.auto_disable_save or self.auto_disable: if self.use_buefy: - context['form_kwargs']['ref'] = self.component_studly + context['form_kwargs'].setdefault('ref', self.component_studly) context['form_kwargs']['@submit'] = 'submit{}'.format(self.component_studly) else: context['form_kwargs']['class_'] = 'autodisable' From f687078bbf7f568e2bf411a6b2292ce6e7496b3e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 7 Dec 2021 16:19:32 -0600 Subject: [PATCH 0526/1681] 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 cdbbe8fe..defb6035 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.182 (2021-12-07) +-------------------- + +* Fix form ref bug, for batch execution. + + 0.8.181 (2021-12-07) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 35cbd36e..c5a1b4cb 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.181' +__version__ = '0.8.182' From 871dd35a3a3f22ac3e3d8bb5c5fc28fd0e998ce7 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 7 Dec 2021 17:45:21 -0600 Subject: [PATCH 0527/1681] Add basic views to expose Problem Reports, and run them not very sophisticated yet but heck better than we had yesterday --- tailbone/templates/reports/problems/view.mako | 76 ++++++++++ tailbone/views/batch/core.py | 12 +- tailbone/views/master.py | 64 +++++--- tailbone/views/reports.py | 139 +++++++++++++++++- 4 files changed, 263 insertions(+), 28 deletions(-) create mode 100644 tailbone/templates/reports/problems/view.mako diff --git a/tailbone/templates/reports/problems/view.mako b/tailbone/templates/reports/problems/view.mako new file mode 100644 index 00000000..cbd2a942 --- /dev/null +++ b/tailbone/templates/reports/problems/view.mako @@ -0,0 +1,76 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> + +<%def name="object_helpers()"> + ${parent.object_helpers()} + % if master.has_perm('execute'): + <nav class="panel"> + <p class="panel-heading">Tools</p> + <div class="panel-block buttons"> + <b-button type="is-primary" + @click="runReportShowDialog = true" + icon-pack="fas" + icon-left="arrow-circle-right"> + Run this Report + </b-button> + </div> + </nav> + + <b-modal has-modal-card + :active.sync="runReportShowDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Run Problem Report</p> + </header> + + <section class="modal-card-body"> + <p class="block"> + You can run this problem report right now if you like. + </p> + + <p class="block"> + Keep in mind the following may receive email, should the + report find any problems. + </p> + + <ul> + % for recip in instance['email_recipients']: + <li>${recip}</li> + % endfor + </ul> + </section> + + <footer class="modal-card-foot"> + <b-button @click="runReportShowDialog = false"> + Cancel + </b-button> + ${h.form(master.get_action_url('execute', instance))} + ${h.csrf_token(request)} + <b-button type="is-primary" + native-type="submit" + @click="runReportSubmitting = true" + :disabled="runReportSubmitting" + icon-pack="fas" + icon-left="arrow-circle-right"> + {{ runReportSubmitting ? "Working, please wait..." : "Run Problem Report" }} + </b-button> + ${h.end_form()} + </footer> + </div> + </b-modal> + % endif +</%def> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + ThisPageData.runReportShowDialog = false + ThisPageData.runReportSubmitting = false + + </script> +</%def> + + +${parent.body()} diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 90614079..c0a3a1a3 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -850,7 +850,7 @@ class BatchMasterView(MasterView): # launch thread to invoke handler action thread = Thread(target=self.action_subprocess_thread, - args=(batch.uuid, port, username, batch_action, progress), + args=((batch.uuid,), port, username, batch_action, progress), kwargs=kwargs) thread.start() @@ -859,7 +859,7 @@ class BatchMasterView(MasterView): # launch thread to populate batch; that will update session progress directly target = getattr(self, '{}_thread'.format(batch_action)) - thread = Thread(target=target, args=(batch.uuid, user_uuid, progress), kwargs=kwargs) + thread = Thread(target=target, args=((batch.uuid,), user_uuid, progress), kwargs=kwargs) thread.start() return self.render_progress(progress, { @@ -894,7 +894,7 @@ class BatchMasterView(MasterView): log.debug("launching command in subprocess: %s", cmd) subprocess.check_call(cmd) - def action_subprocess_thread(self, batch_uuid, port, username, handler_action, progress, **kwargs): + def action_subprocess_thread(self, key, port, username, handler_action, progress, **kwargs): """ This method is sort of an alternative thread target for batch actions, to be used in the event versioning is enabled for the main process but @@ -902,6 +902,8 @@ class BatchMasterView(MasterView): launch a separate process with versioning disabled in order to act on the batch. """ + batch_uuid = key[0] + # figure out the (sub)command args we'll be passing subargs = [ '--batch-type', @@ -1216,7 +1218,7 @@ class BatchMasterView(MasterView): def execute_error_message(self, error): return "Batch execution failed: {}".format(simple_error(error)) - def execute_thread(self, batch_uuid, user_uuid, progress, **kwargs): + def execute_thread(self, key, user_uuid, progress, **kwargs): """ Thread target for executing a batch with progress indicator. """ @@ -1224,7 +1226,7 @@ class BatchMasterView(MasterView): # session here; can't use tailbone because it has web request # transaction binding etc. session = RattailSession() - batch = session.query(self.model_class).get(batch_uuid) + batch = self.get_instance_for_key(key, session) user = session.query(model.User).get(user_uuid) try: result = self.handler.do_execute(batch, user=user, progress=progress, **kwargs) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index cdd958a0..73562e8d 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -1688,36 +1688,43 @@ class MasterView(View): """ obj = self.get_instance() model_title = self.get_model_title() - if self.request.method == 'POST': + progress = self.make_execute_progress(obj) - progress = self.make_execute_progress(obj) - kwargs = {'progress': progress} - thread = Thread(target=self.execute_thread, args=(obj.uuid, self.request.user.uuid), kwargs=kwargs) - thread.start() + 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.start() - return self.render_progress(progress, { - 'instance': obj, - 'initial_msg': self.execute_progress_initial_msg, - 'cancel_url': self.get_action_url('view', obj), - 'cancel_msg': "{} execution was canceled".format(model_title), - }, template=self.execute_progress_template) - - self.request.session.flash("Sorry, you must POST to execute a {}.".format(model_title), 'error') - return self.redirect(self.get_action_url('view', obj)) + return self.render_progress(progress, { + 'instance': obj, + 'initial_msg': self.execute_progress_initial_msg, + 'cancel_url': self.get_action_url('view', obj), + '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 execute_thread(self, uuid, user_uuid, progress=None, **kwargs): + def get_instance_for_key(self, key, session): + model_key = self.get_model_key(as_tuple=True) + if len(model_key) == 1 and model_key[0] == 'uuid': + uuid = key[0] + return session.query(self.model_class).get(uuid) + raise NotImplementedError + + def execute_thread(self, key, user_uuid, progress=None, **kwargs): """ Thread target for executing an object. """ session = RattailSession() - obj = session.query(self.model_class).get(uuid) + obj = self.get_instance_for_key(key, session) user = session.query(model.User).get(user_uuid) try: - self.execute_instance(obj, user, progress=progress, **kwargs) + success_msg = self.execute_instance(obj, user, + progress=progress, + **kwargs) # If anything goes wrong, rollback and log the error etc. except Exception as error: @@ -1733,13 +1740,21 @@ class MasterView(View): # If no error, check result flag (false means user canceled). else: session.commit() - session.refresh(obj) + try: + needs_refresh = obj in session + except: + pass + else: + if needs_refresh: + session.refresh(obj) success_url = self.get_execute_success_url(obj) session.close() if progress: progress.session.load() progress.session['complete'] = True progress.session['success_url'] = success_url + if success_msg: + progress.session['success_msg'] = success_msg progress.session.save() def execute_error_message(self, error): @@ -1991,8 +2006,10 @@ class MasterView(View): the master view class. This is the plural, lower-cased name of the model class by default, e.g. 'products'. """ + if hasattr(cls, 'route_prefix'): + return cls.route_prefix model_name = cls.get_normalized_model_name() - return getattr(cls, 'route_prefix', '{0}s'.format(model_name)) + return '{}s'.format(model_name) @classmethod def get_url_prefix(cls): @@ -2377,7 +2394,10 @@ class MasterView(View): mapper = orm.object_mapper(row) except orm.exc.UnmappedInstanceError: try: - return {self.model_key: row[self.model_key]} + if isinstance(self.model_key, six.string_types): + return {self.model_key: row[self.model_key]} + return dict([(key, row[key]) + for key in self.model_key]) except TypeError: return {self.model_key: getattr(row, self.model_key)} else: @@ -4311,7 +4331,9 @@ class MasterView(View): if cls.executable: config.add_tailbone_permission(permission_prefix, '{}.execute'.format(permission_prefix), "Execute {}".format(model_title)) - config.add_route('{}.execute'.format(route_prefix), '{}/execute'.format(instance_url_prefix)) + config.add_route('{}.execute'.format(route_prefix), + '{}/execute'.format(instance_url_prefix), + request_method='POST') config.add_view(cls, attr='execute', route_name='{}.execute'.format(route_prefix), permission='{}.execute'.format(permission_prefix)) diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index 30a9c6b6..4c8fe6e9 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -43,12 +43,12 @@ import colander from deform import widget as dfwidget from mako.template import Template from pyramid.response import Response -from webhelpers2.html import HTML +from webhelpers2.html import HTML, tags from tailbone import forms from tailbone.db import Session from tailbone.views import View -from tailbone.views.exports import ExportMasterView +from tailbone.views.exports import ExportMasterView, MasterView plu_upc_pattern = re.compile(r'^000000000(\d{5})$') @@ -511,6 +511,140 @@ class NewReport(colander.Schema): validator=valid_report_type) +class ProblemReportView(MasterView): + """ + Master view for problem reports + """ + model_title = "Problem Report" + model_key = ('system_key', 'problem_key') + route_prefix = 'problem_reports' + url_prefix = '/reports/problems' + + creatable = False + editable = False + deletable = False + filterable = False + pageable = False + executable = True + + labels = { + 'system_key': "System", + } + + grid_columns = [ + 'system_key', + # 'problem_key', + 'problem_title', + 'email_recipients', + ] + + form_fields = [ + 'system_key', + 'problem_title', + 'email_key', + 'email_recipients', + ] + + def __init__(self, request): + super(ProblemReportView, self).__init__(request) + + app = self.get_rattail_app() + self.handler = app.get_problem_report_handler() + + def normalize(self, report, keep_report=True): + data = { + 'system_key': report.system_key, + 'problem_key': report.problem_key, + 'problem_title': report.problem_title, + 'email_key': self.handler.get_email_key(report), + } + + app = self.get_rattail_app() + handler = app.get_mail_handler() + email = handler.get_email(data['email_key']) + data['email_recipients'] = email.get_recips('all') + + if keep_report: + data['_report'] = report + return data + + def get_data(self, session=None): + data = [] + + reports = self.handler.get_all_problem_reports() + organized = self.handler.organize_problem_reports(reports) + + for system_key, reports in six.iteritems(organized): + for report in six.itervalues(reports): + data.append(self.normalize(report)) + + return data + + def configure_grid(self, g): + super(ProblemReportView, self).configure_grid(g) + + g.set_renderer('email_recipients', self.render_email_recipients) + + g.set_link('problem_key') + g.set_link('problem_title') + + def get_instance(self): + system_key = self.request.matchdict['system_key'] + problem_key = self.request.matchdict['problem_key'] + return self.get_instance_for_key((system_key, problem_key), + None) + + def get_instance_for_key(self, key, session): + report = self.handler.get_problem_report(*key) + if report: + return self.normalize(report) + raise self.notfound() + + def get_instance_title(self, report_info): + return report_info['problem_title'] + + def make_form_schema(self): + return ProblemReportSchema() + + def configure_form(self, f): + super(ProblemReportView, self).configure_form(f) + + f.set_renderer('email_key', self.render_email_key) + f.set_renderer('email_recipients', self.render_email_recipients) + + def render_email_key(self, report_info, field): + email_key = report_info[field] + if not email_key: + return + + if self.request.has_perm('emailprofiles.view'): + text = email_key + url = self.request.route_url('emailprofiles.view', key=email_key) + return tags.link_to(text, url) + + return email_key + + def render_email_recipients(self, report_info, field): + recips = report_info['email_recipients'] + return ', '.join(recips) + + def execute_instance(self, report_info, user, progress=None, **kwargs): + report = report_info['_report'] + problems = self.handler.run_problem_report(report) + return "Report found {} problems".format(len(problems)) + + +class ProblemReportSchema(colander.MappingSchema): + + system_key = colander.SchemaNode(colander.String()) + + problem_key = colander.SchemaNode(colander.String()) + + problem_title = colander.SchemaNode(colander.String()) + + email_key = colander.SchemaNode(colander.String()) + + def add_routes(config): config.add_route('reports.ordering', '/reports/ordering') config.add_route('reports.inventory', '/reports/inventory') @@ -531,3 +665,4 @@ def includeme(config): # note that GenerateReport must come first, per route matching GenerateReport.defaults(config) ReportOutputView.defaults(config) + ProblemReportView.defaults(config) From ff588b6a5cee0e55edff0a5cacfe978e258ac2f6 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 7 Dec 2021 19:57:26 -0600 Subject: [PATCH 0528/1681] Only include `--runas` arg if we have a value --- tailbone/views/importing.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tailbone/views/importing.py b/tailbone/views/importing.py index 4d142cf3..ecb2ea02 100644 --- a/tailbone/views/importing.py +++ b/tailbone/views/importing.py @@ -467,8 +467,6 @@ And here is the output: '--config={}/app/quiet.conf'.format(sys.prefix), '--progress', '--progress-socket=127.0.0.1:{}'.format(port), - '--runas={}'.format(runas), - subcommand, ] else: cmd = [ @@ -476,10 +474,16 @@ And here is the output: 'bin/{}'.format(command), '-c', 'app/quiet.conf', '-P', - '--runas', runas, - subcommand, ] + if runas: + if typ == 'true': + cmd.apend('--runas={}'.format(runas)) + else: + cmd.extend(['--runas', runas]) + + cmd.append(subcommand) + cmd.extend(data['models']) if data['create']: From 60222c4977d6beb89ceb8c650fc4a436f5d29b2a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 7 Dec 2021 19:58:11 -0600 Subject: [PATCH 0529/1681] Assume default receiving workflow if there is only one --- tailbone/views/purchasing/receiving.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index ccc97d9a..1be9df49 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -291,6 +291,8 @@ class ReceivingBatchView(PurchasingBatchView): else: form.set_widget('workflow', forms.widgets.JQuerySelectWidget(values=values)) + if len(workflows) == 1: + form.set_default('workflow', workflows[0]['workflow_key']) form.submit_label = "Continue" form.cancel_url = self.get_index_url() @@ -753,8 +755,8 @@ class ReceivingBatchView(PurchasingBatchView): # flag is set, since that batch type is only concerned with receiving batch = self.get_instance() if batch.is_truck_dump_parent() and not batch.truck_dump_children_first: - g.hide_column('cases_ordered') - g.hide_column('units_ordered') + g.remove('cases_ordered', + 'units_ordered') # add "Transform to Unit" action, if appropriate if batch.is_truck_dump_parent(): @@ -771,7 +773,7 @@ class ReceivingBatchView(PurchasingBatchView): # truck_dump_status if not batch.is_truck_dump_parent(): - g.hide_column('truck_dump_status') + g.remove('truck_dump_status') else: g.set_enum('truck_dump_status', model.PurchaseBatchRow.STATUS) From 6f60387f30bf60c17ce5e1fea34df52ac20f5d2b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 8 Dec 2021 12:21:23 -0600 Subject: [PATCH 0530/1681] Fix bug when report has no params dict --- tailbone/views/reports.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index 4c8fe6e9..6359c471 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -281,7 +281,7 @@ class ReportOutputView(ExportMasterView): report = kwargs['instance'] params_data = [] - for name, value in report.params.items(): + for name, value in (report.params or {}).items(): params_data.append({ 'key': name, 'value': value, From ae76ceea04e69c08298b6f719e7025ab6147251d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 8 Dec 2021 15:54:23 -0600 Subject: [PATCH 0531/1681] 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 defb6035..cfdaa909 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,18 @@ CHANGELOG ========= +0.8.183 (2021-12-08) +-------------------- + +* Add basic views to expose Problem Reports, and run them. + +* Only include ``--runas`` arg if we have a value, for import jobs. + +* Assume default receiving workflow if there is only one. + +* Fix bug when report has no params dict. + + 0.8.182 (2021-12-07) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index c5a1b4cb..fc96b463 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.182' +__version__ = '0.8.183' From 10e34b83ed4efacfc3d8a93f663dcccaa4ad2433 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 8 Dec 2021 19:44:50 -0600 Subject: [PATCH 0532/1681] Refactor "receive row" and "declare credit" tools per buefy theme --- tailbone/api/batch/receiving.py | 6 +- tailbone/forms/core.py | 24 ++++- tailbone/templates/deform/cases_units.pt | 71 ++++++++++----- tailbone/templates/forms/deform_buefy.mako | 54 +++++------ tailbone/templates/forms/util.mako | 24 +++++ .../templates/receiving/declare_credit.mako | 60 ++++++++++--- tailbone/templates/receiving/receive_row.mako | 57 +++++++++--- tailbone/templates/receiving/view_row.mako | 11 +++ tailbone/views/purchasing/batch.py | 5 ++ tailbone/views/purchasing/receiving.py | 90 +++++++++++++++++-- 10 files changed, 313 insertions(+), 89 deletions(-) create mode 100644 tailbone/templates/forms/util.mako diff --git a/tailbone/api/batch/receiving.py b/tailbone/api/batch/receiving.py index 37bb00b5..905a0872 100644 --- a/tailbone/api/batch/receiving.py +++ b/tailbone/api/batch/receiving.py @@ -118,11 +118,7 @@ class ReceivingBatchViews(APIBatchView): return {'purchases': purchases} def normalize_eligible_purchase(self, purchase): - return { - 'key': purchase.uuid, - 'department_uuid': purchase.department_uuid, - 'display': self.render_eligible_purchase(purchase), - } + return self.handler.normalize_eligible_purchase(purchase) def render_eligible_purchase(self, purchase): return self.handler.render_eligible_purchase(purchase) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 6b463f55..949222bc 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -343,8 +343,9 @@ class Form(object): model_instance=None, model_class=None, appstruct=UNSPECIFIED, nodes={}, enums={}, labels={}, assume_local_times=False, renderers=None, hidden={}, widgets={}, defaults={}, validators={}, required={}, helptext={}, focus_spec=None, - action_url=None, cancel_url=None, use_buefy=None, component='tailbone-form'): - + action_url=None, cancel_url=None, use_buefy=None, component='tailbone-form', + vuejs_field_converters={}, + ): self.fields = None if fields is not None: self.set_fields(fields) @@ -380,6 +381,7 @@ class Form(object): self.cancel_url = cancel_url self.use_buefy = use_buefy self.component = component + self.vuejs_field_converters = vuejs_field_converters or {} @property def component_studly(self): @@ -702,6 +704,9 @@ class Form(object): """ return self.helptext[key] + def set_vuejs_field_converter(self, field, converter): + self.vuejs_field_converters[field] = converter + def render(self, template=None, **kwargs): if not template: if self.readonly and not self.use_buefy: @@ -788,6 +793,11 @@ class Form(object): model value for the given field. This JS will be written as part of the overall response, to be interpreted on the client side. """ + if field.name in self.vuejs_field_converters: + convert = self.vuejs_field_converters[field.name] + value = convert(field.cstruct) + return json.dumps(value) + if isinstance(field.schema.typ, deform.FileData): # TODO: we used to always/only return 'null' here but hopefully # this also works, to show existing filename when present @@ -807,6 +817,16 @@ class Form(object): except Exception as error: raise TailboneJSONFieldError(field.name, error) + def get_error_messages(self, field): + if field.error: + return field.error.messages() + + error = self.make_deform_form().error + if error: + if isinstance(error, colander.Invalid): + if error.node.name == field.name: + return error.messages() + def messages_json(self, messages): dump = json.dumps(messages) dump = dump.replace("'", ''') diff --git a/tailbone/templates/deform/cases_units.pt b/tailbone/templates/deform/cases_units.pt index 05e06d50..db4a49e0 100644 --- a/tailbone/templates/deform/cases_units.pt +++ b/tailbone/templates/deform/cases_units.pt @@ -1,29 +1,58 @@ <!--! -*- mode: html; -*- --> <div tal:define="oid oid|field.oid; + name name|field.name; css_class css_class|field.widget.css_class; - style style|field.widget.style;" + style style|field.widget.style; + use_buefy use_buefy|0;" i18n:domain="deform" tal:omit-tag=""> - ${field.start_mapping()} - <div> - <input type="text" name="cases" value="${cases}" - tal:attributes="style style; - class string: form-control ${css_class or ''}; - cases_attributes|field.widget.cases_attributes|{};" - placeholder="cases" - autocomplete="off" - id="${oid}-cases"/> - Cases + + <div tal:condition="not use_buefy" tal:omit-tag=""> + ${field.start_mapping()} + <div> + <input type="text" name="cases" value="${cases}" + tal:attributes="style style; + class string: form-control ${css_class or ''}; + cases_attributes|field.widget.cases_attributes|{};" + placeholder="cases" + autocomplete="off" + id="${oid}-cases"/> + Cases + </div> + <div> + <input type="text" name="units" value="${units}" + tal:attributes="class string: form-control ${css_class or ''}; + style style; + units_attributes|field.widget.units_attributes|{};" + placeholder="units" + autocomplete="off" + id="${oid}-units"/> + Units + </div> + ${field.end_mapping()} </div> - <div> - <input type="text" name="units" value="${units}" - tal:attributes="class string: form-control ${css_class or ''}; - style style; - units_attributes|field.widget.units_attributes|{};" - placeholder="units" - autocomplete="off" - id="${oid}-units"/> - Units + + <div tal:condition="use_buefy" + tal:define="vmodel vmodel|'field_model_' + name;" + tal:omit-tag=""> + + ${field.start_mapping()} + + <b-field label="Cases"> + <b-input name="cases" autocomplete="off" + tal:attributes="v-model string: ${vmodel + '.cases'}; + cases_attributes|field.widget.cases_attributes|{};"> + </b-input> + </b-field> + + <b-field label="Units"> + <b-input name="units" autocomplete="off" + tal:attributes="v-model string: ${vmodel + '.units'}; + units_attributes|field.widget.units_attributes|{};"> + </b-input> + </b-field> + + ${field.end_mapping()} </div> - ${field.end_mapping()} + </div> diff --git a/tailbone/templates/forms/deform_buefy.mako b/tailbone/templates/forms/deform_buefy.mako index 0f1ae184..17ccf7d1 100644 --- a/tailbone/templates/forms/deform_buefy.mako +++ b/tailbone/templates/forms/deform_buefy.mako @@ -1,4 +1,5 @@ ## -*- coding: utf-8; -*- +<%namespace file="/forms/util.mako" import="render_buefy_field" /> <script type="text/x-template" id="${form.component}-template"> @@ -9,6 +10,9 @@ % endif <section> + % if form_body is not Undefined and form_body: + ${form_body|n} + % else: % for field in form.fields: % if form.readonly or (field not in dform and field in form.readonly_fields): <b-field horizontal @@ -17,29 +21,11 @@ </b-field> % elif field in dform: - <% field = dform[field] %> - - % if form.field_visible(field.name): - <b-field horizontal - label="${form.get_label(field.name)}" - ## TODO: is this class="file" really needed? - % if isinstance(field.schema.typ, deform.FileData): - class="file" - % endif - % if field.error: - type="is-danger" - :message='${form.messages_json(field.error.messages())|n}' - % endif - > - ${field.serialize(use_buefy=True)|n} - </b-field> - % else: - ## hidden field - ${field.serialize()|n} - % endif + ${render_buefy_field(dform[field])} % endif % endfor + % endif </section> % if buttons: @@ -48,6 +34,20 @@ % elif not form.readonly and (buttons is Undefined or (buttons is not None and buttons is not False)): <br /> <div class="buttons"> + % if getattr(form, 'show_cancel', True): + % if form.auto_disable_cancel: + <once-button tag="a" href="${form.cancel_url or request.get_referrer()}" + text="Cancel"> + </once-button> + % else: + <b-button tag="a" href="${form.cancel_url or request.get_referrer()}"> + Cancel + </b-button> + % endif + % endif + % if getattr(form, 'show_reset', False): + <input type="reset" value="Reset" class="button" /> + % endif ## TODO: deprecate / remove the latter option here % if form.auto_disable_save or form.auto_disable: <b-button type="is-primary" @@ -61,20 +61,6 @@ ${getattr(form, 'submit_label', getattr(form, 'save_label', "Submit"))} </b-button> % endif - % if getattr(form, 'show_reset', False): - <input type="reset" value="Reset" class="button" /> - % endif - % if getattr(form, 'show_cancel', True): - % if form.auto_disable_cancel: - <once-button tag="a" href="${form.cancel_url or request.get_referrer()}" - text="Cancel"> - </once-button> - % else: - <b-button tag="a" href="${form.cancel_url or request.get_referrer()}"> - Cancel - </b-button> - % endif - % endif </div> % endif diff --git a/tailbone/templates/forms/util.mako b/tailbone/templates/forms/util.mako new file mode 100644 index 00000000..cc3a5d2d --- /dev/null +++ b/tailbone/templates/forms/util.mako @@ -0,0 +1,24 @@ +## -*- coding: utf-8; -*- + +<%def name="render_buefy_field(field, bfield_kwargs={})"> + % if form.field_visible(field.name): + <% error_messages = form.get_error_messages(field) %> + <b-field horizontal + label="${form.get_label(field.name)}" + ## TODO: is this class="file" really needed? + % if isinstance(field.schema.typ, deform.FileData): + class="file" + % endif + % if error_messages: + type="is-danger" + :message='${form.messages_json(error_messages)|n}' + % endif + ${h.HTML.render_attrs(bfield_kwargs)} + > + ${field.serialize(use_buefy=True)|n} + </b-field> + % else: + ## hidden field + ${field.serialize()|n} + % endif +</%def> diff --git a/tailbone/templates/receiving/declare_credit.mako b/tailbone/templates/receiving/declare_credit.mako index 6596ff1b..84d4dbec 100644 --- a/tailbone/templates/receiving/declare_credit.mako +++ b/tailbone/templates/receiving/declare_credit.mako @@ -1,16 +1,19 @@ ## -*- coding: utf-8; -*- -<%inherit file="/base.mako" /> +<%inherit file="/form.mako" /> +<%namespace file="/forms/util.mako" import="render_buefy_field" /> <%def name="title()">Declare Credit for Row #${row.sequence}</%def> <%def name="context_menu_items()"> - % if master.rows_viewable and request.has_perm('{}.view'.format(permission_prefix)): + ${parent.context_menu_items()} + % if master.rows_viewable and master.has_perm('view'): <li>${h.link_to("View this {}".format(row_model_title), row_action_url('view', row))}</li> % endif </%def> <%def name="extra_javascript()"> ${parent.extra_javascript()} + % if not use_buefy: <script type="text/javascript"> function toggleFields(creditType) { @@ -34,11 +37,49 @@ }); </script> + % endif </%def> -<div style="display: flex; justify-content: space-between;"> +<%def name="render_buefy_form()"> - <div class="form-wrapper"> + <p class="block"> + Please select the "state" of the product, and enter the + appropriate quantity. + </p> + + <p class="block"> + Note that this tool will + <span class="has-text-weight-bold">deduct</span> from the + "received" quantity, and + <span class="has-text-weight-bold">add</span> to the + corresponding credit quantity. + </p> + + <p class="block"> + Please see ${h.link_to("Receive Row", url('{}.receive_row'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid))} + if you need to "receive" instead of "convert" the product. + </p> + + ${parent.render_buefy_form()} + +</%def> + +<%def name="buefy_form_body()"> + + ${render_buefy_field(dform['credit_type'])} + + ${render_buefy_field(dform['quantity'])} + + ${render_buefy_field(dform['expiration_date'], bfield_kwargs={'v-show': "field_model_credit_type == 'expired'"})} + +</%def> + +<%def name="render_form()"> + % if use_buefy: + + ${form.render_deform(buttons=capture(self.render_form_buttons), form_body=capture(self.buefy_form_body))|n} + + % else: <p style="padding: 1em;"> Please select the "state" of the product, and enter the appropriate @@ -55,11 +96,10 @@ if you need to "receive" instead of "convert" the product. </p> - ${form.render()|n} - </div><!-- form-wrapper --> + ${parent.render_form()} - <ul id="context-menu"> - ${self.context_menu_items()} - </ul> + % endif +</%def> -</div> + +${parent.body()} diff --git a/tailbone/templates/receiving/receive_row.mako b/tailbone/templates/receiving/receive_row.mako index 188fbe7b..b17b118a 100644 --- a/tailbone/templates/receiving/receive_row.mako +++ b/tailbone/templates/receiving/receive_row.mako @@ -1,16 +1,19 @@ ## -*- coding: utf-8; -*- -<%inherit file="/base.mako" /> +<%inherit file="/form.mako" /> +<%namespace file="/forms/util.mako" import="render_buefy_field" /> <%def name="title()">Receive for Row #${row.sequence}</%def> <%def name="context_menu_items()"> - % if master.rows_viewable and request.has_perm('{}.view'.format(permission_prefix)): + ${parent.context_menu_items()} + % if master.rows_viewable and master.has_perm('view'): <li>${h.link_to("View this {}".format(row_model_title), row_action_url('view', row))}</li> % endif </%def> <%def name="extra_javascript()"> ${parent.extra_javascript()} + % if not use_buefy: <script type="text/javascript"> function toggleFields(mode) { @@ -34,11 +37,46 @@ }); </script> + % endif </%def> -<div style="display: flex; justify-content: space-between;"> +<%def name="render_buefy_form()"> - <div class="form-wrapper"> + <p class="block"> + Please select the "state" of the product, and enter the appropriate + quantity. + </p> + + <p class="block"> + Note that this tool will <span class="has-text-weight-bold">add</span> + the corresponding quantities for the row. + </p> + + <p class="block"> + Please see ${h.link_to("Declare Credit", url('{}.declare_credit'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid))} + if you need to "convert" some already-received amount, into a credit. + </p> + + ${parent.render_buefy_form()} + +</%def> + +<%def name="buefy_form_body()"> + + ${render_buefy_field(dform['mode'])} + + ${render_buefy_field(dform['quantity'])} + + ${render_buefy_field(dform['expiration_date'], bfield_kwargs={'v-show': "field_model_mode == 'expired'"})} + +</%def> + +<%def name="render_form()"> + % if use_buefy: + + ${form.render_deform(buttons=capture(self.render_form_buttons), form_body=capture(self.buefy_form_body))|n} + + % else: <p style="padding: 1em;"> Please select the "state" of the product, and enter the appropriate @@ -55,11 +93,10 @@ if you need to "convert" some already-received amount, into a credit. </p> - ${form.render()|n} - </div><!-- form-wrapper --> + ${parent.render_form()} - <ul id="context-menu"> - ${self.context_menu_items()} - </ul> + % endif +</%def> -</div> + +${parent.body()} diff --git a/tailbone/templates/receiving/view_row.mako b/tailbone/templates/receiving/view_row.mako index 9ba6a0bb..744f58f3 100644 --- a/tailbone/templates/receiving/view_row.mako +++ b/tailbone/templates/receiving/view_row.mako @@ -8,8 +8,19 @@ <h3>Receiving Tools</h3> <div class="object-helper-content"> <div style="white-space: nowrap;"> + % if use_buefy: + <once-button type="is-primary" + tag="a" href="${url('{}.receive_row'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid)}" + text="Receive Product"> + </once-button> + <once-button type="is-primary" + tag="a" href="${url('{}.declare_credit'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid)}" + text="Declare Credit"> + </once-button> + % else: ${h.link_to("Receive Product", url('{}.receive_row'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid), class_='button autodisable')} ${h.link_to("Declare Credit", url('{}.declare_credit'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid), class_='button autodisable')} + % endif </div> </div> </div> diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index 96fe2128..c75c9fa3 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -637,6 +637,11 @@ class PurchasingBatchView(BatchMasterView): g.set_label('catalog_unit_cost', "Catalog Cost") g.filters['catalog_unit_cost'].label = "Catalog Unit Cost" + # po_unit_cost + g.set_renderer('po_unit_cost', self.render_row_grid_cost) + g.set_label('po_unit_cost', "PO Cost") + g.filters['po_unit_cost'].label = "PO Unit Cost" + # invoice_unit_cost g.set_renderer('invoice_unit_cost', self.render_row_grid_cost) g.set_label('invoice_unit_cost', "Invoice Cost") diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 1be9df49..a3b17c16 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -144,6 +144,7 @@ class ReceivingBatchView(PurchasingBatchView): 'cases_received', 'units_received', 'catalog_unit_cost', + 'po_unit_cost', 'invoice_unit_cost', 'invoice_total_calculated', 'credits', @@ -152,6 +153,7 @@ class ReceivingBatchView(PurchasingBatchView): ] row_form_fields = [ + 'sequence', 'item_entry', 'upc', 'item_id', @@ -487,6 +489,14 @@ class ReceivingBatchView(PurchasingBatchView): if self.creating: f.remove('invoice_total_calculated') + # hide all invoice fields if batch does not have invoice file + if not self.creating and not self.handler.has_invoice_file(batch): + f.remove('invoice_file', + 'invoice_date', + 'invoice_number', + 'invoice_total', + 'invoice_total_calculated') + # receiving_complete if self.creating: f.remove('receiving_complete') @@ -506,8 +516,12 @@ class ReceivingBatchView(PurchasingBatchView): elif workflow == 'from_po': f.remove('truck_dump_batch_uuid', + 'date_ordered', + 'po_number', 'invoice_file', - 'invoice_parser_key') + 'invoice_parser_key', + 'invoice_date', + 'invoice_number') elif workflow == 'from_po_with_invoice': f.remove('truck_dump_batch_uuid') @@ -736,11 +750,25 @@ class ReceivingBatchView(PurchasingBatchView): def configure_row_grid(self, g): super(ReceivingBatchView, self).configure_row_grid(g) + batch = self.get_instance() # vendor_code g.filters['vendor_code'].default_active = True g.filters['vendor_code'].default_verb = 'contains' + # catalog_unit_cost + if (self.handler.has_purchase_order(batch) + or self.handler.has_invoice_file(batch)): + g.remove('catalog_unit_cost') + + # 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): + g.remove('invoice_unit_cost') + # credits # note that sorting by credits involves a subquery with group by clause. # seems likely there may be a better way? but this seems to work fine @@ -753,7 +781,6 @@ class ReceivingBatchView(PurchasingBatchView): # hide 'ordered' columns for truck dump parent, if its "children first" # flag is set, since that batch type is only concerned with receiving - batch = self.get_instance() if batch.is_truck_dump_parent() and not batch.truck_dump_children_first: g.remove('cases_ordered', 'units_ordered') @@ -788,6 +815,11 @@ class ReceivingBatchView(PurchasingBatchView): return css_class + def get_row_instance_title(self, row): + if row.upc: + return row.upc.pretty() + return super(ReceivingBatchView, self).get_row_instance_title(row) + def transform_unit_url(self, row, i): # grid action is shown only when we return a URL here if self.row_editable(row): @@ -795,6 +827,18 @@ class ReceivingBatchView(PurchasingBatchView): if row.product and row.product.is_pack_item(): return self.get_row_action_url('transform_unit', row) + def vuejs_convert_quantity(self, cstruct): + result = dict(cstruct) + if result['cases'] is colander.null: + result['cases'] = None + elif isinstance(result['cases'], decimal.Decimal): + result['cases'] = float(result['cases']) + if result['units'] is colander.null: + result['units'] = None + elif isinstance(result['units'], decimal.Decimal): + result['units'] = float(result['units']) + return result + def receive_row(self, **kwargs): """ Primary desktop view for row-level receiving. @@ -830,14 +874,24 @@ class ReceivingBatchView(PurchasingBatchView): schema = ReceiveRowForm().bind(session=self.Session()) form = forms.Form(schema=schema, request=self.request, use_buefy=use_buefy) form.cancel_url = self.get_row_action_url('view', row) + + # mode mode_values = [(mode, mode) for mode in possible_modes] if use_buefy: - form.set_widget('mode', dfwidget.SelectWidget(values=mode_values)) + mode_widget = dfwidget.SelectWidget(values=mode_values) else: - form.set_widget('mode', forms.widgets.JQuerySelectWidget(values=mode_values)) + mode_widget = forms.widgets.JQuerySelectWidget(values=mode_values) + form.set_widget('mode', mode_widget) + + # quantity form.set_widget('quantity', forms.widgets.CasesUnitsWidget(amount_required=True, one_amount_only=True)) + form.set_vuejs_field_converter('quantity', self.vuejs_convert_quantity) + + # expiration_date form.set_type('expiration_date', 'date_jquery') + + # TODO: what is this one about again? form.remove_field('quick_receive') if form.validate(newstyle=True): @@ -946,6 +1000,7 @@ class ReceivingBatchView(PurchasingBatchView): View for declaring a credit, i.e. converting some "received" or similar quantity, to a credit of some sort. """ + use_buefy = self.get_use_buefy() row = self.get_row_instance() batch = row.batch possible_credit_types = [ @@ -965,11 +1020,23 @@ class ReceivingBatchView(PurchasingBatchView): } schema = DeclareCreditForm() - form = forms.Form(schema=schema, request=self.request) - form.set_widget('credit_type', forms.widgets.JQuerySelectWidget( - values=[(m, m) for m in possible_credit_types])) + form = forms.Form(schema=schema, request=self.request, + use_buefy=use_buefy) + + # credit_type + values = [(m, m) for m in possible_credit_types] + if use_buefy: + widget = dfwidget.SelectWidget(values=values) + else: + widget = forms.widgets.JQuerySelectWidget(values=values) + form.set_widget('credit_type', widget) + + # quantity form.set_widget('quantity', forms.widgets.CasesUnitsWidget( amount_required=True, one_amount_only=True)) + form.set_vuejs_field_converter('quantity', self.vuejs_convert_quantity) + + # expiration_date form.set_type('expiration_date', 'date_jquery') if form.validate(newstyle=True): @@ -1554,6 +1621,15 @@ class ReceiveRowForm(colander.MappingSchema): quick_receive = colander.SchemaNode(colander.Boolean()) + def deserialize(self, *args): + result = super(ReceiveRowForm, self).deserialize(*args) + + if result['mode'] == 'expired' and not result['expiration_date']: + msg = "Expiration date is required for items with 'expired' mode." + self.raise_invalid(msg, node=self.get('expiration_date')) + + return result + class DeclareCreditForm(colander.MappingSchema): From be92075abbd108c5f1c1fd11be20c38ef77b51a6 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 8 Dec 2021 20:26:31 -0600 Subject: [PATCH 0533/1681] Allow "auto-receive all items" batch feature in production but require a dedicated permission --- tailbone/templates/receiving/view.mako | 43 ++++++++++++-------------- tailbone/views/purchasing/receiving.py | 31 ++++++++++++++++--- 2 files changed, 45 insertions(+), 29 deletions(-) diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako index 32c327fe..df42de47 100644 --- a/tailbone/templates/receiving/view.mako +++ b/tailbone/templates/receiving/view.mako @@ -285,31 +285,26 @@ <%def name="object_helpers()"> ${parent.object_helpers()} - ## TODO: for now this is a truck-dump-only feature? maybe should change that - % if not request.rattail_config.production() and master.handler.allow_truck_dump_receiving(): - % if not batch.executed and not batch.complete and request.has_perm('admin'): - % if (batch.is_truck_dump_parent() and batch.truck_dump_children_first) or not batch.is_truck_dump_related(): - <div class="object-helper"> - <h3>Development Tools</h3> - <div class="object-helper-content"> - % if use_buefy: - ${h.form(url('{}.auto_receive'.format(route_prefix), uuid=batch.uuid), ref='auto_receive_all_form')} - ${h.csrf_token(request)} - <once-button type="is-primary" - @click="$refs.auto_receive_all_form.submit()" - text="Auto-Receive All Items"> - </once-button> - ${h.end_form()} - % else: - ${h.form(url('{}.auto_receive'.format(route_prefix), uuid=batch.uuid), class_='autodisable')} - ${h.csrf_token(request)} - ${h.submit('submit', "Auto-Receive All Items")} - ${h.end_form()} - % endif - </div> - </div> + % if master.has_perm('auto_receive') and master.can_auto_receive(batch): + <div class="object-helper"> + <h3>Tools</h3> + <div class="object-helper-content"> + % if use_buefy: + ${h.form(url('{}.auto_receive'.format(route_prefix), uuid=batch.uuid), ref='auto_receive_all_form')} + ${h.csrf_token(request)} + <once-button type="is-primary" + @click="$refs.auto_receive_all_form.submit()" + text="Auto-Receive All Items"> + </once-button> + ${h.end_form()} + % else: + ${h.form(url('{}.auto_receive'.format(route_prefix), uuid=batch.uuid), class_='autodisable')} + ${h.csrf_token(request)} + ${h.submit('submit', "Auto-Receive All Items")} + ${h.end_form()} % endif - % endif + </div> + </div> % endif </%def> diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index a3b17c16..f0fc3e12 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -1469,6 +1469,24 @@ class ReceivingBatchView(PurchasingBatchView): if self.rattail_config.getbool('rattail.batch', 'purchase.mobile_images', default=True): 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 + def auto_receive(self): """ View which can "auto-receive" all items in the batch. Meant only as a @@ -1535,6 +1553,7 @@ class ReceivingBatchView(PurchasingBatchView): url_prefix = cls.get_url_prefix() instance_url_prefix = cls.get_instance_url_prefix() model_key = cls.get_model_key() + model_title = cls.get_model_title() permission_prefix = cls.get_permission_prefix() # new receiving batch using workflow X @@ -1569,11 +1588,13 @@ class ReceivingBatchView(PurchasingBatchView): permission='{}.edit_row'.format(permission_prefix), renderer='json') # auto-receive all items - if not rattail_config.production(): - config.add_route('{}.auto_receive'.format(route_prefix), '{}/auto-receive'.format(instance_url_prefix), - request_method='POST') - config.add_view(cls, attr='auto_receive', route_name='{}.auto_receive'.format(route_prefix), - permission='admin') + config.add_tailbone_permission(permission_prefix, + '{}.auto_receive'.format(permission_prefix), + "Auto-receive all items for a {}".format(model_title)) + config.add_route('{}.auto_receive'.format(route_prefix), '{}/auto-receive'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='auto_receive', route_name='{}.auto_receive'.format(route_prefix), + permission='{}.auto_receive'.format(permission_prefix)) @colander.deferred From e906c01e6477876651f7626081e57eda697dd3a5 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 8 Dec 2021 21:59:41 -0600 Subject: [PATCH 0534/1681] Make "view row" prettier for receiving batch, for buefy themes this seems like a good direction; should make "receive product" and "declare item" use b-modal on same page probably --- tailbone/templates/receiving/view.mako | 61 +++++++++-- tailbone/templates/receiving/view_row.mako | 119 +++++++++++++++++++-- tailbone/views/purchasing/batch.py | 24 +++++ tailbone/views/purchasing/receiving.py | 22 ++++ 4 files changed, 212 insertions(+), 14 deletions(-) diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako index df42de47..0fe3636a 100644 --- a/tailbone/templates/receiving/view.mako +++ b/tailbone/templates/receiving/view.mako @@ -286,17 +286,15 @@ <%def name="object_helpers()"> ${parent.object_helpers()} % if master.has_perm('auto_receive') and master.can_auto_receive(batch): + <div class="object-helper"> <h3>Tools</h3> <div class="object-helper-content"> % if use_buefy: - ${h.form(url('{}.auto_receive'.format(route_prefix), uuid=batch.uuid), ref='auto_receive_all_form')} - ${h.csrf_token(request)} - <once-button type="is-primary" - @click="$refs.auto_receive_all_form.submit()" - text="Auto-Receive All Items"> - </once-button> - ${h.end_form()} + <b-button type="is-primary" + @click="autoReceiveShowDialog = true"> + Auto-Receive All Items + </b-button> % else: ${h.form(url('{}.auto_receive'.format(route_prefix), uuid=batch.uuid), class_='autodisable')} ${h.csrf_token(request)} @@ -305,9 +303,58 @@ % endif </div> </div> + + % if use_buefy: + <b-modal has-modal-card + :active.sync="autoReceiveShowDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Auto-Receive All Items</p> + </header> + + <section class="modal-card-body"> + <p class="block"> + You can automatically mark all items as having been + received normally. + </p> + <p class="block"> + Would you like to do so? + </p> + </section> + + <footer class="modal-card-foot"> + <b-button @click="autoReceiveShowDialog = false"> + Cancel + </b-button> + ${h.form(url('{}.auto_receive'.format(route_prefix), uuid=batch.uuid))} + ${h.csrf_token(request)} + <b-button type="is-primary" + native-type="submit" + :disabled="autoReceiveSubmitting" + @click="autoReceiveSubmitting = true" + icon-pack="fas" + icon-left="arrow-circle-right"> + {{ autoReceiveSubmitting ? "Working, please wait..." : "Auto-Receive All Items" }} + </b-button> + ${h.end_form()} + </footer> + </div> + </b-modal> + % endif % endif </%def> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + ThisPageData.autoReceiveShowDialog = false + ThisPageData.autoReceiveSubmitting = false + + </script> +</%def> + ${parent.body()} diff --git a/tailbone/templates/receiving/view_row.mako b/tailbone/templates/receiving/view_row.mako index 744f58f3..53f426df 100644 --- a/tailbone/templates/receiving/view_row.mako +++ b/tailbone/templates/receiving/view_row.mako @@ -1,14 +1,77 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view_row.mako" /> +<%def name="extra_styles()"> + ${parent.extra_styles()} + <style type="text/css"> + % if use_buefy: + nav.panel { + margin: 0.5rem; + } + % endif + </style> +</%def> + <%def name="object_helpers()"> ${parent.object_helpers()} - % if not batch.executed and not batch.is_truck_dump_child(): + % if not use_buefy and master.row_editable(row) and not batch.is_truck_dump_child(): <div class="object-helper"> <h3>Receiving Tools</h3> <div class="object-helper-content"> <div style="white-space: nowrap;"> - % if use_buefy: + ${h.link_to("Receive Product", url('{}.receive_row'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid), class_='button autodisable')} + ${h.link_to("Declare Credit", url('{}.declare_credit'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid), class_='button autodisable')} + </div> + </div> + </div> + % endif +</%def> + +<%def name="page_content()"> + % if use_buefy: + + <b-field grouped> + ${form.render_field_readonly('sequence')} + ${form.render_field_readonly('status_code')} + </b-field> + + <div style="display: flex;"> + + <nav class="panel"> + <p class="panel-heading">Product</p> + <div class="panel-block"> + <div style="display: flex;"> + <div> + % if not row.product: + ${form.render_field_readonly('item_entry')} + % endif + ${form.render_field_readonly('upc')} + ${form.render_field_readonly('vendor_code')} + ${form.render_field_readonly('product')} + ${form.render_field_readonly('case_quantity')} + ${form.render_field_readonly('catalog_unit_cost')} + </div> + % if image_url: + <div class="is-pulled-right"> + ${h.image(image_url, "Product Image")} + </div> + % endif + </div> + </div> + </nav> + + <nav class="panel"> + <p class="panel-heading">Quantities</p> + <div class="panel-block"> + <div> + ${form.render_field_readonly('ordered')} + ${form.render_field_readonly('shipped')} + ${form.render_field_readonly('received')} + ${form.render_field_readonly('damaged')} + ${form.render_field_readonly('expired')} + ${form.render_field_readonly('mispick')} + + <div class="buttons"> <once-button type="is-primary" tag="a" href="${url('{}.receive_row'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid)}" text="Receive Product"> @@ -17,14 +80,56 @@ tag="a" href="${url('{}.declare_credit'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid)}" text="Declare Credit"> </once-button> - % else: - ${h.link_to("Receive Product", url('{}.receive_row'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid), class_='button autodisable')} - ${h.link_to("Declare Credit", url('{}.declare_credit'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid), class_='button autodisable')} - % endif + </div> + + </div> + </div> + </nav> + + </div> + + <div style="display: flex;"> + + <nav class="panel" > + <p class="panel-heading">Purchase Order</p> + <div class="panel-block"> + <div> + ${form.render_field_readonly('po_line_number')} + ${form.render_field_readonly('po_unit_cost')} + ${form.render_field_readonly('po_total')} + </div> + </div> + </nav> + + <nav class="panel" > + <p class="panel-heading">Invoice</p> + <div class="panel-block"> + <div> + ${form.render_field_readonly('invoice_line_number')} + ${form.render_field_readonly('invoice_unit_cost')} + ${form.render_field_readonly('invoice_cost_confirmed')} + ${form.render_field_readonly('invoice_total')} + ${form.render_field_readonly('invoice_total_calculated')} + </div> + </div> + </nav> + + </div> + + <nav class="panel" > + <p class="panel-heading">Credits</p> + <div class="panel-block"> + <div> + ${form.render_field_readonly('credits')} </div> </div> - </div> + </nav> + + % else: + ## legacy / not buefy + ${parent.page_content()} % endif </%def> + ${parent.body()} diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index c75c9fa3..96e7fda9 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -129,14 +129,19 @@ class PurchasingBatchView(BatchMasterView): 'description', 'size', 'case_quantity', + 'ordered', 'cases_ordered', 'units_ordered', + 'received', 'cases_received', 'units_received', + 'damaged', 'cases_damaged', 'units_damaged', + 'expired', 'cases_expired', 'units_expired', + 'mispick', 'cases_mispick', 'units_mispick', 'po_line_number', @@ -699,6 +704,13 @@ class PurchasingBatchView(BatchMasterView): f.set_readonly('case_quantity') # quantity fields + f.set_renderer('ordered', self.render_row_quantity) + f.set_renderer('shipped', self.render_row_quantity) + f.set_renderer('received', self.render_row_quantity) + f.set_renderer('damaged', self.render_row_quantity) + f.set_renderer('expired', self.render_row_quantity) + f.set_renderer('mispick', self.render_row_quantity) + f.set_type('case_quantity', 'quantity') f.set_type('cases_ordered', 'quantity') f.set_type('units_ordered', 'quantity') @@ -770,6 +782,18 @@ class PurchasingBatchView(BatchMasterView): else: f.remove_field('product') + def render_row_quantity(self, row, field): + app = self.get_rattail_app() + cases = getattr(row, 'cases_{}'.format(field)) + units = getattr(row, 'units_{}'.format(field)) + if cases and units: + return "{} cases + {} units".format(app.render_quantity(cases), + app.render_quantity(units)) + if cases and not units: + return "{} cases".format(app.render_quantity(cases)) + if units and not cases: + return "{} units".format(app.render_quantity(units)) + def render_row_credits(self, row, field): if not row.credits: return "" diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index f0fc3e12..48b5fc00 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -163,18 +163,25 @@ class ReceivingBatchView(PurchasingBatchView): 'description', 'size', 'case_quantity', + 'ordered', 'cases_ordered', 'units_ordered', + 'shipped', 'cases_shipped', 'units_shipped', + 'received', 'cases_received', 'units_received', + 'damaged', 'cases_damaged', 'units_damaged', + 'expired', 'cases_expired', 'units_expired', + 'mispick', 'cases_mispick', 'units_mispick', + 'catalog_unit_cost', 'po_line_number', 'po_unit_cost', 'po_total', @@ -607,6 +614,19 @@ class ReceivingBatchView(PurchasingBatchView): raise NotImplementedError return kwargs + def template_kwargs_view_row(self, **kwargs): + kwargs = super(ReceivingBatchView, self).template_kwargs_view_row(**kwargs) + app = self.get_rattail_app() + handler = app.get_products_handler() + row = kwargs['instance'] + + if row.product: + kwargs['image_url'] = handler.get_image_url(row.product) + elif row.upc: + kwargs['image_url'] = handler.get_image_url(upc=row.upc) + + return kwargs + def department_for_purchase(self, purchase): pass @@ -816,6 +836,8 @@ class ReceivingBatchView(PurchasingBatchView): return css_class def get_row_instance_title(self, row): + if row.product: + return six.text_type(row.product) if row.upc: return row.upc.pretty() return super(ReceivingBatchView, self).get_row_instance_title(row) From 9d02180c92a804f5032c0c1b07889955312a32a4 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 8 Dec 2021 22:31:54 -0600 Subject: [PATCH 0535/1681] Add buttons to edit, confirm cost for receiving batch row view not yet fully implemented --- tailbone/templates/receiving/view_row.mako | 38 +++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/tailbone/templates/receiving/view_row.mako b/tailbone/templates/receiving/view_row.mako index 53f426df..d1c35c5b 100644 --- a/tailbone/templates/receiving/view_row.mako +++ b/tailbone/templates/receiving/view_row.mako @@ -46,8 +46,8 @@ ${form.render_field_readonly('item_entry')} % endif ${form.render_field_readonly('upc')} - ${form.render_field_readonly('vendor_code')} ${form.render_field_readonly('product')} + ${form.render_field_readonly('vendor_code')} ${form.render_field_readonly('case_quantity')} ${form.render_field_readonly('catalog_unit_cost')} </div> @@ -74,10 +74,12 @@ <div class="buttons"> <once-button type="is-primary" tag="a" href="${url('{}.receive_row'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid)}" + icon-left="download" text="Receive Product"> </once-button> <once-button type="is-primary" tag="a" href="${url('{}.declare_credit'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid)}" + icon-left="thumbs-down" text="Declare Credit"> </once-button> </div> @@ -107,7 +109,26 @@ <div> ${form.render_field_readonly('invoice_line_number')} ${form.render_field_readonly('invoice_unit_cost')} + % if master.has_perm('edit_row'): + <div class="is-pulled-right"> + <once-button type="is-primary" + tag="a" href="${master.get_row_action_url('edit', row)}" + ## @click="editUnitCost()" + ## icon-pack="fas" + icon-left="edit" + text="Edit Unit Cost"> + </once-button> + </div> + % endif ${form.render_field_readonly('invoice_cost_confirmed')} + <div class="is-pulled-right"> + <b-button type="is-primary" + @click="confirmUnitCost()" + icon-pack="fas" + icon-left="check"> + Confirm Unit Cost + </b-button> + </div> ${form.render_field_readonly('invoice_total')} ${form.render_field_readonly('invoice_total_calculated')} </div> @@ -131,5 +152,20 @@ % endif </%def> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + +## ThisPage.methods.editUnitCost = function() { +## alert("TODO: not yet implemented") +## } + + ThisPage.methods.confirmUnitCost = function() { + alert("TODO: not yet implemented") + } + + </script> +</%def> + ${parent.body()} From f549858c5d060bfc61e3e58a9c2f97799a46ca00 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 9 Dec 2021 12:13:59 -0600 Subject: [PATCH 0536/1681] 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 cfdaa909..185d0763 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,18 @@ CHANGELOG ========= +0.8.184 (2021-12-09) +-------------------- + +* Refactor "receive row" and "declare credit" tools per buefy theme. + +* Allow "auto-receive all items" batch feature in production. + +* Make "view row" prettier for receiving batch, for buefy themes. + +* Add buttons to edit, confirm cost for receiving batch row view. + + 0.8.183 (2021-12-08) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index fc96b463..3066e92b 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.183' +__version__ = '0.8.184' From a2032a7be22f311ac936e24e2910f993c322d7b7 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 10 Dec 2021 16:33:53 -0600 Subject: [PATCH 0537/1681] Allow for null price when showing price history --- tailbone/views/products.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 40414cf8..97f0b631 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -1093,6 +1093,7 @@ class ProductView(MasterView): """ AJAX view for fetching various types of price history for a product. """ + app = self.get_rattail_app() product = self.get_instance() typ = self.request.params.get('type', 'regular') @@ -1106,8 +1107,9 @@ class ProductView(MasterView): for history in data: history = dict(history) price = history['price'] - history['price'] = float(price) - history['price_display'] = "${:0.2f}".format(price) + if price is not None: + history['price'] = float(price) + history['price_display'] = app.render_currency(price) changed = localtime(self.rattail_config, history['changed'], from_utc=True) history['changed'] = six.text_type(changed) history['changed_display_html'] = raw_datetime(self.rattail_config, changed) From 2f676774e9982c3dee8671cf9d6210332e71feb5 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 11 Dec 2021 15:40:46 -0600 Subject: [PATCH 0538/1681] Bugfix --- tailbone/views/importing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/importing.py b/tailbone/views/importing.py index ecb2ea02..b63e4d43 100644 --- a/tailbone/views/importing.py +++ b/tailbone/views/importing.py @@ -478,7 +478,7 @@ And here is the output: if runas: if typ == 'true': - cmd.apend('--runas={}'.format(runas)) + cmd.append('--runas={}'.format(runas)) else: cmd.extend(['--runas', runas]) From 340a177a29bc3d359b975a7362a749aaf687f13e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 13 Dec 2021 17:53:14 -0600 Subject: [PATCH 0539/1681] Overhaul desktop views for receiving, for efficiency still could use even more i'm sure, but this takes advantage of buefy to add dialogs etc. from the "view receiving batch row" page. this batch no longer allows direct edit of rows but that's hopefully for the better. --- tailbone/forms/core.py | 5 +- tailbone/templates/appsettings.mako | 6 +- tailbone/templates/formposter.mako | 12 +- tailbone/templates/grids/buefy.mako | 2 +- tailbone/templates/master/edit_row.mako | 4 +- tailbone/templates/master/view_row.mako | 2 +- tailbone/templates/page.mako | 1 + tailbone/templates/receiving/view.mako | 24 +- tailbone/templates/receiving/view_row.mako | 652 +++++++++++++++++--- tailbone/templates/themes/falafel/base.mako | 3 +- tailbone/views/batch/core.py | 25 +- tailbone/views/master.py | 12 +- tailbone/views/purchasing/batch.py | 83 ++- tailbone/views/purchasing/receiving.py | 340 ++++++++-- 14 files changed, 1014 insertions(+), 157 deletions(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 949222bc..f194e53e 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -848,7 +848,10 @@ class Form(object): return '' # TODO: fair bit of duplication here, should merge with deform.mako - label = HTML.tag('label', self.get_label(field_name), for_=field_name) + label = kwargs.get('label') + if not label: + label = self.get_label(field_name) + label = HTML.tag('label', label, for_=field_name) field = self.render_field_value(field_name) or '' field_div = HTML.tag('div', class_='field', c=[field]) contents = [label, field_div] diff --git a/tailbone/templates/appsettings.mako b/tailbone/templates/appsettings.mako index 79b2d952..dbe747bf 100644 --- a/tailbone/templates/appsettings.mako +++ b/tailbone/templates/appsettings.mako @@ -145,14 +145,14 @@ </div><!-- card --> <div class="buttons"> + <once-button tag="a" href="${form.cancel_url}" + text="Cancel"> + </once-button> <b-button type="is-primary" native-type="submit" :disabled="formSubmitting"> {{ formButtonText }} </b-button> - <once-button tag="a" href="${form.cancel_url}" - text="Cancel"> - </once-button> </div> </div><!-- app-wrapper --> diff --git a/tailbone/templates/formposter.mako b/tailbone/templates/formposter.mako index 47c6ffd3..6fc6eadc 100644 --- a/tailbone/templates/formposter.mako +++ b/tailbone/templates/formposter.mako @@ -6,7 +6,7 @@ let FormPosterMixin = { methods: { - submitForm(action, params, success) { + submitForm(action, params, success, failure) { let csrftoken = ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n} @@ -21,18 +21,24 @@ } else { this.$buefy.toast.open({ - message: "Failed to send feedback: " + response.data.error, + message: "Submit failed: " + response.data.error, type: 'is-danger', duration: 4000, // 4 seconds }) + if (failure) { + failure(response) + } } }, response => { this.$buefy.toast.open({ - message: "Failed to submit form! (unknown server error)", + message: "Submit failed! (unknown server error)", type: 'is-danger', duration: 4000, // 4 seconds }) + if (failure) { + failure(response) + } }) }, }, diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako index 08cb2969..70ce04f3 100644 --- a/tailbone/templates/grids/buefy.mako +++ b/tailbone/templates/grids/buefy.mako @@ -203,7 +203,7 @@ % for action in grid.main_actions + grid.more_actions: <a v-if="props.row._action_url_${action.key}" :href="props.row._action_url_${action.key}" - class="grid-action${' has-text-danger' if action.key == 'delete' else ''}" + class="grid-action${' has-text-danger' if action.key == 'delete' else ''} ${action.link_class or ''}" % if action.click_handler: @click.prevent="${action.click_handler}" % endif diff --git a/tailbone/templates/master/edit_row.mako b/tailbone/templates/master/edit_row.mako index dab77592..4d6a9573 100644 --- a/tailbone/templates/master/edit_row.mako +++ b/tailbone/templates/master/edit_row.mako @@ -1,8 +1,8 @@ -## -*- coding: utf-8 -*- +## -*- coding: utf-8; -*- <%inherit file="/master/edit.mako" /> <%def name="context_menu_items()"> - <li>${h.link_to("Back to {}".format(model_title), index_url)}</li> + <li>${h.link_to("Back to {}".format(parent_model_title), parent_url)}</li> % if master.rows_viewable and request.has_perm('{}.view'.format(row_permission_prefix)): <li>${h.link_to("View this {}".format(row_model_title), row_action_url('view', instance))}</li> % endif diff --git a/tailbone/templates/master/view_row.mako b/tailbone/templates/master/view_row.mako index 66756c3e..29a77497 100644 --- a/tailbone/templates/master/view_row.mako +++ b/tailbone/templates/master/view_row.mako @@ -12,7 +12,7 @@ % if master.rows_editable and instance_editable and request.has_perm('{}.edit'.format(permission_prefix)): <li>${h.link_to("Edit this {}".format(model_title), action_url('edit', instance))}</li> % endif - % if master.rows_deletable and instance_deletable and request.has_perm('{}.delete'.format(permission_prefix)): + % if instance_deletable and master.has_perm('delete_row'): <li>${h.link_to("Delete this {}".format(model_title), action_url('delete', instance))}</li> % endif % if rows_creatable and request.has_perm('{}.create'.format(permission_prefix)): diff --git a/tailbone/templates/page.mako b/tailbone/templates/page.mako index 2d8227d4..321e60d7 100644 --- a/tailbone/templates/page.mako +++ b/tailbone/templates/page.mako @@ -32,6 +32,7 @@ let ThisPage = { template: '#this-page-template', + mixins: [FormPosterMixin], computed: {}, methods: {}, } diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako index 0fe3636a..01b93724 100644 --- a/tailbone/templates/receiving/view.mako +++ b/tailbone/templates/receiving/view.mako @@ -284,7 +284,19 @@ </%def> <%def name="object_helpers()"> - ${parent.object_helpers()} + ${self.render_status_breakdown()} + + % if use_buefy and master.handler.has_purchase_order(batch) and master.handler.has_invoice_file(batch): + <div class="object-helper"> + <h3>PO vs. Invoice</h3> + <div class="object-helper-content"> + ${po_vs_invoice_breakdown_grid.render_buefy_table_element(data_prop='poVsInvoiceBreakdownData', empty_labels=True)|n} + </div> + </div> + % endif + + ${self.render_execute_helper()} + % if master.has_perm('auto_receive') and master.can_auto_receive(batch): <div class="object-helper"> @@ -292,7 +304,9 @@ <div class="object-helper-content"> % if use_buefy: <b-button type="is-primary" - @click="autoReceiveShowDialog = true"> + @click="autoReceiveShowDialog = true" + icon-pack="fas" + icon-left="check"> Auto-Receive All Items </b-button> % else: @@ -334,7 +348,7 @@ :disabled="autoReceiveSubmitting" @click="autoReceiveSubmitting = true" icon-pack="fas" - icon-left="arrow-circle-right"> + icon-left="check"> {{ autoReceiveSubmitting ? "Working, please wait..." : "Auto-Receive All Items" }} </b-button> ${h.end_form()} @@ -352,6 +366,10 @@ ThisPageData.autoReceiveShowDialog = false ThisPageData.autoReceiveSubmitting = false + % if po_vs_invoice_breakdown_grid is not Undefined: + ThisPageData.poVsInvoiceBreakdownData = ${json.dumps(po_vs_invoice_breakdown_grid.get_buefy_data()['data'])|n} + % endif + </script> </%def> diff --git a/tailbone/templates/receiving/view_row.mako b/tailbone/templates/receiving/view_row.mako index d1c35c5b..bee71475 100644 --- a/tailbone/templates/receiving/view_row.mako +++ b/tailbone/templates/receiving/view_row.mako @@ -5,9 +5,37 @@ ${parent.extra_styles()} <style type="text/css"> % if use_buefy: + nav.panel { margin: 0.5rem; } + + .header-fields { + margin-top: 1rem; + } + + .header-fields .field.is-horizontal { + margin-left: 3rem; + } + + .header-fields .field.is-horizontal .field-label .label { + white-space: nowrap; + } + + .quantity-form-fields { + margin: 2rem auto; + padding-left: 2rem; + } + + .quantity-form-fields .field.is-horizontal .field-label .label { + text-align: left; + width: 8rem; + } + + .remove-credit .field.is-horizontal .field-label .label { + white-space: nowrap; + } + % endif </style> </%def> @@ -30,9 +58,20 @@ <%def name="page_content()"> % if use_buefy: - <b-field grouped> - ${form.render_field_readonly('sequence')} - ${form.render_field_readonly('status_code')} + <b-field grouped class="header-fields"> + + <b-field label="Sequence" horizontal> + {{ rowData.sequence }} + </b-field> + + <b-field label="Status" horizontal> + {{ rowData.status }} + </b-field> + + <b-field label="Calculated Total" horizontal> + {{ rowData.invoice_total_calculated }} + </b-field> + </b-field> <div style="display: flex;"> @@ -42,18 +81,23 @@ <div class="panel-block"> <div style="display: flex;"> <div> - % if not row.product: + % if row.product: + ${form.render_field_readonly('upc')} + ${form.render_field_readonly('product')} + % else: ${form.render_field_readonly('item_entry')} + ${form.render_field_readonly('upc')} + ${form.render_field_readonly('brand_name')} + ${form.render_field_readonly('description')} + ${form.render_field_readonly('size')} % endif - ${form.render_field_readonly('upc')} - ${form.render_field_readonly('product')} ${form.render_field_readonly('vendor_code')} ${form.render_field_readonly('case_quantity')} ${form.render_field_readonly('catalog_unit_cost')} </div> % if image_url: <div class="is-pulled-right"> - ${h.image(image_url, "Product Image")} + ${h.image(image_url, "Product Image", width=150, height=150)} </div> % endif </div> @@ -64,88 +108,351 @@ <p class="panel-heading">Quantities</p> <div class="panel-block"> <div> - ${form.render_field_readonly('ordered')} - ${form.render_field_readonly('shipped')} - ${form.render_field_readonly('received')} - ${form.render_field_readonly('damaged')} - ${form.render_field_readonly('expired')} - ${form.render_field_readonly('mispick')} + <div class="quantity-form-fields"> + + <b-field label="Ordered" horizontal> + {{ rowData.ordered }} + </b-field> + + <hr /> + + <b-field label="Shipped" horizontal> + {{ rowData.shipped }} + </b-field> + + <hr /> + + <b-field label="Received" horizontal + v-if="rowData.received"> + {{ rowData.received }} + </b-field> + + <b-field label="Damaged" horizontal + v-if="rowData.damaged"> + {{ rowData.damaged }} + </b-field> + + <b-field label="Expired" horizontal + v-if="rowData.expired"> + {{ rowData.expired }} + </b-field> + + <b-field label="Mispick" horizontal + v-if="rowData.mispick"> + {{ rowData.mispick }} + </b-field> + + <b-field label="Missing" horizontal + v-if="rowData.missing"> + {{ rowData.missing }} + </b-field> - <div class="buttons"> - <once-button type="is-primary" - tag="a" href="${url('{}.receive_row'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid)}" - icon-left="download" - text="Receive Product"> - </once-button> - <once-button type="is-primary" - tag="a" href="${url('{}.declare_credit'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid)}" - icon-left="thumbs-down" - text="Declare Credit"> - </once-button> </div> - </div> - </div> - </nav> - - </div> - - <div style="display: flex;"> - - <nav class="panel" > - <p class="panel-heading">Purchase Order</p> - <div class="panel-block"> - <div> - ${form.render_field_readonly('po_line_number')} - ${form.render_field_readonly('po_unit_cost')} - ${form.render_field_readonly('po_total')} - </div> - </div> - </nav> - - <nav class="panel" > - <p class="panel-heading">Invoice</p> - <div class="panel-block"> - <div> - ${form.render_field_readonly('invoice_line_number')} - ${form.render_field_readonly('invoice_unit_cost')} - % if master.has_perm('edit_row'): - <div class="is-pulled-right"> - <once-button type="is-primary" - tag="a" href="${master.get_row_action_url('edit', row)}" - ## @click="editUnitCost()" - ## icon-pack="fas" - icon-left="edit" - text="Edit Unit Cost"> - </once-button> + % if master.has_perm('edit_row') and master.row_editable(row): + <div class="buttons"> + <b-button type="is-primary" + @click="accountForProductInit()" + icon-pack="fas" + icon-left="check"> + Account for Product + </b-button> + <b-button type="is-warning" + @click="declareCreditInit()" + :disabled="!rowData.received" + icon-pack="fas" + icon-left="thumbs-down"> + Declare Credit + </b-button> </div> % endif - ${form.render_field_readonly('invoice_cost_confirmed')} - <div class="is-pulled-right"> - <b-button type="is-primary" - @click="confirmUnitCost()" - icon-pack="fas" - icon-left="check"> - Confirm Unit Cost - </b-button> - </div> - ${form.render_field_readonly('invoice_total')} - ${form.render_field_readonly('invoice_total_calculated')} + </div> </div> </nav> </div> + <b-modal has-modal-card + :active.sync="accountForProductShowDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Account for Product</p> + </header> + + <section class="modal-card-body"> + + <p class="block"> + This is for declaring that you have encountered some + amount of the product. Ideally you will just + "receive" it normally, but you can indicate a "credit" + state if there is something amiss. + </p> + + <b-field grouped> + + <b-field label="Case Qty."> + <span class="control"> + {{ rowData.case_quantity }} + </span> + </b-field> + + <span class="control"> + + </span> + + <b-field label="Product State" + :type="accountForProductMode ? null : 'is-danger'"> + <b-select v-model="accountForProductMode"> + <option v-for="mode in possibleReceivingModes" + :key="mode" + :value="mode"> + {{ mode }} + </option> + </b-select> + </b-field> + + <b-field label="Expiration Date" + v-show="accountForProductMode == 'expired'" + :type="accountForProductExpiration ? null : 'is-danger'"> + <tailbone-datepicker v-model="accountForProductExpiration"> + </tailbone-datepicker> + </b-field> + + </b-field> + + <div class="level"> + <div class="level-left"> + + <div class="level-item"> + <b-input v-model="accountForProductQuantity" + type="number" step="0.0001" + ref="accountForProductQuantityInput"> + </b-input> + </div> + + <div class="level-item"> + <b-field> + <b-radio-button v-model="accountForProductUOM" + @click.native="accountForProductUOMClicked('units')" + native-value="units"> + Units + </b-radio-button> + <b-radio-button v-model="accountForProductUOM" + @click.native="accountForProductUOMClicked('cases')" + native-value="cases"> + Cases + </b-radio-button> + </b-field> + </div> + + <div class="level-item" + v-if="accountForProductUOM == 'cases' && accountForProductQuantity"> + = {{ accountForProductTotalUnits }} + </div> + + </div> + </div> + + </section> + + <footer class="modal-card-foot"> + <b-button @click="accountForProductShowDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + @click="accountForProductSubmit()" + :disabled="accountForProductSubmitDisabled" + icon-pack="fas" + icon-left="check"> + {{ accountForProductSubmitting ? "Working, please wait..." : "Account for Product" }} + </b-button> + </footer> + </div> + </b-modal> + + <b-modal has-modal-card + :active.sync="declareCreditShowDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Declare Credit</p> + </header> + + <section class="modal-card-body"> + + <p class="block"> + This is for <span class="is-italic">converting</span> + some amount you <span class="is-italic">already + received</span>, and now declaring there is something + wrong with it. + </p> + + <b-field grouped> + + <b-field label="Received"> + <span class="control"> + {{ rowData.received }} + </span> + </b-field> + + <span class="control"> + + </span> + + <b-field label="Credit Type" + :type="declareCreditType ? null : 'is-danger'"> + <b-select v-model="declareCreditType"> + <option v-for="typ in possibleCreditTypes" + :key="typ" + :value="typ"> + {{ typ }} + </option> + </b-select> + </b-field> + + <b-field label="Expiration Date" + v-show="declareCreditType == 'expired'" + :type="declareCreditExpiration ? null : 'is-danger'"> + <tailbone-datepicker v-model="declareCreditExpiration"> + </tailbone-datepicker> + </b-field> + + </b-field> + + <div class="level"> + <div class="level-left"> + + <div class="level-item"> + <b-input v-model="declareCreditQuantity" + type="number" step="0.0001" + ref="declareCreditQuantityInput"> + </b-input> + </div> + + <div class="level-item"> + <b-field> + <b-radio-button v-model="declareCreditUOM" + @click.native="declareCreditUOMClicked('units')" + native-value="units"> + Units + </b-radio-button> + <b-radio-button v-model="declareCreditUOM" + @click.native="declareCreditUOMClicked('cases')" + native-value="cases"> + Cases + </b-radio-button> + </b-field> + </div> + + <div class="level-item" + v-if="declareCreditUOM == 'cases' && declareCreditQuantity"> + = {{ declareCreditTotalUnits }} + </div> + + </div> + </div> + + </section> + + <footer class="modal-card-foot"> + <b-button @click="declareCreditShowDialog = false"> + Cancel + </b-button> + <b-button type="is-warning" + @click="declareCreditSubmit()" + :disabled="declareCreditSubmitDisabled" + icon-pack="fas" + icon-left="thumbs-down"> + {{ declareCreditSubmitting ? "Working, please wait..." : "Declare this Credit" }} + </b-button> + </footer> + </div> + </b-modal> + <nav class="panel" > <p class="panel-heading">Credits</p> <div class="panel-block"> <div> - ${form.render_field_readonly('credits')} + ${form.render_field_value('credits')} </div> </div> </nav> + <b-modal has-modal-card + :active.sync="removeCreditShowDialog"> + <div class="modal-card remove-credit"> + + <header class="modal-card-head"> + <p class="modal-card-title">Un-Declare Credit</p> + </header> + + <section class="modal-card-body"> + + <p class="block"> + If you un-declare this credit, the quantity below will + be added back to the + <span class="has-text-weight-bold">Received</span> tally. + </p> + + <b-field label="Credit Type" horizontal> + {{ removeCreditRow.credit_type }} + </b-field> + + <b-field label="Quantity" horizontal> + {{ removeCreditRow.shorted }} + </b-field> + + </section> + + <footer class="modal-card-foot"> + <b-button @click="removeCreditShowDialog = false"> + Cancel + </b-button> + <b-button type="is-danger" + @click="removeCreditSubmit()" + :disabled="removeCreditSubmitting" + icon-pack="fas" + icon-left="trash"> + {{ removeCreditSubmitting ? "Working, please wait..." : "Un-Declare this Credit" }} + </b-button> + </footer> + </div> + </b-modal> + + <div style="display: flex;"> + + % if master.batch_handler.has_purchase_order(batch): + <nav class="panel" > + <p class="panel-heading">Purchase Order</p> + <div class="panel-block"> + <div> + ${form.render_field_readonly('po_line_number')} + ${form.render_field_readonly('po_unit_cost')} + ${form.render_field_readonly('po_case_size')} + ${form.render_field_readonly('po_total')} + </div> + </div> + </nav> + % endif + + % if master.batch_handler.has_invoice_file(batch): + <nav class="panel" > + <p class="panel-heading">Invoice</p> + <div class="panel-block"> + <div> + ${form.render_field_readonly('invoice_line_number')} + ${form.render_field_readonly('invoice_unit_cost')} + ${form.render_field_readonly('invoice_case_size')} + ${form.render_field_readonly('invoice_total', label="Invoice Total")} + </div> + </div> + </nav> + % endif + + </div> + % else: ## legacy / not buefy ${parent.page_content()} @@ -164,6 +471,211 @@ alert("TODO: not yet implemented") } + ThisPageData.rowData = ${json.dumps(row_context)|n} + ThisPageData.possibleReceivingModes = ${json.dumps(possible_receiving_modes)|n} + ThisPageData.possibleCreditTypes = ${json.dumps(possible_credit_types)|n} + + ThisPageData.accountForProductShowDialog = false + ThisPageData.accountForProductMode = null + ThisPageData.accountForProductQuantity = null + ThisPageData.accountForProductUOM = 'units' + ThisPageData.accountForProductExpiration = null + ThisPageData.accountForProductSubmitting = false + + ThisPage.computed.accountForProductTotalUnits = function() { + return this.renderQuantity(this.accountForProductQuantity, + this.accountForProductUOM) + } + + ThisPage.computed.accountForProductSubmitDisabled = function() { + if (!this.accountForProductMode) { + return true + } + if (this.accountForProductMode == 'expired' && !this.accountForProductExpiration) { + return true + } + if (!this.accountForProductQuantity) { + return true + } + if (this.accountForProductSubmitting) { + return true + } + return false + } + + ThisPage.methods.accountForProductInit = function() { + this.accountForProductMode = 'received' + this.accountForProductExpiration = null + this.accountForProductQuantity = null + this.accountForProductUOM = 'units' + this.accountForProductShowDialog = true + } + + ThisPage.methods.accountForProductUOMClicked = function(uom) { + + // TODO: this does not seem to work as expected..even though + // the code appears to be correct + this.$nextTick(() => { + this.$refs.accountForProductQuantityInput.focus() + }) + } + + ThisPage.methods.accountForProductSubmit = function() { + + let qty = parseFloat(this.accountForProductQuantity) + if (qty == NaN || !qty) { + this.$buefy.toast.open({ + message: "You must enter a quantity.", + type: 'is-warning', + duration: 4000, // 4 seconds + }) + return + } + + if (this.accountForProductMode != 'received' && qty < 0) { + this.$buefy.toast.open({ + message: "Negative amounts are only allowed for the \"received\" state.", + type: 'is-warning', + duration: 4000, // 4 seconds + }) + return + } + + this.accountForProductSubmitting = true + let url = '${url('{}.receive_row'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid)}' + let params = { + mode: this.accountForProductMode, + quantity: {cases: null, units: null}, + expiration_date: this.accountForProductExpiration, + } + + if (this.accountForProductUOM == 'cases') { + params.quantity.cases = this.accountForProductQuantity + } else { + params.quantity.units = this.accountForProductQuantity + } + + this.submitForm(url, params, response => { + this.rowData = response.data.row + this.accountForProductSubmitting = false + this.accountForProductShowDialog = false + }, response => { + this.accountForProductSubmitting = false + }) + } + + ThisPageData.declareCreditShowDialog = false + ThisPageData.declareCreditType = null + ThisPageData.declareCreditExpiration = null + ThisPageData.declareCreditQuantity = null + ThisPageData.declareCreditUOM = 'units' + ThisPageData.declareCreditSubmitting = false + + ThisPage.methods.renderQuantity = function(qty, uom) { + qty = parseFloat(qty) + if (qty == NaN) { + return "n/a" + } + if (uom == 'cases') { + qty *= this.rowData.case_quantity + } + if (qty == NaN) { + return "n/a" + } + if (qty == 1) { + return "1 unit" + } + if (qty == -1) { + return "-1 unit" + } + if (Math.round(qty) == qty) { + return qty.toString() + " units" + } + return qty.toFixed(4) + " units" + } + + ThisPage.computed.declareCreditTotalUnits = function() { + return this.renderQuantity(this.declareCreditQuantity, + this.declareCreditUOM) + } + + ThisPage.computed.declareCreditSubmitDisabled = function() { + if (!this.declareCreditType) { + return true + } + if (this.declareCreditType == 'expired' && !this.declareCreditExpiration) { + return true + } + if (!this.declareCreditQuantity) { + return true + } + if (this.declareCreditSubmitting) { + return true + } + return false + } + + 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 { + this.declareCreditQuantity = this.rowData.units_received + this.declareCreditUOM = 'units' + } + this.declareCreditShowDialog = true + } + + ThisPage.methods.declareCreditSubmit = function() { + this.declareCreditSubmitting = true + let url = '${url('{}.declare_credit'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid)}' + let params = { + credit_type: this.declareCreditType, + cases: null, + units: null, + expiration_date: this.declareCreditExpiration, + } + + if (this.declareCreditUOM == 'cases') { + params.cases = this.declareCreditQuantity + } else { + params.units = this.declareCreditQuantity + } + + this.submitForm(url, params, response => { + this.rowData = response.data.row + this.declareCreditSubmitting = false + this.declareCreditShowDialog = false + }, response => { + this.declareCreditSubmitting = false + }) + } + + ThisPageData.removeCreditShowDialog = false + ThisPageData.removeCreditRow = {} + ThisPageData.removeCreditSubmitting = false + + ThisPage.methods.removeCreditInit = function(row) { + this.removeCreditRow = row + this.removeCreditShowDialog = true + } + + ThisPage.methods.removeCreditSubmit = function() { + this.removeCreditSubmitting = true + let url = '${url('{}.undeclare_credit'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid)}' + let params = { + uuid: this.removeCreditRow.uuid, + } + + this.submitForm(url, params, response => { + this.rowData = response.data.row + this.removeCreditSubmitting = false + this.removeCreditShowDialog = false + }) + } + </script> </%def> diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index bf8f5ee7..2c2dd2ce 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -31,6 +31,8 @@ </head> <body> + ${declare_formposter_mixin()} + ${self.body()} <div id="whole-page-app"> @@ -517,7 +519,6 @@ </%def> <%def name="declare_whole_page_vars()"> - ${declare_formposter_mixin()} ${h.javascript_link(request.static_url('tailbone:static/themes/falafel/js/tailbone.feedback.js') + '?ver={}'.format(tailbone.__version__))} <script type="text/javascript"> diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index c0a3a1a3..83412761 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -115,7 +115,9 @@ class BatchMasterView(MasterView): def __init__(self, request): super(BatchMasterView, self).__init__(request) - self.handler = self.get_handler() + self.batch_handler = self.get_handler() + # TODO: deprecate / remove this (?) + self.handler = self.batch_handler @classmethod def get_handler_factory(cls, rattail_config): @@ -1149,18 +1151,27 @@ class BatchMasterView(MasterView): """ Batch rows are editable only until batch is complete or executed. """ + if not (self.rows_editable or self.rows_editable_but_not_directly): + return False + batch = self.get_parent(row) - return self.rows_editable and not batch.executed and not batch.complete + if batch.complete or batch.executed: + return False + + return True def row_deletable(self, row): """ Batch rows are deletable only until batch is complete or executed. """ - if self.rows_deletable: - batch = self.get_parent(row) - if not batch.executed and not batch.complete: - return True - return False + if not self.rows_deletable: + return False + + batch = self.get_parent(row) + if batch.complete or batch.executed: + return False + + return True def template_kwargs_view_row(self, **kwargs): kwargs['batch_model_title'] = kwargs['parent_model_title'] diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 73562e8d..f07c2f38 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -166,6 +166,7 @@ class MasterView(View): rows_viewable = True rows_creatable = False rows_editable = False + rows_editable_but_not_directly = False rows_deletable = False rows_deletable_speedbump = True rows_bulk_deletable = False @@ -3852,6 +3853,7 @@ class MasterView(View): return self.render_to_response('edit_row', { 'instance': row, 'row_parent': parent, + 'parent_model_title': self.get_model_title(), 'parent_title': self.get_instance_title(parent), 'parent_url': self.get_action_url('view', parent), 'parent_instance': parent, @@ -3884,6 +3886,8 @@ class MasterView(View): considered "deletable". Returns ``True`` by default; override as necessary. """ + if not self.rows_deletable: + return False return True def delete_row_object(self, row): @@ -4099,6 +4103,7 @@ class MasterView(View): config_title = cls.get_config_title() if cls.has_rows: row_model_title = cls.get_row_model_title() + row_model_title_plural = cls.get_row_model_title_plural() config.add_tailbone_permission_group(permission_prefix, model_title_plural, overwrite=False) @@ -4386,9 +4391,10 @@ class MasterView(View): # edit row if cls.has_rows: - if cls.rows_editable: + if cls.rows_editable or cls.rows_editable_but_not_directly: config.add_tailbone_permission(permission_prefix, '{}.edit_row'.format(permission_prefix), - "Edit individual {} rows".format(model_title)) + "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_view(cls, attr='edit_row', route_name='{}.edit_row'.format(route_prefix), permission='{}.edit_row'.format(permission_prefix)) @@ -4397,7 +4403,7 @@ class MasterView(View): if cls.has_rows: if cls.rows_deletable: config.add_tailbone_permission(permission_prefix, '{}.delete_row'.format(permission_prefix), - "Delete individual {} rows".format(model_title)) + "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_view(cls, attr='delete_row', route_name='{}.delete_row'.format(route_prefix), permission='{}.delete_row'.format(permission_prefix)) diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index 96e7fda9..a4dab2aa 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -99,8 +99,10 @@ class PurchasingBatchView(BatchMasterView): 'upc': "UPC", 'item_id': "Item ID", 'brand_name': "Brand", + 'case_quantity': "Case Size", 'po_line_number': "PO Line Number", 'po_unit_cost': "PO Unit Cost", + 'po_case_size': "PO Case Size", 'po_total': "PO Total", } @@ -144,6 +146,9 @@ class PurchasingBatchView(BatchMasterView): 'mispick', 'cases_mispick', 'units_mispick', + 'missing', + 'cases_missing', + 'units_missing', 'po_line_number', 'po_unit_cost', 'po_total', @@ -710,8 +715,11 @@ class PurchasingBatchView(BatchMasterView): f.set_renderer('damaged', self.render_row_quantity) f.set_renderer('expired', self.render_row_quantity) f.set_renderer('mispick', self.render_row_quantity) + f.set_renderer('missing', self.render_row_quantity) f.set_type('case_quantity', 'quantity') + f.set_type('po_case_size', 'quantity') + f.set_type('invoice_case_size', 'quantity') f.set_type('cases_ordered', 'quantity') f.set_type('units_ordered', 'quantity') f.set_type('cases_shipped', 'quantity') @@ -724,6 +732,8 @@ class PurchasingBatchView(BatchMasterView): f.set_type('units_expired', 'quantity') f.set_type('cases_mispick', 'quantity') f.set_type('units_mispick', 'quantity') + f.set_type('cases_missing', 'quantity') + f.set_type('units_missing', 'quantity') # currency fields # nb. we only show "total" fields as currency, but not case or @@ -746,7 +756,8 @@ class PurchasingBatchView(BatchMasterView): # credits f.set_readonly('credits') - f.set_renderer('credits', self.render_row_credits) + if self.viewing: + f.set_renderer('credits', self.render_row_credits) if self.creating: f.remove_fields( @@ -786,36 +797,58 @@ class PurchasingBatchView(BatchMasterView): app = self.get_rattail_app() cases = getattr(row, 'cases_{}'.format(field)) units = getattr(row, 'units_{}'.format(field)) - if cases and units: - return "{} cases + {} units".format(app.render_quantity(cases), - app.render_quantity(units)) - if cases and not units: - return "{} cases".format(app.render_quantity(cases)) - if units and not cases: - return "{} units".format(app.render_quantity(units)) - - def render_row_credits(self, row, field): - if not row.credits: - return "" + return app.render_cases_units(cases, units) + def make_row_credits_grid(self, row): + use_buefy = self.get_use_buefy() route_prefix = self.get_route_prefix() - columns = [ - 'credit_type', - 'cases_shorted', - 'units_shorted', - 'credit_total', - ] - g = grids.Grid( + factory = self.get_grid_factory() + + g = factory( key='{}.row_credits'.format(route_prefix), - data=row.credits, - columns=columns, - labels={'credit_type': "Type", - 'cases_shorted': "Cases", - 'units_shorted': "Units"}) + data=[] if use_buefy else row.credits, + columns=[ + 'credit_type', + # 'cases_shorted', + # 'units_shorted', + 'shorted', + 'credit_total', + 'expiration_date', + # 'mispick_upc', + # 'mispick_brand_name', + # 'mispick_description', + # 'mispick_size', + ], + 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", + }) + g.set_type('cases_shorted', 'quantity') g.set_type('units_shorted', 'quantity') g.set_type('credit_total', 'currency') - return HTML.literal(g.render_grid()) + + return g + + def render_row_credits(self, row, field): + use_buefy = self.get_use_buefy() + if not use_buefy and not row.credits: + return + + g = self.make_row_credits_grid(row) + + if use_buefy: + return HTML.literal( + g.render_buefy_table_element(data_prop='rowData.credits')) + else: + return HTML.literal(g.render_grid()) # def item_lookup(self, value, field=None): # """ diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 48b5fc00..2165ac7d 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -51,6 +51,21 @@ from tailbone.views.purchasing import PurchasingBatchView log = logging.getLogger(__name__) +POSSIBLE_RECEIVING_MODES = [ + 'received', + 'damaged', + 'expired', + # 'mispick', + 'missing', +] + +POSSIBLE_CREDIT_TYPES = [ + 'damaged', + 'expired', + # 'mispick', + 'missing', +] + class ReceivingBatchView(PurchasingBatchView): """ @@ -63,7 +78,9 @@ class ReceivingBatchView(PurchasingBatchView): index_title = "Receiving" downloadable = True bulk_deletable = True - rows_editable = True + rows_editable = False + rows_editable_but_not_directly = True + rows_deletable = True default_uom_is_case = True @@ -181,13 +198,18 @@ class ReceivingBatchView(PurchasingBatchView): 'mispick', 'cases_mispick', 'units_mispick', + 'missing', + 'cases_missing', + 'units_missing', 'catalog_unit_cost', 'po_line_number', 'po_unit_cost', + 'po_case_size', 'po_total', 'invoice_line_number', 'invoice_unit_cost', 'invoice_cost_confirmed', + 'invoice_case_size', 'invoice_total', 'invoice_total_calculated', 'status_code', @@ -322,17 +344,14 @@ class ReceivingBatchView(PurchasingBatchView): return self.render_to_response('create', context) def row_deletable(self, row): + + # first run it through the normal logic, if that doesn't like + # it then we won't either + if not super(ReceivingBatchView, self).row_deletable(row): + return False + batch = row.batch - # don't allow if master view has disabled that entirely - if not self.rows_deletable: - return False - - # can never delete rows for complete/executed batches - # TODO: not so sure about the 'complete' part though..? - if batch.executed or batch.complete: - return False - # can always delete rows from truck dump parent if batch.is_truck_dump_parent(): return True @@ -362,7 +381,7 @@ class ReceivingBatchView(PurchasingBatchView): super(ReceivingBatchView, self).configure_form(f) model = self.model batch = f.model_instance - allow_truck_dump = self.handler.allow_truck_dump_receiving() + allow_truck_dump = self.batch_handler.allow_truck_dump_receiving() workflow = self.request.matchdict.get('workflow_key') route_prefix = self.get_route_prefix() use_buefy = self.get_use_buefy() @@ -472,9 +491,9 @@ class ReceivingBatchView(PurchasingBatchView): and self.purchase_order_fieldname == 'purchase'): if use_buefy: f.replace('purchase', 'purchase_uuid') - purchases = self.handler.get_eligible_purchases( + purchases = self.batch_handler.get_eligible_purchases( vendor, self.enum.PURCHASE_BATCH_MODE_RECEIVING) - values = [(p.uuid, self.handler.render_eligible_purchase(p)) + values = [(p.uuid, self.batch_handler.render_eligible_purchase(p)) for p in purchases] f.set_widget('purchase_uuid', dfwidget.SelectWidget(values=values)) f.set_label('purchase_uuid', "Purchase Order") @@ -497,12 +516,11 @@ class ReceivingBatchView(PurchasingBatchView): f.remove('invoice_total_calculated') # hide all invoice fields if batch does not have invoice file - if not self.creating and not self.handler.has_invoice_file(batch): + if not self.creating and not self.batch_handler.has_invoice_file(batch): f.remove('invoice_file', 'invoice_date', 'invoice_number', - 'invoice_total', - 'invoice_total_calculated') + 'invoice_total') # receiving_complete if self.creating: @@ -517,9 +535,12 @@ class ReceivingBatchView(PurchasingBatchView): 'invoice_parser_key') elif workflow == 'from_invoice': - f.remove('truck_dump_batch_uuid') f.set_required('invoice_file') f.set_required('invoice_parser_key') + f.remove('truck_dump_batch_uuid', + 'po_number', + 'invoice_date', + 'invoice_number') elif workflow == 'from_po': f.remove('truck_dump_batch_uuid', @@ -531,9 +552,13 @@ class ReceivingBatchView(PurchasingBatchView): 'invoice_number') elif workflow == 'from_po_with_invoice': - f.remove('truck_dump_batch_uuid') f.set_required('invoice_file') f.set_required('invoice_parser_key') + f.remove('truck_dump_batch_uuid', + 'date_ordered', + 'po_number', + 'invoice_date', + 'invoice_number') elif workflow == 'truck_dump_children_first': f.remove('truck_dump_batch_uuid', @@ -614,16 +639,92 @@ class ReceivingBatchView(PurchasingBatchView): raise NotImplementedError return kwargs + def make_po_vs_invoice_breakdown(self, batch): + """ + Returns a simple breakdown as list of 2-tuples, each of which + has the display title as first member, and number of rows as + second member. + """ + grouped = {} + labels = OrderedDict([ + ('both', "Found in both PO and Invoice"), + ('po_not_invoice', "Found in PO but not Invoice"), + ('invoice_not_po', "Found in Invoice but not PO"), + ('neither', "Not found in PO nor Invoice"), + ]) + + for row in batch.active_rows(): + if row.po_line_number and not row.invoice_line_number: + grouped.setdefault('po_not_invoice', []).append(row) + elif row.invoice_line_number and not row.po_line_number: + grouped.setdefault('invoice_not_po', []).append(row) + elif row.po_line_number and row.invoice_line_number: + grouped.setdefault('both', []).append(row) + else: + grouped.setdefault('neither', []).append(row) + + breakdown = [] + + for key, label in labels.items(): + if key in grouped: + breakdown.append({ + 'title': label, + 'count': len(grouped[key]), + }) + + return breakdown + + def template_kwargs_view(self, **kwargs): + kwargs = super(ReceivingBatchView, self).template_kwargs_view(**kwargs) + batch = kwargs['instance'] + + if self.handler.has_purchase_order(batch) and self.handler.has_invoice_file(batch): + breakdown = self.make_po_vs_invoice_breakdown(batch) + + factory = self.get_grid_factory() + kwargs['po_vs_invoice_breakdown_grid'] = factory( + 'batch_po_vs_invoice_breakdown', + data=breakdown, + columns=['title', 'count']) + + return kwargs + + def get_context_credits(self, row): + app = self.get_rattail_app() + credits_data = [] + for credit in row.credits: + credits_data.append({ + 'uuid': credit.uuid, + 'credit_type': credit.credit_type, + 'expiration_date': six.text_type(credit.expiration_date) if credit.expiration_date else None, + 'cases_shorted': app.render_quantity(credit.cases_shorted), + 'units_shorted': app.render_quantity(credit.units_shorted), + 'shorted': app.render_cases_units(credit.cases_shorted, + credit.units_shorted), + 'credit_total': app.render_currency(credit.credit_total), + 'mispick_upc': '-', + 'mispick_brand_name': '-', + 'mispick_description': '-', + 'mispick_size': '-', + }) + return credits_data + def template_kwargs_view_row(self, **kwargs): kwargs = super(ReceivingBatchView, self).template_kwargs_view_row(**kwargs) + use_buefy = self.get_use_buefy() app = self.get_rattail_app() - handler = app.get_products_handler() + products_handler = app.get_products_handler() row = kwargs['instance'] if row.product: - kwargs['image_url'] = handler.get_image_url(row.product) + kwargs['image_url'] = products_handler.get_image_url(row.product) elif row.upc: - kwargs['image_url'] = handler.get_image_url(upc=row.upc) + kwargs['image_url'] = products_handler.get_image_url(upc=row.upc) + + 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 return kwargs @@ -849,6 +950,24 @@ class ReceivingBatchView(PurchasingBatchView): if row.product and row.product.is_pack_item(): return self.get_row_action_url('transform_unit', row) + def make_row_credits_grid(self, row): + + # first make grid like normal + g = super(ReceivingBatchView, self).make_row_credits_grid(row) + + if (self.get_use_buefy() + and self.has_perm('edit_row') + and self.row_editable(row)): + + # add the Un-Declare action + g.main_actions.append(self.make_action( + 'remove', label="Un-Declare", + url='#', icon='trash', + link_class='has-text-danger', + click_handler='removeCreditInit(props.row)')) + + return g + def vuejs_convert_quantity(self, cstruct): result = dict(cstruct) if result['cases'] is colander.null: @@ -872,6 +991,55 @@ class ReceivingBatchView(PurchasingBatchView): self.viewing = True use_buefy = self.get_use_buefy() row = self.get_row_instance() + + # things are a bit different now w/ buefy support.. + if use_buefy: + + # don't even bother showing this page if that's all the + # request was about + if self.request.method == 'GET': + return self.redirect(self.get_row_action_url('view', row)) + + # make sure edit is allowed + if not (self.has_perm('edit_row') and self.row_editable(row)): + raise self.forbidden() + + # check for JSON POST, which is submitted via AJAX from + # the "view row" page + if self.request.method == 'POST' and not self.request.POST: + data = self.request.json_body + kwargs = dict(data) + + # TODO: for some reason quantities can come through as strings? + cases = kwargs['quantity']['cases'] + if cases is not None: + if cases == '': + cases = None + else: + cases = decimal.Decimal(cases) + kwargs['cases'] = cases + units = kwargs['quantity']['units'] + if units is not None: + if units == '': + units = None + else: + units = decimal.Decimal(units) + kwargs['units'] = units + del kwargs['quantity'] + + # handler takes care of the receiving logic for us + try: + self.batch_handler.receive_row(row, **kwargs) + + except Exception as error: + return self.json_response({'error': six.text_type(error)}) + + self.Session.flush() + self.Session.refresh(row) + return self.json_response({ + 'ok': True, + 'row': self.get_context_row(row)}) + batch = row.batch permission_prefix = self.get_permission_prefix() possible_modes = [ @@ -1024,11 +1192,59 @@ class ReceivingBatchView(PurchasingBatchView): """ use_buefy = self.get_use_buefy() row = self.get_row_instance() + + # things are a bit different now w/ buefy support.. + if use_buefy: + + # don't even bother showing this page if that's all the + # request was about + if self.request.method == 'GET': + return self.redirect(self.get_row_action_url('view', row)) + + # make sure edit is allowed + if not (self.has_perm('edit_row') and self.row_editable(row)): + raise self.forbidden() + + # check for JSON POST, which is submitted via AJAX from + # the "view row" page + if self.request.method == 'POST' and not self.request.POST: + data = self.request.json_body + kwargs = dict(data) + + # TODO: for some reason quantities can come through as strings? + if kwargs['cases'] is not None: + if kwargs['cases'] == '': + kwargs['cases'] = None + else: + kwargs['cases'] = decimal.Decimal(kwargs['cases']) + if kwargs['units'] is not None: + if kwargs['units'] == '': + kwargs['units'] = None + else: + kwargs['units'] = decimal.Decimal(kwargs['units']) + + try: + result = self.handler.can_declare_credit(row, **kwargs) + + except Exception as error: + return self.json_response({'error': six.text_type(error)}) + + else: + if result: + self.handler.declare_credit(row, **kwargs) + + else: + return self.json_response({ + 'error': "Handler says you can't declare that credit; " + "not sure why"}) + + self.Session.flush() + self.Session.refresh(row) + return self.json_response({ + 'ok': True, + 'row': self.get_context_row(row)}) + batch = row.batch - possible_credit_types = [ - 'damaged', - 'expired', - ] context = { 'row': row, 'batch': batch, @@ -1044,9 +1260,10 @@ class ReceivingBatchView(PurchasingBatchView): schema = DeclareCreditForm() form = forms.Form(schema=schema, request=self.request, use_buefy=use_buefy) + form.cancel_url = self.get_row_action_url('view', row) # credit_type - values = [(m, m) for m in possible_credit_types] + values = [(m, m) for m in POSSIBLE_CREDIT_TYPES] if use_buefy: widget = dfwidget.SelectWidget(values=values) else: @@ -1085,6 +1302,54 @@ class ReceivingBatchView(PurchasingBatchView): context['parent_title'] = self.get_instance_title(batch) return self.render_to_response('declare_credit', context) + def undeclare_credit(self): + """ + View for un-declaring a credit, i.e. moving the credit amounts + back into the "received" tally. + """ + model = self.model + row = self.get_row_instance() + data = self.request.json_body + + # make sure edit is allowed + if not (self.has_perm('edit_row') and self.row_editable(row)): + raise self.forbidden() + + # figure out which credit to un-declare + credit = None + uuid = data.get('uuid') + if uuid: + credit = self.Session.query(model.PurchaseBatchCredit).get(uuid) + if not credit: + return {'error': "Credit not found"} + + # un-declare it + self.batch_handler.undeclare_credit(row, credit) + self.Session.flush() + self.Session.refresh(row) + + return {'ok': True, + 'row': self.get_context_row(row)} + + def get_context_row(self, row): + app = self.get_rattail_app() + return { + 'sequence': row.sequence, + 'case_quantity': float(row.case_quantity) if row.case_quantity is not None else None, + 'ordered': self.render_row_quantity(row, 'ordered'), + 'shipped': self.render_row_quantity(row, 'shipped'), + 'received': self.render_row_quantity(row, 'received'), + 'cases_received': float(row.cases_received) if row.cases_received is not None else None, + 'units_received': float(row.units_received) if row.units_received is not None else None, + 'damaged': self.render_row_quantity(row, 'damaged'), + 'expired': self.render_row_quantity(row, 'expired'), + 'mispick': self.render_row_quantity(row, 'mispick'), + 'missing': self.render_row_quantity(row, 'missing'), + 'credits': self.get_context_credits(row), + 'invoice_total_calculated': app.render_currency(row.invoice_total_calculated), + 'status': row.STATUS[row.status_code], + } + def transform_unit_row(self): """ View which transforms the given row, which is assumed to associate with @@ -1593,6 +1858,14 @@ class ReceivingBatchView(PurchasingBatchView): config.add_view(cls, attr='declare_credit', route_name='{}.declare_credit'.format(route_prefix), permission='{}.edit_row'.format(permission_prefix)) + # un-declare credit + config.add_route('{}.undeclare_credit'.format(route_prefix), + '{}/rows/{{row_uuid}}/undeclare-credit'.format(instance_url_prefix)) + config.add_view(cls, attr='undeclare_credit', + route_name='{}.undeclare_credit'.format(route_prefix), + permission='{}.edit_row'.format(permission_prefix), + renderer='json') + # update row cost config.add_route('{}.update_row_cost'.format(route_prefix), '{}/update-row-cost'.format(instance_url_prefix)) config.add_view(cls, attr='update_row_cost', route_name='{}.update_row_cost'.format(route_prefix), @@ -1649,12 +1922,8 @@ class NewReceivingBatch(colander.Schema): class ReceiveRowForm(colander.MappingSchema): mode = colander.SchemaNode(colander.String(), - validator=colander.OneOf([ - 'received', - 'damaged', - 'expired', - # 'mispick', - ])) + validator=colander.OneOf( + POSSIBLE_RECEIVING_MODES)) quantity = forms.types.ProductQuantity() @@ -1677,11 +1946,8 @@ class ReceiveRowForm(colander.MappingSchema): class DeclareCreditForm(colander.MappingSchema): credit_type = colander.SchemaNode(colander.String(), - validator=colander.OneOf([ - 'damaged', - 'expired', - # 'mispick', - ])) + validator=colander.OneOf( + POSSIBLE_CREDIT_TYPES)) quantity = forms.types.ProductQuantity() From 1fbe429a08d0cb15b8170ce744568b9d849e2490 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 13 Dec 2021 20:35:23 -0600 Subject: [PATCH 0540/1681] Add basic "config" view for Receiving --- tailbone/templates/configure.mako | 10 +- tailbone/templates/importing/configure.mako | 4 - tailbone/templates/master/configure.mako | 33 +++++++ tailbone/templates/receiving/configure.mako | 94 ++++++++++++++++++ tailbone/views/master.py | 101 +++++++++++++++++++- tailbone/views/purchasing/receiving.py | 44 +++++++++ 6 files changed, 278 insertions(+), 8 deletions(-) create mode 100644 tailbone/templates/master/configure.mako create mode 100644 tailbone/templates/receiving/configure.mako diff --git a/tailbone/templates/configure.mako b/tailbone/templates/configure.mako index b0bfb14e..d80a07c0 100644 --- a/tailbone/templates/configure.mako +++ b/tailbone/templates/configure.mako @@ -112,6 +112,10 @@ ${parent.modify_this_page_vars()} <script type="text/javascript"> + % if simple_settings is not Undefined: + ThisPageData.simpleSettings = ${json.dumps(simple_settings)|n} + % endif + ThisPageData.purgeSettingsShowDialog = false ThisPageData.purgingSettings = false @@ -124,7 +128,11 @@ } ThisPage.methods.settingsCollectParams = function() { - return {} + % if simple_settings is not Undefined: + return {simple_settings: this.simpleSettings} + % else: + return {} + % endif } ThisPage.methods.saveSettings = function() { diff --git a/tailbone/templates/importing/configure.mako b/tailbone/templates/importing/configure.mako index 462a5215..ac215e1c 100644 --- a/tailbone/templates/importing/configure.mako +++ b/tailbone/templates/importing/configure.mako @@ -140,10 +140,6 @@ ThisPageData.editingHandlerSubcommand = null ThisPageData.editingHandlerRunas = null - ThisPageData.settingsNeedSaved = false - ThisPageData.undoChanges = false - ThisPageData.savingSettings = false - ThisPage.computed.updateHandlerDisabled = function() { if (!this.editingHandlerSpec) { return true diff --git a/tailbone/templates/master/configure.mako b/tailbone/templates/master/configure.mako new file mode 100644 index 00000000..4c007730 --- /dev/null +++ b/tailbone/templates/master/configure.mako @@ -0,0 +1,33 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="page_content()"> + ${parent.page_content()} + + <h3 class="block is-size-3">TODO</h3> + + <p class="block"> + You should create a custom template file at: + <span class="is-family-monospace">${master.get_template_prefix()}/configure.mako</span> + </p> + + <p class="block"> + Within that you should define (at least) the + <span class="is-family-monospace">page_content()</span> + def block. + </p> + + <p class="block"> + You can see the following examples for reference: + </p> + + <ul class="block"> + <li class="is-family-monospace">/datasync/configure.mako</li> + <li class="is-family-monospace">/importing/configure.mako</li> + <li class="is-family-monospace">/receiving/configure.mako</li> + </ul> + +</%def> + + +${parent.body()} diff --git a/tailbone/templates/receiving/configure.mako b/tailbone/templates/receiving/configure.mako new file mode 100644 index 00000000..3b2a93e1 --- /dev/null +++ b/tailbone/templates/receiving/configure.mako @@ -0,0 +1,94 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="page_content()"> + ${parent.page_content()} + + <h3 class="block is-size-3">Supported Workflows</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field> + <b-checkbox v-model="simpleSettings['rattail.batch.purchase.allow_receiving_from_scratch']" + @input="settingsNeedSaved = true"> + From Scratch + </b-checkbox> + </b-field> + + <b-field> + <b-checkbox v-model="simpleSettings['rattail.batch.purchase.allow_receiving_from_invoice']" + @input="settingsNeedSaved = true"> + From Invoice + </b-checkbox> + </b-field> + + <b-field> + <b-checkbox v-model="simpleSettings['rattail.batch.purchase.allow_receiving_from_purchase_order']" + @input="settingsNeedSaved = true"> + From Purchase Order + </b-checkbox> + </b-field> + + <b-field> + <b-checkbox v-model="simpleSettings['rattail.batch.purchase.allow_receiving_from_purchase_order_with_invoice']" + @input="settingsNeedSaved = true"> + From Purchase Order, with Invoice + </b-checkbox> + </b-field> + + <b-field> + <b-checkbox v-model="simpleSettings['rattail.batch.purchase.allow_truck_dump_receiving']" + @input="settingsNeedSaved = true"> + Truck Dump + </b-checkbox> + </b-field> + + </div> + + <h3 class="block is-size-3">Product Handling</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field message="NB. Allow Cases setting also affects Ordering behavior."> + <b-checkbox v-model="simpleSettings['rattail.batch.purchase.allow_cases']" + @input="settingsNeedSaved = true"> + Allow Cases + </b-checkbox> + </b-field> + + <b-field> + <b-checkbox v-model="simpleSettings['rattail.batch.purchase.allow_expired_credits']" + @input="settingsNeedSaved = true"> + Allow "Expired" Credits + </b-checkbox> + </b-field> + + </div> + + <h3 class="block is-size-3">Mobile Interface</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field message="TODO: this may also affect Ordering (?)"> + <b-checkbox v-model="simpleSettings['rattail.batch.purchase.mobile_images']" + @input="settingsNeedSaved = true"> + Show Product Images + </b-checkbox> + </b-field> + + <b-field> + <b-checkbox v-model="simpleSettings['rattail.batch.purchase.mobile_quick_receive']" + @input="settingsNeedSaved = true"> + Allow "Quick Receive" + </b-checkbox> + </b-field> + + <b-field> + <b-checkbox v-model="simpleSettings['rattail.batch.purchase.mobile_quick_receive_all']" + @input="settingsNeedSaved = true"> + Allow "Quick Receive All" + </b-checkbox> + </b-field> + + </div> +</%def> + + +${parent.body()} diff --git a/tailbone/views/master.py b/tailbone/views/master.py index f07c2f38..dce2d3ef 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -4041,14 +4041,109 @@ class MasterView(View): context = self.configure_get_context() return self.render_to_response('configure', context) + def configure_get_simple_settings(self): + """ + If you have some "simple" settings, each of which basically + just needs to be rendered as a separate field, then you can + declare them via this method. + + You should return a list of settings; each setting should be + represented as a dict with various pieces of info, e.g.:: + + { + 'section': 'rattail.batch', + 'option': 'purchase.allow_cases', + 'name': 'rattail.batch.purchase.allow_cases', + 'type': bool, + 'value': config.getbool('rattail.batch', + 'purchase.allow_cases'), + } + + Note that some of the above is optional, in particular it + works like this: + + If you pass ``section`` and ``option`` then you do not need to + pass ``name`` since that can be deduced. Also in this case + you need not pass ``value`` as the normal view logic can fetch + the value automatically. Note that when fetching, it honors + ``type`` which, if you do not specify, would be ``str`` by + default. + + However if you pass ``name`` then you need not pass + ``section`` or ``option``, but you must pass ``value`` since + that cannot be automatically fetched in this case. + + :returns: List of simple setting info dicts, as described + above. + """ + + def configure_get_name_for_simple_setting(self, simple): + if 'name' in simple: + return simple['name'] + return '{}.{}'.format(simple['section'], + simple['option']) + def configure_get_context(self): - return {} + context = {} + simple_settings = self.configure_get_simple_settings() + if simple_settings: + + config = self.rattail_config + settings = {} + for simple in simple_settings: + + name = self.configure_get_name_for_simple_setting(simple) + + if 'value' in simple: + value = simple['value'] + elif simple.get('type') is bool: + value = config.getbool(simple['section'], + simple['option'], + default=False) + else: + value = config.get(simple['section'], + simple['option']) + + settings[name] = value + + context['simple_settings'] = settings + + return context def configure_gather_settings(self, data): - return [] + settings = [] + + simple_settings = self.configure_get_simple_settings() + if simple_settings and 'simple_settings' in data: + + data_settings = data['simple_settings'] + + for simple in simple_settings: + name = self.configure_get_name_for_simple_setting(simple) + value = None + + if name in data_settings: + value = data_settings[name] + + if simple.get('type') is bool: + value = six.text_type(bool(value)).lower() + else: + value = six.text_type(value) + + settings.append({'name': name, + 'value': value}) + + return settings def configure_remove_settings(self): - pass + simple_settings = self.configure_get_simple_settings() + if simple_settings: + model = self.model + names = [self.configure_get_name_for_simple_setting(simple) + for simple in simple_settings] + self.Session.query(model.Setting)\ + .filter(model.Setting.name.in_(names))\ + .delete(synchronize_session=False) def configure_save_settings(self, settings): model = self.model diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 2165ac7d..3664cdef 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -78,6 +78,9 @@ class ReceivingBatchView(PurchasingBatchView): index_title = "Receiving" downloadable = True bulk_deletable = True + configurable = True + config_title = "Receiving" + rows_editable = False rows_editable_but_not_directly = True rows_deletable = True @@ -1826,6 +1829,47 @@ class ReceivingBatchView(PurchasingBatchView): progress.session['success_url'] = success_url progress.session.save() + def configure_get_simple_settings(self): + config = self.rattail_config + return [ + + # supported workflows + {'section': 'rattail.batch', + 'option': 'purchase.allow_receiving_from_scratch', + 'type': bool}, + {'section': 'rattail.batch', + 'option': 'purchase.allow_receiving_from_invoice', + 'type': bool}, + {'section': 'rattail.batch', + 'option': 'purchase.allow_receiving_from_purchase_order', + 'type': bool}, + {'section': 'rattail.batch', + 'option': 'purchase.allow_receiving_from_purchase_order_with_invoice', + 'type': bool}, + {'section': 'rattail.batch', + 'option': 'purchase.allow_truck_dump_receiving', + 'type': bool}, + + # product handling + {'section': 'rattail.batch', + 'option': 'purchase.allow_cases', + 'type': bool}, + {'section': 'rattail.batch', + 'option': 'purchase.allow_expired_credits', + 'type': bool}, + + # mobile interface + {'section': 'rattail.batch', + 'option': 'purchase.mobile_images', + 'type': bool}, + {'section': 'rattail.batch', + 'option': 'purchase.mobile_quick_receive', + 'type': bool}, + {'section': 'rattail.batch', + 'option': 'purchase.mobile_quick_receive_all', + 'type': bool}, + ] + @classmethod def defaults(cls, config): cls._receiving_defaults(config) From 16bc3076ad0801c694bb618c52ef556f98ec8045 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 13 Dec 2021 21:06:47 -0600 Subject: [PATCH 0541/1681] Add basic config page for Products --- tailbone/templates/products/configure.mako | 69 ++++++++++++++++++++++ tailbone/views/products.py | 17 ++++++ 2 files changed, 86 insertions(+) create mode 100644 tailbone/templates/products/configure.mako diff --git a/tailbone/templates/products/configure.mako b/tailbone/templates/products/configure.mako new file mode 100644 index 00000000..045e5904 --- /dev/null +++ b/tailbone/templates/products/configure.mako @@ -0,0 +1,69 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="page_content()"> + ${parent.page_content()} + + <h3 class="block is-size-3">Key Field</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field grouped> + + <b-field label="Key Field"> + <b-select v-model="simpleSettings['rattail.product.key']" + @input="updateKeyTitle()"> + <option value="upc">upc</option> + <option value="item_id">item_id</option> + <option value="scancode">scancode</option> + </b-select> + </b-field> + + <b-field label="Key Field Label"> + <b-input v-model="simpleSettings['rattail.product.key_title']" + @input="settingsNeedSaved = true"> + </b-input> + </b-field> + + </b-field> + + </div> + + <h3 class="block is-size-3">Display</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field message="If a product has an image in the DB, that will still be preferred."> + <b-checkbox v-model="simpleSettings['tailbone.products.show_pod_image']" + @input="settingsNeedSaved = true"> + Show "POD" Images as fallback + </b-checkbox> + </b-field> + + </div> +</%def> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + ThisPage.methods.getTitleForKey = function(key) { + switch (key) { + case 'item_id': + return "Item ID" + case 'scancode': + return "Scancode" + default: + return "UPC" + } + } + + ThisPage.methods.updateKeyTitle = function() { + this.simpleSettings['rattail.product.key_title'] = this.getTitleForKey( + this.simpleSettings['rattail.product.key']) + this.settingsNeedSaved = true + } + + </script> +</%def> + + +${parent.body()} diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 97f0b631..e6d2b7d4 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -85,6 +85,7 @@ class ProductView(MasterView): has_versions = True results_downloadable_xlsx = True supports_autocomplete = True + configurable = True labels = { 'item_id': "Item ID", @@ -1906,6 +1907,22 @@ class ProductView(MasterView): if batch.batch_key == 'delproduct': return self.request.route_url('batch.delproduct.view', uuid=batch.uuid) + def configure_get_simple_settings(self): + config = self.rattail_config + return [ + + # key field + {'section': 'rattail', + 'option': 'product.key'}, + {'section': 'rattail', + 'option': 'product.key_title'}, + + # display + {'section': 'tailbone', + 'option': 'products.show_pod_image', + 'type': bool}, + ] + @classmethod def defaults(cls, config): cls._product_defaults(config) From 12446590642c2cc73150ec3b34ab0a7318b771d2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 13 Dec 2021 21:33:10 -0600 Subject: [PATCH 0542/1681] Add more basic config views, obviating some App Settings --- tailbone/templates/master/configure.mako | 1 + .../reports/generated/configure.mako | 21 +++++++++++++++++++ .../templates/settings/email/configure.mako | 21 +++++++++++++++++++ tailbone/templates/vendors/configure.mako | 21 +++++++++++++++++++ tailbone/views/email.py | 12 +++++++++++ tailbone/views/master.py | 8 ++++++- tailbone/views/reports.py | 13 ++++++++++++ tailbone/views/vendors/core.py | 11 ++++++++++ 8 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 tailbone/templates/reports/generated/configure.mako create mode 100644 tailbone/templates/settings/email/configure.mako create mode 100644 tailbone/templates/vendors/configure.mako diff --git a/tailbone/templates/master/configure.mako b/tailbone/templates/master/configure.mako index 4c007730..bfe0574c 100644 --- a/tailbone/templates/master/configure.mako +++ b/tailbone/templates/master/configure.mako @@ -24,6 +24,7 @@ <ul class="block"> <li class="is-family-monospace">/datasync/configure.mako</li> <li class="is-family-monospace">/importing/configure.mako</li> + <li class="is-family-monospace">/products/configure.mako</li> <li class="is-family-monospace">/receiving/configure.mako</li> </ul> diff --git a/tailbone/templates/reports/generated/configure.mako b/tailbone/templates/reports/generated/configure.mako new file mode 100644 index 00000000..27e60afa --- /dev/null +++ b/tailbone/templates/reports/generated/configure.mako @@ -0,0 +1,21 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="page_content()"> + ${parent.page_content()} + + <h3 class="block is-size-3">Generating</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field message="If not set, reports are shown as simple list of hyperlinks."> + <b-checkbox v-model="simpleSettings['tailbone.reporting.choosing_uses_form']" + @input="settingsNeedSaved = true"> + Show report chooser as form, with dropdown + </b-checkbox> + </b-field> + + </div> +</%def> + + +${parent.body()} diff --git a/tailbone/templates/settings/email/configure.mako b/tailbone/templates/settings/email/configure.mako new file mode 100644 index 00000000..f212f635 --- /dev/null +++ b/tailbone/templates/settings/email/configure.mako @@ -0,0 +1,21 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="page_content()"> + ${parent.page_content()} + + <h3 class="block is-size-3">Sending</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field> + <b-checkbox v-model="simpleSettings['rattail.mail.record_attempts']" + @input="settingsNeedSaved = true"> + Make record of all attempts to send email + </b-checkbox> + </b-field> + + </div> +</%def> + + +${parent.body()} diff --git a/tailbone/templates/vendors/configure.mako b/tailbone/templates/vendors/configure.mako new file mode 100644 index 00000000..e1a47644 --- /dev/null +++ b/tailbone/templates/vendors/configure.mako @@ -0,0 +1,21 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="page_content()"> + ${parent.page_content()} + + <h3 class="block is-size-3">Display</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field message="If not set, vendor chooser is a dropdown field."> + <b-checkbox v-model="simpleSettings['rattail.vendor.use_autocomplete']" + @input="settingsNeedSaved = true"> + Show vendor chooser as autocomplete field + </b-checkbox> + </b-field> + + </div> +</%def> + + +${parent.body()} diff --git a/tailbone/views/email.py b/tailbone/views/email.py index 58a0320b..7b46f490 100644 --- a/tailbone/views/email.py +++ b/tailbone/views/email.py @@ -54,6 +54,8 @@ class EmailSettingView(MasterView): pageable = False creatable = False deletable = False + configurable = True + config_title = "Email" grid_columns = [ 'key', @@ -224,6 +226,16 @@ class EmailSettingView(MasterView): kwargs['email'] = self.handler.get_email(key) return kwargs + def configure_get_simple_settings(self): + config = self.rattail_config + return [ + + # sending + {'section': 'rattail.mail', + 'option': 'record_attempts', + 'type': bool}, + ] + # TODO: deprecate / remove this ProfilesView = EmailSettingView diff --git a/tailbone/views/master.py b/tailbone/views/master.py index dce2d3ef..76f8967b 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -287,6 +287,12 @@ class MasterView(View): return self.request.has_perm('{}.{}'.format( self.get_permission_prefix(), name)) + @classmethod + def get_config_url(cls): + if hasattr(cls, 'config_url'): + return cls.config_url + return '{}/configure'.format(cls.get_url_prefix()) + ############################## # Available Views ############################## @@ -4265,7 +4271,7 @@ class MasterView(View): '{}.configure'.format(permission_prefix), label="Configure {}".format(config_title)) config.add_route('{}.configure'.format(route_prefix), - '{}/configure'.format(url_prefix)) + cls.get_config_url()) config.add_view(cls, attr='configure', route_name='{}.configure'.format(route_prefix), permission='{}.configure'.format(permission_prefix)) diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index 6359c471..21ef3a20 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -213,6 +213,9 @@ class ReportOutputView(ExportMasterView): route_prefix = 'report_output' url_prefix = '/reports/generated' downloadable = True + configurable = True + config_title = "Reporting" + config_url = '/reports/configure' grid_columns = [ 'id', @@ -295,6 +298,16 @@ class ReportOutputView(ExportMasterView): path = report.filepath(self.rattail_config) return self.file_response(path) + def configure_get_simple_settings(self): + config = self.rattail_config + return [ + + # generating + {'section': 'tailbone', + 'option': 'reporting.choosing_uses_form', + 'type': bool}, + ] + class GenerateReport(View): """ diff --git a/tailbone/views/vendors/core.py b/tailbone/views/vendors/core.py index ceac1c71..bf73e1b1 100644 --- a/tailbone/views/vendors/core.py +++ b/tailbone/views/vendors/core.py @@ -43,6 +43,7 @@ class VendorView(MasterView): has_versions = True touchable = True supports_autocomplete = True + configurable = True labels = { 'id': "ID", @@ -168,6 +169,16 @@ class VendorView(MasterView): (model.VendorContact, 'vendor_uuid'), ] + def configure_get_simple_settings(self): + config = self.rattail_config + return [ + + # display + {'section': 'rattail', + 'option': 'vendor.use_autocomplete', + 'type': bool}, + ] + def includeme(config): VendorView.defaults(config) From 197d3de74a006c9ac2dd67121f4918290b59c5a6 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 13 Dec 2021 22:32:10 -0600 Subject: [PATCH 0543/1681] Add "jump to" chooser in App Settings, for various "configure" pages --- tailbone/templates/appsettings.mako | 54 ++++++++++++++++++++++------- tailbone/views/settings.py | 26 ++++++++++++++ 2 files changed, 68 insertions(+), 12 deletions(-) diff --git a/tailbone/templates/appsettings.mako b/tailbone/templates/appsettings.mako index dbe747bf..e3fa2ccf 100644 --- a/tailbone/templates/appsettings.mako +++ b/tailbone/templates/appsettings.mako @@ -66,17 +66,40 @@ <div class="app-wrapper"> - <div class="field-wrapper"> - <label for="settings-group">Showing Group</label> - <b-select name="settings-group" - v-model="showingGroup"> - <option value="">(All)</option> - <option v-for="group in groups" - :key="group.label" - :value="group.label"> - {{ group.label }} - </option> - </b-select> + <div class="level"> + + <div class="level-left"> + <div class="level-item"> + <b-field label="Showing Group"> + <b-select name="settings-group" + v-model="showingGroup"> + <option value="">(All)</option> + <option v-for="group in groups" + :key="group.label" + :value="group.label"> + {{ group.label }} + </option> + </b-select> + </b-field> + </div> + </div> + + <div class="level-right" + v-if="configOptions.length"> + <div class="level-item"> + <b-field label="Go To Configure..."> + <b-select v-model="gotoConfigureURL" + @input="gotoConfigure()"> + <option v-for="option in configOptions" + :key="option.url" + :value="option.url"> + {{ option.label }} + </option> + </b-select> + </b-field> + </div> + </div> + </div> <div v-for="group in groups" @@ -186,13 +209,20 @@ return { formSubmitting: false, formButtonText: ${json.dumps(getattr(form, 'submit_label', getattr(form, 'save_label', "Submit")))|n}, + configOptions: ${json.dumps(config_options)|n}, + gotoConfigureURL: null, } }, methods: { submitForm() { this.formSubmitting = true this.formButtonText = "Working, please wait..." - } + }, + gotoConfigure() { + if (this.gotoConfigureURL) { + location.href = this.gotoConfigureURL + } + }, } }) diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index ed63b857..41fa02e9 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -125,6 +125,31 @@ class AppSettingsView(View): if not current_group: current_group = self.request.session.get('appsettings.current_group') + # TODO: this should come from somewhere else + possible_config_options = [ + {'label': "DataSync", + 'route': 'datasync.configure'}, + {'label': "Email", + 'route': 'emailprofiles.configure'}, + {'label': "Importing / Exporting", + 'route': 'importing.configure'}, + {'label': "Products", + 'route': 'products.configure'}, + {'label': "Receiving", + 'route': 'receiving.configure'}, + {'label': "Reporting", + 'route': 'report_output.configure'}, + {'label': "Vendors", + 'route': 'vendors.configure'}, + ] + + config_options = [] + for option in possible_config_options: + perm = option.get('perm', option['route']) + if self.request.has_perm(perm): + option['url'] = self.request.route_url(option['route']) + config_options.append(option) + use_buefy = self.get_use_buefy() context = { 'index_title': "App Settings", @@ -133,6 +158,7 @@ class AppSettingsView(View): 'groups': groups, 'settings': settings, 'use_buefy': use_buefy, + 'config_options': config_options, } if use_buefy: context['buefy_data'] = self.get_buefy_data(form, groups, settings) From 6f62f141d2b539d6a616e0f76e1a1bcbaca2c34c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 14 Dec 2021 19:08:32 -0600 Subject: [PATCH 0544/1681] Fix params field when deleting a report --- .../templates/reports/generated/delete.mako | 16 ++++++++++ tailbone/views/master.py | 6 ++++ tailbone/views/reports.py | 32 +++++++++++++------ 3 files changed, 44 insertions(+), 10 deletions(-) create mode 100644 tailbone/templates/reports/generated/delete.mako diff --git a/tailbone/templates/reports/generated/delete.mako b/tailbone/templates/reports/generated/delete.mako new file mode 100644 index 00000000..0c994ad0 --- /dev/null +++ b/tailbone/templates/reports/generated/delete.mako @@ -0,0 +1,16 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/delete.mako" /> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + % if params_data is not Undefined: + ${form.component_studly}Data.paramsData = ${json.dumps(params_data)|n} + % endif + + </script> +</%def> + + +${parent.body()} diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 76f8967b..eb4ef1a5 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2250,6 +2250,12 @@ class MasterView(View): """ return kwargs + def template_kwargs_delete(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 diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index 21ef3a20..e2aa3db6 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -278,18 +278,30 @@ class ReportOutputView(ExportMasterView): url = self.get_action_url('download', report) return self.render_file_field(path, url=url) - def template_kwargs_view(self, **kwargs): - use_buefy = self.get_use_buefy() - if use_buefy: + def get_params_context(self, report): + params_data = [] + for name, value in (report.params or {}).items(): + params_data.append({ + 'key': name, + 'value': value, + }) + return params_data + def template_kwargs_view(self, **kwargs): + kwargs = super(ReportOutputView, self).template_kwargs_view(**kwargs) + + if self.get_use_buefy(): report = kwargs['instance'] - params_data = [] - for name, value in (report.params or {}).items(): - params_data.append({ - 'key': name, - 'value': value, - }) - kwargs['params_data'] = params_data + kwargs['params_data'] = self.get_params_context(report) + + return kwargs + + def template_kwargs_delete(self, **kwargs): + kwargs = super(ReportOutputView, self).template_kwargs_delete(**kwargs) + + if self.get_use_buefy(): + report = kwargs['instance'] + kwargs['params_data'] = self.get_params_context(report) return kwargs From ca57bd35721857c6ea487fdd538dfb51db9efde0 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 15 Dec 2021 00:00:46 -0600 Subject: [PATCH 0545/1681] Auto-register all config pages, for dropdown in App Settings --- tailbone/app.py | 14 ++++++++++++++ tailbone/templates/configure.mako | 4 ++-- tailbone/views/master.py | 2 ++ tailbone/views/settings.py | 20 +++----------------- 4 files changed, 21 insertions(+), 19 deletions(-) diff --git a/tailbone/app.py b/tailbone/app.py index bbb6d295..80cce0f6 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -156,9 +156,23 @@ def make_pyramid_config(settings, configure_csrf=True): config.add_directive('add_tailbone_permission_group', 'tailbone.auth.add_permission_group') config.add_directive('add_tailbone_permission', 'tailbone.auth.add_permission') + # and some similar magic for config views + config.add_directive('add_tailbone_config_page', 'tailbone.app.add_config_page') + return config +def add_config_page(config, route_name, label): + """ + Register a config page for the app. + """ + def action(): + pages = config.get_settings().get('tailbone_config_pages', []) + pages.append({'label': label, 'route': route_name}) + config.add_settings({'tailbone_config_pages': pages}) + config.action(None, action) + + def establish_theme(settings): rattail_config = settings['rattail_config'] diff --git a/tailbone/templates/configure.mako b/tailbone/templates/configure.mako index d80a07c0..93d059dd 100644 --- a/tailbone/templates/configure.mako +++ b/tailbone/templates/configure.mako @@ -70,8 +70,8 @@ <section class="modal-card-body"> <p class="block"> - If you like we can remove all ${config_title} - settings from the DB. + If you like we can remove all settings for ${config_title} + from the DB. </p> <p class="block"> Note that the tool normally removes all settings first, diff --git a/tailbone/views/master.py b/tailbone/views/master.py index eb4ef1a5..a9d11377 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -4281,6 +4281,8 @@ class MasterView(View): config.add_view(cls, attr='configure', route_name='{}.configure'.format(route_prefix), permission='{}.configure'.format(permission_prefix)) + config.add_tailbone_config_page('{}.configure'.format(route_prefix), + config_title) # quickie (search) if cls.supports_quickie_search: diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index 41fa02e9..acb74f7b 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -125,23 +125,9 @@ class AppSettingsView(View): if not current_group: current_group = self.request.session.get('appsettings.current_group') - # TODO: this should come from somewhere else - possible_config_options = [ - {'label': "DataSync", - 'route': 'datasync.configure'}, - {'label': "Email", - 'route': 'emailprofiles.configure'}, - {'label': "Importing / Exporting", - 'route': 'importing.configure'}, - {'label': "Products", - 'route': 'products.configure'}, - {'label': "Receiving", - 'route': 'receiving.configure'}, - {'label': "Reporting", - 'route': 'report_output.configure'}, - {'label': "Vendors", - 'route': 'vendors.configure'}, - ] + possible_config_options = sorted( + self.request.registry.settings['tailbone_config_pages'], + key=lambda p: p['label']) config_options = [] for option in possible_config_options: From f49fdebd98989ca904e3920f5f39674d47a8d54c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 15 Dec 2021 15:02:28 -0600 Subject: [PATCH 0546/1681] Add some smarts when making batch execution form schema in some cases `has_execution_options()` may return True but the base view class may not need to provide any options itself (i.e. subclass is responsible for declaring the view has options). --- tailbone/views/batch/core.py | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 83412761..29aa308e 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -786,28 +786,30 @@ class BatchMasterView(MasterView): route_prefix = self.get_route_prefix() use_buefy = self.get_use_buefy() + schema = None if self.has_execution_options(batch): if batch is None: batch = self.model_class schema = self.make_execute_schema(batch) - for field in schema: + if schema: + for field in schema: - # if field does not yet have a default, maybe provide one from session storage - if field.default is colander.null: - key = 'batch.{}.execute_option.{}'.format(batch.batch_key, field.name) - if key in self.request.session: - defaults[field.name] = self.request.session[key] + # if field does not yet have a default, maybe provide one from session storage + if field.default is colander.null: + key = 'batch.{}.execute_option.{}'.format(batch.batch_key, field.name) + if key in self.request.session: + defaults[field.name] = self.request.session[key] - # make sure field label is preserved - if field.title: - labels = kwargs.setdefault('labels', {}) - labels[field.name] = field.title + # make sure field label is preserved + if field.title: + labels = kwargs.setdefault('labels', {}) + labels[field.name] = field.title - # auto-convert select widgets for buefy theme - if use_buefy and isinstance(field.widget, forms.widgets.PlainSelectWidget): - field.widget = dfwidget.SelectWidget(values=field.widget.values) + # auto-convert select widgets for buefy theme + if use_buefy and isinstance(field.widget, forms.widgets.PlainSelectWidget): + field.widget = dfwidget.SelectWidget(values=field.widget.values) - else: + if not schema: schema = colander.Schema() kwargs['use_buefy'] = use_buefy From 40d36f980837c587c1f635a8ef02d5e91dd8ea8a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 15 Dec 2021 15:19:36 -0600 Subject: [PATCH 0547/1681] 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 185d0763..85f6cf4c 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,22 @@ CHANGELOG ========= +0.8.185 (2021-12-15) +-------------------- + +* Allow for null price when showing price history. + +* Overhaul desktop views for receiving, for efficiency. + +* Add some basic "config" views, to obviate some App Settings. + +* Add "jump to" chooser in App Settings, for various "configure" pages. + +* Fix params field when deleting a report. + +* Add some smarts when making batch execution form schema. + + 0.8.184 (2021-12-09) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 3066e92b..1d4748a2 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.184' +__version__ = '0.8.185' From bc7ccb6a9fb4dab70ea526a909d7571c0d295e1d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 15 Dec 2021 18:06:53 -0600 Subject: [PATCH 0548/1681] Render "pretty" UPC by default, for batch row form fields --- tailbone/views/batch/core.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 29aa308e..985f5502 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -627,6 +627,11 @@ class BatchMasterView(MasterView): return tags.link_to(text, url) return text + def render_upc_pretty(self, row, field): + upc = getattr(row, field) + if upc: + return upc.pretty() + def render_row_status(self, row, column): code = row.status_code if code is None: @@ -662,6 +667,10 @@ class BatchMasterView(MasterView): # sequence f.set_readonly('sequence') + # upc (default rendering, just in case there is such a field + # on our row model) + f.set_renderer('upc', self.render_upc_pretty) + # status_code if self.model_row_class: f.set_enum('status_code', self.model_row_class.STATUS) From ff348a2aa0ab6ad237b1396eb9069b85f69c6548 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 15 Dec 2021 18:07:14 -0600 Subject: [PATCH 0549/1681] Add some minimal docs for Diff constructor --- tailbone/diffs.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/tailbone/diffs.py b/tailbone/diffs.py index d4031b1f..d57aa9ac 100644 --- a/tailbone/diffs.py +++ b/tailbone/diffs.py @@ -35,8 +35,29 @@ class Diff(object): Core diff class. In sore need of documentation. """ - def __init__(self, old_data, new_data, columns=None, fields=None, render_field=None, render_value=None, monospace=False, - extra_row_attrs=None): + def __init__(self, old_data, new_data, columns=None, fields=None, + render_field=None, render_value=None, + monospace=False, extra_row_attrs=None): + """ + Constructor. You must provide the old and new data sets, and + the set of relevant fields as well, if they cannot be easily + introspected. + + :param old_data: Dict of "old" data values. + + :param new_data: Dict of "old" data values. + + :param fields: Sequence of relevant field names. Note that + both data dicts are expected to have keys which match these + field names. If you do not specify the fields then they + will (hopefully) be introspected from the old or new data + sets; however this will not work if they are both empty. + + :param monospace: If true, this flag will cause the value + columns to be rendered in monospace font. This is assumed + to be helpful when comparing "raw" data values which are + shown as e.g. ``repr(val)``. + """ self.old_data = old_data self.new_data = new_data self.columns = columns or ["field name", "old value", "new value"] From c7d587b4cb25db3fe5e595b2583be29de9071a5a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 16 Dec 2021 14:16:12 -0600 Subject: [PATCH 0550/1681] Tweak wording on base configure template --- tailbone/templates/configure.mako | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/templates/configure.mako b/tailbone/templates/configure.mako index 93d059dd..336ea67c 100644 --- a/tailbone/templates/configure.mako +++ b/tailbone/templates/configure.mako @@ -36,7 +36,7 @@ <div class="level-item"> <p class="block"> - This tool lets you modify the ${config_title} configuration. + This page lets you modify the configuration for ${config_title}. </p> </div> From e99c0016739a346ca3d50623e4b52a0e23ebaf02 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 16 Dec 2021 14:54:09 -0600 Subject: [PATCH 0551/1681] Let config decide which versions of vue.js and buefy to use --- tailbone/subscribers.py | 12 +++++++++++- tailbone/templates/themes/falafel/base.mako | 10 +++++----- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 6fbced82..6677cf33 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -157,8 +157,18 @@ def before_render(event): renderer_globals['background_color'] = request.rattail_config.get( 'tailbone', 'background_color') - # maybe set custom stylesheet for Buefy themes + # buefy themes get some extra treatment if should_use_buefy(request): + + # declare vue.js and buefy versions to use. the default + # values here are "quite conservative" as of this writing, + # perhaps too much so, but at least they should work fine. + renderer_globals['vue_version'] = request.rattail_config.get( + 'tailbone', 'vue_version', default='2.6.10') + renderer_globals['buefy_version'] = request.rattail_config.get( + 'tailbone', 'buefy_version', default='0.8.6') + + # maybe set custom stylesheet css = None if request.user: css = request.rattail_config.get('tailbone.{}'.format(request.user.uuid), diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index 2c2dd2ce..78fcd4d7 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -110,16 +110,16 @@ </%def> <%def name="vuejs()"> - ## Vue.js (last known good @ 2.6.10) - ${h.javascript_link('https://unpkg.com/vue/dist/vue.min.js')} + ${h.javascript_link('https://unpkg.com/vue@{}/dist/vue.js'.format(vue_version))} ## vue-resource ## (needed for e.g. this.$http.get() calls, used by grid at least) + ## TODO: make this configurable also ${h.javascript_link('https://cdn.jsdelivr.net/npm/vue-resource@1.5.1')} </%def> <%def name="buefy()"> - ${h.javascript_link('https://unpkg.com/buefy@0.8.6/dist/buefy.min.js')} + ${h.javascript_link('https://unpkg.com/buefy@{}/dist/buefy.min.js'.format(buefy_version))} </%def> <%def name="fontawesome()"> @@ -160,8 +160,8 @@ ## custom Buefy CSS ${h.stylesheet_link(buefy_css)} % else: - ## Buefy 0.7.4 - ${h.stylesheet_link('https://unpkg.com/buefy@0.7.4/dist/buefy.min.css')} + ## upstream Buefy CSS + ${h.stylesheet_link('https://unpkg.com/buefy@{}/dist/buefy.min.css'.format(buefy_version))} % endif </%def> From da6c782ac3209318236d536ed1b174c7c3a4310e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 16 Dec 2021 20:05:56 -0600 Subject: [PATCH 0552/1681] Fix how fallback/default buefy and vue.js versions are used --- tailbone/subscribers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 6677cf33..bce94a98 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -164,9 +164,9 @@ def before_render(event): # values here are "quite conservative" as of this writing, # perhaps too much so, but at least they should work fine. renderer_globals['vue_version'] = request.rattail_config.get( - 'tailbone', 'vue_version', default='2.6.10') + 'tailbone', 'vue_version') or '2.6.10' renderer_globals['buefy_version'] = request.rattail_config.get( - 'tailbone', 'buefy_version', default='0.8.6') + 'tailbone', 'buefy_version') or '0.8.6' # maybe set custom stylesheet css = None From 099b6915f4ad1e189e979711e7fba6dc0b50d9fe Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 17 Dec 2021 09:17:35 -0600 Subject: [PATCH 0553/1681] 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 85f6cf4c..a0877466 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.186 (2021-12-17) +-------------------- + +* Render "pretty" UPC by default, for batch row form fields. + +* Let config decide which versions of vue.js and buefy to use. + + 0.8.185 (2021-12-15) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 1d4748a2..bb8ce909 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.185' +__version__ = '0.8.186' From 30f95e2f08884d605f8ddac5c2ced67287bfd130 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 17 Dec 2021 19:22:48 -0600 Subject: [PATCH 0554/1681] Add common configuration logic for "input file templates" just used in one batch so far but should be useful for many more..once can get around to migrating them had to rework the configuration logic to use HTML form instead of JSON, to allow for the file uploads --- tailbone/templates/configure.mako | 146 ++++++++--- tailbone/templates/datasync/configure.mako | 14 +- tailbone/templates/importing/configure.mako | 10 +- tailbone/templates/master/index.mako | 5 + tailbone/templates/products/configure.mako | 12 +- tailbone/templates/receiving/configure.mako | 33 ++- .../reports/generated/configure.mako | 6 +- .../templates/settings/email/configure.mako | 6 +- tailbone/templates/vendors/configure.mako | 6 +- tailbone/views/batch/core.py | 6 + tailbone/views/datasync.py | 15 +- tailbone/views/importing.py | 2 +- tailbone/views/master.py | 239 +++++++++++++++++- 13 files changed, 405 insertions(+), 95 deletions(-) diff --git a/tailbone/templates/configure.mako b/tailbone/templates/configure.mako index 336ea67c..de2b4e78 100644 --- a/tailbone/templates/configure.mako +++ b/tailbone/templates/configure.mako @@ -53,6 +53,79 @@ </div> </%def> +<%def name="input_file_template_field(key)"> + <% tmpl = input_file_templates[key] %> + <b-field grouped> + + <b-field label="${tmpl['label']}"> + <b-select name="${tmpl['setting_mode']}" + v-model="inputFileTemplateSettings['${tmpl['setting_mode']}']" + @input="settingsNeedSaved = true"> + <option value="default">use default</option> + <option value="hosted">use uploaded file</option> + <option value="external">use other URL</option> + </b-select> + </b-field> + + <b-field label="File" + v-show="inputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted'" + :message="inputFileTemplateSettings['${tmpl['setting_file']}'] ? 'This file lives on disk at: ${input_file_option_dirs[tmpl['key']]}' : null"> + <b-select name="${tmpl['setting_file']}" + v-model="inputFileTemplateSettings['${tmpl['setting_file']}']" + @input="settingsNeedSaved = true"> + <option :value="null">-new-</option> + <option v-for="option in inputFileTemplateFileOptions['${tmpl['key']}']" + :key="option" + :value="option"> + {{ option }} + </option> + </b-select> + </b-field> + + <b-field label="Upload" + v-show="inputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted' && !inputFileTemplateSettings['${tmpl['setting_file']}']"> + + <b-field class="file is-primary" + :class="{'has-name': !!inputFileTemplateSettings['${tmpl['setting_file']}']}"> + <b-upload name="${tmpl['setting_file']}.upload" + v-model="inputFileTemplateUploads['${tmpl['key']}']" + class="file-label" + @input="settingsNeedSaved = true"> + <span class="file-cta"> + <b-icon class="file-icon" pack="fas" icon="upload"></b-icon> + <span class="file-label">Click to upload</span> + </span> + </b-upload> + <span v-if="inputFileTemplateUploads['${tmpl['key']}']" + class="file-name"> + {{ inputFileTemplateUploads['${tmpl['key']}'].name }} + </span> + </b-field> + + </b-field> + + <b-field label="URL" expanded + v-show="inputFileTemplateSettings['${tmpl['setting_mode']}'] == 'external'"> + <b-input name="${tmpl['setting_url']}" + v-model="inputFileTemplateSettings['${tmpl['setting_url']}']" + @input="settingsNeedSaved = true"> + </b-input> + </b-field> + + </b-field> +</%def> + +<%def name="input_file_templates_section()"> + <h3 class="block is-size-3">Input File Templates</h3> + <div class="block" style="padding-left: 2rem;"> + % for key in input_file_templates: + ${self.input_file_template_field(key)} + % endfor + </div> +</%def> + +<%def name="form_content()"></%def> + <%def name="page_content()"> ${parent.page_content()} @@ -106,6 +179,11 @@ </footer> </div> </b-modal> + + ${h.form(request.current_route_url(), enctype='multipart/form-data', ref='saveSettingsForm')} + ${h.csrf_token(request)} + ${self.form_content()} + ${h.end_form()} </%def> <%def name="modify_this_page_vars()"> @@ -116,6 +194,16 @@ ThisPageData.simpleSettings = ${json.dumps(simple_settings)|n} % endif + % if input_file_template_settings is not Undefined: + ThisPageData.inputFileTemplateSettings = ${json.dumps(input_file_template_settings)|n} + ThisPageData.inputFileTemplateFileOptions = ${json.dumps(input_file_options)|n} + ThisPageData.inputFileTemplateUploads = { + % for key in input_file_templates: + '${key}': null, + % endfor + } + % endif + ThisPageData.purgeSettingsShowDialog = false ThisPageData.purgingSettings = false @@ -127,41 +215,41 @@ this.purgeSettingsShowDialog = true } - ThisPage.methods.settingsCollectParams = function() { - % if simple_settings is not Undefined: - return {simple_settings: this.simpleSettings} - % else: - return {} + % if input_file_template_settings is not Undefined: + ThisPage.methods.validateInputFileTemplateSettings = function() { + % for tmpl in six.itervalues(input_file_templates): + if (this.inputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted') { + if (!this.inputFileTemplateSettings['${tmpl['setting_file']}']) { + if (!this.inputFileTemplateUploads['${tmpl['key']}']) { + return "You must provide a file to upload for the ${tmpl['label']} template." + } + } + } + % endfor + } + % endif + + ThisPage.methods.validateSettings = function() { + let msg + + % if input_file_template_settings is not Undefined: + msg = this.validateInputFileTemplateSettings() + if (msg) { + return msg + } % endif } ThisPage.methods.saveSettings = function() { - this.savingSettings = true - - let url = ${json.dumps(request.current_route_url())|n} - let params = this.settingsCollectParams() - let headers = { - 'X-CSRF-TOKEN': this.csrftoken, + let msg = this.validateSettings() + if (msg) { + alert(msg) + return } - this.$http.post(url, params, {headers: headers}).then((response) => { - if (response.data.success) { - this.settingsNeedSaved = false - location.href = url // reload page - } else { - this.$buefy.toast.open({ - message: "Save failed: " + (response.data.error || "(unknown error)"), - type: 'is-danger', - duration: 4000, // 4 seconds - }) - } - }).catch((error) => { - this.$buefy.toast.open({ - message: "Save failed: (unknown error)", - type: 'is-danger', - duration: 4000, // 4 seconds - }) - }) + this.savingSettings = true + this.settingsNeedSaved = false + this.$refs.saveSettingsForm.submit() } // cf. https://stackoverflow.com/a/56551646 diff --git a/tailbone/templates/datasync/configure.mako b/tailbone/templates/datasync/configure.mako index 0bed21e3..ca57a468 100644 --- a/tailbone/templates/datasync/configure.mako +++ b/tailbone/templates/datasync/configure.mako @@ -44,8 +44,8 @@ </div> </%def> -<%def name="page_content()"> - ${parent.page_content()} +<%def name="form_content()"> + ${h.hidden('profiles', **{':value': 'JSON.stringify(profilesData)'})} <b-notification type="is-warning" :active.sync="showConfigFilesNote"> @@ -401,7 +401,8 @@ <b-field label="Restart Command" message="This will run as '${system_user}' system user - please configure sudoers as needed. Typical command is like: sudo supervisorctl restart poser:poser_datasync" expanded> - <b-input v-model="restartCommand" + <b-input name="restart_command" + v-model="restartCommand" @input="settingsNeedSaved = true"> </b-input> </b-field> @@ -675,13 +676,6 @@ } } - ThisPage.methods.settingsCollectParams = function() { - return { - profiles: this.profilesData, - restart_command: this.restartCommand, - } - } - % if request.has_perm('datasync.restart'): ThisPageData.restartingDatasync = false ThisPageData.restartDatasyncFormButtonText = "Restart Datasync" diff --git a/tailbone/templates/importing/configure.mako b/tailbone/templates/importing/configure.mako index ac215e1c..cbe8463c 100644 --- a/tailbone/templates/importing/configure.mako +++ b/tailbone/templates/importing/configure.mako @@ -1,8 +1,8 @@ ## -*- coding: utf-8; -*- <%inherit file="/configure.mako" /> -<%def name="page_content()"> - ${parent.page_content()} +<%def name="form_content()"> + ${h.hidden('handlers', **{':value': 'JSON.stringify(handlersData)'})} <h3 class="is-size-3">Designated Handlers</h3> @@ -180,12 +180,6 @@ this.editHandlerShowDialog = false } - ThisPage.methods.settingsCollectParams = function() { - return { - handlers: this.handlersData, - } - } - </script> </%def> diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako index f58a59d1..de58af83 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -165,6 +165,11 @@ % if master.configurable and master.has_perm('configure'): <li>${h.link_to("Configure {}".format(config_title), url('{}.configure'.format(route_prefix)))}</li> % endif + % if master.has_input_file_templates and master.has_perm('download_template'): + % for template in six.itervalues(input_file_templates): + <li>${h.link_to("Download {} Template".format(template['label']), template['effective_url'])}</li> + % endfor + % endif </%def> <%def name="grid_tools()"> diff --git a/tailbone/templates/products/configure.mako b/tailbone/templates/products/configure.mako index 045e5904..e3c21307 100644 --- a/tailbone/templates/products/configure.mako +++ b/tailbone/templates/products/configure.mako @@ -1,8 +1,7 @@ ## -*- coding: utf-8; -*- <%inherit file="/configure.mako" /> -<%def name="page_content()"> - ${parent.page_content()} +<%def name="form_content()"> <h3 class="block is-size-3">Key Field</h3> <div class="block" style="padding-left: 2rem;"> @@ -10,7 +9,8 @@ <b-field grouped> <b-field label="Key Field"> - <b-select v-model="simpleSettings['rattail.product.key']" + <b-select name="rattail.product.key" + v-model="simpleSettings['rattail.product.key']" @input="updateKeyTitle()"> <option value="upc">upc</option> <option value="item_id">item_id</option> @@ -19,7 +19,8 @@ </b-field> <b-field label="Key Field Label"> - <b-input v-model="simpleSettings['rattail.product.key_title']" + <b-input name="rattail.product.key_title" + v-model="simpleSettings['rattail.product.key_title']" @input="settingsNeedSaved = true"> </b-input> </b-field> @@ -32,7 +33,8 @@ <div class="block" style="padding-left: 2rem;"> <b-field message="If a product has an image in the DB, that will still be preferred."> - <b-checkbox v-model="simpleSettings['tailbone.products.show_pod_image']" + <b-checkbox name="tailbone.products.show_pod_image" + v-model="simpleSettings['tailbone.products.show_pod_image']" @input="settingsNeedSaved = true"> Show "POD" Images as fallback </b-checkbox> diff --git a/tailbone/templates/receiving/configure.mako b/tailbone/templates/receiving/configure.mako index 3b2a93e1..06ab3769 100644 --- a/tailbone/templates/receiving/configure.mako +++ b/tailbone/templates/receiving/configure.mako @@ -1,42 +1,46 @@ ## -*- coding: utf-8; -*- <%inherit file="/configure.mako" /> -<%def name="page_content()"> - ${parent.page_content()} +<%def name="form_content()"> <h3 class="block is-size-3">Supported Workflows</h3> <div class="block" style="padding-left: 2rem;"> <b-field> - <b-checkbox v-model="simpleSettings['rattail.batch.purchase.allow_receiving_from_scratch']" + <b-checkbox name="rattail.batch.purchase.allow_receiving_from_scratch" + v-model="simpleSettings['rattail.batch.purchase.allow_receiving_from_scratch']" @input="settingsNeedSaved = true"> From Scratch </b-checkbox> </b-field> <b-field> - <b-checkbox v-model="simpleSettings['rattail.batch.purchase.allow_receiving_from_invoice']" + <b-checkbox name="rattail.batch.purchase.allow_receiving_from_invoice" + v-model="simpleSettings['rattail.batch.purchase.allow_receiving_from_invoice']" @input="settingsNeedSaved = true"> From Invoice </b-checkbox> </b-field> <b-field> - <b-checkbox v-model="simpleSettings['rattail.batch.purchase.allow_receiving_from_purchase_order']" + <b-checkbox name="rattail.batch.purchase.allow_receiving_from_purchase_order" + v-model="simpleSettings['rattail.batch.purchase.allow_receiving_from_purchase_order']" @input="settingsNeedSaved = true"> From Purchase Order </b-checkbox> </b-field> <b-field> - <b-checkbox v-model="simpleSettings['rattail.batch.purchase.allow_receiving_from_purchase_order_with_invoice']" + <b-checkbox name="rattail.batch.purchase.allow_receiving_from_purchase_order_with_invoice" + v-model="simpleSettings['rattail.batch.purchase.allow_receiving_from_purchase_order_with_invoice']" @input="settingsNeedSaved = true"> From Purchase Order, with Invoice </b-checkbox> </b-field> <b-field> - <b-checkbox v-model="simpleSettings['rattail.batch.purchase.allow_truck_dump_receiving']" + <b-checkbox name="rattail.batch.purchase.allow_truck_dump_receiving" + v-model="simpleSettings['rattail.batch.purchase.allow_truck_dump_receiving']" @input="settingsNeedSaved = true"> Truck Dump </b-checkbox> @@ -48,14 +52,16 @@ <div class="block" style="padding-left: 2rem;"> <b-field message="NB. Allow Cases setting also affects Ordering behavior."> - <b-checkbox v-model="simpleSettings['rattail.batch.purchase.allow_cases']" + <b-checkbox name="rattail.batch.purchase.allow_cases" + v-model="simpleSettings['rattail.batch.purchase.allow_cases']" @input="settingsNeedSaved = true"> Allow Cases </b-checkbox> </b-field> <b-field> - <b-checkbox v-model="simpleSettings['rattail.batch.purchase.allow_expired_credits']" + <b-checkbox name="rattail.batch.purchase.allow_expired_credits" + v-model="simpleSettings['rattail.batch.purchase.allow_expired_credits']" @input="settingsNeedSaved = true"> Allow "Expired" Credits </b-checkbox> @@ -67,21 +73,24 @@ <div class="block" style="padding-left: 2rem;"> <b-field message="TODO: this may also affect Ordering (?)"> - <b-checkbox v-model="simpleSettings['rattail.batch.purchase.mobile_images']" + <b-checkbox name="rattail.batch.purchase.mobile_images" + v-model="simpleSettings['rattail.batch.purchase.mobile_images']" @input="settingsNeedSaved = true"> Show Product Images </b-checkbox> </b-field> <b-field> - <b-checkbox v-model="simpleSettings['rattail.batch.purchase.mobile_quick_receive']" + <b-checkbox name="rattail.batch.purchase.mobile_quick_receive" + v-model="simpleSettings['rattail.batch.purchase.mobile_quick_receive']" @input="settingsNeedSaved = true"> Allow "Quick Receive" </b-checkbox> </b-field> <b-field> - <b-checkbox v-model="simpleSettings['rattail.batch.purchase.mobile_quick_receive_all']" + <b-checkbox name="rattail.batch.purchase.mobile_quick_receive_all" + v-model="simpleSettings['rattail.batch.purchase.mobile_quick_receive_all']" @input="settingsNeedSaved = true"> Allow "Quick Receive All" </b-checkbox> diff --git a/tailbone/templates/reports/generated/configure.mako b/tailbone/templates/reports/generated/configure.mako index 27e60afa..e8224f28 100644 --- a/tailbone/templates/reports/generated/configure.mako +++ b/tailbone/templates/reports/generated/configure.mako @@ -1,14 +1,14 @@ ## -*- coding: utf-8; -*- <%inherit file="/configure.mako" /> -<%def name="page_content()"> - ${parent.page_content()} +<%def name="form_content()"> <h3 class="block is-size-3">Generating</h3> <div class="block" style="padding-left: 2rem;"> <b-field message="If not set, reports are shown as simple list of hyperlinks."> - <b-checkbox v-model="simpleSettings['tailbone.reporting.choosing_uses_form']" + <b-checkbox name="tailbone.reporting.choosing_uses_form" + v-model="simpleSettings['tailbone.reporting.choosing_uses_form']" @input="settingsNeedSaved = true"> Show report chooser as form, with dropdown </b-checkbox> diff --git a/tailbone/templates/settings/email/configure.mako b/tailbone/templates/settings/email/configure.mako index f212f635..228eb1a4 100644 --- a/tailbone/templates/settings/email/configure.mako +++ b/tailbone/templates/settings/email/configure.mako @@ -1,14 +1,14 @@ ## -*- coding: utf-8; -*- <%inherit file="/configure.mako" /> -<%def name="page_content()"> - ${parent.page_content()} +<%def name="form_content()"> <h3 class="block is-size-3">Sending</h3> <div class="block" style="padding-left: 2rem;"> <b-field> - <b-checkbox v-model="simpleSettings['rattail.mail.record_attempts']" + <b-checkbox name="rattail.mail.record_attempts" + v-model="simpleSettings['rattail.mail.record_attempts']" @input="settingsNeedSaved = true"> Make record of all attempts to send email </b-checkbox> diff --git a/tailbone/templates/vendors/configure.mako b/tailbone/templates/vendors/configure.mako index e1a47644..0bcb4a9e 100644 --- a/tailbone/templates/vendors/configure.mako +++ b/tailbone/templates/vendors/configure.mako @@ -1,14 +1,14 @@ ## -*- coding: utf-8; -*- <%inherit file="/configure.mako" /> -<%def name="page_content()"> - ${parent.page_content()} +<%def name="form_content()"> <h3 class="block is-size-3">Display</h3> <div class="block" style="padding-left: 2rem;"> <b-field message="If not set, vendor chooser is a dropdown field."> - <b-checkbox v-model="simpleSettings['rattail.vendor.use_autocomplete']" + <b-checkbox name="rattail.vendor.use_autocomplete" + v-model="simpleSettings['rattail.vendor.use_autocomplete']" @input="settingsNeedSaved = true"> Show vendor chooser as autocomplete field </b-checkbox> diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 985f5502..0eb956cf 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -83,6 +83,8 @@ class BatchMasterView(MasterView): has_worksheet = False has_worksheet_file = False + input_file_template_config_section = 'rattail.batch' + grid_columns = [ 'id', 'description', @@ -157,6 +159,10 @@ class BatchMasterView(MasterView): factory = self.get_handler_factory(self.rattail_config) return factory(self.rattail_config) + @property + def input_file_template_config_prefix(self): + return '{}.input_file_template'.format(self.batch_handler.batch_key) + def download_path(self, batch, filename): return self.rattail_config.batch_filepath(batch.batch_key, batch.uuid, filename) diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py index 03be846e..6c6db9f1 100644 --- a/tailbone/views/datasync.py +++ b/tailbone/views/datasync.py @@ -27,6 +27,7 @@ DataSync Views from __future__ import unicode_literals, absolute_import import getpass +import json import subprocess import logging @@ -132,7 +133,7 @@ class DataSyncThreadView(MasterView): settings = [] watch = [] - for profile in data['profiles']: + for profile in json.loads(data['profiles']): pkey = profile['key'] if profile['enabled']: watch.append(pkey) @@ -181,12 +182,12 @@ class DataSyncThreadView(MasterView): 'value': ', '.join(consumers)}, ]) - settings.extend([ - {'name': 'rattail.datasync.watch', - 'value': ', '.join(watch)}, - {'name': 'tailbone.datasync.restart', - 'value': data['restart_command']}, - ]) + if watch: + settings.append({'name': 'rattail.datasync.watch', + 'value': ', '.join(watch)}) + + settings.append({'name': 'tailbone.datasync.restart', + 'value': data['restart_command']}) return settings diff --git a/tailbone/views/importing.py b/tailbone/views/importing.py index b63e4d43..d93e4cfd 100644 --- a/tailbone/views/importing.py +++ b/tailbone/views/importing.py @@ -560,7 +560,7 @@ cd {prefix} def configure_gather_settings(self, data): settings = [] - for handler in data['handlers']: + for handler in json.loads(data['handlers']): key = handler['key'] settings.extend([ diff --git a/tailbone/views/master.py b/tailbone/views/master.py index a9d11377..2146ff97 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 shutil import tempfile import logging @@ -36,11 +37,9 @@ import json import six import sqlalchemy as sa from sqlalchemy import orm - import sqlalchemy_continuum as continuum from sqlalchemy_utils.functions import get_primary_keys, get_columns - from rattail.db import model, Session as RattailSession from rattail.db.continuum import model_transaction_query from rattail.util import prettify, OrderedDict, simple_error @@ -57,6 +56,7 @@ from pyramid import httpexceptions from pyramid.renderers import get_renderer, render_to_response, render from pyramid.response import FileResponse from webhelpers2.html import HTML, tags +from webob.compat import cgi_FieldStorage from tailbone import forms, grids, diffs from tailbone.views import View @@ -114,6 +114,7 @@ class MasterView(View): execute_progress_initial_msg = None supports_prev_next = False supports_import_batch_from_file = False + has_input_file_templates = False configurable = False # set to True to add "View *global* Objects" permission, and @@ -1467,6 +1468,26 @@ class MasterView(View): Return a content type for a file download, if known. """ + def download_input_file_template(self): + """ + View for downloading an input file template. + """ + key = self.request.GET['key'] + filespec = self.request.GET['file'] + + matches = [tmpl for tmpl in self.get_input_file_templates() + if tmpl['key'] == key] + if not matches: + raise self.notfound() + + template = matches[0] + templatesdir = os.path.join(self.rattail_config.datadir(), + 'templates', 'input_files', + self.get_route_prefix()) + basedir = os.path.join(templatesdir, template['key']) + path = os.path.join(basedir, filespec) + return self.file_response(path) + def edit(self): """ View for editing an existing model record. @@ -2230,6 +2251,90 @@ class MasterView(View): kwargs['db_picker_options'] = [tags.Option(k) for k in engines] kwargs['db_picker_selected'] = selected + # add info for downloadable input file templates, if any + if self.has_input_file_templates: + templates = self.normalize_input_file_templates() + kwargs['input_file_templates'] = OrderedDict([(tmpl['key'], tmpl) + for tmpl in templates]) + + return kwargs + + def get_input_file_templates(self): + return [] + + def normalize_input_file_templates(self, templates=None, + include_file_options=False): + if templates is None: + templates = self.get_input_file_templates() + + route_prefix = self.get_route_prefix() + + if include_file_options: + templatesdir = os.path.join(self.rattail_config.datadir(), + 'templates', 'input_files', + route_prefix) + + for template in templates: + + if 'config_section' not in template: + template['config_section'] = self.input_file_template_config_section + section = template['config_section'] + + if 'config_prefix' not in template: + template['config_prefix'] = '{}.{}'.format( + self.input_file_template_config_prefix, + template['key']) + prefix = template['config_prefix'] + + for key in ('mode', 'file', 'url'): + + if 'option_{}'.format(key) not in template: + template['option_{}'.format(key)] = '{}.{}'.format(prefix, key) + + if 'setting_{}'.format(key) not in template: + template['setting_{}'.format(key)] = '{}.{}'.format( + section, + template['option_{}'.format(key)]) + + if key not in template: + value = self.rattail_config.get( + section, + template['option_{}'.format(key)]) + if value is not None: + template[key] = value + + template.setdefault('mode', 'default') + template.setdefault('file', None) + template.setdefault('url', template['default_url']) + + if include_file_options: + options = [] + basedir = os.path.join(templatesdir, template['key']) + if os.path.exists(basedir): + for name in sorted(os.listdir(basedir)): + if len(name) == 4 and name.isdigit(): + files = os.listdir(os.path.join(basedir, name)) + if len(files) == 1: + options.append(os.path.join(name, files[0])) + template['file_options'] = options + template['file_options_dir'] = basedir + + if template['mode'] == 'external': + template['effective_url'] = template['url'] + elif template['mode'] == 'hosted': + template['effective_url'] = self.request.route_url( + '{}.download_input_file_template'.format(route_prefix), + _query={'key': template['key'], + 'file': template['file']}) + else: + template['effective_url'] = template['default_url'] + + return templates + + def template_kwargs_index(self, **kwargs): + """ + Method stub, so subclass can always invoke super() for it. + """ return kwargs def template_kwargs_create(self, **kwargs): @@ -4043,16 +4148,71 @@ class MasterView(View): self.request.session.flash("Settings have been removed.") return self.redirect(self.request.current_route_url()) else: - data = self.request.json_body + data = self.request.POST + + # collect any uploaded files + uploads = {} + for key, value in six.iteritems(data): + if isinstance(value, cgi_FieldStorage): + tempdir = tempfile.mkdtemp() + filename = os.path.basename(value.filename) + filepath = os.path.join(tempdir, filename) + with open(filepath, 'wb') as f: + f.write(value.file.read()) + uploads[key] = { + 'filedir': tempdir, + 'filename': filename, + 'filepath': filepath, + } + + # process any uploads first + if uploads: + self.configure_process_uploads(uploads, data) + + # then gather/save settings settings = self.configure_gather_settings(data) self.configure_remove_settings() self.configure_save_settings(settings) self.request.session.flash("Settings have been saved.") - return self.json_response({'success': True}) + return self.redirect(self.request.current_route_url()) context = self.configure_get_context() return self.render_to_response('configure', context) + def configure_process_uploads(self, uploads, data): + if self.has_input_file_templates: + templatesdir = os.path.join(self.rattail_config.datadir(), + 'templates', 'input_files', + self.get_route_prefix()) + + def get_next_filedir(basedir): + nextid = 1 + while True: + path = os.path.join(basedir, '{:04d}'.format(nextid)) + if not os.path.exists(path): + # this should fail if there happens to be a race + # condition and someone else got to this id first + os.mkdir(path) + return path + nextid += 1 + + for template in self.normalize_input_file_templates(): + key = '{}.upload'.format(template['setting_file']) + if key in uploads: + assert self.request.POST[template['setting_mode']] == 'hosted' + assert not self.request.POST[template['setting_file']] + info = uploads[key] + basedir = os.path.join(templatesdir, template['key']) + if not os.path.exists(basedir): + os.makedirs(basedir) + filedir = get_next_filedir(basedir) + filepath = os.path.join(filedir, info['filename']) + shutil.copyfile(info['filepath'], filepath) + shutil.rmtree(info['filedir']) + numdir = os.path.basename(filedir) + data[template['setting_file']] = os.path.join(numdir, + info['filename']) + def configure_get_simple_settings(self): """ If you have some "simple" settings, each of which basically @@ -4120,22 +4280,34 @@ class MasterView(View): context['simple_settings'] = settings + # add settings for downloadable input file templates, if any + if self.has_input_file_templates: + settings = {} + file_options = {} + file_option_dirs = {} + for template in self.normalize_input_file_templates( + include_file_options=True): + settings[template['setting_mode']] = template['mode'] + settings[template['setting_file']] = template['file'] + settings[template['setting_url']] = template['url'] + file_options[template['key']] = template['file_options'] + file_option_dirs[template['key']] = template['file_options_dir'] + context['input_file_template_settings'] = settings + context['input_file_options'] = file_options + context['input_file_option_dirs'] = file_option_dirs + return context def configure_gather_settings(self, data): settings = [] + # maybe collect "simple" settings simple_settings = self.configure_get_simple_settings() - if simple_settings and 'simple_settings' in data: - - data_settings = data['simple_settings'] + if simple_settings: for simple in simple_settings: name = self.configure_get_name_for_simple_setting(simple) - value = None - - if name in data_settings: - value = data_settings[name] + value = data.get(name) if simple.get('type') is bool: value = six.text_type(bool(value)).lower() @@ -4145,14 +4317,45 @@ class MasterView(View): settings.append({'name': name, 'value': value}) + # maybe also collect input file template settings + if self.has_input_file_templates: + for template in self.normalize_input_file_templates(): + + # mode + settings.append({'name': template['setting_mode'], + 'value': data.get(template['setting_mode'])}) + + # file + value = data.get(template['setting_file']) + if value: + # nb. avoid saving if empty, so can remain "null" + settings.append({'name': template['setting_file'], + 'value': value}) + + # url + settings.append({'name': template['setting_url'], + 'value': data.get(template['setting_url'])}) + return settings def configure_remove_settings(self): + model = self.model + names = [] + simple_settings = self.configure_get_simple_settings() if simple_settings: - model = self.model - names = [self.configure_get_name_for_simple_setting(simple) - for simple in simple_settings] + names.extend([self.configure_get_name_for_simple_setting(simple) + for simple in simple_settings]) + + if self.has_input_file_templates: + for template in self.normalize_input_file_templates(): + names.extend([ + template['setting_mode'], + template['setting_file'], + template['setting_url'], + ]) + + if names: self.Session.query(model.Setting)\ .filter(model.Setting.name.in_(names))\ .delete(synchronize_session=False) @@ -4365,6 +4568,14 @@ class MasterView(View): config.add_tailbone_permission(permission_prefix, '{}.merge'.format(permission_prefix), "Merge 2 {}".format(model_title_plural)) + # download input file template + if cls.has_input_file_templates and cls.creatable: + config.add_route('{}.download_input_file_template'.format(route_prefix), + '{}/download-input-file-template'.format(url_prefix)) + config.add_view(cls, attr='download_input_file_template', + route_name='{}.download_input_file_template'.format(route_prefix), + permission='{}.create'.format(permission_prefix)) + # view if cls.viewable: config.add_tailbone_permission(permission_prefix, '{}.view'.format(permission_prefix), From 31dff0d35315306123eb19700044e68427fe52e1 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 17 Dec 2021 21:36:51 -0600 Subject: [PATCH 0555/1681] Add some standard CRUD buttons for buefy themes finally! also disable the permalink "feature" since it seems not useful --- tailbone/static/themes/falafel/css/layout.css | 7 +- tailbone/templates/master/delete.mako | 2 +- tailbone/templates/master/edit.mako | 2 +- tailbone/templates/master/form.mako | 2 +- tailbone/templates/master/index.mako | 2 +- tailbone/templates/master/view.mako | 10 ++- tailbone/templates/master/view_row.mako | 2 +- tailbone/templates/themes/falafel/base.mako | 89 ++++++++++++++++--- 8 files changed, 89 insertions(+), 27 deletions(-) diff --git a/tailbone/static/themes/falafel/css/layout.css b/tailbone/static/themes/falafel/css/layout.css index 20fcf36e..3a292cac 100644 --- a/tailbone/static/themes/falafel/css/layout.css +++ b/tailbone/static/themes/falafel/css/layout.css @@ -51,14 +51,9 @@ header .navbar-item.nested { padding-left: 2.5rem; } -header .level #current-context, -header .level-left #current-context { +header span.header-text { font-size: 2em; font-weight: bold; -} - -header .level #current-context span, -header .level-left #current-context span { margin-right: 10px; } diff --git a/tailbone/templates/master/delete.mako b/tailbone/templates/master/delete.mako index 444c4e1d..62f28241 100644 --- a/tailbone/templates/master/delete.mako +++ b/tailbone/templates/master/delete.mako @@ -11,7 +11,7 @@ % if master.editable and request.has_perm('{}.edit'.format(permission_prefix)): <li>${h.link_to("Edit this {}".format(model_title), action_url('edit', instance))}</li> % endif - % if master.creatable and master.show_create_link and request.has_perm('{}.create'.format(permission_prefix)): + % if not use_buefy and master.creatable and master.show_create_link and master.has_perm('create'): % if master.creates_multiple: <li>${h.link_to("Create new {}".format(model_title_plural), url('{}.create'.format(route_prefix)))}</li> % else: diff --git a/tailbone/templates/master/edit.mako b/tailbone/templates/master/edit.mako index febd0bcd..1aae24b4 100644 --- a/tailbone/templates/master/edit.mako +++ b/tailbone/templates/master/edit.mako @@ -27,7 +27,7 @@ <li>${h.link_to("View this {}".format(model_title), action_url('view', instance))}</li> % endif ${self.context_menu_item_delete()} - % if master.creatable and master.show_create_link and request.has_perm('{}.create'.format(permission_prefix)): + % if not use_buefy and master.creatable and master.show_create_link and master.has_perm('create'): % if master.creates_multiple: <li>${h.link_to("Create new {}".format(model_title_plural), url('{}.create'.format(route_prefix)))}</li> % else: diff --git a/tailbone/templates/master/form.mako b/tailbone/templates/master/form.mako index 6f67f77e..a37e3f91 100644 --- a/tailbone/templates/master/form.mako +++ b/tailbone/templates/master/form.mako @@ -2,7 +2,7 @@ <%inherit file="/form.mako" /> <%def name="context_menu_item_delete()"> - % if master.deletable and instance_deletable and request.has_perm('{}.delete'.format(permission_prefix)): + % if not use_buefy and master.deletable and instance_deletable and master.has_perm('delete'): % if master.delete_confirm == 'simple': <li> ## note, the `ref` here is for buefy only diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako index de58af83..ca0615ce 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -155,7 +155,7 @@ % if master.results_downloadable_xlsx and request.has_perm('{}.results_xlsx'.format(permission_prefix)): <li>${h.link_to("Download results as XLSX", url('{}.results_xlsx'.format(route_prefix)))}</li> % endif - % if master.creatable and master.show_create_link and request.has_perm('{}.create'.format(permission_prefix)): + % if not use_buefy and master.creatable and master.show_create_link and master.has_perm('create'): % if master.creates_multiple: <li>${h.link_to("Create new {}".format(model_title_plural), url('{}.create'.format(route_prefix)))}</li> % else: diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako index 94454bd9..37d60c39 100644 --- a/tailbone/templates/master/view.mako +++ b/tailbone/templates/master/view.mako @@ -48,22 +48,24 @@ </%def> <%def name="context_menu_items()"> - <li>${h.link_to("Permalink for this {}".format(model_title), action_url('view', instance))}</li> + ## TODO: either make this configurable, or just lose it. + ## nobody seems to ever find it useful in practice. + ## <li>${h.link_to("Permalink for this {}".format(model_title), action_url('view', instance))}</li> % if master.has_versions and request.rattail_config.versioning_enabled() and request.has_perm('{}.versions'.format(permission_prefix)): <li>${h.link_to("Version History", action_url('versions', instance))}</li> % endif - % if master.editable and instance_editable and request.has_perm('{}.edit'.format(permission_prefix)): + % if not use_buefy and master.editable and instance_editable and master.has_perm('edit'): <li>${h.link_to("Edit this {}".format(model_title), action_url('edit', instance))}</li> % endif ${self.context_menu_item_delete()} - % if master.creatable and master.show_create_link and request.has_perm('{}.create'.format(permission_prefix)): + % if not use_buefy and master.creatable and master.show_create_link and master.has_perm('create'): % if master.creates_multiple: <li>${h.link_to("Create new {}".format(model_title_plural), url('{}.create'.format(route_prefix)))}</li> % else: <li>${h.link_to("Create a new {}".format(model_title), url('{}.create'.format(route_prefix)))}</li> % endif % endif - % if master.cloneable and request.has_perm('{}.clone'.format(permission_prefix)): + % if not use_buefy and master.cloneable and master.has_perm('clone'): <li>${h.link_to("Clone this as new {}".format(model_title), url('{}.clone'.format(route_prefix), uuid=instance.uuid))}</li> % endif % if master.touchable and request.has_perm('{}.touch'.format(permission_prefix)): diff --git a/tailbone/templates/master/view_row.mako b/tailbone/templates/master/view_row.mako index 29a77497..255caf69 100644 --- a/tailbone/templates/master/view_row.mako +++ b/tailbone/templates/master/view_row.mako @@ -12,7 +12,7 @@ % if master.rows_editable and instance_editable and request.has_perm('{}.edit'.format(permission_prefix)): <li>${h.link_to("Edit this {}".format(model_title), action_url('edit', instance))}</li> % endif - % if instance_deletable and master.has_perm('delete_row'): + % if not use_buefy and instance_deletable and master.has_perm('delete_row'): <li>${h.link_to("Delete this {}".format(model_title), action_url('delete', instance))}</li> % endif % if rows_creatable and request.has_perm('{}.create'.format(permission_prefix)): diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index 78fcd4d7..7b168a5d 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -274,24 +274,48 @@ <div id="current-context" class="level-item"> % if master: % if master.listing: - <span>${index_title}</span> + <span class="header-text"> + ${index_title} + </span> + % if use_buefy and master.creatable and master.show_create_link and master.has_perm('create'): + <once-button type="is-primary" + tag="a" href="${url('{}.create'.format(route_prefix))}" + icon-left="plus" + style="margin-left: 1rem;" + text="Create New"> + </once-button> + % endif % elif index_url: - ${h.link_to(index_title, index_url)} + <span class="header-text"> + ${h.link_to(index_title, index_url)} + </span> % if parent_url is not Undefined: - <span> »</span> - ${h.link_to(parent_title, parent_url)} + <span class="header-text"> + » + </span> + <span class="header-text"> + ${h.link_to(parent_title, parent_url)} + </span> % elif instance_url is not Undefined: - <span> »</span> - ${h.link_to(instance_title, instance_url)} + <span class="header-text"> + » + </span> + <span class="header-text"> + ${h.link_to(instance_title, instance_url)} + </span> % endif % if master.viewing and grid_index: ${grid_index_nav()} % endif % else: - <span>${index_title}</span> + <span class="header-text"> + ${index_title} + </span> % endif % elif index_title: - <span>${index_title}</span> + <span class="header-text"> + ${index_title} + </span> % endif </div> @@ -384,8 +408,49 @@ <h1 class="title" v-html="contentTitleHTML"></h1> </div> </div> - % if show_prev_next is not Undefined and show_prev_next: - <div class="level-right"> + <div class="level-right"> + % if use_buefy and master and master.viewing: + ## TODO: is there a better way to check if viewing parent? + % if parent_instance is Undefined: + % if master.editable and instance_editable and master.has_perm('edit'): + <div class="level-item"> + <once-button tag="a" href="${action_url('edit', instance)}" + icon-left="edit" + text="Edit This"> + </once-button> + </div> + % endif + % if master.cloneable and master.has_perm('clone'): + <div class="level-item"> + <once-button tag="a" href="${action_url('clone', instance)}" + icon-left="object-ungroup" + text="Clone This"> + </once-button> + </div> + % endif + % if master.deletable and instance_deletable and master.has_perm('delete'): + <div class="level-item"> + <once-button tag="a" href="${action_url('delete', instance)}" + type="is-danger" + icon-left="trash" + text="Delete This"> + </once-button> + </div> + % endif + % else: + ## viewing row + % if instance_deletable and master.has_perm('delete_row'): + <div class="level-item"> + <once-button tag="a" href="${action_url('delete', instance)}" + type="is-danger" + icon-left="trash" + text="Delete This"> + </once-button> + </div> + % endif + % endif + % endif + % if show_prev_next is not Undefined and show_prev_next: % if prev_url: <div class="level-item"> ${h.link_to(u"« Older", prev_url, class_='button autodisable')} @@ -404,8 +469,8 @@ ${h.link_to(u"Newer »", '#', class_='button', disabled='disabled')} </div> % endif - </div> - % endif + % endif + </div> </div> </section> % endif From e97b8a9f7ed92463ff7d42cd8bacc8aea1f66f5b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 20 Dec 2021 14:27:47 -0600 Subject: [PATCH 0556/1681] 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 a0877466..d6da0447 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.187 (2021-12-20) +-------------------- + +* Add common configuration logic for "input file templates". + +* Add some standard CRUD buttons for buefy themes. + + 0.8.186 (2021-12-17) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index bb8ce909..c1c66bcb 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.186' +__version__ = '0.8.187' From a6f608e8ccf9abaf332ce78f152f4b26c037d491 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 20 Dec 2021 14:56:25 -0600 Subject: [PATCH 0557/1681] Flag discontinued items for main Products grid no styling is applied but custom app can do so --- tailbone/views/products.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index e6d2b7d4..7945b5db 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -664,6 +664,8 @@ class ProductView(MasterView): classes = [] if product.not_for_sale: classes.append('not-for-sale') + if product.discontinued: + classes.append('discontinued') if product.deleted: classes.append('deleted') if classes: From 408bffb7754e324b21efdfda4805b60a68b5046f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 20 Dec 2021 14:58:01 -0600 Subject: [PATCH 0558/1681] 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 d6da0447..787de2e6 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.188 (2021-12-20) +-------------------- + +* Flag discontinued items for main Products grid. + + 0.8.187 (2021-12-20) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index c1c66bcb..3aae353e 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.187' +__version__ = '0.8.188' From c0db03bc28618816c27e0fb66ebed54e55650db2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 22 Dec 2021 12:06:00 -0600 Subject: [PATCH 0559/1681] Add basic "pending product" support for new custorder batch --- tailbone/templates/custorders/configure.mako | 72 ++++ tailbone/templates/custorders/create.mako | 370 ++++++++++++++++--- tailbone/templates/master/delete.mako | 5 +- tailbone/templates/master/edit.mako | 2 +- tailbone/templates/products/view.mako | 2 +- tailbone/templates/themes/falafel/base.mako | 171 +++++---- tailbone/views/batch/core.py | 10 - tailbone/views/customers.py | 13 +- tailbone/views/custorders/batch.py | 41 +- tailbone/views/custorders/items.py | 4 + tailbone/views/custorders/orders.py | 264 +++++++++---- tailbone/views/master.py | 49 +++ tailbone/views/products.py | 75 +++- 13 files changed, 844 insertions(+), 234 deletions(-) create mode 100644 tailbone/templates/custorders/configure.mako diff --git a/tailbone/templates/custorders/configure.mako b/tailbone/templates/custorders/configure.mako new file mode 100644 index 00000000..e3e47054 --- /dev/null +++ b/tailbone/templates/custorders/configure.mako @@ -0,0 +1,72 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="form_content()"> + + <h3 class="block is-size-3">Customer Handling</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field message="If not set, only a Person is required."> + <b-checkbox name="rattail.custorders.new_order_requires_customer" + v-model="simpleSettings['rattail.custorders.new_order_requires_customer']" + @input="settingsNeedSaved = true"> + Require a Customer account + </b-checkbox> + </b-field> + + <b-field message="If not set, default contact info is always assumed."> + <b-checkbox name="rattail.custorders.new_orders.allow_contact_info_choice" + v-model="simpleSettings['rattail.custorders.new_orders.allow_contact_info_choice']" + @input="settingsNeedSaved = true"> + Allow user to choose contact info + </b-checkbox> + </b-field> + + <b-field message="Only applies if user is allowed to choose contact info."> + <b-checkbox name="rattail.custorders.new_orders.allow_contact_info_create" + v-model="simpleSettings['rattail.custorders.new_orders.allow_contact_info_create']" + @input="settingsNeedSaved = true"> + Allow user to enter new contact info + </b-checkbox> + </b-field> + + <p class="block"> + If you allow users to enter new contact info, the default action + when the order is submitted, is to send email with details of + the new contact info. Settings for these are at: + </p> + + <ul class="list"> + <li class="list-item"> + ${h.link_to("New Phone Request", url('emailprofiles.view', key='new_phone_requested'))} + </li> + <li class="list-item"> + ${h.link_to("New Email Request", url('emailprofiles.view', key='new_email_requested'))} + </li> + </ul> + </div> + + <h3 class="block is-size-3">Product Handling</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field message="If set, user can enter details of an arbitrary new "pending" product."> + <b-checkbox name="rattail.custorders.allow_unknown_product" + v-model="simpleSettings['rattail.custorders.allow_unknown_product']" + @input="settingsNeedSaved = true"> + Allow creating orders for "unknown" products + </b-checkbox> + </b-field> + + <b-field> + <b-checkbox name="rattail.custorders.product_price_may_be_questionable" + v-model="simpleSettings['rattail.custorders.product_price_may_be_questionable']" + @input="settingsNeedSaved = true"> + Allow prices to be flagged as "questionable" + </b-checkbox> + </b-field> + + </div> +</%def> + + +${parent.body()} diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index 0071b61f..ff41f765 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -12,6 +12,18 @@ % endif </%def> +<%def name="render_instance_header_buttons()"> + ${parent.render_instance_header_buttons()} + % if use_buefy and master.configurable and master.has_perm('configure'): + <div class="level-item"> + <once-button tag="a" href="${url('{}.configure'.format(route_prefix))}" + icon-left="cog" + text="Configure"> + </once-button> + </div> + % endif +</%def> + <%def name="page_content()"> <br /> % if use_buefy: @@ -155,11 +167,11 @@ <div class="level-left"> <div class="level-item"> <div v-if="orderPhoneNumber"> - <p> + <p :class="addOtherPhoneNumber ? 'has-text-success': null"> {{ orderPhoneNumber }} </p> <p v-if="addOtherPhoneNumber" - class="is-size-7 is-italic"> + class="is-size-7 is-italic has-text-success"> will be added to customer record </p> </div> @@ -170,7 +182,7 @@ </div> % if allow_contact_info_choice: <div class="level-item" - % if restrict_contact_info: + % if not allow_contact_info_create: v-if="contactPhones.length > 1" % endif > @@ -203,7 +215,7 @@ </b-radio> </b-field> - % if not restrict_contact_info: + % if allow_contact_info_create: <b-field> <b-radio v-model="existingPhoneUUID" :native-value="null"> @@ -249,11 +261,11 @@ <div class="level-left"> <div class="level-item"> <div v-if="orderEmailAddress"> - <p> + <p :class="addOtherEmailAddress ? 'has-text-success' : null"> {{ orderEmailAddress }} </p> <p v-if="addOtherEmailAddress" - class="is-size-7 is-italic"> + class="is-size-7 is-italic has-text-success"> will be added to customer record </p> </div> @@ -264,7 +276,7 @@ </div> % if allow_contact_info_choice: <div class="level-item" - % if restrict_contact_info: + % if not allow_contact_info_create: v-if="contactEmails.length > 1" % endif > @@ -296,7 +308,7 @@ </b-radio> </b-field> - % if not restrict_contact_info: + % if allow_contact_info_create: <b-field> <b-radio v-model="existingEmailUUID" :native-value="null"> @@ -556,7 +568,13 @@ <span>{{ productCaseQuantity }}</span> </b-field> - <b-field label="Unit Price"> + <b-field label="Reg. Price" + v-if="productSalePriceDisplay"> + <span>{{ productUnitRegularPriceDisplay }}</span> + </b-field> + + <b-field label="Unit Price" + v-if="!productSalePriceDisplay"> <span % if product_price_may_be_questionable: :class="productPriceNeedsConfirmation ? 'has-background-warning' : ''" @@ -599,70 +617,172 @@ <br /> <div class="field"> - <b-radio v-model="productIsKnown" disabled + <b-radio v-model="productIsKnown" + % if not allow_unknown_product: + disabled + % endif :native-value="false"> Product is not yet in the system. </b-radio> </div> + <div v-show="!productIsKnown" + style="padding-left: 5rem;"> + + <b-field grouped> + + <b-field label="Brand"> + <b-input v-model="pendingProduct.brand_name"> + </b-input> + </b-field> + + <b-field label="Description" + :type="pendingProduct.description ? null : 'is-danger'"> + <b-input v-model="pendingProduct.description"> + </b-input> + </b-field> + + <b-field label="Unit Size"> + <b-input v-model="pendingProduct.size"> + </b-input> + </b-field> + + </b-field> + + <b-field grouped> + + <b-field :label="productKeyLabel"> + <b-input v-model="pendingProduct[productKeyField]"> + </b-input> + </b-field> + + <b-field label="Department"> + <b-select v-model="pendingProduct.department_uuid"> + <option :value="null">(not known)</option> + <option v-for="option in departmentOptions" + :key="option.value" + :value="option.value"> + {{ option.label }} + </option> + </b-select> + </b-field> + + <b-field label="Unit Reg. Price"> + <b-input v-model="pendingProduct.regular_price_amount" + type="number" step="0.01"> + </b-input> + </b-field> + + </b-field> + + <b-field grouped> + + <b-field label="Vendor"> + <b-input v-model="pendingProduct.vendor_name"> + </b-input> + </b-field> + + <b-field label="Vendor Item Code"> + <b-input v-model="pendingProduct.vendor_item_code"> + </b-input> + </b-field> + + <b-field label="Unit Cost"> + <b-input v-model="pendingProduct.unit_cost" + type="number" step="0.01" + style="width: 10rem;"> + </b-input> + </b-field> + + <b-field label="Case Size"> + <b-input v-model="pendingProduct.case_size" + type="number" step="0.01" + style="width: 7rem;"> + </b-input> + </b-field> + + </b-field> + + <b-field label="Notes"> + <b-input v-model="pendingProduct.notes" + type="textarea"> + </b-input> + </b-field> + + </div> </b-tab-item> <b-tab-item label="Quantity"> <div class="is-pulled-right has-text-centered"> <img :src="productImageURL" style="height: 150px; width: 150px; "/> - ## <p>{{ productKey }}</p> </div> <b-field grouped> <b-field label="Product" horizontal> - <span>{{ productDisplay }}</span> + <span :class="productIsKnown ? null : 'has-text-success'"> + {{ productIsKnown ? productDisplay : pendingProduct.brand_name + ' ' + pendingProduct.description + ' ' + pendingProduct.size }} + </span> </b-field> </b-field> <b-field grouped> <b-field label="Unit Size"> - <span>{{ productSize }}</span> + <span :class="productIsKnown ? null : 'has-text-success'"> + {{ productIsKnown ? productSize : pendingProduct.size }} + </span> </b-field> - <b-field label="Unit Price"> - <span - % if product_price_may_be_questionable: - :class="productPriceNeedsConfirmation ? 'has-background-warning' : ''" - % endif - > - {{ productUnitPriceDisplay }} + <b-field label="Reg. Price" + v-if="productSalePriceDisplay"> + <span> + {{ productUnitRegularPriceDisplay }} + </span> + </b-field> + + <b-field label="Unit Price" + v-if="!productSalePriceDisplay"> + <span :class="productIsKnown ? null : 'has-text-success'" + % if product_price_may_be_questionable: + :class="productPriceNeedsConfirmation ? 'has-background-warning' : ''" + % endif + > + {{ productIsKnown ? productUnitPriceDisplay : '$' + pendingProduct.regular_price_amount }} </span> </b-field> <b-field label="Sale Price" v-if="productSalePriceDisplay"> - <span class="has-background-warning"> + <span class="has-background-warning" + :class="productIsKnown ? null : 'has-text-success'"> {{ productSalePriceDisplay }} </span> </b-field> <b-field label="Sale Ends" v-if="productSaleEndsDisplay"> - <span class="has-background-warning"> + <span class="has-background-warning" + :class="productIsKnown ? null : 'has-text-success'"> {{ productSaleEndsDisplay }} </span> </b-field> <b-field label="Case Size"> - <span>{{ productCaseQuantity }}</span> + <span :class="productIsKnown ? null : 'has-text-success'"> + {{ productIsKnown ? productCaseQuantity : pendingProduct.case_size }} + </span> </b-field> <b-field label="Case Price"> <span - % if product_price_may_be_questionable: - :class="(productPriceNeedsConfirmation || productSalePriceDisplay) ? 'has-background-warning' : ''" - % else: - :class="productSalePriceDisplay ? 'has-background-warning' : ''" - % endif - > - {{ productCasePriceDisplay }} + % if product_price_may_be_questionable: + :class="{'has-text-success': !productIsKnown, 'has-background-warning': productPriceNeedsConfirmation || productSalePriceDisplay}" + % else: + :class="{'has-text-success': !productIsKnown, 'has-background-warning': !!productSalePriceDisplay}" + % endif + > + {{ getCasePriceDisplay() }} </span> </b-field> @@ -671,7 +791,9 @@ <b-field grouped> <b-field label="Quantity" horizontal> - <b-input v-model="productQuantity"></b-input> + <b-input v-model="productQuantity" + type="number" step="0.01"> + </b-input> </b-field> <b-select v-model="productUOM"> @@ -684,6 +806,14 @@ </b-field> + <b-field grouped> + <b-field label="Total Price"> + <span :class="productSalePriceDisplay ? 'has-background-warning': null"> + {{ getItemTotalPriceDisplay() }} + </span> + </b-field> + </b-field> + </b-tab-item> </b-tabs> @@ -692,9 +822,10 @@ Cancel </b-button> <b-button type="is-primary" + @click="itemDialogSave()" + :disabled="itemDialogSaveDisabled" icon-pack="fas" - icon-left="fas fa-save" - @click="itemDialogSave()"> + icon-left="save"> {{ itemDialogSaveButtonText }} </b-button> </div> @@ -807,60 +938,65 @@ </b-modal> <b-table v-if="items.length" - :data="items"> + :data="items" + :row-class="(row, i) => row.product_uuid ? null : 'has-text-success'"> <template slot-scope="props"> - <b-table-column field="product_upc_pretty" label="UPC"> - {{ props.row.product_upc_pretty }} + <b-table-column :label="productKeyLabel"> + {{ props.row.product_key }} </b-table-column> - <b-table-column field="product_brand" label="Brand"> + <b-table-column label="Brand"> {{ props.row.product_brand }} </b-table-column> - <b-table-column field="product_description" label="Description"> + <b-table-column label="Description"> {{ props.row.product_description }} </b-table-column> - <b-table-column field="product_size" label="Size"> + <b-table-column label="Size"> {{ props.row.product_size }} </b-table-column> - <b-table-column field="department_display" label="Department"> + <b-table-column label="Department"> {{ props.row.department_display }} </b-table-column> - <b-table-column field="order_quantity_display" label="Quantity"> + <b-table-column label="Quantity"> <span v-html="props.row.order_quantity_display"></span> </b-table-column> - <b-table-column field="unit_price_display" label="Unit Price"> + <b-table-column label="Unit Price"> <span % if product_price_may_be_questionable: :class="props.row.price_needs_confirmation ? 'has-background-warning' : ''" + % else: + :class="props.row.pricing_reflects_sale ? 'has-background-warning' : null" % endif > {{ props.row.unit_price_display }} </span> </b-table-column> - <b-table-column field="total_price_display" label="Total"> + <b-table-column label="Total"> <span % if product_price_may_be_questionable: :class="props.row.price_needs_confirmation ? 'has-background-warning' : ''" + % else: + :class="props.row.pricing_reflects_sale ? 'has-background-warning' : null" % endif > {{ props.row.total_price_display }} </span> </b-table-column> - <b-table-column field="vendor_display" label="Vendor"> + <b-table-column label="Vendor"> {{ props.row.vendor_display }} </b-table-column> <b-table-column field="actions" label="Actions"> <a href="#" class="grid-action" - @click.prevent="showEditItemDialog(props.index)"> + @click.prevent="showEditItemDialog(props.row)"> <i class="fas fa-edit"></i> Edit </a> @@ -974,11 +1110,16 @@ productDisplay: null, productUPC: null, productKey: null, + productKeyField: ${json.dumps(product_key_field)|n}, productKeyLabel: ${json.dumps(product_key_label)|n}, productSize: null, productCaseQuantity: null, + productUnitPrice: null, productUnitPriceDisplay: null, + productUnitRegularPriceDisplay: null, + productCasePrice: null, productCasePriceDisplay: null, + productSalePrice: null, productSalePriceDisplay: null, productSaleEndsDisplay: null, productURL: null, @@ -994,6 +1135,9 @@ productPriceNeedsConfirmation: false, % endif + pendingProduct: {}, + departmentOptions: ${json.dumps(department_options)|n}, + ## TODO: should find a better way to handle CSRF token csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}, @@ -1171,6 +1315,19 @@ return text }, + itemDialogSaveDisabled() { + if (this.productIsKnown) { + if (!this.productUUID) { + return true + } + } else { + if (!this.pendingProduct.description) { + return true + } + } + return false + }, + itemDialogSaveButtonText() { return this.editingItem ? "Update Item" : "Add Item" }, @@ -1522,6 +1679,71 @@ }, + getCasePriceDisplay() { + if (this.productIsKnown) { + return this.productCasePriceDisplay + } + + let casePrice = this.getItemCasePrice() + if (casePrice) { + return "$" + casePrice + } + }, + + getItemUnitPrice() { + if (this.productIsKnown) { + return this.productSalePrice || this.productUnitPrice + } + return this.pendingProduct.regular_price_amount + }, + + getItemCasePrice() { + if (this.productIsKnown) { + return this.productCasePrice + } + + if (this.pendingProduct.regular_price_amount) { + if (this.pendingProduct.case_size) { + let casePrice = this.pendingProduct.regular_price_amount * this.pendingProduct.case_size + casePrice = casePrice.toFixed(2) + return casePrice + } + } + }, + + getItemTotalPriceDisplay() { + let basePrice = null + if (this.productUOM == '${enum.UNIT_OF_MEASURE_CASE}') { + basePrice = this.getItemCasePrice() + } else { + basePrice = this.getItemUnitPrice() + } + + if (basePrice) { + let totalPrice = basePrice * this.productQuantity + if (totalPrice) { + totalPrice = totalPrice.toFixed(2) + return "$" + totalPrice + } + } + }, + + copyPendingProductAttrs(from, to) { + to.upc = from.upc + to.item_id = from.item_id + to.scancode = from.scancode + to.brand_name = from.brand_name + to.description = from.description + to.size = from.size + to.department_uuid = from.department_uuid + to.regular_price_amount = from.regular_price_amount + to.vendor_name = from.vendor_name + to.vendor_item_code = from.vendor_item_code + to.unit_cost = from.unit_cost + to.case_size = from.case_size + to.notes = from.notes + }, + showAddItemDialog() { this.customerPanelOpen = false this.editingItem = null @@ -1532,11 +1754,18 @@ this.productKey = null this.productSize = null this.productCaseQuantity = null + this.productUnitPrice = null this.productUnitPriceDisplay = null + this.productUnitRegularPriceDisplay = null + this.productCasePrice = null this.productCasePriceDisplay = null + this.productSalePrice = null this.productSalePriceDisplay = null this.productSaleEndsDisplay = null this.productImageURL = '${request.static_url('tailbone:static/img/product.png')}' + + this.pendingProduct = {} + this.productQuantity = 1 this.productUnitChoices = this.defaultUnitChoices this.productUOM = this.defaultUOM @@ -1581,8 +1810,12 @@ this.productKey = selected.key this.productSize = selected.size this.productCaseQuantity = selected.case_quantity + this.productUnitPrice = selected.unit_price this.productUnitPriceDisplay = selected.unit_price_display + this.productUnitRegularPriceDisplay = selected.unit_price_display + this.productCasePrice = selected.case_price this.productCasePriceDisplay = selected.case_price_display + this.productSalePrice = selected.sale_price this.productSalePriceDisplay = selected.sale_price_display this.productSaleEndsDisplay = selected.sale_ends_display this.productImageURL = selected.image_url @@ -1600,30 +1833,41 @@ this.showingItemDialog = true }, - showEditItemDialog(index) { - row = this.items[index] + showEditItemDialog(row) { this.editingItem = row - this.productIsKnown = true // TODO + + this.productIsKnown = !!row.product_uuid this.productUUID = row.product_uuid + this.pendingProduct = {} + if (row.pending_product) { + this.copyPendingProductAttrs(row.pending_product, + this.pendingProduct) + } + this.productDisplay = row.product_full_description this.productUPC = row.product_upc_pretty || row.product_upc this.productKey = row.product_key this.productSize = row.product_size this.productCaseQuantity = row.case_quantity this.productURL = row.product_url + this.productUnitPrice = row.unit_price this.productUnitPriceDisplay = row.unit_price_display + this.productUnitRegularPriceDisplay = row.unit_regular_price_display + this.productCasePrice = row.case_price this.productCasePriceDisplay = row.case_price_display - this.productSalePriceDisplay = row.sale_price_display + this.productSalePrice = row.sale_price + this.productSalePriceDisplay = row.unit_sale_price_display this.productSaleEndsDisplay = row.sale_ends_display - this.productImageURL = row.product_image_url - this.productQuantity = row.order_quantity - this.productUnitChoices = row.order_uom_choices - this.productUOM = row.order_uom + this.productImageURL = row.product_image_url || '${request.static_url('tailbone:static/img/product.png')}' % if product_price_may_be_questionable: this.productPriceNeedsConfirmation = row.price_needs_confirmation % endif + this.productQuantity = row.order_quantity + this.productUnitChoices = row.order_uom_choices + this.productUOM = row.order_uom + this.itemDialogTabIndex = 1 this.showingItemDialog = true }, @@ -1658,8 +1902,12 @@ this.productKey = null this.productSize = null this.productCaseQuantity = null + this.productUnitPrice = null this.productUnitPriceDisplay = null + this.productUnitRegularPriceDisplay = null + this.productCasePrice = null this.productCasePriceDisplay = null + this.productSalePrice = null this.productSalePriceDisplay = null this.productSaleEndsDisplay = null this.productURL = null @@ -1705,8 +1953,12 @@ this.productDisplay = response.data.full_description this.productSize = response.data.size this.productCaseQuantity = response.data.case_quantity + this.productUnitPrice = response.data.unit_price this.productUnitPriceDisplay = response.data.unit_price_display + this.productUnitRegularPriceDisplay = response.data.unit_price_display + this.productCasePrice = response.data.case_price this.productCasePriceDisplay = response.data.case_price_display + this.productSalePrice = response.data.sale_price this.productSalePriceDisplay = response.data.sale_price_display this.productSaleEndsDisplay = response.data.sale_ends_display this.productURL = response.data.url @@ -1728,13 +1980,17 @@ let params = { product_is_known: this.productIsKnown, - product_uuid: this.productUUID, + % if product_price_may_be_questionable: + price_needs_confirmation: this.productPriceNeedsConfirmation, + % endif order_quantity: this.productQuantity, order_uom: this.productUOM, + } - % if product_price_may_be_questionable: - price_needs_confirmation: this.productPriceNeedsConfirmation, - % endif + if (this.productIsKnown) { + params.product_uuid = this.productUUID + } else { + params.pending_product = this.pendingProduct } if (this.editingItem) { diff --git a/tailbone/templates/master/delete.mako b/tailbone/templates/master/delete.mako index 62f28241..dded378f 100644 --- a/tailbone/templates/master/delete.mako +++ b/tailbone/templates/master/delete.mako @@ -4,11 +4,10 @@ <%def name="title()">Delete ${model_title}: ${instance_title}</%def> <%def name="context_menu_items()"> - <li>${h.link_to("Back to {}".format(model_title_plural), url(route_prefix))}</li> - % if master.viewable and request.has_perm('{}.view'.format(permission_prefix)): + % if not use_buefy and master.viewable and master.has_perm('view'): <li>${h.link_to("View this {}".format(model_title), action_url('view', instance))}</li> % endif - % if master.editable and request.has_perm('{}.edit'.format(permission_prefix)): + % if not use_buefy and master.editable and master.has_perm('edit'): <li>${h.link_to("Edit this {}".format(model_title), action_url('edit', instance))}</li> % endif % if not use_buefy and master.creatable and master.show_create_link and master.has_perm('create'): diff --git a/tailbone/templates/master/edit.mako b/tailbone/templates/master/edit.mako index 1aae24b4..de0cb524 100644 --- a/tailbone/templates/master/edit.mako +++ b/tailbone/templates/master/edit.mako @@ -23,7 +23,7 @@ </%def> <%def name="context_menu_items()"> - % if master.viewable and request.has_perm('{}.view'.format(permission_prefix)): + % if not use_buefy and master.viewable and master.has_perm('view'): <li>${h.link_to("View this {}".format(model_title), action_url('view', instance))}</li> % endif ${self.context_menu_item_delete()} diff --git a/tailbone/templates/products/view.mako b/tailbone/templates/products/view.mako index d42c04b9..cac17f1a 100644 --- a/tailbone/templates/products/view.mako +++ b/tailbone/templates/products/view.mako @@ -93,7 +93,7 @@ </%def> <%def name="render_main_fields(form)"> - ${form.render_field_readonly('upc')} + ${form.render_field_readonly(product_key_field)} ${form.render_field_readonly('brand')} ${form.render_field_readonly('description')} ${form.render_field_readonly('size')} diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index 7b168a5d..b15a786d 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -277,7 +277,7 @@ <span class="header-text"> ${index_title} </span> - % if use_buefy and master.creatable and master.show_create_link and master.has_perm('create'): + % if master.creatable and master.show_create_link and master.has_perm('create'): <once-button type="is-primary" tag="a" href="${url('{}.create'.format(route_prefix))}" icon-left="plus" @@ -409,67 +409,7 @@ </div> </div> <div class="level-right"> - % if use_buefy and master and master.viewing: - ## TODO: is there a better way to check if viewing parent? - % if parent_instance is Undefined: - % if master.editable and instance_editable and master.has_perm('edit'): - <div class="level-item"> - <once-button tag="a" href="${action_url('edit', instance)}" - icon-left="edit" - text="Edit This"> - </once-button> - </div> - % endif - % if master.cloneable and master.has_perm('clone'): - <div class="level-item"> - <once-button tag="a" href="${action_url('clone', instance)}" - icon-left="object-ungroup" - text="Clone This"> - </once-button> - </div> - % endif - % if master.deletable and instance_deletable and master.has_perm('delete'): - <div class="level-item"> - <once-button tag="a" href="${action_url('delete', instance)}" - type="is-danger" - icon-left="trash" - text="Delete This"> - </once-button> - </div> - % endif - % else: - ## viewing row - % if instance_deletable and master.has_perm('delete_row'): - <div class="level-item"> - <once-button tag="a" href="${action_url('delete', instance)}" - type="is-danger" - icon-left="trash" - text="Delete This"> - </once-button> - </div> - % endif - % endif - % endif - % if show_prev_next is not Undefined and show_prev_next: - % if prev_url: - <div class="level-item"> - ${h.link_to(u"« Older", prev_url, class_='button autodisable')} - </div> - % else: - <div class="level-item"> - ${h.link_to(u"« Older", '#', class_='button', disabled='disabled')} - </div> - % endif - % if next_url: - <div class="level-item"> - ${h.link_to(u"Newer »", next_url, class_='button autodisable')} - </div> - % else: - <div class="level-item"> - ${h.link_to(u"Newer »", '#', class_='button', disabled='disabled')} - </div> - % endif - % endif + ${self.render_instance_header_buttons()} </div> </div> </section> @@ -583,6 +523,113 @@ ${tailbone_autocomplete_template()} </%def> +<%def name="render_instance_header_buttons()"> + ${self.render_crud_header_buttons()} + ${self.render_prevnext_header_buttons()} +</%def> + +<%def name="render_crud_header_buttons()"> + % if master and master.viewing: + ## TODO: is there a better way to check if viewing parent? + % if parent_instance is Undefined: + % if master.editable and instance_editable and master.has_perm('edit'): + <div class="level-item"> + <once-button tag="a" href="${action_url('edit', instance)}" + icon-left="edit" + text="Edit This"> + </once-button> + </div> + % endif + % if master.cloneable and master.has_perm('clone'): + <div class="level-item"> + <once-button tag="a" href="${action_url('clone', instance)}" + icon-left="object-ungroup" + text="Clone This"> + </once-button> + </div> + % endif + % if master.deletable and instance_deletable and master.has_perm('delete'): + <div class="level-item"> + <once-button tag="a" href="${action_url('delete', instance)}" + type="is-danger" + icon-left="trash" + text="Delete This"> + </once-button> + </div> + % endif + % else: + ## viewing row + % if instance_deletable and master.has_perm('delete_row'): + <div class="level-item"> + <once-button tag="a" href="${action_url('delete', instance)}" + type="is-danger" + icon-left="trash" + text="Delete This"> + </once-button> + </div> + % endif + % endif + % elif master and master.editing: + % if master.viewable and master.has_perm('view'): + <div class="level-item"> + <once-button tag="a" href="${action_url('view', instance)}" + icon-left="eye" + text="View This"> + </once-button> + </div> + % endif + % if master.deletable and instance_deletable and master.has_perm('delete'): + <div class="level-item"> + <once-button tag="a" href="${action_url('delete', instance)}" + type="is-danger" + icon-left="trash" + text="Delete This"> + </once-button> + </div> + % endif + % elif master and master.deleting: + % if master.viewable and master.has_perm('view'): + <div class="level-item"> + <once-button tag="a" href="${action_url('view', instance)}" + icon-left="eye" + text="View This"> + </once-button> + </div> + % endif + % if master.editable and instance_editable and master.has_perm('edit'): + <div class="level-item"> + <once-button tag="a" href="${action_url('edit', instance)}" + icon-left="edit" + text="Edit This"> + </once-button> + </div> + % endif + % endif +</%def> + +<%def name="render_prevnext_header_buttons()"> + % if show_prev_next is not Undefined and show_prev_next: + % if prev_url: + <div class="level-item"> + ${h.link_to(u"« Older", prev_url, class_='button autodisable')} + </div> + % else: + <div class="level-item"> + ${h.link_to(u"« Older", '#', class_='button', disabled='disabled')} + </div> + % endif + % if next_url: + <div class="level-item"> + ${h.link_to(u"Newer »", next_url, class_='button autodisable')} + </div> + % else: + <div class="level-item"> + ${h.link_to(u"Newer »", '#', class_='button', disabled='disabled')} + </div> + % endif + % endif +</%def> + <%def name="declare_whole_page_vars()"> ${h.javascript_link(request.static_url('tailbone:static/themes/falafel/js/tailbone.feedback.js') + '?ver={}'.format(tailbone.__version__))} <script type="text/javascript"> diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 0eb956cf..2f73de53 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -623,16 +623,6 @@ class BatchMasterView(MasterView): def get_row_status_enum(self): return self.model_row_class.STATUS - def render_upc(self, row, field): - upc = row.upc - if not upc: - return "" - text = upc.pretty() - if row.product_uuid: - url = self.request.route_url('products.view', uuid=row.product_uuid) - return tags.link_to(text, url) - return text - def render_upc_pretty(self, row, field): upc = getattr(row, field) if upc: diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index ff99f1af..ba05f475 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -510,7 +510,18 @@ class PendingCustomerView(MasterView): f.set_enum('status_code', self.enum.PENDING_CUSTOMER_STATUS) - f.set_renderer('user', self.render_user) + # created + if self.creating: + f.remove('created') + else: + f.set_readonly('created') + + # user + if self.creating: + f.remove('user') + else: + f.set_readonly('user') + f.set_renderer('user', self.render_user) # # TODO: this is referenced by some custom apps, but should be moved?? diff --git a/tailbone/views/custorders/batch.py b/tailbone/views/custorders/batch.py index ccbf492e..26ac5cde 100644 --- a/tailbone/views/custorders/batch.py +++ b/tailbone/views/custorders/batch.py @@ -74,7 +74,6 @@ class CustomerOrderBatchView(BatchMasterView): ] row_labels = { - 'product_upc': "UPC", 'product_brand': "Brand", 'product_description': "Description", 'product_size': "Size", @@ -83,7 +82,7 @@ class CustomerOrderBatchView(BatchMasterView): row_grid_columns = [ 'sequence', - 'product_upc', + '_product_key_', 'product_brand', 'product_description', 'product_size', @@ -94,6 +93,38 @@ class CustomerOrderBatchView(BatchMasterView): 'status_code', ] + product_key_fields = { + 'upc': 'product_upc', + 'item_id': 'product_item_id', + 'scancode': 'product_scancode', + } + + row_form_fields = [ + 'sequence', + 'item_entry', + 'product', + 'pending_product', + '_product_key_', + 'product_brand', + 'product_description', + 'product_size', + 'product_weighed', + 'product_unit_of_measure', + 'department_number', + 'department_name', + 'product_unit_cost', + 'case_quantity', + 'unit_price', + 'price_needs_confirmation', + 'order_quantity', + 'order_uom', + 'discount_percent', + 'total_price', + 'paid_amount', + # 'payment_transaction_number', + 'status_code', + ] + def configure_grid(self, g): super(CustomerOrderBatchView, self).configure_grid(g) @@ -170,6 +201,8 @@ class CustomerOrderBatchView(BatchMasterView): def row_grid_extra_class(self, row, i): if row.status_code == row.STATUS_PRODUCT_NOT_FOUND: return 'warning' + if row.status_code == row.STATUS_PENDING_PRODUCT: + return 'notice' def configure_row_grid(self, g): super(CustomerOrderBatchView, self).configure_row_grid(g) @@ -189,6 +222,9 @@ class CustomerOrderBatchView(BatchMasterView): super(CustomerOrderBatchView, self).configure_row_form(f) f.set_renderer('product', self.render_product) + f.set_renderer('pending_product', self.render_pending_product) + + f.set_renderer('product_upc', self.render_upc) f.set_type('case_quantity', 'quantity') f.set_type('cases_ordered', 'quantity') @@ -197,3 +233,4 @@ class CustomerOrderBatchView(BatchMasterView): f.set_enum('order_uom', self.enum.UNIT_OF_MEASURE) f.set_type('unit_price', 'currency') f.set_type('total_price', 'currency') + f.set_type('paid_amount', 'currency') diff --git a/tailbone/views/custorders/items.py b/tailbone/views/custorders/items.py index 737b1c20..4d62e505 100644 --- a/tailbone/views/custorders/items.py +++ b/tailbone/views/custorders/items.py @@ -90,6 +90,7 @@ class CustomerOrderItemView(MasterView): 'sequence', 'person', 'product', + 'pending_product', 'product_brand', 'product_description', 'product_size', @@ -178,6 +179,9 @@ class CustomerOrderItemView(MasterView): # product f.set_renderer('product', self.render_product) + # pending_product + f.set_renderer('pending_product', self.render_pending_product) + # product uom f.set_enum('product_unit_of_measure', self.enum.UNIT_OF_MEASURE) diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index 49280d96..95fc35d8 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -48,6 +48,7 @@ class CustomerOrderView(MasterView): model_class = model.CustomerOrder route_prefix = 'custorders' editable = False + configurable = True labels = { 'id': "ID", @@ -96,6 +97,10 @@ class CustomerOrderView(MasterView): 'status_code', ] + def __init__(self, request): + super(CustomerOrderView, self).__init__(request) + self.batch_handler = self.get_batch_handler() + def query(self, session): return session.query(model.CustomerOrder)\ .options(orm.joinedload(model.CustomerOrder.customer)) @@ -241,7 +246,8 @@ class CustomerOrderView(MasterView): submits the order, at which point the batch is converted to a proper order. """ - self.handler = self.get_batch_handler() + # TODO: deprecate / remove this + self.handler = self.batch_handler batch = self.get_current_batch() if self.request.method == 'POST': @@ -285,16 +291,30 @@ class CustomerOrderView(MasterView): context.update({ 'batch': batch, 'normalized_batch': self.normalize_batch(batch), - 'new_order_requires_customer': self.handler.new_order_requires_customer(), - 'product_price_may_be_questionable': self.handler.product_price_may_be_questionable(), - 'allow_contact_info_choice': self.handler.allow_contact_info_choice(), - 'restrict_contact_info': self.handler.should_restrict_contact_info(), + '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': not self.batch_handler.allow_contact_info_creation(), 'order_items': items, 'product_key_label': self.rattail_config.product_key_title(), + 'allow_unknown_product': self.batch_handler.allow_unknown_product(), + 'department_options': self.get_department_options(), }) return self.render_to_response(template, context) + def get_department_options(self): + model = self.model + departments = self.Session.query(model.Department)\ + .order_by(model.Department.name)\ + .all() + options = [] + for department in departments: + options.append({'label': department.name, + 'value': department.uuid}) + return options + def get_current_batch(self): user = self.request.user if not user: @@ -311,7 +331,7 @@ class CustomerOrderView(MasterView): except orm.exc.NoResultFound: # no batch yet for this user, so make one - batch = self.handler.make_batch( + batch = self.batch_handler.make_batch( self.Session(), created_by=user, mode=self.enum.CUSTORDER_BATCH_MODE_CREATING) self.Session.add(batch) @@ -320,16 +340,7 @@ class CustomerOrderView(MasterView): return batch def start_over_entirely(self, batch): - - # delete pending customer if present - pending = batch.pending_customer - if pending: - batch.pending_customer = None - self.Session.delete(pending) - - # just delete current batch outright - # TODO: should use self.handler.do_delete() instead? - self.Session.delete(batch) + self.batch_handler.do_delete(batch) self.Session.flush() # send user back to normal "create" page; a new batch will be generated @@ -339,16 +350,7 @@ class CustomerOrderView(MasterView): return self.redirect(url) def delete_batch(self, batch): - - # delete pending customer if present - pending = batch.pending_customer - if pending: - batch.pending_customer = None - self.Session.delete(pending) - - # just delete current batch outright - # TODO: should use self.handler.do_delete() instead? - self.Session.delete(batch) + self.batch_handler.do_delete(batch) self.Session.flush() # set flash msg just to be more obvious @@ -363,19 +365,21 @@ class CustomerOrderView(MasterView): """ Customer autocomplete logic, which invokes the handler. """ - self.handler = self.get_batch_handler() + # TODO: deprecate / remove this + self.handler = self.batch_handler term = self.request.GET['term'] - return self.handler.customer_autocomplete(self.Session(), term, - user=self.request.user) + return self.batch_handler.customer_autocomplete(self.Session(), term, + user=self.request.user) def person_autocomplete(self): """ Person autocomplete logic, which invokes the handler. """ - self.handler = self.get_batch_handler() + # TODO: deprecate / remove this + self.handler = self.batch_handler term = self.request.GET['term'] - return self.handler.person_autocomplete(self.Session(), term, - user=self.request.user) + return self.batch_handler.person_autocomplete(self.Session(), term, + user=self.request.user) def get_customer_info(self, batch, data): uuid = data.get('uuid') @@ -391,7 +395,7 @@ class CustomerOrderView(MasterView): def info_for_customer(self, batch, data, customer): # most info comes from handler - info = self.handler.get_customer_info(batch) + info = self.batch_handler.get_customer_info(batch) # maybe add profile URL if info['person_uuid']: @@ -407,7 +411,7 @@ class CustomerOrderView(MasterView): # this will either be a Person or Customer UUID uuid = data['uuid'] - if self.handler.new_order_requires_customer(): + if self.batch_handler.new_order_requires_customer(): customer = self.Session.query(model.Customer).get(uuid) if not customer: @@ -423,7 +427,7 @@ class CustomerOrderView(MasterView): # invoke handler to assign contact try: - self.handler.assign_contact(batch, **kwargs) + self.batch_handler.assign_contact(batch, **kwargs) except ValueError as error: return {'error': six.text_type(error)} @@ -439,9 +443,9 @@ class CustomerOrderView(MasterView): 'phone_number': batch.phone_number, 'contact_display': batch.contact_name, 'email_address': batch.email_address, - 'contact_phones': self.handler.get_contact_phones(batch), - 'contact_emails': self.handler.get_contact_emails(batch), - 'contact_notes': self.handler.get_contact_notes(batch), + 'contact_phones': self.batch_handler.get_contact_phones(batch), + 'contact_emails': self.batch_handler.get_contact_emails(batch), + 'contact_notes': self.batch_handler.get_contact_notes(batch), 'add_phone_number': bool(batch.get_param('add_phone_number')), 'add_email_address': bool(batch.get_param('add_email_address')), 'contact_profile_url': None, @@ -467,7 +471,7 @@ class CustomerOrderView(MasterView): # we have a pending customer then it's definitely *not* known, # but if no pending customer yet then we can still "assume" it # is known, by default, until user specifies otherwise. - contact = self.handler.get_contact(batch) + contact = self.batch_handler.get_contact(batch) if contact: context['contact_is_known'] = True else: @@ -482,7 +486,7 @@ class CustomerOrderView(MasterView): return context def unassign_contact(self, batch, data): - self.handler.unassign_contact(batch) + self.batch_handler.unassign_contact(batch) self.Session.flush() context = self.get_context_contact(batch) context['success'] = True @@ -524,7 +528,8 @@ class CustomerOrderView(MasterView): def update_pending_customer(self, batch, data): try: - self.handler.update_pending_customer(batch, self.request.user, data) + self.batch_handler.update_pending_customer(batch, self.request.user, + data) except Exception as error: return {'error': six.text_type(error)} @@ -562,11 +567,14 @@ class CustomerOrderView(MasterView): return self.info_for_product(batch, data, product) def uom_choices_for_product(self, product): - return self.handler.uom_choices_for_product(product) + return self.batch_handler.uom_choices_for_product(product) + + def uom_choices_for_row(self, row): + return self.batch_handler.uom_choices_for_row(row) def info_for_product(self, batch, data, product): try: - info = self.handler.get_product_info(batch, product) + info = self.batch_handler.get_product_info(batch, product) except Exception as error: return {'error': six.text_type(error)} else: @@ -574,12 +582,12 @@ class CustomerOrderView(MasterView): return info def get_past_items(self, batch, data): - past_products = self.handler.get_past_products(batch) + past_products = self.batch_handler.get_past_products(batch) past_items = [] for product in past_products: try: - item = self.handler.get_product_info(batch, product) + item = self.batch_handler.get_product_info(batch, product) except: # nb. handler may raise error if product is "unsupported" pass @@ -613,22 +621,20 @@ class CustomerOrderView(MasterView): def normalize_row(self, row): app = self.get_rattail_app() - products = app.get_products_handler() + products_handler = app.get_products_handler() - product = row.product - department = product.department if product else None - cost = product.cost if product else None data = { 'uuid': row.uuid, 'sequence': row.sequence, 'item_entry': row.item_entry, 'product_uuid': row.product_uuid, 'product_upc': six.text_type(row.product_upc or ''), + 'product_item_id': row.product_item_id, + 'product_scancode': row.product_scancode, 'product_upc_pretty': row.product_upc.pretty() if row.product_upc else None, 'product_brand': row.product_brand, 'product_description': row.product_description, 'product_size': row.product_size, - 'product_full_description': product.full_description if product else row.product_description, 'product_weighed': row.product_weighed, 'case_quantity': pretty_quantity(row.case_quantity), @@ -636,38 +642,88 @@ class CustomerOrderView(MasterView): 'units_ordered': pretty_quantity(row.units_ordered), 'order_quantity': pretty_quantity(row.order_quantity), 'order_uom': row.order_uom, - 'order_uom_choices': self.uom_choices_for_product(product), + 'order_uom_choices': self.uom_choices_for_row(row), - 'department_display': department.name if department else None, - 'vendor_display': cost.vendor.name if cost else None, + 'department_display': row.department_name, - 'unit_price': six.text_type(row.unit_price) if row.unit_price is not None else None, + 'unit_price': float(row.unit_price) if row.unit_price is not None else None, 'unit_price_display': self.get_unit_price_display(row), - 'total_price': six.text_type(row.total_price) if row.total_price is not None else None, - 'total_price_display': "${:0.2f}".format(row.total_price) if row.total_price is not None else None, + 'total_price': float(row.total_price) if row.total_price is not None else None, + 'total_price_display': app.render_currency(row.total_price), 'status_code': row.status_code, 'status_text': row.status_text, } - case_price = self.handler.get_case_price_for_row(row) - data['case_price'] = six.text_type(case_price) if case_price is not None else None + if row.unit_regular_price: + data['unit_regular_price'] = float(row.unit_regular_price) + data['unit_regular_price_display'] = app.render_currency(row.unit_regular_price) + + if row.unit_sale_price: + data['unit_sale_price'] = float(row.unit_sale_price) + data['unit_sale_price_display'] = app.render_currency(row.unit_sale_price) + if row.sale_ends: + sale_ends = app.localtime(row.sale_ends, from_utc=True).date() + data['sale_ends'] = six.text_type(sale_ends) + data['sale_ends_display'] = app.render_date(sale_ends) + + if row.unit_sale_price and row.unit_price == row.unit_sale_price: + data['pricing_reflects_sale'] = True + + if row.product or row.pending_product: + data['product_full_description'] = products_handler.make_full_description( + row.product or row.pending_product) + + if row.product: + cost = row.product.cost + if cost: + data['vendor_display'] = cost.vendor.name + elif row.pending_product: + data['vendor_display'] = row.pending_product.vendor_name + + if row.pending_product: + pending = row.pending_product + data['pending_product'] = { + 'uuid': pending.uuid, + 'upc': six.text_type(pending.upc) if pending.upc is not None else None, + 'item_id': pending.item_id, + 'scancode': pending.scancode, + 'brand_name': pending.brand_name, + 'description': pending.description, + 'size': pending.size, + 'department_uuid': pending.department_uuid, + 'regular_price_amount': float(pending.regular_price_amount) if pending.regular_price_amount is not None else None, + 'vendor_name': pending.vendor_name, + 'vendor_item_code': pending.vendor_item_code, + 'unit_cost': float(pending.unit_cost) if pending.unit_cost is not None else None, + 'case_size': float(pending.case_size) if pending.case_size is not None else None, + 'notes': pending.notes, + } + + case_price = self.batch_handler.get_case_price_for_row(row) + data['case_price'] = float(case_price) if case_price is not None else None data['case_price_display'] = app.render_currency(case_price) - if self.handler.product_price_may_be_questionable(): + if self.batch_handler.product_price_may_be_questionable(): data['price_needs_confirmation'] = row.price_needs_confirmation key = self.rattail_config.product_key() if key == 'upc': data['product_key'] = data['product_upc_pretty'] - else: - data['product_key'] = getattr(product, key, data['product_upc_pretty']) + elif key == 'item_id': + data['product_key'] = row.product_item_id + elif key == 'scancode': + data['product_key'] = row.product_scancode + else: # TODO: this seems not useful + data['product_key'] = getattr(row.product, key, data['product_upc_pretty']) if row.product: data.update({ 'product_url': self.request.route_url('products.view', uuid=row.product.uuid), - 'product_image_url': products.get_image_url(row.product), + 'product_image_url': products_handler.get_image_url(row.product), }) + elif row.product_upc: + data['product_image_url'] = products_handler.get_image_url(upc=row.product_upc) unit_uom = self.enum.UNIT_OF_MEASURE_POUND if data['product_weighed'] else self.enum.UNIT_OF_MEASURE_EACH if row.order_uom == self.enum.UNIT_OF_MEASURE_CASE: @@ -695,6 +751,11 @@ class CustomerOrderView(MasterView): return data def add_item(self, batch, data): + app = self.get_rattail_app() + + order_quantity = decimal.Decimal(data.get('order_quantity') or '0') + order_uom = data.get('order_uom') + if data.get('product_is_known'): uuid = data.get('product_uuid') @@ -706,18 +767,30 @@ class CustomerOrderView(MasterView): return {'error': "Product not found"} kwargs = {} - if self.handler.product_price_may_be_questionable(): + if self.batch_handler.product_price_may_be_questionable(): kwargs['price_needs_confirmation'] = data.get('price_needs_confirmation') - row = self.handler.add_product(batch, product, - decimal.Decimal(data.get('order_quantity') or '0'), - data.get('order_uom'), - **kwargs) - self.Session.flush() + row = self.batch_handler.add_product(batch, product, + order_quantity, order_uom, + **kwargs) - else: # product is not known - raise NotImplementedError # TODO + else: # unknown product; add pending + pending_info = dict(data['pending_product']) + if 'upc' in pending_info: + pending_info['upc'] = app.make_gpc(pending_info['upc']) + + for field in ('unit_cost', 'regular_price_amount', 'case_size'): + if field in pending_info: + pending_info[field] = decimal.Decimal(pending_info[field]) + + pending_info['user'] = self.request.user + + row = self.batch_handler.add_pending_product(batch, + pending_info, + order_quantity, order_uom) + + self.Session.flush() return {'batch': self.normalize_batch(batch), 'row': self.normalize_row(row)} @@ -733,6 +806,9 @@ class CustomerOrderView(MasterView): if row not in batch.active_rows(): return {'error': "Row is not active for the batch"} + order_quantity = decimal.Decimal(data.get('order_quantity') or '0') + order_uom = data.get('order_uom') + if data.get('product_is_known'): uuid = data.get('product_uuid') @@ -745,19 +821,26 @@ class CustomerOrderView(MasterView): row.item_entry = product.uuid row.product = product - row.order_quantity = decimal.Decimal(data.get('order_quantity') or '0') - row.order_uom = data.get('order_uom') + row.order_quantity = order_quantity + row.order_uom = order_uom - if self.handler.product_price_may_be_questionable(): + if self.batch_handler.product_price_may_be_questionable(): row.price_needs_confirmation = data.get('price_needs_confirmation') - self.handler.refresh_row(row) - self.Session.flush() - self.Session.refresh(row) + self.batch_handler.refresh_row(row) else: # product is not known - raise NotImplementedError # TODO + # set these first, since row will be refreshed below + row.order_quantity = order_quantity + row.order_uom = order_uom + + # nb. this will refresh the row + pending_info = dict(data['pending_product']) + self.batch_handler.update_pending_product(row, pending_info) + + self.Session.flush() + self.Session.refresh(row) return {'batch': self.normalize_batch(batch), 'row': self.normalize_row(row)} @@ -774,7 +857,7 @@ class CustomerOrderView(MasterView): if row not in batch.active_rows(): return {'error': "Row is not active for this batch"} - self.handler.do_remove_row(row) + self.batch_handler.do_remove_row(row) return {'ok': True, 'batch': self.normalize_batch(batch)} @@ -794,7 +877,30 @@ class CustomerOrderView(MasterView): return {'ok': True, 'next_url': next_url} def execute_new_order_batch(self, batch, data): - return self.handler.do_execute(batch, self.request.user) + return self.batch_handler.do_execute(batch, self.request.user) + + def configure_get_simple_settings(self): + return [ + + # customer handling + {'section': 'rattail.custorders', + 'option': 'new_order_requires_customer', + 'type': bool}, + {'section': 'rattail.custorders', + 'option': 'new_orders.allow_contact_info_choice', + 'type': bool}, + {'section': 'rattail.custorders', + 'option': 'new_orders.allow_contact_info_create', + 'type': bool}, + + # product handling + {'section': 'rattail.custorders', + 'option': 'allow_unknown_product', + 'type': bool}, + {'section': 'rattail.custorders', + 'option': 'product_price_may_be_questionable', + 'type': bool}, + ] @classmethod def defaults(cls, config): diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 2146ff97..75996653 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -157,6 +157,8 @@ class MasterView(View): labels = {'uuid': "UUID"} + product_key_fields = {} + # ROW-RELATED ATTRS FOLLOW: has_rows = False @@ -449,6 +451,8 @@ class MasterView(View): grid.hide_column('local_only') grid.remove_filter('local_only') + self.configure_column_product_key(grid) + def grid_extra_class(self, obj, i): """ Returns string of extra class(es) for the table row corresponding to @@ -541,6 +545,8 @@ class MasterView(View): # super(MasterView, self).configure_row_grid(grid) self.set_row_labels(grid) + self.configure_column_product_key(grid) + def row_grid_extra_class(self, obj, i): """ Returns string of extra class(es) for the table row corresponding to @@ -753,6 +759,7 @@ class MasterView(View): if obj.emails: return obj.emails[0].address + # TODO: deprecate / remove this def render_product_key_value(self, obj, field=None): """ Render the "canonical" product key value for the given object. @@ -764,6 +771,15 @@ class MasterView(View): return obj.upc.pretty() if obj.upc else '' return getattr(obj, product_key) + def render_upc(self, obj, field): + """ + Render a :class:`~rattail:rattail.gpc.GPC` field. + """ + value = getattr(obj, field) + if value: + app = self.rattail_config.get_app() + return app.render_gpc(value) + def render_store(self, obj, field): store = getattr(obj, field) if store: @@ -779,6 +795,14 @@ class MasterView(View): url = self.request.route_url('products.view', uuid=product.uuid) return tags.link_to(text, url) + def render_pending_product(self, obj, field): + pending = getattr(obj, field) + if not pending: + return + text = six.text_type(pending) + url = self.request.route_url('pending_products.view', uuid=pending.uuid) + return tags.link_to(text, url) + def render_vendor(self, obj, field): vendor = getattr(obj, field) if not vendor: @@ -1567,6 +1591,8 @@ class MasterView(View): return self.render_to_response('delete', { 'instance': instance, 'instance_title': instance_title, + 'instance_editable': self.editable_instance(instance), + 'instance_deletable': self.deletable_instance(instance), 'form': form}) def bulk_delete(self): @@ -3676,6 +3702,8 @@ class MasterView(View): """ self.configure_common_form(form) + self.configure_field_product_key(form) + def validate_form(self, form): if form.validate(newstyle=True): self.form_deserialized = form.validated @@ -4107,12 +4135,33 @@ class MasterView(View): self.set_row_labels(form) + self.configure_field_product_key(form) + def validate_row_form(self, form): if form.validate(newstyle=True): self.form_deserialized = form.validated return True return False + def configure_column_product_key(self, g): + if '_product_key_' in g.columns: + key = self.rattail_config.product_key() + field = self.product_key_fields.get(key, key) + g.replace('_product_key_', field) + g.set_label(field, self.rattail_config.product_key_title(key)) + g.set_link(field) + if key == 'upc': + g.set_renderer(field, self.render_upc) + + def configure_field_product_key(self, f): + if '_product_key_' in f: + key = self.rattail_config.product_key() + field = self.product_key_fields.get(key, key) + f.replace('_product_key_', field) + f.set_label(field, self.rattail_config.product_key_title(key)) + if key == 'upc': + f.set_renderer(field, self.render_upc) + def get_row_action_url(self, action, row, **kwargs): """ Generate a URL for the given action on the given row. diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 7945b5db..6459085b 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -1153,6 +1153,7 @@ class ProductView(MasterView): use_buefy = self.get_use_buefy() kwargs['image_url'] = self.handler.get_image_url(product) + kwargs['product_key_field'] = self.rattail_config.product_key() # add price history, if user has access if self.rattail_config.versioning_enabled() and self.has_perm('versions'): @@ -1910,7 +1911,6 @@ class ProductView(MasterView): return self.request.route_url('batch.delproduct.view', uuid=batch.uuid) def configure_get_simple_settings(self): - config = self.rattail_config return [ # key field @@ -1987,6 +1987,7 @@ class PendingProductView(MasterView): model_class = model.PendingProduct route_prefix = 'pending_products' url_prefix = '/products/pending' + bulk_deletable = True labels = { 'regular_price_amount': "Regular Price", @@ -1996,10 +1997,10 @@ class PendingProductView(MasterView): grid_columns = [ '_product_key_', - 'department_name', 'brand_name', 'description', 'size', + 'department_name', 'created', 'user', 'status_code', @@ -2007,12 +2008,15 @@ class PendingProductView(MasterView): form_fields = [ '_product_key_', - 'department_name', - 'department', 'brand_name', 'brand', 'description', 'size', + 'department_name', + 'department', + 'vendor_name', + 'vendor', + 'unit_cost', 'case_size', 'regular_price_amount', 'special_order', @@ -2025,13 +2029,6 @@ class PendingProductView(MasterView): def configure_grid(self, g): super(PendingProductView, self).configure_grid(g) - # product key - if '_product_key_' in g.columns: - key = self.rattail_config.product_key() - g.replace('_product_key_', key) - g.set_label(key, self.rattail_config.product_key_title(key)) - g.set_link(key) - g.set_enum('status_code', self.enum.PENDING_PRODUCT_STATUS) g.set_sort_defaults('created', 'desc') @@ -2043,13 +2040,6 @@ class PendingProductView(MasterView): model = self.model pending = f.model_instance - # product key - if '_product_key_' in f: - key = self.rattail_config.product_key() - f.replace('_product_key_', key) - f.set_label(key, self.rattail_config.product_key_title(key)) - f.set_renderer(key, self.render_product_key_value) - # department if self.creating or self.editing: if 'department' in f: @@ -2084,10 +2074,35 @@ class PendingProductView(MasterView): f.set_renderer('brand', self.render_brand) if pending.brand: f.remove('brand_name') + elif pending.brand_name: + f.remove('brand') # description f.set_required('description') + # vendor + if self.creating or self.editing: + if 'vendor' in f: + f.remove('vendor_name') + f.replace('vendor', 'vendor_uuid') + f.set_node('vendor_uuid', colander.String()) + vendor_display = "" + if self.request.method == 'POST': + if self.request.POST.get('vendor_uuid'): + vendor = self.Session.query(model.Vendor).get(self.request.POST['vendor_uuid']) + if vendor: + vendor_display = six.text_type(vendor) + f.set_widget('vendor_uuid', forms.widgets.JQueryAutocompleteWidget( + field_display=vendor_display, + service_url=self.request.route_url('vendors.autocomplete'))) + f.set_label('vendor_uuid', "Vendor") + else: + f.set_renderer('vendor', self.render_vendor) + if pending.vendor: + f.remove('vendor_name') + elif pending.vendor_name: + f.remove('vendor') + # case_size f.set_type('case_size', 'quantity') @@ -2138,6 +2153,30 @@ class PendingProductView(MasterView): return pending + def before_delete(self, pending): + """ + Event hook, called just before deletion is attempted. + """ + model = self.model + model_title = self.get_model_title() + count = self.Session.query(model.CustomerOrderItem)\ + .filter(model.CustomerOrderItem.pending_product == pending)\ + .count() + if count: + self.request.session.flash("Cannot delete this {} because it is still " + "referenced by {} Customer Orders.".format(model_title, count), + 'error') + return self.redirect(self.get_action_url('view', pending)) + + count = self.Session.query(model.CustomerOrderBatchRow)\ + .filter(model.CustomerOrderBatchRow.pending_product == pending)\ + .count() + if count: + self.request.session.flash("Cannot delete this {} because it is still " + "referenced by {} \"new\" Customer Order Batches.".format(model_title, count), + 'error') + return self.redirect(self.get_action_url('view', pending)) + def print_labels(request): profile = request.params.get('profile') From 494b1384c4353cd325844b6f2f7fd8f2b28ea75a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 22 Dec 2021 16:45:56 -0600 Subject: [PATCH 0560/1681] Bugfix --- tailbone/views/custorders/orders.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index 95fc35d8..a97b9978 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -295,7 +295,7 @@ class CustomerOrderView(MasterView): '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': not self.batch_handler.allow_contact_info_creation(), + 'allow_contact_info_create': self.batch_handler.allow_contact_info_creation(), 'order_items': items, 'product_key_label': self.rattail_config.product_key_title(), 'allow_unknown_product': self.batch_handler.allow_unknown_product(), From daa5126c216c1707d5a686168d481f1cb0e7c21e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 23 Dec 2021 12:34:57 -0600 Subject: [PATCH 0561/1681] Improve email bounce view per buefy theme --- tailbone/templates/email-bounces/view.mako | 62 +++++++++++++++++++++- tailbone/views/bouncer.py | 11 ++-- 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/tailbone/templates/email-bounces/view.mako b/tailbone/templates/email-bounces/view.mako index 36eb0c12..d24f3a00 100644 --- a/tailbone/templates/email-bounces/view.mako +++ b/tailbone/templates/email-bounces/view.mako @@ -5,6 +5,7 @@ <%def name="extra_javascript()"> ${parent.extra_javascript()} + % if not use_buefy: <script type="text/javascript"> function autosize_message(scrolldown) { @@ -26,10 +27,20 @@ }); </script> + % endif </%def> <%def name="extra_styles()"> ${parent.extra_styles()} + % if use_buefy: + <style type="text/css"> + .email-message-body { + border: 1px solid #000000; + margin-top: 2rem; + height: 500px; + } + </style> + % else: <style type="text/css"> #message { border: 1px solid #000000; @@ -38,23 +49,72 @@ padding: 4px; } </style> + % endif +</%def> + +<%def name="object_helpers()"> + ${parent.object_helpers()} + % if use_buefy: + <nav class="panel"> + <p class="panel-heading">Processing</p> + <div class="panel-block"> + <div class="display: flex; flex-align: column;"> + % if bounce.processed: + <p class="block"> + This bounce was processed + ${h.pretty_datetime(request.rattail_config, bounce.processed)} + by ${bounce.processed_by} + </p> + % if master.has_perm('unprocess'): + <once-button type="is-warning" + tag="a" href="${url('emailbounces.unprocess', uuid=bounce.uuid)}" + text="Mark this bounce as UN-processed"> + </once-button> + % endif + % else: + <p class="block"> + This bounce has NOT yet been processed. + </p> + % if master.has_perm('process'): + <once-button type="is-primary" + tag="a" href="${url('emailbounces.process', uuid=bounce.uuid)}" + text="Mark this bounce as Processed"> + </once-button> + % endif + % endif + </div> + </div> + </nav> + % endif </%def> <%def name="context_menu_items()"> ${parent.context_menu_items()} + % if not use_buefy: % if not bounce.processed and request.has_perm('emailbounces.process'): <li>${h.link_to("Mark this Email Bounce as Processed", url('emailbounces.process', uuid=bounce.uuid))}</li> % elif bounce.processed and request.has_perm('emailbounces.unprocess'): <li>${h.link_to("Mark this Email Bounce as UN-processed", url('emailbounces.unprocess', uuid=bounce.uuid))}</li> % endif + % endif </%def> <%def name="page_content()"> ${parent.page_content()} + % if not use_buefy: <pre id="message"> - ${message} + ${message} </pre> + % endif </%def> +<%def name="render_this_page()"> + ${parent.render_this_page()} + % if use_buefy: + <pre class="email-message-body"> + ${message} + </pre> + % endif +</%def> ${parent.body()} diff --git a/tailbone/views/bouncer.py b/tailbone/views/bouncer.py index 314f0eb6..6bee7099 100644 --- a/tailbone/views/bouncer.py +++ b/tailbone/views/bouncer.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -147,6 +147,7 @@ class EmailBounceView(MasterView): kwargs['message'] = "(file not found)" return kwargs + # TODO: should require POST here def process(self): """ View for marking a bounce as processed. @@ -155,8 +156,9 @@ class EmailBounceView(MasterView): bounce.processed = datetime.datetime.utcnow() bounce.processed_by = self.request.user self.request.session.flash("Email bounce has been marked processed.") - return self.redirect(self.request.route_url('emailbounces')) + return self.redirect(self.get_action_url('view', bounce)) + # TODO: should require POST here def unprocess(self): """ View for marking a bounce as *unprocessed*. @@ -165,7 +167,7 @@ class EmailBounceView(MasterView): bounce.processed = None bounce.processed_by = None self.request.session.flash("Email bounce has been marked UN-processed.") - return self.redirect(self.request.route_url('emailbounces')) + return self.redirect(self.get_action_url('view', bounce)) def download(self): """ @@ -207,9 +209,6 @@ class EmailBounceView(MasterView): cls._defaults(config) -# TODO: deprecate / remove this -EmailBouncesView = EmailBounceView - def includeme(config): EmailBounceView.defaults(config) From 4396c4c628886161257e414af8fbbf7e77250766 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 23 Dec 2021 12:42:12 -0600 Subject: [PATCH 0562/1681] 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 787de2e6..0ee219b4 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.189 (2021-12-23) +-------------------- + +* Add basic "pending product" support for new custorder batch. + +* Improve email bounce view per buefy theme. + + 0.8.188 (2021-12-20) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 3aae353e..ce4233d3 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.188' +__version__ = '0.8.189' From 33af2e6fa1037b6f221923e854b7aed50b4cc0e9 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 23 Dec 2021 14:46:28 -0600 Subject: [PATCH 0563/1681] Show create button on "most" pages for a master view --- tailbone/templates/themes/falafel/base.mako | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index b15a786d..5a873735 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -303,6 +303,13 @@ <span class="header-text"> ${h.link_to(instance_title, instance_url)} </span> + % elif master.creatable and master.show_create_link and master.has_perm('create'): + <once-button type="is-primary" + tag="a" href="${url('{}.create'.format(route_prefix))}" + icon-left="plus" + style="margin-left: 1rem;" + text="Create New"> + </once-button> % endif % if master.viewing and grid_index: ${grid_index_nav()} From 819ae22b0e51e52d26f380ec2d0577cabb132461 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 23 Dec 2021 15:18:30 -0600 Subject: [PATCH 0564/1681] Expose products setting for type 2 UPC lookup also expose Configure button for most master view pages --- tailbone/templates/master/index.mako | 2 +- tailbone/templates/products/configure.mako | 13 ++++++++++ tailbone/templates/themes/falafel/base.mako | 27 ++++++++++++++++----- tailbone/views/products.py | 5 ++++ 4 files changed, 40 insertions(+), 7 deletions(-) diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako index ca0615ce..6be36948 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -162,7 +162,7 @@ <li>${h.link_to("Create a new {}".format(model_title), url('{}.create'.format(route_prefix)))}</li> % endif % endif - % if master.configurable and master.has_perm('configure'): + % if not use_buefy and master.configurable and master.has_perm('configure'): <li>${h.link_to("Configure {}".format(config_title), url('{}.configure'.format(route_prefix)))}</li> % endif % if master.has_input_file_templates and master.has_perm('download_template'): diff --git a/tailbone/templates/products/configure.mako b/tailbone/templates/products/configure.mako index e3c21307..31b879c5 100644 --- a/tailbone/templates/products/configure.mako +++ b/tailbone/templates/products/configure.mako @@ -29,6 +29,19 @@ </div> + <h3 class="block is-size-3">Handling</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field message="If set, GPC values like 002XXXXXYYYYY-Z will be converted to 002XXXXX00000-Z for lokkup"> + <b-checkbox name="rattail.products.convert_type2_for_gpc_lookup" + v-model="simpleSettings['rattail.products.convert_type2_for_gpc_lookup']" + @input="settingsNeedSaved = true"> + Auto-convert Type 2 UPC for sake of lookup + </b-checkbox> + </b-field> + + </div> + <h3 class="block is-size-3">Display</h3> <div class="block" style="padding-left: 2rem;"> diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index 5a873735..1e996e6b 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -304,12 +304,14 @@ ${h.link_to(instance_title, instance_url)} </span> % elif master.creatable and master.show_create_link and master.has_perm('create'): - <once-button type="is-primary" - tag="a" href="${url('{}.create'.format(route_prefix))}" - icon-left="plus" - style="margin-left: 1rem;" - text="Create New"> - </once-button> + % if not request.matched_route.name.endswith('.create'): + <once-button type="is-primary" + tag="a" href="${url('{}.create'.format(route_prefix))}" + icon-left="plus" + style="margin-left: 1rem;" + text="Create New"> + </once-button> + % endif % endif % if master.viewing and grid_index: ${grid_index_nav()} @@ -367,6 +369,19 @@ </div> % endif + % if master.configurable and master.has_perm('configure'): + % if not request.matched_route.name.endswith('.configure'): + <div class="level-item"> + <once-button type="is-primary" + tag="a" + href="${url('{}.configure'.format(route_prefix))}" + icon-left="cog" + text="Configure"> + </once-button> + </div> + % endif + % endif + ## Theme Picker % if expose_theme_picker and request.has_perm('common.change_app_theme'): <div class="level-item"> diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 6459085b..7f40e1e3 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -1919,6 +1919,11 @@ class ProductView(MasterView): {'section': 'rattail', 'option': 'product.key_title'}, + # handling + {'section': 'rattail', + 'option': 'products.convert_type2_for_gpc_lookup', + 'type': bool}, + # display {'section': 'tailbone', 'option': 'products.show_pod_image', From 1b0d6581db543f584352a322c2ac267929244e61 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 23 Dec 2021 16:18:06 -0600 Subject: [PATCH 0565/1681] Bugfix --- tailbone/templates/themes/falafel/base.mako | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index 1e996e6b..b50cfef7 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -369,7 +369,7 @@ </div> % endif - % if master.configurable and master.has_perm('configure'): + % if master and master.configurable and master.has_perm('configure'): % if not request.matched_route.name.endswith('.configure'): <div class="level-item"> <once-button type="is-primary" From 82dfce6f811812f9f9e633df0808144fbffcba93 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 23 Dec 2021 20:24:43 -0600 Subject: [PATCH 0566/1681] Add basic "resolve" support for person, product from new custorder --- .../templates/customers/pending/view.mako | 143 ++++++++++++++++++ tailbone/templates/custorders/create.mako | 15 +- tailbone/templates/products/pending/view.mako | 130 ++++++++++++++++ tailbone/views/customers.py | 48 ++++++ tailbone/views/custorders/items.py | 42 ++++- tailbone/views/custorders/orders.py | 72 +++++++-- tailbone/views/master.py | 9 +- tailbone/views/products.py | 47 ++++++ 8 files changed, 474 insertions(+), 32 deletions(-) create mode 100644 tailbone/templates/customers/pending/view.mako create mode 100644 tailbone/templates/products/pending/view.mako diff --git a/tailbone/templates/customers/pending/view.mako b/tailbone/templates/customers/pending/view.mako new file mode 100644 index 00000000..e9e54c99 --- /dev/null +++ b/tailbone/templates/customers/pending/view.mako @@ -0,0 +1,143 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> +## <%namespace file="/util.mako" import="view_profiles_helper" /> + +<%def name="object_helpers()"> + ${parent.object_helpers()} + + % if instance.custorder_records: + <nav class="panel"> + <p class="panel-heading">Cross-Reference</p> + <div class="panel-block"> + <div style="display: flex; flex-direction: column;"> + <p class="block"> + This ${model_title} is referenced by the following<br /> + Customer Orders: + </p> + <ul class="list"> + % for order in instance.custorder_records: + <li class="list-item"> + ${h.link_to(order, url('custorders.view', uuid=order.uuid))} + </li> + % endfor + </ul> + </div> + </div> + </nav> + % endif + + ## % if instance.status_code == enum.PENDING_CUSTOMER_STATUS_PENDING and master.has_any_perm('resolve_person', 'resolve_customer'): + % if instance.status_code == enum.PENDING_CUSTOMER_STATUS_PENDING and master.has_perm('resolve_person'): + <nav class="panel"> + <p class="panel-heading">Tools</p> + <div class="panel-block"> + <div style="display: flex; flex-direction: column;"> + % if master.has_perm('resolve_person'): + <div class="buttons"> + <b-button type="is-primary" + @click="resolvePersonInit()" + icon-pack="fas" + icon-left="object-ungroup"> + Resolve Person + </b-button> + </div> + % endif +## % if master.has_perm('resolve_customer'): +## <div class="buttons"> +## <b-button type="is-primary" +## icon-pack="fas" +## icon-left="object-ungroup"> +## Resolve Customer +## </b-button> +## </div> +## % endif + </div> + </div> + </nav> + + <b-modal has-modal-card + :active.sync="resolvePersonShowDialog"> + <div class="modal-card"> + ${h.form(url('{}.resolve_person'.format(route_prefix), uuid=instance.uuid), ref='resolvePersonForm')} + ${h.csrf_token(request)} + + <header class="modal-card-head"> + <p class="modal-card-title">Resolve Person</p> + </header> + + <section class="modal-card-body"> + <p class="block"> + If this Person already exists, you can declare that by + identifying the record below. + </p> + <p class="block"> + The app will take care of updating any Customer Orders + etc. as needed once you declare the match. + </p> + <b-field grouped> + <b-field label="Pending"> + <span>${instance.display_name}</span> + </b-field> + <b-field label="Actual Person" expanded> + <tailbone-autocomplete name="person_uuid" + v-model="resolvePersonUUID" + ref="resolvePersonAutocomplete" + service-url="${url('people.autocomplete')}"> + </tailbone-autocomplete> + </b-field> + </b-field> + </section> + + <footer class="modal-card-foot"> + <b-button @click="resolvePersonShowDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + :disabled="resolvePersonSubmitDisabled" + @click="resolvePersonSubmit()" + icon-pack="fas" + icon-left="object-ungroup"> + {{ resolvePersonSubmitting ? "Working, please wait..." : "I declare these are the same" }} + </b-button> + </footer> + ${h.end_form()} + </div> + </b-modal> + % endif +</%def> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + ThisPageData.resolvePersonShowDialog = false + ThisPageData.resolvePersonUUID = null + ThisPageData.resolvePersonSubmitting = false + + ThisPage.computed.resolvePersonSubmitDisabled = function() { + if (this.resolvePersonSubmitting) { + return true + } + if (!this.resolvePersonUUID) { + return true + } + return false + } + + ThisPage.methods.resolvePersonInit = function() { + this.resolvePersonUUID = null + this.resolvePersonShowDialog = true + this.$nextTick(() => { + this.$refs.resolvePersonAutocomplete.focus() + }) + } + + ThisPage.methods.resolvePersonSubmit = function() { + this.resolvePersonSubmitting = true + this.$refs.resolvePersonForm.submit() + } + + </script> +</%def> + +${parent.body()} diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index ff41f765..db9af7ec 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -12,18 +12,6 @@ % endif </%def> -<%def name="render_instance_header_buttons()"> - ${parent.render_instance_header_buttons()} - % if use_buefy and master.configurable and master.has_perm('configure'): - <div class="level-item"> - <once-button tag="a" href="${url('{}.configure'.format(route_prefix))}" - icon-left="cog" - text="Configure"> - </once-button> - </div> - % endif -</%def> - <%def name="page_content()"> <br /> % if use_buefy: @@ -1968,6 +1956,9 @@ % if product_price_may_be_questionable: this.productPriceNeedsConfirmation = false % endif + + this.itemDialogTabIndex = 1 + }, response => { this.clearProduct() }) diff --git a/tailbone/templates/products/pending/view.mako b/tailbone/templates/products/pending/view.mako new file mode 100644 index 00000000..90d9c687 --- /dev/null +++ b/tailbone/templates/products/pending/view.mako @@ -0,0 +1,130 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> + +<%def name="object_helpers()"> + ${parent.object_helpers()} + % if instance.custorder_item_records: + <nav class="panel"> + <p class="panel-heading">Cross-Reference</p> + <div class="panel-block"> + <div style="display: flex; flex-direction: column;"> + <p class="block"> + This ${model_title} is referenced by the following<br /> + Customer Order Items: + </p> + <ul class="list"> + % for item in instance.custorder_item_records: + <li class="list-item"> + ${h.link_to('#{}-{}'.format(item.order.id, item.sequence), url('custorders.items.view', uuid=item.uuid))} + </li> + % endfor + </ul> + </div> + </div> + </nav> + % endif + + % if instance.status_code == enum.PENDING_PRODUCT_STATUS_PENDING and master.has_perm('resolve_product'): + <nav class="panel"> + <p class="panel-heading">Tools</p> + <div class="panel-block"> + <div style="display: flex; flex-direction: column;"> + <div class="buttons"> + <b-button type="is-primary" + @click="resolveProductInit()" + icon-pack="fas" + icon-left="object-ungroup"> + Resolve Product + </b-button> + </div> + </div> + </div> + </nav> + + <b-modal has-modal-card + :active.sync="resolveProductShowDialog"> + <div class="modal-card"> + ${h.form(url('{}.resolve_product'.format(route_prefix), uuid=instance.uuid), ref='resolveProductForm')} + ${h.csrf_token(request)} + + <header class="modal-card-head"> + <p class="modal-card-title">Resolve Product</p> + </header> + + <section class="modal-card-body"> + <p class="block"> + If this Product already exists, you can declare that by + identifying the record below. + </p> + <p class="block"> + The app will take care of updating any Customer Orders + etc. as needed once you declare the match. + </p> + <b-field grouped> + <b-field label="Pending"> + <span>${instance.full_description}</span> + </b-field> + <b-field label="Actual Product" expanded> + <tailbone-autocomplete name="product_uuid" + v-model="resolveProductUUID" + ref="resolveProductAutocomplete" + service-url="${url('products.autocomplete')}"> + </tailbone-autocomplete> + </b-field> + </b-field> + </section> + + <footer class="modal-card-foot"> + <b-button @click="resolveProductShowDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + :disabled="resolveProductSubmitDisabled" + @click="resolveProductSubmit()" + icon-pack="fas" + icon-left="object-ungroup"> + {{ resolveProductSubmitting ? "Working, please wait..." : "I declare these are the same" }} + </b-button> + </footer> + ${h.end_form()} + </div> + </b-modal> + % endif +</%def> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + ThisPageData.resolveProductShowDialog = false + ThisPageData.resolveProductUUID = null + ThisPageData.resolveProductSubmitting = false + + ThisPage.computed.resolveProductSubmitDisabled = function() { + if (this.resolveProductSubmitting) { + return true + } + if (!this.resolveProductUUID) { + return true + } + return false + } + + ThisPage.methods.resolveProductInit = function() { + this.resolveProductUUID = null + this.resolveProductShowDialog = true + this.$nextTick(() => { + this.$refs.resolveProductAutocomplete.focus() + }) + } + + ThisPage.methods.resolveProductSubmit = function() { + this.resolveProductSubmitting = true + this.$refs.resolveProductForm.submit() + } + + </script> +</%def> + + +${parent.body()} diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index ba05f475..bf8284c0 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -500,6 +500,9 @@ class PendingCustomerView(MasterView): super(PendingCustomerView, self).configure_grid(g) g.set_enum('status_code', self.enum.PENDING_CUSTOMER_STATUS) + g.filters['status_code'].default_active = True + g.filters['status_code'].default_verb = 'not_equal' + g.filters['status_code'].default_value = six.text_type(self.enum.PENDING_CUSTOMER_STATUS_RESOLVED) g.set_sort_defaults('display_name') g.set_link('id') @@ -523,6 +526,51 @@ class PendingCustomerView(MasterView): f.set_readonly('user') f.set_renderer('user', self.render_user) + def editable_instance(self, pending): + if pending.status_code == self.enum.PENDING_CUSTOMER_STATUS_RESOLVED: + return False + return True + + def resolve_person(self): + model = self.model + pending = self.get_instance() + redirect = self.redirect(self.get_action_url('view', pending)) + + uuid = self.request.POST['person_uuid'] + person = self.Session.query(model.Person).get(uuid) + if not person: + self.request.session.flash("Person not found!", 'error') + return redirect + + app = self.get_rattail_app() + people_handler = app.get_people_handler() + people_handler.resolve_person(pending, person, self.request.user) + self.Session.flush() + return redirect + + @classmethod + def defaults(cls, config): + cls._defaults(config) + cls._pending_customer_defaults(config) + + @classmethod + def _pending_customer_defaults(cls, config): + route_prefix = cls.get_route_prefix() + instance_url_prefix = cls.get_instance_url_prefix() + permission_prefix = cls.get_permission_prefix() + model_title = cls.get_model_title() + + # resolve person + config.add_tailbone_permission(permission_prefix, + '{}.resolve_person'.format(permission_prefix), + "Resolve a {} as a Person".format(model_title)) + config.add_route('{}.resolve_person'.format(route_prefix), + '{}/resolve-person'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='resolve_person', + route_name='{}.resolve_person'.format(route_prefix), + permission='{}.resolve_person'.format(permission_prefix)) + # # TODO: this is referenced by some custom apps, but should be moved?? # def unique_id(value, field): diff --git a/tailbone/views/custorders/items.py b/tailbone/views/custorders/items.py index 4d62e505..823130ed 100644 --- a/tailbone/views/custorders/items.py +++ b/tailbone/views/custorders/items.py @@ -52,6 +52,7 @@ class CustomerOrderItemView(MasterView): deletable = False labels = { + 'order': "Customer Order", 'order_id': "Order ID", 'order_uom': "Order UOM", 'status_code': "Status", @@ -172,21 +173,37 @@ class CustomerOrderItemView(MasterView): def configure_form(self, f): super(CustomerOrderItemView, self).configure_form(f) use_buefy = self.get_use_buefy() + item = f.model_instance # order f.set_renderer('order', self.render_order) - # product + # (pending) product f.set_renderer('product', self.render_product) - - # pending_product f.set_renderer('pending_product', self.render_pending_product) + if self.viewing: + if item.product and not item.pending_product: + f.remove('pending_product') + elif item.pending_product and not item.product: + f.remove('product') # product uom f.set_enum('product_unit_of_measure', self.enum.UNIT_OF_MEASURE) + # highlight pending fields + f.set_renderer('product_brand', self.highlight_pending_field) + f.set_renderer('product_description', self.highlight_pending_field) + f.set_renderer('product_size', self.highlight_pending_field) + f.set_renderer('case_quantity', self.highlight_pending_field_quantity) + + 'unit_price', + 'total_price', + 'price_needs_confirmation', + 'paid_amount', + 'status_code', + 'notes', + # quantity fields - f.set_type('case_quantity', 'quantity') f.set_type('cases_ordered', 'quantity') f.set_type('units_ordered', 'quantity') f.set_type('order_quantity', 'quantity') @@ -210,10 +227,27 @@ class CustomerOrderItemView(MasterView): else: f.remove('notes') + def highlight_pending_field(self, item, field, value=None): + if value is None: + value = getattr(item, field) + if not item.product_uuid and item.pending_product_uuid: + return HTML.tag('span', c=[value], + class_='has-text-success') + return value + + def highlight_pending_field_quantity(self, item, field): + app = self.get_rattail_app() + value = getattr(item, field) + value = app.render_quantity(value) + return self.highlight_pending_field(item, field, value) + def render_price_with_confirmation(self, item, field): price = getattr(item, field) app = self.get_rattail_app() text = app.render_currency(price) + if not item.product_uuid and item.pending_product_uuid: + text = HTML.tag('span', c=[text], + class_='has-text-success') if item.price_needs_confirmation: return HTML.tag('span', class_='has-background-warning', c=[text]) diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index a97b9978..c60e859e 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -108,20 +108,23 @@ class CustomerOrderView(MasterView): def configure_grid(self, g): super(CustomerOrderView, self).configure_grid(g) - g.set_joiner('customer', lambda q: q.outerjoin(model.Customer)) - g.set_joiner('person', lambda q: q.outerjoin(model.Person)) - - g.filters['customer'] = g.make_filter('customer', model.Customer.name, - label="Customer Name", - default_active=True, - default_verb='contains') - g.filters['person'] = g.make_filter('person', model.Person.display_name, - label="Person Name", - default_active=True, - default_verb='contains') - - g.set_sorter('customer', model.Customer.name) - g.set_sorter('person', model.Person.display_name) + # customer or person + if self.batch_handler.new_order_requires_customer(): + g.remove('person') + g.set_joiner('customer', lambda q: q.outerjoin(model.Customer)) + g.set_sorter('customer', model.Customer.name) + g.filters['customer'] = g.make_filter('customer', model.Customer.name, + label="Customer Name", + default_active=True, + default_verb='contains') + else: + g.remove('customer') + g.set_joiner('person', lambda q: q.outerjoin(model.Person)) + g.set_sorter('person', model.Person.display_name) + g.filters['person'] = g.make_filter('person', model.Person.display_name, + label="Person Name", + default_active=True, + default_verb='contains') g.set_enum('status_code', self.enum.CUSTORDER_STATUS) @@ -133,13 +136,33 @@ class CustomerOrderView(MasterView): def configure_form(self, f): super(CustomerOrderView, self).configure_form(f) + order = f.model_instance f.set_readonly('id') f.set_renderer('store', self.render_store) + + # (pending) customer f.set_renderer('customer', self.render_customer) f.set_renderer('person', self.render_person) f.set_renderer('pending_customer', self.render_pending_customer) + if self.viewing: + if self.batch_handler.new_order_requires_customer(): + f.remove('person') + if order.customer and not order.pending_customer: + f.remove('pending_customer') + elif order.pending_customer and not order.customer: + f.remove('customer') + else: + f.remove('customer') + if order.person and not order.pending_customer: + f.remove('pending_customer') + elif order.pending_customer and not order.person: + f.remove('person') + + # contact info + f.set_renderer('phone_number', self.highlight_pending_field) + f.set_renderer('email_address', self.highlight_pending_field) f.set_type('total_price', 'currency') @@ -150,6 +173,20 @@ class CustomerOrderView(MasterView): f.set_readonly('created_by') f.set_renderer('created_by', self.render_user) + def highlight_pending_field(self, order, field): + value = getattr(order, field) + pending = False + if self.batch_handler.new_order_requires_customer(): + if not order.customer_uuid and order.pending_customer_uuid: + pending = True + else: + if not order.person_uuid and order.pending_customer_uuid: + pending = True + if pending: + return HTML.tag('span', c=[value], + class_='has-text-success') + return value + def render_person(self, order, field): person = order.person if not person: @@ -164,7 +201,8 @@ class CustomerOrderView(MasterView): return text = six.text_type(pending) url = self.request.route_url('pending_customers.view', uuid=pending.uuid) - return tags.link_to(text, url) + return tags.link_to(text, url, + class_='has-background-warning') def get_row_data(self, order): return self.Session.query(model.CustomerOrderItem)\ @@ -218,6 +256,10 @@ class CustomerOrderView(MasterView): g.set_link('product_brand') g.set_link('product_description') + def row_grid_extra_class(self, item, i): + if not item.product_uuid and item.pending_product_uuid: + return 'has-text-success' + def render_price_with_confirmation(self, item, field): price = getattr(item, field) app = self.get_rattail_app() diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 75996653..f83ec52e 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -290,6 +290,12 @@ class MasterView(View): return self.request.has_perm('{}.{}'.format( self.get_permission_prefix(), name)) + def has_any_perm(self, *names): + for name in names: + if self.has_perm(name): + return True + return False + @classmethod def get_config_url(cls): if hasattr(cls, 'config_url'): @@ -801,7 +807,8 @@ class MasterView(View): return text = six.text_type(pending) url = self.request.route_url('pending_products.view', uuid=pending.uuid) - return tags.link_to(text, url) + return tags.link_to(text, url, + class_='has-background-warning') def render_vendor(self, obj, field): vendor = getattr(obj, field) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 7f40e1e3..30e5fe5f 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -2035,6 +2035,9 @@ class PendingProductView(MasterView): super(PendingProductView, self).configure_grid(g) g.set_enum('status_code', self.enum.PENDING_PRODUCT_STATUS) + g.filters['status_code'].default_active = True + g.filters['status_code'].default_verb = 'not_equal' + g.filters['status_code'].default_value = six.text_type(self.enum.PENDING_PRODUCT_STATUS_RESOLVED) g.set_sort_defaults('created', 'desc') @@ -2137,6 +2140,11 @@ class PendingProductView(MasterView): # f.set_readonly('status_code') f.set_enum('status_code', self.enum.PENDING_PRODUCT_STATUS) + def editable_instance(self, pending): + if pending.status_code == self.enum.PENDING_PRODUCT_STATUS_RESOLVED: + return False + return True + def objectify(self, form, data=None): if data is None: data = form.validated @@ -2182,6 +2190,45 @@ class PendingProductView(MasterView): 'error') return self.redirect(self.get_action_url('view', pending)) + def resolve_product(self): + model = self.model + pending = self.get_instance() + redirect = self.redirect(self.get_action_url('view', pending)) + + uuid = self.request.POST['product_uuid'] + product = self.Session.query(model.Product).get(uuid) + if not product: + self.request.session.flash("Product not found!", 'error') + return redirect + + app = self.get_rattail_app() + products_handler = app.get_products_handler() + products_handler.resolve_product(pending, product, self.request.user) + return redirect + + @classmethod + def defaults(cls, config): + cls._defaults(config) + cls._pending_product_defaults(config) + + @classmethod + def _pending_product_defaults(cls, config): + route_prefix = cls.get_route_prefix() + instance_url_prefix = cls.get_instance_url_prefix() + permission_prefix = cls.get_permission_prefix() + model_title = cls.get_model_title() + + # resolve product + config.add_tailbone_permission(permission_prefix, + '{}.resolve_product'.format(permission_prefix), + "Resolve a {} as a Product".format(model_title)) + config.add_route('{}.resolve_product'.format(route_prefix), + '{}/resolve-product'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='resolve_product', + route_name='{}.resolve_product'.format(route_prefix), + permission='{}.resolve_product'.format(permission_prefix)) + def print_labels(request): profile = request.params.get('profile') From c2d76966a339008075c9bc98a4d35088bdc8fc34 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 29 Dec 2021 11:14:40 -0600 Subject: [PATCH 0567/1681] 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 0ee219b4..affe5ccd 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.8.190 (2021-12-29) +-------------------- + +* Show create button on "most" pages for a master view. + +* Expose products setting for type 2 UPC lookup. + +* Add basic "resolve" support for person, product from new custorder. + + 0.8.189 (2021-12-23) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index ce4233d3..b07a949a 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.189' +__version__ = '0.8.190' From 7b7eee92cda66f205d0796f87f31690f51729ec6 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 30 Dec 2021 11:04:59 -0600 Subject: [PATCH 0568/1681] Fix permission check for input file template links --- tailbone/templates/master/index.mako | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako index 6be36948..48e51286 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -165,7 +165,7 @@ % if not use_buefy and master.configurable and master.has_perm('configure'): <li>${h.link_to("Configure {}".format(config_title), url('{}.configure'.format(route_prefix)))}</li> % endif - % if master.has_input_file_templates and master.has_perm('download_template'): + % if master.has_input_file_templates and master.has_perm('create'): % for template in six.itervalues(input_file_templates): <li>${h.link_to("Download {} Template".format(template['label']), template['effective_url'])}</li> % endfor From 94883c1433c9ff6746a128f61cd14bc212208520 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 31 Dec 2021 13:46:36 -0600 Subject: [PATCH 0569/1681] Remove usage of `app.get_designated_import_handler()` --- tailbone/views/importing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/importing.py b/tailbone/views/importing.py index d93e4cfd..2a660b08 100644 --- a/tailbone/views/importing.py +++ b/tailbone/views/importing.py @@ -167,7 +167,7 @@ class ImportingView(MasterView): """ key = self.request.matchdict['key'] app = self.get_rattail_app() - handler = app.get_designated_import_handler(key, ignore_errors=True) + handler = app.get_import_handler(key, ignore_errors=True) if handler: return self.normalize(handler) raise self.notfound() From 3aac855fa1a16539fea3101ef2d12804a4047a32 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 1 Jan 2022 19:12:46 -0600 Subject: [PATCH 0570/1681] Add basic configure page for Trainwreck also the beginnings of a "yearly rollover" page which hopefully will prove useful for helping to automate that, once i figure out how best to go about it... --- tailbone/subscribers.py | 3 +- .../trainwreck/transactions/configure.mako | 31 +++++ .../trainwreck/transactions/index.mako | 12 ++ .../trainwreck/transactions/rollover.mako | 57 ++++++++ tailbone/views/master.py | 18 ++- tailbone/views/trainwreck/base.py | 130 ++++++++++++++++-- 6 files changed, 234 insertions(+), 17 deletions(-) create mode 100644 tailbone/templates/trainwreck/transactions/configure.mako create mode 100644 tailbone/templates/trainwreck/transactions/index.mako create mode 100644 tailbone/templates/trainwreck/transactions/rollover.mako diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index bce94a98..150aa6da 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.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. # @@ -108,6 +108,7 @@ def before_render(event): request = event.get('request') or threadlocal.get_current_request() renderer_globals = event + renderer_globals['rattail_app'] = request.rattail_config.get_app() renderer_globals['h'] = helpers renderer_globals['url'] = request.route_url renderer_globals['rattail'] = rattail diff --git a/tailbone/templates/trainwreck/transactions/configure.mako b/tailbone/templates/trainwreck/transactions/configure.mako new file mode 100644 index 00000000..7cf03165 --- /dev/null +++ b/tailbone/templates/trainwreck/transactions/configure.mako @@ -0,0 +1,31 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="form_content()"> + + <h3 class="block is-size-3">Hidden Databases</h3> + <div class="block" style="padding-left: 2rem;"> + % for key, engine in six.iteritems(trainwreck_engines): + <b-field> + <b-checkbox name="hidedb_${key}" + v-model="hiddenDatabases['${key}']" + native-value="true" + @input="settingsNeedSaved = true"> + ${key} + </b-checkbox> + </b-field> + % endfor + </div> +</%def> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + ThisPageData.hiddenDatabases = ${json.dumps(hidden_databases)|n} + + </script> +</%def> + + +${parent.body()} diff --git a/tailbone/templates/trainwreck/transactions/index.mako b/tailbone/templates/trainwreck/transactions/index.mako new file mode 100644 index 00000000..31d956fc --- /dev/null +++ b/tailbone/templates/trainwreck/transactions/index.mako @@ -0,0 +1,12 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/index.mako" /> + +<%def name="context_menu_items()"> + ${parent.context_menu_items()} + % if master.has_perm('rollover'): + <li>${h.link_to("Yearly Rollover", url('{}.rollover'.format(route_prefix)))}</li> + % endif +</%def> + + +${parent.body()} diff --git a/tailbone/templates/trainwreck/transactions/rollover.mako b/tailbone/templates/trainwreck/transactions/rollover.mako new file mode 100644 index 00000000..6d6e0b17 --- /dev/null +++ b/tailbone/templates/trainwreck/transactions/rollover.mako @@ -0,0 +1,57 @@ +## -*- coding: utf-8; -*- +<%inherit file="/page.mako" /> + +<%def name="title()">${index_title} » Yearly Rollover</%def> + +<%def name="content_title()">Yearly Rollover</%def> + +<%def name="page_content()"> + <br /> + + % if six.text_type(next_year) not in trainwreck_engines: + <b-notification type="is-warning"> + You do not have a database configured for next year (${next_year}). + You should be sure to configure it before next year rolls around. + </b-notification> + % endif + + <p class="block"> + The following Trainwreck databases are configured: + </p> + + <b-table :data="engines"> + <template slot-scope="props"> + <b-table-column field="key" label="DB Key"> + {{ props.row.key }} + </b-table-column> + <b-table-column field="oldest_date" label="Oldest Date"> + <span v-if="props.row.error" class="has-text-danger"> + error + </span> + <span v-if="!props.row.error"> + {{ props.row.oldest_date }} + </span> + </b-table-column> + <b-table-column field="newest_date" label="Newest Date"> + <span v-if="props.row.error" class="has-text-danger"> + error + </span> + <span v-if="!props.row.error"> + {{ props.row.newest_date }} + </span> + </b-table-column> + </template> + </b-table> +</%def> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + ThisPageData.engines = ${json.dumps(engines_data)|n} + + </script> +</%def> + + +${parent.body()} diff --git a/tailbone/views/master.py b/tailbone/views/master.py index f83ec52e..3807408b 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/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. # @@ -60,6 +60,7 @@ from webob.compat import cgi_FieldStorage from tailbone import forms, grids, diffs from tailbone.views import View +from tailbone.db import Session from tailbone.config import global_help_url @@ -4412,15 +4413,20 @@ class MasterView(View): ]) if names: - self.Session.query(model.Setting)\ - .filter(model.Setting.name.in_(names))\ - .delete(synchronize_session=False) + # 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_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 + session = Session() for setting in settings: - self.Session.add(model.Setting(name=setting['name'], - value=setting['value'])) + session.add(model.Setting(name=setting['name'], + value=setting['value'])) ############################## # Pyramid View Config diff --git a/tailbone/views/trainwreck/base.py b/tailbone/views/trainwreck/base.py index 60a0f873..20e7701d 100644 --- a/tailbone/views/trainwreck/base.py +++ b/tailbone/views/trainwreck/base.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. # @@ -29,9 +29,8 @@ from __future__ import unicode_literals, absolute_import import six from rattail.time import localtime -from rattail.util import OrderedDict -from tailbone.db import TrainwreckSession, ExtraTrainwreckSessions +from tailbone.db import Session, TrainwreckSession, ExtraTrainwreckSessions from tailbone.views import MasterView @@ -53,6 +52,8 @@ class TransactionView(MasterView): SessionDefault = TrainwreckSession SessionExtras = ExtraTrainwreckSessions + configurable = True + labels = { 'store_id': "Store", 'cashback': "Cash Back", @@ -139,13 +140,9 @@ class TransactionView(MasterView): ] def get_db_engines(self): - engines = OrderedDict(self.rattail_config.trainwreck_engines) - hidden = self.rattail_config.getlist('tailbone', 'engines.trainwreck.hidden', - default=None) - if hidden: - for key in hidden: - engines.pop(key, None) - return engines + app = self.get_rattail_app() + trainwreck_handler = app.get_trainwreck_handler() + return trainwreck_handler.get_trainwreck_engines(include_hidden=False) def configure_grid(self, g): super(TransactionView, self).configure_grid(g) @@ -228,3 +225,116 @@ class TransactionView(MasterView): f.set_type('discounted_subtotal', 'currency') f.set_type('tax', 'currency') f.set_type('total', 'currency') + + def rollover(self): + """ + View for performing yearly rollover functions. + """ + app = self.get_rattail_app() + trainwreck_handler = app.get_trainwreck_handler() + trainwreck_engines = trainwreck_handler.get_trainwreck_engines() + current_year = app.localtime().year + + # find oldest and newest dates for each database + engines_data = [] + for key, engine in six.iteritems(trainwreck_engines): + + if key == 'default': + session = self.Session() + else: + session = ExtraTrainwreckSessions[key]() + + error = False + oldest = None + newest = None + try: + oldest = trainwreck_handler.get_oldest_transaction_date(session) + newest = trainwreck_handler.get_newest_transaction_date(session) + except: + error = True + + engines_data.append({ + 'key': key, + 'oldest_date': app.render_date(oldest) if oldest else None, + 'newest_date': app.render_date(newest) if newest else None, + 'error': error, + }) + + return self.render_to_response('rollover', { + 'instance_title': "Yearly Rollover", + 'trainwreck_handler': trainwreck_handler, + 'current_year': current_year, + 'next_year': current_year + 1, + 'trainwreck_engines': trainwreck_engines, + 'engines_data': engines_data, + }) + + def configure_get_context(self): + context = super(TransactionView, self).configure_get_context() + + app = self.get_rattail_app() + trainwreck_handler = app.get_trainwreck_handler() + trainwreck_engines = trainwreck_handler.get_trainwreck_engines() + + context['trainwreck_engines'] = trainwreck_engines + context['hidden_databases'] = dict([ + (key, trainwreck_handler.engine_is_hidden(key)) + for key in trainwreck_engines]) + + return context + + def configure_gather_settings(self, data): + settings = super(TransactionView, self).configure_gather_settings(data) + + app = self.get_rattail_app() + trainwreck_handler = app.get_trainwreck_handler() + trainwreck_engines = trainwreck_handler.get_trainwreck_engines() + + hidden = [] + for key in trainwreck_engines: + name = 'hidedb_{}'.format(key) + if data.get(name) == 'true': + hidden.append(key) + settings.append({'name': 'trainwreck.db.hide', + 'value': ', '.join(hidden)}) + + return settings + + def configure_remove_settings(self): + super(TransactionView, self).configure_remove_settings() + + 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) + + @classmethod + def defaults(cls, config): + cls._trainwreck_defaults(config) + cls._defaults(config) + + @classmethod + def _trainwreck_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() + + # fix perm group title + config.add_tailbone_permission_group(permission_prefix, + model_title_plural) + + # rollover + config.add_tailbone_permission(permission_prefix, + '{}.rollover'.format(permission_prefix), + label="Perform yearly rollover for Trainwreck") + config.add_route('{}.rollover'.format(route_prefix), + '{}/rollover'.format(url_prefix)) + config.add_view(cls, attr='rollover', + route_name='{}.rollover'.format(route_prefix), + permission='{}.rollover'.format(permission_prefix)) From a0bb481a4399283042c8514c9478bb348c127370 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 3 Jan 2022 15:34:00 -0600 Subject: [PATCH 0571/1681] Use `AuthHandler.get_permissions()` instead of deprecated `cache_permissions()` --- tailbone/api/auth.py | 4 ++-- tailbone/subscribers.py | 9 +-------- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/tailbone/api/auth.py b/tailbone/api/auth.py index 80f8fac0..c4d04b90 100644 --- a/tailbone/api/auth.py +++ b/tailbone/api/auth.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. # @@ -89,7 +89,7 @@ class AuthenticationView(APIView): return { 'ok': True, 'user': self.get_user_info(user), - 'permissions': list(auth.cache_permissions(Session(), user)), + 'permissions': list(auth.get_permissions(Session(), user)), } def authenticate_user(self, username, password): diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 150aa6da..44b69247 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -89,15 +89,8 @@ def new_request(event): if rattail_config: app = rattail_config.get_app() auth = app.get_auth_handler() - request.tailbone_cached_permissions = auth.cache_permissions( + request.tailbone_cached_permissions = auth.get_permissions( Session(), request.user) - # TODO: until we know otherwise, let's assume this is not needed - # else: - # # TODO: not sure why this would really work, or even be - # # needed, if there was no rattail config? - # from rattail.db.auth import cache_permissions - # request.tailbone_cached_permissions = cache_permissions( - # Session(), request.user) def before_render(event): From 5e0ba81b21ec359872b574c1adb3a01a99943b53 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 3 Jan 2022 16:17:00 -0600 Subject: [PATCH 0572/1681] 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 affe5ccd..10c39d54 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,18 @@ CHANGELOG ========= +0.8.191 (2022-01-03) +-------------------- + +* Fix permission check for input file template links. + +* Remove usage of ``app.get_designated_import_handler()``. + +* Add basic configure page for Trainwreck. + +* Use ``AuthHandler.get_permissions()``. + + 0.8.190 (2021-12-29) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index b07a949a..b6faadaa 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.190' +__version__ = '0.8.191' From ad110c2ce22b030e2f373bb5fb85bd1f4c9aa4d3 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 3 Jan 2022 21:10:34 -0600 Subject: [PATCH 0573/1681] Remove unused import --- tailbone/views/products.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 30e5fe5f..0e192bca 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.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. # @@ -41,7 +41,6 @@ from rattail.gpc import GPC from rattail.threads import Thread from rattail.exceptions import LabelPrintingError from rattail.util import load_object, pretty_quantity, OrderedDict, simple_error -from rattail.batch import get_batch_handler from rattail.time import localtime, make_utc import colander From f89dc88c0eb3276d4765ce3244efc47163807885 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 6 Jan 2022 12:53:01 -0600 Subject: [PATCH 0574/1681] Add configurable template file for vendor catalog batch --- .../static/files/vendor_catalog_template.xlsx | Bin 0 -> 7593 bytes .../batch/vendorcatalog/configure.mako | 9 +++++++++ .../templates/batch/vendorcatalog/index.mako | 3 --- tailbone/views/batch/vendorcatalog.py | 17 +++++++++++------ 4 files changed, 20 insertions(+), 9 deletions(-) create mode 100644 tailbone/static/files/vendor_catalog_template.xlsx create mode 100644 tailbone/templates/batch/vendorcatalog/configure.mako diff --git a/tailbone/static/files/vendor_catalog_template.xlsx b/tailbone/static/files/vendor_catalog_template.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..f68be31d228508d1bd3667c9873b3911f8bab9b3 GIT binary patch literal 7593 zcmeHMWmwev5+<Zsy1S)xX^>tzmTr&+>COcKr5mI~1O!Rx7D>USTe?A7x}`68&vWH? z&p99N=R5mh_t|G>_W!)|d*?Uvj<P)b13VaHWMmkaL<V)3dq#5m+0zzi=xlE50`zcj zwlQ&VuzBohXZ!f>o5uJS#V>5=!Mon!ogOJQq{x}M^;FsV9iA^3-QHT;;Y`BTd^E&D z=C>-~0}JTXEZy8-_!S%*lR=SiPUn~yE65)3FTI^}L!EO6dc>5K5RMWckq_h6_!zyi zCu1pN4}QorsZGXhA)F!qc5FD`)cQj_=xyVUauVd4%BleN_)YEv7pmnj9+i8?i<_Yn zyJ^zrC#yaZgg|YX=nmK?OxEAon1Ae4X2Ty;_&^jcl$5+{Lib}9JQ5a~<k*oa8`!~5 zkSgogdz~>>uM3#UQ_3Jw2{a;r9@k6w%KYU?>3k(+c_d_KCT-TmTNp&(VPKU17AU-b z1Lb0A>}+nP=IU%^Z{c!>Q{vY?MR;l~q`i72P_5p2iJ_G?iT={3@*0nd$a=bZcn;ev zp)-=EbR`<E!MB;x3%r*hDArf}QCl#tQagfygmIjo`}IP6zIZ5MuJNX-a9H-OW0PDX zLdVa+*z`&AuSkTm@ifJhc|V(3F2WlLLr$DHn1H50MhUhg#1~vq@8b^S!0pr)Y46xq zCc-O;XM51McY`&TtIkn$RM_<U`#)kq;T;>NtK{g4p&!pWwqc87ijE1wFZFwYkK#^6 z^<Ak~%JI+0>!39|+GEl-$ciJydFt68W8Ts{ECpIam{uxE<SfQd-|bSWXFG@!xeWJ< znR$l{zSUOO(z^bEBzYZtj&_Hl6Hh+s16UXsDBM4xNc3+M?+%)arMbDQ3-IR~=N*!1 z8VX9J>{yMbTH3;n){~+=Ayg=0nM{c<sNf{O_vs8gd(@ELcES*3IA4B>J0(6!7I)lx z-uI)CxBthMR^}vPk(wpLa|aJ7aWY;G$0mwj?K?mI)n15k_MoLvtKsMU%8ihHg6;So zSV3BtB?iIMCP7)8*Q?6NA0z0Ay3H)n7Okwt!&n|66Z7KpPf|u<#EUV0u?fVz01_pe zGY6r83*jP5L9CXGAADXHe%;^T)2yv#RhtTEl#;cx5EICF$~0E?;6}N<p}9j3wQ9qR zUooX`xLjSGx~!Ge4A-}b@GJX;c*__d{Y{UPKpq3jqo)!00rN$1THP>`jm0So<n|W% zz%W!3pX47X1oba8HdCkS{d+xP4T_R7sE?`U6Li!K)Dl<Z%f)7?WjZBteAZun&$L@2 zwT84RP-);YBxSavS4D@IS$Uoc;PYL!PO_MkgM%2tPo%P1&Kd0eC;?^$VT46AKm{H? z^PnGiVz3@~bm1My%Skk~1sndHpdrA~?9rwdi^~A~lQ_kIMVG=p!Smoi`(&zbK<Yuc zsO?<}a^)qcC#m%hXi5*u#EV=&`6`=~`k1uL+=!)|0WvVZ^egicp*a_QkE~Tn?i}&v z9R4n?S)XPuS+SSoQc(J<;T8<8Jf~ykE6!IV*G&ZO#rb8?bHv*e<RO()=gHe~L@`Ug zWqu<*mLpp0LIi$2%R^;CqQUD(o7r!R3+Pnj%jAzqY{laM=m|`l&&#?LG>X2t8w@OF z#R<ohc9=iJvw(FsHnj`Wb9on_g9T3)p&=nkc7*~Q@?yc$vCNS?ceP(OEqTs61=eLx z&Q3t*{z5ZD{kYdZQ+vhex~YEk*dQW8RpG~)W1ME7aGc2p|NNm8Zv8_UoOtFQ8O9~k zmrZ#*4Y3ECh+sd3%!FeE_E~I62!F)9zySXFuy@^SvieM$H4HRk;klNS9df;gOWxiW z`)kirkJw<#?N@KG;F(y3=cOhPZBo$(WP5fTjL%8$sFS5x`~LWrHoA!a19jN%s8g*q z?fi`o%V)mS@ys~geIEgx4jybSo7}5zkIx~Xalv3qTxcRsAuAeJIzQG%h+dsIO9P>D z-~5rP=^;NVbatS++)v;FB1)?R4S0})O_Q+d((6Rh3H&1UrDY^=6F9^3c322@UQas2 zlP?X>e9`6?6>S^o%Vr%AQi2jcj$JRuQPY+s2F3<zHJwOwvCADvOYQ_Ui5#Iy<l7MS zpLz3Xr^FE%R(PrzF#}LKp2RIQb;>Z!OiHi_IzFWT1ieh6UvLk1era4RVbcITkp3{f zg6j<<UcJ@FkbdRKBa6(KpU+2@r*=xi*Iau=!5kTUDY>Uul}`Sud=^e{c3DO}aOy{5 z{Bp1|x@H&e*pE(kmn1`RL8_zepk{W9>X-3uh{AIV>SGBw#Yub}<xts+`irPuklC4T zk+uOY9iQaer_6++(fi#^2r+q&HD30;j7yX=i9UR4H{xazbcJ~N<>h7ryC$n?f_O6A zyhG#s#z4I${vhd5HxECnyM{(jc)5kSYD1GELR)`NMQ@lo;@YS%qJD_jIH;UXFGCZ* z)<m^Jta!6Kf!Qau(YU}ROFXMW)4I&OfGFh=&u%-V^koG^SZa9e(gAFsPrbcX;7m|g zl}TXtK7+NST#i4GdaGr~JHR=gwFV}+>j*ISjciZ1!Y)AKEsVD}P9=(&#=2qRJXxZc zMl5|4FC5R0csh<kYdTt4{|yzLVx0`av?71yE5lALecD(%aa@VUJ#gDXRa|Bwx=mME z5_(9Z<#VsDro^rBIA7YyU4)?xdL&I}M+pv+BgDlGdRi7@aunOao7krq#mjrO%Nc%N zU9S?!M&qtx=2j-;qGRNJp^pcOmM32wp!m;udU|zz#=QE@vlr8aG1shS<6Ua6z8T!I z50E}@@LWY5IsHZ-XUVf@yXNTPv%$<|kStI+=i@`_aml{m_bI&^7fCM&nXwOz78Qw1 zSktG+eO0NU_~0weHCJeU5b_E3;nY3}_73f1OzbNk=xAoQ5oI}_gv3Q?DI&Wyb?`;# zra07+(#gq2=ea&zyQ`1oxx3~090w%!D}SjmZ<dC!kFuQjw)EiCa&5e705Z3KN<#1z zXe4Y*u!__=w4Wa$B=I2a;FGnDj!d2}LC4HS_L%7Tqt1-^LxGDrVF}5`%65x*{8G0A zImP@2pxe@p*&y;4=nz-`X0FwaZiY{(62sDRluFpa6EJ-8#}Zs3E4naDLP1XW8JZ>r zs?^P~sokjdtj?3^J{GPRFS_tnl`ABA)07rdmNz?Vr2Yx)lU}Fa&rH?COm$3}=p1s* zaf%TI=%31rO3mAa<MVusPuJL5(QX_|d%wvz<y}&ZTa%?koO_A|%c!qN1>+@{Qb)fO zM++yk#Voylh@jy6Ok!*)Cf;#`gv(^za;-?&B}usw4q;kqL2|s_p82snTu=s&>PkjA zw1`go9ad;i*qB&Hr>snhSbF}*Wt4p4nXqh0O&=&tK|3ya-|7Sfe`W2{_0^Hhm*usG zM6-b|NaB;UELc}p*rmqIOV3o6{FQI&lRW`ZCS3iOB2xIc(jp?3x;@<cQx}2bUe4)P z@OKsOK!UrW=m89j59a?+@i_k9Ed0M&_<ytTkIlj@ukOaW`vYB++O+*N8@kWD7Lk|7 zbNLFoP&G5fNP-D~!nkk`54p`NdT~{I@{`@HmD--c_Y>kYDs-5T&ZV=y53BvK3C~oa zbVNU(u&;Xj!|cGmg4sKPd{fFQ%8wDC%jhXHtiru9s@oh)9vG`=<ni6f&sHJ)r5U+i zRHRtzOgRM~Kx%eEvy2%*B{1N=#=+AL1BEwK<gMT<%wSgeq%U%-7bJljqIizhW#0Pb zQt@pKW@HosVi<NQrB&@UxZN(_9vMtsm!w9n*89YpxPs-nxv7TaD4NYhsChd>ofT94 zNsx1|%P`N7#7H8c96u(61v&V>H(!rcg?>~mw_rkFdHYnh-_&H}S`|Rb&Fa@vX@ztw z$~h~No}SFO%+65z{&4ilvQ(*v4gawtnL_Z@^rJF6a}y4fTxuuuzMW}Mo7u9dZdU?N zO{ipM=Q50TX?1L@M_cDkkqEglk~L>QjfUVZYLTs*$)KB2iaD8pCTB2)n^xeXZ-rZX z%mU9J3V3k})OAPCby4W@2qBQUMb}#(;<Z0?2FCgh_KSngBngnqB^U^ABG?|Epw?#c zWxU+I07alZJk%leHZbd@pEHzwQwat=G$=IvwuK+jRT{J8m<n#sBz+vv3GhGeyut%W z+t(?o&FEIh<!(5PR7s{S0yoabr52IAFjrP1>-gKue7au(?UX2j-EL~QOUIYKVcr#D z8$d<@_HFCA^^dbJ;E&nY6=G}tbLidH(N~&g!@KP_%g|GTt6o)Q!%C5~8>O9aP&0m| z6OETmZEr2-><CIS6cJGT`hd~aoDyNB>ttW(G@YU&?5$^ge4sxID@UsirTds2G8>ww z%tSt>QgDEW9+nwSIhIPlBc-AfqQ{k%0Ye@hWe1Ub21i*DrYY0#X;LBryP|SUzgq7I z7%J(XO%nbpF$XS-Z8$qnSAW{Q_yIPePfS!ih=vnuk3HU}Rt5B#%TO$*&y9+0;{=kQ z{k$C=O(N6H!LgM1(pWKh8eKq7A5vdoQaXKdTAenWrG&Ir>KFNG22yD~<$PkRuvUpW z+eGNZ6F(n-xYxLa_>{Wf`ve4%V8l6x3q_7m7F!>Ap$~Y#j`N0Nbnt+NVH6H!n?B1h z8HK4`d*BM>av%kS&#cl7e$4e^H%CT}bwU&%Iz*|cRy_mOA7)UXIF&h)$*{_sF2p{N z8f{#lQTNU-nt4h8K+In%mD(%r#5mA0*cgF)&qmX~N!p1{!vrK%ZcnfDC{02xJzueF zC`VznAgdcG`KxIob+crpp+jMI{Q3@}$(iW3OL60R3b+j>hbaxCRxu8k2rIlw4X})f z!<OGG{w&@?k74F3(q%j^ZcY{YiHeuG+TgQFx!>`&beyR8>Zusjz66C+7?>buLpQ@U z|8uuQYimPUN{<O>VkH(+hP&8Sj)nNe!2(W5PG@ppxNbxh!_cF=tv5<(l0M9?b36qY z<7v#mfHm6O>F_tS&lVd;!>!t?Omx|clO>^iq&-L3uKe;j?K;fdv*xOmSMS=ikC*D7 z^p5xM>qSs3Jsi?saT8KXxSr`hY9oEwY&V@MB8FK{zz7qO!?peHsSU7?>m&EJ?DX)N z(6H~7@8wrP7u|uRua&P)_gtr0ww(rwzHMoG)9R%yJ=GR}={r3eVtD)-Nw;#_1e{OT z8(CBz=#x&DAGv!(5*YRAfur?1ExL~&=Uw`!pwFzynyGz=9gxuB{OZvKySWYls933y z<fOfFZ~e)eH`Z(yd&@Tu?${#;gkglgWlrO*iuU))5!x+#{;jY5)YOXO{-dcepK{NN z-f3!JnR;q2iqCZRq}5?r(13#z30xfXsERMqPQHHECOLCpd)7HN26=-p4V^H!Mg~)# zJZR6g69mZ9_ujW1n@?KRL7dq+MoI3dcM;T`?d=*Xhq`{KAU_1VX1ZktBP1H)tgqq0 zacvg58mO}siQ{CU`F_IhK|`Q|oej6WEG3zjFy}Wdns{pWPC0Pa^E|!=9DsQ+DrC;w z=Tp!8ZUP1|I-86F_G{|Zolb(`kI~du2|XLh$+&f&R-GM9ju87Aa01yZT)h=&ePqBp z@9@bv4do(q(=U5gaqnOXO5sY`xdoyBAEhDO+h3SDm?}FvIJy8$-CSH9?0$~6!wL@m zY(z+V7s3b|<a)~L=tg*40$XL!*WOy;Tmdy!g`_$svs%IHLuSL%!>=#>w&QtH{KgVp z-pg^em@WXY92p<dSX^QS%(`zYzRUuwJ}G&S$8MRAc(}~ur6HzjPBa>=u)vR)!|sfj z$v|IRPBCF_uZ5avZM&sRp23JJWy#Q4o;xE-naNHkZ6~qaEl;+7@a*B?c<0mD6IZWh zyX-#s_b#4Gc-6)b^ri}`z6uzGl4ex3aq+pYfRII~q*~x*&byK?ZQn`xDrUFfLzbrI zo>Q+hZN^6lzR{r<sZt5!rO2Qb!){QNEF-+J!t|ata1`%SVIOwoBc&&|il}$WO82gb zQo~=`<$uI3GT0zKADG|2<pAO3TVH%VHq%wyS(b?@)ge?zUHkq+louP{rx)~^+ix4U zTQ+&+eQysY67rL(52T=5ajx(m%RKg<GT`8BeusaQYR|0%jMZ?6|7thvFazN|T_Vh} z1bsHa%UWXZw<IPV6|=cl-l^HLXh)yzEGz^soOz}rx~E_AG$cI<_s4=zS@qSjx*S!c zTzu^0Op=i*&=rfuFd8hX?ar68DqhqXN3B{h611RGkkg3f87sgu73ZZ2MM)Loch<K7 z6$R8m!v}$?aM52J!x6MwL+NX|k4D!+qmv?|ldKC1`(LP_<0Hq8Eii`e$=ZO>T=qd7 zsZ0nTkq`)T;GYzn2FJAKTKO|<WS86^H$O^Gn#~t}y<NbVK_kEurwV|=9j1b&UXvz| zFE2WAOG}cqms3jl^65)DrOae^9eW4+H97z;YR4pd(qBXn6BT@_*a;Roqg`~AH2dU+ z1{6I9_9$}XPN``^CxQgdQ{d}G_+rpLWzstJUioF$yK+1swPIUmwwc&J*~M4Uat*W_ ze|E8tGIBy2^|>u+8uk1L>-sFFjrT4=8snerOl}i2{f`vEx}P9pN5`LoP^^;8twi6p zdx6(-l){Ii$JpjHNhE+@BhKTFR#IF=aUD{ea5OzIov{t!6%yKyKbmka&*cMwr|Q$W zG!#%Bzp`v&2tg<fy1AKfQ1b%!F}$3EC2B6C8zm{J-Uq9j`O6DEp$P`$$WpX~tD8@o zTO16b_Ht0<zhii`IN3AlFFrNqCT$FIL{%q#EP5b4le*Weys|Rjvth~?(z|2}LmDZ% z5NXiUlEoHK^Lcuv3HgM`e@N9xF>v=txOH<EbxK3wSh>0|&Ha$Wx1_$j9%zr)e%VSW zldaI`l~S!@zB<=R)R^1e410$YQLq~3_gfUaZn66NihEZ|^u!(PUCr%X4b>rz<}Uho zQ1xl_DFv|MHSd;EHIl6J;F_Y_B8f!TK_NVe!WkG-x!bEJ^B1U!OJ=M+p<dSO-p#;s z)O}a|#1_;@d^rX)Jb)|=hsM)RL29ML`n@>t!vN2wAtU&$L>}rOm-BGdw*<=Uq)Y%D zmNy*uE<Y@t3v}v?o(%3gdV<vG$z=%n2S5z_05e;ix&`;B>8Qu8vW~MykPm@u#qzH> zLd?GxR^eM@RFQv9To%}A#k9bt*`&}zwi`HMv3#?O9NPWx@M1eu3$Ptq_aYhT`K**9 z^0VGrZ%t!({ntm6A(;-{tOg0X5|&KgpUhDh>#@OK4iO8wkh)PyMr3z$yn@r(s1TDe ztA=g2gI!>2;oX53ettw^Vbz#=y0sF2BSV?x_s#S&M7V}_a?^5wL<8I@ez?Iye<YmD zZS~sOh0<>c<9*v3m8q|;R%r!_ic^DuBu`rShR6@)pAz#Kc#)1}#b`);kFWH<*>heH zsYYTizR`{MxXV~Z-CUT{ZMK$w&MUBRcrd>fQTI15cZ;Y$+uyIHl;!^%=l<^DZcp>e zm~Q|1HqL)-ZGMk%UxK~cQT#H8TSopdN&VNx;`b=`WsN^aDM0wkC_ly9-=qBfv!tT_ zWt5+S$nR0^clLMF;4d4$ZRdZR4S#pO-`LzW|G$j>wg%k+`kztYch~!u{axqt%S>-+ zd9M%p-SPe%bw}V|7Ji%4zvv8p4{*PR-d$zC44vS9fIlv_-`($*mb*gx%Zdr_x&Kji kfA_wB%>VrKqqisFFN%?}Ji;v_U|`ViUbokB$WH?N3&9)PivR!s literal 0 HcmV?d00001 diff --git a/tailbone/templates/batch/vendorcatalog/configure.mako b/tailbone/templates/batch/vendorcatalog/configure.mako new file mode 100644 index 00000000..e4fa346a --- /dev/null +++ b/tailbone/templates/batch/vendorcatalog/configure.mako @@ -0,0 +1,9 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="form_content()"> + ${self.input_file_templates_section()} +</%def> + + +${parent.body()} diff --git a/tailbone/templates/batch/vendorcatalog/index.mako b/tailbone/templates/batch/vendorcatalog/index.mako index 1fac1170..fa6e4a5a 100644 --- a/tailbone/templates/batch/vendorcatalog/index.mako +++ b/tailbone/templates/batch/vendorcatalog/index.mako @@ -3,9 +3,6 @@ <%def name="context_menu_items()"> ${parent.context_menu_items()} - % if generic_template_url and master.has_perm('create'): - <li>${h.link_to("Download Generic Template", generic_template_url)}</li> - % endif % if h.route_exists(request, 'vendors') and request.has_perm('vendors.list'): <li>${h.link_to("View Vendors", url('vendors'))}</li> % endif diff --git a/tailbone/views/batch/vendorcatalog.py b/tailbone/views/batch/vendorcatalog.py index 7a1a4153..5436f7d7 100644 --- a/tailbone/views/batch/vendorcatalog.py +++ b/tailbone/views/batch/vendorcatalog.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. # @@ -57,6 +57,8 @@ class VendorCatalogView(FileBatchMasterView): template_prefix = '/batch/vendorcatalog' editable = False rows_bulk_deletable = True + has_input_file_templates = True + configurable = True labels = { 'vendor_id': "Vendor ID", @@ -133,6 +135,14 @@ class VendorCatalogView(FileBatchMasterView): 'status_text', ] + def get_input_file_templates(self): + return [ + {'key': 'default', + 'label': "Default", + 'default_url': self.request.static_url( + 'tailbone:static/files/vendor_catalog_template.xlsx')}, + ] + def get_parsers(self): if not hasattr(self, 'parsers'): parsers = sorted(iter_catalog_parsers(), key=lambda p: p.display) @@ -252,11 +262,6 @@ class VendorCatalogView(FileBatchMasterView): f.set_type('upc', 'gpc') f.set_type('discount_percent', 'percent') - def template_kwargs_index(self, **kwargs): - url = self.rattail_config.get('tailbone', 'batch.vendorcatalog.generic_template_url') - kwargs['generic_template_url'] = url - return kwargs - def template_kwargs_create(self, **kwargs): parsers = self.get_parsers() for parser in parsers: From ab61778d3547e38cb0faf46364e57ffba861272d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 7 Jan 2022 15:03:56 -0600 Subject: [PATCH 0575/1681] Some aesthetic improvements for vendor catalog batch hopefully they're improvements... --- .../batch/vendorcatalog/view_row.mako | 13 ++++ tailbone/templates/batch/view.mako | 63 +++++++++++++++++++ tailbone/templates/master/view.mako | 22 ++++++- tailbone/views/batch/core.py | 4 +- tailbone/views/batch/vendorcatalog.py | 39 ++++++++---- 5 files changed, 124 insertions(+), 17 deletions(-) create mode 100644 tailbone/templates/batch/vendorcatalog/view_row.mako diff --git a/tailbone/templates/batch/vendorcatalog/view_row.mako b/tailbone/templates/batch/vendorcatalog/view_row.mako new file mode 100644 index 00000000..6aaf9bf4 --- /dev/null +++ b/tailbone/templates/batch/vendorcatalog/view_row.mako @@ -0,0 +1,13 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view_row.mako" /> + +<%def name="render_buefy_form()"> + <div class="form"> + <tailbone-form></tailbone-form> + <br /> + ${catalog_entry_diff.render_html()} + </div> +</%def> + + +${parent.body()} diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako index 36b9b633..1b7787bb 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -351,6 +351,58 @@ </div> </%def> +<%def name="render_row_grid_tools()"> + ${parent.render_row_grid_tools()} + % if use_buefy and master.rows_bulk_deletable and not batch.executed and master.has_perm('delete_rows'): + <b-button type="is-danger" + @click="deleteResultsInit()" + icon-pack="fas" + icon-left="trash"> + Delete Results + </b-button> + <b-modal has-modal-card + :active.sync="deleteResultsShowDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Delete Results</p> + </header> + + <section class="modal-card-body"> + <p class="block"> + This batch has + <span class="has-text-weight-bold">${batch.rowcount}</span> + total rows. + </p> + <p class="block"> + Your current filters have returned + <span class="has-text-weight-bold">{{ total }}</span> + results. + </p> + <p class="block"> + Would you like to + <span class="has-text-danger has-text-weight-bold"> + delete all {{ total }} + </span> + results? + </p> + </section> + + <footer class="modal-card-foot"> + <b-button @click="deleteResultsShowDialog = false"> + Cancel + </b-button> + <once-button type="is-danger" + tag="a" href="${url('{}.delete_rows'.format(route_prefix), uuid=batch.uuid)}" + icon-left="trash" + text="Delete Results"> + </once-button> + </footer> + </div> + </b-modal> + % endif +</%def> + <%def name="modify_this_page_vars()"> ${parent.modify_this_page_vars()} <script type="text/javascript"> @@ -395,6 +447,17 @@ } % endif + + % if master.rows_bulk_deletable and not batch.executed and master.has_perm('delete_rows'): + + ${rows_grid.component_studly}Data.deleteResultsShowDialog = false + + ${rows_grid.component_studly}.methods.deleteResultsInit = function() { + this.deleteResultsShowDialog = true + } + + % endif + </script> </%def> diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako index 37d60c39..f361ad04 100644 --- a/tailbone/templates/master/view.mako +++ b/tailbone/templates/master/view.mako @@ -71,16 +71,32 @@ % if master.touchable and request.has_perm('{}.touch'.format(permission_prefix)): <li>${h.link_to("\"Touch\" this {}".format(model_title), url('{}.touch'.format(route_prefix), uuid=instance.uuid))}</li> % endif - % if master.has_rows and master.rows_downloadable_csv and request.has_perm('{}.row_results_csv'.format(permission_prefix)): - <li>${h.link_to("Download row results as CSV", url('{}.row_results_csv'.format(route_prefix), uuid=instance.uuid))}</li> + % if not use_buefy and master.has_rows and master.rows_downloadable_csv and master.has_perm('row_results_csv'): + <li>${h.link_to("Download row results as CSV", master.get_action_url('row_results_csv', instance))}</li> % endif - % if master.has_rows and master.rows_downloadable_xlsx and master.has_perm('row_results_xlsx'): + % if not use_buefy and master.has_rows and master.rows_downloadable_xlsx and master.has_perm('row_results_xlsx'): <li>${h.link_to("Download row results as XLSX", master.get_action_url('row_results_xlsx', instance))}</li> % endif </%def> <%def name="render_row_grid_tools()"> ${rows_grid_tools} + % if use_buefy: + % if master.rows_downloadable_xlsx and master.has_perm('row_results_xlsx'): + <b-button tag="a" href="${master.get_action_url('row_results_xlsx', instance)}" + icon-pack="fas" + icon-left="download"> + Download Results XLSX + </b-button> + % endif + % if master.rows_downloadable_csv and master.has_perm('row_results_csv'): + <b-button tag="a" href="${master.get_action_url('row_results_csv', instance)}" + icon-pack="fas" + icon-left="download"> + Download Results CSV + </b-button> + % endif + % endif </%def> <%def name="render_this_page()"> diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 2f73de53..36ad341b 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/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. # @@ -686,6 +686,8 @@ class BatchMasterView(MasterView): return HTML.tag('p', c=[link]) def make_batch_row_grid_tools(self, batch): + if self.get_use_buefy(): + return if self.rows_bulk_deletable and not batch.executed and self.request.has_perm('{}.delete_rows'.format(self.get_permission_prefix())): url = self.request.route_url('{}.delete_rows'.format(self.get_route_prefix()), uuid=batch.uuid) return HTML.tag('p', c=[tags.link_to("Delete all rows matching current search", url)]) diff --git a/tailbone/views/batch/vendorcatalog.py b/tailbone/views/batch/vendorcatalog.py index 5436f7d7..3668500a 100644 --- a/tailbone/views/batch/vendorcatalog.py +++ b/tailbone/views/batch/vendorcatalog.py @@ -40,6 +40,7 @@ from webhelpers2.html import tags from tailbone import forms from tailbone.db import Session from tailbone.views.batch import FileBatchMasterView +from tailbone.diffs import Diff log = logging.getLogger(__name__) @@ -76,11 +77,12 @@ class VendorCatalogView(FileBatchMasterView): form_fields = [ 'id', - 'description', - 'vendor', 'filename', + 'parser_key', + 'vendor', 'future', 'effective', + 'description', 'notes', 'created', 'created_by', @@ -114,16 +116,6 @@ class VendorCatalogView(FileBatchMasterView): 'description', 'size', 'is_preferred_vendor', - 'old_vendor_code', - 'vendor_code', - 'old_case_size', - 'case_size', - 'old_case_cost', - 'case_cost', - 'case_cost_diff', - 'old_unit_cost', - 'unit_cost', - 'unit_cost_diff', 'suggested_retail', 'starts', 'ends', @@ -131,6 +123,8 @@ class VendorCatalogView(FileBatchMasterView): 'discount_ends', 'discount_amount', 'discount_percent', + 'case_cost_diff', + 'unit_cost_diff', 'status_code', 'status_text', ] @@ -228,7 +222,7 @@ class VendorCatalogView(FileBatchMasterView): # starts if not batch.future: - g.hide_column('starts') + g.remove('starts') g.set_type('old_unit_cost', 'currency') g.set_type('unit_cost', 'currency') @@ -262,6 +256,25 @@ class VendorCatalogView(FileBatchMasterView): f.set_type('upc', 'gpc') f.set_type('discount_percent', 'percent') + def template_kwargs_view_row(self, **kwargs): + row = kwargs['instance'] + batch = row.batch + + fields = [ + 'vendor_code', + 'case_size', + 'case_cost', + 'unit_cost', + ] + old_data = dict([(field, getattr(row, 'old_{}'.format(field))) + for field in fields]) + new_data = dict([(field, getattr(row, field)) + for field in fields]) + kwargs['catalog_entry_diff'] = Diff(old_data, new_data, fields=fields, + monospace=True) + + return kwargs + def template_kwargs_create(self, **kwargs): parsers = self.get_parsers() for parser in parsers: From 88b3279e63a66768f16f68666b392e326210cad9 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 7 Jan 2022 19:27:10 -0600 Subject: [PATCH 0576/1681] Several disparate changes needed for vendor catalog improvements - invoke vendor handler where appropriate, e.g. for parsers - reverse "polarity" of dropdown chooser setting; rename it - tweak autocomplete behavior yet again, for dynamic values - auto-select vendor upon parser selection, when possible --- tailbone/forms/widgets.py | 4 +- .../static/js/tailbone.buefy.autocomplete.js | 16 +++ tailbone/templates/autocomplete.mako | 4 +- .../templates/batch/vendorcatalog/create.mako | 24 ++++ .../templates/deform/autocomplete_jquery.pt | 3 +- tailbone/templates/forms/deform_buefy.mako | 2 + tailbone/templates/vendors/configure.mako | 8 +- tailbone/views/batch/vendorcatalog.py | 114 +++++++++++------- tailbone/views/purchasing/batch.py | 24 ++-- tailbone/views/purchasing/costing.py | 27 +++-- tailbone/views/purchasing/receiving.py | 27 +++-- tailbone/views/vendors/core.py | 4 +- 12 files changed, 164 insertions(+), 93 deletions(-) diff --git a/tailbone/forms/widgets.py b/tailbone/forms/widgets.py index ad9d9c31..3dac0a6a 100644 --- a/tailbone/forms/widgets.py +++ b/tailbone/forms/widgets.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. # @@ -247,6 +247,7 @@ class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget): template = 'autocomplete_jquery' requirements = None field_display = "" + assigned_label = None service_url = None cleared_callback = None selected_callback = None @@ -275,6 +276,7 @@ class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget): kw['options'] = json.dumps(options) kw['field_display'] = self.field_display kw['cleared_callback'] = self.cleared_callback + kw['assigned_label'] = self.assigned_label kw.setdefault('selected_callback', self.selected_callback) tmpl_values = self.get_template_values(field, cstruct, kw) template = readonly and self.readonly_template or self.template diff --git a/tailbone/static/js/tailbone.buefy.autocomplete.js b/tailbone/static/js/tailbone.buefy.autocomplete.js index 53c41b40..ce0aece9 100644 --- a/tailbone/static/js/tailbone.buefy.autocomplete.js +++ b/tailbone/static/js/tailbone.buefy.autocomplete.js @@ -95,6 +95,22 @@ const TailboneAutocomplete = { } }, + watch: { + // TODO: yikes this feels hacky. what happens is, when the + // caller explicitly assigns a new UUID value to the tailbone + // autocomplate component, the underlying buefy autocomplete + // component was not getting the new value. so here we are + // explicitly making sure it is in sync. this issue was + // discovered on the "new vendor catalog batch" page + value(val) { + this.$nextTick(() => { + if (this.buefyValue != val) { + this.buefyValue = val + } + }) + }, + }, + methods: { // fetch new search results from the server. this is invoked diff --git a/tailbone/templates/autocomplete.mako b/tailbone/templates/autocomplete.mako index 7961d07c..8c84aedd 100644 --- a/tailbone/templates/autocomplete.mako +++ b/tailbone/templates/autocomplete.mako @@ -64,7 +64,7 @@ <b-autocomplete ref="autocomplete" :name="name" - v-show="!assignedValue && !selected" + v-show="!value && !selected" v-model="buefyValue" :placeholder="placeholder" :data="data" @@ -76,7 +76,7 @@ </template> </b-autocomplete> - <b-button v-if="assignedValue || selected" + <b-button v-if="value || selected" style="width: 100%; justify-content: left;" @click="clearSelection(true)"> {{ getDisplayText() }} (click to change) diff --git a/tailbone/templates/batch/vendorcatalog/create.mako b/tailbone/templates/batch/vendorcatalog/create.mako index 87d65c54..78b5b17d 100644 --- a/tailbone/templates/batch/vendorcatalog/create.mako +++ b/tailbone/templates/batch/vendorcatalog/create.mako @@ -3,6 +3,7 @@ <%def name="extra_javascript()"> ${parent.extra_javascript()} + % if not use_buefy: <script type="text/javascript"> var vendormap = { @@ -50,6 +51,29 @@ }); </script> + % endif </%def> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + ${form.component_studly}Data.parsers = ${json.dumps(parsers_data)|n} + + ${form.component_studly}Data.vendorName = null + + ${form.component_studly}.watch.field_model_parser_key = function(val) { + let parser = this.parsers[val] + if (parser.vendor_uuid) { + if (this.field_model_vendor_uuid != parser.vendor_uuid) { + this.field_model_vendor_uuid = parser.vendor_uuid + this.vendorName = parser.vendor_name + } + } + } + + </script> +</%def> + + ${parent.body()} diff --git a/tailbone/templates/deform/autocomplete_jquery.pt b/tailbone/templates/deform/autocomplete_jquery.pt index 1533cc2b..6e1a1e61 100644 --- a/tailbone/templates/deform/autocomplete_jquery.pt +++ b/tailbone/templates/deform/autocomplete_jquery.pt @@ -110,7 +110,8 @@ <tailbone-autocomplete name="${name}" service-url="${url}" v-model="${vmodel}" - initial-label="${field_display}"> + initial-label="${field_display}" + tal:attributes=":assigned-label assigned_label or 'null';"> </tailbone-autocomplete> </div> diff --git a/tailbone/templates/forms/deform_buefy.mako b/tailbone/templates/forms/deform_buefy.mako index 17ccf7d1..a26c946a 100644 --- a/tailbone/templates/forms/deform_buefy.mako +++ b/tailbone/templates/forms/deform_buefy.mako @@ -76,6 +76,8 @@ template: '#${form.component}-template', components: {}, props: {}, + watch: {}, + computed: {}, methods: { ## TODO: deprecate / remove the latter option here diff --git a/tailbone/templates/vendors/configure.mako b/tailbone/templates/vendors/configure.mako index 0bcb4a9e..121617b2 100644 --- a/tailbone/templates/vendors/configure.mako +++ b/tailbone/templates/vendors/configure.mako @@ -6,11 +6,11 @@ <h3 class="block is-size-3">Display</h3> <div class="block" style="padding-left: 2rem;"> - <b-field message="If not set, vendor chooser is a dropdown field."> - <b-checkbox name="rattail.vendor.use_autocomplete" - v-model="simpleSettings['rattail.vendor.use_autocomplete']" + <b-field message="If not set, vendor chooser is an autocomplete field."> + <b-checkbox name="rattail.vendors.choice_uses_dropdown" + v-model="simpleSettings['rattail.vendors.choice_uses_dropdown']" @input="settingsNeedSaved = true"> - Show vendor chooser as autocomplete field + Show vendor chooser as dropdown (select) element </b-checkbox> </b-field> diff --git a/tailbone/views/batch/vendorcatalog.py b/tailbone/views/batch/vendorcatalog.py index 3668500a..dfd03ac9 100644 --- a/tailbone/views/batch/vendorcatalog.py +++ b/tailbone/views/batch/vendorcatalog.py @@ -30,15 +30,13 @@ import logging import six -from rattail.db import model, api -from rattail.vendors.catalogs import iter_catalog_parsers +from rattail.db import model import colander from deform import widget as dfwidget from webhelpers2.html import tags from tailbone import forms -from tailbone.db import Session from tailbone.views.batch import FileBatchMasterView from tailbone.diffs import Diff @@ -139,13 +137,9 @@ class VendorCatalogView(FileBatchMasterView): def get_parsers(self): if not hasattr(self, 'parsers'): - parsers = sorted(iter_catalog_parsers(), key=lambda p: p.display) - supported = self.rattail_config.getlist( - 'tailbone', 'batch.vendorcatalog.supported_parsers') - if supported: - parsers = [parser for parser in parsers - if parser.key in supported] - self.parsers = parsers + app = self.get_rattail_app() + vendor_handler = app.get_vendor_handler() + self.parsers = vendor_handler.get_supported_catalog_parsers() return self.parsers def configure_grid(self, g): @@ -160,24 +154,8 @@ class VendorCatalogView(FileBatchMasterView): def configure_form(self, f): super(VendorCatalogView, self).configure_form(f) - - # vendor - f.set_renderer('vendor', self.render_vendor) - if self.creating and 'vendor' in f: - f.replace('vendor', 'vendor_uuid') - f.set_node('vendor_uuid', colander.String()) - vendor_display = "" - if self.request.method == 'POST': - if self.request.POST.get('vendor_uuid'): - vendor = self.Session.query(model.Vendor).get(self.request.POST['vendor_uuid']) - if vendor: - vendor_display = six.text_type(vendor) - vendors_url = self.request.route_url('vendors.autocomplete') - f.set_widget('vendor_uuid', forms.widgets.JQueryAutocompleteWidget( - field_display=vendor_display, service_url=vendors_url)) - f.set_label('vendor_uuid', "Vendor") - else: - f.set_readonly('vendor') + app = self.get_rattail_app() + vendor_handler = app.get_vendor_handler() # filename f.set_label('filename', "Catalog File") @@ -196,12 +174,75 @@ class VendorCatalogView(FileBatchMasterView): f.set_widget('parser_key', dfwidget.SelectWidget(values=values)) f.set_label('parser_key', "File Type") + # vendor + f.set_renderer('vendor', self.render_vendor) + if self.creating and 'vendor' in f: + f.replace('vendor', 'vendor_uuid') + f.set_label('vendor_uuid', "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] + f.set_widget('vendor_uuid', + dfwidget.SelectWidget(values=vendor_values)) + else: + vendor_display = "" + if self.request.method == 'POST': + if self.request.POST.get('vendor_uuid'): + vendor = self.Session.query(model.Vendor).get( + self.request.POST['vendor_uuid']) + if vendor: + vendor_display = six.text_type(vendor) + vendors_url = self.request.route_url('vendors.autocomplete') + f.set_widget('vendor_uuid', forms.widgets.JQueryAutocompleteWidget( + field_display=vendor_display, service_url=vendors_url, + assigned_label='vendorName')) + else: + f.set_readonly('vendor') + # effective if self.creating: f.remove('effective') else: f.set_readonly('effective') + def template_kwargs_create(self, **kwargs): + use_buefy = self.get_use_buefy() + app = self.get_rattail_app() + vendor_handler = app.get_vendor_handler() + parsers = self.get_parsers() + parsers_data = {} + for parser in parsers: + if use_buefy: + pdata = {'key': parser.key, + 'vendor_key': parser.vendor_key} + if parser.vendor_key: + vendor = vendor_handler.get_vendor(self.Session(), + parser.vendor_key) + if vendor: + pdata['vendor_uuid'] = vendor.uuid + pdata['vendor_name'] = vendor.name + parsers_data[parser.key] = pdata + else: + if parser.vendor_key: + vendor = vendor_handler.get_vendor(self.Session(), + parser.vendor_key) + if vendor: + parser.vendormap_value = "{{uuid: '{}', name: '{}'}}".format( + vendor.uuid, vendor.name.replace("'", "\\'")) + else: + log.warning("vendor '{}' not found for parser: {}".format( + parser.vendor_key, parser.key)) + parser.vendormap_value = 'null' + else: + parser.vendormap_value = 'null' + kwargs['parsers'] = parsers + kwargs['parsers_data'] = parsers_data + return kwargs + def get_batch_kwargs(self, batch): kwargs = super(VendorCatalogView, self).get_batch_kwargs(batch) kwargs['parser_key'] = batch.parser_key @@ -275,23 +316,6 @@ class VendorCatalogView(FileBatchMasterView): return kwargs - def template_kwargs_create(self, **kwargs): - parsers = self.get_parsers() - for parser in parsers: - if parser.vendor_key: - vendor = api.get_vendor(Session(), parser.vendor_key) - if vendor: - parser.vendormap_value = "{{uuid: '{}', name: '{}'}}".format( - vendor.uuid, vendor.name.replace("'", "\\'")) - else: - log.warning("vendor '{}' not found for parser: {}".format( - parser.vendor_key, parser.key)) - parser.vendormap_value = 'null' - else: - parser.vendormap_value = 'null' - kwargs['parsers'] = parsers - return kwargs - # TODO: deprecate / remove this VendorCatalogsView = VendorCatalogView diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index a4dab2aa..8a015838 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.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. # @@ -29,7 +29,6 @@ from __future__ import unicode_literals, absolute_import import six from rattail.db import model, api -from rattail.time import localtime import colander from deform import widget as dfwidget @@ -230,7 +229,8 @@ class PurchasingBatchView(BatchMasterView): super(PurchasingBatchView, self).configure_form(f) model = self.model batch = f.model_instance - today = localtime(self.rattail_config).date() + app = self.get_rattail_app() + today = app.localtime().date() use_buefy = self.get_use_buefy() # mode @@ -265,9 +265,15 @@ class PurchasingBatchView(BatchMasterView): if self.creating: f.replace('vendor', 'vendor_uuid') f.set_label('vendor_uuid', "Vendor") - use_autocomplete = self.rattail_config.getbool( - 'rattail', 'vendor.use_autocomplete', default=True) - if use_autocomplete: + 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)) + for vendor in vendors] + f.set_widget('vendor_uuid', dfwidget.SelectWidget(values=vendor_values)) + else: vendor_display = "" if self.request.method == 'POST': if self.request.POST.get('vendor_uuid'): @@ -277,12 +283,6 @@ class PurchasingBatchView(BatchMasterView): vendors_url = self.request.route_url('vendors.autocomplete') f.set_widget('vendor_uuid', forms.widgets.JQueryAutocompleteWidget( field_display=vendor_display, service_url=vendors_url)) - else: - vendors = self.Session.query(model.Vendor)\ - .order_by(model.Vendor.id) - vendor_values = [(vendor.uuid, "({}) {}".format(vendor.id, vendor.name)) - for vendor in vendors] - f.set_widget('vendor_uuid', dfwidget.SelectWidget(values=vendor_values)) elif self.editing: f.set_readonly('vendor') diff --git a/tailbone/views/purchasing/costing.py b/tailbone/views/purchasing/costing.py index d790fbc1..2f467feb 100644 --- a/tailbone/views/purchasing/costing.py +++ b/tailbone/views/purchasing/costing.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. # @@ -200,9 +200,19 @@ class CostingBatchView(PurchasingBatchView): form.set_default('workflow', valid_workflows[0]) # configure vendor field - use_autocomplete = self.rattail_config.getbool( - 'rattail', 'vendor.use_autocomplete', default=True) - if use_autocomplete: + 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)) + 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'): @@ -212,15 +222,6 @@ class CostingBatchView(PurchasingBatchView): vendors_url = self.request.route_url('vendors.autocomplete') form.set_widget('vendor', forms.widgets.JQueryAutocompleteWidget( field_display=vendor_display, service_url=vendors_url)) - else: - 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)) # configure workflow field values = [(workflow['workflow_key'], workflow['display']) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 3664cdef..e481db82 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/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. # @@ -294,9 +294,19 @@ class ReceivingBatchView(PurchasingBatchView): use_buefy=use_buefy) # configure vendor field - use_autocomplete = self.rattail_config.getbool( - 'rattail', 'vendor.use_autocomplete', default=True) - if use_autocomplete: + 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)) + 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'): @@ -306,15 +316,6 @@ class ReceivingBatchView(PurchasingBatchView): vendors_url = self.request.route_url('vendors.autocomplete') form.set_widget('vendor', forms.widgets.JQueryAutocompleteWidget( field_display=vendor_display, service_url=vendors_url)) - else: - 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)) # configure workflow field values = [(workflow['workflow_key'], workflow['display']) diff --git a/tailbone/views/vendors/core.py b/tailbone/views/vendors/core.py index bf73e1b1..36280738 100644 --- a/tailbone/views/vendors/core.py +++ b/tailbone/views/vendors/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. # @@ -175,7 +175,7 @@ class VendorView(MasterView): # display {'section': 'rattail', - 'option': 'vendor.use_autocomplete', + 'option': 'vendors.choice_uses_dropdown', 'type': bool}, ] From 2ce7c93aeb79c92ffb855b1c5ae848e9ddb7263a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 8 Jan 2022 12:19:35 -0600 Subject: [PATCH 0577/1681] Expose, honor "allow future" setting for vendor catalog batch --- .../batch/vendorcatalog/configure.mako | 13 ++++++ tailbone/views/batch/vendorcatalog.py | 46 +++++++++++++++---- 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/tailbone/templates/batch/vendorcatalog/configure.mako b/tailbone/templates/batch/vendorcatalog/configure.mako index e4fa346a..1e6309f4 100644 --- a/tailbone/templates/batch/vendorcatalog/configure.mako +++ b/tailbone/templates/batch/vendorcatalog/configure.mako @@ -3,6 +3,19 @@ <%def name="form_content()"> ${self.input_file_templates_section()} + + <h3 class="block is-size-3">Options</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field> + <b-checkbox name="rattail.batch.vendor_catalog.allow_future" + v-model="simpleSettings['rattail.batch.vendor_catalog.allow_future']" + @input="settingsNeedSaved = true"> + Allow "future" cost changes + </b-checkbox> + </b-field> + + </div> </%def> diff --git a/tailbone/views/batch/vendorcatalog.py b/tailbone/views/batch/vendorcatalog.py index dfd03ac9..733f14b5 100644 --- a/tailbone/views/batch/vendorcatalog.py +++ b/tailbone/views/batch/vendorcatalog.py @@ -54,13 +54,15 @@ class VendorCatalogView(FileBatchMasterView): route_prefix = 'vendorcatalogs' url_prefix = '/vendors/catalogs' template_prefix = '/batch/vendorcatalog' - editable = False + bulk_deletable = True + results_executable = True rows_bulk_deletable = True has_input_file_templates = True configurable = True labels = { 'vendor_id': "Vendor ID", + 'parser_key': "Parser", } grid_columns = [ @@ -172,7 +174,9 @@ class VendorCatalogView(FileBatchMasterView): if not use_buefy: values.insert(0, ('', "(please choose)")) f.set_widget('parser_key', dfwidget.SelectWidget(values=values)) - f.set_label('parser_key', "File Type") + else: + f.set_readonly('parser_key') + f.set_renderer('parser_key', self.render_parser_key) # vendor f.set_renderer('vendor', self.render_vendor) @@ -203,11 +207,23 @@ class VendorCatalogView(FileBatchMasterView): else: f.set_readonly('vendor') - # effective - if self.creating: - f.remove('effective') - else: - f.set_readonly('effective') + if self.batch_handler.allow_future(): + + # effective + f.set_type('effective', 'date_jquery') + + else: # future not allowed + f.remove('future', + 'effective') + + def render_parser_key(self, batch, field): + key = getattr(batch, field) + if not key: + return + app = self.get_rattail_app() + vendor_handler = app.get_vendor_handler() + parser = vendor_handler.get_catalog_parser(key) + return parser.display def template_kwargs_create(self, **kwargs): use_buefy = self.get_use_buefy() @@ -254,7 +270,9 @@ class VendorCatalogView(FileBatchMasterView): kwargs['vendor_id'] = batch.vendor_id if batch.vendor_name: kwargs['vendor_name'] = batch.vendor_name - kwargs['future'] = batch.future + if self.batch_handler.allow_future(): + kwargs['future'] = batch.future + kwargs['effective'] = batch.effective return kwargs def configure_row_grid(self, g): @@ -316,6 +334,18 @@ class VendorCatalogView(FileBatchMasterView): return kwargs + def configure_get_simple_settings(self): + settings = super(VendorCatalogView, self).configure_get_simple_settings() or [] + settings.extend([ + + # key field + {'section': 'rattail.batch', + 'option': 'vendor_catalog.allow_future', + 'type': bool}, + + ]) + return settings + # TODO: deprecate / remove this VendorCatalogsView = VendorCatalogView From dc28b1337d1bffc83d074ffb381e1d70c80ab3dd Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 8 Jan 2022 13:35:59 -0600 Subject: [PATCH 0578/1681] Add config for supported vendor catalog parsers also explicitly set "native value" for all configuration checkbox fields, since apparently it will send `'false'` by default... --- .../batch/vendorcatalog/configure.mako | 30 +++++++++++++ tailbone/templates/custorders/configure.mako | 5 +++ tailbone/templates/products/configure.mako | 2 + tailbone/templates/receiving/configure.mako | 10 +++++ .../reports/generated/configure.mako | 1 + .../templates/settings/email/configure.mako | 1 + tailbone/templates/vendors/configure.mako | 1 + tailbone/views/batch/vendorcatalog.py | 42 +++++++++++++++++++ 8 files changed, 92 insertions(+) diff --git a/tailbone/templates/batch/vendorcatalog/configure.mako b/tailbone/templates/batch/vendorcatalog/configure.mako index 1e6309f4..0d57053e 100644 --- a/tailbone/templates/batch/vendorcatalog/configure.mako +++ b/tailbone/templates/batch/vendorcatalog/configure.mako @@ -10,12 +10,42 @@ <b-field> <b-checkbox name="rattail.batch.vendor_catalog.allow_future" v-model="simpleSettings['rattail.batch.vendor_catalog.allow_future']" + native-value="true" @input="settingsNeedSaved = true"> Allow "future" cost changes </b-checkbox> </b-field> </div> + + <h3 class="block is-size-3">Catalog Parsers</h3> + <div class="block" style="padding-left: 2rem;"> + + <p class="block"> + Only the selected parsers will be exposed to users. + </p> + + % for Parser in catalog_parsers: + <b-field message="${Parser.key}"> + <b-checkbox name="catalog_parser_${Parser.key}" + v-model="catalogParsers['${Parser.key}']" + native-value="true" + @input="settingsNeedSaved = true"> + ${Parser.display} + </b-checkbox> + </b-field> + % endfor + + </div> +</%def> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + ThisPageData.catalogParsers = ${json.dumps(catalog_parsers_data)|n} + + </script> </%def> diff --git a/tailbone/templates/custorders/configure.mako b/tailbone/templates/custorders/configure.mako index e3e47054..976f1564 100644 --- a/tailbone/templates/custorders/configure.mako +++ b/tailbone/templates/custorders/configure.mako @@ -9,6 +9,7 @@ <b-field message="If not set, only a Person is required."> <b-checkbox name="rattail.custorders.new_order_requires_customer" v-model="simpleSettings['rattail.custorders.new_order_requires_customer']" + native-value="true" @input="settingsNeedSaved = true"> Require a Customer account </b-checkbox> @@ -17,6 +18,7 @@ <b-field message="If not set, default contact info is always assumed."> <b-checkbox name="rattail.custorders.new_orders.allow_contact_info_choice" v-model="simpleSettings['rattail.custorders.new_orders.allow_contact_info_choice']" + native-value="true" @input="settingsNeedSaved = true"> Allow user to choose contact info </b-checkbox> @@ -25,6 +27,7 @@ <b-field message="Only applies if user is allowed to choose contact info."> <b-checkbox name="rattail.custorders.new_orders.allow_contact_info_create" v-model="simpleSettings['rattail.custorders.new_orders.allow_contact_info_create']" + native-value="true" @input="settingsNeedSaved = true"> Allow user to enter new contact info </b-checkbox> @@ -52,6 +55,7 @@ <b-field message="If set, user can enter details of an arbitrary new "pending" product."> <b-checkbox name="rattail.custorders.allow_unknown_product" v-model="simpleSettings['rattail.custorders.allow_unknown_product']" + native-value="true" @input="settingsNeedSaved = true"> Allow creating orders for "unknown" products </b-checkbox> @@ -60,6 +64,7 @@ <b-field> <b-checkbox name="rattail.custorders.product_price_may_be_questionable" v-model="simpleSettings['rattail.custorders.product_price_may_be_questionable']" + native-value="true" @input="settingsNeedSaved = true"> Allow prices to be flagged as "questionable" </b-checkbox> diff --git a/tailbone/templates/products/configure.mako b/tailbone/templates/products/configure.mako index 31b879c5..3b75bc7f 100644 --- a/tailbone/templates/products/configure.mako +++ b/tailbone/templates/products/configure.mako @@ -35,6 +35,7 @@ <b-field message="If set, GPC values like 002XXXXXYYYYY-Z will be converted to 002XXXXX00000-Z for lokkup"> <b-checkbox name="rattail.products.convert_type2_for_gpc_lookup" v-model="simpleSettings['rattail.products.convert_type2_for_gpc_lookup']" + native-value="true" @input="settingsNeedSaved = true"> Auto-convert Type 2 UPC for sake of lookup </b-checkbox> @@ -48,6 +49,7 @@ <b-field message="If a product has an image in the DB, that will still be preferred."> <b-checkbox name="tailbone.products.show_pod_image" v-model="simpleSettings['tailbone.products.show_pod_image']" + native-value="true" @input="settingsNeedSaved = true"> Show "POD" Images as fallback </b-checkbox> diff --git a/tailbone/templates/receiving/configure.mako b/tailbone/templates/receiving/configure.mako index 06ab3769..349dc621 100644 --- a/tailbone/templates/receiving/configure.mako +++ b/tailbone/templates/receiving/configure.mako @@ -9,6 +9,7 @@ <b-field> <b-checkbox name="rattail.batch.purchase.allow_receiving_from_scratch" v-model="simpleSettings['rattail.batch.purchase.allow_receiving_from_scratch']" + native-value="true" @input="settingsNeedSaved = true"> From Scratch </b-checkbox> @@ -17,6 +18,7 @@ <b-field> <b-checkbox name="rattail.batch.purchase.allow_receiving_from_invoice" v-model="simpleSettings['rattail.batch.purchase.allow_receiving_from_invoice']" + native-value="true" @input="settingsNeedSaved = true"> From Invoice </b-checkbox> @@ -25,6 +27,7 @@ <b-field> <b-checkbox name="rattail.batch.purchase.allow_receiving_from_purchase_order" v-model="simpleSettings['rattail.batch.purchase.allow_receiving_from_purchase_order']" + native-value="true" @input="settingsNeedSaved = true"> From Purchase Order </b-checkbox> @@ -33,6 +36,7 @@ <b-field> <b-checkbox name="rattail.batch.purchase.allow_receiving_from_purchase_order_with_invoice" v-model="simpleSettings['rattail.batch.purchase.allow_receiving_from_purchase_order_with_invoice']" + native-value="true" @input="settingsNeedSaved = true"> From Purchase Order, with Invoice </b-checkbox> @@ -41,6 +45,7 @@ <b-field> <b-checkbox name="rattail.batch.purchase.allow_truck_dump_receiving" v-model="simpleSettings['rattail.batch.purchase.allow_truck_dump_receiving']" + native-value="true" @input="settingsNeedSaved = true"> Truck Dump </b-checkbox> @@ -54,6 +59,7 @@ <b-field message="NB. Allow Cases setting also affects Ordering behavior."> <b-checkbox name="rattail.batch.purchase.allow_cases" v-model="simpleSettings['rattail.batch.purchase.allow_cases']" + native-value="true" @input="settingsNeedSaved = true"> Allow Cases </b-checkbox> @@ -62,6 +68,7 @@ <b-field> <b-checkbox name="rattail.batch.purchase.allow_expired_credits" v-model="simpleSettings['rattail.batch.purchase.allow_expired_credits']" + native-value="true" @input="settingsNeedSaved = true"> Allow "Expired" Credits </b-checkbox> @@ -75,6 +82,7 @@ <b-field message="TODO: this may also affect Ordering (?)"> <b-checkbox name="rattail.batch.purchase.mobile_images" v-model="simpleSettings['rattail.batch.purchase.mobile_images']" + native-value="true" @input="settingsNeedSaved = true"> Show Product Images </b-checkbox> @@ -83,6 +91,7 @@ <b-field> <b-checkbox name="rattail.batch.purchase.mobile_quick_receive" v-model="simpleSettings['rattail.batch.purchase.mobile_quick_receive']" + native-value="true" @input="settingsNeedSaved = true"> Allow "Quick Receive" </b-checkbox> @@ -91,6 +100,7 @@ <b-field> <b-checkbox name="rattail.batch.purchase.mobile_quick_receive_all" v-model="simpleSettings['rattail.batch.purchase.mobile_quick_receive_all']" + native-value="true" @input="settingsNeedSaved = true"> Allow "Quick Receive All" </b-checkbox> diff --git a/tailbone/templates/reports/generated/configure.mako b/tailbone/templates/reports/generated/configure.mako index e8224f28..50109702 100644 --- a/tailbone/templates/reports/generated/configure.mako +++ b/tailbone/templates/reports/generated/configure.mako @@ -9,6 +9,7 @@ <b-field message="If not set, reports are shown as simple list of hyperlinks."> <b-checkbox name="tailbone.reporting.choosing_uses_form" v-model="simpleSettings['tailbone.reporting.choosing_uses_form']" + native-value="true" @input="settingsNeedSaved = true"> Show report chooser as form, with dropdown </b-checkbox> diff --git a/tailbone/templates/settings/email/configure.mako b/tailbone/templates/settings/email/configure.mako index 228eb1a4..1e2e86a0 100644 --- a/tailbone/templates/settings/email/configure.mako +++ b/tailbone/templates/settings/email/configure.mako @@ -9,6 +9,7 @@ <b-field> <b-checkbox name="rattail.mail.record_attempts" v-model="simpleSettings['rattail.mail.record_attempts']" + native-value="true" @input="settingsNeedSaved = true"> Make record of all attempts to send email </b-checkbox> diff --git a/tailbone/templates/vendors/configure.mako b/tailbone/templates/vendors/configure.mako index 121617b2..cb370e43 100644 --- a/tailbone/templates/vendors/configure.mako +++ b/tailbone/templates/vendors/configure.mako @@ -9,6 +9,7 @@ <b-field message="If not set, vendor chooser is an autocomplete field."> <b-checkbox name="rattail.vendors.choice_uses_dropdown" v-model="simpleSettings['rattail.vendors.choice_uses_dropdown']" + native-value="true" @input="settingsNeedSaved = true"> Show vendor chooser as dropdown (select) element </b-checkbox> diff --git a/tailbone/views/batch/vendorcatalog.py b/tailbone/views/batch/vendorcatalog.py index 733f14b5..80fde3fe 100644 --- a/tailbone/views/batch/vendorcatalog.py +++ b/tailbone/views/batch/vendorcatalog.py @@ -39,6 +39,7 @@ from webhelpers2.html import tags from tailbone import forms from tailbone.views.batch import FileBatchMasterView from tailbone.diffs import Diff +from tailbone.db import Session log = logging.getLogger(__name__) @@ -346,6 +347,47 @@ class VendorCatalogView(FileBatchMasterView): ]) return settings + def configure_get_context(self): + context = super(VendorCatalogView, self).configure_get_context() + app = self.get_rattail_app() + vendor_handler = app.get_vendor_handler() + + Parsers = vendor_handler.get_all_catalog_parsers() + Supported = vendor_handler.get_supported_catalog_parsers() + context['catalog_parsers'] = Parsers + context['catalog_parsers_data'] = dict([(Parser.key, Parser in Supported) + for Parser in Parsers]) + + return context + + def configure_gather_settings(self, data): + settings = super(VendorCatalogView, self).configure_gather_settings(data) + app = self.get_rattail_app() + vendor_handler = app.get_vendor_handler() + + supported = [] + for Parser in vendor_handler.get_all_catalog_parsers(): + name = 'catalog_parser_{}'.format(Parser.key) + if data.get(name) == 'true': + supported.append(Parser.key) + settings.append({'name': 'rattail.vendors.supported_catalog_parsers', + 'value': ', '.join(supported)}) + + return settings + + def configure_remove_settings(self): + super(VendorCatalogView, self).configure_remove_settings() + model = self.model + 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) + + # TODO: deprecate / remove this VendorCatalogsView = VendorCatalogView From 6af5157b4eb1537cd25a806bac22db6be0d75279 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 8 Jan 2022 19:48:14 -0600 Subject: [PATCH 0579/1681] Update some method calls to avoid deprecation warnings --- tailbone/views/custorders/orders.py | 7 ++++--- tailbone/views/employees.py | 4 ++-- tailbone/views/master.py | 2 +- tailbone/views/purchases/core.py | 10 +++++----- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index c60e859e..12a0c339 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.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. # @@ -277,8 +277,9 @@ class CustomerOrderView(MasterView): return text def get_batch_handler(self): - return get_batch_handler( - self.rattail_config, 'custorder', + app = self.get_rattail_app() + return app.get_batch_handler( + 'custorder', default='rattail.batch.custorder:CustomerOrderBatchHandler') def create(self, form=None, template='create'): diff --git a/tailbone/views/employees.py b/tailbone/views/employees.py index febe521e..c1f4b01c 100644 --- a/tailbone/views/employees.py +++ b/tailbone/views/employees.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. # @@ -112,7 +112,7 @@ class EmployeeView(MasterView): g.set_sorter('username', model.User.username) g.set_renderer('username', self.grid_render_username) else: - g.hide_column('username') + g.remove('username') # id if self.has_perm('edit'): diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 3807408b..7eb9ebc8 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -455,7 +455,7 @@ class MasterView(View): # hide "local only" grid filter, unless global access allowed if self.secure_global_objects: if not self.has_perm('view_global'): - grid.hide_column('local_only') + grid.remove('local_only') grid.remove_filter('local_only') self.configure_column_product_key(grid) diff --git a/tailbone/views/purchases/core.py b/tailbone/views/purchases/core.py index 2cd28be8..eb32fa73 100644 --- a/tailbone/views/purchases/core.py +++ b/tailbone/views/purchases/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. # @@ -311,12 +311,12 @@ class PurchaseView(MasterView): purchase = self.get_instance() if purchase.status == self.enum.PURCHASE_STATUS_ORDERED: - g.hide_column('cases_received') - g.hide_column('units_received') - g.hide_column('invoice_total') + g.remove('cases_received', + 'units_received', + 'invoice_total') elif purchase.status in (self.enum.PURCHASE_STATUS_RECEIVED, self.enum.PURCHASE_STATUS_COSTED): - g.hide_column('po_total') + g.remove('po_total') def configure_row_form(self, f): super(PurchaseView, self).configure_row_form(f) From 94fc5c18593b0e7bbdfd65f64a34e690704a85d9 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 8 Jan 2022 20:08:32 -0600 Subject: [PATCH 0580/1681] 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 10c39d54..8216c8ad 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,22 @@ CHANGELOG ========= +0.8.192 (2022-01-08) +-------------------- + +* Add configurable template file for vendor catalog batch. + +* Some aesthetic improvements for vendor catalog batch. + +* Several disparate changes needed for vendor catalog improvements. + +* Expose, honor "allow future" setting for vendor catalog batch. + +* Add config for supported vendor catalog parsers. + +* Update some method calls to avoid deprecation warnings. + + 0.8.191 (2022-01-03) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index b6faadaa..4a1fd792 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.191' +__version__ = '0.8.192' From 0545099a2b439e39067a680b466ac6455bed3c70 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 9 Jan 2022 15:20:35 -0600 Subject: [PATCH 0581/1681] Add buefy support for quick-printing product labels; also speed bump --- tailbone/grids/core.py | 6 +- tailbone/templates/formposter.mako | 3 +- tailbone/templates/master/index.mako | 3 - tailbone/templates/products/configure.mako | 31 +++++-- tailbone/templates/products/index.mako | 97 +++++++++++++++++++++- tailbone/views/master.py | 2 + tailbone/views/products.py | 61 +++++++++----- 7 files changed, 167 insertions(+), 36 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index f918fad4..d825e4b4 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/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. # @@ -197,7 +197,7 @@ class Grid(object): """ Mark the given column as "invisible" (but do not remove it). - Use :meth:`hide_column()` if you actually want to remove it. + Use :meth:`remove()` if you actually want to remove it. """ if invisible: if key not in self.invisible: @@ -217,7 +217,7 @@ class Grid(object): def replace(self, oldfield, newfield): self.insert_after(oldfield, newfield) - self.hide_column(oldfield) + self.remove(oldfield) def set_joiner(self, key, joiner): if joiner is None: diff --git a/tailbone/templates/formposter.mako b/tailbone/templates/formposter.mako index 6fc6eadc..885ac6c2 100644 --- a/tailbone/templates/formposter.mako +++ b/tailbone/templates/formposter.mako @@ -21,7 +21,8 @@ } else { this.$buefy.toast.open({ - message: "Submit failed: " + response.data.error, + message: "Submit failed: " + (response.data.error || + "(unknown error)"), type: 'is-danger', duration: 4000, // 4 seconds }) diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako index 48e51286..5830519b 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -162,9 +162,6 @@ <li>${h.link_to("Create a new {}".format(model_title), url('{}.create'.format(route_prefix)))}</li> % endif % endif - % if not use_buefy and master.configurable and master.has_perm('configure'): - <li>${h.link_to("Configure {}".format(config_title), url('{}.configure'.format(route_prefix)))}</li> - % endif % if master.has_input_file_templates and master.has_perm('create'): % for template in six.itervalues(input_file_templates): <li>${h.link_to("Download {} Template".format(template['label']), template['effective_url'])}</li> diff --git a/tailbone/templates/products/configure.mako b/tailbone/templates/products/configure.mako index 3b75bc7f..612b8d36 100644 --- a/tailbone/templates/products/configure.mako +++ b/tailbone/templates/products/configure.mako @@ -3,7 +3,7 @@ <%def name="form_content()"> - <h3 class="block is-size-3">Key Field</h3> + <h3 class="block is-size-3">Display</h3> <div class="block" style="padding-left: 2rem;"> <b-field grouped> @@ -27,6 +27,15 @@ </b-field> + <b-field message="If a product has an image in the DB, that will still be preferred."> + <b-checkbox name="tailbone.products.show_pod_image" + v-model="simpleSettings['tailbone.products.show_pod_image']" + native-value="true" + @input="settingsNeedSaved = true"> + Show "POD" Images as fallback + </b-checkbox> + </b-field> + </div> <h3 class="block is-size-3">Handling</h3> @@ -43,18 +52,28 @@ </div> - <h3 class="block is-size-3">Display</h3> + <h3 class="block is-size-3">Labels</h3> <div class="block" style="padding-left: 2rem;"> - <b-field message="If a product has an image in the DB, that will still be preferred."> - <b-checkbox name="tailbone.products.show_pod_image" - v-model="simpleSettings['tailbone.products.show_pod_image']" + <b-field message="User must also have permission to use this feature."> + <b-checkbox name="tailbone.products.print_labels" + v-model="simpleSettings['tailbone.products.print_labels']" native-value="true" @input="settingsNeedSaved = true"> - Show "POD" Images as fallback + Allow quick/direct label printing from Products page </b-checkbox> </b-field> + <b-field label="Speed Bump Threshold" + message="Show speed bump when at least this many labels are quick-printed at once. Empty means never show speed bump."> + <b-input name="tailbone.products.quick_labels.speedbump_threshold" + v-model="simpleSettings['tailbone.products.quick_labels.speedbump_threshold']" + type="number" + @input="settingsNeedSaved = true" + style="width: 10rem;"> + </b-input> + </b-field> + </div> </%def> diff --git a/tailbone/templates/products/index.mako b/tailbone/templates/products/index.mako index 3f65cd68..8eada2fc 100644 --- a/tailbone/templates/products/index.mako +++ b/tailbone/templates/products/index.mako @@ -1,8 +1,9 @@ -## -*- coding: utf-8 -*- +## -*- coding: utf-8; -*- <%inherit file="/master/index.mako" /> <%def name="extra_styles()"> ${parent.extra_styles()} + % if not use_buefy: <style type="text/css"> table.label-printing th { @@ -32,11 +33,12 @@ } </style> + % endif </%def> <%def name="extra_javascript()"> ${parent.extra_javascript()} - % if label_profiles and request.has_perm('products.print_labels'): + % if not use_buefy and label_profiles and master.has_perm('print_labels'): <script type="text/javascript"> $(function() { @@ -52,6 +54,14 @@ quantity.focus(); } else { quantity = quantity.val(); + + var threshold = ${json.dumps(quick_label_speedbump_threshold)|n}; + if (threshold && parseInt(quantity) >= threshold) { + if (!confirm("Are you sure you want to print " + quantity + " labels?")) { + return false; + } + } + var data = { product: tr.data('uuid'), profile: $('#label-profile').val(), @@ -77,7 +87,26 @@ <%def name="grid_tools()"> ${parent.grid_tools()} - % if label_profiles and request.has_perm('products.print_labels'): + % if label_profiles and master.has_perm('print_labels'): + % if use_buefy: + <b-field grouped> + <b-field label="Label"> + <b-select v-model="quickLabelProfile"> + % for profile in label_profiles: + <option value="${profile.uuid}"> + ${profile.description} + </option> + % endfor + </b-select> + </b-field> + <b-field label="Qty."> + <b-input v-model="quickLabelQuantity" + ref="quickLabelQuantityInput" + style="width: 4rem;"> + </b-input> + </b-field> + </b-field> + % else: <table class="label-printing"> <thead> <tr> @@ -98,7 +127,69 @@ </td> </tbody> </table> + % endif % endif </%def> +<%def name="render_grid_component()"> + <${grid.component} :csrftoken="csrftoken" + % if master.deletable and master.has_perm('delete') and master.delete_confirm == 'simple': + @deleteActionClicked="deleteObject" + % endif + % if label_profiles and master.has_perm('print_labels'): + @quick-label-print="quickLabelPrint" + % endif + > + </${grid.component}> +</%def> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + % if label_profiles and master.has_perm('print_labels'): + <script type="text/javascript"> + + ${grid.component_studly}Data.quickLabelProfile = ${json.dumps(label_profiles[0].uuid)|n} + ${grid.component_studly}Data.quickLabelQuantity = 1 + ${grid.component_studly}Data.quickLabelSpeedbumpThreshold = ${json.dumps(quick_label_speedbump_threshold)|n} + + ${grid.component_studly}.methods.quickLabelPrint = function(row) { + + let quantity = parseInt(this.quickLabelQuantity) + if (isNaN(quantity)) { + alert("You must provide a valid label quantity.") + this.$refs.quickLabelQuantityInput.focus() + return + } + + if (this.quickLabelSpeedbumpThreshold && quantity >= this.quickLabelSpeedbumpThreshold) { + if (!confirm("Are you sure you want to print " + quantity + " labels?")) { + return + } + } + + this.$emit('quick-label-print', row.uuid, this.quickLabelProfile, quantity) + } + + ThisPage.methods.quickLabelPrint = function(product, profile, quantity) { + let url = '${url('products.print_labels')}' + + let data = new FormData() + data.append('product', product) + data.append('profile', profile) + data.append('quantity', quantity) + + this.submitForm(url, data, response => { + if (quantity == 1) { + alert("1 label has been printed.") + } else { + alert(quantity.toString() + " labels have been printed.") + } + }) + } + + </script> + % endif +</%def> + + ${parent.body()} diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 7eb9ebc8..7fd3cccf 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -4368,6 +4368,8 @@ class MasterView(View): if simple.get('type') is bool: value = six.text_type(bool(value)).lower() + elif simple.get('type') is int: + value = six.text_type(int(value or '0')) else: value = six.text_type(value) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 0e192bca..cf7be401 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -181,7 +181,9 @@ class ProductView(MasterView): self.print_labels = request.rattail_config.getbool('tailbone', 'products.print_labels', default=False) app = self.get_rattail_app() - self.handler = app.get_products_handler() + self.product_handler = app.get_products_handler() + # TODO: deprecate / remove this + self.handler = self.product_handler def query(self, session): user = self.request.user @@ -358,8 +360,13 @@ class ProductView(MasterView): g.set_sort_defaults('upc') - if self.print_labels and self.request.has_perm('products.print_labels'): - g.more_actions.append(grids.GridAction('print_label', icon='print')) + if self.print_labels and self.has_perm('print_labels'): + if use_buefy: + g.more_actions.append(self.make_action( + 'print_label', icon='print', url='#', + click_handler='quickLabelPrint(props.row)')) + else: + g.more_actions.append(grids.GridAction('print_label', icon='print')) g.set_type('upc', 'gpc') @@ -522,7 +529,7 @@ class ProductView(MasterView): if not product.not_for_sale: price = product[field] if price: - return self.handler.render_price(price) + return self.product_handler.render_price(price) def render_current_price_for_grid(self, product, field): text = self.render_price(product, field) or "" @@ -651,13 +658,20 @@ class ProductView(MasterView): return pretty_quantity(inventory.on_order) def template_kwargs_index(self, **kwargs): - if self.print_labels: - kwargs['label_profiles'] = Session.query(model.LabelProfile)\ - .filter(model.LabelProfile.visible == True)\ - .order_by(model.LabelProfile.ordinal)\ - .all() - return kwargs + kwargs = super(ProductView, self).template_kwargs_index(**kwargs) + model = self.model + if self.print_labels: + + kwargs['label_profiles'] = self.Session.query(model.LabelProfile)\ + .filter(model.LabelProfile.visible == True)\ + .order_by(model.LabelProfile.ordinal)\ + .all() + + kwargs['quick_label_speedbump_threshold'] = self.rattail_config.getint( + 'tailbone', 'products.quick_labels.speedbump_threshold') + + return kwargs def grid_extra_class(self, product, i): classes = [] @@ -794,10 +808,10 @@ class ProductView(MasterView): def get_instance(self): key = self.request.matchdict['uuid'] - product = Session.query(model.Product).get(key) + product = self.Session.query(model.Product).get(key) if product: return product - price = Session.query(model.ProductPrice).get(key) + price = self.Session.query(model.ProductPrice).get(key) if price: return price.product raise httpexceptions.HTTPNotFound() @@ -1151,7 +1165,7 @@ class ProductView(MasterView): product = kwargs['instance'] use_buefy = self.get_use_buefy() - kwargs['image_url'] = self.handler.get_image_url(product) + kwargs['image_url'] = self.product_handler.get_image_url(product) kwargs['product_key_field'] = self.rattail_config.product_key() # add price history, if user has access @@ -1701,11 +1715,11 @@ class ProductView(MasterView): upc = self.request.GET.get('upc', '').strip() upc = re.sub(r'\D', '', upc) if upc: - product = api.get_product_by_upc(Session(), upc) + product = api.get_product_by_upc(self.Session(), upc) if not product: # Try again, assuming caller did not include check digit. upc = GPC(upc, calc_check_digit='upc') - product = api.get_product_by_upc(Session(), upc) + product = api.get_product_by_upc(self.Session(), upc) if product and (not product.deleted or self.request.has_perm('products.view_deleted')): data = { 'uuid': product.uuid, @@ -1716,7 +1730,7 @@ class ProductView(MasterView): } uuid = self.request.GET.get('with_vendor_cost') if uuid: - vendor = Session.query(model.Vendor).get(uuid) + vendor = self.Session.query(model.Vendor).get(uuid) if not vendor: return {'error': "Vendor not found"} cost = product.cost_for_vendor(vendor) @@ -1912,21 +1926,28 @@ class ProductView(MasterView): def configure_get_simple_settings(self): return [ - # key field + # display {'section': 'rattail', 'option': 'product.key'}, {'section': 'rattail', 'option': 'product.key_title'}, + {'section': 'tailbone', + 'option': 'products.show_pod_image', + 'type': bool}, # handling {'section': 'rattail', 'option': 'products.convert_type2_for_gpc_lookup', 'type': bool}, - # display + # labels {'section': 'tailbone', - 'option': 'products.show_pod_image', + 'option': 'products.print_labels', 'type': bool}, + {'section': 'tailbone', + 'option': 'products.quick_labels.speedbump_threshold', + 'type': int}, + ] @classmethod @@ -2254,7 +2275,7 @@ def print_labels(request): except Exception as error: log.warning("error occurred while printing labels", exc_info=True) return {'error': six.text_type(error)} - return {} + return {'ok': True} def includeme(config): From 8579b890028e75a18d40802a9e7d44f0f229a0f6 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 9 Jan 2022 18:13:12 -0600 Subject: [PATCH 0582/1681] Add way to set form-wide schema validator was needed to enforce rule where one field is required only in some cases, depending on value of another field --- tailbone/forms/core.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index f194e53e..17921c72 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/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. # @@ -542,7 +542,10 @@ class Form(object): # apply any validators for key, validator in self.validators.items(): - if key in schema: + if key is None: + # this one is form-wide + schema.validator = validator + elif key in schema: schema[key].validator = validator # apply required flags @@ -671,6 +674,17 @@ class Form(object): self.schema[key].widget = widget def set_validator(self, key, validator): + """ + Set the validator for the schema node represented by the given + key. + + :param key: Normally this the name of one of the fields + contained in the form. It can also be ``None`` in which + case the validator pertains to the form at large instead of + one of the fields. + + :param validator: Callable validator for the node. + """ self.validators[key] = validator def set_required(self, key, required=True): From cabe4225080cdaaa7a20b9a434ded1cdd0b8fd0f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 9 Jan 2022 19:25:18 -0600 Subject: [PATCH 0583/1681] Add progress support when deleting a batch b/c we must delete all rows individually, and some batches can be several thousand rows each --- tailbone/views/batch/core.py | 52 ++++++++++++++++++++++++++++++++++-- tailbone/views/master.py | 12 ++++++--- 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 36ad341b..88921f13 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -82,6 +82,7 @@ class BatchMasterView(MasterView): results_executable = False has_worksheet = False has_worksheet_file = False + delete_requires_progress = True input_file_template_config_section = 'rattail.batch' @@ -742,8 +743,55 @@ class BatchMasterView(MasterView): """ Delete all data (files etc.) for the batch. """ - self.handler.do_delete(batch) - super(BatchMasterView, self).delete_instance(batch) + app = self.get_rattail_app() + session = app.get_session(batch) + self.batch_handler.do_delete(batch) + session.flush() + + def delete_instance_with_progress(self, batch): + """ + Delete all data (files etc.) for the batch. + """ + return self.handler_action(batch, 'delete') + + def delete_thread(self, key, user_uuid, progress, **kwargs): + """ + Thread target for deleting a batch with progress indicator. + """ + app = self.get_rattail_app() + model = self.model + # nb. must make new session, separate from main thread + session = app.make_session() + batch = self.get_instance_for_key(key, session) + batch_str = six.text_type(batch) + + try: + # try to delete batch + self.handler.do_delete(batch, progress=progress, **kwargs) + + except Exception as error: + # error; log that and rollback + log.exception("delete failed for batch: %s", batch) + session.rollback() + session.close() + if progress: + progress.session.load() + progress.session['error'] = True + progress.session['error_msg'] = "Batch deletion failed: {}".format( + simple_error(error)) + progress.session.save() + + else: + # no error; finish up + session.commit() + session.close() + if progress: + progress.session.load() + progress.session['complete'] = True + progress.session['success_url'] = self.get_index_url() + progress.session['success_msg'] = "Batch has been deleted: {}".format( + batch_str) + progress.session.save() def get_fallback_templates(self, template, **kwargs): return [ diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 7fd3cccf..3b7bed2a 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -100,6 +100,7 @@ class MasterView(View): viewable = True editable = True deletable = True + delete_requires_progress = False delete_confirm = 'full' bulk_deletable = False set_deletable = False @@ -1590,10 +1591,13 @@ class MasterView(View): if isinstance(result, httpexceptions.HTTPException): return result - self.delete_instance(instance) - self.request.session.flash("{} has been deleted: {}".format( - self.get_model_title(), instance_title)) - return self.redirect(self.get_after_delete_url(instance)) + if self.delete_requires_progress: + return self.delete_instance_with_progress(instance) + else: + self.delete_instance(instance) + self.request.session.flash("{} has been deleted: {}".format( + self.get_model_title(), instance_title)) + return self.redirect(self.get_after_delete_url(instance)) form.readonly = True return self.render_to_response('delete', { From eb221417e567312799e67fc96c00b168006d107d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 10 Jan 2022 14:54:49 -0600 Subject: [PATCH 0584/1681] Expose the Sale, TPR, Current price fields for label batch still need to figure out how execution can print e.g. TPR prices... --- tailbone/views/batch/labels.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tailbone/views/batch/labels.py b/tailbone/views/batch/labels.py index c52a5a67..79b14a76 100644 --- a/tailbone/views/batch/labels.py +++ b/tailbone/views/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. # @@ -84,6 +84,11 @@ class LabelBatchView(BatchMasterView): 'upc': "UPC", 'vendor_id': "Vendor ID", 'label_profile': "Label Type", + 'sale_start': "Sale Starts", + 'sale_stop': "Sale Ends", + 'tpr_price': "TPR Price", + 'tpr_starts': "TPR Starts", + 'tpr_ends': "TPR Ends", } row_form_fields = [ @@ -101,6 +106,12 @@ class LabelBatchView(BatchMasterView): 'sale_price', 'sale_start', 'sale_stop', + 'tpr_price', + 'tpr_starts', + 'tpr_ends', + 'current_price', + 'current_starts', + 'current_ends', 'vendor_id', 'vendor_name', 'vendor_item_code', From 9045505153cf0e8d98d872bd65ccd685453f532c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 10 Jan 2022 16:34:06 -0600 Subject: [PATCH 0585/1681] 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 8216c8ad..f8e1196d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,18 @@ CHANGELOG ========= +0.8.193 (2022-01-10) +-------------------- + +* Add buefy support for quick-printing product labels; also speed bump. + +* Add way to set form-wide schema validator. + +* Add progress support when deleting a batch. + +* Expose the Sale, TPR, Current price fields for label batch. + + 0.8.192 (2022-01-08) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 4a1fd792..9d4b02b3 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.192' +__version__ = '0.8.193' From 9eeb921915534c29953eb32d0e8b9cafbf8ed217 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 12 Jan 2022 18:20:01 -0600 Subject: [PATCH 0586/1681] Include all static files in manifest --- MANIFEST.in | 2 ++ 1 file changed, 2 insertions(+) diff --git a/MANIFEST.in b/MANIFEST.in index 0114904a..a3d57f93 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -11,6 +11,8 @@ recursive-include tailbone/static *.jpg recursive-include tailbone/static *.gif recursive-include tailbone/static *.ico +recursive-include tailbone/static/files * + recursive-include tailbone/templates *.mako recursive-include tailbone/templates *.pt recursive-include tailbone/reports *.mako From 765b7b49577a2bccb58448ccd338717e4926946d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 12 Jan 2022 18:20:25 -0600 Subject: [PATCH 0587/1681] Update usage of `app.get_email_handler()` to avoid warnings --- tailbone/views/email.py | 6 +++--- tailbone/views/reports.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tailbone/views/email.py b/tailbone/views/email.py index 7b46f490..42f05c90 100644 --- a/tailbone/views/email.py +++ b/tailbone/views/email.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. # @@ -85,7 +85,7 @@ class EmailSettingView(MasterView): def get_handler(self): app = self.get_rattail_app() - return app.get_mail_handler() + return app.get_email_handler() def get_data(self, session=None): data = [] @@ -292,7 +292,7 @@ class EmailPreview(View): def get_handler(self): app = self.get_rattail_app() - return app.get_mail_handler() + return app.get_email_handler() def __call__(self): diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index e2aa3db6..2839c5b5 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.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. # @@ -585,7 +585,7 @@ class ProblemReportView(MasterView): } app = self.get_rattail_app() - handler = app.get_mail_handler() + handler = app.get_email_handler() email = handler.get_email(data['email_key']) data['email_recipients'] = email.get_recips('all') From 0b25469f33f74050f5128313c571155bfd09e854 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 12 Jan 2022 18:24:27 -0600 Subject: [PATCH 0588/1681] 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 f8e1196d..496639e9 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.194 (2022-01-12) +-------------------- + +* Include all static files in manifest. + +* Update usage of ``app.get_email_handler()`` to avoid warnings. + + 0.8.193 (2022-01-10) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 9d4b02b3..0a66a320 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.193' +__version__ = '0.8.194' From e672e9670fb52aad9d6da2c7580880b2deb3620c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 13 Jan 2022 14:21:40 -0600 Subject: [PATCH 0589/1681] Strip whitespace for new customer fields, in new custorder page --- tailbone/templates/custorders/create.mako | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index db9af7ec..c3aed270 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -415,21 +415,21 @@ <section class="modal-card-body"> <b-field grouped> <b-field label="First Name"> - <b-input v-model="editNewCustomerFirstName" + <b-input v-model.trim="editNewCustomerFirstName" ref="editNewCustomerInput"> </b-input> </b-field> <b-field label="Last Name"> - <b-input v-model="editNewCustomerLastName"> + <b-input v-model.trim="editNewCustomerLastName"> </b-input> </b-field> </b-field> <b-field grouped> <b-field label="Phone Number"> - <b-input v-model="editNewCustomerPhone"></b-input> + <b-input v-model.trim="editNewCustomerPhone"></b-input> </b-field> <b-field label="Email Address"> - <b-input v-model="editNewCustomerEmail"></b-input> + <b-input v-model.trim="editNewCustomerEmail"></b-input> </b-field> </b-field> </section> From 517dd4ad9ed34b53550b4b69b228b0c784a54ace Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 13 Jan 2022 14:36:04 -0600 Subject: [PATCH 0590/1681] 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 496639e9..1910050e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.195 (2022-01-13) +-------------------- + +* Strip whitespace for new customer fields, in new custorder page. + + 0.8.194 (2022-01-12) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 0a66a320..27909901 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.194' +__version__ = '0.8.195' From fe7612c885ab88817cabbc2e1a00ba483abab214 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 13 Jan 2022 21:25:17 -0600 Subject: [PATCH 0591/1681] Use the new label handler also, move "print one-off labels" logic into product master view --- tailbone/views/labels/profiles.py | 22 +++++--- tailbone/views/products.py | 84 +++++++++++++++++-------------- 2 files changed, 59 insertions(+), 47 deletions(-) diff --git a/tailbone/views/labels/profiles.py b/tailbone/views/labels/profiles.py index 3dfe07ab..a91cdfb2 100644 --- a/tailbone/views/labels/profiles.py +++ b/tailbone/views/labels/profiles.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. # @@ -62,6 +62,11 @@ class LabelProfileView(MasterView): 'sync_me', ] + def __init__(self, request): + super(LabelProfileView, self).__init__(request) + app = self.get_rattail_app() + self.label_handler = app.get_label_handler() + def configure_grid(self, g): super(LabelProfileView, self).configure_grid(g) g.set_sort_defaults('ordinal') @@ -80,7 +85,7 @@ class LabelProfileView(MasterView): def after_edit(self, profile): if not profile.format: - formatter = profile.get_formatter(self.rattail_config) + formatter = self.label_handler.get_formatter(profile) if formatter: try: profile.format = formatter.default_format @@ -122,17 +127,17 @@ class LabelProfileView(MasterView): View for editing extended Printer Settings, for a given Label Profile. """ profile = self.get_instance() - read_profile = self.redirect(self.get_action_url('view', profile)) + redirect = self.redirect(self.get_action_url('view', profile)) - printer = profile.get_printer(self.rattail_config) + printer = self.label_handler.get_printer(profile) if not printer: msg = "Label profile \"{}\" does not have a functional printer spec.".format(profile) self.request.session.flash(msg) - return read_profile + return redirect if not printer.required_settings: msg = "Printer class for label profile \"{}\" does not require any settings.".format(profile) self.request.session.flash(msg) - return read_profile + return redirect form = self.make_printer_settings_form(profile, printer) @@ -140,8 +145,9 @@ class LabelProfileView(MasterView): if self.request.method == 'POST': for setting in printer.required_settings: if setting in self.request.POST: - profile.save_printer_setting(setting, self.request.POST[setting]) - return read_profile + self.label_handler.save_printer_setting( + profile, setting, self.request.POST[setting]) + return redirect return self.render_to_response('printer', { 'form': form, diff --git a/tailbone/views/products.py b/tailbone/views/products.py index cf7be401..57aeb8c7 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -49,7 +49,6 @@ from pyramid import httpexceptions from webhelpers2.html import tags, HTML from tailbone import forms, grids -from tailbone.db import Session from tailbone.views import MasterView from tailbone.util import raw_datetime @@ -178,7 +177,8 @@ class ProductView(MasterView): def __init__(self, request): super(ProductView, self).__init__(request) - self.print_labels = request.rattail_config.getbool('tailbone', 'products.print_labels', default=False) + self.expose_label_printing = self.rattail_config.getbool( + 'tailbone', 'products.print_labels', default=False) app = self.get_rattail_app() self.product_handler = app.get_products_handler() @@ -360,7 +360,7 @@ class ProductView(MasterView): g.set_sort_defaults('upc') - if self.print_labels and self.has_perm('print_labels'): + if self.expose_label_printing and self.has_perm('print_labels'): if use_buefy: g.more_actions.append(self.make_action( 'print_label', icon='print', url='#', @@ -661,7 +661,7 @@ class ProductView(MasterView): kwargs = super(ProductView, self).template_kwargs_index(**kwargs) model = self.model - if self.print_labels: + if self.expose_label_printing: kwargs['label_profiles'] = self.Session.query(model.LabelProfile)\ .filter(model.LabelProfile.visible == True)\ @@ -1704,6 +1704,37 @@ class ProductView(MasterView): self.request.response.body = product.image.bytes return self.request.response + def print_labels(self): + app = self.get_rattail_app() + label_handler = app.get_label_handler() + model = self.model + + profile = self.request.params.get('profile') + profile = self.Session.query(model.LabelProfile).get(profile) if profile else None + if not profile: + return {'error': "Label profile not found"} + + product = self.request.params.get('product') + product = self.Session.query(model.Product).get(product) if product else None + if not product: + return {'error': "Product not found"} + + quantity = self.request.params.get('quantity') + if not quantity.isdigit(): + return {'error': "Quantity must be numeric"} + quantity = int(quantity) + + printer = label_handler.get_printer(profile) + if not printer: + return {'error': "Couldn't get printer from label profile"} + + try: + printer.print_labels([({'product': product}, quantity)]) + except Exception as error: + log.warning("error occurred while printing labels", exc_info=True) + return {'error': six.text_type(error)} + return {'ok': True} + def search(self): """ Locate a product(s) by UPC. @@ -1964,10 +1995,18 @@ class ProductView(MasterView): template_prefix = cls.get_template_prefix() permission_prefix = cls.get_permission_prefix() model_title = cls.get_model_title() + model_title_plural = cls.get_model_title_plural() # print labels - config.add_tailbone_permission('products', 'products.print_labels', - "Print labels for products") + config.add_tailbone_permission(permission_prefix, + '{}.print_labels'.format(permission_prefix), + "Print labels for {}".format(model_title_plural)) + config.add_route('{}.print_labels'.format(route_prefix), + '{}/labels'.format(url_prefix)) + config.add_view(cls, attr='print_labels', + route_name='{}.print_labels'.format(route_prefix), + permission='{}.print_labels'.format(permission_prefix), + renderer='json') # view deleted products config.add_tailbone_permission('products', 'products.view_deleted', @@ -2250,39 +2289,6 @@ class PendingProductView(MasterView): permission='{}.resolve_product'.format(permission_prefix)) -def print_labels(request): - profile = request.params.get('profile') - profile = Session.query(model.LabelProfile).get(profile) if profile else None - if not profile: - return {'error': "Label profile not found"} - - product = request.params.get('product') - product = Session.query(model.Product).get(product) if product else None - if not product: - return {'error': "Product not found"} - - quantity = request.params.get('quantity') - if not quantity.isdigit(): - return {'error': "Quantity must be numeric"} - quantity = int(quantity) - - printer = profile.get_printer(request.rattail_config) - if not printer: - return {'error': "Couldn't get printer from label profile"} - - try: - printer.print_labels([(product, quantity, {})]) - except Exception as error: - log.warning("error occurred while printing labels", exc_info=True) - return {'error': six.text_type(error)} - return {'ok': True} - - def includeme(config): - - config.add_route('products.print_labels', '/products/labels') - config.add_view(print_labels, route_name='products.print_labels', - renderer='json', permission='products.print_labels') - ProductView.defaults(config) PendingProductView.defaults(config) From 23fb5e09d1e99df3fdab316c916c3fe575614c92 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 15 Jan 2022 12:47:08 -0600 Subject: [PATCH 0592/1681] 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 1910050e..915b5946 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.196 (2022-01-15) +-------------------- + +* Use the new label handler. + + 0.8.195 (2022-01-13) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 27909901..c1902864 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.195' +__version__ = '0.8.196' From f83fc18ebca1f76cf4ba1fe331d8d7a2c57627f2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 18 Jan 2022 12:29:23 -0600 Subject: [PATCH 0593/1681] Use buefy input for quickie search not sure why this suddenly has poor style / formatting, but this fixes --- tailbone/templates/themes/falafel/base.mako | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index b50cfef7..5ab12a03 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -353,7 +353,14 @@ <div class="level"> <div class="level-right"> <div class="level-item"> - ${h.text('entry', placeholder=quickie.placeholder, autocomplete='off')} + % if use_buefy: + <b-input name="entry" + placeholder="${quickie.placeholder}" + autocomplete="off"> + </b-input> + % else: + ${h.text('entry', placeholder=quickie.placeholder, autocomplete='off')} + % endif </div> <div class="level-item"> <button type="submit" class="button is-primary"> From ae27c110aba1da4dab4b19123c1f363b1775978a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 19 Jan 2022 12:19:15 -0600 Subject: [PATCH 0594/1681] 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 915b5946..64cf4789 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.197 (2022-01-19) +-------------------- + +* Use buefy input for quickie search. + + 0.8.196 (2022-01-15) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index c1902864..9541c7b1 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.196' +__version__ = '0.8.197' From db3cd4ec6ed4571137da2d3502153356d058fd73 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 24 Jan 2022 15:32:24 -0600 Subject: [PATCH 0595/1681] Only expose "product" departments within product view dropdowns --- tailbone/views/products.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 57aeb8c7..0008a0fe 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -211,6 +211,18 @@ class ProductView(MasterView): return query + def get_departments(self): + """ + Returns the list of departments to be exposed in a drop-down. + """ + model = self.model + return self.Session.query(model.Department)\ + .filter(sa.or_( + model.Department.product == True, + model.Department.product == None))\ + .order_by(model.Department.name)\ + .all() + def configure_grid(self, g): super(ProductView, self).configure_grid(g) app = self.get_rattail_app() @@ -247,12 +259,9 @@ class ProductView(MasterView): # department g.set_joiner('department', lambda q: q.outerjoin(model.Department)) g.set_sorter('department', model.Department.name) - department_choices = app.cache_model(self.Session(), model.Department, - order_by=model.Department.name, - normalizer=lambda d: d.name) + departments = self.get_departments() department_choices = OrderedDict([('', "(any)")] - + sorted(six.iteritems(department_choices), - key=lambda itm: itm[1])) + + [(d.uuid, d.name) for d in departments]) if not use_buefy: department_choices = [tags.Option(name, uuid) for uuid, name in six.iteritems(department_choices)] @@ -824,8 +833,7 @@ class ProductView(MasterView): if self.creating or self.editing: if 'department' in f.fields: f.replace('department', 'department_uuid') - departments = self.Session.query(model.Department)\ - .order_by(model.Department.number) + departments = self.get_departments() dept_values = [(d.uuid, "{} {}".format(d.number, d.name)) for d in departments] require_department = False From 7e071a7dafa2d394aa0c2fee5e953bd67fb3d1f2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 24 Jan 2022 20:06:02 -0600 Subject: [PATCH 0596/1681] Hopefully fix tox tests for python 2.7 --- tox.ini | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index 2ac683e2..6dd5ada3 100644 --- a/tox.ini +++ b/tox.ini @@ -13,10 +13,9 @@ commands = nosetests {posargs} [testenv:py27] -# TODO: this only adds the sa-utils restriction, per python2 commands = pip install --upgrade pip - pip install --upgrade --upgrade-strategy eager Tailbone rattail[auth,bouncer,db] rattail-tempmon SQLAlchemy-Utils<0.36.7 + 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} [testenv:coverage] From b9c5f6a869bceacfa8732a71b13d28f4c96df456 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 25 Jan 2022 11:10:23 -0600 Subject: [PATCH 0597/1681] 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 64cf4789..b465a485 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.198 (2022-01-25) +-------------------- + +* Only expose "product" departments within product view dropdowns. + + 0.8.197 (2022-01-19) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 9541c7b1..8d3d1b78 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.197' +__version__ = '0.8.198' From af14216eeac76da6b8e675c6c0501fd4baf1720a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 26 Jan 2022 13:13:00 -0600 Subject: [PATCH 0598/1681] Tweak the "auto-receive all" tool for Chrome browser also split out each helper section --- tailbone/templates/receiving/view.mako | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako index 01b93724..4f04cfa2 100644 --- a/tailbone/templates/receiving/view.mako +++ b/tailbone/templates/receiving/view.mako @@ -283,9 +283,7 @@ % endif </%def> -<%def name="object_helpers()"> - ${self.render_status_breakdown()} - +<%def name="render_po_vs_invoice_helper()"> % if use_buefy and master.handler.has_purchase_order(batch) and master.handler.has_invoice_file(batch): <div class="object-helper"> <h3>PO vs. Invoice</h3> @@ -294,9 +292,9 @@ </div> </div> % endif +</%def> - ${self.render_execute_helper()} - +<%def name="render_auto_receive_helper()"> % if master.has_perm('auto_receive') and master.can_auto_receive(batch): <div class="object-helper"> @@ -329,8 +327,9 @@ <section class="modal-card-body"> <p class="block"> - You can automatically mark all items as having been - received normally. + You can automatically set the "received" quantity to + match the "shipped" quantity for all items, based on + the invoice. </p> <p class="block"> Would you like to do so? @@ -341,12 +340,11 @@ <b-button @click="autoReceiveShowDialog = false"> Cancel </b-button> - ${h.form(url('{}.auto_receive'.format(route_prefix), uuid=batch.uuid))} + ${h.form(url('{}.auto_receive'.format(route_prefix), uuid=batch.uuid), **{'@submit': 'autoReceiveSubmitting = true'})} ${h.csrf_token(request)} <b-button type="is-primary" native-type="submit" :disabled="autoReceiveSubmitting" - @click="autoReceiveSubmitting = true" icon-pack="fas" icon-left="check"> {{ autoReceiveSubmitting ? "Working, please wait..." : "Auto-Receive All Items" }} @@ -359,6 +357,13 @@ % endif </%def> +<%def name="object_helpers()"> + ${self.render_status_breakdown()} + ${self.render_po_vs_invoice_helper()} + ${self.render_execute_helper()} + ${self.render_auto_receive_helper()} +</%def> + <%def name="modify_this_page_vars()"> ${parent.modify_this_page_vars()} <script type="text/javascript"> From cdcb106f2d378114e352e467a4a5afd96d0e32ce Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 26 Jan 2022 13:14:25 -0600 Subject: [PATCH 0599/1681] 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 b465a485..a311d8ed 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.199 (2022-01-26) +-------------------- + +* Tweak the "auto-receive all" tool for Chrome browser. + + 0.8.198 (2022-01-25) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 8d3d1b78..ea7cb4cc 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.198' +__version__ = '0.8.199' From 1575cad4478e387ff0c090882f3afd5c46cdca90 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 29 Jan 2022 08:57:03 -0600 Subject: [PATCH 0600/1681] Improve profile link helper for buefy themes --- tailbone/templates/util.mako | 43 +++++++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/tailbone/templates/util.mako b/tailbone/templates/util.mako index 5d3100ad..2d4653aa 100644 --- a/tailbone/templates/util.mako +++ b/tailbone/templates/util.mako @@ -2,20 +2,43 @@ <%def name="view_profile_button(person)"> <div class="buttons"> - ${h.link_to(person, url('people.view_profile', uuid=person.uuid), class_='button is-primary')} + % if use_buefy: + <b-button type="is-primary" + tag="a" href="${url('people.view_profile', uuid=person.uuid)}" + icon-pack="fas" + icon-left="user"> + ${person} + </b-button> + % else: + ${h.link_to(person, url('people.view_profile', uuid=person.uuid), class_='button is-primary')} + % endif </div> </%def> <%def name="view_profiles_helper(people)"> % if request.has_perm('people.view_profile'): - <div class="object-helper"> - <h3>Profiles</h3> - <div class="object-helper-content"> - <p>View full profile for:</p> - % for person in people: - ${view_profile_button(person)} - % endfor - </div> - </div> + % if use_buefy: + <nav class="panel"> + <p class="panel-heading">Profiles</p> + <div class="panel-block"> + <div style="display: flex; flex-direction: column;"> + <p class="block">View full profile for:</p> + % for person in people: + ${view_profile_button(person)} + % endfor + </div> + </div> + </nav> + % else: + <div class="object-helper"> + <h3>Profiles</h3> + <div class="object-helper-content"> + <p>View full profile for:</p> + % for person in people: + ${view_profile_button(person)} + % endfor + </div> + </div> + % endif % endif </%def> From 999bb29499c40a189b2bb6d71250b17f644a29e2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 29 Jan 2022 12:36:54 -0600 Subject: [PATCH 0601/1681] Add support for rattail-integration project generator --- tailbone/templates/generate_project.mako | 77 ++++++++++++++++++++++++ tailbone/views/projects.py | 29 ++++++++- 2 files changed, 103 insertions(+), 3 deletions(-) diff --git a/tailbone/templates/generate_project.mako b/tailbone/templates/generate_project.mako index 51f404ee..fa39ec08 100644 --- a/tailbone/templates/generate_project.mako +++ b/tailbone/templates/generate_project.mako @@ -9,6 +9,7 @@ <b-field horizontal label="Project Type"> <b-select v-model="projectType"> <option value="rattail">rattail</option> + <option value="rattail_integration">rattail-integration</option> ## <option value="byjove">byjove</option> <option value="fabric">fabric</option> </b-select> @@ -181,6 +182,73 @@ ${h.end_form()} </div> + <div v-if="projectType == 'rattail_integration'"> + ${h.form(request.current_route_url(), ref='rattail_integrationForm')} + ${h.csrf_token(request)} + ${h.hidden('project_type', value='rattail_integration')} + <br /> + <div class="card"> + <header class="card-header"> + <p class="card-header-title">Naming</p> + </header> + <div class="card-content"> + <div class="content"> + + <b-field horizontal label="Integration Name" + message="Name of the system to be integrated"> + <b-input name="integration_name" v-model="rattail_integration.integration_name"></b-input> + </b-field> + + <b-field horizontal label="Integration URL" + message="Reference URL for the system to be integrated"> + <b-input name="integration_url" v-model="rattail_integration.integration_url"></b-input> + </b-field> + + <b-field horizontal label="Package Name for PyPI" + message="Also will be used as slug, e.g. for folder name"> + <b-input name="python_project_name" v-model="rattail_integration.python_project_name"></b-input> + </b-field> + + ${h.hidden('slug', **{'v-model': 'rattail_integration.python_project_name'})} + + <b-field horizontal label="Package Name in Python" + :message="`For example, ~/src/${'$'}{rattail_integration.python_project_name}/${'$'}{rattail_integration.python_package_name}/__init__.py`"> + <b-input name="python_name" v-model="rattail_integration.python_package_name"></b-input> + </b-field> + + </div> + </div> + </div> + <br /> + <div class="card"> + <header class="card-header"> + <p class="card-header-title">Options</p> + </header> + <div class="card-content"> + <div class="content"> + + <b-field horizontal label="Extends Config" + message="Adds custom config extension"> + <b-checkbox name="extends_config" + v-model="rattail_integration.extends_config" + native-value="true"> + </b-checkbox> + </b-field> + + <b-field horizontal label="Extends Rattail Schema" + message="Adds custom tables/columns to the Rattail DB schema"> + <b-checkbox name="extends_db" + v-model="rattail_integration.extends_db" + native-value="true"> + </b-checkbox> + </b-field> + + </div> + </div> + </div> + ${h.end_form()} + </div> + <div v-if="projectType == 'byjove'"> ${h.form(request.current_route_url(), ref='byjoveForm')} ${h.csrf_token(request)} @@ -310,6 +378,15 @@ uses_fabric: true, } + ThisPageData.rattail_integration = { + integration_name: "Foo", + integration_url: "https://www.example.com/", + python_project_name: "rattail-foo", + python_package_name: "rattail_foo", + extends_config: true, + extends_db: true, + } + ThisPageData.byjove = { name: "Okay-Then-Mobile", slug: "okay-then-mobile", diff --git a/tailbone/views/projects.py b/tailbone/views/projects.py index 1770e021..489cb4f4 100644 --- a/tailbone/views/projects.py +++ b/tailbone/views/projects.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. # @@ -81,6 +81,25 @@ class GenerateProject(colander.MappingSchema): uses_fabric = colander.SchemaNode(colander.Boolean()) +class GenerateRattailIntegrationProject(colander.MappingSchema): + """ + Schema to generate new rattail-integration project + """ + integration_name = colander.SchemaNode(colander.String()) + + integration_url = colander.SchemaNode(colander.String()) + + slug = colander.SchemaNode(colander.String()) + + python_project_name = colander.SchemaNode(colander.String()) + + python_name = colander.SchemaNode(colander.String()) + + extends_config = colander.SchemaNode(colander.Boolean()) + + extends_db = colander.SchemaNode(colander.Boolean()) + + class GenerateByjoveProject(colander.MappingSchema): """ Schema for generating a new 'byjove' project @@ -115,7 +134,9 @@ class GenerateProjectView(View): def __init__(self, request): super(GenerateProjectView, self).__init__(request) - self.handler = self.get_handler() + self.project_handler = self.get_handler() + # TODO: deprecate / remove this + self.handler = self.project_handler def get_handler(self): from rattail.projects.handler import RattailProjectHandler @@ -132,13 +153,15 @@ class GenerateProjectView(View): project_type = 'rattail' if self.request.method == 'POST': project_type = self.request.POST.get('project_type', 'rattail') - if project_type not in ('rattail', 'byjove', 'fabric'): + if project_type not in self.project_handler.get_supported_project_types(): raise ValueError("Unknown project type: {}".format(project_type)) if project_type == 'byjove': schema = GenerateByjoveProject elif project_type == 'fabric': schema = GenerateFabricProject + elif project_type == 'rattail_integration': + schema = GenerateRattailIntegrationProject else: schema = GenerateProject form = forms.Form(schema=schema(), request=self.request, From 8a08b3f7c75367249822b24e2141af5330fdf02b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 29 Jan 2022 14:42:52 -0600 Subject: [PATCH 0602/1681] Add support for tailbone-integration project generator --- tailbone/templates/generate_project.mako | 48 ++++++++++++++++++++++++ tailbone/views/projects.py | 17 +++++++++ 2 files changed, 65 insertions(+) diff --git a/tailbone/templates/generate_project.mako b/tailbone/templates/generate_project.mako index fa39ec08..72caa83c 100644 --- a/tailbone/templates/generate_project.mako +++ b/tailbone/templates/generate_project.mako @@ -10,6 +10,7 @@ <b-select v-model="projectType"> <option value="rattail">rattail</option> <option value="rattail_integration">rattail-integration</option> + <option value="tailbone_integration">tailbone-integration</option> ## <option value="byjove">byjove</option> <option value="fabric">fabric</option> </b-select> @@ -249,6 +250,46 @@ ${h.end_form()} </div> + <div v-if="projectType == 'tailbone_integration'"> + ${h.form(request.current_route_url(), ref='tailbone_integrationForm')} + ${h.csrf_token(request)} + ${h.hidden('project_type', value='tailbone_integration')} + <br /> + <div class="card"> + <header class="card-header"> + <p class="card-header-title">Naming</p> + </header> + <div class="card-content"> + <div class="content"> + + <b-field horizontal label="Integration Name" + message="Name of the system to be integrated"> + <b-input name="integration_name" v-model="tailbone_integration.integration_name"></b-input> + </b-field> + + <b-field horizontal label="Integration URL" + message="Reference URL for the system to be integrated"> + <b-input name="integration_url" v-model="tailbone_integration.integration_url"></b-input> + </b-field> + + <b-field horizontal label="Package Name for PyPI" + message="Also will be used as slug, e.g. for folder name"> + <b-input name="python_project_name" v-model="tailbone_integration.python_project_name"></b-input> + </b-field> + + ${h.hidden('slug', **{'v-model': 'tailbone_integration.python_project_name'})} + + <b-field horizontal label="Package Name in Python" + :message="`For example, ~/src/${'$'}{tailbone_integration.python_project_name}/${'$'}{tailbone_integration.python_package_name}/__init__.py`"> + <b-input name="python_name" v-model="tailbone_integration.python_package_name"></b-input> + </b-field> + + </div> + </div> + </div> + ${h.end_form()} + </div> + <div v-if="projectType == 'byjove'"> ${h.form(request.current_route_url(), ref='byjoveForm')} ${h.csrf_token(request)} @@ -387,6 +428,13 @@ extends_db: true, } + ThisPageData.tailbone_integration = { + integration_name: "Foo", + integration_url: "https://www.example.com/", + python_project_name: "tailbone-foo", + python_package_name: "tailbone_foo", + } + ThisPageData.byjove = { name: "Okay-Then-Mobile", slug: "okay-then-mobile", diff --git a/tailbone/views/projects.py b/tailbone/views/projects.py index 489cb4f4..746a3c47 100644 --- a/tailbone/views/projects.py +++ b/tailbone/views/projects.py @@ -100,6 +100,21 @@ class GenerateRattailIntegrationProject(colander.MappingSchema): extends_db = colander.SchemaNode(colander.Boolean()) +class GenerateTailboneIntegrationProject(colander.MappingSchema): + """ + Schema to generate new tailbone-integration project + """ + integration_name = colander.SchemaNode(colander.String()) + + integration_url = colander.SchemaNode(colander.String()) + + slug = colander.SchemaNode(colander.String()) + + python_project_name = colander.SchemaNode(colander.String()) + + python_name = colander.SchemaNode(colander.String()) + + class GenerateByjoveProject(colander.MappingSchema): """ Schema for generating a new 'byjove' project @@ -162,6 +177,8 @@ class GenerateProjectView(View): schema = GenerateFabricProject elif project_type == 'rattail_integration': schema = GenerateRattailIntegrationProject + elif project_type == 'tailbone_integration': + schema = GenerateTailboneIntegrationProject else: schema = GenerateProject form = forms.Form(schema=schema(), request=self.request, From 16a4fe1a4f129b699d959855bab3011084d97cb5 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 31 Jan 2022 14:52:55 -0600 Subject: [PATCH 0603/1681] 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 a311d8ed..67ca37fd 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.200 (2022-01-31) +-------------------- + +* Improve profile link helper for buefy themes. + +* Add project generator support for rattail-integration, tailbone-integration. + + 0.8.199 (2022-01-26) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index ea7cb4cc..e78d61a4 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.199' +__version__ = '0.8.200' From 4716545b7ea27480a1016158bffa21ce7140ca66 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 31 Jan 2022 16:52:16 -0600 Subject: [PATCH 0604/1681] Show helptext for params when generating new report --- tailbone/templates/forms/util.mako | 3 +++ tailbone/views/reports.py | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/tailbone/templates/forms/util.mako b/tailbone/templates/forms/util.mako index cc3a5d2d..0b4f4012 100644 --- a/tailbone/templates/forms/util.mako +++ b/tailbone/templates/forms/util.mako @@ -9,6 +9,9 @@ % if isinstance(field.schema.typ, deform.FileData): class="file" % endif + % if form.has_helptext(field.name): + message="${form.render_helptext(field.name)}" + % endif % if error_messages: type="is-danger" :message='${form.messages_json(error_messages)|n}' diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index 2839c5b5..c9a14be7 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -401,6 +401,7 @@ class GenerateReport(View): } schema = colander.Schema() + helptext = {} for param in report_params: # make a new node of appropriate schema type @@ -420,10 +421,13 @@ class GenerateReport(View): if hasattr(param, 'default'): node.default = param.default + # set docstring + helptext[param.name] = param.helptext + schema.add(node) form = forms.Form(schema=schema, request=self.request, - use_buefy=use_buefy) + use_buefy=use_buefy, helptext=helptext) form.submit_label = "Generate this Report" form.cancel_url = self.request.route_url('generate_report') From 15fc82fc34edec82c0df47155186ac45acfeb5cf Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 31 Jan 2022 17:51:03 -0600 Subject: [PATCH 0605/1681] Tweak handling of empty params when generating report not sure there was a compelling reason to use `colander.null` other than that is what pyramid generally does? but `None` seems to work fine for me so far.. (used w/ optional date param) --- tailbone/views/reports.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index c9a14be7..b9850ed6 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -415,7 +415,7 @@ class GenerateReport(View): # allow empty value if param is optional if not param.required: - node.missing = colander.null + node.missing = None # maybe set default value if hasattr(param, 'default'): From d677cb1bc8b1253d78e9c4128832cfa684de6350 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 31 Jan 2022 17:53:37 -0600 Subject: [PATCH 0606/1681] 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 67ca37fd..fbd74f40 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.201 (2022-01-31) +-------------------- + +* Show helptext for params when generating new report. + +* Tweak handling of empty params when generating report. + + 0.8.200 (2022-01-31) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index e78d61a4..24b92de9 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.200' +__version__ = '0.8.201' From b22e7fd07778cf8150cb63d566695956eea84814 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 31 Jan 2022 19:34:24 -0600 Subject: [PATCH 0607/1681] Make "generate report" the same as "create new generated report" no reason to reinvent that wheel --- .../reports/{ => generated}/choose.mako | 12 +- .../reports/{ => generated}/generate.mako | 10 +- .../templates/reports/generated/index.mako | 11 -- .../templates/reports/generated/view.mako | 7 - tailbone/views/exports.py | 8 +- tailbone/views/reports.py | 128 ++++++++---------- 6 files changed, 58 insertions(+), 118 deletions(-) rename tailbone/templates/reports/{ => generated}/choose.mako (90%) rename tailbone/templates/reports/{ => generated}/generate.mako (63%) delete mode 100644 tailbone/templates/reports/generated/index.mako diff --git a/tailbone/templates/reports/choose.mako b/tailbone/templates/reports/generated/choose.mako similarity index 90% rename from tailbone/templates/reports/choose.mako rename to tailbone/templates/reports/generated/choose.mako index 58c9ee22..a6cb8977 100644 --- a/tailbone/templates/reports/choose.mako +++ b/tailbone/templates/reports/generated/choose.mako @@ -1,9 +1,5 @@ ## -*- coding: utf-8; -*- -<%inherit file="/form.mako" /> - -<%def name="title()">${index_title}</%def> - -<%def name="content_title()"></%def> +<%inherit file="/master/create.mako" /> <%def name="extra_javascript()"> ${parent.extra_javascript()} @@ -60,12 +56,6 @@ </style> </%def> -<%def name="context_menu_items()"> - % if request.has_perm('report_output.list'): - ${h.link_to("View Generated Reports", url('report_output'))} - % endif -</%def> - <%def name="render_buefy_form()"> <div class="form"> <p>Please select the type of report you wish to generate.</p> diff --git a/tailbone/templates/reports/generate.mako b/tailbone/templates/reports/generated/generate.mako similarity index 63% rename from tailbone/templates/reports/generate.mako rename to tailbone/templates/reports/generated/generate.mako index 57e72385..38adfe34 100644 --- a/tailbone/templates/reports/generate.mako +++ b/tailbone/templates/reports/generated/generate.mako @@ -1,15 +1,9 @@ ## -*- coding: utf-8; -*- -<%inherit file="/form.mako" /> +<%inherit file="/master/form.mako" /> <%def name="title()">${index_title} » ${report.name}</%def> -<%def name="content_title()">${report.name}</%def> - -<%def name="context_menu_items()"> - % if request.has_perm('report_output.list'): - ${h.link_to("View Generated Reports", url('report_output'))} - % endif -</%def> +<%def name="content_title()">New Report: ${report.name}</%def> <%def name="render_buefy_form()"> <div class="form"> diff --git a/tailbone/templates/reports/generated/index.mako b/tailbone/templates/reports/generated/index.mako deleted file mode 100644 index 63a5b9b5..00000000 --- a/tailbone/templates/reports/generated/index.mako +++ /dev/null @@ -1,11 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/master/index.mako" /> - -<%def name="context_menu_items()"> - ${parent.context_menu_items()} - % if request.has_perm('{}.generate'.format(permission_prefix)): - <li>${h.link_to("Generate new Report", url('generate_report'))}</li> - % endif -</%def> - -${parent.body()} diff --git a/tailbone/templates/reports/generated/view.mako b/tailbone/templates/reports/generated/view.mako index ce8ef38d..496857c5 100644 --- a/tailbone/templates/reports/generated/view.mako +++ b/tailbone/templates/reports/generated/view.mako @@ -1,13 +1,6 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -<%def name="context_menu_items()"> - ${parent.context_menu_items()} - % if request.has_perm('{}.generate'.format(permission_prefix)): - <li>${h.link_to("Generate new Report", url('generate_report'))}</li> - % endif -</%def> - <%def name="modify_this_page_vars()"> ${parent.modify_this_page_vars()} <script type="text/javascript"> diff --git a/tailbone/views/exports.py b/tailbone/views/exports.py index c136359a..3f6d417c 100644 --- a/tailbone/views/exports.py +++ b/tailbone/views/exports.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -148,12 +148,6 @@ class ExportMasterView(MasterView): obj.created_by = self.request.user return obj - def render_download(self, export, field): - path = self.get_file_path(export) - text = "{} ({})".format(export.filename, self.readable_size(path)) - url = self.request.route_url('{}.download'.format(self.get_route_prefix()), uuid=export.uuid) - return tags.link_to(text, url) - def render_created_by(self, export, field): user = export.created_by if not user: diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index b9850ed6..8985e204 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -212,6 +212,7 @@ class ReportOutputView(ExportMasterView): model_class = model.ReportOutput route_prefix = 'report_output' url_prefix = '/reports/generated' + creatable = True downloadable = True configurable = True config_title = "Reporting" @@ -235,6 +236,14 @@ class ReportOutputView(ExportMasterView): 'created_by', ] + def __init__(self, request): + super(ReportOutputView, self).__init__(request) + self.report_handler = self.get_report_handler() + + def get_report_handler(self): + app = self.get_rattail_app() + return app.get_report_handler() + def configure_grid(self, g): super(ReportOutputView, self).configure_grid(g) g.set_link('filename') @@ -245,10 +254,6 @@ class ReportOutputView(ExportMasterView): # params f.set_renderer('params', self.render_params) - # filename - if self.viewing: - f.set_renderer('filename', self.render_download) - def render_params(self, report, field): params = report.params if not params: @@ -273,11 +278,6 @@ class ReportOutputView(ExportMasterView): else: return HTML.literal(g.render_grid()) - def render_download(self, report, field): - path = report.filepath(self.rattail_config) - url = self.get_action_url('download', report) - return self.render_file_field(path, url=url) - def get_params_context(self, report): params_data = [] for name, value in (report.params or {}).items(): @@ -305,36 +305,7 @@ class ReportOutputView(ExportMasterView): return kwargs - def download(self): - report = self.get_instance() - path = report.filepath(self.rattail_config) - return self.file_response(path) - - def configure_get_simple_settings(self): - config = self.rattail_config - return [ - - # generating - {'section': 'tailbone', - 'option': 'reporting.choosing_uses_form', - 'type': bool}, - ] - - -class GenerateReport(View): - """ - View for generating a new report. - """ - - def __init__(self, request): - super(GenerateReport, self).__init__(request) - self.handler = self.get_handler() - - def get_handler(self): - app = self.get_rattail_app() - return app.get_report_handler() - - def choose(self): + def create(self): """ View which allows user to choose which type of report they wish to generate. @@ -342,7 +313,7 @@ class GenerateReport(View): use_buefy = self.get_use_buefy() # handler is responsible for determining which report types are valid - reports = self.handler.get_reports() + reports = self.report_handler.get_reports() if isinstance(reports, OrderedDict): sorted_reports = list(reports) else: @@ -370,18 +341,17 @@ class GenerateReport(View): raise self.redirect(self.request.route_url('generate_specific_report', type_key=form.validated['report_type'])) - return { - 'index_title': "Generate Report", + return self.render_to_response('choose', { 'form': form, 'dform': form.make_deform_form(), 'reports': reports, 'sorted_reports': sorted_reports, 'report_descriptions': dict([(r.type_key, r.__doc__) for r in reports.values()]), - 'use_form': self.rattail_config.getbool('tailbone', 'reporting.choosing_uses_form', - default=True), - 'use_buefy': use_buefy, - } + 'use_form': self.rattail_config.getbool( + 'tailbone', 'reporting.choosing_uses_form', + default=True), + }) def generate(self): """ @@ -391,8 +361,9 @@ class GenerateReport(View): """ use_buefy = self.get_use_buefy() type_key = self.request.matchdict['type_key'] - report = self.handler.get_report(type_key) + report = self.report_handler.get_report(type_key) report_params = report.make_params(Session()) + route_prefix = self.get_route_prefix() NODE_TYPES = { bool: colander.Boolean, @@ -429,7 +400,7 @@ class GenerateReport(View): form = forms.Form(schema=schema, request=self.request, use_buefy=use_buefy, helptext=helptext) form.submit_label = "Generate this Report" - form.cancel_url = self.request.route_url('generate_report') + form.cancel_url = self.request.route_url('{}.create'.format(route_prefix)) # must declare jquery support for date fields, ugh # TODO: obviously would be nice for this to be automatic? @@ -458,14 +429,16 @@ class GenerateReport(View): 'cancel_msg': "Report generation was canceled", }) - return { - 'index_title': "Generate Report", - 'index_url': self.request.route_url('generate_report'), + # hide the "Create New" button for this page, b/c user is + # already in the process of creating new.. + # TODO: this seems hacky, but works + self.show_create_link = False + + return self.render_to_response('generate', { 'report': report, 'form': form, 'dform': form.make_deform_form(), - 'use_buefy': self.get_use_buefy(), - } + }) def generate_thread(self, report, params, user_uuid, progress=None): """ @@ -476,7 +449,7 @@ class GenerateReport(View): session = RattailSession() user = session.query(model.User).get(user_uuid) try: - output = self.handler.generate_output(session, report, params, user, progress=progress) + output = self.report_handler.generate_output(session, report, params, user, progress=progress) # if anything goes wrong, rollback and log the error etc. except Exception as error: @@ -501,24 +474,36 @@ class GenerateReport(View): progress.session['success_url'] = success_url progress.session.save() + def download(self): + report = self.get_instance() + path = report.filepath(self.rattail_config) + return self.file_response(path) + + def configure_get_simple_settings(self): + config = self.rattail_config + return [ + + # generating + {'section': 'tailbone', + 'option': 'reporting.choosing_uses_form', + 'type': bool}, + ] + @classmethod def defaults(cls, config): + cls._defaults(config) + cls._report_output_defaults(config) - # note that we include this in the "Generated Reports" permissions group - config.add_tailbone_permission('report_output', 'report_output.generate', - "Generate new report (of any type)") + @classmethod + def _report_output_defaults(cls, config): + url_prefix = cls.get_url_prefix() + permission_prefix = cls.get_permission_prefix() - # "generate report" (which is really "choose") - config.add_route('generate_report', '/reports/generate') - config.add_view(cls, attr='choose', route_name='generate_report', - permission='report_output.generate', - renderer='/reports/choose.mako') - - # "generate specific report" (accept custom params, truly generate) - config.add_route('generate_specific_report', '/reports/generate/{type_key}') + # generate report (accept custom params, truly create) + config.add_route('generate_specific_report', + '{}/new/{{type_key}}'.format(url_prefix)) config.add_view(cls, attr='generate', route_name='generate_specific_report', - permission='report_output.generate', - renderer='/reports/generate.mako') + permission='{}.create'.format(permission_prefix)) @colander.deferred @@ -680,18 +665,13 @@ def add_routes(config): def includeme(config): - add_routes(config) + # TODO: not in love with this pattern, but works for now + add_routes(config) config.add_view(OrderingWorksheet, route_name='reports.ordering', renderer='/reports/ordering.mako') - config.add_view(InventoryWorksheet, route_name='reports.inventory', renderer='/reports/inventory.mako') - # fix permission group - config.add_tailbone_permission_group('report_output', "Generated Reports") - - # note that GenerateReport must come first, per route matching - GenerateReport.defaults(config) ReportOutputView.defaults(config) ProblemReportView.defaults(config) From 1117893900fce3902dab69382a7b115c21dbe02d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 31 Jan 2022 21:18:01 -0600 Subject: [PATCH 0608/1681] 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 fbd74f40..b814b0cc 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.202 (2022-01-31) +-------------------- + +* Make "generate report" the same as "create new generated report". + + 0.8.201 (2022-01-31) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 24b92de9..ae67732d 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.201' +__version__ = '0.8.202' From ea180ca107b23826f4d80035df963e216306b94c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 1 Feb 2022 19:14:16 -0600 Subject: [PATCH 0609/1681] Expose batch params for vendor catalogs --- tailbone/views/batch/vendorcatalog.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/batch/vendorcatalog.py b/tailbone/views/batch/vendorcatalog.py index 80fde3fe..b463a5f7 100644 --- a/tailbone/views/batch/vendorcatalog.py +++ b/tailbone/views/batch/vendorcatalog.py @@ -83,6 +83,7 @@ class VendorCatalogView(FileBatchMasterView): 'vendor', 'future', 'effective', + 'params', 'description', 'notes', 'created', From ceb70eec4c95fb40c8c5131a68c64fc3e9790b95 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 1 Feb 2022 20:03:09 -0600 Subject: [PATCH 0610/1681] 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 b814b0cc..390c2065 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.203 (2022-02-01) +-------------------- + +* Expose batch params for vendor catalogs. + + 0.8.202 (2022-01-31) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index ae67732d..16b5a829 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.202' +__version__ = '0.8.203' From 9c75d7b5609f090bc54e948eee50e92d98db9ae9 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 4 Feb 2022 14:42:11 -0600 Subject: [PATCH 0611/1681] Add `CustomerGroupAssignment` to customer version history --- tailbone/views/customers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index bf8284c0..c9bfacb9 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.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. # @@ -400,6 +400,7 @@ class CustomerView(MasterView): def get_version_child_classes(self): return [ + (model.CustomerGroupAssignment, 'customer_uuid'), (model.CustomerPhoneNumber, 'parent_uuid'), (model.CustomerEmailAddress, 'parent_uuid'), (model.CustomerMailingAddress, 'parent_uuid'), From 091b479a02b5b6761631422f2dd2b16b3227e8aa Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 4 Feb 2022 14:56:18 -0600 Subject: [PATCH 0612/1681] 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 390c2065..2496d970 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.204 (2022-02-04) +-------------------- + +* Add ``CustomerGroupAssignment`` to customer version history. + + 0.8.203 (2022-02-01) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 16b5a829..7bcfd6af 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.203' +__version__ = '0.8.204' From a36f775752efdd8c4a4ed2e02c86b84268194c2f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 5 Feb 2022 15:59:36 -0600 Subject: [PATCH 0613/1681] Tweak how product key field is handled for product views --- tailbone/views/products.py | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 0008a0fe..15b2083f 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -97,7 +97,7 @@ class ProductView(MasterView): } grid_columns = [ - 'upc', + '_product_key_', 'brand', 'description', 'size', @@ -108,9 +108,7 @@ class ProductView(MasterView): ] form_fields = [ - 'item_id', - 'scancode', - 'upc', + '_product_key_', 'brand', 'description', 'unit_size', @@ -254,6 +252,15 @@ class ProductView(MasterView): return q.outerjoin(ProductCostCodeAny, ProductCostCodeAny.product_uuid == model.Product.uuid) + # product key + key = self.rattail_config.product_key() + field = self.product_key_fields.get(key, key) + g.filters[field].default_active = True + g.filters[field].default_verb = 'equal' + g.set_sort_defaults(field) + g.set_link(field) + + # brand g.joiners['brand'] = lambda q: q.outerjoin(model.Brand) # department @@ -305,8 +312,6 @@ class ProductView(MasterView): g.set_sorter('on_order', model.ProductInventory.on_order) g.set_filter('on_order', model.ProductInventory.on_order) - g.filters['upc'].default_active = True - g.filters['upc'].default_verb = 'equal' g.filters['description'].default_active = True g.filters['description'].default_verb = 'contains' g.filters['brand'] = g.make_filter('brand', model.Brand.name, @@ -367,8 +372,6 @@ class ProductView(MasterView): g.set_joiner('report_code_name', lambda q: q.outerjoin(model.ReportCode)) g.set_filter('report_code_name', model.ReportCode.name) - g.set_sort_defaults('upc') - if self.expose_label_printing and self.has_perm('print_labels'): if use_buefy: g.more_actions.append(self.make_action( @@ -377,13 +380,10 @@ class ProductView(MasterView): else: g.more_actions.append(grids.GridAction('print_label', icon='print')) - g.set_type('upc', 'gpc') - g.set_renderer('regular_price', self.render_price) g.set_renderer('on_hand', self.render_on_hand) g.set_renderer('on_order', self.render_on_order) - g.set_link('upc') g.set_link('item_id') g.set_link('description') @@ -395,9 +395,6 @@ class ProductView(MasterView): super(ProductView, self).configure_common_form(f) product = f.model_instance - # upc - f.set_type('upc', 'gpc') - # unit_size f.set_type('unit_size', 'quantity') @@ -1173,8 +1170,10 @@ 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.product_handler.get_image_url(product) - kwargs['product_key_field'] = self.rattail_config.product_key() # add price history, if user has access if self.rattail_config.versioning_enabled() and self.has_perm('versions'): From b261e8bb9bfba808beaa8f30fe7eb3ac5449ffb9 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 5 Feb 2022 21:41:05 -0600 Subject: [PATCH 0614/1681] Add some autocomplete workarounds for new vendor catalog batch when user selects a parser, it may auto-select the vendor, but keeping that all in sync is complicated. this seems to be an improvement but it could likely use more.. --- tailbone/forms/widgets.py | 6 +++ .../static/js/tailbone.buefy.autocomplete.js | 39 ++++++++++++------- .../templates/batch/vendorcatalog/create.mako | 20 +++++++++- .../templates/deform/autocomplete_jquery.pt | 5 ++- tailbone/views/batch/vendorcatalog.py | 11 ++++-- 5 files changed, 60 insertions(+), 21 deletions(-) diff --git a/tailbone/forms/widgets.py b/tailbone/forms/widgets.py index 3dac0a6a..91b6cb32 100644 --- a/tailbone/forms/widgets.py +++ b/tailbone/forms/widgets.py @@ -251,6 +251,9 @@ class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget): service_url = None cleared_callback = None selected_callback = None + input_callback = None + new_label_callback = None + ref = None default_options = ( ('autoFocus', True), @@ -277,6 +280,9 @@ class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget): kw['field_display'] = self.field_display kw['cleared_callback'] = self.cleared_callback kw['assigned_label'] = self.assigned_label + kw['input_callback'] = self.input_callback + kw['new_label_callback'] = self.new_label_callback + kw['ref'] = self.ref kw.setdefault('selected_callback', self.selected_callback) tmpl_values = self.get_template_values(field, cstruct, kw) template = readonly and self.readonly_template or self.template diff --git a/tailbone/static/js/tailbone.buefy.autocomplete.js b/tailbone/static/js/tailbone.buefy.autocomplete.js index ce0aece9..f615c2a9 100644 --- a/tailbone/static/js/tailbone.buefy.autocomplete.js +++ b/tailbone/static/js/tailbone.buefy.autocomplete.js @@ -95,21 +95,21 @@ const TailboneAutocomplete = { } }, - watch: { - // TODO: yikes this feels hacky. what happens is, when the - // caller explicitly assigns a new UUID value to the tailbone - // autocomplate component, the underlying buefy autocomplete - // component was not getting the new value. so here we are - // explicitly making sure it is in sync. this issue was - // discovered on the "new vendor catalog batch" page - value(val) { - this.$nextTick(() => { - if (this.buefyValue != val) { - this.buefyValue = val - } - }) - }, - }, + // watch: { + // // TODO: yikes this feels hacky. what happens is, when the + // // caller explicitly assigns a new UUID value to the tailbone + // // autocomplate component, the underlying buefy autocomplete + // // component was not getting the new value. so here we are + // // explicitly making sure it is in sync. this issue was + // // discovered on the "new vendor catalog batch" page + // value(val) { + // this.$nextTick(() => { + // if (this.buefyValue != val) { + // this.buefyValue = val + // } + // }) + // }, + // }, methods: { @@ -163,9 +163,18 @@ const TailboneAutocomplete = { this.buefyValue = null // here is where we alert callers to the new value + if (option) { + this.$emit('new-label', option.label) + } this.$emit('input', option ? option.value : null) }, + // set selection to the given option, which should a simple + // object with (at least) `value` and `label` properties + setSelection(option) { + this.$refs.autocomplete.setSelected(option) + }, + // clear the field of any value, i.e. set the "currently // selected option" to null. this is invoked when you click // the button, which is visible while the field has a value. diff --git a/tailbone/templates/batch/vendorcatalog/create.mako b/tailbone/templates/batch/vendorcatalog/create.mako index 78b5b17d..19e91dd0 100644 --- a/tailbone/templates/batch/vendorcatalog/create.mako +++ b/tailbone/templates/batch/vendorcatalog/create.mako @@ -61,17 +61,33 @@ ${form.component_studly}Data.parsers = ${json.dumps(parsers_data)|n} ${form.component_studly}Data.vendorName = null + ${form.component_studly}Data.vendorNameReplacement = null ${form.component_studly}.watch.field_model_parser_key = function(val) { let parser = this.parsers[val] if (parser.vendor_uuid) { if (this.field_model_vendor_uuid != parser.vendor_uuid) { - this.field_model_vendor_uuid = parser.vendor_uuid - this.vendorName = parser.vendor_name + // this.field_model_vendor_uuid = parser.vendor_uuid + // this.vendorName = parser.vendor_name + this.$refs.vendorAutocomplete.setSelection({ + value: parser.vendor_uuid, + label: parser.vendor_name, + }) } } } + ${form.component_studly}.methods.vendorLabelChanging = function(label) { + this.vendorNameReplacement = label + } + + ${form.component_studly}.methods.vendorChanged = function(uuid) { + if (uuid) { + this.vendorName = this.vendorNameReplacement + this.vendorNameReplacement = null + } + } + </script> </%def> diff --git a/tailbone/templates/deform/autocomplete_jquery.pt b/tailbone/templates/deform/autocomplete_jquery.pt index 6e1a1e61..c0e79c29 100644 --- a/tailbone/templates/deform/autocomplete_jquery.pt +++ b/tailbone/templates/deform/autocomplete_jquery.pt @@ -108,10 +108,13 @@ tal:define="vmodel vmodel|'field_model_' + name;" tal:omit-tag=""> <tailbone-autocomplete name="${name}" + ref="${ref}" service-url="${url}" v-model="${vmodel}" initial-label="${field_display}" - tal:attributes=":assigned-label assigned_label or 'null';"> + tal:attributes=":assigned-label assigned_label or 'null'; + @input input_callback or 'null'; + @new-label new_label_callback or 'null';"> </tailbone-autocomplete> </div> diff --git a/tailbone/views/batch/vendorcatalog.py b/tailbone/views/batch/vendorcatalog.py index b463a5f7..8173cac5 100644 --- a/tailbone/views/batch/vendorcatalog.py +++ b/tailbone/views/batch/vendorcatalog.py @@ -203,9 +203,14 @@ class VendorCatalogView(FileBatchMasterView): if vendor: vendor_display = six.text_type(vendor) vendors_url = self.request.route_url('vendors.autocomplete') - f.set_widget('vendor_uuid', forms.widgets.JQueryAutocompleteWidget( - field_display=vendor_display, service_url=vendors_url, - assigned_label='vendorName')) + f.set_widget('vendor_uuid', + forms.widgets.JQueryAutocompleteWidget( + field_display=vendor_display, + service_url=vendors_url, + ref='vendorAutocomplete', + assigned_label='vendorName', + input_callback='vendorChanged', + new_label_callback='vendorLabelChanging')) else: f.set_readonly('vendor') From 025cabd1ad477e2a6139481f8c778b20fcfc6649 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 5 Feb 2022 21:52:30 -0600 Subject: [PATCH 0615/1681] 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 2496d970..83a9df22 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.205 (2022-02-05) +-------------------- + +* Tweak how product key field is handled for product views. + +* Add some autocomplete workarounds for new vendor catalog batch. + + 0.8.204 (2022-02-04) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 7bcfd6af..152e2331 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.204' +__version__ = '0.8.205' From 072f5da69d8af620a420b5cc37a61879ad99ec5b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 8 Feb 2022 12:21:24 -0600 Subject: [PATCH 0616/1681] Add "full lookup" product search modal for new custorder page --- .../static/js/tailbone.buefy.autocomplete.js | 6 + tailbone/templates/custorders/create.mako | 39 ++- tailbone/templates/products/lookup.mako | 257 ++++++++++++++++++ tailbone/views/products.py | 114 +++++++- 4 files changed, 403 insertions(+), 13 deletions(-) create mode 100644 tailbone/templates/products/lookup.mako diff --git a/tailbone/static/js/tailbone.buefy.autocomplete.js b/tailbone/static/js/tailbone.buefy.autocomplete.js index f615c2a9..b4070fab 100644 --- a/tailbone/static/js/tailbone.buefy.autocomplete.js +++ b/tailbone/static/js/tailbone.buefy.autocomplete.js @@ -223,6 +223,12 @@ const TailboneAutocomplete = { // we have nothing to go on here.. return "" }, + + // returns the "raw" user input from the underlying buefy + // autocomplete component + getUserInput() { + return this.buefyValue + }, }, } diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index c3aed270..ddabfc4d 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -1,5 +1,6 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/create.mako" /> +<%namespace name="product_lookup" file="/products/lookup.mako" /> <%def name="extra_styles()"> ${parent.extra_styles()} @@ -54,6 +55,7 @@ <%def name="render_this_page_template()"> ${parent.render_this_page_template()} + ${product_lookup.tailbone_product_lookup_template()} <script type="text/x-template" id="customer-order-creator-template"> <div> @@ -524,6 +526,15 @@ @input="productChanged"> </tailbone-autocomplete> </b-field> + + <b-button type="is-primary" + v-if="!productUUID" + @click="productFullLookup()" + icon-pack="fas" + icon-left="search"> + Full Lookup + </b-button> + <b-button v-if="productUUID" type="is-primary" tag="a" target="_blank" @@ -822,6 +833,11 @@ </div> </b-modal> + <tailbone-product-lookup ref="productLookup" + @canceled="productLookupCanceled" + @selected="productLookupSelected"> + </tailbone-product-lookup> + <b-modal :active.sync="pastItemsShowDialog"> <div class="card"> <div class="card-content"> @@ -1017,6 +1033,7 @@ <%def name="make_this_page_component()"> ${parent.make_this_page_component()} + ${product_lookup.tailbone_product_lookup_component()} <script type="text/javascript"> const CustomerOrderCreator = { @@ -1096,7 +1113,6 @@ productIsKnown: true, productUUID: null, productDisplay: null, - productUPC: null, productKey: null, productKeyField: ${json.dumps(product_key_field)|n}, productKeyLabel: ${json.dumps(product_key_label)|n}, @@ -1716,6 +1732,22 @@ } }, + productFullLookup() { + this.showingItemDialog = false + let term = this.$refs.productAutocomplete.getUserInput() + this.$refs.productLookup.showDialog(term) + }, + + productLookupCanceled() { + this.showingItemDialog = true + }, + + productLookupSelected(selected) { + this.clearProduct() + this.productChanged(selected.uuid) + this.showingItemDialog = true + }, + copyPendingProductAttrs(from, to) { to.upc = from.upc to.item_id = from.item_id @@ -1738,7 +1770,6 @@ this.productIsKnown = true this.productUUID = null this.productDisplay = null - this.productUPC = null this.productKey = null this.productSize = null this.productCaseQuantity = null @@ -1794,7 +1825,6 @@ this.productIsKnown = true this.productUUID = selected.uuid this.productDisplay = selected.full_description - this.productUPC = selected.upc_pretty || selected.upc this.productKey = selected.key this.productSize = selected.size this.productCaseQuantity = selected.case_quantity @@ -1833,7 +1863,6 @@ } this.productDisplay = row.product_full_description - this.productUPC = row.product_upc_pretty || row.product_upc this.productKey = row.product_key this.productSize = row.product_size this.productCaseQuantity = row.case_quantity @@ -1886,7 +1915,6 @@ clearProduct() { this.productUUID = null this.productDisplay = null - this.productUPC = null this.productKey = null this.productSize = null this.productCaseQuantity = null @@ -1936,7 +1964,6 @@ // whatever came back from handler this.submitBatchData(params, response => { this.productUUID = response.data.uuid - this.productUPC = response.data.upc_pretty this.productKey = response.data.key this.productDisplay = response.data.full_description this.productSize = response.data.size diff --git a/tailbone/templates/products/lookup.mako b/tailbone/templates/products/lookup.mako new file mode 100644 index 00000000..10620749 --- /dev/null +++ b/tailbone/templates/products/lookup.mako @@ -0,0 +1,257 @@ +## -*- coding: utf-8; -*- + +<%def name="tailbone_product_lookup_template()"> + <script type="text/x-template" id="tailbone-product-lookup-template"> + <div> + <b-modal :active.sync="showingDialog"> + <div class="card"> + <div class="card-content"> + + <b-field grouped> + + <b-input v-model="searchTerm" + ref="searchTermInput" + @keydown.native="searchTermInputKeydown"> + </b-input> + + <b-button class="control" + type="is-primary" + @click="performSearch()"> + Search + </b-button> + + <b-checkbox v-model="searchProductKey" + native-value="true"> + ${request.rattail_config.product_key_title()} + </b-checkbox> + + <b-checkbox v-model="searchVendorItemCode" + native-value="true"> + Vendor Code + </b-checkbox> + + <b-checkbox v-model="searchAlternateCode" + native-value="true"> + Alt Code + </b-checkbox> + + <b-checkbox v-model="searchProductBrand" + native-value="true"> + Brand + </b-checkbox> + + <b-checkbox v-model="searchProductDescription" + native-value="true"> + Description + </b-checkbox> + + </b-field> + + <b-table :data="searchResults" + narrowed + icon-pack="fas" + :loading="searchResultsLoading" + :selected.sync="searchResultSelected"> + <template slot-scope="props"> + + <b-table-column label="${request.rattail_config.product_key_title()}" + field="product_key"> + {{ props.row.product_key }} + </b-table-column> + + <b-table-column label="Brand" + field="brand_name"> + {{ props.row.brand_name }} + </b-table-column> + + <b-table-column label="Description" + field="description"> + {{ props.row.description }} + {{ props.row.size }} + </b-table-column> + + <b-table-column label="Unit Price" + field="unit_price"> + {{ props.row.unit_price_display }} + </b-table-column> + + <b-table-column label="Sale Price" + field="sale_price"> + <span class="has-background-warning"> + {{ props.row.sale_price_display }} + </span> + </b-table-column> + + <b-table-column label="Sale Ends" + field="sale_ends"> + <span class="has-background-warning"> + {{ props.row.sale_ends_display }} + </span> + </b-table-column> + + <b-table-column label="Department" + field="department_name"> + {{ props.row.department_name }} + </b-table-column> + + <b-table-column label="Vendor" + field="vendor_name"> + {{ props.row.vendor_name }} + </b-table-column> + + <b-table-column label="Actions"> + <a :href="props.row.url" + target="_blank" + class="grid-action"> + <i class="fas fa-external-link-alt"></i> + View + </a> + </b-table-column> + + </template> + <template slot="empty"> + <div class="content has-text-grey has-text-centered"> + <p> + <b-icon + pack="fas" + icon="fas fa-sad-tear" + size="is-large"> + </b-icon> + </p> + <p>Nothing here.</p> + </div> + </template> + </b-table> + + <br /> + <div class="level"> + <div class="level-left"> + <div class="level-item buttons"> + <b-button @click="cancelDialog()"> + Cancel + </b-button> + <b-button type="is-primary" + @click="selectResult()" + :disabled="!searchResultSelected"> + Choose Selected + </b-button> + </div> + </div> + <div class="level-right"> + <div class="level-item"> + <span v-if="searchResultsElided" + class="has-text-danger"> + {{ searchResultsElided }} results are not shown + </span> + </div> + </div> + </div> + + </div> + </div> + </b-modal> + </div> + </script> +</%def> + +<%def name="tailbone_product_lookup_component()"> + <script type="text/javascript"> + + const TailboneProductLookup = { + template: '#tailbone-product-lookup-template', + data() { + return { + showingDialog: false, + + searchTerm: null, + searchTermLastUsed: null, + + searchProductKey: true, + searchVendorItemCode: true, + searchProductBrand: true, + searchProductDescription: true, + searchAlternateCode: true, + + searchResults: [], + searchResultsLoading: false, + searchResultsElided: 0, + searchResultSelected: null, + } + }, + methods: { + + showDialog(term) { + + this.searchResultSelected = null + + if (term !== undefined) { + this.searchTerm = term + // perform search if invoked with new term + if (term != this.searchTermLastUsed) { + this.searchTermLastUsed = null + this.performSearch() + } + } else { + this.searchTerm = this.searchTermLastUsed + } + + this.showingDialog = true + this.$nextTick(() => { + this.$refs.searchTermInput.focus() + }) + }, + + searchTermInputKeydown(event) { + if (event.which == 13) { + this.performSearch() + } + }, + + cancelDialog() { + this.searchResultSelected = null + this.showingDialog = false + this.$emit('canceled') + }, + + selectResult() { + this.showingDialog = false + this.$emit('selected', this.searchResultSelected) + }, + + performSearch() { + if (this.searchResultsLoading) { + return + } + + if (!this.searchTerm || !this.searchTerm.length) { + this.$refs.searchTermInput.focus() + return + } + + this.searchResultsLoading = true + this.searchResultSelected = null + + let url = '${url('products.search')}' + let params = { + term: this.searchTerm, + search_product_key: this.searchProductKey, + search_vendor_code: this.searchVendorItemCode, + search_brand_name: this.searchProductBrand, + search_description: this.searchProductDescription, + search_alt_code: this.searchAlternateCode, + } + + this.$http.get(url, {params: params}).then((response) => { + this.searchTermLastUsed = params.term + this.searchResults = response.data.results + this.searchResultsElided = response.data.elided + this.searchResultsLoading = false + }) + }, + }, + } + + Vue.component('tailbone-product-lookup', TailboneProductLookup) + + </script> +</%def> diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 15b2083f..752a996d 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -179,9 +179,10 @@ class ProductView(MasterView): 'tailbone', 'products.print_labels', default=False) app = self.get_rattail_app() - self.product_handler = app.get_products_handler() - # TODO: deprecate / remove this - self.handler = self.product_handler + self.products_handler = app.get_products_handler() + # TODO: deprecate / remove these + self.product_handler = self.products_handler + self.handler = self.products_handler def query(self, session): user = self.request.user @@ -535,7 +536,7 @@ class ProductView(MasterView): if not product.not_for_sale: price = product[field] if price: - return self.product_handler.render_price(price) + return self.products_handler.render_price(price) def render_current_price_for_grid(self, product, field): text = self.render_price(product, field) or "" @@ -1173,7 +1174,7 @@ class ProductView(MasterView): key = self.rattail_config.product_key() kwargs['product_key_field'] = self.product_key_fields.get(key, key) - kwargs['image_url'] = self.product_handler.get_image_url(product) + kwargs['image_url'] = self.products_handler.get_image_url(product) # add price history, if user has access if self.rattail_config.versioning_enabled() and self.has_perm('versions'): @@ -1743,6 +1744,105 @@ class ProductView(MasterView): return {'ok': True} def search(self): + """ + Perform a product search across multiple fields, and return + the results as JSON suitable for row data for a Buefy + ``<b-table>`` component. + """ + if 'term' not in self.request.GET: + # TODO: deprecate / remove this? not sure if/where it is used + return self.search_v1() + + term = self.request.GET.get('term') + if not term: + return {'ok': True, 'results': []} + + supported_fields = [ + 'product_key', + 'vendor_code', + 'alt_code', + 'brand_name', + 'description', + ] + + search_fields = [] + for field in supported_fields: + key = 'search_{}'.format(field) + if self.request.GET.get(key) == 'true': + search_fields.append(field) + + final_results = [] + session = self.Session() + model = self.model + + lookup_fields = [] + if 'product_key' in search_fields: + lookup_fields.append('_product_key_') + if 'vendor_code' in search_fields: + lookup_fields.append('vendor_code') + if 'alt_code' in search_fields: + lookup_fields.append('alt_code') + if lookup_fields: + product = self.products_handler.locate_product_for_entry( + session, term, lookup_fields=lookup_fields) + if product: + final_results.append(self.search_normalize_result(product)) + + # base wildcard query + query = session.query(model.Product) + if 'brand_name' in search_fields: + query = query.outerjoin(model.Brand) + + # now figure out wildcard criteria + criteria = [] + for word in term.split(): + if 'brand_name' in search_fields and 'description' in search_fields: + criteria.append(sa.or_( + model.Brand.name.ilike('%{}%'.format(word)), + model.Product.description.ilike('%{}%'.format(word)))) + elif 'brand_name' in search_fields: + criteria.append(model.Brand.name.ilike('%{}%'.format(word))) + elif 'description' in search_fields: + criteria.append(model.Product.description.ilike('%{}%'.format(word))) + + # execute wildcard query if applicable + max_results = 30 # TODO: make conifgurable? + elided = 0 + if criteria: + query = query.filter(sa.and_(*criteria)) + count = query.count() + if count > max_results: + elided = count - max_results + for product in query[:max_results]: + final_results.append(self.search_normalize_result(product)) + + return {'ok': True, 'results': final_results, 'elided': elided} + + def search_normalize_result(self, product, **kwargs): + return self.products_handler.normalize_product(product, fields=[ + 'product_key', + 'url', + 'image_url', + 'brand_name', + 'description', + 'size', + 'full_description', + 'department_name', + 'unit_price', + 'unit_price_display', + 'sale_price', + 'sale_price_display', + 'sale_ends_display', + 'vendor_name', + # TODO: should be case_size + 'case_quantity', + 'case_price', + 'case_price_display', + 'uom_choices', + ]) + + # TODO: deprecate / remove this? not sure if/where it is used + def search_v1(self): """ Locate a product(s) by UPC. @@ -2027,10 +2127,10 @@ class ProductView(MasterView): renderer='{}/batch.mako'.format(template_prefix), permission='{}.make_batch'.format(permission_prefix)) - # search (by upc) + # search config.add_route('products.search', '/products/search') config.add_view(cls, attr='search', route_name='products.search', - renderer='json', permission='products.view') + renderer='json', permission='products.list') # product image config.add_route('products.image', '/products/{uuid}/image') From 8cc54b6106a052e09992c0ee84c4f6d2309e4dea Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 8 Feb 2022 12:23:12 -0600 Subject: [PATCH 0617/1681] 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 83a9df22..b9cdb3d7 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.206 (2022-02-08) +-------------------- + +* Add "full lookup" product search modal for new custorder page. + + 0.8.205 (2022-02-05) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 152e2331..dddd2841 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.205' +__version__ = '0.8.206' From f1c2fd399ea6f2fe285aad080f61950d7d195e49 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 9 Feb 2022 18:02:09 -0600 Subject: [PATCH 0618/1681] Try out new config defaults function for user views pretty sure this is a good idea but we'll see --- tailbone/views/users.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tailbone/views/users.py b/tailbone/views/users.py index 124d355a..157f42ee 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.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. # @@ -516,6 +516,11 @@ class UserEventView(MasterView): UserEventsView = UserEventView +def defaults(config, **kwargs): + base = globals() + kwargs.get('UserView', base['UserView']).defaults(config) + kwargs.get('UserEventView', base['UserEventView']).defaults(config) + + def includeme(config): - UserView.defaults(config) - UserEventView.defaults(config) + defaults(config) From 065ad9e422e69cd88f6c9b382e9ae45f0e132c85 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 10 Feb 2022 10:55:41 -0600 Subject: [PATCH 0619/1681] Add highlight for non-active users in grid --- tailbone/views/users.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tailbone/views/users.py b/tailbone/views/users.py index 157f42ee..52064346 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -135,6 +135,10 @@ class UserView(PrincipalMasterView): g.set_link('last_name') g.set_link('display_name') + def grid_extra_class(self, user, i): + if not user.active: + return 'warning' + def editable_instance(self, user): """ If the given user is "protected" then we only allow edit if current From e8526135672cb014b8a09e9ca3c21163e970f32e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 10 Feb 2022 11:16:39 -0600 Subject: [PATCH 0620/1681] Add highlight for non-active customers in grid --- tailbone/views/customers.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index c9bfacb9..b4dd77c6 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -131,12 +131,20 @@ class CustomerView(MasterView): g.set_sorter('person', model.Person.display_name) 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' + g.set_link('id') g.set_link('number') g.set_link('name') g.set_link('person') g.set_link('email') + def grid_extra_class(self, customer, i): + if not customer.active_in_pos: + return 'warning' + def get_instance(self): try: instance = super(CustomerView, self).get_instance() From 9584fb57b0cf0fcb4dbdabaa91936bf8cc5a7d04 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 10 Feb 2022 20:31:03 -0600 Subject: [PATCH 0621/1681] Only prevent cache for index pages if so configured there is a performance hit for this, depending on your perspective, so let's make it opt-in only for now --- tailbone/views/master.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 3b7bed2a..bcbb58d3 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -4490,14 +4490,19 @@ class MasterView(View): config.add_tailbone_permission(permission_prefix, '{}.list'.format(permission_prefix), "List / search {}".format(model_title_plural)) config.add_route(route_prefix, '{}/'.format(url_prefix)) + kwargs = {} + if rattail_config.getbool('tailbone', + 'prevent_cache_for_index_views', + default=False): + # hopefully, instruct browser to never cache this page. + # on windows/chrome we are seeing some caching when e.g. + # user applies some filters, then views a record, then + # clicks back button, filters no longer are applied + # cf. https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/viewconfig.html#non-predicate-arguments + kwargs['http_cache'] = 0 config.add_view(cls, attr='index', route_name=route_prefix, permission='{}.list'.format(permission_prefix), - # hopefully, instruct browser to never cache this page. - # on windows/chrome we are seeing some caching when e.g. - # user applies some filters, then views a record, then - # clicks back button, filters no longer are applied - # cf. https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/viewconfig.html#non-predicate-arguments - http_cache=0) + **kwargs) # download results # this is the "new" more flexible approach, but we only want to From 86a42064ea9dd7957e605406a8c84e39be1d6f97 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 11 Feb 2022 15:35:12 -0600 Subject: [PATCH 0622/1681] Cleanup labels for Vendor/Code "preferred" vs. "any" in products grid --- tailbone/views/products.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 752a996d..33615ef4 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -283,8 +283,6 @@ class ProductView(MasterView): g.joiners['code'] = lambda q: q.outerjoin(model.ProductCode) g.joiners['vendor'] = join_vendor g.joiners['vendor_any'] = join_vendor_any - g.joiners['vendor_code'] = join_vendor_code - g.joiners['vendor_code_any'] = join_vendor_code_any g.sorters['brand'] = g.make_sorter(model.Brand.name) g.sorters['subdepartment'] = g.make_sorter(model.Subdepartment.name) @@ -322,8 +320,19 @@ class ProductView(MasterView): g.filters['vendor'] = g.make_filter('vendor', model.Vendor.name) g.filters['vendor_any'] = g.make_filter('vendor_any', self.VendorAny.name) # factory=VendorAnyFilter, joiner=join_vendor_any) - g.filters['vendor_code'] = g.make_filter('vendor_code', ProductCostCode.code) - g.filters['vendor_code_any'] = g.make_filter('vendor_code_any', ProductCostCodeAny.code) + + # g.joiners['vendor_code_any'] = join_vendor_code_any + # g.filters['vendor_code_any'] = g.make_filter('vendor_code_any', ProductCostCodeAny.code) + # g.joiners['vendor_code'] = join_vendor_code + # g.filters['vendor_code'] = g.make_filter('vendor_code', ProductCostCode.code) + + # vendor_code* + g.set_joiner('vendor_code', join_vendor_code) + g.set_filter('vendor_code', ProductCostCode.code) + g.set_label('vendor_code', "Vendor Code (preferred)") + g.set_joiner('vendor_code_any', join_vendor_code_any) + g.set_filter('vendor_code_any', ProductCostCodeAny.code) + g.set_label('vendor_code_any', "Vendor Code (any)") # category g.set_joiner('category', lambda q: q.outerjoin(model.Category)) @@ -390,7 +399,7 @@ class ProductView(MasterView): g.set_label('vendor', "Vendor (preferred)") g.set_label('vendor_any', "Vendor (any)") - g.set_label('vendor', "Pref. Vendor") + g.set_label('vendor', "Vendor (preferred)") def configure_common_form(self, f): super(ProductView, self).configure_common_form(f) From 0ead06106c856849e34cb6ec51960161d5d0d64b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 11 Feb 2022 16:48:46 -0600 Subject: [PATCH 0623/1681] Add config for showing ordered vs. shipped amounts when receiving --- tailbone/templates/receiving/configure.mako | 23 ++++++++++++++++++++ tailbone/views/purchasing/receiving.py | 24 +++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/tailbone/templates/receiving/configure.mako b/tailbone/templates/receiving/configure.mako index 349dc621..dff280bb 100644 --- a/tailbone/templates/receiving/configure.mako +++ b/tailbone/templates/receiving/configure.mako @@ -53,6 +53,29 @@ </div> + <h3 class="block is-size-3">Display</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field> + <b-checkbox name="rattail.batch.purchase.receiving.show_ordered_column_in_grid" + v-model="simpleSettings['rattail.batch.purchase.receiving.show_ordered_column_in_grid']" + native-value="true" + @input="settingsNeedSaved = true"> + Show "ordered" quantities in row grid + </b-checkbox> + </b-field> + + <b-field> + <b-checkbox name="rattail.batch.purchase.receiving.show_shipped_column_in_grid" + v-model="simpleSettings['rattail.batch.purchase.receiving.show_shipped_column_in_grid']" + native-value="true" + @input="settingsNeedSaved = true"> + Show "shipped" quantities in row grid + </b-checkbox> + </b-field> + + </div> + <h3 class="block is-size-3">Product Handling</h3> <div class="block" style="padding-left: 2rem;"> diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index e481db82..12979d0b 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -159,6 +159,8 @@ class ReceivingBatchView(PurchasingBatchView): 'description', 'size', 'department_name', + 'cases_ordered', + 'units_ordered', 'cases_shipped', 'units_shipped', 'cases_received', @@ -904,6 +906,20 @@ class ReceivingBatchView(PurchasingBatchView): g.set_joiner('credits', lambda q: q.outerjoin(Credits)) g.sorters['credits'] = lambda q, d: q.order_by(getattr(Credits.c.credit_count, d)()) + show_ordered = self.rattail_config.getbool( + 'rattail.batch', 'purchase.receiving.show_ordered_column_in_grid', + default=False) + if not show_ordered: + g.remove('cases_ordered', + 'units_ordered') + + show_shipped = self.rattail_config.getbool( + 'rattail.batch', 'purchase.receiving.show_shipped_column_in_grid', + default=False) + if not show_shipped: + g.remove('cases_shipped', + 'units_shipped') + # hide 'ordered' columns for truck dump parent, if its "children first" # flag is set, since that batch type is only concerned with receiving if batch.is_truck_dump_parent() and not batch.truck_dump_children_first: @@ -1851,6 +1867,14 @@ class ReceivingBatchView(PurchasingBatchView): 'option': 'purchase.allow_truck_dump_receiving', 'type': bool}, + # display + {'section': 'rattail.batch', + 'option': 'purchase.receiving.show_ordered_column_in_grid', + 'type': bool}, + {'section': 'rattail.batch', + 'option': 'purchase.receiving.show_shipped_column_in_grid', + 'type': bool}, + # product handling {'section': 'rattail.batch', 'option': 'purchase.allow_cases', From 85ef73dcb975882f6c9a35fbc54f0c115e6c6a33 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 11 Feb 2022 16:55:25 -0600 Subject: [PATCH 0624/1681] Tell browser not to cache certain pages, by default main grid/index pages, and any view page which contains a row grid --- tailbone/views/master.py | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index bcbb58d3..77a844a4 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -4485,21 +4485,22 @@ class MasterView(View): config.add_tailbone_permission_group(permission_prefix, model_title_plural, overwrite=False) + # on windows/chrome we are seeing some caching when e.g. user + # applies some filters, then views a record, then clicks back + # button, filters no longer are applied. so by default we + # instruct browser to never cache certain pages which contain + # a grid. at this point only /index and /view + # cf. https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/viewconfig.html#non-predicate-arguments + prevent_cache = rattail_config.getbool('tailbone', + 'prevent_cache_for_index_views', + default=True) + # list/search if cls.listable: config.add_tailbone_permission(permission_prefix, '{}.list'.format(permission_prefix), "List / search {}".format(model_title_plural)) config.add_route(route_prefix, '{}/'.format(url_prefix)) - kwargs = {} - if rattail_config.getbool('tailbone', - 'prevent_cache_for_index_views', - default=False): - # hopefully, instruct browser to never cache this page. - # on windows/chrome we are seeing some caching when e.g. - # user applies some filters, then views a record, then - # clicks back button, filters no longer are applied - # cf. https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/viewconfig.html#non-predicate-arguments - kwargs['http_cache'] = 0 + kwargs = {'http_cache': 0} if prevent_cache else {} config.add_view(cls, attr='index', route_name=route_prefix, permission='{}.list'.format(permission_prefix), **kwargs) @@ -4667,8 +4668,10 @@ class MasterView(View): # view by record key config.add_route('{}.view'.format(route_prefix), instance_url_prefix) + kwargs = {'http_cache': 0} if prevent_cache and cls.has_rows else {} config.add_view(cls, attr='view', route_name='{}.view'.format(route_prefix), - permission='{}.view'.format(permission_prefix)) + permission='{}.view'.format(permission_prefix), + **kwargs) # version history if cls.has_versions and rattail_config and rattail_config.versioning_enabled(): From a6d97538aff3786400e0d1d4f5ef5bd83b57c027 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 11 Feb 2022 19:15:39 -0600 Subject: [PATCH 0625/1681] Use new-style config defaults for customer views --- tailbone/views/customers.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index b4dd77c6..310bddb5 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -614,12 +614,23 @@ def customer_info(request): } -def includeme(config): +def defaults(config, **kwargs): + base = globals() - # info + # TODO: deprecate / remove this config.add_route('customer.info', '/customers/info') + customer_info = kwargs.get('customer_info', base['customer_info']) config.add_view(customer_info, route_name='customer.info', renderer='json', permission='customers.view') + CustomerView = kwargs.get('CustomerView', + base['CustomerView']) CustomerView.defaults(config) + + PendingCustomerView = kwargs.get('PendingCustomerView', + base['PendingCustomerView']) PendingCustomerView.defaults(config) + + +def includeme(config): + defaults(config) From 4e3aa1af8318eaa3a3488621386ab160548b8e9a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 12 Feb 2022 19:16:16 -0600 Subject: [PATCH 0626/1681] Tweak how "duration" fields are rendered for grids, forms --- tailbone/forms/core.py | 10 +++++----- tailbone/grids/core.py | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 17921c72..850768cf 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -27,7 +27,6 @@ Forms Core from __future__ import unicode_literals, absolute_import import json -import datetime import logging import six @@ -36,7 +35,7 @@ from sqlalchemy import orm from sqlalchemy.ext.associationproxy import AssociationProxy, ASSOCIATION_PROXY from rattail.time import localtime -from rattail.util import prettify, pretty_boolean, pretty_hours, pretty_quantity +from rattail.util import prettify, pretty_boolean, pretty_quantity from rattail.core import UNSPECIFIED import colander @@ -902,10 +901,11 @@ class Form(object): return raw_datetime(self.request.rattail_config, value) def render_duration(self, record, field_name): - value = self.obtain_value(record, field_name) - if value is None: + seconds = self.obtain_value(record, field_name) + if seconds is None: return "" - return pretty_hours(datetime.timedelta(seconds=value)) + app = self.request.rattail_config.get_app() + return app.render_duration(seconds=seconds) def render_boolean(self, record, field_name): value = self.obtain_value(record, field_name) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index d825e4b4..cb71e144 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -26,7 +26,6 @@ Core Grid Classes from __future__ import unicode_literals, absolute_import -import datetime import warnings import logging @@ -358,10 +357,11 @@ class Grid(object): return pretty_quantity(value) def render_duration(self, obj, column_name): - value = self.obtain_value(obj, column_name) - if value is None: + seconds = self.obtain_value(obj, column_name) + if seconds is None: return "" - return pretty_hours(datetime.timedelta(seconds=value)) + app = self.request.rattail_config.get_app() + return app.render_duration(seconds=seconds) def render_duration_hours(self, obj, field): value = self.obtain_value(obj, field) From 09227fa30aec42bf5af8c3f2cbc2d9785f01f55c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 13 Feb 2022 16:27:24 -0600 Subject: [PATCH 0627/1681] New upgrades should be enabled by default --- tailbone/views/upgrades.py | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index 0484dabc..11c2930c 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/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. # @@ -155,6 +155,7 @@ class UpgradeView(MasterView): def configure_form(self, f): super(UpgradeView, self).configure_form(f) + upgrade = f.model_instance # status_code if self.creating: @@ -168,7 +169,6 @@ class UpgradeView(MasterView): f.remove('executing') f.set_type('created', 'datetime') - f.set_type('enabled', 'boolean') f.set_type('executed', 'datetime') # f.set_widget('not_until', dfwidget.DateInputWidget()) f.set_widget('notes', dfwidget.TextAreaWidget(cols=80, rows=8)) @@ -179,7 +179,6 @@ class UpgradeView(MasterView): # f.set_readonly('created_by') f.set_readonly('executed') f.set_readonly('executed_by') - upgrade = f.model_instance if self.creating or self.editing: f.remove_field('created') f.remove_field('created_by') @@ -188,11 +187,6 @@ class UpgradeView(MasterView): if self.creating or not upgrade.executed: f.remove_field('executed') f.remove_field('executed_by') - if self.editing and upgrade.executed: - f.remove_field('enabled') - - elif f.model_instance.executed: - f.remove_field('enabled') else: f.remove_field('executed') @@ -200,6 +194,13 @@ class UpgradeView(MasterView): f.remove_field('stdout_file') f.remove_field('stderr_file') + # enabled + if not self.creating and upgrade.executed: + f.remove('enabled') + else: + f.set_type('enabled', 'boolean') + f.set_default('enabled', True) + if not self.viewing or not upgrade.executed: f.remove_field('package_diff') f.remove_field('exit_code') @@ -462,5 +463,12 @@ class UpgradeView(MasterView): cls._defaults(config) -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + UpgradeView = kwargs.get('UpgradeView', base['UpgradeView']) UpgradeView.defaults(config) + + +def includeme(config): + defaults(config) From 753daa55e8c06f65d59b633d8cb2485a7f2adff8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 13 Feb 2022 21:41:47 -0600 Subject: [PATCH 0628/1681] 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 b9cdb3d7..186dcd7f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,24 @@ CHANGELOG ========= +0.8.207 (2022-02-13) +-------------------- + +* Try out new config defaults function for some views (user, customer). + +* Add highlight for non-active users, customers in grid. + +* Prevent cache for index pages by default, unless configured not to. + +* Cleanup labels for Vendor/Code "preferred" vs. "any" in products grid. + +* Add config for showing ordered vs. shipped amounts when receiving. + +* Tweak how "duration" fields are rendered for grids, forms. + +* New upgrades should be enabled by default. + + 0.8.206 (2022-02-08) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index dddd2841..0b0bbd54 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.206' +__version__ = '0.8.207' From 6093be43c9904980106478511cec3d4bb5fbdd6d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 13 Feb 2022 21:51:42 -0600 Subject: [PATCH 0629/1681] Allow override of navbar-end element in falafel theme header --- tailbone/templates/themes/falafel/base.mako | 62 +++++++++++---------- 1 file changed, 34 insertions(+), 28 deletions(-) diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index 5ab12a03..94e20f3e 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -236,34 +236,7 @@ % endfor </div><!-- navbar-start --> - <div class="navbar-end"> - - ## User Menu - % if request.user: - <div class="navbar-item has-dropdown is-hoverable"> - % if messaging_enabled: - <a class="navbar-link ${'root-user' if request.is_root else ''}">${request.user}${" ({})".format(inbox_count) if inbox_count else ''}</a> - % else: - <a class="navbar-link ${'root-user' if request.is_root else ''}">${request.user}</a> - % endif - <div class="navbar-dropdown"> - % if request.is_root: - ${h.link_to("Stop being root", url('stop_root'), class_='navbar-item root-user')} - % elif request.is_admin: - ${h.link_to("Become root", url('become_root'), class_='navbar-item root-user')} - % endif - % if messaging_enabled: - ${h.link_to("Messages{}".format(" ({})".format(inbox_count) if inbox_count else ''), url('messages.inbox'), class_='navbar-item')} - % endif - ${h.link_to("Change Password", url('change_password'), class_='navbar-item')} - ${h.link_to("Logout", url('logout'), class_='navbar-item')} - </div> - </div> - % else: - ${h.link_to("Login", url('login'), class_='navbar-item')} - % endif - - </div><!-- navbar-end --> + ${self.render_navbar_end()} </div> </nav> @@ -552,6 +525,38 @@ ${tailbone_autocomplete_template()} </%def> +<%def name="render_navbar_end()"> + <div class="navbar-end"> + ${self.render_user_menu()} + </div> +</%def> + +<%def name="render_user_menu()"> + % if request.user: + <div class="navbar-item has-dropdown is-hoverable"> + % if messaging_enabled: + <a class="navbar-link ${'root-user' if request.is_root else ''}">${request.user}${" ({})".format(inbox_count) if inbox_count else ''}</a> + % else: + <a class="navbar-link ${'root-user' if request.is_root else ''}">${request.user}</a> + % endif + <div class="navbar-dropdown"> + % if request.is_root: + ${h.link_to("Stop being root", url('stop_root'), class_='navbar-item root-user')} + % elif request.is_admin: + ${h.link_to("Become root", url('become_root'), class_='navbar-item root-user')} + % endif + % if messaging_enabled: + ${h.link_to("Messages{}".format(" ({})".format(inbox_count) if inbox_count else ''), url('messages.inbox'), class_='navbar-item')} + % endif + ${h.link_to("Change Password", url('change_password'), class_='navbar-item')} + ${h.link_to("Logout", url('logout'), class_='navbar-item')} + </div> + </div> + % else: + ${h.link_to("Login", url('login'), class_='navbar-item')} + % endif +</%def> + <%def name="render_instance_header_buttons()"> ${self.render_crud_header_buttons()} ${self.render_prevnext_header_buttons()} @@ -665,6 +670,7 @@ let WholePage = { template: '#whole-page-template', + computed: {}, methods: { changeContentTitle(newTitle) { From 962d31c4c2d7fc6b2c7c5fd2a0a4ee9e747b7c2b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 14 Feb 2022 19:19:33 -0600 Subject: [PATCH 0630/1681] Add initial support for editing user preferences by default this exposes just one setting which has only one possible value, so not very useful. but can override as needed --- tailbone/templates/configure.mako | 16 ++- tailbone/templates/themes/falafel/base.mako | 1 + tailbone/templates/users/preferences.mako | 55 +++++++ tailbone/templates/users/view.mako | 8 ++ tailbone/views/master.py | 32 +++-- tailbone/views/users.py | 151 ++++++++++++++++++-- 6 files changed, 239 insertions(+), 24 deletions(-) create mode 100644 tailbone/templates/users/preferences.mako diff --git a/tailbone/templates/configure.mako b/tailbone/templates/configure.mako index de2b4e78..f05b24c0 100644 --- a/tailbone/templates/configure.mako +++ b/tailbone/templates/configure.mako @@ -30,14 +30,24 @@ </b-button> </%def> +<%def name="intro_message()"> + <p class="block"> + This page lets you modify the + % if config_preferences is not Undefined and config_preferences: + preferences + % else: + configuration + % endif + for ${config_title}. + </p> +</%def> + <%def name="buttons_row()"> <div class="level"> <div class="level-left"> <div class="level-item"> - <p class="block"> - This page lets you modify the configuration for ${config_title}. - </p> + ${self.intro_message()} </div> <div class="level-item"> diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index 94e20f3e..db669c78 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -549,6 +549,7 @@ ${h.link_to("Messages{}".format(" ({})".format(inbox_count) if inbox_count else ''), url('messages.inbox'), class_='navbar-item')} % endif ${h.link_to("Change Password", url('change_password'), class_='navbar-item')} + ${h.link_to("Edit Preferences", url('my.preferences'), class_='navbar-item')} ${h.link_to("Logout", url('logout'), class_='navbar-item')} </div> </div> diff --git a/tailbone/templates/users/preferences.mako b/tailbone/templates/users/preferences.mako new file mode 100644 index 00000000..a44534dc --- /dev/null +++ b/tailbone/templates/users/preferences.mako @@ -0,0 +1,55 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="title()"> + % if current_user: + Edit Preferences + % else: + ${index_title} » ${instance_title} » Preferences + % endif +</%def> + +<%def name="content_title()">Preferences</%def> + +<%def name="intro_message()"> + <p class="block"> + % if current_user: + This page lets you modify your preferences. + % else: + This page lets you modify the preferences for ${config_title}. + % endif + </p> +</%def> + +<%def name="form_content()"> + + <h3 class="block is-size-3">Display</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field label="Theme Style"> + <b-select name="tailbone.${user.uuid}.buefy_css" + v-model="simpleSettings['tailbone.${user.uuid}.buefy_css']" + @input="settingsNeedSaved = true"> + <option v-for="option in buefyCSSOptions" + :key="option.value" + :value="option.value"> + {{ option.label }} + </option> + </b-select> + + </b-field> + + </div> +</%def> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + ThisPageData.buefyCSSOptions = ${json.dumps(buefy_css_options)|n} + + </script> +</%def> + + +${parent.body()} diff --git a/tailbone/templates/users/view.mako b/tailbone/templates/users/view.mako index 8477ebfa..b34902a1 100644 --- a/tailbone/templates/users/view.mako +++ b/tailbone/templates/users/view.mako @@ -14,4 +14,12 @@ % endif </%def> +<%def name="context_menu_items()"> + ${parent.context_menu_items()} + % if master.has_perm('preferences'): + <li>${h.link_to("Edit User Preferences", action_url('preferences', instance))}</li> + % endif +</%def> + + ${parent.body()} diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 77a844a4..89def384 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -4290,6 +4290,7 @@ class MasterView(View): 'type': bool, 'value': config.getbool('rattail.batch', 'purchase.allow_cases'), + 'save_if_empty': False, } Note that some of the above is optional, in particular it @@ -4316,9 +4317,11 @@ class MasterView(View): return '{}.{}'.format(simple['section'], simple['option']) - def configure_get_context(self): + def configure_get_context(self, simple_settings=None, + input_file_templates=True): context = {} - simple_settings = self.configure_get_simple_settings() + if simple_settings is None: + simple_settings = self.configure_get_simple_settings() if simple_settings: config = self.rattail_config @@ -4342,7 +4345,7 @@ class MasterView(View): context['simple_settings'] = settings # add settings for downloadable input file templates, if any - if self.has_input_file_templates: + if input_file_templates and self.has_input_file_templates: settings = {} file_options = {} file_option_dirs = {} @@ -4359,11 +4362,13 @@ class MasterView(View): return context - def configure_gather_settings(self, data): + def configure_gather_settings(self, data, simple_settings=None, + input_file_templates=True): settings = [] # maybe collect "simple" settings - simple_settings = self.configure_get_simple_settings() + if simple_settings is None: + simple_settings = self.configure_get_simple_settings() if simple_settings: for simple in simple_settings: @@ -4377,11 +4382,14 @@ class MasterView(View): else: value = six.text_type(value) - settings.append({'name': name, - 'value': value}) + # only want to save this setting if we received a + # value, or if empty values are okay to save + if value or simple.get('save_if_empty'): + settings.append({'name': name, + 'value': value}) # maybe also collect input file template settings - if self.has_input_file_templates: + if input_file_templates and self.has_input_file_templates: for template in self.normalize_input_file_templates(): # mode @@ -4401,16 +4409,18 @@ class MasterView(View): return settings - def configure_remove_settings(self): + def configure_remove_settings(self, simple_settings=None, + input_file_templates=True): model = self.model names = [] - simple_settings = self.configure_get_simple_settings() + if simple_settings is None: + simple_settings = self.configure_get_simple_settings() if simple_settings: names.extend([self.configure_get_name_for_simple_setting(simple) for simple in simple_settings]) - if self.has_input_file_templates: + if input_file_templates and self.has_input_file_templates: for template in self.normalize_input_file_templates(): names.extend([ template['setting_mode'], diff --git a/tailbone/views/users.py b/tailbone/views/users.py index 52064346..ecff3bb9 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -26,12 +26,10 @@ User Views from __future__ import unicode_literals, absolute_import -import copy - import six from sqlalchemy import orm -from rattail.db import model +from rattail.db.model import User, UserEvent from rattail.db.auth import (administrator_role, guest_role, authenticated_role, set_user_password) @@ -40,8 +38,7 @@ from deform import widget as dfwidget from webhelpers2.html import HTML, tags from tailbone import forms -from tailbone.db import Session -from tailbone.views import MasterView +from tailbone.views import MasterView, View from tailbone.views.principal import PrincipalMasterView, PermissionsRenderer @@ -49,9 +46,9 @@ class UserView(PrincipalMasterView): """ Master view for the User model. """ - model_class = model.User + model_class = User has_rows = True - model_row_class = model.UserEvent + model_row_class = UserEvent has_versions = True touchable = True @@ -99,6 +96,7 @@ class UserView(PrincipalMasterView): def query(self, session): query = super(UserView, self).query(session) + model = self.model # bring in the related Person(s) query = query.outerjoin(model.Person)\ @@ -108,6 +106,7 @@ class UserView(PrincipalMasterView): def configure_grid(self, g): super(UserView, self).configure_grid(g) + model = self.model del g.filters['salt'] g.filters['username'].default_active = True @@ -160,6 +159,7 @@ class UserView(PrincipalMasterView): return not self.user_is_protected(user) def unique_username(self, node, value): + model = self.model query = self.Session.query(model.User)\ .filter(model.User.username == value) if self.editing: @@ -181,6 +181,7 @@ class UserView(PrincipalMasterView): def configure_form(self, f): super(UserView, self).configure_form(f) + model = self.model user = f.model_instance # username @@ -265,6 +266,7 @@ class UserView(PrincipalMasterView): f.remove('set_password') def get_possible_roles(self): + model = self.model # some roles should never have users "belong" to them excluded = [ @@ -281,6 +283,7 @@ class UserView(PrincipalMasterView): .order_by(model.Role.name) def objectify(self, form, data=None): + model = self.model # create/update user as per normal if data is None: @@ -328,6 +331,7 @@ class UserView(PrincipalMasterView): if 'roles' not in data: return + model = self.model old_roles = set([r.uuid for r in user.roles]) new_roles = data['roles'] admin = administrator_role(self.Session()) @@ -389,6 +393,7 @@ class UserView(PrincipalMasterView): return HTML.tag('ul', c=items) def get_row_data(self, user): + model = self.model return self.Session.query(model.UserEvent)\ .filter(model.UserEvent.user == user) @@ -402,6 +407,7 @@ class UserView(PrincipalMasterView): g.main_actions = [] def get_version_child_classes(self): + model = self.model return [ (model.UserRole, 'user_uuid'), ] @@ -409,6 +415,7 @@ class UserView(PrincipalMasterView): def find_principals_with_permission(self, session, permission): app = self.get_rattail_app() auth = app.get_auth_handler() + model = self.model # TODO: this should search Permission table instead, and work backward to User? all_users = session.query(model.User)\ @@ -448,6 +455,105 @@ class UserView(PrincipalMasterView): assert not removing._roles self.Session.delete(removing) + def preferences(self, user=None): + """ + View to modify preferences for a particular user. + """ + current_user = True + if not user: + current_user = False + user = self.get_instance() + + # TODO: this is of course largely copy/pasted from the + # MasterView.configure() method..should refactor? + if self.request.method == 'POST': + if self.request.POST.get('remove_settings'): + self.preferences_remove_settings(user) + self.request.session.flash("Settings have been removed.") + return self.redirect(self.request.current_route_url()) + else: + data = self.request.POST + + # then gather/save settings + settings = self.preferences_gather_settings(data, user) + self.preferences_remove_settings(user) + self.configure_save_settings(settings) + self.request.session.flash("Settings have been saved.") + return self.redirect(self.request.current_route_url()) + + context = self.preferences_get_context(user, current_user) + return self.render_to_response('preferences', context) + + def my_preferences(self): + """ + View to modify preferences for the current user. + """ + user = self.request.user + if not user: + raise self.forbidden() + return self.preferences(user=user) + + def preferences_get_context(self, user, current_user): + simple_settings = self.preferences_get_simple_settings(user) + context = self.configure_get_context(simple_settings=simple_settings, + input_file_templates=False) + + instance_title = self.get_instance_title(user) + context.update({ + 'user': user, + 'instance': user, + 'instance_title': instance_title, + 'instance_url': self.get_action_url('view', user), + 'config_title': instance_title, + 'config_preferences': True, + 'current_user': current_user, + }) + + if current_user: + context.update({ + 'index_url': None, + 'index_title': instance_title, + }) + + # theme style options + options = [{'value': None, 'label': "default"}] + styles = self.rattail_config.getlist('tailbone', 'themes.styles', + default=[]) + for name in styles: + css = self.rattail_config.get('tailbone', + 'themes.style.{}'.format(name)) + if css: + options.append({'value': css, 'label': name}) + context['buefy_css_options'] = options + + return context + + def preferences_get_simple_settings(self, user): + """ + This method is conceptually the same as for + :meth:`~tailbone.views.master.MasterView.configure_get_simple_settings()`. + See its docs for more info. + + The only difference here is that we are given a user account, + so the settings involved should only pertain to that user. + """ + return [ + + # display + {'section': 'tailbone.{}'.format(user.uuid), + 'option': 'buefy_css'}, + ] + + def preferences_gather_settings(self, data, user): + simple_settings = self.preferences_get_simple_settings(user) + return self.configure_gather_settings( + data, simple_settings=simple_settings, input_file_templates=False) + + def preferences_remove_settings(self, user): + simple_settings = self.preferences_get_simple_settings(user) + self.configure_remove_settings(simple_settings=simple_settings, + input_file_templates=False) + @classmethod def defaults(cls, config): cls._user_defaults(config) @@ -459,7 +565,9 @@ class UserView(PrincipalMasterView): """ Provide extra default configuration for the User master view. """ + 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() # view/edit roles @@ -468,6 +576,23 @@ class UserView(PrincipalMasterView): config.add_tailbone_permission(permission_prefix, '{}.edit_roles'.format(permission_prefix), "Edit the Roles to which a {} belongs".format(model_title)) + # edit preferences for any user + config.add_tailbone_permission(permission_prefix, + '{}.preferences'.format(permission_prefix), + "Edit preferences for any {}".format(model_title)) + config.add_route('{}.preferences'.format(route_prefix), + '{}/preferences'.format(instance_url_prefix)) + config.add_view(cls, attr='preferences', + route_name='{}.preferences'.format(route_prefix), + permission='{}.preferences'.format(permission_prefix)) + + # edit "my" preferences (for current user) + config.add_route('my.preferences', + '/my/preferences') + config.add_view(cls, attr='my_preferences', + route_name='my.preferences') + + # TODO: deprecate / remove this UsersView = UserView @@ -476,7 +601,7 @@ class UserEventView(MasterView): """ Master view for all user events """ - model_class = model.UserEvent + model_class = UserEvent url_prefix = '/user-events' viewable = False creatable = False @@ -492,10 +617,12 @@ class UserEventView(MasterView): def get_data(self, session=None): query = super(UserEventView, self).get_data(session=session) + model = self.model return query.join(model.User) def configure_grid(self, g): super(UserEventView, self).configure_grid(g) + model = self.model g.set_joiner('person', lambda q: q.outerjoin(model.Person)) g.set_sorter('user', model.User.username) g.set_sorter('person', model.Person.display_name) @@ -522,8 +649,12 @@ UserEventsView = UserEventView def defaults(config, **kwargs): base = globals() - kwargs.get('UserView', base['UserView']).defaults(config) - kwargs.get('UserEventView', base['UserEventView']).defaults(config) + + UserView = kwargs.get('UserView', base['UserView']) + UserView.defaults(config) + + UserEventView = kwargs.get('UserEventView', base['UserEventView']) + UserEventView.defaults(config) def includeme(config): From 47bfcc23cb1699ed396b1466d5b580e14ae2f6c6 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 15 Feb 2022 10:15:08 -0600 Subject: [PATCH 0631/1681] Add FormPosterMixin to WholePage class --- tailbone/templates/themes/falafel/base.mako | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index db669c78..4378c4cf 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -671,6 +671,7 @@ let WholePage = { template: '#whole-page-template', + mixins: [FormPosterMixin], computed: {}, methods: { From 8744ee74b3b5934838deb595db2a5d78036e7eb6 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 15 Feb 2022 17:34:28 -0600 Subject: [PATCH 0632/1681] 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 186dcd7f..3fabd6b7 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.8.208 (2022-02-15) +-------------------- + +* Allow override of navbar-end element in falafel theme header. + +* Add initial support for editing user preferences. + +* Add FormPosterMixin to WholePage class. + + 0.8.207 (2022-02-13) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 0b0bbd54..1528a0ff 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.207' +__version__ = '0.8.208' From 778578292f73cf850a4679798dcb75caf239e897 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 16 Feb 2022 16:16:40 -0600 Subject: [PATCH 0633/1681] Fix progress bar when running problem report --- tailbone/views/reports.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index 8985e204..12957934 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -644,7 +644,7 @@ class ProblemReportView(MasterView): def execute_instance(self, report_info, user, progress=None, **kwargs): report = report_info['_report'] - problems = self.handler.run_problem_report(report) + problems = self.handler.run_problem_report(report, progress=progress) return "Report found {} problems".format(len(problems)) From b6bd095d8e50f4d256ac5dd23085b811b8391437 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 16 Feb 2022 16:33:49 -0600 Subject: [PATCH 0634/1681] 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 3fabd6b7..e2173067 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.209 (2022-02-16) +-------------------- + +* Fix progress bar when running problem report. + + 0.8.208 (2022-02-15) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 1528a0ff..734c777e 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.208' +__version__ = '0.8.209' From 57e22c9ff5eb121c40cc78dc72b8fad46d0fac41 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 18 Feb 2022 15:39:12 -0600 Subject: [PATCH 0635/1681] Only show DB picker for permissioned users --- tailbone/views/common.py | 6 ++++-- tailbone/views/master.py | 25 ++++++++++++++----------- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/tailbone/views/common.py b/tailbone/views/common.py index 37b2c4a4..c3e40547 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/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. # @@ -222,7 +222,9 @@ class CommonView(View): config.add_tailbone_permission('common', 'common.change_db_engine', "Change which Database Engine is active (for user)") config.add_route('change_db_engine', '/change-db-engine', request_method='POST') - config.add_view(cls, attr='change_db_engine', route_name='change_db_engine') + config.add_view(cls, attr='change_db_engine', + route_name='change_db_engine', + permission='common.change_db_engine') # change theme config.add_tailbone_permission('common', 'common.change_app_theme', diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 89def384..6c174f06 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2276,18 +2276,21 @@ class MasterView(View): kwargs['expose_db_picker'] = False if self.supports_multiple_engines: - # view declares support for multiple engines, but we only want to - # show the picker if we have more than one engine configured - engines = self.get_db_engines() - if len(engines) > 1: + # DB picker is only shown for permissioned users + if self.request.has_perm('common.change_db_engine'): - # user session determines "current" db engine *of this type* - # (note that many master views may declare the same type, and - # would therefore share the "current" engine) - selected = self.get_current_engine_dbkey() - kwargs['expose_db_picker'] = True - kwargs['db_picker_options'] = [tags.Option(k) for k in engines] - kwargs['db_picker_selected'] = selected + # view declares support for multiple engines, but we only want to + # show the picker if we have more than one engine configured + engines = self.get_db_engines() + if len(engines) > 1: + + # user session determines "current" db engine *of this type* + # (note that many master views may declare the same type, and + # would therefore share the "current" engine) + selected = self.get_current_engine_dbkey() + kwargs['expose_db_picker'] = True + kwargs['db_picker_options'] = [tags.Option(k) for k in engines] + kwargs['db_picker_selected'] = selected # add info for downloadable input file templates, if any if self.has_input_file_templates: From e990be3570235b120c3a2993107b2c3ded6b1869 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 19 Feb 2022 14:39:40 -0600 Subject: [PATCH 0636/1681] Expose some new trainwreck fields --- tailbone/views/trainwreck/base.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tailbone/views/trainwreck/base.py b/tailbone/views/trainwreck/base.py index 20e7701d..4f6c9e15 100644 --- a/tailbone/views/trainwreck/base.py +++ b/tailbone/views/trainwreck/base.py @@ -94,6 +94,9 @@ class TransactionView(MasterView): 'tax', 'cashback', 'total', + 'patronage', + 'equity_current', + 'self_updated', 'void', ] @@ -156,6 +159,7 @@ class TransactionView(MasterView): g.set_enum('system', self.enum.TRAINWRECK_SYSTEM) g.set_type('total', 'currency') + g.set_type('patronage', 'currency') g.set_label('terminal_id', "Terminal") g.set_label('receipt_number', "Receipt No.") g.set_label('customer_id', "Customer ID") @@ -184,6 +188,7 @@ class TransactionView(MasterView): f.set_type('tax', 'currency') f.set_type('cashback', 'currency') f.set_type('total', 'currency') + f.set_type('patronage', 'currency') # label overrides f.set_label('system_id', "System ID") From 7f06b3e53bcc07c3c3b9c709bc5979b4ea6045e3 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 19 Feb 2022 17:31:14 -0600 Subject: [PATCH 0637/1681] Expose per-item discounts for Trainwreck --- .../trainwreck/transactions/view_row.mako | 16 +++++++ tailbone/views/trainwreck/base.py | 46 +++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 tailbone/templates/trainwreck/transactions/view_row.mako diff --git a/tailbone/templates/trainwreck/transactions/view_row.mako b/tailbone/templates/trainwreck/transactions/view_row.mako new file mode 100644 index 00000000..9abcb8ba --- /dev/null +++ b/tailbone/templates/trainwreck/transactions/view_row.mako @@ -0,0 +1,16 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view_row.mako" /> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + % if discounts_data is not Undefined: + ${form.component_studly}Data.discountsData = ${json.dumps(discounts_data)|n} + % endif + + </script> +</%def> + + +${parent.body()} diff --git a/tailbone/views/trainwreck/base.py b/tailbone/views/trainwreck/base.py index 4f6c9e15..6fac7605 100644 --- a/tailbone/views/trainwreck/base.py +++ b/tailbone/views/trainwreck/base.py @@ -30,6 +30,8 @@ import six from rattail.time import localtime +from webhelpers2.html import HTML + from tailbone.db import Session, TrainwreckSession, ExtraTrainwreckSessions from tailbone.views import MasterView @@ -136,9 +138,13 @@ class TransactionView(MasterView): 'description', 'unit_quantity', 'subtotal', + 'discounts', + 'discounted_subtotal', 'tax', 'total', 'exempt_from_gross_sales', + 'net_sales', + 'gross_sales', 'void', ] @@ -231,6 +237,46 @@ class TransactionView(MasterView): f.set_type('tax', 'currency') f.set_type('total', 'currency') + # discounts + f.set_renderer('discounts', self.render_discounts) + + def render_discounts(self, item, field): + if not item.discounts: + return + + route_prefix = self.get_route_prefix() + factory = self.get_grid_factory() + use_buefy = self.get_use_buefy() + + g = factory( + key='{}.discounts'.format(route_prefix), + data=[] if use_buefy else item.discounts, + columns=['description', 'amount'], + request=self.request) + + if use_buefy: + return HTML.literal( + g.render_buefy_table_element(data_prop='discountsData')) + else: + g.set_type('amount', 'currency') + return HTML.literal(g.render_grid()) + + def template_kwargs_view_row(self, **kwargs): + use_buefy = self.get_use_buefy() + if use_buefy: + + app = self.get_rattail_app() + item = kwargs['instance'] + discounts_data = [] + for discount in item.discounts: + discounts_data.append({ + 'description': discount.description, + 'amount': app.render_currency(discount.amount), + }) + kwargs['discounts_data'] = discounts_data + + return kwargs + def rollover(self): """ View for performing yearly rollover functions. From 66fd2ff5e674b0cec9251fd58283faa8f758e753 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 19 Feb 2022 21:00:54 -0600 Subject: [PATCH 0638/1681] Show SRP as currency for vendor catalog batch --- tailbone/views/batch/vendorcatalog.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/batch/vendorcatalog.py b/tailbone/views/batch/vendorcatalog.py index 8173cac5..86d8404b 100644 --- a/tailbone/views/batch/vendorcatalog.py +++ b/tailbone/views/batch/vendorcatalog.py @@ -321,6 +321,7 @@ class VendorCatalogView(FileBatchMasterView): f.set_renderer('product', self.render_product) f.set_type('upc', 'gpc') f.set_type('discount_percent', 'percent') + f.set_type('suggested_retail', 'currency') def template_kwargs_view_row(self, **kwargs): row = kwargs['instance'] From ceceb3f03084d8bcd7bcb77b171dd23aa042ead4 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 20 Feb 2022 15:25:17 -0600 Subject: [PATCH 0639/1681] 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 e2173067..3232924d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.8.210 (2022-02-20) +-------------------- + +* Only show DB picker for permissioned users. + +* Expose some new trainwreck fields; per-item discounts. + +* Show SRP as currency for vendor catalog batch. + + 0.8.209 (2022-02-16) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 734c777e..aec2e7bb 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.209' +__version__ = '0.8.210' From 5b697cdf26176ace21f6d5a9061cf7c4db20b4dd Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 20 Feb 2022 17:06:51 -0600 Subject: [PATCH 0640/1681] Add view template stub for trainwreck transaction --- tailbone/templates/trainwreck/transactions/view.mako | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 tailbone/templates/trainwreck/transactions/view.mako diff --git a/tailbone/templates/trainwreck/transactions/view.mako b/tailbone/templates/trainwreck/transactions/view.mako new file mode 100644 index 00000000..601fa053 --- /dev/null +++ b/tailbone/templates/trainwreck/transactions/view.mako @@ -0,0 +1,6 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> + +## nb. this exists just so everyone can inherit from it + +${parent.body()} From 4d404cb20ba33586e1a1e0ad80245c6e9ec25ba7 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 20 Feb 2022 19:40:32 -0600 Subject: [PATCH 0641/1681] Add auto-filter hyperlinks for batch row status breakdown --- tailbone/grids/core.py | 13 +++++++++- tailbone/templates/batch/view.mako | 26 ++++++++++++++++---- tailbone/templates/grids/b-table.mako | 7 ++++++ tailbone/templates/grids/buefy.mako | 35 +++++++++++++++++++++++++++ tailbone/templates/master/view.mako | 2 +- tailbone/views/batch/core.py | 27 +++++++++++++-------- 6 files changed, 93 insertions(+), 17 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index cb71e144..bc7c6684 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -79,7 +79,7 @@ class Grid(object): sortable=False, sorters={}, default_sortkey=None, default_sortdir='asc', pageable=False, default_pagesize=20, default_page=1, checkboxes=False, checked=None, check_handler=None, check_all_handler=None, - clicking_row_checks_box=False, + clicking_row_checks_box=False, click_handlers=None, main_actions=[], more_actions=[], delete_speedbump=False, ajax_data_url=None, component='tailbone-grid', **kwargs): @@ -136,6 +136,8 @@ class Grid(object): self.check_all_handler = check_all_handler self.clicking_row_checks_box = clicking_row_checks_box + self.click_handlers = click_handlers or {} + self.main_actions = main_actions or [] self.more_actions = more_actions or [] self.delete_speedbump = delete_speedbump @@ -261,6 +263,15 @@ class Grid(object): if self.linked_columns and key in self.linked_columns: self.linked_columns.remove(key) + def set_click_handler(self, key, handler): + if handler: + self.click_handlers[key] = handler + else: + self.click_handlers.pop(key, None) + + def has_click_handler(self, key): + return key in self.click_handlers + def set_renderer(self, key, renderer): self.renderers[key] = renderer diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako index 1b7787bb..1faa0f86 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -154,13 +154,18 @@ </%def> <%def name="render_status_breakdown()"> - % if status_breakdown is not Undefined and status_breakdown is not None: + % if use_buefy: <div class="object-helper"> <h3>Row Status Breakdown</h3> <div class="object-helper-content"> - % if use_buefy: - ${status_breakdown_grid.render_buefy_table_element(data_prop='statusBreakdownData', empty_labels=True)|n} - % elif status_breakdown: + ${status_breakdown_grid|n} + </div> + </div> + % elif status_breakdown is not Undefined and status_breakdown is not None: + <div class="object-helper"> + <h3>Row Status Breakdown</h3> + <div class="object-helper-content"> + % if status_breakdown: <div class="grid full"> <table> % for i, (status, count) in enumerate(status_breakdown): @@ -407,7 +412,18 @@ ${parent.modify_this_page_vars()} <script type="text/javascript"> - ThisPageData.statusBreakdownData = ${json.dumps(status_breakdown_grid.get_buefy_data()['data'])|n} + ThisPageData.statusBreakdownData = ${json.dumps(status_breakdown_data)|n} + + ThisPage.methods.autoFilterStatus = function(row) { + this.$refs.rowGrid.setFilters([ + {key: 'status_code', + verb: 'equal', + value: row.code}, + ]) + document.getElementById('rowGrid').scrollIntoView({ + behavior: 'smooth', + }) + } % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'): diff --git a/tailbone/templates/grids/b-table.mako b/tailbone/templates/grids/b-table.mako index 7ff33e73..26e86359 100644 --- a/tailbone/templates/grids/b-table.mako +++ b/tailbone/templates/grids/b-table.mako @@ -40,6 +40,13 @@ % endif > </a> + % elif grid.has_click_handler(column['field']): + <span> + <a href="#" + @click.prevent="${grid.click_handlers[column['field']]}" + v-html="props.row.${column['field']}"> + </a> + </span> % else: <span v-html="props.row.${column['field']}"></span> % endif diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako index 70ce04f3..5ef15a17 100644 --- a/tailbone/templates/grids/buefy.mako +++ b/tailbone/templates/grids/buefy.mako @@ -434,6 +434,41 @@ this.applyFilters() }, + // explicitly set filters for the grid, to the given set. + // this totally overrides whatever might be current. the + // new filter set should look like: + // + // [ + // {key: 'status_code', + // verb: 'equal', + // value: 1}, + // {key: 'description', + // verb: 'contains', + // value: 'whatever'}, + // ] + // + setFilters(newFilters) { + for (let key in this.filters) { + let filter = this.filters[key] + let active = false + for (let newFilter of newFilters) { + if (newFilter.key == key) { + active = true + filter.active = true + filter.visible = true + filter.verb = newFilter.verb + filter.value = newFilter.value + break + } + } + if (!active) { + filter.active = false + filter.visible = false + } + } + this.applyFilters() + }, + saveDefaults() { // apply current filters as normal, but add special directive diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako index f361ad04..b311a14a 100644 --- a/tailbone/templates/master/view.mako +++ b/tailbone/templates/master/view.mako @@ -104,7 +104,7 @@ % if master.has_rows: % if use_buefy: <br /> - <tailbone-grid></tailbone-grid> + <tailbone-grid ref="rowGrid" id="rowGrid"></tailbone-grid> % else: ${rows_grid|n} % endif diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 88921f13..65ccfe3d 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -188,13 +188,23 @@ class BatchMasterView(MasterView): else: kwargs['why_not_execute'] = self.handler.why_not_execute(batch) - kwargs['status_breakdown'] = self.make_status_breakdown(batch) + breakdown = self.make_status_breakdown(batch) + if use_buefy: - data = [{'title': title, 'count': count} - for title, count in kwargs['status_breakdown']] - Grid = self.get_grid_factory() - kwargs['status_breakdown_grid'] = Grid('batch_row_status_breakdown', - data, ['title', 'count']) + factory = self.get_grid_factory() + g = factory('batch_row_status_breakdown', [], + columns=['title', 'count']) + g.set_click_handler('title', "autoFilterStatus(props.row)") + kwargs['status_breakdown_data'] = breakdown + kwargs['status_breakdown_grid'] = HTML.literal( + g.render_buefy_table_element(data_prop='statusBreakdownData', + empty_labels=True)) + + else: + kwargs['status_breakdown'] = [ + (status['title'], status['count']) + for status in breakdown] + return kwargs def make_upload_worksheet_form(self, batch): @@ -281,10 +291,7 @@ class BatchMasterView(MasterView): 'count': 0, } breakdown[row.status_code]['count'] += 1 - breakdown = [ - (status['title'], status['count']) - for code, status in six.iteritems(breakdown)] - return breakdown + return list(breakdown.values()) def allow_worksheet(self, batch): return not batch.executed and not batch.complete From 8ae1b87a1efc77f555f4ec884d5a1f716225ee86 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 20 Feb 2022 19:52:24 -0600 Subject: [PATCH 0642/1681] Auto-filter hyperlinks for PO vs. invoice breakdown in Receiving --- tailbone/templates/batch/view.mako | 2 +- tailbone/templates/receiving/view.mako | 49 ++++++++++++++++++++++++-- tailbone/views/purchasing/receiving.py | 22 +++++++++--- 3 files changed, 64 insertions(+), 9 deletions(-) diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako index 1faa0f86..919924f0 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -158,7 +158,7 @@ <div class="object-helper"> <h3>Row Status Breakdown</h3> <div class="object-helper-content"> - ${status_breakdown_grid|n} + ${status_breakdown_grid} </div> </div> % elif status_breakdown is not Undefined and status_breakdown is not None: diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako index 4f04cfa2..eb1e476e 100644 --- a/tailbone/templates/receiving/view.mako +++ b/tailbone/templates/receiving/view.mako @@ -288,7 +288,7 @@ <div class="object-helper"> <h3>PO vs. Invoice</h3> <div class="object-helper-content"> - ${po_vs_invoice_breakdown_grid.render_buefy_table_element(data_prop='poVsInvoiceBreakdownData', empty_labels=True)|n} + ${po_vs_invoice_breakdown_grid} </div> </div> % endif @@ -371,8 +371,51 @@ ThisPageData.autoReceiveShowDialog = false ThisPageData.autoReceiveSubmitting = false - % if po_vs_invoice_breakdown_grid is not Undefined: - ThisPageData.poVsInvoiceBreakdownData = ${json.dumps(po_vs_invoice_breakdown_grid.get_buefy_data()['data'])|n} + % if po_vs_invoice_breakdown_data is not Undefined: + ThisPageData.poVsInvoiceBreakdownData = ${json.dumps(po_vs_invoice_breakdown_data)|n} + + ThisPage.methods.autoFilterPoVsInvoice = function(row) { + let filters = [] + if (row.key == 'both') { + filters = [ + {key: 'po_line_number', + verb: 'is_not_null'}, + {key: 'invoice_line_number', + verb: 'is_not_null'}, + ] + } else if (row.key == 'po_not_invoice') { + filters = [ + {key: 'po_line_number', + verb: 'is_not_null'}, + {key: 'invoice_line_number', + verb: 'is_null'}, + ] + } else if (row.key == 'invoice_not_po') { + filters = [ + {key: 'po_line_number', + verb: 'is_null'}, + {key: 'invoice_line_number', + verb: 'is_not_null'}, + ] + } else if (row.key == 'neither') { + filters = [ + {key: 'po_line_number', + verb: 'is_null'}, + {key: 'invoice_line_number', + verb: 'is_null'}, + ] + } + + if (!filters.length) { + return + } + + this.$refs.rowGrid.setFilters(filters) + document.getElementById('rowGrid').scrollIntoView({ + behavior: 'smooth', + }) + } + % endif </script> diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 12979d0b..e8123406 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -674,6 +674,7 @@ class ReceivingBatchView(PurchasingBatchView): for key, label in labels.items(): if key in grouped: breakdown.append({ + 'key': key, 'title': label, 'count': len(grouped[key]), }) @@ -683,15 +684,26 @@ class ReceivingBatchView(PurchasingBatchView): def template_kwargs_view(self, **kwargs): kwargs = super(ReceivingBatchView, self).template_kwargs_view(**kwargs) batch = kwargs['instance'] + use_buefy = self.get_use_buefy() if self.handler.has_purchase_order(batch) and self.handler.has_invoice_file(batch): breakdown = self.make_po_vs_invoice_breakdown(batch) - factory = self.get_grid_factory() - kwargs['po_vs_invoice_breakdown_grid'] = factory( - 'batch_po_vs_invoice_breakdown', - data=breakdown, - columns=['title', 'count']) + if use_buefy: + + g = factory('batch_po_vs_invoice_breakdown', [], + columns=['title', 'count']) + g.set_click_handler('title', "autoFilterPoVsInvoice(props.row)") + kwargs['po_vs_invoice_breakdown_data'] = breakdown + kwargs['po_vs_invoice_breakdown_grid'] = HTML.literal( + g.render_buefy_table_element(data_prop='poVsInvoiceBreakdownData', + empty_labels=True)) + + else: + kwargs['po_vs_invoice_breakdown_grid'] = factory( + 'batch_po_vs_invoice_breakdown', + data=breakdown, + columns=['title', 'count']) return kwargs From 0c5992ad756abf0407fb15e126d58d0b80900f51 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 22 Feb 2022 20:39:06 -0600 Subject: [PATCH 0643/1681] Add grid hyperlinks for trainwreck transaction line items --- tailbone/views/trainwreck/base.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tailbone/views/trainwreck/base.py b/tailbone/views/trainwreck/base.py index 6fac7605..167848cd 100644 --- a/tailbone/views/trainwreck/base.py +++ b/tailbone/views/trainwreck/base.py @@ -220,6 +220,9 @@ class TransactionView(MasterView): g.set_type('tax', 'currency') g.set_type('total', 'currency') + g.set_link('item_scancode') + g.set_link('description') + def row_grid_extra_class(self, row, i): if row.void: return 'warning' From 3553f23eab25a5999082cec80dc09693c7cb776f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 23 Feb 2022 00:26:14 -0600 Subject: [PATCH 0644/1681] Use dict instead of custom object to represent menus as prep for editing menu config directly in app --- tailbone/menus.py | 86 ++++++++++----------- tailbone/templates/menu.mako | 12 +-- tailbone/templates/themes/falafel/base.mako | 28 +++---- 3 files changed, 63 insertions(+), 63 deletions(-) diff --git a/tailbone/menus.py b/tailbone/menus.py index 2402e768..e2c025c1 100644 --- a/tailbone/menus.py +++ b/tailbone/menus.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,38 +26,11 @@ App Menus from __future__ import unicode_literals, absolute_import -from rattail.core import Object +import re + from rattail.util import import_module_path -class MenuGroup(Object): - title = None - items = None - is_menu = True - is_link = False - - -class MenuItem(Object): - title = None - url = None - target = None - is_link = True - is_menu = False - is_sep = False - - -class MenuItemMenu(Object): - title = None - items = None - is_menu = True - is_sep = False - - -class MenuSeparator(object): - is_menu = False - is_sep = True - - def make_simple_menus(request): """ Build the main menu list for the app. @@ -93,30 +66,48 @@ def make_simple_menus(request): for subitem in item['items']: if subitem['allowed']: submenu_items.append(make_menu_entry(subitem)) - menu_items.append(MenuItemMenu( - title=item['title'], - items=submenu_items)) + menu_items.append({ + 'type': 'submenu', + 'title': item['title'], + 'items': submenu_items, + 'is_menu': True, + 'is_sep': False, + }) elif item.get('type') == 'sep': # we only want to add a sep, *if* we already have some # menu items (i.e. there is something to separate) # *and* the last menu item is not a sep (avoid doubles) - if menu_items and not menu_items[-1].is_sep: + if menu_items and not menu_items[-1]['is_sep']: menu_items.append(make_menu_entry(item)) else: # standard menu item menu_items.append(make_menu_entry(item)) # remove final separator if present - if menu_items and menu_items[-1].is_sep: + if menu_items and menu_items[-1]['is_sep']: menu_items.pop() # only add if we wound up with something assert menu_items if menu_items: - final_menus.append(MenuGroup( - title=topitem['title'], - items=menu_items)) + group = { + 'type': 'menu', + 'key': topitem.get('key'), + 'title': topitem['title'], + 'items': menu_items, + 'is_menu': True, + 'is_link': False, + } + + # topitem w/ no key likely means it did not come + # from config but rather explicit definition in + # code. so we are free to "invent" a (safe) key + # for it, since that is only for editing config + if not group['key']: + group['key'] = re.sub(r'\W', '', topitem['title'].lower()) + + final_menus.append(group) return final_menus @@ -128,13 +119,22 @@ def make_menu_entry(item): """ # separator if item.get('type') == 'sep': - return MenuSeparator() + return { + 'type': 'sep', + 'is_menu': False, + 'is_sep': True, + } # standard menu item - return MenuItem( - title=item['title'], - url=item['url'], - target=item.get('target')) + return { + 'type': 'item', + 'title': item['title'], + 'url': item['url'], + 'target': item.get('target'), + 'is_link': True, + 'is_menu': False, + 'is_sep': False, + } def is_allowed(request, item): diff --git a/tailbone/templates/menu.mako b/tailbone/templates/menu.mako index 7549e763..65acd0dd 100644 --- a/tailbone/templates/menu.mako +++ b/tailbone/templates/menu.mako @@ -4,16 +4,16 @@ % for topitem in menus: <li> - % if topitem.is_link: - ${h.link_to(topitem.title, topitem.url, target=topitem.target)} + % if topitem['is_link']: + ${h.link_to(topitem['title'], topitem['url'], target=topitem['target'])} % else: - <a>${topitem.title}</a> + <a>${topitem['title']}</a> <ul> - % for subitem in topitem.items: - % if subitem.is_sep: + % for subitem in topitem['items']: + % if subitem['is_sep']: <li>-</li> % else: - <li>${h.link_to(subitem.title, subitem.url, target=subitem.target)}</li> + <li>${h.link_to(subitem['title'], subitem['url'], target=subitem['target'])}</li> % endif % endfor </ul> diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index 4378c4cf..0bc0aca8 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -200,33 +200,33 @@ <div class="navbar-start"> % for topitem in menus: - % if topitem.is_link: - ${h.link_to(topitem.title, topitem.url, target=topitem.target, class_='navbar-item')} + % if topitem['is_link']: + ${h.link_to(topitem['title'], topitem['url'], target=topitem['target'], class_='navbar-item')} % else: <div class="navbar-item has-dropdown is-hoverable"> - <a class="navbar-link">${topitem.title}</a> + <a class="navbar-link">${topitem['title']}</a> <div class="navbar-dropdown"> - % for item in topitem.items: - % if item.is_menu: + % for item in topitem['items']: + % if item['is_menu']: <% item_hash = id(item) %> <% toggle = 'menu_{}_shown'.format(item_hash) %> <div> <a class="navbar-link" @click.prevent="toggleNestedMenu('${item_hash}')"> - ${item.title} + ${item['title']} </a> </div> - % for subitem in item.items: - % if subitem.is_sep: + % for subitem in item['items']: + % if subitem['is_sep']: <hr class="navbar-divider" v-show="${toggle}"> % else: - ${h.link_to("{}".format(subitem.title), subitem.url, class_='navbar-item nested', target=subitem.target, **{'v-show': toggle})} + ${h.link_to("{}".format(subitem['title']), subitem['url'], class_='navbar-item nested', target=subitem['target'], **{'v-show': toggle})} % endif % endfor % else: - % if item.is_sep: + % if item['is_sep']: <hr class="navbar-divider"> % else: - ${h.link_to(item.title, item.url, class_='navbar-item', target=item.target)} + ${h.link_to(item['title'], item['url'], class_='navbar-item', target=item['target'])} % endif % endif % endfor @@ -705,9 +705,9 @@ ## declare nested menu visibility toggle flags % for topitem in menus: - % if topitem.is_menu: - % for item in topitem.items: - % if item.is_menu: + % if topitem['is_menu']: + % for item in topitem['items']: + % if item['is_menu']: WholePageData.menu_${id(item)}_shown = false % endif % endfor From 2290d9f9905ba1fb7986a703663e380b537aacdf Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 24 Feb 2022 10:39:11 -0600 Subject: [PATCH 0645/1681] Expose "discount type" for Trainwrewck line items --- tailbone/views/trainwreck/base.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tailbone/views/trainwreck/base.py b/tailbone/views/trainwreck/base.py index 167848cd..081e7119 100644 --- a/tailbone/views/trainwreck/base.py +++ b/tailbone/views/trainwreck/base.py @@ -254,7 +254,8 @@ class TransactionView(MasterView): g = factory( key='{}.discounts'.format(route_prefix), data=[] if use_buefy else item.discounts, - columns=['description', 'amount'], + columns=['discount_type', 'description', 'amount'], + labels={'discount_type': "Type"}, request=self.request) if use_buefy: @@ -273,6 +274,7 @@ class TransactionView(MasterView): discounts_data = [] for discount in item.discounts: discounts_data.append({ + 'discount_type': discount.discount_type, 'description': discount.description, 'amount': app.render_currency(discount.amount), }) From 587a4daf7a55f619d0524fad316f5d511bdb4cd2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 25 Feb 2022 14:30:02 -0600 Subject: [PATCH 0646/1681] 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 3232924d..df13fb49 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,22 @@ CHANGELOG ========= +0.8.211 (2022-02-25) +-------------------- + +* Add view template stub for trainwreck transaction. + +* Add auto-filter hyperlinks for batch row status breakdown. + +* Auto-filter hyperlinks for PO vs. invoice breakdown in Receiving. + +* Add grid hyperlinks for trainwreck transaction line items. + +* Use dict instead of custom object to represent menus. + +* Expose "discount type" for Trainwreck line items. + + 0.8.210 (2022-02-20) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index aec2e7bb..44794282 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.210' +__version__ = '0.8.211' From 74fecf553eb800347beaf655ec6061e76919ce62 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 26 Feb 2022 17:08:23 -0600 Subject: [PATCH 0647/1681] Add page/way to configure main menus just the basics so far, index page routes and separators should be supported but nothing else. also "menus from config" is all or nothing, no way to mix config + code at this point --- tailbone/app.py | 20 +- tailbone/menus.py | 234 ++++++++++- tailbone/templates/configure-menus.mako | 424 ++++++++++++++++++++ tailbone/templates/configure.mako | 10 +- tailbone/templates/themes/falafel/base.mako | 20 +- tailbone/views/master.py | 9 +- tailbone/views/menus.py | 191 +++++++++ 7 files changed, 883 insertions(+), 25 deletions(-) create mode 100644 tailbone/templates/configure-menus.mako create mode 100644 tailbone/views/menus.py diff --git a/tailbone/app.py b/tailbone/app.py index 80cce0f6..6896ea4d 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -156,19 +156,33 @@ def make_pyramid_config(settings, configure_csrf=True): config.add_directive('add_tailbone_permission_group', 'tailbone.auth.add_permission_group') config.add_directive('add_tailbone_permission', 'tailbone.auth.add_permission') - # and some similar magic for config views + # and some similar magic for certain master views + config.add_directive('add_tailbone_index_page', 'tailbone.app.add_index_page') config.add_directive('add_tailbone_config_page', 'tailbone.app.add_config_page') return config -def add_config_page(config, route_name, label): +def add_index_page(config, route_name, label, permission): + """ + Register a config page for the app. + """ + def action(): + pages = config.get_settings().get('tailbone_index_pages', []) + pages.append({'label': label, 'route': route_name, + 'permission': permission}) + config.add_settings({'tailbone_index_pages': pages}) + config.action(None, action) + + +def add_config_page(config, route_name, label, permission): """ Register a config page for the app. """ def action(): pages = config.get_settings().get('tailbone_config_pages', []) - pages.append({'label': label, 'route': route_name}) + pages.append({'label': label, 'route': route_name, + 'permission': permission}) config.add_settings({'tailbone_config_pages': pages}) config.action(None, action) diff --git a/tailbone/menus.py b/tailbone/menus.py index e2c025c1..464f081c 100644 --- a/tailbone/menus.py +++ b/tailbone/menus.py @@ -28,22 +28,28 @@ from __future__ import unicode_literals, absolute_import import re -from rattail.util import import_module_path +from rattail.util import import_module_path, prettify + +from tailbone.db import Session def make_simple_menus(request): """ Build the main menu list for the app. """ - menus_module = import_module_path( - request.rattail_config.require('tailbone', 'menus')) + # first try to make menus from config + raw_menus = make_menus_from_config(request) + if not raw_menus: - if not hasattr(menus_module, 'simple_menus') or not callable(menus_module.simple_menus): - raise RuntimeError("module does not have a simple_menus() callable: {}".format(menus_module)) + # no config, so import/invoke function to build them + menus_module = import_module_path( + request.rattail_config.require('tailbone', 'menus')) + if not hasattr(menus_module, 'simple_menus') or not callable(menus_module.simple_menus): + raise RuntimeError("module does not have a simple_menus() callable: {}".format(menus_module)) + raw_menus = menus_module.simple_menus(request) - # collect "simple" menus definition, but must refine that somewhat to - # produce our final menus - raw_menus = menus_module.simple_menus(request) + # now we have "simple" (raw) menus definition, but must refine + # that somewhat to produce our final menus mark_allowed(request, raw_menus) final_menus = [] for topitem in raw_menus: @@ -51,7 +57,7 @@ def make_simple_menus(request): if topitem['allowed']: if topitem.get('type') == 'link': - final_menus.append(make_menu_entry(topitem)) + final_menus.append(make_menu_entry(request, topitem)) else: # assuming 'menu' type @@ -65,7 +71,7 @@ def make_simple_menus(request): submenu_items = [] for subitem in item['items']: if subitem['allowed']: - submenu_items.append(make_menu_entry(subitem)) + submenu_items.append(make_menu_entry(request, subitem)) menu_items.append({ 'type': 'submenu', 'title': item['title'], @@ -79,10 +85,10 @@ def make_simple_menus(request): # menu items (i.e. there is something to separate) # *and* the last menu item is not a sep (avoid doubles) if menu_items and not menu_items[-1]['is_sep']: - menu_items.append(make_menu_entry(item)) + menu_items.append(make_menu_entry(request, item)) else: # standard menu item - menu_items.append(make_menu_entry(item)) + menu_items.append(make_menu_entry(request, item)) # remove final separator if present if menu_items and menu_items[-1]['is_sep']: @@ -105,14 +111,201 @@ def make_simple_menus(request): # code. so we are free to "invent" a (safe) key # for it, since that is only for editing config if not group['key']: - group['key'] = re.sub(r'\W', '', topitem['title'].lower()) + group['key'] = make_menu_key(request.rattail_config, + topitem['title']) final_menus.append(group) return final_menus -def make_menu_entry(item): +def make_menus_from_config(request): + """ + Try to build a complete menu set from config/settings. + + This essentially checks for the top-level menu list in config; if + found then it will build a full menu set from config. If this + top-level list is not present in config then menus will be built + purely from code instead. An example of this top-level list: + + .. code-hightlight:: ini + + [tailbone.menu] + menus = first, second, third, admin + + Obviously much more config would be needed to define those menus + etc. but that is the option that determines whether the rest of + menu config is even read, or not. + """ + config = request.rattail_config + main_keys = config.getlist('tailbone.menu', 'menus') + if not main_keys: + return + + menus = [] + + # menu definition can come either from config file or db settings, + # but if the latter then we want to optimize with one big query + if config.getbool('tailbone.menu', 'from_settings', + default=False): + app = config.get_app() + model = config.get_model() + + # fetch all menu-related settings at once + query = Session().query(model.Setting)\ + .filter(model.Setting.name.like('tailbone.menu.%')) + settings = app.cache_model(Session(), model.Setting, + query=query, key='name', + normalizer=lambda s: s.value) + for key in main_keys: + menus.append(make_single_menu_from_settings(request, key, settings)) + + else: # read from config file only + for key in main_keys: + menus.append(make_single_menu_from_config(request, key)) + + return menus + + +def make_single_menu_from_config(request, key): + """ + Makes a single top-level menu dict from config file. Note that + this will read from config file(s) *only* and avoids querying the + database, for efficiency. + """ + config = request.rattail_config + menu = { + 'key': key, + 'type': 'menu', + 'items': [], + } + + # title + title = config.get('tailbone.menu', + 'menu.{}.label'.format(key), + usedb=False) + menu['title'] = title or prettify(key) + + # items + item_keys = config.getlist('tailbone.menu', + 'menu.{}.items'.format(key), + usedb=False) + for item_key in item_keys: + item = {} + + if item_key == 'SEP': + item['type'] = 'sep' + + else: + item['type'] = 'item' + item['key'] = item_key + + # title + title = config.get('tailbone.menu', + 'menu.{}.item.{}.label'.format(key, item_key), + usedb=False) + item['title'] = title or prettify(item_key) + + # route + route = config.get('tailbone.menu', + 'menu.{}.item.{}.route'.format(key, item_key), + usedb=False) + if route: + item['route'] = route + item['url'] = request.route_url(route) + + else: + + # url + url = config.get('tailbone.menu', + 'menu.{}.item.{}.url'.format(key, item_key), + usedb=False) + if not url: + url = request.route_url(item_key) + elif url.startswith('route:'): + url = request.route_url(url[6:]) + item['url'] = url + + # perm + perm = config.get('tailbone.menu', + 'menu.{}.item.{}.perm'.format(key, item_key), + usedb=False) + item['perm'] = perm or '{}.list'.format(item_key) + + menu['items'].append(item) + + return menu + + +def make_single_menu_from_settings(request, key, settings): + """ + Makes a single top-level menu dict from DB settings. + """ + config = request.rattail_config + menu = { + 'key': key, + 'type': 'menu', + 'items': [], + } + + # title + title = settings.get('tailbone.menu.menu.{}.label'.format(key)) + menu['title'] = title or prettify(key) + + # items + item_keys = config.parse_list( + settings.get('tailbone.menu.menu.{}.items'.format(key))) + for item_key in item_keys: + item = {} + + if item_key == 'SEP': + item['type'] = 'sep' + + else: + item['type'] = 'item' + item['key'] = item_key + + # title + title = settings.get('tailbone.menu.menu.{}.item.{}.label'.format( + key, item_key)) + item['title'] = title or prettify(item_key) + + # route + route = settings.get('tailbone.menu.menu.{}.item.{}.route'.format( + key, item_key)) + if route: + item['route'] = route + item['url'] = request.route_url(route) + + else: + + # url + url = settings.get('tailbone.menu.menu.{}.item.{}.url'.format( + key, item_key)) + if not url: + url = request.route_url(item_key) + if url.startswith('route:'): + url = request.route_url(url[6:]) + item['url'] = url + + # perm + perm = settings.get('tailbone.menu.menu.{}.item.{}.perm'.format( + key, item_key)) + item['perm'] = perm or '{}.list'.format(item_key) + + menu['items'].append(item) + + return menu + + +def make_menu_key(config, value): + """ + Generate a normalized menu key for the given value. + """ + return re.sub(r'\W', '', value.lower()) + + +def make_menu_entry(request, item): """ Convert a simple menu entry dict, into a proper menu-related object, for use in constructing final menu. @@ -126,15 +319,24 @@ def make_menu_entry(item): } # standard menu item - return { + entry = { 'type': 'item', 'title': item['title'], - 'url': item['url'], + 'perm': item.get('perm'), 'target': item.get('target'), 'is_link': True, 'is_menu': False, 'is_sep': False, } + if item.get('route'): + entry['route'] = item['route'] + entry['url'] = request.route_url(entry['route']) + entry['key'] = entry['route'] + else: + if item.get('url'): + entry['url'] = item['url'] + entry['key'] = make_menu_key(request.rattail_config, entry['title']) + return entry def is_allowed(request, item): diff --git a/tailbone/templates/configure-menus.mako b/tailbone/templates/configure-menus.mako new file mode 100644 index 00000000..495b5c65 --- /dev/null +++ b/tailbone/templates/configure-menus.mako @@ -0,0 +1,424 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="extra_styles()"> + ${parent.extra_styles()} + <style type="text/css"> + .topmenu-dropper { + min-width: 0.8rem; + } + .topmenu-dropper:-moz-drag-over { + background-color: blue; + } + </style> +</%def> + +<%def name="form_content()"> + ${h.hidden('menus', **{':value': 'JSON.stringify(allMenuData)'})} + + <h3 class="is-size-3">Top-Level Menus</h3> + <p class="block">Click on a menu to edit. Drag things around to rearrange.</p> + + <b-field grouped> + + <b-field grouped v-for="key in menuSequence" + :key="key"> + <span class="topmenu-dropper control" + @dragover.prevent + @dragenter.prevent + @drop="dropMenu($event, key)"> + + </span> + <b-button :type="editingMenu && editingMenu.key == key ? 'is-primary' : null" + class="control" + @click="editMenu(key)" + :disabled="editingMenu && editingMenu.key != key" + :draggable="!editingMenu" + @dragstart.native="topMenuStartDrag($event, key)"> + {{ allMenus[key].title }} + </b-button> + </b-field> + + <div class="topmenu-dropper control" + @dragover.prevent + @dragenter.prevent + @drop="dropMenu($event, '_last_')"> + + </div> + <b-button v-show="!editingMenu" + type="is-primary" + icon-pack="fas" + icon-left="plus" + @click="editMenuNew()"> + Add + </b-button> + + </b-field> + + <div v-if="editingMenu" + style="max-width: 40%;"> + + <b-field grouped> + + <b-field label="Label"> + <b-input v-model="editingMenu.title" + ref="editingMenuTitleInput"> + </b-input> + </b-field> + + <b-field label="Actions"> + <div class="buttons"> + <b-button icon-pack="fas" + icon-left="redo" + @click="editMenuCancel()"> + Revert / Cancel + </b-button> + <b-button type="is-primary" + icon-pack="fas" + icon-left="save" + @click="editMenuSave()"> + Save + </b-button> + <b-button type="is-danger" + icon-pack="fas" + icon-left="trash" + @click="editMenuDelete()"> + Delete + </b-button> + </div> + </b-field> + + </b-field> + + <b-field> + <template #label> + <span style="margin-right: 2rem;">Menu Items</span> + <b-button type="is-primary" + icon-pack="fas" + icon-left="plus" + @click="editMenuItemInitDialog()"> + Add + </b-button> + </template> + <ul class="list"> + <li v-for="item in editingMenu.items" + class="list-item" + draggable + @dragstart="menuItemStartDrag($event, item)" + @dragover.prevent + @dragenter.prevent + @drop="menuItemDrop($event, item)"> + <span :class="item.type == 'sep' ? 'has-text-info' : null"> + {{ item.type == 'sep' ? "-- separator --" : item.title }} + </span> + <span class="is-pulled-right grid-action"> + <a href="#" @click.prevent="editMenuItemInitDialog(item)"> + <i class="fas fa-edit"></i> + Edit + </a> + + <a href="#" class="has-text-danger" + @click.prevent="editMenuItemDelete(item)"> + <i class="fas fa-trash"></i> + Delete + </a> + + </span> + </li> + </ul> + </b-field> + + <b-modal has-modal-card + :active.sync="editMenuItemShowDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">{{ editingMenuItem.isNew ? "Add" : "Edit" }} Item</p> + </header> + + <section class="modal-card-body"> + + <b-field label="Item Type"> + <b-select v-model="editingMenuItem.type"> + <option value="item">Route Link</option> + <option value="sep">Separator</option> + </b-select> + </b-field> + + <b-field label="Route" + v-show="editingMenuItem.type == 'item'"> + <b-select v-model="editingMenuItem.route" + @input="editingMenuItemRouteChanged"> + <option v-for="route in editMenuIndexRoutes" + :key="route.route" + :value="route.route"> + {{ route.label }} + </option> + </b-select> + </b-field> + + <b-field label="Label" + v-show="editingMenuItem.type == 'item'"> + <b-input v-model="editingMenuItem.title"> + </b-input> + </b-field> + + </section> + + <footer class="modal-card-foot"> + <b-button @click="editMenuItemShowDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + icon-pack="fas" + icon-left="save" + :disabled="editMenuItemSaveDisabled" + @click="editMenuSaveItem()"> + Save + </b-button> + </footer> + </div> + </b-modal> + + </div> + +</%def> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + ThisPageData.menuSequence = ${json.dumps([m['key'] for m in menus])|n} + + ThisPageData.allMenus = {} + % for topitem in menus: + ThisPageData.allMenus['${topitem['key']}'] = ${json.dumps(topitem)|n} + % endfor + + ThisPageData.editMenuIndexRoutes = ${json.dumps(index_route_options)|n} + + ThisPageData.editingMenu = null + ThisPageData.editingMenuItem = {isNew: true} + ThisPageData.editingMenuItemIndex = null + + ThisPageData.editMenuItemShowDialog = false + + // nb. this value is sent on form submit + ThisPage.computed.allMenuData = function() { + let menus = [] + for (key of this.menuSequence) { + menus.push(this.allMenus[key]) + } + return menus + } + + ThisPage.methods.editMenu = function(key) { + if (this.editingMenu) { + return + } + + // copy existing (original) menu to be edited + let original = this.allMenus[key] + this.editingMenu = { + key: key, + title: original.title, + items: [], + } + + // and copy each item separately + for (let item of original.items) { + this.editingMenu.items.push({ + key: item.key, + title: item.title, + route: item.route, + url: item.url, + perm: item.perm, + type: item.type, + }) + } + } + + ThisPage.methods.editMenuNew = function() { + + // editing brand new menu + this.editingMenu = {items: []} + + // focus title input + this.$nextTick(() => { + this.$refs.editingMenuTitleInput.focus() + }) + } + + ThisPage.methods.editMenuCancel = function(key) { + this.editingMenu = null + } + + ThisPage.methods.editMenuSave = function() { + + let key = this.editingMenu.key + if (key) { + + // update existing (original) menu with user edits + this.allMenus[key] = this.editingMenu + + } else { + + // generate makeshift key + key = this.editingMenu.title.replace(/\W/g, '') + + // add new menu to data set + this.allMenus[key] = this.editingMenu + this.menuSequence.push(key) + } + + // no longer editing + this.editingMenu = null + this.settingsNeedSaved = true + } + + ThisPage.methods.editMenuDelete = function() { + + if (confirm("Really delete this menu?")) { + let key = this.editingMenu.key + + // remove references from primary collections + let i = this.menuSequence.indexOf(key) + this.menuSequence.splice(i, 1) + delete this.allMenus[key] + + // no longer editing + this.editingMenu = null + this.settingsNeedSaved = true + } + } + + ## TODO: see also https://learnvue.co/2020/01/how-to-add-drag-and-drop-to-your-vuejs-project/#adding-drag-and-drop-functionality + + ## TODO: see also https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API + + ## TODO: maybe try out https://www.npmjs.com/package/vue-drag-drop + + ThisPage.methods.topMenuStartDrag = function(event, key) { + event.dataTransfer.setData('key', key) + } + + ThisPage.methods.dropMenu = function(event, target) { + let key = event.dataTransfer.getData('key') + if (target == key) { + return // same target + } + + let i = this.menuSequence.indexOf(key) + let j = this.menuSequence.indexOf(target) + if (i + 1 == j) { + return // same target + } + + if (target == '_last_') { + if (this.menuSequence[this.menuSequence.length-1] != key) { + this.menuSequence.splice(i, 1) + this.menuSequence.push(key) + this.settingsNeedSaved = true + } + } else { + this.menuSequence.splice(i, 1) + j = this.menuSequence.indexOf(target) + this.menuSequence.splice(j, 0, key) + this.settingsNeedSaved = true + } + } + + ThisPage.methods.menuItemStartDrag = function(event, item) { + let i = this.editingMenu.items.indexOf(item) + event.dataTransfer.setData('itemIndex', i) + } + + ThisPage.methods.menuItemDrop = function(event, item) { + let oldIndex = event.dataTransfer.getData('itemIndex') + let pruned = this.editingMenu.items.splice(oldIndex, 1) + let newIndex = this.editingMenu.items.indexOf(item) + this.editingMenu.items.splice(newIndex, 0, pruned[0]) + } + + ThisPage.methods.editMenuItemInitDialog = function(item) { + + if (item === undefined) { + this.editingMenuItemIndex = null + + // create new item to edit + this.editingMenuItem = { + isNew: true, + route: null, + title: null, + perm: null, + type: 'item', + } + + } else { + this.editingMenuItemIndex = this.editingMenu.items.indexOf(item) + + // copy existing (original item to be edited + this.editingMenuItem = { + key: item.key, + title: item.title, + route: item.route, + url: item.url, + perm: item.perm, + type: item.type, + } + } + + this.editMenuItemShowDialog = true + } + + ThisPage.methods.editingMenuItemRouteChanged = function(routeName) { + for (let route of this.editMenuIndexRoutes) { + if (route.route == routeName) { + this.editingMenuItem.title = route.label + this.editingMenuItem.perm = route.perm + break + } + } + } + + ThisPage.computed.editMenuItemSaveDisabled = function() { + if (this.editingMenuItem.type == 'item') { + if (!this.editingMenuItem.route) { + return true + } + if (!this.editingMenuItem.title) { + return true + } + } + return false + } + + ThisPage.methods.editMenuSaveItem = function() { + + if (this.editingMenuItem.isNew) { + this.editingMenu.items.push(this.editingMenuItem) + + } else { + this.editingMenu.items.splice(this.editingMenuItemIndex, + 1, + this.editingMenuItem) + } + + this.editMenuItemShowDialog = false + } + + ThisPage.methods.editMenuItemDelete = function(item) { + + if (confirm("Really delete this item?")) { + + // remove item from editing menu + let i = this.editingMenu.items.indexOf(item) + this.editingMenu.items.splice(i, 1) + } + } + + </script> +</%def> + + +${parent.body()} diff --git a/tailbone/templates/configure.mako b/tailbone/templates/configure.mako index f05b24c0..2fe8ee72 100644 --- a/tailbone/templates/configure.mako +++ b/tailbone/templates/configure.mako @@ -190,7 +190,7 @@ </div> </b-modal> - ${h.form(request.current_route_url(), enctype='multipart/form-data', ref='saveSettingsForm')} + ${h.form(request.current_route_url(), enctype='multipart/form-data', ref='saveSettingsForm', **{'@submit': 'saveSettingsFormSubmit'})} ${h.csrf_token(request)} ${self.form_content()} ${h.end_form()} @@ -262,6 +262,14 @@ this.$refs.saveSettingsForm.submit() } + // nb. this is here to avoid auto-submitting form when user + // presses ENTER while some random input field has focus + ThisPage.methods.saveSettingsFormSubmit = function(event) { + if (!this.savingSettings) { + event.preventDefault() + } + } + // cf. https://stackoverflow.com/a/56551646 ThisPage.methods.beforeWindowUnload = function(e) { if (this.settingsNeedSaved && !this.undoChanges) { diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index 0bc0aca8..61471aaa 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -295,9 +295,15 @@ </span> % endif % elif index_title: - <span class="header-text"> - ${index_title} - </span> + % if index_url: + <span class="header-text"> + ${h.link_to(index_title, index_url)} + </span> + % else: + <span class="header-text"> + ${index_title} + </span> + % endif % endif </div> @@ -430,6 +436,14 @@ % endfor % endif + % if request.session.peek_flash('warning'): + % for msg in request.session.pop_flash('warning'): + <b-notification type="is-warning"> + ${msg} + </b-notification> + % endfor + % endif + % if request.session.peek_flash(): % for msg in request.session.pop_flash(): <b-notification type="is-info"> diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 6c174f06..b4d76d80 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -4209,7 +4209,9 @@ class MasterView(View): if self.request.method == 'POST': if self.request.POST.get('remove_settings'): self.configure_remove_settings() - self.request.session.flash("Settings have been removed.") + self.request.session.flash("All settings for {} have been " + "removed.".format(self.get_config_title()), + 'warning') return self.redirect(self.request.current_route_url()) else: data = self.request.POST @@ -4517,6 +4519,8 @@ class MasterView(View): config.add_view(cls, attr='index', route_name=route_prefix, permission='{}.list'.format(permission_prefix), **kwargs) + config.add_tailbone_index_page(route_prefix, model_title_plural, + '{}.list'.format(permission_prefix)) # download results # this is the "new" more flexible approach, but we only want to @@ -4572,7 +4576,8 @@ class MasterView(View): route_name='{}.configure'.format(route_prefix), permission='{}.configure'.format(permission_prefix)) config.add_tailbone_config_page('{}.configure'.format(route_prefix), - config_title) + config_title, + '{}.configure'.format(permission_prefix)) # quickie (search) if cls.supports_quickie_search: diff --git a/tailbone/views/menus.py b/tailbone/views/menus.py new file mode 100644 index 00000000..37c2536c --- /dev/null +++ b/tailbone/views/menus.py @@ -0,0 +1,191 @@ +# -*- 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 <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Base class for Config Views +""" + +from __future__ import unicode_literals, absolute_import + +import json + +import sqlalchemy as sa + +from tailbone.views import View +from tailbone.db import Session +from tailbone.menus import make_menu_key + + +class MenuConfigView(View): + """ + View for configuring the main menu. + """ + + def configure(self): + """ + Main entry point to menu config views. + """ + if self.request.method == 'POST': + if self.request.POST.get('remove_settings'): + self.configure_remove_settings() + self.request.session.flash("All settings for Menus have been removed.", + 'warning') + return self.redirect(self.request.current_route_url()) + else: + data = self.request.POST + + # gather/save settings + settings = self.configure_gather_settings(data) + self.configure_remove_settings() + self.configure_save_settings(settings) + self.request.session.flash("Settings have been saved.") + return self.redirect(self.request.current_route_url()) + + context = { + 'config_title': "Menus", + 'use_buefy': True, + 'index_title': "App Settings", + 'index_url': self.request.route_url('appsettings'), + } + + possible_index_options = sorted( + self.request.registry.settings['tailbone_index_pages'], + key=lambda p: p['label']) + + index_options = [] + for option in possible_index_options: + perm = option['permission'] + option['perm'] = perm + option['url'] = self.request.route_url(option['route']) + index_options.append(option) + + context['index_route_options'] = index_options + return context + + def configure_gather_settings(self, data): + settings = [{'name': 'tailbone.menu.from_settings', + 'value': 'true'}] + + main_keys = [] + for topitem in json.loads(data['menus']): + key = make_menu_key(self.rattail_config, topitem['title']) + main_keys.append(key) + + settings.extend([ + {'name': 'tailbone.menu.menu.{}.label'.format(key), + 'value': topitem['title']}, + ]) + + item_keys = [] + for item in topitem['items']: + item_type = item.get('type', 'item') + if item_type == 'item': + if item.get('route'): + item_key = item['route'] + else: + item_key = make_menu_key(self.rattail_config, item['title']) + item_keys.append(item_key) + + settings.extend([ + {'name': 'tailbone.menu.menu.{}.item.{}.label'.format(key, item_key), + 'value': item['title']}, + ]) + + if item.get('route'): + settings.extend([ + {'name': 'tailbone.menu.menu.{}.item.{}.route'.format(key, item_key), + 'value': item['route']}, + ]) + + elif item.get('url'): + settings.extend([ + {'name': 'tailbone.menu.menu.{}.item.{}.url'.format(key, item_key), + 'value': item['url']}, + ]) + + if item.get('perm'): + settings.extend([ + {'name': 'tailbone.menu.menu.{}.item.{}.perm'.format(key, item_key), + 'value': item['perm']}, + ]) + + elif item_type == 'sep': + item_keys.append('SEP') + + settings.extend([ + {'name': 'tailbone.menu.menu.{}.items'.format(key), + 'value': ' '.join(item_keys)}, + ]) + + settings.append({'name': 'tailbone.menu.menus', + 'value': ' '.join(main_keys)}) + return settings + + def configure_remove_settings(self): + model = self.model + Session.query(model.Setting)\ + .filter(sa.or_( + model.Setting.name == 'tailbone.menu.from_settings', + model.Setting.name == 'tailbone.menu.menus', + model.Setting.name.like('tailbone.menu.menu.%.label'), + model.Setting.name.like('tailbone.menu.menu.%.items'), + model.Setting.name.like('tailbone.menu.menu.%.item.%.label'), + model.Setting.name.like('tailbone.menu.menu.%.item.%.route'), + model.Setting.name.like('tailbone.menu.menu.%.item.%.perm'), + model.Setting.name.like('tailbone.menu.menu.%.item.%.url')))\ + .delete(synchronize_session=False) + + def configure_save_settings(self, settings): + model = self.model + session = Session() + for setting in settings: + session.add(model.Setting(name=setting['name'], + value=setting['value'])) + + @classmethod + def defaults(cls, config): + cls._defaults(config) + + @classmethod + def _defaults(cls, config): + + # configure menus + config.add_route('configure_menus', + '/configure-menus') + config.add_view(cls, attr='configure', + route_name='configure_menus', + # nb. must be root to configure menus! b/c + # otherwise some route options may be hidden + permission='admin', + renderer='/configure-menus.mako') + config.add_tailbone_config_page('configure_menus', "Menus", 'admin') + + +def defaults(config, **kwargs): + base = globals() + + MenuConfigView = kwargs.get('MenuConfigView', base['MenuConfigView']) + MenuConfigView.defaults(config) + + +def includeme(config): + defaults(config) From 63fef16c37c4ba0c28d4fa53326e8fb31af91119 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 26 Feb 2022 20:09:30 -0600 Subject: [PATCH 0648/1681] 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 df13fb49..020c4b52 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.212 (2022-02-26) +-------------------- + +* Add page/way to configure main menus. + + 0.8.211 (2022-02-25) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 44794282..1f6ce045 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.211' +__version__ = '0.8.212' From ec2600ddf7179276849e970d7beb43498d21313e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 26 Feb 2022 21:00:05 -0600 Subject: [PATCH 0649/1681] Add simple searchable column support for non-AJAX grids idk maybe even AJAX grids can use? not gonna try at the moment --- tailbone/grids/core.py | 12 ++++++++++++ tailbone/templates/grids/buefy.mako | 5 ++++- tailbone/views/email.py | 3 +++ tailbone/views/tables.py | 4 +++- 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index bc7c6684..75c2ffd5 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -76,6 +76,7 @@ class Grid(object): enums={}, labels={}, assume_local_times=False, renderers={}, invisible=[], extra_row_class=None, linked_columns=[], url='#', joiners={}, filterable=False, filters={}, use_byte_string_filters=False, + searchable={}, sortable=False, sorters={}, default_sortkey=None, default_sortdir='asc', pageable=False, default_pagesize=20, default_page=1, checkboxes=False, checked=None, check_handler=None, check_all_handler=None, @@ -119,6 +120,8 @@ class Grid(object): self.use_byte_string_filters = use_byte_string_filters self.filters = self.make_filters(filters) + self.searchable = searchable or {} + self.sortable = sortable self.sorters = self.make_sorters(sorters) self.default_sortkey = default_sortkey @@ -241,6 +244,15 @@ class Grid(object): kwargs['label'] = self.labels[key] self.filters[key] = self.make_filter(key, *args, **kwargs) + def set_searchable(self, key, searchable=True): + if searchable: + self.searchable[key] = True + else: + self.searchable.pop(key, None) + + def is_searchable(self, key): + return self.searchable.get(key, False) + def remove_filter(self, key): self.filters.pop(key, None) diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako index 5ef15a17..0801bbe8 100644 --- a/tailbone/templates/grids/buefy.mako +++ b/tailbone/templates/grids/buefy.mako @@ -187,6 +187,9 @@ <b-table-column field="${column['field']}" label="${column['label']}" :sortable="${json.dumps(column['sortable'])}" + % if grid.is_searchable(column['field']): + searchable + % endif :visible="${json.dumps(column['visible'])}"> % if grid.is_linked(column['field']): <a :href="props.row._action_url_view" v-html="props.row.${column['field']}"></a> @@ -217,7 +220,7 @@ % endif </template> - <template slot="empty"> + <template #empty> <section class="section"> <div class="content has-text-grey has-text-centered"> <p> diff --git a/tailbone/views/email.py b/tailbone/views/email.py index 42f05c90..59737c08 100644 --- a/tailbone/views/email.py +++ b/tailbone/views/email.py @@ -105,6 +105,9 @@ class EmailSettingView(MasterView): g.set_link('key') g.set_link('subject') + g.set_searchable('key') + g.set_searchable('subject') + # to g.set_renderer('to', self.render_to_short) g.sorters['to'] = g.make_simple_sorter('to', foldcase=True) diff --git a/tailbone/views/tables.py b/tailbone/views/tables.py index df03a692..ea8aef99 100644 --- a/tailbone/views/tables.py +++ b/tailbone/views/tables.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. # @@ -70,6 +70,8 @@ class TableView(MasterView): g.sorters['row_count'] = g.make_simple_sorter('row_count') g.set_sort_defaults('name') + g.set_searchable('name') + # TODO: deprecate / remove this TablesView = TableView From 7b485d5ad22a64444e9a6bcbc0de63baa8189113 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 28 Feb 2022 12:05:16 -0600 Subject: [PATCH 0650/1681] Remove some duplicated code in fact it wasn't exactly duplicate..it had a bug which the shared function code does not have --- tailbone/forms/core.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 850768cf..c2398fdb 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -111,22 +111,14 @@ class CustomSchemaNode(SQLAlchemySchemaNode): for the given association proxy field name. Typically this will refer to the "extension" model class. """ - proxy = self.association_proxy(field) - if proxy: - proxy_target = self.inspector.get_property(proxy.target_collection) - if isinstance(proxy_target, orm.RelationshipProperty) and not proxy_target.uselist: - return proxy_target + return get_association_proxy_target(self.inspector, field) def association_proxy_column(self, field): """ Returns the property on the proxy target class, for the column which is reflected by the proxy. """ - proxy_target = self.association_proxy_target(field) - if proxy_target: - prop = proxy_target.mapper.get_property(field) - if isinstance(prop, orm.ColumnProperty) and isinstance(prop.columns[0], sa.Column): - return prop + return get_association_proxy_column(self.inspector, field) def supported_association_proxy(self, field): """ From ee961edf9431c5733fb5b884accdf736b45355e3 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 28 Feb 2022 22:16:52 -0600 Subject: [PATCH 0651/1681] Fix stdout/stderr fields for upgrade view whoops..missed that one --- tailbone/views/upgrades.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index 11c2930c..bcbda9c4 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.py @@ -188,7 +188,7 @@ class UpgradeView(MasterView): f.remove_field('executed') f.remove_field('executed_by') - else: + elif not upgrade.executed: f.remove_field('executed') f.remove_field('executed_by') f.remove_field('stdout_file') From 59a9d2cf86f4e3f9ad6a6bb2f268550055bc826d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 1 Mar 2022 12:17:06 -0600 Subject: [PATCH 0652/1681] Pass query along for download results, so subclass can modify --- tailbone/views/master.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index b4d76d80..b36f18b3 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2722,7 +2722,7 @@ class MasterView(View): os.remove(path) # generate file for download - results = results.with_session(session).all() + results = results.with_session(session) self.download_results_setup(fields, progress=progress) self.download_results_generate(session, results, path, fmt, fields, progress=progress) @@ -2779,7 +2779,7 @@ class MasterView(View): csvrow = self.download_results_coerce_csv(data, fields) writer.writerow(csvrow) - self.progress_loop(write, results, progress, + self.progress_loop(write, results.all(), progress, message="Writing data to CSV file") csv_file.close() @@ -2796,7 +2796,7 @@ class MasterView(View): xlrow = [row[field] for field in fields] xlrows.append(xlrow) - self.progress_loop(write, results, progress, + self.progress_loop(write, results.all(), progress, message="Collecting data for Excel") def finalize(x, i): From 031d97aea303628f9baba740ddd998553327f958 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 1 Mar 2022 13:01:59 -0600 Subject: [PATCH 0653/1681] Avoid making discounts data if missing field, for trainwreck item view --- tailbone/views/trainwreck/base.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/tailbone/views/trainwreck/base.py b/tailbone/views/trainwreck/base.py index 081e7119..8a610104 100644 --- a/tailbone/views/trainwreck/base.py +++ b/tailbone/views/trainwreck/base.py @@ -268,17 +268,19 @@ class TransactionView(MasterView): def template_kwargs_view_row(self, **kwargs): use_buefy = self.get_use_buefy() if use_buefy: + form = kwargs['form'] + if 'discounts' in form: - app = self.get_rattail_app() - item = kwargs['instance'] - discounts_data = [] - for discount in item.discounts: - discounts_data.append({ - 'discount_type': discount.discount_type, - 'description': discount.description, - 'amount': app.render_currency(discount.amount), - }) - kwargs['discounts_data'] = discounts_data + app = self.get_rattail_app() + item = kwargs['instance'] + discounts_data = [] + for discount in item.discounts: + discounts_data.append({ + 'discount_type': discount.discount_type, + 'description': discount.description, + 'amount': app.render_currency(discount.amount), + }) + kwargs['discounts_data'] = discounts_data return kwargs From 2e0bc63e20c86d8abaef1f14d2ca12eb8ed4d224 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 1 Mar 2022 13:31:50 -0600 Subject: [PATCH 0654/1681] 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 020c4b52..497a7108 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,18 @@ CHANGELOG ========= +0.8.213 (2022-03-01) +-------------------- + +* Add simple searchable column support for non-AJAX grids. + +* Fix stdout/stderr fields for upgrade view. + +* Pass query along for download results, so subclass can modify. + +* Avoid making discounts data if missing field, for trainwreck item view. + + 0.8.212 (2022-02-26) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 1f6ce045..cdb5c0d2 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.212' +__version__ = '0.8.213' From 206d51f59b27885d4d42e20b7d1832f88425ea83 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 1 Mar 2022 15:03:48 -0600 Subject: [PATCH 0655/1681] Params should be readonly when editing batch --- tailbone/views/batch/core.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 65ccfe3d..b4911cbc 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -356,6 +356,8 @@ class BatchMasterView(MasterView): # params if self.creating: f.remove('params') + else: + f.set_readonly('params') # created f.set_readonly('created') From 78fb38e072cf6450056743bf0939b564ad7db790 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 1 Mar 2022 15:18:47 -0600 Subject: [PATCH 0656/1681] Tweak styles for links in object helper panel --- tailbone/static/themes/falafel/css/layout.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tailbone/static/themes/falafel/css/layout.css b/tailbone/static/themes/falafel/css/layout.css index 3a292cac..db3ebaf8 100644 --- a/tailbone/static/themes/falafel/css/layout.css +++ b/tailbone/static/themes/falafel/css/layout.css @@ -93,6 +93,10 @@ header .level .theme-picker { * "object helper" panel ******************************/ +.object-helpers a { + white-space: nowrap; +} + .object-helper { border: 1px solid black; margin: 1em; From 8104657ae971996377dc0ef8b22787a03e373107 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 1 Mar 2022 16:14:18 -0600 Subject: [PATCH 0657/1681] 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 497a7108..e0423061 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.214 (2022-03-01) +-------------------- + +* Params should be readonly when editing batch. + +* Tweak styles for links in object helper panel. + + 0.8.213 (2022-03-01) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index cdb5c0d2..d1e30db8 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.213' +__version__ = '0.8.214' From a3195267c995294bc0075b99413b0e736ddd2c2c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 1 Mar 2022 19:51:30 -0600 Subject: [PATCH 0658/1681] Show toast msg instead of alert after sending feedback --- tailbone/static/themes/falafel/js/tailbone.feedback.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tailbone/static/themes/falafel/js/tailbone.feedback.js b/tailbone/static/themes/falafel/js/tailbone.feedback.js index e83b59ed..11745ab4 100644 --- a/tailbone/static/themes/falafel/js/tailbone.feedback.js +++ b/tailbone/static/themes/falafel/js/tailbone.feedback.js @@ -22,7 +22,13 @@ let FeedbackForm = { } this.submitForm(this.action, params, response => { - alert("Message successfully sent.\n\nThank you for your feedback.") + + this.$buefy.toast.open({ + message: "Message sent! Thank you for your feedback.", + type: 'is-info', + duration: 4000, // 4 seconds + }) + this.showDialog = false // clear out message, in case they need to send another this.message = "" From 72177aef0ad513b93861148260df990fb10b2cb7 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 1 Mar 2022 23:00:11 -0600 Subject: [PATCH 0659/1681] Add basic support for Poser reports, list/create --- tailbone/forms/core.py | 8 + tailbone/templates/poser/reports/view.mako | 77 ++++++ tailbone/templates/poser/setup.mako | 57 ++++ tailbone/views/common.py | 36 ++- tailbone/views/master.py | 10 +- tailbone/views/poser/__init__.py | 31 +++ tailbone/views/poser/reports.py | 294 +++++++++++++++++++++ tailbone/views/reports.py | 3 +- 8 files changed, 512 insertions(+), 4 deletions(-) create mode 100644 tailbone/templates/poser/reports/view.mako create mode 100644 tailbone/templates/poser/setup.mako create mode 100644 tailbone/views/poser/__init__.py create mode 100644 tailbone/views/poser/reports.py diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index c2398fdb..1088ca9b 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -674,10 +674,18 @@ class Form(object): case the validator pertains to the form at large instead of one of the fields. + TODO: what should the validator look like? + :param validator: Callable validator for the node. """ self.validators[key] = validator + # we normally apply the validator when creating the schema, so + # if this form already has a schema, then go ahead and apply + # the validator to it + if self.schema and key in self.schema: + self.schema[key].validator = validator + def set_required(self, key, required=True): """ Set whether or not value is required for a given field. diff --git a/tailbone/templates/poser/reports/view.mako b/tailbone/templates/poser/reports/view.mako new file mode 100644 index 00000000..990a35af --- /dev/null +++ b/tailbone/templates/poser/reports/view.mako @@ -0,0 +1,77 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> + +<%def name="render_form_buttons()"> + <div v-if="!showUploadForm" class="buttons"> + <b-button type="is-primary" + @click="heckYeah()"> + Upload Replacement Module + </b-button> + <once-button type="is-primary" + tag="a" + % if instance.get('error'): + href="#" disabled + % else: + href="${url('generate_specific_report', type_key=instance['report'].type_key)}" + % endif + text="Generate this Report"> + </once-button> + </div> + <div v-if="showUploadForm"> + ${h.form(master.get_action_url('replace', instance), enctype='multipart/form-data', **{'@submit': 'uploadSubmitting = true'})} + ${h.csrf_token(request)} + <b-field label="New Module File" horizontal> + + <b-field class="file is-primary" + :class="{'has-name': !!uploadFile}" + > + <b-upload name="replacement_module" + v-model="uploadFile" + class="file-label"> + <span class="file-cta"> + <b-icon class="file-icon" pack="fas" icon="upload"></b-icon> + <span class="file-label">Click to upload</span> + </span> + </b-upload> + <span v-if="uploadFile" + class="file-name"> + {{ uploadFile.name }} + </span> + </b-field> + + <div class="buttons"> + <b-button @click="showUploadForm = false"> + Cancel + </b-button> + <b-button type="is-primary" + native-type="submit" + :disabled="uploadSubmitting || !uploadFile" + icon-pack="fas" + icon-left="save"> + {{ uploadSubmitting ? "Working, please wait..." : "Save" }} + </b-button> + </div> + + </b-field> + ${h.end_form()} + </div> +</%def> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + ${form.component_studly}Data.showUploadForm = false + + ${form.component_studly}Data.uploadFile = null + + ${form.component_studly}Data.uploadSubmitting = false + + ${form.component_studly}.methods.heckYeah = function() { + this.showUploadForm = true + } + + </script> +</%def> + +${parent.body()} diff --git a/tailbone/templates/poser/setup.mako b/tailbone/templates/poser/setup.mako new file mode 100644 index 00000000..18fda0d7 --- /dev/null +++ b/tailbone/templates/poser/setup.mako @@ -0,0 +1,57 @@ +## -*- coding: utf-8; -*- +<%inherit file="/page.mako" /> + +<%def name="title()">Poser Setup</%def> + +<%def name="page_content()"> + <br /> + + <p class="block"> + Before you can use Poser features, ${app_title} must create the + file structure for it. + </p> + + <p class="block"> + A new folder will be created at this location: + <span class="is-family-monospace has-text-weight-bold"> + ${poser_dir} + </span> + </p> + + <p class="block"> + Once set up, ${app_title} can generate code for certain features, + in the Poser folder. You can then access these features from + within ${app_title}. + </p> + + <p class="block"> + You are free to edit most files in the Poser folder as well. + When you do so ${app_title} should pick up on the changes with no + need for app restart. + </p> + + <p class="block"> + Proceed? + </p> + + ${h.form(request.current_route_url(), **{'@submit': 'setupSubmitting = true'})} + ${h.csrf_token(request)} + <b-button type="is-primary" + native-type="submit" + :disabled="setupSubmitting"> + {{ setupSubmitting ? "Working, please wait..." : "Go for it!" }} + </b-button> + ${h.end_form()} +</%def> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + ThisPageData.setupSubmitting = false + + </script> +</%def> + + +${parent.body()} diff --git a/tailbone/views/common.py b/tailbone/views/common.py index c3e40547..b4a947fa 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -32,7 +32,7 @@ import rattail from rattail.db import model from rattail.batch import consume_batch_id from rattail.mail import send_email -from rattail.util import OrderedDict +from rattail.util import OrderedDict, simple_error from rattail.files import resource_path from pyramid import httpexceptions @@ -188,6 +188,32 @@ class CommonView(View): """ raise Exception("Congratulations, you have triggered a bogus error.") + def poser_setup(self): + if not self.request.is_root: + raise self.forbidden() + + use_buefy = self.get_use_buefy() + app = self.get_rattail_app() + app_title = self.rattail_config.app_title() + poser_handler = app.get_poser_handler() + + if self.request.method == 'POST': + try: + path = poser_handler.make_poser_dir() + except Exception as error: + self.request.session.flash(simple_error(error), 'error') + else: + self.request.session.flash("Poser folder created at: {}".format(path)) + self.request.session.flash("Please restart the web app!", 'warning') + return self.redirect(self.request.route_url('home')) + + return { + 'use_buefy': use_buefy, + 'app_title': app_title, + 'index_title': app_title, + 'poser_dir': poser_handler.get_default_poser_dir(), + } + @classmethod def defaults(cls, config): cls._defaults(config) @@ -249,6 +275,14 @@ class CommonView(View): config.add_route('bogus_error', '/bogus-error') config.add_view(cls, attr='bogus_error', route_name='bogus_error', permission='errors.bogus') + # make poser dir + config.add_route('poser_setup', '/poser-setup') + config.add_view(cls, attr='poser_setup', + route_name='poser_setup', + renderer='/poser/setup.mako', + # nb. root only + permission='admin') + def includeme(config): CommonView.defaults(config) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index b36f18b3..1214d8aa 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -638,7 +638,7 @@ class MasterView(View): """ self.creating = True if form is None: - form = self.make_form(self.get_model_class()) + form = self.make_create_form() if self.request.method == 'POST': if self.validate_form(form): # let save_create_form() return alternate object if necessary @@ -651,6 +651,9 @@ class MasterView(View): context['dform'] = form.make_deform_form() return self.render_to_response(template, context) + def make_create_form(self): + return self.make_form(self.get_model_class()) + def save_create_form(self, form): uploads = self.normalize_uploads(form) self.before_create(form) @@ -3618,7 +3621,10 @@ class MasterView(View): raise NotImplementedError def render_downloadable_file(self, obj, field): - filename = getattr(obj, field) + if hasattr(obj, field): + filename = getattr(obj, field) + else: + filename = obj[field] if not filename: return "" path = self.download_path(obj, filename) diff --git a/tailbone/views/poser/__init__.py b/tailbone/views/poser/__init__.py new file mode 100644 index 00000000..e721c862 --- /dev/null +++ b/tailbone/views/poser/__init__.py @@ -0,0 +1,31 @@ +# -*- 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 <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Poser Views +""" + +from __future__ import unicode_literals, absolute_import + + +def includeme(config): + config.include('tailbone.views.poser.reports') diff --git a/tailbone/views/poser/reports.py b/tailbone/views/poser/reports.py new file mode 100644 index 00000000..146398f8 --- /dev/null +++ b/tailbone/views/poser/reports.py @@ -0,0 +1,294 @@ +# -*- 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 <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Poser Report Views +""" + +from __future__ import unicode_literals, absolute_import + +import os + +import six + +from rattail.util import simple_error + +import colander +from deform import widget as dfwidget +from webhelpers2.html import HTML, tags + +from tailbone.views import MasterView + + +class PoserReportView(MasterView): + """ + Master view for Poser reports + """ + normalized_model_name = 'poserreport' + model_title = "Poser Report" + model_key = 'report_key' + route_prefix = 'poser.reports' + url_prefix = '/poser/reports' + filterable = False + pageable = False + editable = False # TODO: should allow this somehow? + downloadable = True + + labels = { + 'report_key': "Poser Key", + } + + grid_columns = [ + 'report_key', + 'report_name', + 'description', + 'error', + ] + + form_fields = [ + 'report_key', + 'report_name', + 'description', + 'flavor', + 'include_comments', + 'module_file', + 'module_file_path', + 'error', + ] + + def __init__(self, request): + super(PoserReportView, self).__init__(request) + app = self.get_rattail_app() + self.poser_handler = app.get_poser_handler() + + # nb. pre-load all reports b/c all views potentially need + # access to the data set + self.data = self.get_data() + + def get_data(self, session=None): + if hasattr(self, 'data'): + return self.data + + try: + return self.poser_handler.get_all_reports() + + except Exception as error: + self.request.session.flash(simple_error(error), 'error') + + if not self.request.is_root: + self.request.session.flash("You must become root in order " + "to do Poser Setup.", 'error') + else: + link = tags.link_to("Poser Setup", + self.request.route_url('poser_setup')) + msg = HTML.literal("Please see the {} page.".format(link)) + self.request.session.flash(msg, 'error') + return [] + + def configure_grid(self, g): + super(PoserReportView, self).configure_grid(g) + + g.sorters['report_key'] = g.make_simple_sorter('report_key', foldcase=True) + g.sorters['report_name'] = g.make_simple_sorter('report_name', foldcase=True) + + g.set_renderer('error', self.render_report_error) + + g.set_sort_defaults('report_name') + + g.set_link('report_key') + g.set_link('report_name') + g.set_link('description') + g.set_link('error') + + g.set_searchable('report_key') + g.set_searchable('report_name') + g.set_searchable('description') + + if self.request.has_perm('report_output.create'): + g.more_actions.append(self.make_action( + 'generate', icon='arrow-circle-right', + url=self.get_generate_url)) + + def get_generate_url(self, report, i=None): + if not report.get('error'): + return self.request.route_url('generate_specific_report', + type_key=report['report'].type_key) + + def render_report_error(self, report, field): + error = report.get('error') + if error: + return HTML.tag('span', class_='has-background-warning', c=[error]) + + def get_instance(self): + report_key = self.request.matchdict['report_key'] + for report in self.get_data(): + if report['report_key'] == report_key: + return report + raise self.notfound() + + def get_instance_title(self, report): + return report['report_name'] + + def make_form_schema(self): + return PoserReportSchema() + + def make_create_form(self): + return self.make_form({}) + + def save_create_form(self, form): + self.before_create(form) + + report = self.poser_handler.make_report( + form.validated['report_key'], + form.validated['report_name'], + form.validated['description'], + flavor=form.validated['flavor'], + include_comments=form.validated['include_comments']) + + return report + + def configure_form(self, f): + super(PoserReportView, self).configure_form(f) + report = f.model_instance + + # report_key + f.set_default('report_key', 'cool_widgets') + f.set_helptext('report_key', "Unique computer-friendly key for the report type.") + if self.creating: + f.set_validator('report_key', self.unique_report_key) + + # report_name + f.set_default('report_name', "Cool Widgets Weekly") + f.set_helptext('report_name', "Human-friendly display name for the report.") + + # description + f.set_default('description', "How many cool widgets we come across each week") + f.set_helptext('description', "Brief description of the report.") + + # flavor + if self.creating: + f.set_helptext('flavor', "Determines the type of sample code to generate.") + flavors = self.poser_handler.get_supported_report_flavors() + values = [(key, flavor['description']) + for key, flavor in six.iteritems(flavors)] + f.set_widget('flavor', dfwidget.SelectWidget(values=values)) + f.set_validator('flavor', colander.OneOf(flavors)) + if flavors: + f.set_default('flavor', list(flavors)[0]) + else: + f.remove('flavor') + + # include_comments + if not self.creating: + f.remove('include_comments') + + # module_file + if self.creating: + f.remove('module_file') + else: + # nb. set this key as workaround for render method, which + # expects object to have this field + report['module_file'] = os.path.basename(report['module_file_path']) + f.set_renderer('module_file', self.render_downloadable_file) + + # error + if self.creating or not report.get('error'): + f.remove('error') + else: + f.set_renderer('error', self.render_report_error) + + def unique_report_key(self, node, value): + for report in self.get_data(): + if report['report_key'] == value: + raise node.raise_invalid("Poser report key must be unique") + + def download_path(self, report, filename): + return report['module_file_path'] + + def delete_instance(self, report): + self.poser_handler.delete_report(report['report_key']) + + def replace(self): + app = self.get_rattail_app() + report = self.get_instance() + + value = self.request.POST['replacement_module'] + tempdir = app.make_temp_dir() + filepath = os.path.join(tempdir, os.path.basename(value.filename)) + with open(filepath, 'wb') as f: + f.write(value.file.read()) + + try: + newreport = self.poser_handler.replace_report(report['report_key'], + filepath) + except Exception as error: + self.request.session.flash(simple_error(error), 'error') + else: + report = newreport + finally: + os.remove(filepath) + os.rmdir(tempdir) + + return self.redirect(self.get_action_url('view', report)) + + @classmethod + def defaults(cls, config): + cls._poser_report_defaults(config) + cls._defaults(config) + + @classmethod + def _poser_report_defaults(cls, config): + route_prefix = cls.get_route_prefix() + instance_url_prefix = cls.get_instance_url_prefix() + + # replace module + config.add_route('{}.replace'.format(route_prefix), + '{}/replace'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='replace', + route_name='{}.replace'.format(route_prefix), + # TODO: requires root, should add custom permission? + permission='admin') + + +class PoserReportSchema(colander.MappingSchema): + + report_key = colander.SchemaNode(colander.String()) + + report_name = colander.SchemaNode(colander.String()) + + description = colander.SchemaNode(colander.String()) + + flavor = colander.SchemaNode(colander.String()) + + include_comments = colander.SchemaNode(colander.Bool()) + + +def defaults(config, **kwargs): + base = globals() + + PoserReportView = kwargs.get('PoserReportView', base['PoserReportView']) + PoserReportView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index 12957934..cb0b718b 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -400,7 +400,8 @@ class ReportOutputView(ExportMasterView): form = forms.Form(schema=schema, request=self.request, use_buefy=use_buefy, helptext=helptext) form.submit_label = "Generate this Report" - form.cancel_url = self.request.route_url('{}.create'.format(route_prefix)) + form.cancel_url = self.request.get_referrer( + default=self.request.route_url('{}.create'.format(route_prefix))) # must declare jquery support for date fields, ugh # TODO: obviously would be nice for this to be automatic? From d99f2541df2ae5ae4aaec046d57be4267e180d46 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 2 Mar 2022 18:52:28 -0600 Subject: [PATCH 0660/1681] Add dedicated perm for replacing poser report module --- tailbone/templates/poser/reports/view.mako | 12 +++++++----- tailbone/views/auth.py | 4 ++-- tailbone/views/poser/reports.py | 10 +++++++--- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/tailbone/templates/poser/reports/view.mako b/tailbone/templates/poser/reports/view.mako index 990a35af..cb6490cd 100644 --- a/tailbone/templates/poser/reports/view.mako +++ b/tailbone/templates/poser/reports/view.mako @@ -3,10 +3,12 @@ <%def name="render_form_buttons()"> <div v-if="!showUploadForm" class="buttons"> + % if master.has_perm('replace'): <b-button type="is-primary" - @click="heckYeah()"> + @click="showUploadForm = true"> Upload Replacement Module </b-button> + % endif <once-button type="is-primary" tag="a" % if instance.get('error'): @@ -17,6 +19,7 @@ text="Generate this Report"> </once-button> </div> + % if master.has_perm('replace'): <div v-if="showUploadForm"> ${h.form(master.get_action_url('replace', instance), enctype='multipart/form-data', **{'@submit': 'uploadSubmitting = true'})} ${h.csrf_token(request)} @@ -55,10 +58,12 @@ </b-field> ${h.end_form()} </div> + % endif </%def> <%def name="modify_this_page_vars()"> ${parent.modify_this_page_vars()} + % if master.has_perm('replace'): <script type="text/javascript"> ${form.component_studly}Data.showUploadForm = false @@ -67,11 +72,8 @@ ${form.component_studly}Data.uploadSubmitting = false - ${form.component_studly}.methods.heckYeah = function() { - this.showUploadForm = true - } - </script> + % endif </%def> ${parent.body()} diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index efe2794d..406b8add 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.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. # @@ -87,7 +87,7 @@ class AuthenticationView(View): # Store current URL in session, for smarter redirect after login. self.request.session['next_url'] = self.request.current_route_url() next_url = self.request.route_url('login') - self.request.session.flash(msg, allow_duplicate=False) + self.request.session.flash(msg, 'warning', allow_duplicate=False) return self.redirect(next_url) def login(self, **kwargs): diff --git a/tailbone/views/poser/reports.py b/tailbone/views/poser/reports.py index 146398f8..8098e1f9 100644 --- a/tailbone/views/poser/reports.py +++ b/tailbone/views/poser/reports.py @@ -252,22 +252,26 @@ class PoserReportView(MasterView): @classmethod def defaults(cls, config): - cls._poser_report_defaults(config) cls._defaults(config) + cls._poser_report_defaults(config) @classmethod def _poser_report_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() # replace module + config.add_tailbone_permission(permission_prefix, + '{}.replace'.format(permission_prefix), + "Upload replacement module for {}".format(model_title)) config.add_route('{}.replace'.format(route_prefix), '{}/replace'.format(instance_url_prefix), request_method='POST') config.add_view(cls, attr='replace', route_name='{}.replace'.format(route_prefix), - # TODO: requires root, should add custom permission? - permission='admin') + permission='{}.replace'.format(permission_prefix)) class PoserReportSchema(colander.MappingSchema): From 18625efa87d5babc05e9eaeeca43d35055a34d5a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 2 Mar 2022 21:33:21 -0600 Subject: [PATCH 0661/1681] 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 e0423061..45cc6777 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.215 (2022-03-02) +-------------------- + +* Show toast msg instead of alert after sending feedback. + +* Add basic support for Poser reports, list/create. + + 0.8.214 (2022-03-01) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index d1e30db8..07333dcc 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.214' +__version__ = '0.8.215' From 691a5e84f9eb952609b310717471c1935e4732c2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 3 Mar 2022 18:36:35 -0600 Subject: [PATCH 0662/1681] Show list of generated reports when viewing Poser Report --- tailbone/templates/poser/reports/view.mako | 1 + tailbone/views/poser/reports.py | 48 ++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/tailbone/templates/poser/reports/view.mako b/tailbone/templates/poser/reports/view.mako index cb6490cd..aac0c7ae 100644 --- a/tailbone/templates/poser/reports/view.mako +++ b/tailbone/templates/poser/reports/view.mako @@ -59,6 +59,7 @@ ${h.end_form()} </div> % endif + <br /> </%def> <%def name="modify_this_page_vars()"> diff --git a/tailbone/views/poser/reports.py b/tailbone/views/poser/reports.py index 8098e1f9..972ee66f 100644 --- a/tailbone/views/poser/reports.py +++ b/tailbone/views/poser/reports.py @@ -75,6 +75,24 @@ class PoserReportView(MasterView): 'error', ] + has_rows = True + + @property + def model_row_class(self): + return self.model.ReportOutput + + row_labels = { + 'id': "ID", + } + + row_grid_columns = [ + 'id', + 'report_name', + 'filename', + 'created', + 'created_by', + ] + def __init__(self, request): super(PoserReportView, self).__init__(request) app = self.get_rattail_app() @@ -224,6 +242,36 @@ class PoserReportView(MasterView): def download_path(self, report, filename): return report['module_file_path'] + def get_row_data(self, report): + model = self.model + + if report.get('error'): + return [] + + return self.Session.query(model.ReportOutput)\ + .filter(model.ReportOutput.report_type == report['report'].type_key) + + def get_parent(self, output): + key = output.report_type + for report in self.get_data(): + if not report.get('error'): + if report['report'].type_key == key: + return report + + def configure_row_grid(self, g): + super(PoserReportView, self).configure_row_grid(g) + + g.set_renderer('id', self.render_id_str) + + g.set_sort_defaults('created', 'desc') + + g.set_link('id') + g.set_link('filename') + g.set_link('created') + + def row_view_action_url(self, output, i): + return self.request.route_url('report_output.view', uuid=output.uuid) + def delete_instance(self, report): self.poser_handler.delete_report(report['report_key']) From 3fae9e62706f435021a47da6edec4bd7d2909f22 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 3 Mar 2022 18:47:26 -0600 Subject: [PATCH 0663/1681] Show link back to Poser Report when viewing Generated Report i.e. where applicable / possible. also allow bulk-delete of generated reports, and show name filter by default for that grid --- tailbone/views/reports.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index cb0b718b..0319b725 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -214,6 +214,7 @@ class ReportOutputView(ExportMasterView): url_prefix = '/reports/generated' creatable = True downloadable = True + bulk_deletable = True configurable = True config_title = "Reporting" config_url = '/reports/configure' @@ -246,14 +247,38 @@ class ReportOutputView(ExportMasterView): def configure_grid(self, g): super(ReportOutputView, self).configure_grid(g) + + g.filters['report_name'].default_active = True + g.filters['report_name'].default_verb = 'contains' + g.set_link('filename') def configure_form(self, f): super(ReportOutputView, self).configure_form(f) + # report_type + f.set_renderer('report_type', self.render_report_type) + # params f.set_renderer('params', self.render_params) + def render_report_type(self, output, field): + type_key = getattr(output, field) + + # (try to) show link to poser report if applicable + if type_key and type_key.startswith('poser_'): + app = self.get_rattail_app() + poser_handler = app.get_poser_handler() + poser_key = type_key[6:] + report = poser_handler.normalize_report(poser_key) + if not report.get('error'): + url = self.request.route_url('poser.reports.view', + report_key=poser_key) + return tags.link_to(type_key, url) + + # fallback to showing value as-is + return type_key + def render_params(self, report, field): params = report.params if not params: From 738d5d94e0a9b3fe00010e591a11324e03231af8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 3 Mar 2022 19:14:21 -0600 Subject: [PATCH 0664/1681] Always include `app_title` in global template rendering context --- tailbone/subscribers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 44b69247..741f9265 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -102,6 +102,7 @@ def before_render(event): renderer_globals = event renderer_globals['rattail_app'] = request.rattail_config.get_app() + renderer_globals['app_title'] = request.rattail_config.app_title() renderer_globals['h'] = helpers renderer_globals['url'] = request.route_url renderer_globals['rattail'] = rattail From a28a801a62ebd5f10036b9645357e6177d30c2bf Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 4 Mar 2022 12:32:16 -0600 Subject: [PATCH 0665/1681] Update some more view config syntax some common ones used by a particular app.. --- tailbone/views/employees.py | 9 ++++++++- tailbone/views/people.py | 15 +++++++++++++-- tailbone/views/products.py | 11 ++++++++++- tailbone/views/vendors/__init__.py | 7 ++++++- tailbone/views/vendors/core.py | 9 ++++++++- 5 files changed, 45 insertions(+), 6 deletions(-) diff --git a/tailbone/views/employees.py b/tailbone/views/employees.py index c1f4b01c..46375bb4 100644 --- a/tailbone/views/employees.py +++ b/tailbone/views/employees.py @@ -329,5 +329,12 @@ class EmployeeView(MasterView): "View *all* (not just current) {}".format(model_title_plural)) -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + EmployeeView = kwargs.get('EmployeeView', base['EmployeeView']) EmployeeView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 3f055493..55f35927 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/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. # @@ -1470,7 +1470,18 @@ class MergePeopleRequestView(MasterView): return "(person not found)" -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + PersonView = kwargs.get('PersonView', base['PersonView']) PersonView.defaults(config) + + PersonNoteView = kwargs.get('PersonNoteView', base['PersonNoteView']) PersonNoteView.defaults(config) + + MergePeopleRequestView = kwargs.get('MergePeopleRequestView', base['MergePeopleRequestView']) MergePeopleRequestView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 33615ef4..145f55cb 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -2405,6 +2405,15 @@ class PendingProductView(MasterView): permission='{}.resolve_product'.format(permission_prefix)) -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + ProductView = kwargs.get('ProductView', base['ProductView']) ProductView.defaults(config) + + PendingProductView = kwargs.get('PendingProductView', base['PendingProductView']) PendingProductView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/vendors/__init__.py b/tailbone/views/vendors/__init__.py index 6a31777c..7d35780e 100644 --- a/tailbone/views/vendors/__init__.py +++ b/tailbone/views/vendors/__init__.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. # @@ -29,5 +29,10 @@ from __future__ import unicode_literals, absolute_import from .core import VendorView +def defaults(config, **kwargs): + from .core import defaults + return defaults(config, **kwargs) + + def includeme(config): config.include('tailbone.views.vendors.core') diff --git a/tailbone/views/vendors/core.py b/tailbone/views/vendors/core.py index 36280738..b3b003e7 100644 --- a/tailbone/views/vendors/core.py +++ b/tailbone/views/vendors/core.py @@ -180,5 +180,12 @@ class VendorView(MasterView): ] -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + VendorView = kwargs.get('VendorView', base['VendorView']) VendorView.defaults(config) + + +def includeme(config): + defaults(config) From f5d24133f75b4baa0655b029a1fb510386ef62db Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 4 Mar 2022 17:44:34 -0600 Subject: [PATCH 0666/1681] Make common web view a bit more common i.e. avoid the need to subclass it in derived projects --- tailbone/views/common.py | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/tailbone/views/common.py b/tailbone/views/common.py index b4a947fa..3f846f75 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -28,17 +28,15 @@ from __future__ import unicode_literals, absolute_import import six -import rattail 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 +from rattail.util import OrderedDict, simple_error, import_module_path from rattail.files import resource_path from pyramid import httpexceptions from pyramid.response import Response -import tailbone from tailbone import forms from tailbone.forms.common import Feedback from tailbone.db import Session @@ -51,8 +49,6 @@ class CommonView(View): """ Base class for common views; override as needed. """ - project_title = "Tailbone" - project_version = tailbone.__version__ robots_txt_path = resource_path('tailbone.static:robots.txt') def home(self, **kwargs): @@ -96,11 +92,24 @@ class CommonView(View): response.content_type = b'text/plain' return response + def get_project_title(self): + return self.rattail_config.app_title() + + def get_project_version(self): + + # TODO: deprecate this + if hasattr(self, 'project_version'): + return self.project_version + + pkg = self.rattail_config.app_package() + mod = import_module_path(pkg) + return mod.__version__ + def exception(self): """ Generic exception view """ - return {'project_title': self.project_title} + return {'project_title': self.get_project_title()} def about(self): """ @@ -108,8 +117,8 @@ class CommonView(View): """ use_buefy = self.get_use_buefy() context = { - 'project_title': self.project_title, - 'project_version': self.project_version, + 'project_title': self.get_project_title(), + 'project_version': self.get_project_version(), 'packages': self.get_packages(), 'use_buefy': use_buefy, } @@ -122,6 +131,7 @@ class CommonView(View): Should return the full set of packages which should be displayed on the 'about' page. """ + import rattail, tailbone return OrderedDict([ ('rattail', rattail.__version__), ('Tailbone', tailbone.__version__), From 128657810bed9aab0de8e5a934029aa54f17b8a7 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 5 Mar 2022 09:10:05 -0600 Subject: [PATCH 0667/1681] Add PoserMasterView, rename route for `poser_reports` must use e.g. `poser_reports` and `poser_views` for the "meta" stuff, i.e. maintenance of actual poser things, b/c it will be possible to define poser views, and those routes should be `poser.*` probably.. --- tailbone/views/poser/master.py | 73 +++++++++++++++++++++++++++++++++ tailbone/views/poser/reports.py | 42 ++++--------------- tailbone/views/reports.py | 2 +- 3 files changed, 81 insertions(+), 36 deletions(-) create mode 100644 tailbone/views/poser/master.py diff --git a/tailbone/views/poser/master.py b/tailbone/views/poser/master.py new file mode 100644 index 00000000..1f04fe61 --- /dev/null +++ b/tailbone/views/poser/master.py @@ -0,0 +1,73 @@ +# -*- 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 <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Poser Views for Views... +""" + +from __future__ import unicode_literals, absolute_import + +from rattail.util import simple_error + +from webhelpers2.html import HTML, tags + +from tailbone.views import MasterView + + +class PoserMasterView(MasterView): + """ + Master view base class for Poser + """ + model_key = 'key' + filterable = False + pageable = False + + def __init__(self, request): + super(PoserMasterView, self).__init__(request) + app = self.get_rattail_app() + self.poser_handler = app.get_poser_handler() + + # nb. pre-load all data b/c all views potentially need access + self.data = self.get_data() + + def get_data(self, session=None): + if hasattr(self, 'data'): + return self.data + + try: + return self.get_poser_data(session) + + except Exception as error: + self.request.session.flash(simple_error(error), 'error') + + if not self.request.is_root: + self.request.session.flash("You must become root in order " + "to do Poser Setup.", 'error') + else: + link = tags.link_to("Poser Setup", + self.request.route_url('poser_setup')) + msg = HTML.literal("Please see the {} page.".format(link)) + self.request.session.flash(msg, 'error') + return [] + + def get_poser_data(self, session=None): + raise NotImplementedError("TODO: you must implement this in subclass") diff --git a/tailbone/views/poser/reports.py b/tailbone/views/poser/reports.py index 972ee66f..80876233 100644 --- a/tailbone/views/poser/reports.py +++ b/tailbone/views/poser/reports.py @@ -34,22 +34,20 @@ from rattail.util import simple_error import colander from deform import widget as dfwidget -from webhelpers2.html import HTML, tags +from webhelpers2.html import HTML -from tailbone.views import MasterView +from .master import PoserMasterView -class PoserReportView(MasterView): +class PoserReportView(PoserMasterView): """ Master view for Poser reports """ - normalized_model_name = 'poserreport' + normalized_model_name = 'poser_report' model_title = "Poser Report" model_key = 'report_key' - route_prefix = 'poser.reports' + route_prefix = 'poser_reports' url_prefix = '/poser/reports' - filterable = False - pageable = False editable = False # TODO: should allow this somehow? downloadable = True @@ -93,34 +91,8 @@ class PoserReportView(MasterView): 'created_by', ] - def __init__(self, request): - super(PoserReportView, self).__init__(request) - app = self.get_rattail_app() - self.poser_handler = app.get_poser_handler() - - # nb. pre-load all reports b/c all views potentially need - # access to the data set - self.data = self.get_data() - - def get_data(self, session=None): - if hasattr(self, 'data'): - return self.data - - try: - return self.poser_handler.get_all_reports() - - except Exception as error: - self.request.session.flash(simple_error(error), 'error') - - if not self.request.is_root: - self.request.session.flash("You must become root in order " - "to do Poser Setup.", 'error') - else: - link = tags.link_to("Poser Setup", - self.request.route_url('poser_setup')) - msg = HTML.literal("Please see the {} page.".format(link)) - self.request.session.flash(msg, 'error') - return [] + def get_poser_data(self, session=None): + return self.poser_handler.get_all_reports() def configure_grid(self, g): super(PoserReportView, self).configure_grid(g) diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index 0319b725..74cba68d 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -272,7 +272,7 @@ class ReportOutputView(ExportMasterView): poser_key = type_key[6:] report = poser_handler.normalize_report(poser_key) if not report.get('error'): - url = self.request.route_url('poser.reports.view', + url = self.request.route_url('poser_reports.view', report_key=poser_key) return tags.link_to(type_key, url) From 33abeb1acaf6edc1a468f1ba80af880a1184e492 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 5 Mar 2022 09:12:01 -0600 Subject: [PATCH 0668/1681] Improve the Poser Setup page; allow poser dir refresh --- tailbone/templates/poser/setup.mako | 136 +++++++++++++++++++++------- tailbone/views/common.py | 64 +++++++++++-- 2 files changed, 160 insertions(+), 40 deletions(-) diff --git a/tailbone/templates/poser/setup.mako b/tailbone/templates/poser/setup.mako index 18fda0d7..8d01bb33 100644 --- a/tailbone/templates/poser/setup.mako +++ b/tailbone/templates/poser/setup.mako @@ -6,42 +6,116 @@ <%def name="page_content()"> <br /> - <p class="block"> - Before you can use Poser features, ${app_title} must create the - file structure for it. - </p> + % if not poser_dir_exists: - <p class="block"> - A new folder will be created at this location: - <span class="is-family-monospace has-text-weight-bold"> - ${poser_dir} - </span> - </p> + <p class="block"> + Before you can use Poser features, ${app_title} must create the + file structure for it. + </p> - <p class="block"> - Once set up, ${app_title} can generate code for certain features, - in the Poser folder. You can then access these features from - within ${app_title}. - </p> + <p class="block"> + A new folder will be created at this location: + <span class="is-family-monospace has-text-weight-bold"> + ${poser_dir} + </span> + </p> - <p class="block"> - You are free to edit most files in the Poser folder as well. - When you do so ${app_title} should pick up on the changes with no - need for app restart. - </p> + <p class="block"> + Once set up, ${app_title} can generate code for certain features, + in the Poser folder. You can then access these features from + within ${app_title}. + </p> - <p class="block"> - Proceed? - </p> + <p class="block"> + You are free to edit most files in the Poser folder as well. + When you do so ${app_title} should pick up on the changes with no + need for app restart. + </p> - ${h.form(request.current_route_url(), **{'@submit': 'setupSubmitting = true'})} - ${h.csrf_token(request)} - <b-button type="is-primary" - native-type="submit" - :disabled="setupSubmitting"> - {{ setupSubmitting ? "Working, please wait..." : "Go for it!" }} - </b-button> - ${h.end_form()} + <p class="block"> + Proceed? + </p> + + ${h.form(request.current_route_url(), **{'@submit': 'setupSubmitting = true'})} + ${h.csrf_token(request)} + <b-button type="is-primary" + native-type="submit" + :disabled="setupSubmitting"> + {{ setupSubmitting ? "Working, please wait..." : "Go for it!" }} + </b-button> + ${h.end_form()} + + % else: + + <h3 class="is-size-3 block">Root Folder</h3> + + <p class="block"> + Poser folder already exists at: + <span class="is-family-monospace has-text-weight-bold"> + ${poser_dir} + </span> + </p> + + ${h.form(request.current_route_url(), class_='block', **{'@submit': 'setupSubmitting = true'})} + ${h.csrf_token(request)} + ${h.hidden('action', value='refresh')} + <b-button type="is-primary" + native-type="submit" + :disabled="setupSubmitting" + icon-pack="fas" + icon-left="redo"> + {{ setupSubmitting ? "Working, please wait..." : "Refresh Folder" }} + </b-button> + ${h.end_form()} + + <h3 class="is-size-3 block">Modules</h3> + + <ul class="list" style="max-width: 80%;"> + <li class="list-item"> + <span class="is-family-monospace">poser</span> + <span class="is-pulled-right"> + % if poser_imported['poser']: + <span class="is-family-monospace"> + ${poser_imported['poser'].__file__} + </span> + % else: + <span class="has-background-warning"> + ${poser_import_errors['poser']} + </span> + % endif + </span> + </li> + <li class="list-item"> + <span class="is-family-monospace">poser.reports</span> + <span class="is-pulled-right"> + % if poser_imported['reports']: + <span class="is-family-monospace"> + ${poser_imported['reports'].__file__} + </span> + % else: + <span class="has-background-warning"> + ${poser_import_errors['reports']} + </span> + % endif + </span> + </li> + <li class="list-item"> + <span class="is-family-monospace">poser.web.views</span> + <span class="is-pulled-right"> + % if poser_imported['views']: + <span class="is-family-monospace"> + ${poser_imported['views'].__file__} + </span> + % else: + <span class="has-background-warning"> + ${poser_import_errors['views']} + </span> + % endif + </span> + </li> + </ul> + + % endif </%def> <%def name="modify_this_page_vars()"> diff --git a/tailbone/views/common.py b/tailbone/views/common.py index 3f846f75..1a0567e5 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -26,6 +26,7 @@ Various common views from __future__ import unicode_literals, absolute_import +import os import six from rattail.db import model @@ -206,22 +207,67 @@ class CommonView(View): app = self.get_rattail_app() app_title = self.rattail_config.app_title() poser_handler = app.get_poser_handler() + poser_dir = poser_handler.get_default_poser_dir() + poser_dir_exists = os.path.isdir(poser_dir) if self.request.method == 'POST': - try: - path = poser_handler.make_poser_dir() - except Exception as error: - self.request.session.flash(simple_error(error), 'error') - else: - self.request.session.flash("Poser folder created at: {}".format(path)) - self.request.session.flash("Please restart the web app!", 'warning') - return self.redirect(self.request.route_url('home')) + + # maybe refresh poser dir + if self.request.POST.get('action') == 'refresh': + poser_handler.refresh_poser_dir() + self.request.session.flash("Poser folder has been refreshed.") + + else: # otherwise make poser dir + + if poser_dir_exists: + self.request.session.flash("Poser folder already exists!", 'error') + else: + try: + path = poser_handler.make_poser_dir() + except Exception as error: + self.request.session.flash(simple_error(error), 'error') + else: + self.request.session.flash("Poser folder created at: {}".format(path)) + self.request.session.flash("Please restart the web app!", 'warning') + return self.redirect(self.request.route_url('home')) + + try: + from poser import reports + reports_error = None + except Exception as error: + reports = None + reports_error = simple_error(error) + + try: + from poser.web import views + views_error = None + except Exception as error: + views = None + views_error = simple_error(error) + + try: + import poser + poser_error = None + except Exception as error: + poser = None + poser_error = simple_error(error) return { 'use_buefy': use_buefy, 'app_title': app_title, 'index_title': app_title, - 'poser_dir': poser_handler.get_default_poser_dir(), + 'poser_dir': poser_dir, + 'poser_dir_exists': poser_dir_exists, + 'poser_imported': { + 'poser': poser, + 'reports': reports, + 'views': views, + }, + 'poser_import_errors': { + 'poser': poser_error, + 'reports': reports_error, + 'views': views_error, + }, } @classmethod From 66a15fb9a12a32ae4c96ad6c9a0f4c56e700edf1 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 5 Mar 2022 09:26:25 -0600 Subject: [PATCH 0669/1681] Add initial/basic support for configuring "included views" also stub for managing "poser views" --- tailbone/menus.py | 29 ++- tailbone/templates/poser/views/configure.mako | 41 +++ tailbone/util.py | 23 +- tailbone/views/master.py | 5 +- tailbone/views/poser/__init__.py | 1 + tailbone/views/poser/views.py | 241 ++++++++++++++++++ 6 files changed, 334 insertions(+), 6 deletions(-) create mode 100644 tailbone/templates/poser/views/configure.mako create mode 100644 tailbone/views/poser/views.py diff --git a/tailbone/menus.py b/tailbone/menus.py index 464f081c..46f5c62a 100644 --- a/tailbone/menus.py +++ b/tailbone/menus.py @@ -27,21 +27,42 @@ App Menus from __future__ import unicode_literals, absolute_import import re +import logging -from rattail.util import import_module_path, prettify +from rattail.util import import_module_path, prettify, simple_error + +from webhelpers2.html import tags, HTML from tailbone.db import Session +log = logging.getLogger(__name__) + + def make_simple_menus(request): """ Build the main menu list for the app. """ - # first try to make menus from config - raw_menus = make_menus_from_config(request) + # first try to make menus from config, but this is highly + # susceptible to failure, so try to warn user of problems + raw_menus = None + try: + raw_menus = make_menus_from_config(request) + except Exception as error: + # TODO: these messages show up multiple times on some pages?! + # that must mean the BeforeRender event is firing multiple + # times..but why?? seems like there is only 1 request... + log.warning("failed to make menus from config", exc_info=True) + request.session.flash(simple_error(error), 'error') + request.session.flash("Menu config is invalid! Reverting to menus " + "defined in code!", 'warning') + msg = HTML.literal('Please edit your {} ASAP.'.format( + tags.link_to("Menu Config", request.route_url('configure_menus')))) + request.session.flash(msg, 'warning') + if not raw_menus: - # no config, so import/invoke function to build them + # no config, so import/invoke code function to build them menus_module = import_module_path( request.rattail_config.require('tailbone', 'menus')) if not hasattr(menus_module, 'simple_menus') or not callable(menus_module.simple_menus): diff --git a/tailbone/templates/poser/views/configure.mako b/tailbone/templates/poser/views/configure.mako new file mode 100644 index 00000000..448c56f0 --- /dev/null +++ b/tailbone/templates/poser/views/configure.mako @@ -0,0 +1,41 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="form_content()"> + + <p class="block has-text-weight-bold is-italic"> + NB. Any changes made here will require an app restart! + </p> + + <h3 class="block is-size-3">Tailbone Views</h3> + + <h4 class="block is-size-4">People</h4> + % for key, label in view_settings['people']: + ${self.simple_flag(key, label)} + % endfor + + <h4 class="block is-size-4">Products</h4> + % for key, label in view_settings['products']: + ${self.simple_flag(key, label)} + % endfor + + <h4 class="block is-size-4">Other</h4> + % for key, label in view_settings['other']: + ${self.simple_flag(key, label)} + % endfor + +</%def> + +<%def name="simple_flag(key, label)"> + <b-field label="${label}" horizontal> + <b-select name="tailbone.includes.${key}" + v-model="simpleSettings['tailbone.includes.${key}']" + @input="settingsNeedSaved = true"> + <option :value="null">(disabled)</option> + <option value="${key}">${key}</option> + </b-select> + </b-field> +</%def> + + +${parent.body()} diff --git a/tailbone/util.py b/tailbone/util.py index c0ab4e3e..25e2c587 100644 --- a/tailbone/util.py +++ b/tailbone/util.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. # @@ -251,3 +251,24 @@ def route_exists(request, route_name): mapper = reg.getUtility(IRoutesMapper) route = mapper.get_route(route_name) return bool(route) + + +def include_configured_views(pyramid_config): + """ + Include arbitrary additional views based on DB settings. + """ + rattail_config = pyramid_config.registry.settings.get('rattail_config') + app = rattail_config.get_app() + model = rattail_config.get_model() + session = app.make_session() + + # fetch all include-related settings at once + settings = session.query(model.Setting)\ + .filter(model.Setting.name.like('tailbone.includes.%'))\ + .all() + + for setting in settings: + if setting.value: + pyramid_config.include(setting.value) + + session.close() diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 1214d8aa..a42cca8e 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -4245,12 +4245,15 @@ class MasterView(View): settings = self.configure_gather_settings(data) self.configure_remove_settings() self.configure_save_settings(settings) - self.request.session.flash("Settings have been saved.") + self.configure_flash_settings_saved() return self.redirect(self.request.current_route_url()) context = self.configure_get_context() return self.render_to_response('configure', context) + def configure_flash_settings_saved(self): + self.request.session.flash("Settings have been saved.") + def configure_process_uploads(self, uploads, data): if self.has_input_file_templates: templatesdir = os.path.join(self.rattail_config.datadir(), diff --git a/tailbone/views/poser/__init__.py b/tailbone/views/poser/__init__.py index e721c862..b81580d4 100644 --- a/tailbone/views/poser/__init__.py +++ b/tailbone/views/poser/__init__.py @@ -29,3 +29,4 @@ from __future__ import unicode_literals, absolute_import def includeme(config): config.include('tailbone.views.poser.reports') + config.include('tailbone.views.poser.views') diff --git a/tailbone/views/poser/views.py b/tailbone/views/poser/views.py new file mode 100644 index 00000000..66a0b0db --- /dev/null +++ b/tailbone/views/poser/views.py @@ -0,0 +1,241 @@ +# -*- 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 <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Poser Views for Views... +""" + +from __future__ import unicode_literals, absolute_import + +import six + +import colander + +from .master import PoserMasterView + + +class PoserViewView(PoserMasterView): + """ + Master view for Poser views + """ + normalized_model_name = 'poser_view' + model_title = "Poser View" + route_prefix = 'poser_views' + url_prefix = '/poser/views' + configurable = True + config_title = "Included Views" + + # TODO + creatable = False + editable = False + deletable = False + # downloadable = True + + grid_columns = [ + 'key', + 'class_name', + 'description', + 'error', + ] + + def get_poser_data(self, session=None): + return self.poser_handler.get_all_tailbone_views() + + def make_form_schema(self): + return PoserViewSchema() + + def make_create_form(self): + return self.make_form({}) + + def configure_form(self, f): + super(PoserViewView, self).configure_form(f) + view = f.model_instance + + # key + f.set_default('key', 'cool_widget') + f.set_helptext('key', "Unique key for the view; used as basis for filename.") + if self.creating: + f.set_validator('view_key', self.unique_view_key) + + # class_name + f.set_default('class_name', "CoolWidget") + f.set_helptext('class_name', "Python-friendly basis for view class name.") + + # description + f.set_default('description', "Master view for Cool Widgets") + f.set_helptext('description', "Brief description of the view.") + + def unique_view_key(self, node, value): + for view in self.get_data(): + if view['key'] == value: + raise node.raise_invalid("Poser view key must be unique") + + def collect_available_view_settings(self): + + # TODO: this probably should be more dynamic? definitely need + # to let integration packages register some more options... + + return { + + 'people': { + + # TODO: need some way for integration / extension + # packages to register alternate view options for some + # of these. that is the main reason these are dicts + # even though at the moment it's a bit overkill. + + 'tailbone.views.customers': { + # 'spec': 'tailbone.views.customers', + 'label': "Customers", + }, + 'tailbone.views.customergroups': { + # 'spec': 'tailbone.views.customergroups', + 'label': "Customer Groups", + }, + 'tailbone.views.employees': { + # 'spec': 'tailbone.views.employees', + 'label': "Employees", + }, + 'tailbone.views.members': { + # 'spec': 'tailbone.views.members', + 'label': "Members", + }, + }, + + 'products': { + + 'tailbone.views.departments': { + # 'spec': 'tailbone.views.departments', + 'label': "Departments", + }, + + 'tailbone.views.subdepartments': { + # 'spec': 'tailbone.views.subdepartments', + 'label': "Subdepartments", + }, + + 'tailbone.views.vendors': { + # 'spec': 'tailbone.views.vendors', + 'label': "Vendors", + }, + + 'tailbone.views.products': { + # 'spec': 'tailbone.views.products', + 'label': "Products", + }, + + 'tailbone.views.brands': { + # 'spec': 'tailbone.views.brands', + 'label': "Brands", + }, + + 'tailbone.views.categories': { + # 'spec': 'tailbone.views.categories', + 'label': "Categories", + }, + + 'tailbone.views.depositlinks': { + # 'spec': 'tailbone.views.depositlinks', + 'label': "Deposit Links", + }, + + 'tailbone.views.families': { + # 'spec': 'tailbone.views.families', + 'label': "Families", + }, + + 'tailbone.views.reportcodes': { + # 'spec': 'tailbone.views.reportcodes', + 'label': "Report Codes", + }, + }, + + 'other': { + + 'tailbone.views.stores': { + # 'spec': 'tailbone.views.stores', + 'label': "Stores", + }, + + 'tailbone.views.taxes': { + # 'spec': 'tailbone.views.taxes', + 'label': "Taxes", + }, + }, + } + + def configure_get_simple_settings(self): + settings = [] + + view_settings = self.collect_available_view_settings() + for view_section, section_settings in six.iteritems(view_settings): + for key in section_settings: + settings.append({'section': 'tailbone.includes', + 'option': key}) + + return settings + + def configure_get_context(self, simple_settings=None, + input_file_templates=True): + + # first get normal context + context = super(PoserViewView, self).configure_get_context( + simple_settings=simple_settings, + input_file_templates=input_file_templates) + + # add available settings as sorted (key, label) options + view_settings = self.collect_available_view_settings() + for key in list(view_settings): + settings = view_settings[key] + settings = [(key, setting['label']) + for key, setting in six.iteritems(settings)] + settings.sort(key=lambda itm: itm[1]) + view_settings[key] = settings + context['view_settings'] = view_settings + + return context + + def configure_flash_settings_saved(self): + super(PoserViewView, self).configure_flash_settings_saved() + self.request.session.flash("Please restart the web app!", 'warning') + + +class PoserViewSchema(colander.MappingSchema): + + key = colander.SchemaNode(colander.String()) + + class_name = colander.SchemaNode(colander.String()) + + description = colander.SchemaNode(colander.String()) + + # include_comments = colander.SchemaNode(colander.Bool()) + + +def defaults(config, **kwargs): + base = globals() + + PoserViewView = kwargs.get('PoserViewView', base['PoserViewView']) + PoserViewView.defaults(config) + + +def includeme(config): + defaults(config) From b5effaa01bafb5ac1c86b0bc192a011aa9efc26d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 5 Mar 2022 10:50:33 -0600 Subject: [PATCH 0670/1681] Add `tailbone.views.essentials` to include common / "core" views --- tailbone/views/essentials.py | 41 ++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 tailbone/views/essentials.py diff --git a/tailbone/views/essentials.py b/tailbone/views/essentials.py new file mode 100644 index 00000000..b38749d1 --- /dev/null +++ b/tailbone/views/essentials.py @@ -0,0 +1,41 @@ +# -*- 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 <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Essential views for convenient includes +""" + +from __future__ import unicode_literals, absolute_import + + +def includeme(config): + config.include('tailbone.views.auth') + config.include('tailbone.views.common') + config.include('tailbone.views.email') + config.include('tailbone.views.menus') + config.include('tailbone.views.people') + config.include('tailbone.views.progress') + config.include('tailbone.views.roles') + config.include('tailbone.views.settings') + config.include('tailbone.views.tables') + config.include('tailbone.views.upgrades') + config.include('tailbone.views.users') From 37d4ef751cafabe0d1aa44a266a0b8f55e3b97f4 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 5 Mar 2022 14:31:43 -0600 Subject: [PATCH 0671/1681] Add flash message when upgrade execution completes (pass or fail) --- tailbone/views/upgrades.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index bcbda9c4..ff4de768 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.py @@ -417,6 +417,8 @@ class UpgradeView(MasterView): self.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.") def execute_progress(self): upgrade = self.get_instance() From 57f3b942e5bab5dbd60c1a4699b53577de4cc1a1 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 5 Mar 2022 14:53:09 -0600 Subject: [PATCH 0672/1681] Update changelog --- CHANGES.rst | 22 ++++++++++++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 45cc6777..73099e9b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,28 @@ CHANGELOG ========= +0.8.216 (2022-03-05) +-------------------- + +* Show list of generated reports when viewing Poser Report. + +* Show link back to Poser Report when viewing Generated Report. + +* Always include ``app_title`` in global template rendering context. + +* Update some more view config syntax. + +* Make common web view a bit more common. + +* Improve the Poser Setup page; allow poser dir refresh. + +* Add initial/basic support for configuring "included views". + +* Add ``tailbone.views.essentials`` to include common / "core" views. + +* Add flash message when upgrade execution completes (pass or fail). + + 0.8.215 (2022-03-02) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 07333dcc..f1e71bfd 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.215' +__version__ = '0.8.216' From c4e872c94c395ec4a08fb18304582df598a28f85 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 6 Mar 2022 18:49:09 -0600 Subject: [PATCH 0673/1681] Add the "provider" concept, let them configure db sessions more to come... --- tailbone/app.py | 11 +++++++-- tailbone/providers.py | 56 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 tailbone/providers.py diff --git a/tailbone/app.py b/tailbone/app.py index 6896ea4d..9874e51a 100644 --- a/tailbone/app.py +++ b/tailbone/app.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. # @@ -45,6 +45,7 @@ import tailbone.db from tailbone.auth import TailboneAuthorizationPolicy from tailbone.config import csrf_token_name, csrf_header_name from tailbone.util import get_effective_theme, get_theme_template_path +from tailbone.providers import get_all_providers def make_rattail_config(settings): @@ -115,6 +116,8 @@ def make_pyramid_config(settings, configure_csrf=True): """ Make a Pyramid config object from the given settings. """ + rattail_config = settings['rattail_config'] + config = settings.pop('pyramid_config', None) if config: config.set_root_factory(Root) @@ -132,7 +135,6 @@ def make_pyramid_config(settings, configure_csrf=True): # maybe require CSRF token protection if configure_csrf: - rattail_config = settings['rattail_config'] config.set_default_csrf_options(require_csrf=True, token=csrf_token_name(rattail_config), header=csrf_header_name(rattail_config)) @@ -152,6 +154,11 @@ def make_pyramid_config(settings, configure_csrf=True): else: config.include('pyramid_retry') + # configure DB sessions associated with transaction manager + providers = get_all_providers(rattail_config) + for provider in six.itervalues(providers): + provider.configure_db_sessions(rattail_config, config) + # Add some permissions magic. config.add_directive('add_tailbone_permission_group', 'tailbone.auth.add_permission_group') config.add_directive('add_tailbone_permission', 'tailbone.auth.add_permission') diff --git a/tailbone/providers.py b/tailbone/providers.py new file mode 100644 index 00000000..6752a447 --- /dev/null +++ b/tailbone/providers.py @@ -0,0 +1,56 @@ +# -*- 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 <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Providers for Tailbone features +""" + +from __future__ import unicode_literals, absolute_import + +from rattail.util import load_entry_points + + +class TailboneProvider(object): + """ + Base class for Tailbone providers. These are responsible for + declaring which things a given project makes available to the app. + (Or at least the things which should be easily configurable.) + """ + + def __init__(self, config): + self.config = config + + def configure_db_sessions(self, rattail_config, pyramid_config): + pass + + def get_provided_views(self): + return {} + + +def get_all_providers(config): + """ + Returns a dict of all registered providers. + """ + providers = load_entry_points('tailbone.providers') + for key in list(providers): + providers[key] = providers[key](config) + return providers From d18bade951800fc2606b3f08a6b024ddf00790e9 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 6 Mar 2022 19:03:08 -0600 Subject: [PATCH 0674/1681] Let providers add extra views, options for includes config --- tailbone/templates/poser/views/configure.mako | 27 +++---- tailbone/views/poser/views.py | 79 ++++++++++++++++--- 2 files changed, 77 insertions(+), 29 deletions(-) diff --git a/tailbone/templates/poser/views/configure.mako b/tailbone/templates/poser/views/configure.mako index 448c56f0..f4d75779 100644 --- a/tailbone/templates/poser/views/configure.mako +++ b/tailbone/templates/poser/views/configure.mako @@ -7,21 +7,14 @@ NB. Any changes made here will require an app restart! </p> - <h3 class="block is-size-3">Tailbone Views</h3> - - <h4 class="block is-size-4">People</h4> - % for key, label in view_settings['people']: - ${self.simple_flag(key, label)} - % endfor - - <h4 class="block is-size-4">Products</h4> - % for key, label in view_settings['products']: - ${self.simple_flag(key, label)} - % endfor - - <h4 class="block is-size-4">Other</h4> - % for key, label in view_settings['other']: - ${self.simple_flag(key, label)} + % for topkey, topgroup in sorted(view_settings.items(), key=lambda itm: 'aaaa' if itm[0] == 'rattail' else itm[0]): + <h3 class="block is-size-3">Views for: ${topkey}</h3> + % for group_key, group in six.iteritems(topgroup): + <h4 class="block is-size-4">${group_key.capitalize()}</h4> + % for key, label in group: + ${self.simple_flag(key, label)} + % endfor + % endfor % endfor </%def> @@ -32,7 +25,9 @@ v-model="simpleSettings['tailbone.includes.${key}']" @input="settingsNeedSaved = true"> <option :value="null">(disabled)</option> - <option value="${key}">${key}</option> + % for option in view_options[key]: + <option value="${option}">${option}</option> + % endfor </b-select> </b-field> </%def> diff --git a/tailbone/views/poser/views.py b/tailbone/views/poser/views.py index 66a0b0db..a5fc9a4a 100644 --- a/tailbone/views/poser/views.py +++ b/tailbone/views/poser/views.py @@ -31,6 +31,7 @@ import six import colander from .master import PoserMasterView +from tailbone.providers import get_all_providers class PoserViewView(PoserMasterView): @@ -94,7 +95,7 @@ class PoserViewView(PoserMasterView): # TODO: this probably should be more dynamic? definitely need # to let integration packages register some more options... - return { + everything = {'rattail': { 'people': { @@ -181,16 +182,59 @@ class PoserViewView(PoserMasterView): 'label': "Taxes", }, }, - } + }} + + for key, views in six.iteritems(everything['rattail']): + for vkey, view in six.iteritems(views): + view['options'] = [vkey] + + providers = get_all_providers(self.rattail_config) + for provider in six.itervalues(providers): + + # loop thru provider top-level groups + for topkey, groups in six.iteritems(provider.get_provided_views()): + + # get or create top group + topgroup = everything.setdefault(topkey, {}) + + # loop thru provider view groups + for key, views in six.iteritems(groups): + + # add group to top group, if it's new + if key not in topgroup: + topgroup[key] = views + + # also must init the options for group + for vkey, view in six.iteritems(views): + view['options'] = [vkey] + + else: # otherwise must "update" existing group + + # get ref to existing ("standard") group + stdgroup = topgroup[key] + + # loop thru views within provider group + for vkey, view in six.iteritems(views): + + # add view to group if it's new + if vkey not in stdgroup: + view['options'] = [vkey] + stdgroup[vkey] = view + + else: # otherwise "update" existing view + stdgroup[vkey]['options'].append(view['spec']) + + return everything def configure_get_simple_settings(self): settings = [] view_settings = self.collect_available_view_settings() - for view_section, section_settings in six.iteritems(view_settings): - for key in section_settings: - settings.append({'section': 'tailbone.includes', - 'option': key}) + for topgroup in six.itervalues(view_settings): + for view_section, section_settings in six.iteritems(topgroup): + for key in section_settings: + settings.append({'section': 'tailbone.includes', + 'option': key}) return settings @@ -202,14 +246,23 @@ class PoserViewView(PoserMasterView): simple_settings=simple_settings, input_file_templates=input_file_templates) - # add available settings as sorted (key, label) options + # first add available options view_settings = self.collect_available_view_settings() - for key in list(view_settings): - settings = view_settings[key] - settings = [(key, setting['label']) - for key, setting in six.iteritems(settings)] - settings.sort(key=lambda itm: itm[1]) - view_settings[key] = settings + view_options = {} + for topgroup in six.itervalues(view_settings): + for key, views in six.iteritems(topgroup): + for vkey, view in six.iteritems(views): + view_options[vkey] = view['options'] + context['view_options'] = view_options + + # then add all available settings as sorted (key, label) options + for topkey, topgroup in six.iteritems(view_settings): + for key in list(topgroup): + settings = topgroup[key] + settings = [(key, setting['label']) + for key, setting in six.iteritems(settings)] + settings.sort(key=lambda itm: itm[1]) + topgroup[key] = settings context['view_settings'] = view_settings return context From 7c4e9b56c7f2dc8c25f561a02f6cf6f8e490d368 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 6 Mar 2022 22:06:57 -0600 Subject: [PATCH 0675/1681] Let tailbone providers include static views also add more native (batch) views to default list --- tailbone/app.py | 10 +++++++++- tailbone/providers.py | 3 +++ tailbone/views/poser/views.py | 25 +++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/tailbone/app.py b/tailbone/app.py index 9874e51a..0f24f1fb 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -154,11 +154,19 @@ def make_pyramid_config(settings, configure_csrf=True): else: config.include('pyramid_retry') - # configure DB sessions associated with transaction manager + # fetch all tailbone providers providers = get_all_providers(rattail_config) for provider in six.itervalues(providers): + + # configure DB sessions associated with transaction manager provider.configure_db_sessions(rattail_config, config) + # add any static includes + includes = provider.get_static_includes() + if includes: + for spec in includes: + config.include(spec) + # Add some permissions magic. config.add_directive('add_tailbone_permission_group', 'tailbone.auth.add_permission_group') config.add_directive('add_tailbone_permission', 'tailbone.auth.add_permission') diff --git a/tailbone/providers.py b/tailbone/providers.py index 6752a447..baa2a15d 100644 --- a/tailbone/providers.py +++ b/tailbone/providers.py @@ -42,6 +42,9 @@ class TailboneProvider(object): def configure_db_sessions(self, rattail_config, pyramid_config): pass + def get_static_includes(self): + pass + def get_provided_views(self): return {} diff --git a/tailbone/views/poser/views.py b/tailbone/views/poser/views.py index a5fc9a4a..a7ace23e 100644 --- a/tailbone/views/poser/views.py +++ b/tailbone/views/poser/views.py @@ -170,6 +170,31 @@ class PoserViewView(PoserMasterView): }, }, + 'batches': { + + 'tailbone.views.batch.delproduct': { + 'label': "Delete Product", + }, + 'tailbone.views.batch.inventory': { + 'label': "Inventory", + }, + 'tailbone.views.batch.labels': { + 'label': "Labels", + }, + 'tailbone.views.batch.newproduct': { + 'label': "New Product", + }, + 'tailbone.views.batch.pricing': { + 'label': "Pricing", + }, + 'tailbone.views.batch.product': { + 'label': "Product", + }, + 'tailbone.views.batch.vendorcatalog': { + 'label': "Vendor Catalog", + }, + }, + 'other': { 'tailbone.views.stores': { From 511e185f33514935a03077a006c9fdcd5aec0f80 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 7 Mar 2022 10:53:12 -0600 Subject: [PATCH 0676/1681] Link to email settings profile when viewing email attempt --- tailbone/views/email.py | 3 +++ tailbone/views/master.py | 14 ++++++++++++++ tailbone/views/reports.py | 12 ------------ 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/tailbone/views/email.py b/tailbone/views/email.py index 59737c08..a5687254 100644 --- a/tailbone/views/email.py +++ b/tailbone/views/email.py @@ -425,6 +425,9 @@ class EmailAttemptView(MasterView): def configure_form(self, f): super(EmailAttemptView, self).configure_form(f) + # key + f.set_renderer('key', self.render_email_key) + # status_code f.set_enum('status_code', self.enum.EMAIL_ATTEMPT) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index a42cca8e..873439c4 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -911,6 +911,20 @@ class MasterView(View): url = self.request.route_url('customers.view', uuid=customer.uuid) return tags.link_to(text, url) + def render_email_key(self, obj, field): + if hasattr(obj, field): + email_key = getattr(obj, field) + else: + email_key = obj[field] + if not email_key: + return + + if self.request.has_perm('emailprofiles.view'): + url = self.request.route_url('emailprofiles.view', key=email_key) + return tags.link_to(email_key, url) + + return email_key + def before_create_flush(self, obj, form): pass diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index 74cba68d..1322567d 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -652,18 +652,6 @@ class ProblemReportView(MasterView): f.set_renderer('email_key', self.render_email_key) f.set_renderer('email_recipients', self.render_email_recipients) - def render_email_key(self, report_info, field): - email_key = report_info[field] - if not email_key: - return - - if self.request.has_perm('emailprofiles.view'): - text = email_key - url = self.request.route_url('emailprofiles.view', key=email_key) - return tags.link_to(text, url) - - return email_key - def render_email_recipients(self, report_info, field): recips = report_info['email_recipients'] return ', '.join(recips) From e38cfda0766af36b6b934bba6ba7498eb088ce98 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 7 Mar 2022 11:16:25 -0600 Subject: [PATCH 0677/1681] 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 73099e9b..23ee5e5c 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,18 @@ CHANGELOG ========= +0.8.217 (2022-03-07) +-------------------- + +* Add the "provider" concept, let them configure db sessions. + +* Let providers add extra views, options for includes config. + +* Let tailbone providers include static views. + +* Link to email settings profile when viewing email attempt. + + 0.8.216 (2022-03-05) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index f1e71bfd..c31482ec 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.216' +__version__ = '0.8.217' From 8f4b22312517c5de7e350c8ed72559deadcc9300 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 7 Mar 2022 17:12:06 -0600 Subject: [PATCH 0678/1681] Log warning/traceback when failing to include a configured view --- tailbone/util.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tailbone/util.py b/tailbone/util.py index 25e2c587..38b9a0c2 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -31,6 +31,7 @@ import datetime import six import pytz import humanize +import logging from rattail.time import timezone, make_utc from rattail.files import resource_path @@ -41,6 +42,9 @@ from pyramid.interfaces import IRoutesMapper from webhelpers2.html import HTML, tags +log = logging.getLogger(__name__) + + def get_csrf_token(request): """ Convenience function to retrieve the effective CSRF token for the given @@ -269,6 +273,9 @@ def include_configured_views(pyramid_config): for setting in settings: if setting.value: - pyramid_config.include(setting.value) + try: + pyramid_config.include(setting.value) + except: + log.warning("pyramid failed to include: %s", exc_info=True) session.close() From 9d5adf7793dffaa24d72b60b3acf667c828363ab Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 7 Mar 2022 17:40:48 -0600 Subject: [PATCH 0679/1681] Fix gotcha when defining new provider views UI should show the key if label is missing --- tailbone/views/poser/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/poser/views.py b/tailbone/views/poser/views.py index a7ace23e..e69d51d3 100644 --- a/tailbone/views/poser/views.py +++ b/tailbone/views/poser/views.py @@ -284,7 +284,7 @@ class PoserViewView(PoserMasterView): for topkey, topgroup in six.iteritems(view_settings): for key in list(topgroup): settings = topgroup[key] - settings = [(key, setting['label']) + settings = [(key, setting.get('label', key)) for key, setting in six.iteritems(settings)] settings.sort(key=lambda itm: itm[1]) topgroup[key] = settings From caa13f5a753f966aa3d0b9846a83cc499253fb1d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 8 Mar 2022 14:47:00 -0600 Subject: [PATCH 0680/1681] Bump the default Buefy version to 0.8.13 0.8.6 seemed to be causing some problems. probably need to bump it even further but 0.8.13 has been the "soft default" for a while.. --- tailbone/subscribers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 741f9265..e830f1f4 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -161,7 +161,7 @@ def before_render(event): renderer_globals['vue_version'] = request.rattail_config.get( 'tailbone', 'vue_version') or '2.6.10' renderer_globals['buefy_version'] = request.rattail_config.get( - 'tailbone', 'buefy_version') or '0.8.6' + 'tailbone', 'buefy_version') or '0.8.13' # maybe set custom stylesheet css = None From a9e64e931eff433b9c8369dad1e38505e07cc932 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 8 Mar 2022 14:49:00 -0600 Subject: [PATCH 0681/1681] 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 23ee5e5c..d3b01973 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.8.218 (2022-03-08) +-------------------- + +* Log warning/traceback when failing to include a configured view. + +* Fix gotcha when defining new provider views. + +* Bump the default Buefy version to 0.8.13. + + 0.8.217 (2022-03-07) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index c31482ec..8f687a63 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.217' +__version__ = '0.8.218' From 0a42ec77b2b130be77413c44456c2a43c22f8d92 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 8 Mar 2022 16:35:11 -0600 Subject: [PATCH 0682/1681] Cleanup grid filters for vendor catalog batches --- tailbone/views/batch/vendorcatalog.py | 31 +++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/tailbone/views/batch/vendorcatalog.py b/tailbone/views/batch/vendorcatalog.py index 86d8404b..3661dc25 100644 --- a/tailbone/views/batch/vendorcatalog.py +++ b/tailbone/views/batch/vendorcatalog.py @@ -148,10 +148,33 @@ class VendorCatalogView(FileBatchMasterView): def configure_grid(self, g): super(VendorCatalogView, self).configure_grid(g) - g.joiners['vendor'] = lambda q: q.join(model.Vendor) - g.filters['vendor'] = g.make_filter('vendor', model.Vendor.name, - default_active=True, default_verb='contains') - g.sorters['vendor'] = g.make_sorter(model.Vendor.name) + model = self.model + + # nb. this batch has vendor_id and vendor_name fields, but in + # practice they aren't used much and normally just the vendor + # proper is set. so we remove simple filters and add the + # custom one for (referenced) vendor name + g.remove_filter('vendor_id') + g.remove_filter('vendor_name') + g.set_joiner('vendor', lambda q: q.join(model.Vendor)) + g.set_filter('vendor', model.Vendor.name, + default_active=True, + default_verb='contains') + # and this is the same thing we are showing as grid column + g.set_sorter('vendor', model.Vendor.name) + + # preferred filters + g.set_filters_sequence([ + 'id', + 'vendor', + 'description', + 'executed', + 'created', + 'filename', + 'future', + 'effective', + 'notes', + ]) g.set_link('vendor') g.set_link('filename') From b9fa324bb47f970a283d7edd526be3bda3ab18ae Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 9 Mar 2022 18:26:27 -0600 Subject: [PATCH 0683/1681] Cleanup view config syntax for vendor catalog batch also make sure vendor autocomplete url exists, before using that widget. this can be an issue when app deals "directly" with POS when making the batch etc. --- tailbone/views/batch/vendorcatalog.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/tailbone/views/batch/vendorcatalog.py b/tailbone/views/batch/vendorcatalog.py index 3661dc25..f1dd9754 100644 --- a/tailbone/views/batch/vendorcatalog.py +++ b/tailbone/views/batch/vendorcatalog.py @@ -208,7 +208,17 @@ class VendorCatalogView(FileBatchMasterView): if self.creating and 'vendor' in f: f.replace('vendor', 'vendor_uuid') f.set_label('vendor_uuid', "Vendor") + + # should we use dropdown or autocomplete? note that if + # autocomplete is to be used, we also must make sure we + # have an autocomplete url registered use_dropdown = vendor_handler.choice_uses_dropdown() + if not use_dropdown: + try: + vendors_url = self.request.route_url('vendors.autocomplete') + except KeyError: + use_dropdown = True + if use_dropdown: vendors = self.Session.query(model.Vendor)\ .order_by(model.Vendor.id) @@ -225,7 +235,6 @@ class VendorCatalogView(FileBatchMasterView): self.request.POST['vendor_uuid']) if vendor: vendor_display = six.text_type(vendor) - vendors_url = self.request.route_url('vendors.autocomplete') f.set_widget('vendor_uuid', forms.widgets.JQueryAutocompleteWidget( field_display=vendor_display, @@ -328,6 +337,11 @@ class VendorCatalogView(FileBatchMasterView): g.set_label('unit_cost', "New Cost") g.set_label('unit_cost_diff', "Diff. $") + g.set_link('upc') + g.set_link('brand') + g.set_link('description') + g.set_link('vendor_code') + def row_grid_extra_class(self, row, i): if row.status_code == row.STATUS_PRODUCT_NOT_FOUND: return 'warning' @@ -422,5 +436,12 @@ class VendorCatalogView(FileBatchMasterView): VendorCatalogsView = VendorCatalogView -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + VendorCatalogView = kwargs.get('VendorCatalogView', base['VendorCatalogView']) VendorCatalogView.defaults(config) + + +def includeme(config): + defaults(config) From 01b78d7513fa3dfbc0ca9acb4cc1d522d0c2899d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 9 Mar 2022 18:39:12 -0600 Subject: [PATCH 0684/1681] Add workaround when inserting new fields to form field list i.e. if inserting "before" or "after" a field which does not exist --- tailbone/forms/core.py | 18 ++++++++++++++---- tailbone/views/users.py | 4 +++- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 1088ca9b..98d11a45 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -1044,12 +1044,22 @@ class FieldList(list): """ def insert_before(self, field, newfield): - i = self.index(field) - self.insert(i, newfield) + if field in self: + i = self.index(field) + self.insert(i, newfield) + else: + log.warning("field '%s' not found, will append new field: %s", + field, newfield) + self.append(newfield) def insert_after(self, field, newfield): - i = self.index(field) - self.insert(i + 1, newfield) + if field in self: + i = self.index(field) + self.insert(i + 1, newfield) + else: + log.warning("field '%s' not found, will append new field: %s", + field, newfield) + self.append(newfield) @colander.deferred diff --git a/tailbone/views/users.py b/tailbone/views/users.py index ecff3bb9..d9815f50 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -69,6 +69,7 @@ class UserView(PrincipalMasterView): 'active_sticky', 'set_password', 'roles', + 'permissions', ] row_grid_columns = [ @@ -256,11 +257,12 @@ class UserView(PrincipalMasterView): if self.viewing: permissions = self.request.registry.settings.get('tailbone_permissions', {}) - f.append('permissions') f.set_renderer('permissions', PermissionsRenderer(request=self.request, permissions=permissions, include_guest=True, include_authenticated=True)) + else: + f.remove('permissions') if self.viewing or self.deleting: f.remove('set_password') From e284370c4bc81844b08574afb4bb343de6e83145 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 9 Mar 2022 19:41:46 -0600 Subject: [PATCH 0685/1681] Add `Form.insert()` method, to insert field based on index --- tailbone/forms/core.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 98d11a45..3d87cee7 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -447,6 +447,9 @@ class Form(object): def append(self, field): self.fields.append(field) + def insert(self, index, field): + self.fields.insert(index, field) + def insert_before(self, field, newfield): self.fields.insert_before(field, newfield) From 4e892d09ecf59958a0d389a6f6241fd886fda643 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 10 Mar 2022 09:53:15 -0600 Subject: [PATCH 0686/1681] Add line break for report chooser page --- tailbone/templates/reports/generated/choose.mako | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/templates/reports/generated/choose.mako b/tailbone/templates/reports/generated/choose.mako index a6cb8977..7f24bfde 100644 --- a/tailbone/templates/reports/generated/choose.mako +++ b/tailbone/templates/reports/generated/choose.mako @@ -84,6 +84,7 @@ % endif % else: <div> + <br /> <p>Please select the type of report you wish to generate.</p> <div class="report-selection"> From 69161b7037b766ae1e0b956e9ff3cd5d10e49787 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 10 Mar 2022 09:55:42 -0600 Subject: [PATCH 0687/1681] Default behavior for report chooser should *not* be form/dropdown --- tailbone/views/reports.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index 1322567d..95c50772 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -375,7 +375,7 @@ class ReportOutputView(ExportMasterView): for r in reports.values()]), 'use_form': self.rattail_config.getbool( 'tailbone', 'reporting.choosing_uses_form', - default=True), + default=False), }) def generate(self): From 25ecade1e665b6f5b87cb841afb758ea6e5c400b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 10 Mar 2022 10:18:43 -0600 Subject: [PATCH 0688/1681] Add "batch" to model title for new customer order batch just to make things a bit more clear.. --- tailbone/views/custorders/creating.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/views/custorders/creating.py b/tailbone/views/custorders/creating.py index c14448eb..d0d648b5 100644 --- a/tailbone/views/custorders/creating.py +++ b/tailbone/views/custorders/creating.py @@ -40,8 +40,8 @@ class CreateCustomerOrderBatchView(CustomerOrderBatchView): """ route_prefix = 'new_custorders' url_prefix = '/new-customer-orders' - model_title = "New Customer Order" - model_title_plural = "New Customer Orders" + model_title = "New Customer Order Batch" + model_title_plural = "New Customer Order Batches" creatable = False From 7e15f75d4459ccf2f05c74afd671a20a80419cfc Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 10 Mar 2022 10:19:55 -0600 Subject: [PATCH 0689/1681] 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 d3b01973..0d63cc5e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,20 @@ CHANGELOG ========= +0.8.219 (2022-03-10) +-------------------- + +* Cleanup grid filters for vendor catalog batches. + +* Cleanup view config syntax for vendor catalog batch. + +* Add workaround when inserting new fields to form field list. + +* Add ``Form.insert()`` method, to insert field based on index. + +* Default behavior for report chooser should *not* be form/dropdown. + + 0.8.218 (2022-03-08) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 8f687a63..d878423c 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.218' +__version__ = '0.8.219' From 6037519fbe307a26eeb6e0bbfdbaab4bc85625b1 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 11 Mar 2022 12:37:43 -0600 Subject: [PATCH 0690/1681] Log error instead of warning, when batch population fails user experience does not change but should help the admin to track down the problem quicker.. --- tailbone/views/batch/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index b4911cbc..ae96419c 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -1057,7 +1057,7 @@ class BatchMasterView(MasterView): self.handler.do_populate(batch, user, progress=progress) except Exception as error: session.rollback() - log.warning("batch population failed: %s", batch, exc_info=True) + log.exception("batch population failed: %s", batch) session.close() if progress: progress.session.load() From da910b1414fd1eb887312bd9ae9423a3a3902546 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 11 Mar 2022 20:55:01 -0600 Subject: [PATCH 0691/1681] Add default help link for Receiving feature also stop showing "buyer" filter by default --- tailbone/views/master.py | 4 ++++ tailbone/views/purchasing/batch.py | 8 ++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 873439c4..e6e96a67 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -155,6 +155,7 @@ class MasterView(View): use_index_links = False has_versions = False + default_help_url = None help_url = None labels = {'uuid': "UUID"} @@ -2167,6 +2168,9 @@ class MasterView(View): if self.help_url: return self.help_url + if self.default_help_url: + return self.default_help_url + return global_help_url(self.rattail_config) def render_to_response(self, template, data, **kwargs): diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index 8a015838..93d7ff21 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -49,6 +49,7 @@ 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", @@ -179,10 +180,9 @@ class PurchasingBatchView(BatchMasterView): g.filters['department'] = g.make_filter('department', model.Department.name) g.sorters['department'] = g.make_sorter(model.Department.name) - g.joiners['buyer'] = lambda q: q.join(model.Employee).join(model.Person) - g.filters['buyer'] = g.make_filter('buyer', model.Person.display_name, - default_active=True, default_verb='contains') - g.sorters['buyer'] = g.make_sorter(model.Person.display_name) + g.set_joiner('buyer', lambda q: q.join(model.Employee).join(model.Person)) + g.set_filter('buyer', model.Person.display_name) + g.set_sorter('buyer', model.Person.display_name) # TODO: we used to include the 'complete' filter by default, but it # seems to likely be confusing for newcomers, so it is no longer From fad8b44be2660c60868619fde24792c2f728ff40 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 15 Mar 2022 19:55:55 -0500 Subject: [PATCH 0692/1681] 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 0d63cc5e..05329751 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.220 (2022-03-15) +-------------------- + +* Log error instead of warning, when batch population fails. + +* Add default help link for Receiving feature. + + 0.8.219 (2022-03-10) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index d878423c..70a09665 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.219' +__version__ = '0.8.220' From 0904cda2c634f33b02069892cdd149d451accfc9 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 15 Mar 2022 22:53:24 -0500 Subject: [PATCH 0693/1681] Always show batch params by default when viewing --- 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 ae96419c..61c893e9 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -102,6 +102,7 @@ class BatchMasterView(MasterView): 'id', 'description', 'notes', + 'params', 'created', 'created_by', 'rowcount', From 322335f4abd3b9db629c1e1d20f91e6f0781f312 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 15 Mar 2022 22:58:19 -0500 Subject: [PATCH 0694/1681] Show helptext when applicable for "new batch from product query" --- tailbone/templates/products/batch.mako | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tailbone/templates/products/batch.mako b/tailbone/templates/products/batch.mako index efeaac1e..be055b50 100644 --- a/tailbone/templates/products/batch.mako +++ b/tailbone/templates/products/batch.mako @@ -39,6 +39,9 @@ <%def name="render_deform_field(form, field)"> % if use_buefy: <b-field horizontal + % if field.description: + message="${field.description}" + % endif % if field.error: type="is-danger" :message='${form.messages_json(field.error.messages())|n}' From 71d8d5a70d58e0392d390a60118b368219103214 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 16 Mar 2022 21:27:59 -0500 Subject: [PATCH 0695/1681] Make problem report titles searchable in grid at least if buefy version is new enough --- tailbone/views/reports.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index 95c50772..ced301c8 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -625,6 +625,8 @@ class ProblemReportView(MasterView): g.set_renderer('email_recipients', self.render_email_recipients) + g.set_searchable('problem_title') + g.set_link('problem_key') g.set_link('problem_title') From cdae4bf8da020d10e2703dcedc5407e755317e73 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 16 Mar 2022 21:28:47 -0500 Subject: [PATCH 0696/1681] 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 05329751..21d6627d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.8.221 (2022-03-16) +-------------------- + +* Always show batch params by default when viewing. + +* Show helptext when applicable for "new batch from product query". + +* Make problem report titles searchable in grid. + + 0.8.220 (2022-03-15) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 70a09665..aa34e70a 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.220' +__version__ = '0.8.221' From fc5b93100744db66cb4f2832be1cdb77109d78bf Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 17 Mar 2022 16:59:50 -0500 Subject: [PATCH 0697/1681] Expose custorder xref markers for trainwreck --- .../trainwreck/transactions/view.mako | 11 ++++- tailbone/views/trainwreck/base.py | 44 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/tailbone/templates/trainwreck/transactions/view.mako b/tailbone/templates/trainwreck/transactions/view.mako index 601fa053..2be51c7d 100644 --- a/tailbone/templates/trainwreck/transactions/view.mako +++ b/tailbone/templates/trainwreck/transactions/view.mako @@ -1,6 +1,15 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -## nb. this exists just so everyone can inherit from it +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + % if custorder_xref_markers_data is not Undefined: + ${form.component_studly}Data.custorderXrefMarkersData = ${json.dumps(custorder_xref_markers_data)|n} + % endif + + </script> +</%def> ${parent.body()} diff --git a/tailbone/views/trainwreck/base.py b/tailbone/views/trainwreck/base.py index 8a610104..a1bfbe0a 100644 --- a/tailbone/views/trainwreck/base.py +++ b/tailbone/views/trainwreck/base.py @@ -91,6 +91,7 @@ class TransactionView(MasterView): 'shopper_id', 'shopper_name', 'shopper_level_number', + 'custorder_xref_markers', 'subtotal', 'discounted_subtotal', 'tax', @@ -136,6 +137,7 @@ class TransactionView(MasterView): 'subdepartment_number', 'subdepartment_name', 'description', + 'custorder_item_xref', 'unit_quantity', 'subtotal', 'discounts', @@ -196,6 +198,9 @@ class TransactionView(MasterView): f.set_type('total', 'currency') f.set_type('patronage', 'currency') + # custorder_xref_markers + f.set_renderer('custorder_xref_markers', self.render_custorder_xref_markers) + # label overrides f.set_label('system_id', "System ID") f.set_label('terminal_id', "Terminal") @@ -203,6 +208,45 @@ class TransactionView(MasterView): f.set_label('customer_id', "Customer ID") f.set_label('shopper_id', "Shopper ID") + def render_custorder_xref_markers(self, txn, field): + markers = getattr(txn, field) + if not markers: + return + + route_prefix = self.get_route_prefix() + factory = self.get_grid_factory() + use_buefy = self.get_use_buefy() + + g = factory( + key='{}.custorder_xref_markers'.format(route_prefix), + data=[] if use_buefy else txn.custorder_xref_markers, + columns=['custorder_xref', 'custorder_item_xref'], + request=self.request) + + if use_buefy: + return HTML.literal( + g.render_buefy_table_element(data_prop='custorderXrefMarkersData')) + else: + return HTML.literal(g.render_grid()) + + def template_kwargs_view(self, **kwargs): + kwargs = super(TransactionView, self).template_kwargs_view(**kwargs) + + use_buefy = self.get_use_buefy() + if use_buefy: + form = kwargs['form'] + if 'custorder_xref_markers' in form: + txn = kwargs['instance'] + markers = [] + for marker in txn.custorder_xref_markers: + markers.append({ + 'custorder_xref': marker.custorder_xref, + 'custorder_item_xref': marker.custorder_item_xref, + }) + kwargs['custorder_xref_markers_data'] = markers + + return kwargs + def get_row_data(self, transaction): return self.Session.query(self.model_row_class)\ .filter(self.model_row_class.transaction == transaction) From c72d99794ec40cf44b484ba87c64b0f329ffd0a4 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 17 Mar 2022 17:35:00 -0500 Subject: [PATCH 0698/1681] 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 21d6627d..5c805cb7 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.222 (2022-03-17) +-------------------- + +* Expose custorder xref markers for trainwreck. + + 0.8.221 (2022-03-16) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index aa34e70a..f02db635 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.221' +__version__ = '0.8.222' From ab3a66542d44b2fee6ac61477d4f47b24b9bcc3c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 17 Mar 2022 21:19:05 -0500 Subject: [PATCH 0699/1681] Show link to txn as field when viewing trainwreck item --- tailbone/views/trainwreck/base.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tailbone/views/trainwreck/base.py b/tailbone/views/trainwreck/base.py index a1bfbe0a..43b52657 100644 --- a/tailbone/views/trainwreck/base.py +++ b/tailbone/views/trainwreck/base.py @@ -30,7 +30,7 @@ import six from rattail.time import localtime -from webhelpers2.html import HTML +from webhelpers2.html import HTML, tags from tailbone.db import Session, TrainwreckSession, ExtraTrainwreckSessions from tailbone.views import MasterView @@ -128,6 +128,7 @@ class TransactionView(MasterView): ] row_form_fields = [ + 'transaction', 'sequence', 'item_type', 'item_scancode', @@ -271,9 +272,15 @@ class TransactionView(MasterView): if row.void: return 'warning' + def get_row_instance_title(self, instance): + return "Trainwreck Line Item" + def configure_row_form(self, f): super(TransactionView, self).configure_row_form(f) + # transaction + f.set_renderer('transaction', self.render_transaction) + # quantity fields f.set_type('unit_quantity', 'quantity') @@ -287,6 +294,12 @@ class TransactionView(MasterView): # discounts f.set_renderer('discounts', self.render_discounts) + def render_transaction(self, item, field): + txn = getattr(item, field) + text = six.text_type(txn) + url = self.get_action_url('view', txn) + return tags.link_to(text, url) + def render_discounts(self, item, field): if not item.discounts: return From 777a7afd46ea9907c88a04f4484214545d65f4a7 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 21 Mar 2022 17:33:26 -0500 Subject: [PATCH 0700/1681] 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 5c805cb7..bb2d5914 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.223 (2022-03-21) +-------------------- + +* Show link to txn as field when viewing trainwreck item. + + 0.8.222 (2022-03-17) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index f02db635..457a3aa5 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.222' +__version__ = '0.8.223' From ae1e9dba0f409a1ee507cb0fcc670c5f73eb3d91 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 25 Mar 2022 12:33:37 -0500 Subject: [PATCH 0701/1681] Improve vendor validation for new receiving batch --- tailbone/views/purchasing/batch.py | 6 ++++++ tailbone/views/purchasing/receiving.py | 16 +++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index 93d7ff21..86ee057a 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -405,6 +405,12 @@ class PurchasingBatchView(BatchMasterView): 'vendor_contact', 'status_code') + def valid_vendor_uuid(self, node, value): + model = self.model + vendor = self.Session.query(model.Vendor).get(value) + if not vendor: + raise colander.Invalid(node, "Invalid vendor selection") + def render_store(self, batch, field): store = batch.store if not store: diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index e8123406..d2fc2fc5 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -266,6 +266,8 @@ class ReceivingBatchView(PurchasingBatchView): # least will not need customization for that. if self.request.matched_route.name.endswith('create_workflow'): + redirect = self.redirect(self.request.route_url('{}.create'.format(route_prefix))) + # however we do have one more thing to check - the workflow # requested must of course be valid! workflow_key = self.request.matchdict['workflow_key'] @@ -273,7 +275,18 @@ class ReceivingBatchView(PurchasingBatchView): self.request.session.flash( "Not a supported workflow: {}".format(workflow_key), 'error') - raise self.redirect(self.request.route_url('{}.create'.format(route_prefix))) + raise redirect + + # also, we require vendor to be correctly identified. if + # someone e.g. navigates to a URL by accident etc. we want + # to gracefully handle and redirect + uuid = self.request.matchdict['vendor_uuid'] + vendor = self.Session.query(model.Vendor).get(uuid) + if not vendor: + self.request.session.flash("Invalid vendor selection. " + "Please choose an existing vendor.", + 'warning') + raise redirect # okay now do the normal thing, per workflow return super(ReceivingBatchView, self).create(**kwargs) @@ -318,6 +331,7 @@ class ReceivingBatchView(PurchasingBatchView): vendors_url = self.request.route_url('vendors.autocomplete') form.set_widget('vendor', forms.widgets.JQueryAutocompleteWidget( field_display=vendor_display, service_url=vendors_url)) + form.set_validator('vendor', self.valid_vendor_uuid) # configure workflow field values = [(workflow['workflow_key'], workflow['display']) From f0b6b627914ed8077656d8341bf1e5a0776bfc08 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 25 Mar 2022 13:49:39 -0500 Subject: [PATCH 0702/1681] Use common logic for fetching batch handler --- tailbone/views/batch/core.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 61c893e9..7d6216d3 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -45,7 +45,7 @@ from sqlalchemy import orm from rattail.db import model, Session as RattailSession from rattail.db.util import short_session from rattail.threads import Thread -from rattail.util import load_object, prettify, simple_error +from rattail.util import prettify, simple_error from rattail.progress import SocketProgress import colander @@ -142,12 +142,13 @@ class BatchMasterView(MasterView): ``batch_key`` attribute of the main batch model class. """ # first try to figure out if config defines a factory class + app = rattail_config.get_app() model_class = cls.get_model_class() batch_key = model_class.batch_key - spec = rattail_config.get('rattail.batch', '{}.handler'.format(batch_key), - default=cls.default_handler_spec) - if spec: # yep, so use that - return load_object(spec) + handler = app.get_batch_handler(batch_key, + default=cls.default_handler_spec) + if handler: + return handler.__class__ # fall back to whatever class was defined statically return cls.batch_handler_class From 1bad1cd3e7ac70e3e6790865e347bfb609cc4536 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 25 Mar 2022 22:18:01 -0500 Subject: [PATCH 0703/1681] 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 bb2d5914..46885a95 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.224 (2022-03-25) +-------------------- + +* Improve vendor validation for new receiving batch. + +* Use common logic for fetching batch handler. + + 0.8.223 (2022-03-21) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 457a3aa5..f1a759ec 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.223' +__version__ = '0.8.224' From b4d5d70e4cc9f68a89b132c3bcc1a8746820ff54 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 26 Mar 2022 15:29:28 -0500 Subject: [PATCH 0704/1681] 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 <lance@edbob.org> Date: Tue, 29 Mar 2022 10:32:03 -0500 Subject: [PATCH 0705/1681] 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 <lance@edbob.org> Date: Tue, 29 Mar 2022 11:39:32 -0500 Subject: [PATCH 0706/1681] 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 <lance@edbob.org> Date: Tue, 29 Mar 2022 11:49:24 -0500 Subject: [PATCH 0707/1681] 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 <lance@edbob.org> Date: Tue, 29 Mar 2022 17:19:14 -0500 Subject: [PATCH 0708/1681] 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 <lance@edbob.org> Date: Tue, 29 Mar 2022 17:19:23 -0500 Subject: [PATCH 0709/1681] 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 <lance@edbob.org> Date: Tue, 29 Mar 2022 17:30:37 -0500 Subject: [PATCH 0710/1681] 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 <lance@edbob.org> Date: Tue, 29 Mar 2022 17:43:42 -0500 Subject: [PATCH 0711/1681] 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 <lance@edbob.org> Date: Tue, 29 Mar 2022 18:19:14 -0500 Subject: [PATCH 0712/1681] 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 @@ <h3 class="block is-size-3">Product Handling</h3> <div class="block" style="padding-left: 2rem;"> - <b-field message="If set, user can enter details of an arbitrary new "pending" product."> - <b-checkbox name="rattail.custorders.allow_unknown_product" - v-model="simpleSettings['rattail.custorders.allow_unknown_product']" + <b-field> + <b-checkbox name="rattail.custorders.allow_case_orders" + v-model="simpleSettings['rattail.custorders.allow_case_orders']" native-value="true" @input="settingsNeedSaved = true"> - Allow creating orders for "unknown" products + Allow "case" orders + </b-checkbox> + </b-field> + + <b-field> + <b-checkbox name="rattail.custorders.allow_unit_orders" + v-model="simpleSettings['rattail.custorders.allow_unit_orders']" + native-value="true" + @input="settingsNeedSaved = true"> + Allow "unit" orders </b-checkbox> </b-field> @@ -70,6 +79,15 @@ </b-checkbox> </b-field> + <b-field message="If set, user can enter details of an arbitrary new "pending" product."> + <b-checkbox name="rattail.custorders.allow_unknown_product" + v-model="simpleSettings['rattail.custorders.allow_unknown_product']" + native-value="true" + @input="settingsNeedSaved = true"> + Allow creating orders for "unknown" products + </b-checkbox> + </b-field> + </div> </%def> 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 @@ <b-field grouped> <b-field label="Quantity" horizontal> - <b-input v-model="productQuantity" - type="number" step="0.01"> - </b-input> + <numeric-input v-model="productQuantity"> + </numeric-input> </b-field> <b-select v-model="productUOM"> @@ -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 <lance@edbob.org> Date: Sun, 3 Apr 2022 14:42:40 -0500 Subject: [PATCH 0713/1681] 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 <lance@edbob.org> Date: Mon, 4 Apr 2022 13:56:27 -0500 Subject: [PATCH 0714/1681] 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 @@ <li>${h.link_to("Clone this as new {}".format(model_title), url('{}.clone'.format(route_prefix), uuid=instance.uuid))}</li> % endif % if master.touchable and request.has_perm('{}.touch'.format(permission_prefix)): - <li>${h.link_to("\"Touch\" this {}".format(model_title), url('{}.touch'.format(route_prefix), uuid=instance.uuid))}</li> + <li>${h.link_to("\"Touch\" this {}".format(model_title), master.get_action_url('touch', instance))}</li> % endif % if not use_buefy and master.has_rows and master.rows_downloadable_csv and master.has_perm('row_results_csv'): <li>${h.link_to("Download row results as CSV", master.get_action_url('row_results_csv', instance))}</li> From 56c5c4e540c5aa6a5b1e2b1cacb2c57f3033f90c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 4 Apr 2022 13:57:31 -0500 Subject: [PATCH 0715/1681] 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 <lance@edbob.org> Date: Thu, 7 Apr 2022 12:57:40 -0500 Subject: [PATCH 0716/1681] 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 <lance@edbob.org> Date: Wed, 13 Apr 2022 16:42:47 -0500 Subject: [PATCH 0717/1681] 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 <lance@edbob.org> Date: Wed, 13 Apr 2022 20:19:00 -0500 Subject: [PATCH 0718/1681] 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 <lance@edbob.org> Date: Tue, 3 May 2022 13:36:14 -0500 Subject: [PATCH 0719/1681] 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 <lance@edbob.org> Date: Tue, 3 May 2022 13:43:57 -0500 Subject: [PATCH 0720/1681] 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 <lance@edbob.org> Date: Tue, 3 May 2022 14:13:47 -0500 Subject: [PATCH 0721/1681] 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 <lance@edbob.org> Date: Tue, 10 May 2022 20:06:21 -0500 Subject: [PATCH 0722/1681] 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 <lance@edbob.org> Date: Tue, 10 May 2022 20:08:06 -0500 Subject: [PATCH 0723/1681] 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 <lance@edbob.org> Date: Sun, 15 May 2022 16:04:22 -0500 Subject: [PATCH 0724/1681] 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 @@ </b-field> </div> + + <h3 class="block is-size-3">Supported Vendors</h3> + <div class="block" style="padding-left: 2rem;"> + + <p class="block"> + 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. + </p> + + <b-field v-for="setting in supportedVendorSettings" + :key="setting.key" + horizontal + :label="setting.key" + :type="supportedVendorSettings[setting.key].value ? null : 'is-warning'" + style="max-width: 75%;"> + + <tailbone-autocomplete :name="'rattail.vendor.' + setting.key" + service-url="${url('vendors.autocomplete')}" + v-model="supportedVendorSettings[setting.key].value" + :initial-label="setting.label" + @input="settingsNeedSaved = true"> + </tailbone-autocomplete> + </b-field> + + </div> +</%def> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + ThisPageData.supportedVendorSettings = ${json.dumps(supported_vendor_settings)|n} + + </script> </%def> 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 <lance@edbob.org> Date: Sun, 15 May 2022 16:45:31 -0500 Subject: [PATCH 0725/1681] 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()"> - <h3 class="block is-size-3">Supported Workflows</h3> + <h3 class="block is-size-3">Workflows</h3> <div class="block" style="padding-left: 2rem;"> + <p class="block"> + Users can only choose from the workflows enabled below. + </p> + <b-field> <b-checkbox name="rattail.batch.purchase.allow_receiving_from_scratch" v-model="simpleSettings['rattail.batch.purchase.allow_receiving_from_scratch']" @@ -53,6 +57,20 @@ </div> + <h3 class="block is-size-3">Vendors</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field message="If set, user must choose a "supported" vendor; otherwise they may choose "any" vendor."> + <b-checkbox name="rattail.batch.purchase.supported_vendors_only" + v-model="simpleSettings['rattail.batch.purchase.supported_vendors_only']" + native-value="true" + @input="settingsNeedSaved = true"> + Only allow batch for "supported" vendors + </b-checkbox> + </b-field> + + </div> + <h3 class="block is-size-3">Display</h3> <div class="block" style="padding-left: 2rem;"> 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 <lance@edbob.org> Date: Sun, 15 May 2022 16:47:31 -0500 Subject: [PATCH 0726/1681] 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 <lance@edbob.org> Date: Tue, 14 Jun 2022 11:25:29 -0500 Subject: [PATCH 0727/1681] 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 <lance@edbob.org> Date: Tue, 14 Jun 2022 13:51:00 -0500 Subject: [PATCH 0728/1681] 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()"> + + <h3 class="block is-size-3">Options</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field> + <b-checkbox name="rattail.batch.pricing.allow_future" + v-model="simpleSettings['rattail.batch.pricing.allow_future']" + native-value="true" + @input="settingsNeedSaved = true"> + Allow "future" pricing + </b-checkbox> + </b-field> + + </div> +</%def> + + +${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 <lance@edbob.org> Date: Tue, 14 Jun 2022 14:03:10 -0500 Subject: [PATCH 0729/1681] 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 <lance@edbob.org> Date: Tue, 14 Jun 2022 14:03:42 -0500 Subject: [PATCH 0730/1681] 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 <lance@edbob.org> Date: Tue, 14 Jun 2022 17:39:42 -0500 Subject: [PATCH 0731/1681] 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 @@ <span tal:define="name name|field.name; css_class css_class|field.widget.css_class; oid oid|field.oid; + field_name field_name|field.name; mask mask|field.widget.mask; mask_placeholder mask_placeholder|field.widget.mask_placeholder; style style|field.widget.style; - autocomplete autocomplete|field.widget.autocomplete|'off'; -" + autocomplete autocomplete|field.widget.autocomplete|'off';" tal:omit-tag=""> + + <div tal:condition="not use_buefy" tal:omit-tag=""> <input type="text" name="${name}" value="${cstruct}" tal:attributes="class string: form-control ${css_class or ''}; style style; @@ -22,4 +24,14 @@ {placeholder:"${mask_placeholder}"}); }); </script> + </div> + + <div tal:condition="use_buefy" + tal:define="vmodel vmodel|'field_model_' + field_name;"> + <!-- TODO: need to handle mask somehow? --> + <b-input name="${field_name}" + id="${oid}" + v-model="${vmodel}"> + </b-input> + </div> </span> From a289216eacf511d0e3a036be09229e381f5aeadb Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 14 Jun 2022 17:52:59 -0500 Subject: [PATCH 0732/1681] 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 <lance@edbob.org> Date: Fri, 24 Jun 2022 14:20:17 -0500 Subject: [PATCH 0733/1681] 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 <lance@edbob.org> Date: Fri, 1 Jul 2022 12:00:06 -0500 Subject: [PATCH 0734/1681] 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: - <div class="error-messages"> - <div class="ui-state-error ui-corner-all"> - <span style="float: left; margin-right: .3em;" class="ui-icon ui-icon-alert"></span> - Please see errors below. - </div> - <div class="ui-state-error ui-corner-all"> - <span style="float: left; margin-right: .3em;" class="ui-icon ui-icon-alert"></span> - ${dform.error} - </div> - </div> + <b-notification type="is-warning"> + Please see errors below. + </b-notification> + <b-notification type="is-warning"> + ${dform.error} + </b-notification> % endif <div class="app-wrapper"> @@ -115,17 +111,13 @@ ## :class="'field-wrapper' + (setting.error ? ' with-error' : '')" > - <div v-if="setting.error" class="field-error"> - <span v-for="msg in setting.error_messages" - class="error-msg"> - {{ msg }} - </span> - </div> - <div style="margin-bottom: 2rem;"> <b-field horizontal - :label="setting.label"> + :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"> <b-checkbox v-if="setting.data_type == 'bool'" :name="setting.field_name" @@ -158,8 +150,9 @@ </b-field> - <span v-if="setting.helptext" class="instructions"> - {{ setting.helptext }} + <span v-if="setting.helptext" + v-html="setting.helptext" + class="instructions"> </span> </div> 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 <lance@edbob.org> Date: Fri, 1 Jul 2022 12:00:17 -0500 Subject: [PATCH 0735/1681] 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 <lance@edbob.org> Date: Fri, 8 Jul 2022 12:57:57 -0500 Subject: [PATCH 0736/1681] 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 <lance@edbob.org> Date: Mon, 18 Jul 2022 12:31:54 -0500 Subject: [PATCH 0737/1681] 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 <lance@edbob.org> Date: Mon, 18 Jul 2022 12:41:27 -0500 Subject: [PATCH 0738/1681] 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 <lance@edbob.org> Date: Tue, 19 Jul 2022 11:45:52 -0500 Subject: [PATCH 0739/1681] 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 - <this-page - v-on:change-content-title="changeContentTitle"> - </this-page> + ${self.render_this_page_component()} </section> ## Footer @@ -539,6 +537,12 @@ ${tailbone_autocomplete_template()} </%def> +<%def name="render_this_page_component()"> + <this-page + v-on:change-content-title="changeContentTitle"> + </this-page> +</%def> + <%def name="render_navbar_end()"> <div class="navbar-end"> ${self.render_user_menu()} From 6397a93f97f5989ba7bec2e3dea86f0c05c6c203 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 19 Jul 2022 14:52:31 -0500 Subject: [PATCH 0740/1681] Allow download of results for common product-related tables --- tailbone/views/brands.py | 1 + tailbone/views/categories.py | 4 ++-- tailbone/views/departments.py | 3 ++- tailbone/views/families.py | 1 + tailbone/views/vendors/core.py | 1 + 5 files changed, 7 insertions(+), 3 deletions(-) diff --git a/tailbone/views/brands.py b/tailbone/views/brands.py index 92c6a41b..109c80a7 100644 --- a/tailbone/views/brands.py +++ b/tailbone/views/brands.py @@ -38,6 +38,7 @@ class BrandView(MasterView): model_class = model.Brand has_versions = True bulk_deletable = True + results_downloadable = True supports_autocomplete = True mergeable = True diff --git a/tailbone/views/categories.py b/tailbone/views/categories.py index 229d60ef..c76c9292 100644 --- a/tailbone/views/categories.py +++ b/tailbone/views/categories.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. # @@ -40,7 +40,7 @@ class CategoryView(MasterView): model_title_plural = "Categories" route_prefix = 'categories' has_versions = True - results_downloadable_xlsx = True + results_downloadable = True grid_columns = [ 'code', diff --git a/tailbone/views/departments.py b/tailbone/views/departments.py index 8c841f6b..1e964624 100644 --- a/tailbone/views/departments.py +++ b/tailbone/views/departments.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. # @@ -43,6 +43,7 @@ class DepartmentView(MasterView): model_class = model.Department touchable = True has_versions = True + results_downloadable = True supports_autocomplete = True grid_columns = [ diff --git a/tailbone/views/families.py b/tailbone/views/families.py index 1190ad06..0b8ba31d 100644 --- a/tailbone/views/families.py +++ b/tailbone/views/families.py @@ -39,6 +39,7 @@ class FamilyView(MasterView): model_title_plural = "Families" route_prefix = 'families' has_versions = True + results_downloadable = True grid_key = 'families' grid_columns = [ diff --git a/tailbone/views/vendors/core.py b/tailbone/views/vendors/core.py index 9f964d2c..63da1ca9 100644 --- a/tailbone/views/vendors/core.py +++ b/tailbone/views/vendors/core.py @@ -43,6 +43,7 @@ class VendorView(MasterView): model_class = model.Vendor has_versions = True touchable = True + results_downloadable = True supports_autocomplete = True configurable = True From e9edf205d95b260ed51b16996055097e58c25fcc Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 19 Jul 2022 15:50:57 -0500 Subject: [PATCH 0741/1681] Make caching products optional, when creating vendor catalog batch --- tailbone/views/batch/vendorcatalog.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tailbone/views/batch/vendorcatalog.py b/tailbone/views/batch/vendorcatalog.py index f1dd9754..e630c57e 100644 --- a/tailbone/views/batch/vendorcatalog.py +++ b/tailbone/views/batch/vendorcatalog.py @@ -83,6 +83,7 @@ class VendorCatalogView(FileBatchMasterView): 'vendor', 'future', 'effective', + 'cache_products', 'params', 'description', 'notes', @@ -255,6 +256,15 @@ class VendorCatalogView(FileBatchMasterView): f.remove('future', 'effective') + if self.creating: + f.set_node('cache_products', colander.Boolean()) + f.set_type('cache_products', 'boolean') + f.set_helptext('cache_products', + "If set, will pre-cache all products for quicker " + "lookups when loading the catalog.") + else: + f.remove('cache_products') + def render_parser_key(self, batch, field): key = getattr(batch, field) if not key: @@ -314,6 +324,11 @@ class VendorCatalogView(FileBatchMasterView): kwargs['effective'] = batch.effective return kwargs + def save_create_form(self, form): + batch = super(VendorCatalogView, self).save_create_form(form) + batch.set_param('cache_products', form.validated['cache_products']) + return batch + def configure_row_grid(self, g): super(VendorCatalogView, self).configure_row_grid(g) batch = self.get_instance() From 20aa6a3fbbd98854720c28276ce01b94b6ef6237 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 19 Jul 2022 16:36:21 -0500 Subject: [PATCH 0742/1681] Expose the `complete` flag for pricing batch also update view config defaults per new convention --- tailbone/views/batch/pricing.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tailbone/views/batch/pricing.py b/tailbone/views/batch/pricing.py index 18a4ea90..cb0f3be9 100644 --- a/tailbone/views/batch/pricing.py +++ b/tailbone/views/batch/pricing.py @@ -68,7 +68,7 @@ class PricingBatchView(BatchMasterView): 'created_by', 'rowcount', # 'status_code', - # 'complete', + 'complete', 'executed', 'executed_by', ] @@ -87,6 +87,7 @@ class PricingBatchView(BatchMasterView): 'created_by', 'rowcount', 'shelved', + 'complete', 'executed', 'executed_by', ] @@ -378,5 +379,12 @@ class PricingBatchView(BatchMasterView): ] -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + PricingBatchView = kwargs.get('PricingBatchView', base['PricingBatchView']) PricingBatchView.defaults(config) + + +def includeme(config): + defaults(config) From 10628eeb91bff47810b6ad1d38911d1af9ca5baf Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 20 Jul 2022 11:01:22 -0500 Subject: [PATCH 0743/1681] Add `template_kwargs_clone()` stub for master view --- tailbone/views/master.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index fe77c7b5..b3a99e49 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2409,6 +2409,12 @@ class MasterView(View): """ return kwargs + def template_kwargs_clone(self, **kwargs): + """ + Method stub, so subclass can always invoke super() for it. + """ + return kwargs + def template_kwargs_view(self, **kwargs): """ Method stub, so subclass can always invoke super() for it. From da3aaafbcd4352cc41b63a9554e05b7022527797 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 20 Jul 2022 21:36:52 -0500 Subject: [PATCH 0744/1681] Misc deform template improvements for sake of a custom form --- tailbone/templates/deform/autocomplete_jquery.pt | 5 +++-- tailbone/templates/deform/select.pt | 3 ++- tailbone/templates/deform/select_dynamic.pt | 3 ++- tailbone/templates/deform/textinput.pt | 9 +++++---- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/tailbone/templates/deform/autocomplete_jquery.pt b/tailbone/templates/deform/autocomplete_jquery.pt index c0e79c29..4ebc17b2 100644 --- a/tailbone/templates/deform/autocomplete_jquery.pt +++ b/tailbone/templates/deform/autocomplete_jquery.pt @@ -113,8 +113,9 @@ v-model="${vmodel}" initial-label="${field_display}" tal:attributes=":assigned-label assigned_label or 'null'; - @input input_callback or 'null'; - @new-label new_label_callback or 'null';"> + @input input_callback|''; + @new-label new_label_callback|'';"> + </tailbone-autocomplete> </div> 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|{};"> <tal:loop tal:repeat="item values"> <optgroup tal:condition="isinstance(item, optgroup_class)" diff --git a/tailbone/templates/deform/select_dynamic.pt b/tailbone/templates/deform/select_dynamic.pt index 5d1de2f6..a0ee1daf 100644 --- a/tailbone/templates/deform/select_dynamic.pt +++ b/tailbone/templates/deform/select_dynamic.pt @@ -20,7 +20,8 @@ size size; style style; v-model vmodel; - @input input_handler;"> + @input input_handler; + attributes|field.widget.attributes|{};"> <option v-for="item in ${name}_options" tal:attributes=":key 'item.value'; diff --git a/tailbone/templates/deform/textinput.pt b/tailbone/templates/deform/textinput.pt index 2e1c32ef..52873cb7 100644 --- a/tailbone/templates/deform/textinput.pt +++ b/tailbone/templates/deform/textinput.pt @@ -28,10 +28,11 @@ <div tal:condition="use_buefy" tal:define="vmodel vmodel|'field_model_' + name;" tal:omit-tag=""> - <b-input name="${name}" - v-model="${vmodel}" - placeholder="${placeholder}" - autocomplete="${autocomplete}"> + <b-input tal:attributes="name name; + v-model vmodel; + placeholder placeholder; + autocomplete autocomplete; + attributes|field.widget.attributes|{};"> </b-input> </div> </span> From e77ca93d80959b402202064c09c23e04463b9fdc Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 22 Jul 2022 12:41:54 -0500 Subject: [PATCH 0745/1681] 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 <lance@edbob.org> Date: Sat, 23 Jul 2022 22:06:18 -0500 Subject: [PATCH 0746/1681] 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()"> + + <h3 class="block is-size-3">POS</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field> + <b-checkbox name="rattail.customers.active_in_pos" + v-model="simpleSettings['rattail.customers.active_in_pos']" + native-value="true" + @input="settingsNeedSaved = true"> + Expose/track the "Active in POS" flag for customers. + </b-checkbox> + </b-field> + + </div> + +</%def> + + +${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 <lance@edbob.org> Date: Sat, 23 Jul 2022 22:18:17 -0500 Subject: [PATCH 0747/1681] 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: <br /> + % if rows_title: + <h4 class="block is-size-4">${rows_title}</h4> + % endif <tailbone-grid ref="rowGrid" id="rowGrid"></tailbone-grid> % 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 <lance@edbob.org> Date: Sun, 24 Jul 2022 12:05:05 -0500 Subject: [PATCH 0748/1681] 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 <lance@edbob.org> Date: Sun, 24 Jul 2022 15:05:51 -0500 Subject: [PATCH 0749/1681] 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 <lance@edbob.org> Date: Sun, 24 Jul 2022 21:10:52 -0500 Subject: [PATCH 0750/1681] 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 <lance@edbob.org> Date: Sun, 24 Jul 2022 21:11:12 -0500 Subject: [PATCH 0751/1681] 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 <lance@edbob.org> Date: Sun, 24 Jul 2022 22:29:55 -0500 Subject: [PATCH 0752/1681] 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 @@ </b-checkbox> </b-field> - <b-field> + <b-field message="If set, one or more "quick receive" buttons will be available for mobile receiving."> <b-checkbox name="rattail.batch.purchase.mobile_quick_receive" v-model="simpleSettings['rattail.batch.purchase.mobile_quick_receive']" native-value="true" @@ -138,12 +138,12 @@ </b-checkbox> </b-field> - <b-field> + <b-field message="If set, only a "quick receive all" button will be shown. Only applicable if quick receive (above) is enabled."> <b-checkbox name="rattail.batch.purchase.mobile_quick_receive_all" v-model="simpleSettings['rattail.batch.purchase.mobile_quick_receive_all']" native-value="true" @input="settingsNeedSaved = true"> - Allow "Quick Receive All" + Quick Receive "All or Nothing" </b-checkbox> </b-field> 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 <lance@edbob.org> Date: Mon, 25 Jul 2022 11:42:46 -0500 Subject: [PATCH 0753/1681] 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 <lance@edbob.org> Date: Tue, 26 Jul 2022 14:25:20 -0500 Subject: [PATCH 0754/1681] 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 <lance@edbob.org> Date: Tue, 26 Jul 2022 16:30:04 -0500 Subject: [PATCH 0755/1681] 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 @@ <div style="display: flex;"> <div> % 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 @@ <b-field grouped> - <b-field label="Case Qty."> - <span class="control"> - {{ rowData.case_quantity }} - </span> - </b-field> + % if allow_cases: + <b-field label="Case Qty."> + <span class="control"> + {{ rowData.case_quantity }} + </span> + </b-field> - <span class="control"> - - </span> + <span class="control"> + + </span> + % endif <b-field label="Product State" :type="accountForProductMode ? null : 'is-danger'"> @@ -226,31 +228,39 @@ <div class="level-left"> <div class="level-item"> - <b-input v-model="accountForProductQuantity" - type="number" step="0.0001" - ref="accountForProductQuantityInput"> - </b-input> + <numeric-input v-model="accountForProductQuantity" + ref="accountForProductQuantityInput"> + </numeric-input> </div> <div class="level-item"> - <b-field> - <b-radio-button v-model="accountForProductUOM" - @click.native="accountForProductUOMClicked('units')" - native-value="units"> - Units - </b-radio-button> - <b-radio-button v-model="accountForProductUOM" - @click.native="accountForProductUOMClicked('cases')" - native-value="cases"> - Cases - </b-radio-button> - </b-field> + % if allow_cases: + <b-field> + <b-radio-button v-model="accountForProductUOM" + @click.native="accountForProductUOMClicked('units')" + native-value="units"> + Units + </b-radio-button> + <b-radio-button v-model="accountForProductUOM" + @click.native="accountForProductUOMClicked('cases')" + native-value="cases"> + Cases + </b-radio-button> + </b-field> + % else: + <b-field> + <input type="hidden" v-model="accountForProductUOM" /> + Units + </b-field> + % endif </div> - <div class="level-item" - v-if="accountForProductUOM == 'cases' && accountForProductQuantity"> - = {{ accountForProductTotalUnits }} - </div> + % if allow_cases: + <div class="level-item" + v-if="accountForProductUOM == 'cases' && accountForProductQuantity"> + = {{ accountForProductTotalUnits }} + </div> + % endif </div> </div> @@ -325,31 +335,39 @@ <div class="level-left"> <div class="level-item"> - <b-input v-model="declareCreditQuantity" - type="number" step="0.0001" - ref="declareCreditQuantityInput"> - </b-input> + <numeric-input v-model="declareCreditQuantity" + ref="declareCreditQuantityInput"> + </numeric-input> </div> <div class="level-item"> - <b-field> - <b-radio-button v-model="declareCreditUOM" - @click.native="declareCreditUOMClicked('units')" - native-value="units"> - Units - </b-radio-button> - <b-radio-button v-model="declareCreditUOM" - @click.native="declareCreditUOMClicked('cases')" - native-value="cases"> - Cases - </b-radio-button> - </b-field> + % if allow_cases: + <b-field> + <b-radio-button v-model="declareCreditUOM" + @click.native="declareCreditUOMClicked('units')" + native-value="units"> + Units + </b-radio-button> + <b-radio-button v-model="declareCreditUOM" + @click.native="declareCreditUOMClicked('cases')" + native-value="cases"> + Cases + </b-radio-button> + </b-field> + % else: + <b-field> + <input type="hidden" v-model="declareCreditUOM" /> + Units + </b-field> + % endif </div> - <div class="level-item" - v-if="declareCreditUOM == 'cases' && declareCreditQuantity"> - = {{ declareCreditTotalUnits }} - </div> + % if allow_cases: + <div class="level-item" + v-if="declareCreditUOM == 'cases' && declareCreditQuantity"> + = {{ declareCreditTotalUnits }} + </div> + % endif </div> </div> @@ -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 <lance@edbob.org> Date: Wed, 27 Jul 2022 10:21:08 -0500 Subject: [PATCH 0756/1681] 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 <lance@edbob.org> Date: Wed, 3 Aug 2022 11:13:43 -0500 Subject: [PATCH 0757/1681] 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 <lance@edbob.org> Date: Wed, 3 Aug 2022 14:44:38 -0500 Subject: [PATCH 0758/1681] 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 <lance@edbob.org> Date: Wed, 3 Aug 2022 15:15:49 -0500 Subject: [PATCH 0759/1681] 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 <lance@edbob.org> Date: Wed, 3 Aug 2022 16:58:06 -0500 Subject: [PATCH 0760/1681] 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 <lance@edbob.org> Date: Thu, 4 Aug 2022 08:14:04 -0500 Subject: [PATCH 0761/1681] 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 <lance@edbob.org> Date: Thu, 4 Aug 2022 09:08:56 -0500 Subject: [PATCH 0762/1681] 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 <lance@edbob.org> Date: Fri, 5 Aug 2022 12:09:32 -0500 Subject: [PATCH 0763/1681] 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 <lance@edbob.org> Date: Fri, 5 Aug 2022 13:28:47 -0500 Subject: [PATCH 0764/1681] 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 <lance@edbob.org> Date: Sat, 6 Aug 2022 18:38:17 -0500 Subject: [PATCH 0765/1681] 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 @@ </div> <b-table - :data="data" + :data="visibleData" ## :columns="columns" :loading="loading" :row-class="getRowClass" @@ -211,7 +211,7 @@ @click.prevent="${action.click_handler}" % endif > - <i class="fas fa-${action.icon}"></i> + ${action.render_icon()|n} ${action.render_label()|n} </a> @@ -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> <%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'): + <b-field horizontal label="Showing:"> + <b-select v-model="showEmails" @input="updateVisibleEmails()"> + <option value="available">Available Emails</option> + <option value="all">All Emails</option> + <option value="hidden">Hidden Emails</option> + </b-select> + </b-field> + % endif + + ${parent.render_grid_component()} +</%def> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + % if master.has_perm('configure'): + <script type="text/javascript"> + + ThisPageData.showEmails = 'available' + + ThisPage.methods.updateVisibleEmails = function() { + this.$refs.grid.showEmails = this.showEmails + } + + ${grid.component_studly}Data.showEmails = 'available' + + ${grid.component_studly}.computed.visibleData = function() { + + if (this.showEmails == 'available') { + return this.data.filter(email => email.hidden == 'No') + + } else if (this.showEmails == 'hidden') { + return this.data.filter(email => email.hidden == 'Yes') + } + + // showing all + return this.data + } + + ${grid.component_studly}.methods.renderLabelToggleHidden = function(row) { + return row.hidden == 'Yes' ? "Un-hide" : "Hide" + } + + ${grid.component_studly}.methods.toggleHidden = function(row) { + let url = '${url('{}.toggle_hidden'.format(route_prefix))}' + let params = { + key: row.key, + hidden: row.hidden == 'No'? true : false, + } + this.submitForm(url, params, response => { + row.hidden = params.hidden ? 'Yes' : 'No' + }) + } + + </script> + % endif +</%def> + +${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 <lance@edbob.org> Date: Sat, 6 Aug 2022 19:18:49 -0500 Subject: [PATCH 0766/1681] 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 <lance@edbob.org> Date: Sat, 6 Aug 2022 20:48:34 -0500 Subject: [PATCH 0767/1681] 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 <lance@edbob.org> Date: Sat, 6 Aug 2022 22:57:10 -0500 Subject: [PATCH 0768/1681] 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 <lance@edbob.org> Date: Sun, 7 Aug 2022 10:10:17 -0500 Subject: [PATCH 0769/1681] 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 <lance@edbob.org> Date: Sun, 7 Aug 2022 12:58:49 -0500 Subject: [PATCH 0770/1681] 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 @@ </b-field> </div> + + % if request.has_perm('errors.bogus'): + <h3 class="block is-size-3">Testing</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field grouped> + <p class="control"> + You can raise a "bogus" error to test if/how it generates email: + </p> + <b-button type="is-primary" + @click="raiseBogusError()" + :disabled="raisingBogusError"> + {{ raisingBogusError ? "Working, please wait..." : "Raise Bogus Error" }} + </b-button> + </b-field> + + </div> + </h3> + % endif +</%def> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + % if request.has_perm('errors.bogus'): + <script type="text/javascript"> + + ThisPageData.raisingBogusError = false + + ThisPage.methods.raiseBogusError = function() { + this.raisingBogusError = true + + let url = '${url('bogus_error')}' + this.$http.get(url).then(response => { + this.$buefy.toast.open({ + message: "Ironically, response was 200 which means we failed to raise an error!\n\nPlease investigate!", + type: 'is-danger', + duration: 5000, // 5 seconds + }) + this.raisingBogusError = false + }, response => { + this.$buefy.toast.open({ + message: "Error was raised; please check your email and/or logs.", + type: 'is-success', + duration: 4000, // 4 seconds + }) + this.raisingBogusError = false + }) + } + + </script> + % endif </%def> From fe4c3d4942cdaf16a15f60527c8e8b67075a77ca Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 7 Aug 2022 18:23:15 -0500 Subject: [PATCH 0771/1681] 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 <lance@edbob.org> Date: Sun, 7 Aug 2022 18:45:45 -0500 Subject: [PATCH 0772/1681] 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 @@ </b-checkbox> </b-field> + <b-field> + <b-checkbox name="rattail.mail.send_email_on_failure" + v-model="simpleSettings['rattail.mail.send_email_on_failure']" + native-value="true" + @input="settingsNeedSaved = true"> + When sending an email fails, send another to report the failure + </b-checkbox> + </b-field> + </div> % 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 <lance@edbob.org> Date: Mon, 8 Aug 2022 09:42:54 -0500 Subject: [PATCH 0773/1681] 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 <lance@edbob.org> Date: Mon, 8 Aug 2022 14:39:26 -0500 Subject: [PATCH 0774/1681] 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 <lance@edbob.org> Date: Mon, 8 Aug 2022 18:13:34 -0500 Subject: [PATCH 0775/1681] 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 <lance@edbob.org> Date: Mon, 8 Aug 2022 23:34:40 -0500 Subject: [PATCH 0776/1681] 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 <b-field> 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 <b-field> (*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 <b-field> + 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()"> + <h3 class="block is-size-3">General</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field message="If not set, customer chooser is an autocomplete field."> + <b-checkbox name="rattail.customers.choice_uses_dropdown" + v-model="simpleSettings['rattail.customers.choice_uses_dropdown']" + native-value="true" + @input="settingsNeedSaved = true"> + Show customer chooser as dropdown (select) element + </b-checkbox> + </b-field> + + </div> + <h3 class="block is-size-3">POS</h3> <div class="block" style="padding-left: 2rem;"> 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|'';"> </tailbone-autocomplete> 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" /> <script type="text/x-template" id="${form.component}-template"> @@ -21,7 +20,7 @@ </b-field> % elif field in dform: - ${render_buefy_field(dform[field])} + ${form.render_buefy_field(field)} % endif % endfor diff --git a/tailbone/templates/forms/util.mako b/tailbone/templates/forms/util.mako index 0b4f4012..22e7f918 100644 --- a/tailbone/templates/forms/util.mako +++ b/tailbone/templates/forms/util.mako @@ -1,27 +1,7 @@ ## -*- coding: utf-8; -*- +## TODO: deprecate / remove this +## (tried to add deprecation warning here but it didn't seem to work) <%def name="render_buefy_field(field, bfield_kwargs={})"> - % if form.field_visible(field.name): - <% error_messages = form.get_error_messages(field) %> - <b-field horizontal - label="${form.get_label(field.name)}" - ## TODO: is this class="file" really needed? - % if isinstance(field.schema.typ, deform.FileData): - class="file" - % endif - % if form.has_helptext(field.name): - message="${form.render_helptext(field.name)}" - % endif - % if error_messages: - type="is-danger" - :message='${form.messages_json(error_messages)|n}' - % endif - ${h.HTML.render_attrs(bfield_kwargs)} - > - ${field.serialize(use_buefy=True)|n} - </b-field> - % else: - ## hidden field - ${field.serialize()|n} - % endif + ${form.render_buefy_field(field.name, bfield_attrs=bfield_kwargs)} </%def> diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index 693cd323..84d53925 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -490,6 +490,11 @@ class CustomerView(MasterView): def configure_get_simple_settings(self): return [ + # General + {'section': 'rattail', + 'option': 'customers.choice_uses_dropdown', + 'type': bool}, + # POS {'section': 'rattail', 'option': 'customers.active_in_pos', From 3edbe96968e521be9fd9ca49011e4b284c7bed61 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 9 Aug 2022 14:37:08 -0500 Subject: [PATCH 0777/1681] Some API tweaks to support a byjove app --- tailbone/api/auth.py | 10 ++++++++++ tailbone/api/customers.py | 4 +++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/tailbone/api/auth.py b/tailbone/api/auth.py index c4d04b90..584f397e 100644 --- a/tailbone/api/auth.py +++ b/tailbone/api/auth.py @@ -57,6 +57,16 @@ class AuthenticationView(APIView): data['background_color'] = self.rattail_config.get( 'tailbone', 'background_color') + # TODO: this seems the best place to return some global app + # settings, but maybe not desirable in all cases..in which + # case should caller need to ask for these explicitly? or + # make a different call altogether to get them..? + app = self.get_rattail_app() + customer_handler = app.get_clientele_handler() + data['settings'] = { + 'customer_field_dropdown': customer_handler.choice_uses_dropdown(), + } + return data @api diff --git a/tailbone/api/customers.py b/tailbone/api/customers.py index fdd2c18e..9a06caaa 100644 --- a/tailbone/api/customers.py +++ b/tailbone/api/customers.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. # @@ -40,6 +40,8 @@ class CustomerView(APIMasterView): model_class = model.Customer collection_url_prefix = '/customers' object_url_prefix = '/customer' + supports_autocomplete = True + autocomplete_fieldname = 'name' def normalize(self, customer): return { From 8f1f8abf425190e0ebf948192e0ab967c31a71f6 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 9 Aug 2022 14:48:23 -0500 Subject: [PATCH 0778/1681] Fix HTML literal for hidden form field --- tailbone/forms/core.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 14703eb7..bd939272 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -925,7 +925,8 @@ class Form(object): else: # hidden field # can just do normal thing for these - return field.serialize() + # TODO: again, why does serialize() not return literal? + return HTML.literal(field.serialize()) def render_field_readonly(self, field_name, **kwargs): """ From 5952df82ffd42034bf698408352682801c28aa56 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 9 Aug 2022 15:05:03 -0500 Subject: [PATCH 0779/1681] Tweak flash msg, logging when batch population fails --- tailbone/views/batch/core.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 84b2e77c..77da3344 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -1060,13 +1060,12 @@ class BatchMasterView(MasterView): session.flush() except Exception as error: session.rollback() - log.exception("batch population failed: %s", batch) + log.exception("population failed for batch %s: %s", batch.uuid, batch) session.close() if progress: progress.session.load() progress.session['error'] = True - progress.session['error_msg'] = "Batch population failed: {}".format( - simple_error(error)) + progress.session['error_msg'] = simple_error(error) progress.session.save() return From a6d5b262f9dda1df5638553596ba7c30e885a190 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 9 Aug 2022 16:35:48 -0500 Subject: [PATCH 0780/1681] Log traceback output when batch action subprocess fails --- tailbone/views/batch/core.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 77da3344..24aa94d4 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -962,7 +962,13 @@ class BatchMasterView(MasterView): # run command in subprocess log.debug("launching command in subprocess: %s", cmd) - subprocess.check_call(cmd) + try: + subprocess.check_output(cmd, stderr=subprocess.PIPE) + except subprocess.CalledProcessError as error: + log.warning("command failed with exit code %s! output was:", + error.returncode) + log.warning(error.stderr.decode('utf_8')) + raise def action_subprocess_thread(self, key, port, username, handler_action, progress, **kwargs): """ From ca5e2c1ff9e5980ff9f1259d5b0e79f7fbfafef0 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 9 Aug 2022 21:08:03 -0500 Subject: [PATCH 0781/1681] Add initial views for work orders at least a head start maybe --- tailbone/api/workorders.py | 238 ++++++++++++++ tailbone/templates/workorders/view.mako | 221 +++++++++++++ tailbone/views/workorders.py | 419 ++++++++++++++++++++++++ 3 files changed, 878 insertions(+) create mode 100644 tailbone/api/workorders.py create mode 100644 tailbone/templates/workorders/view.mako create mode 100644 tailbone/views/workorders.py diff --git a/tailbone/api/workorders.py b/tailbone/api/workorders.py new file mode 100644 index 00000000..315a92bb --- /dev/null +++ b/tailbone/api/workorders.py @@ -0,0 +1,238 @@ +# -*- 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 <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Tailbone Web API - Work Order Views +""" + +from __future__ import unicode_literals, absolute_import + +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 + + +class WorkOrderView(APIMasterView): + + model_class = WorkOrder + collection_url_prefix = '/workorders' + object_url_prefix = '/workorder' + + def __init__(self, *args, **kwargs): + super(WorkOrderView, self).__init__(*args, **kwargs) + app = self.get_rattail_app() + 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, + 'customer_name': workorder.customer.name, + '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 ''), + } + + def update_object(self, workorder, data): + date_fields = [ + 'date_submitted', + 'date_received', + 'date_released', + 'date_delivered', + ] + + # coerce date field values to proper datetime.date objects + for field in date_fields: + if field in data: + if data[field] == '': + data[field] = None + else: + date = datetime.datetime.strptime(data[field], '%Y-%m-%d').date() + data[field] = date + + # coerce status code value to proper integer + if 'status_code' in data: + data['status_code'] = int(data['status_code']) + + return super(WorkOrderView, self).update_object(workorder, data) + + def status_codes(self): + """ + Retrieve all info about possible work order status codes. + """ + return self.workorder_handler.status_codes() + + def receive(self): + """ + Sets work order status to "received". + """ + workorder = self.get_object() + self.workorder_handler.receive(workorder) + self.Session.flush() + return self.normalize(workorder) + + def await_estimate(self): + """ + Sets work order status to "awaiting estimate confirmation". + """ + workorder = self.get_object() + self.workorder_handler.await_estimate(workorder) + self.Session.flush() + return self.normalize(workorder) + + def await_parts(self): + """ + Sets work order status to "awaiting parts". + """ + workorder = self.get_object() + self.workorder_handler.await_parts(workorder) + self.Session.flush() + return self.normalize(workorder) + + def work_on_it(self): + """ + Sets work order status to "working on it". + """ + workorder = self.get_object() + self.workorder_handler.work_on_it(workorder) + self.Session.flush() + return self.normalize(workorder) + + def release(self): + """ + Sets work order status to "released". + """ + workorder = self.get_object() + self.workorder_handler.release(workorder) + self.Session.flush() + return self.normalize(workorder) + + def deliver(self): + """ + Sets work order status to "delivered". + """ + workorder = self.get_object() + self.workorder_handler.deliver(workorder) + self.Session.flush() + return self.normalize(workorder) + + def cancel(self): + """ + Sets work order status to "canceled". + """ + workorder = self.get_object() + self.workorder_handler.cancel(workorder) + self.Session.flush() + return self.normalize(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() + collection_url_prefix = cls.get_collection_url_prefix() + object_url_prefix = cls.get_object_url_prefix() + + # status codes + status_codes = Service(name='{}.status_codes'.format(route_prefix), + path='{}/status-codes'.format(collection_url_prefix)) + status_codes.add_view('GET', 'status_codes', klass=cls, + permission='{}.list'.format(permission_prefix)) + config.add_cornice_service(status_codes) + + # receive + receive = Service(name='{}.receive'.format(route_prefix), + path='{}/{{uuid}}/receive'.format(object_url_prefix)) + receive.add_view('POST', 'receive', klass=cls, + permission='{}.edit'.format(permission_prefix)) + config.add_cornice_service(receive) + + # await estimate confirmation + await_estimate = Service(name='{}.await_estimate'.format(route_prefix), + path='{}/{{uuid}}/await-estimate'.format(object_url_prefix)) + await_estimate.add_view('POST', 'await_estimate', klass=cls, + permission='{}.edit'.format(permission_prefix)) + config.add_cornice_service(await_estimate) + + # await parts + await_parts = Service(name='{}.await_parts'.format(route_prefix), + path='{}/{{uuid}}/await-parts'.format(object_url_prefix)) + await_parts.add_view('POST', 'await_parts', klass=cls, + permission='{}.edit'.format(permission_prefix)) + config.add_cornice_service(await_parts) + + # work on it + work_on_it = Service(name='{}.work_on_it'.format(route_prefix), + path='{}/{{uuid}}/work-on-it'.format(object_url_prefix)) + work_on_it.add_view('POST', 'work_on_it', klass=cls, + permission='{}.edit'.format(permission_prefix)) + config.add_cornice_service(work_on_it) + + # release + release = Service(name='{}.release'.format(route_prefix), + path='{}/{{uuid}}/release'.format(object_url_prefix)) + release.add_view('POST', 'release', klass=cls, + permission='{}.edit'.format(permission_prefix)) + config.add_cornice_service(release) + + # deliver + deliver = Service(name='{}.deliver'.format(route_prefix), + path='{}/{{uuid}}/deliver'.format(object_url_prefix)) + deliver.add_view('POST', 'deliver', klass=cls, + permission='{}.edit'.format(permission_prefix)) + config.add_cornice_service(deliver) + + # cancel + cancel = Service(name='{}.cancel'.format(route_prefix), + path='{}/{{uuid}}/cancel'.format(object_url_prefix)) + cancel.add_view('POST', 'cancel', klass=cls, + permission='{}.edit'.format(permission_prefix)) + config.add_cornice_service(cancel) + + +def defaults(config, **kwargs): + base = globals() + + WorkOrderView = kwargs.get('WorkOrderView', base['WorkOrderView']) + WorkOrderView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/templates/workorders/view.mako b/tailbone/templates/workorders/view.mako new file mode 100644 index 00000000..e631c141 --- /dev/null +++ b/tailbone/templates/workorders/view.mako @@ -0,0 +1,221 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> + +## TODO: what was this about? +<%def name="content_title()"> + ## ${instance_title} + #${instance.id} for ${instance.customer} (${enum.WORKORDER_STATUS[instance.status_code]}) +</%def> + +<%def name="object_helpers()"> + % if instance.status_code not in (enum.WORKORDER_STATUS_DELIVERED, enum.WORKORDER_STATUS_CANCELED): + ${self.render_workflow_helper()} + % endif +</%def> + +<%def name="render_workflow_helper()"> + <nav class="panel"> + <p class="panel-heading">Workflow</p> + + % if instance.status_code == enum.WORKORDER_STATUS_SUBMITTED: + <div class="panel-block"> + <div class="buttons"> + ${h.form(url('{}.receive'.format(route_prefix), uuid=instance.uuid), ref='receiveForm')} + ${h.csrf_token(request)} + <b-button type="is-primary" + icon-pack="fas" + icon-left="fas fa-arrow-right" + @click="receive()" + :disabled="receiveButtonDisabled"> + {{ receiveButtonText }} + </b-button> + ${h.end_form()} + </div> + </div> + % endif + + % if instance.status_code == enum.WORKORDER_STATUS_RECEIVED: + <div class="panel-block"> + <div class="buttons"> + ${h.form(url('{}.await_estimate'.format(route_prefix), uuid=instance.uuid), ref='awaitEstimateForm')} + ${h.csrf_token(request)} + <b-button type="is-primary" + icon-pack="fas" + icon-left="fas fa-arrow-right" + @click="awaitEstimate()" + :disabled="awaitEstimateButtonDisabled"> + {{ awaitEstimateButtonText }} + </b-button> + ${h.end_form()} + </div> + </div> + % endif + + % if instance.status_code in (enum.WORKORDER_STATUS_RECEIVED, enum.WORKORDER_STATUS_PENDING_ESTIMATE): + <div class="panel-block"> + <div class="buttons"> + ${h.form(url('{}.await_parts'.format(route_prefix), uuid=instance.uuid), ref='awaitPartsForm')} + ${h.csrf_token(request)} + <b-button type="is-primary" + icon-pack="fas" + icon-left="fas fa-arrow-right" + @click="awaitParts()" + :disabled="awaitPartsButtonDisabled"> + {{ awaitPartsButtonText }} + </b-button> + ${h.end_form()} + </div> + </div> + % endif + + % if instance.status_code in (enum.WORKORDER_STATUS_RECEIVED, enum.WORKORDER_STATUS_PENDING_ESTIMATE, enum.WORKORDER_STATUS_WAITING_FOR_PARTS): + <div class="panel-block"> + <div class="buttons"> + ${h.form(url('{}.work_on_it'.format(route_prefix), uuid=instance.uuid), ref='workOnItForm')} + ${h.csrf_token(request)} + <b-button type="is-primary" + icon-pack="fas" + icon-left="fas fa-arrow-right" + @click="workOnIt()" + :disabled="workOnItButtonDisabled"> + {{ workOnItButtonText }} + </b-button> + ${h.end_form()} + </div> + </div> + % endif + + % if instance.status_code == enum.WORKORDER_STATUS_WORKING_ON_IT: + <div class="panel-block"> + <div class="buttons"> + ${h.form(url('{}.release'.format(route_prefix), uuid=instance.uuid), ref='releaseForm')} + ${h.csrf_token(request)} + <b-button type="is-primary" + icon-pack="fas" + icon-left="fas fa-arrow-right" + @click="release()" + :disabled="releaseButtonDisabled"> + {{ releaseButtonText }} + </b-button> + ${h.end_form()} + </div> + </div> + % endif + + % if instance.status_code == enum.WORKORDER_STATUS_RELEASED: + <div class="panel-block"> + <div class="buttons"> + ${h.form(url('{}.deliver'.format(route_prefix), uuid=instance.uuid), ref='deliverForm')} + ${h.csrf_token(request)} + <b-button type="is-primary" + icon-pack="fas" + icon-left="fas fa-arrow-right" + @click="deliver()" + :disabled="deliverButtonDisabled"> + {{ deliverButtonText }} + </b-button> + ${h.end_form()} + </div> + </div> + % endif + + % if instance.status_code not in (enum.WORKORDER_STATUS_DELIVERED, enum.WORKORDER_STATUS_CANCELED): + <div class="panel-block"> + <p class="is-italic has-text-centered" + style="width: 100%;"> + OR + </p> + </div> + <div class="panel-block"> + <div class="buttons"> + ${h.form(url('{}.cancel'.format(route_prefix), uuid=instance.uuid), ref='cancelForm')} + ${h.csrf_token(request)} + <b-button type="is-warning" + icon-pack="fas" + icon-left="fas fa-ban" + @click="confirmCancel()" + :disabled="cancelButtonDisabled"> + {{ cancelButtonText }} + </b-button> + ${h.end_form()} + </div> + </div> + % endif + + </nav> +</%def> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + ThisPageData.receiveButtonDisabled = false + ThisPageData.receiveButtonText = "I've received the order from customer" + + ThisPageData.awaitEstimateButtonDisabled = false + ThisPageData.awaitEstimateButtonText = "I'm waiting for estimate confirmation" + + ThisPageData.awaitPartsButtonDisabled = false + ThisPageData.awaitPartsButtonText = "I'm waiting for parts" + + ThisPageData.workOnItButtonDisabled = false + ThisPageData.workOnItButtonText = "I'm working on it" + + ThisPageData.releaseButtonDisabled = false + ThisPageData.releaseButtonText = "I've sent this back to customer" + + ThisPageData.deliverButtonDisabled = false + ThisPageData.deliverButtonText = "Customer has the completed order!" + + ThisPageData.cancelButtonDisabled = false + ThisPageData.cancelButtonText = "Cancel this Work Order" + + ThisPage.methods.receive = function() { + this.receiveButtonDisabled = true + this.receiveButtonText = "Working, please wait..." + this.$refs.receiveForm.submit() + } + + ThisPage.methods.awaitEstimate = function() { + this.awaitEstimateButtonDisabled = true + this.awaitEstimateButtonText = "Working, please wait..." + this.$refs.awaitEstimateForm.submit() + } + + ThisPage.methods.awaitParts = function() { + this.awaitPartsButtonDisabled = true + this.awaitPartsButtonText = "Working, please wait..." + this.$refs.awaitPartsForm.submit() + } + + ThisPage.methods.workOnIt = function() { + this.workOnItButtonDisabled = true + this.workOnItButtonText = "Working, please wait..." + this.$refs.workOnItForm.submit() + } + + ThisPage.methods.release = function() { + this.releaseButtonDisabled = true + this.releaseButtonText = "Working, please wait..." + this.$refs.releaseForm.submit() + } + + ThisPage.methods.deliver = function() { + this.deliverButtonDisabled = true + this.deliverButtonText = "Working, please wait..." + this.$refs.deliverForm.submit() + } + + ThisPage.methods.confirmCancel = function() { + if (confirm("Are you sure you wish to cancel this Work Order?")) { + this.cancelButtonDisabled = true + this.cancelButtonText = "Working, please wait..." + this.$refs.cancelForm.submit() + } + } + + </script> +</%def> + + +${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 <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +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 <lance@edbob.org> Date: Tue, 9 Aug 2022 23:26:41 -0500 Subject: [PATCH 0782/1681] 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> -<%def name="make_this_page_component()"> +<%def name="finalize_this_page_vars()"> + ${parent.finalize_this_page_vars()} % if master.has_rows: <script type="text/javascript"> @@ -132,7 +133,6 @@ </script> % endif - ${parent.make_this_page_component()} </%def> From 51aeb50d39e4e38970b11589dd93357c8f22a395 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 10 Aug 2022 18:55:59 -0500 Subject: [PATCH 0783/1681] 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 <lance@edbob.org> Date: Wed, 10 Aug 2022 18:58:18 -0500 Subject: [PATCH 0784/1681] 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 <lance@edbob.org> Date: Thu, 11 Aug 2022 00:15:12 -0500 Subject: [PATCH 0785/1681] 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 <lance@edbob.org> Date: Fri, 12 Aug 2022 14:27:26 -0500 Subject: [PATCH 0786/1681] 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 <lance@edbob.org> Date: Fri, 12 Aug 2022 18:29:46 -0500 Subject: [PATCH 0787/1681] 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 <lance@edbob.org> Date: Fri, 12 Aug 2022 19:47:25 -0500 Subject: [PATCH 0788/1681] 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 <lance@edbob.org> Date: Fri, 12 Aug 2022 20:47:32 -0500 Subject: [PATCH 0789/1681] 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 <lance@edbob.org> Date: Sat, 13 Aug 2022 23:12:39 -0500 Subject: [PATCH 0790/1681] 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 <lance@edbob.org> Date: Sat, 13 Aug 2022 23:23:30 -0500 Subject: [PATCH 0791/1681] 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 <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +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 <lance@edbob.org> Date: Sat, 13 Aug 2022 23:59:09 -0500 Subject: [PATCH 0792/1681] 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 <lance@edbob.org> Date: Sun, 14 Aug 2022 00:52:53 -0500 Subject: [PATCH 0793/1681] 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 <lance@edbob.org> Date: Sun, 14 Aug 2022 10:17:52 -0500 Subject: [PATCH 0794/1681] 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 <lance@edbob.org> Date: Sun, 14 Aug 2022 15:53:43 -0500 Subject: [PATCH 0795/1681] 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 <lance@edbob.org> Date: Sun, 14 Aug 2022 17:33:12 -0500 Subject: [PATCH 0796/1681] 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 <lance@edbob.org> Date: Mon, 15 Aug 2022 21:06:19 -0500 Subject: [PATCH 0797/1681] 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'): - <li>${h.link_to("View DataSync Threads", url('datasync'))}</li> + <li>${h.link_to("View DataSync Status", url('datasync.status'))}</li> % endif </%def> 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 @@ <p class="block"> This tool works by modifying settings in the DB. It does <span class="is-italic">not</span> modify any config - files. If you intend to manage datasync config via files - only then you should - <span class="is-italic">not</span> 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. </p> <p class="block"> - 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 <span class="is-family-code">[rattail.datasync]</span> entries from your config file (and restart apps) so as to avoid confusion. </p> - <p class="block"> - Finally, you should know that this tool will - <span class="is-italic">overwrite</span> the entire - <span class="is-family-code">rattail.datasync</span> 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. - </p> </b-notification> + <b-field> + <b-checkbox name="use_profile_settings" + v-model="useProfileSettings" + native-value="true" + @input="settingsNeedSaved = true"> + Use these Settings to configure watchers and consumers + </b-checkbox> + </b-field> + <div class="level"> <div class="level-left"> <div class="level-item"> @@ -83,7 +84,8 @@ </div> </div> <div class="level-right"> - <div class="level-item"> + <div class="level-item" + v-show="useProfileSettings"> <b-button type="is-primary" @click="newProfile()" icon-pack="fas" @@ -130,7 +132,8 @@ <b-table-column field="enabled" label="Enabled"> {{ props.row.enabled ? "Yes" : "No" }} </b-table-column> - <b-table-column label="Actions"> + <b-table-column label="Actions" + v-if="useProfileSettings"> <a href="#" class="grid-action" @click.prevent="editProfile(props.row)"> @@ -397,15 +400,22 @@ <h3 class="is-size-3">Misc.</h3> - <b-field grouped> - <b-field label="Restart Command" - message="This will run as '${system_user}' system user - please configure sudoers as needed. Typical command is like: sudo supervisorctl restart poser:poser_datasync" - expanded> - <b-input name="restart_command" - v-model="restartCommand" - @input="settingsNeedSaved = true"> - </b-input> - </b-field> + <b-field label="Supervisor Process Name" + message="This should be the complete name, including group - e.g. poser:poser_datasync" + expanded> + <b-input name="supervisor_process_name" + v-model="supervisorProcessName" + @input="settingsNeedSaved = true"> + </b-input> + </b-field> + + <b-field label="Restart Command" + message="This will run as '${system_user}' system user - please configure sudoers as needed. Typical command is like: sudo supervisorctl restart poser:poser_datasync" + expanded> + <b-input name="restart_command" + v-model="restartCommand" + @input="settingsNeedSaved = true"> + </b-input> </b-field> </%def> @@ -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'): - <li>${h.link_to("View DataSync Changes", url('datasyncchanges'))}</li> - % endif -</%def> - -<%def name="render_grid_component()"> - <b-notification :closable="false"> - TODO: this page coming soon... - </b-notification> - ${parent.render_grid_component()} -</%def> - - -${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> + +<%def name="context_menu_items()"> + ${parent.context_menu_items()} + % if request.has_perm('datasync_changes.list'): + <li>${h.link_to("View DataSync Changes", url('datasyncchanges'))}</li> + % endif +</%def> + +<%def name="page_content()"> + <b-field label="Supervisor Status"> + <div style="display: flex;"> + + % if process_info: + <pre class="has-background-${'success' if process_info['statename'] == 'RUNNING' else 'danger'}">${process_info['group']}:${process_info['name']} ${process_info['statename']} ${process_info['description']}</pre> + % else: + <pre class="has-background-warning">${supervisor_error}</pre> + % endif + + <div style="margin-left: 1rem;"> + % if request.has_perm('datasync.restart'): + ${h.form(url('datasync.restart'), **{'@submit': 'restartProcess'})} + ${h.csrf_token(request)} + <b-button type="is-primary" + native-type="submit" + icon-pack="fas" + icon-left="redo" + :disabled="restartingProcess"> + {{ restartingProcess ? "Working, please wait..." : "Restart Process" }} + </b-button> + ${h.end_form()} + % endif + </div> + + </div> + </b-field> + + <b-field label="Watcher Status"> + <b-table :data="watchers"> + <template slot-scope="props"> + <b-table-column field="key" + label="Watcher"> + {{ props.row.key }} + </b-table-column> + <b-table-column field="spec" + label="Spec"> + {{ props.row.spec }} + </b-table-column> + <b-table-column field="dbkey" + label="DB Key"> + {{ props.row.dbkey }} + </b-table-column> + <b-table-column field="delay" + label="Delay"> + {{ props.row.delay }} second(s) + </b-table-column> + <b-table-column field="lastrun" + label="Last Watched"> + <span v-html="props.row.lastrun"></span> + </b-table-column> + <b-table-column field="status" + label="Status" + :class="props.row.status == 'okay' ? 'has-background-success' : 'has-background-warning'"> + {{ props.row.status }} + </b-table-column> + </template> + </b-table> + </b-field> + + <b-field label="Consumer Status"> + <b-table :data="consumers"> + <template slot-scope="props"> + <b-table-column field="key" + label="Consumer"> + {{ props.row.key }} + </b-table-column> + <b-table-column field="spec" + label="Spec"> + {{ props.row.spec }} + </b-table-column> + <b-table-column field="dbkey" + label="DB Key"> + {{ props.row.dbkey }} + </b-table-column> + <b-table-column field="delay" + label="Delay"> + {{ props.row.delay }} second(s) + </b-table-column> + <b-table-column field="changes" + label="Pending Changes"> + {{ props.row.changes }} + </b-table-column> + <b-table-column field="status" + label="Status" + :class="props.row.status == 'okay' ? 'has-background-success' : 'has-background-warning'"> + {{ props.row.status }} + </b-table-column> + </template> + </b-table> + </b-field> +</%def> + +<%def name="modify_this_page_vars()"> + <script type="text/javascript"> + + ThisPageData.restartingProcess = false + ThisPageData.watchers = ${json.dumps(watcher_data)|n} + ThisPageData.consumers = ${json.dumps(consumer_data)|n} + + ThisPage.methods.restartProcess = function() { + this.restartingProcess = true + } + + </script> +</%def> + + +${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 <lance@edbob.org> Date: Wed, 17 Aug 2022 18:19:37 -0500 Subject: [PATCH 0798/1681] 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 <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +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> <%def name="page_content()"> + % if expose_websockets: + <b-notification type="is-warning" + :active="websocketClosed" + :closable="false"> + Server connection was broken - please refresh page to see accurate status! + </b-notification> + % endif <b-field label="Supervisor Status"> <div style="display: flex;"> - % if process_info: - <pre class="has-background-${'success' if process_info['statename'] == 'RUNNING' else 'danger'}">${process_info['group']}:${process_info['name']} ${process_info['statename']} ${process_info['description']}</pre> - % else: - <pre class="has-background-warning">${supervisor_error}</pre> - % endif + <pre :class="processInfo.statename == 'RUNNING' ? 'has-background-success' : 'has-background-warning'">{{ processDescription }}</pre> <div style="margin-left: 1rem;"> % if request.has_perm('datasync.restart'): @@ -106,6 +109,17 @@ <%def name="modify_this_page_vars()"> <script type="text/javascript"> + ThisPageData.processInfo = ${json.dumps(process_info)|n} + + ThisPage.computed.processDescription = function() { + let info = this.processInfo + if (info) { + return `${'$'}{info.group}:${'$'}{info.name} ${'$'}{info.statename} ${'$'}{info.description}` + } else { + return "NO PROCESS INFO AVAILABLE" + } + } + ThisPageData.restartingProcess = false ThisPageData.watchers = ${json.dumps(watcher_data)|n} ThisPageData.consumers = ${json.dumps(consumer_data)|n} @@ -114,6 +128,31 @@ this.restartingProcess = true } + % if expose_websockets: + + ThisPageData.ws = null + ThisPageData.websocketClosed = false + + ThisPage.mounted = function() { + + ## TODO: should be a cleaner way to get this url? + let url = '${request.route_url('ws.datasync.status')}' + url = url.replace(/^https?:/, 'wss:') + + this.ws = new WebSocket(url) + let that = this + + this.ws.onclose = (event) => { + that.websocketClosed = true + } + + this.ws.onmessage = (event) => { + that.processInfo = JSON.parse(event.data) + } + } + + % endif + </script> </%def> 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 <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +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 <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +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 <lance@edbob.org> Date: Wed, 17 Aug 2022 18:44:10 -0500 Subject: [PATCH 0799/1681] 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 <lance@edbob.org> Date: Wed, 17 Aug 2022 19:06:02 -0500 Subject: [PATCH 0800/1681] 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> <%def name="page_content()"> - % if expose_websockets: + % if expose_websockets and not supervisor_error: <b-notification type="is-warning" :active="websocketClosed" :closable="false"> @@ -21,7 +21,11 @@ <b-field label="Supervisor Status"> <div style="display: flex;"> - <pre :class="processInfo.statename == 'RUNNING' ? 'has-background-success' : 'has-background-warning'">{{ processDescription }}</pre> + % if supervisor_error: + <pre class="has-background-warning">${supervisor_error}</pre> + % else: + <pre :class="(processInfo && processInfo.statename == 'RUNNING') ? 'has-background-success' : 'has-background-warning'">{{ processDescription }}</pre> + % endif <div style="margin-left: 1rem;"> % 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 <lance@edbob.org> Date: Wed, 17 Aug 2022 21:08:54 -0500 Subject: [PATCH 0801/1681] 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 @@ </header> <section class="modal-card-body"> - <p> + <p class="block"> Questions, suggestions, comments, complaints, etc. <span class="red">regarding this website</span> are welcome and may be submitted below. @@ -516,6 +516,24 @@ </b-input> </b-field> + % if request.rattail_config.getbool('tailbone', 'feedback_allows_reply'): + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <b-checkbox v-model="pleaseReply" + @input="pleaseReplyChanged"> + Please email me back{{ pleaseReply ? " at: " : "" }} + </b-checkbox> + </div> + <div class="level-item" v-show="pleaseReply"> + <b-input v-model="userEmail" + ref="userEmail"> + </b-input> + </div> + </div> + </div> + % endif + </section> <footer class="modal-card-foot"> 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 <lance@edbob.org> Date: Wed, 17 Aug 2022 21:30:39 -0500 Subject: [PATCH 0802/1681] 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 <lance@edbob.org> Date: Wed, 17 Aug 2022 22:59:50 -0500 Subject: [PATCH 0803/1681] 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: <b-notification type="is-warning" - :active="websocketClosed" + :active="websocketBroken" :closable="false"> Server connection was broken - please refresh page to see accurate status! </b-notification> @@ -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 <lance@edbob.org> Date: Thu, 18 Aug 2022 15:11:09 -0500 Subject: [PATCH 0804/1681] 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)'})} + + <h3 class="is-size-3">Overnight Tasks</h3> + <div class="block" style="padding-left: 2rem; display: flex;"> + + <b-table :data="overnightTasks"> + <template slot-scope="props"> + <b-table-column field="key" + label="Key" + sortable> + {{ props.row.key }} + </b-table-column> + </template> + </b-table> + + <div style="margin-left: 1rem;"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="plus" + @click="overnightTaskCreate()"> + New Task + </b-button> + + <b-modal has-modal-card + :active.sync="overnightTaskShowDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Overnight Task</p> + </header> + + <section class="modal-card-body"> + <b-field label="Key"> + <b-input v-model.trim="overnightTaskKey" + ref="overnightTaskKey"> + </b-input> + </b-field> + </section> + + <footer class="modal-card-foot"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="save" + @click="overnightTaskSave()" + :disabled="!overnightTaskKey"> + Save + </b-button> + <b-button @click="overnightTaskShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </b-modal> + + </div> + </div> + + <h3 class="is-size-3">Luigi Proper</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field label="Luigi URL" + message="This should be the URL to Luigi Task Visualiser web user interface." + expanded> + <b-input name="luigi.url" + v-model="simpleSettings['luigi.url']" + @input="settingsNeedSaved = true"> + </b-input> + </b-field> + + <b-field label="Supervisor Process Name" + message="This should be the complete name, including group - e.g. luigi:luigid" + expanded> + <b-input name="luigi.scheduler.supervisor_process_name" + v-model="simpleSettings['luigi.scheduler.supervisor_process_name']" + @input="settingsNeedSaved = true"> + </b-input> + </b-field> + + <b-field label="Restart Command" + message="This will run as '${system_user}' system user - please configure sudoers as needed. Typical command is like: sudo supervisorctl restart luigi:luigid" + expanded> + <b-input name="luigi.scheduler.restart_command" + v-model="simpleSettings['luigi.scheduler.restart_command']" + @input="settingsNeedSaved = true"> + </b-input> + </b-field> + + </div> + +</%def> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + ThisPageData.overnightTasks = ${json.dumps(overnight_tasks)|n} + ThisPageData.overnightTaskShowDialog = false + ThisPageData.overnightTask = null + ThisPageData.overnightTaskKey = null + + ThisPage.methods.overnightTaskCreate = function() { + this.overnightTask = null + this.overnightTaskKey = null + this.overnightTaskShowDialog = true + this.$nextTick(() => { + this.$refs.overnightTaskKey.focus() + }) + } + + ThisPage.methods.overnightTaskSave = function() { + if (this.overnightTask) { + this.overnightTask.key = this.overnightTaskKey + } else { + let task = {key: this.overnightTaskKey} + this.overnightTasks.push(task) + } + this.overnightTaskShowDialog = false + this.settingsNeedSaved = true + } + + </script> +</%def> + + +${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> + +<%def name="page_content()"> + <br /> + <div class="form"> + + <div class="buttons"> + + <b-button tag="a" + % if luigi_url: + href="${luigi_url}" + % else: + href="#" disabled + title="Luigi URL is not configured" + % endif + icon-pack="fas" + icon-left="external-link-alt" + target="_blank"> + Luigi Task Visualiser + </b-button> + + <b-button tag="a" + % if luigi_history_url: + href="${luigi_history_url}" + % else: + href="#" disabled + title="Luigi URL is not configured" + % endif + icon-pack="fas" + icon-left="external-link-alt" + target="_blank"> + Luigi Task History + </b-button> + + % if master.has_perm('restart_scheduler'): + ${h.form(url('{}.restart_scheduler'.format(route_prefix)), **{'@submit': 'submitRestartSchedulerForm'})} + ${h.csrf_token(request)} + <b-button type="is-primary" + native-type="submit" + icon-pack="fas" + icon-left="redo" + :disabled="restartSchedulerFormSubmitting"> + {{ restartSchedulerFormSubmitting ? "Working, please wait..." : "Restart Luigi Scheduler" }} + </b-button> + ${h.end_form()} + % endif + </div> + + % if master.has_perm('launch'): + <h3 class="block is-size-3">Overnight Tasks</h3> + % for task in overnight_tasks: + <launch-job job-name="${task['key']}" + button-text="Restart Overnight ${task['key'].capitalize()}"> + </launch-job> + % endfor + % endif + + </div> +</%def> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + % if master.has_perm('restart_scheduler'): + <script type="text/javascript"> + + ThisPageData.restartSchedulerFormSubmitting = false + + ThisPage.methods.submitRestartSchedulerForm = function() { + this.restartSchedulerFormSubmitting = true + } + + </script> + % endif +</%def> + +<%def name="finalize_this_page_vars()"> + ${parent.finalize_this_page_vars()} + % if master.has_perm('launch'): + <script type="text/javascript"> + + const LaunchJob = { + template: '#launch-job-template', + props: { + jobName: String, + buttonText: String, + }, + data() { + return { + formSubmitting: false, + } + }, + methods: { + submitForm() { + this.formSubmitting = true + }, + }, + } + + Vue.component('launch-job', LaunchJob) + + </script> + % endif +</%def> + +<%def name="render_this_page_template()"> + ${parent.render_this_page_template()} + % if master.has_perm('launch'): + <script type="text/x-template" id="launch-job-template"> + ${h.form(url('{}.launch'.format(route_prefix)), method='post', **{'@submit': 'submitForm'})} + ${h.csrf_token(request)} + <input type="hidden" name="job" v-model="jobName" /> + <b-button type="is-primary" + native-type="submit" + :disabled="formSubmitting"> + {{ formSubmitting ? "Working, please wait..." : buttonText }} + </b-button> + ${h.end_form()} + </script> + % endif +</%def> + + +${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 <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +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 <lance@edbob.org> Date: Thu, 18 Aug 2022 17:27:30 -0500 Subject: [PATCH 0805/1681] 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 @@ </b-checkbox> </b-field> + <b-checkbox name="rattail.batch.purchase.receiving.should_autofix_invoice_case_vs_unit" + v-model="simpleSettings['rattail.batch.purchase.receiving.should_autofix_invoice_case_vs_unit']" + native-value="true" + @input="settingsNeedSaved = true"> + Try to auto-correct "case vs. unit" mistakes from invoice parser + </b-checkbox> + </div> <h3 class="block is-size-3">Mobile Interface</h3> 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 <lance@edbob.org> Date: Thu, 18 Aug 2022 17:29:13 -0500 Subject: [PATCH 0806/1681] 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 <lance@edbob.org> Date: Thu, 18 Aug 2022 18:19:54 -0500 Subject: [PATCH 0807/1681] 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 <lance@edbob.org> Date: Thu, 18 Aug 2022 18:23:30 -0500 Subject: [PATCH 0808/1681] 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 <lance@edbob.org> Date: Thu, 18 Aug 2022 19:22:04 -0500 Subject: [PATCH 0809/1681] 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 <lance@edbob.org> Date: Fri, 19 Aug 2022 17:20:01 -0500 Subject: [PATCH 0810/1681] 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> +<%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> + +<%def name="render_buefy_form()"> + <div class="form"> + <${form.component} + % if master.has_perm('execute'): + @declare-failure="declareFailure" + % endif + > + </${form.component}> + </div> +</%def> + <%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)): <div class="buttons"> @@ -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 + </script> </%def> 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 <lance@edbob.org> Date: Sat, 20 Aug 2022 17:39:33 -0500 Subject: [PATCH 0811/1681] 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: <div class="level-item"> - ${h.link_to(u"« Older", prev_url, class_='button autodisable')} + % if use_buefy: + <b-button tag="a" href="${prev_url}" + icon-pack="fas" + icon-left="arrow-left"> + Older + </b-button> + % else: + ${h.link_to(u"« Older", prev_url, class_='button autodisable')} + % endif </div> % else: <div class="level-item"> - ${h.link_to(u"« Older", '#', class_='button', disabled='disabled')} + % if use_buefy: + <b-button tag="a" href="#" + disabled + icon-pack="fas" + icon-left="arrow-left"> + Older + </b-button> + % else: + ${h.link_to(u"« Older", '#', class_='button', disabled='disabled')} + % endif </div> % endif % if next_url: <div class="level-item"> - ${h.link_to(u"Newer »", next_url, class_='button autodisable')} + % if use_buefy: + <b-button tag="a" href="${next_url}" + icon-pack="fas" + icon-left="arrow-right"> + Newer + </b-button> + % else: + ${h.link_to(u"Newer »", next_url, class_='button autodisable')} + % endif </div> % else: <div class="level-item"> - ${h.link_to(u"Newer »", '#', class_='button', disabled='disabled')} + % if use_buefy: + <b-button tag="a" href="#" + disabled + icon-pack="fas" + icon-left="arrow-right"> + Newer + </b-button> + % else: + ${h.link_to(u"Newer »", '#', class_='button', disabled='disabled')} + % endif </div> % 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)'})} + + <h3 class="is-size-3">Upgradable Systems</h3> + <div class="block" style="padding-left: 2rem; display: flex;"> + + <b-table :data="upgradeSystems" + sortable> + <template slot-scope="props"> + <b-table-column field="key" + label="Key" + sortable> + {{ props.row.key }} + </b-table-column> + <b-table-column field="label" + label="Label" + sortable> + {{ props.row.label }} + </b-table-column> + <b-table-column field="command" + label="Command" + sortable> + {{ props.row.command }} + </b-table-column> + <b-table-column label="Actions"> + <a href="#" + @click.prevent="upgradeSystemEdit(props.row)"> + <i class="fas fa-edit"></i> + Edit + </a> + + <a href="#" + v-if="props.row.key != 'rattail'" + class="has-text-danger" + @click.prevent="updateSystemDelete(props.row)"> + <i class="fas fa-trash"></i> + Delete + </a> + </b-table-column> + </template> + </b-table> + + <div style="margin-left: 1rem;"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="plus" + @click="upgradeSystemCreate()"> + New System + </b-button> + + <b-modal has-modal-card + :active.sync="upgradeSystemShowDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Upgradable System</p> + </header> + + <section class="modal-card-body"> + <b-field label="Key" + :type="upgradeSystemKey ? null : 'is-danger'"> + <b-input v-model.trim="upgradeSystemKey" + ref="upgradeSystemKey" + :disabled="upgradeSystemKey == 'rattail'"> + </b-input> + </b-field> + <b-field label="Label" + :type="upgradeSystemLabel ? null : 'is-danger'"> + <b-input v-model.trim="upgradeSystemLabel" + ref="upgradeSystemLabel" + :disabled="upgradeSystemKey == 'rattail'"> + </b-input> + </b-field> + <b-field label="Command" + :type="upgradeSystemCommand ? null : 'is-danger'"> + <b-input v-model.trim="upgradeSystemCommand" + ref="upgradeSystemCommand"> + </b-input> + </b-field> + </section> + + <footer class="modal-card-foot"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="save" + @click="upgradeSystemSave()" + :disabled="!upgradeSystemKey || !upgradeSystemLabel || !upgradeSystemCommand"> + Save + </b-button> + <b-button @click="upgradeSystemShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </b-modal> + + </div> + </div> +</%def> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + ThisPageData.upgradeSystems = ${json.dumps(upgrade_systems)|n} + ThisPageData.upgradeSystemShowDialog = false + ThisPageData.upgradeSystem = null + ThisPageData.upgradeSystemKey = null + ThisPageData.upgradeSystemLabel = null + ThisPageData.upgradeSystemCommand = null + + ThisPage.methods.upgradeSystemCreate = function() { + this.upgradeSystem = null + this.upgradeSystemKey = null + this.upgradeSystemLabel = null + this.upgradeSystemCommand = null + this.upgradeSystemShowDialog = true + this.$nextTick(() => { + this.$refs.upgradeSystemKey.focus() + }) + } + + ThisPage.methods.upgradeSystemEdit = function(system) { + this.upgradeSystem = system + this.upgradeSystemKey = system.key + this.upgradeSystemLabel = system.label + this.upgradeSystemCommand = system.command + this.upgradeSystemShowDialog = true + this.$nextTick(() => { + this.$refs.upgradeSystemCommand.focus() + }) + } + + ThisPage.methods.upgradeSystemSave = function() { + if (this.upgradeSystem) { + this.upgradeSystem.key = this.upgradeSystemKey + this.upgradeSystem.label = this.upgradeSystemLabel + this.upgradeSystem.command = this.upgradeSystemCommand + } else { + let system = {key: this.upgradeSystemKey, + label: this.upgradeSystemLabel, + command: this.upgradeSystemCommand} + this.upgradeSystems.push(system) + } + this.upgradeSystemShowDialog = false + this.settingsNeedSaved = true + } + + </script> +</%def> + + +${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> +<%def name="extra_styles()"> + ${parent.extra_styles()} + <style type="text/css"> + .progress-with-textout { + border: 1px solid Black; + line-height: 1.2; + overflow: auto; + padding: 1rem; + } + </style> +</%def> + <%def name="render_this_page()"> ${parent.render_this_page()} @@ -60,31 +72,86 @@ </%def> <%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'): <div class="buttons"> % 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: + <b-button type="is-primary" + icon-pack="fas" + icon-left="arrow-circle-right" + :disabled="upgradeExecuting" + @click="executeUpgrade()"> + {{ upgradeExecuting ? "Working, please wait..." : "Execute this upgrade" }} + </b-button> + % elif use_buefy: + ${h.form(url('{}.execute'.format(route_prefix), uuid=instance.uuid), **{'@submit': 'submitForm'})} + ${h.csrf_token(request)} <b-button type="is-primary" native-type="submit" + icon-pack="fas" + icon-left="arrow-circle-right" :disabled="formSubmitting"> - {{ formButtonText }} + {{ formSubmitting ? "Working, please wait..." : "Execute this upgrade" }} </b-button> + ${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: <button type="button" class="button is-primary" disabled="disabled" title="This upgrade is currently executing">Execute this upgrade</button> % else: <button type="button" class="button is-primary" disabled="disabled" title="This upgrade is not enabled">Execute this upgrade</button> % endif </div> + + <b-modal :active.sync="upgradeExecuting" + full-screen + :can-cancel="false"> + <div class="card"> + <div class="card-content"> + + <div class="level"> + <div class="level-item has-text-centered" + style="display: flex; flex-direction: column;"> + <p class="block">Upgrading (please wait) ...</p> + <b-progress size="is-large" + style="width: 400px;" +## :value="80" +## show-value +## format="percent" + > + </b-progress> + </div> + <div class="level-right"> + <div class="level-item"> + <b-button type="is-warning" + icon-pack="fas" + icon-left="sad-tear" + @click="declareFailureClick()"> + Declare Failure + </b-button> + </div> + </div> + </div> + + <div class="container progress-with-textout is-family-monospace is-size-7" + ref="textout"> + <span v-for="line in progressOutput" + :key="line.key" + v-html="line.text"> + </span> + + ## nb. we auto-scroll down to "see" this element + <div ref="seeme"></div> + </div> + + </div> + </div> + </b-modal> + % endif </%def> @@ -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 <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +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', '<br />') + 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 <lance@edbob.org> Date: Sat, 20 Aug 2022 18:55:33 -0500 Subject: [PATCH 0812/1681] 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()} - <style type="text/css"> - .progress-with-textout { - border: 1px solid Black; - line-height: 1.2; - overflow: auto; - padding: 1rem; - } - </style> + % if master.has_perm('execute'): + <style type="text/css"> + .progress-with-textout { + border: 1px solid Black; + line-height: 1.2; + overflow: auto; + padding: 1rem; + } + </style> + % endif </%def> <%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> - -<%def name="render_buefy_form()"> - <div class="form"> - <${form.component} - % if master.has_perm('execute'): - @declare-failure="declareFailure" - % endif - > - </${form.component}> - </div> -</%def> - -<%def name="render_form_buttons()"> - % if not instance.executed and instance.status_code == enum.UPGRADE_STATUS_PENDING and master.has_perm('execute'): - <div class="buttons"> - % if instance.enabled and not instance.executing: - % if use_buefy and expose_websockets: - <b-button type="is-primary" - icon-pack="fas" - icon-left="arrow-circle-right" - :disabled="upgradeExecuting" - @click="executeUpgrade()"> - {{ upgradeExecuting ? "Working, please wait..." : "Execute this upgrade" }} - </b-button> - % elif use_buefy: - ${h.form(url('{}.execute'.format(route_prefix), uuid=instance.uuid), **{'@submit': 'submitForm'})} - ${h.csrf_token(request)} - <b-button type="is-primary" - native-type="submit" - icon-pack="fas" - icon-left="arrow-circle-right" - :disabled="formSubmitting"> - {{ formSubmitting ? "Working, please wait..." : "Execute this upgrade" }} - </b-button> - ${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: - <button type="button" class="button is-primary" disabled="disabled" title="This upgrade is currently executing">Execute this upgrade</button> - % else: - <button type="button" class="button is-primary" disabled="disabled" title="This upgrade is not enabled">Execute this upgrade</button> - % endif - </div> - + % if expose_websockets and master.has_perm('execute'): <b-modal :active.sync="upgradeExecuting" full-screen :can-cancel="false"> @@ -116,12 +65,15 @@ <div class="level"> <div class="level-item has-text-centered" style="display: flex; flex-direction: column;"> - <p class="block">Upgrading (please wait) ...</p> + <p class="block"> + Upgrading (please wait) ... + {{ executeUpgradeComplete ? "DONE!" : "" }} + </p> <b-progress size="is-large" style="width: 400px;" -## :value="80" -## show-value -## format="percent" + ## :value="80" + ## show-value + ## format="percent" > </b-progress> </div> @@ -151,7 +103,64 @@ </div> </div> </b-modal> + % 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> + +<%def name="render_buefy_form()"> + <div class="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 + > + </${form.component}> + </div> +</%def> + +<%def name="render_form_buttons()"> + % if instance_executable and master.has_perm('execute'): + <div class="buttons"> + % if instance.enabled and not instance.executing: + % if use_buefy and expose_websockets: + <b-button type="is-primary" + icon-pack="fas" + icon-left="arrow-circle-right" + :disabled="upgradeExecuting" + @click="$emit('execute-upgrade-click')"> + {{ upgradeExecuting ? "Working, please wait..." : "Execute this upgrade" }} + </b-button> + % elif use_buefy: + ${h.form(url('{}.execute'.format(route_prefix), uuid=instance.uuid), **{'@submit': 'submitForm'})} + ${h.csrf_token(request)} + <b-button type="is-primary" + native-type="submit" + icon-pack="fas" + icon-left="arrow-circle-right" + :disabled="formSubmitting"> + {{ formSubmitting ? "Working, please wait..." : "Execute this upgrade" }} + </b-button> + ${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: + <button type="button" class="button is-primary" disabled="disabled" title="This upgrade is currently executing">Execute this upgrade</button> + % else: + <button type="button" class="button is-primary" disabled="disabled" title="This upgrade is not enabled">Execute this upgrade</button> + % endif + </div> % endif </%def> @@ -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 <lance@edbob.org> Date: Sat, 20 Aug 2022 21:19:20 -0500 Subject: [PATCH 0813/1681] 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 <lance@edbob.org> Date: Sat, 20 Aug 2022 22:40:16 -0500 Subject: [PATCH 0814/1681] 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 @@ <div class="form"> <${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', '<br />') - 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', '<br />') + 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 <lance@edbob.org> Date: Sat, 20 Aug 2022 23:07:19 -0500 Subject: [PATCH 0815/1681] 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 <lance@edbob.org> Date: Sat, 20 Aug 2022 23:33:46 -0500 Subject: [PATCH 0816/1681] 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 <lance@edbob.org> Date: Sun, 21 Aug 2022 11:32:39 -0500 Subject: [PATCH 0817/1681] 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'): <li>${h.link_to("View DataSync Status", url('datasync.status'))}</li> % endif </%def> From 7b2fef5f093a615c812b473bcf460ec011ada6c4 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 21 Aug 2022 15:22:29 -0500 Subject: [PATCH 0818/1681] 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 @@ </b-input> </b-field> + <b-field :label="`Kwargs (${'$'}{editingProfilePendingWatcherKwargs.length})`"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="edit" + :disabled="editingWatcherKwarg" + @click="editingWatcherKwargs = !editingWatcherKwargs"> + {{ editingWatcherKwargs ? "Stop Editing" : "Edit Kwargs" }} + </b-button> + </b-field> + </b-field> - <div style="display: flex;"> + <div v-show="editingWatcherKwargs" + style="display: flex; justify-content: end;"> + + <b-button type="is-primary" + v-show="!editingWatcherKwarg" + icon-pack="fas" + icon-left="plus" + @click="newWatcherKwarg()"> + New Watcher Kwarg + </b-button> + + <div v-show="editingWatcherKwarg"> + + <b-field grouped> + + <b-field label="Key" + :type="editingWatcherKwargKey ? null : 'is-danger'"> + <b-input v-model="editingWatcherKwargKey" + ref="watcherKwargKey"> + </b-input> + </b-field> + + <b-field label="Value" + :type="editingWatcherKwargValue ? null : 'is-danger'"> + <b-input v-model="editingWatcherKwargValue"> + </b-input> + </b-field> + + </b-field> + + <b-field grouped> + + <b-button @click="editingWatcherKwarg = null" + class="control" + > + Cancel + </b-button> + + <b-button type="is-primary" + @click="updateWatcherKwarg()" + class="control"> + Update Kwarg + </b-button> + + </b-field> + + </div> + + + <b-table :data="editingProfilePendingWatcherKwargs" + style="margin-left: 1rem;"> + <template slot-scope="props"> + <b-table-column field="key" label="Key"> + {{ props.row.key }} + </b-table-column> + <b-table-column field="value" label="Value"> + {{ props.row.value }} + </b-table-column> + <b-table-column label="Actions"> + <a href="#" + @click.prevent="editProfileWatcherKwarg(props.row)"> + <i class="fas fa-edit"></i> + Edit + </a> + + <a href="#" + class="has-text-danger" + @click.prevent="deleteProfileWatcherKwarg(props.row)"> + <i class="fas fa-trash"></i> + Delete + </a> + </b-table-column> + </template> + <template slot="empty"> + <section class="section"> + <div class="content has-text-grey has-text-centered"> + <p> + <b-icon + pack="fas" + icon="fas fa-sad-tear" + size="is-large"> + </b-icon> + </p> + <p>Nothing here.</p> + </div> + </section> + </template> + </b-table> + + </div> + + <div v-show="!editingWatcherKwargs" + style="display: flex;"> <div style="width: 40%;"> @@ -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 <lance@edbob.org> Date: Sun, 21 Aug 2022 19:36:48 -0500 Subject: [PATCH 0819/1681] 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 <lance@edbob.org> Date: Sun, 21 Aug 2022 20:41:55 -0500 Subject: [PATCH 0820/1681] 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 <lance@edbob.org> Date: Mon, 22 Aug 2022 01:07:58 -0500 Subject: [PATCH 0821/1681] 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 <lance@edbob.org> Date: Tue, 23 Aug 2022 23:27:47 -0500 Subject: [PATCH 0822/1681] 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)'})} - <h3 class="is-size-3">Overnight Tasks</h3> + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <h3 class="is-size-3">Overnight Tasks</h3> + </div> + <div class="level-item"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="plus" + @click="overnightTaskCreate()"> + New Task + </b-button> + </div> + </div> + </div> <div class="block" style="padding-left: 2rem; display: flex;"> <b-table :data="overnightTasks"> <template slot-scope="props"> - <b-table-column field="key" - label="Key" - sortable> - {{ props.row.key }} + <!-- <b-table-column field="key" --> + <!-- label="Key" --> + <!-- sortable> --> + <!-- {{ props.row.key }} --> + <!-- </b-table-column> --> + <b-table-column field="description" + label="Description"> + {{ props.row.description }} + </b-table-column> + <b-table-column field="script" + label="Script"> + {{ props.row.script }} + </b-table-column> + <b-table-column label="Actions"> + <a href="#" + @click.prevent="overnightTaskEdit(props.row)"> + <i class="fas fa-edit"></i> + Edit + </a> + + <a href="#" + class="has-text-danger" + @click.prevent="overnightTaskDelete(props.row)"> + <i class="fas fa-trash"></i> + Delete + </a> </b-table-column> </template> </b-table> - <div style="margin-left: 1rem;"> - <b-button type="is-primary" - icon-pack="fas" - icon-left="plus" - @click="overnightTaskCreate()"> - New Task - </b-button> + <b-modal has-modal-card + :active.sync="overnightTaskShowDialog"> + <div class="modal-card"> - <b-modal has-modal-card - :active.sync="overnightTaskShowDialog"> - <div class="modal-card"> + <header class="modal-card-head"> + <p class="modal-card-title">Overnight Task</p> + </header> - <header class="modal-card-head"> - <p class="modal-card-title">Overnight Task</p> - </header> + <section class="modal-card-body"> + <!-- <b-field label="Key"> --> + <!-- <b-input v-model.trim="overnightTaskKey" --> + <!-- ref="overnightTaskKey"> --> + <!-- </b-input> --> + <!-- </b-field> --> + <b-field label="Description" + :type="overnightTaskDescription ? null : 'is-danger'"> + <b-input v-model.trim="overnightTaskDescription" + ref="overnightTaskDescription"> + </b-input> + </b-field> + <b-field label="Script" + :type="overnightTaskScript ? null : 'is-danger'"> + <b-input v-model.trim="overnightTaskScript"> + </b-input> + </b-field> + <b-field label="Notes"> + <b-input v-model.trim="overnightTaskNotes" + type="textarea"> + </b-input> + </b-field> + </section> - <section class="modal-card-body"> - <b-field label="Key"> - <b-input v-model.trim="overnightTaskKey" - ref="overnightTaskKey"> - </b-input> - </b-field> - </section> + <footer class="modal-card-foot"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="save" + @click="overnightTaskSave()" + :disabled="!overnightTaskDescription || !overnightTaskScript"> + Save + </b-button> + <b-button @click="overnightTaskShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </b-modal> - <footer class="modal-card-foot"> - <b-button type="is-primary" - icon-pack="fas" - icon-left="save" - @click="overnightTaskSave()" - :disabled="!overnightTaskKey"> - Save - </b-button> - <b-button @click="overnightTaskShowDialog = false"> - Cancel - </b-button> - </footer> - </div> - </b-modal> + </div> + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <h3 class="is-size-3">Backfill Tasks</h3> + </div> + <div class="level-item"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="plus" + @click="backfillTaskCreate()"> + New Task + </b-button> + </div> </div> </div> + <div class="block" style="padding-left: 2rem; display: flex;"> + + <b-table :data="backfillTasks"> + <template slot-scope="props"> + <b-table-column field="description" + label="Description"> + {{ props.row.description }} + </b-table-column> + <b-table-column field="script" + label="Script"> + {{ props.row.script }} + </b-table-column> + <b-table-column field="forward" + label="Orientation"> + {{ props.row.forward ? "Forward" : "Backward" }} + </b-table-column> + <b-table-column field="target_date" + label="Target Date"> + {{ props.row.target_date }} + </b-table-column> + <b-table-column label="Actions"> + <a href="#" + @click.prevent="backfillTaskEdit(props.row)"> + <i class="fas fa-edit"></i> + Edit + </a> + + <a href="#" + class="has-text-danger" + @click.prevent="backfillTaskDelete(props.row)"> + <i class="fas fa-trash"></i> + Delete + </a> + </b-table-column> + </template> + </b-table> + + <b-modal has-modal-card + :active.sync="backfillTaskShowDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Backfill Task</p> + </header> + + <section class="modal-card-body"> + <b-field label="Description" + :type="backfillTaskDescription ? null : 'is-danger'"> + <b-input v-model.trim="backfillTaskDescription" + ref="backfillTaskDescription"> + </b-input> + </b-field> + <b-field label="Script" + :type="backfillTaskScript ? null : 'is-danger'"> + <b-input v-model.trim="backfillTaskScript"> + </b-input> + </b-field> + <b-field grouped> + <b-field label="Orientation"> + <b-select v-model="backfillTaskForward"> + <option :value="false">Backward</option> + <option :value="true">Forward</option> + </b-select> + </b-field> + <b-field label="Target Date"> + <tailbone-datepicker v-model="backfillTaskTargetDate"> + </tailbone-datepicker> + </b-field> + </b-field> + <b-field label="Notes"> + <b-input v-model.trim="backfillTaskNotes" + type="textarea"> + </b-input> + </b-field> + </section> + + <footer class="modal-card-foot"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="save" + @click="backfillTaskSave()" + :disabled="!backfillTaskDescription || !backfillTaskScript"> + Save + </b-button> + <b-button @click="backfillTaskShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </b-modal> + + </div> <h3 class="is-size-3">Luigi Proper</h3> <div class="block" style="padding-left: 2rem;"> @@ -65,8 +217,8 @@ <b-field label="Luigi URL" message="This should be the URL to Luigi Task Visualiser web user interface." expanded> - <b-input name="luigi.url" - v-model="simpleSettings['luigi.url']" + <b-input name="rattail.luigi.url" + v-model="simpleSettings['rattail.luigi.url']" @input="settingsNeedSaved = true"> </b-input> </b-field> @@ -74,8 +226,8 @@ <b-field label="Supervisor Process Name" message="This should be the complete name, including group - e.g. luigi:luigid" expanded> - <b-input name="luigi.scheduler.supervisor_process_name" - v-model="simpleSettings['luigi.scheduler.supervisor_process_name']" + <b-input name="rattail.luigi.scheduler.supervisor_process_name" + v-model="simpleSettings['rattail.luigi.scheduler.supervisor_process_name']" @input="settingsNeedSaved = true"> </b-input> </b-field> @@ -83,8 +235,8 @@ <b-field label="Restart Command" message="This will run as '${system_user}' system user - please configure sudoers as needed. Typical command is like: sudo supervisorctl restart luigi:luigid" expanded> - <b-input name="luigi.scheduler.restart_command" - v-model="simpleSettings['luigi.scheduler.restart_command']" + <b-input name="rattail.luigi.scheduler.restart_command" + v-model="simpleSettings['rattail.luigi.scheduler.restart_command']" @input="settingsNeedSaved = true"> </b-input> </b-field> @@ -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 + } + </script> </%def> 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> +<%def name="title()">View / Launch Tasks</%def> <%def name="page_content()"> <br /> @@ -49,13 +49,141 @@ % endif </div> - % if master.has_perm('launch'): + % if master.has_perm('launch_overnight'): + <h3 class="block is-size-3">Overnight Tasks</h3> - % for task in overnight_tasks: - <launch-job job-name="${task['key']}" - button-text="Restart Overnight ${task['key'].capitalize()}"> - </launch-job> - % endfor + + <b-table :data="overnightTasks" hoverable> + <template slot-scope="props"> + <b-table-column field="description" + label="Description"> + {{ props.row.description }} + </b-table-column> + <b-table-column field="script" + label="Script"> + {{ props.row.script }} + </b-table-column> + <b-table-column field="last_date" + label="Last Date" + :class="overnightTextClass(props.row)"> + {{ props.row.last_date || "never!" }} + </b-table-column> + <b-table-column label="Actions"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="arrow-circle-right" + :disabled="overnightTaskLaunching == props.row.key" + @click="overnightTaskLaunch(props.row)"> + {{ overnightTaskLaunching == props.row.key ? "Working, please wait..." : "Launch" }} + </b-button> + </b-table-column> + </template> + <template #empty> + <p class="block">No tasks defined.</p> + </template> + </b-table> + + % endif + + % if master.has_perm('launch_backfill'): + + <h3 class="block is-size-3">Backfill Tasks</h3> + + <b-table :data="backfillTasks" hoverable> + <template slot-scope="props"> + <b-table-column field="description" + label="Description"> + {{ props.row.description }} + </b-table-column> + <b-table-column field="script" + label="Script"> + {{ props.row.script }} + </b-table-column> + <b-table-column field="forward" + label="Orientation"> + {{ props.row.forward ? "Forward" : "Backward" }} + </b-table-column> + <b-table-column field="last_date" + label="Last Date" + :class="backfillTextClass(props.row)"> + {{ props.row.last_date }} + </b-table-column> + <b-table-column field="target_date" + label="Target Date"> + {{ props.row.target_date }} + </b-table-column> + <b-table-column label="Actions"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="arrow-circle-right" + @click="backfillTaskLaunch(props.row)"> + Launch + </b-button> + </b-table-column> + </template> + <template #empty> + <p class="block">No tasks defined.</p> + </template> + </b-table> + + <b-modal has-modal-card + :active.sync="backfillTaskShowLaunchDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Launch Backfill Task</p> + </header> + + <section class="modal-card-body" + v-if="backfillTask"> + + <p class="block has-text-weight-bold"> + {{ backfillTask.description }} + (goes {{ backfillTask.forward ? "FORWARD" : "BACKWARD" }}) + </p> + + <b-field grouped> + <b-field label="Last Date"> + {{ backfillTask.last_date || "n/a" }} + </b-field> + <b-field label="Target Date"> + {{ backfillTask.target_date || "n/a" }} + </b-field> + </b-field> + + <b-field grouped> + + <b-field label="Start Date" + :type="backfillTaskStartDate ? null : 'is-danger'"> + <tailbone-datepicker v-model="backfillTaskStartDate"> + </tailbone-datepicker> + </b-field> + + <b-field label="End Date" + :type="backfillTaskEndDate ? null : 'is-danger'"> + <tailbone-datepicker v-model="backfillTaskEndDate"> + </tailbone-datepicker> + </b-field> + + </b-field> + + </section> + + <footer class="modal-card-foot"> + <b-button @click="backfillTaskShowLaunchDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + icon-pack="fas" + icon-left="arrow-circle-right" + @click="backfillTaskLaunchSubmit()" + :disabled="backfillTaskLaunching || !backfillTaskStartDate || !backfillTaskEndDate"> + {{ backfillTaskLaunching ? "Working, please wait..." : "Launch" }} + </b-button> + </footer> + </div> + </b-modal> + % endif </div> @@ -63,8 +191,9 @@ <%def name="modify_this_page_vars()"> ${parent.modify_this_page_vars()} - % if master.has_perm('restart_scheduler'): - <script type="text/javascript"> + <script type="text/javascript"> + + % if master.has_perm('restart_scheduler'): ThisPageData.restartSchedulerFormSubmitting = false @@ -72,54 +201,104 @@ this.restartSchedulerFormSubmitting = true } - </script> - % endif -</%def> + % endif -<%def name="finalize_this_page_vars()"> - ${parent.finalize_this_page_vars()} - % if master.has_perm('launch'): - <script type="text/javascript"> + % if master.has_perm('launch_overnight'): - const LaunchJob = { - template: '#launch-job-template', - props: { - jobName: String, - buttonText: String, - }, - data() { - return { - formSubmitting: false, + ThisPageData.overnightTasks = ${json.dumps(overnight_tasks)|n} + ThisPageData.overnightTaskLaunching = false + + ThisPage.methods.overnightTextClass = function(task) { + let yesterday = '${rattail_app.today() - datetime.timedelta(days=1)}' + if (task.last_date) { + if (task.last_date == yesterday) { + return 'has-text-success' + } else { + return 'has-text-warning' } - }, - methods: { - submitForm() { - this.formSubmitting = true - }, - }, + } else { + return 'has-text-warning' + } } - Vue.component('launch-job', LaunchJob) + ThisPage.methods.overnightTaskLaunch = function(task) { + this.overnightTaskLaunching = task.key - </script> - % endif -</%def> + 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'): - <script type="text/x-template" id="launch-job-template"> - ${h.form(url('{}.launch'.format(route_prefix)), method='post', **{'@submit': 'submitForm'})} - ${h.csrf_token(request)} - <input type="hidden" name="job" v-model="jobName" /> - <b-button type="is-primary" - native-type="submit" - :disabled="formSubmitting"> - {{ formSubmitting ? "Working, please wait..." : buttonText }} - </b-button> - ${h.end_form()} - </script> - % 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 + + </script> </%def> 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 <lance@edbob.org> Date: Wed, 24 Aug 2022 18:24:42 -0500 Subject: [PATCH 0823/1681] 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 <lance@edbob.org> Date: Wed, 24 Aug 2022 18:29:46 -0500 Subject: [PATCH 0824/1681] 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 <lance@edbob.org> Date: Wed, 24 Aug 2022 20:06:38 -0500 Subject: [PATCH 0825/1681] 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 <lance@edbob.org> Date: Thu, 25 Aug 2022 22:15:56 -0500 Subject: [PATCH 0826/1681] 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 @@ <section class="modal-card-body"> <b-field label="First Name"> - <b-input v-model.trim="personFirstName"></b-input> + <b-input v-model.trim="personFirstName" + :maxlength="maxLengths.person_first_name || null"> + </b-input> </b-field> <b-field label="Middle Name"> - <b-input v-model.trim="personMiddleName"></b-input> + <b-input v-model.trim="personMiddleName" + :maxlength="maxLengths.person_middle_name || null"> + </b-input> </b-field> <b-field label="Last Name"> - <b-input v-model.trim="personLastName"></b-input> + <b-input v-model.trim="personLastName" + :maxlength="maxLengths.person_last_name || null"> + </b-input> </b-field> </section> 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 <lance@edbob.org> Date: Thu, 25 Aug 2022 22:18:33 -0500 Subject: [PATCH 0827/1681] 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 <lance@edbob.org> Date: Sat, 27 Aug 2022 22:45:52 -0500 Subject: [PATCH 0828/1681] 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 <lance@edbob.org> Date: Sat, 27 Aug 2022 23:36:09 -0500 Subject: [PATCH 0829/1681] 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 @@ </div> % endif + % if request.session.peek_flash('warning'): + <div class="error-messages"> + % for msg in request.session.pop_flash('warning'): + <div class="ui-state-error ui-corner-all"> + <span style="float: left; margin-right: .3em;" class="ui-icon ui-icon-alert"></span> + ${msg} + </div> + % endfor + </div> + % endif + % if request.session.peek_flash(): <div class="flash-messages"> % for msg in request.session.pop_flash(): From bb4e98af8d3d1eccd911cbecc00a0036daf7435d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 30 Aug 2022 10:58:13 -0500 Subject: [PATCH 0830/1681] 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<Zmy!@97bK*< z-E*$<$?N+b=lNrw-D_u_>$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^$mh<sX|uahuWIas)Ufc~}vhJwCwJCl@>0OSkUB^5z#ESQ9GgN#C`Q zO@&aZ@EX+|h#K-(btT?4E$CO(ES~Id3ftD^9Ym;%Wun7w=$08{*1B>+nmxO&Dy=68 ziOll@bNKoQabX^GNrIns608<Ec?AH6E~l%KtI|xgQ_KSuVL3X&zPMk^?dWy`@ZE4{ z%OJs9=PU<^hIECpih1}W42T*4sI_+*Fp7L{Rtz*W$iE^({x>q74wfFaHo9IO4_xg% zQNW}m%c^1V6GZG@#*8-4x)U?0y}=(|R=~2P+4a8NteaL;r$YH-(s-T0FKLIr{}|E5 z9B{Qn=^iQ1W5MYr97i5voko*wB-SEfvY;aC>|U)V<FE=&oO{Ko(VeH9WF#ioo24T( zyvXfx4~y%$weq|N`<RNhx&=4`eTvs{R;XdXI7%m$k(qL~jQkcnFmk8CQb;Tx`kYqb zF2!zj1Vff=#!};0Ox%6xA?djwj+f9RKrVk{A8RB>uuN~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)KJc<I_!*w}>Gx;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>(sY8<C!Y!( zG4JKXo2;iFIMXuXDd0V7uWJ)-pjY%3gMNETS;Vzko}EItPAp#FA2bGx%<A>ld^(E0 zUfe=JL`Ky_6hl6ZUYFfY2#v81SQsiCg^``#5*(w}rGnSYg{T$9ycV;nsXrA(7*DTS z3%S6E#`ds_ECn@x&TW5p^hmX<v?Lu=>@+a3gnO}lc!)+kFBmI#gB=hut68x}q}xH= z??XW4g1_a>vc~A8pJ5I+kMSr5zh#NT!v&nHTBS@>z3Aq*Q}T{;h@`XUA!lG7^3o^) zOR_Y<Q^7zSYu#|D!%X9&lob_re-9H6FNA9UuGre!ahi)J>v*;Peg6La4*ILB<Tr&Y z8^rgI?y`UjS60$i9Qe}D!|gQHU>g-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$SSOV7W<By~vg#<W|Xo_~W$>WE~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<oE85y%|tGjzNq)w6}e zfu<G5073ieV(&T}K^h%`Jq;n3G^spsQq|iw%`(VM)r##%<nk-hs2;zcAX}Q~=br|* z<mB<h$hjYsSOU;sSr+TGQ_ZEQH&WAO2H#e}2(lX36Jgh1dL<g%?lezwkl0)0-F_g# zHn_kuwEM~p8|t>(>8rL*CGmrH1ax*m<kqQ&-~8ZV=cjXDk^sUuyW0M95k*t7UXul? znPkqxne}kn3;U6Ca})z9Vi&!Tz(PY4C;Ss{6aQkMe|lM*Zolg+ACh=DvF2U4%^ayy zF`@B^XmHS*{`6!;3a1$IzWvm^t9cUJLiQOsVqr1-g_d-Gqxnpy4cbnF*ZM<UmK_q1 zMxOAQo~>DH@ihJsCMfnxBiSJyi)q*<98mz18VlI{9B6BOpHUcpp}`;fPJ<jgBu}|m z1D2#-bKqhfLRpW|Vf0fQq<)cs-y<F#aY|2Mn#vRvH%xqoZA*qgIlg=TeqNPx9alv7 zn{vhk5tzLh*>?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;+PRA2e<V*&--Uq5-=>q<>S#|B6ILX@3< z*Ft_MxFtt%%tkm4=d(BsVb`9IzzH9f7S4uC#^_?QhJe=N<Wic=&|np%g}m2pE9e8; z5%&XkSn7RVJQ~=%q!nz3c5hOmYUY`r;Px>(8k#HSpK1nKP$(7T>E-Wi`&X0SHZXO2 z#YY~rP*Hi_c5hV31$^&|_dxB-Vk6z+(OLk2z~Bd<I!pL)a+it>L#6q_bOEbQwln>L z=cD&+6p`N?*+m2LX82gaeh*2l<ZbhGZXe*b<!CIJTi9?Vu`j8P;+DVdgzh)@O~s5g zhw40&k<Gy6Ddm>NdZL2I5iO?8n!@*-ojW?>^)RNcqpHqV<xs!}J{mwM-_x(kJl%Q* zVWf456wb9E1nebw$3Bpm*Ee6M5|OT2g4-)ggB>SBW2;*3JzNP`?rX^fcgeDNEhPgt zdnYseBVvIcIf;p!RJ7)v(Cyp`+5wlEK|~rnIW|8Qrn%1-`9({Dlvt}$9mOKh9e>u8 zga;)<M9qrg&osQsRqou(_6B-mejiTJd<|(<SY~`7kj$xa5+v4nYbkP>;%7njDqbwJ zrXYS-#%hF983!iVs*LoLq5IsZS>tvG0(bR%SpPz<KKLg{{^hh!%0K{GRKLGllUebt z#OjqVdSXcd))ISFL>sb`nX5XV;yR4WxHO!D9Gpbs`#3;1RBI>`eW=q!qPnx(2T1{^ z>pgv=g%?F!`y7iKhR;24EMCdq9t*Hc6O6D!xEB?bW<?66lo598Bb3Kd`a=t4w&igK zotbr)#I?dq55`aC)b%KL>>TxRqO9%wgbpIpvqmO95B+oT*eSj_J1n9hJQ{#GRLl_^ z_rq2n3zK<O>|P2iwD#@>#F^X84mSs1j{bZN-w1p_ePK9r36G?gnFuXHyPK2Z!XNJr zP}so?3T<z?qHB5n{xsBmuz((WHk_Ij%dm%dI3RpX1ovCuNj_^_V8Q;of`|#d+xmlI zyY7nZ%ZC$9-}Z;izruC0I_PR&4AS*n@fm>0aK4-C?Q5ZmdS*Y~HUlZc)=B?V)cDAv z{#)1ot>u$PR5AHMQM*AgiGjZLH{@+<TBAR`QVt498@jX<N$0!e9TXS_G%79xB&Y-* zz(nLS;G}jxVltIt6x~XUIAi0_ZwBV)5FLM~Zgqmfn1}DHvJhJjl#F*fA2KAnqE}P% zO_^wS8JLMLTNKhXM%ln^0>fQQ%`vZRz8ysuAI&?=pT*seIu3tXS^Qlw%6G*KLs9JW zGBhud`<P9USFnXdYeZgGKbK0g*_qe(G*A=BHep<~810_*UauxLt=(IqG0G!LnruD1 z{L)kGJy0>>vL#t#W6<GC$7isEG*Za}fOaLJ4U5RvzWYaw3PS$F#?2by;r7sz*Ye>* 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~<W4 z+WELs{+{aQ0lqmylJJjB_X))m?)*tUMc(3lHxy2Li-Sa)NE8B)%=qsU5Xp6%tld0p zQ9#A%m}-R~neiB!*d~YXiGy1y56d>Os^+FzaBQ^JH_E7|o^5aFu+TkToRvJz%s%_# z<pWA-0J!*Z3zFkt`7;=rJ~Xs*Xw5j|N5mFZ=jwAa$|><Vq%IVwvzAWm<vC%`c<^Nl zCf~N?kTZ7D53RB^290BBGSW5{*X2&gnp!DLQ*xs#h0j@{r&5gT%u)Sx^pXb;hQ_5j zzIivCYjKkU#8B3030kh12&Gut42y4s;JGH?m8$|g^ymrbBQ(aaU<J>cF6GYa{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*<TvNa%JRUdrWWXNl<kwNgnZ9b(sb)c$|CyCc5%7tTFeyyE z+L-O6ny4R6mC(=x1oy-0c~w`#jg2`wF;fjot2HyjW{%OIMs)Ahq_ec;>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()} +</%def> + + +${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 <lance@edbob.org> Date: Tue, 30 Aug 2022 11:04:26 -0500 Subject: [PATCH 0831/1681] 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 <lance@edbob.org> Date: Tue, 30 Aug 2022 11:28:16 -0500 Subject: [PATCH 0832/1681] 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 <lance@edbob.org> Date: Tue, 30 Aug 2022 13:57:18 -0500 Subject: [PATCH 0833/1681] 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 <lance@edbob.org> Date: Tue, 30 Aug 2022 14:18:57 -0500 Subject: [PATCH 0834/1681] 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 <lance@edbob.org> Date: Tue, 30 Aug 2022 21:14:01 -0500 Subject: [PATCH 0835/1681] 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 <lance@edbob.org> Date: Tue, 30 Aug 2022 21:56:46 -0500 Subject: [PATCH 0836/1681] 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 <lance@edbob.org> Date: Wed, 31 Aug 2022 16:41:58 -0500 Subject: [PATCH 0837/1681] 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'): <b-button type="is-danger" @click="deleteResultsInit()" + :disabled="!total" icon-pack="fas" icon-left="trash"> Delete Results From c43a4edec7ef1ea59794021fbf61658fe716f60f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 31 Aug 2022 20:52:17 -0500 Subject: [PATCH 0838/1681] 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 <lance@edbob.org> Date: Tue, 6 Sep 2022 13:09:14 -0500 Subject: [PATCH 0839/1681] 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 <lance@edbob.org> Date: Tue, 6 Sep 2022 13:21:29 -0500 Subject: [PATCH 0840/1681] 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 <lance@edbob.org> Date: Tue, 6 Sep 2022 16:31:59 -0500 Subject: [PATCH 0841/1681] 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 @@ </b-checkbox> </b-field> + <b-field> + <b-checkbox name="rattail.custorders.allow_item_discounts" + v-model="simpleSettings['rattail.custorders.allow_item_discounts']" + native-value="true" + @input="settingsNeedSaved = true"> + Allow per-item discounts + </b-checkbox> + </b-field> + </div> </%def> 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 @@ </b-field> <b-field grouped> - <b-field label="Total Price"> + % if allow_item_discounts: + <b-field label="Discount" horizontal> + <div class="level"> + <div class="level-item"> + <numeric-input v-model="productDiscountPercent" + style="width: 5rem;"> + </numeric-input> + </div> + <div class="level-item"> + <span> %</span> + </div> + </div> + </b-field> + % endif + <b-field label="Total Price" horizontal expanded> <span :class="productSalePriceDisplay ? 'has-background-warning': null"> {{ getItemTotalPriceDisplay() }} </span> @@ -981,6 +995,12 @@ </span> </b-table-column> + % if allow_item_discounts: + <b-table-column label="Discount"> + {{ props.row.discount_percent }}{{ props.row.discount_percent ? " %" : "" }} + </b-table-column> + % endif + <b-table-column label="Total"> <span % if product_price_may_be_questionable: @@ -1099,6 +1119,7 @@ items: ${json.dumps(order_items)|n}, editingItem: null, showingItemDialog: false, + itemDialogSaving: false, itemDialogTabIndex: 0, pastItemsShowDialog: false, pastItemsLoading: false, @@ -1133,6 +1154,10 @@ productPriceNeedsConfirmation: false, % endif + % if allow_item_discounts: + productDiscountPercent: null, + % endif + pendingProduct: {}, departmentOptions: ${json.dumps(department_options)|n}, @@ -1314,6 +1339,9 @@ }, itemDialogSaveDisabled() { + if (this.itemDialogSaving) { + return true + } if (this.productIsKnown) { if (!this.productUUID) { return true @@ -1330,6 +1358,9 @@ }, itemDialogSaveButtonText() { + if (this.itemDialogSaving) { + return "Working, please wait..." + } return this.editingItem ? "Update Item" : "Add Item" }, @@ -1723,6 +1754,11 @@ if (basePrice) { let totalPrice = basePrice * this.productQuantity if (totalPrice) { + % if allow_item_discounts: + if (this.productDiscountPercent) { + totalPrice *= (100 - this.productDiscountPercent) / 100 + } + % endif totalPrice = totalPrice.toFixed(2) return "$" + totalPrice } @@ -1790,6 +1826,10 @@ this.productPriceNeedsConfirmation = false % endif + % if allow_item_discounts: + this.productDiscountPercent = null + % endif + this.itemDialogTabIndex = 0 this.showingItemDialog = true this.$nextTick(() => { @@ -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 <lance@edbob.org> Date: Tue, 6 Sep 2022 16:44:26 -0500 Subject: [PATCH 0842/1681] 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 @@ </b-checkbox> </b-field> + <b-field> + <b-checkbox name="rattail.custorders.allow_past_item_reorder" + v-model="simpleSettings['rattail.custorders.allow_past_item_reorder']" + native-value="true" + @input="settingsNeedSaved = true"> + Allow re-order via past item lookup + </b-checkbox> + </b-field> + </div> </%def> 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 </b-button> + % if allow_past_item_reorder: <b-button v-if="contactUUID" icon-pack="fas" icon-left="fas fa-plus" @click="showAddPastItem()"> Add Past Item </b-button> + % endif </div> <b-modal :active.sync="showingItemDialog"> @@ -851,6 +853,7 @@ @selected="productLookupSelected"> </tailbone-product-lookup> + % if allow_past_item_reorder: <b-modal :active.sync="pastItemsShowDialog"> <div class="card"> <div class="card-content"> @@ -953,6 +956,7 @@ </div> </div> </b-modal> + % endif <b-table v-if="items.length" :data="items" @@ -1121,10 +1125,12 @@ showingItemDialog: false, itemDialogSaving: false, itemDialogTabIndex: 0, + % if allow_past_item_reorder: pastItemsShowDialog: false, pastItemsLoading: false, pastItems: [], pastItemsSelected: null, + % endif productIsKnown: true, productUUID: null, productDisplay: null, @@ -1503,9 +1509,11 @@ contactChanged(uuid, callback) { + % if allow_past_item_reorder: // clear out the past items cache this.pastItemsSelected = null this.pastItems = [] + % endif let params if (!uuid) { @@ -1837,6 +1845,8 @@ }) }, + % if allow_past_item_reorder: + showAddPastItem() { this.pastItemsSelected = null @@ -1888,6 +1898,8 @@ this.showingItemDialog = true }, + % endif + showEditItemDialog(row) { this.editingItem = row diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index 224ec33a..e8ce8fd3 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -349,6 +349,7 @@ class CustomerOrderView(MasterView): 'default_uom_choices': self.batch_handler.uom_choices_for_product(None), 'default_uom': None, 'allow_item_discounts': self.batch_handler.allow_item_discounts(), + 'allow_past_item_reorder': self.batch_handler.allow_past_item_reorder(), }) if self.batch_handler.allow_case_orders(): @@ -986,6 +987,9 @@ class CustomerOrderView(MasterView): {'section': 'rattail.custorders', 'option': 'allow_item_discounts', 'type': bool}, + {'section': 'rattail.custorders', + 'option': 'allow_past_item_reorder', + 'type': bool}, ] @classmethod From e46f4bf01eaab1cd2c42f377efa3a834db6cd398 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 6 Sep 2022 22:19:01 -0500 Subject: [PATCH 0843/1681] 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 <lance@edbob.org> Date: Wed, 7 Sep 2022 20:46:18 -0500 Subject: [PATCH 0844/1681] 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 <lance@edbob.org> Date: Fri, 9 Sep 2022 14:53:47 -0500 Subject: [PATCH 0845/1681] 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 <lance@edbob.org> Date: Sat, 24 Sep 2022 10:34:32 -0500 Subject: [PATCH 0846/1681] 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: <h4 class="block is-size-4">${rows_title}</h4> % endif - <tailbone-grid ref="rowGrid" id="rowGrid"></tailbone-grid> + ${self.render_row_grid_component()} % else: ${rows_grid|n} % endif % endif </%def> +<%def name="render_row_grid_component()"> + <tailbone-grid ref="rowGrid" id="rowGrid"></tailbone-grid> +</%def> + <%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 <lance@edbob.org> Date: Sun, 25 Sep 2022 09:18:34 -0500 Subject: [PATCH 0847/1681] 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 <lance@edbob.org> Date: Tue, 18 Oct 2022 10:55:47 -0500 Subject: [PATCH 0848/1681] 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 <lance@edbob.org> Date: Wed, 19 Oct 2022 16:26:05 -0500 Subject: [PATCH 0849/1681] 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 <lance@edbob.org> Date: Sat, 29 Oct 2022 13:40:35 -0500 Subject: [PATCH 0850/1681] 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 <lance@edbob.org> Date: Mon, 31 Oct 2022 21:41:01 -0500 Subject: [PATCH 0851/1681] 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 <lance@edbob.org> Date: Thu, 3 Nov 2022 11:28:38 -0500 Subject: [PATCH 0852/1681] 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 <lance@edbob.org> Date: Thu, 3 Nov 2022 11:34:32 -0500 Subject: [PATCH 0853/1681] 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 <lance@edbob.org> Date: Tue, 15 Nov 2022 13:37:37 -0600 Subject: [PATCH 0854/1681] 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 <lance@edbob.org> Date: Tue, 15 Nov 2022 13:39:17 -0600 Subject: [PATCH 0855/1681] 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 <lance@edbob.org> Date: Tue, 15 Nov 2022 16:29:15 -0600 Subject: [PATCH 0856/1681] 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; -*- +<script type="text/x-template" id="grid-filter-numeric-value-template"> + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <b-input v-model="startValue" + ref="startValue" + @input="startValueChanged"> + </b-input> + </div> + <div v-show="wantsRange" + class="level-item"> + and + </div> + <div v-show="wantsRange" + class="level-item"> + <b-input v-model="endValue" + ref="endValue" + @input="endValueChanged"> + </b-input> + </div> + </div> + </div> +</script> + <script type="text/x-template" id="grid-filter-date-value-template"> <div class="level"> <div class="level-left"> @@ -75,6 +99,13 @@ </option> </b-select> + <grid-filter-numeric-value v-if="filter.data_type == 'number'" + v-model="filter.value" + v-show="valuedVerb()" + :wants-range="filter.verb == 'between'" + ref="valueInput"> + </grid-filter-numeric-value> + <b-input v-if="filter.data_type == 'string' && !multiValuedVerb()" v-model="filter.value" v-show="valuedVerb()" From 3178894e4f61677b32fab31e6cd0599d70d33d8f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 17 Nov 2022 19:23:44 -0600 Subject: [PATCH 0857/1681] 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 8eca2ac4..9145b4db 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.259 (2022-11-17) +-------------------- + +* Add "between" verb for numeric grid filters. + + 0.8.258 (2022-11-15) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 3447d6bf..8b608b84 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.258' +__version__ = '0.8.259' From 3c740549e282a3e395065113f2baafcc867c775e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 18 Nov 2022 11:20:29 -0600 Subject: [PATCH 0858/1681] Turn on download results feature for Employees --- tailbone/views/employees.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/employees.py b/tailbone/views/employees.py index e42d32fa..b45e78e7 100644 --- a/tailbone/views/employees.py +++ b/tailbone/views/employees.py @@ -47,6 +47,7 @@ class EmployeeView(MasterView): has_versions = True touchable = True supports_autocomplete = True + results_downloadable = True labels = { 'id': "ID", From 163c65600d33f10a41a458e168b415a12b6bc441 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 18 Nov 2022 11:22:08 -0600 Subject: [PATCH 0859/1681] 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 9145b4db..77aa69c6 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.260 (2022-11-18) +-------------------- + +* Turn on download results feature for Employees. + + 0.8.259 (2022-11-17) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 8b608b84..bd5efa2c 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.259' +__version__ = '0.8.260' From e4392cd00aee900419f6aec1f7c467f0d8adbc3f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 19 Nov 2022 17:44:09 -0600 Subject: [PATCH 0860/1681] Allow disabling, or per-day scheduling, of problem reports --- .../templates/deform/problem_report_days.pt | 17 ++ tailbone/templates/reports/problems/view.mako | 4 + tailbone/views/reports.py | 174 +++++++++++++++--- 3 files changed, 167 insertions(+), 28 deletions(-) create mode 100644 tailbone/templates/deform/problem_report_days.pt diff --git a/tailbone/templates/deform/problem_report_days.pt b/tailbone/templates/deform/problem_report_days.pt new file mode 100644 index 00000000..ff3dd70c --- /dev/null +++ b/tailbone/templates/deform/problem_report_days.pt @@ -0,0 +1,17 @@ +<div tal:define="name name|field.name;" + tal:omit-tag=""> + <div tal:define="vmodel vmodel|'field_model_' + name;" + tal:omit-tag=""> + <b-field grouped> + <input type="hidden" name="${name}" + tal:attributes=":value 'JSON.stringify('+vmodel+')';" /> + <b-checkbox v-model="${vmodel}.day0">${day_labels[0]['abbr']}</b-checkbox> + <b-checkbox v-model="${vmodel}.day1">${day_labels[1]['abbr']}</b-checkbox> + <b-checkbox v-model="${vmodel}.day2">${day_labels[2]['abbr']}</b-checkbox> + <b-checkbox v-model="${vmodel}.day3">${day_labels[3]['abbr']}</b-checkbox> + <b-checkbox v-model="${vmodel}.day4">${day_labels[4]['abbr']}</b-checkbox> + <b-checkbox v-model="${vmodel}.day5">${day_labels[5]['abbr']}</b-checkbox> + <b-checkbox v-model="${vmodel}.day6">${day_labels[6]['abbr']}</b-checkbox> + </b-field> + </div> +</div> diff --git a/tailbone/templates/reports/problems/view.mako b/tailbone/templates/reports/problems/view.mako index cbd2a942..026c73dc 100644 --- a/tailbone/templates/reports/problems/view.mako +++ b/tailbone/templates/reports/problems/view.mako @@ -66,6 +66,10 @@ ${parent.modify_this_page_vars()} <script type="text/javascript"> + % if weekdays_data is not Undefined: + ${form.component_studly}Data.weekdaysData = ${json.dumps(weekdays_data)|n} + % endif + ThisPageData.runReportShowDialog = false ThisPageData.runReportSubmitting = false diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index f2e27d58..69eec23d 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -26,6 +26,8 @@ Reporting views from __future__ import unicode_literals, absolute_import +import calendar +import json import re import datetime import logging @@ -563,7 +565,6 @@ class ProblemReportView(MasterView): url_prefix = '/reports/problems' creatable = False - editable = False deletable = False filterable = False pageable = False @@ -571,6 +572,7 @@ class ProblemReportView(MasterView): labels = { 'system_key': "System", + 'days': "Schedule", } grid_columns = [ @@ -580,32 +582,17 @@ class ProblemReportView(MasterView): 'email_recipients', ] - form_fields = [ - 'system_key', - 'problem_title', - 'email_key', - 'email_recipients', - ] - def __init__(self, request): super(ProblemReportView, self).__init__(request) app = self.get_rattail_app() - self.handler = app.get_problem_report_handler() + self.problem_handler = app.get_problem_report_handler() + # TODO: deprecate / remove this + self.handler = self.problem_handler def normalize(self, report, keep_report=True): - data = { - 'system_key': report.system_key, - 'problem_key': report.problem_key, - 'problem_title': report.problem_title, - 'email_key': self.handler.get_email_key(report), - } - - app = self.get_rattail_app() - handler = app.get_email_handler() - email = handler.get_email(data['email_key']) - data['email_recipients'] = email.get_recips('all') - + data = self.problem_handler.normalize_problem_report( + report, include_schedule=True, include_recipients=True) if keep_report: data['_report'] = report return data @@ -653,28 +640,159 @@ class ProblemReportView(MasterView): def configure_form(self, f): super(ProblemReportView, self).configure_form(f) - f.set_renderer('email_key', self.render_email_key) - f.set_renderer('email_recipients', self.render_email_recipients) + # email_* + if self.editing: + f.remove('email_key', + 'email_recipients') + else: + f.set_renderer('email_key', self.render_email_key) + f.set_renderer('email_recipients', self.render_email_recipients) + + # enabled + f.set_type('enabled', 'boolean') + + # days + f.set_renderer('days', self.render_days) + f.set_widget('days', DaysWidget()) + f.set_vuejs_field_converter('days', self.convert_vuejs_days) + f.set_helptext('days', "NB. enabling a given day means you want the " + "report to be available that morning (assuming that " + "reports run overnight)") + + # only allow edit of certain fields + if self.editing: + editable = ('enabled', 'days') + for field in f: + if field not in editable: + f.set_readonly(field) + + def convert_vuejs_days(self, days): + days = dict(days) + for key in days: + if days[key] is colander.null: + days[key] = 'null' + return days def render_email_recipients(self, report_info, field): recips = report_info['email_recipients'] return ', '.join(recips) + def render_days(self, report_info, field): + g = self.get_grid_factory()('days', [], + columns=['weekday_name', 'enabled'], + labels={'weekday_name': "Weekday"}) + return HTML.literal(g.render_buefy_table_element(data_prop='weekdaysData')) + + def template_kwargs_view(self, **kwargs): + kwargs = super(ProblemReportView, self).template_kwargs_view(**kwargs) + report_info = kwargs['instance'] + + data = [] + for i in range(7): + data.append({ + 'weekday': i, + 'weekday_name': calendar.day_name[i], + 'enabled': "Yes" if report_info['day{}'.format(i)] else "No", + }) + kwargs['weekdays_data'] = data + + return kwargs + + def save_edit_form(self, form): + app = self.get_rattail_app() + session = self.Session() + data = form.validated + report = self.get_instance() + key = '{}.{}'.format(report['system_key'], + report['problem_key']) + + app.save_setting(session, 'rattail.problems.{}.enabled'.format(key), + six.text_type(data['enabled']).lower()) + + for i in range(7): + daykey = 'day{}'.format(i) + app.save_setting(session, 'rattail.problems.{}.{}'.format(key, daykey), + six.text_type(data['days'][daykey]).lower()) + def execute_instance(self, report_info, user, progress=None, **kwargs): report = report_info['_report'] - problems = self.handler.run_problem_report(report, progress=progress) + problems = self.handler.run_problem_report(report, progress=progress, + force=True) return "Report found {} problems".format(len(problems)) +class ProblemReportDays(colander.MappingSchema): + + day0 = colander.SchemaNode(colander.Boolean(), + title=calendar.day_abbr[0]) + day1 = colander.SchemaNode(colander.Boolean(), + title=calendar.day_abbr[1]) + day2 = colander.SchemaNode(colander.Boolean(), + title=calendar.day_abbr[2]) + day3 = colander.SchemaNode(colander.Boolean(), + title=calendar.day_abbr[3]) + day4 = colander.SchemaNode(colander.Boolean(), + title=calendar.day_abbr[4]) + day5 = colander.SchemaNode(colander.Boolean(), + title=calendar.day_abbr[5]) + day6 = colander.SchemaNode(colander.Boolean(), + title=calendar.day_abbr[6]) + + class ProblemReportSchema(colander.MappingSchema): - system_key = colander.SchemaNode(colander.String()) + system_key = colander.SchemaNode(colander.String(), + missing=colander.null) - problem_key = colander.SchemaNode(colander.String()) + problem_key = colander.SchemaNode(colander.String(), + missing=colander.null) - problem_title = colander.SchemaNode(colander.String()) + problem_title = colander.SchemaNode(colander.String(), + missing=colander.null) - email_key = colander.SchemaNode(colander.String()) + description = colander.SchemaNode(colander.String(), + missing=colander.null) + + email_key = colander.SchemaNode(colander.String(), + missing=colander.null) + + email_recipients = colander.SchemaNode(colander.String(), + missing=colander.null) + + enabled = colander.SchemaNode(colander.Boolean()) + + days = ProblemReportDays() + + +class DaysWidget(dfwidget.Widget): + template = 'problem_report_days' + + def serialize(self, field, cstruct, **kw): + if cstruct in (colander.null, None): + cstruct = "" + readonly = kw.get("readonly", self.readonly) + template = self.template + values = dict(kw) + if 'day_labels' not in values: + values['day_labels'] = self.get_day_labels() + values = self.get_template_values(field, cstruct, values) + return field.renderer(template, **values) + + def get_day_labels(self): + labels = {} + for i in range(7): + labels[i] = {'name': calendar.day_name[i], + 'abbr': calendar.day_abbr[i]} + return labels + + def deserialize(self, field, pstruct): + from deform.compat import string_types + if pstruct is colander.null: + return colander.null + elif not isinstance(pstruct, string_types): + raise colander.Invalid(field.schema, "Pstruct is not a string") + pstruct = json.loads(pstruct) + return pstruct def add_routes(config): From d4801f58e35d617e02ccb314392f39a9e8e01166 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 19 Nov 2022 21:45:23 -0600 Subject: [PATCH 0861/1681] Make sure `Grid` class is included in package API docs --- docs/api/grids.core.rst | 6 ++++++ docs/index.rst | 1 + 2 files changed, 7 insertions(+) create mode 100644 docs/api/grids.core.rst diff --git a/docs/api/grids.core.rst b/docs/api/grids.core.rst new file mode 100644 index 00000000..60155cb2 --- /dev/null +++ b/docs/api/grids.core.rst @@ -0,0 +1,6 @@ + +``tailbone.grids.core`` +======================= + +.. automodule:: tailbone.grids.core + :members: diff --git a/docs/index.rst b/docs/index.rst index ffa516e9..b19d859f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -46,6 +46,7 @@ Package API: api/api/batch/ordering api/forms api/grids + api/grids.core api/progress api/subscribers api/views/batch From 7f0305fb7aa26c5ca9f6b7cacf266cbeec15b742 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 20 Nov 2022 13:58:39 -0600 Subject: [PATCH 0862/1681] Fix how keys are stored for luigi overnight/backfill tasks --- tailbone/templates/luigi/configure.mako | 49 +++++++++++++++++-------- tailbone/views/luigi.py | 47 ++++++++++-------------- 2 files changed, 54 insertions(+), 42 deletions(-) diff --git a/tailbone/templates/luigi/configure.mako b/tailbone/templates/luigi/configure.mako index cf590adb..dd9581ae 100644 --- a/tailbone/templates/luigi/configure.mako +++ b/tailbone/templates/luigi/configure.mako @@ -29,6 +29,10 @@ <!-- sortable> --> <!-- {{ props.row.key }} --> <!-- </b-table-column> --> + <b-table-column field="key" + label="Key"> + {{ props.row.key }} + </b-table-column> <b-table-column field="description" label="Description"> {{ props.row.description }} @@ -63,11 +67,12 @@ </header> <section class="modal-card-body"> - <!-- <b-field label="Key"> --> - <!-- <b-input v-model.trim="overnightTaskKey" --> - <!-- ref="overnightTaskKey"> --> - <!-- </b-input> --> - <!-- </b-field> --> + <b-field label="Key" + :type="overnightTaskKey ? null : 'is-danger'"> + <b-input v-model.trim="overnightTaskKey" + ref="overnightTaskKey"> + </b-input> + </b-field> <b-field label="Description" :type="overnightTaskDescription ? null : 'is-danger'"> <b-input v-model.trim="overnightTaskDescription" @@ -91,7 +96,7 @@ icon-pack="fas" icon-left="save" @click="overnightTaskSave()" - :disabled="!overnightTaskDescription || !overnightTaskScript"> + :disabled="!overnightTaskKey || !overnightTaskDescription || !overnightTaskScript"> Save </b-button> <b-button @click="overnightTaskShowDialog = false"> @@ -122,6 +127,10 @@ <b-table :data="backfillTasks"> <template slot-scope="props"> + <b-table-column field="key" + label="Key"> + {{ props.row.key }} + </b-table-column> <b-table-column field="description" label="Description"> {{ props.row.description }} @@ -164,6 +173,12 @@ </header> <section class="modal-card-body"> + <b-field label="Key" + :type="backfillTaskKey ? null : 'is-danger'"> + <b-input v-model.trim="backfillTaskKey" + ref="backfillTaskKey"> + </b-input> + </b-field> <b-field label="Description" :type="backfillTaskDescription ? null : 'is-danger'"> <b-input v-model.trim="backfillTaskDescription" @@ -199,7 +214,7 @@ icon-pack="fas" icon-left="save" @click="backfillTaskSave()" - :disabled="!backfillTaskDescription || !backfillTaskScript"> + :disabled="!backfillTaskKey || !backfillTaskDescription || !backfillTaskScript"> Save </b-button> <b-button @click="backfillTaskShowDialog = false"> @@ -259,14 +274,14 @@ ThisPageData.overnightTaskNotes = null ThisPage.methods.overnightTaskCreate = function() { - this.overnightTask = {key: null} + this.overnightTask = {key: null, isNew: true} this.overnightTaskKey = null this.overnightTaskDescription = null this.overnightTaskScript = null this.overnightTaskNotes = null this.overnightTaskShowDialog = true this.$nextTick(() => { - this.$refs.overnightTaskDescription.focus() + this.$refs.overnightTaskKey.focus() }) } @@ -280,13 +295,14 @@ } ThisPage.methods.overnightTaskSave = function() { + this.overnightTask.key = this.overnightTaskKey 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}` + if (this.overnightTask.isNew) { this.overnightTasks.push(this.overnightTask) + this.overnightTask.isNew = false } this.overnightTaskShowDialog = false @@ -313,7 +329,8 @@ ThisPageData.backfillTaskNotes = null ThisPage.methods.backfillTaskCreate = function() { - this.backfillTask = {key: null} + this.backfillTask = {key: null, isNew: true} + this.backfillTaskKey = null this.backfillTaskDescription = null this.backfillTaskScript = null this.backfillTaskForward = false @@ -321,12 +338,13 @@ this.backfillTaskNotes = null this.backfillTaskShowDialog = true this.$nextTick(() => { - this.$refs.backfillTaskDescription.focus() + this.$refs.backfillTaskKey.focus() }) } ThisPage.methods.backfillTaskEdit = function(task) { this.backfillTask = task + this.backfillTaskKey = task.key this.backfillTaskDescription = task.description this.backfillTaskScript = task.script this.backfillTaskForward = task.forward @@ -344,15 +362,16 @@ } ThisPage.methods.backfillTaskSave = function() { + this.backfillTask.key = this.backfillTaskKey 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}` + if (this.backfillTask.isNew) { this.backfillTasks.push(this.backfillTask) + this.backfillTask.isNew = false } this.backfillTaskShowDialog = false diff --git a/tailbone/views/luigi.py b/tailbone/views/luigi.py index 054f24ee..c7efa50e 100644 --- a/tailbone/views/luigi.py +++ b/tailbone/views/luigi.py @@ -166,56 +166,39 @@ class LuigiTaskView(MasterView): # overnight tasks keys = [] for task in json.loads(data['overnight_tasks']): - 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), + {'name': 'rattail.luigi.overnight.task.{}.description'.format(key), 'value': task['description']}, - {'name': 'rattail.luigi.overnight.{}.script'.format(key), + {'name': 'rattail.luigi.overnight.task.{}.script'.format(key), 'value': task['script']}, - {'name': 'rattail.luigi.overnight.{}.notes'.format(key), + {'name': 'rattail.luigi.overnight.task.{}.notes'.format(key), 'value': task['notes']}, ]) if keys: - settings.append({'name': 'rattail.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), + {'name': 'rattail.luigi.backfill.task.{}.description'.format(key), 'value': task['description']}, - {'name': 'rattail.luigi.backfill.{}.script'.format(key), + {'name': 'rattail.luigi.backfill.task.{}.script'.format(key), 'value': task['script']}, - {'name': 'rattail.luigi.backfill.{}.forward'.format(key), + {'name': 'rattail.luigi.backfill.task.{}.forward'.format(key), 'value': 'true' if task['forward'] else 'false'}, - {'name': 'rattail.luigi.backfill.{}.notes'.format(key), + {'name': 'rattail.luigi.backfill.task.{}.notes'.format(key), 'value': task['notes']}, - {'name': 'rattail.luigi.backfill.{}.target_date'.format(key), + {'name': 'rattail.luigi.backfill.task.{}.target_date'.format(key), 'value': six.text_type(task['target_date'])}, ]) if keys: - settings.append({'name': 'rattail.luigi.backfill_tasks', + settings.append({'name': 'rattail.luigi.backfill.tasks', 'value': ', '.join(keys)}) return settings @@ -228,15 +211,25 @@ class LuigiTaskView(MasterView): to_delete = session.query(model.Setting)\ .filter(sa.or_( + model.Setting.name == 'rattail.luigi.backfill.tasks', model.Setting.name == 'rattail.luigi.backfill_tasks', + model.Setting.name.like('rattail.luigi.backfill.task.%.description'), model.Setting.name.like('rattail.luigi.backfill.%.description'), + model.Setting.name.like('rattail.luigi.backfill.task.%.forward'), model.Setting.name.like('rattail.luigi.backfill.%.forward'), + model.Setting.name.like('rattail.luigi.backfill.task.%.notes'), model.Setting.name.like('rattail.luigi.backfill.%.notes'), + model.Setting.name.like('rattail.luigi.backfill.task.%.script'), model.Setting.name.like('rattail.luigi.backfill.%.script'), + model.Setting.name.like('rattail.luigi.backfill.task.%.target_date'), model.Setting.name.like('rattail.luigi.backfill.%.target_date'), + model.Setting.name == 'rattail.luigi.overnight.tasks', model.Setting.name == 'rattail.luigi.overnight_tasks', + model.Setting.name.like('rattail.luigi.overnight.task.%.description'), model.Setting.name.like('rattail.luigi.overnight.%.description'), + model.Setting.name.like('rattail.luigi.overnight.task.%.notes'), model.Setting.name.like('rattail.luigi.overnight.%.notes'), + model.Setting.name.like('rattail.luigi.overnight.task.%.script'), model.Setting.name.like('rattail.luigi.overnight.%.script')))\ .all() From 922b550c17360874dfa33f66ab26adac6bdc99d6 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 20 Nov 2022 16:00:03 -0600 Subject: [PATCH 0863/1681] 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 77aa69c6..b732fab7 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.261 (2022-11-20) +-------------------- + +* Allow disabling, or per-day scheduling, of problem reports. + +* Fix how keys are stored for luigi overnight/backfill tasks. + + 0.8.260 (2022-11-18) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index bd5efa2c..dbc90623 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.260' +__version__ = '0.8.261' From 194f49c561c1b43d2c1142a16bf0fddc7079637b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 20 Nov 2022 19:37:29 -0600 Subject: [PATCH 0864/1681] Add luigi module/class awareness for overnight tasks --- tailbone/templates/luigi/configure.mako | 25 ++++++++++++++++++++++--- tailbone/views/luigi.py | 10 +++++++++- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/tailbone/templates/luigi/configure.mako b/tailbone/templates/luigi/configure.mako index dd9581ae..bac57b75 100644 --- a/tailbone/templates/luigi/configure.mako +++ b/tailbone/templates/luigi/configure.mako @@ -37,6 +37,10 @@ label="Description"> {{ props.row.description }} </b-table-column> + <b-table-column field="class_name" + label="Class Name"> + {{ props.row.class_name }} + </b-table-column> <b-table-column field="script" label="Script"> {{ props.row.script }} @@ -79,8 +83,15 @@ ref="overnightTaskDescription"> </b-input> </b-field> - <b-field label="Script" - :type="overnightTaskScript ? null : 'is-danger'"> + <b-field label="Module"> + <b-input v-model.trim="overnightTaskModule"> + </b-input> + </b-field> + <b-field label="Class Name"> + <b-input v-model.trim="overnightTaskClass"> + </b-input> + </b-field> + <b-field label="Script"> <b-input v-model.trim="overnightTaskScript"> </b-input> </b-field> @@ -96,7 +107,7 @@ icon-pack="fas" icon-left="save" @click="overnightTaskSave()" - :disabled="!overnightTaskKey || !overnightTaskDescription || !overnightTaskScript"> + :disabled="!overnightTaskKey || !overnightTaskDescription"> Save </b-button> <b-button @click="overnightTaskShowDialog = false"> @@ -270,6 +281,8 @@ ThisPageData.overnightTaskCounter = 0 ThisPageData.overnightTaskKey = null ThisPageData.overnightTaskDescription = null + ThisPageData.overnightTaskModule = null + ThisPageData.overnightTaskClass = null ThisPageData.overnightTaskScript = null ThisPageData.overnightTaskNotes = null @@ -277,6 +290,8 @@ this.overnightTask = {key: null, isNew: true} this.overnightTaskKey = null this.overnightTaskDescription = null + this.overnightTaskModule = null + this.overnightTaskClass = null this.overnightTaskScript = null this.overnightTaskNotes = null this.overnightTaskShowDialog = true @@ -289,6 +304,8 @@ this.overnightTask = task this.overnightTaskKey = task.key this.overnightTaskDescription = task.description + this.overnightTaskModule = task.module + this.overnightTaskClass = task.class_name this.overnightTaskScript = task.script this.overnightTaskNotes = task.notes this.overnightTaskShowDialog = true @@ -297,6 +314,8 @@ ThisPage.methods.overnightTaskSave = function() { this.overnightTask.key = this.overnightTaskKey this.overnightTask.description = this.overnightTaskDescription + this.overnightTask.module = this.overnightTaskModule + this.overnightTask.class_name = this.overnightTaskClass this.overnightTask.script = this.overnightTaskScript this.overnightTask.notes = this.overnightTaskNotes diff --git a/tailbone/views/luigi.py b/tailbone/views/luigi.py index c7efa50e..4f293943 100644 --- a/tailbone/views/luigi.py +++ b/tailbone/views/luigi.py @@ -86,7 +86,9 @@ class LuigiTaskView(MasterView): return self.json_response({'error': "Task not found"}) try: - self.luigi_handler.launch_overnight_task(task, app.yesterday()) + self.luigi_handler.launch_overnight_task(task, app.yesterday(), + email_if_empty=True, + wait=False) except Exception as error: log.warning("failed to launch overnight task: %s", task, exc_info=True) @@ -171,6 +173,10 @@ class LuigiTaskView(MasterView): settings.extend([ {'name': 'rattail.luigi.overnight.task.{}.description'.format(key), 'value': task['description']}, + {'name': 'rattail.luigi.overnight.task.{}.module'.format(key), + 'value': task['module']}, + {'name': 'rattail.luigi.overnight.task.{}.class_name'.format(key), + 'value': task['class_name']}, {'name': 'rattail.luigi.overnight.task.{}.script'.format(key), 'value': task['script']}, {'name': 'rattail.luigi.overnight.task.{}.notes'.format(key), @@ -229,6 +235,8 @@ class LuigiTaskView(MasterView): model.Setting.name.like('rattail.luigi.overnight.%.description'), model.Setting.name.like('rattail.luigi.overnight.task.%.notes'), model.Setting.name.like('rattail.luigi.overnight.%.notes'), + model.Setting.name.like('rattail.luigi.overnight.task.%.module'), + model.Setting.name.like('rattail.luigi.overnight.task.%.class_name'), model.Setting.name.like('rattail.luigi.overnight.task.%.script'), model.Setting.name.like('rattail.luigi.overnight.%.script')))\ .all() From a63d7e9b64a187cae6dbefc11761e7f679f7cad9 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 20 Nov 2022 20:26:48 -0600 Subject: [PATCH 0865/1681] 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 b732fab7..fe59c234 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.262 (2022-11-20) +-------------------- + +* Add luigi module/class awareness for overnight tasks. + + 0.8.261 (2022-11-20) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index dbc90623..881aaa84 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.261' +__version__ = '0.8.262' From de5a8fae7c64fdb891faa972d449630244b1e9da Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 20 Nov 2022 21:01:15 -0600 Subject: [PATCH 0866/1681] Update 'testing' watermark for dev background for some reason Firefox suddenly would not display the old one. so i opened it in gimp, then re-exported to same filename. apparently something changed, this one worked in FF. obviously not much care was taken in the migration here. so maybe see the previous file as starting point in case this needs revisiting --- tailbone/static/img/testing.png | Bin 4780 -> 10375 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/tailbone/static/img/testing.png b/tailbone/static/img/testing.png index 281ec8cf2bf26ff4ca9b64d8c8fd8608a0841e7b..7228b334edf2e11f718b6fd3bf550e18c1eb9563 100644 GIT binary patch delta 5693 zcmV-D7Q*SQC5KUvBYzd{dQ@0+Qek%>aB^>EX>4U6ba`-PAZ2)IW&i+q+U=TGa%8!7 zMgK7h4FUcD!-0*^3^aVL4JNDIi+)v7e<?&(F_V|q0LN?tq&xrhpV$2dU!|7pa<Nu> zt)8#kbB}{J&3}E3_cQqXe!j0>KYt5<JnroC#zzh%UenjF?SK1%!|}-Z@pF8A?lU-E zes`mOK7W{h{KNR%D7Mc7z90B`PzqlU@ShvI@wriopC{+9-{;q~eC^GCKZW%<|9<}d z!oYv0A30g9r3TCV%+Z_G&ifX9HsZE5&}8rVKOXew-`)4Akuk-SLh}4<8e!7^m<GoJ zKlxqfy!XtHpMN=hMoPA#^TzxB9oN7A`n!?4@1cKE{)d$Qc=xANFaOs}yBE;!Q_`O@ zRDZnw%j2Q^cHI3wr7v&4dvdLxckz$M$M_EMZ<%x7>AQEgd(YSGMy^b`ekS!j#rKZ$ zrlR$(s(c&&i*E|HzB}KImRLM%6NJh43Oz*9ctZ|5jDK*$d9N!hhM41t#%qj=R4&KC zMlyO_q%<g{DTf;^^|VtX-HUQa9F0H565eC`d)x|*2k+1v)f2c`cs29C_;x>Y(!EM} z2#V$UiWTFEs%%D~%!S~5;52d&?kA|_1^D~>&tJ-GB7^0HxiP`vr|v-dX9>R*S9*y~ zoLG4MN`G>&{kZ`lVg!Z3&^9^v8d3>0c#9D{3Iwu|pux~%%5jpGP)ghkGUk*j$)O8) zHrH5qr^XV$4g-WFqNM7Qk(-_d$;!D<Kb9LcG)gMD6hPEUE4_@GsyeJzt3uRa$+8uz zX4b6Ra4ogmN~_IUYrTygdjfGwryqOky^p~|2Y+`STs=5pj2UN|d6rqH%{KcSE4r0d zUS-u~tF6AqjyrAoc-L*a?Y_qehf+HElv9tLcKR8YQoHHqTW-B}+wJa-A6fgP^)KJQ zkhSp1T6~$(iS{FF95wsCMG%}6<&2EQ97wn+10lH>l{4Q$&QY0D&U}w_MTrcuC^<VQ zV}E2YpAgFpKXUh5=88-8pUIme5%HhMoKxz4B6EMu+rP@%9uJ7q%aGboLG_95yZxRv zVjE)0zkPN8?u4Ix{l9*r)6+`j__izgJ3f<BN_b{~XUug@CKVAaF({P1+X|=OJT6d^ zCN)}HsifaP0i!c-J*|>ui*x2Ixv@uRdVlEkokH4`I<j>~sRKacj=&uFW&TcqKSCu@ zHO6^kOI;NH{rao>g$wA2;cttxsNaZ(QRTHCXfjV%W69L@+H<c~2tavU+fUzII*P5> zXW7vcn$~X(yARHS>C6fZUx_SNHy8T3;=n8Ac{x9v#o2U`nkdsovz;AdFO8><e1F<p zA>DzLx@}vzR}a3+`C0D?RqfYzDwTd(`-*vcN@Lb=5tq%UNz3$NHcy;a-Os?vCiJ5; zy0f}HS}$u1Qrz~*>lpCh^R=^2iv#WKDUNE#m)UkQ^g`~tFg?FYv^{#Nb<zF1cfXfS zFo~idZS|aX2WRkH0<^=PW%M2nV}EablyD%j(x{SST+u1OoJMt*2(J7F4DjtVmN7}9 z;^*w(Sj#BNjK1>;f~34^Xou*JyAntf7!=xbE~gw)a0E5<2sDn{xk2xFCaSX~<Z!i6 zlCF$*3xjrTXHdw1oXrZpq9vPKxspUmvo7e=*Fr#i4~t1!N+*&Vc$6twD}RxZlgf?k zJY{fY=|l$#UmnbcP!dQ^eGknSq`tY4^c^os3$Sej^vqnXzeHq;CD0v1iI1(UI2y@t z=cvf)JcQ=-PDK?zL22f+dMwonDTaBr1O!!HG%0dPx$JWGJZL!MLdP?bKg4>Tl!0uj z@2cl&DFOjM%A#E8$ez%63V-8ho6OMG;KU4H!<f^-U52Uz{|m#|i`i3XRDFuMBsc_S z+Y^~m6?jx(83zo&5yV&34FaI&ugjG}4VgSyU;M4t+d+pLp}$PdDTxgQpiu)8p4@^v z(hC6^yOuLysArt(*2o>+MB)m$Ji%`KN`WIr<6NUoMowLhMasFJYJa1n1GUD)hpNVU zxo5fIfxU>It5g^o(bn<=a9>6PcG8FuBq)Z+I*GUE)Jk)~XOT7*lRi_n+zlJyrmOmR z@Qbkjg;$jLKA0n^T1X$oSMf%Z`5{J#CqV>|0>ez1lXGtCMwJ|2iJ|nvjTjsya1~iU zT&3_L#k?g5CJ~QeHh%^<HXbqs3ULwdOw3dx1Goc^3|#76b&230R=HIQa;Z|lJ3udo zlVb^UtrhA4`BS5ZPbXz7-zJU-<reV_j1#G0;smUqd0Y<k4*ZfyEyco^(^Re8Q!>p= zjY$=Sg&ZnVtU*zs)^<u<oq#~7ie`>(u5E%y+L1T-5Y&ZdzJCY{S~XooB!rDX!i7ZZ z!Y&*LNu!?oZOTyryIiM8@`Q;xGg~NYAdRqg>Y4a*731;YP}{@_PJ5t0a1)iJ!i<DP zz~oSQWyZd8w>T)L5bq==JafP}1${-q@8fwmq3Cw&tS9ew-t2jJ6hmuj38bZm*3BF5 zf?NV1cub-cnSY_Z)}@JxU+69jfNFuC-6PZnB8#9)khw*C{b6!wxelnFK}x)Yz?If4 z^v>jzk)Q~Z0|h1GhSQkenokS9a5w`x?~FXXW!R*x1iep?3XE=#ye@OUqr&_(5KCeU zK~knd2hXJG&1lCFgbS#_2(U5jOl8uW0CxqqPF0cK34bM|1fI?d-6X9p5QL1V%vU6` zfi^c$sPxKM0N)u9NnH>L7#3*=PP|>~ET|WPU63iXf8D)uS)qvN6+1}t0@G&nX}wT; zTF7ldSLUwuwpmVu9y9E+6M6_30?1HBfFQYELYav#wjOqy4vA0MMsSV_8%6{vW-9-N z*qOV+<bUbk7~mJH)IW`lCXwGfhm!%byI?(7gzluQr{Ldsfig~z>8H$Mz>pgnABtw$ zO~7!2U$DUvXcer50nxQY*8?8h!oo^0OA3ZUN_3feG^wsCOphQ85v<0G!=VeNF#L)w zx&pNCoa`NgyQ<(mh+sp}LHVc>5R#yjF5dS`kbjL)Az&qz-sEWr8;=okQTQ<(^wBD! z(Pc=VR0<8hMYoI{4@XPb7+PRct-}1|h{x`F<~?b`Mz443o(^DYlMX){EYA5{jFbF_ zmL`}rT?nCoEHoJ;&6MkeSx!`1)<oJ_c-eH7AON{}Y@5uQueMHUgI+z5JK^l~pAqa0 zJ%2<{>C&*gK_g}2ybocVDLY)A)q9JW2z6Y5RYW2AjAaJ8t{H&_nJ#UCb&>|@zQf2~ zD4IL2jCjxF23kQK2A!4ql^*aijxQFqixQx7+gf>>;>S=Lb0=sFHrFKVA<oy)%$-hx zmFQ#I9AU{hG(6QY7ibf>9&Lx^Bzc1#jek%!-~$NSJ_1;{<|G%;K#O=Mt>h4Lf}SwH zhL>9$L_p-e0H0pMGXc5sOhgE$j*I6+6~Y`aEv1DrTM{%>X*`4%+2z%q2YV`L%!!Pz zbr(v8V&y^r1K$ugEv;%Hjvc7=A<!mp6RKkxmu*^=G?|nqNLHTs|NGb49aT7j3V)ey zFIi%=5g8D$;tWbfo>9Aig@1B1J~pI;@HNN^+-~EYk7GE>x@VDXmwLia;KpfcniaBw zk%d5HkD3_^3>k}}rd*G@NRFytlP<c=LH&VNnRNb(K8<rB%_wyq={jh!(Vs=@Z?ru` zlqVH`sh=gLq>&z$JLm)dFXcl^#D6w7;^Z>d0Qk%>vbO^OB_sjO1H9rhNP4{m@daGr z{Sm<EG}p;?6mKk02J#m%SKe<@o?t~Ss)I93C0)rpn?*_ptxVaR(>0-0S8=rf4;8!x z^pkb)Ov{CoEz`v(j4f#2&4|<TTMH`bN!*UdDzL3_{zY9o5U&$FEsrqL2Y>WNPg>cA zXiA8W6?i5YLZu?p(b_O8!Y7Xap@Oq#4D#r*yvs#5S$%L|lp$JZGdTzr{aV93$R%JX z)C9W*xD28eaP_EqXAG?rhY8EXa8e*-1NyA0js!SvWb#p6LZrxKW?*FCJW7UtQAJ#_ z7<4<JvRg-$*7Wpr{Fs<6D1Sa8>0k_$45gP5B0b<25Ix{460V4M{2McUhImQEg&iy4 zP!HI`zX7m;j9RINq4606V43heobe=Bqs!4>n5;`rz^k}=AlA{0mep-LT)LH#_vqt{ z=B5GVR*)QcKgtzljafw1P@$+WeA`>mg3<vrcrDY_5D0w*vvvl{5r6SLJ)zo#bs-nV ziVi^=Hk4(5*6TpHs0t{^kR-?9z!~m@*V@1YH<A8_dNJS#VBiuomB=rW*N5p>k>Q{O zP!!-<lR<Md1NRrKComj#$Q&q8Y+{ZG#h$fbM)slTggdlj(zvVC&HWf35Jlf6hdtZ5 z2AQf1o+46kX>cse>VJV}1kx%ky)_jy@u*)SSq_Ab0)e~Aq$0E^Y&>wnIu}ZP0nt(_ zNN+1RI*0`#4)iiGq_sI_w-LVFfXvd8bT)_%N;q5ze2Qc?gKXbw1X8GmYGNL(4{WW1 zY)FSb)4U`kAuMz%_#sUcB)=4FBoEZf)8l!n84w1ZaVrzVlz&%|9_;eDgebLpFpIR9 z)X@P8k>_x^BlcZ{LyeHDyYa&a3H7b)fX^{K1J|hpd_8D#n5%de%L*GJf=EA_%smx` zyhk@iaV0PUd{&7n#+lVz+6yDL%o=v7(;#;xjfyV3L4<d*<b2F^e1#@?<IyK=(^5mn zbt0{+C&rj}#D5`O*R|$u=rJQlQ>=GFLMB;8u|ep74XGmSEu%C6vJ7dWqd11u8dwlC zPgq`#E%QLMCQXimV~ljn2BnD6VdDG@<TNd@5jA`J24g^bum&fgc_EBwdt|4U<G{|6 z1ua^zNfpoNXk@Gl245R5b!A3arHcu4DvNHVYKus3tAB*C7FZj}6yt9LE&^5pZFd|r zNQ-LAEo_Pu5dd1vFR9BEdEC#rTldEUa18|obzgtTK_6&0-;iFg7$#**L_h#t7dfp} zj19yv?3LZ3A~IsLbS>pHiY(09LLLc?Hs*{O(ghnt8^{BTTT9c$Dy&2$LQgO<fb6se zryMcCtbYX6vk-20t4ue5y4FO(<Ws(mQqO8x`jiqzmKDJm_IG-BApt<Tl%8IQtDTwz z6oM8h)F`WfK@TDWl+vD<DC&X4OV~veZ>&)ErO2TXgx3uFz@Pvx>UuTc8E`G68&wH) zlUrFuivpCW$5DXb#lR;3s*Yz;?pmQ$v7@V|QGfC1`cUR66{dn<aS(-oYJp_F;zr$L zBlbeI66{izci6{7UuYZK28vJhz6k^3kfvdj@!~p|9o314BL!3*fQ`jMTrfu=CpyRt z@#;}COw?n6FQpA}wHK`(i63ea9KX*n@2MRVtx8VZMSWW#qnAU_omGR3h+9e8XbW_| z-GA^bvOk8|<}t#2DXXq*Dp4Ep?ls4(jUO~siw?Q#t1blqfPXyR!CvCCs5m2?*eQ*E zY565WJS668k#-s`0>+dm^`kf%ol+8dP{Pn##+B0des=j@GK0DnXaJ))5!4112XtWT z9;de~kDT+M*JhWA=-W_W*pRLjuGNqkT7RaY@X;&=b`3NQOn|lqTT2q>@lRVPV6s)W zv>lI}ruKO_y-)h8QjaD<TP1=432AMf3-Gf*<wPf}P|UP3h4ARIZtkHCC5I2)ffANk z1NYjLdqKfLD7|7R;1w7O5`+Rs4HgdWPw~oqLTt@};M(&+3uXum=Msw)8p4zO4u6?6 z2oG98GYEtFX$xI&-?|ryU%L>k(SPo${kmx@f+PS*O9uW+++-~@_{s<sd3$E$Fl#I~ z7lW3TrERr{HeHoL87UiBI0)|Qs5?B#%A~iW!Il*5ZuACXr)k(jd_W`0P*S2u9;WUJ zIX))VrN)s-1NKUy5=_><JdxXvrhlvhxr|seX2>V4<fBIs4d~Lnw*wb9Kp`mryZ|NO zOoiZ0Ub2?AaM$Sj!xXuk2zf2Usxxz*H#CWOLgBJ@3JF0bBSoveSdJulc@Y*<#GGFr z>M=pv`M5Dqc@VG2k*Y4aX&twbRB2tUbN}*c=I>wKU;dyq28l|P1JG0fO@HVQu%_}6 zAc#GGv|dJ$E74Vgf^O?>Rms^>5k}yJ7L>KvD|n4lO9&pvFPE-Jo2X9Egd5kcfDjjL zL2nFmbWF=fMkx@JfpbwOZBlb(hGy1BiVz<`QH(`JB}yf-SeqNQk}`FXn{-seDXRXp z#&ni=<Y@T{mju<f_emkd4S&)Ye@&JKc4q>+S17RGnrjA+wAiVSCB&sTX^{Yk8I&<N z1>4g}rb!L}RLMr_YfD)>Lnx$*8A1CYK^$o=!araeVF#4sG>;0J7M}~<2<Ukre0vfP z1Rs4^1iI<PV>ppAS_?P42yBWzL``iE2}$f-_?Rw6k9K_o1l&L`Tz{x`cYU^lI>QPS zeXarU){f&w1EaH_m)t9hx5p%>KprTWbZ^WyBY>NwZo#L0r84R0L#LaT<&no&g*Jo3 z8B6dpFBCRh=(WuO{x<~KK5unz$#2?Qr2u0hc}rT?LAt??S}?fhT=!O*?gas1HBlxp zMTl>G=E4Zf>@DZDo_|T(l20(^q;RoR>Y#(v(!MH~tyK$-Aka2O8Tjt<d;Tpb;@Abg zJVhqT>*G*)OO!w<1Ppo3KN^$UIxeZgs|&$M7b4BaCdbqfYy}l4bRDsMq^jd6RPR9a zhj9SODk8xC#^e0DsHp?I%&AV(M}D*vr%g??)c)Dk5%;qvet(Ix3wmNDD5RKZRV~|S zUvmwh7JWv6n6$kCoU{^HJj^~@L_$-rn<|m@f&kNcOSI74+Q=RLA7(lnodD2k;TwbB z-Y#W2o0<toucK%awV++3dbU0ff(y`M@F+=JfD(&n7p?iLa9bTIM4n11WU2*;pSa7x z=l8t0kXx(55`Su3TaIaIGZ3`d#9Ye_-5j(wS{^l`MdH+s6jT6oAS<;5$(*(2hGeK0 z**38*V_)!MbM*kBr#_(rsJV<HqQ?-Sf!-_Hh=Q8JfL2hE7W}~>Xdyu=tOGKYDZ~mf zZU~hA)wXu>SuKKJ#3+>~i%<x8(zx2nHvkST>jU(OeSZU=Du!Z9`nHBzra|aTkQw8= z9MwfVYRKDO@IX5+N<yWU=dAGrK?Yyuqkyv7G7J#SKo6tbPBqfeo?k=SF+yqG%XB^6 z^0ubb20pLo5iv!da0ECr)ReWs*_bTmU}`;U_lD3)Z|Ac6TyqjfA4u|O@dQ%kfe1&h z<&cI`Hh&2JT<xe)V_qwgAhch2n~H_du2sQ-QHXJd)BcC-=6>mdD7{9hkwQtHY1;47 z2I@#|QdhKi3fipFq7cG^N*nn$$sa&2O76_JP)Wwo6j+n)(PSR|z5pO9yACJUk0R2t z6mWw_exHD-+6|SN&Pm%-3+*-_!*&xE7|KCNM<mAPyKLE1*}-f7#K%wkSB(DuMt}Eu jX%V!|e*<>EoF+$q#vPGB9t!3Q6$1b@NGrbiv1mjRi*B0? delta 56 zcmZn<T%$TcS(bsZILO_JVcj{ImkbOHY)RhkE)4%caKYZ?lP61y$#LCdVG%J`(bQb2 KxY=3EMF;>3T@l*= From 4741ee0a7b9a45eda48eb437ba2450da78861c78 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 21 Nov 2022 14:01:22 -0600 Subject: [PATCH 0867/1681] Let the Luigi handler take care of removing some DB settings so that command line can also remove them via same logic --- tailbone/views/luigi.py | 33 ++------------------------------- 1 file changed, 2 insertions(+), 31 deletions(-) diff --git a/tailbone/views/luigi.py b/tailbone/views/luigi.py index 4f293943..96e5cbe9 100644 --- a/tailbone/views/luigi.py +++ b/tailbone/views/luigi.py @@ -211,38 +211,9 @@ class LuigiTaskView(MasterView): def configure_remove_settings(self): 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 == 'rattail.luigi.backfill_tasks', - model.Setting.name.like('rattail.luigi.backfill.task.%.description'), - model.Setting.name.like('rattail.luigi.backfill.%.description'), - model.Setting.name.like('rattail.luigi.backfill.task.%.forward'), - model.Setting.name.like('rattail.luigi.backfill.%.forward'), - model.Setting.name.like('rattail.luigi.backfill.task.%.notes'), - model.Setting.name.like('rattail.luigi.backfill.%.notes'), - model.Setting.name.like('rattail.luigi.backfill.task.%.script'), - model.Setting.name.like('rattail.luigi.backfill.%.script'), - model.Setting.name.like('rattail.luigi.backfill.task.%.target_date'), - model.Setting.name.like('rattail.luigi.backfill.%.target_date'), - model.Setting.name == 'rattail.luigi.overnight.tasks', - model.Setting.name == 'rattail.luigi.overnight_tasks', - model.Setting.name.like('rattail.luigi.overnight.task.%.description'), - model.Setting.name.like('rattail.luigi.overnight.%.description'), - model.Setting.name.like('rattail.luigi.overnight.task.%.notes'), - model.Setting.name.like('rattail.luigi.overnight.%.notes'), - model.Setting.name.like('rattail.luigi.overnight.task.%.module'), - model.Setting.name.like('rattail.luigi.overnight.task.%.class_name'), - model.Setting.name.like('rattail.luigi.overnight.task.%.script'), - model.Setting.name.like('rattail.luigi.overnight.%.script')))\ - .all() - - for setting in to_delete: - app.delete_setting(session, setting.name) + self.luigi_handler.purge_overnight_settings(self.Session()) + self.luigi_handler.purge_backfill_settings(self.Session()) @classmethod def defaults(cls, config): From 9abbc001b37076fdf1c5a7557acab630347ade46 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 21 Nov 2022 14:31:49 -0600 Subject: [PATCH 0868/1681] 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 fe59c234..6ba1c2d3 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.263 (2022-11-21) +-------------------- + +* Update 'testing' watermark for dev background. + +* Let the Luigi handler take care of removing some DB settings. + + 0.8.262 (2022-11-20) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 881aaa84..319bd42e 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.262' +__version__ = '0.8.263' From 42888c0983a7b03eb739bce3d9c2722d0d55dd36 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 23 Nov 2022 11:40:03 -0600 Subject: [PATCH 0869/1681] Add prompt dialog when launching overnight task --- tailbone/templates/luigi/index.mako | 66 +++++++++++++++++++++++++---- 1 file changed, 58 insertions(+), 8 deletions(-) diff --git a/tailbone/templates/luigi/index.mako b/tailbone/templates/luigi/index.mako index c4407ff1..44ad30d3 100644 --- a/tailbone/templates/luigi/index.mako +++ b/tailbone/templates/luigi/index.mako @@ -60,8 +60,8 @@ {{ props.row.description }} </b-table-column> <b-table-column field="script" - label="Script"> - {{ props.row.script }} + label="Command"> + {{ props.row.script || props.row.class_name }} </b-table-column> <b-table-column field="last_date" label="Last Date" @@ -72,10 +72,52 @@ <b-button type="is-primary" icon-pack="fas" icon-left="arrow-circle-right" - :disabled="overnightTaskLaunching == props.row.key" - @click="overnightTaskLaunch(props.row)"> - {{ overnightTaskLaunching == props.row.key ? "Working, please wait..." : "Launch" }} + @click="overnightTaskLaunchInit(props.row)"> + Launch </b-button> + <b-modal has-modal-card + :active.sync="overnightTaskShowLaunchDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Launch Overnight Task</p> + </header> + + <section class="modal-card-body" + v-if="overnightTask"> + + <b-field label="Task" horizontal> + <span>{{ overnightTask.description }}</span> + </b-field> + + <b-field label="Last Date" horizontal> + <span :class="overnightTextClass(overnightTask)"> + {{ overnightTask.last_date || "n/a" }} + </span> + </b-field> + + <p class="block"> + Launching this task will schedule it to begin + within one minute. See the Luigi Task + Visualizer after that, for current status. + </p> + + </section> + + <footer class="modal-card-foot"> + <b-button @click="overnightTaskShowLaunchDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + icon-pack="fas" + icon-left="arrow-circle-right" + @click="overnightTaskLaunchSubmit()" + :disabled="overnightTaskLaunching"> + {{ overnightTaskLaunching ? "Working, please wait..." : "Launch" }} + </b-button> + </footer> + </div> + </b-modal> </b-table-column> </template> <template #empty> @@ -206,6 +248,8 @@ % if master.has_perm('launch_overnight'): ThisPageData.overnightTasks = ${json.dumps(overnight_tasks)|n} + ThisPageData.overnightTask = null + ThisPageData.overnightTaskShowLaunchDialog = false ThisPageData.overnightTaskLaunching = false ThisPage.methods.overnightTextClass = function(task) { @@ -221,11 +265,16 @@ } } - ThisPage.methods.overnightTaskLaunch = function(task) { - this.overnightTaskLaunching = task.key + ThisPage.methods.overnightTaskLaunchInit = function(task) { + this.overnightTask = task + this.overnightTaskShowLaunchDialog = true + } + + ThisPage.methods.overnightTaskLaunchSubmit = function() { + this.overnightTaskLaunching = true let url = '${url('{}.launch_overnight'.format(route_prefix))}' - let params = {key: task.key} + let params = {key: this.overnightTask.key} this.submitForm(url, params, response => { this.$buefy.toast.open({ @@ -234,6 +283,7 @@ duration: 5000, // 5 seconds }) this.overnightTaskLaunching = false + this.overnightTaskShowLaunchDialog = false }) } From db9b3617a422d77cd136ea5c04909ebb96295cf0 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 23 Nov 2022 11:52:44 -0600 Subject: [PATCH 0870/1681] Fix page title for datasync status --- tailbone/templates/datasync/status.mako | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tailbone/templates/datasync/status.mako b/tailbone/templates/datasync/status.mako index 29ca00cf..0d0f5994 100644 --- a/tailbone/templates/datasync/status.mako +++ b/tailbone/templates/datasync/status.mako @@ -1,6 +1,8 @@ ## -*- coding: utf-8; -*- <%inherit file="/page.mako" /> +<%def name="title()">${index_title}</%def> + <%def name="content_title()"></%def> <%def name="context_menu_items()"> From b64f6c7884b7a9f39f03c380b17c077290d68d15 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 23 Nov 2022 12:20:58 -0600 Subject: [PATCH 0871/1681] Use newer config strategy for all views to make inheritance easier --- tailbone/views/auth.py | 9 ++++++++- tailbone/views/bouncer.py | 11 +++++++++-- tailbone/views/categories.py | 9 ++++++++- tailbone/views/common.py | 9 ++++++++- tailbone/views/customergroups.py | 11 +++++++++-- tailbone/views/custorders/creating.py | 11 +++++++++-- tailbone/views/custorders/items.py | 11 +++++++++-- tailbone/views/departments.py | 9 ++++++++- tailbone/views/depositlinks.py | 11 +++++++++-- tailbone/views/families.py | 9 ++++++++- tailbone/views/features.py | 11 +++++++++-- tailbone/views/filemon.py | 11 +++++++++-- tailbone/views/ifps.py | 11 +++++++++-- tailbone/views/importing.py | 9 ++++++++- tailbone/views/inventory.py | 11 +++++++++-- tailbone/views/labels/profiles.py | 9 ++++++++- tailbone/views/members.py | 11 +++++++++-- tailbone/views/messages.py | 25 +++++++++++++++++++------ tailbone/views/permissions.py | 11 +++++++++-- tailbone/views/progress.py | 12 ++++++++++-- tailbone/views/projects.py | 9 ++++++++- tailbone/views/purchases/core.py | 9 ++++++++- tailbone/views/purchases/credits.py | 11 +++++++++-- tailbone/views/purchasing/costing.py | 9 ++++++++- tailbone/views/purchasing/ordering.py | 9 ++++++++- tailbone/views/purchasing/receiving.py | 9 ++++++++- tailbone/views/reportcodes.py | 11 +++++++++-- tailbone/views/reports.py | 12 +++++++++++- tailbone/views/roles.py | 9 ++++++++- tailbone/views/settings.py | 11 ++++++++++- tailbone/views/shifts/core.py | 13 +++++++++++-- tailbone/views/shifts/schedule.py | 11 +++++++++-- tailbone/views/shifts/timesheet.py | 11 +++++++++-- tailbone/views/stores.py | 11 +++++++++-- tailbone/views/subdepartments.py | 9 ++++++++- tailbone/views/tables.py | 9 ++++++++- tailbone/views/taxes.py | 11 +++++++++-- tailbone/views/tempmon/appliances.py | 11 +++++++++-- tailbone/views/tempmon/clients.py | 11 +++++++++-- tailbone/views/tempmon/dashboard.py | 11 +++++++++-- tailbone/views/tempmon/probes.py | 11 +++++++++-- tailbone/views/tempmon/readings.py | 11 +++++++++-- tailbone/views/trainwreck/defaults.py | 11 +++++++++-- tailbone/views/uoms.py | 11 +++++++++-- 44 files changed, 397 insertions(+), 75 deletions(-) diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index 406b8add..e7922e3d 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -239,5 +239,12 @@ class AuthenticationView(View): config.add_view(cls, attr='stop_root', route_name='stop_root') -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/views/bouncer.py b/tailbone/views/bouncer.py index 6bee7099..628ed07c 100644 --- a/tailbone/views/bouncer.py +++ b/tailbone/views/bouncer.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. # @@ -210,5 +210,12 @@ class EmailBounceView(MasterView): cls._defaults(config) -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + EmailBounceView = kwargs.get('EmailBounceView', base['EmailBounceView']) EmailBounceView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/categories.py b/tailbone/views/categories.py index c76c9292..941257b8 100644 --- a/tailbone/views/categories.py +++ b/tailbone/views/categories.py @@ -110,5 +110,12 @@ class CategoryView(MasterView): CategoriesView = CategoryView -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + CategoryView = kwargs.get('CategoryView', base['CategoryView']) CategoryView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/common.py b/tailbone/views/common.py index c2ec897f..f531b48e 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -340,5 +340,12 @@ class CommonView(View): permission='admin') -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/views/customergroups.py b/tailbone/views/customergroups.py index 02138346..98cea8e0 100644 --- a/tailbone/views/customergroups.py +++ b/tailbone/views/customergroups.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. # @@ -80,5 +80,12 @@ class CustomerGroupView(MasterView): CustomerGroupsView = CustomerGroupView -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + CustomerGroupView = kwargs.get('CustomerGroupView', base['CustomerGroupView']) CustomerGroupView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/custorders/creating.py b/tailbone/views/custorders/creating.py index d0d648b5..cbdf6d5e 100644 --- a/tailbone/views/custorders/creating.py +++ b/tailbone/views/custorders/creating.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. # @@ -45,5 +45,12 @@ class CreateCustomerOrderBatchView(CustomerOrderBatchView): creatable = False -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + CreateCustomerOrderBatchView = kwargs.get('CreateCustomerOrderBatchView', base['CreateCustomerOrderBatchView']) CreateCustomerOrderBatchView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/custorders/items.py b/tailbone/views/custorders/items.py index 823130ed..c780756a 100644 --- a/tailbone/views/custorders/items.py +++ b/tailbone/views/custorders/items.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. # @@ -566,5 +566,12 @@ class CustomerOrderItemView(MasterView): CustomerOrderItemsView = CustomerOrderItemView -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + CustomerOrderItemView = kwargs.get('CustomerOrderItemView', base['CustomerOrderItemView']) CustomerOrderItemView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/departments.py b/tailbone/views/departments.py index 1e964624..96dcfb61 100644 --- a/tailbone/views/departments.py +++ b/tailbone/views/departments.py @@ -241,5 +241,12 @@ class DepartmentView(MasterView): cls._defaults(config) -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + DepartmentView = kwargs.get('DepartmentView', base['DepartmentView']) DepartmentView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/depositlinks.py b/tailbone/views/depositlinks.py index 42f83460..1c9abde1 100644 --- a/tailbone/views/depositlinks.py +++ b/tailbone/views/depositlinks.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. # @@ -64,5 +64,12 @@ class DepositLinkView(MasterView): DepositLinksView = DepositLinkView -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + DepositLinkView = kwargs.get('DepositLinkView', base['DepositLinkView']) DepositLinkView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/families.py b/tailbone/views/families.py index 0b8ba31d..2d445b78 100644 --- a/tailbone/views/families.py +++ b/tailbone/views/families.py @@ -108,5 +108,12 @@ class FamilyView(MasterView): FamiliesView = FamilyView -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + FamilyView = kwargs.get('FamilyView', base['FamilyView']) FamilyView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/features.py b/tailbone/views/features.py index f9c2b5c7..d55be524 100644 --- a/tailbone/views/features.py +++ b/tailbone/views/features.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. # @@ -112,5 +112,12 @@ class GenerateFeatureView(View): renderer='/generate_feature.mako') -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + GenerateFeatureView = kwargs.get('GenerateFeatureView', base['GenerateFeatureView']) GenerateFeatureView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/filemon.py b/tailbone/views/filemon.py index 1d164c83..b0c81b45 100644 --- a/tailbone/views/filemon.py +++ b/tailbone/views/filemon.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. # @@ -63,5 +63,12 @@ class FilemonView(View): config.add_view(cls, attr='restart', route_name='filemon.restart', permission='filemon.restart') -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + FilemonView = kwargs.get('FilemonView', base['FilemonView']) FilemonView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/ifps.py b/tailbone/views/ifps.py index be3bb0f8..af626ef3 100644 --- a/tailbone/views/ifps.py +++ b/tailbone/views/ifps.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. # @@ -90,5 +90,12 @@ class IFPS_PLUView(MasterView): -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + IFPS_PLUView = kwargs.get('IFPS_PLUView', base['IFPS_PLUView']) IFPS_PLUView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/importing.py b/tailbone/views/importing.py index a6126c9e..003d7ac4 100644 --- a/tailbone/views/importing.py +++ b/tailbone/views/importing.py @@ -650,5 +650,12 @@ class RunJobSchema(colander.MappingSchema): warnings = colander.SchemaNode(colander.Bool()) -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + ImportingView = kwargs.get('ImportingView', base['ImportingView']) ImportingView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/inventory.py b/tailbone/views/inventory.py index 7cf5d8d0..4622fa9f 100644 --- a/tailbone/views/inventory.py +++ b/tailbone/views/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. # @@ -70,5 +70,12 @@ class InventoryAdjustmentReasonView(MasterView): InventoryAdjustmentReasonsView = InventoryAdjustmentReasonView -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + InventoryAdjustmentReasonView = kwargs.get('InventoryAdjustmentReasonView', base['InventoryAdjustmentReasonView']) InventoryAdjustmentReasonView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/labels/profiles.py b/tailbone/views/labels/profiles.py index a91cdfb2..c392e510 100644 --- a/tailbone/views/labels/profiles.py +++ b/tailbone/views/labels/profiles.py @@ -177,5 +177,12 @@ class LabelProfileView(MasterView): ProfilesView = LabelProfileView -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + LabelProfileView = kwargs.get('LabelProfileView', base['LabelProfileView']) LabelProfileView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/members.py b/tailbone/views/members.py index 070b543a..a0157649 100644 --- a/tailbone/views/members.py +++ b/tailbone/views/members.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. # @@ -189,5 +189,12 @@ class MemberView(MasterView): return member.phones[0].number -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + MemberView = kwargs.get('MemberView', base['MemberView']) MemberView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/messages.py b/tailbone/views/messages.py index 7371e2e9..f483d03b 100644 --- a/tailbone/views/messages.py +++ b/tailbone/views/messages.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. # @@ -575,23 +575,36 @@ class RecipientsWidgetBuefy(dfwidget.Widget): return field.renderer(template, **values) -def includeme(config): +def defaults(config, **kwargs): + base = globals() - config.add_tailbone_permission('messages', 'messages.list', "List/Search Messages") + config.add_tailbone_permission('messages', 'messages.list', + "List/Search Messages") # inbox + InboxView = kwargs.get('InboxView', base['InboxView']) config.add_route('messages.inbox', '/messages/inbox/') - config.add_view(InboxView, attr='index', route_name='messages.inbox', + config.add_view(InboxView, attr='index', + route_name='messages.inbox', permission='messages.list') # archive + ArchiveView = kwargs.get('ArchiveView', base['ArchiveView']) config.add_route('messages.archive', '/messages/archive/') - config.add_view(ArchiveView, attr='index', route_name='messages.archive', + config.add_view(ArchiveView, attr='index', + route_name='messages.archive', permission='messages.list') # sent + SentView = kwargs.get('SentView', base['SentView']) config.add_route('messages.sent', '/messages/sent/') - config.add_view(SentView, attr='index', route_name='messages.sent', + config.add_view(SentView, attr='index', + route_name='messages.sent', permission='messages.list') + MessageView = kwargs.get('MessageView', base['MessageView']) MessageView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/permissions.py b/tailbone/views/permissions.py index 67f6e9b1..5168d544 100644 --- a/tailbone/views/permissions.py +++ b/tailbone/views/permissions.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,5 +54,12 @@ class PermissionView(MasterView): return query -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + PermissionView = kwargs.get('PermissionView', base['PermissionView']) PermissionView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/progress.py b/tailbone/views/progress.py index 5d06f158..169f324e 100644 --- a/tailbone/views/progress.py +++ b/tailbone/views/progress.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. # @@ -65,9 +65,17 @@ def cancel(request): return {} -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + progress = kwargs.get('progress', base['progress']) config.add_route('progress', '/progress/{key}') config.add_view(progress, route_name='progress', renderer='json') + cancel = kwargs.get('cancel', base['cancel']) config.add_route('progress.cancel', '/progress/{key}/cancel') config.add_view(cancel, route_name='progress.cancel', renderer='json') + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/projects.py b/tailbone/views/projects.py index 746a3c47..9a6633f4 100644 --- a/tailbone/views/projects.py +++ b/tailbone/views/projects.py @@ -223,5 +223,12 @@ class GenerateProjectView(View): renderer='/generate_project.mako') -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + GenerateProjectView = kwargs.get('GenerateProjectView', base['GenerateProjectView']) GenerateProjectView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/purchases/core.py b/tailbone/views/purchases/core.py index eca5de34..77b02501 100644 --- a/tailbone/views/purchases/core.py +++ b/tailbone/views/purchases/core.py @@ -408,5 +408,12 @@ class PurchaseView(MasterView): permission='{}.receiving_worksheet'.format(permission_prefix)) -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + PurchaseView = kwargs.get('PurchaseView', base['PurchaseView']) PurchaseView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/purchases/credits.py b/tailbone/views/purchases/credits.py index 79530fe2..71902426 100644 --- a/tailbone/views/purchases/credits.py +++ b/tailbone/views/purchases/credits.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. # @@ -201,5 +201,12 @@ class PurchaseCreditView(MasterView): permission='{}.change_status'.format(permission_prefix)) -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + PurchaseCreditView = kwargs.get('PurchaseCreditView', base['PurchaseCreditView']) PurchaseCreditView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/purchasing/costing.py b/tailbone/views/purchasing/costing.py index 2f467feb..2d62d6e1 100644 --- a/tailbone/views/purchasing/costing.py +++ b/tailbone/views/purchasing/costing.py @@ -377,5 +377,12 @@ class NewCostingBatch(colander.Schema): validator=valid_workflow) -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + CostingBatchView = kwargs.get('CostingBatchView', base['CostingBatchView']) CostingBatchView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/purchasing/ordering.py b/tailbone/views/purchasing/ordering.py index d772a359..f4820783 100644 --- a/tailbone/views/purchasing/ordering.py +++ b/tailbone/views/purchasing/ordering.py @@ -527,5 +527,12 @@ class OrderingBatchView(PurchasingBatchView): "Download {} as Excel".format(model_title)) -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + OrderingBatchView = kwargs.get('OrderingBatchView', base['OrderingBatchView']) OrderingBatchView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 2fe692f0..78136ef3 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -2082,5 +2082,12 @@ class DeclareCreditForm(colander.MappingSchema): missing=colander.null) -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + ReceivingBatchView = kwargs.get('ReceivingBatchView', base['ReceivingBatchView']) ReceivingBatchView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/reportcodes.py b/tailbone/views/reportcodes.py index 0f85aecb..ef090c22 100644 --- a/tailbone/views/reportcodes.py +++ b/tailbone/views/reportcodes.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. # @@ -107,5 +107,12 @@ class ReportCodeView(MasterView): ReportCodesView = ReportCodeView -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + ReportCodeView = kwargs.get('ReportCodeView', base['ReportCodeView']) ReportCodeView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index 69eec23d..7cb5ecfe 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -800,14 +800,24 @@ def add_routes(config): config.add_route('reports.inventory', '/reports/inventory') -def includeme(config): +def defaults(config, **kwargs): + base = globals() # TODO: not in love with this pattern, but works for now add_routes(config) + OrderingWorksheet = kwargs.get('OrderingWorksheet', base['OrderingWorksheet']) config.add_view(OrderingWorksheet, route_name='reports.ordering', renderer='/reports/ordering.mako') + InventoryWorksheet = kwargs.get('InventoryWorksheet', base['InventoryWorksheet']) config.add_view(InventoryWorksheet, route_name='reports.inventory', renderer='/reports/inventory.mako') + ReportOutputView = kwargs.get('ReportOutputView', base['ReportOutputView']) ReportOutputView.defaults(config) + + ProblemReportView = kwargs.get('ProblemReportView', base['ProblemReportView']) ProblemReportView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py index 61de606a..224356ed 100644 --- a/tailbone/views/roles.py +++ b/tailbone/views/roles.py @@ -529,5 +529,12 @@ class PermissionsWidget(dfwidget.Widget): return field.renderer(template, **values) -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + RoleView = kwargs.get('RoleView', base['RoleView']) RoleView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index eaebce93..c38e3136 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -310,6 +310,15 @@ class AppSettingsView(View): permission='settings.edit') -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + AppSettingsView = kwargs.get('AppSettingsView', base['AppSettingsView']) AppSettingsView.defaults(config) + + SettingView = kwargs.get('SettingView', base['SettingView']) SettingView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/shifts/core.py b/tailbone/views/shifts/core.py index b33ae0f0..b6d9aadf 100644 --- a/tailbone/views/shifts/core.py +++ b/tailbone/views/shifts/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. # @@ -207,6 +207,15 @@ class WorkedShiftView(MasterView): WorkedShiftsView = WorkedShiftView -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + ScheduledShiftView = kwargs.get('ScheduledShiftView', base['ScheduledShiftView']) ScheduledShiftView.defaults(config) + + WorkedShiftView = kwargs.get('WorkedShiftView', base['WorkedShiftView']) WorkedShiftView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/shifts/schedule.py b/tailbone/views/shifts/schedule.py index efaf4e33..7a1ccbe5 100644 --- a/tailbone/views/shifts/schedule.py +++ b/tailbone/views/shifts/schedule.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. # @@ -212,5 +212,12 @@ class ScheduleView(TimeSheetView): permission='schedule.print') -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + ScheduleView = kwargs.get('ScheduleView', base['ScheduleView']) ScheduleView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/shifts/timesheet.py b/tailbone/views/shifts/timesheet.py index 84d303e9..9898cd04 100644 --- a/tailbone/views/shifts/timesheet.py +++ b/tailbone/views/shifts/timesheet.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. # @@ -133,5 +133,12 @@ class TimeSheetView(BaseTimeSheetView): permission='timesheet.edit') -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + TimeSheetView = kwargs.get('TimeSheetView', base['TimeSheetView']) TimeSheetView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/stores.py b/tailbone/views/stores.py index ef09e69b..5d507745 100644 --- a/tailbone/views/stores.py +++ b/tailbone/views/stores.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. # @@ -121,5 +121,12 @@ class StoreView(MasterView): StoresView = StoreView -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + StoreView = kwargs.get('StoreView', base['StoreView']) StoreView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/subdepartments.py b/tailbone/views/subdepartments.py index 67945581..d2a337cc 100644 --- a/tailbone/views/subdepartments.py +++ b/tailbone/views/subdepartments.py @@ -154,5 +154,12 @@ class SubdepartmentView(MasterView): SubdepartmentsView = SubdepartmentView -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + SubdepartmentView = kwargs.get('SubdepartmentView', base['SubdepartmentView']) SubdepartmentView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/tables.py b/tailbone/views/tables.py index ea8aef99..5d4f7d95 100644 --- a/tailbone/views/tables.py +++ b/tailbone/views/tables.py @@ -76,5 +76,12 @@ class TableView(MasterView): TablesView = TableView -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + TableView = kwargs.get('TableView', base['TableView']) TableView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/taxes.py b/tailbone/views/taxes.py index 96a404c7..19a385ba 100644 --- a/tailbone/views/taxes.py +++ b/tailbone/views/taxes.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. # @@ -64,5 +64,12 @@ class TaxView(MasterView): TaxesView = TaxView -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + TaxView = kwargs.get('TaxView', base['TaxView']) TaxView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/tempmon/appliances.py b/tailbone/views/tempmon/appliances.py index eeb22882..6b8ee036 100644 --- a/tailbone/views/tempmon/appliances.py +++ b/tailbone/views/tempmon/appliances.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. # @@ -187,5 +187,12 @@ class TempmonApplianceView(MasterView): raise NotImplementedError("too many uploads?") -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + TempmonApplianceView = kwargs.get('TempmonApplianceView', base['TempmonApplianceView']) TempmonApplianceView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/tempmon/clients.py b/tailbone/views/tempmon/clients.py index 1952c56d..a3fdb31b 100644 --- a/tailbone/views/tempmon/clients.py +++ b/tailbone/views/tempmon/clients.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -276,5 +276,12 @@ class TempmonClientView(MasterView): permission='{}.restart'.format(permission_prefix)) -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + TempmonClientView = kwargs.get('TempmonClientView', base['TempmonClientView']) TempmonClientView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/tempmon/dashboard.py b/tailbone/views/tempmon/dashboard.py index 321f8c83..954acf94 100644 --- a/tailbone/views/tempmon/dashboard.py +++ b/tailbone/views/tempmon/dashboard.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. # @@ -158,5 +158,12 @@ class TempmonDashboardView(View): renderer='json') -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + TempmonDashboardView = kwargs.get('TempmonDashboardView', base['TempmonDashboardView']) TempmonDashboardView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/tempmon/probes.py b/tailbone/views/tempmon/probes.py index de0ca42d..218caafa 100644 --- a/tailbone/views/tempmon/probes.py +++ b/tailbone/views/tempmon/probes.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -344,5 +344,12 @@ class TempmonProbeView(MasterView): cls._defaults(config) -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + TempmonProbeView = kwargs.get('TempmonProbeView', base['TempmonProbeView']) TempmonProbeView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/tempmon/readings.py b/tailbone/views/tempmon/readings.py index a9c6d2c2..a8223dd2 100644 --- a/tailbone/views/tempmon/readings.py +++ b/tailbone/views/tempmon/readings.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. # @@ -125,5 +125,12 @@ class TempmonReadingView(MasterView): return tags.link_to(text, url) -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + TempmonReadingView = kwargs.get('TempmonReadingView', base['TempmonReadingView']) TempmonReadingView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/trainwreck/defaults.py b/tailbone/views/trainwreck/defaults.py index 68b08a42..85c61fae 100644 --- a/tailbone/views/trainwreck/defaults.py +++ b/tailbone/views/trainwreck/defaults.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. # @@ -39,5 +39,12 @@ class TransactionView(base.TransactionView): model_row_class = trainwreck.TransactionItem -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + TransactionView = kwargs.get('TransactionView', base['TransactionView']) TransactionView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/uoms.py b/tailbone/views/uoms.py index 964401f1..0b7b060f 100644 --- a/tailbone/views/uoms.py +++ b/tailbone/views/uoms.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. # @@ -126,5 +126,12 @@ class UnitOfMeasureView(MasterView): permission='{}.collect_wild_uoms'.format(permission_prefix)) -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + UnitOfMeasureView = kwargs.get('UnitOfMeasureView', base['UnitOfMeasureView']) UnitOfMeasureView.defaults(config) + + +def includeme(config): + defaults(config) From 604420c7d4c4e427417168f6ba6bdd4695e1228c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 23 Nov 2022 12:27:09 -0600 Subject: [PATCH 0872/1681] Auto-format phone number when saving for contact records --- tailbone/views/master.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index c98d1a0e..af776d97 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -3864,6 +3864,7 @@ class MasterView(View): return obj def objectify_contact(self, contact, data): + app = self.get_rattail_app() if 'default_email' in data: address = data['default_email'] @@ -3877,7 +3878,7 @@ class MasterView(View): contact.add_email_address(address) if 'default_phone' in data: - number = data['default_phone'] + number = app.format_phone_number(data['default_phone']) if contact.phones: if number: phone = contact.phones[0] From 88aeaf31c2acf17f9a143857895c5b692b057d8b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 27 Nov 2022 14:55:49 -0600 Subject: [PATCH 0873/1681] Show "next date" when launching overnight task just to make things a bit more clear --- tailbone/templates/luigi/index.mako | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tailbone/templates/luigi/index.mako b/tailbone/templates/luigi/index.mako index 44ad30d3..6faade8d 100644 --- a/tailbone/templates/luigi/index.mako +++ b/tailbone/templates/luigi/index.mako @@ -96,6 +96,12 @@ </span> </b-field> + <b-field label="Next Date" horizontal> + <span> + ${rattail_app.render_date(rattail_app.yesterday())} (yesterday) + </span> + </b-field> + <p class="block"> Launching this task will schedule it to begin within one minute. See the Luigi Task From 434633924a6ab5ab47d89f83ec57ada9bb0eea5b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 28 Nov 2022 10:54:37 -0600 Subject: [PATCH 0874/1681] 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 6ba1c2d3..823fcb45 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,18 @@ CHANGELOG ========= +0.8.264 (2022-11-28) +-------------------- + +* Add prompt dialog when launching overnight task. + +* Fix page title for datasync status. + +* Use newer config strategy for all views. + +* Auto-format phone number when saving for contact records. + + 0.8.263 (2022-11-21) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 319bd42e..5ccf12b2 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.263' +__version__ = '0.8.264' From b3bdee60bb104f11dc828af4141d2f0359e9a4e7 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 28 Nov 2022 17:55:08 -0600 Subject: [PATCH 0875/1681] Add way to quickly re-run "any" report --- .../templates/reports/generated/view.mako | 22 +++++++++++++++++++ tailbone/views/reports.py | 22 +++++++++++++++++-- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/reports/generated/view.mako b/tailbone/templates/reports/generated/view.mako index 496857c5..6260efba 100644 --- a/tailbone/templates/reports/generated/view.mako +++ b/tailbone/templates/reports/generated/view.mako @@ -1,6 +1,28 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> +<%def name="object_helpers()"> + % if master.has_perm('create'): + <nav class="panel"> + <p class="panel-heading">Tools</p> + <div class="panel-block buttons"> + <div style="display: flex; flex-direction: column;"> + <once-button type="is-primary" + % if rerun_report_url: + tag="a" href="${rerun_report_url}" + % else: + disabled title="Unknown report type" + % endif + text="Re-run This Report" + icon-pack="fas" + icon-left="arrow-circle-right"> + </once-button> + </div> + </div> + </nav> + % endif +</%def> + <%def name="modify_this_page_vars()"> ${parent.modify_this_page_vars()} <script type="text/javascript"> diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index 7cb5ecfe..640dc6a9 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -316,10 +316,18 @@ class ReportOutputView(ExportMasterView): def template_kwargs_view(self, **kwargs): kwargs = super(ReportOutputView, self).template_kwargs_view(**kwargs) + output = kwargs['instance'] if self.get_use_buefy(): - report = kwargs['instance'] - kwargs['params_data'] = self.get_params_context(report) + kwargs['params_data'] = self.get_params_context(output) + + # build custom URL to re-build this report + url = None + if output.report_type: + url = self.request.route_url('generate_specific_report', + type_key=output.report_type, + _query=output.params) + kwargs['rerun_report_url'] = url return kwargs @@ -386,6 +394,7 @@ class ReportOutputView(ExportMasterView): input parameters specific to the report type, then creates a new report and redirects user to view the output. """ + app = self.get_rattail_app() use_buefy = self.get_use_buefy() type_key = self.request.matchdict['type_key'] report = self.report_handler.get_report(type_key) @@ -445,6 +454,15 @@ class ReportOutputView(ExportMasterView): if len(values) == 1: form.set_default(param.name, values[0][0]) + # set default field values according to query string, if applicable + if self.request.GET: + for param in report_params: + if param.name in self.request.GET: + value = self.request.GET[param.name] + if param.type is datetime.date: + value = app.parse_date(value) + form.set_default(param.name, value) + # if form validates, start generating new report output; show progress page if form.validate(newstyle=True): key = 'report_output.generate' From 94fa0380ba72026d915097ab8571287768767659 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 1 Dec 2022 09:37:30 -0600 Subject: [PATCH 0876/1681] Avoid web config when launching overnight task --- tailbone/views/luigi.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/luigi.py b/tailbone/views/luigi.py index 96e5cbe9..aaa7e2be 100644 --- a/tailbone/views/luigi.py +++ b/tailbone/views/luigi.py @@ -87,6 +87,7 @@ class LuigiTaskView(MasterView): try: self.luigi_handler.launch_overnight_task(task, app.yesterday(), + keep_config=False, email_if_empty=True, wait=False) except Exception as error: From 9f62c280de26de8356e216e3188a50547060c43c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 1 Dec 2022 13:14:17 -0600 Subject: [PATCH 0877/1681] 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 823fcb45..4d3f69a2 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.265 (2022-12-01) +-------------------- + +* Add way to quickly re-run "any" report. + +* Avoid web config when launching overnight task. + + 0.8.264 (2022-11-28) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 5ccf12b2..5e7f9907 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.264' +__version__ = '0.8.265' From 4030904d40138e78197581370d53b5503b3cfa31 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 2 Dec 2022 12:16:51 -0600 Subject: [PATCH 0878/1681] Add simple template hook for "before object helpers" not sure how useful, but needed in one place, and hook makes for cleaner template inheritance --- tailbone/templates/form.mako | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tailbone/templates/form.mako b/tailbone/templates/form.mako index 5b11face..11d4d6ae 100644 --- a/tailbone/templates/form.mako +++ b/tailbone/templates/form.mako @@ -34,6 +34,9 @@ </div> <div style="display: flex; align-items: flex-start;"> + + ${before_object_helpers()} + <div class="object-helpers"> ${self.object_helpers()} </div> @@ -46,6 +49,8 @@ </div> </%def> +<%def name="before_object_helpers()"></%def> + <%def name="render_this_page_template()"> % if form is not Underined: ${self.render_form()} From ec71f532a1edc4f7975b300e4e541a1b2bec53fd Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 4 Dec 2022 09:39:08 -0600 Subject: [PATCH 0879/1681] Include email address for current API user info --- tailbone/api/core.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tailbone/api/core.py b/tailbone/api/core.py index c2cea0a8..613b1566 100644 --- a/tailbone/api/core.py +++ b/tailbone/api/core.py @@ -101,6 +101,7 @@ class APIView(View): return info """ app = self.get_rattail_app() + auth_handler = app.get_auth_handler() # basic / default info is_admin = user.is_admin() @@ -113,6 +114,7 @@ class APIView(View): 'is_admin': is_admin, 'is_root': is_admin and self.request.session.get('is_root', False), 'employee_uuid': employee.uuid if employee else None, + 'email_address': auth_handler.get_email_address(user), } # maybe get/use "extra" info From 2e3823364cac6a608b5d14418ef24feaebade9b9 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 5 Dec 2022 14:03:03 -0600 Subject: [PATCH 0880/1681] Add support for editing catalog cost in receiving batch, per new theme had to add several "under the hood" features to make this work, to embed a Vue component within grid `<td>` cells, etc. --- tailbone/forms/core.py | 23 ++- tailbone/grids/core.py | 59 +++++++- tailbone/templates/grids/buefy.mako | 20 ++- tailbone/templates/receiving/configure.mako | 23 ++- tailbone/templates/receiving/view.mako | 155 +++++++++++++++++++- tailbone/util.py | 15 ++ tailbone/views/purchasing/receiving.py | 28 +++- 7 files changed, 296 insertions(+), 27 deletions(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index fb11ffba..bf508a6f 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -48,7 +48,7 @@ from pyramid_deform import SessionFileUploadTempStore from pyramid.renderers import render from webhelpers2.html import tags, HTML -from tailbone.util import raw_datetime +from tailbone.util import raw_datetime, get_form_data from . import types from .widgets import ReadonlyWidget, PlainDateWidget, JQueryDateWidget, JQueryTimeWidget from tailbone.exceptions import TailboneJSONFieldError @@ -1071,17 +1071,15 @@ class Form(object): if self.request.method != 'POST': return False - # use POST or JSON body, whichever is present - # TODO: per docs, some JS libraries may not set this flag? - # https://docs.pylonsproject.org/projects/pyramid/en/latest/api/request.html#pyramid.request.Request.is_xhr - if self.request.is_xhr and not self.request.POST: - controls = self.request.json_body.items() + controls = get_form_data(self.request).items() - # unfortunately the normal form logic (i.e. peppercorn) is - # expecting all values to be strings, whereas the JSON body we - # just parsed, may have given us some Pythonic objects. so - # here we must convert them *back* to strings... - # TODO: this seems like a hack, i must be missing something + # unfortunately the normal form logic (i.e. peppercorn) is + # expecting all values to be strings, whereas if our data + # came from JSON body, may have given us some Pythonic + # objects. so here we must convert them *back* to strings + # TODO: this seems like a hack, i must be missing something + # TODO: also this uses same "JSON" check as get_form_data() + if self.request.is_xhr and not self.request.POST: controls = [[key, val] for key, val in controls] for i in range(len(controls)): key, value = controls[i] @@ -1094,9 +1092,6 @@ class Form(object): elif not isinstance(value, six.string_types): controls[i][1] = six.text_type(value) - else: - controls = self.request.POST.items() - dform = self.make_deform_form() try: self.validated = dform.validate(controls) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index db976432..2f11f094 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -68,11 +68,49 @@ class FieldList(list): class Grid(object): """ Core grid class. In sore need of documentation. + + .. attribute:: raw_renderers + + Dict of "raw" field renderers. See also + :meth:`set_raw_renderer()`. + + When present, these are rendered "as-is" into the grid + template, whereas the more typical scenario involves rendering + each field "into" a span element, like: + + .. code-block:: html + + <span v-html="RENDERED-FIELD"></span> + + So instead of injecting into a span, any "raw" fields defined + via this dict, will be injected as-is, like: + + .. code-block:: html + + RENDERED-FIELD + + Note that each raw renderer is called only once, and *without* + any arguments. Likely the only use case for this, is to inject + a Vue component into the field. A basic example:: + + from webhelpers2.html import HTML + + def myrender(): + return HTML.tag('my-component', **{'v-model': 'props.row.myfield'}) + + grid = Grid( + # ..normal constructor args here.. + + raw_renderers={ + 'myfield': myrender, + }, + ) """ def __init__(self, key, data, columns=None, width='auto', request=None, model_class=None, model_title=None, model_title_plural=None, enums={}, labels={}, assume_local_times=False, renderers={}, invisible=[], + raw_renderers={}, extra_row_class=None, linked_columns=[], url='#', joiners={}, filterable=False, filters={}, use_byte_string_filters=False, searchable={}, @@ -109,6 +147,7 @@ class Grid(object): self.labels = labels or {} self.assume_local_times = assume_local_times self.renderers = self.make_default_renderers(renderers or {}) + self.raw_renderers = raw_renderers or {} self.invisible = invisible or [] self.extra_row_class = extra_row_class self.linked_columns = linked_columns or [] @@ -286,6 +325,21 @@ class Grid(object): def set_renderer(self, key, renderer): self.renderers[key] = renderer + def set_raw_renderer(self, key, renderer): + """ + Set or remove the "raw" renderer for the given field. + + See :attr:`raw_renderers` for more about these. + + :param key: Field name. + + :param renderer: Either a renderer callable, or ``None``. + """ + if renderer: + self.raw_renderers[key] = renderer + else: + self.raw_renderers.pop(key, None) + def set_type(self, key, type_): if type_ == 'boolean': self.set_renderer(key, self.render_boolean) @@ -1313,7 +1367,10 @@ class Grid(object): # iterate over data rows for i in range(count): rowobj = raw_data[i] - row = {} + + # nb. cache 0-based index on the row, in case client-side + # logic finds it useful + row = {'_index': i} # sometimes we need to include some "raw" data columns in our # result set, even though the column is not displayed as part of diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako index ec1a4875..9d8359d9 100644 --- a/tailbone/templates/grids/buefy.mako +++ b/tailbone/templates/grids/buefy.mako @@ -221,8 +221,14 @@ % if grid.is_searchable(column['field']): searchable % endif + cell-class="${column['field']}" + % if grid.has_click_handler(column['field']): + @click.native="${grid.click_handlers[column['field']]}" + % endif :visible="${json.dumps(column['visible'])}"> - % if grid.is_linked(column['field']): + % if column['field'] in grid.raw_renderers: + ${grid.raw_renderers[column['field']]()} + % elif grid.is_linked(column['field']): <a :href="props.row._action_url_view" v-html="props.row.${column['field']}"></a> % else: <span v-html="props.row.${column['field']}"></span> @@ -349,6 +355,18 @@ methods: { + addRowClass(index, className) { + + // TODO: this may add duplicated name to class string + // (not a serious problem i think, but could be improved) + this.rowStatusMap[index] = (this.rowStatusMap[index] || '') + + ' ' + className + + // nb. for some reason b-table does not always "notice" + // when we update status; so we force it to refresh + this.$forceUpdate() + }, + getRowClass(row, index) { return this.rowStatusMap[index] }, diff --git a/tailbone/templates/receiving/configure.mako b/tailbone/templates/receiving/configure.mako index f4a697f4..9d06d811 100644 --- a/tailbone/templates/receiving/configure.mako +++ b/tailbone/templates/receiving/configure.mako @@ -115,12 +115,23 @@ </b-checkbox> </b-field> - <b-checkbox name="rattail.batch.purchase.receiving.should_autofix_invoice_case_vs_unit" - v-model="simpleSettings['rattail.batch.purchase.receiving.should_autofix_invoice_case_vs_unit']" - native-value="true" - @input="settingsNeedSaved = true"> - Try to auto-correct "case vs. unit" mistakes from invoice parser - </b-checkbox> + <b-field> + <b-checkbox name="rattail.batch.purchase.receiving.should_autofix_invoice_case_vs_unit" + v-model="simpleSettings['rattail.batch.purchase.receiving.should_autofix_invoice_case_vs_unit']" + native-value="true" + @input="settingsNeedSaved = true"> + Try to auto-correct "case vs. unit" mistakes from invoice parser + </b-checkbox> + </b-field> + + <b-field> + <b-checkbox name="rattail.batch.purchase.receiving.allow_edit_catalog_unit_cost" + v-model="simpleSettings['rattail.batch.purchase.receiving.allow_edit_catalog_unit_cost']" + native-value="true" + @input="settingsNeedSaved = true"> + Allow edit of Catalog Unit Cost + </b-checkbox> + </b-field> </div> diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako index eb1e476e..d7a2a287 100644 --- a/tailbone/templates/receiving/view.mako +++ b/tailbone/templates/receiving/view.mako @@ -3,7 +3,7 @@ <%def name="extra_javascript()"> ${parent.extra_javascript()} - % if master.has_perm('edit_row'): + % if not use_buefy and master.has_perm('edit_row'): ${h.javascript_link(request.static_url('tailbone:static/js/numeric.js'))} <script type="text/javascript"> @@ -264,7 +264,21 @@ <%def name="extra_styles()"> ${parent.extra_styles()} - % if not batch.executed and master.has_perm('edit_row'): + % if use_buefy and allow_edit_catalog_unit_cost: + <style type="text/css"> + + td.catalog_unit_cost { + cursor: pointer; + background-color: #fcc; + } + + tr.catalog_cost_confirmed td.catalog_unit_cost { + /* cursor: pointer; */ + background-color: #cfc; + } + + </style> + % elif not use_buefy and not batch.executed and master.has_perm('edit_row'): <style type="text/css"> .grid tr:not(.header) td.catalog_unit_cost, .grid tr:not(.header) td.invoice_unit_cost { @@ -357,6 +371,26 @@ % endif </%def> +<%def name="render_this_page_template()"> + ${parent.render_this_page_template()} + + % if allow_edit_catalog_unit_cost: + <script type="text/x-template" id="receiving-cost-editor-template"> + <div> + <span v-show="!editing"> + {{ value }} + </span> + <b-input v-model="inputValue" + ref="input" + v-show="editing" + @keydown.native="inputKeyDown" + @blur="inputBlur"> + </b-input> + </div> + </script> + % endif +</%def> + <%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 + </script> </%def> ${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 <lance@edbob.org> Date: Mon, 5 Dec 2022 15:22:59 -0600 Subject: [PATCH 0881/1681] 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 <lance@edbob.org> Date: Mon, 5 Dec 2022 16:05:27 -0600 Subject: [PATCH 0882/1681] 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 <lance@edbob.org> Date: Mon, 5 Dec 2022 16:25:55 -0600 Subject: [PATCH 0883/1681] 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 @@ </b-checkbox> </b-field> + <b-field> + <b-checkbox name="rattail.batch.purchase.receiving.allow_edit_invoice_unit_cost" + v-model="simpleSettings['rattail.batch.purchase.receiving.allow_edit_invoice_unit_cost']" + native-value="true" + @input="settingsNeedSaved = true"> + Allow edit of Invoice Unit Cost + </b-checkbox> + </b-field> + </div> <h3 class="block is-size-3">Mobile Interface</h3> 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: <style type="text/css"> - - td.catalog_unit_cost { - cursor: pointer; - background-color: #fcc; - } - - tr.catalog_cost_confirmed td.catalog_unit_cost { - /* cursor: pointer; */ - background-color: #cfc; - } - + % if allow_edit_catalog_unit_cost: + td.catalog_unit_cost { + cursor: pointer; + background-color: #fcc; + } + tr.catalog_cost_confirmed td.catalog_unit_cost { + background-color: #cfc; + } + % endif + % if allow_edit_invoice_unit_cost: + td.invoice_unit_cost { + cursor: pointer; + background-color: #fcc; + } + tr.invoice_cost_confirmed td.invoice_unit_cost { + background-color: #cfc; + } + % endif </style> % elif not use_buefy and not batch.executed and master.has_perm('edit_row'): <style type="text/css"> @@ -374,7 +381,7 @@ <%def name="render_this_page_template()"> ${parent.render_this_page_template()} - % if allow_edit_catalog_unit_cost: + % if allow_edit_catalog_unit_cost or allow_edit_invoice_unit_cost: <script type="text/x-template" id="receiving-cost-editor-template"> <div> <span v-show="!editing"> @@ -452,12 +459,13 @@ % endif - % if allow_edit_catalog_unit_cost: + % if allow_edit_catalog_unit_cost or allow_edit_invoice_unit_cost: let ReceivingCostEditor = { template: '#receiving-cost-editor-template', props: { row: Object, + 'field': String, value: String, }, data() { @@ -510,8 +518,8 @@ let params = { row_uuid: this.$props.row.uuid, - catalog_unit_cost: this.inputValue, } + params[this.$props.field] = this.inputValue this.$http.post(url, params, {headers: headers}).then(response => { if (!response.data.error) { @@ -519,7 +527,7 @@ // 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.$emit('input', response.data.row[this.$props.field], this.$props.row._index) // and hide the input box @@ -546,6 +554,10 @@ Vue.component('receiving-cost-editor', ReceivingCostEditor) + % endif + + % if allow_edit_catalog_unit_cost: + ${rows_grid.component_studly}.methods.catalogUnitCostClicked = function(row) { // start edit for clicked cell @@ -567,6 +579,29 @@ % endif + % if allow_edit_invoice_unit_cost: + + ${rows_grid.component_studly}.methods.invoiceUnitCostClicked = function(row) { + + // start edit for clicked cell + this.$refs['invoiceUnitCost_' + row.uuid].startEdit() + } + + ${rows_grid.component_studly}.methods.invoiceCostConfirmed = function(amount, index) { + + // update display to indicate cost was confirmed + this.addRowClass(index, 'invoice_cost_confirmed') + + // start editing next row, unless there are no more + let nextRow = index + 1 + if (this.data.length > nextRow) { + nextRow = this.data[nextRow] + this.$refs['invoiceUnitCost_' + nextRow.uuid].startEdit() + } + } + + % endif + </script> </%def> diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 4937b80f..d654289b 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -724,6 +724,11 @@ class ReceivingBatchView(PurchasingBatchView): and self.has_perm('edit_row') and self.batch_handler.allow_receiving_edit_catalog_unit_cost()) + def allow_edit_invoice_unit_cost(self, batch): + return (not batch.executed + and self.has_perm('edit_row') + and self.batch_handler.allow_receiving_edit_invoice_unit_cost()) + def template_kwargs_view(self, **kwargs): kwargs = super(ReceivingBatchView, self).template_kwargs_view(**kwargs) batch = kwargs['instance'] @@ -749,6 +754,7 @@ class ReceivingBatchView(PurchasingBatchView): columns=['title', 'count']) kwargs['allow_edit_catalog_unit_cost'] = self.allow_edit_catalog_unit_cost(batch) + kwargs['allow_edit_invoice_unit_cost'] = self.allow_edit_invoice_unit_cost(batch) return kwargs @@ -960,6 +966,12 @@ class ReceivingBatchView(PurchasingBatchView): g.set_click_handler('catalog_unit_cost', 'catalogUnitCostClicked(props.row)') + # invoice_unit_cost + if use_buefy and self.allow_edit_invoice_unit_cost(batch): + g.set_raw_renderer('invoice_unit_cost', self.render_invoice_unit_cost) + g.set_click_handler('invoice_unit_cost', + 'invoiceUnitCostClicked(props.row)') + # 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) @@ -1019,12 +1031,22 @@ class ReceivingBatchView(PurchasingBatchView): def render_catalog_unit_cost(self): return HTML.tag('receiving-cost-editor', **{ + 'field': 'catalog_unit_cost', 'v-model': 'props.row.catalog_unit_cost', ':ref': "'catalogUnitCost_' + props.row.uuid", ':row': 'props.row', '@input': 'catalogCostConfirmed', }) + def render_invoice_unit_cost(self): + return HTML.tag('receiving-cost-editor', **{ + 'field': 'invoice_unit_cost', + 'v-model': 'props.row.invoice_unit_cost', + ':ref': "'invoiceUnitCost_' + props.row.uuid", + ':row': 'props.row', + '@input': 'invoiceCostConfirmed', + }) + def row_grid_extra_class(self, row, i): css_class = super(ReceivingBatchView, self).row_grid_extra_class(row, i) @@ -1966,6 +1988,9 @@ class ReceivingBatchView(PurchasingBatchView): {'section': 'rattail.batch', 'option': 'purchase.receiving.allow_edit_catalog_unit_cost', 'type': bool}, + {'section': 'rattail.batch', + 'option': 'purchase.receiving.allow_edit_invoice_unit_cost', + 'type': bool}, # mobile interface {'section': 'rattail.batch', From ebe201384993d76971ee54ebe48f00dc4d45b1be Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 6 Dec 2022 10:30:30 -0600 Subject: [PATCH 0884/1681] Add helptext for "Admin-ish" field when editing Role --- tailbone/views/roles.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py index 224356ed..29bb2ef4 100644 --- a/tailbone/views/roles.py +++ b/tailbone/views/roles.py @@ -180,7 +180,11 @@ class RoleView(PrincipalMasterView): f.set_validator('name', self.unique_name) # adminish - if not self.request.is_admin: + if self.request.is_admin: + f.set_helptext('adminish', + "If checked, only Administrators may add/remove " + "users for the role.") + else: f.remove('adminish') # session_timeout From 1509b6fce559f1847c9f39897da47210a1f5b831 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 6 Dec 2022 10:33:57 -0600 Subject: [PATCH 0885/1681] 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 4d3f69a2..4cfed77a 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,24 @@ CHANGELOG ========= +0.8.266 (2022-12-06) +-------------------- + +* Add simple template hook for "before object helpers". + +* Include email address for current API user info. + +* Add support for editing catalog cost in receiving batch, per new theme. + +* Add receiving workflow as param when making receiving batch. + +* Show invoice cost in receiving batch, if "from scratch". + +* Add support for editing invoice cost in receiving batch, per new theme. + +* Add helptext for "Admin-ish" field when editing Role. + + 0.8.265 (2022-12-01) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 5e7f9907..5ea1c7b7 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.265' +__version__ = '0.8.266' From 6ac07e125575c4dd630e9a7475c3d77d2e969af8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 6 Dec 2022 19:31:22 -0600 Subject: [PATCH 0886/1681] Fix bug when viewing certain receiving batches --- 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 d654289b..7b668dc5 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -975,7 +975,7 @@ class ReceivingBatchView(PurchasingBatchView): # 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)): + and not self.batch_handler.has_invoice_file(batch)): g.remove('invoice_unit_cost') else: g.remove('po_unit_cost') From c1b2b7e177b970cde6c9b57cfdf45ef205e80975 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 6 Dec 2022 19:32:10 -0600 Subject: [PATCH 0887/1681] 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 4cfed77a..ebba8d69 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.267 (2022-12-06) +-------------------- + +* Fix bug when viewing certain receiving batches. + + 0.8.266 (2022-12-06) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 5ea1c7b7..321a1037 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.266' +__version__ = '0.8.267' From 2b220459c7a39c47c28e59163f2ed39ed4704fb0 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 7 Dec 2022 12:33:32 -0600 Subject: [PATCH 0888/1681] Temporary cap version for Beaker, per broken web apps! --- setup.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/setup.py b/setup.py index 3328785e..eaa9e5b1 100644 --- a/setup.py +++ b/setup.py @@ -75,6 +75,10 @@ requires = [ # (still, probably a better idea is to refactor so we can use 0.9) 'webhelpers2_grid==0.1', # 0.1 + # TODO: latest version breaks us totally! need to fix ASAP, but + # for the moment, must restrict version + 'Beaker<1.12', # 1.11.0 + # TODO: remove version cap once we can drop support for python 2.x 'cornice<5.0', # 3.4.2 4.0.1 From 22176e89dd7f790274665479addcb613a7985fa1 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 7 Dec 2022 14:00:32 -0600 Subject: [PATCH 0889/1681] Add support for Beaker >= 1.12.0 but still support previous versions too, for now --- setup.py | 4 ---- tailbone/beaker.py | 22 +++++++++++++++------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/setup.py b/setup.py index eaa9e5b1..3328785e 100644 --- a/setup.py +++ b/setup.py @@ -75,10 +75,6 @@ requires = [ # (still, probably a better idea is to refactor so we can use 0.9) 'webhelpers2_grid==0.1', # 0.1 - # TODO: latest version breaks us totally! need to fix ASAP, but - # for the moment, must restrict version - 'Beaker<1.12', # 1.11.0 - # TODO: remove version cap once we can drop support for python 2.x 'cornice<5.0', # 3.4.2 4.0.1 diff --git a/tailbone/beaker.py b/tailbone/beaker.py index 1f7f20c5..b5d592f1 100644 --- a/tailbone/beaker.py +++ b/tailbone/beaker.py @@ -1,8 +1,8 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -30,7 +30,9 @@ pyramid_beaker projects. from __future__ import unicode_literals, absolute_import import time +from pkg_resources import parse_version +import beaker from beaker.session import Session from beaker.util import coerce_session_params from pyramid.settings import asbool @@ -45,6 +47,10 @@ class TailboneSession(Session): def load(self): "Loads the data from this session from persistent storage" + + # are we using older version of beaker? + old_beaker = parse_version(beaker.__version__) < parse_version('1.12') + self.namespace = self.namespace_class(self.id, data_dir=self.data_dir, digest_filenames=False, @@ -60,8 +66,12 @@ class TailboneSession(Session): try: session_data = self.namespace['session'] - if (session_data is not None and self.encrypt_key): - session_data = self._decrypt_data(session_data) + if old_beaker: + if (session_data is not None and self.encrypt_key): + session_data = self._decrypt_data(session_data) + else: # beaker >= 1.12 + if session_data is not None: + session_data = self._decrypt_data(session_data) # Memcached always returns a key, its None when its not # present @@ -90,6 +100,7 @@ class TailboneSession(Session): # for this module entirely... timeout = session_data.get('_timeout', self.timeout) if timeout is not None and \ + '_accessed_time' in session_data and \ now - session_data['_accessed_time'] > timeout: timed_out = True else: @@ -103,9 +114,6 @@ class TailboneSession(Session): # Update the current _accessed_time session_data['_accessed_time'] = now - # Set the path if applicable - if '_path' in session_data: - self._path = session_data['_path'] self.update(session_data) self.accessed_dict = session_data.copy() finally: From cea06c96735fa1a98f5b635ca4ea6b78a69835ac Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 7 Dec 2022 14:20:04 -0600 Subject: [PATCH 0890/1681] 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 ebba8d69..11b3a793 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.268 (2022-12-07) +-------------------- + +* Add support for Beaker >= 1.12.0. + + 0.8.267 (2022-12-06) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 321a1037..358b32de 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.267' +__version__ = '0.8.268' From f80d3cd530936d4839dc4303c40c670887ba63f8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 8 Dec 2022 14:15:38 -0600 Subject: [PATCH 0891/1681] Show simple error string, when subprocess batch actions fail logs still have more info, can't show user the whole traceback..but this is better than we had before.. --- tailbone/views/batch/core.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 6dc2436d..4e3ff9f5 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -963,12 +963,15 @@ class BatchMasterView(MasterView): # run command in subprocess log.debug("launching command in subprocess: %s", cmd) try: - subprocess.check_output(cmd, stderr=subprocess.PIPE) + # nb. we do not capture stderr, but on failure the stdout + # will contain a simple error string + subprocess.check_output(cmd) except subprocess.CalledProcessError as error: log.warning("command failed with exit code %s! output was:", error.returncode) - log.warning(error.stderr.decode('utf_8')) - raise + output = error.output.decode('utf_8') + log.warning(output) + raise Exception(output) def action_subprocess_thread(self, key, port, username, handler_action, progress, **kwargs): """ @@ -1009,8 +1012,8 @@ class BatchMasterView(MasterView): progress.session.load() progress.session['error'] = True progress.session['error_msg'] = ( - "{} of '{}' batch failed (see logs for more info)").format( - handler_action, self.handler.batch_key) + "{} of '{}' batch failed: {} (see logs for more info)").format( + handler_action, self.handler.batch_key, error) progress.session.save() return From 1a51f3d85449a491187c5d8cd910c03988a3a6d2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 8 Dec 2022 14:54:36 -0600 Subject: [PATCH 0892/1681] Fix ordering worksheet API for date objects --- tailbone/api/batch/ordering.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tailbone/api/batch/ordering.py b/tailbone/api/batch/ordering.py index 9ab9617c..3c489fcd 100644 --- a/tailbone/api/batch/ordering.py +++ b/tailbone/api/batch/ordering.py @@ -29,6 +29,8 @@ API. from __future__ import unicode_literals, absolute_import +import datetime + import six from rattail.db import model @@ -94,6 +96,8 @@ class OrderingBatchViews(APIBatchView): if batch.executed: raise self.forbidden() + app = self.get_rattail_app() + # TODO: much of the logic below was copied from the traditional master # view for ordering batches. should maybe let them share it somehow? @@ -179,6 +183,16 @@ class OrderingBatchViews(APIBatchView): for i in range(6 - len(history)): history.append(None) history = list(reversed(history)) + # must convert some date objects to string, for JSON sake + for h in history: + purchase = h.get('purchase') + if purchase: + dt = purchase.get('date_ordered') + if dt and isinstance(dt, datetime.date): + purchase['date_ordered'] = app.render_date(dt) + dt = purchase.get('date_received') + if dt and isinstance(dt, datetime.date): + purchase['date_received'] = app.render_date(dt) return { 'batch': self.normalize(batch), From d5d9c644a26022966c7d8eec3b6635df1b21b40e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 8 Dec 2022 18:19:32 -0600 Subject: [PATCH 0893/1681] Add the ViewSupplement concept also fix cell-class for grid columns. cannot use "raw" fieldname because in some cases (e.g. 'number', 'rate') Bulma may interpret that as actually meaning something, and affect the display --- tailbone/app.py | 12 +++ tailbone/templates/grids/buefy.mako | 2 +- tailbone/templates/receiving/view.mako | 8 +- tailbone/views/__init__.py | 4 +- tailbone/views/customers.py | 5 +- tailbone/views/departments.py | 12 ++- tailbone/views/master.py | 118 ++++++++++++++++++++++++- tailbone/views/subdepartments.py | 4 +- tailbone/views/vendors/core.py | 2 +- 9 files changed, 153 insertions(+), 14 deletions(-) diff --git a/tailbone/app.py b/tailbone/app.py index d7155829..1cfae6b2 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -177,6 +177,7 @@ def make_pyramid_config(settings, configure_csrf=True): # and some similar magic for certain master views 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_view_supplement', 'tailbone.app.add_view_supplement') config.add_directive('add_tailbone_websocket', 'tailbone.app.add_websocket') @@ -239,6 +240,17 @@ def add_config_page(config, route_name, label, permission): config.action(None, action) +def add_view_supplement(config, route_prefix, cls): + """ + Register a master view supplement for the app. + """ + def action(): + supplements = config.get_settings().get('tailbone_view_supplements', {}) + supplements.setdefault(route_prefix, []).append(cls) + config.add_settings({'tailbone_view_supplements': supplements}) + config.action(None, action) + + def establish_theme(settings): rattail_config = settings['rattail_config'] diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako index 9d8359d9..47e9a2dc 100644 --- a/tailbone/templates/grids/buefy.mako +++ b/tailbone/templates/grids/buefy.mako @@ -221,7 +221,7 @@ % if grid.is_searchable(column['field']): searchable % endif - cell-class="${column['field']}" + cell-class="c_${column['field']}" % if grid.has_click_handler(column['field']): @click.native="${grid.click_handlers[column['field']]}" % endif diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako index b16aa5b8..f4a90dcb 100644 --- a/tailbone/templates/receiving/view.mako +++ b/tailbone/templates/receiving/view.mako @@ -267,20 +267,20 @@ % if use_buefy: <style type="text/css"> % if allow_edit_catalog_unit_cost: - td.catalog_unit_cost { + td.c_catalog_unit_cost { cursor: pointer; background-color: #fcc; } - tr.catalog_cost_confirmed td.catalog_unit_cost { + tr.catalog_cost_confirmed td.c_catalog_unit_cost { background-color: #cfc; } % endif % if allow_edit_invoice_unit_cost: - td.invoice_unit_cost { + td.c_invoice_unit_cost { cursor: pointer; background-color: #fcc; } - tr.invoice_cost_confirmed td.invoice_unit_cost { + tr.invoice_cost_confirmed td.c_invoice_unit_cost { background-color: #cfc; } % endif diff --git a/tailbone/views/__init__.py b/tailbone/views/__init__.py index 6b6ebc19..29c73b61 100644 --- a/tailbone/views/__init__.py +++ b/tailbone/views/__init__.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,7 +27,7 @@ Pyramid Views from __future__ import unicode_literals, absolute_import from .core import View -from .master import MasterView +from .master import MasterView, ViewSupplement def includeme(config): diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index a905ea07..0ce0ad44 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -115,6 +115,10 @@ class CustomerView(MasterView): def configure_grid(self, g): super(CustomerView, self).configure_grid(g) + model = self.model + + # number + g.set_link('number') # name g.filters['name'].default_active = True @@ -158,7 +162,6 @@ class CustomerView(MasterView): g.filters['active_in_pos'].default_verb = 'is_true' g.set_link('id') - g.set_link('number') g.set_link('name') g.set_link('person') g.set_link('email') diff --git a/tailbone/views/departments.py b/tailbone/views/departments.py index 96dcfb61..a0212d63 100644 --- a/tailbone/views/departments.py +++ b/tailbone/views/departments.py @@ -66,6 +66,7 @@ class DepartmentView(MasterView): has_rows = True model_row_class = model.Product + rows_title = "Products" row_labels = { 'upc': "UPC", @@ -83,13 +84,18 @@ class DepartmentView(MasterView): def configure_grid(self, g): super(DepartmentView, self).configure_grid(g) + + # number + g.set_sort_defaults('number') + g.set_link('number') + + # name g.filters['name'].default_active = True g.filters['name'].default_verb = 'contains' - g.set_sort_defaults('number') + g.set_link('name') + g.set_type('product', 'boolean') g.set_type('personnel', 'boolean') - g.set_link('number') - g.set_link('name') def configure_form(self, f): super(DepartmentView, self).configure_form(f) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index af776d97..7e1925a2 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -259,6 +259,8 @@ class MasterView(View): Collect all labels defined within the master class hierarchy. """ labels = {} + for supp in self.iter_view_supplements(): + labels.update(supp.labels) hierarchy = self.get_class_hierarchy() for cls in hierarchy: if hasattr(cls, 'labels'): @@ -473,6 +475,9 @@ class MasterView(View): self.configure_column_product_key(grid) + for supp in self.iter_view_supplements(): + supp.configure_grid(grid) + def grid_extra_class(self, obj, i): """ Returns string of extra class(es) for the table row corresponding to @@ -1226,7 +1231,10 @@ class MasterView(View): If applicable, should return a list of child classes which should be considered when querying for version history of an object. """ - return [] + classes = [] + for supp in self.iter_view_supplements(): + classes.extend(supp.get_version_child_classes()) + return classes def normalize_version_child_classes(self): classes = [] @@ -2707,6 +2715,9 @@ class MasterView(View): if not self.has_perm('view_global'): query = query.filter(model_class.local_only == True) + for supp in self.iter_view_supplements(): + query = supp.get_grid_query(query) + return query def get_effective_query(self, session=None, **kwargs): @@ -3826,6 +3837,17 @@ class MasterView(View): defaults.update(kwargs) return defaults + def iter_view_supplements(self): + """ + Iterate over all registered supplements for this master view. + """ + supplements = self.request.registry.settings['tailbone_view_supplements'] + route_prefix = self.get_route_prefix() + if supplements and route_prefix in supplements: + for cls in supplements[route_prefix]: + supp = cls(self) + yield supp + def configure_form(self, form): """ Configure the main "desktop" form for the view's data model. @@ -3834,6 +3856,9 @@ class MasterView(View): self.configure_field_product_key(form) + for supp in self.iter_view_supplements(): + supp.configure_form(form) + def validate_form(self, form): if form.validate(newstyle=True): self.form_deserialized = form.validated @@ -5009,3 +5034,94 @@ class MasterView(View): '{}/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)) + + +class ViewSupplement(object): + """ + Base class for view "supplements" - which are sort of like plugins + which can "supplement" certain aspects of the view. + + Instead of subclassing a master view and "supplementing" it via + method overrides etc., packages can instead define one or more + ``ViewSupplement`` classes. All such supplements are registered + so they can be located; their logic is then merged into the + appropriate master view at runtime. + + The primary use case for this is within integration packages, such + as tailbone-corepos and the like. A truly custom app might want + supplemental logic from multiple integration packages, in which + case the "subclassing" approach sort of falls apart. + + :attribute:: labels + + This can be a dict of extra field labels to be used by the + master view. Same meaning as for + :attr:`tailbone.views.master.MasterView.labels`. + """ + labels = {} + + def __init__(self, master): + self.master = master + + @property + def model(self): + return self.master.model + + def get_grid_query(self, query): + """ + Return the "base" query for the grid. This is invoked from + within :meth:`tailbone.views.master.MasterView.query()`. + + A typical grid query is + essentially: + + .. code-block:: sql + + SELECT * FROM mytable + + But when a schema extension is in "primary" use, meaning for + instance one of the main grid columns displays extension data, + it may be helpful for the base query to join the extension + table, as opposed to doing a "just in time" join based on + sorting and/or filters: + + .. code-block:: sql + + SELECT * FROM mytable m + LEFT OUTER JOIN myextension e ON e.uuid = m.uuid + + This is accomplished by subjecting the current base query to a + join, e.g. something like:: + + model = self.model + query = query.outerjoin(model.MyExtension) + return query + """ + return query + + def configure_grid(self, g): + """ + Configure the grid as needed, e.g. add columns, and set + renderers etc. for them. + """ + + def configure_form(self, f): + """ + Configure the form as needed, e.g. add fields, and set + renderers, default values etc. for them. + """ + + def get_version_child_classes(self): + """ + Return a list of additional "version child classes" which are + to be taken into account when displaying version history for a + given record. + + See also + :meth:`tailbone.views.master.MasterView.get_version_child_classes()`. + """ + return [] + + @classmethod + def defaults(cls, config): + config.add_tailbone_view_supplement(cls.route_prefix, cls) diff --git a/tailbone/views/subdepartments.py b/tailbone/views/subdepartments.py index d2a337cc..84a34dee 100644 --- a/tailbone/views/subdepartments.py +++ b/tailbone/views/subdepartments.py @@ -85,6 +85,9 @@ class SubdepartmentView(MasterView): def configure_grid(self, g): super(SubdepartmentView, self).configure_grid(g) + # number + g.set_link('number') + # name g.filters['name'].default_active = True g.filters['name'].default_verb = 'contains' @@ -95,7 +98,6 @@ class SubdepartmentView(MasterView): g.set_sorter('department', model.Department.name) g.set_filter('department', model.Department.name) - g.set_link('number') g.set_link('name') def configure_form(self, f): diff --git a/tailbone/views/vendors/core.py b/tailbone/views/vendors/core.py index 87b2de75..a55c351b 100644 --- a/tailbone/views/vendors/core.py +++ b/tailbone/views/vendors/core.py @@ -165,7 +165,7 @@ class VendorView(MasterView): self.Session.delete(cost) def get_version_child_classes(self): - return [ + return super(VendorView, self).get_version_child_classes() + [ (model.VendorPhoneNumber, 'parent_uuid'), (model.VendorEmailAddress, 'parent_uuid'), (model.VendorContact, 'vendor_uuid'), From 2278082a4d183fd15a62c7dbb9d08f8f9f763b53 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 8 Dec 2022 20:51:07 -0600 Subject: [PATCH 0894/1681] Cleanup employees view per new supplements also add permission for "view employee secrets" (where applicable) --- tailbone/views/employees.py | 13 ++++++++++--- tailbone/views/master.py | 4 ++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/tailbone/views/employees.py b/tailbone/views/employees.py index b45e78e7..f07d319a 100644 --- a/tailbone/views/employees.py +++ b/tailbone/views/employees.py @@ -154,10 +154,11 @@ class EmployeeView(MasterView): g.set_link('last_name') def query(self, session): - q = session.query(model.Employee).join(model.Person) + query = super(EmployeeView, self).query(session) + query = query.join(model.Person) if not self.has_perm('view_all'): - q = q.filter(model.Employee.status == self.enum.EMPLOYEE_STATUS_CURRENT) - return q + query = query.filter(model.Employee.status == self.enum.EMPLOYEE_STATUS_CURRENT) + return query def grid_render_username(self, employee, field): person = employee.person if employee else None @@ -327,6 +328,7 @@ class EmployeeView(MasterView): @classmethod def _employee_defaults(cls, config): permission_prefix = cls.get_permission_prefix() + model_title = cls.get_model_title() model_title_plural = cls.get_model_title_plural() # view *all* employees @@ -334,6 +336,11 @@ class EmployeeView(MasterView): '{}.view_all'.format(permission_prefix), "View *all* (not just current) {}".format(model_title_plural)) + # view employee "secrets" + config.add_tailbone_permission(permission_prefix, + '{}.view_secrets'.format(permission_prefix), + "View \"secrets\" for {} (e.g. login ID, passcode)".format(model_title)) + def defaults(config, **kwargs): base = globals() diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 7e1925a2..b03ebcf8 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -5124,4 +5124,8 @@ class ViewSupplement(object): @classmethod def defaults(cls, config): + cls._defaults(config) + + @classmethod + def _defaults(cls, config): config.add_tailbone_view_supplement(cls.route_prefix, cls) From 273fa7eb55393c222b18da29cf67dff65964c1f7 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 8 Dec 2022 21:49:49 -0600 Subject: [PATCH 0895/1681] Add common logic for xref buttons, links when viewing object about dang time for this..probaby needs improvement but a good start --- tailbone/static/themes/falafel/css/layout.css | 4 +++ tailbone/templates/master/view.mako | 29 +++++++++++++++++++ tailbone/views/master.py | 27 ++++++++++++++--- tailbone/views/products.py | 22 +++----------- 4 files changed, 60 insertions(+), 22 deletions(-) diff --git a/tailbone/static/themes/falafel/css/layout.css b/tailbone/static/themes/falafel/css/layout.css index db3ebaf8..b0fd0cc9 100644 --- a/tailbone/static/themes/falafel/css/layout.css +++ b/tailbone/static/themes/falafel/css/layout.css @@ -93,6 +93,10 @@ header .level .theme-picker { * "object helper" panel ******************************/ +.object-helpers .panel-heading { + white-space: nowrap; +} + .object-helpers a { white-space: nowrap; } diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako index 7b0b2de5..f8be8f5b 100644 --- a/tailbone/templates/master/view.mako +++ b/tailbone/templates/master/view.mako @@ -47,6 +47,35 @@ ${instance_title} </%def> +<%def name="object_helpers()"> + ${parent.object_helpers()} + ${self.render_xref_helper()} +</%def> + +<%def name="render_xref_helper()"> + % if xref_buttons or xref_links: + % if use_buefy: + <nav class="panel"> + <p class="panel-heading">Cross-Reference</p> + <div class="panel-block"> + % for link in xref_links: + ${link} + % endfor + </div> + </nav> + % else: + <div class="object-helper"> + <h3>Cross-Reference</h3> + <div class="object-helper-content"> + % for link in xref_links: + ${link} + % endfor + </div> + </div> + % endif + % endif +</%def> + <%def name="context_menu_items()"> ## TODO: either make this configurable, or just lose it. ## nobody seems to ever find it useful in practice. diff --git a/tailbone/views/master.py b/tailbone/views/master.py index b03ebcf8..c782b52e 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2502,8 +2502,23 @@ class MasterView(View): """ Method stub, so subclass can always invoke super() for it. """ + obj = kwargs['instance'] + kwargs['xref_buttons'] = self.get_xref_buttons(obj) + kwargs['xref_links'] = self.get_xref_links(obj) return kwargs + def get_xref_buttons(self, obj): + buttons = [] + for supp in self.iter_view_supplements(): + buttons.extend(supp.get_xref_buttons(obj)) + return buttons + + def get_xref_links(self, obj): + links = [] + for supp in self.iter_view_supplements(): + links.extend(supp.get_xref_links(obj)) + return links + def template_kwargs_edit(self, **kwargs): """ Method stub, so subclass can always invoke super() for it. @@ -5062,10 +5077,8 @@ class ViewSupplement(object): def __init__(self, master): self.master = master - - @property - def model(self): - return self.master.model + self.request = master.request + self.model = master.model def get_grid_query(self, query): """ @@ -5111,6 +5124,12 @@ class ViewSupplement(object): renderers, default values etc. for them. """ + def get_xref_buttons(self, obj): + return [] + + def get_xref_links(self, obj): + return [] + def get_version_child_classes(self): """ Return a list of additional "version child classes" which are diff --git a/tailbone/views/products.py b/tailbone/views/products.py index ab9f55c6..8357c895 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -187,27 +187,12 @@ class ProductView(MasterView): self.handler = self.products_handler def query(self, session): - user = self.request.user - if user and user not in session: - user = session.merge(user) + query = super(ProductView, self).query(session) - query = session.query(model.Product) - # TODO: was this old `has_permission()` call here for a reason..? hope not.. - # if not auth.has_permission(session, user, 'products.view_deleted'): - if not self.request.has_perm('products.view_deleted'): + if not self.has_perm('view_deleted'): query = query.filter(model.Product.deleted == False) - # TODO: This used to be a good idea I thought...but in dev it didn't - # seem to make much difference, except with a larger (50K) data set it - # totally bogged things down instead of helping... - # query = query\ - # .options(orm.joinedload(model.Product.brand))\ - # .options(orm.joinedload(model.Product.department))\ - # .options(orm.joinedload(model.Product.subdepartment))\ - # .options(orm.joinedload(model.Product.regular_price))\ - # .options(orm.joinedload(model.Product.current_price))\ - # .options(orm.joinedload(model.Product.vendor)) - + # TODO: surely this is not always needed query = query.outerjoin(model.ProductInventory) return query @@ -1190,6 +1175,7 @@ class ProductView(MasterView): return jsdata def template_kwargs_view(self, **kwargs): + kwargs = super(ProductView, self).template_kwargs_view(**kwargs) product = kwargs['instance'] use_buefy = self.get_use_buefy() From 05a3e3f805cee861013a39cdc4a3742e10965aae Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 8 Dec 2022 22:00:57 -0600 Subject: [PATCH 0896/1681] Add common logic to determine panel fields for product view so we don't have to override templates, but just the view logic more needed, but this proves the concept --- tailbone/templates/products/view.mako | 28 ++++------------ tailbone/views/products.py | 47 +++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 22 deletions(-) diff --git a/tailbone/templates/products/view.mako b/tailbone/templates/products/view.mako index cac17f1a..0d0b2650 100644 --- a/tailbone/templates/products/view.mako +++ b/tailbone/templates/products/view.mako @@ -93,21 +93,9 @@ </%def> <%def name="render_main_fields(form)"> - ${form.render_field_readonly(product_key_field)} - ${form.render_field_readonly('brand')} - ${form.render_field_readonly('description')} - ${form.render_field_readonly('size')} - ${form.render_field_readonly('unit_size')} - ${form.render_field_readonly('unit_of_measure')} - ${form.render_field_readonly('average_weight')} - ${form.render_field_readonly('case_size')} - % if instance.is_pack_item(): - ${form.render_field_readonly('pack_size')} - ${form.render_field_readonly('unit')} - ${form.render_field_readonly('default_pack')} - % elif instance.packs: - ${form.render_field_readonly('packs')} - % endif + % for field in panel_fields['main']: + ${form.render_field_readonly(field)} + % endfor ${self.extra_main_fields(form)} </%def> @@ -201,13 +189,9 @@ </%def> <%def name="render_flag_fields(form)"> - ${form.render_field_readonly('weighed')} - ${form.render_field_readonly('discountable')} - ${form.render_field_readonly('special_order')} - ${form.render_field_readonly('organic')} - ${form.render_field_readonly('not_for_sale')} - ${form.render_field_readonly('discontinued')} - ${form.render_field_readonly('deleted')} + % for field in panel_fields['flag']: + ${form.render_field_readonly(field)} + % endfor </%def> <%def name="movement_panel()"> diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 8357c895..0e6321fd 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -1269,8 +1269,55 @@ class ProductView(MasterView): kwargs['vendor_sources'] = self.get_context_vendor_sources(product) kwargs['lookup_codes'] = self.get_context_lookup_codes(product) + kwargs['panel_fields'] = self.get_panel_fields(product) + return kwargs + def get_panel_fields(self, product): + return { + 'main': self.get_panel_fields_main(product), + 'flag': self.get_panel_fields_flag(product), + } + + def get_panel_fields_main(self, product): + key = self.rattail_config.product_key() + product_key_field = self.product_key_fields.get(key, key) + fields = [ + product_key_field, + 'brand', + 'description', + 'size', + 'unit_size', + 'unit_of_measure', + 'average_weight', + 'case_size', + ] + if product.is_pack_item(): + fields.extend([ + 'pack_size', + 'unit', + 'default_pack', + ]) + elif product.packs: + fields.append('packs') + + for supp in self.iter_view_supplements(): + if hasattr(supp, 'get_panel_fields_main'): + fields.extend(supp.get_panel_fields_main(product)) + + return fields + + def get_panel_fields_flag(self, product): + return [ + 'weighed', + 'discountable', + 'special_order', + 'organic', + 'not_for_sale', + 'discontinued', + 'deleted', + ] + def get_context_vendor_sources(self, product): app = self.get_rattail_app() route_prefix = self.get_route_prefix() From cb6c25f829c43faaa2de739b9b2272d209f62506 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 9 Dec 2022 23:13:28 -0600 Subject: [PATCH 0897/1681] Let view supps give data instead of actual xref button --- tailbone/templates/master/view.mako | 16 +++++++++++---- tailbone/views/master.py | 32 +++++++++++++++++++++++++++-- 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako index f8be8f5b..f6dd584a 100644 --- a/tailbone/templates/master/view.mako +++ b/tailbone/templates/master/view.mako @@ -57,16 +57,24 @@ % if use_buefy: <nav class="panel"> <p class="panel-heading">Cross-Reference</p> - <div class="panel-block"> - % for link in xref_links: - ${link} - % endfor + <div class="panel-block buttons"> + <div style="display: flex; flex-direction: column;"> + % for button in xref_buttons: + ${button} + % endfor + % for link in xref_links: + ${link} + % endfor + </div> </div> </nav> % else: <div class="object-helper"> <h3>Cross-Reference</h3> <div class="object-helper-content"> + % for button in xref_buttons: + ${button} + % endfor % for link in xref_links: ${link} % endfor diff --git a/tailbone/views/master.py b/tailbone/views/master.py index c782b52e..c96270b7 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2510,13 +2510,40 @@ class MasterView(View): def get_xref_buttons(self, obj): buttons = [] for supp in self.iter_view_supplements(): - buttons.extend(supp.get_xref_buttons(obj)) + buttons.extend(supp.get_xref_buttons(obj) or []) + buttons = self.normalize_xref_buttons(buttons) return buttons + def normalize_xref_buttons(self, buttons): + normal = [] + for button in buttons: + + # build a button if only given the data + if isinstance(button, dict): + button = self.make_xref_button(**button) + + normal.append(button) + return normal + + def make_xref_button(self, **kwargs): + + # nb. unfortunately HTML.tag() calls its first arg 'tag' and + # so we can't pass a kwarg with that name...so instead we + # patch that into place manually + button = HTML.tag('b-button', type='is-primary', + href=kwargs['url'], target='_blank', + icon_pack='fas', icon_left='external-link-alt', + c=kwargs['text']) + button = six.text_type(button) + button = button.replace('target="_blank"', + 'target="_blank" tag="a"') + button = HTML.literal(button) + return button + def get_xref_links(self, obj): links = [] for supp in self.iter_view_supplements(): - links.extend(supp.get_xref_links(obj)) + links.extend(supp.get_xref_links(obj) or []) return links def template_kwargs_edit(self, **kwargs): @@ -5079,6 +5106,7 @@ class ViewSupplement(object): self.master = master self.request = master.request self.model = master.model + self.rattail_config = master.rattail_config def get_grid_query(self, query): """ From f8f6b766578b213c6bfcb3cc1f8bde375ca18474 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 10 Dec 2022 09:11:27 -0600 Subject: [PATCH 0898/1681] Add xref buttons for Customer, Member tabs in profile view --- .../templates/people/view_profile_buefy.mako | 6 ++++++ tailbone/views/master.py | 1 + tailbone/views/people.py | 18 ++++++++++++++++++ 3 files changed, 25 insertions(+) diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako index 51ecaed0..89cafb6a 100644 --- a/tailbone/templates/people/view_profile_buefy.mako +++ b/tailbone/templates/people/view_profile_buefy.mako @@ -579,6 +579,9 @@ </%def> <%def name="render_member_panel_buttons(member)"> + % for button in member_xref_buttons: + ${button} + % endfor % if request.has_perm('members.view'): <b-button tag="a" :href="member.view_url"> View Member @@ -665,6 +668,9 @@ </%def> <%def name="render_customer_panel_buttons(customer)"> + % for button in customer_xref_buttons: + ${button} + % endfor % if request.has_perm('customers.view'): <b-button tag="a" :href="customer.view_url"> View Customer diff --git a/tailbone/views/master.py b/tailbone/views/master.py index c96270b7..304494c3 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -5107,6 +5107,7 @@ class ViewSupplement(object): self.request = master.request self.model = master.model self.rattail_config = master.rattail_config + self.Session = master.Session def get_grid_query(self, query): """ diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 6d517e3a..d5330076 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -417,7 +417,9 @@ class PersonView(MasterView): 'email_type_options': self.get_email_type_options(), 'max_lengths': self.get_max_lengths(), 'customers_data': self.get_context_customers(person), + 'customer_xref_buttons': self.get_customer_xref_buttons(person), 'members_data': self.get_context_members(person), + 'member_xref_buttons': self.get_member_xref_buttons(person), 'employee': employee, 'employee_data': self.get_context_employee(employee) if employee else {}, 'employee_view_url': self.request.route_url('employees.view', uuid=employee.uuid) if employee else None, @@ -430,6 +432,22 @@ class PersonView(MasterView): template = 'view_profile_buefy' if use_buefy else 'view_profile' return self.render_to_response(template, context) + def get_customer_xref_buttons(self, person): + buttons = [] + for supp in self.iter_view_supplements(): + if hasattr(supp, 'get_customer_xref_buttons'): + buttons.extend(supp.get_customer_xref_buttons(person) or []) + buttons = self.normalize_xref_buttons(buttons) + return buttons + + def get_member_xref_buttons(self, person): + buttons = [] + for supp in self.iter_view_supplements(): + if hasattr(supp, 'get_member_xref_buttons'): + buttons.extend(supp.get_member_xref_buttons(person) or []) + buttons = self.normalize_xref_buttons(buttons) + return buttons + def template_kwargs_view_profile(self, **kwargs): """ Stub method so subclass can call `super()` for it. From f388f84b071a551425f333d647555552d85df30e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 10 Dec 2022 10:09:39 -0600 Subject: [PATCH 0899/1681] Suppress error if menu entry has bad route name --- tailbone/menus.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tailbone/menus.py b/tailbone/menus.py index 46f5c62a..8b432879 100644 --- a/tailbone/menus.py +++ b/tailbone/menus.py @@ -351,7 +351,11 @@ def make_menu_entry(request, item): } if item.get('route'): entry['route'] = item['route'] - entry['url'] = request.route_url(entry['route']) + try: + entry['url'] = request.route_url(entry['route']) + except KeyError: # happens if no such route + log.warning("invalid route name for menu entry: %s", entry) + entry['url'] = entry['route'] entry['key'] = entry['route'] else: if item.get('url'): From 5045df0b570cea84390ec37427c3401b052905cc Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 10 Dec 2022 11:35:02 -0600 Subject: [PATCH 0900/1681] Update changelog --- CHANGES.rst | 20 ++++++++++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 11b3a793..e86bc4e0 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,26 @@ CHANGELOG ========= +0.8.269 (2022-12-10) +-------------------- + +* Show simple error string, when subprocess batch actions fail. + +* Fix ordering worksheet API for date objects. + +* Add the ViewSupplement concept. + +* Cleanup employees view per new supplements. + +* Add common logic for xref buttons, links when viewing object. + +* Add common logic to determine panel fields for product view. + +* Add xref buttons for Customer, Member tabs in profile view. + +* Suppress error if menu entry has bad route name. + + 0.8.268 (2022-12-07) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 358b32de..ae241c15 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.268' +__version__ = '0.8.269' From 3c5496061285adbf2a4d37b1b819b442ba3c6b4d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 10 Dec 2022 12:41:10 -0600 Subject: [PATCH 0901/1681] Fix error if no view supplements defined --- tailbone/views/master.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 304494c3..3ae2be88 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -3883,7 +3883,7 @@ class MasterView(View): """ Iterate over all registered supplements for this master view. """ - supplements = self.request.registry.settings['tailbone_view_supplements'] + supplements = self.request.registry.settings.get('tailbone_view_supplements', []) route_prefix = self.get_route_prefix() if supplements and route_prefix in supplements: for cls in supplements[route_prefix]: From c8201de2ff352eaf3f001ff87be0b0ae5718cbe1 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 10 Dec 2022 12:41:41 -0600 Subject: [PATCH 0902/1681] 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 e86bc4e0..bf4e91f6 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.270 (2022-12-10) +-------------------- + +* Fix error if no view supplements defined. + + 0.8.269 (2022-12-10) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index ae241c15..5bf78f4f 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.269' +__version__ = '0.8.270' From 99a5615e91e1ad24013d030f2b96cc31cf653eb2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 15 Dec 2022 09:12:26 -0600 Subject: [PATCH 0903/1681] Add `configure_execute_form()` hook for batch views also enable bulk-delete of row results by default for batch views --- tailbone/views/batch/core.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 4e3ff9f5..35b19e51 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -72,6 +72,7 @@ class BatchMasterView(MasterView): batch_handler_class = None has_rows = True rows_deletable = True + rows_bulk_deletable = True rows_downloadable_csv = True rows_downloadable_xlsx = True refreshable = True @@ -170,6 +171,7 @@ class BatchMasterView(MasterView): return self.rattail_config.batch_filepath(batch.batch_key, batch.uuid, filename) def template_kwargs_view(self, **kwargs): + kwargs = super(BatchMasterView, self).template_kwargs_view(**kwargs) use_buefy = self.get_use_buefy() batch = kwargs['instance'] kwargs['batch'] = batch @@ -880,7 +882,12 @@ class BatchMasterView(MasterView): kwargs['use_buefy'] = use_buefy kwargs['component'] = 'execute-form' - return forms.Form(schema=schema, request=self.request, defaults=defaults, **kwargs) + form = forms.Form(schema=schema, request=self.request, defaults=defaults, **kwargs) + self.configure_execute_form(form) + return form + + def configure_execute_form(self, form): + pass def get_execute_title(self, batch): if hasattr(self.handler, 'get_execute_title'): From e427e50d670ce94fbaab41bc22d4e960b7e4b658 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 15 Dec 2022 13:32:27 -0600 Subject: [PATCH 0904/1681] 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 bf4e91f6..e81f068d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.271 (2022-12-15) +-------------------- + +* Add ``configure_execute_form()`` hook for batch views. + + 0.8.270 (2022-12-10) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 5bf78f4f..33a29fa8 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.270' +__version__ = '0.8.271' From 871ea84f9662bf33ee424b8510387c6a5e724814 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 20 Dec 2022 19:14:54 -0600 Subject: [PATCH 0905/1681] Add support for "is row checkable" in grids i.e. when grid has checkboxes, some rows maybe shouldn't get one --- tailbone/grids/core.py | 76 +++++++++++++++++++++++++++++ tailbone/templates/grids/buefy.mako | 9 +++- tailbone/views/master.py | 2 + 3 files changed, 85 insertions(+), 2 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 2f11f094..54f578ed 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -69,6 +69,74 @@ class Grid(object): """ Core grid class. In sore need of documentation. + .. _Buefy docs: https://buefy.org/documentation/table/ + + .. attribute:: checkable + + Optional callback to determine if a given row is checkable, + i.e. this allows hiding checkbox for certain rows if needed. + + This may be either a Python callable, or string representing a + JS callable. If the latter, according to the `Buefy docs`_: + + .. code-block:: none + + Custom method to verify if a row is checkable, works when is + checkable. + + Function (row: Object) + + In other words this JS callback would be invoked for each data + row in the client-side grid. + + But if a Python callable is used, then it will be invoked for + each row object in the server-side grid. For instance:: + + def checkable(obj): + if obj.some_property == True: + return True + return False + + grid.checkable = checkable + + .. attribute:: check_handler + + Optional JS callback for the ``@check`` event of the underlying + Buefy table component. See the `Buefy docs`_ for more info, + but for convenience they say this (as of writing): + + .. code-block:: none + + Triggers when the checkbox in a row is clicked and/or when + the header checkbox is clicked + + For instance, you might set ``grid.check_handler = + 'rowChecked'`` and then define the handler within your template + (e.g. ``/widgets/index.mako``) like so: + + .. code-block:: none + + <%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + TailboneGrid.methods.rowChecked = function(checkedList, row) { + if (!row) { + console.log("no row, so header checkbox was clicked") + } else { + console.log(row) + if (checkedList.includes(row)) { + console.log("clicking row checkbox ON") + } else { + console.log("clicking row checkbox OFF") + } + } + console.log(checkedList) + } + + </script> + </%def> + .. attribute:: raw_renderers Dict of "raw" field renderers. See also @@ -117,6 +185,7 @@ class Grid(object): sortable=False, sorters={}, default_sortkey=None, default_sortdir='asc', pageable=False, default_pagesize=None, default_page=1, checkboxes=False, checked=None, check_handler=None, check_all_handler=None, + checkable=None, clicking_row_checks_box=False, click_handlers=None, main_actions=[], more_actions=[], delete_speedbump=False, ajax_data_url=None, component='tailbone-grid', @@ -175,6 +244,7 @@ class Grid(object): self.checked = lambda item: False self.check_handler = check_handler self.check_all_handler = check_all_handler + self.checkable = checkable self.clicking_row_checks_box = clicking_row_checks_box self.click_handlers = click_handlers or {} @@ -1365,6 +1435,7 @@ class Grid(object): count = len(raw_data) # iterate over data rows + checkable = self.checkboxes and self.checkable and callable(self.checkable) for i in range(count): rowobj = raw_data[i] @@ -1372,6 +1443,11 @@ class Grid(object): # logic finds it useful row = {'_index': i} + # if grid allows checkboxes, and we have logic to see if + # any given row is checkable, add data for that here + if checkable: + row['_checkable'] = self.checkable(rowobj) + # sometimes we need to include some "raw" data columns in our # result set, even though the column is not displayed as part of # the grid. this can be used for front-end editing of row data for diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako index 47e9a2dc..10c52389 100644 --- a/tailbone/templates/grids/buefy.mako +++ b/tailbone/templates/grids/buefy.mako @@ -192,12 +192,17 @@ % if grid.check_all_handler: @check-all="${grid.check_all_handler}" % endif - ## TODO: definitely will be wanting this... - ## :is-row-checkable="" + % if isinstance(grid.checkable, six.string_types): + :is-row-checkable="${grid.row_checkable}" + % elif grid.checkable: + :is-row-checkable="row => row._checkable" + % endif + % if grid.sortable: :default-sort="[sortField, sortOrder]" backend-sorting @sort="onSort" + % endif :paginated="paginated" :per-page="perPage" diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 3ae2be88..a0d51329 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -451,6 +451,7 @@ class MasterView(View): 'url': lambda obj: self.get_action_url('view', obj), 'checkboxes': checkboxes, 'checked': self.checked, + 'checkable': self.checkbox, 'clicking_row_checks_box': self.clicking_row_checks_box, 'assume_local_times': self.has_local_times, } @@ -2765,6 +2766,7 @@ class MasterView(View): def get_effective_query(self, session=None, **kwargs): return self.get_effective_data(session=session, **kwargs) + # TODO: should rename to checkable? def checkbox(self, instance): """ Returns a boolean indicating whether ot not a checkbox should be From ed0a1f27406909f8d8b593e2d14ccb8f1930907c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 20 Dec 2022 19:15:31 -0600 Subject: [PATCH 0906/1681] Add `make_status_renderer()` to MasterView batches aren't the only table/view where a status code/text combo may be in use --- tailbone/views/batch/core.py | 11 ----------- tailbone/views/master.py | 18 ++++++++++++++++++ 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 35b19e51..d0babe87 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -419,17 +419,6 @@ class BatchMasterView(MasterView): f.remove_fields('executed', 'executed_by') - def make_status_renderer(self, enum): - def render_status(batch, field): - value = batch.status_code - if value is None: - return "" - status_code_text = enum.get(value, six.text_type(value)) - if batch.status_text: - return HTML.tag('span', title=batch.status_text, c=status_code_text) - return status_code_text - return render_status - def render_complete(self, batch, field): permission_prefix = self.get_permission_prefix() use_buefy = self.get_use_buefy() diff --git a/tailbone/views/master.py b/tailbone/views/master.py index a0d51329..313d7fd8 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -953,6 +953,24 @@ class MasterView(View): return email_key + def make_status_renderer(self, enum): + """ + Creates and returns a function for use with rendering a + "status combo" field(s) for a record. Assumes the record has + both ``status_code`` and ``status_text`` fields, as batches + do. Renders the simple status code text, and if custom status + text is present, it is rendered as a tooltip. + """ + def render_status(obj, field): + value = obj.status_code + if value is None: + return "" + status_code_text = enum.get(value, six.text_type(value)) + if obj.status_text: + return HTML.tag('span', title=obj.status_text, c=status_code_text) + return status_code_text + return render_status + def before_create_flush(self, obj, form): pass From ef9dc9ff6dd70f8a68f5622a36ee66e9d56b0610 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 21 Dec 2022 18:05:38 -0600 Subject: [PATCH 0907/1681] Expose the `terms` field for Vendor CRUD --- tailbone/views/vendors/core.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tailbone/views/vendors/core.py b/tailbone/views/vendors/core.py index a55c351b..176afab2 100644 --- a/tailbone/views/vendors/core.py +++ b/tailbone/views/vendors/core.py @@ -60,6 +60,7 @@ class VendorView(MasterView): 'phone', 'email', 'contact', + 'terms', ] form_fields = [ @@ -73,6 +74,7 @@ class VendorView(MasterView): 'default_email', 'orders_email', 'contact', + 'terms', ] def configure_grid(self, g): From 7ccd9ad89617f531cc66e8ae5c80b506ef72e0fa Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 21 Dec 2022 20:01:31 -0600 Subject: [PATCH 0908/1681] 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 e81f068d..86fd59c0 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.8.272 (2022-12-21) +-------------------- + +* Add support for "is row checkable" in grids. + +* Add ``make_status_renderer()`` to MasterView. + +* Expose the ``terms`` field for Vendor CRUD. + + 0.8.271 (2022-12-15) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 33a29fa8..7bd0956b 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.271' +__version__ = '0.8.272' From 6fbc79fe5e80f77aefb601d785eaee59757d204f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 22 Dec 2022 20:49:20 -0600 Subject: [PATCH 0909/1681] Add support for Buefy 0.9.x or: add hacks to continue supporting Buefy 0.8.x ..depending on your perspective --- tailbone/config.py | 12 ++ tailbone/subscribers.py | 9 +- tailbone/templates/custorders/create.mako | 99 ++++++++++++++-- tailbone/templates/custorders/items/view.mako | 70 +++++++++-- tailbone/templates/datasync/configure.mako | 109 +++++++++++++++--- tailbone/templates/datasync/status.mako | 86 +++++++++++--- tailbone/templates/generate_feature.mako | 39 ++++++- tailbone/templates/grids/b-table.mako | 14 ++- tailbone/templates/grids/buefy.mako | 14 ++- tailbone/templates/importing/configure.mako | 52 +++++++-- tailbone/templates/luigi/configure.mako | 74 ++++++++++-- tailbone/templates/luigi/index.mako | 76 +++++++++--- tailbone/templates/people/index.mako | 22 +++- .../templates/people/view_profile_buefy.mako | 94 +++++++++++++-- tailbone/templates/products/lookup.mako | 58 ++++++++-- .../trainwreck/transactions/rollover.mako | 25 +++- tailbone/templates/upgrades/configure.mako | 19 ++- 17 files changed, 753 insertions(+), 119 deletions(-) diff --git a/tailbone/config.py b/tailbone/config.py index 4c393b49..6807d861 100644 --- a/tailbone/config.py +++ b/tailbone/config.py @@ -26,6 +26,8 @@ Rattail config extension for Tailbone from __future__ import unicode_literals, absolute_import +from pkg_resources import parse_version + from rattail.config import ConfigExtension as BaseExtension from rattail.db.config import configure_session @@ -61,6 +63,16 @@ def csrf_header_name(config): return config.get('tailbone', 'csrf_header_name', default='X-CSRF-TOKEN') +def get_buefy_version(config): + return config.get('tailbone', 'buefy_version') or '0.8.17' + + +def get_buefy_0_8(config, version=None): + if not version: + version = get_buefy_version(config) + return parse_version(version) < parse_version('0.9') + + def global_help_url(config): return config.get('tailbone', 'global_help_url') diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 6e8e2d33..db73ff7b 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -40,7 +40,8 @@ from webhelpers2.html import tags import tailbone from tailbone import helpers from tailbone.db import Session -from tailbone.config import csrf_header_name, should_expose_websockets +from tailbone.config import (csrf_header_name, should_expose_websockets, + get_buefy_version, get_buefy_0_8) from tailbone.menus import make_simple_menus from tailbone.util import should_use_buefy @@ -164,8 +165,10 @@ def before_render(event): # perhaps too much so, but at least they should work fine. renderer_globals['vue_version'] = request.rattail_config.get( 'tailbone', 'vue_version') or '2.6.10' - renderer_globals['buefy_version'] = request.rattail_config.get( - 'tailbone', 'buefy_version') or '0.8.13' + version = get_buefy_version(rattail_config) + renderer_globals['buefy_version'] = version + renderer_globals['buefy_0_8'] = get_buefy_0_8(rattail_config, + version=version) # maybe set custom stylesheet css = None diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index cdbf584c..77e72244 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -866,16 +866,24 @@ paginated per-page="5" :debounce-search="1000"> + % if buefy_0_8: <template slot-scope="props"> + % endif <b-table-column :label="productKeyLabel" field="key" + % if not buefy_0_8: + v-slot="props" + % endif sortable> {{ props.row.key }} </b-table-column> <b-table-column label="Brand" field="brand_name" + % if not buefy_0_8: + v-slot="props" + % endif sortable searchable> {{ props.row.brand_name }} @@ -883,6 +891,9 @@ <b-table-column label="Description" field="description" + % if not buefy_0_8: + v-slot="props" + % endif sortable searchable> {{ props.row.description }} @@ -891,12 +902,18 @@ <b-table-column label="Unit Price" field="unit_price" + % if not buefy_0_8: + v-slot="props" + % endif sortable> {{ props.row.unit_price_display }} </b-table-column> <b-table-column label="Sale Price" field="sale_price" + % if not buefy_0_8: + v-slot="props" + % endif sortable> <span class="has-background-warning"> {{ props.row.sale_price_display }} @@ -905,6 +922,9 @@ <b-table-column label="Sale Ends" field="sale_ends" + % if not buefy_0_8: + v-slot="props" + % endif sortable> <span class="has-background-warning"> {{ props.row.sale_ends_display }} @@ -913,6 +933,9 @@ <b-table-column label="Department" field="department_name" + % if not buefy_0_8: + v-slot="props" + % endif sortable searchable> {{ props.row.department_name }} @@ -920,12 +943,17 @@ <b-table-column label="Vendor" field="vendor_name" + % if not buefy_0_8: + v-slot="props" + % endif sortable searchable> {{ props.row.vendor_name }} </b-table-column> + % if buefy_0_8: </template> + % endif <template slot="empty"> <div class="content has-text-grey has-text-centered"> <p> @@ -961,33 +989,63 @@ <b-table v-if="items.length" :data="items" :row-class="(row, i) => row.product_uuid ? null : 'has-text-success'"> + % if buefy_0_8: <template slot-scope="props"> + % endif - <b-table-column :label="productKeyLabel"> + <b-table-column :label="productKeyLabel" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.product_key }} </b-table-column> - <b-table-column label="Brand"> + <b-table-column label="Brand" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.product_brand }} </b-table-column> - <b-table-column label="Description"> + <b-table-column label="Description" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.product_description }} </b-table-column> - <b-table-column label="Size"> + <b-table-column label="Size" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.product_size }} </b-table-column> - <b-table-column label="Department"> + <b-table-column label="Department" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.department_display }} </b-table-column> - <b-table-column label="Quantity"> + <b-table-column label="Quantity" + % if not buefy_0_8: + v-slot="props" + % endif + > <span v-html="props.row.order_quantity_display"></span> </b-table-column> - <b-table-column label="Unit Price"> + <b-table-column label="Unit Price" + % if not buefy_0_8: + v-slot="props" + % endif + > <span % if product_price_may_be_questionable: :class="props.row.price_needs_confirmation ? 'has-background-warning' : ''" @@ -1000,12 +1058,20 @@ </b-table-column> % if allow_item_discounts: - <b-table-column label="Discount"> + <b-table-column label="Discount" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.discount_percent }}{{ props.row.discount_percent ? " %" : "" }} </b-table-column> % endif - <b-table-column label="Total"> + <b-table-column label="Total" + % if not buefy_0_8: + v-slot="props" + % endif + > <span % if product_price_may_be_questionable: :class="props.row.price_needs_confirmation ? 'has-background-warning' : ''" @@ -1017,11 +1083,20 @@ </span> </b-table-column> - <b-table-column label="Vendor"> + <b-table-column label="Vendor" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.vendor_display }} </b-table-column> - <b-table-column field="actions" label="Actions"> + <b-table-column field="actions" + label="Actions" + % if not buefy_0_8: + v-slot="props" + % endif + > <a href="#" class="grid-action" @click.prevent="showEditItemDialog(props.row)"> <i class="fas fa-edit"></i> @@ -1037,7 +1112,9 @@ </b-table-column> + % if buefy_0_8: </template> + % endif </b-table> </div> </div> diff --git a/tailbone/templates/custorders/items/view.mako b/tailbone/templates/custorders/items/view.mako index 030b0ade..c39b7ffe 100644 --- a/tailbone/templates/custorders/items/view.mako +++ b/tailbone/templates/custorders/items/view.mako @@ -106,47 +106,95 @@ :checked-rows.sync="changeStatusCheckedRows" narrowed class="is-size-7"> + % if buefy_0_8: <template slot-scope="props"> - <b-table-column field="product_brand" label="Brand"> + % endif + <b-table-column field="product_brand" label="Brand" + % if not buefy_0_8: + v-slot="props" + % endif + > <span v-html="props.row.product_brand"></span> </b-table-column> - <b-table-column field="product_description" label="Product"> + <b-table-column field="product_description" label="Product" + % if not buefy_0_8: + v-slot="props" + % endif + > <span v-html="props.row.product_description"></span> </b-table-column> <!-- <b-table-column field="quantity" label="Quantity"> --> <!-- <span v-html="props.row.quantity"></span> --> <!-- </b-table-column> --> - <b-table-column field="product_case_quantity" label="cPack"> + <b-table-column field="product_case_quantity" label="cPack" + % if not buefy_0_8: + v-slot="props" + % endif + > <span v-html="props.row.product_case_quantity"></span> </b-table-column> - <b-table-column field="order_quantity" label="oQty"> + <b-table-column field="order_quantity" label="oQty" + % if not buefy_0_8: + v-slot="props" + % endif + > <span v-html="props.row.order_quantity"></span> </b-table-column> - <b-table-column field="order_uom" label="UOM"> + <b-table-column field="order_uom" label="UOM" + % if not buefy_0_8: + v-slot="props" + % endif + > <span v-html="props.row.order_uom"></span> </b-table-column> - <b-table-column field="department_name" label="Department"> + <b-table-column field="department_name" label="Department" + % if not buefy_0_8: + v-slot="props" + % endif + > <span v-html="props.row.department_name"></span> </b-table-column> - <b-table-column field="product_barcode" label="Product Barcode"> + <b-table-column field="product_barcode" label="Product Barcode" + % if not buefy_0_8: + v-slot="props" + % endif + > <span v-html="props.row.product_barcode"></span> </b-table-column> - <b-table-column field="unit_price" label="Unit $"> + <b-table-column field="unit_price" label="Unit $" + % if not buefy_0_8: + v-slot="props" + % endif + > <span v-html="props.row.unit_price"></span> </b-table-column> - <b-table-column field="total_price" label="Total $"> + <b-table-column field="total_price" label="Total $" + % if not buefy_0_8: + v-slot="props" + % endif + > <span v-html="props.row.total_price"></span> </b-table-column> - <b-table-column field="order_date" label="Order Date"> + <b-table-column field="order_date" label="Order Date" + % if not buefy_0_8: + v-slot="props" + % endif + > <span v-html="props.row.order_date"></span> </b-table-column> - <b-table-column field="status_code" label="Status"> + <b-table-column field="status_code" label="Status" + % if not buefy_0_8: + v-slot="props" + % endif + > <span v-html="props.row.status_code"></span> </b-table-column> <!-- <b-table-column field="flagged" label="Flagged"> --> <!-- <span v-html="props.row.flagged"></span> --> <!-- </b-table-column> --> + % if buefy_0_8: </template> + % endif </b-table> <br /> diff --git a/tailbone/templates/datasync/configure.mako b/tailbone/templates/datasync/configure.mako index 014668be..63769ee8 100644 --- a/tailbone/templates/datasync/configure.mako +++ b/tailbone/templates/datasync/configure.mako @@ -103,36 +103,80 @@ <b-table :data="filteredProfilesData" :row-class="(row, i) => row.enabled ? null : 'has-background-warning'"> + % if buefy_0_8: <template slot-scope="props"> - <b-table-column field="key" label="Watcher Key"> + % endif + <b-table-column field="key" + label="Watcher Key" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.key }} </b-table-column> - <b-table-column field="watcher_spec" label="Watcher Spec"> + <b-table-column field="watcher_spec" + label="Watcher Spec" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.watcher_spec }} </b-table-column> - <b-table-column field="watcher_dbkey" label="DB Key"> + <b-table-column field="watcher_dbkey" + label="DB Key" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.watcher_dbkey }} </b-table-column> - <b-table-column field="watcher_delay" label="Loop Delay"> + <b-table-column field="watcher_delay" + label="Loop Delay" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.watcher_delay }} sec </b-table-column> - <b-table-column field="watcher_retry_attempts" label="Attempts / Delay"> + <b-table-column field="watcher_retry_attempts" + label="Attempts / Delay" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.watcher_retry_attempts }} / {{ props.row.watcher_retry_delay }} sec </b-table-column> - <b-table-column field="watcher_default_runas" label="Default Runas"> + <b-table-column field="watcher_default_runas" + label="Default Runas" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.watcher_default_runas }} </b-table-column> - <b-table-column label="Consumers"> + <b-table-column label="Consumers" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ consumerShortList(props.row) }} </b-table-column> ## <b-table-column field="notes" label="Notes"> ## TODO ## ## {{ props.row.notes }} ## </b-table-column> - <b-table-column field="enabled" label="Enabled"> + <b-table-column field="enabled" + label="Enabled" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.enabled ? "Yes" : "No" }} </b-table-column> <b-table-column label="Actions" + % if not buefy_0_8: + v-slot="props" + % endif v-if="useProfileSettings"> <a href="#" class="grid-action" @@ -148,7 +192,9 @@ Delete </a> </b-table-column> + % if buefy_0_8: </template> + % endif <template slot="empty"> <section class="section"> <div class="content has-text-grey has-text-centered"> @@ -281,14 +327,30 @@ <b-table :data="editingProfilePendingWatcherKwargs" style="margin-left: 1rem;"> + % if buefy_0_8: <template slot-scope="props"> - <b-table-column field="key" label="Key"> + % endif + <b-table-column field="key" + label="Key" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.key }} </b-table-column> - <b-table-column field="value" label="Value"> + <b-table-column field="value" + label="Value" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.value }} </b-table-column> - <b-table-column label="Actions"> + <b-table-column label="Actions" + % if not buefy_0_8: + v-slot="props" + % endif + > <a href="#" @click.prevent="editProfileWatcherKwarg(props.row)"> <i class="fas fa-edit"></i> @@ -302,7 +364,9 @@ Delete </a> </b-table-column> + % if buefy_0_8: </template> + % endif <template slot="empty"> <section class="section"> <div class="content has-text-grey has-text-centered"> @@ -336,14 +400,29 @@ <b-table :data="editingProfilePendingConsumers" v-if="!editingProfileWatcherConsumesSelf" :row-class="(row, i) => row.enabled ? null : 'has-background-warning'"> + % if buefy_0_8: <template slot-scope="props"> - <b-table-column field="key" label="Consumer"> + % endif + <b-table-column field="key" + label="Consumer" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.key }} </b-table-column> - <b-table-column style="white-space: nowrap;"> + <b-table-column style="white-space: nowrap;" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.consumer_delay }} / {{ props.row.consumer_retry_attempts }} / {{ props.row.consumer_retry_delay }} </b-table-column> - <b-table-column label="Actions"> + <b-table-column label="Actions" + % if not buefy_0_8: + v-slot="props" + % endif + > <a href="#" class="grid-action" @click.prevent="editProfileConsumer(props.row)"> @@ -358,7 +437,9 @@ Delete </a> </b-table-column> + % if buefy_0_8: </template> + % endif <template slot="empty"> <section class="section"> <div class="content has-text-grey has-text-centered"> diff --git a/tailbone/templates/datasync/status.mako b/tailbone/templates/datasync/status.mako index 0d0f5994..11c4aa12 100644 --- a/tailbone/templates/datasync/status.mako +++ b/tailbone/templates/datasync/status.mako @@ -49,65 +49,123 @@ <b-field label="Watcher Status"> <b-table :data="watchers"> + % if buefy_0_8: <template slot-scope="props"> + % endif <b-table-column field="key" - label="Watcher"> + label="Watcher" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.key }} </b-table-column> <b-table-column field="spec" - label="Spec"> + label="Spec" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.spec }} </b-table-column> <b-table-column field="dbkey" - label="DB Key"> + label="DB Key" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.dbkey }} </b-table-column> <b-table-column field="delay" - label="Delay"> + label="Delay" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.delay }} second(s) </b-table-column> <b-table-column field="lastrun" - label="Last Watched"> + label="Last Watched" + % if not buefy_0_8: + v-slot="props" + % endif + > <span v-html="props.row.lastrun"></span> </b-table-column> <b-table-column field="status" label="Status" - :class="props.row.status == 'okay' ? 'has-background-success' : 'has-background-warning'"> - {{ props.row.status }} + % if not buefy_0_8: + v-slot="props" + % endif + > + <span :class="props.row.status == 'okay' ? 'has-background-success' : 'has-background-warning'"> + {{ props.row.status }} + </span> </b-table-column> + % if buefy_0_8: </template> + % endif </b-table> </b-field> <b-field label="Consumer Status"> <b-table :data="consumers"> + % if buefy_0_8: <template slot-scope="props"> + % endif <b-table-column field="key" - label="Consumer"> + label="Consumer" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.key }} </b-table-column> <b-table-column field="spec" - label="Spec"> + label="Spec" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.spec }} </b-table-column> <b-table-column field="dbkey" - label="DB Key"> + label="DB Key" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.dbkey }} </b-table-column> <b-table-column field="delay" - label="Delay"> + label="Delay" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.delay }} second(s) </b-table-column> <b-table-column field="changes" - label="Pending Changes"> + label="Pending Changes" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.changes }} </b-table-column> <b-table-column field="status" label="Status" - :class="props.row.status == 'okay' ? 'has-background-success' : 'has-background-warning'"> - {{ props.row.status }} + % if not buefy_0_8: + v-slot="props" + % endif + > + <span :class="props.row.status == 'okay' ? 'has-background-success' : 'has-background-warning'"> + {{ props.row.status }} + </span> </b-table-column> + % if buefy_0_8: </template> + % endif </b-table> </b-field> </%def> diff --git a/tailbone/templates/generate_feature.mako b/tailbone/templates/generate_feature.mako index acd1db2f..005cc3c2 100644 --- a/tailbone/templates/generate_feature.mako +++ b/tailbone/templates/generate_feature.mako @@ -123,25 +123,52 @@ <b-table :data="new_table.columns"> + % if buefy_0_8: <template slot-scope="props"> + % endif - <b-table-column field="name" label="Name"> + <b-table-column field="name" + label="Name" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.name }} </b-table-column> - <b-table-column field="data_type" label="Data Type"> + <b-table-column field="data_type" + label="Data Type" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.data_type }} </b-table-column> - <b-table-column field="nullable" label="Nullable"> + <b-table-column field="nullable" + label="Nullable" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.nullable }} </b-table-column> - <b-table-column field="description" label="Description"> + <b-table-column field="description" + label="Description" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.description }} </b-table-column> - <b-table-column field="actions" label="Actions"> + <b-table-column field="actions" + label="Actions" + % if not buefy_0_8: + v-slot="props" + % endif + > <a href="#" class="grid-action" @click.prevent="editColumnRow(props.row)"> <i class="fas fa-edit"></i> @@ -157,7 +184,9 @@ </b-table-column> + % if buefy_0_8: </template> + % endif </b-table> <b-modal has-modal-card diff --git a/tailbone/templates/grids/b-table.mako b/tailbone/templates/grids/b-table.mako index 26e86359..d8d81f6d 100644 --- a/tailbone/templates/grids/b-table.mako +++ b/tailbone/templates/grids/b-table.mako @@ -20,7 +20,9 @@ % endif > + % if buefy_0_8: <template slot-scope="props"> + % endif % for i, column in enumerate(grid_columns): <b-table-column field="${column['field']}" % if not empty_labels: @@ -28,6 +30,9 @@ % elif i > 0: label=" " % endif + % if not buefy_0_8: + v-slot="props" + % endif ${'sortable' if column['sortable'] else ''}> % if empty_labels and i == 0: <template slot="header" slot-scope="{ column }"></template> @@ -54,7 +59,12 @@ % endfor % if grid.main_actions or grid.more_actions: - <b-table-column field="actions" label="Actions"> + <b-table-column field="actions" + label="Actions" + % if not buefy_0_8: + v-slot="props" + % endif + > % for action in grid.main_actions: <a :href="props.row._action_url_${action.key}" % if action.link_class: @@ -73,7 +83,9 @@ % endfor </b-table-column> % endif + % if buefy_0_8: </template> + % endif <template slot="empty"> <div class="content has-text-grey has-text-centered"> diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako index 10c52389..12231606 100644 --- a/tailbone/templates/grids/buefy.mako +++ b/tailbone/templates/grids/buefy.mako @@ -218,10 +218,15 @@ :hoverable="true" :narrowed="true"> + % if buefy_0_8: <template slot-scope="props"> + % endif % for column in grid_columns: <b-table-column field="${column['field']}" label="${column['label']}" + % if not buefy_0_8: + v-slot="props" + % endif :sortable="${json.dumps(column['sortable'])}" % if grid.is_searchable(column['field']): searchable @@ -242,7 +247,12 @@ % endfor % if grid.main_actions or grid.more_actions: - <b-table-column field="actions" label="Actions"> + <b-table-column field="actions" + label="Actions" + % if not buefy_0_8: + v-slot="props" + % endif + > ## TODO: we do not currently differentiate for "main vs. more" ## here, but ideally we would tuck "more" away in a drawer etc. % for action in grid.main_actions + grid.more_actions: @@ -260,7 +270,9 @@ % endfor </b-table-column> % endif + % if buefy_0_8: </template> + % endif <template #empty> <section class="section"> diff --git a/tailbone/templates/importing/configure.mako b/tailbone/templates/importing/configure.mako index cbe8463c..398d4939 100644 --- a/tailbone/templates/importing/configure.mako +++ b/tailbone/templates/importing/configure.mako @@ -10,33 +10,71 @@ narrowed icon-pack="fas" :default-sort="['host_title', 'asc']"> + % if buefy_0_8: <template slot-scope="props"> - <b-table-column field="host_title" label="Data Source" sortable> + % endif + <b-table-column field="host_title" + label="Data Source" + % if not buefy_0_8: + v-slot="props" + % endif + sortable> {{ props.row.host_title }} </b-table-column> - <b-table-column field="local_title" label="Data Target" sortable> + <b-table-column field="local_title" + label="Data Target" + % if not buefy_0_8: + v-slot="props" + % endif + sortable> {{ props.row.local_title }} </b-table-column> - <b-table-column field="direction" label="Direction" sortable> + <b-table-column field="direction" + label="Direction" + % if not buefy_0_8: + v-slot="props" + % endif + sortable> {{ props.row.direction_display }} </b-table-column> - <b-table-column field="handler_spec" label="Handler Spec" sortable> + <b-table-column field="handler_spec" + label="Handler Spec" + % if not buefy_0_8: + v-slot="props" + % endif + sortable> {{ props.row.handler_spec }} </b-table-column> - <b-table-column field="cmd" label="Command" sortable> + <b-table-column field="cmd" + label="Command" + % if not buefy_0_8: + v-slot="props" + % endif + sortable> {{ props.row.command }} {{ props.row.subcommand }} </b-table-column> - <b-table-column field="runas" label="Default Runas" sortable> + <b-table-column field="runas" + label="Default Runas" + % if not buefy_0_8: + v-slot="props" + % endif + sortable> {{ props.row.default_runas }} </b-table-column> - <b-table-column label="Actions"> + <b-table-column label="Actions" + % if not buefy_0_8: + v-slot="props" + % endif + > <a href="#" class="grid-action" @click.prevent="editHandler(props.row)"> <i class="fas fa-edit"></i> Edit </a> </b-table-column> + % if buefy_0_8: </template> + % endif <template slot="empty"> <section class="section"> <div class="content has-text-grey has-text-centered"> diff --git a/tailbone/templates/luigi/configure.mako b/tailbone/templates/luigi/configure.mako index bac57b75..05f2981e 100644 --- a/tailbone/templates/luigi/configure.mako +++ b/tailbone/templates/luigi/configure.mako @@ -23,29 +23,51 @@ <div class="block" style="padding-left: 2rem; display: flex;"> <b-table :data="overnightTasks"> + % if buefy_0_8: <template slot-scope="props"> + % endif <!-- <b-table-column field="key" --> <!-- label="Key" --> <!-- sortable> --> <!-- {{ props.row.key }} --> <!-- </b-table-column> --> <b-table-column field="key" - label="Key"> + label="Key" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.key }} </b-table-column> <b-table-column field="description" - label="Description"> + label="Description" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.description }} </b-table-column> <b-table-column field="class_name" - label="Class Name"> + label="Class Name" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.class_name }} </b-table-column> <b-table-column field="script" - label="Script"> + label="Script" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.script }} </b-table-column> - <b-table-column label="Actions"> + <b-table-column label="Actions" + % if not buefy_0_8: + v-slot="props" + % endif + > <a href="#" @click.prevent="overnightTaskEdit(props.row)"> <i class="fas fa-edit"></i> @@ -59,7 +81,9 @@ Delete </a> </b-table-column> + % if buefy_0_8: </template> + % endif </b-table> <b-modal has-modal-card @@ -137,28 +161,54 @@ <div class="block" style="padding-left: 2rem; display: flex;"> <b-table :data="backfillTasks"> + % if buefy_0_8: <template slot-scope="props"> + % endif <b-table-column field="key" - label="Key"> + label="Key" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.key }} </b-table-column> <b-table-column field="description" - label="Description"> + label="Description" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.description }} </b-table-column> <b-table-column field="script" - label="Script"> + label="Script" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.script }} </b-table-column> <b-table-column field="forward" - label="Orientation"> + label="Orientation" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.forward ? "Forward" : "Backward" }} </b-table-column> <b-table-column field="target_date" - label="Target Date"> + label="Target Date" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.target_date }} </b-table-column> - <b-table-column label="Actions"> + <b-table-column label="Actions" + % if not buefy_0_8: + v-slot="props" + % endif + > <a href="#" @click.prevent="backfillTaskEdit(props.row)"> <i class="fas fa-edit"></i> @@ -172,7 +222,9 @@ Delete </a> </b-table-column> + % if buefy_0_8: </template> + % endif </b-table> <b-modal has-modal-card diff --git a/tailbone/templates/luigi/index.mako b/tailbone/templates/luigi/index.mako index 6faade8d..3e047f83 100644 --- a/tailbone/templates/luigi/index.mako +++ b/tailbone/templates/luigi/index.mako @@ -49,26 +49,45 @@ % endif </div> - % if master.has_perm('launch_overnight'): + % if master.has_perm('launch_overnight'): <h3 class="block is-size-3">Overnight Tasks</h3> <b-table :data="overnightTasks" hoverable> + % if buefy_0_8: <template slot-scope="props"> + % endif <b-table-column field="description" - label="Description"> + label="Description" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.description }} </b-table-column> <b-table-column field="script" - label="Command"> + label="Command" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.script || props.row.class_name }} </b-table-column> <b-table-column field="last_date" label="Last Date" - :class="overnightTextClass(props.row)"> - {{ props.row.last_date || "never!" }} + % if not buefy_0_8: + v-slot="props" + % endif + > + <span :class="overnightTextClass(props.row)"> + {{ props.row.last_date || "never!" }} + </span> </b-table-column> - <b-table-column label="Actions"> + <b-table-column label="Actions" + % if not buefy_0_8: + v-slot="props" + % endif + > <b-button type="is-primary" icon-pack="fas" icon-left="arrow-circle-right" @@ -125,7 +144,9 @@ </div> </b-modal> </b-table-column> + % if buefy_0_8: </template> + % endif <template #empty> <p class="block">No tasks defined.</p> </template> @@ -138,29 +159,56 @@ <h3 class="block is-size-3">Backfill Tasks</h3> <b-table :data="backfillTasks" hoverable> + % if buefy_0_8: <template slot-scope="props"> + % endif <b-table-column field="description" - label="Description"> + label="Description" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.description }} </b-table-column> <b-table-column field="script" - label="Script"> + label="Script" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.script }} </b-table-column> <b-table-column field="forward" - label="Orientation"> + label="Orientation" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.forward ? "Forward" : "Backward" }} </b-table-column> <b-table-column field="last_date" label="Last Date" - :class="backfillTextClass(props.row)"> - {{ props.row.last_date }} + % if not buefy_0_8: + v-slot="props" + % endif + > + <span :class="backfillTextClass(props.row)"> + {{ props.row.last_date }} + </span> </b-table-column> <b-table-column field="target_date" - label="Target Date"> + label="Target Date" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.target_date }} </b-table-column> - <b-table-column label="Actions"> + <b-table-column label="Actions" + % if not buefy_0_8: + v-slot="props" + % endif + > <b-button type="is-primary" icon-pack="fas" icon-left="arrow-circle-right" @@ -168,7 +216,9 @@ Launch </b-button> </b-table-column> + % if buefy_0_8: </template> + % endif <template #empty> <p class="block">No tasks defined.</p> </template> diff --git a/tailbone/templates/people/index.mako b/tailbone/templates/people/index.mako index 377063b8..8ddc3f52 100644 --- a/tailbone/templates/people/index.mako +++ b/tailbone/templates/people/index.mako @@ -22,20 +22,36 @@ <section class="modal-card-body"> <b-table :data="mergeRequestRows" striped hoverable> + % if buefy_0_8: <template slot-scope="props"> + % endif <b-table-column field="customer_number" - label="Customer #"> + label="Customer #" + % if not buefy_0_8: + v-slot="props" + % endif + > <span v-html="props.row.customer_number"></span> </b-table-column> <b-table-column field="first_name" - label="First Name"> + label="First Name" + % if not buefy_0_8: + v-slot="props" + % endif + > <span v-html="props.row.first_name"></span> </b-table-column> <b-table-column field="last_name" - label="Last Name"> + label="Last Name" + % if not buefy_0_8: + v-slot="props" + % endif + > <span v-html="props.row.last_name"></span> </b-table-column> + % if buefy_0_8: </template> + % endif </b-table> </section> diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako index 89cafb6a..abb99b09 100644 --- a/tailbone/templates/people/view_profile_buefy.mako +++ b/tailbone/templates/people/view_profile_buefy.mako @@ -296,22 +296,43 @@ % endif <b-table :data="person.phones"> + % if buefy_0_8: <template slot-scope="props"> + % endif - <b-table-column field="preference" label="Preferred"> + <b-table-column field="preference" + label="Preferred" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.preferred ? "Yes" : "" }} </b-table-column> - <b-table-column field="type" label="Type"> + <b-table-column field="type" + label="Type" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.type }} </b-table-column> - <b-table-column field="number" label="Number"> + <b-table-column field="number" + label="Number" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.number }} </b-table-column> % if request.has_perm('people_profile.edit_person'): - <b-table-column label="Actions"> + <b-table-column label="Actions" + % if not buefy_0_8: + v-slot="props" + % endif + > <a href="#" @click.prevent="editPhoneInit(props.row)"> <i class="fas fa-edit"></i> Edit @@ -329,7 +350,9 @@ </b-table-column> % endif + % if buefy_0_8: </template> + % endif </b-table> </div> @@ -417,26 +440,52 @@ % endif <b-table :data="person.emails"> + % if buefy_0_8: <template slot-scope="props"> + % endif - <b-table-column field="preference" label="Preferred"> + <b-table-column field="preference" + label="Preferred" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.preferred ? "Yes" : "" }} </b-table-column> - <b-table-column field="type" label="Type"> + <b-table-column field="type" + label="Type" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.type }} </b-table-column> - <b-table-column field="address" label="Address"> + <b-table-column field="address" + label="Address" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.address }} </b-table-column> - <b-table-column field="invalid" label="Invalid"> + <b-table-column field="invalid" + label="Invalid" + % if not buefy_0_8: + v-slot="props" + % endif + > <span v-if="props.row.invalid" class="has-text-danger">Yes</span> </b-table-column> % if request.has_perm('people_profile.edit_person'): - <b-table-column label="Actions"> + <b-table-column label="Actions" + % if not buefy_0_8: + v-slot="props" + % endif + > <a href="#" @click.prevent="editEmailInit(props.row)"> <i class="fas fa-edit"></i> Edit @@ -454,7 +503,9 @@ </b-table-column> % endif + % if buefy_0_8: </template> + % endif </b-table> </div> @@ -752,18 +803,35 @@ <br /> <b-table :data="employeeHistory"> + % if buefy_0_8: <template slot-scope="props"> + % endif - <b-table-column field="start_date" label="Start Date"> + <b-table-column field="start_date" + label="Start Date" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.start_date }} </b-table-column> - <b-table-column field="end_date" label="End Date"> + <b-table-column field="end_date" + label="End Date" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.end_date }} </b-table-column> % if request.has_perm('people_profile.edit_employee_history'): - <b-table-column field="actions" label="Actions"> + <b-table-column field="actions" + label="Actions" + % if not buefy_0_8: + v-slot="props" + % endif + > <a href="#" @click.prevent="editEmployeeHistory(props.row)"> <i class="fas fa-edit"></i> Edit @@ -771,7 +839,9 @@ </b-table-column> % endif + % if buefy_0_8: </template> + % endif </b-table> </div> diff --git a/tailbone/templates/products/lookup.mako b/tailbone/templates/products/lookup.mako index 10620749..299d938d 100644 --- a/tailbone/templates/products/lookup.mako +++ b/tailbone/templates/products/lookup.mako @@ -52,54 +52,92 @@ icon-pack="fas" :loading="searchResultsLoading" :selected.sync="searchResultSelected"> + % if buefy_0_8: <template slot-scope="props"> + % endif <b-table-column label="${request.rattail_config.product_key_title()}" - field="product_key"> + field="product_key" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.product_key }} </b-table-column> <b-table-column label="Brand" - field="brand_name"> + field="brand_name" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.brand_name }} </b-table-column> <b-table-column label="Description" - field="description"> + field="description" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.description }} {{ props.row.size }} </b-table-column> <b-table-column label="Unit Price" - field="unit_price"> + field="unit_price" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.unit_price_display }} </b-table-column> <b-table-column label="Sale Price" - field="sale_price"> + field="sale_price" + % if not buefy_0_8: + v-slot="props" + % endif + > <span class="has-background-warning"> {{ props.row.sale_price_display }} </span> </b-table-column> <b-table-column label="Sale Ends" - field="sale_ends"> + field="sale_ends" + % if not buefy_0_8: + v-slot="props" + % endif + > <span class="has-background-warning"> {{ props.row.sale_ends_display }} </span> </b-table-column> <b-table-column label="Department" - field="department_name"> + field="department_name" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.department_name }} </b-table-column> <b-table-column label="Vendor" - field="vendor_name"> + field="vendor_name" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.vendor_name }} </b-table-column> - <b-table-column label="Actions"> + <b-table-column label="Actions" + % if not buefy_0_8: + v-slot="props" + % endif + > <a :href="props.row.url" target="_blank" class="grid-action"> @@ -108,7 +146,9 @@ </a> </b-table-column> + % if buefy_0_8: </template> + % endif <template slot="empty"> <div class="content has-text-grey has-text-centered"> <p> diff --git a/tailbone/templates/trainwreck/transactions/rollover.mako b/tailbone/templates/trainwreck/transactions/rollover.mako index 6d6e0b17..f654a40d 100644 --- a/tailbone/templates/trainwreck/transactions/rollover.mako +++ b/tailbone/templates/trainwreck/transactions/rollover.mako @@ -20,11 +20,23 @@ </p> <b-table :data="engines"> + % if buefy_0_8: <template slot-scope="props"> - <b-table-column field="key" label="DB Key"> + % endif + <b-table-column field="key" + label="DB Key" + % if not buefy_0_8: + v-slot="props" + % endif + > {{ props.row.key }} </b-table-column> - <b-table-column field="oldest_date" label="Oldest Date"> + <b-table-column field="oldest_date" + label="Oldest Date" + % if not buefy_0_8: + v-slot="props" + % endif + > <span v-if="props.row.error" class="has-text-danger"> error </span> @@ -32,7 +44,12 @@ {{ props.row.oldest_date }} </span> </b-table-column> - <b-table-column field="newest_date" label="Newest Date"> + <b-table-column field="newest_date" + label="Newest Date" + % if not buefy_0_8: + v-slot="props" + % endif + > <span v-if="props.row.error" class="has-text-danger"> error </span> @@ -40,7 +57,9 @@ {{ props.row.newest_date }} </span> </b-table-column> + % if buefy_0_8: </template> + % endif </b-table> </%def> diff --git a/tailbone/templates/upgrades/configure.mako b/tailbone/templates/upgrades/configure.mako index cde81b9e..5d516cc8 100644 --- a/tailbone/templates/upgrades/configure.mako +++ b/tailbone/templates/upgrades/configure.mako @@ -9,23 +9,38 @@ <b-table :data="upgradeSystems" sortable> + % if buefy_0_8: <template slot-scope="props"> + % endif <b-table-column field="key" label="Key" + % if not buefy_0_8: + v-slot="props" + % endif sortable> {{ props.row.key }} </b-table-column> <b-table-column field="label" label="Label" + % if not buefy_0_8: + v-slot="props" + % endif sortable> {{ props.row.label }} </b-table-column> <b-table-column field="command" label="Command" + % if not buefy_0_8: + v-slot="props" + % endif sortable> {{ props.row.command }} </b-table-column> - <b-table-column label="Actions"> + <b-table-column label="Actions" + % if not buefy_0_8: + v-slot="props" + % endif + > <a href="#" @click.prevent="upgradeSystemEdit(props.row)"> <i class="fas fa-edit"></i> @@ -40,7 +55,9 @@ Delete </a> </b-table-column> + % if buefy_0_8: </template> + % endif </b-table> <div style="margin-left: 1rem;"> From 8a6fdb5ea58e3b43ba6d60b00665378a9b5216ee Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 23 Dec 2022 18:55:53 -0600 Subject: [PATCH 0910/1681] Warn user when luigi is not installed, for relevant view better than getting a server error --- tailbone/views/luigi.py | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/tailbone/views/luigi.py b/tailbone/views/luigi.py index aaa7e2be..d340bfee 100644 --- a/tailbone/views/luigi.py +++ b/tailbone/views/luigi.py @@ -62,9 +62,22 @@ class LuigiTaskView(MasterView): def __init__(self, request, context=None): super(LuigiTaskView, self).__init__(request, context=context) app = self.get_rattail_app() - self.luigi_handler = app.get_luigi_handler() + + # nb. luigi may not be installed, which (for now) may prevent + # us from getting our handler; in which case warn user + try: + self.luigi_handler = app.get_luigi_handler() + except Exception as error: + self.luigi_handler = None + self.luigi_handler_error = error + log.warning("could not get luigi handler", exc_info=True) def index(self): + + if not self.luigi_handler: + self.request.session.flash("Could not create handler: {}".format( + simple_error(self.luigi_handler_error)), 'error') + 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', { @@ -147,14 +160,20 @@ class LuigiTaskView(MasterView): return context def get_overnight_tasks(self): - tasks = self.luigi_handler.get_all_overnight_tasks() + if self.luigi_handler: + tasks = self.luigi_handler.get_all_overnight_tasks() + else: + 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() + if self.luigi_handler: + tasks = self.luigi_handler.get_all_backfill_tasks() + else: + tasks = [] for task in tasks: if task['last_date']: task['last_date'] = six.text_type(task['last_date']) From c5bd40793b6aa660c0b1517d43889bff7d8364a6 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 23 Dec 2022 19:06:05 -0600 Subject: [PATCH 0911/1681] Fix HUD display when toggling employee status in profile view --- tailbone/views/people.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index d5330076..6ae184f9 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -906,12 +906,14 @@ class PersonView(MasterView): return self.profile_start_employee_result(employee, start_date) def profile_start_employee_result(self, employee, start_date): + person = employee.person return { 'success': True, 'employee': self.get_context_employee(employee), 'employee_view_url': self.request.route_url('employees.view', uuid=employee.uuid), 'start_date': six.text_type(start_date), 'employee_history_data': self.get_context_employee_history(employee), + 'dynamic_content_title': self.get_context_content_title(person), } def profile_end_employee(self): @@ -935,12 +937,14 @@ class PersonView(MasterView): return self.profile_end_employee_result(employee, end_date) def profile_end_employee_result(self, employee, end_date): + person = employee.person return { 'success': True, 'employee': self.get_context_employee(employee), 'employee_view_url': self.request.route_url('employees.view', uuid=employee.uuid), 'end_date': six.text_type(end_date), 'employee_history_data': self.get_context_employee_history(employee), + 'dynamic_content_title': self.get_context_content_title(person), } def profile_edit_employee_history(self): From 64c876831486c1696453e0fe3c073bb3761ba713 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 23 Dec 2022 19:43:31 -0600 Subject: [PATCH 0912/1681] Fix checkbox values when re-running a report --- tailbone/views/reports.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index 640dc6a9..101c541b 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -461,6 +461,8 @@ class ReportOutputView(ExportMasterView): value = self.request.GET[param.name] if param.type is datetime.date: value = app.parse_date(value) + elif param.type is bool: + value = self.rattail_config.parse_bool(value) form.set_default(param.name, value) # if form validates, start generating new report output; show progress page From d409e1d0881c4f11beb4741d729a63dd5c732576 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 23 Dec 2022 20:18:49 -0600 Subject: [PATCH 0913/1681] Make static files optional, for new tailbone-integration project --- tailbone/templates/generate_project.mako | 19 +++++++++++++++++++ tailbone/views/projects.py | 2 ++ 2 files changed, 21 insertions(+) diff --git a/tailbone/templates/generate_project.mako b/tailbone/templates/generate_project.mako index 72caa83c..f2b67cb3 100644 --- a/tailbone/templates/generate_project.mako +++ b/tailbone/templates/generate_project.mako @@ -287,6 +287,25 @@ </div> </div> </div> + <br /> + <div class="card"> + <header class="card-header"> + <p class="card-header-title">Options</p> + </header> + <div class="card-content"> + <div class="content"> + + <b-field horizontal label="Has Static Files" + message="Register a subfolder for static files (images etc.)"> + <b-checkbox name="has_static_files" + v-model="tailbone_integration.has_static_files" + native-value="true"> + </b-checkbox> + </b-field> + + </div> + </div> + </div> ${h.end_form()} </div> diff --git a/tailbone/views/projects.py b/tailbone/views/projects.py index 9a6633f4..bc768d05 100644 --- a/tailbone/views/projects.py +++ b/tailbone/views/projects.py @@ -114,6 +114,8 @@ class GenerateTailboneIntegrationProject(colander.MappingSchema): python_name = colander.SchemaNode(colander.String()) + has_static_files = colander.SchemaNode(colander.Boolean()) + class GenerateByjoveProject(colander.MappingSchema): """ From 50dafc91d4c02c935e2d6e09b33cead3e4289d48 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 23 Dec 2022 20:58:27 -0600 Subject: [PATCH 0914/1681] Preserve current tab for page reload in profile view also makes sharing links better etc. --- .../templates/people/view_profile_buefy.mako | 41 ++++++++++++++++--- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako index abb99b09..93f3ea09 100644 --- a/tailbone/templates/people/view_profile_buefy.mako +++ b/tailbone/templates/people/view_profile_buefy.mako @@ -540,6 +540,7 @@ <%def name="render_personal_tab()"> <b-tab-item label="Personal" + value="personal" icon-pack="fas" icon="check"> <personal-tab :person="person" @@ -554,7 +555,10 @@ </%def> <%def name="render_member_tab()"> - <b-tab-item label="Member" icon-pack="fas" :icon="members.length ? 'check' : null"> + <b-tab-item label="Member" + value="member" + icon-pack="fas" + :icon="members.length ? 'check' : null"> <div v-if="members.length"> @@ -641,7 +645,10 @@ </%def> <%def name="render_customer_tab()"> - <b-tab-item label="Customer" icon-pack="fas" :icon="customers.length ? 'check' : null"> + <b-tab-item label="Customer" + value="customer" + icon-pack="fas" + :icon="customers.length ? 'check' : null"> <div v-if="customers.length"> @@ -983,6 +990,7 @@ <%def name="render_employee_tab()"> <b-tab-item label="Employee" + value="employee" icon-pack="fas" :icon="employee.current ? 'check' : null"> <employee-tab :employee="employee" @@ -995,7 +1003,9 @@ </%def> <%def name="render_user_tab()"> - <b-tab-item label="User" ${'icon="check" icon-pack="fas"' if person.users else ''|n}> + <b-tab-item label="User" + value="user" + ${'icon="check" icon-pack="fas"' if person.users else ''|n}> % if person.users: <p>${person} is associated with <strong>${len(person.users)}</strong> user account(s)</p> <br /> @@ -1064,7 +1074,9 @@ <%def name="render_profile_info_template()"> <script type="text/x-template" id="profile-info-template"> <div> - <b-tabs v-model="activeTab" type="is-boxed"> + <b-tabs v-model="activeTab" + type="is-boxed" + @input="activeTabChanged"> ${self.render_profile_tabs()} </b-tabs> </div> @@ -1641,7 +1653,11 @@ <script type="text/javascript"> let ProfileInfoData = { - activeTab: 0, + % if buefy_0_8: + activeTab: location.hash ? parseInt(location.hash.substring(1)) : undefined, + % else: + activeTab: location.hash ? location.hash.substring(1) : undefined, + % endif person: ${json.dumps(person_data)|n}, customers: ${json.dumps(customers_data)|n}, member: null, // TODO @@ -1658,18 +1674,33 @@ mixins: [FormPosterMixin], computed: {}, methods: { + personUpdated(person) { this.person = person }, + employeeUpdated(employee) { this.employee = employee }, + employeeHistoryUpdated(employeeHistory) { this.employeeHistory = employeeHistory }, + changeContentTitle(newTitle) { this.$emit('change-content-title', newTitle) }, + + activeTabChanged(value) { + % if buefy_0_8: + location.hash = value.toString() + % else: + location.hash = value + % endif + this.activeTabChangedExtra(value) + }, + + activeTabChangedExtra(value) {}, }, } From ed54092268a1d21019c4fae40e6a7b00beb5c887 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 23 Dec 2022 23:30:45 -0600 Subject: [PATCH 0915/1681] Add cleanup logic for old Beaker session data pretty basic, but good enough for now --- setup.py | 4 +++ tailbone/cleanup.py | 80 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 tailbone/cleanup.py diff --git a/setup.py b/setup.py index 3328785e..70f7cd56 100644 --- a/setup.py +++ b/setup.py @@ -179,6 +179,10 @@ setup( 'webapi = tailbone.webapi:main', ], + 'rattail.cleaners': [ + 'beaker = tailbone.cleanup:BeakerCleaner', + ], + 'rattail.config.extensions': [ 'tailbone = tailbone.config:ConfigExtension', ], diff --git a/tailbone/cleanup.py b/tailbone/cleanup.py new file mode 100644 index 00000000..0ed5d026 --- /dev/null +++ b/tailbone/cleanup.py @@ -0,0 +1,80 @@ +# -*- 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 <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Cleanup logic +""" + +from __future__ import unicode_literals, absolute_import + +import os +import logging +import time + +from rattail.cleanup import Cleaner + + +log = logging.getLogger(__name__) + + +class BeakerCleaner(Cleaner): + """ + Cleanup logic for old Beaker session files. + """ + + def get_session_dir(self): + session_dir = self.config.get('rattail.cleanup', 'beaker.session_dir') + if session_dir and os.path.isdir(session_dir): + return session_dir + + session_dir = os.path.join(self.config.appdir(), 'sessions') + if os.path.isdir(session_dir): + return session_dir + + def cleanup(self, session, dry_run=False, progress=None, **kwargs): + session_dir = self.get_session_dir() + if not session_dir: + return + + data_dir = os.path.join(session_dir, 'data') + lock_dir = os.path.join(session_dir, 'lock') + + # looking for files older than X days + days = self.config.getint('rattail.cleanup', + 'beaker.session_cutoff_days', + default=30) + cutoff = time.time() - 3600 * 24 * days + + for topdir in (data_dir, lock_dir): + if not os.path.isdir(topdir): + continue + + for dirpath, dirnames, filenames in os.walk(topdir): + for fname in filenames: + path = os.path.join(dirpath, fname) + ts = os.path.getmtime(path) + if ts <= cutoff: + if dry_run: + log.debug("would delete file: %s", path) + else: + os.remove(path) + log.debug("deleted file: %s", path) From 9fe9983bf9e00284c041c725fd37af7c2ffd16b7 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 24 Dec 2022 14:56:12 -0600 Subject: [PATCH 0916/1681] Add basic support for editing page help info site admin should be able to point help wherever they want --- tailbone/helpers.py | 3 +- tailbone/static/themes/falafel/css/layout.css | 13 ++ tailbone/templates/generate_feature.mako | 17 +- tailbone/templates/page_help.mako | 204 ++++++++++++++++++ tailbone/templates/themes/falafel/base.mako | 20 +- tailbone/util.py | 12 ++ tailbone/views/common.py | 2 + tailbone/views/master.py | 72 +++++++ 8 files changed, 315 insertions(+), 28 deletions(-) create mode 100644 tailbone/templates/page_help.mako diff --git a/tailbone/helpers.py b/tailbone/helpers.py index a3d07f79..750d3f39 100644 --- a/tailbone/helpers.py +++ b/tailbone/helpers.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. # @@ -40,6 +40,7 @@ from webhelpers2.html.tags import * from tailbone.util import (csrf_token, get_csrf_token, pretty_datetime, raw_datetime, + render_markdown, route_exists) diff --git a/tailbone/static/themes/falafel/css/layout.css b/tailbone/static/themes/falafel/css/layout.css index b0fd0cc9..cc4d0015 100644 --- a/tailbone/static/themes/falafel/css/layout.css +++ b/tailbone/static/themes/falafel/css/layout.css @@ -112,6 +112,19 @@ header .level .theme-picker { margin-top: 1em; } +/****************************** + * markdown + ******************************/ + +.rendered-markdown p, +.rendered-markdown ul { + margin-bottom: 1rem; +} + +.rendered-markdown .codehilite { + margin-bottom: 2rem; +} + /****************************** * fix datepicker within modals * TODO: someday this may not be necessary? cf. diff --git a/tailbone/templates/generate_feature.mako b/tailbone/templates/generate_feature.mako index 005cc3c2..10d3d265 100644 --- a/tailbone/templates/generate_feature.mako +++ b/tailbone/templates/generate_feature.mako @@ -5,21 +5,6 @@ <%def name="content_title()"></%def> -<%def name="extra_styles()"> - ${parent.extra_styles()} - <style type="text/css"> - - .content.result p { - margin-bottom: 1rem; - } - - .content.result .codehilite { - margin-bottom: 2rem; - } - - </style> -</%def> - <%def name="page_content()"> <b-field horizontal label="App Prefix" @@ -290,7 +275,7 @@ </p> </header> <div class="card-content"> - <div class="content result">${rendered_result or ""|n}</div> + <div class="content result rendered-markdown">${rendered_result or ""|n}</div> </div> </div> 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()"> + <script type="text/x-template" id="page-help-template"> + <div> + + % if help_url or help_markdown: + + <b-field> + <p class="control"> + <b-button icon-pack="fas" + icon-left="question-circle" + % if help_markdown: + @click="displayInit()" + % elif help_url: + tag="a" href="${help_url}" + target="_blank" + % endif + > + Help + </b-button> + </p> + % if can_edit_help: + <p class="control"> + <b-button @click="configureInit()"> + <span><i class="fa fa-cog"></i></span> + </b-button> + </p> + % endif + </b-field> + + % elif can_edit_help: + + <b-field> + <p class="control"> + <b-button @click="configureInit()"> + <span><i class="fa fa-question-circle"></i></span> + <span><i class="fa fa-cog"></i></span> + </b-button> + </p> + </b-field> + + % endif + + % if help_markdown: + <b-modal has-modal-card + :active.sync="displayShowDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">${index_title}</p> + </header> + + <section class="modal-card-body"> + ${h.render_markdown(help_markdown)} + </section> + + <footer class="modal-card-foot"> + % if help_url: + <b-button type="is-primary" + icon-pack="fas" + icon-left="external-link-alt" + tag="a" href="${help_url}" + target="_blank"> + More Info + </b-button> + % endif + <b-button @click="displayShowDialog = false"> + Close + </b-button> + </footer> + </div> + </b-modal> + % endif + + % if can_edit_help: + + <b-modal has-modal-card + :active.sync="configureShowDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Configure Help</p> + </header> + + <section class="modal-card-body"> + + <p class="block"> + This help info applies to all views with the current + route prefix. + </p> + + <b-field grouped> + + <b-field label="Route Prefix"> + <span>${route_prefix}</span> + </b-field> + + <b-field label="URL Prefix"> + <span>${master.get_url_prefix()}</span> + </b-field> + + </b-field> + + <b-field label="Help Link (URL)"> + <b-input v-model="helpURL" + ref="helpURL"> + </b-input> + </b-field> + + <b-field label="Markdown Text"> + <b-input v-model="markdownText" + type="textarea" rows="8"> + </b-input> + </b-field> + + </section> + + <footer class="modal-card-foot"> + <b-button @click="configureShowDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + @click="configureSave()" + :disabled="configureSaving" + icon-pack="fas" + icon-left="save"> + {{ configureSaving ? "Working, please wait..." : "Save" }} + </b-button> + </footer> + </div> + </b-modal> + + % endif + + </div> + </script> +</%def> + +<%def name="declare_vars()"> + <script type="text/javascript"> + + let PageHelp = { + + template: '#page-help-template', + mixins: [FormPosterMixin], + + methods: { + + displayInit() { + this.displayShowDialog = true + }, + + configureInit() { + this.configureShowDialog = true + this.$nextTick(() => { + this.$refs.helpURL.focus() + }) + }, + + % if can_edit_help: + configureSave() { + this.configureSaving = true + let url = '${url('{}.edit_help'.format(route_prefix))}' + let params = { + help_url: this.helpURL, + markdown_text: this.markdownText, + } + this.submitForm(url, params, response => { + this.configureShowDialog = false + this.$buefy.toast.open({ + message: "Info was saved; please refresh page to see changes.", + type: 'is-info', + duration: 4000, // 4 seconds + }) + this.configureSaving = false + }, response => { + this.configureSaving = false + }) + }, + % endif + }, + } + + let PageHelpData = { + displayShowDialog: false, + configureShowDialog: false, + configureSaving: false, + helpURL: ${json.dumps(help_url or None)|n}, + markdownText: ${json.dumps(help_markdown or None)|n}, + } + + </script> +</%def> + +<%def name="make_component()"> + <script type="text/javascript"> + + PageHelp.data = function() { return PageHelpData } + + Vue.component('page-help', PageHelp) + + </script> +</%def> 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" /> <!DOCTYPE html> <html lang="en"> <head> @@ -383,17 +384,9 @@ </div> % endif - ## Help Button - % if help_url is not Undefined and help_url: - <div class="level-item"> - <b-button tag="a" href="${help_url}" - target="_blank" - icon-pack="fas" - icon-left="fas fa-question-circle"> - Help - </b-button> - </div> - % endif + <div class="level-item"> + <page-help></page-help> + </div> ## Feedback Button / Dialog % if request.has_perm('common.feedback'): @@ -466,6 +459,8 @@ </div> </script> + ${page_help.render_template()} + <script type="text/x-template" id="feedback-template"> <div> @@ -736,6 +731,7 @@ </%def> <%def name="declare_whole_page_vars()"> + ${page_help.declare_vars()} ${h.javascript_link(request.static_url('tailbone:static/themes/falafel/js/tailbone.feedback.js') + '?ver={}'.format(tailbone.__version__))} <script type="text/javascript"> @@ -811,6 +807,8 @@ ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.autocomplete.js') + '?ver={}'.format(tailbone.__version__))} + ${page_help.make_component()} + <script type="text/javascript"> FeedbackForm.data = function() { return FeedbackFormData } diff --git a/tailbone/util.py b/tailbone/util.py index 5dee997f..f5457149 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -33,6 +33,8 @@ import pytz import humanize import logging +import markdown + from rattail.time import timezone, make_utc from rattail.files import resource_path @@ -181,6 +183,16 @@ def raw_datetime(config, value, verbose=False, as_date=False): return HTML.tag('span', **kwargs) +def render_markdown(text, **kwargs): + """ + Render the given markdown text as HTML. + """ + kwargs.setdefault('extensions', ['fenced_code', 'codehilite']) + md = markdown.markdown(text, **kwargs) + md = HTML.literal(md) + return HTML.tag('div', class_='rendered-markdown', c=[md]) + + def set_app_theme(request, theme, session=None): """ Set the app theme. This modifies the *global* Mako template lookup diff --git a/tailbone/views/common.py b/tailbone/views/common.py index f531b48e..5e3060f1 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -287,6 +287,8 @@ class CommonView(View): # permissions config.add_tailbone_permission_group('common', "(common)", overwrite=False) + config.add_tailbone_permission('common', 'common.edit_help', + "Edit help info for *any* page") # home config.add_route('home', '/') diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 313d7fd8..396c953e 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2267,6 +2267,16 @@ class MasterView(View): so if you like you can return a different help URL depending on which type of CRUD view is in effect, etc. """ + model = self.model + route_prefix = self.get_route_prefix() + + # nb. self.Session may differ, so use tailbone.db.Session + info = Session.query(model.TailbonePageHelp)\ + .filter(model.TailbonePageHelp.route_prefix == route_prefix)\ + .first() + if info and info.help_url: + return info.help_url + if self.help_url: return self.help_url @@ -2275,6 +2285,54 @@ class MasterView(View): return global_help_url(self.rattail_config) + def get_help_markdown(self): + """ + Return the markdown help text for current page, if defined. + """ + model = self.model + route_prefix = self.get_route_prefix() + + # nb. self.Session may differ, so use tailbone.db.Session + info = Session.query(model.TailbonePageHelp)\ + .filter(model.TailbonePageHelp.route_prefix == route_prefix)\ + .first() + if info and info.markdown_text: + return info.markdown_text + + def edit_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='help_url', + missing=None)) + + 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.TailbonePageHelp)\ + .filter(model.TailbonePageHelp.route_prefix == route_prefix)\ + .first() + if not info: + info = model.TailbonePageHelp(route_prefix=route_prefix) + Session.add(info) + + info.help_url = form.validated['help_url'] + 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. @@ -2296,6 +2354,9 @@ class MasterView(View): 'action_url': self.get_action_url, 'grid_index': self.grid_index, 'help_url': self.get_help_url(), + 'help_markdown': self.get_help_markdown(), + 'can_edit_help': (self.has_perm('edit_help') + or self.request.has_perm('common.edit_help')), 'quickie': None, } @@ -4761,6 +4822,17 @@ class MasterView(View): 'prevent_cache_for_index_views', default=True) + # edit help info + config.add_tailbone_permission(permission_prefix, + '{}.edit_help'.format(permission_prefix), + "Edit help info for {}".format(model_title_plural)) + config.add_route('{}.edit_help'.format(route_prefix), + '{}/edit-help'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='edit_help', + route_name='{}.edit_help'.format(route_prefix), + renderer='json') + # list/search if cls.listable: config.add_tailbone_permission(permission_prefix, '{}.list'.format(permission_prefix), From 3befdc09e377b6ec80c5588183bc5a4729099993 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 24 Dec 2022 21:46:02 -0600 Subject: [PATCH 0917/1681] Add basic support for editing field help info --- tailbone/forms/core.py | 75 ++++++++++++++++++++-- tailbone/templates/form.mako | 19 ++++++ tailbone/templates/forms/deform_buefy.mako | 75 +++++++++++++++++++++- tailbone/templates/page_help.mako | 2 +- tailbone/util.py | 4 +- tailbone/views/master.py | 50 +++++++++++++++ 6 files changed, 218 insertions(+), 7 deletions(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index bf508a6f..f4d5f14f 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -48,7 +48,8 @@ from pyramid_deform import SessionFileUploadTempStore from pyramid.renderers import render from webhelpers2.html import tags, HTML -from tailbone.util import raw_datetime, get_form_data +from tailbone.db import Session +from tailbone.util import raw_datetime, get_form_data, render_markdown from . import types from .widgets import ReadonlyWidget, PlainDateWidget, JQueryDateWidget, JQueryTimeWidget from tailbone.exceptions import TailboneJSONFieldError @@ -337,6 +338,8 @@ class Form(object): hidden={}, widgets={}, defaults={}, validators={}, required={}, helptext={}, focus_spec=None, action_url=None, cancel_url=None, use_buefy=None, component='tailbone-form', vuejs_field_converters={}, + # TODO: ugh this is getting out hand! + can_edit_help=False, edit_help_url=None, route_prefix=None, ): self.fields = None if fields is not None: @@ -375,6 +378,9 @@ class Form(object): self.use_buefy = use_buefy self.component = component self.vuejs_field_converters = vuejs_field_converters or {} + self.can_edit_help = can_edit_help + self.edit_help_url = edit_help_url + self.route_prefix = route_prefix def __iter__(self): return iter(self.fields) @@ -800,6 +806,11 @@ class Form(object): context = kwargs context['form'] = self context['dform'] = dform + context.setdefault('can_edit_help', self.can_edit_help) + if context['can_edit_help']: + context.setdefault('edit_help_url', self.edit_help_url) + context['field_labels'] = self.get_field_labels() + context['field_markdowns'] = self.get_field_markdowns() context.setdefault('form_kwargs', {}) # TODO: deprecate / remove the latter option here if self.auto_disable_save or self.auto_disable: @@ -815,6 +826,22 @@ class Form(object): context['render_field_readonly'] = self.render_field_readonly return render(template, context) + def get_field_labels(self): + return dict([(field, self.get_label(field)) + for field in self]) + + def get_field_markdowns(self): + model = self.request.rattail_config.get_model() + + if not hasattr(self, 'field_markdowns'): + infos = Session.query(model.TailboneFieldInfo)\ + .filter(model.TailboneFieldInfo.route_prefix == self.route_prefix)\ + .all() + self.field_markdowns = dict([(info.field_name, info.markdown_text) + for info in infos]) + + return self.field_markdowns + def get_vuejs_model_value(self, field): """ This method must return "raw" JS which will be assigned as the initial @@ -874,13 +901,14 @@ class Form(object): """ dform = self.make_deform_form() field = dform[fieldname] + label = self.get_label(fieldname) + markdowns = self.get_field_markdowns() if self.field_visible(fieldname): # these attrs will be for the <b-field> (*not* the widget) attrs = { ':horizontal': 'true', - 'label': self.get_label(fieldname), } # add some magic for file input fields @@ -915,11 +943,50 @@ class Form(object): # 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) + # may need a complex label + label_contents = [label] + + # add 'configure' icon if allowed + if self.can_edit_help: + icon = HTML.tag('b-icon', size='is-small', pack='fas', + icon='cog') + icon = HTML.tag('a', title="Configure field", c=[icon], + **{'@click.prevent': "configureFieldInit('{}')".format(fieldname)}) + label_contents.append(HTML.literal(' ')) + label_contents.append(icon) + + # add 'help' icon/tooltip if defined + if markdowns.get(fieldname): + icon = HTML.tag('b-icon', size='is-small', pack='fas', + icon='question-circle') + tooltip = render_markdown(markdowns[fieldname]) + + # nb. must apply hack to get <template #content> as final result + tooltip_template = HTML.tag('template', c=[tooltip], + **{'#content': 1}) + tooltip_template = tooltip_template.replace( + HTML.literal('<template #content="1"'), + HTML.literal('<template #content')) + + tooltip = HTML.tag('b-tooltip', + type='is-white', + size='is-large', + multilined='multilined', + c=[icon, tooltip_template]) + label_contents.append(HTML.literal(' ')) + label_contents.append(tooltip) + + # nb. must apply hack to get <template #label> as final result + label_template = HTML.tag('template', c=label_contents, + **{'#label': 1}) + label_template = label_template.replace( + HTML.literal('<template #label="1"'), + HTML.literal('<template #label')) + # and finally wrap it all in a <b-field> - return HTML.tag('b-field', c=[html], **attrs) + return HTML.tag('b-field', c=[label_template, html], **attrs) else: # hidden field diff --git a/tailbone/templates/form.mako b/tailbone/templates/form.mako index 11d4d6ae..c04dce21 100644 --- a/tailbone/templates/form.mako +++ b/tailbone/templates/form.mako @@ -58,6 +58,25 @@ ${parent.render_this_page_template()} </%def> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + % if can_edit_help: + <script type="text/javascript"> + + ${form.component_studly}.methods.configureFieldInit = function(fieldname) { + this.configureFieldName = fieldname + this.configureFieldLabel = this.fieldLabels[fieldname] + this.configureFieldMarkdown = this.fieldMarkdowns[fieldname] + this.configureFieldShowDialog = true + this.$nextTick(() => { + this.$refs.configureFieldMarkdown.focus() + }) + } + + </script> + % endif +</%def> + <%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: + <b-modal has-modal-card + :active.sync="configureFieldShowDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Field: {{ configureFieldName }}</p> + </header> + + <section class="modal-card-body"> + + <b-field label="Label"> + <b-input v-model="configureFieldLabel" disabled></b-input> + </b-field> + + <b-field label="Help Text (Markdown)"> + <b-input v-model="configureFieldMarkdown" + type="textarea" rows="8" + ref="configureFieldMarkdown"> + </b-input> + </b-field> + + </section> + + <footer class="modal-card-foot"> + <b-button @click="configureFieldShowDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + @click="configureFieldSave()" + :disabled="configureFieldSaving" + icon-pack="fas" + icon-left="save"> + {{ configureFieldSaving ? "Working, please wait..." : "Save" }} + </b-button> + </footer> + </div> + </b-modal> + % endif + </div> </script> @@ -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 @@ </b-input> </b-field> - <b-field label="Markdown Text"> + <b-field label="Help Text (Markdown)"> <b-input v-model="markdownText" type="textarea" rows="8"> </b-input> 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 <lance@edbob.org> Date: Sun, 25 Dec 2022 12:25:55 -0600 Subject: [PATCH 0918/1681] 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 @@ <div class="level-item has-text-centered" style="display: flex; flex-direction: column;"> <p class="block"> - Upgrading (please wait) ... + Upgrading ${app_title} (please wait) ... {{ executeUpgradeComplete ? "DONE!" : "" }} </p> <b-progress size="is-large" @@ -202,6 +202,7 @@ ThisPage.methods.showExecuteDialog = function() { this.upgradeExecuting = true + document.title = "Upgrading ${app_title} ..." this.$nextTick(() => { this.adjustTextoutHeight() }) From cd466a64e53406d98aca0f6e9af8724a398d27f3 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 25 Dec 2022 12:45:23 -0600 Subject: [PATCH 0919/1681] 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 <lance@edbob.org> Date: Sun, 25 Dec 2022 14:41:58 -0600 Subject: [PATCH 0920/1681] 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 @@ </section> </template> - % if grid.pageable: - <template slot="footer"> - <b-field grouped position="is-right" - v-if="firstItem"> - <span class="control"> - showing {{ firstItem.toLocaleString('en') }} - {{ lastItem.toLocaleString('en') }} of {{ total.toLocaleString('en') }} results; - </span> - <b-select v-model="perPage" - size="is-small" - @input="loadAsyncData()"> - % for value in grid.get_pagesize_options(): - <option value="${value}">${value}</option> - % endfor - </b-select> - <span class="control"> - per page - </span> - </b-field> + <template #footer> + <div style="display: flex; justify-content: space-between;"> + + % if grid.expose_direct_link: + <b-button type="is-primary" + size="is-small" + @click="copyDirectLink()" + title="Copy link to clipboard"> + <span><i class="fa fa-share-alt"></i></span> + </b-button> + % else: + <div></div> + % endif + + % if grid.pageable: + <b-field grouped + v-if="firstItem"> + <span class="control"> + showing {{ firstItem.toLocaleString('en') }} - {{ lastItem.toLocaleString('en') }} of {{ total.toLocaleString('en') }} results; + </span> + <b-select v-model="perPage" + size="is-small" + @input="loadAsyncData()"> + % for value in grid.get_pagesize_options(): + <option value="${value}">${value}</option> + % endfor + </b-select> + <span class="control"> + per page + </span> + </b-field> + % endif + + </div> </template> - % endif </b-table> </div> @@ -368,10 +383,24 @@ visibleData() { return this.data }, + + directLink() { + let params = new URLSearchParams(this.getAllParams()) + return `${request.current_route_url(_query=None)}?${'$'}{params}` + }, }, methods: { + copyDirectLink() { + navigator.clipboard.writeText(this.directLink) + this.$buefy.toast.open({ + message: "Link was copied to clipboard", + type: 'is-info', + duration: 2000, // 2 seconds + }) + }, + addRowClass(index, className) { // TODO: this may add duplicated name to class string @@ -388,16 +417,45 @@ return this.rowStatusMap[index] }, + getBasicParams() { + let params = {} + % if grid.sortable: + params.sortkey = this.sortField + params.sortdir = this.sortOrder + % endif + % if grid.pageable: + params.pagesize = this.perPage + params.page = this.currentPage + % endif + return params + }, + + getFilterParams() { + let params = {} + for (var key in this.filters) { + var filter = this.filters[key] + if (filter.active) { + params[key] = filter.value + params[key+'.verb'] = filter.verb + } + } + if (Object.keys(params).length) { + params.filter = true + } + return params + }, + + getAllParams() { + return {...this.getBasicParams(), + ...this.getFilterParams()} + }, + loadAsyncData(params, callback) { if (params === undefined || params === null) { - params = [ - 'partial=true', - `sortkey=${'$'}{this.sortField}`, - `sortdir=${'$'}{this.sortOrder}`, - `pagesize=${'$'}{this.perPage}`, - `page=${'$'}{this.currentPage}` - ].join('&') + params = new URLSearchParams(this.getBasicParams()) + params.append('partial', true) + params = params.toString() } this.loading = true @@ -482,23 +540,27 @@ applyFilters(params) { if (params === undefined) { - params = [] + params = {} } - params.push('partial=true') - params.push('filter=true') + // merge in actual filter params + // cf. https://stackoverflow.com/a/171256 + params = {...params, ...this.getFilterParams()} + // hide inactive filters for (var key in this.filters) { var filter = this.filters[key] - if (filter.active) { - params.push(key + '=' + encodeURIComponent(filter.value)) - params.push(key + '.verb=' + encodeURIComponent(filter.verb)) - } else { + if (!filter.active) { filter.visible = false } } - this.loadAsyncData(params.join('&')) + // set some explicit params + params.partial = true + params.filter = true + + params = new URLSearchParams(params) + this.loadAsyncData(params) }, clearFilters() { @@ -550,8 +612,7 @@ saveDefaults() { // apply current filters as normal, but add special directive - const params = ['save-current-filters-as-defaults=true'] - this.applyFilters(params) + this.applyFilters({'save-current-filters-as-defaults': true}) }, deleteObject(event) { diff --git a/tailbone/templates/grids/filters_buefy.mako b/tailbone/templates/grids/filters_buefy.mako index 914c98d4..3136a15f 100644 --- a/tailbone/templates/grids/filters_buefy.mako +++ b/tailbone/templates/grids/filters_buefy.mako @@ -1,6 +1,6 @@ ## -*- coding: utf-8; -*- -<form action="${form.action_url}" method="GET" v-on:submit.prevent="applyFilters()"> +<form action="${form.action_url}" method="GET" @submit.prevent="applyFilters()"> <grid-filter v-for="key in filtersSequence" :key="key" diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 2431b437..d382918c 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -455,6 +455,10 @@ class MasterView(View): 'clicking_row_checks_box': self.clicking_row_checks_box, 'assume_local_times': self.has_local_times, } + + if self.sortable or self.pageable or self.filterable: + defaults['expose_direct_link'] = True + if 'main_actions' not in kwargs and 'more_actions' not in kwargs: main, more = self.get_grid_actions() defaults['main_actions'] = main From c389ebabd00ac5917a5802c6f3533249c1a9b3af Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 25 Dec 2022 15:13:59 -0600 Subject: [PATCH 0921/1681] Show *correct* system title when upgrading may not be the same as primary app title --- tailbone/templates/upgrades/view.mako | 4 ++-- tailbone/views/upgrades.py | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/upgrades/view.mako b/tailbone/templates/upgrades/view.mako index a5b6445e..61bb2264 100644 --- a/tailbone/templates/upgrades/view.mako +++ b/tailbone/templates/upgrades/view.mako @@ -66,7 +66,7 @@ <div class="level-item has-text-centered" style="display: flex; flex-direction: column;"> <p class="block"> - Upgrading ${app_title} (please wait) ... + Upgrading ${system_title} (please wait) ... {{ executeUpgradeComplete ? "DONE!" : "" }} </p> <b-progress size="is-large" @@ -202,7 +202,7 @@ ThisPage.methods.showExecuteDialog = function() { this.upgradeExecuting = true - document.title = "Upgrading ${app_title} ..." + document.title = "Upgrading ${system_title} ..." this.$nextTick(() => { this.adjustTextoutHeight() }) diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index 0b5e4b87..73b4461b 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.py @@ -149,8 +149,15 @@ class UpgradeView(MasterView): return 'notice' def template_kwargs_view(self, **kwargs): + kwargs = super(UpgradeView, self).template_kwargs_view(**kwargs) upgrade = kwargs['instance'] + kwargs['system_title'] = self.rattail_config.app_title() + if upgrade.system: + system = self.upgrade_handler.get_system(upgrade.system) + if system: + kwargs['system_title'] = system['label'] + kwargs['show_prev_next'] = True kwargs['prev_url'] = None kwargs['next_url'] = None From 0a0b471a039059d4e7ec5051ee10dc131b664577 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 25 Dec 2022 15:37:54 -0600 Subject: [PATCH 0922/1681] Add support for websockets over HTTP in addition to HTTPS --- tailbone/templates/datasync/status.mako | 4 ++-- tailbone/templates/upgrades/view.mako | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tailbone/templates/datasync/status.mako b/tailbone/templates/datasync/status.mako index 11c4aa12..6b9e02a9 100644 --- a/tailbone/templates/datasync/status.mako +++ b/tailbone/templates/datasync/status.mako @@ -200,8 +200,8 @@ ThisPage.mounted = function() { ## TODO: should be a cleaner way to get this url? - let url = '${request.route_url('ws.datasync.status')}' - url = url.replace(/^https?:/, 'wss:') + let url = '${url('ws.datasync.status')}' + url = url.replace(/^http(s?):/, 'ws$1:') this.ws = new WebSocket(url) let that = this diff --git a/tailbone/templates/upgrades/view.mako b/tailbone/templates/upgrades/view.mako index 61bb2264..7d83a332 100644 --- a/tailbone/templates/upgrades/view.mako +++ b/tailbone/templates/upgrades/view.mako @@ -211,8 +211,8 @@ 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:') + let url = '${url('ws.upgrades.execution_progress', _query={'uuid': instance.uuid})}' + url = url.replace(/^http(s?):/, 'ws$1:') this.ws = new WebSocket(url) From b653351f711cad7e4ff78af27e9af2b037ff0640 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 25 Dec 2022 23:05:53 -0600 Subject: [PATCH 0923/1681] Avoid error when no form present --- tailbone/templates/form.mako | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/templates/form.mako b/tailbone/templates/form.mako index c04dce21..cb8afb53 100644 --- a/tailbone/templates/form.mako +++ b/tailbone/templates/form.mako @@ -60,7 +60,7 @@ <%def name="modify_this_page_vars()"> ${parent.modify_this_page_vars()} - % if can_edit_help: + % if can_edit_help and form: <script type="text/javascript"> ${form.component_studly}.methods.configureFieldInit = function(fieldname) { From b985124befb353c22504b5014a79dd5edd006cfa Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 26 Dec 2022 10:33:12 -0600 Subject: [PATCH 0924/1681] Fix product image view for python3 --- tailbone/views/products.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 0e6321fd..a7110660 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -45,7 +45,6 @@ from rattail.time import localtime, make_utc import colander from deform import widget as dfwidget -from pyramid import httpexceptions from webhelpers2.html import tags, HTML from tailbone import forms, grids @@ -828,7 +827,7 @@ class ProductView(MasterView): price = self.Session.query(model.ProductPrice).get(key) if price: return price.product - raise httpexceptions.HTTPNotFound() + raise self.notfound() def configure_form(self, f): super(ProductView, self).configure_form(f) @@ -1757,10 +1756,13 @@ class ProductView(MasterView): """ product = self.get_instance() if not product.image: - raise httpexceptions.HTTPNotFound() + raise self.notfound() # TODO: how to properly detect image type? - # self.request.response.content_type = six.binary_type('image/png') - self.request.response.content_type = six.binary_type('image/jpeg') + # content_type = 'image/png' + content_type = 'image/jpeg' + if not six.PY3: + content_type = six.binary_type(content_type) + self.request.response.content_type = content_type self.request.response.body = product.image.bytes return self.request.response From dc90abcf094e26e2735d4f9e117dff399b14537d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 26 Dec 2022 17:31:37 -0600 Subject: [PATCH 0925/1681] Add "global searchbox" for quicker access to main views --- tailbone/subscribers.py | 5 +- tailbone/templates/themes/falafel/base.mako | 84 ++++++++++++++++++++- tailbone/util.py | 17 +++++ tailbone/views/custorders/orders.py | 8 ++ 4 files changed, 111 insertions(+), 3 deletions(-) diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index db73ff7b..4aed36cd 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -43,7 +43,7 @@ from tailbone.db import Session from tailbone.config import (csrf_header_name, should_expose_websockets, get_buefy_version, get_buefy_0_8) from tailbone.menus import make_simple_menus -from tailbone.util import should_use_buefy +from tailbone.util import should_use_buefy, get_global_search_options def new_request(event): @@ -179,6 +179,9 @@ def before_render(event): css = request.rattail_config.get('tailbone', 'theme.falafel.buefy_css') renderer_globals['buefy_css'] = css + # add global search data for quick access + renderer_globals['global_search_data'] = get_global_search_options(request) + # here we globally declare widths for grid filter pseudo-columns widths = request.rattail_config.get('tailbone', 'grids.filters.column_widths') if widths: diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index e46be1a5..1df54405 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -184,12 +184,27 @@ <nav class="navbar" role="navigation" aria-label="main navigation"> <div class="navbar-brand"> - <a class="navbar-item" href="${url('home')}"> + <a class="navbar-item" href="${url('home')}" + v-show="!globalSearchActive"> ${base_meta.header_logo()} <div id="global-header-title"> ${base_meta.global_title()} </div> </a> + <div v-show="globalSearchActive" + class="navbar-item"> + <b-autocomplete ref="globalSearchAutocomplete" + v-model="globalSearchTerm" + :data="globalSearchFilteredData" + field="label" + open-on-focus + keep-first + icon-pack="fas" + clearable + @keydown.native="globalSearchKeydown" + @select="globalSearchSelect"> + </b-autocomplete> + </div> <a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false"> <span aria-hidden="true"></span> <span aria-hidden="true"></span> @@ -200,6 +215,13 @@ <div class="navbar-menu"> <div class="navbar-start"> + <div v-if="globalSearchData.length" + class="navbar-item"> + <a @click.prevent="globalSearchInit()"> + <b-icon pack="fas" icon="search"></b-icon> + </a> + </div> + % for topitem in menus: % if topitem['is_link']: ${h.link_to(topitem['title'], topitem['url'], target=topitem['target'], class_='navbar-item')} @@ -738,7 +760,29 @@ let WholePage = { template: '#whole-page-template', mixins: [FormPosterMixin], - computed: {}, + computed: { + + globalSearchFilteredData() { + if (!this.globalSearchTerm.length) { + return this.globalSearchData + } + return this.globalSearchData.filter((option) => { + return option.label.toLowerCase().indexOf(this.globalSearchTerm.toLowerCase()) >= 0 + }) + }, + + }, + + mounted() { + window.addEventListener('keydown', this.globalKey) + for (let hook of this.mountedHooks) { + hook(this) + } + }, + beforeDestroy() { + window.removeEventListener('keydown', this.globalKey) + }, + methods: { changeContentTitle(newTitle) { @@ -757,6 +801,36 @@ }, % endif + globalKey(event) { + + // Ctrl+8 opens global search + if (event.target.tagName == 'BODY') { + if (event.ctrlKey && event.key == '8') { + this.globalSearchInit() + } + } + }, + + globalSearchInit() { + this.globalSearchTerm = '' + this.globalSearchActive = true + this.$nextTick(() => { + this.$refs.globalSearchAutocomplete.focus() + }) + }, + + globalSearchKeydown(event) { + + // ESC will dismiss searchbox + if (event.which == 27) { + this.globalSearchActive = false + } + }, + + globalSearchSelect(option) { + location.href = option.url + }, + toggleNestedMenu(hash) { const key = 'menu_' + hash + '_shown' this[key] = !this[key] @@ -767,6 +841,12 @@ let WholePageData = { contentTitleHTML: ${json.dumps(capture(self.content_title))|n}, feedbackMessage: "", + + globalSearchActive: false, + globalSearchTerm: '', + globalSearchData: ${json.dumps(global_search_data)|n}, + + mountedHooks: [], } ## declare nested menu visibility toggle flags diff --git a/tailbone/util.py b/tailbone/util.py index ca8d0933..c1d39eac 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -81,6 +81,23 @@ def get_form_data(request): return request.POST +def get_global_search_options(request): + """ + Returns global search options for current request. Basically a + list of all "index views" minus the ones they aren't allowed to + access. + """ + options = [] + pages = sorted(request.registry.settings['tailbone_index_pages'], + key=lambda page: page['label']) + for page in pages: + if not page['permission'] or request.has_perm(page['permission']): + option = dict(page) + option['url'] = request.route_url(page['route']) + options.append(option) + return options + + def should_use_buefy(request): """ Returns a flag indicating whether or not the current theme supports (and diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index e8ce8fd3..7d97b47f 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -1001,6 +1001,14 @@ class CustomerOrderView(MasterView): def _order_defaults(cls, config): route_prefix = cls.get_route_prefix() url_prefix = cls.get_url_prefix() + model_title = cls.get_model_title() + permission_prefix = cls.get_permission_prefix() + + # add pseudo-index page for creating new custorder + # (makes it available when building menus etc.) + config.add_tailbone_index_page('{}.create'.format(route_prefix), + "New {}".format(model_title), + '{}.create'.format(permission_prefix)) # person autocomplete config.add_route('{}.person_autocomplete'.format(route_prefix), From cfc92ac9e78e2518cd2fa0beb1e71f58a597a60e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 27 Dec 2022 22:30:25 -0600 Subject: [PATCH 0926/1681] Hide the "configure field help" icons until user requests access user can technically "request access" on "any page" and not just those with configurable fields..but who cares for now i think.. --- tailbone/forms/core.py | 48 +++++++++++------- tailbone/templates/form.mako | 26 +++------- tailbone/templates/forms/deform_buefy.mako | 28 +++++++---- tailbone/templates/page.mako | 3 ++ tailbone/templates/page_help.mako | 56 +++++++++++++++++---- tailbone/templates/themes/falafel/base.mako | 18 +++++-- 6 files changed, 117 insertions(+), 62 deletions(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index f4d5f14f..ea8808e3 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -900,11 +900,17 @@ class Form(object): not for "readonly" fields. """ dform = self.make_deform_form() - field = dform[fieldname] - label = self.get_label(fieldname) - markdowns = self.get_field_markdowns() + field = dform[fieldname] if fieldname in dform else None + + include = bool(field) + if self.readonly or (not field and fieldname in self.readonly_fields): + include = True + if not include: + return if self.field_visible(fieldname): + label = self.get_label(fieldname) + markdowns = self.get_field_markdowns() # these attrs will be for the <b-field> (*not* the widget) attrs = { @@ -912,15 +918,17 @@ class Form(object): } # add some magic for file input fields - if isinstance(field.schema.typ, deform.FileData): + if field and isinstance(field.schema.typ, deform.FileData): attrs['class_'] = 'file' # show helptext if present + # TODO: older logic did this only if field was *not* + # readonly, perhaps should add that back.. if self.has_helptext(fieldname): attrs['message'] = self.render_helptext(fieldname) # show errors if present - error_messages = self.get_error_messages(field) + error_messages = self.get_error_messages(field) if field else None if error_messages: # TODO: this surely can't be what we ought to do @@ -941,22 +949,16 @@ class Form(object): attrs.update(bfield_attrs) # render the field widget or whatever - html = field.serialize(use_buefy=True, - **self.get_renderer_kwargs(fieldname)) - html = HTML.literal(html) + if self.readonly or fieldname in self.readonly_fields: + html = self.render_field_value(fieldname) or HTML.tag('span') + elif field: + html = field.serialize(use_buefy=True, + **self.get_renderer_kwargs(fieldname)) + html = HTML.literal(html) # may need a complex label label_contents = [label] - # add 'configure' icon if allowed - if self.can_edit_help: - icon = HTML.tag('b-icon', size='is-small', pack='fas', - icon='cog') - icon = HTML.tag('a', title="Configure field", c=[icon], - **{'@click.prevent': "configureFieldInit('{}')".format(fieldname)}) - label_contents.append(HTML.literal(' ')) - label_contents.append(icon) - # add 'help' icon/tooltip if defined if markdowns.get(fieldname): icon = HTML.tag('b-icon', size='is-small', pack='fas', @@ -978,6 +980,16 @@ class Form(object): label_contents.append(HTML.literal(' ')) label_contents.append(tooltip) + # add 'configure' icon if allowed + if self.can_edit_help: + icon = HTML.tag('b-icon', size='is-small', pack='fas', + icon='cog') + icon = HTML.tag('a', title="Configure field", c=[icon], + **{'@click.prevent': "configureFieldInit('{}')".format(fieldname), + 'v-show': 'configureFieldsHelp'}) + label_contents.append(HTML.literal(' ')) + label_contents.append(icon) + # nb. must apply hack to get <template #label> as final result label_template = HTML.tag('template', c=label_contents, **{'#label': 1}) @@ -988,7 +1000,7 @@ class Form(object): # and finally wrap it all in a <b-field> return HTML.tag('b-field', c=[label_template, html], **attrs) - else: # hidden field + elif field: # hidden field # can just do normal thing for these # TODO: again, why does serialize() not return literal? diff --git a/tailbone/templates/form.mako b/tailbone/templates/form.mako index cb8afb53..19e5a4a7 100644 --- a/tailbone/templates/form.mako +++ b/tailbone/templates/form.mako @@ -11,7 +11,12 @@ <%def name="render_buefy_form()"> <div class="form"> - <${form.component}></${form.component}> + <${form.component} + % if can_edit_help: + :configure-fields-help="configureFieldsHelp" + % endif + > + </${form.component}> </div> </%def> @@ -58,25 +63,6 @@ ${parent.render_this_page_template()} </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - % if can_edit_help and form: - <script type="text/javascript"> - - ${form.component_studly}.methods.configureFieldInit = function(fieldname) { - this.configureFieldName = fieldname - this.configureFieldLabel = this.fieldLabels[fieldname] - this.configureFieldMarkdown = this.fieldMarkdowns[fieldname] - this.configureFieldShowDialog = true - this.$nextTick(() => { - this.$refs.configureFieldMarkdown.focus() - }) - } - - </script> - % endif -</%def> - <%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 0b1e8d90..4ff9c0b5 100644 --- a/tailbone/templates/forms/deform_buefy.mako +++ b/tailbone/templates/forms/deform_buefy.mako @@ -13,16 +13,7 @@ ${form_body|n} % else: % for field in form.fields: - % if form.readonly or (field not in dform and field in form.readonly_fields): - <b-field horizontal - label="${form.get_label(field)}"> - ${form.render_field_value(field) or h.HTML.tag('span')} - </b-field> - - % elif field in dform: - ${form.render_buefy_field(field)} - % endif - + ${form.render_buefy_field(field)} % endfor % endif </section> @@ -116,7 +107,11 @@ template: '#${form.component}-template', mixins: [FormPosterMixin], components: {}, - props: {}, + props: { + % if can_edit_help: + configureFieldsHelp: Boolean, + % endif + }, watch: {}, computed: {}, methods: { @@ -130,6 +125,17 @@ % endif % if can_edit_help: + + configureFieldInit(fieldname) { + this.configureFieldName = fieldname + this.configureFieldLabel = this.fieldLabels[fieldname] + this.configureFieldMarkdown = this.fieldMarkdowns[fieldname] + this.configureFieldShowDialog = true + this.$nextTick(() => { + this.$refs.configureFieldMarkdown.focus() + }) + }, + configureFieldSave() { this.configureFieldSaving = true let url = '${edit_help_url}' diff --git a/tailbone/templates/page.mako b/tailbone/templates/page.mako index 321e60d7..9f497268 100644 --- a/tailbone/templates/page.mako +++ b/tailbone/templates/page.mako @@ -33,6 +33,9 @@ let ThisPage = { template: '#this-page-template', mixins: [FormPosterMixin], + props: { + configureFieldsHelp: Boolean, + }, computed: {}, methods: {}, } diff --git a/tailbone/templates/page_help.mako b/tailbone/templates/page_help.mako index b745965a..4da6ac37 100644 --- a/tailbone/templates/page_help.mako +++ b/tailbone/templates/page_help.mako @@ -21,11 +21,22 @@ </b-button> </p> % if can_edit_help: - <p class="control"> - <b-button @click="configureInit()"> - <span><i class="fa fa-cog"></i></span> - </b-button> - </p> + ## TODO: this dropdown is duplicated, below + <b-dropdown aria-role="list" position="is-bottom-left"> + <template #trigger="{ active }"> + <b-button> + <span><i class="fa fa-cog"></i></span> + </b-button> + </template> + <b-dropdown-item aria-role="listitem" + @click="configureInit()"> + Edit Page Help + </b-dropdown-item> + <b-dropdown-item aria-role="listitem" + @click="configureFieldsInit()"> + Edit Fields Help + </b-dropdown-item> + </b-dropdown> % endif </b-field> @@ -33,10 +44,23 @@ <b-field> <p class="control"> - <b-button @click="configureInit()"> - <span><i class="fa fa-question-circle"></i></span> - <span><i class="fa fa-cog"></i></span> - </b-button> + ## TODO: this dropdown is duplicated, above + <b-dropdown aria-role="list" position="is-bottom-left"> + <template #trigger="{ active }"> + <b-button> + <span><i class="fa fa-question-circle"></i></span> + <span><i class="fa fa-cog"></i></span> + </b-button> + </template> + <b-dropdown-item aria-role="listitem" + @click="configureInit()"> + Edit Page Help + </b-dropdown-item> + <b-dropdown-item aria-role="listitem" + @click="configureFieldsInit()"> + Edit Fields Help + </b-dropdown-item> + </b-dropdown> </p> </b-field> @@ -151,6 +175,7 @@ this.displayShowDialog = true }, + % if can_edit_help: configureInit() { this.configureShowDialog = true this.$nextTick(() => { @@ -158,7 +183,15 @@ }) }, - % if can_edit_help: + configureFieldsInit() { + this.$emit('configure-fields-help') + this.$buefy.toast.open({ + message: "Please see the gear icon next to configurable fields", + type: 'is-info', + duration: 4000, // 4 seconds + }) + }, + configureSave() { this.configureSaving = true let url = '${url('{}.edit_help'.format(route_prefix))}' @@ -184,10 +217,13 @@ let PageHelpData = { displayShowDialog: false, + + % if can_edit_help: configureShowDialog: false, configureSaving: false, helpURL: ${json.dumps(help_url or None)|n}, markdownText: ${json.dumps(help_markdown or None)|n}, + % endif } </script> diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index 1df54405..4e8a56ba 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -407,7 +407,12 @@ % endif <div class="level-item"> - <page-help></page-help> + <page-help + % if can_edit_help: + @configure-fields-help="configureFieldsHelp = true" + % endif + > + </page-help> </div> ## Feedback Button / Dialog @@ -573,8 +578,11 @@ </%def> <%def name="render_this_page_component()"> - <this-page - v-on:change-content-title="changeContentTitle"> + <this-page @change-content-title="changeContentTitle" + % if can_edit_help: + :configure-fields-help="configureFieldsHelp" + % endif + > </this-page> </%def> @@ -842,6 +850,10 @@ contentTitleHTML: ${json.dumps(capture(self.content_title))|n}, feedbackMessage: "", + % if can_edit_help: + configureFieldsHelp: false, + % endif + globalSearchActive: false, globalSearchTerm: '', globalSearchData: ${json.dumps(global_search_data)|n}, From 03639d73fa45c3cfffe275c9f32b98a645298390 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 27 Dec 2022 22:51:42 -0600 Subject: [PATCH 0927/1681] Show global search as button instead of link --- tailbone/templates/themes/falafel/base.mako | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index 4e8a56ba..95547ee7 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -217,9 +217,11 @@ <div v-if="globalSearchData.length" class="navbar-item"> - <a @click.prevent="globalSearchInit()"> - <b-icon pack="fas" icon="search"></b-icon> - </a> + <b-button type="is-primary" + size="is-small" + @click="globalSearchInit()"> + <span><i class="fa fa-search"></i></span> + </b-button> </div> % for topitem in menus: From 0c6bfcbee67c7ac15adf63ddbc584b510cff8da8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 28 Dec 2022 14:40:41 -0600 Subject: [PATCH 0928/1681] Use minified version of vue.js by default, in falafel theme --- tailbone/templates/themes/falafel/base.mako | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index 95547ee7..654f61df 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -111,7 +111,7 @@ </%def> <%def name="vuejs()"> - ${h.javascript_link('https://unpkg.com/vue@{}/dist/vue.js'.format(vue_version))} + ${h.javascript_link('https://unpkg.com/vue@{}/dist/vue.min.js'.format(vue_version))} ## vue-resource ## (needed for e.g. this.$http.get() calls, used by grid at least) From 884f960d3bcc42a88e932e3a10a10e0d9c749b47 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 28 Dec 2022 16:12:33 -0600 Subject: [PATCH 0929/1681] Update changelog --- CHANGES.rst | 34 ++++++++++++++++++++++++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 86fd59c0..d30fca49 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,40 @@ CHANGELOG ========= +0.8.273 (2022-12-28) +-------------------- + +* Add support for Buefy 0.9.x. + +* Warn user when luigi is not installed, for relevant view. + +* Fix HUD display when toggling employee status in profile view. + +* Fix checkbox values when re-running a report. + +* Make static files optional, for new tailbone-integration project. + +* Preserve current tab for page reload in profile view. + +* Add cleanup logic for old Beaker session data. + +* Add basic support for editing help info for page, fields. + +* Override document title when upgrading. + +* Filter by person instead of user, for Generated Reports "Created by". + +* Add "direct link" support for master grids. + +* Add support for websockets over HTTP. + +* Fix product image view for python3. + +* Add "global searchbox" for quicker access to main views. + +* Use minified version of vue.js by default, in falafel theme. + + 0.8.272 (2022-12-21) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 7bd0956b..663a9797 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.272' +__version__ = '0.8.273' From a01982ae5523b2f1689bbdca480359e06e7cec51 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 31 Dec 2022 17:57:22 -0600 Subject: [PATCH 0930/1681] Show only "core" app settings by default --- tailbone/views/settings.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index c38e3136..9a1e8620 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -255,11 +255,20 @@ class AppSettingsView(View): """ Iterate over all known settings. """ - for module in self.rattail_config.getlist('rattail', 'settings', default=['rattail.settings']): + modules = self.rattail_config.getlist('rattail', 'settings') + if modules: + core_only = False + else: + modules = ['rattail.settings'] + core_only = True + + for module in modules: module = import_module_path(module) for name in dir(module): obj = getattr(module, name) if isinstance(obj, type) and issubclass(obj, Setting) and obj is not Setting: + if core_only and not obj.core: + continue # NOTE: we set this here, and reference it elsewhere obj.node_name = self.get_node_name(obj) yield obj From 7e852c1836d579f605a84ad56915441eef1fb83a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 1 Jan 2023 13:17:55 -0600 Subject: [PATCH 0931/1681] Allow buefy version to be 'latest' --- tailbone/config.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tailbone/config.py b/tailbone/config.py index 6807d861..1cb6236e 100644 --- a/tailbone/config.py +++ b/tailbone/config.py @@ -70,6 +70,8 @@ def get_buefy_version(config): def get_buefy_0_8(config, version=None): if not version: version = get_buefy_version(config) + if version == 'latest': + return False return parse_version(version) < parse_version('0.9') From a061e362c377fe1d89389d697c4121f9e83a3467 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 2 Jan 2023 09:44:05 -0600 Subject: [PATCH 0932/1681] Add beginnings of "New Table" feature nowhere near complete yet, but skeleton is more or less in place --- tailbone/forms/core.py | 2 + tailbone/templates/tables/create.mako | 214 ++++++++++++++++++++++++++ tailbone/views/master.py | 2 +- tailbone/views/tables.py | 183 ++++++++++++++++++++-- 4 files changed, 391 insertions(+), 10 deletions(-) create mode 100644 tailbone/templates/tables/create.mako diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index ea8808e3..a791f4cb 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -1135,6 +1135,8 @@ class Form(object): if record: try: return record[field_name] + except KeyError: + return None except TypeError: return getattr(record, field_name, None) diff --git a/tailbone/templates/tables/create.mako b/tailbone/templates/tables/create.mako new file mode 100644 index 00000000..90d9d26f --- /dev/null +++ b/tailbone/templates/tables/create.mako @@ -0,0 +1,214 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/create.mako" /> + +<%def name="extra_styles()"> + ${parent.extra_styles()} + <style type="text/css"> + .label { + white-space: nowrap; + } + </style> +</%def> + +<%def name="render_buefy_form()"> + <b-steps v-model="activeStep" + :animated="false" + rounded + :has-navigation="false" + vertical + icon-pack="fas"> + + <b-step-item step="1" + value="enter-details" + label="Enter Details" + clickable> + <h3 class="is-size-3 block"> + Enter Details + </h3> + ${parent.render_buefy_form()} + </b-step-item> + + <b-step-item step="2" + value="write-model" + label="Write Model" + clickable> + <h3 class="is-size-3 block"> + Write Model + </h3> + <div class="form"> + <b-field horizontal label="Table Name"> + <span>TODO: poser_widget</span> + </b-field> + <b-field horizontal label="Model Class"> + <span>TODO: PoserWidget</span> + </b-field> + <b-field horizontal label="File"> + <span>TODO: ~/src/poser/poser/db/model/widgets.py</span> + </b-field> + <div class="buttons"> + <b-button icon-pack="fas" + icon-left="arrow-left" + @click="activeStep = 'enter-details'"> + Back + </b-button> + <b-button type="is-primary" + icon-pack="fas" + icon-left="save" + @click="activeStep = 'review-model'"> + Write model class to file + </b-button> + </div> + </div> + </b-step-item> + + <b-step-item step="3" + value="review-model" + label="Review Model"> + <h3 class="is-size-3 block"> + Review Model + </h3> + <p class="block">TODO: review model class here</p> + <div class="buttons"> + <b-button icon-pack="fas" + icon-left="arrow-left" + @click="activeStep = 'write-model'"> + Back + </b-button> + <b-button type="is-primary" + icon-pack="fas" + icon-left="check" + @click="activeStep = 'write-revision'"> + Model class looks good! + </b-button> + </div> + </b-step-item> + + <b-step-item step="4" + value="write-revision" + label="Write Revision"> + <h3 class="is-size-3 block"> + Write Revision + </h3> + <p class="block"> + You said the model class looked good, so next we will generate + a revision script, used to modify DB schema. + </p> + <p class="block"> + TODO: write revision script here + </p> + <div class="buttons"> + <b-button icon-pack="fas" + icon-left="arrow-left" + @click="activeStep = 'review-model'"> + Back + </b-button> + <b-button type="is-primary" + icon-pack="fas" + icon-left="save" + @click="activeStep = 'review-revision'"> + Write revision script to file + </b-button> + </div> + </b-step-item> + + <b-step-item step="5" + value="review-revision" + label="Review Revision"> + <h3 class="is-size-3 block"> + Review Revision + </h3> + <p class="block">TODO: review revision script here</p> + <div class="buttons"> + <b-button icon-pack="fas" + icon-left="arrow-left" + @click="activeStep = 'write-revision'"> + Back + </b-button> + <b-button type="is-primary" + icon-pack="fas" + icon-left="check" + @click="activeStep = 'upgrade-db'"> + Revision script looks good! + </b-button> + </div> + </b-step-item> + + <b-step-item step="6" + value="upgrade-db" + label="Upgrade DB"> + <h3 class="is-size-3 block"> + Upgrade DB + </h3> + <p class="block">TODO: upgrade DB here</p> + <div class="buttons"> + <b-button icon-pack="fas" + icon-left="arrow-left" + @click="activeStep = 'review-revision'"> + Back + </b-button> + <b-button type="is-primary" + icon-pack="fas" + icon-left="check" + @click="activeStep = 'review-db'"> + Upgrade database + </b-button> + </div> + </b-step-item> + + <b-step-item step="7" + value="review-db" + label="Review DB"> + <h3 class="is-size-3 block"> + Review DB + </h3> + <p class="block">TODO: review DB here</p> + <div class="buttons"> + <b-button icon-pack="fas" + icon-left="arrow-left" + @click="activeStep = 'upgrade-db'"> + Back + </b-button> + <b-button type="is-primary" + icon-pack="fas" + icon-left="check" + @click="activeStep = 'commit-code'"> + DB looks good! + </b-button> + </div> + </b-step-item> + + <b-step-item step="8" + value="commit-code" + label="Commit Code"> + <h3 class="is-size-3 block"> + Commit Code + </h3> + <p class="block">TODO: commit changes here</p> + <div class="buttons"> + <b-button icon-pack="fas" + icon-left="arrow-left" + @click="activeStep = 'review-db'"> + Back + </b-button> + <b-button type="is-primary" + icon-pack="fas" + icon-left="check" + @click="alert('TODO: redirect to table view')"> + Code changes are committed! + </b-button> + </div> + </b-step-item> + </b-steps> +</%def> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + ThisPageData.activeStep = null + + </script> +</%def> + + +${parent.body()} diff --git a/tailbone/views/master.py b/tailbone/views/master.py index d382918c..98355ec3 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -676,7 +676,7 @@ class MasterView(View): return self.render_to_response(template, context) def make_create_form(self): - return self.make_form(self.get_model_class()) + return self.make_form() def save_create_form(self, form): uploads = self.normalize_uploads(form) diff --git a/tailbone/views/tables.py b/tailbone/views/tables.py index 5d4f7d95..49d9e7a5 100644 --- a/tailbone/views/tables.py +++ b/tailbone/views/tables.py @@ -26,6 +26,12 @@ Views with info about the underlying Rattail tables from __future__ import unicode_literals, absolute_import +import sys +import warnings + +import colander +from deform import widget as dfwidget + from tailbone.views import MasterView @@ -34,20 +40,31 @@ class TableView(MasterView): Master view for tables """ normalized_model_name = 'table' - model_key = 'name' + model_key = 'table_name' model_title = "Table" creatable = False editable = False deletable = False - viewable = False filterable = False pageable = False + labels = { + 'branch_name': "Schema Branch", + 'model_name': "Model Class", + 'module_name': "Module", + 'module_file': "File", + } + grid_columns = [ - 'name', + 'table_name', 'row_count', ] + def __init__(self, request): + super(TableView, self).__init__(request) + app = self.get_rattail_app() + self.db_handler = app.get_db_handler() + def get_data(self, **kwargs): """ Fetch existing table names and estimate row counts via PG SQL @@ -62,18 +79,166 @@ class TableView(MasterView): order by n_live_tup desc; """ result = self.Session.execute(sql) - return [dict(name=row['relname'], row_count=row['n_live_tup']) + return [dict(table_name=row['relname'], row_count=row['n_live_tup']) for row in result] def configure_grid(self, g): - g.sorters['name'] = g.make_simple_sorter('name', foldcase=True) + super(TableView, self).configure_grid(g) + + # table_name + g.sorters['table_name'] = g.make_simple_sorter('table_name', foldcase=True) + g.set_sort_defaults('table_name') + g.set_searchable('table_name') + g.set_link('table_name') + + # row_count g.sorters['row_count'] = g.make_simple_sorter('row_count') - g.set_sort_defaults('name') - g.set_searchable('name') + def get_instance(self): + from sqlalchemy_utils import get_mapper -# TODO: deprecate / remove this -TablesView = TableView + model = self.model + table_name = self.request.matchdict['table_name'] + + sql = """ + select n_live_tup + from pg_stat_user_tables + where schemaname = 'public' and relname = :table_name + order by n_live_tup desc; + """ + result = self.Session.execute(sql, {'table_name': table_name}) + row = result.fetchone() + if not row: + raise self.notfound() + + data = { + 'table_name': table_name, + 'row_count': row['n_live_tup'], + } + + table = model.Base.metadata.tables.get(table_name) + if table is not None: + try: + mapper = get_mapper(table) + except ValueError: + pass + else: + data['model_name'] = mapper.class_.__name__ + data['model_title'] = mapper.class_.get_model_title() + data['model_title_plural'] = mapper.class_.get_model_title_plural() + data['description'] = mapper.class_.__doc__ + + # TODO: how to reliably get branch? must walk all revisions? + module_parts = mapper.class_.__module__.split('.') + data['branch_name'] = module_parts[0] + + data['module_name'] = mapper.class_.__module__ + data['module_file'] = sys.modules[mapper.class_.__module__].__file__ + + return data + + def get_instance_title(self, table): + return table['table_name'] + + def make_form_schema(self): + return TableSchema() + + def configure_form(self, f): + super(TableView, self).configure_form(f) + app = self.get_rattail_app() + + # exclude some fields when creating + if self.creating: + f.remove('row_count', + 'module_name', + 'module_file') + + # branch_name + if self.creating: + + # move this field to top of form, as it's more fundamental + # when creating new table + f.remove('branch_name') + f.insert(0, 'branch_name') + + # define options for dropdown + branches = self.db_handler.get_alembic_branch_names() + values = [(branch, branch) for branch in branches] + f.set_widget('branch_name', dfwidget.SelectWidget(values=values)) + + # default to custom app branch, if applicable + table_prefix = app.get_table_prefix() + if table_prefix in branches: + f.set_default('branch_name', table_prefix) + f.set_helptext('branch_name', "Leave this set to your custom app branch, unless you know what you're doing.") + + # table_name + if self.creating: + f.set_default('table_name', '{}_widget'.format(app.get_table_prefix())) + f.set_helptext('table_name', "Should be singular in nature, i.e. 'widget' not 'widgets'") + + # model_name + if self.creating: + f.set_default('model_name', '{}Widget'.format(app.get_class_prefix())) + f.set_helptext('model_name', "Should be singular in nature, i.e. 'Widget' not 'Widgets'") + + # model_title* + if self.creating: + f.set_default('model_title', 'Widget') + f.set_helptext('model_title', "Human-friendly singular model title.") + f.set_default('model_title_plural', 'Widgets') + f.set_helptext('model_title_plural', "Human-friendly plural model title.") + + # description + if self.creating: + f.set_default('description', "Represents a cool widget.") + f.set_helptext('description', "Brief description of what a record in this table represents.") + + # TODO: not sure yet how to handle "save" action + # def save_create_form(self, form): + # return form.validated + + @classmethod + def defaults(cls, config): + rattail_config = config.registry.settings.get('rattail_config') + + # allow creating tables only if *not* production + if not rattail_config.production(): + cls.creatable = True + + cls._defaults(config) + + +class TablesView(TableView): + + def __init__(self, request): + warnings.warn("TablesView is deprecated; please use TableView instead", + DeprecationWarning, stacklevel=2) + super(TablesView, self).__init__(request) + + +class TableSchema(colander.Schema): + + table_name = colander.SchemaNode(colander.String()) + + row_count = colander.SchemaNode(colander.Integer(), + missing=colander.null) + + model_name = colander.SchemaNode(colander.String()) + + model_title = colander.SchemaNode(colander.String()) + + model_title_plural = colander.SchemaNode(colander.String()) + + description = colander.SchemaNode(colander.String()) + + branch_name = colander.SchemaNode(colander.String()) + + module_name = colander.SchemaNode(colander.String(), + missing=colander.null) + + module_file = colander.SchemaNode(colander.String(), + missing=colander.null) def defaults(config, **kwargs): From d21826c70d7d3609e5beb34b228798e948dcc54a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 2 Jan 2023 11:11:01 -0600 Subject: [PATCH 0933/1681] Make invalid email more obvious, in profile view --- tailbone/templates/people/view_profile_buefy.mako | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako index 93f3ea09..9845e343 100644 --- a/tailbone/templates/people/view_profile_buefy.mako +++ b/tailbone/templates/people/view_profile_buefy.mako @@ -472,12 +472,12 @@ </b-table-column> <b-table-column field="invalid" - label="Invalid" + label="Invalid?" % if not buefy_0_8: v-slot="props" % endif > - <span v-if="props.row.invalid" class="has-text-danger">Yes</span> + <span v-if="props.row.invalid" class="has-text-danger has-text-weight-bold">Invalid</span> </b-table-column> % if request.has_perm('people_profile.edit_person'): From 9f763b46ebb02a4e380c0b52ba4297685621c9cf Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 2 Jan 2023 13:12:01 -0600 Subject: [PATCH 0934/1681] Expose some settings for Trainwreck DB rotation --- .../trainwreck/transactions/configure.mako | 31 +++++++++++++++++++ tailbone/views/trainwreck/base.py | 13 ++++++++ 2 files changed, 44 insertions(+) diff --git a/tailbone/templates/trainwreck/transactions/configure.mako b/tailbone/templates/trainwreck/transactions/configure.mako index 7cf03165..fd6c53a7 100644 --- a/tailbone/templates/trainwreck/transactions/configure.mako +++ b/tailbone/templates/trainwreck/transactions/configure.mako @@ -3,13 +3,44 @@ <%def name="form_content()"> + <h3 class="block is-size-3">Rotation</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field message="There is only one Trainwreck DB, unless rotation is used."> + <b-checkbox name="trainwreck.use_rotation" + v-model="simpleSettings['trainwreck.use_rotation']" + native-value="true" + @input="settingsNeedSaved = true"> + Rotate Databases + </b-checkbox> + </b-field> + + <b-field grouped> + <b-field label="Current Years" + message="How many years (max) to keep in "current" DB. Default is 2 if not set."> + <b-input name="trainwreck.current_years" + v-model="simpleSettings['trainwreck.current_years']" + @input="settingsNeedSaved = true"> + </b-input> + </b-field> + </b-field> + + </div> + <h3 class="block is-size-3">Hidden Databases</h3> <div class="block" style="padding-left: 2rem;"> + <p class="block"> + The selected DBs will be hidden from the DB picker when viewing + Trainwreck data. + </p> % for key, engine in six.iteritems(trainwreck_engines): <b-field> <b-checkbox name="hidedb_${key}" v-model="hiddenDatabases['${key}']" native-value="true" + % if key == 'default': + disabled + % endif @input="settingsNeedSaved = true"> ${key} </b-checkbox> diff --git a/tailbone/views/trainwreck/base.py b/tailbone/views/trainwreck/base.py index 163d17b0..509b4398 100644 --- a/tailbone/views/trainwreck/base.py +++ b/tailbone/views/trainwreck/base.py @@ -384,6 +384,19 @@ class TransactionView(MasterView): 'engines_data': engines_data, }) + def configure_get_simple_settings(self): + return [ + + # rotation + {'section': 'trainwreck', + 'option': 'use_rotation', + 'type': bool}, + {'section': 'trainwreck', + 'option': 'current_years', + 'type': int}, + + ] + def configure_get_context(self): context = super(TransactionView, self).configure_get_context() From c7537e799457ca63897d52c05ba3a7288dba2c16 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 2 Jan 2023 16:55:39 -0600 Subject: [PATCH 0935/1681] 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 d30fca49..93e174eb 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,20 @@ CHANGELOG ========= +0.8.274 (2023-01-02) +-------------------- + +* Show only "core" app settings by default. + +* Allow buefy version to be 'latest'. + +* Add beginnings of "New Table" feature. + +* Make invalid email more obvious, in profile view. + +* Expose some settings for Trainwreck DB rotation. + + 0.8.273 (2022-12-28) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 663a9797..61940cf9 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.273' +__version__ = '0.8.274' From ab80aedb63a5d7962c163c3808e402dfa99ad34b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 4 Jan 2023 00:09:35 -0600 Subject: [PATCH 0936/1681] Allow xref buttons to have "internal" links still assume external (open in new tab) by default --- tailbone/views/master.py | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 98355ec3..735755f0 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2644,17 +2644,33 @@ class MasterView(View): return normal def make_xref_button(self, **kwargs): + """ + Make and return a HTML ``<b-button>`` literal, for display in + the cross-reference helper panel. + :param url: URL for the link. + :param text: Label for the button. + :param internal: Boolean indicating if the link is internal to + the site. This is false by default, meaning the link is + assumed to be external, which affects the icon and causes + button click to open link in a new tab. + """ # nb. unfortunately HTML.tag() calls its first arg 'tag' and # so we can't pass a kwarg with that name...so instead we # patch that into place manually - button = HTML.tag('b-button', type='is-primary', - href=kwargs['url'], target='_blank', - icon_pack='fas', icon_left='external-link-alt', - c=kwargs['text']) + btn_kw = dict(type='is-primary', + href=kwargs['url'], + icon_pack='fas', + c=kwargs['text']) + if kwargs.get('internal'): + btn_kw['icon_left'] = 'eye' + else: + btn_kw['icon_left'] = 'external-link-alt' + btn_kw['target'] = '_blank' + button = HTML.tag('b-button', **btn_kw) button = six.text_type(button) - button = button.replace('target="_blank"', - 'target="_blank" tag="a"') + button = button.replace('<b-button ', + '<b-button tag="a"') button = HTML.literal(button) return button From 7e4bd851f1a98641e388516f77fe0cce53c094c8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 4 Jan 2023 10:57:14 -0600 Subject: [PATCH 0937/1681] 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 93e174eb..0b9467e4 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.275 (2023-01-04) +-------------------- + +* Allow xref buttons to have "internal" links. + + 0.8.274 (2023-01-02) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 61940cf9..248cbb09 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.274' +__version__ = '0.8.275' From d0881cbd097eedde5e0af719228e4e32c7ce22a1 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 4 Jan 2023 12:38:04 -0600 Subject: [PATCH 0938/1681] Keep aspect ratio for product images in new custorder --- tailbone/templates/custorders/create.mako | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index 77e72244..45d4a510 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -552,7 +552,7 @@ <div class="is-pulled-right has-text-centered"> <img :src="productImageURL" - style="height: 150px; width: 150px; "/> + style="max-height: 150px; max-width: 150px; "/> ## <p>{{ productKey }}</p> </div> @@ -716,7 +716,7 @@ <div class="is-pulled-right has-text-centered"> <img :src="productImageURL" - style="height: 150px; width: 150px; "/> + style="max-height: 150px; max-width: 150px; "/> </div> <b-field grouped> From 31b213610f646955b3deeed70e8515379229d03e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 4 Jan 2023 15:31:51 -0600 Subject: [PATCH 0939/1681] Fix template bug for generating report --- tailbone/templates/reports/generated/choose.mako | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/reports/generated/choose.mako b/tailbone/templates/reports/generated/choose.mako index 7f24bfde..31aa3cd5 100644 --- a/tailbone/templates/reports/generated/choose.mako +++ b/tailbone/templates/reports/generated/choose.mako @@ -98,8 +98,8 @@ % endif </%def> -<%def name="finalize_this_page_vars()"> - ${parent.finalize_this_page_vars()} +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} <script type="text/javascript"> TailboneFormData.reportDescriptions = ${json.dumps(report_descriptions)|n} From db62bd20b3f992889e48f00fd0163b33bd15f772 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 4 Jan 2023 16:39:37 -0600 Subject: [PATCH 0940/1681] Show help link when generating or viewing report, if applicable --- .../templates/reports/generated/generate.mako | 15 ++++++- tailbone/views/master.py | 45 ++++++++++++++++++- tailbone/views/reports.py | 21 +++++++-- 3 files changed, 74 insertions(+), 7 deletions(-) diff --git a/tailbone/templates/reports/generated/generate.mako b/tailbone/templates/reports/generated/generate.mako index 38adfe34..f7b4ab34 100644 --- a/tailbone/templates/reports/generated/generate.mako +++ b/tailbone/templates/reports/generated/generate.mako @@ -7,8 +7,19 @@ <%def name="render_buefy_form()"> <div class="form"> - <p style="padding: 1em;">${report.__doc__}</p> - <br /> + <p class="block"> + ${report.__doc__} + </p> + % if report.help_url: + <p class="block"> + <b-button icon-pack="fas" + icon-left="question-circle" + tag="a" target="_blank" + href="${report.help_url}"> + Help for this report + </b-button> + </p> + % endif <tailbone-form></tailbone-form> </div> </%def> diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 735755f0..dbe4049e 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -2643,6 +2643,47 @@ class MasterView(View): normal.append(button) return normal + def make_buefy_button(self, label, is_primary=False, + url=None, is_external=False, + **kwargs): + """ + Make and return a HTML ``<b-button>`` literal. + """ + btn_kw = dict(c=label, icon_pack='fas') + + if 'type' in kwargs: + btn_kw['type'] = kwargs['type'] + elif is_primary: + btn_kw['type'] = 'is-primary' + + if url: + btn_kw['href'] = url + + if 'icon_left' in kwargs: + btn_kw['icon_left'] = kwargs['icon_left'] + elif is_external: + btn_kw['icon_left'] = 'external-link-alt' + else: + btn_kw['icon_left'] = 'eye' + + if 'target' in kwargs: + btn_kw['target'] = kwargs['target'] + elif is_external: + btn_kw['target'] = '_blank' + + button = HTML.tag('b-button', **btn_kw) + + if url: + # nb. unfortunately HTML.tag() calls its first arg 'tag' and + # so we can't pass a kwarg with that name...so instead we + # patch that into place manually + button = six.text_type(button) + button = button.replace('<b-button ', + '<b-button tag="a"') + button = HTML.literal(button) + + return button + def make_xref_button(self, **kwargs): """ Make and return a HTML ``<b-button>`` literal, for display in @@ -2655,6 +2696,8 @@ class MasterView(View): assumed to be external, which affects the icon and causes button click to open link in a new tab. """ + # TODO: this should call make_buefy_button() + # nb. unfortunately HTML.tag() calls its first arg 'tag' and # so we can't pass a kwarg with that name...so instead we # patch that into place manually diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index 101c541b..70e4d7e6 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -267,6 +267,9 @@ class ReportOutputView(ExportMasterView): def render_report_type(self, output, field): type_key = getattr(output, field) + # just show type key by default + rendered = type_key + # (try to) show link to poser report if applicable if type_key and type_key.startswith('poser_'): app = self.get_rattail_app() @@ -276,10 +279,20 @@ class ReportOutputView(ExportMasterView): if not report.get('error'): url = self.request.route_url('poser_reports.view', report_key=poser_key) - return tags.link_to(type_key, url) + rendered = tags.link_to(type_key, url) - # fallback to showing value as-is - return type_key + # add help button if report has a link + report = self.report_handler.get_report(type_key) + if report and report.help_url: + button = self.make_buefy_button("Help for this report", + url=report.help_url, + is_external=True, + icon_left='question-circle') + button = HTML.tag('div', class_='level-item', c=[button]) + rendered = HTML.tag('div', class_='level-item', c=[rendered]) + rendered = HTML.tag('div', class_='level-left', c=[rendered, button]) + + return rendered def render_params(self, report, field): params = report.params From 71851e1a05b1e5ce0649762d59630c27e51fd4e6 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 4 Jan 2023 21:23:57 -0600 Subject: [PATCH 0941/1681] Use product handler to normalize data for products API at least, as much as possible --- tailbone/api/products.py | 68 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 62 insertions(+), 6 deletions(-) diff --git a/tailbone/api/products.py b/tailbone/api/products.py index 48a6e4aa..a1547cce 100644 --- a/tailbone/api/products.py +++ b/tailbone/api/products.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -30,6 +30,8 @@ import six import sqlalchemy as sa from sqlalchemy import orm +from cornice import Service + from rattail.db import model from tailbone.api import APIMasterView @@ -44,20 +46,41 @@ class ProductView(APIMasterView): object_url_prefix = '/product' supports_autocomplete = True + def __init__(self, request, context=None): + super(ProductView, self).__init__(request, context=context) + app = self.get_rattail_app() + self.products_handler = app.get_products_handler() + def normalize(self, product): + + # get what we can from handler + data = self.products_handler.normalize_product(product, fields=[ + 'brand_name', + 'full_description', + 'department_name', + 'unit_price_display', + 'sale_price', + 'sale_price_display', + 'sale_ends', + 'sale_ends_display', + 'vendor_name', + 'costs', + 'image_url', + ]) + + # but must supplement cost = product.cost - return { - 'uuid': product.uuid, - '_str': six.text_type(product), + data.update({ 'upc': six.text_type(product.upc), 'scancode': product.scancode, 'item_id': product.item_id, 'item_type': product.item_type, - 'description': product.description, 'status_code': product.status_code, 'default_unit_cost': cost.unit_cost if cost else None, 'default_unit_cost_display': "${:0.2f}".format(cost.unit_cost) if cost and cost.unit_cost is not None else None, - } + }) + + return data def make_autocomplete_query(self, term): query = self.Session.query(model.Product)\ @@ -77,6 +100,39 @@ class ProductView(APIMasterView): def autocomplete_display(self, product): return product.full_description + def quick_lookup(self): + """ + View for handling "quick lookup" user input, for index page. + """ + data = self.request.GET + entry = data['entry'] + + product = self.products_handler.locate_product_for_entry(self.Session(), + entry) + if not product: + return {'error': "Product not found"} + + return {'ok': True, + 'product': self.normalize(product)} + + @classmethod + def defaults(cls, config): + cls._defaults(config) + cls._product_defaults(config) + + @classmethod + def _product_defaults(cls, config): + route_prefix = cls.get_route_prefix() + permission_prefix = cls.get_permission_prefix() + collection_url_prefix = cls.get_collection_url_prefix() + + # quick lookup + quick_lookup = Service(name='{}.quick_lookup'.format(route_prefix), + path='{}/quick-lookup'.format(collection_url_prefix)) + quick_lookup.add_view('GET', 'quick_lookup', klass=cls, + permission='{}.list'.format(permission_prefix)) + config.add_cornice_service(quick_lookup) + def defaults(config, **kwargs): base = globals() From 8c201dced7ed4a94fcac7876a75d6f8ebda13079 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 5 Jan 2023 13:43:38 -0600 Subject: [PATCH 0942/1681] 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 0b9467e4..63f1bf4a 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,18 @@ CHANGELOG ========= +0.8.276 (2023-01-05) +-------------------- + +* Keep aspect ratio for product images in new custorder. + +* Fix template bug for generating report. + +* Show help link when generating or viewing report, if applicable. + +* Use product handler to normalize data for products API. + + 0.8.275 (2023-01-04) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 248cbb09..02aa98d7 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.275' +__version__ = '0.8.276' From c6765fd9a9f7fee92c469d86e312d8c5a474e3a1 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 7 Jan 2023 11:52:37 -0600 Subject: [PATCH 0943/1681] Expose, start to honor "units only" setting for products --- tailbone/templates/products/configure.mako | 9 ++++++ tailbone/views/batch/importer.py | 3 +- tailbone/views/products.py | 35 ++++++++++++++-------- 3 files changed, 33 insertions(+), 14 deletions(-) diff --git a/tailbone/templates/products/configure.mako b/tailbone/templates/products/configure.mako index 612b8d36..9f895280 100644 --- a/tailbone/templates/products/configure.mako +++ b/tailbone/templates/products/configure.mako @@ -50,6 +50,15 @@ </b-checkbox> </b-field> + <b-field message="If set, then "case size" etc. will not be shown."> + <b-checkbox name="rattail.products.units_only" + v-model="simpleSettings['rattail.products.units_only']" + native-value="true" + @input="settingsNeedSaved = true"> + Products only come in units + </b-checkbox> + </b-field> + </div> <h3 class="block is-size-3">Labels</h3> diff --git a/tailbone/views/batch/importer.py b/tailbone/views/batch/importer.py index 001de0ff..c16c6180 100644 --- a/tailbone/views/batch/importer.py +++ b/tailbone/views/batch/importer.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -41,7 +41,6 @@ class ImporterBatchView(BatchMasterView): """ model_class = model.ImporterBatch default_handler_spec = 'rattail.batch.importer:ImporterBatchHandler' - model_title_plural = "Import / Export Batches" route_prefix = 'batch.importer' url_prefix = '/batches/importer' template_prefix = '/batch/importer' diff --git a/tailbone/views/products.py b/tailbone/views/products.py index a7110660..9a143dc7 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -1320,20 +1320,26 @@ class ProductView(MasterView): def get_context_vendor_sources(self, product): app = self.get_rattail_app() route_prefix = self.get_route_prefix() + units_only = self.products_handler.units_only() + + columns = [ + 'preferred', + 'vendor', + 'vendor_item_code', + 'case_size', + 'case_cost', + 'unit_cost', + 'status', + ] + if units_only: + columns.remove('case_size') + columns.remove('case_cost') factory = self.get_grid_factory() g = factory( key='{}.vendor_sources'.format(route_prefix), data=[], - columns=[ - 'preferred', - 'vendor', - 'vendor_item_code', - 'case_size', - 'case_cost', - 'unit_cost', - 'status', - ], + columns=columns, labels={ 'preferred': "Pref.", 'vendor_item_code': "Order Code", @@ -1348,12 +1354,14 @@ class ProductView(MasterView): 'uuid': cost.uuid, 'preferred': "X" if cost.preference == 1 else None, 'vendor_item_code': cost.code, - 'case_size': app.render_quantity(cost.case_size), - 'case_cost': app.render_currency(cost.case_cost), 'unit_cost': app.render_currency(cost.unit_cost, scale=4), 'status': "discontinued" if cost.discontinued else "available", } + if not units_only: + source['case_size'] = app.render_quantity(cost.case_size) + source['case_cost'] = app.render_currency(cost.case_cost) + text = six.text_type(cost.vendor) if link_vendor: url = self.request.route_url('vendors.view', uuid=cost.vendor.uuid) @@ -2145,6 +2153,9 @@ class ProductView(MasterView): {'section': 'rattail', 'option': 'products.convert_type2_for_gpc_lookup', 'type': bool}, + {'section': 'rattail', + 'option': 'products.units_only', + 'type': bool}, # labels {'section': 'tailbone', From b11f9f62b7ab1bd618d9b5ad915dd3652b66d377 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 7 Jan 2023 11:53:10 -0600 Subject: [PATCH 0944/1681] 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 63f1bf4a..ad27c6e2 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.277 (2023-01-07) +-------------------- + +* Expose, start to honor "units only" setting for products. + + 0.8.276 (2023-01-05) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 02aa98d7..83c8f35e 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.276' +__version__ = '0.8.277' From 33ffd7e8551b1ece0e3ed9eedb95fd677b49ac6f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 7 Jan 2023 22:46:35 -0600 Subject: [PATCH 0945/1681] Improve "download rows as XLSX" for importer batch still could be better, but at least this avoids error --- tailbone/views/batch/importer.py | 26 +++++++++++++++++++++++++- tailbone/views/master.py | 16 +++++++++++----- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/tailbone/views/batch/importer.py b/tailbone/views/batch/importer.py index c16c6180..ac690f80 100644 --- a/tailbone/views/batch/importer.py +++ b/tailbone/views/batch/importer.py @@ -286,6 +286,30 @@ class ImporterBatchView(BatchMasterView): delete_query.execute() return self.redirect(self.get_action_url('view', batch)) + def get_row_xlsx_fields(self): + return [ + 'sequence', + 'object_key', + 'object_str', + 'status', + 'status_code', + 'status_text', + ] + + def get_row_xlsx_row(self, row, fields): + xlrow = super(ImporterBatchView, self).get_row_xlsx_row(row, fields) + + xlrow['status'] = self.enum.IMPORTER_BATCH_ROW_STATUS[row.status_code] + + return xlrow + + +def defaults(config, **kwargs): + base = globals() + + ImporterBatchView = kwargs.get('ImporterBatchView', base['ImporterBatchView']) + ImporterBatchView.defaults(config) + def includeme(config): - ImporterBatchView.defaults(config) + defaults(config) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index dbe4049e..096108a6 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -3850,11 +3850,17 @@ class MasterView(View): """ Return the list of row fields to be written to CSV download. """ - fields = [] - mapper = orm.class_mapper(self.model_row_class) - for prop in mapper.iterate_properties: - if isinstance(prop, orm.ColumnProperty): - fields.append(prop.key) + try: + mapper = orm.class_mapper(self.model_row_class) + except: + fields = self.get_row_form_fields() + if not fields: + fields = self.get_row_grid_columns() + else: + fields = [] + for prop in mapper.iterate_properties: + if isinstance(prop, orm.ColumnProperty): + fields.append(prop.key) return fields def get_csv_row(self, obj, fields): From 2b7ebedb220cd62d7c15b4b322ebb9c3f9a17f00 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 8 Jan 2023 11:36:42 -0600 Subject: [PATCH 0946/1681] 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 ad27c6e2..4d628263 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.278 (2023-01-08) +-------------------- + +* Improve "download rows as XLSX" for importer batch. + + 0.8.277 (2023-01-07) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 83c8f35e..43ec4886 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.277' +__version__ = '0.8.278' From dfa4178204ce410798e10ba44bc8f0f35048e476 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 10 Jan 2023 16:46:21 -0600 Subject: [PATCH 0947/1681] Add basic support for receiving from multiple invoice files --- tailbone/forms/core.py | 53 +++++++++---- tailbone/forms/widgets.py | 75 ++++++++++++++++++- .../templates/deform/multi_file_upload.pt | 7 ++ tailbone/templates/multi_file_upload.mako | 60 +++++++++++++++ tailbone/templates/receiving/configure.mako | 11 ++- tailbone/templates/receiving/view_row.mako | 2 +- tailbone/templates/themes/falafel/base.mako | 4 + tailbone/views/batch/core.py | 25 ++++++- tailbone/views/master.py | 51 ++++++++----- tailbone/views/purchasing/receiving.py | 47 +++++++++++- 10 files changed, 295 insertions(+), 40 deletions(-) create mode 100644 tailbone/templates/deform/multi_file_upload.pt create mode 100644 tailbone/templates/multi_file_upload.mako diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index a791f4cb..99e6ba2d 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -51,7 +51,9 @@ from webhelpers2.html import tags, HTML from tailbone.db import Session from tailbone.util import raw_datetime, get_form_data, render_markdown from . import types -from .widgets import ReadonlyWidget, PlainDateWidget, JQueryDateWidget, JQueryTimeWidget +from .widgets import (ReadonlyWidget, PlainDateWidget, + JQueryDateWidget, JQueryTimeWidget, + MultiFileUploadWidget) from tailbone.exceptions import TailboneJSONFieldError @@ -579,6 +581,10 @@ class Form(object): node = colander.SchemaNode(nodeinfo, **kwargs) self.nodes[key] = node + # must explicitly replace node, if we already have a schema + if self.schema: + self.schema[key] = node + def set_type(self, key, type_, **kwargs): if type_ == 'datetime': self.set_renderer(key, self.render_datetime) @@ -624,9 +630,18 @@ class Form(object): if 'required' in kwargs and not kwargs['required']: kw['missing'] = colander.null self.set_node(key, colander.SchemaNode(deform.FileData(), **kw)) - # must explicitly replace node, if we already have a schema - if self.schema: - self.schema[key] = self.nodes[key] + elif type_ == 'multi_file': + tmpstore = SessionFileUploadTempStore(self.request) + file_node = colander.SchemaNode(deform.FileData(), + name='upload') + + kw = {'name': key, + 'title': self.get_label(key), + 'widget': MultiFileUploadWidget(tmpstore)} + # if 'required' in kwargs and not kwargs['required']: + # kw['missing'] = colander.null + files_node = colander.SequenceSchema(file_node, **kw) + self.set_node(key, files_node) else: raise ValueError("unknown type for '{}' field: {}".format(key, type_)) @@ -853,25 +868,31 @@ class Form(object): value = convert(field.cstruct) return json.dumps(value) - if isinstance(field.schema.typ, deform.FileData): - # TODO: we used to always/only return 'null' here but hopefully - # this also works, to show existing filename when present - if field.cstruct and field.cstruct['filename']: - return json.dumps({'name': field.cstruct['filename']}) - return 'null' - if isinstance(field.schema.typ, colander.Set): if field.cstruct is colander.null: return '[]' - if field.cstruct is colander.null: - return 'null' - try: - return json.dumps(field.cstruct) + return self.jsonify_value(field.cstruct) except Exception as error: raise TailboneJSONFieldError(field.name, error) + def jsonify_value(self, value): + """ + Take a Python value and convert to JSON + """ + if value is colander.null: + return 'null' + + if isinstance(value, dfwidget.filedict): + # TODO: we used to always/only return 'null' here but hopefully + # this also works, to show existing filename when present + if value and value['filename']: + return json.dumps({'name': value['filename']}) + return 'null' + + return json.dumps(value) + def get_error_messages(self, field): if field.error: return field.error.messages() diff --git a/tailbone/forms/widgets.py b/tailbone/forms/widgets.py index e72ab6b9..02fcdb76 100644 --- a/tailbone/forms/widgets.py +++ b/tailbone/forms/widgets.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -289,6 +289,79 @@ class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget): return field.renderer(template, **tmpl_values) +class MultiFileUploadWidget(dfwidget.FileUploadWidget): + """ + Widget to handle multiple (arbitrary number) of file uploads. + """ + template = 'multi_file_upload' + requirements = () + + def deserialize(self, field, pstruct): + if pstruct is colander.null: + return colander.null + + # TODO: why is this a thing? pstruct == [b''] + if len(pstruct) == 1 and not pstruct[0]: + return colander.null + + files_data = [] + for upload in pstruct: + + data = self.deserialize_upload(upload) + if data: + files_data.append(data) + + if not files_data: + return colander.null + + return files_data + + def deserialize_upload(self, upload): + # nb. this logic was copied from parent class and adapted + # to allow for multiple files. needs some more love. + + uid = None # TODO? + + if hasattr(upload, "file"): + # the upload control had a file selected + data = dfwidget.filedict() + data["fp"] = upload.file + filename = upload.filename + # sanitize IE whole-path filenames + filename = filename[filename.rfind("\\") + 1 :].strip() + data["filename"] = filename + data["mimetype"] = upload.type + data["size"] = upload.length + if uid is None: + # no previous file exists + while 1: + uid = self.random_id() + if self.tmpstore.get(uid) is None: + data["uid"] = uid + self.tmpstore[uid] = data + preview_url = self.tmpstore.preview_url(uid) + self.tmpstore[uid]["preview_url"] = preview_url + break + else: + # a previous file exists + data["uid"] = uid + self.tmpstore[uid] = data + preview_url = self.tmpstore.preview_url(uid) + self.tmpstore[uid]["preview_url"] = preview_url + else: + # the upload control had no file selected + if uid is None: + # no previous file exists + return colander.null + else: + # a previous file should exist + data = self.tmpstore.get(uid) + # but if it doesn't, don't blow up + if data is None: + return colander.null + return data + + def make_customer_widget(request, **kwargs): """ Make a customer widget; will be either autocomplete or dropdown diff --git a/tailbone/templates/deform/multi_file_upload.pt b/tailbone/templates/deform/multi_file_upload.pt new file mode 100644 index 00000000..f94e59c8 --- /dev/null +++ b/tailbone/templates/deform/multi_file_upload.pt @@ -0,0 +1,7 @@ +<tal:block tal:define="field_name field_name|field.name; + vmodel vmodel|'field_model_' + field_name;"> + ${field.start_sequence()} + <multi-file-upload v-model="${vmodel}"> + </multi-file-upload> + ${field.end_sequence()} +</tal:block> diff --git a/tailbone/templates/multi_file_upload.mako b/tailbone/templates/multi_file_upload.mako new file mode 100644 index 00000000..ea9b5121 --- /dev/null +++ b/tailbone/templates/multi_file_upload.mako @@ -0,0 +1,60 @@ +## -*- coding: utf-8; -*- + +<%def name="render_template()"> + <script type="text/x-template" id="multi-file-upload-template"> + <section> + <b-field class="file"> + <b-upload name="upload" multiple drag-drop expanded + v-model="files"> + <section class="section"> + <div class="content has-text-centered"> + <p> + <b-icon icon="upload" size="is-large"></b-icon> + </p> + <p>Drop your files here or click to upload</p> + </div> + </section> + </b-upload> + </b-field> + + <div class="tags" style="max-width: 40rem;"> + <span v-for="(file, index) in files" :key="index" class="tag is-primary"> + {{file.name}} + <button class="delete is-small" type="button" + @click="deleteFile(index)"> + </button> + </span> + </div> + </section> + </script> +</%def> + +<%def name="declare_vars()"> + <script type="text/javascript"> + + let MultiFileUpload = { + template: '#multi-file-upload-template', + methods: { + + deleteFile(index) { + this.files.splice(index, 1); + }, + }, + } + + let MultiFileUploadData = { + files: [], + } + + </script> +</%def> + +<%def name="make_component()"> + <script type="text/javascript"> + + MultiFileUpload.data = function() { return MultiFileUploadData } + + Vue.component('multi-file-upload', MultiFileUpload) + + </script> +</%def> diff --git a/tailbone/templates/receiving/configure.mako b/tailbone/templates/receiving/configure.mako index 9f4a6c3b..faa13a24 100644 --- a/tailbone/templates/receiving/configure.mako +++ b/tailbone/templates/receiving/configure.mako @@ -24,7 +24,16 @@ v-model="simpleSettings['rattail.batch.purchase.allow_receiving_from_invoice']" native-value="true" @input="settingsNeedSaved = true"> - From Invoice + From Single Invoice + </b-checkbox> + </b-field> + + <b-field> + <b-checkbox name="rattail.batch.purchase.allow_receiving_from_multi_invoice" + v-model="simpleSettings['rattail.batch.purchase.allow_receiving_from_multi_invoice']" + native-value="true" + @input="settingsNeedSaved = true"> + From Multiple (Combined) Invoices </b-checkbox> </b-field> diff --git a/tailbone/templates/receiving/view_row.mako b/tailbone/templates/receiving/view_row.mako index dca71c35..8c397c4f 100644 --- a/tailbone/templates/receiving/view_row.mako +++ b/tailbone/templates/receiving/view_row.mako @@ -81,12 +81,12 @@ <div class="panel-block"> <div style="display: flex;"> <div> + ${form.render_field_readonly('item_entry')} % if row.product: ${form.render_field_readonly(product_key_field)} ${form.render_field_readonly('product')} % else: ${form.render_field_readonly(product_key_field)} - ${form.render_field_readonly('item_entry')} % if product_key_field != 'upc': ${form.render_field_readonly('upc')} % endif diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index 654f61df..3cfa00a2 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -4,6 +4,7 @@ <%namespace name="base_meta" file="/base_meta.mako" /> <%namespace file="/formposter.mako" import="declare_formposter_mixin" /> <%namespace name="page_help" file="/page_help.mako" /> +<%namespace name="multi_file_upload" file="/multi_file_upload.mako" /> <!DOCTYPE html> <html lang="en"> <head> @@ -577,6 +578,7 @@ </script> ${tailbone_autocomplete_template()} + ${multi_file_upload.render_template()} </%def> <%def name="render_this_page_component()"> @@ -764,6 +766,7 @@ <%def name="declare_whole_page_vars()"> ${page_help.declare_vars()} + ${multi_file_upload.declare_vars()} ${h.javascript_link(request.static_url('tailbone:static/themes/falafel/js/tailbone.feedback.js') + '?ver={}'.format(tailbone.__version__))} <script type="text/javascript"> @@ -902,6 +905,7 @@ ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.autocomplete.js') + '?ver={}'.format(tailbone.__version__))} ${page_help.make_component()} + ${multi_file_upload.make_component()} <script type="text/javascript"> diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index d0babe87..efb61b20 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -362,6 +362,7 @@ class BatchMasterView(MasterView): f.remove('params') else: f.set_readonly('params') + f.set_renderer('params', self.render_params) # created f.set_readonly('created') @@ -419,6 +420,16 @@ class BatchMasterView(MasterView): f.remove_fields('executed', 'executed_by') + def render_params(self, batch, field): + params = self.get_visible_params(batch) + if not params: + return + + return params + + def get_visible_params(self, batch): + return dict(batch.params or {}) + def render_complete(self, batch, field): permission_prefix = self.get_permission_prefix() use_buefy = self.get_use_buefy() @@ -515,11 +526,21 @@ class BatchMasterView(MasterView): return batch def process_uploads(self, batch, form, uploads): - for key, upload in six.iteritems(uploads): + + def process(upload, key): self.handler.set_input_file(batch, upload['temp_path'], attr=key) os.remove(upload['temp_path']) os.rmdir(upload['tempdir']) + for key, upload in six.iteritems(uploads): + if isinstance(upload, dict): + process(upload, key) + else: + uploads = upload + for upload in uploads: + if isinstance(upload, dict): + process(upload, key) + def get_batch_kwargs(self, batch, **kwargs): """ Return a kwargs dict for use with ``self.handler.make_batch()``, using diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 096108a6..1771a3b7 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -53,6 +53,7 @@ from rattail.gpc import GPC import colander import deform +from deform import widget as dfwidget from pyramid import httpexceptions from pyramid.renderers import get_renderer, render_to_response, render from pyramid.response import FileResponse @@ -691,26 +692,40 @@ class MasterView(View): def normalize_uploads(self, form, skip=None): uploads = {} + + def normalize(filedict): + tempdir = tempfile.mkdtemp() + filepath = os.path.join(tempdir, filedict['filename']) + tmpinfo = form.deform_form[node.name].widget.tmpstore.get(filedict['uid']) + tmpdata = tmpinfo['fp'].read() + with open(filepath, 'wb') as f: + f.write(tmpdata) + return {'tempdir': tempdir, + 'temp_path': filepath} + for node in form.schema: - if isinstance(node.typ, deform.FileData): - if skip and node.name in skip: - continue - # TODO: does form ever *not* have 'validated' attr here? - if hasattr(form, 'validated'): - filedict = form.validated.get(node.name) + if skip and node.name in skip: + continue + + value = form.validated.get(node.name) + if not value: + continue + + if isinstance(value, dfwidget.filedict): + uploads[node.name] = normalize(value) + + elif not isinstance(value, dict): + + try: + values = iter(value) + except TypeError: + pass else: - filedict = self.form_deserialized.get(node.name) - if filedict: - tempdir = tempfile.mkdtemp() - filepath = os.path.join(tempdir, filedict['filename']) - tmpinfo = form.deform_form[node.name].widget.tmpstore.get(filedict['uid']) - tmpdata = tmpinfo['fp'].read() - with open(filepath, 'wb') as f: - f.write(tmpdata) - uploads[node.name] = { - 'tempdir': tempdir, - 'temp_path': filepath, - } + for value in values: + if isinstance(value, dfwidget.filedict): + uploads.setdefault(node.name, []).append( + normalize(value)) + return uploads def process_uploads(self, obj, form, uploads): diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 7b668dc5..cf1e802e 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -26,6 +26,7 @@ Views for 'receiving' (purchasing) batches from __future__ import unicode_literals, absolute_import +import os import re import decimal import logging @@ -551,6 +552,15 @@ class ReceivingBatchView(PurchasingBatchView): if not self.editing: f.remove_field('order_quantities_known') + # multiple invoice files (if applicable) + if (not self.creating + and batch.get_param('receiving_workflow') == 'from_multi_invoice'): + + if 'invoice_files' not in f: + f.insert_before('invoice_file', 'invoice_files') + f.set_renderer('invoice_files', self.render_invoice_files) + f.set_readonly('invoice_files', True) + # invoice totals f.set_label('invoice_total', "Invoice Total (Orig.)") f.set_label('invoice_total_calculated', "Invoice Total (Calc.)") @@ -584,6 +594,17 @@ class ReceivingBatchView(PurchasingBatchView): 'invoice_date', 'invoice_number') + elif workflow == 'from_multi_invoice': + if 'invoice_files' not in f: + f.insert_before('invoice_file', 'invoice_files') + f.set_type('invoice_files', 'multi_file') + f.set_required('invoice_parser_key') + f.remove('truck_dump_batch_uuid', + 'po_number', + 'invoice_file', + 'invoice_date', + 'invoice_number') + elif workflow == 'from_po': f.remove('truck_dump_batch_uuid', 'date_ordered', @@ -620,12 +641,31 @@ class ReceivingBatchView(PurchasingBatchView): 'invoice_date', 'invoice_number') + def render_invoice_files(self, batch, field): + datadir = self.batch_handler.datadir(batch) + items = [] + for filename in batch.get_param('invoice_files', []): + path = os.path.join(datadir, filename) + url = self.get_action_url('download', batch, + _query={'filename': filename}) + link = self.render_file_field(path, url) + items.append(HTML.tag('li', c=[link])) + return HTML.tag('ul', c=items) + def render_receiving_workflow(self, batch, field): key = self.request.matchdict['workflow_key'] info = self.handler.receiving_workflow_info(key) if info: return info['display'] + def get_visible_params(self, batch): + params = super(ReceivingBatchView, self).get_visible_params(batch) + + # remove this since we show it separately + params.pop('invoice_files', None) + + return params + def template_kwargs_create(self, **kwargs): kwargs = super(ReceivingBatchView, self).template_kwargs_create(**kwargs) if self.handler.allow_truck_dump_receiving(): @@ -655,6 +695,8 @@ class ReceivingBatchView(PurchasingBatchView): kwargs.pop('truck_dump_batch_uuid', None) elif batch_type == 'from_invoice': pass + elif batch_type == 'from_multi_invoice': + pass elif batch_type == 'from_po': # TODO: how to best handle this field? this doesn't seem flexible kwargs['purchase_key'] = batch.purchase_uuid @@ -1952,6 +1994,9 @@ class ReceivingBatchView(PurchasingBatchView): {'section': 'rattail.batch', 'option': 'purchase.allow_receiving_from_invoice', 'type': bool}, + {'section': 'rattail.batch', + 'option': 'purchase.allow_receiving_from_multi_invoice', + 'type': bool}, {'section': 'rattail.batch', 'option': 'purchase.allow_receiving_from_purchase_order', 'type': bool}, From b8389c72bbe6764c48b52bc6d0d4d1c8d7750716 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 11 Jan 2023 10:29:36 -0600 Subject: [PATCH 0948/1681] Add support for per-item default discount, for new custorder --- tailbone/forms/core.py | 2 ++ tailbone/templates/custorders/configure.mako | 23 ++++++++++++ tailbone/templates/custorders/create.mako | 37 ++++++++++++++++---- tailbone/views/custorders/orders.py | 18 ++++++++-- 4 files changed, 72 insertions(+), 8 deletions(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 99e6ba2d..4f227838 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -891,6 +891,8 @@ class Form(object): return json.dumps({'name': value['filename']}) return 'null' + app = self.request.rattail_config.get_app() + value = app.json_friendly(value) return json.dumps(value) def get_error_messages(self, field): diff --git a/tailbone/templates/custorders/configure.mako b/tailbone/templates/custorders/configure.mako index 6d51e433..ee1f06c5 100644 --- a/tailbone/templates/custorders/configure.mako +++ b/tailbone/templates/custorders/configure.mako @@ -97,6 +97,29 @@ </b-checkbox> </b-field> + <b-field> + <b-checkbox name="rattail.custorders.allow_item_discounts_if_on_sale" + v-model="simpleSettings['rattail.custorders.allow_item_discounts_if_on_sale']" + native-value="true" + @input="settingsNeedSaved = true" + :disabled="!simpleSettings['rattail.custorders.allow_item_discounts']"> + Allow discount even if item is on sale + </b-checkbox> + </b-field> + + <div class="level-left block"> + <div class="level-item">Default item discount</div> + <div class="level-item"> + <b-input name="rattail.custorders.default_item_discount" + v-model="simpleSettings['rattail.custorders.default_item_discount']" + @input="settingsNeedSaved = true" + style="width: 5rem;" + :disabled="!simpleSettings['rattail.custorders.allow_item_discounts']"> + </b-input> + </div> + <div class="level-item">%</div> + </div> + <b-field> <b-checkbox name="rattail.custorders.allow_past_item_reorder" v-model="simpleSettings['rattail.custorders.allow_past_item_reorder']" diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index 45d4a510..f6aa2ed4 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -792,7 +792,8 @@ <b-field grouped> <b-field label="Quantity" horizontal> - <numeric-input v-model="productQuantity"> + <numeric-input v-model="productQuantity" + style="width: 5rem;"> </numeric-input> </b-field> @@ -811,9 +812,10 @@ <b-field label="Discount" horizontal> <div class="level"> <div class="level-item"> - <numeric-input v-model="productDiscountPercent" - style="width: 5rem;"> - </numeric-input> + <numeric-input v-model="productDiscountPercent" + style="width: 5rem;" + :disabled="!allowItemDiscount"> + </numeric-input> </div> <div class="level-item"> <span> %</span> @@ -1238,7 +1240,8 @@ % endif % if allow_item_discounts: - productDiscountPercent: null, + productDiscountPercent: ${json.dumps(default_item_discount)|n}, + allowDiscountsIfOnSale: ${json.dumps(allow_item_discounts_if_on_sale)|n}, % endif pendingProduct: {}, @@ -1421,6 +1424,19 @@ return text }, + % if allow_item_discounts: + + allowItemDiscount() { + if (!this.allowDiscountsIfOnSale) { + if (this.productSalePriceDisplay) { + return false + } + } + return true + }, + + % endif + itemDialogSaveDisabled() { if (this.itemDialogSaving) { return true @@ -1912,7 +1928,7 @@ % endif % if allow_item_discounts: - this.productDiscountPercent = null + this.productDiscountPercent = ${json.dumps(default_item_discount)|n} % endif this.itemDialogTabIndex = 0 @@ -2060,6 +2076,10 @@ this.productImageURL = null this.productUnitChoices = this.defaultUnitChoices + % if allow_item_discounts: + this.productDiscountPercent = ${json.dumps(default_item_discount)|n} + % endif + % if product_price_may_be_questionable: this.productPriceNeedsConfirmation = false % endif @@ -2106,6 +2126,11 @@ this.productSalePrice = response.data.sale_price this.productSalePriceDisplay = response.data.sale_price_display this.productSaleEndsDisplay = response.data.sale_ends_display + + % if allow_item_discounts: + this.productDiscountPercent = this.allowItemDiscount ? response.data.default_item_discount : null + % endif + this.productURL = response.data.url this.productImageURL = response.data.image_url this.setProductUnitChoices(response.data.uom_choices) diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index 7d97b47f..8bc53a67 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -293,6 +293,7 @@ class CustomerOrderView(MasterView): submits the order, at which point the batch is converted to a proper order. """ + app = self.get_rattail_app() # TODO: deprecate / remove this self.handler = self.batch_handler batch = self.get_current_batch() @@ -349,6 +350,10 @@ class CustomerOrderView(MasterView): 'default_uom_choices': self.batch_handler.uom_choices_for_product(None), 'default_uom': None, 'allow_item_discounts': self.batch_handler.allow_item_discounts(), + 'allow_item_discounts_if_on_sale': self.batch_handler.allow_item_discounts_if_on_sale(), + # nb. render quantity so that '10.0' => '10' + 'default_item_discount': app.render_quantity( + self.batch_handler.get_default_item_discount()), 'allow_past_item_reorder': self.batch_handler.allow_past_item_reorder(), }) @@ -633,9 +638,11 @@ class CustomerOrderView(MasterView): return {'error': six.text_type(error)} else: info['url'] = self.request.route_url('products.view', uuid=info['uuid']) - return info + app = self.get_rattail_app() + return app.json_friendly(info) def get_past_items(self, batch, data): + app = self.get_rattail_app() past_products = self.batch_handler.get_past_products(batch) past_items = [] @@ -646,6 +653,7 @@ class CustomerOrderView(MasterView): # nb. handler may raise error if product is "unsupported" pass else: + item = app.json_friendly(item) past_items.append(item) return {'past_items': past_items} @@ -987,6 +995,12 @@ class CustomerOrderView(MasterView): {'section': 'rattail.custorders', 'option': 'allow_item_discounts', 'type': bool}, + {'section': 'rattail.custorders', + 'option': 'allow_item_discounts_if_on_sale', + 'type': bool}, + {'section': 'rattail.custorders', + 'option': 'default_item_discount', + 'type': float}, {'section': 'rattail.custorders', 'option': 'allow_past_item_reorder', 'type': bool}, From 2c7f2c0fcd405a8f408ca110e31f1c29e78e2a42 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 11 Jan 2023 16:02:35 -0600 Subject: [PATCH 0949/1681] Fix panel header icon behavior for new custorder had to work around a buefy bug..? --- tailbone/templates/custorders/create.mako | 78 ++++++++++++++--------- 1 file changed, 48 insertions(+), 30 deletions(-) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index f6aa2ed4..3189c0b3 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -65,21 +65,30 @@ <b-collapse class="panel" :class="customerPanelType" :open.sync="customerPanelOpen"> - <div slot="trigger" - slot-scope="props" - class="panel-heading" - role="button"> - <b-icon pack="fas" - ## TODO: this icon toggling should work, according to - ## Buefy docs, but i could not ever get it to work. - ## what am i missing? - ## https://buefy.org/documentation/collapse/ - ## :icon="props.open ? 'caret-down' : 'caret-right'"> - ## (for now we just always show caret-right instead) - icon="caret-right"> - </b-icon> - <strong v-html="customerPanelHeader"></strong> - </div> + <template #trigger="props"> + <div class="panel-heading" + role="button"> + + ## TODO: for some reason buefy will "reuse" the icon + ## element in such a way that its display does not + ## refresh. so to work around that, we use different + ## structure for the two icons, so buefy is forced to + ## re-draw + + <b-icon v-if="props.open" + pack="fas" + icon="angle-down"> + </b-icon> + + <span v-if="!props.open"> + <b-icon pack="fas" + icon="angle-right"> + </b-icon> + </span> + + <strong v-html="customerPanelHeader"></strong> + </div> + </template> <div class="panel-block"> <div style="width: 100%;"> @@ -460,21 +469,30 @@ <b-collapse class="panel" open> - <div slot="trigger" - slot-scope="props" - class="panel-heading" - role="button"> - <b-icon pack="fas" - ## TODO: this icon toggling should work, according to - ## Buefy docs, but i could not ever get it to work. - ## what am i missing? - ## https://buefy.org/documentation/collapse/ - ## :icon="props.open ? 'caret-down' : 'caret-right'"> - ## (for now we just always show caret-right instead) - icon="caret-right"> - </b-icon> - <strong v-html="itemsPanelHeader"></strong> - </div> + <template #trigger="props"> + <div class="panel-heading" + role="button"> + + ## TODO: for some reason buefy will "reuse" the icon + ## element in such a way that its display does not + ## refresh. so to work around that, we use different + ## structure for the two icons, so buefy is forced to + ## re-draw + + <b-icon v-if="props.open" + pack="fas" + icon="angle-down"> + </b-icon> + + <span v-if="!props.open"> + <b-icon pack="fas" + icon="angle-right"> + </b-icon> + </span> + + <strong v-html="itemsPanelHeader"></strong> + </div> + </template> <div class="panel-block"> <div> From 4746b6fae914835f47991abba62214ec21984976 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 11 Jan 2023 19:24:50 -0600 Subject: [PATCH 0950/1681] Refactor inventory batch "add row" page, per new theme --- .../batch/inventory/desktop_form.mako | 384 +++++++++++++++--- tailbone/templates/form.mako | 2 +- tailbone/views/batch/core.py | 12 +- tailbone/views/batch/inventory.py | 63 +-- 4 files changed, 369 insertions(+), 92 deletions(-) diff --git a/tailbone/templates/batch/inventory/desktop_form.mako b/tailbone/templates/batch/inventory/desktop_form.mako index bc64d498..7e3787ae 100644 --- a/tailbone/templates/batch/inventory/desktop_form.mako +++ b/tailbone/templates/batch/inventory/desktop_form.mako @@ -1,10 +1,11 @@ ## -*- coding: utf-8; -*- -<%inherit file="/base.mako" /> +<%inherit file="/form.mako" /> <%def name="title()">Inventory Form</%def> <%def name="extra_javascript()"> ${parent.extra_javascript()} + % if not use_buefy: ${h.javascript_link(request.static_url('tailbone:static/js/numeric.js'))} <script type="text/javascript"> @@ -200,10 +201,12 @@ }); </script> + % endif </%def> <%def name="extra_styles()"> ${parent.extra_styles()} + % if not use_buefy: <style type="text/css"> #product-info { @@ -226,76 +229,337 @@ } </style> + % endif </%def> - <%def name="context_menu_items()"> + ${parent.context_menu_items()} <li>${h.link_to("Back to Inventory Batch", url('batch.inventory.view', uuid=batch.uuid))}</li> </%def> +<%def name="render_form()"> + % if use_buefy: -<ul id="context-menu"> - ${self.context_menu_items()} -</ul> + <script type="text/x-template" id="${form.component}-template"> + <div class="product-info"> -<div class="form-wrapper"> - ${h.form(form.action_url, id='inventory-form')} - ${h.csrf_token(request)} + ${h.form(form.action_url, **{'@submit': 'handleSubmit'})} + ${h.csrf_token(request)} - <div class="field-wrapper"> - <label for="upc">Product UPC</label> - <div class="field"> - ${h.hidden('product')} - <div>${h.text('upc', autocomplete='off')}</div> - <div id="product-info"> - <p>please ENTER a scancode</p> - <div class="img-wrapper"><img /></div> - <div class="warning notfound">please confirm UPC and provide more details</div> - <div class="warning present">product already exists in batch, please confirm count</div> - <div class="warning force-unit">pack item scanned, but must count units instead</div> + ${h.hidden('product', **{':value': 'productInfo.uuid'})} + ${h.hidden('upc', **{':value': 'productInfo.upc'})} + ${h.hidden('brand_name', **{':value': 'productInfo.brand_name'})} + ${h.hidden('description', **{':value': 'productInfo.description'})} + ${h.hidden('size', **{':value': 'productInfo.size'})} + ${h.hidden('case_quantity', **{':value': 'productInfo.case_quantity'})} + + <b-field label="Product UPC" horizontal> + <div style="display: flex; flex-direction: column;"> + <b-input v-model="productUPC" + ref="productUPC" + @input="productChanged" + @keydown.native="productKeydown"> + </b-input> + <div class="has-text-centered block"> + + <p v-if="!productInfo.uuid" + class="block"> + please ENTER a scancode + </p> + + <p v-if="productInfo.uuid" + class="block"> + {{ productInfo.full_description }} + </p> + + <div style="min-height: 150px; margin: 0.5rem 0;"> + <img v-if="productInfo.uuid" + :src="productInfo.image_url" /> + </div> + + <div v-if="alreadyPresentInBatch" + class="has-background-danger"> + product already exists in batch, please confirm count + </div> + + <div v-if="forceUnitItem" + class="has-background-danger"> + pack item scanned, but must count units instead + </div> + +## <div v-if="productNotFound" +## class="has-background-danger"> +## please confirm UPC and provide more details +## </div> + + </div> + </div> + </b-field> + +## <div v-if="productNotFound" +## ## class="product-fields" +## > +## +## <div class="field-wrapper brand_name"> +## <label for="brand_name">Brand Name</label> +## <div class="field">${h.text('brand_name')}</div> +## </div> +## +## <div class="field-wrapper description"> +## <label for="description">Description</label> +## <div class="field">${h.text('description')}</div> +## </div> +## +## <div class="field-wrapper size"> +## <label for="size">Size</label> +## <div class="field">${h.text('size')}</div> +## </div> +## +## <div class="field-wrapper case_quantity"> +## <label for="case_quantity">Units in Case</label> +## <div class="field">${h.text('case_quantity')}</div> +## </div> +## +## </div> + + % if allow_cases: + <b-field label="Cases" horizontal> + <b-input name="cases" + v-model="productCases" + ref="productCases" + :disabled="!productInfo.uuid"> + </b-input> + </b-field> + % endif + + <b-field label="Units" horizontal> + <b-input name="units" + v-model="productUnits" + ref="productUnits" + :disabled="!productInfo.uuid"> + </b-input> + </b-field> + + <b-button type="is-primary" + native-type="submit" + :disabled="submitting"> + {{ submitting ? "Working, please wait..." : "Submit" }} + </b-button> + + ${h.end_form()} + </div> + </script> + + <script type="text/javascript"> + + let ${form.component_studly} = { + template: '#${form.component}-template', + + mounted() { + this.$refs.productUPC.focus() + }, + + methods: { + + clearProduct() { + this.productInfo = {} + ## this.productNotFound = false + this.alreadyPresentInBatch = false + this.forceUnitItem = false + this.productCases = null + this.productUnits = null + }, + + assertQuantity() { + + % if allow_cases: + let cases = parseFloat(this.productCases) + if (!isNaN(cases)) { + if (cases > 999999) { + alert("Case amount is invalid!") + this.$refs.productCases.focus() + return false + } + return true + } + % endif + + let units = parseFloat(this.productUnits) + if (!isNaN(units)) { + if (units > 999999) { + alert("Unit amount is invalid!") + this.$refs.productUnits.focus() + return false + } + return true + } + + alert("Please provide case and/or unit quantity") + % if allow_cases: + this.$refs.productCases.focus() + % else: + this.$refs.productUnits.focus() + % endif + }, + + handleSubmit(event) { + if (!this.assertQuantity()) { + event.preventDefault() + return + } + this.submitting = true + }, + + productChanged() { + this.clearProduct() + }, + + productKeydown(event) { + if (event.which == 13) { // ENTER + this.productLookup() + event.preventDefault() + } + }, + + productLookup() { + let url = '${url('batch.inventory.desktop_lookup', uuid=batch.uuid)}' + let params = { + upc: this.productUPC, + } + this.$http.get(url, {params: params}).then(response => { + + if (response.data.error) { + alert(response.data.error) + if (response.data.redirect) { + location.href = response.data.redirect + } + + } else if (response.data.product.uuid) { + + this.productUPC = response.data.product.upc_pretty + this.productInfo = response.data.product + this.forceUnitItem = response.data.force_unit_item + this.alreadyPresentInBatch = response.data.already_present_in_batch + + if (this.alreadyPresentInBatch) { + this.productCases = response.data.cases + this.productUnits = response.data.units + } else if (this.productInfo.type2) { + this.productUnits = this.productInfo.units + } + + this.$nextTick(() => { + if (this.productInfo.type2) { + this.$refs.productUnits.focus() + } else { + % if allow_cases and prefer_cases: + if (this.productCases) { + this.$refs.productCases.focus() + } else if (this.productUnits) { + this.$refs.productUnits.focus() + } else { + this.$refs.productCases.focus() + } + % else: + this.$refs.productUnits.focus() + % endif + } + }) + + } else { + ## this.productNotFound = true + alert("Product not found!") + } + }) + }, + }, + } + + let ${form.component_studly}Data = { + submitting: false, + + productUPC: null, + ## productNotFound: false, + productInfo: {}, + + % if allow_cases: + productCases: null, + % endif + productUnits: null, + + alreadyPresentInBatch: false, + forceUnitItem: false, + } + + </script> + + % else: + ## not buefy + + <div class="form-wrapper"> + ${h.form(form.action_url, id='inventory-form')} + ${h.csrf_token(request)} + + <div class="field-wrapper"> + <label for="upc">Product UPC</label> + <div class="field"> + ${h.hidden('product')} + <div>${h.text('upc', autocomplete='off')}</div> + <div id="product-info"> + <p>please ENTER a scancode</p> + <div class="img-wrapper"><img /></div> + <div class="warning notfound">please confirm UPC and provide more details</div> + <div class="warning present">product already exists in batch, please confirm count</div> + <div class="warning force-unit">pack item scanned, but must count units instead</div> + </div> + </div> + </div> + + <div class="product-fields" style="display: none;"> + + <div class="field-wrapper brand_name"> + <label for="brand_name">Brand Name</label> + <div class="field">${h.text('brand_name')}</div> + </div> + + <div class="field-wrapper description"> + <label for="description">Description</label> + <div class="field">${h.text('description')}</div> + </div> + + <div class="field-wrapper size"> + <label for="size">Size</label> + <div class="field">${h.text('size')}</div> + </div> + + <div class="field-wrapper case_quantity"> + <label for="case_quantity">Units in Case</label> + <div class="field">${h.text('case_quantity')}</div> + </div> + + </div> + + % if allow_cases: + <div class="field-wrapper cases"> + <label for="cases">Cases</label> + <div class="field">${h.text('cases', autocomplete='off')}</div> + </div> + % endif + + <div class="field-wrapper units"> + <label for="units">Units</label> + <div class="field">${h.text('units', autocomplete='off')}</div> + </div> + + <div class="buttons"> + ${h.submit('submit', "Submit")} + </div> + + ${h.end_form()} </div> - </div> - </div> - <div class="product-fields" style="display: none;"> - - <div class="field-wrapper brand_name"> - <label for="brand_name">Brand Name</label> - <div class="field">${h.text('brand_name')}</div> - </div> - - <div class="field-wrapper description"> - <label for="description">Description</label> - <div class="field">${h.text('description')}</div> - </div> - - <div class="field-wrapper size"> - <label for="size">Size</label> - <div class="field">${h.text('size')}</div> - </div> - - <div class="field-wrapper case_quantity"> - <label for="case_quantity">Units in Case</label> - <div class="field">${h.text('case_quantity')}</div> - </div> - - </div> - - % if allow_cases: - <div class="field-wrapper cases"> - <label for="cases">Cases</label> - <div class="field">${h.text('cases', autocomplete='off')}</div> - </div> % endif +</%def> - <div class="field-wrapper units"> - <label for="units">Units</label> - <div class="field">${h.text('units', autocomplete='off')}</div> - </div> - <div class="buttons"> - ${h.submit('submit', "Submit")} - </div> - - ${h.end_form()} -</div> +${parent.body()} diff --git a/tailbone/templates/form.mako b/tailbone/templates/form.mako index 19e5a4a7..a00b8d97 100644 --- a/tailbone/templates/form.mako +++ b/tailbone/templates/form.mako @@ -57,7 +57,7 @@ <%def name="before_object_helpers()"></%def> <%def name="render_this_page_template()"> - % if form is not Underined: + % if form is not Undefined: ${self.render_form()} % endif ${parent.render_this_page_template()} diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index efb61b20..1c35169a 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -705,9 +705,15 @@ class BatchMasterView(MasterView): if self.rows_creatable and not batch.executed and not batch.complete: permission_prefix = self.get_permission_prefix() if self.request.has_perm('{}.create_row'.format(permission_prefix)): - link = tags.link_to("Create a new {}".format(self.get_row_model_title()), - self.get_action_url('create_row', batch)) - return HTML.tag('p', c=[link]) + url = self.get_action_url('create_row', batch) + if self.get_use_buefy(): + return self.make_buefy_button("New Row", url=url, + is_primary=True, + icon_left='plus') + else: + text = "Create a new {}".format(self.get_row_model_title()) + link = tags.link_to(text, url) + return HTML.tag('p', c=[link]) def make_batch_row_grid_tools(self, batch): if self.get_use_buefy(): diff --git a/tailbone/views/batch/inventory.py b/tailbone/views/batch/inventory.py index f8699725..48bc9267 100644 --- a/tailbone/views/batch/inventory.py +++ b/tailbone/views/batch/inventory.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2021 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -234,40 +234,47 @@ class InventoryBatchView(BatchMasterView): if batch.executed: return self.redirect(self.get_action_url('view', batch)) + use_buefy = self.get_use_buefy() schema = DesktopForm().bind(session=self.Session()) - form = forms.Form(schema=schema, request=self.request) - if form.validate(newstyle=True): + form = forms.Form(schema=schema, request=self.request, use_buefy=use_buefy) + if self.request.method == 'POST': + if form.validate(newstyle=True): - product = self.Session.query(model.Product).get(form.validated['product']) + product = self.Session.query(model.Product).get(form.validated['product']) - row = None - if self.should_aggregate_products(batch): - row = self.find_row_for_product(batch, product) - if row: + row = None + if self.should_aggregate_products(batch): + row = self.find_row_for_product(batch, product) + if row: + row.cases = form.validated['cases'] + row.units = form.validated['units'] + self.handler.refresh_row(row) + + if not row: + row = model.InventoryBatchRow() + row.product = product + row.upc = form.validated['upc'] + row.brand_name = form.validated['brand_name'] + row.description = form.validated['description'] + row.size = form.validated['size'] + row.case_quantity = form.validated['case_quantity'] row.cases = form.validated['cases'] row.units = form.validated['units'] - self.handler.refresh_row(row) + self.handler.capture_current_units(row) + self.handler.add_row(batch, row) - if not row: - row = model.InventoryBatchRow() - row.product = product - row.upc = form.validated['upc'] - row.brand_name = form.validated['brand_name'] - row.description = form.validated['description'] - row.size = form.validated['size'] - row.case_quantity = form.validated['case_quantity'] - row.cases = form.validated['cases'] - row.units = form.validated['units'] - self.handler.capture_current_units(row) - self.handler.add_row(batch, row) + description = make_full_description(form.validated['brand_name'], + form.validated['description'], + form.validated['size']) + self.request.session.flash("{} cases, {} units: {} {}".format( + form.validated['cases'] or 0, form.validated['units'] or 0, + form.validated['upc'].pretty(), description)) + return self.redirect(self.request.current_route_url()) - description = make_full_description(form.validated['brand_name'], - form.validated['description'], - form.validated['size']) - self.request.session.flash("{} cases, {} units: {} {}".format( - form.validated['cases'] or 0, form.validated['units'] or 0, - form.validated['upc'].pretty(), description)) - return self.redirect(self.request.current_route_url()) + else: + dform = form.make_deform_form() + msg = "Form did not validate: {}".format(six.text_type(dform.error)) + self.request.session.flash(msg, 'error') title = self.get_instance_title(batch) return self.render_to_response('desktop_form', { From fa1cf353b86ff1979363d597d4168a0e0f94fc57 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 11 Jan 2023 19:55:52 -0600 Subject: [PATCH 0951/1681] 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 4d628263..722886ea 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,18 @@ CHANGELOG ========= +0.8.279 (2023-01-11) +-------------------- + +* Add basic support for receiving from multiple invoice files. + +* Add support for per-item default discount, for new custorder. + +* Fix panel header icon behavior for new custorder. + +* Refactor inventory batch "add row" page, per new theme. + + 0.8.278 (2023-01-08) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 43ec4886..13c8abaa 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.278' +__version__ = '0.8.279' From 225e13f43bedc853ef6deed42adda660b941c003 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 11 Jan 2023 23:29:28 -0600 Subject: [PATCH 0952/1681] Allow all external dependency URLs to be set in config so can host all files locally if needed. we also now assume all themes support buefy unless otherwise configured --- tailbone/templates/themes/falafel/base.mako | 20 ++++++++------------ tailbone/util.py | 11 +++-------- 2 files changed, 11 insertions(+), 20 deletions(-) diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index 3cfa00a2..888b3bb6 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -107,25 +107,21 @@ </%def> <%def name="jquery()"> - ## jQuery 1.12.4 - ${h.javascript_link('https://code.jquery.com/jquery-1.12.4.min.js')} + ${h.javascript_link(request.rattail_config.get('tailbone', 'liburl.jquery', default='https://code.jquery.com/jquery-1.12.4.min.js'))} </%def> <%def name="vuejs()"> - ${h.javascript_link('https://unpkg.com/vue@{}/dist/vue.min.js'.format(vue_version))} - - ## vue-resource - ## (needed for e.g. this.$http.get() calls, used by grid at least) - ## TODO: make this configurable also - ${h.javascript_link('https://cdn.jsdelivr.net/npm/vue-resource@1.5.1')} + ${h.javascript_link(request.rattail_config.get('tailbone', 'liburl.vue', default='https://unpkg.com/vue@{}/dist/vue.min.js'.format(vue_version)))} + ## TODO: make this version configurable also + ${h.javascript_link(request.rattail_config.get('tailbone', 'liburl.vue_resource', default='https://cdn.jsdelivr.net/npm/vue-resource@1.5.1'))} </%def> <%def name="buefy()"> - ${h.javascript_link('https://unpkg.com/buefy@{}/dist/buefy.min.js'.format(buefy_version))} + ${h.javascript_link(request.rattail_config.get('tailbone', 'liburl.buefy', default='https://unpkg.com/buefy@{}/dist/buefy.min.js'.format(buefy_version)))} </%def> <%def name="fontawesome()"> - <script defer src="https://use.fontawesome.com/releases/v5.3.1/js/all.js"></script> + <script defer src="${request.rattail_config.get('tailbone', 'liburl.fontawesome', default='https://use.fontawesome.com/releases/v5.3.1/js/all.js')}"></script> </%def> <%def name="extra_javascript()"></%def> @@ -163,14 +159,14 @@ ${h.stylesheet_link(buefy_css)} % else: ## upstream Buefy CSS - ${h.stylesheet_link('https://unpkg.com/buefy@{}/dist/buefy.min.css'.format(buefy_version))} + ${h.stylesheet_link(request.rattail_config.get('tailbone', 'liburl.buefy.css', default='https://unpkg.com/buefy@{}/dist/buefy.min.css'.format(buefy_version)))} % endif </%def> ## TODO: this is only being referenced by the progress template i think? ## (so, should make a Buefy progress page at least) <%def name="jquery_theme()"> - ${h.stylesheet_link('https://code.jquery.com/ui/1.11.4/themes/dark-hive/jquery-ui.css')} + ${h.stylesheet_link(request.rattail_config.get('tailbone', 'liburl.jquery.css', default='https://code.jquery.com/ui/1.11.4/themes/dark-hive/jquery-ui.css'))} </%def> <%def name="extra_styles()"></%def> diff --git a/tailbone/util.py b/tailbone/util.py index c1d39eac..a9aa3bf3 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -109,13 +109,8 @@ def should_use_buefy(request): if buefy is not None: return buefy - # TODO: should not hard-code this surely, but works for now... - if theme == 'falafel': - return True - - # TODO: probably should not use this fallback? it was the first setting - # i tested with, but is poorly named to say the least - return request.rattail_config.getbool('tailbone', 'grids.use_buefy', default=False) + # otherwise assume buefy is in effect + return True def pretty_datetime(config, value): From 2163522e7c9baf9d00e039f3d00ce98f0709344e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 11 Jan 2023 23:31:09 -0600 Subject: [PATCH 0953/1681] 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 722886ea..b08f2e9f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.280 (2023-01-11) +-------------------- + +* Allow all external dependency URLs to be set in config. + + 0.8.279 (2023-01-11) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 13c8abaa..5716918c 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.279' +__version__ = '0.8.280' From d842a3d8e0c4f875fe78599d4342dd0082d2db39 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 12 Jan 2023 15:19:46 -0600 Subject: [PATCH 0954/1681] Add new views for App Info, and Configure App and a way to specify version/url overrides for buefy, vue etc. also, begin logic for "standard" admin menu --- tailbone/config.py | 14 +- tailbone/grids/core.py | 12 +- tailbone/helpers.py | 5 +- tailbone/menus.py | 102 ++++++++- tailbone/subscribers.py | 13 +- tailbone/templates/appinfo/configure.mako | 242 ++++++++++++++++++++ tailbone/templates/appinfo/index.mako | 114 +++++++++ tailbone/templates/themes/falafel/base.mako | 15 +- tailbone/util.py | 100 ++++++++ tailbone/views/master.py | 4 + tailbone/views/settings.py | 157 ++++++++++++- 11 files changed, 752 insertions(+), 26 deletions(-) create mode 100644 tailbone/templates/appinfo/configure.mako create mode 100644 tailbone/templates/appinfo/index.mako diff --git a/tailbone/config.py b/tailbone/config.py index 1cb6236e..bcdde8a6 100644 --- a/tailbone/config.py +++ b/tailbone/config.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -26,6 +26,7 @@ Rattail config extension for Tailbone from __future__ import unicode_literals, absolute_import +import warnings from pkg_resources import parse_version from rattail.config import ConfigExtension as BaseExtension @@ -64,7 +65,16 @@ def csrf_header_name(config): def get_buefy_version(config): - return config.get('tailbone', 'buefy_version') or '0.8.17' + warnings.warn("get_buefy_version() is deprecated; please use " + "tailbone.util.get_libver() instead", + DeprecationWarning, stacklevel=2) + + version = config.get('tailbone', 'libver.buefy') + if version: + return version + + return config.get('tailbone', 'buefy_version', + default='latest') def get_buefy_0_8(config, version=None): diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 78fd2cc6..59ab6018 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -453,10 +453,18 @@ class Grid(object): return pretty_boolean(value) def obtain_value(self, obj, column_name): + """ + Try to obtain and return the value from the given object, for + the given column name. + + :returns: The value, or ``None`` if no value was found. + """ try: return obj[column_name] + except KeyError: + pass except TypeError: - return getattr(obj, column_name) + return getattr(obj, column_name, None) def render_currency(self, obj, column_name): value = self.obtain_value(obj, column_name) diff --git a/tailbone/helpers.py b/tailbone/helpers.py index 750d3f39..aeb6aa01 100644 --- a/tailbone/helpers.py +++ b/tailbone/helpers.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -41,7 +41,8 @@ from webhelpers2.html.tags import * from tailbone.util import (csrf_token, get_csrf_token, pretty_datetime, raw_datetime, render_markdown, - route_exists) + route_exists, + get_liburl) def pretty_date(date): diff --git a/tailbone/menus.py b/tailbone/menus.py index 8b432879..7da22696 100644 --- a/tailbone/menus.py +++ b/tailbone/menus.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -403,3 +403,103 @@ def mark_allowed(request, menus): if item['allowed'] and item.get('type') != 'sep': topitem['allowed'] = True break + + +def make_admin_menu(request, include_stores=False): + """ + Generate a typical Admin menu + """ + items = [] + + if include_stores: + items.append({ + 'title': "Stores", + 'route': 'stores', + 'perm': 'stores.list', + }) + + items.extend([ + { + 'title': "Users", + 'route': 'users', + 'perm': 'users.list', + }, + { + 'title': "User Events", + 'route': 'userevents', + 'perm': 'userevents.list', + }, + { + 'title': "Roles", + 'route': 'roles', + 'perm': 'roles.list', + }, + {'type': 'sep'}, + { + 'title': "App Settings", + 'route': 'appsettings', + 'perm': 'settings.list', + }, + { + 'title': "Email Settings", + 'route': 'emailprofiles', + 'perm': 'emailprofiles.list', + }, + { + 'title': "Email Attempts", + 'route': 'email_attempts', + 'perm': 'email_attempts.list', + }, + { + 'title': "Raw Settings", + 'route': 'settings', + 'perm': 'settings.list', + }, + {'type': 'sep'}, + { + 'title': "DataSync Changes", + 'route': 'datasyncchanges', + 'perm': 'datasync_changes.list', + }, + { + 'title': "DataSync Status", + 'route': 'datasync.status', + 'perm': 'datasync.status', + }, + { + 'title': "Importing / Exporting", + 'route': 'importing', + 'perm': 'importing.list', + }, + { + 'title': "Luigi Tasks", + 'route': 'luigi', + 'perm': 'luigi.list', + }, + { + 'title': "Tables", + 'route': 'tables', + 'perm': 'tables.list', + }, + { + 'title': "App Info", + 'route': 'appinfo', + 'perm': 'appinfo.list', + }, + { + 'title': "Configure App", + 'route': 'appinfo.configure', + 'perm': 'appinfo.configure', + }, + { + 'title': "Upgrades", + 'route': 'upgrades', + 'perm': 'upgrades.list', + }, + ]) + + return { + 'title': "Admin", + 'type': 'menu', + 'items': items, + } diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 4aed36cd..cbbcb95a 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -41,9 +41,9 @@ import tailbone from tailbone import helpers from tailbone.db import Session from tailbone.config import (csrf_header_name, should_expose_websockets, - get_buefy_version, get_buefy_0_8) + get_buefy_0_8) from tailbone.menus import make_simple_menus -from tailbone.util import should_use_buefy, get_global_search_options +from tailbone.util import should_use_buefy, get_global_search_options, get_libver def new_request(event): @@ -160,13 +160,8 @@ def before_render(event): # buefy themes get some extra treatment if should_use_buefy(request): - # declare vue.js and buefy versions to use. the default - # values here are "quite conservative" as of this writing, - # perhaps too much so, but at least they should work fine. - renderer_globals['vue_version'] = request.rattail_config.get( - 'tailbone', 'vue_version') or '2.6.10' - version = get_buefy_version(rattail_config) - renderer_globals['buefy_version'] = version + # TODO: remove this hack once all nodes safely on buefy 0.9 + version = get_libver(request, 'buefy') renderer_globals['buefy_0_8'] = get_buefy_0_8(rattail_config, version=version) diff --git a/tailbone/templates/appinfo/configure.mako b/tailbone/templates/appinfo/configure.mako new file mode 100644 index 00000000..821f937f --- /dev/null +++ b/tailbone/templates/appinfo/configure.mako @@ -0,0 +1,242 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="form_content()"> + + <h3 class="block is-size-3">Basics</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field grouped> + + <b-field label="App Title"> + <b-input name="rattail.app_title" + v-model="simpleSettings['rattail.app_title']" + @input="settingsNeedSaved = true"> + </b-input> + </b-field> + + <b-field label="Node Type"> + ## TODO: should be a dropdown, app handler defines choices + <b-input name="rattail.node_type" + v-model="simpleSettings['rattail.node_type']" + @input="settingsNeedSaved = true"> + </b-input> + </b-field> + + <b-field label="Node Title"> + <b-input name="rattail.node_title" + v-model="simpleSettings['rattail.node_title']" + @input="settingsNeedSaved = true"> + </b-input> + </b-field> + + </b-field> + + <b-field> + <b-checkbox name="rattail.production" + v-model="simpleSettings['rattail.production']" + native-value="true" + @input="settingsNeedSaved = true"> + Production Mode + </b-checkbox> + </b-field> + + </div> + + <h3 class="block is-size-3">Display</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field grouped> + + <b-field label="Background Color"> + <b-input name="tailbone.background_color" + v-model="simpleSettings['tailbone.background_color']" + @input="settingsNeedSaved = true"> + </b-input> + </b-field> + + </b-field> + + </div> + + <h3 class="block is-size-3">Grids</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field grouped> + + <b-field label="Default Page Size"> + <b-input name="tailbone.grid.default_pagesize" + v-model="simpleSettings['tailbone.grid.default_pagesize']" + @input="settingsNeedSaved = true"> + </b-input> + </b-field> + + </b-field> + + </div> + + <h3 class="block is-size-3">Web Libraries</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-table :data="weblibs"> + + % if buefy_0_8: + <template slot-scope="props"> + % endif + + <b-table-column field="title" + label="Name" + % if not buefy_0_8: + v-slot="props" + % endif + > + {{ props.row.title }} + </b-table-column> + + <b-table-column field="configured_version" + label="Version" + % if not buefy_0_8: + v-slot="props" + % endif + > + {{ props.row.configured_version || props.row.default_version }} + </b-table-column> + + <b-table-column field="configured_url" + label="URL Override" + % if not buefy_0_8: + v-slot="props" + % endif + > + {{ props.row.configured_url }} + </b-table-column> + + <b-table-column field="live_url" + label="Effective (Live) URL" + % if not buefy_0_8: + v-slot="props" + % endif + > + <span v-if="props.row.modified" + class="has-text-warning"> + save settings and refresh page to see new URL + </span> + <span v-if="!props.row.modified"> + {{ props.row.live_url }} + </span> + </b-table-column> + + <b-table-column field="actions" + label="Actions" + % if not buefy_0_8: + v-slot="props" + % endif + > + <a href="#" + @click.prevent="editWebLibraryInit(props.row)"> + <i class="fas fa-edit"></i> + Edit + </a> + </b-table-column> + + % if buefy_0_8: + </template> + % endif + + </b-table> + + % for weblib in weblibs: + ${h.hidden('tailbone.libver.{}'.format(weblib['key']), **{':value': "simpleSettings['tailbone.libver.{}']".format(weblib['key'])})} + ${h.hidden('tailbone.liburl.{}'.format(weblib['key']), **{':value': "simpleSettings['tailbone.liburl.{}']".format(weblib['key'])})} + % endfor + + <b-modal has-modal-card + :active.sync="editWebLibraryShowDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Web Library: {{ editWebLibraryRecord.title }}</p> + </header> + + <section class="modal-card-body"> + + <b-field grouped> + + <b-field label="Default Version"> + <b-input v-model="editWebLibraryRecord.default_version" + disabled> + </b-input> + </b-field> + + <b-field label="Override Version"> + <b-input v-model="editWebLibraryVersion"> + </b-input> + </b-field> + + </b-field> + + <b-field label="Override URL"> + <b-input v-model="editWebLibraryURL"> + </b-input> + </b-field> + + <b-field label="Effective URL (as of last page load)"> + <b-input v-model="editWebLibraryRecord.live_url" + disabled> + </b-input> + </b-field> + + </section> + + <footer class="modal-card-foot"> + <b-button type="is-primary" + @click="editWebLibrarySave()" + icon-pack="fas" + icon-left="save"> + Save + </b-button> + <b-button @click="editWebLibraryShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </b-modal> + + </div> +</%def> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + ThisPageData.weblibs = ${json.dumps(weblibs)|n} + + ThisPageData.editWebLibraryShowDialog = false + ThisPageData.editWebLibraryRecord = {} + ThisPageData.editWebLibraryVersion = null + ThisPageData.editWebLibraryURL = null + + ThisPage.methods.editWebLibraryInit = function(row) { + this.editWebLibraryRecord = row + this.editWebLibraryVersion = row.configured_version + this.editWebLibraryURL = row.configured_url + this.editWebLibraryShowDialog = true + } + + ThisPage.methods.editWebLibrarySave = function() { + this.editWebLibraryRecord.configured_version = this.editWebLibraryVersion + this.editWebLibraryRecord.configured_url = this.editWebLibraryURL + this.editWebLibraryRecord.modified = true + + this.simpleSettings[`tailbone.libver.${'$'}{this.editWebLibraryRecord.key}`] = this.editWebLibraryVersion + this.simpleSettings[`tailbone.liburl.${'$'}{this.editWebLibraryRecord.key}`] = this.editWebLibraryURL + + this.settingsNeedSaved = true + this.editWebLibraryShowDialog = false + } + + </script> +</%def> + + +${parent.body()} diff --git a/tailbone/templates/appinfo/index.mako b/tailbone/templates/appinfo/index.mako new file mode 100644 index 00000000..4bf70354 --- /dev/null +++ b/tailbone/templates/appinfo/index.mako @@ -0,0 +1,114 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/index.mako" /> + +<%def name="render_grid_component()"> + + <b-collapse class="panel" open> + + <template #trigger="props"> + <div class="panel-heading" + role="button"> + + ## TODO: for some reason buefy will "reuse" the icon + ## element in such a way that its display does not + ## refresh. so to work around that, we use different + ## structure for the two icons, so buefy is forced to + ## re-draw + + <b-icon v-if="props.open" + pack="fas" + icon="angle-down"> + </b-icon> + + <span v-if="!props.open"> + <b-icon pack="fas" + icon="angle-right"> + </b-icon> + </span> + + <strong>Configuration Files</strong> + </div> + </template> + + <div class="panel-block"> + <div style="width: 100%;"> + <b-table :data="configFiles"> + + % if buefy_0_8: + <template slot-scope="props"> + % endif + + <b-table-column field="priority" + label="Priority" + % if not buefy_0_8: + v-slot="props" + % endif + > + {{ props.row.priority }} + </b-table-column> + + <b-table-column field="path" + label="File Path" + % if not buefy_0_8: + v-slot="props" + % endif + > + {{ props.row.path }} + </b-table-column> + + % if buefy_0_8: + </template> + % endif + + </b-table> + </div> + </div> + </b-collapse> + + <b-collapse class="panel" + :open="false"> + + <template #trigger="props"> + <div class="panel-heading" + role="button"> + + ## TODO: for some reason buefy will "reuse" the icon + ## element in such a way that its display does not + ## refresh. so to work around that, we use different + ## structure for the two icons, so buefy is forced to + ## re-draw + + <b-icon v-if="props.open" + pack="fas" + icon="angle-down"> + </b-icon> + + <span v-if="!props.open"> + <b-icon pack="fas" + icon="angle-right"> + </b-icon> + </span> + + <strong>Installed Packages</strong> + </div> + </template> + + <div class="panel-block"> + <div style="width: 100%;"> + ${parent.render_grid_component()} + </div> + </div> + </b-collapse> +</%def> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + ThisPageData.configFiles = ${json.dumps([dict(path=p, priority=i) for i, p in enumerate(reversed(request.rattail_config.files_read), 1)])|n} + + </script> +</%def> + + +${parent.body()} diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index 888b3bb6..adbcd893 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -107,21 +107,20 @@ </%def> <%def name="jquery()"> - ${h.javascript_link(request.rattail_config.get('tailbone', 'liburl.jquery', default='https://code.jquery.com/jquery-1.12.4.min.js'))} + ${h.javascript_link(h.get_liburl(request, 'jquery'))} </%def> <%def name="vuejs()"> - ${h.javascript_link(request.rattail_config.get('tailbone', 'liburl.vue', default='https://unpkg.com/vue@{}/dist/vue.min.js'.format(vue_version)))} - ## TODO: make this version configurable also - ${h.javascript_link(request.rattail_config.get('tailbone', 'liburl.vue_resource', default='https://cdn.jsdelivr.net/npm/vue-resource@1.5.1'))} + ${h.javascript_link(h.get_liburl(request, 'vue'))} + ${h.javascript_link(h.get_liburl(request, 'vue_resource'))} </%def> <%def name="buefy()"> - ${h.javascript_link(request.rattail_config.get('tailbone', 'liburl.buefy', default='https://unpkg.com/buefy@{}/dist/buefy.min.js'.format(buefy_version)))} + ${h.javascript_link(h.get_liburl(request, 'buefy'))} </%def> <%def name="fontawesome()"> - <script defer src="${request.rattail_config.get('tailbone', 'liburl.fontawesome', default='https://use.fontawesome.com/releases/v5.3.1/js/all.js')}"></script> + <script defer src="${h.get_liburl(request, 'fontawesome')}"></script> </%def> <%def name="extra_javascript()"></%def> @@ -159,14 +158,14 @@ ${h.stylesheet_link(buefy_css)} % else: ## upstream Buefy CSS - ${h.stylesheet_link(request.rattail_config.get('tailbone', 'liburl.buefy.css', default='https://unpkg.com/buefy@{}/dist/buefy.min.css'.format(buefy_version)))} + ${h.stylesheet_link(h.get_liburl(request, 'buefy.css'))} % endif </%def> ## TODO: this is only being referenced by the progress template i think? ## (so, should make a Buefy progress page at least) <%def name="jquery_theme()"> - ${h.stylesheet_link(request.rattail_config.get('tailbone', 'liburl.jquery.css', default='https://code.jquery.com/ui/1.11.4/themes/dark-hive/jquery-ui.css'))} + ${h.stylesheet_link(h.get_liburl(request, 'jquery_ui'))} </%def> <%def name="extra_styles()"></%def> diff --git a/tailbone/util.py b/tailbone/util.py index a9aa3bf3..ccab81c6 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -98,6 +98,106 @@ def get_global_search_options(request): return options +def get_libver(request, key, fallback=True, default_only=False): + """ + Return the appropriate URL for the library identified by ``key``. + """ + config = request.rattail_config + + if not default_only: + version = config.get('tailbone', 'libver.{}'.format(key)) + if version: + return version + + if not fallback and not default_only: + + if key == 'buefy': + version = config.get('tailbone', 'buefy_version') + if version: + return version + + elif key == 'buefy.css': + version = get_libver(request, 'buefy', fallback=False) + if version: + return version + + elif key == 'vue': + version = config.get('tailbone', 'vue_version') + if version: + return version + + return + + if key == 'buefy': + if not default_only: + version = config.get('tailbone', 'buefy_version') + if version: + return version + return 'latest' + + elif key == 'buefy.css': + version = get_libver(request, 'buefy', default_only=default_only) + if version: + return version + return 'latest' + + elif key == 'vue': + if not default_only: + version = config.get('tailbone', 'vue_version') + if version: + return version + return '2.6.14' + + elif key == 'vue_resource': + return 'latest' + + elif key == 'fontawesome': + return '5.3.1' + + elif key == 'jquery': + return '1.12.4' + + elif key == 'jquery_ui': + return '1.11.4' + + +def get_liburl(request, key, fallback=True): + """ + Return the appropriate URL for the library identified by ``key``. + """ + config = request.rattail_config + + url = config.get('tailbone', 'liburl.{}'.format(key)) + if url: + return url + + if not fallback: + return + + version = get_libver(request, key) + + if key == 'buefy': + return 'https://unpkg.com/buefy@{}/dist/buefy.min.js'.format(version) + + elif key == 'buefy.css': + return 'https://unpkg.com/buefy@{}/dist/buefy.min.css'.format(version) + + elif key == 'vue': + return 'https://unpkg.com/vue@{}/dist/vue.min.js'.format(version) + + elif key == 'vue_resource': + return 'https://cdn.jsdelivr.net/npm/vue-resource@{}'.format(version) + + elif key == 'fontawesome': + return 'https://use.fontawesome.com/releases/v{}/js/all.js'.format(version) + + elif key == 'jquery': + return 'https://code.jquery.com/jquery-{}.min.js'.format(version) + + elif key == 'jquery_ui': + return 'https://code.jquery.com/ui/{}/themes/dark-hive/jquery-ui.css'.format(version) + + def should_use_buefy(request): """ Returns a flag indicating whether or not the current theme supports (and diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 1771a3b7..a80b6c26 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2248,6 +2248,8 @@ class MasterView(View): route = self.get_route_prefix() return self.request.route_url(route, **kwargs) + # TODO: this should not be class method, if possible + # (pretty sure overriding as instance method works fine) @classmethod def get_index_title(cls): """ @@ -4822,6 +4824,8 @@ class MasterView(View): value = six.text_type(bool(value)).lower() elif simple.get('type') is int: value = six.text_type(int(value or '0')) + elif value is None: + value = '' else: value = six.text_type(value) diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index 9a1e8620..f4a213c0 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -26,14 +26,17 @@ Settings Views from __future__ import unicode_literals, absolute_import +import os import re +import subprocess +import sys import json import six from rattail.db import model from rattail.settings import Setting -from rattail.util import import_module_path +from rattail.util import import_module_path, OrderedDict import colander from webhelpers2.html import tags @@ -41,6 +44,153 @@ from webhelpers2.html import tags from tailbone import forms from tailbone.db import Session from tailbone.views import MasterView, View +from tailbone.util import get_libver, get_liburl + + +class AppInfoView(MasterView): + """ + Master view for the overall app, to show/edit config etc. + """ + route_prefix = 'appinfo' + model_key = 'UNUSED' + model_title = "UNUSED" + model_title_plural = "App Info" + creatable = False + viewable = False + editable = False + deletable = False + filterable = False + pageable = False + configurable = True + + grid_columns = [ + 'name', + 'version', + 'editable_project_location', + ] + + def get_index_title(self): + return "App Info for {}".format(self.rattail_config.app_title()) + + def get_data(self, session=None): + pip = os.path.join(sys.prefix, 'bin', 'pip') + output = subprocess.check_output([pip, 'list', '--format=json']) + data = json.loads(output.decode('utf_8').strip()) + + for pkg in data: + pkg.setdefault('editable_project_location', '') + + return data + + def configure_grid(self, g): + super(AppInfoView, self).configure_grid(g) + + g.sorters['name'] = g.make_simple_sorter('name', foldcase=True) + g.set_sort_defaults('name') + g.set_searchable('name') + + g.sorters['version'] = g.make_simple_sorter('version', foldcase=True) + + g.sorters['editable_project_location'] = g.make_simple_sorter( + 'editable_project_location', foldcase=True) + g.set_searchable('editable_project_location') + + def configure_get_context(self, **kwargs): + context = super(AppInfoView, self).configure_get_context(**kwargs) + + weblibs = OrderedDict([ + ('vue', "Vue"), + ('vue_resource', "vue-resource"), + ('buefy', "Buefy"), + ('buefy.css', "Buefy CSS"), + ('fontawesome', "FontAwesome"), + ('jquery', "jQuery"), + ('jquery_ui', "jQuery UI"), + ]) + + for key in weblibs: + title = weblibs[key] + weblibs[key] = { + 'key': key, + 'title': title, + + # nb. these values are exactly as configured, and are + # used for editing the settings + 'configured_version': get_libver(self.request, key, fallback=False), + 'configured_url': get_liburl(self.request, key, fallback=False), + + # these are for informational purposes only + 'default_version': get_libver(self.request, key, default_only=True), + 'live_url': get_liburl(self.request, key), + } + + context['weblibs'] = list(weblibs.values()) + return context + + def configure_get_simple_settings(self): + return [ + + # basics + {'section': 'rattail', + 'option': 'app_title'}, + {'section': 'rattail', + 'option': 'node_type'}, + {'section': 'rattail', + 'option': 'node_title'}, + {'section': 'rattail', + 'option': 'production', + 'type': bool}, + + # display + {'section': 'tailbone', + 'option': 'background_color'}, + + # grids + {'section': 'tailbone', + 'option': 'grid.default_pagesize', + # TODO: seems like should enforce this, but validation is + # not setup yet + # 'type': int + }, + + # web libs + {'section': 'tailbone', + 'option': 'libver.vue'}, + {'section': 'tailbone', + 'option': 'liburl.vue'}, + {'section': 'tailbone', + 'option': 'libver.vue_resource'}, + {'section': 'tailbone', + 'option': 'liburl.vue_resource'}, + {'section': 'tailbone', + 'option': 'libver.buefy'}, + {'section': 'tailbone', + 'option': 'liburl.buefy'}, + {'section': 'tailbone', + 'option': 'libver.buefy.css'}, + {'section': 'tailbone', + 'option': 'liburl.buefy.css'}, + {'section': 'tailbone', + 'option': 'libver.fontawesome'}, + {'section': 'tailbone', + 'option': 'liburl.fontawesome'}, + {'section': 'tailbone', + 'option': 'libver.jquery'}, + {'section': 'tailbone', + 'option': 'liburl.jquery'}, + {'section': 'tailbone', + 'option': 'libver.jquery_ui'}, + {'section': 'tailbone', + 'option': 'liburl.jquery_ui'}, + + # nb. these are no longer used (deprecated), but we keep + # them defined here so the tool auto-deletes them + {'section': 'tailbone', + 'option': 'buefy_version'}, + {'section': 'tailbone', + 'option': 'vue_version'}, + + ] class SettingView(MasterView): @@ -322,6 +472,9 @@ class AppSettingsView(View): def defaults(config, **kwargs): base = globals() + AppInfoView = kwargs.get('AppInfoView', base['AppInfoView']) + AppInfoView.defaults(config) + AppSettingsView = kwargs.get('AppSettingsView', base['AppSettingsView']) AppSettingsView.defaults(config) From 38f88407ff8a096158153a5aec4ed7f2f9de2977 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 12 Jan 2023 15:33:56 -0600 Subject: [PATCH 0955/1681] 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 b08f2e9f..b530a0d2 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.281 (2023-01-12) +-------------------- + +* Add new views for App Info, and Configure App. + + 0.8.280 (2023-01-11) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 5716918c..1bdfa062 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.280' +__version__ = '0.8.281' From fb7368993c71a9a8bcc9497b2424f153854bb465 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 12 Jan 2023 22:56:12 -0600 Subject: [PATCH 0956/1681] Show basic column info as row grid when viewing Table --- tailbone/views/tables.py | 65 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/tailbone/views/tables.py b/tailbone/views/tables.py index 49d9e7a5..db045a73 100644 --- a/tailbone/views/tables.py +++ b/tailbone/views/tables.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -29,8 +29,11 @@ from __future__ import unicode_literals, absolute_import import sys import warnings +import six + import colander from deform import widget as dfwidget +from webhelpers2.html import HTML from tailbone.views import MasterView @@ -60,6 +63,19 @@ class TableView(MasterView): 'row_count', ] + has_rows = True + rows_pageable = False + rows_filterable = False + rows_viewable = False + + row_grid_columns = [ + 'sequence', + 'column_name', + 'data_type', + 'nullable', + 'description', + ] + def __init__(self, request): super(TableView, self).__init__(request) app = self.get_rattail_app() @@ -117,6 +133,7 @@ class TableView(MasterView): } table = model.Base.metadata.tables.get(table_name) + data['table'] = table if table is not None: try: mapper = get_mapper(table) @@ -198,6 +215,52 @@ class TableView(MasterView): # def save_create_form(self, form): # return form.validated + def get_row_data(self, table): + data = [] + for i, column in enumerate(table['table'].columns, 1): + + data.append({ + 'column': column, + 'sequence': i, + 'column_name': column.name, + 'data_type': six.text_type(repr(column.type)), + 'nullable': column.nullable, + 'description': column.doc, + }) + return data + + def configure_row_grid(self, g): + super(TableView, self).configure_row_grid(g) + + g.sorters['sequence'] = g.make_simple_sorter('sequence') + g.set_sort_defaults('sequence') + g.set_label('sequence', "Seq.") + + g.sorters['column_name'] = g.make_simple_sorter('column_name', + foldcase=True) + g.set_searchable('column_name') + + g.sorters['data_type'] = g.make_simple_sorter('data_type', + foldcase=True) + g.set_searchable('data_type') + + g.set_type('nullable', 'boolean') + g.sorters['nullable'] = g.make_simple_sorter('nullable') + + g.set_renderer('description', self.render_column_description) + + def render_column_description(self, column, field): + text = column[field] + if not text: + return + + max_length = 80 + + if len(text) < max_length: + return text + + return HTML.tag('span', title=text, c="{} ...".format(text[:max_length])) + @classmethod def defaults(cls, config): rattail_config = config.registry.settings.get('rattail_config') From cac005f993529c0f5422d48ec6d39e449a73e7df Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 13 Jan 2023 03:51:12 -0600 Subject: [PATCH 0957/1681] Semi-finish logic for writing new table model class to file definitely needs more polish and features, but the gist.. --- tailbone/templates/tables/create.mako | 363 ++++++++++++++++++++++++-- tailbone/views/tables.py | 86 +++--- 2 files changed, 387 insertions(+), 62 deletions(-) diff --git a/tailbone/templates/tables/create.mako b/tailbone/templates/tables/create.mako index 90d9d26f..4d46273a 100644 --- a/tailbone/templates/tables/create.mako +++ b/tailbone/templates/tables/create.mako @@ -10,7 +10,7 @@ </style> </%def> -<%def name="render_buefy_form()"> +<%def name="render_this_page()"> <b-steps v-model="activeStep" :animated="false" rounded @@ -25,26 +25,257 @@ <h3 class="is-size-3 block"> Enter Details </h3> - ${parent.render_buefy_form()} + + <b-field label="Schema Branch" horizontal + message="Leave this set to your custom app branch, unless you know what you're doing."> + <b-select v-model="tableBranch"> + <option v-for="branch in branchOptions" + :key="branch" + :value="branch"> + {{ branch }} + </option> + </b-select> + </b-field> + + <b-field grouped> + + <b-field label="Table Name" + message="Should be singular in nature, i.e. 'widget' not 'widgets'"> + <b-input v-model="tableName"> + </b-input> + </b-field> + + <b-field label="Model/Class Name" + message="Should be singular in nature, i.e. 'Widget' not 'Widgets'"> + <b-input v-model="tableModelName"> + </b-input> + </b-field> + + </b-field> + + <b-field grouped> + + <b-field label="Model Title" + message="Human-friendly singular model title."> + <b-input v-model="tableModelTitle"> + </b-input> + </b-field> + + <b-field label="Model Title Plural" + message="Human-friendly plural model title."> + <b-input v-model="tableModelTitlePlural"> + </b-input> + </b-field> + + </b-field> + + <b-field label="Description" + message="Brief description of what a record in this table represents."> + <b-input v-model="tableDescription"> + </b-input> + </b-field> + + <b-field> + <b-checkbox v-model="tableVersioned"> + Record version data for this table + </b-checkbox> + </b-field> + + <br /> + + <div class="level-left"> + <div class="level-item"> + <h4 class="block is-size-4">Columns</h4> + </div> + <div class="level-item"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="plus" + @click="tableAddColumn()"> + New + </b-button> + </div> + </div> + + <b-table + :data="tableColumns"> + % if buefy_0_8: + <template slot-scope="props"> + % endif + + <b-table-column field="name" + label="Name" + % if not buefy_0_8: + v-slot="props" + % endif + > + {{ props.row.name }} + </b-table-column> + + <b-table-column field="data_type" + label="Data Type" + % if not buefy_0_8: + v-slot="props" + % endif + > + {{ props.row.data_type }} + </b-table-column> + + <b-table-column field="nullable" + label="Nullable" + % if not buefy_0_8: + v-slot="props" + % endif + > + {{ props.row.nullable ? "Yes" : "No" }} + </b-table-column> + + <b-table-column field="versioned" + label="Versioned" + :visible="tableVersioned" + % if not buefy_0_8: + v-slot="props" + % endif + > + {{ props.row.versioned ? "Yes" : "No" }} + </b-table-column> + + <b-table-column field="description" + label="Description" + % if not buefy_0_8: + v-slot="props" + % endif + > + {{ props.row.description }} + </b-table-column> + + <b-table-column field="actions" + label="Actions" + % if not buefy_0_8: + v-slot="props" + % endif + > + <a v-if="props.row.name != 'uuid'" + href="#" + @click.prevent="tableEditColumn(props.row)"> + <i class="fas fa-edit"></i> + Edit + </a> + + + <a v-if="props.row.name != 'uuid'" + href="#" + class="has-text-danger" + @click.prevent="tableDeleteColumn(props.index)"> + <i class="fas fa-trash"></i> + Delete + </a> + + </b-table-column> + + % if buefy_0_8: + </template> + % endif + </b-table> + + <b-modal has-modal-card + :active.sync="editingColumnShowDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title"> + {{ (editingColumn && editingColumn.name) ? "Edit" : "New" }} Column + </p> + </header> + + <section class="modal-card-body"> + + <b-field label="Name"> + <b-input v-model="editingColumnName" + ref="editingColumnName"> + </b-input> + </b-field> + + <b-field label="Data Type"> + <b-input v-model="editingColumnDataType"></b-input> + </b-field> + + <b-field grouped> + + <b-field label="Nullable"> + <b-checkbox v-model="editingColumnNullable" + native-value="true"> + {{ editingColumnNullable }} + </b-checkbox> + </b-field> + + <b-field label="Versioned" + v-if="tableVersioned"> + <b-checkbox v-model="editingColumnVersioned" + native-value="true"> + {{ editingColumnVersioned }} + </b-checkbox> + </b-field> + + </b-field> + + <b-field label="Description"> + <b-input v-model="editingColumnDescription"></b-input> + </b-field> + + </section> + + <footer class="modal-card-foot"> + <b-button @click="editingColumnShowDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + icon-pack="fas" + icon-left="save" + @click="editingColumnSave()"> + Save + </b-button> + </footer> + </div> + </b-modal> + + <br /> + + <div class="buttons"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="check" + @click="activeStep = 'write-model'"> + Details are complete + </b-button> + </div> + </b-step-item> <b-step-item step="2" value="write-model" - label="Write Model" - clickable> + label="Write Model"> <h3 class="is-size-3 block"> Write Model </h3> + + <b-field label="Schema Branch" horizontal> + {{ tableBranch }} + </b-field> + + <b-field label="Table Name" horizontal> + {{ tableName }} + </b-field> + + <b-field label="Model Class" horizontal> + {{ tableModelName }} + </b-field> + + <b-field horizontal label="File"> + <b-input v-model="tableModelFile"></b-input> + </b-field> + <div class="form"> - <b-field horizontal label="Table Name"> - <span>TODO: poser_widget</span> - </b-field> - <b-field horizontal label="Model Class"> - <span>TODO: PoserWidget</span> - </b-field> - <b-field horizontal label="File"> - <span>TODO: ~/src/poser/poser/db/model/widgets.py</span> - </b-field> <div class="buttons"> <b-button icon-pack="fas" icon-left="arrow-left" @@ -54,8 +285,9 @@ <b-button type="is-primary" icon-pack="fas" icon-left="save" - @click="activeStep = 'review-model'"> - Write model class to file + @click="writeModelFile()" + :disabled="writingModelFile"> + {{ writingModelFile ? "Working, please wait..." : "Write model class to file" }} </b-button> </div> </div> @@ -206,6 +438,107 @@ <script type="text/javascript"> ThisPageData.activeStep = null + ThisPageData.branchOptions = ${json.dumps(branch_name_options)|n} + + ThisPageData.tableBranch = ${json.dumps(branch_name)|n} + ThisPageData.tableName = '${rattail_app.get_table_prefix()}_widget' + ThisPageData.tableModelName = '${rattail_app.get_class_prefix()}Widget' + ThisPageData.tableModelTitle = 'Widget' + ThisPageData.tableModelTitlePlural = 'Widgets' + ThisPageData.tableDescription = "Represents a cool widget." + ThisPageData.tableVersioned = true + + ThisPageData.tableColumns = [{ + name: 'uuid', + data_type: 'String(length=32)', + nullable: false, + description: "UUID primary key", + versioned: true, + }] + + ThisPageData.editingColumnShowDialog = false + ThisPageData.editingColumn = null + ThisPageData.editingColumnName = null + ThisPageData.editingColumnDataType = null + ThisPageData.editingColumnNullable = true + ThisPageData.editingColumnDescription = null + ThisPageData.editingColumnVersioned = true + + ThisPage.methods.tableAddColumn = function() { + this.editingColumn = null + this.editingColumnName = null + this.editingColumnDataType = null + this.editingColumnNullable = true + this.editingColumnDescription = null + this.editingColumnVersioned = true + this.editingColumnShowDialog = true + this.$nextTick(() => { + this.$refs.editingColumnName.focus() + }) + } + + ThisPage.methods.tableEditColumn = function(column) { + this.editingColumn = column + this.editingColumnName = column.name + this.editingColumnDataType = column.data_type + this.editingColumnNullable = column.nullable + this.editingColumnDescription = column.description + this.editingColumnVersioned = column.versioned + this.editingColumnShowDialog = true + this.$nextTick(() => { + this.$refs.editingColumnName.focus() + }) + } + + ThisPage.methods.editingColumnSave = function() { + let column + if (this.editingColumn) { + column = this.editingColumn + } else { + column = {} + this.tableColumns.push(column) + } + + column.name = this.editingColumnName + column.data_type = this.editingColumnDataType + column.nullable = this.editingColumnNullable + column.description = this.editingColumnDescription + column.versioned = this.editingColumnVersioned + + this.editingColumnShowDialog = false + } + + ThisPage.methods.tableDeleteColumn = function(index) { + if (confirm("Really delete this column?")) { + this.tableColumns.splice(index, 1) + } + } + + ThisPageData.tableModelFile = '${model_dir}widget.py' + ThisPageData.writingModelFile = false + + ThisPage.methods.writeModelFile = function() { + this.writingModelFile = true + + let url = '${url('{}.write_model_file'.format(route_prefix))}' + let params = { + branch_name: this.tableBranch, + table_name: this.tableName, + model_name: this.tableModelName, + model_title: this.tableModelTitle, + model_title_plural: this.tableModelTitlePlural, + description: this.tableDescription, + versioned: this.tableVersioned, + module_file: this.tableModelFile, + columns: this.tableColumns, + } + this.submitForm(url, params, response => { + this.writingModelFile = false + this.activeStep = 'review-model' + }, response => { + this.writingModelFile = false + }) + } </script> </%def> diff --git a/tailbone/views/tables.py b/tailbone/views/tables.py index db045a73..196e70f5 100644 --- a/tailbone/views/tables.py +++ b/tailbone/views/tables.py @@ -26,6 +26,7 @@ Views with info about the underlying Rattail tables from __future__ import unicode_literals, absolute_import +import os import sys import warnings @@ -160,65 +161,36 @@ class TableView(MasterView): def make_form_schema(self): return TableSchema() - def configure_form(self, f): - super(TableView, self).configure_form(f) + def template_kwargs_create(self, **kwargs): + kwargs = super(TableView, self).template_kwargs_create(**kwargs) app = self.get_rattail_app() + model = self.model - # exclude some fields when creating - if self.creating: - f.remove('row_count', - 'module_name', - 'module_file') + kwargs['branch_name_options'] = self.db_handler.get_alembic_branch_names() - # branch_name - if self.creating: + branch_name = app.get_table_prefix() + if branch_name not in kwargs['branch_name_options']: + branch_name = None + kwargs['branch_name'] = branch_name - # move this field to top of form, as it's more fundamental - # when creating new table - f.remove('branch_name') - f.insert(0, 'branch_name') + kwargs['model_dir'] = (os.path.dirname(model.__file__) + + os.sep) - # define options for dropdown - branches = self.db_handler.get_alembic_branch_names() - values = [(branch, branch) for branch in branches] - f.set_widget('branch_name', dfwidget.SelectWidget(values=values)) + return kwargs - # default to custom app branch, if applicable - table_prefix = app.get_table_prefix() - if table_prefix in branches: - f.set_default('branch_name', table_prefix) - f.set_helptext('branch_name', "Leave this set to your custom app branch, unless you know what you're doing.") + def write_model_file(self): + data = self.request.json_body + path = data['module_file'] - # table_name - if self.creating: - f.set_default('table_name', '{}_widget'.format(app.get_table_prefix())) - f.set_helptext('table_name', "Should be singular in nature, i.e. 'widget' not 'widgets'") + if os.path.exists(path): + return {'error': "File already exists"} - # model_name - if self.creating: - f.set_default('model_name', '{}Widget'.format(app.get_class_prefix())) - f.set_helptext('model_name', "Should be singular in nature, i.e. 'Widget' not 'Widgets'") - - # model_title* - if self.creating: - f.set_default('model_title', 'Widget') - f.set_helptext('model_title', "Human-friendly singular model title.") - f.set_default('model_title_plural', 'Widgets') - f.set_helptext('model_title_plural', "Human-friendly plural model title.") - - # description - if self.creating: - f.set_default('description', "Represents a cool widget.") - f.set_helptext('description', "Brief description of what a record in this table represents.") - - # TODO: not sure yet how to handle "save" action - # def save_create_form(self, form): - # return form.validated + self.db_handler.write_table_model(data, path) + return {'ok': True} def get_row_data(self, table): data = [] for i, column in enumerate(table['table'].columns, 1): - data.append({ 'column': column, 'sequence': i, @@ -269,8 +241,26 @@ class TableView(MasterView): if not rattail_config.production(): cls.creatable = True + cls._table_defaults(config) cls._defaults(config) + @classmethod + def _table_defaults(cls, config): + route_prefix = cls.get_route_prefix() + url_prefix = cls.get_url_prefix() + permission_prefix = cls.get_permission_prefix() + + if cls.creatable: + + # write model class to file + config.add_route('{}.write_model_file'.format(route_prefix), + '{}/write-model-file'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='write_model_file', + route_name='{}.write_model_file'.format(route_prefix), + renderer='json', + permission='{}.create'.format(permission_prefix)) + class TablesView(TableView): @@ -303,6 +293,8 @@ class TableSchema(colander.Schema): module_file = colander.SchemaNode(colander.String(), missing=colander.null) + versioned = colander.SchemaNode(colander.Bool()) + def defaults(config, **kwargs): base = globals() From 83f9a3faa7be4ed4572238d87bfc99c2c04047bb Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 13 Jan 2023 16:49:16 -0600 Subject: [PATCH 0958/1681] Fix "toggle batch complete" for Chrome browser --- tailbone/templates/batch/view.mako | 4 ++++ tailbone/views/batch/core.py | 17 +++++++++-------- tailbone/views/master.py | 22 +++++++++++++--------- 3 files changed, 26 insertions(+), 17 deletions(-) diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako index 66a6881a..4288f6e2 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -426,6 +426,10 @@ }) } + % if not batch.executed and master.has_perm('edit'): + ${form.component_studly}Data.togglingBatchComplete = false + % endif + % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'): ThisPageData.showUploadDialog = false diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 1c35169a..56bfa2f1 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -431,11 +431,10 @@ class BatchMasterView(MasterView): return dict(batch.params or {}) def render_complete(self, batch, field): - permission_prefix = self.get_permission_prefix() use_buefy = self.get_use_buefy() text = "Yes" if batch.complete else "No" - if batch.executed or not self.request.has_perm('{}.edit'.format(permission_prefix)): + if batch.executed or not self.has_perm('edit'): return text if batch.complete: @@ -445,16 +444,18 @@ class BatchMasterView(MasterView): label = "Mark Complete" value = 'true' - kwargs = {} + url = self.get_action_url('toggle_complete', batch) + kwargs = {'@submit': 'togglingBatchComplete = true'} if not use_buefy: kwargs['class_'] = 'autodisable' - begin_form = tags.form(self.get_action_url('toggle_complete', batch), **kwargs) + begin_form = tags.form(url, **kwargs) if use_buefy: - submit = HTML.tag('once-button', - type='is-primary', - native_type='submit', - text=label) + label = HTML.literal( + '{{{{ togglingBatchComplete ? "Working, please wait..." : "{}" }}}}'.format(label)) + submit = self.make_buefy_button(label, is_primary=True, + native_type='submit', + **{':disabled': 'togglingBatchComplete'}) else: submit = tags.submit('submit', label) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index a80b6c26..d01bb462 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2660,31 +2660,35 @@ class MasterView(View): normal.append(button) return normal - def make_buefy_button(self, label, is_primary=False, - url=None, is_external=False, + def make_buefy_button(self, label, + type=None, is_primary=False, + url=None, target=None, is_external=False, + icon_left=None, **kwargs): """ Make and return a HTML ``<b-button>`` literal. """ - btn_kw = dict(c=label, icon_pack='fas') + btn_kw = kwargs + btn_kw.setdefault('c', label) + btn_kw.setdefault('icon_pack', 'fas') - if 'type' in kwargs: - btn_kw['type'] = kwargs['type'] + if type: + btn_kw['type'] = type elif is_primary: btn_kw['type'] = 'is-primary' if url: btn_kw['href'] = url - if 'icon_left' in kwargs: - btn_kw['icon_left'] = kwargs['icon_left'] + if icon_left: + btn_kw['icon_left'] = icon_left elif is_external: btn_kw['icon_left'] = 'external-link-alt' else: btn_kw['icon_left'] = 'eye' - if 'target' in kwargs: - btn_kw['target'] = kwargs['target'] + if target: + btn_kw['target'] = target elif is_external: btn_kw['target'] = '_blank' From 0753e956f9272e176466d974e05184f23d7f6b6c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 13 Jan 2023 18:10:28 -0600 Subject: [PATCH 0959/1681] Revert logic that assumes all themes use buefy that just isn't a safe assumption yet..alas --- tailbone/util.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tailbone/util.py b/tailbone/util.py index ccab81c6..9eae1740 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -209,8 +209,13 @@ def should_use_buefy(request): if buefy is not None: return buefy - # otherwise assume buefy is in effect - return True + # TODO: should not hard-code this surely, but works for now... + if theme == 'falafel': + return True + + # TODO: probably should not use this fallback? it was the first setting + # i tested with, but is poorly named to say the least + return request.rattail_config.getbool('tailbone', 'grids.use_buefy', default=False) def pretty_datetime(config, value): From f18f24962ef5d957fb01d8a3bb309ca6225cf3d6 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 13 Jan 2023 20:18:42 -0600 Subject: [PATCH 0960/1681] Refactor tempmon dashboard view, for buefy themes --- .../templates/tempmon/appliances/view.mako | 9 ++ tailbone/templates/tempmon/clients/view.mako | 10 ++ tailbone/templates/tempmon/dashboard.mako | 112 ++++++++++++++++++ tailbone/views/tempmon/appliances.py | 10 +- tailbone/views/tempmon/clients.py | 10 +- tailbone/views/tempmon/core.py | 57 ++++++++- tailbone/views/tempmon/dashboard.py | 36 +++--- 7 files changed, 225 insertions(+), 19 deletions(-) diff --git a/tailbone/templates/tempmon/appliances/view.mako b/tailbone/templates/tempmon/appliances/view.mako index bbaa0e3f..07a524b8 100644 --- a/tailbone/templates/tempmon/appliances/view.mako +++ b/tailbone/templates/tempmon/appliances/view.mako @@ -8,5 +8,14 @@ % endif </%def> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + ${form.component_studly}Data.probesData = ${json.dumps(probes_data)|n} + + </script> +</%def> + ${parent.body()} diff --git a/tailbone/templates/tempmon/clients/view.mako b/tailbone/templates/tempmon/clients/view.mako index ab65bac6..2141d977 100644 --- a/tailbone/templates/tempmon/clients/view.mako +++ b/tailbone/templates/tempmon/clients/view.mako @@ -40,4 +40,14 @@ % endif </%def> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + ${form.component_studly}Data.probesData = ${json.dumps(probes_data)|n} + + </script> +</%def> + + ${parent.body()} diff --git a/tailbone/templates/tempmon/dashboard.mako b/tailbone/templates/tempmon/dashboard.mako index 815eb89e..214ff480 100644 --- a/tailbone/templates/tempmon/dashboard.mako +++ b/tailbone/templates/tempmon/dashboard.mako @@ -83,6 +83,57 @@ </%def> <%def name="render_this_page()"> + % if use_buefy: + + ${h.form(request.current_route_url(), ref='applianceForm')} + ${h.csrf_token(request)} + <div class="level-left"> + + <div class="level-item"> + <b-field label="Appliance" horizontal> + <b-select name="appliance_uuid" + v-model="applianceUUID" + @input="$refs.applianceForm.submit()"> + <option v-for="appliance in appliances" + :key="appliance.uuid" + :value="appliance.uuid"> + {{ appliance.name }} + </option> + </b-select> + </b-field> + </div> + + % if appliance: + <div class="level-item"> + <a href="${url('tempmon.appliances.view', uuid=appliance.uuid)}"> + ${h.image(url('tempmon.appliances.thumbnail', uuid=appliance.uuid), "")} + </a> + </div> + % endif + + </div> + ${h.end_form()} + + % if appliance and appliance.probes: + % for probe in appliance.probes: + <h4 class="is-size-4"> + Probe: ${h.link_to(probe.description, url('tempmon.probes.graph', uuid=probe.uuid))} + (status: ${enum.TEMPMON_PROBE_STATUS[probe.status]}) + </h4> + % if probe.enabled: + <canvas ref="tempchart-${probe.uuid}" width="400" height="60"></canvas> + % else: + <p>This probe is not enabled.</p> + % endif + % endfor + % elif appliance: + <h3>This appliance has no probes configured!</h3> + % else: + <h3>Please choose an appliance.</h3> + % endif + + % else: + ## not buefy <div style="display: flex;"> <div class="form-wrapper"> @@ -129,6 +180,67 @@ % else: <h3>This appliance has no probes configured!</h3> % endif + % endif +</%def> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + ThisPageData.appliances = ${json.dumps(appliances_data)|n} + ThisPageData.applianceUUID = ${json.dumps(appliance.uuid if appliance else None)|n} + ThisPageData.charts = {} + + ThisPage.methods.fetchReadings = function(uuid) { + + if (!uuid) { + uuid = this.applianceUUID + } + + for (let chart in this.charts) { + chart.destroy() + } + this.charts = [] + + let url = '${url('tempmon.dashboard.readings')}' + let params = {appliance_uuid: uuid} + this.$http.get(url, {params: params}).then(response => { + if (response.data.probes) { + + for (let probe of response.data.probes) { + + let context = this.$refs[`tempchart-${'$'}{probe.uuid}`] + this.charts[probe.uuid] = new Chart(context, { + type: 'scatter', + data: { + datasets: [{ + label: probe.description, + data: probe.readings + }] + }, + options: { + scales: { + xAxes: [{ + type: 'time', + time: {unit: 'minute'}, + position: 'bottom' + }] + } + } + }) + } + + } else { + alert(response.data.error) + } + }) + } + + ThisPage.mounted = function() { + this.fetchReadings() + } + + </script> </%def> diff --git a/tailbone/views/tempmon/appliances.py b/tailbone/views/tempmon/appliances.py index 6b8ee036..c523ae78 100644 --- a/tailbone/views/tempmon/appliances.py +++ b/tailbone/views/tempmon/appliances.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -121,6 +121,14 @@ class TempmonApplianceView(MasterView): elif self.creating or self.editing: f.remove_field('probes') + def template_kwargs_view(self, **kwargs): + kwargs = super(TempmonApplianceView, self).template_kwargs_view(**kwargs) + appliance = kwargs['instance'] + + kwargs['probes_data'] = self.normalize_probes(appliance.probes) + + return kwargs + def unique_name(self, node, value): query = self.Session.query(tempmon.Appliance)\ .filter(tempmon.Appliance.name == value) diff --git a/tailbone/views/tempmon/clients.py b/tailbone/views/tempmon/clients.py index a3fdb31b..9edbd2ba 100644 --- a/tailbone/views/tempmon/clients.py +++ b/tailbone/views/tempmon/clients.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -159,6 +159,14 @@ class TempmonClientView(MasterView): # archived f.set_helptext('archived', tempmon.Client.archived.__doc__) + def template_kwargs_view(self, **kwargs): + kwargs = super(TempmonClientView, self).template_kwargs_view(**kwargs) + client = kwargs['instance'] + + kwargs['probes_data'] = self.normalize_probes(client.probes) + + return kwargs + def objectify(self, form, data=None): # this is a hack to prevent updates to the 'enabled' timestamp, when diff --git a/tailbone/views/tempmon/core.py b/tailbone/views/tempmon/core.py index 6665f50e..3f16860d 100644 --- a/tailbone/views/tempmon/core.py +++ b/tailbone/views/tempmon/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -42,6 +42,28 @@ class MasterView(views.MasterView): from rattail_tempmon.db import Session return Session() + def normalize_probes(self, probes): + data = [] + for probe in probes: + view_url = self.request.route_url('tempmon.probes.view', uuid=probe.uuid) + edit_url = self.request.route_url('tempmon.probes.edit', uuid=probe.uuid) + data.append({ + 'uuid': probe.uuid, + 'url': view_url, + '_action_url_view': view_url, + '_action_url_edit': edit_url, + 'description': probe.description, + 'critical_temp_min': probe.critical_temp_min, + 'good_temp_min': probe.good_temp_min, + 'good_temp_max': probe.good_temp_max, + 'critical_temp_max': probe.critical_temp_max, + 'status': self.enum.TEMPMON_PROBE_STATUS[probe.status], + 'enabled': "Yes" if probe.enabled else "No", + }) + app = self.get_rattail_app() + data = app.json_friendly(data) + return data + def render_probes(self, obj, field): """ This method is used by Appliance and Client views. @@ -50,6 +72,39 @@ class MasterView(views.MasterView): return "" route_prefix = self.get_route_prefix() + use_buefy = self.get_use_buefy() + if use_buefy: + + actions = [self.make_grid_action_view()] + if self.request.has_perm('tempmon.probes.edit'): + actions.append(self.make_grid_action_edit()) + + factory = self.get_grid_factory() + g = factory( + key='{}.probes'.format(route_prefix), + data=[], + columns=[ + 'description', + 'critical_temp_min', + 'good_temp_min', + 'good_temp_max', + 'critical_temp_max', + 'status', + 'enabled', + ], + labels={ + 'critical_temp_min': "Crit. Min", + 'good_temp_min': "Good Min", + 'good_temp_max': "Good Max", + 'critical_temp_max': "Crit. Max", + }, + linked_columns=['description'], + main_actions=actions, + ) + return HTML.literal( + g.render_buefy_table_element(data_prop='probesData')) + + # not buefy! view_url = lambda p, i: self.request.route_url('tempmon.probes.view', uuid=p.uuid) actions = [ grids.GridAction('view', icon='zoomin', url=view_url), diff --git a/tailbone/views/tempmon/dashboard.py b/tailbone/views/tempmon/dashboard.py index 954acf94..c2b925a8 100644 --- a/tailbone/views/tempmon/dashboard.py +++ b/tailbone/views/tempmon/dashboard.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -74,27 +74,31 @@ class TempmonDashboardView(View): selected_appliance = TempmonSession.query(tempmon.Appliance)\ .get(selected_uuid) - appliances = TempmonSession.query(tempmon.Appliance)\ - .order_by(tempmon.Appliance.name)\ - .all() - appliance_options = tags.Options([ - tags.Option(appliance.name, appliance.uuid) - for appliance in appliances]) - - if use_buefy: - appliance_select = None - raise NotImplementedError - else: - appliance_select = tags.select('appliance_uuid', selected_uuid, appliance_options) - - return { + context = { 'index_url': self.request.route_url('tempmon.appliances'), 'index_title': "TempMon Appliances", 'use_buefy': use_buefy, - 'appliance_select': appliance_select, 'appliance': selected_appliance, } + appliances = TempmonSession.query(tempmon.Appliance)\ + .order_by(tempmon.Appliance.name)\ + .all() + + if use_buefy: + context['appliances_data'] = [{'uuid': a.uuid, + 'name': a.name} + for a in appliances] + + else: + appliance_options = tags.Options([ + tags.Option(appliance.name, appliance.uuid) + for appliance in appliances]) + context['appliance_select'] = tags.select( + 'appliance_uuid', selected_uuid, appliance_options) + + return context + def readings(self): # track down the requested appliance From d8bd4bd847063df00b76d97dc7c9b93c57768a26 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 13 Jan 2023 20:28:00 -0600 Subject: [PATCH 0961/1681] Prevent listing for top-level Messages view user must access inbox, archive etc. directly instead --- tailbone/views/messages.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tailbone/views/messages.py b/tailbone/views/messages.py index f483d03b..29766b6b 100644 --- a/tailbone/views/messages.py +++ b/tailbone/views/messages.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -52,6 +52,7 @@ class MessageView(MasterView): checkboxes = True replying = False reply_header_sent_format = '%a %d %b %Y at %I:%M %p' + listable = False grid_columns = [ 'subject', From 80989cc84fcb71a022adcf62db50b9553438fe37 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 13 Jan 2023 20:53:26 -0600 Subject: [PATCH 0962/1681] 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 b530a0d2..3323bc18 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,22 @@ CHANGELOG ========= +0.8.282 (2023-01-13) +-------------------- + +* Show basic column info as row grid when viewing Table. + +* Semi-finish logic for writing new table model class to file. + +* Fix "toggle batch complete" for Chrome browser. + +* Revert logic that assumes all themes use buefy. + +* Refactor tempmon dashboard view, for buefy themes. + +* Prevent listing for top-level Messages view. + + 0.8.281 (2023-01-12) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 1bdfa062..fa24a9ed 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.281' +__version__ = '0.8.282' From 23358d9c5d3ed15e271e79a0921aae4d34eb0d4f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 14 Jan 2023 02:20:21 -0600 Subject: [PATCH 0963/1681] Tweak how backfill task is launched per upstream changes --- tailbone/views/luigi.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tailbone/views/luigi.py b/tailbone/views/luigi.py index d340bfee..d3bd7b43 100644 --- a/tailbone/views/luigi.py +++ b/tailbone/views/luigi.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -121,7 +121,10 @@ class LuigiTaskView(MasterView): 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) + self.luigi_handler.launch_backfill_task(task, start_date, end_date, + keep_config=False, + email_if_empty=True, + wait=False) except Exception as error: log.warning("failed to launch backfill task: %s", task, exc_info=True) From e82e27acd752907c6107bdb232fed21afec92b09 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 14 Jan 2023 08:40:08 -0600 Subject: [PATCH 0964/1681] 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 3323bc18..18d9fef1 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.283 (2023-01-14) +-------------------- + +* Tweak how backfill task is launched. + + 0.8.282 (2023-01-13) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index fa24a9ed..de749230 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.282' +__version__ = '0.8.283' From dec0ebba3035b916ed4de1a302cd0a9790faf96d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 14 Jan 2023 10:31:31 -0600 Subject: [PATCH 0965/1681] Let the API "rawbytes" response be just that, w/ no file --- tailbone/api/master.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/tailbone/api/master.py b/tailbone/api/master.py index 97426214..3d21cfbe 100644 --- a/tailbone/api/master.py +++ b/tailbone/api/master.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -479,13 +479,16 @@ class APIMasterView(APIView): """ obj = self.get_object() - filename = self.request.GET.get('filename', None) - if not filename: - raise self.notfound() - path = self.download_path(obj, filename) + # TODO: is this really needed? + # filename = self.request.GET.get('filename', None) + # if filename: + # path = self.download_path(obj, filename) + # return self.file_response(path, attachment=False) - response = self.file_response(path, attachment=False) - return response + return self.rawbytes_response(obj) + + def rawbytes_response(self, obj): + raise NotImplementedError ############################## # autocomplete From aef679c030baa3987202a3dec23f24a2a5989955 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 14 Jan 2023 11:51:22 -0600 Subject: [PATCH 0966/1681] Fix bug when adding new profile via datasync configure --- tailbone/templates/datasync/configure.mako | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/templates/datasync/configure.mako b/tailbone/templates/datasync/configure.mako index 63769ee8..f65d69a5 100644 --- a/tailbone/templates/datasync/configure.mako +++ b/tailbone/templates/datasync/configure.mako @@ -693,7 +693,7 @@ } ThisPage.methods.newProfile = function() { - this.editingProfile = {} + this.editingProfile = {watcher_kwargs_data: []} this.editingConsumer = null this.editingWatcherKwargs = false From cfdaa1e92713bce29055345ec23b34294c90bb9e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 14 Jan 2023 12:17:05 -0600 Subject: [PATCH 0967/1681] Add default logic to get merge data for object --- tailbone/views/master.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index d01bb462..c53dac60 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2094,7 +2094,8 @@ class MasterView(View): if self.merge_handler: return self.merge_handler.get_merge_preview_data(obj) - raise NotImplementedError("please implement `{}.get_merge_data()`".format(self.__class__.__name__)) + return dict([(f, getattr(obj, f)) + for f in self.get_merge_fields()]) def get_merge_resulting_data(self, remove, keep): result = dict(keep) From 39d53617bd70d16ed54f43b7ecd5847f144573d8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 14 Jan 2023 16:01:26 -0600 Subject: [PATCH 0968/1681] Add new handlers, TailboneHandler and MenuHandler --- tailbone/handler.py | 49 ++++ tailbone/menus.py | 632 +++++++++++++++++++++++--------------------- 2 files changed, 384 insertions(+), 297 deletions(-) create mode 100644 tailbone/handler.py diff --git a/tailbone/handler.py b/tailbone/handler.py new file mode 100644 index 00000000..c665545a --- /dev/null +++ b/tailbone/handler.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2023 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Tailbone Handler +""" + +from __future__ import unicode_literals, absolute_import + +from rattail.app import GenericHandler + + +class TailboneHandler(GenericHandler): + """ + Base class and default implementation for Tailbone handler. + """ + + def get_menu_handler(self, **kwargs): + """ + Get the configured "menu" handler. + + :returns: The :class:`~tailbone.menus.MenuHandler` instance + for the app. + """ + if not hasattr(self, 'menu_handler'): + spec = self.config.get('tailbone.menus', 'handler', + default='tailbone.menus:MenuHandler') + Handler = self.app.load_object(spec) + self.menu_handler = Handler(self.config) + return self.menu_handler diff --git a/tailbone/menus.py b/tailbone/menus.py index 7da22696..d3a2a4aa 100644 --- a/tailbone/menus.py +++ b/tailbone/menus.py @@ -28,7 +28,9 @@ from __future__ import unicode_literals, absolute_import import re import logging +import warnings +from rattail.app import GenericHandler from rattail.util import import_module_path, prettify, simple_error from webhelpers2.html import tags, HTML @@ -39,35 +41,336 @@ from tailbone.db import Session log = logging.getLogger(__name__) +class MenuHandler(GenericHandler): + """ + Base class and default implementation for menu handler. + """ + + def make_raw_menus(self, request, **kwargs): + """ + Generate a full set of "raw" menus for the app. + + The "raw" menus are basically just a set of dicts to represent + the final menus. + """ + # first try to make menus from config, but this is highly + # susceptible to failure, so try to warn user of problems + try: + menus = self.make_menus_from_config(request) + if menus: + return menus + except Exception as error: + + # TODO: these messages show up multiple times on some pages?! + # that must mean the BeforeRender event is firing multiple + # times..but why?? seems like there is only 1 request... + log.warning("failed to make menus from config", exc_info=True) + request.session.flash(simple_error(error), 'error') + request.session.flash("Menu config is invalid! Reverting to menus " + "defined in code!", 'warning') + msg = HTML.literal('Please edit your {} ASAP.'.format( + tags.link_to("Menu Config", request.route_url('configure_menus')))) + request.session.flash(msg, 'warning') + + # okay, no config, so menus must be built from code.. + + # first check for a "simple menus" module; use that if defined + menumod = self.config.get('tailbone', 'menus') + if menumod: + menumod = import_module_path(menumod) + if (not hasattr(menumod, 'simple_menus') + or not callable(menumod.simple_menus)): + raise RuntimeError("module does not have a simple_menus() " + "callable: {}".format(menumod)) + return menumod.simple_menus(request) + + # now we fallback to menu handler method + return self.make_menus(request) + + def make_menus_from_config(self, request, **kwargs): + """ + Try to build a complete menu set from config/settings. + + This will look in the DB settings table, or config file, for + menu data. If found, it constructs menus from that data. + """ + # bail unless config defines top-level menu keys + main_keys = self.config.getlist('tailbone.menu', 'menus') + if not main_keys: + return + + model = self.model + menus = [] + + # menu definition can come either from config file or db + # settings, but if the latter then we want to optimize with + # one big query + if self.config.getbool('tailbone.menu', 'from_settings', + default=False): + + # fetch all menu-related settings at once + query = Session().query(model.Setting)\ + .filter(model.Setting.name.like('tailbone.menu.%')) + settings = self.app.cache_model(Session(), model.Setting, + query=query, key='name', + normalizer=lambda s: s.value) + for key in main_keys: + menus.append(self.make_single_menu_from_settings(request, key, + settings)) + + else: # read from config file only + for key in main_keys: + menus.append(self.make_single_menu_from_config(request, key)) + + return menus + + def make_single_menu_from_config(self, request, key, **kwargs): + """ + Makes a single top-level menu dict from config file. Note + that this will read from config file(s) *only* and avoids + querying the database, for efficiency. + """ + menu = { + 'key': key, + 'type': 'menu', + 'items': [], + } + + # title + title = self.config.get('tailbone.menu', + 'menu.{}.label'.format(key), + usedb=False) + menu['title'] = title or prettify(key) + + # items + item_keys = self.config.getlist('tailbone.menu', + 'menu.{}.items'.format(key), + usedb=False) + for item_key in item_keys: + item = {} + + if item_key == 'SEP': + item['type'] = 'sep' + + else: + item['type'] = 'item' + item['key'] = item_key + + # title + title = self.config.get('tailbone.menu', + 'menu.{}.item.{}.label'.format(key, item_key), + usedb=False) + item['title'] = title or prettify(item_key) + + # route + route = self.config.get('tailbone.menu', + 'menu.{}.item.{}.route'.format(key, item_key), + usedb=False) + if route: + item['route'] = route + item['url'] = request.route_url(route) + + else: + + # url + url = self.config.get('tailbone.menu', + 'menu.{}.item.{}.url'.format(key, item_key), + usedb=False) + if not url: + url = request.route_url(item_key) + elif url.startswith('route:'): + url = request.route_url(url[6:]) + item['url'] = url + + # perm + perm = self.config.get('tailbone.menu', + 'menu.{}.item.{}.perm'.format(key, item_key), + usedb=False) + item['perm'] = perm or '{}.list'.format(item_key) + + menu['items'].append(item) + + return menu + + def make_single_menu_from_settings(self, request, key, settings, **kwargs): + """ + Makes a single top-level menu dict from DB settings. + """ + menu = { + 'key': key, + 'type': 'menu', + 'items': [], + } + + # title + title = settings.get('tailbone.menu.menu.{}.label'.format(key)) + menu['title'] = title or prettify(key) + + # items + item_keys = self.config.parse_list( + settings.get('tailbone.menu.menu.{}.items'.format(key))) + for item_key in item_keys: + item = {} + + if item_key == 'SEP': + item['type'] = 'sep' + + else: + item['type'] = 'item' + item['key'] = item_key + + # title + title = settings.get('tailbone.menu.menu.{}.item.{}.label'.format( + key, item_key)) + item['title'] = title or prettify(item_key) + + # route + route = settings.get('tailbone.menu.menu.{}.item.{}.route'.format( + key, item_key)) + if route: + item['route'] = route + item['url'] = request.route_url(route) + + else: + + # url + url = settings.get('tailbone.menu.menu.{}.item.{}.url'.format( + key, item_key)) + if not url: + url = request.route_url(item_key) + if url.startswith('route:'): + url = request.route_url(url[6:]) + item['url'] = url + + # perm + perm = settings.get('tailbone.menu.menu.{}.item.{}.perm'.format( + key, item_key)) + item['perm'] = perm or '{}.list'.format(item_key) + + menu['items'].append(item) + + return menu + + def make_menus(self, request, **kwargs): + """ + Make the full set of menus for the app. + + This method provides a semi-sane menu set by default, but it + is expected for most apps to override it. + """ + return [ + self.make_admin_menu(request), + ] + + def make_admin_menu(self, request, include_stores=False, **kwargs): + """ + Generate a typical Admin menu + """ + items = [] + + if include_stores: + items.append({ + 'title': "Stores", + 'route': 'stores', + 'perm': 'stores.list', + }) + + items.extend([ + { + 'title': "Users", + 'route': 'users', + 'perm': 'users.list', + }, + { + 'title': "User Events", + 'route': 'userevents', + 'perm': 'userevents.list', + }, + { + 'title': "Roles", + 'route': 'roles', + 'perm': 'roles.list', + }, + {'type': 'sep'}, + { + 'title': "App Settings", + 'route': 'appsettings', + 'perm': 'settings.list', + }, + { + 'title': "Email Settings", + 'route': 'emailprofiles', + 'perm': 'emailprofiles.list', + }, + { + 'title': "Email Attempts", + 'route': 'email_attempts', + 'perm': 'email_attempts.list', + }, + { + 'title': "Raw Settings", + 'route': 'settings', + 'perm': 'settings.list', + }, + {'type': 'sep'}, + { + 'title': "DataSync Changes", + 'route': 'datasyncchanges', + 'perm': 'datasync_changes.list', + }, + { + 'title': "DataSync Status", + 'route': 'datasync.status', + 'perm': 'datasync.status', + }, + { + 'title': "Importing / Exporting", + 'route': 'importing', + 'perm': 'importing.list', + }, + { + 'title': "Luigi Tasks", + 'route': 'luigi', + 'perm': 'luigi.list', + }, + { + 'title': "Tables", + 'route': 'tables', + 'perm': 'tables.list', + }, + { + 'title': "App Info", + 'route': 'appinfo', + 'perm': 'appinfo.list', + }, + { + 'title': "Configure App", + 'route': 'appinfo.configure', + 'perm': 'appinfo.configure', + }, + { + 'title': "Upgrades", + 'route': 'upgrades', + 'perm': 'upgrades.list', + }, + ]) + + return { + 'title': "Admin", + 'type': 'menu', + 'items': items, + } + + def make_simple_menus(request): """ Build the main menu list for the app. """ - # first try to make menus from config, but this is highly - # susceptible to failure, so try to warn user of problems - raw_menus = None - try: - raw_menus = make_menus_from_config(request) - except Exception as error: - # TODO: these messages show up multiple times on some pages?! - # that must mean the BeforeRender event is firing multiple - # times..but why?? seems like there is only 1 request... - log.warning("failed to make menus from config", exc_info=True) - request.session.flash(simple_error(error), 'error') - request.session.flash("Menu config is invalid! Reverting to menus " - "defined in code!", 'warning') - msg = HTML.literal('Please edit your {} ASAP.'.format( - tags.link_to("Menu Config", request.route_url('configure_menus')))) - request.session.flash(msg, 'warning') + app = request.rattail_config.get_app() + tailbone_handler = app.get_tailbone_handler() + menu_handler = tailbone_handler.get_menu_handler() - if not raw_menus: - - # no config, so import/invoke code function to build them - menus_module = import_module_path( - request.rattail_config.require('tailbone', 'menus')) - if not hasattr(menus_module, 'simple_menus') or not callable(menus_module.simple_menus): - raise RuntimeError("module does not have a simple_menus() callable: {}".format(menus_module)) - raw_menus = menus_module.simple_menus(request) + raw_menus = menu_handler.make_raw_menus(request) # now we have "simple" (raw) menus definition, but must refine # that somewhat to produce our final menus @@ -140,185 +443,6 @@ def make_simple_menus(request): return final_menus -def make_menus_from_config(request): - """ - Try to build a complete menu set from config/settings. - - This essentially checks for the top-level menu list in config; if - found then it will build a full menu set from config. If this - top-level list is not present in config then menus will be built - purely from code instead. An example of this top-level list: - - .. code-hightlight:: ini - - [tailbone.menu] - menus = first, second, third, admin - - Obviously much more config would be needed to define those menus - etc. but that is the option that determines whether the rest of - menu config is even read, or not. - """ - config = request.rattail_config - main_keys = config.getlist('tailbone.menu', 'menus') - if not main_keys: - return - - menus = [] - - # menu definition can come either from config file or db settings, - # but if the latter then we want to optimize with one big query - if config.getbool('tailbone.menu', 'from_settings', - default=False): - app = config.get_app() - model = config.get_model() - - # fetch all menu-related settings at once - query = Session().query(model.Setting)\ - .filter(model.Setting.name.like('tailbone.menu.%')) - settings = app.cache_model(Session(), model.Setting, - query=query, key='name', - normalizer=lambda s: s.value) - for key in main_keys: - menus.append(make_single_menu_from_settings(request, key, settings)) - - else: # read from config file only - for key in main_keys: - menus.append(make_single_menu_from_config(request, key)) - - return menus - - -def make_single_menu_from_config(request, key): - """ - Makes a single top-level menu dict from config file. Note that - this will read from config file(s) *only* and avoids querying the - database, for efficiency. - """ - config = request.rattail_config - menu = { - 'key': key, - 'type': 'menu', - 'items': [], - } - - # title - title = config.get('tailbone.menu', - 'menu.{}.label'.format(key), - usedb=False) - menu['title'] = title or prettify(key) - - # items - item_keys = config.getlist('tailbone.menu', - 'menu.{}.items'.format(key), - usedb=False) - for item_key in item_keys: - item = {} - - if item_key == 'SEP': - item['type'] = 'sep' - - else: - item['type'] = 'item' - item['key'] = item_key - - # title - title = config.get('tailbone.menu', - 'menu.{}.item.{}.label'.format(key, item_key), - usedb=False) - item['title'] = title or prettify(item_key) - - # route - route = config.get('tailbone.menu', - 'menu.{}.item.{}.route'.format(key, item_key), - usedb=False) - if route: - item['route'] = route - item['url'] = request.route_url(route) - - else: - - # url - url = config.get('tailbone.menu', - 'menu.{}.item.{}.url'.format(key, item_key), - usedb=False) - if not url: - url = request.route_url(item_key) - elif url.startswith('route:'): - url = request.route_url(url[6:]) - item['url'] = url - - # perm - perm = config.get('tailbone.menu', - 'menu.{}.item.{}.perm'.format(key, item_key), - usedb=False) - item['perm'] = perm or '{}.list'.format(item_key) - - menu['items'].append(item) - - return menu - - -def make_single_menu_from_settings(request, key, settings): - """ - Makes a single top-level menu dict from DB settings. - """ - config = request.rattail_config - menu = { - 'key': key, - 'type': 'menu', - 'items': [], - } - - # title - title = settings.get('tailbone.menu.menu.{}.label'.format(key)) - menu['title'] = title or prettify(key) - - # items - item_keys = config.parse_list( - settings.get('tailbone.menu.menu.{}.items'.format(key))) - for item_key in item_keys: - item = {} - - if item_key == 'SEP': - item['type'] = 'sep' - - else: - item['type'] = 'item' - item['key'] = item_key - - # title - title = settings.get('tailbone.menu.menu.{}.item.{}.label'.format( - key, item_key)) - item['title'] = title or prettify(item_key) - - # route - route = settings.get('tailbone.menu.menu.{}.item.{}.route'.format( - key, item_key)) - if route: - item['route'] = route - item['url'] = request.route_url(route) - - else: - - # url - url = settings.get('tailbone.menu.menu.{}.item.{}.url'.format( - key, item_key)) - if not url: - url = request.route_url(item_key) - if url.startswith('route:'): - url = request.route_url(url[6:]) - item['url'] = url - - # perm - perm = settings.get('tailbone.menu.menu.{}.item.{}.perm'.format( - key, item_key)) - item['perm'] = perm or '{}.list'.format(item_key) - - menu['items'].append(item) - - return menu - - def make_menu_key(config, value): """ Generate a normalized menu key for the given value. @@ -405,101 +529,15 @@ def mark_allowed(request, menus): break -def make_admin_menu(request, include_stores=False): +def make_admin_menu(request, **kwargs): """ Generate a typical Admin menu """ - items = [] + warnings.warn("make_admin_menu() function is deprecated; please use " + "MenuHandler.make_admin_menu() instead", + DeprecationWarning, stacklevel=2) - if include_stores: - items.append({ - 'title': "Stores", - 'route': 'stores', - 'perm': 'stores.list', - }) - - items.extend([ - { - 'title': "Users", - 'route': 'users', - 'perm': 'users.list', - }, - { - 'title': "User Events", - 'route': 'userevents', - 'perm': 'userevents.list', - }, - { - 'title': "Roles", - 'route': 'roles', - 'perm': 'roles.list', - }, - {'type': 'sep'}, - { - 'title': "App Settings", - 'route': 'appsettings', - 'perm': 'settings.list', - }, - { - 'title': "Email Settings", - 'route': 'emailprofiles', - 'perm': 'emailprofiles.list', - }, - { - 'title': "Email Attempts", - 'route': 'email_attempts', - 'perm': 'email_attempts.list', - }, - { - 'title': "Raw Settings", - 'route': 'settings', - 'perm': 'settings.list', - }, - {'type': 'sep'}, - { - 'title': "DataSync Changes", - 'route': 'datasyncchanges', - 'perm': 'datasync_changes.list', - }, - { - 'title': "DataSync Status", - 'route': 'datasync.status', - 'perm': 'datasync.status', - }, - { - 'title': "Importing / Exporting", - 'route': 'importing', - 'perm': 'importing.list', - }, - { - 'title': "Luigi Tasks", - 'route': 'luigi', - 'perm': 'luigi.list', - }, - { - 'title': "Tables", - 'route': 'tables', - 'perm': 'tables.list', - }, - { - 'title': "App Info", - 'route': 'appinfo', - 'perm': 'appinfo.list', - }, - { - 'title': "Configure App", - 'route': 'appinfo.configure', - 'perm': 'appinfo.configure', - }, - { - 'title': "Upgrades", - 'route': 'upgrades', - 'perm': 'upgrades.list', - }, - ]) - - return { - 'title': "Admin", - 'type': 'menu', - 'items': items, - } + app = request.rattail_config.get_app() + tailbone_handler = app.get_tailbone_handler() + menu_handler = tailbone_handler.get_menu_handler() + return menu_handler.make_admin_menu(request, **kwargs) From 9d2bcff96bcb9d71250af55ebace4e50b81132ad Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 14 Jan 2023 18:48:56 -0600 Subject: [PATCH 0969/1681] Add full set of default menus plus dynamic set of integration menus, from providers --- tailbone/handler.py | 9 ++ tailbone/menus.py | 350 ++++++++++++++++++++++++++++++------------ tailbone/providers.py | 5 +- 3 files changed, 269 insertions(+), 95 deletions(-) diff --git a/tailbone/handler.py b/tailbone/handler.py index c665545a..cb78dc82 100644 --- a/tailbone/handler.py +++ b/tailbone/handler.py @@ -26,8 +26,12 @@ Tailbone Handler from __future__ import unicode_literals, absolute_import +import six + from rattail.app import GenericHandler +from tailbone.providers import get_all_providers + class TailboneHandler(GenericHandler): """ @@ -46,4 +50,9 @@ class TailboneHandler(GenericHandler): default='tailbone.menus:MenuHandler') Handler = self.app.load_object(spec) self.menu_handler = Handler(self.config) + self.menu_handler.tb = self return self.menu_handler + + def iter_providers(self): + providers = get_all_providers(self.config) + return six.itervalues(providers) diff --git a/tailbone/menus.py b/tailbone/menus.py index d3a2a4aa..a9de79dc 100644 --- a/tailbone/menus.py +++ b/tailbone/menus.py @@ -258,107 +258,269 @@ class MenuHandler(GenericHandler): This method provides a semi-sane menu set by default, but it is expected for most apps to override it. """ - return [ - self.make_admin_menu(request), + menus = [ + self.make_people_menu(request), + self.make_products_menu(request), + self.make_vendors_menu(request), ] - def make_admin_menu(self, request, include_stores=False, **kwargs): + integration_menus = self.make_integration_menus(request) + if integration_menus: + menus.extend(integration_menus) + + menus.extend([ + # TODO: add reporting menu + self.make_batches_menu(request), + self.make_admin_menu(request), + ]) + + return menus + + def make_integration_menus(self, request, **kwargs): + """ + Make a set of menus for all registered system integrations. + """ + menus = [] + for provider in self.tb.iter_providers(): + menu = provider.make_integration_menu(request) + if menu: + menus.append(menu) + menus.sort(key=lambda menu: menu['title'].lower()) + return menus + + def make_people_menu(self, request, **kwargs): + """ + Generate a typical People menu + """ + return { + 'title': "People", + 'type': 'menu', + 'items': [ + { + 'title': "Members", + 'route': 'members', + 'perm': 'members.list', + }, + { + 'title': "Customers", + 'route': 'customers', + 'perm': 'customers.list', + }, + { + 'title': "Customer Groups", + 'route': 'customergroups', + 'perm': 'customergroups.list', + }, + { + 'title': "Employees", + 'route': 'employees', + 'perm': 'employees.list', + }, + { + 'title': "All People", + 'route': 'people', + 'perm': 'people.list', + }, + ], + } + + def make_products_menu(self, request, **kwargs): + """ + Generate a typical Products menu + """ + return { + 'title': "Products", + 'type': 'menu', + 'items': [ + { + 'title': "Products", + 'route': 'products', + 'perm': 'products.list', + }, + { + 'title': "Departments", + 'route': 'departments', + 'perm': 'departments.list', + }, + { + 'title': "Subdepartments", + 'route': 'subdepartments', + 'perm': 'subdepartments.list', + }, + { + 'title': "Brands", + 'route': 'brands', + 'perm': 'brands.list', + }, + { + 'title': "Families", + 'route': 'families', + 'perm': 'families.list', + }, + { + 'title': "Report Codes", + 'route': 'reportcodes', + 'perm': 'reportcodes.list', + }, + ], + } + + def make_vendors_menu(self, request, **kwargs): + """ + Generate a typical Vendors menu + """ + return { + 'title': "Vendors", + 'type': 'menu', + 'items': [ + { + 'title': "Vendors", + 'route': 'vendors', + 'perm': 'vendors.list', + }, + {'type': 'sep'}, + { + 'title': "Ordering", + 'route': 'ordering', + 'perm': 'ordering.list', + }, + { + 'title': "Receiving", + 'route': 'receiving', + 'perm': 'receiving.list', + }, + {'type': 'sep'}, + { + 'title': "Purchases", + 'route': 'purchases', + 'perm': 'purchases.list', + }, + { + 'title': "Credits", + 'route': 'purchases.credits', + 'perm': 'purchases.credits.list', + }, + # {'type': 'sep'}, + # { + # 'title': "Catalogs", + # 'route': 'vendorcatalogs', + # 'perm': 'vendorcatalogs.list', + # }, + ], + } + + def make_batches_menu(self, request, **kwargs): + """ + Generate a typical Batches menu + """ + return { + 'title': "Batches", + 'type': 'menu', + 'items': [ + { + 'title': "Handheld", + 'route': 'batch.handheld', + 'perm': 'batch.handheld.list', + }, + # { + # 'title': "Inventory", + # 'route': 'batch.inventory', + # 'perm': 'batch.inventory.list', + # }, + ], + } + + def make_admin_menu(self, request, **kwargs): """ Generate a typical Admin menu """ - items = [] - - if include_stores: - items.append({ - 'title': "Stores", - 'route': 'stores', - 'perm': 'stores.list', - }) - - items.extend([ - { - 'title': "Users", - 'route': 'users', - 'perm': 'users.list', - }, - { - 'title': "User Events", - 'route': 'userevents', - 'perm': 'userevents.list', - }, - { - 'title': "Roles", - 'route': 'roles', - 'perm': 'roles.list', - }, - {'type': 'sep'}, - { - 'title': "App Settings", - 'route': 'appsettings', - 'perm': 'settings.list', - }, - { - 'title': "Email Settings", - 'route': 'emailprofiles', - 'perm': 'emailprofiles.list', - }, - { - 'title': "Email Attempts", - 'route': 'email_attempts', - 'perm': 'email_attempts.list', - }, - { - 'title': "Raw Settings", - 'route': 'settings', - 'perm': 'settings.list', - }, - {'type': 'sep'}, - { - 'title': "DataSync Changes", - 'route': 'datasyncchanges', - 'perm': 'datasync_changes.list', - }, - { - 'title': "DataSync Status", - 'route': 'datasync.status', - 'perm': 'datasync.status', - }, - { - 'title': "Importing / Exporting", - 'route': 'importing', - 'perm': 'importing.list', - }, - { - 'title': "Luigi Tasks", - 'route': 'luigi', - 'perm': 'luigi.list', - }, - { - 'title': "Tables", - 'route': 'tables', - 'perm': 'tables.list', - }, - { - 'title': "App Info", - 'route': 'appinfo', - 'perm': 'appinfo.list', - }, - { - 'title': "Configure App", - 'route': 'appinfo.configure', - 'perm': 'appinfo.configure', - }, - { - 'title': "Upgrades", - 'route': 'upgrades', - 'perm': 'upgrades.list', - }, - ]) - return { 'title': "Admin", 'type': 'menu', - 'items': items, + 'items': [ + { + 'title': "Stores", + 'route': 'stores', + 'perm': 'stores.list', + }, + { + 'title': "Users", + 'route': 'users', + 'perm': 'users.list', + }, + { + 'title': "User Events", + 'route': 'userevents', + 'perm': 'userevents.list', + }, + { + 'title': "Roles", + 'route': 'roles', + 'perm': 'roles.list', + }, + {'type': 'sep'}, + { + 'title': "App Settings", + 'route': 'appsettings', + 'perm': 'settings.list', + }, + { + 'title': "Email Settings", + 'route': 'emailprofiles', + 'perm': 'emailprofiles.list', + }, + { + 'title': "Email Attempts", + 'route': 'email_attempts', + 'perm': 'email_attempts.list', + }, + { + 'title': "Raw Settings", + 'route': 'settings', + 'perm': 'settings.list', + }, + {'type': 'sep'}, + { + 'title': "DataSync Changes", + 'route': 'datasyncchanges', + 'perm': 'datasync_changes.list', + }, + { + 'title': "DataSync Status", + 'route': 'datasync.status', + 'perm': 'datasync.status', + }, + { + 'title': "Importing / Exporting", + 'route': 'importing', + 'perm': 'importing.list', + }, + { + 'title': "Luigi Tasks", + 'route': 'luigi', + 'perm': 'luigi.list', + }, + { + 'title': "Tables", + 'route': 'tables', + 'perm': 'tables.list', + }, + { + 'title': "App Info", + 'route': 'appinfo', + 'perm': 'appinfo.list', + }, + { + 'title': "Configure App", + 'route': 'appinfo.configure', + 'perm': 'appinfo.configure', + }, + { + 'title': "Upgrades", + 'route': 'upgrades', + 'perm': 'upgrades.list', + }, + ], } @@ -478,7 +640,7 @@ def make_menu_entry(request, item): try: entry['url'] = request.route_url(entry['route']) except KeyError: # happens if no such route - log.warning("invalid route name for menu entry: %s", entry) + log.debug("invalid route name for menu entry: %s", entry) entry['url'] = entry['route'] entry['key'] = entry['route'] else: diff --git a/tailbone/providers.py b/tailbone/providers.py index baa2a15d..a538fa73 100644 --- a/tailbone/providers.py +++ b/tailbone/providers.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -48,6 +48,9 @@ class TailboneProvider(object): def get_provided_views(self): return {} + def make_integration_menu(self, request, **kwargs): + pass + def get_all_providers(config): """ From 68ed5942e650423eb74e8dc8c4658952ea288ff8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 14 Jan 2023 23:23:21 -0600 Subject: [PATCH 0970/1681] Add basic "Review Model" step for new table wizard --- tailbone/templates/tables/create.mako | 132 +++++++++++++++++++++++++- tailbone/views/tables.py | 28 +++++- 2 files changed, 156 insertions(+), 4 deletions(-) diff --git a/tailbone/templates/tables/create.mako b/tailbone/templates/tables/create.mako index 4d46273a..9cf4a112 100644 --- a/tailbone/templates/tables/create.mako +++ b/tailbone/templates/tables/create.mako @@ -275,6 +275,12 @@ <b-input v-model="tableModelFile"></b-input> </b-field> + <b-field horizontal> + <b-checkbox v-model="tableModelFileOverwrite"> + Overwrite file if it exists + </b-checkbox> + </b-field> + <div class="form"> <div class="buttons"> <b-button icon-pack="fas" @@ -289,6 +295,11 @@ :disabled="writingModelFile"> {{ writingModelFile ? "Working, please wait..." : "Write model class to file" }} </b-button> + <b-button icon-pack="fas" + icon-left="arrow-right" + @click="activeStep = 'review-model'"> + Skip + </b-button> </div> </div> </b-step-item> @@ -299,7 +310,87 @@ <h3 class="is-size-3 block"> Review Model </h3> - <p class="block">TODO: review model class here</p> + + <p class="block"> + Model code was generated to file: + </p> + + <p class="block is-family-code" style="padding-left: 3rem;"> + {{ tableModelFile }} + </p> + + <p class="block"> + First, review that code and adjust to your liking. + </p> + + <p class="block"> + Next be sure to import the new model. Typically this is done + by editing the file... + </p> + + <p class="block is-family-code" style="padding-left: 3rem;"> + ${model_dir}__init__.py + </p> + + <p class="block"> + ...and adding a line such as: + </p> + + <p class="block is-family-code" style="padding-left: 3rem;"> + from .{{ tableModelFileModuleName }} import {{ tableModelName }} + </p> + + <p class="block"> + Once you've done all that, the web app must be restarted. + This may happen automatically depending on your setup. + Test the model import status below. + </p> + + <div class="card block"> + <header class="card-header"> + <p class="card-header-title"> + Model Import Status + </p> + </header> + <div class="card-content"> + <div class="content"> + <div class="level"> + <div class="level-left"> + + <div class="level-item"> + <span v-if="!modelImported && !modelImportProblem"> + import not yet attempted + </span> + <span v-if="modelImported" + class="has-text-success has-text-weight-bold"> + imported okay + </span> + <span v-if="modelImportProblem" + class="has-text-danger"> + import failed: {{ modelImportStatus }} + </span> + </div> + </div> + <div class="level-right"> + <div class="level-item"> + <b-field horizontal label="Model Class"> + <b-input v-model="modelImportName"></b-input> + </b-field> + </div> + <div class="level-item"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="redo" + @click="modelImportTest()"> + Refresh / Test Import + </b-button> + </div> + </div> + </div> + </div> + </div> + </div> + <div class="buttons"> <b-button icon-pack="fas" icon-left="arrow-left" @@ -309,7 +400,8 @@ <b-button type="is-primary" icon-pack="fas" icon-left="check" - @click="activeStep = 'write-revision'"> + @click="activeStep = 'write-revision'" + :disabled="!modelImported"> Model class looks good! </b-button> </div> @@ -515,11 +607,17 @@ } ThisPageData.tableModelFile = '${model_dir}widget.py' + ThisPageData.tableModelFileOverwrite = false ThisPageData.writingModelFile = false ThisPage.methods.writeModelFile = function() { this.writingModelFile = true + this.modelImportName = this.tableModelName + this.modelImported = false + this.modelImportStatus = "import not yet attempted" + this.modelImportProblem = false + let url = '${url('{}.write_model_file'.format(route_prefix))}' let params = { branch_name: this.tableBranch, @@ -529,8 +627,9 @@ model_title_plural: this.tableModelTitlePlural, description: this.tableDescription, versioned: this.tableVersioned, - module_file: this.tableModelFile, columns: this.tableColumns, + module_file: this.tableModelFile, + overwrite: this.tableModelFileOverwrite, } this.submitForm(url, params, response => { this.writingModelFile = false @@ -540,6 +639,33 @@ }) } + ThisPageData.modelImportName = '${rattail_app.get_class_prefix()}Widget' + ThisPageData.modelImportStatus = "import not yet attempted" + ThisPageData.modelImported = false + ThisPageData.modelImportProblem = false + + ThisPage.computed.tableModelFileModuleName = function() { + let path = this.tableModelFile + path = path.replace(/^.*\//, '') + path = path.replace(/\.py$/, '') + return path + } + + ThisPage.methods.modelImportTest = function() { + let url = '${url('{}.check_model'.format(route_prefix))}' + let params = {model_name: this.modelImportName} + this.submitForm(url, params, response => { + if (response.data.problem) { + this.modelImportProblem = true + this.modelImported = false + this.modelImportStatus = response.data.problem + } else { + this.modelImportProblem = false + this.modelImported = true + } + }) + } + </script> </%def> diff --git a/tailbone/views/tables.py b/tailbone/views/tables.py index 196e70f5..d398733c 100644 --- a/tailbone/views/tables.py +++ b/tailbone/views/tables.py @@ -183,11 +183,28 @@ class TableView(MasterView): path = data['module_file'] if os.path.exists(path): - return {'error': "File already exists"} + if data['overwrite']: + os.remove(path) + else: + return {'error': "File already exists"} self.db_handler.write_table_model(data, path) return {'ok': True} + def check_model(self): + model = self.model + data = self.request.json_body + model_name = data['model_name'] + + if not hasattr(model, model_name): + return {'ok': True, + 'problem': "class not found in primary model contents", + 'model': self.model.__name__} + + # TODO: probably should inspect closer before assuming ok..? + + return {'ok': True} + def get_row_data(self, table): data = [] for i, column in enumerate(table['table'].columns, 1): @@ -261,6 +278,15 @@ class TableView(MasterView): renderer='json', permission='{}.create'.format(permission_prefix)) + # check model + config.add_route('{}.check_model'.format(route_prefix), + '{}/check-model'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='check_model', + route_name='{}.check_model'.format(route_prefix), + renderer='json', + permission='{}.create'.format(permission_prefix)) + class TablesView(TableView): From f4bc280da7e3869ece64b28b365d61598f8753e6 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 15 Jan 2023 22:52:01 -0600 Subject: [PATCH 0971/1681] Wrap up steps for new table wizard it actually works.. :) needs more polish, but will let usage drive that --- tailbone/templates/tables/create.mako | 219 ++++++++++++++++++++++---- tailbone/views/tables.py | 68 ++++++++ 2 files changed, 258 insertions(+), 29 deletions(-) diff --git a/tailbone/templates/tables/create.mako b/tailbone/templates/tables/create.mako index 9cf4a112..6c88bb7a 100644 --- a/tailbone/templates/tables/create.mako +++ b/tailbone/templates/tables/create.mako @@ -26,10 +26,10 @@ Enter Details </h3> - <b-field label="Schema Branch" horizontal + <b-field label="Schema Branch" message="Leave this set to your custom app branch, unless you know what you're doing."> - <b-select v-model="tableBranch"> - <option v-for="branch in branchOptions" + <b-select v-model="alembicBranch"> + <option v-for="branch in alembicBranchOptions" :key="branch" :value="branch"> {{ branch }} @@ -260,7 +260,7 @@ </h3> <b-field label="Schema Branch" horizontal> - {{ tableBranch }} + {{ alembicBranch }} </b-field> <b-field label="Table Name" horizontal> @@ -306,7 +306,8 @@ <b-step-item step="3" value="review-model" - label="Review Model"> + label="Review Model" + clickable> <h3 class="is-size-3 block"> Review Model </h3> @@ -404,12 +405,18 @@ :disabled="!modelImported"> Model class looks good! </b-button> + <b-button icon-pack="fas" + icon-left="arrow-right" + @click="activeStep = 'write-revision'"> + Skip + </b-button> </div> </b-step-item> <b-step-item step="4" value="write-revision" - label="Write Revision"> + label="Write Revision" + clickable> <h3 class="is-size-3 block"> Write Revision </h3> @@ -417,9 +424,25 @@ You said the model class looked good, so next we will generate a revision script, used to modify DB schema. </p> - <p class="block"> - TODO: write revision script here - </p> + + <b-field label="Schema Branch" + message="Leave this set to your custom app branch, unless you know what you're doing."> + <b-select v-model="alembicBranch"> + <option v-for="branch in alembicBranchOptions" + :key="branch" + :value="branch"> + {{ branch }} + </option> + </b-select> + </b-field> + + <b-field label="Message" + message="Human-friendly brief description of the changes"> + <b-input v-model="revisionMessage"></b-input> + </b-field> + + <br /> + <div class="buttons"> <b-button icon-pack="fas" icon-left="arrow-left" @@ -429,8 +452,14 @@ <b-button type="is-primary" icon-pack="fas" icon-left="save" + @click="writeRevisionScript()" + :disabled="writingRevisionScript"> + {{ writingRevisionScript ? "Working, please wait..." : "Generate revision script" }} + </b-button> + <b-button icon-pack="fas" + icon-left="arrow-right" @click="activeStep = 'review-revision'"> - Write revision script to file + Skip </b-button> </div> </b-step-item> @@ -441,7 +470,19 @@ <h3 class="is-size-3 block"> Review Revision </h3> - <p class="block">TODO: review revision script here</p> + + <p class="block"> + Revision script was generated to file: + </p> + + <p class="block is-family-code" style="padding-left: 3rem;"> + {{ revisionScript }} + </p> + + <p class="block"> + Please review that code and adjust to your liking. + </p> + <div class="buttons"> <b-button icon-pack="fas" icon-left="arrow-left" @@ -459,11 +500,16 @@ <b-step-item step="6" value="upgrade-db" - label="Upgrade DB"> + label="Upgrade DB" + clickable> <h3 class="is-size-3 block"> Upgrade DB </h3> - <p class="block">TODO: upgrade DB here</p> + <p class="block"> + You said the revision script looked good, so next we will use + it to upgrade your actual database. + </p> + <div class="buttons"> <b-button icon-pack="fas" icon-left="arrow-left" @@ -472,20 +518,72 @@ </b-button> <b-button type="is-primary" icon-pack="fas" - icon-left="check" - @click="activeStep = 'review-db'"> - Upgrade database + icon-left="arrow-up" + @click="upgradeDB()" + :disabled="upgradingDB"> + {{ upgradingDB ? "Working, please wait..." : "Upgrade database" }} </b-button> </div> </b-step-item> <b-step-item step="7" value="review-db" - label="Review DB"> + label="Review DB" + clickable> <h3 class="is-size-3 block"> Review DB </h3> - <p class="block">TODO: review DB here</p> + + <p class="block"> + At this point your new table should be present in the DB. + Test below. + </p> + + <div class="card block"> + <header class="card-header"> + <p class="card-header-title"> + Table Status + </p> + </header> + <div class="card-content"> + <div class="content"> + <div class="level"> + <div class="level-left"> + + <div class="level-item"> + <span v-if="!tableCheckAttempted"> + check not yet attempted + </span> + <span v-if="tableCheckAttempted && !tableCheckProblem" + class="has-text-success has-text-weight-bold"> + table exists! + </span> + <span v-if="tableCheckProblem" + class="has-text-danger"> + {{ tableCheckProblem }} + </span> + </div> + </div> + <div class="level-right"> + <div class="level-item"> + <b-field horizontal label="Table Name"> + <b-input v-model="tableName"></b-input> + </b-field> + </div> + <div class="level-item"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="redo" + @click="tableCheck()"> + Test for Table + </b-button> + </div> + </div> + </div> + </div> + </div> + </div> + <div class="buttons"> <b-button icon-pack="fas" icon-left="arrow-left" @@ -495,7 +593,8 @@ <b-button type="is-primary" icon-pack="fas" icon-left="check" - @click="activeStep = 'commit-code'"> + @click="activeStep = 'commit-code'" + :disabled="!tableCheckAttempted || tableCheckProblem"> DB looks good! </b-button> </div> @@ -507,19 +606,26 @@ <h3 class="is-size-3 block"> Commit Code </h3> - <p class="block">TODO: commit changes here</p> + + <p class="block"> + Hope you're having a great day. + </p> + + <p class="block"> + Don't forget to commit code changes to your source repo. + </p> + <div class="buttons"> <b-button icon-pack="fas" icon-left="arrow-left" @click="activeStep = 'review-db'"> Back </b-button> - <b-button type="is-primary" - icon-pack="fas" - icon-left="check" - @click="alert('TODO: redirect to table view')"> - Code changes are committed! - </b-button> + <once-button type="is-primary" + tag="a" :href="tableURL" + icon-left="arrow-right" + :text="`Show me my new table: ${'$'}{tableName}`"> + </once-button> </div> </b-step-item> </b-steps> @@ -530,9 +636,9 @@ <script type="text/javascript"> ThisPageData.activeStep = null - ThisPageData.branchOptions = ${json.dumps(branch_name_options)|n} + ThisPageData.alembicBranchOptions = ${json.dumps(branch_name_options)|n} - ThisPageData.tableBranch = ${json.dumps(branch_name)|n} + ThisPageData.alembicBranch = ${json.dumps(branch_name)|n} ThisPageData.tableName = '${rattail_app.get_table_prefix()}_widget' ThisPageData.tableModelName = '${rattail_app.get_class_prefix()}Widget' ThisPageData.tableModelTitle = 'Widget' @@ -620,7 +726,7 @@ let url = '${url('{}.write_model_file'.format(route_prefix))}' let params = { - branch_name: this.tableBranch, + branch_name: this.alembicBranch, table_name: this.tableName, model_name: this.tableModelName, model_title: this.tableModelTitle, @@ -662,10 +768,65 @@ } else { this.modelImportProblem = false this.modelImported = true + this.revisionMessage = `add table for ${'$'}{this.tableModelTitlePlural}` } }) } + ThisPageData.writingRevisionScript = false + ThisPageData.revisionMessage = null + ThisPageData.revisionScript = null + + ThisPage.methods.writeRevisionScript = function() { + this.writingRevisionScript = true + + let url = '${url('{}.write_revision_script'.format(route_prefix))}' + let params = { + branch: this.alembicBranch, + message: this.revisionMessage, + } + this.submitForm(url, params, response => { + this.writingRevisionScript = false + this.revisionScript = response.data.script + this.activeStep = 'review-revision' + }, response => { + this.writingRevisionScript = false + }) + } + + ThisPageData.upgradingDB = false + + ThisPage.methods.upgradeDB = function() { + this.upgradingDB = true + + let url = '${url('{}.upgrade_db'.format(route_prefix))}' + let params = {} + this.submitForm(url, params, response => { + this.upgradingDB = false + this.activeStep = 'review-db' + }, response => { + this.upgradingDB = false + }) + } + + ThisPageData.tableCheckAttempted = false + ThisPageData.tableCheckProblem = null + + ThisPageData.tableURL = null + + ThisPage.methods.tableCheck = function() { + let url = '${url('{}.check_table'.format(route_prefix))}' + let params = {table_name: this.tableName} + this.submitForm(url, params, response => { + if (response.data.problem) { + this.tableCheckProblem = response.data.problem + } else { + this.tableURL = response.data.url + } + this.tableCheckAttempted = true + }) + } + </script> </%def> diff --git a/tailbone/views/tables.py b/tailbone/views/tables.py index d398733c..b9213d9d 100644 --- a/tailbone/views/tables.py +++ b/tailbone/views/tables.py @@ -32,6 +32,8 @@ import warnings import six +from rattail.util import simple_error + import colander from deform import widget as dfwidget from webhelpers2.html import HTML @@ -111,6 +113,13 @@ class TableView(MasterView): # row_count g.sorters['row_count'] = g.make_simple_sorter('row_count') + def configure_form(self, f): + super(TableView, self).configure_form(f) + + # TODO: should render this instead, by inspecting table + if not self.creating: + f.remove('versioned') + def get_instance(self): from sqlalchemy_utils import get_mapper @@ -205,6 +214,37 @@ class TableView(MasterView): return {'ok': True} + def write_revision_script(self): + data = self.request.json_body + script = self.db_handler.generate_revision_script(data['branch'], + message=data['message']) + return {'ok': True, + 'script': script.path} + + def upgrade_db(self): + self.db_handler.upgrade_db() + return {'ok': True} + + def check_table(self): + model = self.model + data = self.request.json_body + table_name = data['table_name'] + + table = model.Base.metadata.tables.get(table_name) + if table is None: + return {'ok': True, + 'problem': "Table does not exist in model metadata!"} + + try: + count = self.Session.query(table).count() + except Exception as error: + return {'ok': True, + 'problem': simple_error(error)} + + url = self.request.route_url('{}.view'.format(self.get_route_prefix()), + table_name=table_name) + return {'ok': True, 'url': url} + def get_row_data(self, table): data = [] for i, column in enumerate(table['table'].columns, 1): @@ -237,6 +277,7 @@ class TableView(MasterView): g.sorters['nullable'] = g.make_simple_sorter('nullable') g.set_renderer('description', self.render_column_description) + g.set_searchable('description') def render_column_description(self, column, field): text = column[field] @@ -287,6 +328,33 @@ class TableView(MasterView): renderer='json', permission='{}.create'.format(permission_prefix)) + # generate revision script + config.add_route('{}.write_revision_script'.format(route_prefix), + '{}/write-revision-script'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='write_revision_script', + route_name='{}.write_revision_script'.format(route_prefix), + renderer='json', + permission='{}.create'.format(permission_prefix)) + + # upgrade db + config.add_route('{}.upgrade_db'.format(route_prefix), + '{}/upgrade-db'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='upgrade_db', + route_name='{}.upgrade_db'.format(route_prefix), + renderer='json', + permission='{}.create'.format(permission_prefix)) + + # check table + config.add_route('{}.check_table'.format(route_prefix), + '{}/check-table'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='check_table', + route_name='{}.check_table'.format(route_prefix), + renderer='json', + permission='{}.create'.format(permission_prefix)) + class TablesView(TableView): From 00548a259b5a5a6be46f5c0c71f6f787c342b852 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 16 Jan 2023 13:50:27 -0600 Subject: [PATCH 0972/1681] Add basic "new model view" wizard --- tailbone/app.py | 22 +- tailbone/handler.py | 22 ++ tailbone/templates/appinfo/configure.mako | 22 ++ tailbone/templates/configure.mako | 9 + tailbone/templates/page.mako | 1 + tailbone/templates/views/model/create.mako | 339 +++++++++++++++++++++ tailbone/views/master.py | 25 +- tailbone/views/settings.py | 5 + tailbone/views/tables.py | 22 ++ tailbone/views/views.py | 219 +++++++++++++ 10 files changed, 681 insertions(+), 5 deletions(-) create mode 100644 tailbone/templates/views/model/create.mako create mode 100644 tailbone/views/views.py diff --git a/tailbone/app.py b/tailbone/app.py index 1cfae6b2..9e8348bc 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -177,6 +177,7 @@ def make_pyramid_config(settings, configure_csrf=True): # and some similar magic for certain master views 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_model_view', 'tailbone.app.add_model_view') config.add_directive('add_tailbone_view_supplement', 'tailbone.app.add_view_supplement') config.add_directive('add_tailbone_websocket', 'tailbone.app.add_websocket') @@ -240,6 +241,25 @@ def add_config_page(config, route_name, label, permission): config.action(None, action) +def add_model_view(config, model_name, label, route_prefix, permission_prefix): + """ + Register a model view for the app. + """ + def action(): + all_views = config.get_settings().get('tailbone_model_views', {}) + + model_views = all_views.setdefault(model_name, []) + model_views.append({ + 'label': label, + 'route_prefix': route_prefix, + 'permission_prefix': permission_prefix, + }) + + config.add_settings({'tailbone_model_views': all_views}) + + config.action(None, action) + + def add_view_supplement(config, route_prefix, cls): """ Register a master view supplement for the app. diff --git a/tailbone/handler.py b/tailbone/handler.py index cb78dc82..db95bc71 100644 --- a/tailbone/handler.py +++ b/tailbone/handler.py @@ -27,8 +27,10 @@ Tailbone Handler from __future__ import unicode_literals, absolute_import import six +from mako.lookup import TemplateLookup from rattail.app import GenericHandler +from rattail.files import resource_path from tailbone.providers import get_all_providers @@ -38,6 +40,13 @@ class TailboneHandler(GenericHandler): Base class and default implementation for Tailbone handler. """ + def __init__(self, *args, **kwargs): + super(TailboneHandler, self).__init__(*args, **kwargs) + + # TODO: make templates dir configurable? + templates = [resource_path('rattail:templates/web')] + self.templates = TemplateLookup(directories=templates) + def get_menu_handler(self, **kwargs): """ Get the configured "menu" handler. @@ -54,5 +63,18 @@ class TailboneHandler(GenericHandler): return self.menu_handler def iter_providers(self): + """ + Returns an iterator over all registered Tailbone providers. + """ providers = get_all_providers(self.config) return six.itervalues(providers) + + def write_model_view(self, data, path, **kwargs): + """ + Write code for a new model view, based on the given data dict, + to the given path. + """ + template = self.templates.get_template('/new-model-view.mako') + content = template.render(**data) + with open(path, 'wt') as f: + f.write(content) diff --git a/tailbone/templates/appinfo/configure.mako b/tailbone/templates/appinfo/configure.mako index 821f937f..bb932148 100644 --- a/tailbone/templates/appinfo/configure.mako +++ b/tailbone/templates/appinfo/configure.mako @@ -41,6 +41,28 @@ </b-checkbox> </b-field> + <div class="level-left"> + <div class="level-item"> + <b-field> + <b-checkbox name="rattail.running_from_source" + v-model="simpleSettings['rattail.running_from_source']" + native-value="true" + @input="settingsNeedSaved = true"> + Running from Source + </b-checkbox> + </b-field> + </div> + <div class="level-item"> + <b-field label="Top-Level Package" horizontal + v-if="simpleSettings['rattail.running_from_source']"> + <b-input name="rattail.running_from_source.rootpkg" + v-model="simpleSettings['rattail.running_from_source.rootpkg']" + @input="settingsNeedSaved = true"> + </b-input> + </b-field> + </div> + </div> + </div> <h3 class="block is-size-3">Display</h3> diff --git a/tailbone/templates/configure.mako b/tailbone/templates/configure.mako index 2fe8ee72..3aa60f31 100644 --- a/tailbone/templates/configure.mako +++ b/tailbone/templates/configure.mako @@ -3,6 +3,15 @@ <%def name="title()">Configure ${config_title}</%def> +<%def name="extra_styles()"> + ${parent.extra_styles()} + <style type="text/css"> + .label { + white-space: nowrap; + } + </style> +</%def> + <%def name="save_undo_buttons()"> <div class="buttons" v-if="settingsNeedSaved"> diff --git a/tailbone/templates/page.mako b/tailbone/templates/page.mako index 9f497268..c1e07db3 100644 --- a/tailbone/templates/page.mako +++ b/tailbone/templates/page.mako @@ -37,6 +37,7 @@ configureFieldsHelp: Boolean, }, computed: {}, + watch: {}, methods: {}, } diff --git a/tailbone/templates/views/model/create.mako b/tailbone/templates/views/model/create.mako new file mode 100644 index 00000000..6a542c52 --- /dev/null +++ b/tailbone/templates/views/model/create.mako @@ -0,0 +1,339 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/create.mako" /> + +<%def name="extra_styles()"> + ${parent.extra_styles()} + <style type="text/css"> + .label { + white-space: nowrap; + } + </style> +</%def> + +<%def name="render_this_page()"> + <b-steps v-model="activeStep" + :animated="false" + rounded + :has-navigation="false" + vertical + icon-pack="fas"> + + <b-step-item step="1" + value="enter-details" + label="Enter Details" + clickable> + <h3 class="is-size-3 block"> + Enter Details + </h3> + + <b-field grouped> + + <b-field label="Model Name"> + <b-select v-model="modelName"> + <option v-for="name in modelNames" + :key="name" + :value="name"> + {{ name }} + </option> + </b-select> + </b-field> + + <b-field label="View Class Name"> + <b-input v-model="viewClassName"> + </b-input> + </b-field> + + <b-field label="View Route Prefix"> + <b-input v-model="viewRoutePrefix"> + </b-input> + </b-field> + + </b-field> + + <br /> + + <div class="buttons"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="check" + @click="activeStep = 'write-view'"> + Details are complete + </b-button> + </div> + + </b-step-item> + + <b-step-item step="2" + value="write-view" + label="Write View"> + <h3 class="is-size-3 block"> + Write View + </h3> + + <b-field label="Model Name" horizontal> + {{ modelName }} + </b-field> + + <b-field label="View Class" horizontal> + {{ viewClassName }} + </b-field> + + <b-field horizontal label="File"> + <b-input v-model="viewFile"></b-input> + </b-field> + + <b-field horizontal> + <b-checkbox v-model="viewFileOverwrite"> + Overwrite file if it exists + </b-checkbox> + </b-field> + + <div class="form"> + <div class="buttons"> + <b-button icon-pack="fas" + icon-left="arrow-left" + @click="activeStep = 'enter-details'"> + Back + </b-button> + <b-button type="is-primary" + icon-pack="fas" + icon-left="save" + @click="writeViewFile()" + :disabled="writingViewFile"> + {{ writingViewFile ? "Working, please wait..." : "Write view class to file" }} + </b-button> + <b-button icon-pack="fas" + icon-left="arrow-right" + @click="activeStep = 'review-view'"> + Skip + </b-button> + </div> + </div> + </b-step-item> + + <b-step-item step="3" + value="review-view" + label="Review View" + ## clickable + > + <h3 class="is-size-3 block"> + Review View + </h3> + + <p class="block"> + View code was generated to file: + </p> + + <p class="block is-family-code" style="padding-left: 3rem;"> + {{ viewFile }} + </p> + + <p class="block"> + First, review that code and adjust to your liking. + </p> + + <p class="block"> + Next be sure to include the new view in your config. + Typically this is done by editing the file... + </p> + + <p class="block is-family-code" style="padding-left: 3rem;"> + ${view_dir}__init__.py + </p> + + <p class="block"> + ...and adding a line to the includeme() block such as: + </p> + + <pre class="block"> +def includeme(config): + + # ...existing config includes here... + + ## TODO: stop hard-coding widgets + config.include('${pkgroot}.web.views.widgets') + </pre> + + <p class="block"> + Once you've done all that, the web app must be restarted. + This may happen automatically depending on your setup. + Test the view status below. + </p> + + <div class="card block"> + <header class="card-header"> + <p class="card-header-title"> + View Status + </p> + </header> + <div class="card-content"> + <div class="content"> + <div class="level"> + <div class="level-left"> + + <div class="level-item"> + <span v-if="!viewImportAttempted"> + check not yet attempted + </span> + <span v-if="viewImported" + class="has-text-success has-text-weight-bold"> + route found! + </span> + <span v-if="viewImportAttempted && viewImportProblem" + class="has-text-danger"> + {{ viewImportProblem }} + </span> + </div> + </div> + <div class="level-right"> + <div class="level-item"> + <b-field horizontal label="Route Prefix"> + <b-input v-model="viewRoutePrefix"></b-input> + </b-field> + </div> + <div class="level-item"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="redo" + @click="testView()"> + Test View + </b-button> + </div> + </div> + </div> + </div> + </div> + </div> + + <div class="buttons"> + <b-button icon-pack="fas" + icon-left="arrow-left" + @click="activeStep = 'write-view'"> + Back + </b-button> + <b-button type="is-primary" + icon-pack="fas" + icon-left="check" + @click="activeStep = 'commit-code'" + :disabled="!viewImported"> + View class looks good! + </b-button> + <b-button icon-pack="fas" + icon-left="arrow-right" + @click="activeStep = 'commit-code'"> + Skip + </b-button> + </div> + </b-step-item> + + <b-step-item step="4" + value="commit-code" + label="Commit Code"> + <h3 class="is-size-3 block"> + Commit Code + </h3> + + <p class="block"> + Hope you're having a great day. + </p> + + <p class="block"> + Don't forget to commit code changes to your source repo. + </p> + + <div class="buttons"> + <b-button icon-pack="fas" + icon-left="arrow-left" + @click="activeStep = 'review-view'"> + Back + </b-button> + <once-button type="is-primary" + tag="a" :href="viewURL" + icon-left="arrow-right" + :disabled="!viewURL" + :text="`Show me my new view: ${'$'}{viewClassName}`"> + </once-button> + </div> + </b-step-item> + + </b-steps> +</%def> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + ThisPageData.activeStep = null + + ThisPageData.modelNames = ${json.dumps(model_names)|n} + ThisPageData.modelName = null + ThisPageData.viewClassName = null + ThisPageData.viewRoutePrefix = null + + ThisPage.watch.modelName = function(newName, oldName) { + this.viewClassName = `${'$'}{newName}View` + this.viewRoutePrefix = newName.toLowerCase() + } + + ThisPage.mounted = function() { + let params = new URLSearchParams(location.search) + if (params.has('model_name')) { + this.modelName = params.get('model_name') + } + } + + ThisPageData.viewFile = '${view_dir}widgets.py' + ThisPageData.viewFileOverwrite = false + ThisPageData.writingViewFile = false + + ThisPage.methods.writeViewFile = function() { + this.writingViewFile = true + + let url = '${url('{}.write_view_file'.format(route_prefix))}' + let params = { + view_file: this.viewFile, + overwrite: this.viewFileOverwrite, + view_class_name: this.viewClassName, + model_name: this.modelName, + route_prefix: this.viewRoutePrefix, + } + this.submitForm(url, params, response => { + this.writingViewFile = false + this.activeStep = 'review-view' + }, response => { + this.writingViewFile = false + }) + } + + ThisPageData.viewImported = false + ThisPageData.viewImportAttempted = false + ThisPageData.viewImportProblem = null + + ThisPage.methods.testView = function() { + + this.viewImported = false + this.viewImportProblem = null + + let url = '${url('{}.check_view'.format(route_prefix))}' + + let params = { + route_prefix: this.viewRoutePrefix, + } + this.submitForm(url, params, response => { + this.viewImportAttempted = true + if (response.data.problem) { + this.viewImportProblem = response.data.problem + } else { + this.viewImported = true + this.viewURL = response.data.url + } + }) + } + + ThisPageData.viewURL = null + + </script> +</%def> + + +${parent.body()} diff --git a/tailbone/views/master.py b/tailbone/views/master.py index c53dac60..1afbc639 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2841,9 +2841,12 @@ class MasterView(View): def make_grid_action_view(self): use_buefy = self.get_use_buefy() - url = self.get_view_index_url if self.use_index_links else None icon = 'eye' if use_buefy else 'zoomin' - return self.make_action('view', icon=icon, url=url) + return self.make_action('view', icon=icon, url=self.default_view_url()) + + def default_view_url(self): + if self.use_index_links: + return self.get_view_index_url def get_view_index_url(self, row, i): route = '{}.view_index'.format(self.get_route_prefix()) @@ -4978,6 +4981,22 @@ class MasterView(View): # list/search if cls.listable: + + # master views which represent a typical model class, and + # allow for an index view, are registered specially so the + # admin may browse the full list of such views + modclass = cls.get_model_class(error=False) + if modclass: + config.add_tailbone_model_view(modclass.__name__, + model_title_plural, + route_prefix, + permission_prefix) + + # but regardless we register the index view, for similar reasons + config.add_tailbone_index_page(route_prefix, model_title_plural, + '{}.list'.format(permission_prefix)) + + # index view config.add_tailbone_permission(permission_prefix, '{}.list'.format(permission_prefix), "List / search {}".format(model_title_plural)) config.add_route(route_prefix, '{}/'.format(url_prefix)) @@ -4985,8 +5004,6 @@ class MasterView(View): config.add_view(cls, attr='index', route_name=route_prefix, permission='{}.list'.format(permission_prefix), **kwargs) - config.add_tailbone_index_page(route_prefix, model_title_plural, - '{}.list'.format(permission_prefix)) # download results # this is the "new" more flexible approach, but we only want to diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index f4a213c0..72ee704e 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -140,6 +140,11 @@ class AppInfoView(MasterView): {'section': 'rattail', 'option': 'production', 'type': bool}, + {'section': 'rattail', + 'option': 'running_from_source', + 'type': bool}, + {'section': 'rattail', + 'option': 'running_from_source.rootpkg'}, # display {'section': 'tailbone', diff --git a/tailbone/views/tables.py b/tailbone/views/tables.py index b9213d9d..6f717a58 100644 --- a/tailbone/views/tables.py +++ b/tailbone/views/tables.py @@ -67,6 +67,7 @@ class TableView(MasterView): ] has_rows = True + rows_title = "Columns" rows_pageable = False rows_filterable = False rows_viewable = False @@ -170,6 +171,27 @@ class TableView(MasterView): def make_form_schema(self): return TableSchema() + def get_xref_buttons(self, table): + buttons = super(TableView, self).get_xref_buttons(table) + + if table.get('model_name'): + all_views = self.request.registry.settings['tailbone_model_views'] + model_views = all_views.get(table['model_name'], []) + for view in model_views: + url = self.request.route_url(view['route_prefix']) + buttons.append(self.make_xref_button(url=url, text=view['label'], + internal=True)) + + if self.request.has_perm('model_views.create'): + url = self.request.route_url('model_views.create', + _query={'model_name': table['model_name']}) + buttons.append(self.make_buefy_button("New View", + is_primary=True, + url=url, + icon_left='plus')) + + return buttons + def template_kwargs_create(self, **kwargs): kwargs = super(TableView, self).template_kwargs_create(**kwargs) app = self.get_rattail_app() diff --git a/tailbone/views/views.py b/tailbone/views/views.py new file mode 100644 index 00000000..64f94112 --- /dev/null +++ b/tailbone/views/views.py @@ -0,0 +1,219 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2023 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Views for views +""" + +from __future__ import unicode_literals, absolute_import + +import os +import sys + +from rattail.db.util import get_fieldnames +from rattail.util import simple_error + +import colander +from deform import widget as dfwidget + +from tailbone.views import MasterView + + +class ModelViewView(MasterView): + """ + Master view for views + """ + normalized_model_name = 'model_view' + model_key = 'route_prefix' + model_title = "Model View" + url_prefix = '/views/model' + viewable = True + creatable = True + editable = False + deletable = False + filterable = False + pageable = False + + grid_columns = [ + 'label', + 'model_name', + 'route_prefix', + 'permission_prefix', + ] + + def get_data(self, **kwargs): + """ + Fetch existing model views from app registry + """ + data = [] + + all_views = self.request.registry.settings['tailbone_model_views'] + for model_name in sorted(all_views): + model_views = all_views[model_name] + for view in model_views: + data.append({ + 'model_name': model_name, + 'label': view['label'], + 'route_prefix': view['route_prefix'], + 'permission_prefix': view['permission_prefix'], + }) + + return data + + def configure_grid(self, g): + super(ModelViewView, self).configure_grid(g) + + # label + g.sorters['label'] = g.make_simple_sorter('label') + g.set_sort_defaults('label') + g.set_link('label') + + # model_name + g.sorters['model_name'] = g.make_simple_sorter('model_name', foldcase=True) + g.set_searchable('model_name') + + # route + g.sorters['route'] = g.make_simple_sorter('route') + + # permission + g.sorters['permission'] = g.make_simple_sorter('permission') + + def default_view_url(self, view, i=None): + return self.request.route_url(view['route_prefix']) + + def make_form_schema(self): + return ModelViewSchema() + + def template_kwargs_create(self, **kwargs): + kwargs = super(ModelViewView, self).template_kwargs_create(**kwargs) + app = self.get_rattail_app() + db_handler = app.get_db_handler() + + model_classes = db_handler.get_model_classes() + kwargs['model_names'] = [cls.__name__ for cls in model_classes] + + pkg = self.rattail_config.get('rattail', 'running_from_source.rootpkg') + if pkg: + kwargs['pkgroot'] = pkg + pkg = sys.modules[pkg] + pkgdir = os.path.dirname(pkg.__file__) + kwargs['view_dir'] = os.path.join(pkgdir, 'web', 'views') + os.sep + else: + kwargs['pkgroot'] = 'poser' + kwargs['view_dir'] = '??' + os.sep + + return kwargs + + def write_view_file(self): + data = self.request.json_body + path = data['view_file'] + + if os.path.exists(path): + if data['overwrite']: + os.remove(path) + else: + return {'error': "File already exists"} + + app = self.get_rattail_app() + tb = app.get_tailbone_handler() + model_class = getattr(self.model, data['model_name']) + + data['model_module_name'] = self.model.__name__ + data['model_title_plural'] = getattr(model_class, + 'model_title_plural', + # TODO + model_class.__name__) + + data['model_versioned'] = hasattr(model_class, '__versioned__') + + fieldnames = get_fieldnames(self.rattail_config, + model_class) + fieldnames.remove('uuid') + data['model_fieldnames'] = fieldnames + + tb.write_model_view(data, path) + + return {'ok': True} + + def check_view(self): + data = self.request.json_body + + try: + url = self.request.route_url(data['route_prefix']) + except Exception as error: + return {'ok': True, + 'problem': simple_error(error)} + + return {'ok': True, 'url': url} + + @classmethod + def defaults(cls, config): + rattail_config = config.registry.settings.get('rattail_config') + + # allow creating views only if *not* production + if not rattail_config.production(): + cls.creatable = True + + cls._model_view_defaults(config) + cls._defaults(config) + + @classmethod + def _model_view_defaults(cls, config): + route_prefix = cls.get_route_prefix() + url_prefix = cls.get_url_prefix() + permission_prefix = cls.get_permission_prefix() + + if cls.creatable: + + # write view class to file + config.add_route('{}.write_view_file'.format(route_prefix), + '{}/write-view-file'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='write_view_file', + route_name='{}.write_view_file'.format(route_prefix), + renderer='json', + permission='{}.create'.format(permission_prefix)) + + # check view + config.add_route('{}.check_view'.format(route_prefix), + '{}/check-view'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='check_view', + route_name='{}.check_view'.format(route_prefix), + renderer='json', + permission='{}.create'.format(permission_prefix)) + + +class ModelViewSchema(colander.Schema): + + model_name = colander.SchemaNode(colander.String()) + + +def defaults(config, **kwargs): + base = globals() + + ModelViewView = kwargs.get('ModelViewView', base['ModelViewView']) + ModelViewView.defaults(config) + + +def includeme(config): + defaults(config) From 9b21d52206de478d5466d768984aeacaf59db731 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 16 Jan 2023 18:44:54 -0600 Subject: [PATCH 0973/1681] 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 18d9fef1..179b9a33 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,24 @@ CHANGELOG ========= +0.8.284 (2023-01-15) +-------------------- + +* Let the API "rawbytes" response be just that, w/ no file. + +* Fix bug when adding new profile via datasync configure. + +* Add default logic to get merge data for object. + +* Add new handlers, TailboneHandler and MenuHandler. + +* Add full set of default menus. + +* Wrap up steps for new table wizard. + +* Add basic "new model view" wizard. + + 0.8.283 (2023-01-14) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index de749230..b1eb871d 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.283' +__version__ = '0.8.284' From 98fa6eea05a95fe690cc09659af9837042b2e08f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 16 Jan 2023 21:55:52 -0600 Subject: [PATCH 0974/1681] Misc. tweaks for App Details / Configure Menus --- tailbone/menus.py | 12 +--------- tailbone/templates/appinfo/index.mako | 25 +++++++++++++++++++++ tailbone/templates/configure-menus.mako | 24 ++++++++++++++++++++ tailbone/templates/themes/falafel/base.mako | 2 +- tailbone/views/menus.py | 10 ++++----- tailbone/views/settings.py | 10 +++++++-- tailbone/views/views.py | 7 ++++-- 7 files changed, 68 insertions(+), 22 deletions(-) diff --git a/tailbone/menus.py b/tailbone/menus.py index a9de79dc..e956685d 100644 --- a/tailbone/menus.py +++ b/tailbone/menus.py @@ -501,20 +501,10 @@ class MenuHandler(GenericHandler): 'perm': 'luigi.list', }, { - 'title': "Tables", - 'route': 'tables', - 'perm': 'tables.list', - }, - { - 'title': "App Info", + 'title': "App Details", 'route': 'appinfo', 'perm': 'appinfo.list', }, - { - 'title': "Configure App", - 'route': 'appinfo.configure', - 'perm': 'appinfo.configure', - }, { 'title': "Upgrades", 'route': 'upgrades', diff --git a/tailbone/templates/appinfo/index.mako b/tailbone/templates/appinfo/index.mako index 4bf70354..9b50b8a9 100644 --- a/tailbone/templates/appinfo/index.mako +++ b/tailbone/templates/appinfo/index.mako @@ -3,6 +3,31 @@ <%def name="render_grid_component()"> + <div class="buttons"> + + <once-button type="is-primary" + tag="a" href="${url('tables')}" + icon-pack="fas" + icon-left="eye" + text="Tables"> + </once-button> + + <once-button type="is-primary" + tag="a" href="${url('model_views')}" + icon-pack="fas" + icon-left="eye" + text="Model Views"> + </once-button> + + <once-button type="is-primary" + tag="a" href="${url('configure_menus')}" + icon-pack="fas" + icon-left="cog" + text="Configure Menus"> + </once-button> + + </div> + <b-collapse class="panel" open> <template #trigger="props"> diff --git a/tailbone/templates/configure-menus.mako b/tailbone/templates/configure-menus.mako index 495b5c65..c0200912 100644 --- a/tailbone/templates/configure-menus.mako +++ b/tailbone/templates/configure-menus.mako @@ -14,6 +14,12 @@ </%def> <%def name="form_content()"> + + ## nb. must be root to configure menus! otherwise some of the + ## currently-defined menus may not appear on the page, so saving + ## would inadvertently remove them! + % if request.is_root: + ${h.hidden('menus', **{':value': 'JSON.stringify(allMenuData)'})} <h3 class="is-size-3">Top-Level Menus</h3> @@ -182,6 +188,24 @@ </div> + % else: + ## not root! + + <b-notification type="is-warning"> + You must become root to configure menus! + </b-notification> + + % endif + +</%def> + +## TODO: should probably make some global "editable" flag that the +## base configure template has knowledge of, and just set that to +## false for this view +<%def name="purge_button()"> + % if request.is_root: + ${parent.purge_button()} + % endif </%def> <%def name="modify_this_page_vars()"> diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index adbcd893..2db0547f 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -383,7 +383,7 @@ tag="a" href="${url('{}.configure'.format(route_prefix))}" icon-left="cog" - text="Configure"> + text="${(configure_button_title or "Configure") if configure_button_title is not Undefined else "Configure"}"> </once-button> </div> % endif diff --git a/tailbone/views/menus.py b/tailbone/views/menus.py index 37c2536c..a25b1543 100644 --- a/tailbone/views/menus.py +++ b/tailbone/views/menus.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -63,8 +63,8 @@ class MenuConfigView(View): context = { 'config_title': "Menus", 'use_buefy': True, - 'index_title': "App Settings", - 'index_url': self.request.route_url('appsettings'), + 'index_title': "App Details", + 'index_url': self.request.route_url('appinfo'), } possible_index_options = sorted( @@ -173,9 +173,7 @@ class MenuConfigView(View): '/configure-menus') config.add_view(cls, attr='configure', route_name='configure_menus', - # nb. must be root to configure menus! b/c - # otherwise some route options may be hidden - permission='admin', + permission='appinfo.configure', renderer='/configure-menus.mako') config.add_tailbone_config_page('configure_menus', "Menus", 'admin') diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index 72ee704e..190b8b78 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -54,7 +54,7 @@ class AppInfoView(MasterView): route_prefix = 'appinfo' model_key = 'UNUSED' model_title = "UNUSED" - model_title_plural = "App Info" + model_title_plural = "App Details" creatable = False viewable = False editable = False @@ -70,7 +70,8 @@ class AppInfoView(MasterView): ] def get_index_title(self): - return "App Info for {}".format(self.rattail_config.app_title()) + return "{} for {}".format(self.get_model_title_plural(), + self.rattail_config.app_title()) def get_data(self, session=None): pip = os.path.join(sys.prefix, 'bin', 'pip') @@ -95,6 +96,11 @@ class AppInfoView(MasterView): 'editable_project_location', foldcase=True) g.set_searchable('editable_project_location') + def template_kwargs_index(self, **kwargs): + kwargs = super(AppInfoView, self).template_kwargs_index(**kwargs) + kwargs['configure_button_title'] = "Configure App" + return kwargs + def configure_get_context(self, **kwargs): context = super(AppInfoView, self).configure_get_context(**kwargs) diff --git a/tailbone/views/views.py b/tailbone/views/views.py index 64f94112..25828cde 100644 --- a/tailbone/views/views.py +++ b/tailbone/views/views.py @@ -86,6 +86,7 @@ class ModelViewView(MasterView): g.sorters['label'] = g.make_simple_sorter('label') g.set_sort_defaults('label') g.set_link('label') + g.set_searchable('label') # model_name g.sorters['model_name'] = g.make_simple_sorter('model_name', foldcase=True) @@ -93,12 +94,14 @@ class ModelViewView(MasterView): # route g.sorters['route'] = g.make_simple_sorter('route') + g.set_searchable('route') # permission g.sorters['permission'] = g.make_simple_sorter('permission') + g.set_searchable('permission') - def default_view_url(self, view, i=None): - return self.request.route_url(view['route_prefix']) + def default_view_url(self): + return lambda view, i: self.request.route_url(view['route_prefix']) def make_form_schema(self): return ModelViewSchema() From e4c23366599f979116dfaef843192e8a74d36cec Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 16 Jan 2023 22:50:51 -0600 Subject: [PATCH 0975/1681] Add specific data type options for new table entry form including basic FK / relationship support --- tailbone/templates/tables/create.mako | 163 +++++++++++++++++++++++--- tailbone/views/tables.py | 24 +++- 2 files changed, 166 insertions(+), 21 deletions(-) diff --git a/tailbone/templates/tables/create.mako b/tailbone/templates/tables/create.mako index 6c88bb7a..58bcba18 100644 --- a/tailbone/templates/tables/create.mako +++ b/tailbone/templates/tables/create.mako @@ -118,7 +118,7 @@ v-slot="props" % endif > - {{ props.row.data_type }} + {{ formatDataType(props.row.data_type) }} </b-table-column> <b-table-column field="nullable" @@ -196,26 +196,89 @@ </b-input> </b-field> - <b-field label="Data Type"> - <b-input v-model="editingColumnDataType"></b-input> + <b-field grouped> + + <b-field label="Data Type"> + <b-select v-model="editingColumnDataType"> + <option value="String">String</option> + <option value="Boolean">Boolean</option> + <option value="Integer">Integer</option> + <option value="Numeric">Numeric</option> + <option value="Date">Date</option> + <option value="DateTime">DateTime</option> + <option value="Text">Text</option> + <option value="_fk_uuid_">FK/UUID</option> + <option value="_other_">Other</option> + </b-select> + </b-field> + + <b-field v-if="editingColumnDataType == 'String'" + label="Length" + :type="{'is-danger': !editingColumnDataTypeLength}" + style="max-width: 6rem;"> + <b-input v-model="editingColumnDataTypeLength"> + </b-input> + </b-field> + + <b-field v-if="editingColumnDataType == 'Numeric'" + label="Precision" + :type="{'is-danger': !editingColumnDataTypePrecision}" + style="max-width: 6rem;"> + <b-input v-model="editingColumnDataTypePrecision"> + </b-input> + </b-field> + + <b-field v-if="editingColumnDataType == 'Numeric'" + label="Scale" + :type="{'is-danger': !editingColumnDataTypeScale}" + style="max-width: 6rem;"> + <b-input v-model="editingColumnDataTypeScale"> + </b-input> + </b-field> + + <b-field v-if="editingColumnDataType == '_fk_uuid_'" + label="Reference Table" + :type="{'is-danger': !editingColumnDataTypeReference}"> + <b-select v-model="editingColumnDataTypeReference"> + <option v-for="table in existingTables" + :key="table.name" + :value="table.name"> + {{ table.name }} + </option> + </b-select> + </b-field> + + <b-field v-if="editingColumnDataType == '_other_'" + label="Literal (include parens!)" + :type="{'is-danger': !editingColumnDataTypeLiteral}" + expanded> + <b-input v-model="editingColumnDataTypeLiteral"> + </b-input> + </b-field> + </b-field> <b-field grouped> - <b-field label="Nullable"> - <b-checkbox v-model="editingColumnNullable" - native-value="true"> - {{ editingColumnNullable }} - </b-checkbox> - </b-field> + <b-field label="Nullable"> + <b-checkbox v-model="editingColumnNullable" + native-value="true"> + {{ editingColumnNullable }} + </b-checkbox> + </b-field> - <b-field label="Versioned" - v-if="tableVersioned"> - <b-checkbox v-model="editingColumnVersioned" - native-value="true"> - {{ editingColumnVersioned }} - </b-checkbox> - </b-field> + <b-field label="Versioned" + v-if="tableVersioned"> + <b-checkbox v-model="editingColumnVersioned" + native-value="true"> + {{ editingColumnVersioned }} + </b-checkbox> + </b-field> + + <b-field v-if="editingColumnDataType == '_fk_uuid_'" + label="Relationship"> + <b-input v-model="editingColumnRelationship"></b-input> + </b-field> </b-field> @@ -638,6 +701,8 @@ ThisPageData.activeStep = null ThisPageData.alembicBranchOptions = ${json.dumps(branch_name_options)|n} + ThisPageData.existingTables = ${json.dumps(existing_tables)|n} + ThisPageData.alembicBranch = ${json.dumps(branch_name)|n} ThisPageData.tableName = '${rattail_app.get_table_prefix()}_widget' ThisPageData.tableModelName = '${rattail_app.get_class_prefix()}Widget' @@ -648,7 +713,10 @@ ThisPageData.tableColumns = [{ name: 'uuid', - data_type: 'String(length=32)', + data_type: { + type: 'String', + length: 32, + }, nullable: false, description: "UUID primary key", versioned: true, @@ -658,17 +726,29 @@ ThisPageData.editingColumn = null ThisPageData.editingColumnName = null ThisPageData.editingColumnDataType = null + ThisPageData.editingColumnDataTypeLength = null + ThisPageData.editingColumnDataTypePrecision = null + ThisPageData.editingColumnDataTypeScale = null + ThisPageData.editingColumnDataTypeReference = null + ThisPageData.editingColumnDataTypeLiteral = null ThisPageData.editingColumnNullable = true ThisPageData.editingColumnDescription = null ThisPageData.editingColumnVersioned = true + ThisPageData.editingColumnRelationship = null ThisPage.methods.tableAddColumn = function() { this.editingColumn = null this.editingColumnName = null this.editingColumnDataType = null + this.editingColumnDataTypeLength = null + this.editingColumnDataTypePrecision = null + this.editingColumnDataTypeScale = null + this.editingColumnDataTypeReference = null + this.editingColumnDataTypeLiteral = null this.editingColumnNullable = true this.editingColumnDescription = null this.editingColumnVersioned = true + this.editingColumnRelationship = null this.editingColumnShowDialog = true this.$nextTick(() => { this.$refs.editingColumnName.focus() @@ -678,16 +758,43 @@ ThisPage.methods.tableEditColumn = function(column) { this.editingColumn = column this.editingColumnName = column.name - this.editingColumnDataType = column.data_type + this.editingColumnDataType = column.data_type.type + this.editingColumnDataTypeLength = column.data_type.length + this.editingColumnDataTypePrecision = column.data_type.precision + this.editingColumnDataTypeScale = column.data_type.scale + this.editingColumnDataTypeReference = column.data_type.reference + this.editingColumnDataTypeLiteral = column.data_type.literal this.editingColumnNullable = column.nullable this.editingColumnDescription = column.description this.editingColumnVersioned = column.versioned + this.editingColumnRelationship = column.relationship this.editingColumnShowDialog = true this.$nextTick(() => { this.$refs.editingColumnName.focus() }) } + ThisPage.methods.formatDataType = function(dataType) { + if (dataType.type == 'String') { + return `sa.String(length=${'$'}{dataType.length})` + } else if (dataType.type == 'Numeric') { + return `sa.Numeric(precision=${'$'}{dataType.precision}, scale=${'$'}{dataType.scale})` + } else if (dataType.type == '_fk_uuid_') { + return 'sa.String(length=32)' + } else if (dataType.type == '_other_') { + return dataType.literal + } else { + return `sa.${'$'}{dataType.type}()` + } + } + + ThisPage.watch.editingColumnDataTypeReference = function(newval, oldval) { + this.editingColumnRelationship = newval + if (newval && !this.editingColumnName) { + this.editingColumnName = `${'$'}{newval}_uuid` + } + } + ThisPage.methods.editingColumnSave = function() { let column if (this.editingColumn) { @@ -698,10 +805,24 @@ } column.name = this.editingColumnName - column.data_type = this.editingColumnDataType + + let dataType = {type: this.editingColumnDataType} + if (dataType.type == 'String') { + dataType.length = this.editingColumnDataTypeLength + } else if (dataType.type == 'Numeric') { + dataType.precision = this.editingColumnDataTypePrecision + dataType.scale = this.editingColumnDataTypeScale + } else if (dataType.type == '_fk_uuid_') { + dataType.reference = this.editingColumnDataTypeReference + } else if (dataType.type == '_other_') { + dataType.literal = this.editingColumnDataTypeLiteral + } + column.data_type = dataType + column.nullable = this.editingColumnNullable column.description = this.editingColumnDescription column.versioned = this.editingColumnVersioned + column.relationship = this.editingColumnRelationship this.editingColumnShowDialog = false } @@ -724,6 +845,10 @@ this.modelImportStatus = "import not yet attempted" this.modelImportProblem = false + for (let column of this.tableColumns) { + column.formatted_data_type = this.formatDataType(column.data_type) + } + let url = '${url('{}.write_model_file'.format(route_prefix))}' let params = { branch_name: this.alembicBranch, diff --git a/tailbone/views/tables.py b/tailbone/views/tables.py index 6f717a58..d11a2923 100644 --- a/tailbone/views/tables.py +++ b/tailbone/views/tables.py @@ -31,6 +31,7 @@ import sys import warnings import six +from sqlalchemy_utils import get_mapper from rattail.util import simple_error @@ -122,8 +123,6 @@ class TableView(MasterView): f.remove('versioned') def get_instance(self): - from sqlalchemy_utils import get_mapper - model = self.model table_name = self.request.matchdict['table_name'] @@ -204,6 +203,9 @@ class TableView(MasterView): branch_name = None kwargs['branch_name'] = branch_name + kwargs['existing_tables'] = [{'name': table.name} + for table in model.Base.metadata.sorted_tables] + kwargs['model_dir'] = (os.path.dirname(model.__file__) + os.sep) @@ -212,6 +214,7 @@ class TableView(MasterView): def write_model_file(self): data = self.request.json_body path = data['module_file'] + model = self.model if os.path.exists(path): if data['overwrite']: @@ -219,6 +222,23 @@ class TableView(MasterView): else: return {'error': "File already exists"} + for column in data['columns']: + if column['data_type']['type'] == '_fk_uuid_' and column['relationship']: + name = column['relationship'] + + table = model.Base.metadata.tables[column['data_type']['reference']] + try: + mapper = get_mapper(table) + except ValueError: + reference_model = table.name.capitalize() + else: + reference_model = mapper.class_.__name__ + + column['relationship'] = { + 'name': name, + 'reference_model': reference_model, + } + self.db_handler.write_table_model(data, path) return {'ok': True} From 23dea7bcedb3144263109067b6d45a2a0d26110d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 18 Jan 2023 16:55:30 -0600 Subject: [PATCH 0976/1681] Add more views, menus to default set --- tailbone/menus.py | 311 +++++++++++++++++++++++++---------- tailbone/views/essentials.py | 7 +- 2 files changed, 231 insertions(+), 87 deletions(-) diff --git a/tailbone/menus.py b/tailbone/menus.py index e956685d..c42f91ae 100644 --- a/tailbone/menus.py +++ b/tailbone/menus.py @@ -259,6 +259,7 @@ class MenuHandler(GenericHandler): is expected for most apps to override it. """ menus = [ + self.make_custorders_menu(request), self.make_people_menu(request), self.make_products_menu(request), self.make_vendors_menu(request), @@ -269,9 +270,9 @@ class MenuHandler(GenericHandler): menus.extend(integration_menus) menus.extend([ - # TODO: add reporting menu + self.make_reports_menu(request, include_trainwreck=True), self.make_batches_menu(request), - self.make_admin_menu(request), + self.make_admin_menu(request, include_stores=True), ]) return menus @@ -288,6 +289,38 @@ class MenuHandler(GenericHandler): menus.sort(key=lambda menu: menu['title'].lower()) return menus + def make_custorders_menu(self, request, **kwargs): + """ + Generate a typical Customer Orders menu + """ + return { + 'title': "Orders", + 'type': 'menu', + 'items': [ + { + 'title': "New Customer Order", + 'route': 'custorders.create', + 'perm': 'custorders.create', + }, + { + 'title': "All New Orders", + 'route': 'new_custorders', + 'perm': 'new_custorders.list', + }, + {'type': 'sep'}, + { + 'title': "All Customer Orders", + 'route': 'custorders', + 'perm': 'custorders.list', + }, + { + 'title': "All Order Items", + 'route': 'custorders.items', + 'perm': 'custorders.items.list', + }, + ], + } + def make_people_menu(self, request, **kwargs): """ Generate a typical People menu @@ -321,6 +354,12 @@ class MenuHandler(GenericHandler): 'route': 'people', 'perm': 'people.list', }, + {'type': 'sep'}, + { + 'title': "Pending Customers", + 'route': 'pending_customers', + 'perm': 'pending_customers.list', + }, ], } @@ -362,6 +401,17 @@ class MenuHandler(GenericHandler): 'route': 'reportcodes', 'perm': 'reportcodes.list', }, + { + 'title': "Units of Measure", + 'route': 'uoms', + 'perm': 'uoms.list', + }, + {'type': 'sep'}, + { + 'title': "Pending Products", + 'route': 'pending_products', + 'perm': 'pending_products.list', + }, ], } @@ -400,13 +450,13 @@ class MenuHandler(GenericHandler): 'route': 'purchases.credits', 'perm': 'purchases.credits.list', }, - # {'type': 'sep'}, - # { - # 'title': "Catalogs", - # 'route': 'vendorcatalogs', - # 'perm': 'vendorcatalogs.list', - # }, - ], + {'type': 'sep'}, + { + 'title': "Catalog Batches", + 'route': 'vendorcatalogs', + 'perm': 'vendorcatalogs.list', + }, + ], } def make_batches_menu(self, request, **kwargs): @@ -422,95 +472,184 @@ class MenuHandler(GenericHandler): 'route': 'batch.handheld', 'perm': 'batch.handheld.list', }, - # { - # 'title': "Inventory", - # 'route': 'batch.inventory', - # 'perm': 'batch.inventory.list', - # }, - ], + { + 'title': "Inventory", + 'route': 'batch.inventory', + 'perm': 'batch.inventory.list', + }, + { + 'title': "Import / Export", + 'route': 'batch.importer', + 'perm': 'batch.importer.list', + }, + ], + } + + def make_reports_menu(self, request, **kwargs): + """ + Generate a typical Reports menu + """ + items = [ + { + 'title': "New Report", + 'route': 'report_output.create', + 'perm': 'report_output.create', + }, + { + 'title': "Generated Reports", + 'route': 'report_output', + 'perm': 'report_output.list', + }, + { + 'title': "Problem Reports", + 'route': 'problem_reports', + 'perm': 'problem_reports.list', + }, + ] + + if kwargs.get('include_poser', False): + items.extend([ + {'type': 'sep'}, + { + 'title': "Poser Reports", + 'route': 'poser_reports', + 'perm': 'poser_reports.list', + }, + ]) + + if kwargs.get('include_trainwreck', False): + items.extend([ + {'type': 'sep'}, + { + 'title': "Trainwreck", + 'route': 'trainwreck.transactions', + 'perm': 'trainwreck.transactions.list', + }, + ]) + + return { + 'title': "Reports", + 'type': 'menu', + 'items': items, + } + + def make_tempmon_menu(self, request, **kwargs): + """ + Generate a typical TempMon menu + """ + return { + 'title': "TempMon", + 'type': 'menu', + 'items': [ + { + 'title': "Appliances", + 'route': 'tempmon.appliances', + 'perm': 'tempmon.appliances.list', + }, + { + 'title': "Clients", + 'route': 'tempmon.clients', + 'perm': 'tempmon.clients.list', + }, + { + 'title': "Probes", + 'route': 'tempmon.probes', + 'perm': 'tempmon.probes.list', + }, + { + 'title': "Readings", + 'route': 'tempmon.readings', + 'perm': 'tempmon.readings.list', + }, + ], } def make_admin_menu(self, request, **kwargs): """ Generate a typical Admin menu """ - return { - 'title': "Admin", - 'type': 'menu', - 'items': [ + items = [] + + if kwargs.get('include_stores', True): + items.extend([ { 'title': "Stores", 'route': 'stores', 'perm': 'stores.list', }, - { - 'title': "Users", - 'route': 'users', - 'perm': 'users.list', - }, - { - 'title': "User Events", - 'route': 'userevents', - 'perm': 'userevents.list', - }, - { - 'title': "Roles", - 'route': 'roles', - 'perm': 'roles.list', - }, {'type': 'sep'}, - { - 'title': "App Settings", - 'route': 'appsettings', - 'perm': 'settings.list', - }, - { - 'title': "Email Settings", - 'route': 'emailprofiles', - 'perm': 'emailprofiles.list', - }, - { - 'title': "Email Attempts", - 'route': 'email_attempts', - 'perm': 'email_attempts.list', - }, - { - 'title': "Raw Settings", - 'route': 'settings', - 'perm': 'settings.list', - }, - {'type': 'sep'}, - { - 'title': "DataSync Changes", - 'route': 'datasyncchanges', - 'perm': 'datasync_changes.list', - }, - { - 'title': "DataSync Status", - 'route': 'datasync.status', - 'perm': 'datasync.status', - }, - { - 'title': "Importing / Exporting", - 'route': 'importing', - 'perm': 'importing.list', - }, - { - 'title': "Luigi Tasks", - 'route': 'luigi', - 'perm': 'luigi.list', - }, - { - 'title': "App Details", - 'route': 'appinfo', - 'perm': 'appinfo.list', - }, - { - 'title': "Upgrades", - 'route': 'upgrades', - 'perm': 'upgrades.list', - }, - ], + ]) + + items.extend([ + { + 'title': "Users", + 'route': 'users', + 'perm': 'users.list', + }, + { + 'title': "Roles", + 'route': 'roles', + 'perm': 'roles.list', + }, + { + 'title': "Raw Permissions", + 'route': 'permissions', + 'perm': 'permissions.list', + }, + {'type': 'sep'}, + { + 'title': "Email Settings", + 'route': 'emailprofiles', + 'perm': 'emailprofiles.list', + }, + { + 'title': "Email Attempts", + 'route': 'email_attempts', + 'perm': 'email_attempts.list', + }, + {'type': 'sep'}, + { + 'title': "DataSync Status", + 'route': 'datasync.status', + 'perm': 'datasync.status', + }, + { + 'title': "DataSync Changes", + 'route': 'datasyncchanges', + 'perm': 'datasync_changes.list', + }, + { + 'title': "Importing / Exporting", + 'route': 'importing', + 'perm': 'importing.list', + }, + { + 'title': "Luigi Tasks", + 'route': 'luigi', + 'perm': 'luigi.list', + }, + {'type': 'sep'}, + { + 'title': "App Details", + 'route': 'appinfo', + 'perm': 'appinfo.list', + }, + { + 'title': "Raw Settings", + 'route': 'settings', + 'perm': 'settings.list', + }, + { + 'title': "Upgrades", + 'route': 'upgrades', + 'perm': 'upgrades.list', + }, + ]) + + return { + 'title': "Admin", + 'type': 'menu', + 'items': items, } diff --git a/tailbone/views/essentials.py b/tailbone/views/essentials.py index b38749d1..c59eb794 100644 --- a/tailbone/views/essentials.py +++ b/tailbone/views/essentials.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -30,12 +30,17 @@ from __future__ import unicode_literals, absolute_import def includeme(config): config.include('tailbone.views.auth') config.include('tailbone.views.common') + config.include('tailbone.views.datasync') config.include('tailbone.views.email') + config.include('tailbone.views.importing') + config.include('tailbone.views.luigi') config.include('tailbone.views.menus') config.include('tailbone.views.people') config.include('tailbone.views.progress') + config.include('tailbone.views.reports') config.include('tailbone.views.roles') config.include('tailbone.views.settings') config.include('tailbone.views.tables') config.include('tailbone.views.upgrades') config.include('tailbone.views.users') + config.include('tailbone.views.views') From 79e4e596e8d29f35f6198649568ea15047556ba2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 18 Jan 2023 17:58:04 -0600 Subject: [PATCH 0977/1681] Include permission views by default --- tailbone/views/essentials.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/essentials.py b/tailbone/views/essentials.py index c59eb794..cb1a722a 100644 --- a/tailbone/views/essentials.py +++ b/tailbone/views/essentials.py @@ -36,6 +36,7 @@ def includeme(config): config.include('tailbone.views.luigi') config.include('tailbone.views.menus') config.include('tailbone.views.people') + config.include('tailbone.views.permissions') config.include('tailbone.views.progress') config.include('tailbone.views.reports') config.include('tailbone.views.roles') From 2b1fd9e986d240ec2249881745187959e0dd0625 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 18 Jan 2023 18:41:23 -0600 Subject: [PATCH 0978/1681] Add way to override particular 'essential' views --- tailbone/views/essentials.py | 40 +++++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/tailbone/views/essentials.py b/tailbone/views/essentials.py index cb1a722a..a8ded812 100644 --- a/tailbone/views/essentials.py +++ b/tailbone/views/essentials.py @@ -27,21 +27,27 @@ Essential views for convenient includes from __future__ import unicode_literals, absolute_import +def defaults(config, **kwargs): + mod = lambda spec: kwargs.get(spec, spec) + + config.include(mod('tailbone.views.auth')) + config.include(mod('tailbone.views.common')) + config.include(mod('tailbone.views.datasync')) + config.include(mod('tailbone.views.email')) + config.include(mod('tailbone.views.importing')) + config.include(mod('tailbone.views.luigi')) + config.include(mod('tailbone.views.menus')) + config.include(mod('tailbone.views.people')) + config.include(mod('tailbone.views.permissions')) + config.include(mod('tailbone.views.progress')) + config.include(mod('tailbone.views.reports')) + config.include(mod('tailbone.views.roles')) + config.include(mod('tailbone.views.settings')) + config.include(mod('tailbone.views.tables')) + config.include(mod('tailbone.views.upgrades')) + config.include(mod('tailbone.views.users')) + config.include(mod('tailbone.views.views')) + + def includeme(config): - config.include('tailbone.views.auth') - config.include('tailbone.views.common') - config.include('tailbone.views.datasync') - config.include('tailbone.views.email') - config.include('tailbone.views.importing') - config.include('tailbone.views.luigi') - config.include('tailbone.views.menus') - config.include('tailbone.views.people') - config.include('tailbone.views.permissions') - config.include('tailbone.views.progress') - config.include('tailbone.views.reports') - config.include('tailbone.views.roles') - config.include('tailbone.views.settings') - config.include('tailbone.views.tables') - config.include('tailbone.views.upgrades') - config.include('tailbone.views.users') - config.include('tailbone.views.views') + defaults(config) From eece358e20ecc6e4e4c578fad92eb154145f421b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 18 Jan 2023 18:58:32 -0600 Subject: [PATCH 0979/1681] 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 179b9a33..7ed842fc 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,18 @@ CHANGELOG ========= +0.8.285 (2023-01-18) +-------------------- + +* Misc. tweaks for App Details / Configure Menus. + +* Add specific data type options for new table entry form. + +* Add more views, menus to default set. + +* Add way to override particular 'essential' views. + + 0.8.284 (2023-01-15) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index b1eb871d..b932f0cb 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.284' +__version__ = '0.8.285' From 3f61c9ee18c1f8822f9665af63991f1069ef0b7f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 18 Jan 2023 19:21:34 -0600 Subject: [PATCH 0980/1681] Add some more menu items to default set --- tailbone/menus.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tailbone/menus.py b/tailbone/menus.py index c42f91ae..7304d1ab 100644 --- a/tailbone/menus.py +++ b/tailbone/menus.py @@ -391,6 +391,11 @@ class MenuHandler(GenericHandler): 'route': 'brands', 'perm': 'brands.list', }, + { + 'title': "Categories", + 'route': 'categories', + 'perm': 'categories.list', + }, { 'title': "Families", 'route': 'families', @@ -439,6 +444,11 @@ class MenuHandler(GenericHandler): 'route': 'receiving', 'perm': 'receiving.list', }, + { + 'title': "Invoice Costing", + 'route': 'invoice_costing', + 'perm': 'invoice_costing.list', + }, {'type': 'sep'}, { 'title': "Purchases", From c874d975073d4638fb34c80b3e2564b5603a4b53 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 18 Jan 2023 20:11:46 -0600 Subject: [PATCH 0981/1681] Add default view config for Trainwreck --- tailbone/views/trainwreck/__init__.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tailbone/views/trainwreck/__init__.py b/tailbone/views/trainwreck/__init__.py index 33662c67..b5eea351 100644 --- a/tailbone/views/trainwreck/__init__.py +++ b/tailbone/views/trainwreck/__init__.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2021 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -27,3 +27,7 @@ Trainwreck Views from __future__ import unicode_literals, absolute_import from .base import TransactionView + + +def includeme(config): + config.include('tailbone.views.trainwreck.defaults') From 1e5b7e7ee7cd5f250df10fabab179275316269b1 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 18 Jan 2023 21:54:24 -0600 Subject: [PATCH 0982/1681] Add a couple more menu items to default set --- tailbone/menus.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tailbone/menus.py b/tailbone/menus.py index 7304d1ab..62cdbfe4 100644 --- a/tailbone/menus.py +++ b/tailbone/menus.py @@ -551,6 +551,12 @@ class MenuHandler(GenericHandler): 'title': "TempMon", 'type': 'menu', 'items': [ + { + 'title': "Dashboard", + 'route': 'tempmon.dashboard', + 'perm': 'tempmon.appliances.dashboard', + }, + {'type': 'sep'}, { 'title': "Appliances", 'route': 'tempmon.appliances', @@ -644,6 +650,11 @@ class MenuHandler(GenericHandler): 'route': 'appinfo', 'perm': 'appinfo.list', }, + { + 'title': "Label Settings", + 'route': 'labelprofiles', + 'perm': 'labelprofiles.list', + }, { 'title': "Raw Settings", 'route': 'settings', From dc6bd4d4a71f490f268d678f36c8486f21db592a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 18 Jan 2023 21:56:29 -0600 Subject: [PATCH 0983/1681] Rename frontend request handler logic to SimpleRequestMixin --- tailbone/templates/formposter.mako | 39 ++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/formposter.mako b/tailbone/templates/formposter.mako index 885ac6c2..5c695eb2 100644 --- a/tailbone/templates/formposter.mako +++ b/tailbone/templates/formposter.mako @@ -3,10 +3,41 @@ <%def name="declare_formposter_mixin()"> <script type="text/javascript"> - let FormPosterMixin = { + let SimpleRequestMixin = { methods: { - submitForm(action, params, success, failure) { + simpleGET(url, params, success, failure) { + + this.$http.get(url, {params: params}).then(response => { + + if (response.data.error) { + this.$buefy.toast.open({ + message: `Request failed: ${'$'}{response.data.error}`, + type: 'is-danger', + duration: 4000, // 4 seconds + }) + if (failure) { + failure(response) + } + + } else { + success(response) + } + + }, response => { + this.$buefy.toast.open({ + message: "Request failed: (unknown server error)", + type: 'is-danger', + duration: 4000, // 4 seconds + }) + if (failure) { + failure(response) + } + }) + + }, + + simplePOST(action, params, success, failure) { let csrftoken = ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n} @@ -45,5 +76,9 @@ }, } + // TODO: deprecate / remove + SimpleRequestMixin.methods.submitForm = SimpleRequestMixin.methods.simplePOST + let FormPosterMixin = SimpleRequestMixin + </script> </%def> From 884f136e99f86b0f463546c476bf1d9476884a6f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 18 Jan 2023 22:04:35 -0600 Subject: [PATCH 0984/1681] 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 7ed842fc..3792e631 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.8.286 (2023-01-18) +-------------------- + +* Add some more menu items to default set. + +* Add default view config for Trainwreck. + +* Rename frontend request handler logic to ``SimpleRequestMixin``. + + 0.8.285 (2023-01-18) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index b932f0cb..be48d691 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.285' +__version__ = '0.8.286' From 55a3f9669b78ae34d3701c2a8594832dc07e4c86 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 26 Jan 2023 13:34:13 -0600 Subject: [PATCH 0985/1681] Fix click event for right-aligned buttons on profile view for some reason when `is-pulled-right` was used, buttons were not clickable?! never did figure out precisely why, but this fixes anyway. was not an issue w/ buefy 0.8 fwiw, but using 0.9 now --- tailbone/templates/people/view_profile_buefy.mako | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako index 9845e343..584597b4 100644 --- a/tailbone/templates/people/view_profile_buefy.mako +++ b/tailbone/templates/people/view_profile_buefy.mako @@ -234,7 +234,7 @@ </b-notification> % if request.has_perm('people_profile.edit_person'): - <div class="is-pulled-right"> + <div class="has-text-right"> <b-button type="is-primary" icon-pack="fas" icon-left="plus" @@ -369,7 +369,7 @@ <div class="content"> % if request.has_perm('people_profile.edit_person'): - <div class="is-pulled-right"> + <div class="has-text-right"> <b-button type="is-primary" icon-pack="fas" icon-left="plus" From 64acfbcb4e46142691607c25e24772221d77c804 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 26 Jan 2023 13:36:14 -0600 Subject: [PATCH 0986/1681] 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 3792e631..19205cad 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.287 (2023-01-26) +-------------------- + +* Fix click event for right-aligned buttons on profile view. + + 0.8.286 (2023-01-18) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index be48d691..e7f09fdd 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.286' +__version__ = '0.8.287' From 17251b2c88b5e26e7d1f7b0f86e0fec58980b5de Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 28 Jan 2023 15:54:53 -0600 Subject: [PATCH 0987/1681] Tweak import handler form, some fields not required those particular fields are for read-only display, not meant for user to provide values. so must provide defaults, else form missing those will not validate. --- tailbone/views/importing.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tailbone/views/importing.py b/tailbone/views/importing.py index 003d7ac4..b3358f23 100644 --- a/tailbone/views/importing.py +++ b/tailbone/views/importing.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -627,11 +627,14 @@ class ImportHandlerSchema(colander.MappingSchema): class RunJobSchema(colander.MappingSchema): - handler_spec = colander.SchemaNode(colander.String()) + handler_spec = colander.SchemaNode(colander.String(), + missing=colander.null) - host_title = colander.SchemaNode(colander.String()) + host_title = colander.SchemaNode(colander.String(), + missing=colander.null) - local_title = colander.SchemaNode(colander.String()) + local_title = colander.SchemaNode(colander.String(), + missing=colander.null) models = colander.SchemaNode(colander.List()) From d6f05684be86710dae32ff9795a254d8f6705091 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 28 Jan 2023 16:11:47 -0600 Subject: [PATCH 0988/1681] Tweak styles for Quantity panel when viewing Receiving row when no buttons were visible in panel, right-hand side looked "cut off" --- tailbone/templates/receiving/view_row.mako | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tailbone/templates/receiving/view_row.mako b/tailbone/templates/receiving/view_row.mako index 8c397c4f..308e97d7 100644 --- a/tailbone/templates/receiving/view_row.mako +++ b/tailbone/templates/receiving/view_row.mako @@ -23,8 +23,7 @@ } .quantity-form-fields { - margin: 2rem auto; - padding-left: 2rem; + margin: 2rem; } .quantity-form-fields .field.is-horizontal .field-label .label { From 8cdfe4a22c389d9075e5918e45b888b22baaca69 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 28 Jan 2023 16:22:54 -0600 Subject: [PATCH 0989/1681] 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 19205cad..493c7d16 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.288 (2023-01-28) +-------------------- + +* Tweak import handler form, some fields not required. + +* Tweak styles for Quantity panel when viewing Receiving row. + + 0.8.287 (2023-01-26) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index e7f09fdd..cd1943d8 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.287' +__version__ = '0.8.288' From 86af4baef5416ffcfd5e5c07a299cb345e97d82e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 29 Jan 2023 12:45:14 -0600 Subject: [PATCH 0990/1681] Fix icon for multi-file upload widget --- tailbone/templates/multi_file_upload.mako | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/templates/multi_file_upload.mako b/tailbone/templates/multi_file_upload.mako index ea9b5121..e78de194 100644 --- a/tailbone/templates/multi_file_upload.mako +++ b/tailbone/templates/multi_file_upload.mako @@ -9,7 +9,7 @@ <section class="section"> <div class="content has-text-centered"> <p> - <b-icon icon="upload" size="is-large"></b-icon> + <b-icon pack="fas" icon="upload" size="is-large"></b-icon> </p> <p>Drop your files here or click to upload</p> </div> From c880065da8097490583f8bb72b7d4e6e6acc21fb Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 29 Jan 2023 13:02:39 -0600 Subject: [PATCH 0991/1681] Tweak customer panel header style for new custorder --- tailbone/templates/custorders/create.mako | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index 3189c0b3..b0dca6ec 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -1298,9 +1298,7 @@ customerHeaderClass() { if (!this.customerPanelOpen) { if (this.customerStatusType == 'is-danger') { - return 'has-text-danger' - } else if (this.customerStatusType == 'is-warning') { - return 'has-text-warning' + return 'has-text-white' } } }, From b7f3a67cd03178524c9161f9767d05bea384adad Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 29 Jan 2023 18:46:49 -0600 Subject: [PATCH 0992/1681] Add basic API support for printing product labels --- tailbone/api/labels.py | 51 ++++++++++++++++++++++++++++++++++++++ tailbone/api/products.py | 53 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 tailbone/api/labels.py diff --git a/tailbone/api/labels.py b/tailbone/api/labels.py new file mode 100644 index 00000000..8bc11f8f --- /dev/null +++ b/tailbone/api/labels.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2023 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Tailbone Web API - Label Views +""" + +from __future__ import unicode_literals, absolute_import + +from rattail.db.model import LabelProfile + +from tailbone.api import APIMasterView + + +class LabelProfileView(APIMasterView): + """ + API views for Label Profile data + """ + model_class = LabelProfile + collection_url_prefix = '/label-profiles' + object_url_prefix = '/label-profile' + + +def defaults(config, **kwargs): + base = globals() + + LabelProfileView = kwargs.get('LabelProfileView', base['LabelProfileView']) + LabelProfileView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/api/products.py b/tailbone/api/products.py index a1547cce..4c3df983 100644 --- a/tailbone/api/products.py +++ b/tailbone/api/products.py @@ -26,6 +26,8 @@ Tailbone Web API - Product Views from __future__ import unicode_literals, absolute_import +import logging + import six import sqlalchemy as sa from sqlalchemy import orm @@ -37,6 +39,9 @@ from rattail.db import model from tailbone.api import APIMasterView +log = logging.getLogger(__name__) + + class ProductView(APIMasterView): """ API views for Product data @@ -63,6 +68,14 @@ class ProductView(APIMasterView): 'sale_price_display', 'sale_ends', 'sale_ends_display', + 'tpr_price', + 'tpr_price_display', + 'tpr_ends', + 'tpr_ends_display', + 'current_price', + 'current_price_display', + 'current_ends', + 'current_ends_display', 'vendor_name', 'costs', 'image_url', @@ -115,6 +128,39 @@ class ProductView(APIMasterView): return {'ok': True, 'product': self.normalize(product)} + def print_labels(self): + app = self.get_rattail_app() + label_handler = app.get_label_handler() + model = self.model + data = self.request.json_body + + uuid = data.get('label_profile_uuid') + profile = self.Session.query(model.LabelProfile).get(uuid) if uuid else None + if not profile: + return {'error': "Label profile not found"} + + uuid = data.get('product_uuid') + product = self.Session.query(model.Product).get(uuid) if uuid else None + if not product: + return {'error': "Product not found"} + + try: + quantity = int(data.get('quantity')) + except: + return {'error': "Quantity must be integer"} + + printer = label_handler.get_printer(profile) + if not printer: + return {'error': "Couldn't get printer from label profile"} + + try: + printer.print_labels([({'product': product}, quantity)]) + except Exception as error: + log.warning("error occurred while printing labels", exc_info=True) + return {'error': six.text_type(error)} + + return {'ok': True} + @classmethod def defaults(cls, config): cls._defaults(config) @@ -133,6 +179,13 @@ class ProductView(APIMasterView): permission='{}.list'.format(permission_prefix)) config.add_cornice_service(quick_lookup) + # print labels + print_labels = Service(name='{}.print_labels'.format(route_prefix), + path='{}/print-labels'.format(collection_url_prefix)) + print_labels.add_view('POST', 'print_labels', klass=cls, + permission='{}.print_labels'.format(permission_prefix)) + config.add_cornice_service(print_labels) + def defaults(config, **kwargs): base = globals() From a3723e48797db17686a3a5a557a8a7ec299abd9e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 30 Jan 2023 11:46:07 -0600 Subject: [PATCH 0993/1681] Tweak the Ordering Worksheet generator, per Buefy --- tailbone/templates/reports/base.mako | 5 +++-- tailbone/views/departments.py | 15 ++++++++++++++- tailbone/views/reports.py | 2 +- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/tailbone/templates/reports/base.mako b/tailbone/templates/reports/base.mako index 5833b0ec..cc379506 100644 --- a/tailbone/templates/reports/base.mako +++ b/tailbone/templates/reports/base.mako @@ -1,3 +1,4 @@ -## -*- coding: utf-8 -*- -<%inherit file="/base.mako" /> +## -*- coding: utf-8; -*- +<%inherit file="/page.mako" /> + ${parent.body()} diff --git a/tailbone/views/departments.py b/tailbone/views/departments.py index a0212d63..3e876c66 100644 --- a/tailbone/views/departments.py +++ b/tailbone/views/departments.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -218,6 +218,19 @@ class DepartmentView(MasterView): .distinct()\ .order_by(model.Department.name) + if self.get_use_buefy(): + + def normalize(dept): + return { + 'uuid': dept.uuid, + 'number': dept.number, + 'name': dept.name, + } + + return self.json_response([normalize(d) for d in data]) + + # nb. the rest of this is legacy / not buefy + def configure(g): g.configure(include=[ g.name, diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index 70e4d7e6..7bcc05da 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -101,7 +101,7 @@ class OrderingWorksheet(View): response.headers['Content-Disposition'] = 'attachment; filename=ordering.html' response.text = body return response - return {} + return {'use_buefy': self.get_use_buefy()} def write_report(self, vendor, departments, preferred_only): """ From a1d88a5e6bca998c444e033df413f61abad6bb0d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 30 Jan 2023 11:56:09 -0600 Subject: [PATCH 0994/1681] Refactor the Inventory Worksheet generator, per Buefy --- tailbone/templates/reports/inventory.mako | 108 +++++++++++++++++----- tailbone/views/reports.py | 3 +- 2 files changed, 86 insertions(+), 25 deletions(-) diff --git a/tailbone/templates/reports/inventory.mako b/tailbone/templates/reports/inventory.mako index 41c74cda..e3033c4c 100644 --- a/tailbone/templates/reports/inventory.mako +++ b/tailbone/templates/reports/inventory.mako @@ -3,33 +3,93 @@ <%def name="title()">Report : Inventory Worksheet</%def> -<p>Please provide the following criteria to generate your report:</p> -<br /> +<%def name="page_content()"> + % if use_buefy: -${h.form(request.current_route_url())} -${h.csrf_token(request)} + <p class="block"> + Please provide the following criteria to generate your report: + </p> -<div class="field-wrapper"> - <label for="department">Department</label> - <div class="field"> - <select name="department"> - % for department in departments: - <option value="${department.uuid}">${department.name}</option> - % endfor - </select> - </div> -</div> + ${h.form(request.current_route_url())} + ${h.csrf_token(request)} -<div class="field-wrapper"> - ${h.checkbox('weighted-only', label="Only include items which are sold by weight.")} -</div> + <b-field label="Department"> + <b-select name="department"> + <option v-for="dept in departments" + :key="dept.uuid" + :value="dept.uuid"> + {{ dept.name }} + </option> + </b-select> + </b-field> -<div class="field-wrapper"> - ${h.checkbox('exclude-not-for-sale', label="Exclude items marked \"not for sale\".", checked=True)} -</div> + <b-field> + <b-checkbox name="weighted-only"> + Only include items which are sold by weight. + </b-checkbox> + </b-field> -<div class="buttons"> - ${h.submit('submit', "Generate Report")} -</div> + <b-field> + <b-checkbox name="exclude-not-for-sale" :value="true"> + Exclude items marked "not for sale". + </b-checkbox> + </b-field> -${h.end_form()} + <div class="buttons"> + <b-button type="is-primary" + native-type="submit" + icon-pack="fas" + icon-left="arrow-circle-right"> + Generate Report + </b-button> + </div> + + ${h.end_form()} + + % else: + + <p>Please provide the following criteria to generate your report:</p> + <br /> + + ${h.form(request.current_route_url())} + ${h.csrf_token(request)} + + <div class="field-wrapper"> + <label for="department">Department</label> + <div class="field"> + <select name="department"> + % for department in departments: + <option value="${department.uuid}">${department.name}</option> + % endfor + </select> + </div> + </div> + + <div class="field-wrapper"> + ${h.checkbox('weighted-only', label="Only include items which are sold by weight.")} + </div> + + <div class="field-wrapper"> + ${h.checkbox('exclude-not-for-sale', label="Exclude items marked \"not for sale\".", checked=True)} + </div> + + <div class="buttons"> + ${h.submit('submit', "Generate Report")} + </div> + + ${h.end_form()} + + % endif +</%def> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + ThisPageData.departments = ${json.dumps([{'uuid': d.uuid, 'name': d.name} for d in departments])|n} + + </script> +</%def> + + +${parent.body()} diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index 7bcc05da..0054569d 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -174,7 +174,8 @@ class InventoryWorksheet(View): departments = departments.order_by(model.Department.name) departments = departments.all() - return{'departments': departments} + return{'departments': departments, + 'use_buefy': self.get_use_buefy()} def write_report(self, department): """ From 5f7fa33eb2831990d30300f1aeebd322da7b2a58 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 30 Jan 2023 21:06:08 -0600 Subject: [PATCH 0995/1681] 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 493c7d16..86be543a 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,20 @@ CHANGELOG ========= +0.8.289 (2023-01-30) +-------------------- + +* Fix icon for multi-file upload widget. + +* Tweak customer panel header style for new custorder. + +* Add basic API support for printing product labels. + +* Tweak the Ordering Worksheet generator, per Buefy. + +* Refactor the Inventory Worksheet generator, per Buefy. + + 0.8.288 (2023-01-28) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index cd1943d8..00ea9c62 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.288' +__version__ = '0.8.289' From 8410419717e33907d8f823b7348b068cdb159c5b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 1 Feb 2023 18:44:55 -0600 Subject: [PATCH 0996/1681] Remove support for Buefy 0.8 only Buefy 0.9 and greater are supported now --- tailbone/config.py | 9 +- tailbone/subscribers.py | 13 +- tailbone/templates/appinfo/configure.mako | 93 ++--- tailbone/templates/appinfo/index.mako | 34 +- tailbone/templates/custorders/create.mako | 337 +++++++----------- tailbone/templates/custorders/items/view.mako | 139 +++----- tailbone/templates/datasync/configure.mako | 274 ++++++-------- tailbone/templates/datasync/status.mako | 176 ++++----- tailbone/templates/generate_feature.mako | 93 ++--- tailbone/templates/grids/b-table.mako | 115 +++--- tailbone/templates/grids/buefy.mako | 95 +++-- tailbone/templates/importing/configure.mako | 137 +++---- tailbone/templates/luigi/configure.mako | 205 +++++------ tailbone/templates/luigi/index.mako | 248 ++++++------- tailbone/templates/people/index.mako | 45 +-- .../templates/people/view_profile_buefy.mako | 266 ++++++-------- tailbone/templates/products/lookup.mako | 141 +++----- tailbone/templates/tables/create.mako | 114 +++--- .../trainwreck/transactions/rollover.mako | 65 ++-- tailbone/templates/upgrades/configure.mako | 83 ++--- 20 files changed, 1057 insertions(+), 1625 deletions(-) diff --git a/tailbone/config.py b/tailbone/config.py index bcdde8a6..be8f2dc2 100644 --- a/tailbone/config.py +++ b/tailbone/config.py @@ -27,7 +27,6 @@ Rattail config extension for Tailbone from __future__ import unicode_literals, absolute_import import warnings -from pkg_resources import parse_version from rattail.config import ConfigExtension as BaseExtension from rattail.db.config import configure_session @@ -78,11 +77,9 @@ def get_buefy_version(config): def get_buefy_0_8(config, version=None): - if not version: - version = get_buefy_version(config) - if version == 'latest': - return False - return parse_version(version) < parse_version('0.9') + warnings.warn("get_buefy_0_8() is no longer supported", + DeprecationWarning, stacklevel=2) + return False def global_help_url(config): diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index cbbcb95a..7ffd92bc 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -40,10 +40,9 @@ from webhelpers2.html import tags import tailbone from tailbone import helpers from tailbone.db import Session -from tailbone.config import (csrf_header_name, should_expose_websockets, - get_buefy_0_8) +from tailbone.config import csrf_header_name, should_expose_websockets from tailbone.menus import make_simple_menus -from tailbone.util import should_use_buefy, get_global_search_options, get_libver +from tailbone.util import should_use_buefy, get_global_search_options def new_request(event): @@ -160,10 +159,8 @@ def before_render(event): # buefy themes get some extra treatment if should_use_buefy(request): - # TODO: remove this hack once all nodes safely on buefy 0.9 - version = get_libver(request, 'buefy') - renderer_globals['buefy_0_8'] = get_buefy_0_8(rattail_config, - version=version) + # TODO: remove this hack once nothing references it + renderer_globals['buefy_0_8'] = False # maybe set custom stylesheet css = None diff --git a/tailbone/templates/appinfo/configure.mako b/tailbone/templates/appinfo/configure.mako index bb932148..8483a7a2 100644 --- a/tailbone/templates/appinfo/configure.mako +++ b/tailbone/templates/appinfo/configure.mako @@ -102,68 +102,45 @@ <b-table :data="weblibs"> - % if buefy_0_8: - <template slot-scope="props"> - % endif + <b-table-column field="title" + label="Name" + v-slot="props"> + {{ props.row.title }} + </b-table-column> - <b-table-column field="title" - label="Name" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.title }} - </b-table-column> + <b-table-column field="configured_version" + label="Version" + v-slot="props"> + {{ props.row.configured_version || props.row.default_version }} + </b-table-column> - <b-table-column field="configured_version" - label="Version" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.configured_version || props.row.default_version }} - </b-table-column> + <b-table-column field="configured_url" + label="URL Override" + v-slot="props"> + {{ props.row.configured_url }} + </b-table-column> - <b-table-column field="configured_url" - label="URL Override" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.configured_url }} - </b-table-column> + <b-table-column field="live_url" + label="Effective (Live) URL" + v-slot="props"> + <span v-if="props.row.modified" + class="has-text-warning"> + save settings and refresh page to see new URL + </span> + <span v-if="!props.row.modified"> + {{ props.row.live_url }} + </span> + </b-table-column> - <b-table-column field="live_url" - label="Effective (Live) URL" - % if not buefy_0_8: - v-slot="props" - % endif - > - <span v-if="props.row.modified" - class="has-text-warning"> - save settings and refresh page to see new URL - </span> - <span v-if="!props.row.modified"> - {{ props.row.live_url }} - </span> - </b-table-column> - - <b-table-column field="actions" - label="Actions" - % if not buefy_0_8: - v-slot="props" - % endif - > - <a href="#" - @click.prevent="editWebLibraryInit(props.row)"> - <i class="fas fa-edit"></i> - Edit - </a> - </b-table-column> - - % if buefy_0_8: - </template> - % endif + <b-table-column field="actions" + label="Actions" + v-slot="props"> + <a href="#" + @click.prevent="editWebLibraryInit(props.row)"> + <i class="fas fa-edit"></i> + Edit + </a> + </b-table-column> </b-table> diff --git a/tailbone/templates/appinfo/index.mako b/tailbone/templates/appinfo/index.mako index 9b50b8a9..40bf31ce 100644 --- a/tailbone/templates/appinfo/index.mako +++ b/tailbone/templates/appinfo/index.mako @@ -59,31 +59,17 @@ <div style="width: 100%;"> <b-table :data="configFiles"> - % if buefy_0_8: - <template slot-scope="props"> - % endif + <b-table-column field="priority" + label="Priority" + v-slot="props"> + {{ props.row.priority }} + </b-table-column> - <b-table-column field="priority" - label="Priority" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.priority }} - </b-table-column> - - <b-table-column field="path" - label="File Path" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.path }} - </b-table-column> - - % if buefy_0_8: - </template> - % endif + <b-table-column field="path" + label="File Path" + v-slot="props"> + {{ props.row.path }} + </b-table-column> </b-table> </div> diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index b0dca6ec..f75b6c65 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -886,94 +886,72 @@ paginated per-page="5" :debounce-search="1000"> - % if buefy_0_8: - <template slot-scope="props"> - % endif - <b-table-column :label="productKeyLabel" - field="key" - % if not buefy_0_8: - v-slot="props" - % endif - sortable> - {{ props.row.key }} - </b-table-column> + <b-table-column :label="productKeyLabel" + field="key" + v-slot="props" + sortable> + {{ props.row.key }} + </b-table-column> - <b-table-column label="Brand" - field="brand_name" - % if not buefy_0_8: - v-slot="props" - % endif - sortable - searchable> - {{ props.row.brand_name }} - </b-table-column> + <b-table-column label="Brand" + field="brand_name" + v-slot="props" + sortable + searchable> + {{ props.row.brand_name }} + </b-table-column> - <b-table-column label="Description" - field="description" - % if not buefy_0_8: - v-slot="props" - % endif - sortable - searchable> - {{ props.row.description }} - {{ props.row.size }} - </b-table-column> + <b-table-column label="Description" + field="description" + v-slot="props" + sortable + searchable> + {{ props.row.description }} + {{ props.row.size }} + </b-table-column> - <b-table-column label="Unit Price" - field="unit_price" - % if not buefy_0_8: - v-slot="props" - % endif - sortable> - {{ props.row.unit_price_display }} - </b-table-column> + <b-table-column label="Unit Price" + field="unit_price" + v-slot="props" + sortable> + {{ props.row.unit_price_display }} + </b-table-column> - <b-table-column label="Sale Price" - field="sale_price" - % if not buefy_0_8: - v-slot="props" - % endif - sortable> - <span class="has-background-warning"> - {{ props.row.sale_price_display }} - </span> - </b-table-column> + <b-table-column label="Sale Price" + field="sale_price" + v-slot="props" + sortable> + <span class="has-background-warning"> + {{ props.row.sale_price_display }} + </span> + </b-table-column> - <b-table-column label="Sale Ends" - field="sale_ends" - % if not buefy_0_8: - v-slot="props" - % endif - sortable> - <span class="has-background-warning"> - {{ props.row.sale_ends_display }} - </span> - </b-table-column> + <b-table-column label="Sale Ends" + field="sale_ends" + v-slot="props" + sortable> + <span class="has-background-warning"> + {{ props.row.sale_ends_display }} + </span> + </b-table-column> - <b-table-column label="Department" - field="department_name" - % if not buefy_0_8: - v-slot="props" - % endif - sortable - searchable> - {{ props.row.department_name }} - </b-table-column> + <b-table-column label="Department" + field="department_name" + v-slot="props" + sortable + searchable> + {{ props.row.department_name }} + </b-table-column> - <b-table-column label="Vendor" - field="vendor_name" - % if not buefy_0_8: - v-slot="props" - % endif - sortable - searchable> - {{ props.row.vendor_name }} - </b-table-column> + <b-table-column label="Vendor" + field="vendor_name" + v-slot="props" + sortable + searchable> + {{ props.row.vendor_name }} + </b-table-column> - % if buefy_0_8: - </template> - % endif <template slot="empty"> <div class="content has-text-grey has-text-centered"> <p> @@ -1009,132 +987,93 @@ <b-table v-if="items.length" :data="items" :row-class="(row, i) => row.product_uuid ? null : 'has-text-success'"> - % if buefy_0_8: - <template slot-scope="props"> + + <b-table-column :label="productKeyLabel" + v-slot="props"> + {{ props.row.product_key }} + </b-table-column> + + <b-table-column label="Brand" + v-slot="props"> + {{ props.row.product_brand }} + </b-table-column> + + <b-table-column label="Description" + v-slot="props"> + {{ props.row.product_description }} + </b-table-column> + + <b-table-column label="Size" + v-slot="props"> + {{ props.row.product_size }} + </b-table-column> + + <b-table-column label="Department" + v-slot="props"> + {{ props.row.department_display }} + </b-table-column> + + <b-table-column label="Quantity" + v-slot="props"> + <span v-html="props.row.order_quantity_display"></span> + </b-table-column> + + <b-table-column label="Unit Price" + v-slot="props"> + <span + % if product_price_may_be_questionable: + :class="props.row.price_needs_confirmation ? 'has-background-warning' : ''" + % else: + :class="props.row.pricing_reflects_sale ? 'has-background-warning' : null" + % endif + > + {{ props.row.unit_price_display }} + </span> + </b-table-column> + + % if allow_item_discounts: + <b-table-column label="Discount" + v-slot="props"> + {{ props.row.discount_percent }}{{ props.row.discount_percent ? " %" : "" }} + </b-table-column> % endif - <b-table-column :label="productKeyLabel" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.product_key }} - </b-table-column> + <b-table-column label="Total" + v-slot="props"> + <span + % if product_price_may_be_questionable: + :class="props.row.price_needs_confirmation ? 'has-background-warning' : ''" + % else: + :class="props.row.pricing_reflects_sale ? 'has-background-warning' : null" + % endif + > + {{ props.row.total_price_display }} + </span> + </b-table-column> - <b-table-column label="Brand" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.product_brand }} - </b-table-column> + <b-table-column label="Vendor" + v-slot="props"> + {{ props.row.vendor_display }} + </b-table-column> - <b-table-column label="Description" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.product_description }} - </b-table-column> + <b-table-column field="actions" + label="Actions" + v-slot="props"> + <a href="#" class="grid-action" + @click.prevent="showEditItemDialog(props.row)"> + <i class="fas fa-edit"></i> + Edit + </a> + - <b-table-column label="Size" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.product_size }} - </b-table-column> + <a href="#" class="grid-action has-text-danger" + @click.prevent="deleteItem(props.index)"> + <i class="fas fa-trash"></i> + Delete + </a> + + </b-table-column> - <b-table-column label="Department" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.department_display }} - </b-table-column> - - <b-table-column label="Quantity" - % if not buefy_0_8: - v-slot="props" - % endif - > - <span v-html="props.row.order_quantity_display"></span> - </b-table-column> - - <b-table-column label="Unit Price" - % if not buefy_0_8: - v-slot="props" - % endif - > - <span - % if product_price_may_be_questionable: - :class="props.row.price_needs_confirmation ? 'has-background-warning' : ''" - % else: - :class="props.row.pricing_reflects_sale ? 'has-background-warning' : null" - % endif - > - {{ props.row.unit_price_display }} - </span> - </b-table-column> - - % if allow_item_discounts: - <b-table-column label="Discount" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.discount_percent }}{{ props.row.discount_percent ? " %" : "" }} - </b-table-column> - % endif - - <b-table-column label="Total" - % if not buefy_0_8: - v-slot="props" - % endif - > - <span - % if product_price_may_be_questionable: - :class="props.row.price_needs_confirmation ? 'has-background-warning' : ''" - % else: - :class="props.row.pricing_reflects_sale ? 'has-background-warning' : null" - % endif - > - {{ props.row.total_price_display }} - </span> - </b-table-column> - - <b-table-column label="Vendor" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.vendor_display }} - </b-table-column> - - <b-table-column field="actions" - label="Actions" - % if not buefy_0_8: - v-slot="props" - % endif - > - <a href="#" class="grid-action" - @click.prevent="showEditItemDialog(props.row)"> - <i class="fas fa-edit"></i> - Edit - </a> - - - <a href="#" class="grid-action has-text-danger" - @click.prevent="deleteItem(props.index)"> - <i class="fas fa-trash"></i> - Delete - </a> - - </b-table-column> - - % if buefy_0_8: - </template> - % endif </b-table> </div> </div> diff --git a/tailbone/templates/custorders/items/view.mako b/tailbone/templates/custorders/items/view.mako index c39b7ffe..9eb239ed 100644 --- a/tailbone/templates/custorders/items/view.mako +++ b/tailbone/templates/custorders/items/view.mako @@ -106,95 +106,56 @@ :checked-rows.sync="changeStatusCheckedRows" narrowed class="is-size-7"> - % if buefy_0_8: - <template slot-scope="props"> - % endif - <b-table-column field="product_brand" label="Brand" - % if not buefy_0_8: - v-slot="props" - % endif - > - <span v-html="props.row.product_brand"></span> - </b-table-column> - <b-table-column field="product_description" label="Product" - % if not buefy_0_8: - v-slot="props" - % endif - > - <span v-html="props.row.product_description"></span> - </b-table-column> - <!-- <b-table-column field="quantity" label="Quantity"> --> - <!-- <span v-html="props.row.quantity"></span> --> - <!-- </b-table-column> --> - <b-table-column field="product_case_quantity" label="cPack" - % if not buefy_0_8: - v-slot="props" - % endif - > - <span v-html="props.row.product_case_quantity"></span> - </b-table-column> - <b-table-column field="order_quantity" label="oQty" - % if not buefy_0_8: - v-slot="props" - % endif - > - <span v-html="props.row.order_quantity"></span> - </b-table-column> - <b-table-column field="order_uom" label="UOM" - % if not buefy_0_8: - v-slot="props" - % endif - > - <span v-html="props.row.order_uom"></span> - </b-table-column> - <b-table-column field="department_name" label="Department" - % if not buefy_0_8: - v-slot="props" - % endif - > - <span v-html="props.row.department_name"></span> - </b-table-column> - <b-table-column field="product_barcode" label="Product Barcode" - % if not buefy_0_8: - v-slot="props" - % endif - > - <span v-html="props.row.product_barcode"></span> - </b-table-column> - <b-table-column field="unit_price" label="Unit $" - % if not buefy_0_8: - v-slot="props" - % endif - > - <span v-html="props.row.unit_price"></span> - </b-table-column> - <b-table-column field="total_price" label="Total $" - % if not buefy_0_8: - v-slot="props" - % endif - > - <span v-html="props.row.total_price"></span> - </b-table-column> - <b-table-column field="order_date" label="Order Date" - % if not buefy_0_8: - v-slot="props" - % endif - > - <span v-html="props.row.order_date"></span> - </b-table-column> - <b-table-column field="status_code" label="Status" - % if not buefy_0_8: - v-slot="props" - % endif - > - <span v-html="props.row.status_code"></span> - </b-table-column> - <!-- <b-table-column field="flagged" label="Flagged"> --> - <!-- <span v-html="props.row.flagged"></span> --> - <!-- </b-table-column> --> - % if buefy_0_8: - </template> - % endif + <b-table-column field="product_brand" label="Brand" + v-slot="props"> + <span v-html="props.row.product_brand"></span> + </b-table-column> + <b-table-column field="product_description" label="Product" + v-slot="props"> + <span v-html="props.row.product_description"></span> + </b-table-column> + <!-- <b-table-column field="quantity" label="Quantity"> --> + <!-- <span v-html="props.row.quantity"></span> --> + <!-- </b-table-column> --> + <b-table-column field="product_case_quantity" label="cPack" + v-slot="props"> + <span v-html="props.row.product_case_quantity"></span> + </b-table-column> + <b-table-column field="order_quantity" label="oQty" + v-slot="props"> + <span v-html="props.row.order_quantity"></span> + </b-table-column> + <b-table-column field="order_uom" label="UOM" + v-slot="props"> + <span v-html="props.row.order_uom"></span> + </b-table-column> + <b-table-column field="department_name" label="Department" + v-slot="props"> + <span v-html="props.row.department_name"></span> + </b-table-column> + <b-table-column field="product_barcode" label="Product Barcode" + v-slot="props"> + <span v-html="props.row.product_barcode"></span> + </b-table-column> + <b-table-column field="unit_price" label="Unit $" + v-slot="props"> + <span v-html="props.row.unit_price"></span> + </b-table-column> + <b-table-column field="total_price" label="Total $" + v-slot="props"> + <span v-html="props.row.total_price"></span> + </b-table-column> + <b-table-column field="order_date" label="Order Date" + v-slot="props"> + <span v-html="props.row.order_date"></span> + </b-table-column> + <b-table-column field="status_code" label="Status" + v-slot="props"> + <span v-html="props.row.status_code"></span> + </b-table-column> + <!-- <b-table-column field="flagged" label="Flagged"> --> + <!-- <span v-html="props.row.flagged"></span> --> + <!-- </b-table-column> --> </b-table> <br /> diff --git a/tailbone/templates/datasync/configure.mako b/tailbone/templates/datasync/configure.mako index f65d69a5..6dc13e14 100644 --- a/tailbone/templates/datasync/configure.mako +++ b/tailbone/templates/datasync/configure.mako @@ -103,98 +103,66 @@ <b-table :data="filteredProfilesData" :row-class="(row, i) => row.enabled ? null : 'has-background-warning'"> - % if buefy_0_8: - <template slot-scope="props"> - % endif - <b-table-column field="key" - label="Watcher Key" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.key }} - </b-table-column> - <b-table-column field="watcher_spec" - label="Watcher Spec" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.watcher_spec }} - </b-table-column> - <b-table-column field="watcher_dbkey" - label="DB Key" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.watcher_dbkey }} - </b-table-column> - <b-table-column field="watcher_delay" - label="Loop Delay" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.watcher_delay }} sec - </b-table-column> - <b-table-column field="watcher_retry_attempts" - label="Attempts / Delay" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.watcher_retry_attempts }} / {{ props.row.watcher_retry_delay }} sec - </b-table-column> - <b-table-column field="watcher_default_runas" - label="Default Runas" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.watcher_default_runas }} - </b-table-column> - <b-table-column label="Consumers" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ consumerShortList(props.row) }} - </b-table-column> + <b-table-column field="key" + label="Watcher Key" + v-slot="props"> + {{ props.row.key }} + </b-table-column> + <b-table-column field="watcher_spec" + label="Watcher Spec" + v-slot="props"> + {{ props.row.watcher_spec }} + </b-table-column> + <b-table-column field="watcher_dbkey" + label="DB Key" + v-slot="props"> + {{ props.row.watcher_dbkey }} + </b-table-column> + <b-table-column field="watcher_delay" + label="Loop Delay" + v-slot="props"> + {{ props.row.watcher_delay }} sec + </b-table-column> + <b-table-column field="watcher_retry_attempts" + label="Attempts / Delay" + v-slot="props"> + {{ props.row.watcher_retry_attempts }} / {{ props.row.watcher_retry_delay }} sec + </b-table-column> + <b-table-column field="watcher_default_runas" + label="Default Runas" + v-slot="props"> + {{ props.row.watcher_default_runas }} + </b-table-column> + <b-table-column label="Consumers" + v-slot="props"> + {{ consumerShortList(props.row) }} + </b-table-column> ## <b-table-column field="notes" label="Notes"> ## TODO ## ## {{ props.row.notes }} ## </b-table-column> - <b-table-column field="enabled" - label="Enabled" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.enabled ? "Yes" : "No" }} - </b-table-column> - <b-table-column label="Actions" - % if not buefy_0_8: - v-slot="props" - % endif - v-if="useProfileSettings"> - <a href="#" - class="grid-action" - @click.prevent="editProfile(props.row)"> - <i class="fas fa-edit"></i> - Edit - </a> - - <a href="#" - class="grid-action has-text-danger" - @click.prevent="deleteProfile(props.row)"> - <i class="fas fa-trash"></i> - Delete - </a> - </b-table-column> - % if buefy_0_8: - </template> - % endif + <b-table-column field="enabled" + label="Enabled" + v-slot="props"> + {{ props.row.enabled ? "Yes" : "No" }} + </b-table-column> + <b-table-column label="Actions" + v-slot="props" + v-if="useProfileSettings"> + <a href="#" + class="grid-action" + @click.prevent="editProfile(props.row)"> + <i class="fas fa-edit"></i> + Edit + </a> + + <a href="#" + class="grid-action has-text-danger" + @click.prevent="deleteProfile(props.row)"> + <i class="fas fa-trash"></i> + Delete + </a> + </b-table-column> <template slot="empty"> <section class="section"> <div class="content has-text-grey has-text-centered"> @@ -327,46 +295,31 @@ <b-table :data="editingProfilePendingWatcherKwargs" style="margin-left: 1rem;"> - % if buefy_0_8: - <template slot-scope="props"> - % endif - <b-table-column field="key" - label="Key" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.key }} - </b-table-column> - <b-table-column field="value" - label="Value" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.value }} - </b-table-column> - <b-table-column label="Actions" - % if not buefy_0_8: - v-slot="props" - % endif - > - <a href="#" - @click.prevent="editProfileWatcherKwarg(props.row)"> - <i class="fas fa-edit"></i> - Edit - </a> - - <a href="#" - class="has-text-danger" - @click.prevent="deleteProfileWatcherKwarg(props.row)"> - <i class="fas fa-trash"></i> - Delete - </a> - </b-table-column> - % if buefy_0_8: - </template> - % endif + <b-table-column field="key" + label="Key" + v-slot="props"> + {{ props.row.key }} + </b-table-column> + <b-table-column field="value" + label="Value" + v-slot="props"> + {{ props.row.value }} + </b-table-column> + <b-table-column label="Actions" + v-slot="props"> + <a href="#" + @click.prevent="editProfileWatcherKwarg(props.row)"> + <i class="fas fa-edit"></i> + Edit + </a> + + <a href="#" + class="has-text-danger" + @click.prevent="deleteProfileWatcherKwarg(props.row)"> + <i class="fas fa-trash"></i> + Delete + </a> + </b-table-column> <template slot="empty"> <section class="section"> <div class="content has-text-grey has-text-centered"> @@ -400,46 +353,31 @@ <b-table :data="editingProfilePendingConsumers" v-if="!editingProfileWatcherConsumesSelf" :row-class="(row, i) => row.enabled ? null : 'has-background-warning'"> - % if buefy_0_8: - <template slot-scope="props"> - % endif - <b-table-column field="key" - label="Consumer" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.key }} - </b-table-column> - <b-table-column style="white-space: nowrap;" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.consumer_delay }} / {{ props.row.consumer_retry_attempts }} / {{ props.row.consumer_retry_delay }} - </b-table-column> - <b-table-column label="Actions" - % if not buefy_0_8: - v-slot="props" - % endif - > - <a href="#" - class="grid-action" - @click.prevent="editProfileConsumer(props.row)"> - <i class="fas fa-edit"></i> - Edit - </a> - - <a href="#" - class="grid-action has-text-danger" - @click.prevent="deleteProfileConsumer(props.row)"> - <i class="fas fa-trash"></i> - Delete - </a> - </b-table-column> - % if buefy_0_8: - </template> - % endif + <b-table-column field="key" + label="Consumer" + v-slot="props"> + {{ props.row.key }} + </b-table-column> + <b-table-column style="white-space: nowrap;" + v-slot="props"> + {{ props.row.consumer_delay }} / {{ props.row.consumer_retry_attempts }} / {{ props.row.consumer_retry_delay }} + </b-table-column> + <b-table-column label="Actions" + v-slot="props"> + <a href="#" + class="grid-action" + @click.prevent="editProfileConsumer(props.row)"> + <i class="fas fa-edit"></i> + Edit + </a> + + <a href="#" + class="grid-action has-text-danger" + @click.prevent="deleteProfileConsumer(props.row)"> + <i class="fas fa-trash"></i> + Delete + </a> + </b-table-column> <template slot="empty"> <section class="section"> <div class="content has-text-grey has-text-centered"> diff --git a/tailbone/templates/datasync/status.mako b/tailbone/templates/datasync/status.mako index 6b9e02a9..6df35bbb 100644 --- a/tailbone/templates/datasync/status.mako +++ b/tailbone/templates/datasync/status.mako @@ -49,123 +49,75 @@ <b-field label="Watcher Status"> <b-table :data="watchers"> - % if buefy_0_8: - <template slot-scope="props"> - % endif - <b-table-column field="key" - label="Watcher" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.key }} - </b-table-column> - <b-table-column field="spec" - label="Spec" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.spec }} - </b-table-column> - <b-table-column field="dbkey" - label="DB Key" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.dbkey }} - </b-table-column> - <b-table-column field="delay" - label="Delay" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.delay }} second(s) - </b-table-column> - <b-table-column field="lastrun" - label="Last Watched" - % if not buefy_0_8: - v-slot="props" - % endif - > - <span v-html="props.row.lastrun"></span> - </b-table-column> - <b-table-column field="status" - label="Status" - % if not buefy_0_8: - v-slot="props" - % endif - > - <span :class="props.row.status == 'okay' ? 'has-background-success' : 'has-background-warning'"> - {{ props.row.status }} - </span> - </b-table-column> - % if buefy_0_8: - </template> - % endif + <b-table-column field="key" + label="Watcher" + v-slot="props"> + {{ props.row.key }} + </b-table-column> + <b-table-column field="spec" + label="Spec" + v-slot="props"> + {{ props.row.spec }} + </b-table-column> + <b-table-column field="dbkey" + label="DB Key" + v-slot="props"> + {{ props.row.dbkey }} + </b-table-column> + <b-table-column field="delay" + label="Delay" + v-slot="props"> + {{ props.row.delay }} second(s) + </b-table-column> + <b-table-column field="lastrun" + label="Last Watched" + v-slot="props"> + <span v-html="props.row.lastrun"></span> + </b-table-column> + <b-table-column field="status" + label="Status" + v-slot="props"> + <span :class="props.row.status == 'okay' ? 'has-background-success' : 'has-background-warning'"> + {{ props.row.status }} + </span> + </b-table-column> </b-table> </b-field> <b-field label="Consumer Status"> <b-table :data="consumers"> - % if buefy_0_8: - <template slot-scope="props"> - % endif - <b-table-column field="key" - label="Consumer" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.key }} - </b-table-column> - <b-table-column field="spec" - label="Spec" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.spec }} - </b-table-column> - <b-table-column field="dbkey" - label="DB Key" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.dbkey }} - </b-table-column> - <b-table-column field="delay" - label="Delay" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.delay }} second(s) - </b-table-column> - <b-table-column field="changes" - label="Pending Changes" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.changes }} - </b-table-column> - <b-table-column field="status" - label="Status" - % if not buefy_0_8: - v-slot="props" - % endif - > - <span :class="props.row.status == 'okay' ? 'has-background-success' : 'has-background-warning'"> - {{ props.row.status }} - </span> - </b-table-column> - % if buefy_0_8: - </template> - % endif + <b-table-column field="key" + label="Consumer" + v-slot="props"> + {{ props.row.key }} + </b-table-column> + <b-table-column field="spec" + label="Spec" + v-slot="props"> + {{ props.row.spec }} + </b-table-column> + <b-table-column field="dbkey" + label="DB Key" + v-slot="props"> + {{ props.row.dbkey }} + </b-table-column> + <b-table-column field="delay" + label="Delay" + v-slot="props"> + {{ props.row.delay }} second(s) + </b-table-column> + <b-table-column field="changes" + label="Pending Changes" + v-slot="props"> + {{ props.row.changes }} + </b-table-column> + <b-table-column field="status" + label="Status" + v-slot="props"> + <span :class="props.row.status == 'okay' ? 'has-background-success' : 'has-background-warning'"> + {{ props.row.status }} + </span> + </b-table-column> </b-table> </b-field> </%def> diff --git a/tailbone/templates/generate_feature.mako b/tailbone/templates/generate_feature.mako index 10d3d265..18c9a7a2 100644 --- a/tailbone/templates/generate_feature.mako +++ b/tailbone/templates/generate_feature.mako @@ -108,70 +108,49 @@ <b-table :data="new_table.columns"> - % if buefy_0_8: - <template slot-scope="props"> - % endif - <b-table-column field="name" - label="Name" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.name }} - </b-table-column> + <b-table-column field="name" + label="Name" + v-slot="props"> + {{ props.row.name }} + </b-table-column> - <b-table-column field="data_type" - label="Data Type" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.data_type }} - </b-table-column> + <b-table-column field="data_type" + label="Data Type" + v-slot="props"> + {{ props.row.data_type }} + </b-table-column> - <b-table-column field="nullable" - label="Nullable" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.nullable }} - </b-table-column> + <b-table-column field="nullable" + label="Nullable" + v-slot="props"> + {{ props.row.nullable }} + </b-table-column> - <b-table-column field="description" - label="Description" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.description }} - </b-table-column> + <b-table-column field="description" + label="Description" + v-slot="props"> + {{ props.row.description }} + </b-table-column> - <b-table-column field="actions" - label="Actions" - % if not buefy_0_8: - v-slot="props" - % endif - > - <a href="#" class="grid-action" - @click.prevent="editColumnRow(props.row)"> - <i class="fas fa-edit"></i> - Edit - </a> - + <b-table-column field="actions" + label="Actions" + v-slot="props"> + <a href="#" class="grid-action" + @click.prevent="editColumnRow(props.row)"> + <i class="fas fa-edit"></i> + Edit + </a> + - <a href="#" class="grid-action has-text-danger" - @click.prevent="deleteColumn(props.index)"> - <i class="fas fa-trash"></i> - Delete - </a> - - </b-table-column> + <a href="#" class="grid-action has-text-danger" + @click.prevent="deleteColumn(props.index)"> + <i class="fas fa-trash"></i> + Delete + </a> + + </b-table-column> - % if buefy_0_8: - </template> - % endif </b-table> <b-modal has-modal-card diff --git a/tailbone/templates/grids/b-table.mako b/tailbone/templates/grids/b-table.mako index d8d81f6d..fbd36cbb 100644 --- a/tailbone/templates/grids/b-table.mako +++ b/tailbone/templates/grids/b-table.mako @@ -20,71 +20,60 @@ % endif > - % if buefy_0_8: - <template slot-scope="props"> - % endif - % for i, column in enumerate(grid_columns): - <b-table-column field="${column['field']}" - % if not empty_labels: - label="${column['label']}" - % elif i > 0: - label=" " - % endif - % if not buefy_0_8: - v-slot="props" - % endif - ${'sortable' if column['sortable'] else ''}> - % if empty_labels and i == 0: - <template slot="header" slot-scope="{ column }"></template> - % endif - % if grid.is_linked(column['field']): - <a :href="props.row._action_url_view" - v-html="props.row.${column['field']}" - % if view_click_handler: - @click.prevent="${view_click_handler}" - % endif - > + % for i, column in enumerate(grid_columns): + <b-table-column field="${column['field']}" + % if not empty_labels: + label="${column['label']}" + % elif i > 0: + label=" " + % endif + v-slot="props" + ${'sortable' if column['sortable'] else ''}> + % if empty_labels and i == 0: + <template slot="header" slot-scope="{ column }"></template> + % endif + % if grid.is_linked(column['field']): + <a :href="props.row._action_url_view" + v-html="props.row.${column['field']}" + % if view_click_handler: + @click.prevent="${view_click_handler}" + % endif + > + </a> + % elif grid.has_click_handler(column['field']): + <span> + <a href="#" + @click.prevent="${grid.click_handlers[column['field']]}" + v-html="props.row.${column['field']}"> </a> - % elif grid.has_click_handler(column['field']): - <span> - <a href="#" - @click.prevent="${grid.click_handlers[column['field']]}" - v-html="props.row.${column['field']}"> - </a> - </span> - % else: - <span v-html="props.row.${column['field']}"></span> - % endif - </b-table-column> - % endfor + </span> + % else: + <span v-html="props.row.${column['field']}"></span> + % endif + </b-table-column> + % endfor - % if grid.main_actions or grid.more_actions: - <b-table-column field="actions" - label="Actions" - % if not buefy_0_8: - v-slot="props" - % endif - > - % for action in grid.main_actions: - <a :href="props.row._action_url_${action.key}" - % if action.link_class: - class="${action.link_class}" - % else: - class="grid-action${' has-text-danger' if action.key == 'delete' else ''}" - % endif - % if action.click_handler: - @click.prevent="${action.click_handler}" - % endif - > - <i class="fas fa-${action.icon}"></i> - ${action.label} - </a> - - % endfor - </b-table-column> - % endif - % if buefy_0_8: - </template> + % if grid.main_actions or grid.more_actions: + <b-table-column field="actions" + label="Actions" + v-slot="props"> + % for action in grid.main_actions: + <a :href="props.row._action_url_${action.key}" + % if action.link_class: + class="${action.link_class}" + % else: + class="grid-action${' has-text-danger' if action.key == 'delete' else ''}" + % endif + % if action.click_handler: + @click.prevent="${action.click_handler}" + % endif + > + <i class="fas fa-${action.icon}"></i> + ${action.label} + </a> + + % endfor + </b-table-column> % endif <template slot="empty"> diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako index c99d0f70..98de939d 100644 --- a/tailbone/templates/grids/buefy.mako +++ b/tailbone/templates/grids/buefy.mako @@ -218,60 +218,49 @@ :hoverable="true" :narrowed="true"> - % if buefy_0_8: - <template slot-scope="props"> - % endif - % for column in grid_columns: - <b-table-column field="${column['field']}" - label="${column['label']}" - % if not buefy_0_8: - v-slot="props" - % endif - :sortable="${json.dumps(column['sortable'])}" - % if grid.is_searchable(column['field']): - searchable - % endif - cell-class="c_${column['field']}" - % if grid.has_click_handler(column['field']): - @click.native="${grid.click_handlers[column['field']]}" - % endif - :visible="${json.dumps(column['visible'])}"> - % if column['field'] in grid.raw_renderers: - ${grid.raw_renderers[column['field']]()} - % elif grid.is_linked(column['field']): - <a :href="props.row._action_url_view" v-html="props.row.${column['field']}"></a> - % else: - <span v-html="props.row.${column['field']}"></span> - % endif - </b-table-column> - % endfor + % for column in grid_columns: + <b-table-column field="${column['field']}" + label="${column['label']}" + v-slot="props" + :sortable="${json.dumps(column['sortable'])}" + % if grid.is_searchable(column['field']): + searchable + % endif + cell-class="c_${column['field']}" + % if grid.has_click_handler(column['field']): + @click.native="${grid.click_handlers[column['field']]}" + % endif + :visible="${json.dumps(column['visible'])}"> + % if column['field'] in grid.raw_renderers: + ${grid.raw_renderers[column['field']]()} + % elif grid.is_linked(column['field']): + <a :href="props.row._action_url_view" v-html="props.row.${column['field']}"></a> + % else: + <span v-html="props.row.${column['field']}"></span> + % endif + </b-table-column> + % endfor - % if grid.main_actions or grid.more_actions: - <b-table-column field="actions" - label="Actions" - % if not buefy_0_8: - v-slot="props" - % endif - > - ## TODO: we do not currently differentiate for "main vs. more" - ## here, but ideally we would tuck "more" away in a drawer etc. - % for action in grid.main_actions + grid.more_actions: - <a v-if="props.row._action_url_${action.key}" - :href="props.row._action_url_${action.key}" - class="grid-action${' has-text-danger' if action.key == 'delete' else ''} ${action.link_class or ''}" - % if action.click_handler: - @click.prevent="${action.click_handler}" - % endif - > - ${action.render_icon()|n} - ${action.render_label()|n} - </a> - - % endfor - </b-table-column> - % endif - % if buefy_0_8: - </template> + % if grid.main_actions or grid.more_actions: + <b-table-column field="actions" + label="Actions" + v-slot="props"> + ## TODO: we do not currently differentiate for "main vs. more" + ## here, but ideally we would tuck "more" away in a drawer etc. + % for action in grid.main_actions + grid.more_actions: + <a v-if="props.row._action_url_${action.key}" + :href="props.row._action_url_${action.key}" + class="grid-action${' has-text-danger' if action.key == 'delete' else ''} ${action.link_class or ''}" + % if action.click_handler: + @click.prevent="${action.click_handler}" + % endif + > + ${action.render_icon()|n} + ${action.render_label()|n} + </a> + + % endfor + </b-table-column> % endif <template #empty> diff --git a/tailbone/templates/importing/configure.mako b/tailbone/templates/importing/configure.mako index 398d4939..90f7cabd 100644 --- a/tailbone/templates/importing/configure.mako +++ b/tailbone/templates/importing/configure.mako @@ -10,85 +10,64 @@ narrowed icon-pack="fas" :default-sort="['host_title', 'asc']"> - % if buefy_0_8: - <template slot-scope="props"> - % endif - <b-table-column field="host_title" - label="Data Source" - % if not buefy_0_8: - v-slot="props" - % endif - sortable> - {{ props.row.host_title }} - </b-table-column> - <b-table-column field="local_title" - label="Data Target" - % if not buefy_0_8: - v-slot="props" - % endif - sortable> - {{ props.row.local_title }} - </b-table-column> - <b-table-column field="direction" - label="Direction" - % if not buefy_0_8: - v-slot="props" - % endif - sortable> - {{ props.row.direction_display }} - </b-table-column> - <b-table-column field="handler_spec" - label="Handler Spec" - % if not buefy_0_8: - v-slot="props" - % endif - sortable> - {{ props.row.handler_spec }} - </b-table-column> - <b-table-column field="cmd" - label="Command" - % if not buefy_0_8: - v-slot="props" - % endif - sortable> - {{ props.row.command }} {{ props.row.subcommand }} - </b-table-column> - <b-table-column field="runas" - label="Default Runas" - % if not buefy_0_8: - v-slot="props" - % endif - sortable> - {{ props.row.default_runas }} - </b-table-column> - <b-table-column label="Actions" - % if not buefy_0_8: - v-slot="props" - % endif - > - <a href="#" class="grid-action" - @click.prevent="editHandler(props.row)"> - <i class="fas fa-edit"></i> - Edit - </a> - </b-table-column> - % if buefy_0_8: - </template> - % endif - <template slot="empty"> - <section class="section"> - <div class="content has-text-grey has-text-centered"> - <p> - <b-icon - pack="fas" - icon="fas fa-sad-tear" - size="is-large"> - </b-icon> - </p> - <p>Nothing here.</p> - </div> - </section> - </template> + <b-table-column field="host_title" + label="Data Source" + v-slot="props" + sortable> + {{ props.row.host_title }} + </b-table-column> + <b-table-column field="local_title" + label="Data Target" + v-slot="props" + sortable> + {{ props.row.local_title }} + </b-table-column> + <b-table-column field="direction" + label="Direction" + v-slot="props" + sortable> + {{ props.row.direction_display }} + </b-table-column> + <b-table-column field="handler_spec" + label="Handler Spec" + v-slot="props" + sortable> + {{ props.row.handler_spec }} + </b-table-column> + <b-table-column field="cmd" + label="Command" + v-slot="props" + sortable> + {{ props.row.command }} {{ props.row.subcommand }} + </b-table-column> + <b-table-column field="runas" + label="Default Runas" + v-slot="props" + sortable> + {{ props.row.default_runas }} + </b-table-column> + <b-table-column label="Actions" + v-slot="props"> + <a href="#" class="grid-action" + @click.prevent="editHandler(props.row)"> + <i class="fas fa-edit"></i> + Edit + </a> + </b-table-column> + <template slot="empty"> + <section class="section"> + <div class="content has-text-grey has-text-centered"> + <p> + <b-icon + pack="fas" + icon="fas fa-sad-tear" + size="is-large"> + </b-icon> + </p> + <p>Nothing here.</p> + </div> + </section> + </template> </b-table> <b-modal :active.sync="editHandlerShowDialog"> diff --git a/tailbone/templates/luigi/configure.mako b/tailbone/templates/luigi/configure.mako index 05f2981e..c35e3216 100644 --- a/tailbone/templates/luigi/configure.mako +++ b/tailbone/templates/luigi/configure.mako @@ -23,67 +23,46 @@ <div class="block" style="padding-left: 2rem; display: flex;"> <b-table :data="overnightTasks"> - % if buefy_0_8: - <template slot-scope="props"> - % endif - <!-- <b-table-column field="key" --> - <!-- label="Key" --> - <!-- sortable> --> - <!-- {{ props.row.key }} --> - <!-- </b-table-column> --> - <b-table-column field="key" - label="Key" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.key }} - </b-table-column> - <b-table-column field="description" - label="Description" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.description }} - </b-table-column> - <b-table-column field="class_name" - label="Class Name" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.class_name }} - </b-table-column> - <b-table-column field="script" - label="Script" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.script }} - </b-table-column> - <b-table-column label="Actions" - % if not buefy_0_8: - v-slot="props" - % endif - > - <a href="#" - @click.prevent="overnightTaskEdit(props.row)"> - <i class="fas fa-edit"></i> - Edit - </a> - - <a href="#" - class="has-text-danger" - @click.prevent="overnightTaskDelete(props.row)"> - <i class="fas fa-trash"></i> - Delete - </a> - </b-table-column> - % if buefy_0_8: - </template> - % endif + <!-- <b-table-column field="key" --> + <!-- label="Key" --> + <!-- sortable> --> + <!-- {{ props.row.key }} --> + <!-- </b-table-column> --> + <b-table-column field="key" + label="Key" + v-slot="props"> + {{ props.row.key }} + </b-table-column> + <b-table-column field="description" + label="Description" + v-slot="props"> + {{ props.row.description }} + </b-table-column> + <b-table-column field="class_name" + label="Class Name" + v-slot="props"> + {{ props.row.class_name }} + </b-table-column> + <b-table-column field="script" + label="Script" + v-slot="props"> + {{ props.row.script }} + </b-table-column> + <b-table-column label="Actions" + v-slot="props"> + <a href="#" + @click.prevent="overnightTaskEdit(props.row)"> + <i class="fas fa-edit"></i> + Edit + </a> + + <a href="#" + class="has-text-danger" + @click.prevent="overnightTaskDelete(props.row)"> + <i class="fas fa-trash"></i> + Delete + </a> + </b-table-column> </b-table> <b-modal has-modal-card @@ -161,70 +140,46 @@ <div class="block" style="padding-left: 2rem; display: flex;"> <b-table :data="backfillTasks"> - % if buefy_0_8: - <template slot-scope="props"> - % endif - <b-table-column field="key" - label="Key" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.key }} - </b-table-column> - <b-table-column field="description" - label="Description" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.description }} - </b-table-column> - <b-table-column field="script" - label="Script" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.script }} - </b-table-column> - <b-table-column field="forward" - label="Orientation" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.forward ? "Forward" : "Backward" }} - </b-table-column> - <b-table-column field="target_date" - label="Target Date" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.target_date }} - </b-table-column> - <b-table-column label="Actions" - % if not buefy_0_8: - v-slot="props" - % endif - > - <a href="#" - @click.prevent="backfillTaskEdit(props.row)"> - <i class="fas fa-edit"></i> - Edit - </a> - - <a href="#" - class="has-text-danger" - @click.prevent="backfillTaskDelete(props.row)"> - <i class="fas fa-trash"></i> - Delete - </a> - </b-table-column> - % if buefy_0_8: - </template> - % endif + <b-table-column field="key" + label="Key" + v-slot="props"> + {{ props.row.key }} + </b-table-column> + <b-table-column field="description" + label="Description" + v-slot="props"> + {{ props.row.description }} + </b-table-column> + <b-table-column field="script" + label="Script" + v-slot="props"> + {{ props.row.script }} + </b-table-column> + <b-table-column field="forward" + label="Orientation" + v-slot="props"> + {{ props.row.forward ? "Forward" : "Backward" }} + </b-table-column> + <b-table-column field="target_date" + label="Target Date" + v-slot="props"> + {{ props.row.target_date }} + </b-table-column> + <b-table-column label="Actions" + v-slot="props"> + <a href="#" + @click.prevent="backfillTaskEdit(props.row)"> + <i class="fas fa-edit"></i> + Edit + </a> + + <a href="#" + class="has-text-danger" + @click.prevent="backfillTaskDelete(props.row)"> + <i class="fas fa-trash"></i> + Delete + </a> + </b-table-column> </b-table> <b-modal has-modal-card diff --git a/tailbone/templates/luigi/index.mako b/tailbone/templates/luigi/index.mako index 3e047f83..a64866df 100644 --- a/tailbone/templates/luigi/index.mako +++ b/tailbone/templates/luigi/index.mako @@ -54,99 +54,81 @@ <h3 class="block is-size-3">Overnight Tasks</h3> <b-table :data="overnightTasks" hoverable> - % if buefy_0_8: - <template slot-scope="props"> - % endif - <b-table-column field="description" - label="Description" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.description }} - </b-table-column> - <b-table-column field="script" - label="Command" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.script || props.row.class_name }} - </b-table-column> - <b-table-column field="last_date" - label="Last Date" - % if not buefy_0_8: - v-slot="props" - % endif - > - <span :class="overnightTextClass(props.row)"> - {{ props.row.last_date || "never!" }} - </span> - </b-table-column> - <b-table-column label="Actions" - % if not buefy_0_8: - v-slot="props" - % endif - > - <b-button type="is-primary" - icon-pack="fas" - icon-left="arrow-circle-right" - @click="overnightTaskLaunchInit(props.row)"> - Launch - </b-button> - <b-modal has-modal-card - :active.sync="overnightTaskShowLaunchDialog"> - <div class="modal-card"> + <b-table-column field="description" + label="Description" + v-slot="props"> + {{ props.row.description }} + </b-table-column> + <b-table-column field="script" + label="Command" + v-slot="props"> + {{ props.row.script || props.row.class_name }} + </b-table-column> + <b-table-column field="last_date" + label="Last Date" + v-slot="props"> + <span :class="overnightTextClass(props.row)"> + {{ props.row.last_date || "never!" }} + </span> + </b-table-column> + <b-table-column label="Actions" + v-slot="props"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="arrow-circle-right" + @click="overnightTaskLaunchInit(props.row)"> + Launch + </b-button> + <b-modal has-modal-card + :active.sync="overnightTaskShowLaunchDialog"> + <div class="modal-card"> - <header class="modal-card-head"> - <p class="modal-card-title">Launch Overnight Task</p> - </header> + <header class="modal-card-head"> + <p class="modal-card-title">Launch Overnight Task</p> + </header> - <section class="modal-card-body" - v-if="overnightTask"> + <section class="modal-card-body" + v-if="overnightTask"> - <b-field label="Task" horizontal> - <span>{{ overnightTask.description }}</span> - </b-field> + <b-field label="Task" horizontal> + <span>{{ overnightTask.description }}</span> + </b-field> - <b-field label="Last Date" horizontal> - <span :class="overnightTextClass(overnightTask)"> - {{ overnightTask.last_date || "n/a" }} - </span> - </b-field> + <b-field label="Last Date" horizontal> + <span :class="overnightTextClass(overnightTask)"> + {{ overnightTask.last_date || "n/a" }} + </span> + </b-field> - <b-field label="Next Date" horizontal> - <span> - ${rattail_app.render_date(rattail_app.yesterday())} (yesterday) - </span> - </b-field> + <b-field label="Next Date" horizontal> + <span> + ${rattail_app.render_date(rattail_app.yesterday())} (yesterday) + </span> + </b-field> - <p class="block"> - Launching this task will schedule it to begin - within one minute. See the Luigi Task - Visualizer after that, for current status. - </p> + <p class="block"> + Launching this task will schedule it to begin + within one minute. See the Luigi Task + Visualizer after that, for current status. + </p> - </section> + </section> - <footer class="modal-card-foot"> - <b-button @click="overnightTaskShowLaunchDialog = false"> - Cancel - </b-button> - <b-button type="is-primary" - icon-pack="fas" - icon-left="arrow-circle-right" - @click="overnightTaskLaunchSubmit()" - :disabled="overnightTaskLaunching"> - {{ overnightTaskLaunching ? "Working, please wait..." : "Launch" }} - </b-button> - </footer> - </div> - </b-modal> - </b-table-column> - % if buefy_0_8: - </template> - % endif + <footer class="modal-card-foot"> + <b-button @click="overnightTaskShowLaunchDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + icon-pack="fas" + icon-left="arrow-circle-right" + @click="overnightTaskLaunchSubmit()" + :disabled="overnightTaskLaunching"> + {{ overnightTaskLaunching ? "Working, please wait..." : "Launch" }} + </b-button> + </footer> + </div> + </b-modal> + </b-table-column> <template #empty> <p class="block">No tasks defined.</p> </template> @@ -159,66 +141,42 @@ <h3 class="block is-size-3">Backfill Tasks</h3> <b-table :data="backfillTasks" hoverable> - % if buefy_0_8: - <template slot-scope="props"> - % endif - <b-table-column field="description" - label="Description" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.description }} - </b-table-column> - <b-table-column field="script" - label="Script" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.script }} - </b-table-column> - <b-table-column field="forward" - label="Orientation" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.forward ? "Forward" : "Backward" }} - </b-table-column> - <b-table-column field="last_date" - label="Last Date" - % if not buefy_0_8: - v-slot="props" - % endif - > - <span :class="backfillTextClass(props.row)"> - {{ props.row.last_date }} - </span> - </b-table-column> - <b-table-column field="target_date" - label="Target Date" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.target_date }} - </b-table-column> - <b-table-column label="Actions" - % if not buefy_0_8: - v-slot="props" - % endif - > - <b-button type="is-primary" - icon-pack="fas" - icon-left="arrow-circle-right" - @click="backfillTaskLaunch(props.row)"> - Launch - </b-button> - </b-table-column> - % if buefy_0_8: - </template> - % endif + <b-table-column field="description" + label="Description" + v-slot="props"> + {{ props.row.description }} + </b-table-column> + <b-table-column field="script" + label="Script" + v-slot="props"> + {{ props.row.script }} + </b-table-column> + <b-table-column field="forward" + label="Orientation" + v-slot="props"> + {{ props.row.forward ? "Forward" : "Backward" }} + </b-table-column> + <b-table-column field="last_date" + label="Last Date" + v-slot="props"> + <span :class="backfillTextClass(props.row)"> + {{ props.row.last_date }} + </span> + </b-table-column> + <b-table-column field="target_date" + label="Target Date" + v-slot="props"> + {{ props.row.target_date }} + </b-table-column> + <b-table-column label="Actions" + v-slot="props"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="arrow-circle-right" + @click="backfillTaskLaunch(props.row)"> + Launch + </b-button> + </b-table-column> <template #empty> <p class="block">No tasks defined.</p> </template> diff --git a/tailbone/templates/people/index.mako b/tailbone/templates/people/index.mako index 8ddc3f52..977ca1b7 100644 --- a/tailbone/templates/people/index.mako +++ b/tailbone/templates/people/index.mako @@ -22,36 +22,21 @@ <section class="modal-card-body"> <b-table :data="mergeRequestRows" striped hoverable> - % if buefy_0_8: - <template slot-scope="props"> - % endif - <b-table-column field="customer_number" - label="Customer #" - % if not buefy_0_8: - v-slot="props" - % endif - > - <span v-html="props.row.customer_number"></span> - </b-table-column> - <b-table-column field="first_name" - label="First Name" - % if not buefy_0_8: - v-slot="props" - % endif - > - <span v-html="props.row.first_name"></span> - </b-table-column> - <b-table-column field="last_name" - label="Last Name" - % if not buefy_0_8: - v-slot="props" - % endif - > - <span v-html="props.row.last_name"></span> - </b-table-column> - % if buefy_0_8: - </template> - % endif + <b-table-column field="customer_number" + label="Customer #" + v-slot="props"> + <span v-html="props.row.customer_number"></span> + </b-table-column> + <b-table-column field="first_name" + label="First Name" + v-slot="props"> + <span v-html="props.row.first_name"></span> + </b-table-column> + <b-table-column field="last_name" + label="Last Name" + v-slot="props"> + <span v-html="props.row.last_name"></span> + </b-table-column> </b-table> </section> diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako index 584597b4..6937f592 100644 --- a/tailbone/templates/people/view_profile_buefy.mako +++ b/tailbone/templates/people/view_profile_buefy.mako @@ -296,63 +296,45 @@ % endif <b-table :data="person.phones"> - % if buefy_0_8: - <template slot-scope="props"> + + <b-table-column field="preference" + label="Preferred" + v-slot="props"> + {{ props.row.preferred ? "Yes" : "" }} + </b-table-column> + + <b-table-column field="type" + label="Type" + v-slot="props"> + {{ props.row.type }} + </b-table-column> + + <b-table-column field="number" + label="Number" + v-slot="props"> + {{ props.row.number }} + </b-table-column> + + % if request.has_perm('people_profile.edit_person'): + <b-table-column label="Actions" + v-slot="props"> + <a href="#" @click.prevent="editPhoneInit(props.row)"> + <i class="fas fa-edit"></i> + Edit + </a> + <a href="#" @click.prevent="deletePhone(props.row)" + class="has-text-danger"> + <i class="fas fa-trash"></i> + Delete + </a> + <a href="#" @click.prevent="setPreferredPhone(props.row)" + v-if="!props.row.preferred"> + <i class="fas fa-star"></i> + Set Preferred + </a> + </b-table-column> % endif - <b-table-column field="preference" - label="Preferred" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.preferred ? "Yes" : "" }} - </b-table-column> - - <b-table-column field="type" - label="Type" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.type }} - </b-table-column> - - <b-table-column field="number" - label="Number" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.number }} - </b-table-column> - - % if request.has_perm('people_profile.edit_person'): - <b-table-column label="Actions" - % if not buefy_0_8: - v-slot="props" - % endif - > - <a href="#" @click.prevent="editPhoneInit(props.row)"> - <i class="fas fa-edit"></i> - Edit - </a> - <a href="#" @click.prevent="deletePhone(props.row)" - class="has-text-danger"> - <i class="fas fa-trash"></i> - Delete - </a> - <a href="#" @click.prevent="setPreferredPhone(props.row)" - v-if="!props.row.preferred"> - <i class="fas fa-star"></i> - Set Preferred - </a> - </b-table-column> - % endif - - % if buefy_0_8: - </template> - % endif </b-table> </div> @@ -440,72 +422,51 @@ % endif <b-table :data="person.emails"> - % if buefy_0_8: - <template slot-scope="props"> + + <b-table-column field="preference" + label="Preferred" + v-slot="props"> + {{ props.row.preferred ? "Yes" : "" }} + </b-table-column> + + <b-table-column field="type" + label="Type" + v-slot="props"> + {{ props.row.type }} + </b-table-column> + + <b-table-column field="address" + label="Address" + v-slot="props"> + {{ props.row.address }} + </b-table-column> + + <b-table-column field="invalid" + label="Invalid?" + v-slot="props"> + <span v-if="props.row.invalid" class="has-text-danger has-text-weight-bold">Invalid</span> + </b-table-column> + + % if request.has_perm('people_profile.edit_person'): + <b-table-column label="Actions" + v-slot="props"> + <a href="#" @click.prevent="editEmailInit(props.row)"> + <i class="fas fa-edit"></i> + Edit + </a> + <a href="#" @click.prevent="deleteEmail(props.row)" + class="has-text-danger"> + <i class="fas fa-trash"></i> + Delete + </a> + <a href="#" @click.prevent="setPreferredEmail(props.row)" + v-if="!props.row.preferred"> + <i class="fas fa-star"></i> + Set Preferred + </a> + </b-table-column> % endif - <b-table-column field="preference" - label="Preferred" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.preferred ? "Yes" : "" }} - </b-table-column> - - <b-table-column field="type" - label="Type" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.type }} - </b-table-column> - - <b-table-column field="address" - label="Address" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.address }} - </b-table-column> - - <b-table-column field="invalid" - label="Invalid?" - % if not buefy_0_8: - v-slot="props" - % endif - > - <span v-if="props.row.invalid" class="has-text-danger has-text-weight-bold">Invalid</span> - </b-table-column> - - % if request.has_perm('people_profile.edit_person'): - <b-table-column label="Actions" - % if not buefy_0_8: - v-slot="props" - % endif - > - <a href="#" @click.prevent="editEmailInit(props.row)"> - <i class="fas fa-edit"></i> - Edit - </a> - <a href="#" @click.prevent="deleteEmail(props.row)" - class="has-text-danger"> - <i class="fas fa-trash"></i> - Delete - </a> - <a href="#" @click.prevent="setPreferredEmail(props.row)" - v-if="!props.row.preferred"> - <i class="fas fa-star"></i> - Set Preferred - </a> - </b-table-column> - % endif - - % if buefy_0_8: - </template> - % endif </b-table> </div> @@ -810,45 +771,30 @@ <br /> <b-table :data="employeeHistory"> - % if buefy_0_8: - <template slot-scope="props"> + + <b-table-column field="start_date" + label="Start Date" + v-slot="props"> + {{ props.row.start_date }} + </b-table-column> + + <b-table-column field="end_date" + label="End Date" + v-slot="props"> + {{ props.row.end_date }} + </b-table-column> + + % if request.has_perm('people_profile.edit_employee_history'): + <b-table-column field="actions" + label="Actions" + v-slot="props"> + <a href="#" @click.prevent="editEmployeeHistory(props.row)"> + <i class="fas fa-edit"></i> + Edit + </a> + </b-table-column> % endif - <b-table-column field="start_date" - label="Start Date" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.start_date }} - </b-table-column> - - <b-table-column field="end_date" - label="End Date" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.end_date }} - </b-table-column> - - % if request.has_perm('people_profile.edit_employee_history'): - <b-table-column field="actions" - label="Actions" - % if not buefy_0_8: - v-slot="props" - % endif - > - <a href="#" @click.prevent="editEmployeeHistory(props.row)"> - <i class="fas fa-edit"></i> - Edit - </a> - </b-table-column> - % endif - - % if buefy_0_8: - </template> - % endif </b-table> </div> @@ -1653,11 +1599,7 @@ <script type="text/javascript"> let ProfileInfoData = { - % if buefy_0_8: - activeTab: location.hash ? parseInt(location.hash.substring(1)) : undefined, - % else: activeTab: location.hash ? location.hash.substring(1) : undefined, - % endif person: ${json.dumps(person_data)|n}, customers: ${json.dumps(customers_data)|n}, member: null, // TODO @@ -1692,11 +1634,7 @@ }, activeTabChanged(value) { - % if buefy_0_8: - location.hash = value.toString() - % else: location.hash = value - % endif this.activeTabChangedExtra(value) }, diff --git a/tailbone/templates/products/lookup.mako b/tailbone/templates/products/lookup.mako index 299d938d..cdc4c565 100644 --- a/tailbone/templates/products/lookup.mako +++ b/tailbone/templates/products/lookup.mako @@ -52,103 +52,70 @@ icon-pack="fas" :loading="searchResultsLoading" :selected.sync="searchResultSelected"> - % if buefy_0_8: - <template slot-scope="props"> - % endif - <b-table-column label="${request.rattail_config.product_key_title()}" - field="product_key" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.product_key }} - </b-table-column> + <b-table-column label="${request.rattail_config.product_key_title()}" + field="product_key" + v-slot="props"> + {{ props.row.product_key }} + </b-table-column> - <b-table-column label="Brand" - field="brand_name" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.brand_name }} - </b-table-column> + <b-table-column label="Brand" + field="brand_name" + v-slot="props"> + {{ props.row.brand_name }} + </b-table-column> - <b-table-column label="Description" - field="description" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.description }} - {{ props.row.size }} - </b-table-column> + <b-table-column label="Description" + field="description" + v-slot="props"> + {{ props.row.description }} + {{ props.row.size }} + </b-table-column> - <b-table-column label="Unit Price" - field="unit_price" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.unit_price_display }} - </b-table-column> + <b-table-column label="Unit Price" + field="unit_price" + v-slot="props"> + {{ props.row.unit_price_display }} + </b-table-column> - <b-table-column label="Sale Price" - field="sale_price" - % if not buefy_0_8: - v-slot="props" - % endif - > - <span class="has-background-warning"> - {{ props.row.sale_price_display }} - </span> - </b-table-column> + <b-table-column label="Sale Price" + field="sale_price" + v-slot="props"> + <span class="has-background-warning"> + {{ props.row.sale_price_display }} + </span> + </b-table-column> - <b-table-column label="Sale Ends" - field="sale_ends" - % if not buefy_0_8: - v-slot="props" - % endif - > - <span class="has-background-warning"> - {{ props.row.sale_ends_display }} - </span> - </b-table-column> + <b-table-column label="Sale Ends" + field="sale_ends" + v-slot="props"> + <span class="has-background-warning"> + {{ props.row.sale_ends_display }} + </span> + </b-table-column> - <b-table-column label="Department" - field="department_name" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.department_name }} - </b-table-column> + <b-table-column label="Department" + field="department_name" + v-slot="props"> + {{ props.row.department_name }} + </b-table-column> - <b-table-column label="Vendor" - field="vendor_name" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.vendor_name }} - </b-table-column> + <b-table-column label="Vendor" + field="vendor_name" + v-slot="props"> + {{ props.row.vendor_name }} + </b-table-column> - <b-table-column label="Actions" - % if not buefy_0_8: - v-slot="props" - % endif - > - <a :href="props.row.url" - target="_blank" - class="grid-action"> - <i class="fas fa-external-link-alt"></i> - View - </a> - </b-table-column> + <b-table-column label="Actions" + v-slot="props"> + <a :href="props.row.url" + target="_blank" + class="grid-action"> + <i class="fas fa-external-link-alt"></i> + View + </a> + </b-table-column> - % if buefy_0_8: - </template> - % endif <template slot="empty"> <div class="content has-text-grey has-text-centered"> <p> diff --git a/tailbone/templates/tables/create.mako b/tailbone/templates/tables/create.mako index 58bcba18..3ebad9d1 100644 --- a/tailbone/templates/tables/create.mako +++ b/tailbone/templates/tables/create.mako @@ -99,83 +99,59 @@ <b-table :data="tableColumns"> - % if buefy_0_8: - <template slot-scope="props"> - % endif - <b-table-column field="name" - label="Name" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.name }} - </b-table-column> + <b-table-column field="name" + label="Name" + v-slot="props"> + {{ props.row.name }} + </b-table-column> - <b-table-column field="data_type" - label="Data Type" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ formatDataType(props.row.data_type) }} - </b-table-column> + <b-table-column field="data_type" + label="Data Type" + v-slot="props"> + {{ formatDataType(props.row.data_type) }} + </b-table-column> - <b-table-column field="nullable" - label="Nullable" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.nullable ? "Yes" : "No" }} - </b-table-column> + <b-table-column field="nullable" + label="Nullable" + v-slot="props"> + {{ props.row.nullable ? "Yes" : "No" }} + </b-table-column> - <b-table-column field="versioned" - label="Versioned" - :visible="tableVersioned" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.versioned ? "Yes" : "No" }} - </b-table-column> + <b-table-column field="versioned" + label="Versioned" + :visible="tableVersioned" + v-slot="props"> + {{ props.row.versioned ? "Yes" : "No" }} + </b-table-column> - <b-table-column field="description" - label="Description" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.description }} - </b-table-column> + <b-table-column field="description" + label="Description" + v-slot="props"> + {{ props.row.description }} + </b-table-column> - <b-table-column field="actions" - label="Actions" - % if not buefy_0_8: - v-slot="props" - % endif - > - <a v-if="props.row.name != 'uuid'" - href="#" - @click.prevent="tableEditColumn(props.row)"> - <i class="fas fa-edit"></i> - Edit - </a> - + <b-table-column field="actions" + label="Actions" + v-slot="props"> + <a v-if="props.row.name != 'uuid'" + href="#" + @click.prevent="tableEditColumn(props.row)"> + <i class="fas fa-edit"></i> + Edit + </a> + - <a v-if="props.row.name != 'uuid'" - href="#" - class="has-text-danger" - @click.prevent="tableDeleteColumn(props.index)"> - <i class="fas fa-trash"></i> - Delete - </a> - - </b-table-column> + <a v-if="props.row.name != 'uuid'" + href="#" + class="has-text-danger" + @click.prevent="tableDeleteColumn(props.index)"> + <i class="fas fa-trash"></i> + Delete + </a> + + </b-table-column> - % if buefy_0_8: - </template> - % endif </b-table> <b-modal has-modal-card diff --git a/tailbone/templates/trainwreck/transactions/rollover.mako b/tailbone/templates/trainwreck/transactions/rollover.mako index f654a40d..8e27d087 100644 --- a/tailbone/templates/trainwreck/transactions/rollover.mako +++ b/tailbone/templates/trainwreck/transactions/rollover.mako @@ -20,46 +20,31 @@ </p> <b-table :data="engines"> - % if buefy_0_8: - <template slot-scope="props"> - % endif - <b-table-column field="key" - label="DB Key" - % if not buefy_0_8: - v-slot="props" - % endif - > - {{ props.row.key }} - </b-table-column> - <b-table-column field="oldest_date" - label="Oldest Date" - % if not buefy_0_8: - v-slot="props" - % endif - > - <span v-if="props.row.error" class="has-text-danger"> - error - </span> - <span v-if="!props.row.error"> - {{ props.row.oldest_date }} - </span> - </b-table-column> - <b-table-column field="newest_date" - label="Newest Date" - % if not buefy_0_8: - v-slot="props" - % endif - > - <span v-if="props.row.error" class="has-text-danger"> - error - </span> - <span v-if="!props.row.error"> - {{ props.row.newest_date }} - </span> - </b-table-column> - % if buefy_0_8: - </template> - % endif + <b-table-column field="key" + label="DB Key" + v-slot="props"> + {{ props.row.key }} + </b-table-column> + <b-table-column field="oldest_date" + label="Oldest Date" + v-slot="props"> + <span v-if="props.row.error" class="has-text-danger"> + error + </span> + <span v-if="!props.row.error"> + {{ props.row.oldest_date }} + </span> + </b-table-column> + <b-table-column field="newest_date" + label="Newest Date" + v-slot="props"> + <span v-if="props.row.error" class="has-text-danger"> + error + </span> + <span v-if="!props.row.error"> + {{ props.row.newest_date }} + </span> + </b-table-column> </b-table> </%def> diff --git a/tailbone/templates/upgrades/configure.mako b/tailbone/templates/upgrades/configure.mako index 5d516cc8..4172c2b1 100644 --- a/tailbone/templates/upgrades/configure.mako +++ b/tailbone/templates/upgrades/configure.mako @@ -9,55 +9,40 @@ <b-table :data="upgradeSystems" sortable> - % if buefy_0_8: - <template slot-scope="props"> - % endif - <b-table-column field="key" - label="Key" - % if not buefy_0_8: - v-slot="props" - % endif - sortable> - {{ props.row.key }} - </b-table-column> - <b-table-column field="label" - label="Label" - % if not buefy_0_8: - v-slot="props" - % endif - sortable> - {{ props.row.label }} - </b-table-column> - <b-table-column field="command" - label="Command" - % if not buefy_0_8: - v-slot="props" - % endif - sortable> - {{ props.row.command }} - </b-table-column> - <b-table-column label="Actions" - % if not buefy_0_8: - v-slot="props" - % endif - > - <a href="#" - @click.prevent="upgradeSystemEdit(props.row)"> - <i class="fas fa-edit"></i> - Edit - </a> - - <a href="#" - v-if="props.row.key != 'rattail'" - class="has-text-danger" - @click.prevent="updateSystemDelete(props.row)"> - <i class="fas fa-trash"></i> - Delete - </a> - </b-table-column> - % if buefy_0_8: - </template> - % endif + <b-table-column field="key" + label="Key" + v-slot="props" + sortable> + {{ props.row.key }} + </b-table-column> + <b-table-column field="label" + label="Label" + v-slot="props" + sortable> + {{ props.row.label }} + </b-table-column> + <b-table-column field="command" + label="Command" + v-slot="props" + sortable> + {{ props.row.command }} + </b-table-column> + <b-table-column label="Actions" + v-slot="props"> + <a href="#" + @click.prevent="upgradeSystemEdit(props.row)"> + <i class="fas fa-edit"></i> + Edit + </a> + + <a href="#" + v-if="props.row.key != 'rattail'" + class="has-text-danger" + @click.prevent="updateSystemDelete(props.row)"> + <i class="fas fa-trash"></i> + Delete + </a> + </b-table-column> </b-table> <div style="margin-left: 1rem;"> From 01182ef752c18dc861974568aadc3c77e0ac0da3 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 1 Feb 2023 23:09:33 -0600 Subject: [PATCH 0997/1681] Add progress bar page for Buefy theme --- .../templates/themes/falafel/progress.mako | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 tailbone/templates/themes/falafel/progress.mako diff --git a/tailbone/templates/themes/falafel/progress.mako b/tailbone/templates/themes/falafel/progress.mako new file mode 100644 index 00000000..f1973e81 --- /dev/null +++ b/tailbone/templates/themes/falafel/progress.mako @@ -0,0 +1,190 @@ +## -*- coding: utf-8; -*- +<%namespace file="/base.mako" import="core_javascript" /> +<%namespace file="/base.mako" import="core_styles" /> +<!DOCTYPE html> +<html lang="en"> + <head> + <meta http-equiv="content-type" content="text/html; charset=UTF-8" /> + <title>${initial_msg or "Working"}...</title> + ${core_javascript()} + ${core_styles()} + </head> + <body style="height: 100%;"> + + <div id="whole-page-app"> + <whole-page></whole-page> + </div> + + <script type="text/x-template" id="whole-page-template"> + + <section class="hero is-fullheight"> + <div class="hero-body"> + <div class="container"> + + <div style="display: flex;"> + <div style="flex-grow: 1;"></div> + <div> + + <p class="block"> + {{ progressMessage }} ... {{ totalDisplay }} + </p> + + <div class="level"> + + <div class="level-item"> + <b-progress size="is-large" + style="width: 400px;" + :max="progressMax" + :value="progressValue" + show-value + format="percent" + precision="0"> + </b-progress> + </div> + + % if can_cancel: + <div class="level-item" + style="margin-left: 2rem;"> + <b-button v-show="canCancel" + @click="cancelProgress()" + :disabled="cancelingProgress" + icon-pack="fas" + icon-left="ban"> + {{ cancelingProgress ? "Canceling, please wait..." : "Cancel" }} + </b-button> + </div> + % endif + + </div> + + </div> + <div style="flex-grow: 1;"></div> + </div> + + </div> + </div> + </section> + + </script> + + <script type="text/javascript"> + + let WholePage = { + template: '#whole-page-template', + + data() { + return { + progressURL: '${url('progress', key=progress.key, _query={'sessiontype': progress.session.type})}', + progressMessage: "${(initial_msg or "Working").replace('"', '\\"')} (please wait)", + progressMax: null, + progressMaxDisplay: null, + progressValue: null, + stillInProgress: true, + + % if can_cancel: + canCancel: true, + cancelURL: '${url('progress.cancel', key=progress.key, _query={'sessiontype': progress.session.type})}', + cancelingProgress: false, + % endif + } + }, + + computed: { + + totalDisplay() { + + % if can_cancel: + if (!this.stillInProgress && !this.cancelingProgress) { + % else: + if (!this.stillInProgress) { + % endif + return "done!" + } + + if (this.progressMaxDisplay) { + return `(${'$'}{this.progressMaxDisplay} total)` + } + }, + }, + + mounted() { + + // fetch first progress data, one second from now + setTimeout(() => { + this.updateProgress() + }, 1000) + }, + + methods: { + + updateProgress() { + + this.$http.get(this.progressURL).then(response => { + + if (response.data.error) { + // errors stop the show, we redirect to "cancel" page + location.href = '${cancel_url}' + + } else { + + if (response.data.complete || response.data.maximum) { + this.progressMessage = response.data.message + this.progressMaxDisplay = response.data.maximum_display + + if (response.data.complete) { + this.progressValue = this.progressMax + this.stillInProgress = false + % if can_cancel: + this.canCancel = false + % endif + + location.href = response.data.success_url + + } else { + this.progressValue = response.data.value + this.progressMax = response.data.maximum + } + } + + if (this.stillInProgress) { + + // fetch progress data again, in one second from now + setTimeout(() => { + this.updateProgress() + }, 1000) + } + } + }) + }, + + % if can_cancel: + + cancelProgress() { + + if (confirm("Do you really wish to cancel this operation?")) { + + this.cancelingProgress = true + this.stillInProgress = false + + let params = {cancel_msg: ${json.dumps(cancel_msg)|n}} + this.$http.get(this.cancelURL, {params: params}).then(response => { + location.href = ${json.dumps(cancel_url)|n} + }) + } + + }, + + % endif + } + } + + Vue.component('whole-page', WholePage) + + new Vue({ + el: '#whole-page-app' + }) + + </script> + + </body> +</html> From f7f8f8dabf8a51a0538a3f21ec5354e1ce482abe Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 2 Feb 2023 16:51:12 -0600 Subject: [PATCH 0998/1681] 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 86be543a..cc5c680a 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.290 (2023-02-02) +-------------------- + +* Remove support for Buefy 0.8. + +* Add progress bar page for Buefy theme. + + 0.8.289 (2023-01-30) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 00ea9c62..33abc637 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.289' +__version__ = '0.8.290' From 9b67010f2c6800a2539b7787761eb348c2e94efd Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 2 Feb 2023 19:26:47 -0600 Subject: [PATCH 0999/1681] Fix checkbox behavior for Inventory Worksheet --- tailbone/templates/reports/inventory.mako | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/reports/inventory.mako b/tailbone/templates/reports/inventory.mako index e3033c4c..230f6028 100644 --- a/tailbone/templates/reports/inventory.mako +++ b/tailbone/templates/reports/inventory.mako @@ -24,13 +24,14 @@ </b-field> <b-field> - <b-checkbox name="weighted-only"> + <b-checkbox name="weighted-only" native-value="1"> Only include items which are sold by weight. </b-checkbox> </b-field> <b-field> - <b-checkbox name="exclude-not-for-sale" :value="true"> + <b-checkbox name="exclude-not-for-sale" :value="true" + native-value="1"> Exclude items marked "not for sale". </b-checkbox> </b-field> From 506de0383fae0814cb9b2425ee20092b2a036d30 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 2 Feb 2023 20:21:19 -0600 Subject: [PATCH 1000/1681] Form constructor assumes `use_buefy=True` by default until we get rid of it altogether --- tailbone/forms/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 4f227838..1f04b07e 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -338,7 +338,7 @@ class Form(object): model_instance=None, model_class=None, appstruct=UNSPECIFIED, nodes={}, enums={}, labels={}, assume_local_times=False, renderers=None, renderer_kwargs={}, hidden={}, widgets={}, defaults={}, validators={}, required={}, helptext={}, focus_spec=None, - action_url=None, cancel_url=None, use_buefy=None, component='tailbone-form', + action_url=None, cancel_url=None, use_buefy=True, component='tailbone-form', vuejs_field_converters={}, # TODO: ugh this is getting out hand! can_edit_help=False, edit_help_url=None, route_prefix=None, From 36a902398acacf9731391b0688209795bac93e33 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 2 Feb 2023 20:24:19 -0600 Subject: [PATCH 1001/1681] 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 cc5c680a..233b373f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.291 (2023-02-02) +-------------------- + +* Fix checkbox behavior for Inventory Worksheet. + +* Form constructor assumes ``use_buefy=True`` by default. + + 0.8.290 (2023-02-02) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 33abc637..0261e0f8 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.290' +__version__ = '0.8.291' From 265c7ad76fdba1abebd50ff71ae68b75d554ec80 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 2 Feb 2023 21:17:30 -0600 Subject: [PATCH 1002/1681] Always assume `use_buefy=True` within main page template so can start removing from context for various views --- tailbone/templates/page.mako | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tailbone/templates/page.mako b/tailbone/templates/page.mako index c1e07db3..94147a04 100644 --- a/tailbone/templates/page.mako +++ b/tailbone/templates/page.mako @@ -72,9 +72,5 @@ </%def> -% if use_buefy: - ${self.render_this_page_template()} - ${self.make_this_page_component()} -% else: - ${self.render_this_page()} -% endif +${self.render_this_page_template()} +${self.make_this_page_component()} From 94a0a57cfeef55a432baf6fc66c0c3ef6205abcf Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 2 Feb 2023 22:45:58 -0600 Subject: [PATCH 1003/1681] 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 233b373f..6068b107 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.292 (2023-02-02) +-------------------- + +* Always assume ``use_buefy=True`` within main page template. + + 0.8.291 (2023-02-02) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 0261e0f8..b0708fd4 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.291' +__version__ = '0.8.292' From 01e5eee981b16b01c372e4e246b3371f9b630d6f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 2 Feb 2023 23:21:45 -0600 Subject: [PATCH 1004/1681] Officially drop support for python2 --- setup.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/setup.py b/setup.py index 70f7cd56..d4af2ce7 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,6 @@ Setup script for Tailbone """ -from __future__ import unicode_literals, absolute_import - import os.path from setuptools import setup, find_packages @@ -155,9 +153,7 @@ setup( 'Natural Language :: English', 'Operating System :: OS Independent', 'Programming Language :: Python', - 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.5', 'Topic :: Internet :: WWW/HTTP', 'Topic :: Office/Business', 'Topic :: Software Development :: Libraries :: Python Modules', From 9faaea881d9b06af9c6b76837f96e6e3dee863dd Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 3 Feb 2023 12:05:17 -0600 Subject: [PATCH 1005/1681] Remove all deprecated `use_buefy` logic also remove some static files no longer used, etc. --- tailbone/forms/core.py | 38 +- tailbone/static/css/jquery.tagit.css | 69 -- tailbone/static/css/login.css | 48 -- tailbone/static/js/lib/tag-it.min.js | 17 - tailbone/static/js/login.js | 32 - tailbone/static/js/tailbone.appsettings.js | 29 - tailbone/static/js/tailbone.batch.js | 41 - tailbone/subscribers.py | 31 +- tailbone/templates/appsettings.mako | 102 +-- .../templates/batch/importer/view_row.mako | 7 - tailbone/templates/batch/index.mako | 197 +---- .../batch/inventory/desktop_form.mako | 646 ++++---------- .../templates/batch/vendorcatalog/create.mako | 53 -- tailbone/templates/batch/view.mako | 301 ++----- tailbone/templates/batch/worksheet.mako | 20 - tailbone/templates/custorders/create.mako | 18 +- .../templates/datasync/changes/index.mako | 16 - .../templates/deform/autocomplete_jquery.pt | 103 +-- tailbone/templates/deform/cases_units.pt | 31 +- tailbone/templates/deform/checkbox.pt | 15 +- tailbone/templates/deform/checked_password.pt | 32 +- tailbone/templates/deform/date_jquery.pt | 34 +- tailbone/templates/deform/file_upload.pt | 24 +- tailbone/templates/deform/password.pt | 16 +- tailbone/templates/deform/percentinput.pt | 21 +- tailbone/templates/deform/permissions.pt | 37 +- tailbone/templates/deform/select.pt | 50 +- tailbone/templates/deform/textarea.pt | 16 +- tailbone/templates/deform/textinput.pt | 21 +- tailbone/templates/deform/time_jquery.pt | 26 +- tailbone/templates/departments/view.mako | 14 - tailbone/templates/email-bounces/view.mako | 127 +-- tailbone/templates/form.mako | 8 +- tailbone/templates/forms/form.mako | 8 +- tailbone/templates/login.mako | 63 +- tailbone/templates/master/clone.mako | 46 +- tailbone/templates/master/delete.mako | 62 +- tailbone/templates/master/edit.mako | 33 - tailbone/templates/master/form.mako | 25 - tailbone/templates/master/index.mako | 615 +++++--------- tailbone/templates/master/merge.mako | 131 +-- tailbone/templates/master/versions.mako | 8 +- tailbone/templates/master/view.mako | 138 +-- tailbone/templates/master/view_row.mako | 3 - .../templates/messages/archive/index.mako | 9 - tailbone/templates/messages/create.mako | 134 +-- tailbone/templates/messages/inbox/index.mako | 9 - tailbone/templates/messages/index.mako | 75 +- tailbone/templates/messages/view.mako | 142 +--- tailbone/templates/ordering/create.mako | 78 +- tailbone/templates/ordering/worksheet.mako | 145 +--- tailbone/templates/people/index.mako | 2 - .../templates/people/merge-requests/view.mako | 2 - tailbone/templates/people/view.mako | 25 +- .../templates/principal/find_by_perm.mako | 73 +- tailbone/templates/products/batch.mako | 118 +-- tailbone/templates/products/index.mako | 141 +--- tailbone/templates/products/view.mako | 371 ++------ .../purchases/batches/receive_form.mako | 468 ---------- tailbone/templates/receiving/create.mako | 76 +- .../templates/receiving/declare_credit.mako | 54 +- tailbone/templates/receiving/receive_row.mako | 54 +- tailbone/templates/receiving/view.mako | 413 ++------- tailbone/templates/receiving/view_row.mako | 797 +++++++++--------- .../templates/reports/generated/choose.mako | 53 +- .../templates/reports/generated/generate.mako | 6 - tailbone/templates/reports/inventory.mako | 104 +-- tailbone/templates/roles/view.mako | 18 - tailbone/templates/settings/email/view.mako | 35 - tailbone/templates/tempmon/clients/view.mako | 26 +- tailbone/templates/tempmon/dashboard.mako | 188 +---- tailbone/templates/tempmon/probes/graph.mako | 128 +-- tailbone/templates/tempmon/probes/view.mako | 136 +-- tailbone/templates/themes/falafel/base.mako | 72 +- tailbone/templates/upgrades/view.mako | 46 +- tailbone/templates/util.mako | 48 +- tailbone/util.py | 25 +- tailbone/views/auth.py | 20 +- tailbone/views/batch/core.py | 115 +-- tailbone/views/batch/importer.py | 8 +- tailbone/views/batch/inventory.py | 15 +- tailbone/views/batch/vendorcatalog.py | 44 +- tailbone/views/common.py | 28 +- tailbone/views/core.py | 16 +- tailbone/views/customers.py | 72 +- tailbone/views/custorders/items.py | 22 +- tailbone/views/departments.py | 78 +- tailbone/views/employees.py | 25 +- tailbone/views/features.py | 18 +- tailbone/views/importing.py | 9 +- tailbone/views/labels/profiles.py | 6 +- tailbone/views/luigi.py | 12 +- tailbone/views/master.py | 173 ++-- tailbone/views/menus.py | 3 - tailbone/views/messages.py | 95 +-- tailbone/views/people.py | 59 +- tailbone/views/principal.py | 15 +- tailbone/views/products.py | 77 +- tailbone/views/projects.py | 9 +- tailbone/views/purchasing/batch.py | 44 +- tailbone/views/purchasing/costing.py | 43 +- tailbone/views/purchasing/ordering.py | 17 +- tailbone/views/purchasing/receiving.py | 282 +++---- tailbone/views/reports.py | 53 +- tailbone/views/roles.py | 53 +- tailbone/views/settings.py | 21 +- tailbone/views/tempmon/core.py | 53 +- tailbone/views/tempmon/dashboard.py | 20 +- tailbone/views/tempmon/probes.py | 22 +- tailbone/views/trainwreck/base.py | 81 +- tailbone/views/upgrades.py | 56 +- tailbone/views/workorders.py | 11 +- 112 files changed, 2079 insertions(+), 7039 deletions(-) delete mode 100644 tailbone/static/css/jquery.tagit.css delete mode 100644 tailbone/static/css/login.css delete mode 100644 tailbone/static/js/lib/tag-it.min.js delete mode 100644 tailbone/static/js/login.js delete mode 100644 tailbone/static/js/tailbone.appsettings.js delete mode 100644 tailbone/static/js/tailbone.batch.js delete mode 100644 tailbone/templates/purchases/batches/receive_form.mako diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 1f04b07e..161bfa25 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -24,12 +24,9 @@ Forms Core """ -from __future__ import unicode_literals, absolute_import - import json import logging -import six import sqlalchemy as sa from sqlalchemy import orm from sqlalchemy.ext.associationproxy import AssociationProxy, ASSOCIATION_PROXY @@ -338,7 +335,7 @@ class Form(object): model_instance=None, model_class=None, appstruct=UNSPECIFIED, nodes={}, enums={}, labels={}, assume_local_times=False, renderers=None, renderer_kwargs={}, hidden={}, widgets={}, defaults={}, validators={}, required={}, helptext={}, focus_spec=None, - action_url=None, cancel_url=None, use_buefy=True, component='tailbone-form', + action_url=None, cancel_url=None, component='tailbone-form', vuejs_field_converters={}, # TODO: ugh this is getting out hand! can_edit_help=False, edit_help_url=None, route_prefix=None, @@ -377,7 +374,6 @@ class Form(object): self.focus_spec = focus_spec self.action_url = action_url self.cancel_url = cancel_url - self.use_buefy = use_buefy self.component = component self.vuejs_field_converters = vuejs_field_converters or {} self.can_edit_help = can_edit_help @@ -758,10 +754,7 @@ class Form(object): def render(self, template=None, **kwargs): if not template: - if self.readonly and not self.use_buefy: - template = '/forms/form_readonly.mako' - else: - template = '/forms/form.mako' + template = '/forms/form.mako' context = kwargs context['form'] = self return render(template, context) @@ -806,10 +799,7 @@ class Form(object): def render_deform(self, dform=None, template=None, **kwargs): if not template: - if self.use_buefy: - template = '/forms/deform_buefy.mako' - else: - template = '/forms/deform.mako' + template = '/forms/deform_buefy.mako' if dform is None: dform = self.make_deform_form() @@ -829,11 +819,8 @@ class Form(object): context.setdefault('form_kwargs', {}) # TODO: deprecate / remove the latter option here if self.auto_disable_save or self.auto_disable: - if self.use_buefy: - context['form_kwargs'].setdefault('ref', self.component_studly) - context['form_kwargs']['@submit'] = 'submit{}'.format(self.component_studly) - else: - context['form_kwargs']['class_'] = 'autodisable' + context['form_kwargs'].setdefault('ref', self.component_studly) + context['form_kwargs']['@submit'] = 'submit{}'.format(self.component_studly) if self.focus_spec: context['form_kwargs']['data-focus'] = self.focus_spec context['request'] = self.request @@ -975,8 +962,7 @@ class Form(object): if self.readonly or fieldname in self.readonly_fields: html = self.render_field_value(fieldname) or HTML.tag('span') elif field: - html = field.serialize(use_buefy=True, - **self.get_renderer_kwargs(fieldname)) + html = field.serialize(**self.get_renderer_kwargs(fieldname)) html = HTML.literal(html) # may need a complex label @@ -1064,7 +1050,7 @@ class Form(object): value = self.obtain_value(record, field_name) if value is None: return "" - return six.text_type(value) + return str(value) def render_datetime(self, record, field_name): value = self.obtain_value(record, field_name) @@ -1099,7 +1085,7 @@ class Form(object): return "(${:0,.2f})".format(0 - value) return "${:0,.2f}".format(value) except ValueError: - return six.text_type(value) + return str(value) def render_quantity(self, obj, field): value = self.obtain_value(obj, field) @@ -1124,8 +1110,8 @@ class Form(object): return "" enum = self.enums.get(field_name) if enum and value in enum: - return six.text_type(enum[value]) - return six.text_type(value) + return str(enum[value]) + return str(value) def render_codeblock(self, record, field_name): value = self.obtain_value(record, field_name) @@ -1193,8 +1179,8 @@ class Form(object): controls[i][1] = 'true' elif value is False: controls[i][1] = 'false' - elif not isinstance(value, six.string_types): - controls[i][1] = six.text_type(value) + elif not isinstance(value, str): + controls[i][1] = str(value) dform = self.make_deform_form() try: diff --git a/tailbone/static/css/jquery.tagit.css b/tailbone/static/css/jquery.tagit.css deleted file mode 100644 index f18650d9..00000000 --- a/tailbone/static/css/jquery.tagit.css +++ /dev/null @@ -1,69 +0,0 @@ -ul.tagit { - padding: 1px 5px; - overflow: auto; - margin-left: inherit; /* usually we don't want the regular ul margins. */ - margin-right: inherit; -} -ul.tagit li { - display: block; - float: left; - margin: 2px 5px 2px 0; -} -ul.tagit li.tagit-choice { - position: relative; - line-height: inherit; -} -input.tagit-hidden-field { - display: none; -} -ul.tagit li.tagit-choice-read-only { - padding: .2em .5em .2em .5em; -} - -ul.tagit li.tagit-choice-editable { - padding: .2em 18px .2em .5em; -} - -ul.tagit li.tagit-new { - padding: .25em 4px .25em 0; -} - -ul.tagit li.tagit-choice a.tagit-label { - cursor: pointer; - text-decoration: none; -} -ul.tagit li.tagit-choice .tagit-close { - cursor: pointer; - position: absolute; - right: .1em; - top: 50%; - margin-top: -8px; - line-height: 17px; -} - -/* used for some custom themes that don't need image icons */ -ul.tagit li.tagit-choice .tagit-close .text-icon { - display: none; -} - -ul.tagit li.tagit-choice input { - display: block; - float: left; - margin: 2px 5px 2px 0; -} -ul.tagit input[type="text"] { - -moz-box-sizing: border-box; - -webkit-box-sizing: border-box; - box-sizing: border-box; - - -moz-box-shadow: none; - -webkit-box-shadow: none; - box-shadow: none; - - border: none; - margin: 0; - padding: 0; - width: inherit; - background-color: inherit; - outline: none; -} diff --git a/tailbone/static/css/login.css b/tailbone/static/css/login.css deleted file mode 100644 index 448f9f70..00000000 --- a/tailbone/static/css/login.css +++ /dev/null @@ -1,48 +0,0 @@ - -/****************************** - * login.css - ******************************/ - -.logo img, -#logo { - display: block; - margin: 40px auto; - max-height: 350px; - max-width: 800px; -} - -div.form { - margin: auto; - float: none; - text-align: center; -} - -div.field-wrapper { - margin: 10px auto; - width: 300px; -} - -div.field-wrapper label { - text-align: right; - width: auto; -} - -div.field-wrapper div.field input[type="text"], -div.field-wrapper div.field input[type="password"] { - margin-left: 1em; - width: 150px; -} - -div.buttons { - display: block; -} - -div.buttons input { - margin: auto 5px; -} - -/* this is for "login as chuck" tip in demo mode */ -.tips { - margin-top: 2em; - text-align: center; -} diff --git a/tailbone/static/js/lib/tag-it.min.js b/tailbone/static/js/lib/tag-it.min.js deleted file mode 100644 index fd6140c8..00000000 --- a/tailbone/static/js/lib/tag-it.min.js +++ /dev/null @@ -1,17 +0,0 @@ -(function(b){b.widget("ui.tagit",{options:{allowDuplicates:!1,caseSensitive:!0,fieldName:"tags",placeholderText:null,readOnly:!1,removeConfirmation:!1,tagLimit:null,availableTags:[],autocomplete:{},showAutocompleteOnFocus:!1,allowSpaces:!1,singleField:!1,singleFieldDelimiter:",",singleFieldNode:null,animate:!0,tabIndex:null,beforeTagAdded:null,afterTagAdded:null,beforeTagRemoved:null,afterTagRemoved:null,onTagClicked:null,onTagLimitExceeded:null,onTagAdded:null,onTagRemoved:null,tagSource:null},_create:function(){var a= -this;this.element.is("input")?(this.tagList=b("<ul></ul>").insertAfter(this.element),this.options.singleField=!0,this.options.singleFieldNode=this.element,this.element.addClass("tagit-hidden-field")):this.tagList=this.element.find("ul, ol").andSelf().last();this.tagInput=b('<input type="text" />').addClass("ui-widget-content");this.options.readOnly&&this.tagInput.attr("disabled","disabled");this.options.tabIndex&&this.tagInput.attr("tabindex",this.options.tabIndex);this.options.placeholderText&&this.tagInput.attr("placeholder", -this.options.placeholderText);this.options.autocomplete.source||(this.options.autocomplete.source=function(a,e){var d=a.term.toLowerCase(),c=b.grep(this.options.availableTags,function(a){return 0===a.toLowerCase().indexOf(d)});this.options.allowDuplicates||(c=this._subtractArray(c,this.assignedTags()));e(c)});this.options.showAutocompleteOnFocus&&(this.tagInput.focus(function(b,d){a._showAutocomplete()}),"undefined"===typeof this.options.autocomplete.minLength&&(this.options.autocomplete.minLength= -0));b.isFunction(this.options.autocomplete.source)&&(this.options.autocomplete.source=b.proxy(this.options.autocomplete.source,this));b.isFunction(this.options.tagSource)&&(this.options.tagSource=b.proxy(this.options.tagSource,this));this.tagList.addClass("tagit").addClass("ui-widget ui-widget-content ui-corner-all").append(b('<li class="tagit-new"></li>').append(this.tagInput)).click(function(d){var c=b(d.target);c.hasClass("tagit-label")?(c=c.closest(".tagit-choice"),c.hasClass("removed")||a._trigger("onTagClicked", -d,{tag:c,tagLabel:a.tagLabel(c)})):a.tagInput.focus()});var c=!1;if(this.options.singleField)if(this.options.singleFieldNode){var d=b(this.options.singleFieldNode),f=d.val().split(this.options.singleFieldDelimiter);d.val("");b.each(f,function(b,d){a.createTag(d,null,!0);c=!0})}else this.options.singleFieldNode=b('<input type="hidden" style="display:none;" value="" name="'+this.options.fieldName+'" />'),this.tagList.after(this.options.singleFieldNode);c||this.tagList.children("li").each(function(){b(this).hasClass("tagit-new")|| -(a.createTag(b(this).text(),b(this).attr("class"),!0),b(this).remove())});this.tagInput.keydown(function(c){if(c.which==b.ui.keyCode.BACKSPACE&&""===a.tagInput.val()){var d=a._lastTag();!a.options.removeConfirmation||d.hasClass("remove")?a.removeTag(d):a.options.removeConfirmation&&d.addClass("remove ui-state-highlight")}else a.options.removeConfirmation&&a._lastTag().removeClass("remove ui-state-highlight");if(c.which===b.ui.keyCode.COMMA&&!1===c.shiftKey||c.which===b.ui.keyCode.ENTER||c.which== -b.ui.keyCode.TAB&&""!==a.tagInput.val()||c.which==b.ui.keyCode.SPACE&&!0!==a.options.allowSpaces&&('"'!=b.trim(a.tagInput.val()).replace(/^s*/,"").charAt(0)||'"'==b.trim(a.tagInput.val()).charAt(0)&&'"'==b.trim(a.tagInput.val()).charAt(b.trim(a.tagInput.val()).length-1)&&0!==b.trim(a.tagInput.val()).length-1))c.which===b.ui.keyCode.ENTER&&""===a.tagInput.val()||c.preventDefault(),a.options.autocomplete.autoFocus&&a.tagInput.data("autocomplete-open")||(a.tagInput.autocomplete("close"),a.createTag(a._cleanedInput()))}).blur(function(b){a.tagInput.data("autocomplete-open")|| -a.createTag(a._cleanedInput())});if(this.options.availableTags||this.options.tagSource||this.options.autocomplete.source)d={select:function(b,c){a.createTag(c.item.value);return!1}},b.extend(d,this.options.autocomplete),d.source=this.options.tagSource||d.source,this.tagInput.autocomplete(d).bind("autocompleteopen.tagit",function(b,c){a.tagInput.data("autocomplete-open",!0)}).bind("autocompleteclose.tagit",function(b,c){a.tagInput.data("autocomplete-open",!1)}),this.tagInput.autocomplete("widget").addClass("tagit-autocomplete")}, -destroy:function(){b.Widget.prototype.destroy.call(this);this.element.unbind(".tagit");this.tagList.unbind(".tagit");this.tagInput.removeData("autocomplete-open");this.tagList.removeClass("tagit ui-widget ui-widget-content ui-corner-all tagit-hidden-field");this.element.is("input")?(this.element.removeClass("tagit-hidden-field"),this.tagList.remove()):(this.element.children("li").each(function(){b(this).hasClass("tagit-new")?b(this).remove():(b(this).removeClass("tagit-choice ui-widget-content ui-state-default ui-state-highlight ui-corner-all remove tagit-choice-editable tagit-choice-read-only"), -b(this).text(b(this).children(".tagit-label").text()))}),this.singleFieldNode&&this.singleFieldNode.remove());return this},_cleanedInput:function(){return b.trim(this.tagInput.val().replace(/^"(.*)"$/,"$1"))},_lastTag:function(){return this.tagList.find(".tagit-choice:last:not(.removed)")},_tags:function(){return this.tagList.find(".tagit-choice:not(.removed)")},assignedTags:function(){var a=this,c=[];this.options.singleField?(c=b(this.options.singleFieldNode).val().split(this.options.singleFieldDelimiter), -""===c[0]&&(c=[])):this._tags().each(function(){c.push(a.tagLabel(this))});return c},_updateSingleTagsField:function(a){b(this.options.singleFieldNode).val(a.join(this.options.singleFieldDelimiter)).trigger("change")},_subtractArray:function(a,c){for(var d=[],f=0;f<a.length;f++)-1==b.inArray(a[f],c)&&d.push(a[f]);return d},tagLabel:function(a){return this.options.singleField?b(a).find(".tagit-label:first").text():b(a).find("input:first").val()},_showAutocomplete:function(){this.tagInput.autocomplete("search", -"")},_findTagByLabel:function(a){var c=this,d=null;this._tags().each(function(f){if(c._formatStr(a)==c._formatStr(c.tagLabel(this)))return d=b(this),!1});return d},_isNew:function(a){return!this._findTagByLabel(a)},_formatStr:function(a){return this.options.caseSensitive?a:b.trim(a.toLowerCase())},_effectExists:function(a){return Boolean(b.effects&&(b.effects[a]||b.effects.effect&&b.effects.effect[a]))},createTag:function(a,c,d){var f=this;a=b.trim(a);this.options.preprocessTag&&(a=this.options.preprocessTag(a)); -if(""===a)return!1;if(!this.options.allowDuplicates&&!this._isNew(a))return a=this._findTagByLabel(a),!1!==this._trigger("onTagExists",null,{existingTag:a,duringInitialization:d})&&this._effectExists("highlight")&&a.effect("highlight"),!1;if(this.options.tagLimit&&this._tags().length>=this.options.tagLimit)return this._trigger("onTagLimitExceeded",null,{duringInitialization:d}),!1;var g=b(this.options.onTagClicked?'<a class="tagit-label"></a>':'<span class="tagit-label"></span>').text(a),e=b("<li></li>").addClass("tagit-choice ui-widget-content ui-state-default ui-corner-all").addClass(c).append(g); -this.options.readOnly?e.addClass("tagit-choice-read-only"):(e.addClass("tagit-choice-editable"),c=b("<span></span>").addClass("ui-icon ui-icon-close"),c=b('<a><span class="text-icon">\u00d7</span></a>').addClass("tagit-close").append(c).click(function(a){f.removeTag(e)}),e.append(c));this.options.singleField||(g=g.html(),e.append('<input type="hidden" value="'+g+'" name="'+this.options.fieldName+'" class="tagit-hidden-field" />'));!1!==this._trigger("beforeTagAdded",null,{tag:e,tagLabel:this.tagLabel(e), -duringInitialization:d})&&(this.options.singleField&&(g=this.assignedTags(),g.push(a),this._updateSingleTagsField(g)),this._trigger("onTagAdded",null,e),this.tagInput.val(""),this.tagInput.parent().before(e),this._trigger("afterTagAdded",null,{tag:e,tagLabel:this.tagLabel(e),duringInitialization:d}),this.options.showAutocompleteOnFocus&&!d&&setTimeout(function(){f._showAutocomplete()},0))},removeTag:function(a,c){c="undefined"===typeof c?this.options.animate:c;a=b(a);this._trigger("onTagRemoved", -null,a);if(!1!==this._trigger("beforeTagRemoved",null,{tag:a,tagLabel:this.tagLabel(a)})){if(this.options.singleField){var d=this.assignedTags(),f=this.tagLabel(a),d=b.grep(d,function(a){return a!=f});this._updateSingleTagsField(d)}if(c){a.addClass("removed");var d=this._effectExists("blind")?["blind",{direction:"horizontal"},"fast"]:["fast"],g=this;d.push(function(){a.remove();g._trigger("afterTagRemoved",null,{tag:a,tagLabel:g.tagLabel(a)})});a.fadeOut("fast").hide.apply(a,d).dequeue()}else a.remove(), -this._trigger("afterTagRemoved",null,{tag:a,tagLabel:this.tagLabel(a)})}},removeTagByLabel:function(a,b){var d=this._findTagByLabel(a);if(!d)throw"No such tag exists with the name '"+a+"'";this.removeTag(d,b)},removeAll:function(){var a=this;this._tags().each(function(b,d){a.removeTag(d,!1)})}})})(jQuery); diff --git a/tailbone/static/js/login.js b/tailbone/static/js/login.js deleted file mode 100644 index f2a072b8..00000000 --- a/tailbone/static/js/login.js +++ /dev/null @@ -1,32 +0,0 @@ - -$(function() { - - $('input[name="username"]').keydown(function(event) { - if (event.which == 13) { - $('input[name="password"]').focus().select(); - return false; - } - return true; - }); - - $('form').submit(function() { - if (! $('input[name="username"]').val()) { - with ($('input[name="username"]').get(0)) { - select(); - focus(); - } - return false; - } - if (! $('input[name="password"]').val()) { - with ($('input[name="password"]').get(0)) { - select(); - focus(); - } - return false; - } - return true; - }); - - $('input[name="username"]').focus(); - -}); diff --git a/tailbone/static/js/tailbone.appsettings.js b/tailbone/static/js/tailbone.appsettings.js deleted file mode 100644 index ae378931..00000000 --- a/tailbone/static/js/tailbone.appsettings.js +++ /dev/null @@ -1,29 +0,0 @@ - -/************************************************************ - * - * tailbone.appsettings.js - * - * Logic for App Settings page. - * - ************************************************************/ - - -function show_group(group) { - if (group == "(All)") { - $('.panel').show(); - } else { - $('.panel').hide(); - $('.panel[data-groupname="' + group + '"]').show(); - } -} - - -$(function() { - - $('#settings-group').on('selectmenuchange', function(event, ui) { - show_group(ui.item.value); - }); - - show_group($('#settings-group').val()); - -}); diff --git a/tailbone/static/js/tailbone.batch.js b/tailbone/static/js/tailbone.batch.js deleted file mode 100644 index 2844c0b4..00000000 --- a/tailbone/static/js/tailbone.batch.js +++ /dev/null @@ -1,41 +0,0 @@ - -/************************************************************ - * - * tailbone.batch.js - * - * Common logic for view/edit batch pages - * - ************************************************************/ - - -$(function() { - - $('#execute-batch').click(function() { - if (has_execution_options) { - $('#execution-options-dialog').dialog({ - title: "Execution Options", - width: 600, - modal: true, - buttons: [ - { - text: "Execute", - click: function(event) { - dialog_button(event).button('option', 'label', "Executing, please wait...").button('disable'); - $('form[name="batch-execution"]').submit(); - } - }, - { - text: "Cancel", - click: function() { - $(this).dialog('close'); - } - } - ] - }); - } else { - $(this).button('option', 'label', "Executing, please wait...").button('disable'); - $('form[name="batch-execution"]').submit(); - } - }); - -}); diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 7ffd92bc..229296ab 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -24,8 +24,6 @@ Event Subscribers """ -from __future__ import unicode_literals, absolute_import - import six import json import datetime @@ -42,7 +40,7 @@ from tailbone import helpers from tailbone.db import Session from tailbone.config import csrf_header_name, should_expose_websockets from tailbone.menus import make_simple_menus -from tailbone.util import should_use_buefy, get_global_search_options +from tailbone.util import get_global_search_options def new_request(event): @@ -156,23 +154,20 @@ def before_render(event): renderer_globals['background_color'] = request.rattail_config.get( 'tailbone', 'background_color') - # buefy themes get some extra treatment - if should_use_buefy(request): + # TODO: remove this hack once nothing references it + renderer_globals['buefy_0_8'] = False - # TODO: remove this hack once nothing references it - renderer_globals['buefy_0_8'] = False + # maybe set custom stylesheet + css = None + if request.user: + css = request.rattail_config.get('tailbone.{}'.format(request.user.uuid), + 'buefy_css') + if not css: + css = request.rattail_config.get('tailbone', 'theme.falafel.buefy_css') + renderer_globals['buefy_css'] = css - # maybe set custom stylesheet - css = None - if request.user: - css = request.rattail_config.get('tailbone.{}'.format(request.user.uuid), - 'buefy_css') - if not css: - css = request.rattail_config.get('tailbone', 'theme.falafel.buefy_css') - renderer_globals['buefy_css'] = css - - # add global search data for quick access - renderer_globals['global_search_data'] = get_global_search_options(request) + # add global search data for quick access + renderer_globals['global_search_data'] = get_global_search_options(request) # here we globally declare widths for grid filter pseudo-columns widths = request.rattail_config.get('tailbone', 'grids.filters.column_widths') diff --git a/tailbone/templates/appsettings.mako b/tailbone/templates/appsettings.mako index a80dafc2..46f4a7e3 100644 --- a/tailbone/templates/appsettings.mako +++ b/tailbone/templates/appsettings.mako @@ -5,34 +5,6 @@ <%def name="content_title()"></%def> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.appsettings.js') + '?ver={}'.format(tailbone.__version__))} - % endif -</%def> - -<%def name="extra_styles()"> - ${parent.extra_styles()} - % if not use_buefy: - <style type="text/css"> - div.form { - float: none; - } - div.panel { - width: 85%; - } - .field-wrapper { - margin-bottom: 2em; - } - .panel .field-wrapper label { - font-family: monospace; - width: 50em; - } - </style> - % endif -</%def> - <%def name="context_menu_items()"> % if request.has_perm('settings.list'): <li>${h.link_to("View Raw Settings", url('settings'))}</li> @@ -223,76 +195,4 @@ </%def> -% if use_buefy: - ${parent.body()} - -% else: -## legacy / not buefy -<div class="form"> - ${h.form(form.action_url, id=dform.formid, method='post', class_='autodisable')} - ${h.csrf_token(request)} - - % if dform.error: - <div class="error-messages"> - <div class="ui-state-error ui-corner-all"> - <span style="float: left; margin-right: .3em;" class="ui-icon ui-icon-alert"></span> - Please see errors below. - </div> - <div class="ui-state-error ui-corner-all"> - <span style="float: left; margin-right: .3em;" class="ui-icon ui-icon-alert"></span> - ${dform.error} - </div> - </div> - % endif - - <div class="group-picker"> - <div class="field-wrapper"> - <label for="settings-group">Showing Group</label> - <div class="field select"> - ${h.select('settings-group', current_group, group_options, **{'auto-enhance': 'true'})} - </div> - </div> - </div> - - % for group in groups: - <div class="panel" data-groupname="${group}"> - <h2>${group}</h2> - <div class="panel-body"> - - % for setting in settings: - % if setting.group == group: - <% field = dform[setting.node_name] %> - - <div class="field-wrapper ${field.name} ${'with-error' if field.error else ''}"> - % if field.error: - <div class="field-error"> - % for msg in field.error.messages(): - <span class="error-msg">${msg}</span> - % endfor - </div> - % endif - <div class="field-row"> - <label for="${field.oid}">${form.get_label(field.name)}</label> - <div class="field"> - ${field.serialize()|n} - </div> - </div> - % if form.has_helptext(field.name): - <span class="instructions">${form.render_helptext(field.name)}</span> - % endif - </div> - % endif - % endfor - - </div><!-- panel-body --> - </div><! -- panel --> - % endfor - - <div class="buttons"> - ${h.submit('save', getattr(form, 'submit_label', getattr(form, 'save_label', "Submit")), class_='button is-primary')} - ${h.link_to("Cancel", form.cancel_url, class_='cancel button{}'.format(' autodisable' if form.auto_disable_cancel else ''))} - </div> - - ${h.end_form()} -</div> -% endif +${parent.body()} diff --git a/tailbone/templates/batch/importer/view_row.mako b/tailbone/templates/batch/importer/view_row.mako index 24eb6456..9e08cf43 100644 --- a/tailbone/templates/batch/importer/view_row.mako +++ b/tailbone/templates/batch/importer/view_row.mako @@ -76,12 +76,5 @@ </div> </%def> -<%def name="render_form()"> - ${parent.render_form()} - % if not use_buefy: - ${self.field_diff_table()} - % endif -</%def> - ${parent.body()} diff --git a/tailbone/templates/batch/index.mako b/tailbone/templates/batch/index.mako index 89358567..3ea76641 100644 --- a/tailbone/templates/batch/index.mako +++ b/tailbone/templates/batch/index.mako @@ -1,150 +1,66 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/index.mako" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - % if master.results_executable and master.has_perm('execute_multiple'): - <script type="text/javascript"> - - var has_execution_options = ${'true' if master.has_execution_options(batch) else 'false'}; - var dialog_opened = false; - - $(function() { - - $('#refresh-results-button').click(function() { - var count = $('.grid-wrapper').gridwrapper('results_count'); - if (!count) { - alert("There are no batch results to refresh."); - return; - } - var form = $('form[name="refresh-results"]'); - $(this).button('option', 'label', "Refreshing, please wait...").button('disable'); - form.submit(); - }); - - $('#execute-results-button').click(function() { - var count = $('.grid-wrapper').gridwrapper('results_count'); - if (!count) { - alert("There are no batch results to execute."); - return; - } - var form = $('form[name="execute-results"]'); - if (has_execution_options) { - $('#execution-options-dialog').dialog({ - title: "Execution Options", - width: 550, - height: 300, - modal: true, - buttons: [ - { - text: "Execute", - click: function(event) { - dialog_button(event).button('option', 'label', "Executing, please wait...").button('disable'); - form.submit(); - } - }, - { - text: "Cancel", - click: function() { - $(this).dialog('close'); - } - } - ], - open: function() { - if (! dialog_opened) { - $('#execution-options-dialog select[auto-enhance="true"]').selectmenu(); - $('#execution-options-dialog select[auto-enhance="true"]').on('selectmenuopen', function(event, ui) { - show_all_options($(this)); - }); - dialog_opened = true; - } - } - }); - } else { - $(this).button('option', 'label', "Executing, please wait...").button('disable'); - form.submit(); - } - }); - - }); - - </script> - % endif - % endif -</%def> - <%def name="grid_tools()"> ${parent.grid_tools()} ## Refresh Results % if master.results_refreshable and master.has_perm('refresh'): - % if use_buefy: - <b-button type="is-primary" - :disabled="refreshResultsButtonDisabled" - icon-pack="fas" - icon-left="fas fa-redo" - @click="refreshResults()"> - {{ refreshResultsButtonText }} - </b-button> - ${h.form(url('{}.refresh_results'.format(route_prefix)), ref='refreshResultsForm')} - ${h.csrf_token(request)} - ${h.end_form()} - % else: - <button type="button" id="refresh-results-button"> - Refresh Results - </button> - % endif + <b-button type="is-primary" + :disabled="refreshResultsButtonDisabled" + icon-pack="fas" + icon-left="fas fa-redo" + @click="refreshResults()"> + {{ refreshResultsButtonText }} + </b-button> + ${h.form(url('{}.refresh_results'.format(route_prefix)), ref='refreshResultsForm')} + ${h.csrf_token(request)} + ${h.end_form()} % endif ## Execute Results % if master.results_executable and master.has_perm('execute_multiple'): - % if use_buefy: - <b-button type="is-primary" - @click="executeResults()" - icon-pack="fas" - icon-left="arrow-circle-right" - :disabled="!total"> - Execute Results - </b-button> + <b-button type="is-primary" + @click="executeResults()" + icon-pack="fas" + icon-left="arrow-circle-right" + :disabled="!total"> + Execute Results + </b-button> - <b-modal has-modal-card - :active.sync="showExecutionOptions"> - <div class="modal-card"> + <b-modal has-modal-card + :active.sync="showExecutionOptions"> + <div class="modal-card"> - <header class="modal-card-head"> - <p class="modal-card-title">Execution Options</p> - </header> - - <section class="modal-card-body"> - <p> - Please be advised, you are about to execute {{ total }} batches! - </p> - <br /> - <div class="form-wrapper"> - <div class="form"> - <${execute_form.component} ref="executeResultsForm"></${execute_form.component}> - </div> - </div> - </section> - - <footer class="modal-card-foot"> - <b-button @click="showExecutionOptions = false"> - Cancel - </b-button> - <once-button type="is-primary" - @click="submitExecuteResults()" - icon-left="arrow-circle-right" - :text="'Execute ' + total + ' Batches'"> - </once-button> - </footer> + <header class="modal-card-head"> + <p class="modal-card-title">Execution Options</p> + </header> + <section class="modal-card-body"> + <p> + Please be advised, you are about to execute {{ total }} batches! + </p> + <br /> + <div class="form-wrapper"> + <div class="form"> + <${execute_form.component} ref="executeResultsForm"></${execute_form.component}> + </div> </div> - </b-modal> + </section> - % else: - <button type="button" id="execute-results-button">Execute Results</button> - % endif + <footer class="modal-card-foot"> + <b-button @click="showExecutionOptions = false"> + Cancel + </b-button> + <once-button type="is-primary" + @click="submitExecuteResults()" + icon-left="arrow-circle-right" + :text="'Execute ' + total + ' Batches'"> + </once-button> + </footer> + + </div> + </b-modal> % endif </%def> @@ -224,24 +140,3 @@ ${parent.body()} - -% if not use_buefy: - -## Refresh Results -% if master.results_refreshable and master.has_perm('refresh'): - ${h.form(url('{}.refresh_results'.format(route_prefix)), name='refresh-results')} - ${h.csrf_token(request)} - ${h.end_form()} -% endif - -% if master.results_executable and master.has_perm('execute_multiple'): - <div id="execution-options-dialog" style="display: none;"> - <br /> - <p> - Please be advised, you are about to execute multiple batches! - </p> - <br /> - ${execute_form.render_deform(form_kwargs={'name': 'execute-results'}, buttons=False)|n} - </div> -% endif -% endif diff --git a/tailbone/templates/batch/inventory/desktop_form.mako b/tailbone/templates/batch/inventory/desktop_form.mako index 7e3787ae..2a853f4f 100644 --- a/tailbone/templates/batch/inventory/desktop_form.mako +++ b/tailbone/templates/batch/inventory/desktop_form.mako @@ -3,298 +3,67 @@ <%def name="title()">Inventory Form</%def> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - ${h.javascript_link(request.static_url('tailbone:static/js/numeric.js'))} - <script type="text/javascript"> - - function assert_quantity() { - % if allow_cases: - var cases = parseFloat($('#cases').val()); - if (!isNaN(cases)) { - if (cases > 999999) { - alert("Case amount is invalid!"); - $('#cases').select().focus(); - return false; - } - return true; - } - % endif - var units = parseFloat($('#units').val()); - if (!isNaN(units)) { - if (units > 999999) { - alert("Unit amount is invalid!"); - $('#units').select().focus(); - return false; - } - return true; - } - alert("Please provide case and/or unit quantity"); - % if allow_cases: - $('#cases').select().focus(); - % else: - $('#units').select().focus(); - % endif - return false; - } - - function invalid_product(msg) { - $('#product-info p').text(msg); - $('#product-info img').hide(); - $('#upc').focus().select(); - % if allow_cases: - $('.field-wrapper.cases input').prop('disabled', true); - % endif - $('.field-wrapper.units input').prop('disabled', true); - $('.buttons button').button('disable'); - } - - function pretty_quantity(cases, units) { - if (cases && units) { - return cases + " cases, " + units + " units"; - } else if (cases) { - return cases + " cases"; - } else if (units) { - return units + " units"; - } - return ''; - } - - function show_quantity(name, cases, units) { - var quantity = pretty_quantity(cases, units); - var field = $('.field-wrapper.quantity_' + name); - field.find('.field').text(quantity); - if (quantity || name == 'ordered') { - field.show(); - } else { - field.hide(); - } - } - - $(function() { - - $('#upc').keydown(function(event) { - - if (key_allowed(event)) { - return true; - } - if (key_modifies(event)) { - $('#product').val(''); - $('#product-info p').html("please ENTER a scancode"); - $('#product-info img').hide(); - $('#product-info .warning').hide(); - $('.product-fields').hide(); - // $('.receiving-fields').hide(); - % if allow_cases: - $('.field-wrapper.cases input').prop('disabled', true); - % endif - $('.field-wrapper.units input').prop('disabled', true); - $('.buttons button').button('disable'); - return true; - } - - // when user presses ENTER, do product lookup - if (event.which == 13) { - var upc = $(this).val(); - var data = {'upc': upc}; - $.get('${url('batch.inventory.desktop_lookup', uuid=batch.uuid)}', data, function(data) { - - if (data.error) { - alert(data.error); - if (data.redirect) { - $('#inventory-form').mask("Redirecting..."); - location.href = data.redirect; - } - - } else if (data.product) { - $('#upc').val(data.product.upc_pretty); - $('#product').val(data.product.uuid); - $('#brand_name').val(data.product.brand_name); - $('#description').val(data.product.description); - $('#size').val(data.product.size); - $('#case_quantity').val(data.product.case_quantity); - - if (data.force_unit_item) { - $('#product-info .warning.force-unit').show(); - } - - if (data.already_present_in_batch) { - $('#product-info .warning.present').show(); - $('#cases').val(data.cases); - $('#units').val(data.units); - - } else if (data.product.type2) { - $('#units').val(data.product.units); - } - - $('#product-info p').text(data.product.full_description); - $('#product-info img').attr('src', data.product.image_url).show(); - if (! data.product.uuid) { - // $('#product-info .warning.notfound').show(); - $('.product-fields').show(); - } - $('#product-info .warning.notordered').show(); - % if allow_cases: - $('.field-wrapper.cases input').prop('disabled', false); - % endif - $('.field-wrapper.units input').prop('disabled', false); - $('.buttons button').button('enable'); - - if (data.product.type2) { - $('#units').focus().select(); - } else { - % if allow_cases and prefer_cases: - if ($('#cases').val()) { - $('#cases').focus().select(); - } else if ($('#units').val()) { - $('#units').focus().select(); - } else { - $('#cases').focus().select(); - } - % else: - $('#units').focus().select(); - % endif - } - - // TODO: this is maybe useful if "new products" may be added via inventory batch - // } else if (data.upc) { - // $('#upc').val(data.upc_pretty); - // $('#product-info p').text("product not found in our system"); - // $('#product-info img').attr('src', data.image_url).show(); - - // $('#product').val(''); - // $('#brand_name').val(''); - // $('#description').val(''); - // $('#size').val(''); - // $('#case_quantity').val(''); - - // $('#product-info .warning.notfound').show(); - // $('.product-fields').show(); - // $('#brand_name').focus(); - // $('.field-wrapper.cases input').prop('disabled', false); - // $('.field-wrapper.units input').prop('disabled', false); - // $('.buttons button').button('enable'); - - } else { - invalid_product('product not found'); - } - }); - } - return false; - }); - - $('#inventory-form').submit(function() { - if (! assert_quantity()) { - return false; - } - disable_submit_button(this); - $(this).mask("Working..."); - }); - - $('#upc').focus(); - % if allow_cases: - $('.field-wrapper.cases input').prop('disabled', true); - % endif - $('.field-wrapper.units input').prop('disabled', true); - $('.buttons button').button('disable'); - - }); - </script> - % endif -</%def> - -<%def name="extra_styles()"> - ${parent.extra_styles()} - % if not use_buefy: - <style type="text/css"> - - #product-info { - margin-top: 0.5em; - text-align: center; - } - - #product-info p { - margin-left: 0.5em; - } - - #product-info .img-wrapper { - height: 150px; - margin: 0.5em 0; - } - - #product-info .warning { - background: #f66; - display: none; - } - - </style> - % endif -</%def> - <%def name="context_menu_items()"> ${parent.context_menu_items()} <li>${h.link_to("Back to Inventory Batch", url('batch.inventory.view', uuid=batch.uuid))}</li> </%def> <%def name="render_form()"> - % if use_buefy: + <script type="text/x-template" id="${form.component}-template"> + <div class="product-info"> - <script type="text/x-template" id="${form.component}-template"> - <div class="product-info"> + ${h.form(form.action_url, **{'@submit': 'handleSubmit'})} + ${h.csrf_token(request)} - ${h.form(form.action_url, **{'@submit': 'handleSubmit'})} - ${h.csrf_token(request)} + ${h.hidden('product', **{':value': 'productInfo.uuid'})} + ${h.hidden('upc', **{':value': 'productInfo.upc'})} + ${h.hidden('brand_name', **{':value': 'productInfo.brand_name'})} + ${h.hidden('description', **{':value': 'productInfo.description'})} + ${h.hidden('size', **{':value': 'productInfo.size'})} + ${h.hidden('case_quantity', **{':value': 'productInfo.case_quantity'})} - ${h.hidden('product', **{':value': 'productInfo.uuid'})} - ${h.hidden('upc', **{':value': 'productInfo.upc'})} - ${h.hidden('brand_name', **{':value': 'productInfo.brand_name'})} - ${h.hidden('description', **{':value': 'productInfo.description'})} - ${h.hidden('size', **{':value': 'productInfo.size'})} - ${h.hidden('case_quantity', **{':value': 'productInfo.case_quantity'})} + <b-field label="Product UPC" horizontal> + <div style="display: flex; flex-direction: column;"> + <b-input v-model="productUPC" + ref="productUPC" + @input="productChanged" + @keydown.native="productKeydown"> + </b-input> + <div class="has-text-centered block"> - <b-field label="Product UPC" horizontal> - <div style="display: flex; flex-direction: column;"> - <b-input v-model="productUPC" - ref="productUPC" - @input="productChanged" - @keydown.native="productKeydown"> - </b-input> - <div class="has-text-centered block"> + <p v-if="!productInfo.uuid" + class="block"> + please ENTER a scancode + </p> - <p v-if="!productInfo.uuid" - class="block"> - please ENTER a scancode - </p> + <p v-if="productInfo.uuid" + class="block"> + {{ productInfo.full_description }} + </p> - <p v-if="productInfo.uuid" - class="block"> - {{ productInfo.full_description }} - </p> + <div style="min-height: 150px; margin: 0.5rem 0;"> + <img v-if="productInfo.uuid" + :src="productInfo.image_url" /> + </div> - <div style="min-height: 150px; margin: 0.5rem 0;"> - <img v-if="productInfo.uuid" - :src="productInfo.image_url" /> - </div> + <div v-if="alreadyPresentInBatch" + class="has-background-danger"> + product already exists in batch, please confirm count + </div> - <div v-if="alreadyPresentInBatch" - class="has-background-danger"> - product already exists in batch, please confirm count - </div> - - <div v-if="forceUnitItem" - class="has-background-danger"> - pack item scanned, but must count units instead - </div> + <div v-if="forceUnitItem" + class="has-background-danger"> + pack item scanned, but must count units instead + </div> ## <div v-if="productNotFound" ## class="has-background-danger"> ## please confirm UPC and provide more details ## </div> - </div> - </div> - </b-field> + </div> + </div> + </b-field> ## <div v-if="productNotFound" ## ## class="product-fields" @@ -322,243 +91,176 @@ ## ## </div> - % if allow_cases: - <b-field label="Cases" horizontal> - <b-input name="cases" - v-model="productCases" - ref="productCases" - :disabled="!productInfo.uuid"> - </b-input> - </b-field> - % endif - - <b-field label="Units" horizontal> - <b-input name="units" - v-model="productUnits" - ref="productUnits" + % if allow_cases: + <b-field label="Cases" horizontal> + <b-input name="cases" + v-model="productCases" + ref="productCases" :disabled="!productInfo.uuid"> </b-input> </b-field> + % endif - <b-button type="is-primary" - native-type="submit" - :disabled="submitting"> - {{ submitting ? "Working, please wait..." : "Submit" }} - </b-button> + <b-field label="Units" horizontal> + <b-input name="units" + v-model="productUnits" + ref="productUnits" + :disabled="!productInfo.uuid"> + </b-input> + </b-field> - ${h.end_form()} - </div> - </script> + <b-button type="is-primary" + native-type="submit" + :disabled="submitting"> + {{ submitting ? "Working, please wait..." : "Submit" }} + </b-button> - <script type="text/javascript"> + ${h.end_form()} + </div> + </script> - let ${form.component_studly} = { - template: '#${form.component}-template', + <script type="text/javascript"> - mounted() { - this.$refs.productUPC.focus() + let ${form.component_studly} = { + template: '#${form.component}-template', + + mounted() { + this.$refs.productUPC.focus() + }, + + methods: { + + clearProduct() { + this.productInfo = {} + ## this.productNotFound = false + this.alreadyPresentInBatch = false + this.forceUnitItem = false + this.productCases = null + this.productUnits = null }, - methods: { + assertQuantity() { - clearProduct() { - this.productInfo = {} - ## this.productNotFound = false - this.alreadyPresentInBatch = false - this.forceUnitItem = false - this.productCases = null - this.productUnits = null - }, - - assertQuantity() { - - % if allow_cases: - let cases = parseFloat(this.productCases) - if (!isNaN(cases)) { - if (cases > 999999) { - alert("Case amount is invalid!") - this.$refs.productCases.focus() - return false - } - return true - } - % endif - - let units = parseFloat(this.productUnits) - if (!isNaN(units)) { - if (units > 999999) { - alert("Unit amount is invalid!") - this.$refs.productUnits.focus() + % if allow_cases: + let cases = parseFloat(this.productCases) + if (!isNaN(cases)) { + if (cases > 999999) { + alert("Case amount is invalid!") + this.$refs.productCases.focus() return false } return true } + % endif - alert("Please provide case and/or unit quantity") - % if allow_cases: - this.$refs.productCases.focus() - % else: + let units = parseFloat(this.productUnits) + if (!isNaN(units)) { + if (units > 999999) { + alert("Unit amount is invalid!") this.$refs.productUnits.focus() - % endif - }, - - handleSubmit(event) { - if (!this.assertQuantity()) { - event.preventDefault() - return + return false } - this.submitting = true - }, + return true + } - productChanged() { - this.clearProduct() - }, - - productKeydown(event) { - if (event.which == 13) { // ENTER - this.productLookup() - event.preventDefault() - } - }, - - productLookup() { - let url = '${url('batch.inventory.desktop_lookup', uuid=batch.uuid)}' - let params = { - upc: this.productUPC, - } - this.$http.get(url, {params: params}).then(response => { - - if (response.data.error) { - alert(response.data.error) - if (response.data.redirect) { - location.href = response.data.redirect - } - - } else if (response.data.product.uuid) { - - this.productUPC = response.data.product.upc_pretty - this.productInfo = response.data.product - this.forceUnitItem = response.data.force_unit_item - this.alreadyPresentInBatch = response.data.already_present_in_batch - - if (this.alreadyPresentInBatch) { - this.productCases = response.data.cases - this.productUnits = response.data.units - } else if (this.productInfo.type2) { - this.productUnits = this.productInfo.units - } - - this.$nextTick(() => { - if (this.productInfo.type2) { - this.$refs.productUnits.focus() - } else { - % if allow_cases and prefer_cases: - if (this.productCases) { - this.$refs.productCases.focus() - } else if (this.productUnits) { - this.$refs.productUnits.focus() - } else { - this.$refs.productCases.focus() - } - % else: - this.$refs.productUnits.focus() - % endif - } - }) - - } else { - ## this.productNotFound = true - alert("Product not found!") - } - }) - }, + alert("Please provide case and/or unit quantity") + % if allow_cases: + this.$refs.productCases.focus() + % else: + this.$refs.productUnits.focus() + % endif }, - } - let ${form.component_studly}Data = { - submitting: false, + handleSubmit(event) { + if (!this.assertQuantity()) { + event.preventDefault() + return + } + this.submitting = true + }, - productUPC: null, - ## productNotFound: false, - productInfo: {}, + productChanged() { + this.clearProduct() + }, - % if allow_cases: - productCases: null, - % endif - productUnits: null, + productKeydown(event) { + if (event.which == 13) { // ENTER + this.productLookup() + event.preventDefault() + } + }, - alreadyPresentInBatch: false, - forceUnitItem: false, - } + productLookup() { + let url = '${url('batch.inventory.desktop_lookup', uuid=batch.uuid)}' + let params = { + upc: this.productUPC, + } + this.$http.get(url, {params: params}).then(response => { - </script> + if (response.data.error) { + alert(response.data.error) + if (response.data.redirect) { + location.href = response.data.redirect + } - % else: - ## not buefy + } else if (response.data.product.uuid) { - <div class="form-wrapper"> - ${h.form(form.action_url, id='inventory-form')} - ${h.csrf_token(request)} + this.productUPC = response.data.product.upc_pretty + this.productInfo = response.data.product + this.forceUnitItem = response.data.force_unit_item + this.alreadyPresentInBatch = response.data.already_present_in_batch - <div class="field-wrapper"> - <label for="upc">Product UPC</label> - <div class="field"> - ${h.hidden('product')} - <div>${h.text('upc', autocomplete='off')}</div> - <div id="product-info"> - <p>please ENTER a scancode</p> - <div class="img-wrapper"><img /></div> - <div class="warning notfound">please confirm UPC and provide more details</div> - <div class="warning present">product already exists in batch, please confirm count</div> - <div class="warning force-unit">pack item scanned, but must count units instead</div> - </div> - </div> - </div> + if (this.alreadyPresentInBatch) { + this.productCases = response.data.cases + this.productUnits = response.data.units + } else if (this.productInfo.type2) { + this.productUnits = this.productInfo.units + } - <div class="product-fields" style="display: none;"> + this.$nextTick(() => { + if (this.productInfo.type2) { + this.$refs.productUnits.focus() + } else { + % if allow_cases and prefer_cases: + if (this.productCases) { + this.$refs.productCases.focus() + } else if (this.productUnits) { + this.$refs.productUnits.focus() + } else { + this.$refs.productCases.focus() + } + % else: + this.$refs.productUnits.focus() + % endif + } + }) - <div class="field-wrapper brand_name"> - <label for="brand_name">Brand Name</label> - <div class="field">${h.text('brand_name')}</div> - </div> + } else { + ## this.productNotFound = true + alert("Product not found!") + } + }) + }, + }, + } - <div class="field-wrapper description"> - <label for="description">Description</label> - <div class="field">${h.text('description')}</div> - </div> + let ${form.component_studly}Data = { + submitting: false, - <div class="field-wrapper size"> - <label for="size">Size</label> - <div class="field">${h.text('size')}</div> - </div> - - <div class="field-wrapper case_quantity"> - <label for="case_quantity">Units in Case</label> - <div class="field">${h.text('case_quantity')}</div> - </div> - - </div> + productUPC: null, + ## productNotFound: false, + productInfo: {}, % if allow_cases: - <div class="field-wrapper cases"> - <label for="cases">Cases</label> - <div class="field">${h.text('cases', autocomplete='off')}</div> - </div> + productCases: null, % endif + productUnits: null, - <div class="field-wrapper units"> - <label for="units">Units</label> - <div class="field">${h.text('units', autocomplete='off')}</div> - </div> + alreadyPresentInBatch: false, + forceUnitItem: false, + } - <div class="buttons"> - ${h.submit('submit', "Submit")} - </div> - - ${h.end_form()} - </div> - - % endif + </script> </%def> diff --git a/tailbone/templates/batch/vendorcatalog/create.mako b/tailbone/templates/batch/vendorcatalog/create.mako index 19e91dd0..d25c8f16 100644 --- a/tailbone/templates/batch/vendorcatalog/create.mako +++ b/tailbone/templates/batch/vendorcatalog/create.mako @@ -1,59 +1,6 @@ ## -*- coding: utf-8; -*- <%inherit file="/batch/create.mako" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - <script type="text/javascript"> - - var vendormap = { - % for i, parser in enumerate(parsers, 1): - '${parser.key}': ${parser.vendormap_value|n}${',' if i < len(parsers) else ''} - % endfor - }; - - $(function() { - - if ($('select[name="parser_key"] option:first').is(':selected')) { - $('.vendor_uuid .autocomplete-container').hide(); - } else { - $('.vendor_uuid input[name="vendor_uuid"]').val(''); - $('.vendor_uuid .autocomplete-display').hide(); - $('.vendor_uuid .autocomplete-display button').show(); - $('.vendor_uuid .autocomplete-textbox').val(''); - $('.vendor_uuid .autocomplete-textbox').show(); - $('.vendor_uuid .autocomplete-container').show(); - } - - $('select[name="parser_key"]').on('selectmenuchange', function() { - if ($(this).find('option:first').is(':selected')) { - $('.vendor_uuid .autocomplete-container').hide(); - } else { - var vendor = vendormap[$(this).val()]; - if (vendor) { - $('.vendor_uuid input[name="vendor_uuid"]').val(vendor.uuid); - $('.vendor_uuid .autocomplete-textbox').hide(); - $('.vendor_uuid .autocomplete-display span:first').text(vendor.name); - $('.vendor_uuid .autocomplete-display button').hide(); - $('.vendor_uuid .autocomplete-display').show(); - $('.vendor_uuid .autocomplete-container').show(); - } else { - $('.vendor_uuid input[name="vendor_uuid"]').val(''); - $('.vendor_uuid .autocomplete-display').hide(); - $('.vendor_uuid .autocomplete-display button').show(); - $('.vendor_uuid .autocomplete-textbox').val(''); - $('.vendor_uuid .autocomplete-textbox').show(); - $('.vendor_uuid .autocomplete-container').show(); - $('.vendor_uuid .autocomplete-textbox').focus(); - } - } - }); - - }); - </script> - % endif -</%def> - <%def name="modify_this_page_vars()"> ${parent.modify_this_page_vars()} <script type="text/javascript"> diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako index 4288f6e2..fa8fa19f 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -1,68 +1,8 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.batch.js') + '?ver={}'.format(tailbone.__version__))} - <script type="text/javascript"> - - var has_execution_options = ${'true' if master.has_execution_options(batch) else 'false'}; - - $(function() { - % if master.has_worksheet and master.allow_worksheet(batch) and master.has_perm('worksheet'): - $('.load-worksheet').click(function() { - disable_button(this); - location.href = '${url('{}.worksheet'.format(route_prefix), uuid=batch.uuid)}'; - }); - % endif - % if master.batch_refreshable(batch) and master.has_perm('refresh'): - $('#refresh-data').click(function() { - $(this) - .button('option', 'disabled', true) - .button('option', 'label', "Working, please wait..."); - location.href = '${url('{}.refresh'.format(route_prefix), uuid=batch.uuid)}'; - }); - % endif - % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'): - $('.upload-worksheet').click(function() { - $('#upload-worksheet-dialog').dialog({ - title: "Upload Worksheet", - width: 600, - modal: true, - buttons: [ - { - text: "Upload & Update Batch", - click: function(event) { - var form = $('form[name="upload-worksheet"]'); - var field = form.find('input[type="file"]').get(0); - if (!field.value) { - alert("Please choose a file to upload."); - return - } - disable_button(dialog_button(event)); - form.submit(); - } - }, - { - text: "Cancel", - click: function() { - $(this).dialog('close'); - } - } - ] - }); - }); - % endif - }); - - </script> - % endif -</%def> - <%def name="extra_styles()"> ${parent.extra_styles()} - % if use_buefy: <style type="text/css"> .modal-card-body label { @@ -74,19 +14,6 @@ } </style> - % else: - <style type="text/css"> - - .grid-wrapper { - margin-top: 10px; - } - - .complete form { - display: inline; - } - - </style> - % endif </%def> <%def name="buttons()"> @@ -99,52 +26,39 @@ <%def name="leading_buttons()"> % if master.has_worksheet and master.allow_worksheet(batch) and master.has_perm('worksheet'): - % if use_buefy: - <once-button type="is-primary" - tag="a" href="${url('{}.worksheet'.format(route_prefix), uuid=batch.uuid)}" - icon-left="edit" - text="Edit as Worksheet"> - </once-button> - % else: - <button type="button" class="load-worksheet">Edit as Worksheet</button> - % endif + <once-button type="is-primary" + tag="a" href="${url('{}.worksheet'.format(route_prefix), uuid=batch.uuid)}" + icon-left="edit" + text="Edit as Worksheet"> + </once-button> % endif </%def> <%def name="refresh_button()"> % if master.batch_refreshable(batch) and master.has_perm('refresh'): - % if use_buefy: - ## TODO: this should surely use a POST request? - <once-button type="is-primary" - tag="a" href="${url('{}.refresh'.format(route_prefix), uuid=batch.uuid)}" - text="Refresh Data" - icon-left="redo"> - </once-button> - % else: - <button type="button" class="button" id="refresh-data">Refresh Data</button> - % endif + ## TODO: this should surely use a POST request? + <once-button type="is-primary" + tag="a" href="${url('{}.refresh'.format(route_prefix), uuid=batch.uuid)}" + text="Refresh Data" + icon-left="redo"> + </once-button> % endif </%def> <%def name="trailing_buttons()"> % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'): - % if use_buefy: - <b-button tag="a" - href="${master.get_action_url('download_worksheet', batch)}" - icon-pack="fas" - icon-left="fas fa-download"> - Download Worksheet - </b-button> - <b-button type="is-primary" - icon-pack="fas" - icon-left="fas fa-upload" - @click="$emit('show-upload')"> - Upload Worksheet - </b-button> - % else: - ${h.link_to("Download Worksheet", master.get_action_url('download_worksheet', batch), class_='button')} - <button type="button" class="upload-worksheet">Upload Worksheet</button> - % endif + <b-button tag="a" + href="${master.get_action_url('download_worksheet', batch)}" + icon-pack="fas" + icon-left="fas fa-download"> + Download Worksheet + </b-button> + <b-button type="is-primary" + icon-pack="fas" + icon-left="fas fa-upload" + @click="$emit('show-upload')"> + Upload Worksheet + </b-button> % endif </%def> @@ -154,34 +68,12 @@ </%def> <%def name="render_status_breakdown()"> - % if use_buefy: - <div class="object-helper"> - <h3>Row Status Breakdown</h3> - <div class="object-helper-content"> - ${status_breakdown_grid} - </div> - </div> - % elif status_breakdown is not Undefined and status_breakdown is not None: - <div class="object-helper"> - <h3>Row Status Breakdown</h3> - <div class="object-helper-content"> - % if status_breakdown: - <div class="grid full"> - <table> - % for i, (status, count) in enumerate(status_breakdown): - <tr class="${'even' if i % 2 == 0 else 'odd'}"> - <td>${status}</td> - <td>${count}</td> - </tr> - % endfor - </table> - </div> - % else: - <p>Nothing to report yet.</p> - % endif - </div> - </div> - % endif + <div class="object-helper"> + <h3>Row Status Breakdown</h3> + <div class="object-helper-content"> + ${status_breakdown_grid} + </div> + </div> </%def> <%def name="render_execute_helper()"> @@ -197,22 +89,21 @@ % elif master.handler.executable(batch): % if master.has_perm('execute'): <p>Batch has not yet been executed.</p> - % if use_buefy: - <br /> - <b-button type="is-primary" - % if not execute_enabled: - disabled - % if why_not_execute: - title="${why_not_execute}" - % endif - % endif - @click="showExecutionDialog = true" - icon-pack="fas" - icon-left="arrow-circle-right"> - ${execute_title} - </b-button> + <br /> + <b-button type="is-primary" + % if not execute_enabled: + disabled + % if why_not_execute: + title="${why_not_execute}" + % endif + % endif + @click="showExecutionDialog = true" + icon-pack="fas" + icon-left="arrow-circle-right"> + ${execute_title} + </b-button> - % if execute_enabled: + % if execute_enabled: <b-modal has-modal-card :active.sync="showExecutionDialog"> <div class="modal-card"> @@ -245,22 +136,8 @@ </div> </b-modal> - % endif - - % else: - ## no buefy, do legacy thing - <button type="button" - % if not execute_enabled: - disabled="disabled" - % endif - % if why_not_execute: - title="${why_not_execute}" - % endif - class="button is-primary" - id="execute-batch"> - ${execute_title} - </button> % endif + % else: <p>TODO: batch *may* be executed, but not by *you*</p> % endif @@ -281,71 +158,51 @@ ${parent.render_this_page()} % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'): - % if use_buefy: - <b-modal has-modal-card - :active.sync="showUploadDialog"> - <div class="modal-card"> + <b-modal has-modal-card + :active.sync="showUploadDialog"> + <div class="modal-card"> - <header class="modal-card-head"> - <p class="modal-card-title">Upload Worksheet</p> - </header> + <header class="modal-card-head"> + <p class="modal-card-title">Upload Worksheet</p> + </header> - <section class="modal-card-body"> - <p> - This will <span class="has-text-weight-bold">update</span> - the batch data with the worksheet file you provide. - Please be certain to use the right one! - </p> - <br /> - <${upload_worksheet_form.component} ref="uploadForm"> - </${upload_worksheet_form.component}> - </section> - - <footer class="modal-card-foot"> - <b-button @click="showUploadDialog = false"> - Cancel - </b-button> - <b-button type="is-primary" - @click="submitUpload()" - icon-pack="fas" - icon-left="fas fa-upload" - :disabled="uploadButtonDisabled"> - {{ uploadButtonText }} - </b-button> - </footer> - - </div> - </b-modal> - % else: - <div id="upload-worksheet-dialog" style="display: none;"> + <section class="modal-card-body"> <p> - This will <strong>update</strong> the batch data with the worksheet - file you provide. Please be certain to use the right one! + This will <span class="has-text-weight-bold">update</span> + the batch data with the worksheet file you provide. + Please be certain to use the right one! </p> - ${upload_worksheet_form.render_deform(buttons=False, form_kwargs={'name': 'upload-worksheet'})|n} - </div> - % endif - % endif + <br /> + <${upload_worksheet_form.component} ref="uploadForm"> + </${upload_worksheet_form.component}> + </section> - % if not use_buefy: - % if master.handler.executable(batch) and master.has_perm('execute'): - <div id="execution-options-dialog" style="display: none;"> - ${execute_form.render_deform(form_kwargs={'name': 'batch-execution'}, buttons=False)|n} - </div> - % endif + <footer class="modal-card-foot"> + <b-button @click="showUploadDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + @click="submitUpload()" + icon-pack="fas" + icon-left="fas fa-upload" + :disabled="uploadButtonDisabled"> + {{ uploadButtonText }} + </b-button> + </footer> + + </div> + </b-modal> % endif </%def> <%def name="render_this_page_template()"> ${parent.render_this_page_template()} - % if use_buefy: - % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'): - ${upload_worksheet_form.render_deform(buttons=False, form_kwargs={'ref': 'actualUploadForm'})|n} - % endif - % if master.handler.executable(batch) and master.has_perm('execute'): - ${execute_form.render_deform(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)|n} - % endif + % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'): + ${upload_worksheet_form.render_deform(buttons=False, form_kwargs={'ref': 'actualUploadForm'})|n} + % endif + % if master.handler.executable(batch) and master.has_perm('execute'): + ${execute_form.render_deform(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)|n} % endif </%def> @@ -358,7 +215,7 @@ <%def name="render_row_grid_tools()"> ${parent.render_row_grid_tools()} - % if use_buefy and master.rows_bulk_deletable and not batch.executed and master.has_perm('delete_rows'): + % if master.rows_bulk_deletable and not batch.executed and master.has_perm('delete_rows'): <b-button type="is-danger" @click="deleteResultsInit()" :disabled="!total" diff --git a/tailbone/templates/batch/worksheet.mako b/tailbone/templates/batch/worksheet.mako index cf19a0e0..a0dca748 100644 --- a/tailbone/templates/batch/worksheet.mako +++ b/tailbone/templates/batch/worksheet.mako @@ -1,26 +1,6 @@ ## -*- coding: utf-8; -*- <%inherit file="/page.mako" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - <script type="text/javascript"> - - $(function() { - - $('.worksheet .current-entry input').focus(function(event) { - $(this).parents('tr:first').addClass('active'); - }); - - $('.worksheet .current-entry input').blur(function(event) { - $(this).parents('tr:first').removeClass('active'); - }); - - }); - </script> - % endif -</%def> - <%def name="extra_styles()"> ${parent.extra_styles()} <style type="text/css"> diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index f75b6c65..77129fb8 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -4,22 +4,16 @@ <%def name="extra_styles()"> ${parent.extra_styles()} - % if use_buefy: - <style type="text/css"> - .this-page-content { - flex-grow: 1; - } - </style> - % endif + <style type="text/css"> + .this-page-content { + flex-grow: 1; + } + </style> </%def> <%def name="page_content()"> <br /> - % if use_buefy: - <customer-order-creator></customer-order-creator> - % else: - <p>Sorry, but this page is not supported by your current theme configuration.</p> - % endif + <customer-order-creator></customer-order-creator> </%def> <%def name="order_form_buttons()"> diff --git a/tailbone/templates/datasync/changes/index.mako b/tailbone/templates/datasync/changes/index.mako index e92c3c3c..b5aeb79a 100644 --- a/tailbone/templates/datasync/changes/index.mako +++ b/tailbone/templates/datasync/changes/index.mako @@ -12,38 +12,22 @@ ${parent.grid_tools()} % if request.has_perm('datasync.restart'): - % if use_buefy: ${h.form(url('datasync.restart'), name='restart-datasync', class_='control', **{'@submit': 'submitRestartDatasyncForm'})} - % else: - ${h.form(url('datasync.restart'), name='restart-datasync', class_='autodisable')} - % endif ${h.csrf_token(request)} - % if use_buefy: <b-button native-type="submit" :disabled="restartDatasyncFormSubmitting"> {{ restartDatasyncFormButtonText }} </b-button> - % else: - ${h.submit('submit', "Restart DataSync", data_working_label="Restarting DataSync", class_='button')} - % endif ${h.end_form()} % endif % if allow_filemon_restart and request.has_perm('filemon.restart'): - % if use_buefy: ${h.form(url('filemon.restart'), name='restart-filemon', class_='control', **{'@submit': 'submitRestartFilemonForm'})} - % else: - ${h.form(url('filemon.restart'), name='restart-filemon', class_='autodisable')} - % endif ${h.csrf_token(request)} - % if use_buefy: <b-button native-type="submit" :disabled="restartFilemonFormSubmitting"> {{ restartFilemonFormButtonText }} </b-button> - % else: - ${h.submit('submit', "Restart FileMon", data_working_label="Restarting FileMon", class_='button')} - % endif ${h.end_form()} % endif diff --git a/tailbone/templates/deform/autocomplete_jquery.pt b/tailbone/templates/deform/autocomplete_jquery.pt index dd9a6084..7a15c7f0 100644 --- a/tailbone/templates/deform/autocomplete_jquery.pt +++ b/tailbone/templates/deform/autocomplete_jquery.pt @@ -3,109 +3,10 @@ oid oid|field.oid; field_display field_display; style style|field.widget.style; - url url|field.widget.service_url; - use_buefy use_buefy|0;" + url url|field.widget.service_url;" tal:omit-tag=""> - <div tal:condition="not use_buefy" - id="${oid}-container" - class="autocomplete-container"> - <input type="hidden" - name="${name}" - id="${oid}" - value="${cstruct}" /> - - <input type="text" - name="${oid}-textbox" - id="${oid}-textbox" - value="${field_display}" - class="autocomplete-textbox" - style="display: none;" /> - - <div id="${oid}-display" - class="autocomplete-display" - style="display: none;"> - - <span>${field_display or ''}</span> - <button type="button" id="${oid}-change" class="autocomplete-change">Change</button> - - </div> - - <script type="text/javascript"> - deform.addCallback( - '${oid}', - function (oid) { - - $('#' + oid + '-textbox').autocomplete(${options}); - - $('#' + oid + '-textbox').on('autocompleteselect', function (event, ui) { - $('#' + oid).val(ui.item.value); - $('#' + oid + '-display span:first').text(ui.item.label); - $('#' + oid + '-textbox').hide(); - $('#' + oid + '-display').show(); - $('#' + oid + '-textbox').trigger('autocompletevalueselected', - [ui.item.value, ui.item.label]); - return false; - }); - - $('#' + oid + '-change').click(function() { - $('#' + oid).val(''); - $('#' + oid + '-display').hide(); - with ($('#' + oid + '-textbox')) { - val(''); - show(); - focus(); - } - $('#' + oid + '-textbox').trigger('autocompletevaluecleared'); - }); - - } - ); - </script> - - <script tal:condition="cleared_callback" type="text/javascript"> - deform.addCallback( - '${oid}', - function (oid) { - $('#' + oid + '-textbox').on('autocompletevaluecleared', function() { - ${cleared_callback}(); - }); - } - ); - </script> - - <script tal:condition="selected_callback" type="text/javascript"> - deform.addCallback( - '${oid}', - function (oid) { - $('#' + oid + '-textbox').on('autocompletevalueselected', function(event, uuid, label) { - ${selected_callback}(uuid, label); - }); - } - ); - </script> - - <script tal:condition="cstruct" type="text/javascript"> - deform.addCallback( - '${oid}', - function (oid) { - $('#' + oid + '-display').show(); - } - ); - </script> - - <script tal:condition="not cstruct" type="text/javascript"> - deform.addCallback( - '${oid}', - function (oid) { - $('#' + oid + '-textbox').show(); - } - ); - </script> - </div> - - <div tal:condition="use_buefy" - tal:define="vmodel vmodel|'field_model_' + name;" + <div tal:define="vmodel vmodel|'field_model_' + name;" tal:omit-tag=""> <tailbone-autocomplete name="${name}" ref="${ref}" diff --git a/tailbone/templates/deform/cases_units.pt b/tailbone/templates/deform/cases_units.pt index db4a49e0..b30d1d63 100644 --- a/tailbone/templates/deform/cases_units.pt +++ b/tailbone/templates/deform/cases_units.pt @@ -2,38 +2,11 @@ <div tal:define="oid oid|field.oid; name name|field.name; css_class css_class|field.widget.css_class; - style style|field.widget.style; - use_buefy use_buefy|0;" + style style|field.widget.style;" i18n:domain="deform" tal:omit-tag=""> - <div tal:condition="not use_buefy" tal:omit-tag=""> - ${field.start_mapping()} - <div> - <input type="text" name="cases" value="${cases}" - tal:attributes="style style; - class string: form-control ${css_class or ''}; - cases_attributes|field.widget.cases_attributes|{};" - placeholder="cases" - autocomplete="off" - id="${oid}-cases"/> - Cases - </div> - <div> - <input type="text" name="units" value="${units}" - tal:attributes="class string: form-control ${css_class or ''}; - style style; - units_attributes|field.widget.units_attributes|{};" - placeholder="units" - autocomplete="off" - id="${oid}-units"/> - Units - </div> - ${field.end_mapping()} - </div> - - <div tal:condition="use_buefy" - tal:define="vmodel vmodel|'field_model_' + name;" + <div tal:define="vmodel vmodel|'field_model_' + name;" tal:omit-tag=""> ${field.start_mapping()} diff --git a/tailbone/templates/deform/checkbox.pt b/tailbone/templates/deform/checkbox.pt index b00ced03..408fa1cb 100644 --- a/tailbone/templates/deform/checkbox.pt +++ b/tailbone/templates/deform/checkbox.pt @@ -2,21 +2,10 @@ true_val true_val|field.widget.true_val; css_class css_class|field.widget.css_class; style style|field.widget.style; - oid oid|field.oid; - use_buefy use_buefy|0;" + oid oid|field.oid;" tal:omit-tag=""> - <div tal:condition="not use_buefy" class="checkbox"> - <input type="checkbox" - name="${name}" value="${true_val}" - id="${oid}" - tal:attributes="checked cstruct == true_val; - class css_class; - style style;" /> - </div> - - <div tal:condition="use_buefy" - tal:define="vmodel vmodel|'field_model_' + name;"> + <div tal:define="vmodel vmodel|'field_model_' + name;"> <b-checkbox name="${name}" v-model="${vmodel}" native-value="${true_val}"> diff --git a/tailbone/templates/deform/checked_password.pt b/tailbone/templates/deform/checked_password.pt index 43657045..f78c0b85 100644 --- a/tailbone/templates/deform/checked_password.pt +++ b/tailbone/templates/deform/checked_password.pt @@ -2,37 +2,9 @@ tal:define="oid oid|field.oid; name name|field.name; css_class css_class|field.widget.css_class; - style style|field.widget.style; - use_buefy use_buefy|0;"> + style style|field.widget.style;"> - <div tal:condition="not use_buefy" tal:omit-tag=""> - ${field.start_mapping()} - <div> - <input type="password" - name="${name}" - value="${field.widget.redisplay and cstruct or ''}" - tal:attributes="class string: form-control ${css_class or ''}; - style style; - attributes|field.widget.attributes|{};" - id="${oid}" - i18n:attributes="placeholder" - placeholder="Password"/> - </div> - <div> - <input type="password" - name="${name}-confirm" - value="${field.widget.redisplay and confirm or ''}" - tal:attributes="class string: form-control ${css_class or ''}; - style style; - confirm_attributes|field.widget.confirm_attributes|{};" - id="${oid}-confirm" - i18n:attributes="placeholder" - placeholder="Confirm Password"/> - </div> - ${field.end_mapping()} - </div> - - <div tal:condition="use_buefy"> + <div> ${field.start_mapping()} <b-input type="password" name="${name}" diff --git a/tailbone/templates/deform/date_jquery.pt b/tailbone/templates/deform/date_jquery.pt index 0539b99a..c55021b9 100644 --- a/tailbone/templates/deform/date_jquery.pt +++ b/tailbone/templates/deform/date_jquery.pt @@ -3,40 +3,10 @@ oid oid|field.oid; field_name field_name|field.name; style style|field.widget.style; - type_name type_name|field.widget.type_name; - use_buefy use_buefy|0;" + type_name type_name|field.widget.type_name;" tal:omit-tag=""> - <div tal:condition="not use_buefy" tal:omit-tag=""> - ${field.start_mapping()} - <input type="${type_name}" - name="date" - value="${cstruct}" - - tal:attributes="class string: ${css_class or ''} form-control; - style style" - id="${oid}"/> - ${field.end_mapping()} - <script type="text/javascript"> - deform.addCallback( - '${oid}', - function deform_cb(oid) { - $('#' + oid).datepicker(${options_json}); - } - ); - </script> - <script tal:condition="selected_callback" type="text/javascript"> - deform.addCallback( - '${oid}', - function (oid) { - $('#' + oid).datepicker('option', 'onSelect', ${selected_callback}); - } - ); - </script> - </div> - - <div tal:condition="use_buefy" - tal:define="vmodel vmodel|'field_model_' + field_name;"> + <div tal:define="vmodel vmodel|'field_model_' + field_name;"> ${field.start_mapping()} <tailbone-datepicker name="date" id="${oid}" diff --git a/tailbone/templates/deform/file_upload.pt b/tailbone/templates/deform/file_upload.pt index 3cb83a5d..e165fdfa 100644 --- a/tailbone/templates/deform/file_upload.pt +++ b/tailbone/templates/deform/file_upload.pt @@ -2,29 +2,9 @@ <tal:block tal:define="oid oid|field.oid; css_class css_class|field.widget.css_class; style style|field.widget.style; - field_name field_name|field.name; - use_buefy use_buefy|0;"> + field_name field_name|field.name;"> - <div tal:condition="not use_buefy" tal:omit-tag=""> - ${field.start_mapping()} - <input type="file" name="upload" id="${oid}" - tal:attributes="style style; - accept accept|field.widget.accept; - data-filename cstruct.get('filename'); - attributes|field.widget.attributes|{};"/> - <input tal:define="uid cstruct.get('uid')" - tal:condition="uid" - type="hidden" name="uid" value="${uid}"/> - ${field.end_mapping()} - <script type="text/javascript"> - deform.addCallback('${oid}', function (oid) { - $('#' + oid).upload(); - }); - </script> - </div> - - <div tal:condition="use_buefy" - tal:define="vmodel vmodel|'field_model_' + field_name;"> + <div tal:define="vmodel vmodel|'field_model_' + field_name;"> ${field.start_mapping()} <b-field class="file"> <b-upload name="upload" diff --git a/tailbone/templates/deform/password.pt b/tailbone/templates/deform/password.pt index 9ad77d1b..d81b570f 100644 --- a/tailbone/templates/deform/password.pt +++ b/tailbone/templates/deform/password.pt @@ -3,21 +3,9 @@ oid oid|field.oid; mask mask|field.widget.mask; mask_placeholder mask_placeholder|field.widget.mask_placeholder; - style style|field.widget.style; - use_buefy use_buefy|0;" + style style|field.widget.style;" tal:omit-tag=""> - - <input tal:condition="not use_buefy" - type="password" - name="${name}" - value="${field.widget.redisplay and cstruct or ''}" - tal:attributes="style style; - class string: form-control ${css_class or ''}; - attributes|field.widget.attributes|{};" - id="${oid}" /> - - <div tal:condition="use_buefy" - tal:define="vmodel vmodel|'field_model_' + name;" + <div tal:define="vmodel vmodel|'field_model_' + name;" tal:omit-tag=""> <b-input name="${name}" v-model="${vmodel}" diff --git a/tailbone/templates/deform/percentinput.pt b/tailbone/templates/deform/percentinput.pt index 40aa71f1..d76e5848 100644 --- a/tailbone/templates/deform/percentinput.pt +++ b/tailbone/templates/deform/percentinput.pt @@ -8,26 +8,7 @@ autocomplete autocomplete|field.widget.autocomplete|'off';" tal:omit-tag=""> - <div tal:condition="not use_buefy" tal:omit-tag=""> - <input type="text" name="${name}" value="${cstruct}" - tal:attributes="class string: form-control ${css_class or ''}; - style style; - autocomplete autocomplete; - " - id="${oid}"/> - % - <script tal:condition="mask" type="text/javascript"> - deform.addCallback( - '${oid}', - function (oid) { - $("#" + oid).mask("${mask}", - {placeholder:"${mask_placeholder}"}); - }); - </script> - </div> - - <div tal:condition="use_buefy" - tal:define="vmodel vmodel|'field_model_' + field_name;"> + <div tal:define="vmodel vmodel|'field_model_' + field_name;"> <!-- TODO: need to handle mask somehow? --> <b-input name="${field_name}" id="${oid}" diff --git a/tailbone/templates/deform/permissions.pt b/tailbone/templates/deform/permissions.pt index f5cbeef4..b32f36ea 100644 --- a/tailbone/templates/deform/permissions.pt +++ b/tailbone/templates/deform/permissions.pt @@ -1,41 +1,8 @@ <div tal:define="oid oid|field.oid; - true_val true_val|field.widget.true_val; - use_buefy use_buefy|0;" + true_val true_val|field.widget.true_val;" tal:omit-tag=""> - <div tal:condition="not use_buefy" - tal:omit-tag=""> - ${field.start_mapping()} - - <div class="permissions-outer"> - - <tal:loop tal:repeat="groupkey sorted(permissions, key=lambda k: permissions[k]['label'].lower())"> - <div tal:define="perms permissions[groupkey]['perms'];" - class="permissions-group"> - <p class="group-label">${permissions[groupkey]['label']}</p> - - <tal:loop tal:repeat="key sorted(perms, key=lambda p: perms[p]['label'].lower())"> - <div class="perm"> - <label> - <input type="checkbox" - name="${key}" - id="${oid}-${key}" - value="${true_val}" - tal:attributes="checked python:field.widget.get_checked_value(cstruct, key);" /> - ${perms[key]['label']} - </label> - </div> - </tal:loop> - - </div> - </tal:loop> - - </div> - - ${field.end_mapping()} - </div> - - <div tal:condition="use_buefy"> + <div> ${field.start_mapping()} <div class="level"> diff --git a/tailbone/templates/deform/select.pt b/tailbone/templates/deform/select.pt index 4295380b..b033a8e2 100644 --- a/tailbone/templates/deform/select.pt +++ b/tailbone/templates/deform/select.pt @@ -7,58 +7,10 @@ unicode unicode|str; optgroup_class optgroup_class|field.widget.optgroup_class; multiple multiple|field.widget.multiple; - use_buefy use_buefy|0; input_handler input_handler|'';" tal:omit-tag=""> - <div tal:condition="not use_buefy" tal:omit-tag=""> - <input type="hidden" name="__start__" value="${name}:sequence" - tal:condition="multiple" /> - <div class="select"> - <select tal:attributes=" - name name; - id oid; - class string: form-control ${css_class or ''}; - multiple multiple; - size size; - style style;"> - <tal:loop tal:repeat="item values"> - <optgroup tal:condition="isinstance(item, optgroup_class)" - tal:attributes="label item.label"> - <option tal:repeat="(value, description) item.options" - tal:attributes=" - selected python:field.widget.get_select_value(cstruct, value); - class css_class; - label field.widget.long_label_generator and description; - value value" - tal:content="field.widget.long_label_generator and field.widget.long_label_generator(item.label, description) or description"/> - </optgroup> - <option tal:condition="not isinstance(item, optgroup_class)" - tal:attributes=" - selected python:field.widget.get_select_value(cstruct, item[0]); - class css_class; - value item[0]">${item[1]}</option> - </tal:loop> - </select> - </div> - <input type="hidden" name="__end__" value="${name}:sequence" - tal:condition="multiple" /> - <script tal:condition="not multiple" type="text/javascript"> - deform.addCallback( - '${oid}', - function(oid) { - $('#' + oid).selectmenu(); - $('#' + oid).on('selectmenuopen', function(event, ui) { - show_all_options($(this)); - }); - } - ); - </script> - </div> - - <div tal:condition="use_buefy" - tal:define="vmodel vmodel|'field_model_' + name;" - > + <div tal:define="vmodel vmodel|'field_model_' + name;"> <input type="hidden" name="__start__" value="${name}:sequence" tal:condition="multiple" /> <b-select tal:attributes="name name; diff --git a/tailbone/templates/deform/textarea.pt b/tailbone/templates/deform/textarea.pt index 25583b4e..f705c652 100644 --- a/tailbone/templates/deform/textarea.pt +++ b/tailbone/templates/deform/textarea.pt @@ -3,22 +3,10 @@ css_class css_class|field.widget.css_class; oid oid|field.oid; name name|field.name; - style style|field.widget.style; - use_buefy use_buefy|0;" + style style|field.widget.style;" tal:omit-tag=""> - <div tal:condition="not use_buefy" tal:omit-tag=""> - <textarea tal:attributes="rows rows; - cols cols; - class string: form-control ${css_class or ''}; - style style; - attributes|field.widget.attributes|{};" - id="${oid}" - name="${name}">${cstruct}</textarea> - </div> - - <div tal:condition="use_buefy" - tal:define="vmodel vmodel|'field_model_' + name;"> + <div tal:define="vmodel vmodel|'field_model_' + name;"> <b-input type="textarea" name="${name}" v-model="${vmodel}"> diff --git a/tailbone/templates/deform/textinput.pt b/tailbone/templates/deform/textinput.pt index 52873cb7..47621654 100644 --- a/tailbone/templates/deform/textinput.pt +++ b/tailbone/templates/deform/textinput.pt @@ -4,29 +4,10 @@ mask mask|field.widget.mask; mask_placeholder mask_placeholder|field.widget.mask_placeholder; style style|field.widget.style; - use_buefy use_buefy|0; placeholder placeholder|getattr(field.widget, 'placeholder', ''); autocomplete autocomplete|getattr(field.widget, 'autocomplete', 'on');" tal:omit-tag=""> - <div tal:condition="not use_buefy" tal:omit-tag=""> - <input type="text" name="${name}" value="${cstruct}" - tal:attributes="class string: form-control ${css_class or ''}; - style style; - attributes|field.widget.attributes|{};" - autocomplete="${autocomplete}" - id="${oid}"/> - <script tal:condition="mask" type="text/javascript"> - deform.addCallback( - '${oid}', - function (oid) { - $("#" + oid).mask("${mask}", - {placeholder:"${mask_placeholder}"}); - }); - </script> - </div> - - <div tal:condition="use_buefy" - tal:define="vmodel vmodel|'field_model_' + name;" + <div tal:define="vmodel vmodel|'field_model_' + name;" tal:omit-tag=""> <b-input tal:attributes="name name; v-model vmodel; diff --git a/tailbone/templates/deform/time_jquery.pt b/tailbone/templates/deform/time_jquery.pt index 1575b3fa..8fa6cbe7 100644 --- a/tailbone/templates/deform/time_jquery.pt +++ b/tailbone/templates/deform/time_jquery.pt @@ -4,32 +4,10 @@ oid oid|field.oid; style style|field.widget.style|None; type_name type_name|field.widget.type_name; - field_name field_name|field.name; - use_buefy use_buefy|0;" + field_name field_name|field.name;" tal:omit-tag=""> - <div tal:condition="not use_buefy" tal:omit-tag=""> - ${field.start_mapping()} - <input type="${type_name}" - name="time" - value="${cstruct}" - tal:attributes="size size; - class string: ${css_class or ''} form-control; - style style" - id="${oid}"/> - ${field.end_mapping()} - <script type="text/javascript"> - deform.addCallback( - '${oid}', - function(oid) { - $('#' + oid).timepicker(${options_json}); - } - ); - </script> - </div> - - <div tal:condition="use_buefy" - tal:define="vmodel vmodel|'field_model_' + field_name;"> + <div tal:define="vmodel vmodel|'field_model_' + field_name;"> ${field.start_mapping()} <tailbone-timepicker name="time" id="${oid}" diff --git a/tailbone/templates/departments/view.mako b/tailbone/templates/departments/view.mako index 20c2a266..442f045f 100644 --- a/tailbone/templates/departments/view.mako +++ b/tailbone/templates/departments/view.mako @@ -1,20 +1,6 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -<%def name="page_content()"> - ${parent.page_content()} - % if not use_buefy: - <h2>Employees</h2> - - % if employees: - <p>The following employees are assigned to this department:</p> - ${employees.render_grid()|n} - % else: - <p>No employees are assigned to this department.</p> - % endif - % endif -</%def> - <%def name="modify_this_page_vars()"> ${parent.modify_this_page_vars()} <script type="text/javascript"> diff --git a/tailbone/templates/email-bounces/view.mako b/tailbone/templates/email-bounces/view.mako index d24f3a00..df5f7842 100644 --- a/tailbone/templates/email-bounces/view.mako +++ b/tailbone/templates/email-bounces/view.mako @@ -3,36 +3,8 @@ ## TODO: this page still uses jQuery but should use Vue.js -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - <script type="text/javascript"> - - function autosize_message(scrolldown) { - var msg = $('#message'); - var height = $(window).height() - msg.offset().top - 50; - msg.height(height); - if (scrolldown) { - msg.animate({scrollTop: msg.get(0).scrollHeight - height}, 250); - } - } - - $(function () { - autosize_message(true); - $('#message').focus(); - }); - - $(window).resize(function() { - autosize_message(false); - }); - - </script> - % endif -</%def> - <%def name="extra_styles()"> ${parent.extra_styles()} - % if use_buefy: <style type="text/css"> .email-message-body { border: 1px solid #000000; @@ -40,81 +12,48 @@ height: 500px; } </style> - % else: - <style type="text/css"> - #message { - border: 1px solid #000000; - height: 400px; - overflow: auto; - padding: 4px; - } - </style> - % endif </%def> <%def name="object_helpers()"> ${parent.object_helpers()} - % if use_buefy: - <nav class="panel"> - <p class="panel-heading">Processing</p> - <div class="panel-block"> - <div class="display: flex; flex-align: column;"> - % if bounce.processed: - <p class="block"> - This bounce was processed - ${h.pretty_datetime(request.rattail_config, bounce.processed)} - by ${bounce.processed_by} - </p> - % if master.has_perm('unprocess'): - <once-button type="is-warning" - tag="a" href="${url('emailbounces.unprocess', uuid=bounce.uuid)}" - text="Mark this bounce as UN-processed"> - </once-button> - % endif - % else: - <p class="block"> - This bounce has NOT yet been processed. - </p> - % if master.has_perm('process'): - <once-button type="is-primary" - tag="a" href="${url('emailbounces.process', uuid=bounce.uuid)}" - text="Mark this bounce as Processed"> - </once-button> - % endif + <nav class="panel"> + <p class="panel-heading">Processing</p> + <div class="panel-block"> + <div class="display: flex; flex-align: column;"> + % if bounce.processed: + <p class="block"> + This bounce was processed + ${h.pretty_datetime(request.rattail_config, bounce.processed)} + by ${bounce.processed_by} + </p> + % if master.has_perm('unprocess'): + <once-button type="is-warning" + tag="a" href="${url('emailbounces.unprocess', uuid=bounce.uuid)}" + text="Mark this bounce as UN-processed"> + </once-button> % endif - </div> - </div> - </nav> - % endif -</%def> - -<%def name="context_menu_items()"> - ${parent.context_menu_items()} - % if not use_buefy: - % if not bounce.processed and request.has_perm('emailbounces.process'): - <li>${h.link_to("Mark this Email Bounce as Processed", url('emailbounces.process', uuid=bounce.uuid))}</li> - % elif bounce.processed and request.has_perm('emailbounces.unprocess'): - <li>${h.link_to("Mark this Email Bounce as UN-processed", url('emailbounces.unprocess', uuid=bounce.uuid))}</li> - % endif - % endif -</%def> - -<%def name="page_content()"> - ${parent.page_content()} - % if not use_buefy: - <pre id="message"> - ${message} - </pre> - % endif + % else: + <p class="block"> + This bounce has NOT yet been processed. + </p> + % if master.has_perm('process'): + <once-button type="is-primary" + tag="a" href="${url('emailbounces.process', uuid=bounce.uuid)}" + text="Mark this bounce as Processed"> + </once-button> + % endif + % endif + </div> + </div> + </nav> </%def> <%def name="render_this_page()"> ${parent.render_this_page()} - % if use_buefy: - <pre class="email-message-body"> - ${message} - </pre> - % endif + <pre class="email-message-body"> + ${message} + </pre> </%def> + ${parent.body()} diff --git a/tailbone/templates/form.mako b/tailbone/templates/form.mako index a00b8d97..cb6ef9c1 100644 --- a/tailbone/templates/form.mako +++ b/tailbone/templates/form.mako @@ -22,12 +22,8 @@ <%def name="page_content()"> <div class="form-wrapper"> - % if use_buefy: - <br /> - ${self.render_buefy_form()} - % else: - ${self.render_form()} - % endif + <br /> + ${self.render_buefy_form()} </div> </%def> diff --git a/tailbone/templates/forms/form.mako b/tailbone/templates/forms/form.mako index 71e28817..cd8fecc8 100644 --- a/tailbone/templates/forms/form.mako +++ b/tailbone/templates/forms/form.mako @@ -1,8 +1,2 @@ ## -*- coding: utf-8; -*- -% if form.use_buefy: - ${form.render_deform(buttons=buttons)|n} -% else: - <div class="form"> - ${form.render_deform(buttons=buttons)|n} - </div> -% endif +${form.render_deform(buttons=buttons)|n} diff --git a/tailbone/templates/login.mako b/tailbone/templates/login.mako index 0e65b4ad..e8f06848 100644 --- a/tailbone/templates/login.mako +++ b/tailbone/templates/login.mako @@ -4,36 +4,27 @@ <%def name="title()">Login</%def> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - ${h.javascript_link(request.static_url('tailbone:static/js/login.js'))} -</%def> - <%def name="extra_styles()"> ${parent.extra_styles()} - % if use_buefy: - <style type="text/css"> - .logo img { - display: block; - margin: 3rem auto; - max-height: 350px; - max-width: 800px; - } + <style type="text/css"> + .logo img { + display: block; + margin: 3rem auto; + max-height: 350px; + max-width: 800px; + } - /* must force a particular label with, in order to make sure */ - /* the username and password inputs are the same size */ - .field.is-horizontal .field-label .label { - text-align: left; - width: 6rem; - } + /* must force a particular label with, in order to make sure */ + /* the username and password inputs are the same size */ + .field.is-horizontal .field-label .label { + text-align: left; + width: 6rem; + } - .buttons { - justify-content: right; - } - </style> - % else: - ${h.stylesheet_link(request.static_url('tailbone:static/css/login.css'))} - % endif + .buttons { + justify-content: right; + } + </style> </%def> <%def name="logo()"> @@ -55,21 +46,15 @@ ${self.logo()} </div> - % if use_buefy: - - <div class="columns is-centered"> - <div class="column is-narrow"> - <div class="card"> - <div class="card-content"> - <tailbone-form></tailbone-form> - </div> - </div> + <div class="columns is-centered"> + <div class="column is-narrow"> + <div class="card"> + <div class="card-content"> + <tailbone-form></tailbone-form> </div> </div> - - % else: - ${self.login_form()} - % endif + </div> + </div> </%def> diff --git a/tailbone/templates/master/clone.mako b/tailbone/templates/master/clone.mako index 17b13751..07784f74 100644 --- a/tailbone/templates/master/clone.mako +++ b/tailbone/templates/master/clone.mako @@ -5,49 +5,31 @@ <%def name="render_buefy_form()"> <br /> - % if use_buefy: - <b-notification :closable="false"> - You are about to clone the following ${model_title} as a new record: - </b-notification> - % else: - <p>You are about to clone the following ${model_title} as a new record:</p> - % endif - + <b-notification :closable="false"> + You are about to clone the following ${model_title} as a new record: + </b-notification> ${parent.render_buefy_form()} </%def> <%def name="render_form_buttons()"> <br /> - % if use_buefy: - <b-notification :closable="false"> - Are you sure about this? - </b-notification> - % else: - <p>Are you sure about this?</p> - % endif + <b-notification :closable="false"> + Are you sure about this? + </b-notification> <br /> - % if use_buefy: ${h.form(request.current_route_url(), **{'@submit': 'submitForm'})} - % else: - ${h.form(request.current_route_url(), class_='autodisable')} - % endif ${h.csrf_token(request)} ${h.hidden('clone', value='clone')} <div class="buttons"> - % if use_buefy: - <once-button tag="a" href="${form.cancel_url}" - text="Whoops, nevermind..."> - </once-button> - <b-button type="is-primary" - native-type="submit" - :disabled="formSubmitting"> - {{ submitButtonText }} - </b-button> - % else: - ${h.link_to("Whoops, nevermind...", form.cancel_url, class_='button autodisable')} - ${h.submit('submit', "Yes, please clone away")} - % endif + <once-button tag="a" href="${form.cancel_url}" + text="Whoops, nevermind..."> + </once-button> + <b-button type="is-primary" + native-type="submit" + :disabled="formSubmitting"> + {{ submitButtonText }} + </b-button> </div> ${h.end_form()} </%def> diff --git a/tailbone/templates/master/delete.mako b/tailbone/templates/master/delete.mako index dded378f..0cb5b6c2 100644 --- a/tailbone/templates/master/delete.mako +++ b/tailbone/templates/master/delete.mako @@ -3,66 +3,32 @@ <%def name="title()">Delete ${model_title}: ${instance_title}</%def> -<%def name="context_menu_items()"> - % if not use_buefy and master.viewable and master.has_perm('view'): - <li>${h.link_to("View this {}".format(model_title), action_url('view', instance))}</li> - % endif - % if not use_buefy and master.editable and master.has_perm('edit'): - <li>${h.link_to("Edit this {}".format(model_title), action_url('edit', instance))}</li> - % endif - % if not use_buefy and master.creatable and master.show_create_link and master.has_perm('create'): - % if master.creates_multiple: - <li>${h.link_to("Create new {}".format(model_title_plural), url('{}.create'.format(route_prefix)))}</li> - % else: - <li>${h.link_to("Create a new {}".format(model_title), url('{}.create'.format(route_prefix)))}</li> - % endif - % endif -</%def> - <%def name="render_buefy_form()"> <br /> - % if use_buefy: - <b-notification type="is-danger" :closable="false"> - You are about to delete the following ${model_title} and all associated data: - </b-notification> - % else: - <p>You are about to delete the following ${model_title} and all associated data:</p> - % endif - + <b-notification type="is-danger" :closable="false"> + You are about to delete the following ${model_title} and all associated data: + </b-notification> ${parent.render_buefy_form()} </%def> <%def name="render_form_buttons()"> <br /> - % if use_buefy: - <b-notification type="is-danger" :closable="false"> - Are you sure about this? - </b-notification> - % else: - <p>Are you sure about this?</p> - % endif + <b-notification type="is-danger" :closable="false"> + Are you sure about this? + </b-notification> <br /> - % if use_buefy: ${h.form(request.current_route_url(), **{'@submit': 'submitForm'})} - % else: - ${h.form(request.current_route_url(), class_='autodisable')} - % endif ${h.csrf_token(request)} <div class="buttons"> - % if use_buefy: - <once-button tag="a" href="${form.cancel_url}" - text="Whoops, nevermind..."> - </once-button> - <b-button type="is-primary is-danger" - native-type="submit" - :disabled="formSubmitting"> - {{ formButtonText }} - </b-button> - % else: - <a class="button" href="${form.cancel_url}">Whoops, nevermind...</a> - ${h.submit('submit', "Yes, please DELETE this data forever!", class_='button is-primary')} - % endif + <once-button tag="a" href="${form.cancel_url}" + text="Whoops, nevermind..."> + </once-button> + <b-button type="is-primary is-danger" + native-type="submit" + :disabled="formSubmitting"> + {{ formButtonText }} + </b-button> </div> ${h.end_form()} </%def> diff --git a/tailbone/templates/master/edit.mako b/tailbone/templates/master/edit.mako index de0cb524..f1bc7318 100644 --- a/tailbone/templates/master/edit.mako +++ b/tailbone/templates/master/edit.mako @@ -3,38 +3,5 @@ <%def name="title()">Edit: ${instance_title}</%def> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - <script type="text/javascript"> - - $(function() { - - $('form').submit(function() { - var submit = $(this).find('input[type="submit"]'); - if (submit.length) { - submit.button('disable').button('option', 'label', "Saving, please wait..."); - } - }); - - }); - </script> - % endif -</%def> - -<%def name="context_menu_items()"> - % if not use_buefy and master.viewable and master.has_perm('view'): - <li>${h.link_to("View this {}".format(model_title), action_url('view', instance))}</li> - % endif - ${self.context_menu_item_delete()} - % if not use_buefy and master.creatable and master.show_create_link and master.has_perm('create'): - % if master.creates_multiple: - <li>${h.link_to("Create new {}".format(model_title_plural), url('{}.create'.format(route_prefix)))}</li> - % else: - <li>${h.link_to("Create a new {}".format(model_title), url('{}.create'.format(route_prefix)))}</li> - % endif - % endif -</%def> - ${parent.body()} diff --git a/tailbone/templates/master/form.mako b/tailbone/templates/master/form.mako index a37e3f91..c142d8ef 100644 --- a/tailbone/templates/master/form.mako +++ b/tailbone/templates/master/form.mako @@ -1,31 +1,6 @@ ## -*- coding: utf-8; -*- <%inherit file="/form.mako" /> -<%def name="context_menu_item_delete()"> - % if not use_buefy and master.deletable and instance_deletable and master.has_perm('delete'): - % if master.delete_confirm == 'simple': - <li> - ## note, the `ref` here is for buefy only - ${h.form(action_url('delete', instance), ref='deleteObjectForm')} - ${h.csrf_token(request)} - <a href="${action_url('delete', instance)}" - % if use_buefy: - @click.prevent="deleteObject" - % else: - class="delete-instance" - % endif - > - Delete this ${model_title} - </a> - ${h.end_form()} - </li> - % else: - ## assuming here that: delete_confirm == 'full' - <li>${h.link_to("Delete this {}".format(model_title), action_url('delete', instance))}</li> - % endif - % endif -</%def> - <%def name="modify_this_page_vars()"> ${parent.modify_this_page_vars()} % if master.deletable and instance_deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple': diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako index 053e09fb..d2215abe 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -12,142 +12,6 @@ <%def name="content_title()"></%def> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - <script type="text/javascript"> - $(function() { - - % if download_results_rows_path: - function downloadResultsRowsRedirect() { - location.href = '${url('{}.download_results_rows'.format(route_prefix))}?filename=${h.os.path.basename(download_results_rows_path)}'; - } - // we give this 1 second before attempting the redirect; so this - // way the page should fully render before redirecting - window.setTimeout(downloadResultsRowsRedirect, 1000); - % endif - - $('.grid-wrapper').gridwrapper(); - - % if master.deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple': - - $('.grid-wrapper').on('click', '.grid .actions a.delete', function() { - if (confirm("Are you sure you wish to delete this ${model_title}?")) { - var link = $(this).get(0); - var form = $('#delete-object-form').get(0); - form.action = link.href; - form.submit(); - } - return false; - }); - - % endif - - % if master.mergeable and master.has_perm('merge'): - - $('form[name="merge-things"] button').button('option', 'disabled', $('.grid').gridcore('count_selected') != 2); - - $('.grid-wrapper').on('gridchecked', '.grid', function(event, count) { - $('form[name="merge-things"] button').button('option', 'disabled', count != 2); - }); - - $('form[name="merge-things"]').submit(function() { - var uuids = $('.grid').gridcore('selected_uuids'); - if (uuids.length != 2) { - return false; - } - $(this).find('[name="uuids"]').val(uuids.toString()); - $(this).find('button') - .button('option', 'label', "Preparing to Merge...") - .button('disable'); - }); - - % endif - - % if master.has_rows and master.results_rows_downloadable: - - $('#download-row-results-button').click(function() { - if (confirm("This will generate an Excel file which contains " - + "not the results themselves, but the *rows* for " - + "each.\n\nAre you sure you want this?")) { - disable_button(this); - var form = $(this).parents('form'); - form.submit(); - } - }); - - % endif - - % if master.bulk_deletable and request.has_perm('{}.bulk_delete'.format(permission_prefix)): - - $('form[name="bulk-delete"] button').click(function() { - var count = $('.grid-wrapper').gridwrapper('results_count', true); - if (count === null) { - alert("There don't seem to be any results to delete!"); - return; - } - if (! confirm("You are about to delete " + count + " ${model_title_plural}.\n\nAre you sure?")) { - return - } - $(this).button('disable').button('option', 'label', "Deleting Results..."); - $('form[name="bulk-delete"]').submit(); - }); - - % endif - - % if master.supports_set_enabled_toggle and request.has_perm('{}.enable_disable_set'.format(permission_prefix)): - $('form[name="enable-set"] button').click(function() { - var form = $(this).parents('form'); - var uuids = $('.grid').gridcore('selected_uuids'); - if (! uuids.length) { - alert("You must first select one or more objects to enable."); - return false; - } - if (! confirm("Are you sure you wish to ENABLE the " + uuids.length + " selected objects?")) { - return false; - } - form.find('[name="uuids"]').val(uuids.toString()); - disable_button(this); - form.submit(); - }); - - $('form[name="disable-set"] button').click(function() { - var form = $(this).parents('form'); - var uuids = $('.grid').gridcore('selected_uuids'); - if (! uuids.length) { - alert("You must first select one or more objects to disable."); - return false; - } - if (! confirm("Are you sure you wish to DISABLE the " + uuids.length + " selected objects?")) { - return false; - } - form.find('[name="uuids"]').val(uuids.toString()); - disable_button(this); - form.submit(); - }); - % endif - - % if master.set_deletable and request.has_perm('{}.delete_set'.format(permission_prefix)): - $('form[name="delete-set"] button').click(function() { - var form = $(this).parents('form'); - var uuids = $('.grid').gridcore('selected_uuids'); - if (! uuids.length) { - alert("You must first select one or more objects to delete."); - return false; - } - if (! confirm("Are you sure you wish to DELETE the " + uuids.length + " selected objects?")) { - return false; - } - form.find('[name="uuids"]').val(uuids.toString()); - disable_button(this); - form.submit(); - }); - % endif - }); - </script> - % endif -</%def> - <%def name="context_menu_items()"> % if master.results_downloadable_csv and request.has_perm('{}.results_csv'.format(permission_prefix)): <li>${h.link_to("Download results as CSV", url('{}.results_csv'.format(route_prefix)))}</li> @@ -155,13 +19,6 @@ % if master.results_downloadable_xlsx and request.has_perm('{}.results_xlsx'.format(permission_prefix)): <li>${h.link_to("Download results as XLSX", url('{}.results_xlsx'.format(route_prefix)))}</li> % endif - % if not use_buefy and master.creatable and master.show_create_link and master.has_perm('create'): - % if master.creates_multiple: - <li>${h.link_to("Create new {}".format(model_title_plural), url('{}.create'.format(route_prefix)))}</li> - % else: - <li>${h.link_to("Create a new {}".format(model_title), url('{}.create'.format(route_prefix)))}</li> - % endif - % endif % if master.has_input_file_templates and master.has_perm('create'): % for template in six.itervalues(input_file_templates): <li>${h.link_to("Download {} Template".format(template['label']), template['effective_url'])}</li> @@ -173,284 +30,233 @@ ## download search results % if master.results_downloadable and master.has_perm('download_results'): - % if use_buefy: - <b-button type="is-primary" - icon-pack="fas" - icon-left="fas fa-download" - @click="showDownloadResultsDialog = true" - :disabled="!total"> - Download Results - </b-button> + <b-button type="is-primary" + icon-pack="fas" + icon-left="fas fa-download" + @click="showDownloadResultsDialog = true" + :disabled="!total"> + Download Results + </b-button> - ${h.form(url('{}.download_results'.format(route_prefix)), ref='download_results_form')} - ${h.csrf_token(request)} - <input type="hidden" name="fmt" :value="downloadResultsFormat" /> - <input type="hidden" name="fields" :value="downloadResultsFieldsIncluded" /> - ${h.end_form()} + ${h.form(url('{}.download_results'.format(route_prefix)), ref='download_results_form')} + ${h.csrf_token(request)} + <input type="hidden" name="fmt" :value="downloadResultsFormat" /> + <input type="hidden" name="fields" :value="downloadResultsFieldsIncluded" /> + ${h.end_form()} - <b-modal :active.sync="showDownloadResultsDialog"> - <div class="card"> + <b-modal :active.sync="showDownloadResultsDialog"> + <div class="card"> - <div class="card-content"> - <p> - There are - <span class="is-size-4 has-text-weight-bold"> - {{ total.toLocaleString('en') }} ${model_title_plural} - </span> - matching your current filters. - </p> - <p> - You may download this set as a single data file if you like. - </p> - <br /> + <div class="card-content"> + <p> + There are + <span class="is-size-4 has-text-weight-bold"> + {{ total.toLocaleString('en') }} ${model_title_plural} + </span> + matching your current filters. + </p> + <p> + You may download this set as a single data file if you like. + </p> + <br /> - <b-notification type="is-warning" :closable="false" - v-if="downloadResultsFormat == 'xlsx' && total >= 1000"> - Excel downloads for large data sets can take a long time to - generate, and bog down the server in the meantime. You are - encouraged to choose CSV for a large data set, even though - the end result (file size) may be larger with CSV. - </b-notification> + <b-notification type="is-warning" :closable="false" + v-if="downloadResultsFormat == 'xlsx' && total >= 1000"> + Excel downloads for large data sets can take a long time to + generate, and bog down the server in the meantime. You are + encouraged to choose CSV for a large data set, even though + the end result (file size) may be larger with CSV. + </b-notification> - <div style="display: flex; justify-content: space-between"> + <div style="display: flex; justify-content: space-between"> - <div> - <b-field horizontal label="Format"> - <b-select v-model="downloadResultsFormat"> - % for key, label in six.iteritems(master.download_results_supported_formats()): - <option value="${key}">${label}</option> - % endfor - </b-select> - </b-field> - </div> + <div> + <b-field horizontal label="Format"> + <b-select v-model="downloadResultsFormat"> + % for key, label in six.iteritems(master.download_results_supported_formats()): + <option value="${key}">${label}</option> + % endfor + </b-select> + </b-field> + </div> - <div> + <div> - <div v-show="downloadResultsFieldsMode != 'choose'" - class="has-text-right"> - <p v-if="downloadResultsFieldsMode == 'default'"> - Will use DEFAULT fields. - </p> - <p v-if="downloadResultsFieldsMode == 'all'"> - Will use ALL fields. - </p> + <div v-show="downloadResultsFieldsMode != 'choose'" + class="has-text-right"> + <p v-if="downloadResultsFieldsMode == 'default'"> + Will use DEFAULT fields. + </p> + <p v-if="downloadResultsFieldsMode == 'all'"> + Will use ALL fields. + </p> + <br /> + </div> + + <div class="buttons is-right"> + <b-button type="is-primary" + v-show="downloadResultsFieldsMode != 'default'" + @click="downloadResultsUseDefaultFields()"> + Use Default Fields + </b-button> + <b-button type="is-primary" + v-show="downloadResultsFieldsMode != 'all'" + @click="downloadResultsUseAllFields()"> + Use All Fields + </b-button> + <b-button type="is-primary" + v-show="downloadResultsFieldsMode != 'choose'" + @click="downloadResultsFieldsMode = 'choose'"> + Choose Fields + </b-button> + </div> + + <div v-show="downloadResultsFieldsMode == 'choose'"> + <div style="display: flex;"> + <div> + <b-field label="Excluded Fields"> + <b-select multiple native-size="8" + expanded + ref="downloadResultsExcludedFields"> + <option v-for="field in downloadResultsFieldsAvailable" + v-if="!downloadResultsFieldsIncluded.includes(field)" + :key="field" + :value="field"> + {{ field }} + </option> + </b-select> + </b-field> + </div> + <div> + <br /><br /> + <b-button style="margin: 0.5rem;" + @click="downloadResultsExcludeFields()"> + < + </b-button> <br /> - </div> - - <div class="buttons is-right"> - <b-button type="is-primary" - v-show="downloadResultsFieldsMode != 'default'" - @click="downloadResultsUseDefaultFields()"> - Use Default Fields - </b-button> - <b-button type="is-primary" - v-show="downloadResultsFieldsMode != 'all'" - @click="downloadResultsUseAllFields()"> - Use All Fields - </b-button> - <b-button type="is-primary" - v-show="downloadResultsFieldsMode != 'choose'" - @click="downloadResultsFieldsMode = 'choose'"> - Choose Fields + <b-button style="margin: 0.5rem;" + @click="downloadResultsIncludeFields()"> + > </b-button> </div> - - <div v-show="downloadResultsFieldsMode == 'choose'"> - <div style="display: flex;"> - <div> - <b-field label="Excluded Fields"> - <b-select multiple native-size="8" - expanded - ref="downloadResultsExcludedFields"> - <option v-for="field in downloadResultsFieldsAvailable" - v-if="!downloadResultsFieldsIncluded.includes(field)" - :key="field" - :value="field"> - {{ field }} - </option> - </b-select> - </b-field> - </div> - <div> - <br /><br /> - <b-button style="margin: 0.5rem;" - @click="downloadResultsExcludeFields()"> - < - </b-button> - <br /> - <b-button style="margin: 0.5rem;" - @click="downloadResultsIncludeFields()"> - > - </b-button> - </div> - <div> - <b-field label="Included Fields"> - <b-select multiple native-size="8" - expanded - ref="downloadResultsIncludedFields"> - <option v-for="field in downloadResultsFieldsIncluded" - :key="field" - :value="field"> - {{ field }} - </option> - </b-select> - </b-field> - </div> - </div> + <div> + <b-field label="Included Fields"> + <b-select multiple native-size="8" + expanded + ref="downloadResultsIncludedFields"> + <option v-for="field in downloadResultsFieldsIncluded" + :key="field" + :value="field"> + {{ field }} + </option> + </b-select> + </b-field> </div> - </div> </div> - </div> <!-- card-content --> - <footer class="modal-card-foot"> - <b-button @click="showDownloadResultsDialog = false"> - Cancel - </b-button> - <once-button type="is-primary" - @click="downloadResultsSubmit()" - icon-pack="fas" - icon-left="fas fa-download" - :disabled="!downloadResultsFieldsIncluded.length" - text="Download Results"> - </once-button> - </footer> + </div> </div> - </b-modal> - % endif + </div> <!-- card-content --> + + <footer class="modal-card-foot"> + <b-button @click="showDownloadResultsDialog = false"> + Cancel + </b-button> + <once-button type="is-primary" + @click="downloadResultsSubmit()" + icon-pack="fas" + icon-left="fas fa-download" + :disabled="!downloadResultsFieldsIncluded.length" + text="Download Results"> + </once-button> + </footer> + </div> + </b-modal> % endif ## download rows for search results % if master.has_rows and master.results_rows_downloadable and master.has_perm('download_results_rows'): - % if use_buefy: - <b-button type="is-primary" - icon-pack="fas" - icon-left="fas fa-download" - @click="downloadResultsRows()" - :disabled="downloadResultsRowsButtonDisabled"> - {{ downloadResultsRowsButtonText }} - </b-button> - ${h.form(url('{}.download_results_rows'.format(route_prefix)), ref='downloadResultsRowsForm')} - ${h.csrf_token(request)} - ${h.end_form()} - % else: - ${h.form(url('{}.download_results_rows'.format(route_prefix)))} - ${h.csrf_token(request)} - <button type="button" id="download-row-results-button"> - Download Rows for Results - </button> - ${h.end_form()} - % endif + <b-button type="is-primary" + icon-pack="fas" + icon-left="fas fa-download" + @click="downloadResultsRows()" + :disabled="downloadResultsRowsButtonDisabled"> + {{ downloadResultsRowsButtonText }} + </b-button> + ${h.form(url('{}.download_results_rows'.format(route_prefix)), ref='downloadResultsRowsForm')} + ${h.csrf_token(request)} + ${h.end_form()} % endif ## merge 2 objects % if master.mergeable and request.has_perm('{}.merge'.format(permission_prefix)): - % if use_buefy: ${h.form(url('{}.merge'.format(route_prefix)), class_='control', **{'@submit': 'submitMergeForm'})} - % else: - ${h.form(url('{}.merge'.format(route_prefix)), name='merge-things')} - % endif ${h.csrf_token(request)} - % if use_buefy: - <input type="hidden" - name="uuids" - :value="checkedRowUUIDs()" /> - <b-button type="is-primary" - native-type="submit" - icon-pack="fas" - icon-left="object-ungroup" - :disabled="mergeFormSubmitting || checkedRows.length != 2"> - {{ mergeFormButtonText }} - </b-button> - % else: - ${h.hidden('uuids')} - <button type="submit" class="button">Merge 2 ${model_title_plural}</button> - % endif + <input type="hidden" + name="uuids" + :value="checkedRowUUIDs()" /> + <b-button type="is-primary" + native-type="submit" + icon-pack="fas" + icon-left="object-ungroup" + :disabled="mergeFormSubmitting || checkedRows.length != 2"> + {{ mergeFormButtonText }} + </b-button> ${h.end_form()} % endif ## enable / disable selected objects % if master.supports_set_enabled_toggle and master.has_perm('enable_disable_set'): - % if use_buefy: - ${h.form(url('{}.enable_set'.format(route_prefix)), class_='control', ref='enable_selected_form')} - ${h.csrf_token(request)} - ${h.hidden('uuids', v_model='selected_uuids')} - <b-button :disabled="enableSelectedDisabled" - @click="enableSelectedSubmit()"> - {{ enableSelectedText }} - </b-button> - ${h.end_form()} - % else: - ${h.form(url('{}.enable_set'.format(route_prefix)), name='enable-set', class_='control')} - ${h.csrf_token(request)} - ${h.hidden('uuids')} - <button type="button" class="button">Enable Selected</button> - ${h.end_form()} - % endif + ${h.form(url('{}.enable_set'.format(route_prefix)), class_='control', ref='enable_selected_form')} + ${h.csrf_token(request)} + ${h.hidden('uuids', v_model='selected_uuids')} + <b-button :disabled="enableSelectedDisabled" + @click="enableSelectedSubmit()"> + {{ enableSelectedText }} + </b-button> + ${h.end_form()} - % if use_buefy: - ${h.form(url('{}.disable_set'.format(route_prefix)), ref='disable_selected_form', class_='control')} - ${h.csrf_token(request)} - ${h.hidden('uuids', v_model='selected_uuids')} - <b-button :disabled="disableSelectedDisabled" - @click="disableSelectedSubmit()"> - {{ disableSelectedText }} - </b-button> - ${h.end_form()} - % else: - ${h.form(url('{}.disable_set'.format(route_prefix)), name='disable-set', class_='control')} - ${h.csrf_token(request)} - ${h.hidden('uuids')} - <button type="button" class="button">Disable Selected</button> - ${h.end_form()} - % endif + ${h.form(url('{}.disable_set'.format(route_prefix)), ref='disable_selected_form', class_='control')} + ${h.csrf_token(request)} + ${h.hidden('uuids', v_model='selected_uuids')} + <b-button :disabled="disableSelectedDisabled" + @click="disableSelectedSubmit()"> + {{ disableSelectedText }} + </b-button> + ${h.end_form()} % endif ## delete selected objects % if master.set_deletable and master.has_perm('delete_set'): - % if use_buefy: - ${h.form(url('{}.delete_set'.format(route_prefix)), ref='delete_selected_form', class_='control')} - ${h.csrf_token(request)} - ${h.hidden('uuids', v_model='selected_uuids')} - <b-button type="is-danger" - :disabled="deleteSelectedDisabled" - @click="deleteSelectedSubmit()" - icon-pack="fas" - icon-left="trash"> - {{ deleteSelectedText }} - </b-button> - ${h.end_form()} - % else: - ${h.form(url('{}.delete_set'.format(route_prefix)), name='delete-set', class_='control')} - ${h.csrf_token(request)} - ${h.hidden('uuids')} - <button type="button" class="button">Delete Selected</button> - ${h.end_form()} - % endif + ${h.form(url('{}.delete_set'.format(route_prefix)), ref='delete_selected_form', class_='control')} + ${h.csrf_token(request)} + ${h.hidden('uuids', v_model='selected_uuids')} + <b-button type="is-danger" + :disabled="deleteSelectedDisabled" + @click="deleteSelectedSubmit()" + icon-pack="fas" + icon-left="trash"> + {{ deleteSelectedText }} + </b-button> + ${h.end_form()} % endif ## delete search results % if master.bulk_deletable and request.has_perm('{}.bulk_delete'.format(permission_prefix)): - % if use_buefy: - ${h.form(url('{}.bulk_delete'.format(route_prefix)), ref='delete_results_form', class_='control')} - ${h.csrf_token(request)} - <b-button type="is-danger" - :disabled="deleteResultsDisabled" - :title="total ? null : 'There are no results to delete'" - @click="deleteResultsSubmit()" - icon-pack="fas" - icon-left="trash"> - {{ deleteResultsText }} - </b-button> - ${h.end_form()} - % else: - ${h.form(url('{}.bulk_delete'.format(route_prefix)), name='bulk-delete', class_='control')} - ${h.csrf_token(request)} - <button type="button">Delete Results</button> - ${h.end_form()} - % endif + ${h.form(url('{}.bulk_delete'.format(route_prefix)), ref='delete_results_form', class_='control')} + ${h.csrf_token(request)} + <b-button type="is-danger" + :disabled="deleteResultsDisabled" + :title="total ? null : 'There are no results to delete'" + @click="deleteResultsSubmit()" + icon-pack="fas" + icon-left="trash"> + {{ deleteResultsText }} + </b-button> + ${h.end_form()} % endif </%def> @@ -516,7 +322,7 @@ <script type="text/javascript"> ## maybe auto-redirect to download latest results file - % if download_results_path and use_buefy: + % if download_results_path: ThisPage.methods.downloadResultsRedirect = function() { location.href = '${url('{}.download_results'.format(route_prefix))}?filename=${h.os.path.basename(download_results_path)}'; } @@ -529,7 +335,7 @@ % endif ## maybe auto-redirect to download latest "rows for results" file - % if download_results_rows_path and use_buefy: + % if download_results_rows_path: ThisPage.methods.downloadResultsRowsRedirect = function() { location.href = '${url('{}.download_results_rows'.format(route_prefix))}?filename=${h.os.path.basename(download_results_rows_path)}'; } @@ -541,13 +347,12 @@ } % endif - ## TODO: stop checking for buefy here once we only have the one session.pop() - % if use_buefy and request.session.pop('{}.results_csv.generated'.format(route_prefix), False): + % if request.session.pop('{}.results_csv.generated'.format(route_prefix), False): ThisPage.mounted = function() { location.href = '${url('{}.results_csv_download'.format(route_prefix))}'; } % endif - % if use_buefy and request.session.pop('{}.results_xlsx.generated'.format(route_prefix), False): + % if request.session.pop('{}.results_xlsx.generated'.format(route_prefix), False): ThisPage.mounted = function() { location.href = '${url('{}.results_xlsx_download'.format(route_prefix))}'; } @@ -793,44 +598,4 @@ </%def> -% if use_buefy: - ${parent.body()} - -% else: - ## no buefy, so do the traditional thing - - % if download_results_rows_path: - <div class="flash-messages"> - <div class="ui-state-highlight ui-corner-all"> - <span style="float: left; margin-right: .3em;" class="ui-icon ui-icon-info"></span> - Your download should start automatically, or you can - ${h.link_to("click here", '{}?filename={}'.format(url('{}.download_results_rows'.format(route_prefix)), h.os.path.basename(download_results_rows_path)))} - </div> - </div> - % endif - - ${grid.render_complete(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())|n} - - % if master.deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple': - ${h.form('#', id='delete-object-form')} - ${h.csrf_token(request)} - ${h.end_form()} - % endif - - ## TODO: can stop checking for buefy above once this legacy chunk is gone - % if request.session.pop('{}.results_csv.generated'.format(route_prefix), False): - <script type="text/javascript"> - $(function() { - location.href = '${url('{}.results_csv_download'.format(route_prefix))}'; - }); - </script> - % endif - % if request.session.pop('{}.results_xlsx.generated'.format(route_prefix), False): - <script type="text/javascript"> - $(function() { - location.href = '${url('{}.results_xlsx_download'.format(route_prefix))}'; - }); - </script> - % endif - -% endif +${parent.body()} diff --git a/tailbone/templates/master/merge.mako b/tailbone/templates/master/merge.mako index 8924fcd0..565dece3 100644 --- a/tailbone/templates/master/merge.mako +++ b/tailbone/templates/master/merge.mako @@ -3,36 +3,6 @@ <%def name="title()">Merge 2 ${model_title_plural}</%def> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - <script type="text/javascript"> - - $(function() { - - $('button.swap').click(function() { - $(this).button('disable').button('option', 'label', "Swapping, please wait..."); - var form = $(this).parents('form'); - var input = form.find('input[name="uuids"]'); - var uuids = input.val().split(','); - uuids.reverse(); - input.val(uuids.join(',')); - form.submit(); - }); - - $('form.merge input[type="submit"]').click(function() { - $(this).button('disable').button('option', 'label', "Merging, please wait..."); - var form = $(this).parents('form'); - form.append($('<input type="hidden" name="commit-merge" value="yes" />')); - form.submit(); - }); - - }); - - </script> - % endif -</%def> - <%def name="extra_styles()"> ${parent.extra_styles()} <style type="text/css"> @@ -92,66 +62,51 @@ </%def> <%def name="page_content()"> + <p> + You are about to <strong>merge</strong> two ${model_title} records, + (possibly) along with various related data. The tool you are using now + is somewhat generic and is not able to give you the full picture of the + implications of this merge. You are urged to proceed with caution! + </p> -<p> - You are about to <strong>merge</strong> two ${model_title} records, - (possibly) along with various related data. The tool you are using now - is somewhat generic and is not able to give you the full picture of the - implications of this merge. You are urged to proceed with caution! -</p> + <p class="warning"> + <strong>Unless you know what you're doing, a good rule of thumb (though still no + guarantee) is to merge <em>only</em> if the "resulting" column is all-white.</strong> + (You may be able to swap kept/removed in order to achieve this.) + </p> -<p class="warning"> - <strong>Unless you know what you're doing, a good rule of thumb (though still no - guarantee) is to merge <em>only</em> if the "resulting" column is all-white.</strong> - (You may be able to swap kept/removed in order to achieve this.) -</p> + <p> + The ${h.link_to("{} on the left".format(model_title), view_url(object_to_remove), target='_blank', class_='merge-object')} + will be <strong>deleted</strong> + and the ${h.link_to("{} on the right".format(model_title), view_url(object_to_keep), target='_blank', class_='merge-object')} + will be <strong>kept</strong>. The one which is to be kept may also + be updated to reflect certain aspects of the one being deleted; however again + the details are up to the app logic for this type of merge and aren't fully + known to the generic tool which you're using now. + </p> -<p> - The ${h.link_to("{} on the left".format(model_title), view_url(object_to_remove), target='_blank', class_='merge-object')} - will be <strong>deleted</strong> - and the ${h.link_to("{} on the right".format(model_title), view_url(object_to_keep), target='_blank', class_='merge-object')} - will be <strong>kept</strong>. The one which is to be kept may also - be updated to reflect certain aspects of the one being deleted; however again - the details are up to the app logic for this type of merge and aren't fully - known to the generic tool which you're using now. -</p> + <table class="diff"> + <thead> + <tr> + <th>field name</th> + <th>deleting ${model_title}</th> + <th>keeping ${model_title}</th> + <th>resulting ${model_title}</th> + </tr> + </thead> + <tbody> + % for field in sorted(merge_fields): + <tr${' class="diff"' if keep_data[field] != remove_data[field] else ''|n}> + <td class="field">${field}</td> + <td class="value remove-value">${repr(remove_data[field])}</td> + <td class="value keep-value">${repr(keep_data[field])}</td> + <td class="value result-value${' diff' if resulting_data[field] != keep_data[field] else ''}">${repr(resulting_data[field])}</td> + </tr> + % endfor + </tbody> + </table> -<table class="diff"> - <thead> - <tr> - <th>field name</th> - <th>deleting ${model_title}</th> - <th>keeping ${model_title}</th> - <th>resulting ${model_title}</th> - </tr> - </thead> - <tbody> - % for field in sorted(merge_fields): - <tr${' class="diff"' if keep_data[field] != remove_data[field] else ''|n}> - <td class="field">${field}</td> - <td class="value remove-value">${repr(remove_data[field])}</td> - <td class="value keep-value">${repr(keep_data[field])}</td> - <td class="value result-value${' diff' if resulting_data[field] != keep_data[field] else ''}">${repr(resulting_data[field])}</td> - </tr> - % endfor - </tbody> -</table> - -% if use_buefy: - <merge-buttons></merge-buttons> - -% else: -## no buefy; do legacy stuff -${h.form(request.current_route_url(), class_='merge')} -${h.csrf_token(request)} -<div class="buttons"> - ${h.hidden('uuids', value='{},{}'.format(object_to_remove.uuid, object_to_keep.uuid))} - <a class="button" href="${index_url}">Whoops, nevermind</a> - <button type="button" class="swap">Swap which ${model_title} is kept/removed</button> - ${h.submit('merge', "Yes, perform this merge")} -</div> -${h.end_form()} -% endif + <merge-buttons></merge-buttons> </%def> <%def name="render_this_page_template()"> @@ -177,11 +132,7 @@ ${h.end_form()} </div> <div class="level-item"> - % if use_buefy: ${h.form(request.current_route_url(), **{'@submit': 'submitMergeForm'})} - % else: - ${h.form(request.current_route_url())} - % endif ${h.csrf_token(request)} ${h.hidden('uuids', value='{},{}'.format(object_to_remove.uuid, object_to_keep.uuid))} ${h.hidden('commit-merge', value='yes')} diff --git a/tailbone/templates/master/versions.mako b/tailbone/templates/master/versions.mako index 2d1b4db3..e6574356 100644 --- a/tailbone/templates/master/versions.mako +++ b/tailbone/templates/master/versions.mako @@ -46,12 +46,8 @@ </%def> <%def name="page_content()"> - % if use_buefy: - <tailbone-grid :csrftoken="csrftoken"> - </tailbone-grid> - % else: - ${grid.render_complete()|n} - % endif + <tailbone-grid :csrftoken="csrftoken"> + </tailbone-grid> </%def> ${parent.body()} diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako index f6dd584a..69485dd1 100644 --- a/tailbone/templates/master/view.mako +++ b/tailbone/templates/master/view.mako @@ -3,46 +3,6 @@ <%def name="title()">${index_title} » ${instance_title}</%def> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - % if master.deletable and instance_deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple': - <script type="text/javascript"> - - $(function () { - - $('#context-menu a.delete-instance').on('click', function() { - if (confirm("Are you sure you wish to delete this ${model_title}?")) { - $(this).parents('form').submit(); - } - return false; - }); - - }); - - </script> - % endif - % if master.has_rows: - <script type="text/javascript"> - $(function() { - $('.grid-wrapper').gridwrapper(); - }); - </script> - % endif - % endif -</%def> - -<%def name="extra_styles()"> - ${parent.extra_styles()} - % if master.has_rows and not use_buefy: - <style type="text/css"> - .grid-wrapper { - margin-top: 10px; - } - </style> - % endif -</%def> - <%def name="content_title()"> ${instance_title} </%def> @@ -54,33 +14,19 @@ <%def name="render_xref_helper()"> % if xref_buttons or xref_links: - % if use_buefy: - <nav class="panel"> - <p class="panel-heading">Cross-Reference</p> - <div class="panel-block buttons"> - <div style="display: flex; flex-direction: column;"> - % for button in xref_buttons: - ${button} - % endfor - % for link in xref_links: - ${link} - % endfor - </div> - </div> - </nav> - % else: - <div class="object-helper"> - <h3>Cross-Reference</h3> - <div class="object-helper-content"> - % for button in xref_buttons: - ${button} - % endfor - % for link in xref_links: - ${link} - % endfor - </div> + <nav class="panel"> + <p class="panel-heading">Cross-Reference</p> + <div class="panel-block buttons"> + <div style="display: flex; flex-direction: column;"> + % for button in xref_buttons: + ${button} + % endfor + % for link in xref_links: + ${link} + % endfor </div> - % endif + </div> + </nav> % endif </%def> @@ -91,63 +37,37 @@ % if master.has_versions and request.rattail_config.versioning_enabled() and request.has_perm('{}.versions'.format(permission_prefix)): <li>${h.link_to("Version History", action_url('versions', instance))}</li> % endif - % if not use_buefy and master.editable and instance_editable and master.has_perm('edit'): - <li>${h.link_to("Edit this {}".format(model_title), action_url('edit', instance))}</li> - % endif - ${self.context_menu_item_delete()} - % if not use_buefy and master.creatable and master.show_create_link and master.has_perm('create'): - % if master.creates_multiple: - <li>${h.link_to("Create new {}".format(model_title_plural), url('{}.create'.format(route_prefix)))}</li> - % else: - <li>${h.link_to("Create a new {}".format(model_title), url('{}.create'.format(route_prefix)))}</li> - % endif - % endif - % if not use_buefy and master.cloneable and master.has_perm('clone'): - <li>${h.link_to("Clone this as new {}".format(model_title), url('{}.clone'.format(route_prefix), uuid=instance.uuid))}</li> - % endif % if master.touchable and request.has_perm('{}.touch'.format(permission_prefix)): <li>${h.link_to("\"Touch\" this {}".format(model_title), master.get_action_url('touch', instance))}</li> % endif - % if not use_buefy and master.has_rows and master.rows_downloadable_csv and master.has_perm('row_results_csv'): - <li>${h.link_to("Download row results as CSV", master.get_action_url('row_results_csv', instance))}</li> - % endif - % if not use_buefy and master.has_rows and master.rows_downloadable_xlsx and master.has_perm('row_results_xlsx'): - <li>${h.link_to("Download row results as XLSX", master.get_action_url('row_results_xlsx', instance))}</li> - % endif </%def> <%def name="render_row_grid_tools()"> ${rows_grid_tools} - % if use_buefy: - % if master.rows_downloadable_xlsx and master.has_perm('row_results_xlsx'): - <b-button tag="a" href="${master.get_action_url('row_results_xlsx', instance)}" - icon-pack="fas" - icon-left="download"> - Download Results XLSX - </b-button> - % endif - % if master.rows_downloadable_csv and master.has_perm('row_results_csv'): - <b-button tag="a" href="${master.get_action_url('row_results_csv', instance)}" - icon-pack="fas" - icon-left="download"> - Download Results CSV - </b-button> - % endif + % if master.rows_downloadable_xlsx and master.has_perm('row_results_xlsx'): + <b-button tag="a" href="${master.get_action_url('row_results_xlsx', instance)}" + icon-pack="fas" + icon-left="download"> + Download Results XLSX + </b-button> + % endif + % if master.rows_downloadable_csv and master.has_perm('row_results_csv'): + <b-button tag="a" href="${master.get_action_url('row_results_csv', instance)}" + icon-pack="fas" + icon-left="download"> + Download Results CSV + </b-button> % endif </%def> <%def name="render_this_page()"> ${parent.render_this_page()} % if master.has_rows: - % if use_buefy: - <br /> - % if rows_title: - <h4 class="block is-size-4">${rows_title}</h4> - % endif - ${self.render_row_grid_component()} - % else: - ${rows_grid|n} + <br /> + % if rows_title: + <h4 class="block is-size-4">${rows_title}</h4> % endif + ${self.render_row_grid_component()} % endif </%def> diff --git a/tailbone/templates/master/view_row.mako b/tailbone/templates/master/view_row.mako index 255caf69..623a33a0 100644 --- a/tailbone/templates/master/view_row.mako +++ b/tailbone/templates/master/view_row.mako @@ -12,9 +12,6 @@ % if master.rows_editable and instance_editable and request.has_perm('{}.edit'.format(permission_prefix)): <li>${h.link_to("Edit this {}".format(model_title), action_url('edit', instance))}</li> % endif - % if not use_buefy and instance_deletable and master.has_perm('delete_row'): - <li>${h.link_to("Delete this {}".format(model_title), action_url('delete', instance))}</li> - % endif % if rows_creatable and request.has_perm('{}.create'.format(permission_prefix)): <li>${h.link_to("Create a new {}".format(model_title), url('{}.create'.format(route_prefix)))}</li> % endif diff --git a/tailbone/templates/messages/archive/index.mako b/tailbone/templates/messages/archive/index.mako index 002b9e90..16a05ee2 100644 --- a/tailbone/templates/messages/archive/index.mako +++ b/tailbone/templates/messages/archive/index.mako @@ -3,15 +3,6 @@ <%def name="title()">Message Archive</%def> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - <script type="text/javascript"> - destination = "Inbox"; - </script> - % endif -</%def> - <%def name="context_menu_items()"> ${parent.context_menu_items()} <li>${h.link_to("Go to my Message Inbox", url('messages.inbox'))}</li> diff --git a/tailbone/templates/messages/create.mako b/tailbone/templates/messages/create.mako index fc046e36..10729590 100644 --- a/tailbone/templates/messages/create.mako +++ b/tailbone/templates/messages/create.mako @@ -2,148 +2,26 @@ <%inherit file="/master/create.mako" /> <%namespace file="/messages/recipients.mako" import="message_recipients_template" /> -<%def name="content_title()">${parent.content_title() if not use_buefy else ''}</%def> +<%def name="content_title()"></%def> <%def name="extra_javascript()"> ${parent.extra_javascript()} - % if use_buefy: - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.message_recipients.js'))} - % else: - ${h.javascript_link(request.static_url('tailbone:static/js/lib/tag-it.min.js'))} - <script type="text/javascript"> - - var recipient_mappings = new Map([ - <% last = len(available_recipients) %> - % for i, recip in enumerate(available_recipients, 1): - <% uuid, entry = recip %> - ['${uuid}', ${json.dumps(entry)|n}]${',' if i < last else ''} - % endfor - ]); - - // validate message before sending - function validate_message_form() { - var form = $('#deform'); - - if (! form.find('input[name="set_recipients"]').val()) { - alert("You must specify some recipient(s) for the message."); - $('.set_recipients input').data('ui-tagit').tagInput.focus(); - return false; - } - - if (! form.find('input[name="subject"]').val()) { - alert("You must provide a subject for the message."); - form.find('input[name="subject"]').focus(); - return false; - } - - return true; - } - - $(function() { - - var recipients = $('.set_recipients input'); - - recipients.tagit({ - - autocomplete: { - delay: 0, - minLength: 2, - autoFocus: true, - removeConfirmation: true, - - source: function(request, response) { - var term = request.term.toLowerCase(); - var data = []; - recipient_mappings.forEach(function(name, uuid) { - if (!name.toLowerCase && name.name) { - name = name.name; - } - if (name.toLowerCase().indexOf(term) >= 0) { - data.push({value: uuid, label: name}); - } - }); - response(data); - } - }, - - beforeTagAdded: ${self.before_tag_added()}, - - beforeTagRemoved: function(event, ui) { - - // Unfortunately we're responsible for cleaning up the hidden - // field, since the values there do not match the tag labels. - var tags = recipients.tagit('assignedTags'); - var uuid = ui.tag.data('uuid'); - tags = tags.filter(function(element) { - return element != uuid; - }); - recipients.data('ui-tagit')._updateSingleTagsField(tags); - } - }); - - // set focus to recipients field - recipients.data('ui-tagit').tagInput.focus(); - }); - - </script> - ${self.validate_message_js()} - % endif -</%def> - -<%def name="validate_message_js()"> - <script type="text/javascript"> - $(function() { - $('#new-message').submit(function() { - return validate_message_form(); - }); - }); - </script> + ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.message_recipients.js'))} </%def> <%def name="extra_styles()"> ${parent.extra_styles()} - % if use_buefy: - <style type="text/css"> - - .this-page-content { - width: 100%; - } - - .this-page-content .buttons { - margin-left: 20rem; - } - - </style> - % else: - ${h.stylesheet_link(request.static_url('tailbone:static/css/jquery.tagit.css'))} <style type="text/css"> - .recipients input { - min-width: 525px; + .this-page-content { + width: 100%; } - .subject input { - min-width: 540px; - } - - .body textarea { - min-width: 540px; + .this-page-content .buttons { + margin-left: 20rem; } </style> - % endif -</%def> - -<%def name="before_tag_added()"> - function(event, ui) { - - // Lookup the name in cached mapping, and show that on the tag, instead - // of the UUID. The tagit widget should take care of keeping the - // hidden field in sync for us, still using the UUID. - var uuid = ui.tagLabel; - var name = recipient_mappings.get(uuid); - ui.tag.find('.tagit-label').html(name); - } </%def> <%def name="context_menu_items()"> diff --git a/tailbone/templates/messages/inbox/index.mako b/tailbone/templates/messages/inbox/index.mako index f88010b0..2ac24b9e 100644 --- a/tailbone/templates/messages/inbox/index.mako +++ b/tailbone/templates/messages/inbox/index.mako @@ -3,15 +3,6 @@ <%def name="title()">Message Inbox</%def> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - <script type="text/javascript"> - destination = "Archive"; - </script> - % endif -</%def> - <%def name="context_menu_items()"> ${parent.context_menu_items()} <li>${h.link_to("Go to my Message Archive", url('messages.archive'))}</li> diff --git a/tailbone/templates/messages/index.mako b/tailbone/templates/messages/index.mako index 4ded5571..3fc82fd3 100644 --- a/tailbone/templates/messages/index.mako +++ b/tailbone/templates/messages/index.mako @@ -1,52 +1,6 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/index.mako" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - <script type="text/javascript"> - - var destination = null; - - function update_move_button() { - var count = $('.grid tr:not(.header) td.checkbox input:checked').length; - $('form[name="move-selected"] button') - .button('option', 'label', "Move " + count + " selected to " + destination) - .button('option', 'disabled', count < 1); - } - - $(function() { - - update_move_button(); - - $('.grid-wrapper').on('change', 'tr.header td.checkbox input', function() { - update_move_button(); - }); - - $('.grid-wrapper').on('click', 'tr:not(.header) td.checkbox input', function() { - update_move_button(); - }); - - $('form[name="move-selected"]').submit(function() { - var uuids = []; - $('.grid tr:not(.header) td.checkbox input:checked').each(function() { - uuids.push($(this).parents('tr:first').data('uuid')); - }); - if (! uuids.length) { - return false; - } - $(this).find('[name="uuids"]').val(uuids.toString()); - $(this).find('button') - .button('option', 'label', "Moving " + uuids.length + " messages to " + destination + "...") - .button('disable'); - }); - - }); - - </script> - % endif -</%def> - <%def name="context_menu_items()"> % if request.has_perm('messages.create'): <li>${h.link_to("Send a new Message", url('messages.create'))}</li> @@ -55,25 +9,16 @@ <%def name="grid_tools()"> % if request.matched_route.name in ('messages.inbox', 'messages.archive'): - % if use_buefy: - ${h.form(url('messages.move_bulk'), **{'@submit': 'moveMessagesSubmit'})} - ${h.csrf_token(request)} - ${h.hidden('destination', value='archive' if request.matched_route.name == 'messages.inbox' else 'inbox')} - ${h.hidden('uuids', v_model='selected_uuids')} - <b-button type="is-primary" - native-type="submit" - :disabled="moveMessagesSubmitting || !checkedRows.length"> - {{ moveMessagesTextCurrent }} - </b-button> - ${h.end_form()} - % else: - ${h.form(url('messages.move_bulk'), name='move-selected')} - ${h.csrf_token(request)} - ${h.hidden('destination', value='archive' if request.matched_route.name == 'messages.inbox' else 'inbox')} - ${h.hidden('uuids')} - <button type="submit">Move 0 selected to ${'Archive' if request.matched_route.name == 'messages.inbox' else 'Inbox'}</button> - ${h.end_form()} - % endif + ${h.form(url('messages.move_bulk'), **{'@submit': 'moveMessagesSubmit'})} + ${h.csrf_token(request)} + ${h.hidden('destination', value='archive' if request.matched_route.name == 'messages.inbox' else 'inbox')} + ${h.hidden('uuids', v_model='selected_uuids')} + <b-button type="is-primary" + native-type="submit" + :disabled="moveMessagesSubmitting || !checkedRows.length"> + {{ moveMessagesTextCurrent }} + </b-button> + ${h.end_form()} % endif </%def> diff --git a/tailbone/templates/messages/view.mako b/tailbone/templates/messages/view.mako index 78caab93..2e2baa60 100644 --- a/tailbone/templates/messages/view.mako +++ b/tailbone/templates/messages/view.mako @@ -1,66 +1,20 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - <script type="text/javascript"> - - $(function() { - - $('.field-wrapper.recipients .more').click(function() { - $(this).hide(); - $(this).siblings('.everyone').css('display', 'inline-block'); - return false; - }); - - $('.field-wrapper.recipients .everyone').click(function() { - $(this).hide(); - $(this).siblings('.more').show(); - }); - - }); - - </script> - % endif -</%def> - <%def name="extra_styles()"> ${parent.extra_styles()} - % if use_buefy: - <style type="text/css"> - .everyone { - cursor: pointer; - } - .tailbone-message-body { - margin: 1rem auto; - min-height: 10rem; - } - .tailbone-message-body p { - margin-bottom: 1rem; - } - </style> - % else: <style type="text/css"> - .recipients .everyone { + .everyone { cursor: pointer; - display: none; } - .message-tools { - margin-bottom: 15px; + .tailbone-message-body { + margin: 1rem auto; + min-height: 10rem; } - .message-body { - border-top: 1px solid black; - border-bottom: 1px solid black; - margin-bottom: 15px; - padding: 0 5em; - white-space: pre-line; - } - .message-body p { - margin-bottom: 15px; + .tailbone-message-body p { + margin-bottom: 1rem; } </style> - % endif </%def> <%def name="context_menu_items()"> @@ -86,43 +40,29 @@ <%def name="message_tools()"> % if recipient: - % if use_buefy: - <div class="buttons"> - % if request.has_perm('messages.create'): - <once-button type="is-primary" - tag="a" href="${url('messages.reply', uuid=instance.uuid)}" - text="Reply"> - </once-button> - <once-button type="is-primary" - tag="a" href="${url('messages.reply_all', uuid=instance.uuid)}" - text="Reply to All"> - </once-button> - % endif - % if recipient.status == enum.MESSAGE_STATUS_INBOX: - <once-button type="is-primary" - tag="a" href="${url('messages.move', uuid=instance.uuid)}?dest=archive" - text="Move to Archive"> - </once-button> - % else: - <once-button type="is-primary" - tag="a" href="${url('messages.move', uuid=instance.uuid)}?dest=inbox" - text="Move to Inbox"> - </once-button> - % endif - </div> - % else: - <div class="message-tools"> - % if request.has_perm('messages.create'): - ${h.link_to("Reply", url('messages.reply', uuid=instance.uuid), class_='button')} - ${h.link_to("Reply to All", url('messages.reply_all', uuid=instance.uuid), class_='button')} - % endif - % if recipient.status == enum.MESSAGE_STATUS_INBOX: - ${h.link_to("Move to Archive", url('messages.move', uuid=instance.uuid) + '?dest=archive', class_='button')} - % else: - ${h.link_to("Move to Inbox", url('messages.move', uuid=instance.uuid) + '?dest=inbox', class_='button')} - % endif - </div> - % endif + <div class="buttons"> + % if request.has_perm('messages.create'): + <once-button type="is-primary" + tag="a" href="${url('messages.reply', uuid=instance.uuid)}" + text="Reply"> + </once-button> + <once-button type="is-primary" + tag="a" href="${url('messages.reply_all', uuid=instance.uuid)}" + text="Reply to All"> + </once-button> + % endif + % if recipient.status == enum.MESSAGE_STATUS_INBOX: + <once-button type="is-primary" + tag="a" href="${url('messages.move', uuid=instance.uuid)}?dest=archive" + text="Move to Archive"> + </once-button> + % else: + <once-button type="is-primary" + tag="a" href="${url('messages.move', uuid=instance.uuid)}?dest=inbox" + text="Move to Inbox"> + </once-button> + % endif + </div> % endif </%def> @@ -132,22 +72,14 @@ <%def name="page_content()"> ${parent.page_content()} - % if use_buefy: - <br /> - <div style="margin-left: 5rem;"> - ${self.message_tools()} - <div class="tailbone-message-body"> - ${self.message_body()} - </div> - ${self.message_tools()} - </div> - % else: - ${self.message_tools()} - <div class="message-body"> - ${self.message_body()} - </div> - ${self.message_tools()} - % endif + <br /> + <div style="margin-left: 5rem;"> + ${self.message_tools()} + <div class="tailbone-message-body"> + ${self.message_body()} + </div> + ${self.message_tools()} + </div> </%def> <%def name="modify_this_page_vars()"> diff --git a/tailbone/templates/ordering/create.mako b/tailbone/templates/ordering/create.mako index ff0fc836..8f2d5e27 100644 --- a/tailbone/templates/ordering/create.mako +++ b/tailbone/templates/ordering/create.mako @@ -1,82 +1,6 @@ ## -*- coding: utf-8; -*- <%inherit file="/batch/create.mako" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - ${self.func_show_mode()} - <script type="text/javascript"> - - var purchases_field = '${purchases_field}'; - var purchases = null; // TODO: where is this used? - - function vendor_selected(uuid, name) { -## var mode = $('.mode select').val(); -## if (mode == ${enum.PURCHASE_BATCH_MODE_RECEIVING} || mode == ${enum.PURCHASE_BATCH_MODE_COSTING}) { -## var purchases = $('.purchase_uuid select'); -## purchases.empty(); -## -## var data = {'vendor_uuid': uuid, 'mode': mode}; -## $.get('${url('purchases.batch.eligible_purchases')}', data, function(data) { -## if (data.error) { -## alert(data.error); -## } else { -## $.each(data.purchases, function(i, purchase) { -## purchases.append($('<option value="' + purchase.key + '">' + purchase.display + '</option>')); -## }); -## } -## }); -## -## // TODO: apparently refresh doesn't work right? -## // http://stackoverflow.com/a/10280078 -## // purchases.selectmenu('refresh'); -## purchases.selectmenu('destroy').selectmenu(); -## } - } - - function vendor_cleared() { - var purchases = $('.purchase_uuid select'); - purchases.empty(); - - // TODO: apparently refresh doesn't work right? - // http://stackoverflow.com/a/10280078 - // purchases.selectmenu('refresh'); - purchases.selectmenu('destroy').selectmenu(); - } - - $(function() { - - $('.field-wrapper.mode select').selectmenu({ - change: function(event, ui) { - show_mode(ui.item.value); - } - }); - - show_mode(${enum.PURCHASE_BATCH_MODE_ORDERING}); - - }); - - </script> - % endif -</%def> - -<%def name="func_show_mode()"> - <script type="text/javascript"> - - // TODO: mode is presumably null here.. - function show_mode(mode) { - $('.field-wrapper.store_uuid').show(); - $('.field-wrapper.' + purchases_field).hide(); - $('.field-wrapper.department_uuid').show(); - $('.field-wrapper.buyer_uuid').show(); - $('.field-wrapper.date_ordered').show(); - $('.field-wrapper.date_received').hide(); - $('.field-wrapper.po_number').show(); - $('.field-wrapper.invoice_date').hide(); - $('.field-wrapper.invoice_number').hide(); - } - - </script> -</%def> +## TODO: deprecate / remove ${parent.body()} diff --git a/tailbone/templates/ordering/worksheet.mako b/tailbone/templates/ordering/worksheet.mako index 97b1b51b..e41fe15f 100644 --- a/tailbone/templates/ordering/worksheet.mako +++ b/tailbone/templates/ordering/worksheet.mako @@ -3,63 +3,6 @@ <%def name="title()">Ordering Worksheet</%def> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - ${h.javascript_link(request.static_url('tailbone:static/js/numeric.js'))} - <script type="text/javascript"> - - var submitting = false; - - $(function() { - - $('.order-form td.current-order input').focus(function(event) { - $(this).parents('tr:first').addClass('active'); - }); - - $('.order-form td.current-order input').blur(function(event) { - $(this).parents('tr:first').removeClass('active'); - }); - - $('.order-form td.current-order input').keydown(function(event) { - if (key_allowed(event) || key_modifies(event)) { - return true; - } - if (event.which == 13) { - if (! submitting) { - submitting = true; - var row = $(this).parents('tr:first'); - var form = $('#item-update-form'); - form.find('[name="product_uuid"]').val(row.data('uuid')); - form.find('[name="cases_ordered"]').val(row.find('input[name^="cases_ordered_"]').val() || '0'); - form.find('[name="units_ordered"]').val(row.find('input[name^="units_ordered_"]').val() || '0'); - $.post(form.attr('action'), form.serialize(), function(data) { - if (data.error) { - alert(data.error); - } else { - if (data.row_cases_ordered || data.row_units_ordered) { - row.find('input[name^="cases_ordered_"]').val(data.row_cases_ordered); - row.find('input[name^="units_ordered_"]').val(data.row_units_ordered); - row.find('td.po-total').html(data.row_po_total_calculated); - } else { - row.find('input[name^="cases_ordered_"]').val(''); - row.find('input[name^="units_ordered_"]').val(''); - row.find('td.po-total').html(''); - } - $('.po-total .field').html(data.batch_po_total_calculated); - } - submitting = false; - }); - } - } - return false; - }); - - }); - </script> - % endif -</%def> - <%def name="extra_styles()"> ${parent.extra_styles()} <style type="text/css"> @@ -187,10 +130,7 @@ <tbody> % for i, cost in enumerate(subdepartment._order_costs, 1): <tr data-uuid="${cost.product_uuid}" class="${'even' if i % 2 == 0 else 'odd'}" - % if use_buefy: - :class="{active: activeUUID == '${cost.uuid}'}" - % endif - > + :class="{active: activeUUID == '${cost.uuid}'}"> ${self.order_form_row(cost)} % for data in history: <td class="scratch_pad"> @@ -216,34 +156,21 @@ % endfor % if not ignore_cases: <td class="current-order"> - % if use_buefy: - <numeric-input v-model="worksheet.cost_${cost.uuid}_cases" - @focus="activeUUID = '${cost.uuid}'; $event.target.select()" - @blur="activeUUID = null" - @keydown.native="inputKeydown($event, '${cost.uuid}', '${cost.product_uuid}')"> - </numeric-input> - % else: - ${h.text('cases_ordered_{}'.format(cost.uuid), value=int(cost._batchrow.cases_ordered or 0) if cost._batchrow else None)} - % endif - </td> - % endif - <td class="current-order"> - % if use_buefy: - <numeric-input v-model="worksheet.cost_${cost.uuid}_units" + <numeric-input v-model="worksheet.cost_${cost.uuid}_cases" @focus="activeUUID = '${cost.uuid}'; $event.target.select()" @blur="activeUUID = null" @keydown.native="inputKeydown($event, '${cost.uuid}', '${cost.product_uuid}')"> </numeric-input> - % else: - ${h.text('units_ordered_{}'.format(cost.uuid), value=int(cost._batchrow.units_ordered or 0) if cost._batchrow else None)} - % endif - </td> - ## TODO: should not fall back to po_total - % if use_buefy: - <td class="po-total">{{ worksheet.cost_${cost.uuid}_total_display }}</td> - % else: - <td class="po-total">${'${:0,.2f}'.format(cost._batchrow.po_total_calculated or cost._batchrow.po_total or 0) if cost._batchrow else ''}</td> + </td> % endif + <td class="current-order"> + <numeric-input v-model="worksheet.cost_${cost.uuid}_units" + @focus="activeUUID = '${cost.uuid}'; $event.target.select()" + @blur="activeUUID = null" + @keydown.native="inputKeydown($event, '${cost.uuid}', '${cost.product_uuid}')"> + </numeric-input> + </td> + <td class="po-total">{{ worksheet.cost_${cost.uuid}_total_display }}</td> ${self.extra_td(cost)} </tr> % endfor @@ -269,55 +196,7 @@ </%def> <%def name="page_content()"> - % if use_buefy: - <ordering-worksheet></ordering-worksheet> - % else: - <div class="form-wrapper"> - - <div class="field-wrapper"> - <label>Vendor</label> - <div class="field">${h.link_to(vendor, url('vendors.view', uuid=vendor.uuid))}</div> - </div> - - <div class="field-wrapper"> - <label>Vendor Email</label> - <div class="field">${vendor.email or ''}</div> - </div> - - <div class="field-wrapper"> - <label>Vendor Fax</label> - <div class="field">${vendor.fax_number or ''}</div> - </div> - - <div class="field-wrapper"> - <label>Vendor Contact</label> - <div class="field">${vendor.contact or ''}</div> - </div> - - <div class="field-wrapper"> - <label>Vendor Phone</label> - <div class="field">${vendor.phone or ''}</div> - </div> - - ${self.extra_vendor_fields()} - - <div class="field-wrapper po-total"> - <label>PO Total</label> - ## TODO: should not fall back to po_total - <div class="field">$${'{:0,.2f}'.format(batch.po_total_calculated or batch.po_total or 0)}</div> - </div> - - </div><!-- form-wrapper --> - - ${self.order_form_grid()} - - ${h.form(url('ordering.worksheet_update', uuid=batch.uuid), id='item-update-form', style='display: none;')} - ${h.csrf_token(request)} - ${h.hidden('product_uuid')} - ${h.hidden('cases_ordered')} - ${h.hidden('units_ordered')} - ${h.end_form()} - % endif + <ordering-worksheet></ordering-worksheet> </%def> <%def name="render_this_page_template()"> diff --git a/tailbone/templates/people/index.mako b/tailbone/templates/people/index.mako index 977ca1b7..c819050a 100644 --- a/tailbone/templates/people/index.mako +++ b/tailbone/templates/people/index.mako @@ -4,7 +4,6 @@ <%def name="grid_tools()"> % if master.mergeable and master.has_perm('request_merge'): - % if use_buefy: <b-button @click="showMergeRequest()" icon-pack="fas" icon-left="object-ungroup" @@ -57,7 +56,6 @@ </footer> </div> </b-modal> - % endif % endif ${parent.grid_tools()} diff --git a/tailbone/templates/people/merge-requests/view.mako b/tailbone/templates/people/merge-requests/view.mako index 5dcbea03..9e8905cf 100644 --- a/tailbone/templates/people/merge-requests/view.mako +++ b/tailbone/templates/people/merge-requests/view.mako @@ -4,7 +4,6 @@ <%def name="page_content()"> ${parent.page_content()} % if not instance.merged and request.has_perm('people.merge'): - % if use_buefy: ${h.form(url('people.merge'), **{'@submit': 'submitMergeForm'})} ${h.csrf_token(request)} ${h.hidden('uuids', value=','.join([instance.removing_uuid, instance.keeping_uuid]))} @@ -16,7 +15,6 @@ {{ mergeFormButtonText }} </b-button> ${h.end_form()} - % endif % endif </%def> diff --git a/tailbone/templates/people/view.mako b/tailbone/templates/people/view.mako index 50804392..973a1da8 100644 --- a/tailbone/templates/people/view.mako +++ b/tailbone/templates/people/view.mako @@ -2,25 +2,6 @@ <%inherit file="/master/view.mako" /> <%namespace file="/util.mako" import="view_profiles_helper" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy and not instance.users and request.has_perm('users.create'): - <script type="text/javascript"> - ## TODO: should do this differently for Buefy themes - $(function() { - $('#make-user').click(function() { - if (confirm("Really make a user account for this person?")) { - % if not use_buefy: - disable_button(this); - % endif - $('form[name="make-user-form"]').submit(); - } - }); - }); - </script> - % endif -</%def> - <%def name="object_helpers()"> ${parent.object_helpers()} ${view_profiles_helper([instance])} @@ -52,11 +33,7 @@ <%def name="page_content()"> ${parent.page_content()} % if not instance.users and request.has_perm('users.create'): - % if use_buefy: - ${h.form(url('people.make_user'), ref='makeUserForm')} - % else: - ${h.form(url('people.make_user'), name='make-user-form')} - % endif + ${h.form(url('people.make_user'), ref='makeUserForm')} ${h.csrf_token(request)} ${h.hidden('person_uuid', value=instance.uuid)} ${h.end_form()} diff --git a/tailbone/templates/principal/find_by_perm.mako b/tailbone/templates/principal/find_by_perm.mako index f055ce5d..24b43e36 100644 --- a/tailbone/templates/principal/find_by_perm.mako +++ b/tailbone/templates/principal/find_by_perm.mako @@ -3,77 +3,10 @@ <%def name="title()">Find ${model_title_plural} by Permission</%def> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - <script type="text/javascript"> - - <% gcount = len(permissions) %> - var permissions_by_group = { - % for g, (gkey, group) in enumerate(permissions, 1): - <% pcount = len(group['perms']) %> - '${gkey}': { - % for p, (pkey, perm) in enumerate(group['perms'], 1): - '${pkey}': "${perm['label']}"${',' if p < pcount else ''} - % endfor - }${',' if g < gcount else ''} - % endfor - }; - - $(function() { - - $('#permission_group').selectmenu({ - change: function(event, ui) { - var perms = $('#permission'); - perms.find('option:first').siblings('option').remove(); - $.each(permissions_by_group[ui.item.value], function(key, label) { - perms.append($('<option value="' + key + '">' + label + '</option>')); - }); - perms.selectmenu('refresh'); - } - }); - - $('#permission').selectmenu(); - - $('#find-by-perm-form').submit(function() { - $('.grid').remove(); - $(this).find('#submit').button('disable').button('option', 'label', "Searching, please wait..."); - }); - - }); - - </script> - % endif -</%def> - <%def name="page_content()"> - % if use_buefy: - <find-principals :permission-groups="permissionGroups" - :sorted-groups="sortedGroups"> - </find-principals> - % else: - ## not buefy - ${h.form(request.current_route_url(), id='find-by-perm-form')} - ${h.csrf_token(request)} - - <div class="form"> - ${self.wtfield(form, 'permission_group')} - ${self.wtfield(form, 'permission')} - <div class="buttons"> - ${h.submit('submit', "Find {}".format(model_title_plural))} - </div> - </div> - - ${h.end_form()} - - % if principals is not None: - <div class="grid half"> - <br /> - <h2>${model_title_plural} with that permission (${len(principals)} total):</h2> - ${self.principal_table()} - </div> - % endif - % endif + <find-principals :permission-groups="permissionGroups" + :sorted-groups="sortedGroups"> + </find-principals> </%def> <%def name="render_this_page_template()"> diff --git a/tailbone/templates/products/batch.mako b/tailbone/templates/products/batch.mako index be055b50..81af729b 100644 --- a/tailbone/templates/products/batch.mako +++ b/tailbone/templates/products/batch.mako @@ -7,66 +7,22 @@ <li>${h.link_to("Back to Products", url('products'))}</li> </%def> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - <script type="text/javascript"> - $(function() { - - $('select[name="batch_type"]').on('selectmenuchange', function(event, ui) { - $('.params-wrapper').hide(); - $('.params-wrapper.' + ui.item.value).show(); - }); - - $('.params-wrapper.' + $('select[name="batch_type"]').val()).show(); - - }); - </script> - % endif -</%def> - -<%def name="extra_styles()"> - ${parent.extra_styles()} - % if not use_buefy: - <style type="text/css"> - .params-wrapper { - display: none; - } - </style> - % endif -</%def> - <%def name="render_deform_field(form, field)"> - % if use_buefy: - <b-field horizontal - % if field.description: - message="${field.description}" - % endif - % if field.error: - type="is-danger" - :message='${form.messages_json(field.error.messages())|n}' - % endif - label="${field.title}"> - ${field.serialize(use_buefy=True)|n} - </b-field> - % else: - <div class="field-wrapper ${field.name}"> - <div class="field-row"> - <label for="${field.oid}">${field.title}</label> - <div class="field"> - ${field.serialize()|n} - </div> - </div> - </div> - % endif + <b-field horizontal + % if field.description: + message="${field.description}" + % endif + % if field.error: + type="is-danger" + :message='${form.messages_json(field.error.messages())|n}' + % endif + label="${field.title}"> + ${field.serialize()|n} + </b-field> </%def> <%def name="render_form_innards()"> - % if use_buefy: ${h.form(request.current_route_url(), **{'@submit': 'submit{}'.format(form.component_studly)})} - % else: - ${h.form(request.current_route_url(), class_='autodisable')} - % endif ${h.csrf_token(request)} <section> @@ -75,53 +31,33 @@ ${render_deform_field(form, dform['notes'])} % for key, pform in six.iteritems(params_forms): - % if use_buefy: - <div v-show="field_model_batch_type == '${key}'"> - % for field in pform.make_deform_form(): - ${render_deform_field(pform, field)} - % endfor - </div> - % else: - <div class="params-wrapper ${key}"> - ## TODO: hacky to use deform? at least is explicit.. - % for field in pform.make_deform_form(): - ${render_deform_field(pform, field)} - % endfor - </div> - % endif + <div v-show="field_model_batch_type == '${key}'"> + % for field in pform.make_deform_form(): + ${render_deform_field(pform, field)} + % endfor + </div> % endfor </section> <br /> <div class="buttons"> - % if use_buefy: - <b-button type="is-primary" - native-type="submit" - :disabled="${form.component_studly}Submitting"> - {{ ${form.component_studly}ButtonText }} - </b-button> - <b-button tag="a" href="${url('products')}"> - Cancel - </b-button> - % else: - ${h.submit('make-batch', "Create Batch")} - ${h.link_to("Cancel", url('products'), class_='button')} - % endif + <b-button type="is-primary" + native-type="submit" + :disabled="${form.component_studly}Submitting"> + {{ ${form.component_studly}ButtonText }} + </b-button> + <b-button tag="a" href="${url('products')}"> + Cancel + </b-button> </div> ${h.end_form()} </%def> <%def name="render_form()"> - % if use_buefy: - <script type="text/x-template" id="${form.component}-template"> - ${self.render_form_innards()} - </script> - % else: - <div class="form"> - ${self.render_form_innards()} - </div> - % endif + <script type="text/x-template" id="${form.component}-template"> + ${self.render_form_innards()} + </script> </%def> <%def name="modify_this_page_vars()"> diff --git a/tailbone/templates/products/index.mako b/tailbone/templates/products/index.mako index 8eada2fc..0d4bc410 100644 --- a/tailbone/templates/products/index.mako +++ b/tailbone/templates/products/index.mako @@ -1,133 +1,26 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/index.mako" /> -<%def name="extra_styles()"> - ${parent.extra_styles()} - % if not use_buefy: - <style type="text/css"> - - table.label-printing th { - font-weight: normal; - padding: 0px 0px 2px 4px; - text-align: left; - } - - table.label-printing td { - padding: 0px 0px 0px 4px; - } - - table.label-printing #label-quantity { - text-align: right; - width: 30px; - } - - div.grid table tbody td.size, - div.grid table tbody td.regular_price_uuid, - div.grid table tbody td.current_price_uuid { - padding-right: 6px; - text-align: right; - } - - div.grid table tbody td.labels { - text-align: center; - } - - </style> - % endif -</%def> - -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy and label_profiles and master.has_perm('print_labels'): - <script type="text/javascript"> - - $(function() { - - $('.grid-wrapper .grid-header .tools select').selectmenu(); - - $('.grid-wrapper').on('click', 'a.print_label', function() { - var tr = $(this).parents('tr:first'); - var quantity = $('table.label-printing #label-quantity'); - if (isNaN(quantity.val())) { - alert("You must provide a valid label quantity."); - quantity.select(); - quantity.focus(); - } else { - quantity = quantity.val(); - - var threshold = ${json.dumps(quick_label_speedbump_threshold)|n}; - if (threshold && parseInt(quantity) >= threshold) { - if (!confirm("Are you sure you want to print " + quantity + " labels?")) { - return false; - } - } - - var data = { - product: tr.data('uuid'), - profile: $('#label-profile').val(), - quantity: quantity - }; - $.get('${url('products.print_labels')}', data, function(data) { - if (data.error) { - alert("An error occurred while attempting to print:\n\n" + data.error); - } else if (quantity == '1') { - alert("1 label has been printed."); - } else { - alert(quantity + " labels have been printed."); - } - }); - } - return false; - }); - }); - - </script> - % endif -</%def> - <%def name="grid_tools()"> ${parent.grid_tools()} % if label_profiles and master.has_perm('print_labels'): - % if use_buefy: - <b-field grouped> - <b-field label="Label"> - <b-select v-model="quickLabelProfile"> - % for profile in label_profiles: - <option value="${profile.uuid}"> - ${profile.description} - </option> - % endfor - </b-select> - </b-field> - <b-field label="Qty."> - <b-input v-model="quickLabelQuantity" - ref="quickLabelQuantityInput" - style="width: 4rem;"> - </b-input> - </b-field> - </b-field> - % else: - <table class="label-printing"> - <thead> - <tr> - <th>Label</th> - <th>Qty.</th> - </tr> - </thead> - <tbody> - <td> - <select name="label-profile" id="label-profile"> - % for profile in label_profiles: - <option value="${profile.uuid}">${profile.description}</option> - % endfor - </select> - </td> - <td> - <input type="text" name="label-quantity" id="label-quantity" value="1" /> - </td> - </tbody> - </table> - % endif + <b-field grouped> + <b-field label="Label"> + <b-select v-model="quickLabelProfile"> + % for profile in label_profiles: + <option value="${profile.uuid}"> + ${profile.description} + </option> + % endfor + </b-select> + </b-field> + <b-field label="Qty."> + <b-input v-model="quickLabelQuantity" + ref="quickLabelQuantityInput" + style="width: 4rem;"> + </b-input> + </b-field> + </b-field> % endif </%def> diff --git a/tailbone/templates/products/view.mako b/tailbone/templates/products/view.mako index 0d0b2650..a6df1e7a 100644 --- a/tailbone/templates/products/view.mako +++ b/tailbone/templates/products/view.mako @@ -1,94 +1,16 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy and request.rattail_config.versioning_enabled() and master.has_perm('versions'): - <script type="text/javascript"> - - function showPriceHistory(typ) { - var dialog = $('#' + typ + '-price-history-dialog'); - dialog.dialog({ - title: typ[0].toUpperCase() + typ.slice(1) + " Price History", - width: 600, - height: 300, - modal: true, - buttons: [ - { - text: "Close", - click: function() { - dialog.dialog('close'); - } - } - ] - }); - } - - function showCostHistory() { - var dialog = $('#cost-history-dialog'); - dialog.dialog({ - title: "Cost History", - width: 600, - height: 300, - modal: true, - buttons: [ - { - text: "Close", - click: function() { - dialog.dialog('close'); - } - } - ] - }); - } - - $(function() { - - $('#view-regular-price-history').on('click', function() { - showPriceHistory('regular'); - return false; - }); - - $('#view-current-price-history').on('click', function() { - showPriceHistory('current'); - return false; - }); - - $('#view-suggested-price-history').on('click', function() { - showPriceHistory('suggested'); - return false; - }); - - $('#view-cost-history').on('click', function() { - showCostHistory(); - return false; - }); - - }); - - </script> - % endif -</%def> - <%def name="extra_styles()"> ${parent.extra_styles()} <style type="text/css"> - % if use_buefy: - #main-product-panel { - margin-right: 2em; - margin-top: 1em; - } - #pricing-panel .field-wrapper .field { - white-space: nowrap; - } - % else: - .price-history-dialog { - display: none; - } - .price-history-dialog .grid { - color: black; - } - % endif + #main-product-panel { + margin-right: 2em; + margin-top: 1em; + } + #pricing-panel .field-wrapper .field { + white-space: nowrap; + } </style> </%def> @@ -100,37 +22,22 @@ </%def> <%def name="left_column()"> - % if use_buefy: - <nav class="panel" id="pricing-panel"> - <p class="panel-heading">Pricing</p> - <div class="panel-block"> - <div> - ${self.render_price_fields(form)} - </div> - </div> - </nav> - <nav class="panel"> - <p class="panel-heading">Flags</p> - <div class="panel-block"> - <div> - ${self.render_flag_fields(form)} - </div> - </div> - </nav> - % else: - <div class="panel"> - <h2>Pricing</h2> - <div class="panel-body"> - ${self.render_price_fields(form)} + <nav class="panel" id="pricing-panel"> + <p class="panel-heading">Pricing</p> + <div class="panel-block"> + <div> + ${self.render_price_fields(form)} + </div> </div> - </div> - <div class="panel"> - <h2>Flags</h2> - <div class="panel-body"> - ${self.render_flag_fields(form)} + </nav> + <nav class="panel"> + <p class="panel-heading">Flags</p> + <div class="panel-block"> + <div> + ${self.render_flag_fields(form)} + </div> </div> - </div> - % endif + </nav> ${self.extra_left_panels()} </%def> @@ -147,23 +54,14 @@ <%def name="extra_main_fields(form)"></%def> <%def name="organization_panel()"> - % if use_buefy: - <nav class="panel"> - <p class="panel-heading">Organization</p> - <div class="panel-block"> - <div> - ${self.render_organization_fields(form)} - </div> - </div> - </nav> - % else: - <div class="panel"> - <h2>Organization</h2> - <div class="panel-body"> - ${self.render_organization_fields(form)} + <nav class="panel"> + <p class="panel-heading">Organization</p> + <div class="panel-block"> + <div> + ${self.render_organization_fields(form)} + </div> </div> - </div> - % endif + </nav> </%def> <%def name="render_organization_fields(form)"> @@ -195,23 +93,14 @@ </%def> <%def name="movement_panel()"> - % if use_buefy: - <nav class="panel"> - <p class="panel-heading">Movement</p> - <div class="panel-block"> - <div> - ${self.render_movement_fields(form)} - </div> - </div> - </nav> - % else: - <div class="panel"> - <h2>Movement</h2> - <div class="panel-body"> - ${self.render_movement_fields(form)} + <nav class="panel"> + <p class="panel-heading">Movement</p> + <div class="panel-block"> + <div> + ${self.render_movement_fields(form)} + </div> </div> - </div> - % endif + </nav> </%def> <%def name="render_movement_fields(form)"> @@ -219,9 +108,7 @@ </%def> <%def name="lookup_codes_grid()"> - % if use_buefy: - ${lookup_codes['grid'].render_buefy_table_element(data_prop='lookupCodesData')|n} - % else: + ${lookup_codes['grid'].render_buefy_table_element(data_prop='lookupCodesData')|n} <div class="grid full no-border"> <table> <thead> @@ -238,29 +125,19 @@ </tbody> </table> </div> - % endif </%def> <%def name="lookup_codes_panel()"> - % if use_buefy: - <nav class="panel"> - <p class="panel-heading">Additional Lookup Codes</p> - <div class="panel-block"> - ${self.lookup_codes_grid()} - </div> - </nav> - % else: - <div class="panel-grid" id="product-codes"> - <h2>Additional Lookup Codes</h2> - ${self.lookup_codes_grid()} - </div> - % endif + <nav class="panel"> + <p class="panel-heading">Additional Lookup Codes</p> + <div class="panel-block"> + ${self.lookup_codes_grid()} + </div> + </nav> </%def> <%def name="sources_grid()"> - % if use_buefy: - ${vendor_sources['grid'].render_buefy_table_element(data_prop='vendorSourcesData')|n} - % else: + ${vendor_sources['grid'].render_buefy_table_element(data_prop='vendorSourcesData')|n} <div class="grid full no-border"> <table> <thead> @@ -293,71 +170,40 @@ </tbody> </table> </div> - % endif </%def> <%def name="sources_panel()"> - % if use_buefy: - <nav class="panel"> - <p class="panel-heading"> - Vendor Sources - % if request.rattail_config.versioning_enabled() and master.has_perm('versions'): - <a href="#" @click.prevent="showCostHistory()"> - (view cost history) - </a> - % endif - </p> - <div class="panel-block"> - ${self.sources_grid()} - </div> - </nav> - % else: - <div class="panel-grid" id="product-costs"> - <h2> + <nav class="panel"> + <p class="panel-heading"> Vendor Sources % if request.rattail_config.versioning_enabled() and master.has_perm('versions'): - <a id="view-cost-history" href="#">(view cost history)</a> + <a href="#" @click.prevent="showCostHistory()"> + (view cost history) + </a> % endif - </h2> - ${self.sources_grid()} - </div> - % endif + </p> + <div class="panel-block"> + ${self.sources_grid()} + </div> + </nav> </%def> <%def name="notes_panel()"> - % if use_buefy: - <nav class="panel"> - <p class="panel-heading">Notes</p> - <div class="panel-block"> - <div class="field">${form.render_field_readonly('notes')}</div> - </div> - </nav> - % else: - <div class="panel"> - <h2>Notes</h2> - <div class="panel-body"> + <nav class="panel"> + <p class="panel-heading">Notes</p> + <div class="panel-block"> <div class="field">${form.render_field_readonly('notes')}</div> </div> - </div> - % endif + </nav> </%def> <%def name="ingredients_panel()"> - % if use_buefy: - <nav class="panel"> - <p class="panel-heading">Ingredients</p> - <div class="panel-block"> - ${form.render_field_readonly('ingredients')} - </div> - </nav> - % else: - <div class="panel"> - <h2>Ingredients</h2> - <div class="panel-body"> + <nav class="panel"> + <p class="panel-heading">Ingredients</p> + <div class="panel-block"> ${form.render_field_readonly('ingredients')} </div> - </div> - % endif + </nav> </%def> <%def name="extra_left_panels()"></%def> @@ -366,7 +212,7 @@ <%def name="render_this_page()"> ${parent.render_this_page()} - % if use_buefy and request.rattail_config.versioning_enabled() and master.has_perm('versions'): + % if request.rattail_config.versioning_enabled() and master.has_perm('versions'): <b-modal :active.sync="showingPriceHistory_regular" has-modal-card> @@ -447,83 +293,34 @@ </%def> <%def name="page_content()"> - % if use_buefy: <div style="display: flex; flex-direction: column;"> - <nav class="panel" id="main-product-panel"> - <p class="panel-heading">Product</p> - <div class="panel-block"> - <div style="display: flex; justify-content: space-between; width: 100%;"> - <div> - ${self.render_main_fields(form)} - </div> - <div> - % if image_url: - ${h.image(image_url, "Product Image", id='product-image', width=150, height=150)} - % endif - </div> - </div> - </div> - </nav> - - <div style="display: flex;"> - <div class="panel-wrapper"> <!-- left column --> - ${self.left_column()} - </div> <!-- left column --> - <div class="panel-wrapper" style="margin-left: 1em;"> <!-- right column --> - ${self.right_column()} - </div> <!-- right column --> - </div> - + <nav class="panel" id="main-product-panel"> + <p class="panel-heading">Product</p> + <div class="panel-block"> + <div style="display: flex; justify-content: space-between; width: 100%;"> + <div> + ${self.render_main_fields(form)} </div> - - % else: - ## legacy / not buefy - - <div style="display: flex; flex-direction: column;"> - - <div class="panel" id="product-main"> - <h2>Product</h2> - <div class="panel-body"> - <div style="display: flex; justify-content: space-between;"> - <div> - ${self.render_main_fields(form)} - </div> - <div> - % if image_url: - ${h.image(image_url, "Product Image", id='product-image', width=150, height=150)} - % endif - </div> - </div> - </div> + <div> + % if image_url: + ${h.image(image_url, "Product Image", id='product-image', width=150, height=150)} + % endif </div> - - <div style="display: flex;"> - <div class="panel-wrapper"> <!-- left column --> - ${self.left_column()} - </div> <!-- left column --> - <div class="panel-wrapper" style="margin-left: 1em;"> <!-- right column --> - ${self.right_column()} - </div> <!-- right column --> - </div> - </div> + </div> + </nav> - % if request.rattail_config.versioning_enabled() and master.has_perm('versions'): - <div class="price-history-dialog" id="regular-price-history-dialog"> - ${regular_price_history_grid.render_grid()|n} - </div> - <div class="price-history-dialog" id="current-price-history-dialog"> - ${current_price_history_grid.render_grid()|n} - </div> - <div class="price-history-dialog" id="suggested-price-history-dialog"> - ${suggested_price_history_grid.render_grid()|n} - </div> - <div class="price-history-dialog" id="cost-history-dialog"> - ${cost_history_grid.render_grid()|n} - </div> - % endif - % endif + <div style="display: flex;"> + <div class="panel-wrapper"> <!-- left column --> + ${self.left_column()} + </div> <!-- left column --> + <div class="panel-wrapper" style="margin-left: 1em;"> <!-- right column --> + ${self.right_column()} + </div> <!-- right column --> + </div> + + </div> % if buttons: ${buttons|n} diff --git a/tailbone/templates/purchases/batches/receive_form.mako b/tailbone/templates/purchases/batches/receive_form.mako deleted file mode 100644 index 3a3ed888..00000000 --- a/tailbone/templates/purchases/batches/receive_form.mako +++ /dev/null @@ -1,468 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/base.mako" /> - -<%def name="title()">Receiving Form (${batch.vendor})</%def> - -<%def name="head_tags()"> - ${parent.head_tags()} - ${h.javascript_link(request.static_url('tailbone:static/js/numeric.js'))} - <script type="text/javascript"> - - function assert_quantity() { - if ($('#cases').val() && parseFloat($('#cases').val())) { - return true; - } - if ($('#units').val() && parseFloat($('#units').val())) { - return true; - } - alert("Please provide case and/or unit quantity"); - $('#cases').select().focus(); - return false; - } - - function invalid_product(msg) { - $('#received-product-info p').text(msg); - $('#received-product-info img').hide(); - $('#upc').focus().select(); - $('.field-wrapper.cases input').prop('disabled', true); - $('.field-wrapper.units input').prop('disabled', true); - $('.buttons button').button('disable'); - } - - function pretty_quantity(cases, units) { - if (cases && units) { - return cases + " cases, " + units + " units"; - } else if (cases) { - return cases + " cases"; - } else if (units) { - return units + " units"; - } - return ''; - } - - function show_quantity(name, cases, units) { - var quantity = pretty_quantity(cases, units); - var field = $('.field-wrapper.quantity_' + name); - field.find('.field').text(quantity); - if (quantity || name == 'ordered') { - field.show(); - } else { - field.hide(); - } - } - - $(function() { - - $('#upc').keydown(function(event) { - - if (key_allowed(event)) { - return true; - } - if (key_modifies(event)) { - $('#product').val(''); - $('#received-product-info p').html("please ENTER a scancode"); - $('#received-product-info img').hide(); - $('#received-product-info .warning').hide(); - $('.product-fields').hide(); - $('.receiving-fields').hide(); - $('.field-wrapper.cases input').prop('disabled', true); - $('.field-wrapper.units input').prop('disabled', true); - $('.buttons button').button('disable'); - return true; - } - - // when user presses ENTER, do product lookup - if (event.which == 13) { - var upc = $(this).val(); - var data = {'upc': upc}; - $.get('${url('purchases.batch.receiving_lookup', uuid=batch.uuid)}', data, function(data) { - - if (data.error) { - alert(data.error); - if (data.redirect) { - $('#receiving-form').mask("Redirecting..."); - location.href = data.redirect; - } - - } else if (data.product) { - $('#upc').val(data.product.upc_pretty); - $('#product').val(data.product.uuid); - $('#brand_name').val(data.product.brand_name); - $('#description').val(data.product.description); - $('#size').val(data.product.size); - $('#case_quantity').val(data.product.case_quantity); - - $('#received-product-info p').text(data.product.full_description); - $('#received-product-info img').attr('src', data.product.image_url).show(); - if (! data.product.uuid) { - // $('#received-product-info .warning.notfound').show(); - $('.product-fields').show(); - } - if (data.product.found_in_batch) { - show_quantity('ordered', data.product.cases_ordered, data.product.units_ordered); - show_quantity('received', data.product.cases_received, data.product.units_received); - show_quantity('damaged', data.product.cases_damaged, data.product.units_damaged); - show_quantity('expired', data.product.cases_expired, data.product.units_expired); - show_quantity('mispick', data.product.cases_mispick, data.product.units_mispick); - $('.receiving-fields').show(); - } else { - $('#received-product-info .warning.notordered').show(); - } - $('.field-wrapper.cases input').prop('disabled', false); - $('.field-wrapper.units input').prop('disabled', false); - $('.buttons button').button('enable'); - $('#cases').focus().select(); - - } else if (data.upc) { - $('#upc').val(data.upc_pretty); - $('#received-product-info p').text("product not found in our system"); - $('#received-product-info img').attr('src', data.image_url).show(); - - $('#product').val(''); - $('#brand_name').val(''); - $('#description').val(''); - $('#size').val(''); - $('#case_quantity').val(''); - - $('#received-product-info .warning.notfound').show(); - $('.product-fields').show(); - $('#brand_name').focus(); - $('.field-wrapper.cases input').prop('disabled', false); - $('.field-wrapper.units input').prop('disabled', false); - $('.buttons button').button('enable'); - - } else { - invalid_product('product not found'); - } - }); - } - return false; - }); - - $('#received').click(function() { - if (! assert_quantity()) { - return; - } - $(this).button('disable').button('option', 'label', "Working..."); - $('#mode').val('received'); - $('#receiving-form').submit(); - }); - - $('#damaged').click(function() { - if (! assert_quantity()) { - return; - } - $(this).button('disable').button('option', 'label', "Working..."); - $('#mode').val('damaged'); - $('#damaged-dialog').dialog({ - title: "Damaged Product", - modal: true, - width: '500px', - buttons: [ - { - text: "OK", - click: function() { - $('#damaged-dialog').dialog('close'); - $('#receiving-form #trash').val($('#damaged-dialog #trash').is(':checked') ? '1' : ''); - $('#receiving-form').submit(); - } - }, - { - text: "Cancel", - click: function() { - $('#damaged').button('option', 'label', "Damaged").button('enable'); - $('#damaged-dialog').dialog('close'); - } - } - ] - }); - }); - - $('#expiration input[type="date"]').datepicker(); - - $('#expired').click(function() { - if (! assert_quantity()) { - return; - } - $(this).button('disable').button('option', 'label', "Working..."); - $('#mode').val('expired'); - $('#expiration').dialog({ - title: "Expired / Short Date", - modal: true, - width: '500px', - buttons: [ - { - text: "OK", - click: function() { - $('#expiration').dialog('close'); - $('#receiving-form #expiration_date').val( - $('#expiration input[type="date"]').val()); - $('#receiving-form #trash').val($('#expiration #trash').is(':checked') ? '1' : ''); - $('#receiving-form').submit(); - } - }, - { - text: "Cancel", - click: function() { - $('#expired').button('option', 'label', "Expired").button('enable'); - $('#expiration').dialog('close'); - } - } - ] - }); - }); - - $('#mispick').click(function() { - if (! assert_quantity()) { - return; - } - $(this).button('disable').button('option', 'label', "Working..."); - $('#ordered-product').val(''); - $('#ordered-product-textbox').val(''); - $('#ordered-product-info p').html("please ENTER a scancode"); - $('#ordered-product-info img').hide(); - $('#mispick-dialog').dialog({ - title: "Mispick - Ordered Product", - modal: true, - width: 400, - buttons: [ - { - text: "OK", - click: function() { - if ($('#ordered-product-info .warning').is(':visible')) { - alert("You must choose a product which was ordered."); - $('#ordered-product-textbox').select().focus(); - return; - } - $('#mispick-dialog').dialog('close'); - $('#mode').val('mispick'); - $('#receiving-form').submit(); - } - }, - { - text: "Cancel", - click: function() { - $('#mispick').button('option', 'label', "Mispick").button('enable'); - $('#mispick-dialog').dialog('close'); - } - } - ] - }); - }); - - $('#ordered-product-textbox').keydown(function(event) { - - if (key_allowed(event)) { - return true; - } - if (key_modifies(event)) { - $('#ordered_product').val(''); - $('#ordered-product-info p').html("please ENTER a scancode"); - $('#ordered-product-info img').hide(); - $('#ordered-product-info .warning').hide(); - return true; - } - if (event.which == 13) { - var input = $(this); - var data = {upc: input.val()}; - $.get('${url('purchases.batch.receiving_lookup', uuid=batch.uuid)}', data, function(data) { - if (data.error) { - alert(data.error); - if (data.redirect) { - $('#mispick-dialog').mask("Redirecting..."); - location.href = data.redirect; - } - } else if (data.product) { - input.val(data.product.upc_pretty); - $('#ordered_product').val(data.product.uuid); - $('#ordered-product-info p').text(data.product.full_description); - $('#ordered-product-info img').attr('src', data.product.image_url).show(); - if (data.product.found_in_batch) { - $('#ordered-product-info .warning').hide(); - } else { - $('#ordered-product-info .warning').show(); - } - } else { - $('#ordered-product-info p').text("product not found"); - $('#ordered-product-info img').hide(); - $('#ordered-product-info .warning').hide(); - } - }); - } - return false; - }); - - $('#receiving-form').submit(function() { - $(this).mask("Working..."); - }); - - $('#upc').focus(); - $('.field-wrapper.cases input').prop('disabled', true); - $('.field-wrapper.units input').prop('disabled', true); - $('.buttons button').button('disable'); - - }); - </script> -</%def> - -<%def name="extra_styles()"> - ${parent.extra_styles()} - <style type="text/css"> - - .product-info { - margin-top: 0.5em; - text-align: center; - } - - .product-info p { - margin-left: 0.5em; - } - - .product-info .img-wrapper { - height: 150px; - margin: 0.5em 0; - } - - #received-product-info .warning { - background: #f66; - display: none; - } - - #mispick-dialog input[type="text"], - #ordered-product-info { - width: 320px; - } - - #ordered-product-info .warning { - background: #f66; - display: none; - } - - </style> -</%def> - - -<%def name="context_menu_items()"> - <li>${h.link_to("Back to Purchase Batch", url('purchases.batch.view', uuid=batch.uuid))}</li> -</%def> - - -<ul id="context-menu"> - ${self.context_menu_items()} -</ul> - -<div class="form-wrapper"> - ${form.begin(id='receiving-form')} - ${form.csrf_token()} - ${h.hidden('mode')} - ${h.hidden('expiration_date')} - ${h.hidden('trash')} - ${h.hidden('ordered_product')} - - <div class="field-wrapper"> - <label for="upc">Receiving UPC</label> - <div class="field"> - ${h.hidden('product')} - <div>${h.text('upc', autocomplete='off')}</div> - <div id="received-product-info" class="product-info"> - <p>please ENTER a scancode</p> - <div class="img-wrapper"><img /></div> - <div class="warning notfound">please confirm UPC and provide more details</div> - <div class="warning notordered">warning: product not found on current purchase</div> - </div> - </div> - </div> - - <div class="product-fields" style="display: none;"> - - <div class="field-wrapper brand_name"> - <label for="brand_name">Brand Name</label> - <div class="field">${h.text('brand_name')}</div> - </div> - - <div class="field-wrapper description"> - <label for="description">Description</label> - <div class="field">${h.text('description')}</div> - </div> - - <div class="field-wrapper size"> - <label for="size">Size</label> - <div class="field">${h.text('size')}</div> - </div> - - <div class="field-wrapper case_quantity"> - <label for="case_quantity">Units in Case</label> - <div class="field">${h.text('case_quantity')}</div> - </div> - - </div> - - <div class="receiving-fields" style="display: none;"> - - <div class="field-wrapper quantity_ordered"> - <label for="quantity_ordered">Ordered</label> - <div class="field"></div> - </div> - - <div class="field-wrapper quantity_received"> - <label for="quantity_received">Received</label> - <div class="field"></div> - </div> - - <div class="field-wrapper quantity_damaged"> - <label for="quantity_damaged">Damaged</label> - <div class="field"></div> - </div> - - <div class="field-wrapper quantity_expired"> - <label for="quantity_expired">Expired</label> - <div class="field"></div> - </div> - - <div class="field-wrapper quantity_mispick"> - <label for="quantity_mispick">Mispick</label> - <div class="field"></div> - </div> - - </div> - - <div class="field-wrapper cases"> - <label for="cases">Cases</label> - <div class="field">${h.text('cases', autocomplete='off')}</div> - </div> - - <div class="field-wrapper units"> - <label for="units">Units</label> - <div class="field">${h.text('units', autocomplete='off')}</div> - </div> - - <div class="buttons"> - <button type="button" id="received">Received</button> - <button type="button" id="damaged">Damaged</button> - <button type="button" id="expired">Expired</button> - <!-- <button type="button" id="mispick">Mispick</button> --> - </div> - - ${form.end()} -</div> - -<div id="damaged-dialog" style="display: none;"> - <div class="field-wrapper trash">${h.checkbox('trash', label="Product will be discarded and cannot be returned", checked=False)}</div> -</div> - -<div id="expiration" style="display: none;"> - <div class="field-wrapper expiration-date"> - <label for="expiration-date">Expiration Date</label> - <div class="field">${h.text('expiration-date', type='date')}</div> - </div> - <div class="field-wrapper trash">${h.checkbox('trash', label="Product will be discarded and cannot be returned", checked=False)}</div> -</div> - -<div id="mispick-dialog" style="display: none;"> - <div>${h.text('ordered-product-textbox', autocomplete='off')}</div> - <div id="ordered-product-info" class="product-info"> - <p>please ENTER a scancode</p> - <div class="img-wrapper"><img /></div> - <div class="warning">warning: product not found on current purchase</div> - </div> -</div> diff --git a/tailbone/templates/receiving/create.mako b/tailbone/templates/receiving/create.mako index c31cb849..35ee878a 100644 --- a/tailbone/templates/receiving/create.mako +++ b/tailbone/templates/receiving/create.mako @@ -1,80 +1,6 @@ ## -*- coding: utf-8; -*- <%inherit file="/batch/create.mako" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - ${self.func_show_batch_type()} - <script type="text/javascript"> - - % if master.handler.allow_truck_dump_receiving(): - var batch_vendor_map = ${json.dumps(batch_vendor_map)|n}; - % endif - - $(function() { - - $('.batch_type select').on('selectmenuchange', function(event, ui) { - show_batch_type(ui.item.value); - }); - - $('.truck_dump_batch_uuid select').on('selectmenuchange', function(event, ui) { - var form = $(this).parents('form'); - var uuid = ui.item.value ? batch_vendor_map[ui.item.value] : ''; - form.find('input[name="vendor_uuid"]').val(uuid); - }); - - show_batch_type(); - }); - - </script> - % endif -</%def> - -<%def name="func_show_batch_type()"> - <script type="text/javascript"> - - function show_batch_type(batch_type) { - - if (batch_type === undefined) { - batch_type = $('.field-wrapper.batch_type select').val(); - } - - if (batch_type == 'from_scratch') { - $('.field-wrapper.truck_dump_batch_uuid').hide(); - $('.field-wrapper.invoice_file').hide(); - $('.field-wrapper.invoice_parser_key').hide(); - $('.field-wrapper.vendor_uuid').show(); - $('.field-wrapper.date_ordered').show(); - $('.field-wrapper.date_received').show(); - $('.field-wrapper.po_number').show(); - $('.field-wrapper.invoice_date').show(); - $('.field-wrapper.invoice_number').show(); - - } else if (batch_type == 'truck_dump_children_first') { - $('.field-wrapper.truck_dump_batch_uuid').hide(); - $('.field-wrapper.invoice_file').hide(); - $('.field-wrapper.invoice_parser_key').hide(); - $('.field-wrapper.vendor_uuid').show(); - $('.field-wrapper.date_ordered').hide(); - $('.field-wrapper.date_received').show(); - $('.field-wrapper.po_number').hide(); - $('.field-wrapper.invoice_date').hide(); - $('.field-wrapper.invoice_number').hide(); - - } else if (batch_type == 'truck_dump_children_last') { - $('.field-wrapper.truck_dump_batch_uuid').hide(); - $('.field-wrapper.invoice_file').hide(); - $('.field-wrapper.invoice_parser_key').hide(); - $('.field-wrapper.vendor_uuid').show(); - $('.field-wrapper.date_ordered').hide(); - $('.field-wrapper.date_received').show(); - $('.field-wrapper.po_number').hide(); - $('.field-wrapper.invoice_date').hide(); - $('.field-wrapper.invoice_number').hide(); - } - } - - </script> -</%def> +## TODO: deprecate / remove this ${parent.body()} diff --git a/tailbone/templates/receiving/declare_credit.mako b/tailbone/templates/receiving/declare_credit.mako index 84d4dbec..6224a539 100644 --- a/tailbone/templates/receiving/declare_credit.mako +++ b/tailbone/templates/receiving/declare_credit.mako @@ -11,35 +11,6 @@ % endif </%def> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - <script type="text/javascript"> - - function toggleFields(creditType) { - if (creditType === undefined) { - creditType = $('select[name="credit_type"]').val(); - } - if (creditType == 'expired') { - $('.field-wrapper.expiration_date').show(); - } else { - $('.field-wrapper.expiration_date').hide(); - } - } - - $(function() { - - toggleFields(); - - $('select[name="credit_type"]').on('selectmenuchange', function(event, ui) { - toggleFields(ui.item.value); - }); - - }); - </script> - % endif -</%def> - <%def name="render_buefy_form()"> <p class="block"> @@ -75,30 +46,7 @@ </%def> <%def name="render_form()"> - % if use_buefy: - - ${form.render_deform(buttons=capture(self.render_form_buttons), form_body=capture(self.buefy_form_body))|n} - - % else: - - <p style="padding: 1em;"> - Please select the "state" of the product, and enter the appropriate - quantity. - </p> - - <p style="padding: 1em;"> - Note that this tool will <strong>deduct</strong> from the "received" - quantity, and <strong>add</strong> to the corresponding credit quantity. - </p> - - <p style="padding: 1em;"> - Please see ${h.link_to("Receive Row", url('{}.receive_row'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid))} - if you need to "receive" instead of "convert" the product. - </p> - - ${parent.render_form()} - - % endif + ${form.render_deform(buttons=capture(self.render_form_buttons), form_body=capture(self.buefy_form_body))|n} </%def> diff --git a/tailbone/templates/receiving/receive_row.mako b/tailbone/templates/receiving/receive_row.mako index b17b118a..7ef95ac4 100644 --- a/tailbone/templates/receiving/receive_row.mako +++ b/tailbone/templates/receiving/receive_row.mako @@ -11,35 +11,6 @@ % endif </%def> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - <script type="text/javascript"> - - function toggleFields(mode) { - if (mode === undefined) { - mode = $('select[name="mode"]').val(); - } - if (mode == 'expired') { - $('.field-wrapper.expiration_date').show(); - } else { - $('.field-wrapper.expiration_date').hide(); - } - } - - $(function() { - - toggleFields(); - - $('select[name="mode"]').on('selectmenuchange', function(event, ui) { - toggleFields(ui.item.value); - }); - - }); - </script> - % endif -</%def> - <%def name="render_buefy_form()"> <p class="block"> @@ -72,30 +43,7 @@ </%def> <%def name="render_form()"> - % if use_buefy: - - ${form.render_deform(buttons=capture(self.render_form_buttons), form_body=capture(self.buefy_form_body))|n} - - % else: - - <p style="padding: 1em;"> - Please select the "state" of the product, and enter the appropriate - quantity. - </p> - - <p style="padding: 1em;"> - Note that this tool will <strong>add</strong> the corresponding - quantities for the row. - </p> - - <p style="padding: 1em;"> - Please see ${h.link_to("Declare Credit", url('{}.declare_credit'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid))} - if you need to "convert" some already-received amount, into a credit. - </p> - - ${parent.render_form()} - - % endif + ${form.render_deform(buttons=capture(self.render_form_buttons), form_body=capture(self.buefy_form_body))|n} </%def> diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako index f4a90dcb..463fdf6c 100644 --- a/tailbone/templates/receiving/view.mako +++ b/tailbone/templates/receiving/view.mako @@ -1,311 +1,32 @@ ## -*- coding: utf-8; -*- <%inherit file="/batch/view.mako" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy and master.has_perm('edit_row'): - ${h.javascript_link(request.static_url('tailbone:static/js/numeric.js'))} - <script type="text/javascript"> - - % if not batch.executed: - // keep track of which cost value is currently being edited - var editing_catalog_cost = null; - var editing_invoice_cost = null; - - function start_editing(td) { - var value = null; - var text = td.text().replace(/^\s+|\s+$/g, ''); - if (text) { - td.data('previous-value', text); - td.text(''); - value = parseFloat(text.replace('$', '')); - } - var input = $('<input type="text" />'); - td.append(input); - value = value ? value.toString() : ''; - input.val(value).select().focus(); - } - - function start_editing_catalog_cost(td) { - start_editing(td); - editing_catalog_cost = td; - } - - function start_editing_invoice_cost(td) { - start_editing(td); - editing_invoice_cost = td; - } - - function start_editing_next_catalog_cost() { - var tr = editing_catalog_cost.parents('tr:first'); - var next = tr.next('tr:first'); - if (next.length) { - start_editing_catalog_cost(next.find('td.catalog_unit_cost')); - } else { - editing_catalog_cost = null; - } - } - - function start_editing_next_invoice_cost() { - var tr = editing_invoice_cost.parents('tr:first'); - var next = tr.next('tr:first'); - if (next.length) { - start_editing_invoice_cost(next.find('td.invoice_unit_cost')); - } else { - editing_invoice_cost = null; - } - } - - function cancel_edit(td) { - var input = td.find('input'); - input.blur(); - input.remove(); - var value = td.data('previous-value'); - if (value) { - td.text(value); - } - } - - function cancel_edit_catalog_cost() { - cancel_edit(editing_catalog_cost); - editing_catalog_cost = null; - } - - function cancel_edit_invoice_cost() { - cancel_edit(editing_invoice_cost); - editing_invoice_cost = null; - } - - % endif - - $(function() { - - % if not batch.executed: - $('.grid-wrapper').on('click', '.grid td.catalog_unit_cost', function() { - if (editing_catalog_cost) { - editing_catalog_cost.find('input').focus(); - return - } - if (editing_invoice_cost) { - editing_invoice_cost.find('input').focus(); - return - } - var td = $(this); - start_editing_catalog_cost(td); - }); - - $('.grid-wrapper').on('click', '.grid td.invoice_unit_cost', function() { - if (editing_invoice_cost) { - editing_invoice_cost.find('input').focus(); - return - } - if (editing_catalog_cost) { - editing_catalog_cost.find('input').focus(); - return - } - var td = $(this); - start_editing_invoice_cost(td); - }); - - $('.grid-wrapper').on('keyup', '.grid td.catalog_unit_cost input', function(event) { - var input = $(this); - - // let numeric keys modify input value - if (! key_modifies(event)) { - - // when user presses Enter while editing cost value, submit - // value to server for immediate persistence - if (event.which == 13) { - $('.grid-wrapper').mask("Updating cost..."); - var url = '${url('receiving.update_row_cost', uuid=batch.uuid)}'; - var td = input.parents('td:first'); - var tr = td.parents('tr:first'); - var data = { - '_csrf': $('[name="_csrf"]').val(), - 'row_uuid': tr.data('uuid'), - 'catalog_unit_cost': input.val() - }; - $.post(url, data, function(data) { - if (data.error) { - alert(data.error); - } else { - var total = null; - - // update catalog cost for row - td.text(data.row.catalog_unit_cost); - - // mark cost as confirmed - if (data.row.catalog_cost_confirmed) { - tr.addClass('catalog_cost_confirmed'); - } - - input.blur(); - input.remove(); - start_editing_next_catalog_cost(); - } - $('.grid-wrapper').unmask(); - }); - - // When user presses Escape while editing totals, cancel the edit. - } else if (event.which == 27) { - cancel_edit_catalog_cost(); - - // Most other keys at this point should be unwanted... - } else if (! key_allowed(event)) { - return false; - } - } - }); - - $('.grid-wrapper').on('keyup', '.grid td.invoice_unit_cost input', function(event) { - var input = $(this); - - // let numeric keys modify input value - if (! key_modifies(event)) { - - // when user presses Enter while editing cost value, submit - // value to server for immediate persistence - if (event.which == 13) { - $('.grid-wrapper').mask("Updating cost..."); - var url = '${url('receiving.update_row_cost', uuid=batch.uuid)}'; - var td = input.parents('td:first'); - var tr = td.parents('tr:first'); - var data = { - '_csrf': $('[name="_csrf"]').val(), - 'row_uuid': tr.data('uuid'), - 'invoice_unit_cost': input.val() - }; - $.post(url, data, function(data) { - if (data.error) { - alert(data.error); - } else { - var total = null; - - // update unit cost for row - td.text(data.row.invoice_unit_cost); - - // update invoice total for row - total = tr.find('td.invoice_total_calculated'); - total.text('$' + data.row.invoice_total_calculated); - - // update invoice total for batch - total = $('.form .field-wrapper.invoice_total_calculated .field'); - total.text('$' + data.batch.invoice_total_calculated); - - // mark cost as confirmed - if (data.row.invoice_cost_confirmed) { - tr.addClass('invoice_cost_confirmed'); - } - - input.blur(); - input.remove(); - start_editing_next_invoice_cost(); - } - $('.grid-wrapper').unmask(); - }); - - // When user presses Escape while editing totals, cancel the edit. - } else if (event.which == 27) { - cancel_edit_invoice_cost(); - - // Most other keys at this point should be unwanted... - } else if (! key_allowed(event)) { - return false; - } - } - }); - % endif - - $('.grid-wrapper').on('click', '.grid .actions a.transform', function() { - - var form = $('form[name="transform-unit-form"]'); - var row_uuid = $(this).parents('tr:first').data('uuid'); - form.find('[name="row_uuid"]').val(row_uuid); - - $.get(form.attr('action'), {row_uuid: row_uuid}, function(data) { - - if (typeof(data) == 'object') { - alert(data.error); - - } else { - $('#transform-unit-dialog').html(data); - $('#transform-unit-dialog').dialog({ - title: "Transform Pack to Unit Item", - width: 800, - height: 450, - modal: true, - buttons: [ - { - text: "Transform", - click: function(event) { - disable_button(dialog_button(event)); - form.submit(); - } - }, - { - text: "Cancel", - click: function() { - $(this).dialog('close'); - } - } - ] - }); - } - }); - - return false; - }); - - }); - - </script> - % endif -</%def> - <%def name="extra_styles()"> ${parent.extra_styles()} - % if use_buefy: - <style type="text/css"> - % if allow_edit_catalog_unit_cost: - td.c_catalog_unit_cost { - cursor: pointer; - background-color: #fcc; - } - tr.catalog_cost_confirmed td.c_catalog_unit_cost { - background-color: #cfc; - } - % endif - % if allow_edit_invoice_unit_cost: - td.c_invoice_unit_cost { - cursor: pointer; - background-color: #fcc; - } - tr.invoice_cost_confirmed td.c_invoice_unit_cost { - background-color: #cfc; - } - % endif - </style> - % elif not use_buefy and not batch.executed and master.has_perm('edit_row'): - <style type="text/css"> - .grid tr:not(.header) td.catalog_unit_cost, - .grid tr:not(.header) td.invoice_unit_cost { - cursor: pointer; - background-color: #fcc; + <style type="text/css"> + % if allow_edit_catalog_unit_cost: + td.c_catalog_unit_cost { + cursor: pointer; + background-color: #fcc; } - .grid tr.catalog_cost_confirmed:not(.header) td.catalog_unit_cost, - .grid tr.invoice_cost_confirmed:not(.header) td.invoice_unit_cost { - background-color: #cfc; + tr.catalog_cost_confirmed td.c_catalog_unit_cost { + background-color: #cfc; } - .grid td.catalog_unit_cost input, - .grid td.invoice_unit_cost input { - width: 4rem; + % endif + % if allow_edit_invoice_unit_cost: + td.c_invoice_unit_cost { + cursor: pointer; + background-color: #fcc; } - </style> - % endif + tr.invoice_cost_confirmed td.c_invoice_unit_cost { + background-color: #cfc; + } + % endif + </style> </%def> <%def name="render_po_vs_invoice_helper()"> - % if use_buefy and master.handler.has_purchase_order(batch) and master.handler.has_invoice_file(batch): + % if master.handler.has_purchase_order(batch) and master.handler.has_invoice_file(batch): <div class="object-helper"> <h3>PO vs. Invoice</h3> <div class="object-helper-content"> @@ -321,60 +42,51 @@ <div class="object-helper"> <h3>Tools</h3> <div class="object-helper-content"> - % if use_buefy: - <b-button type="is-primary" - @click="autoReceiveShowDialog = true" - icon-pack="fas" - icon-left="check"> - Auto-Receive All Items - </b-button> - % else: - ${h.form(url('{}.auto_receive'.format(route_prefix), uuid=batch.uuid), class_='autodisable')} - ${h.csrf_token(request)} - ${h.submit('submit', "Auto-Receive All Items")} - ${h.end_form()} - % endif + <b-button type="is-primary" + @click="autoReceiveShowDialog = true" + icon-pack="fas" + icon-left="check"> + Auto-Receive All Items + </b-button> </div> </div> - % if use_buefy: - <b-modal has-modal-card - :active.sync="autoReceiveShowDialog"> - <div class="modal-card"> + <b-modal has-modal-card + :active.sync="autoReceiveShowDialog"> + <div class="modal-card"> - <header class="modal-card-head"> - <p class="modal-card-title">Auto-Receive All Items</p> - </header> + <header class="modal-card-head"> + <p class="modal-card-title">Auto-Receive All Items</p> + </header> - <section class="modal-card-body"> - <p class="block"> - You can automatically set the "received" quantity to - match the "shipped" quantity for all items, based on - the invoice. - </p> - <p class="block"> - Would you like to do so? - </p> - </section> + <section class="modal-card-body"> + <p class="block"> + You can automatically set the "received" quantity to + match the "shipped" quantity for all items, based on + the invoice. + </p> + <p class="block"> + Would you like to do so? + </p> + </section> - <footer class="modal-card-foot"> - <b-button @click="autoReceiveShowDialog = false"> - Cancel - </b-button> - ${h.form(url('{}.auto_receive'.format(route_prefix), uuid=batch.uuid), **{'@submit': 'autoReceiveSubmitting = true'})} - ${h.csrf_token(request)} - <b-button type="is-primary" - native-type="submit" - :disabled="autoReceiveSubmitting" - icon-pack="fas" - icon-left="check"> - {{ autoReceiveSubmitting ? "Working, please wait..." : "Auto-Receive All Items" }} - </b-button> - ${h.end_form()} - </footer> - </div> - </b-modal> - % endif + <footer class="modal-card-foot"> + <b-button @click="autoReceiveShowDialog = false"> + Cancel + </b-button> + ${h.form(url('{}.auto_receive'.format(route_prefix), uuid=batch.uuid), **{'@submit': 'autoReceiveSubmitting = true'})} + ${h.csrf_token(request)} + <b-button type="is-primary" + native-type="submit" + :disabled="autoReceiveSubmitting" + icon-pack="fas" + icon-left="check"> + {{ autoReceiveSubmitting ? "Working, please wait..." : "Auto-Receive All Items" }} + </b-button> + ${h.end_form()} + </footer> + </div> + </b-modal> % endif </%def> @@ -607,14 +319,3 @@ ${parent.body()} - -% 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')} - ${h.end_form()} - - <div id="transform-unit-dialog" style="display: none;"> - <p>hello world</p> - </div> -% endif diff --git a/tailbone/templates/receiving/view_row.mako b/tailbone/templates/receiving/view_row.mako index 308e97d7..4d596391 100644 --- a/tailbone/templates/receiving/view_row.mako +++ b/tailbone/templates/receiving/view_row.mako @@ -4,479 +4,456 @@ <%def name="extra_styles()"> ${parent.extra_styles()} <style type="text/css"> - % if use_buefy: - nav.panel { - margin: 0.5rem; - } + nav.panel { + margin: 0.5rem; + } - .header-fields { - margin-top: 1rem; - } + .header-fields { + margin-top: 1rem; + } - .header-fields .field.is-horizontal { - margin-left: 3rem; - } + .header-fields .field.is-horizontal { + margin-left: 3rem; + } - .header-fields .field.is-horizontal .field-label .label { - white-space: nowrap; - } + .header-fields .field.is-horizontal .field-label .label { + white-space: nowrap; + } - .quantity-form-fields { - margin: 2rem; - } + .quantity-form-fields { + margin: 2rem; + } - .quantity-form-fields .field.is-horizontal .field-label .label { - text-align: left; - width: 8rem; - } + .quantity-form-fields .field.is-horizontal .field-label .label { + text-align: left; + width: 8rem; + } - .remove-credit .field.is-horizontal .field-label .label { - white-space: nowrap; - } + .remove-credit .field.is-horizontal .field-label .label { + white-space: nowrap; + } - % endif </style> </%def> -<%def name="object_helpers()"> - ${parent.object_helpers()} - % if not use_buefy and master.row_editable(row) and not batch.is_truck_dump_child(): - <div class="object-helper"> - <h3>Receiving Tools</h3> - <div class="object-helper-content"> - <div style="white-space: nowrap;"> - ${h.link_to("Receive Product", url('{}.receive_row'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid), class_='button autodisable')} - ${h.link_to("Declare Credit", url('{}.declare_credit'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid), class_='button autodisable')} +<%def name="page_content()"> + + <b-field grouped class="header-fields"> + + <b-field label="Sequence" horizontal> + {{ rowData.sequence }} + </b-field> + + <b-field label="Status" horizontal> + {{ rowData.status }} + </b-field> + + <b-field label="Calculated Total" horizontal> + {{ rowData.invoice_total_calculated }} + </b-field> + + </b-field> + + <div style="display: flex;"> + + <nav class="panel"> + <p class="panel-heading">Product</p> + <div class="panel-block"> + <div style="display: flex;"> + <div> + ${form.render_field_readonly('item_entry')} + % if row.product: + ${form.render_field_readonly(product_key_field)} + ${form.render_field_readonly('product')} + % else: + ${form.render_field_readonly(product_key_field)} + % 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')} + % endif + ${form.render_field_readonly('vendor_code')} + ${form.render_field_readonly('case_quantity')} + ${form.render_field_readonly('catalog_unit_cost')} </div> + % if image_url: + <div class="is-pulled-right"> + ${h.image(image_url, "Product Image", width=150, height=150)} + </div> + % endif </div> </div> - % endif -</%def> + </nav> -<%def name="page_content()"> - % if use_buefy: - - <b-field grouped class="header-fields"> - - <b-field label="Sequence" horizontal> - {{ rowData.sequence }} - </b-field> - - <b-field label="Status" horizontal> - {{ rowData.status }} - </b-field> - - <b-field label="Calculated Total" horizontal> - {{ rowData.invoice_total_calculated }} - </b-field> - - </b-field> - - <div style="display: flex;"> - - <nav class="panel"> - <p class="panel-heading">Product</p> - <div class="panel-block"> - <div style="display: flex;"> - <div> - ${form.render_field_readonly('item_entry')} - % if row.product: - ${form.render_field_readonly(product_key_field)} - ${form.render_field_readonly('product')} - % else: - ${form.render_field_readonly(product_key_field)} - % 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')} - % endif - ${form.render_field_readonly('vendor_code')} - ${form.render_field_readonly('case_quantity')} - ${form.render_field_readonly('catalog_unit_cost')} - </div> - % if image_url: - <div class="is-pulled-right"> - ${h.image(image_url, "Product Image", width=150, height=150)} - </div> - % endif - </div> - </div> - </nav> - - <nav class="panel"> - <p class="panel-heading">Quantities</p> - <div class="panel-block"> - <div> - <div class="quantity-form-fields"> - - <b-field label="Ordered" horizontal> - {{ rowData.ordered }} - </b-field> - - <hr /> - - <b-field label="Shipped" horizontal> - {{ rowData.shipped }} - </b-field> - - <hr /> - - <b-field label="Received" horizontal - v-if="rowData.received"> - {{ rowData.received }} - </b-field> - - <b-field label="Damaged" horizontal - v-if="rowData.damaged"> - {{ rowData.damaged }} - </b-field> - - <b-field label="Expired" horizontal - v-if="rowData.expired"> - {{ rowData.expired }} - </b-field> - - <b-field label="Mispick" horizontal - v-if="rowData.mispick"> - {{ rowData.mispick }} - </b-field> - - <b-field label="Missing" horizontal - v-if="rowData.missing"> - {{ rowData.missing }} - </b-field> - - </div> - - % if master.has_perm('edit_row') and master.row_editable(row): - <div class="buttons"> - <b-button type="is-primary" - @click="accountForProductInit()" - icon-pack="fas" - icon-left="check"> - Account for Product - </b-button> - <b-button type="is-warning" - @click="declareCreditInit()" - :disabled="!rowData.received" - icon-pack="fas" - icon-left="thumbs-down"> - Declare Credit - </b-button> - </div> - % endif - - </div> - </div> - </nav> - - </div> - - <b-modal has-modal-card - :active.sync="accountForProductShowDialog"> - <div class="modal-card"> - - <header class="modal-card-head"> - <p class="modal-card-title">Account for Product</p> - </header> - - <section class="modal-card-body"> - - <p class="block"> - This is for declaring that you have encountered some - amount of the product. Ideally you will just - "receive" it normally, but you can indicate a "credit" - state if there is something amiss. - </p> - - <b-field grouped> - - % if allow_cases: - <b-field label="Case Qty."> - <span class="control"> - {{ rowData.case_quantity }} - </span> - </b-field> - - <span class="control"> - - </span> - % endif - - <b-field label="Product State" - :type="accountForProductMode ? null : 'is-danger'"> - <b-select v-model="accountForProductMode"> - <option v-for="mode in possibleReceivingModes" - :key="mode" - :value="mode"> - {{ mode }} - </option> - </b-select> - </b-field> - - <b-field label="Expiration Date" - v-show="accountForProductMode == 'expired'" - :type="accountForProductExpiration ? null : 'is-danger'"> - <tailbone-datepicker v-model="accountForProductExpiration"> - </tailbone-datepicker> - </b-field> + <nav class="panel"> + <p class="panel-heading">Quantities</p> + <div class="panel-block"> + <div> + <div class="quantity-form-fields"> + <b-field label="Ordered" horizontal> + {{ rowData.ordered }} </b-field> - <div class="level"> - <div class="level-left"> + <hr /> - <div class="level-item"> - <numeric-input v-model="accountForProductQuantity" - ref="accountForProductQuantityInput"> - </numeric-input> - </div> + <b-field label="Shipped" horizontal> + {{ rowData.shipped }} + </b-field> - <div class="level-item"> - % if allow_cases: - <b-field> - <b-radio-button v-model="accountForProductUOM" - @click.native="accountForProductUOMClicked('units')" - native-value="units"> - Units - </b-radio-button> - <b-radio-button v-model="accountForProductUOM" - @click.native="accountForProductUOMClicked('cases')" - native-value="cases"> - Cases - </b-radio-button> - </b-field> - % else: - <b-field> - <input type="hidden" v-model="accountForProductUOM" /> - Units - </b-field> - % endif - </div> + <hr /> - % if allow_cases: - <div class="level-item" - v-if="accountForProductUOM == 'cases' && accountForProductQuantity"> - = {{ accountForProductTotalUnits }} - </div> - % endif + <b-field label="Received" horizontal + v-if="rowData.received"> + {{ rowData.received }} + </b-field> + <b-field label="Damaged" horizontal + v-if="rowData.damaged"> + {{ rowData.damaged }} + </b-field> + + <b-field label="Expired" horizontal + v-if="rowData.expired"> + {{ rowData.expired }} + </b-field> + + <b-field label="Mispick" horizontal + v-if="rowData.mispick"> + {{ rowData.mispick }} + </b-field> + + <b-field label="Missing" horizontal + v-if="rowData.missing"> + {{ rowData.missing }} + </b-field> + + </div> + + % if master.has_perm('edit_row') and master.row_editable(row): + <div class="buttons"> + <b-button type="is-primary" + @click="accountForProductInit()" + icon-pack="fas" + icon-left="check"> + Account for Product + </b-button> + <b-button type="is-warning" + @click="declareCreditInit()" + :disabled="!rowData.received" + icon-pack="fas" + icon-left="thumbs-down"> + Declare Credit + </b-button> </div> - </div> + % endif - </section> - - <footer class="modal-card-foot"> - <b-button @click="accountForProductShowDialog = false"> - Cancel - </b-button> - <b-button type="is-primary" - @click="accountForProductSubmit()" - :disabled="accountForProductSubmitDisabled" - icon-pack="fas" - icon-left="check"> - {{ accountForProductSubmitting ? "Working, please wait..." : "Account for Product" }} - </b-button> - </footer> </div> - </b-modal> + </div> + </nav> - <b-modal has-modal-card - :active.sync="declareCreditShowDialog"> - <div class="modal-card"> + </div> - <header class="modal-card-head"> - <p class="modal-card-title">Declare Credit</p> - </header> + <b-modal has-modal-card + :active.sync="accountForProductShowDialog"> + <div class="modal-card"> - <section class="modal-card-body"> + <header class="modal-card-head"> + <p class="modal-card-title">Account for Product</p> + </header> - <p class="block"> - This is for <span class="is-italic">converting</span> - some amount you <span class="is-italic">already - received</span>, and now declaring there is something - wrong with it. - </p> + <section class="modal-card-body"> - <b-field grouped> + <p class="block"> + This is for declaring that you have encountered some + amount of the product. Ideally you will just + "receive" it normally, but you can indicate a "credit" + state if there is something amiss. + </p> - <b-field label="Received"> + <b-field grouped> + + % if allow_cases: + <b-field label="Case Qty."> <span class="control"> - {{ rowData.received }} + {{ rowData.case_quantity }} </span> </b-field> <span class="control"> </span> + % endif - <b-field label="Credit Type" - :type="declareCreditType ? null : 'is-danger'"> - <b-select v-model="declareCreditType"> - <option v-for="typ in possibleCreditTypes" - :key="typ" - :value="typ"> - {{ typ }} - </option> - </b-select> - </b-field> + <b-field label="Product State" + :type="accountForProductMode ? null : 'is-danger'"> + <b-select v-model="accountForProductMode"> + <option v-for="mode in possibleReceivingModes" + :key="mode" + :value="mode"> + {{ mode }} + </option> + </b-select> + </b-field> - <b-field label="Expiration Date" - v-show="declareCreditType == 'expired'" - :type="declareCreditExpiration ? null : 'is-danger'"> - <tailbone-datepicker v-model="declareCreditExpiration"> - </tailbone-datepicker> - </b-field> + <b-field label="Expiration Date" + v-show="accountForProductMode == 'expired'" + :type="accountForProductExpiration ? null : 'is-danger'"> + <tailbone-datepicker v-model="accountForProductExpiration"> + </tailbone-datepicker> + </b-field> - </b-field> + </b-field> - <div class="level"> - <div class="level-left"> + <div class="level"> + <div class="level-left"> - <div class="level-item"> - <numeric-input v-model="declareCreditQuantity" - ref="declareCreditQuantityInput"> - </numeric-input> - </div> - - <div class="level-item"> - % if allow_cases: - <b-field> - <b-radio-button v-model="declareCreditUOM" - @click.native="declareCreditUOMClicked('units')" - native-value="units"> - Units - </b-radio-button> - <b-radio-button v-model="declareCreditUOM" - @click.native="declareCreditUOMClicked('cases')" - native-value="cases"> - Cases - </b-radio-button> - </b-field> - % else: - <b-field> - <input type="hidden" v-model="declareCreditUOM" /> - Units - </b-field> - % endif - </div> - - % if allow_cases: - <div class="level-item" - v-if="declareCreditUOM == 'cases' && declareCreditQuantity"> - = {{ declareCreditTotalUnits }} - </div> - % endif - - </div> + <div class="level-item"> + <numeric-input v-model="accountForProductQuantity" + ref="accountForProductQuantityInput"> + </numeric-input> </div> - </section> + <div class="level-item"> + % if allow_cases: + <b-field> + <b-radio-button v-model="accountForProductUOM" + @click.native="accountForProductUOMClicked('units')" + native-value="units"> + Units + </b-radio-button> + <b-radio-button v-model="accountForProductUOM" + @click.native="accountForProductUOMClicked('cases')" + native-value="cases"> + Cases + </b-radio-button> + </b-field> + % else: + <b-field> + <input type="hidden" v-model="accountForProductUOM" /> + Units + </b-field> + % endif + </div> - <footer class="modal-card-foot"> - <b-button @click="declareCreditShowDialog = false"> - Cancel - </b-button> - <b-button type="is-warning" - @click="declareCreditSubmit()" - :disabled="declareCreditSubmitDisabled" - icon-pack="fas" - icon-left="thumbs-down"> - {{ declareCreditSubmitting ? "Working, please wait..." : "Declare this Credit" }} - </b-button> - </footer> - </div> - </b-modal> + % if allow_cases: + <div class="level-item" + v-if="accountForProductUOM == 'cases' && accountForProductQuantity"> + = {{ accountForProductTotalUnits }} + </div> + % endif - <nav class="panel" > - <p class="panel-heading">Credits</p> - <div class="panel-block"> - <div> - ${form.render_field_value('credits')} </div> </div> - </nav> - <b-modal has-modal-card - :active.sync="removeCreditShowDialog"> - <div class="modal-card remove-credit"> + </section> - <header class="modal-card-head"> - <p class="modal-card-title">Un-Declare Credit</p> - </header> + <footer class="modal-card-foot"> + <b-button @click="accountForProductShowDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + @click="accountForProductSubmit()" + :disabled="accountForProductSubmitDisabled" + icon-pack="fas" + icon-left="check"> + {{ accountForProductSubmitting ? "Working, please wait..." : "Account for Product" }} + </b-button> + </footer> + </div> + </b-modal> - <section class="modal-card-body"> + <b-modal has-modal-card + :active.sync="declareCreditShowDialog"> + <div class="modal-card"> - <p class="block"> - If you un-declare this credit, the quantity below will - be added back to the - <span class="has-text-weight-bold">Received</span> tally. - </p> + <header class="modal-card-head"> + <p class="modal-card-title">Declare Credit</p> + </header> - <b-field label="Credit Type" horizontal> - {{ removeCreditRow.credit_type }} - </b-field> + <section class="modal-card-body"> - <b-field label="Quantity" horizontal> - {{ removeCreditRow.shorted }} - </b-field> + <p class="block"> + This is for <span class="is-italic">converting</span> + some amount you <span class="is-italic">already + received</span>, and now declaring there is something + wrong with it. + </p> - </section> + <b-field grouped> - <footer class="modal-card-foot"> - <b-button @click="removeCreditShowDialog = false"> - Cancel - </b-button> - <b-button type="is-danger" - @click="removeCreditSubmit()" - :disabled="removeCreditSubmitting" - icon-pack="fas" - icon-left="trash"> - {{ removeCreditSubmitting ? "Working, please wait..." : "Un-Declare this Credit" }} - </b-button> - </footer> + <b-field label="Received"> + <span class="control"> + {{ rowData.received }} + </span> + </b-field> + + <span class="control"> + + </span> + + <b-field label="Credit Type" + :type="declareCreditType ? null : 'is-danger'"> + <b-select v-model="declareCreditType"> + <option v-for="typ in possibleCreditTypes" + :key="typ" + :value="typ"> + {{ typ }} + </option> + </b-select> + </b-field> + + <b-field label="Expiration Date" + v-show="declareCreditType == 'expired'" + :type="declareCreditExpiration ? null : 'is-danger'"> + <tailbone-datepicker v-model="declareCreditExpiration"> + </tailbone-datepicker> + </b-field> + + </b-field> + + <div class="level"> + <div class="level-left"> + + <div class="level-item"> + <numeric-input v-model="declareCreditQuantity" + ref="declareCreditQuantityInput"> + </numeric-input> + </div> + + <div class="level-item"> + % if allow_cases: + <b-field> + <b-radio-button v-model="declareCreditUOM" + @click.native="declareCreditUOMClicked('units')" + native-value="units"> + Units + </b-radio-button> + <b-radio-button v-model="declareCreditUOM" + @click.native="declareCreditUOMClicked('cases')" + native-value="cases"> + Cases + </b-radio-button> + </b-field> + % else: + <b-field> + <input type="hidden" v-model="declareCreditUOM" /> + Units + </b-field> + % endif + </div> + + % if allow_cases: + <div class="level-item" + v-if="declareCreditUOM == 'cases' && declareCreditQuantity"> + = {{ declareCreditTotalUnits }} + </div> + % endif + + </div> </div> - </b-modal> - <div style="display: flex;"> + </section> - % if master.batch_handler.has_purchase_order(batch): - <nav class="panel" > - <p class="panel-heading">Purchase Order</p> - <div class="panel-block"> - <div> - ${form.render_field_readonly('po_line_number')} - ${form.render_field_readonly('po_unit_cost')} - ${form.render_field_readonly('po_case_size')} - ${form.render_field_readonly('po_total')} - </div> - </div> - </nav> - % endif - - % if master.batch_handler.has_invoice_file(batch): - <nav class="panel" > - <p class="panel-heading">Invoice</p> - <div class="panel-block"> - <div> - ${form.render_field_readonly('invoice_line_number')} - ${form.render_field_readonly('invoice_unit_cost')} - ${form.render_field_readonly('invoice_case_size')} - ${form.render_field_readonly('invoice_total', label="Invoice Total")} - </div> - </div> - </nav> - % endif + <footer class="modal-card-foot"> + <b-button @click="declareCreditShowDialog = false"> + Cancel + </b-button> + <b-button type="is-warning" + @click="declareCreditSubmit()" + :disabled="declareCreditSubmitDisabled" + icon-pack="fas" + icon-left="thumbs-down"> + {{ declareCreditSubmitting ? "Working, please wait..." : "Declare this Credit" }} + </b-button> + </footer> + </div> + </b-modal> + <nav class="panel" > + <p class="panel-heading">Credits</p> + <div class="panel-block"> + <div> + ${form.render_field_value('credits')} </div> + </div> + </nav> - % else: - ## legacy / not buefy - ${parent.page_content()} - % endif + <b-modal has-modal-card + :active.sync="removeCreditShowDialog"> + <div class="modal-card remove-credit"> + + <header class="modal-card-head"> + <p class="modal-card-title">Un-Declare Credit</p> + </header> + + <section class="modal-card-body"> + + <p class="block"> + If you un-declare this credit, the quantity below will + be added back to the + <span class="has-text-weight-bold">Received</span> tally. + </p> + + <b-field label="Credit Type" horizontal> + {{ removeCreditRow.credit_type }} + </b-field> + + <b-field label="Quantity" horizontal> + {{ removeCreditRow.shorted }} + </b-field> + + </section> + + <footer class="modal-card-foot"> + <b-button @click="removeCreditShowDialog = false"> + Cancel + </b-button> + <b-button type="is-danger" + @click="removeCreditSubmit()" + :disabled="removeCreditSubmitting" + icon-pack="fas" + icon-left="trash"> + {{ removeCreditSubmitting ? "Working, please wait..." : "Un-Declare this Credit" }} + </b-button> + </footer> + </div> + </b-modal> + + <div style="display: flex;"> + + % if master.batch_handler.has_purchase_order(batch): + <nav class="panel" > + <p class="panel-heading">Purchase Order</p> + <div class="panel-block"> + <div> + ${form.render_field_readonly('po_line_number')} + ${form.render_field_readonly('po_unit_cost')} + ${form.render_field_readonly('po_case_size')} + ${form.render_field_readonly('po_total')} + </div> + </div> + </nav> + % endif + + % if master.batch_handler.has_invoice_file(batch): + <nav class="panel" > + <p class="panel-heading">Invoice</p> + <div class="panel-block"> + <div> + ${form.render_field_readonly('invoice_line_number')} + ${form.render_field_readonly('invoice_unit_cost')} + ${form.render_field_readonly('invoice_case_size')} + ${form.render_field_readonly('invoice_total', label="Invoice Total")} + </div> + </div> + </nav> + % endif + + </div> </%def> <%def name="modify_this_page_vars()"> diff --git a/tailbone/templates/reports/generated/choose.mako b/tailbone/templates/reports/generated/choose.mako index 31aa3cd5..55cf71dd 100644 --- a/tailbone/templates/reports/generated/choose.mako +++ b/tailbone/templates/reports/generated/choose.mako @@ -1,47 +1,14 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/create.mako" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - <script type="text/javascript"> - - var report_descriptions = ${json.dumps(report_descriptions)|n}; - - function show_description(key) { - var desc = report_descriptions[key]; - $('#report-description').text(desc); - } - - $(function() { - - var report_type = $('select[name="report_type"]'); - - report_type.change(function(event) { - show_description(report_type.val()); - }); - - }); - - </script> - % endif -</%def> - <%def name="extra_styles()"> ${parent.extra_styles()} <style type="text/css"> % if use_form: - % if use_buefy: - #report-description { - margin-left: 2em; - } - % else: - #report-description { - margin-top: 2em; - margin-left: 2em; - } - % endif + #report-description { + margin-left: 2em; + } % else: .report-selection { margin-left: 10em; @@ -69,19 +36,7 @@ <%def name="page_content()"> % if use_form: - % if use_buefy: - ${parent.page_content()} - % else: - <div class="form-wrapper"> - <p>Please select the type of report you wish to generate.</p> - - <div style="display: flex;"> - ${form.render()|n} - <div id="report-description"></div> - </div> - - </div><!-- form-wrapper --> - % endif + ${parent.page_content()} % else: <div> <br /> diff --git a/tailbone/templates/reports/generated/generate.mako b/tailbone/templates/reports/generated/generate.mako index f7b4ab34..9feb9f83 100644 --- a/tailbone/templates/reports/generated/generate.mako +++ b/tailbone/templates/reports/generated/generate.mako @@ -24,11 +24,5 @@ </div> </%def> -<%def name="render_form()"> - % if not use_buefy: - <p style="padding: 1em;">${report.__doc__}</p> - % endif - ${parent.render_form()} -</%def> ${parent.body()} diff --git a/tailbone/templates/reports/inventory.mako b/tailbone/templates/reports/inventory.mako index 230f6028..74f378fa 100644 --- a/tailbone/templates/reports/inventory.mako +++ b/tailbone/templates/reports/inventory.mako @@ -4,83 +4,47 @@ <%def name="title()">Report : Inventory Worksheet</%def> <%def name="page_content()"> - % if use_buefy: - <p class="block"> - Please provide the following criteria to generate your report: - </p> + <p class="block"> + Please provide the following criteria to generate your report: + </p> - ${h.form(request.current_route_url())} - ${h.csrf_token(request)} + ${h.form(request.current_route_url())} + ${h.csrf_token(request)} - <b-field label="Department"> - <b-select name="department"> - <option v-for="dept in departments" - :key="dept.uuid" - :value="dept.uuid"> - {{ dept.name }} - </option> - </b-select> - </b-field> + <b-field label="Department"> + <b-select name="department"> + <option v-for="dept in departments" + :key="dept.uuid" + :value="dept.uuid"> + {{ dept.name }} + </option> + </b-select> + </b-field> - <b-field> - <b-checkbox name="weighted-only" native-value="1"> - Only include items which are sold by weight. - </b-checkbox> - </b-field> + <b-field> + <b-checkbox name="weighted-only" native-value="1"> + Only include items which are sold by weight. + </b-checkbox> + </b-field> - <b-field> - <b-checkbox name="exclude-not-for-sale" :value="true" - native-value="1"> - Exclude items marked "not for sale". - </b-checkbox> - </b-field> + <b-field> + <b-checkbox name="exclude-not-for-sale" :value="true" + native-value="1"> + Exclude items marked "not for sale". + </b-checkbox> + </b-field> - <div class="buttons"> - <b-button type="is-primary" - native-type="submit" - icon-pack="fas" - icon-left="arrow-circle-right"> - Generate Report - </b-button> - </div> + <div class="buttons"> + <b-button type="is-primary" + native-type="submit" + icon-pack="fas" + icon-left="arrow-circle-right"> + Generate Report + </b-button> + </div> - ${h.end_form()} - - % else: - - <p>Please provide the following criteria to generate your report:</p> - <br /> - - ${h.form(request.current_route_url())} - ${h.csrf_token(request)} - - <div class="field-wrapper"> - <label for="department">Department</label> - <div class="field"> - <select name="department"> - % for department in departments: - <option value="${department.uuid}">${department.name}</option> - % endfor - </select> - </div> - </div> - - <div class="field-wrapper"> - ${h.checkbox('weighted-only', label="Only include items which are sold by weight.")} - </div> - - <div class="field-wrapper"> - ${h.checkbox('exclude-not-for-sale', label="Exclude items marked \"not for sale\".", checked=True)} - </div> - - <div class="buttons"> - ${h.submit('submit', "Generate Report")} - </div> - - ${h.end_form()} - - % endif + ${h.end_form()} </%def> <%def name="modify_this_page_vars()"> diff --git a/tailbone/templates/roles/view.mako b/tailbone/templates/roles/view.mako index c5a5b78d..5dcd9408 100644 --- a/tailbone/templates/roles/view.mako +++ b/tailbone/templates/roles/view.mako @@ -6,24 +6,6 @@ ${h.stylesheet_link(request.static_url('tailbone:static/css/perms.css'))} </%def> -<%def name="page_content()"> - ${parent.page_content()} - % if not use_buefy: - <h2>Users</h2> - - % if instance is guest_role: - <p>The guest role is implied for all anonymous users, i.e. when not logged in.</p> - % elif instance is authenticated_role: - <p>The authenticated role is implied for all users, but only when logged in.</p> - % elif users: - <p>The following users are assigned to this role:</p> - ${users.render_grid()|n} - % else: - <p>There are no users assigned to this role.</p> - % endif - % endif -</%def> - <%def name="modify_this_page_vars()"> ${parent.modify_this_page_vars()} <script type="text/javascript"> diff --git a/tailbone/templates/settings/email/view.mako b/tailbone/templates/settings/email/view.mako index be4f5774..59842498 100644 --- a/tailbone/templates/settings/email/view.mako +++ b/tailbone/templates/settings/email/view.mako @@ -1,41 +1,6 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - <script type="text/javascript"> - % if not email.get_template('html'): - $(function() { - $('#preview-html').button('disable'); - $('#preview-html').attr('title', "There is no HTML template on file for this email."); - }); - % endif - % if not email.get_template('txt'): - $(function() { - $('#preview-txt').button('disable'); - $('#preview-txt').attr('title', "There is no TXT template on file for this email."); - }); - % endif - </script> - % endif -</%def> - -<%def name="render_form()"> - ${parent.render_form()} - % if not use_buefy: - ${h.form(url('email.preview'), name='send-email-preview', class_='autodisable')} - ${h.csrf_token(request)} - ${h.hidden('email_key', value=instance['key'])} - ${h.link_to("Preview HTML", '{}?key={}&type=html'.format(url('email.preview'), instance['key']), id='preview-html', class_='button', target='_blank')} - ${h.link_to("Preview TXT", '{}?key={}&type=txt'.format(url('email.preview'), instance['key']), id='preview-txt', class_='button', target='_blank')} - or - ${h.text('recipient', value=request.user.email_address or '')} - ${h.submit('send_{}'.format(instance['key']), value="Send Preview Email")} - ${h.end_form()} - % endif -</%def> - <%def name="render_buefy_form()"> ${parent.render_buefy_form()} <email-preview-tools></email-preview-tools> diff --git a/tailbone/templates/tempmon/clients/view.mako b/tailbone/templates/tempmon/clients/view.mako index 2141d977..cff22fed 100644 --- a/tailbone/templates/tempmon/clients/view.mako +++ b/tailbone/templates/tempmon/clients/view.mako @@ -1,20 +1,6 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - <script type="text/javascript"> - $(function() { - $('#restart-client').click(function() { - disable_button(this); - location.href = '${url('tempmon.clients.restart', uuid=instance.uuid)}'; - }); - }); - </script> - % endif -</%def> - <%def name="context_menu_items()"> ${parent.context_menu_items()} % if request.has_perm('tempmon.appliances.dashboard'): @@ -27,14 +13,10 @@ <div class="object-helper"> <h3>Client Tools</h3> <div class="object-helper-content"> - % if use_buefy: - <once-button tag="a" href="${url('{}.restart'.format(route_prefix), uuid=instance.uuid)}" - type="is-primary" - text="Restart tempmon-client daemon"> - </once-button> - % else: - <button type="button" id="restart-client">Restart tempmon-client daemon</button> - % endif + <once-button tag="a" href="${url('{}.restart'.format(route_prefix), uuid=instance.uuid)}" + type="is-primary" + text="Restart tempmon-client daemon"> + </once-button> </div> </div> % endif diff --git a/tailbone/templates/tempmon/dashboard.mako b/tailbone/templates/tempmon/dashboard.mako index 214ff480..396b0e68 100644 --- a/tailbone/templates/tempmon/dashboard.mako +++ b/tailbone/templates/tempmon/dashboard.mako @@ -8,178 +8,54 @@ <%def name="extra_javascript()"> ${parent.extra_javascript()} <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.5.0/Chart.bundle.min.js"></script> - % if not use_buefy: - <script type="text/javascript"> - - var contexts = {}; - var charts = {}; - - function fetchReadings(appliance_uuid) { - if (appliance_uuid === undefined) { - appliance_uuid = $('#appliance_uuid').val(); - } - - $('.form-wrapper').mask("Fetching data"); - - if (Object.keys(charts).length) { - Object.keys(charts).forEach(function(key) { - charts[key].destroy(); - delete charts[key]; - }); - } - - var url = '${url("tempmon.dashboard.readings")}'; - var params = {'appliance_uuid': appliance_uuid}; - $.get(url, params, function(data) { - - if (data.probes) { - data.probes.forEach(function(probe) { - charts[probe.uuid] = new Chart(contexts[probe.uuid], { - type: 'scatter', - data: { - datasets: [{ - label: probe.description, - data: probe.readings - }] - }, - options: { - scales: { - xAxes: [{ - type: 'time', - time: {unit: 'minute'}, - position: 'bottom' - }] - } - } - }); - }); - } else { - // TODO: should improve this - alert(data.error); - } - - $('.form-wrapper').unmask(); - }); - } - - $(function() { - - % for probe in appliance.probes: - contexts['${probe.uuid}'] = $('#tempchart-${probe.uuid}'); - % endfor - - $('#appliance_uuid').selectmenu({ - change: function(event, ui) { - $('.form-wrapper').mask("Fetching data"); - $(this).parents('form').submit(); - } - }); - - fetchReadings(); - }); - - </script> - % endif </%def> <%def name="render_this_page()"> - % if use_buefy: + ${h.form(request.current_route_url(), ref='applianceForm')} + ${h.csrf_token(request)} + <div class="level-left"> - ${h.form(request.current_route_url(), ref='applianceForm')} - ${h.csrf_token(request)} - <div class="level-left"> - - <div class="level-item"> - <b-field label="Appliance" horizontal> - <b-select name="appliance_uuid" - v-model="applianceUUID" - @input="$refs.applianceForm.submit()"> - <option v-for="appliance in appliances" - :key="appliance.uuid" - :value="appliance.uuid"> - {{ appliance.name }} - </option> - </b-select> - </b-field> - </div> - - % if appliance: - <div class="level-item"> - <a href="${url('tempmon.appliances.view', uuid=appliance.uuid)}"> - ${h.image(url('tempmon.appliances.thumbnail', uuid=appliance.uuid), "")} - </a> - </div> - % endif - - </div> - ${h.end_form()} - - % if appliance and appliance.probes: - % for probe in appliance.probes: - <h4 class="is-size-4"> - Probe: ${h.link_to(probe.description, url('tempmon.probes.graph', uuid=probe.uuid))} - (status: ${enum.TEMPMON_PROBE_STATUS[probe.status]}) - </h4> - % if probe.enabled: - <canvas ref="tempchart-${probe.uuid}" width="400" height="60"></canvas> - % else: - <p>This probe is not enabled.</p> - % endif - % endfor - % elif appliance: - <h3>This appliance has no probes configured!</h3> - % else: - <h3>Please choose an appliance.</h3> - % endif - - % else: - ## not buefy - <div style="display: flex;"> - - <div class="form-wrapper"> - <div class="form"> - ${h.form(request.current_route_url())} - ${h.csrf_token(request)} - % if use_buefy: - <b-field horizontal label="Appliance"> - ${appliance_select} - </b-field> - % else: - <div class="field-wrapper"> - <label>Appliance</label> - <div class="field"> - ${appliance_select} - </div> - </div> - % endif - ${h.end_form()} - </div> + <div class="level-item"> + <b-field label="Appliance" horizontal> + <b-select name="appliance_uuid" + v-model="applianceUUID" + @input="$refs.applianceForm.submit()"> + <option v-for="appliance in appliances" + :key="appliance.uuid" + :value="appliance.uuid"> + {{ appliance.name }} + </option> + </b-select> + </b-field> </div> - <a href="${url('tempmon.appliances.view', uuid=appliance.uuid)}"> - ${h.image(url('tempmon.appliances.thumbnail', uuid=appliance.uuid), "")} - </a> - </div> + % if appliance: + <div class="level-item"> + <a href="${url('tempmon.appliances.view', uuid=appliance.uuid)}"> + ${h.image(url('tempmon.appliances.thumbnail', uuid=appliance.uuid), "")} + </a> + </div> + % endif - % if appliance.probes: + </div> + ${h.end_form()} + + % if appliance and appliance.probes: % for probe in appliance.probes: - <h3> + <h4 class="is-size-4"> Probe: ${h.link_to(probe.description, url('tempmon.probes.graph', uuid=probe.uuid))} (status: ${enum.TEMPMON_PROBE_STATUS[probe.status]}) - </h3> + </h4> % if probe.enabled: - % if use_buefy: - <canvas ref="tempchart" width="400" height="150"></canvas> - % else: - <canvas id="tempchart-${probe.uuid}" width="400" height="60"></canvas> - % endif + <canvas ref="tempchart-${probe.uuid}" width="400" height="60"></canvas> % else: <p>This probe is not enabled.</p> % endif % endfor - % else: + % elif appliance: <h3>This appliance has no probes configured!</h3> - % endif + % else: + <h3>Please choose an appliance.</h3> % endif </%def> diff --git a/tailbone/templates/tempmon/probes/graph.mako b/tailbone/templates/tempmon/probes/graph.mako index 3255edb7..795af145 100644 --- a/tailbone/templates/tempmon/probes/graph.mako +++ b/tailbone/templates/tempmon/probes/graph.mako @@ -6,71 +6,6 @@ <%def name="extra_javascript()"> ${parent.extra_javascript()} <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.5.0/Chart.bundle.min.js"></script> - % if not use_buefy: - <script type="text/javascript"> - - var ctx = null; - var chart = null; - - function fetchReadings(timeRange) { - if (timeRange === undefined) { - timeRange = $('#time-range').val(); - } - - var timeUnit; - if (timeRange == 'last hour') { - timeUnit = 'minute'; - } else if (['last 6 hours', 'last day'].includes(timeRange)) { - timeUnit = 'hour'; - } else { - timeUnit = 'day'; - } - - $('.form-wrapper').mask("Fetching data"); - if (chart) { - chart.destroy(); - } - - $.get('${url('{}.graph_readings'.format(route_prefix), uuid=probe.uuid)}', {'time-range': timeRange}, function(data) { - - chart = new Chart(ctx, { - type: 'scatter', - data: { - datasets: [{ - label: "${probe.description}", - data: data - }] - }, - options: { - scales: { - xAxes: [{ - type: 'time', - time: {unit: timeUnit}, - position: 'bottom' - }] - } - } - }); - - $('.form-wrapper').unmask(); - }); - } - - $(function() { - - ctx = $('#tempchart'); - - $('#time-range').selectmenu({ - change: function(event, ui) { - fetchReadings(ui.item.value); - } - }); - - fetchReadings(); - }); - - </script> - % endif </%def> <%def name="context_menu_items()"> @@ -89,50 +24,23 @@ <div class="form-wrapper"> <div class="form"> - % if use_buefy: - <b-field horizontal label="Appliance"> - <div> - % if probe.appliance: - <a href="${url('tempmon.appliances.view', uuid=probe.appliance.uuid)}">${probe.appliance}</a> - % endif - </div> - </b-field> - % else: - <div class="field-wrapper"> - <label>Appliance</label> - <div class="field"> - % if probe.appliance: - <a href="${url('tempmon.appliances.view', uuid=probe.appliance.uuid)}">${probe.appliance}</a> - % endif - </div> - </div> - % endif + <b-field horizontal label="Appliance"> + <div> + % if probe.appliance: + <a href="${url('tempmon.appliances.view', uuid=probe.appliance.uuid)}">${probe.appliance}</a> + % endif + </div> + </b-field> - % if use_buefy: - <b-field horizontal label="Probe Location"> - <div> - ${probe.location or ""} - </div> - </b-field> - % else: - <div class="field-wrapper"> - <label>Probe Location</label> - <div class="field">${probe.location or ""}</div> - </div> - % endif + <b-field horizontal label="Probe Location"> + <div> + ${probe.location or ""} + </div> + </b-field> - % if use_buefy: - <b-field horizontal label="Showing"> - ${time_range} - </b-field> - % else: - <div class="field-wrapper"> - <label>Showing</label> - <div class="field"> - ${time_range} - </div> - </div> - % endif + <b-field horizontal label="Showing"> + ${time_range} + </b-field> </div> </div> @@ -149,11 +57,7 @@ </div> - % if use_buefy: - <canvas ref="tempchart" width="400" height="150"></canvas> - % else: - <canvas id="tempchart" width="400" height="150"></canvas> - % endif + <canvas ref="tempchart" width="400" height="150"></canvas> </%def> <%def name="modify_this_page_vars()"> diff --git a/tailbone/templates/tempmon/probes/view.mako b/tailbone/templates/tempmon/probes/view.mako index 1e309129..207c48d4 100644 --- a/tailbone/templates/tempmon/probes/view.mako +++ b/tailbone/templates/tempmon/probes/view.mako @@ -2,86 +2,48 @@ <%inherit file="/master/view.mako" /> <%def name="render_form_complete()"> - % if use_buefy: - ## ${self.render_form()} + ## ${self.render_form()} - <script type="text/x-template" id="form-page-template"> + <script type="text/x-template" id="form-page-template"> - <div style="display: flex; justify-content: space-between;"> + <div style="display: flex; justify-content: space-between;"> - <div class="form-wrapper"> + <div class="form-wrapper"> - <div style="display: flex; flex-direction: column;"> + <div style="display: flex; flex-direction: column;"> - <nav class="panel" id="probe-main"> - <p class="panel-heading">General</p> - <div class="panel-block"> - <div> - ${self.render_main_fields(form)} - </div> - </div> - </nav> - - <div style="display: flex;"> - <div class="panel-wrapper"> - ${self.left_column()} - </div> - <div class="panel-wrapper" style="margin-left: 1em;"> <!-- right column --> - ${self.right_column()} - </div> + <nav class="panel" id="probe-main"> + <p class="panel-heading">General</p> + <div class="panel-block"> + <div> + ${self.render_main_fields(form)} </div> + </div> + </nav> + <div style="display: flex;"> + <div class="panel-wrapper"> + ${self.left_column()} + </div> + <div class="panel-wrapper" style="margin-left: 1em;"> <!-- right column --> + ${self.right_column()} </div> </div> - <ul id="context-menu"> - ${self.context_menu_items()} - </ul> - </div> - </script> - - <div id="form-page-app"> - <form-page></form-page> </div> - % else: - ## legacy / not buefy + <ul id="context-menu"> + ${self.context_menu_items()} + </ul> - <div style="display: flex; justify-content: space-between;"> + </div> + </script> - <div class="form-wrapper"> - - <div style="display: flex; flex-direction: column;"> - - <div class="panel" id="probe-main"> - <h2>General</h2> - <div class="panel-body"> - <div> - ${self.render_main_fields(form)} - </div> - </div> - </div> - - <div style="display: flex;"> - <div class="panel-wrapper"> - ${self.left_column()} - </div> - <div class="panel-wrapper" style="margin-left: 1em;"> <!-- right column --> - ${self.right_column()} - </div> - </div> - - </div> - </div> - - <ul id="context-menu"> - ${self.context_menu_items()} - </ul> - - </div> - % endif + <div id="form-page-app"> + <form-page></form-page> + </div> </%def> @@ -113,43 +75,25 @@ </%def> <%def name="left_column()"> - % if use_buefy: - <nav class="panel"> - <p class="panel-heading">Temperatures</p> - <div class="panel-block"> - <div> - ${self.render_temperature_fields(form)} - </div> - </div> - </nav> - % else: - <div class="panel"> - <h2>Temperatures</h2> - <div class="panel-body"> - ${self.render_temperature_fields(form)} + <nav class="panel"> + <p class="panel-heading">Temperatures</p> + <div class="panel-block"> + <div> + ${self.render_temperature_fields(form)} + </div> </div> - </div> - % endif + </nav> </%def> <%def name="right_column()"> - % if use_buefy: - <nav class="panel"> - <p class="panel-heading">Timeouts</p> - <div class="panel-block"> - <div> - ${self.render_timeout_fields(form)} - </div> - </div> - </nav> - % else: - <div class="panel"> - <h2>Timeouts</h2> - <div class="panel-body"> - ${self.render_timeout_fields(form)} + <nav class="panel"> + <p class="panel-heading">Timeouts</p> + <div class="panel-block"> + <div> + ${self.render_timeout_fields(form)} + </div> </div> - </div> - % endif + </nav> </%def> <%def name="render_temperature_fields(form)"> diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index 2db0547f..c2970193 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -353,14 +353,10 @@ <div class="level"> <div class="level-right"> <div class="level-item"> - % if use_buefy: - <b-input name="entry" - placeholder="${quickie.placeholder}" - autocomplete="off"> - </b-input> - % else: - ${h.text('entry', placeholder=quickie.placeholder, autocomplete='off')} - % endif + <b-input name="entry" + placeholder="${quickie.placeholder}" + autocomplete="off"> + </b-input> </div> <div class="level-item"> <button type="submit" class="button is-primary"> @@ -706,54 +702,38 @@ % if show_prev_next is not Undefined and show_prev_next: % if prev_url: <div class="level-item"> - % if use_buefy: - <b-button tag="a" href="${prev_url}" - icon-pack="fas" - icon-left="arrow-left"> - Older - </b-button> - % else: - ${h.link_to(u"« Older", prev_url, class_='button autodisable')} - % endif + <b-button tag="a" href="${prev_url}" + icon-pack="fas" + icon-left="arrow-left"> + Older + </b-button> </div> % else: <div class="level-item"> - % if use_buefy: - <b-button tag="a" href="#" - disabled - icon-pack="fas" - icon-left="arrow-left"> - Older - </b-button> - % else: - ${h.link_to(u"« Older", '#', class_='button', disabled='disabled')} - % endif + <b-button tag="a" href="#" + disabled + icon-pack="fas" + icon-left="arrow-left"> + Older + </b-button> </div> % endif % if next_url: <div class="level-item"> - % if use_buefy: - <b-button tag="a" href="${next_url}" - icon-pack="fas" - icon-left="arrow-right"> - Newer - </b-button> - % else: - ${h.link_to(u"Newer »", next_url, class_='button autodisable')} - % endif + <b-button tag="a" href="${next_url}" + icon-pack="fas" + icon-left="arrow-right"> + Newer + </b-button> </div> % else: <div class="level-item"> - % if use_buefy: - <b-button tag="a" href="#" - disabled - icon-pack="fas" - icon-left="arrow-right"> - Newer - </b-button> - % else: - ${h.link_to(u"Newer »", '#', class_='button', disabled='disabled')} - % endif + <b-button tag="a" href="#" + disabled + icon-pack="fas" + icon-left="arrow-right"> + Newer + </b-button> </div> % endif % endif diff --git a/tailbone/templates/upgrades/view.mako b/tailbone/templates/upgrades/view.mako index 7d83a332..530b8757 100644 --- a/tailbone/templates/upgrades/view.mako +++ b/tailbone/templates/upgrades/view.mako @@ -1,43 +1,6 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - <script type="text/javascript"> - - function show_packages(type) { - if (type == 'all') { - $('.showing .diffs').css('font-weight', 'normal'); - $('table.diff tbody tr').show(); - $('.showing .all').css('font-weight', 'bold'); - } else if (type == 'diffs') { - $('.showing .all').css('font-weight', 'normal'); - $('table.diff tbody tr:not(.diff)').hide(); - $('.showing .diffs').css('font-weight', 'bold'); - } - } - - $(function() { - - show_packages('diffs'); - - $('.showing .all').click(function() { - show_packages('all'); - return false; - }); - - $('.showing .diffs').click(function() { - show_packages('diffs') - return false; - }); - - }); - - </script> - % endif -</%def> - <%def name="extra_styles()"> ${parent.extra_styles()} % if master.has_perm('execute'): @@ -132,7 +95,7 @@ % if instance_executable and master.has_perm('execute'): <div class="buttons"> % if instance.enabled and not instance.executing: - % if use_buefy and expose_websockets: + % if expose_websockets: <b-button type="is-primary" icon-pack="fas" icon-left="arrow-circle-right" @@ -140,7 +103,7 @@ @click="$emit('execute-upgrade-click')"> {{ upgradeExecuting ? "Working, please wait..." : "Execute this upgrade" }} </b-button> - % elif use_buefy: + % else: ${h.form(url('{}.execute'.format(route_prefix), uuid=instance.uuid), **{'@submit': 'submitForm'})} ${h.csrf_token(request)} <b-button type="is-primary" @@ -151,11 +114,6 @@ {{ formSubmitting ? "Working, please wait..." : "Execute this upgrade" }} </b-button> ${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: <button type="button" class="button is-primary" disabled="disabled" title="This upgrade is currently executing">Execute this upgrade</button> diff --git a/tailbone/templates/util.mako b/tailbone/templates/util.mako index 2d4653aa..19a1b89d 100644 --- a/tailbone/templates/util.mako +++ b/tailbone/templates/util.mako @@ -2,43 +2,27 @@ <%def name="view_profile_button(person)"> <div class="buttons"> - % if use_buefy: - <b-button type="is-primary" - tag="a" href="${url('people.view_profile', uuid=person.uuid)}" - icon-pack="fas" - icon-left="user"> - ${person} - </b-button> - % else: - ${h.link_to(person, url('people.view_profile', uuid=person.uuid), class_='button is-primary')} - % endif + <b-button type="is-primary" + tag="a" href="${url('people.view_profile', uuid=person.uuid)}" + icon-pack="fas" + icon-left="user"> + ${person} + </b-button> </div> </%def> <%def name="view_profiles_helper(people)"> % if request.has_perm('people.view_profile'): - % if use_buefy: - <nav class="panel"> - <p class="panel-heading">Profiles</p> - <div class="panel-block"> - <div style="display: flex; flex-direction: column;"> - <p class="block">View full profile for:</p> - % for person in people: - ${view_profile_button(person)} - % endfor - </div> - </div> - </nav> - % else: - <div class="object-helper"> - <h3>Profiles</h3> - <div class="object-helper-content"> - <p>View full profile for:</p> - % for person in people: - ${view_profile_button(person)} - % endfor - </div> + <nav class="panel"> + <p class="panel-heading">Profiles</p> + <div class="panel-block"> + <div style="display: flex; flex-direction: column;"> + <p class="block">View full profile for:</p> + % for person in people: + ${view_profile_button(person)} + % endfor </div> - % endif + </div> + </nav> % endif </%def> diff --git a/tailbone/util.py b/tailbone/util.py index 9eae1740..7414a4c4 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -24,11 +24,8 @@ Utilities """ -from __future__ import unicode_literals, absolute_import - import datetime -import six import pytz import humanize import logging @@ -198,26 +195,6 @@ def get_liburl(request, key, fallback=True): return 'https://code.jquery.com/ui/{}/themes/dark-hive/jquery-ui.css'.format(version) -def should_use_buefy(request): - """ - Returns a flag indicating whether or not the current theme supports (and - therefore should use) the Buefy JS library. - """ - # first check theme-specific setting, if one has been defined - theme = request.registry.settings['tailbone.theme'] - buefy = request.rattail_config.getbool('tailbone', 'themes.{}.use_buefy'.format(theme)) - if buefy is not None: - return buefy - - # TODO: should not hard-code this surely, but works for now... - if theme == 'falafel': - return True - - # TODO: probably should not use this fallback? it was the first setting - # i tested with, but is poorly named to say the least - return request.rattail_config.getbool('tailbone', 'grids.use_buefy', default=False) - - def pretty_datetime(config, value): """ Formats a datetime as a "pretty" human-readable string, with a tooltip @@ -284,7 +261,7 @@ def raw_datetime(config, value, verbose=False, as_date=False): else: kwargs['c'] = value.strftime('%Y-%m-%d %I:%M:%S %p') else: - kwargs['c'] = six.text_type(value) + kwargs['c'] = str(value) time_diff = app.render_time_ago(time_ago, fallback=None) if time_diff is not None: diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index e7922e3d..b16ff539 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,6 @@ Auth Views """ -from __future__ import unicode_literals, absolute_import - from rattail.db.auth import authenticate_user, set_user_password import colander @@ -101,9 +99,7 @@ class AuthenticationView(View): self.request.session.flash("{} is already logged in".format(self.request.user), 'error') return self.redirect(referrer) - use_buefy = self.get_use_buefy() - form = forms.Form(schema=UserLogin(), request=self.request, - use_buefy=use_buefy) + form = forms.Form(schema=UserLogin(), request=self.request) form.save_label = "Login" form.auto_disable_save = False form.auto_disable = False # TODO: deprecate / remove this @@ -126,16 +122,13 @@ class AuthenticationView(View): 'tailbone', 'main_image_url', default=self.request.static_url('tailbone:static/img/home_logo.png')) - context = { + return { 'form': form, 'referrer': referrer, 'image_url': image_url, - 'use_buefy': use_buefy, + 'index_title': self.rattail_config.node_title(), 'help_url': global_help_url(self.rattail_config), } - if use_buefy: - context['index_title'] = self.rattail_config.node_title() - return context def authenticate_user(self, username, password): app = self.get_rattail_app() @@ -177,15 +170,14 @@ class AuthenticationView(View): self.request.session.flash("Cannot change password for user: {}".format(self.request.user)) return self.redirect(self.request.get_referrer()) - use_buefy = self.get_use_buefy() schema = ChangePassword().bind(user=self.request.user, request=self.request) - form = forms.Form(schema=schema, request=self.request, use_buefy=use_buefy) + form = forms.Form(schema=schema, request=self.request) if form.validate(newstyle=True): set_user_password(self.request.user, form.validated['new_password']) self.request.session.flash("Your password has been changed.") return self.redirect(self.request.get_referrer()) - return {'form': form, 'use_buefy': use_buefy} + return {'form': form} def become_root(self): """ diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 56bfa2f1..54fe9b0c 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -24,8 +24,6 @@ Base views for maintaining "new-style" batches. """ -from __future__ import unicode_literals, absolute_import - import os import sys import json @@ -37,7 +35,6 @@ import tempfile from six import StringIO import json -import six import markdown import sqlalchemy as sa from sqlalchemy import orm @@ -172,7 +169,6 @@ class BatchMasterView(MasterView): def template_kwargs_view(self, **kwargs): kwargs = super(BatchMasterView, self).template_kwargs_view(**kwargs) - use_buefy = self.get_use_buefy() batch = kwargs['instance'] kwargs['batch'] = batch kwargs['handler'] = self.handler @@ -194,30 +190,22 @@ class BatchMasterView(MasterView): breakdown = self.make_status_breakdown(batch) - if use_buefy: - factory = self.get_grid_factory() - g = factory('batch_row_status_breakdown', [], - columns=['title', 'count']) - g.set_click_handler('title', "autoFilterStatus(props.row)") - kwargs['status_breakdown_data'] = breakdown - kwargs['status_breakdown_grid'] = HTML.literal( - g.render_buefy_table_element(data_prop='statusBreakdownData', - empty_labels=True)) - - else: - kwargs['status_breakdown'] = [ - (status['title'], status['count']) - for status in breakdown] + factory = self.get_grid_factory() + g = factory('batch_row_status_breakdown', [], + columns=['title', 'count']) + g.set_click_handler('title', "autoFilterStatus(props.row)") + kwargs['status_breakdown_data'] = breakdown + kwargs['status_breakdown_grid'] = HTML.literal( + g.render_buefy_table_element(data_prop='statusBreakdownData', + empty_labels=True)) return kwargs def make_upload_worksheet_form(self, batch): action_url = self.get_action_url('upload_worksheet', batch) - use_buefy = self.get_use_buefy() form = forms.Form(schema=UploadWorksheet(), request=self.request, action_url=action_url, - use_buefy=use_buefy, component='upload-worksheet-form') form.set_type('worksheet_file', 'file') # TODO: must set these to avoid some default Buefy code @@ -431,7 +419,6 @@ class BatchMasterView(MasterView): return dict(batch.params or {}) def render_complete(self, batch, field): - use_buefy = self.get_use_buefy() text = "Yes" if batch.complete else "No" if batch.executed or not self.has_perm('edit'): @@ -446,18 +433,13 @@ class BatchMasterView(MasterView): url = self.get_action_url('toggle_complete', batch) kwargs = {'@submit': 'togglingBatchComplete = true'} - if not use_buefy: - kwargs['class_'] = 'autodisable' begin_form = tags.form(url, **kwargs) - if use_buefy: - label = HTML.literal( - '{{{{ togglingBatchComplete ? "Working, please wait..." : "{}" }}}}'.format(label)) - submit = self.make_buefy_button(label, is_primary=True, - native_type='submit', - **{':disabled': 'togglingBatchComplete'}) - else: - submit = tags.submit('submit', label) + label = HTML.literal( + '{{{{ togglingBatchComplete ? "Working, please wait..." : "{}" }}}}'.format(label)) + submit = self.make_buefy_button(label, is_primary=True, + native_type='submit', + **{':disabled': 'togglingBatchComplete'}) form = [ begin_form, @@ -467,23 +449,16 @@ class BatchMasterView(MasterView): tags.end_form(), ] - if use_buefy: - text = HTML.tag('div', class_='control', c=text) - form = HTML.tag('div', class_='control', c=form) - content = [ - HTML.tag('nav', class_='level', - c=[HTML.tag('div', class_='level-left', c=[ - text, - HTML.literal(' '), - form, - ])]), - ] - - else: - content = [ - text, - HTML.literal(' '), - ] + form + text = HTML.tag('div', class_='control', c=text) + form = HTML.tag('div', class_='control', c=form) + content = [ + HTML.tag('nav', class_='level', + c=[HTML.tag('div', class_='level-left', c=[ + text, + HTML.literal(' '), + form, + ])]), + ] return HTML.tag('div', c=content) @@ -491,7 +466,7 @@ class BatchMasterView(MasterView): user = getattr(batch, field) if not user: return "" - title = six.text_type(user) + title = str(user) url = self.request.route_url('users.view', uuid=user.uuid) return tags.link_to(title, url) @@ -513,7 +488,7 @@ class BatchMasterView(MasterView): # TODO: this needs work yet surely...why is this an issue? # treat 'filename' field specially, for some reason it can be a filedict? - if 'filename' in kwargs and not isinstance(kwargs['filename'], six.string_types): + if 'filename' in kwargs and not isinstance(kwargs['filename'], str): kwargs['filename'] = '' # null not allowed # TODO: is this still necessary with colander? @@ -533,7 +508,7 @@ class BatchMasterView(MasterView): os.remove(upload['temp_path']) os.rmdir(upload['tempdir']) - for key, upload in six.iteritems(uploads): + for key, upload in uploads.items(): if isinstance(upload, dict): process(upload, key) else: @@ -657,7 +632,7 @@ class BatchMasterView(MasterView): code = row.status_code if code is None: return "" - text = self.get_row_status_enum().get(code, six.text_type(code)) + text = self.get_row_status_enum().get(code, str(code)) if row.status_text: return HTML.tag('span', title=row.status_text, c=text) return text @@ -707,21 +682,12 @@ class BatchMasterView(MasterView): permission_prefix = self.get_permission_prefix() if self.request.has_perm('{}.create_row'.format(permission_prefix)): url = self.get_action_url('create_row', batch) - if self.get_use_buefy(): - return self.make_buefy_button("New Row", url=url, - is_primary=True, - icon_left='plus') - else: - text = "Create a new {}".format(self.get_row_model_title()) - link = tags.link_to(text, url) - return HTML.tag('p', c=[link]) + return self.make_buefy_button("New Row", url=url, + is_primary=True, + icon_left='plus') def make_batch_row_grid_tools(self, batch): - if self.get_use_buefy(): - return - if self.rows_bulk_deletable and not batch.executed and self.request.has_perm('{}.delete_rows'.format(self.get_permission_prefix())): - url = self.request.route_url('{}.delete_rows'.format(self.get_route_prefix()), uuid=batch.uuid) - return HTML.tag('p', c=[tags.link_to("Delete all rows matching current search", url)]) + pass def make_row_grid_kwargs(self, **kwargs): """ @@ -733,21 +699,19 @@ class BatchMasterView(MasterView): # TODO: most of this logic is copied from MasterView, should refactor/merge somehow... if 'main_actions' not in kwargs: actions = [] - use_buefy = self.get_use_buefy() # view action if self.rows_viewable: view = lambda r, i: self.get_row_action_url('view', r) - icon = 'eye' if use_buefy else 'zoomin' - actions.append(self.make_action('view', icon=icon, url=view)) + actions.append(self.make_action('view', icon='eye', url=view)) # edit and delete are NOT allowed after execution, or if batch is "complete" if not batch.executed and not batch.complete: # edit action if self.rows_editable and self.has_perm('edit_row'): - icon = 'edit' if use_buefy else 'pencil' - actions.append(self.make_action('edit', icon=icon, url=self.row_edit_action_url)) + actions.append(self.make_action('edit', icon='edit', + url=self.row_edit_action_url)) # delete action if self.rows_deletable and self.has_perm('delete_row'): @@ -793,7 +757,7 @@ class BatchMasterView(MasterView): # nb. must make new session, separate from main thread session = app.make_session() batch = self.get_instance_for_key(key, session) - batch_str = six.text_type(batch) + batch_str = str(batch) try: # try to delete batch @@ -869,7 +833,6 @@ class BatchMasterView(MasterView): """ defaults = {} route_prefix = self.get_route_prefix() - use_buefy = self.get_use_buefy() schema = None if self.has_execution_options(batch): @@ -891,13 +854,12 @@ class BatchMasterView(MasterView): labels[field.name] = field.title # auto-convert select widgets for buefy theme - if use_buefy and isinstance(field.widget, forms.widgets.PlainSelectWidget): + if isinstance(field.widget, forms.widgets.PlainSelectWidget): field.widget = dfwidget.SelectWidget(values=field.widget.values) if not schema: schema = colander.Schema() - kwargs['use_buefy'] = use_buefy kwargs['component'] = 'execute-form' form = forms.Form(schema=schema, request=self.request, defaults=defaults, **kwargs) self.configure_execute_form(form) @@ -1051,8 +1013,7 @@ class BatchMasterView(MasterView): data = json.dumps({ 'everything_complete': True, }) - if six.PY3: - data = data.encode('utf_8') + data = data.encode('utf_8') cxn.send(data) cxn.send(suffix) cxn.close() @@ -1061,7 +1022,7 @@ class BatchMasterView(MasterView): with short_session() as s: batch = s.query(self.model_class).get(batch_uuid) batch_id = batch.id_str - description = six.text_type(batch) + description = str(batch) self.launch_subprocess( port=port, username=username, diff --git a/tailbone/views/batch/importer.py b/tailbone/views/batch/importer.py index ac690f80..ef22a429 100644 --- a/tailbone/views/batch/importer.py +++ b/tailbone/views/batch/importer.py @@ -24,8 +24,6 @@ Views for importer batches """ -from __future__ import unicode_literals, absolute_import - import sqlalchemy as sa from rattail.db import model @@ -139,7 +137,6 @@ class ImporterBatchView(BatchMasterView): def configure_row_grid(self, g): super(ImporterBatchView, self).configure_row_grid(g) - use_buefy = self.get_use_buefy() def make_filter(field, **kwargs): column = getattr(self.current_row_table.c, field) @@ -150,11 +147,8 @@ class ImporterBatchView(BatchMasterView): # for some reason we have to do this differently for Buefy? kwargs = {} - if not use_buefy: - kwargs['value_enum'] = self.enum.IMPORTER_BATCH_ROW_STATUS make_filter('status_code', label="Status", **kwargs) - if use_buefy: - g.filters['status_code'].set_choices(self.enum.IMPORTER_BATCH_ROW_STATUS) + g.filters['status_code'].set_choices(self.enum.IMPORTER_BATCH_ROW_STATUS) def make_sorter(field): column = getattr(self.current_row_table.c, field) diff --git a/tailbone/views/batch/inventory.py b/tailbone/views/batch/inventory.py index 48bc9267..657d5758 100644 --- a/tailbone/views/batch/inventory.py +++ b/tailbone/views/batch/inventory.py @@ -24,14 +24,10 @@ Views for inventory batches """ -from __future__ import unicode_literals, absolute_import - import re import decimal import logging -import six - from rattail import pod from rattail.db import model from rattail.db.util import make_full_description @@ -234,9 +230,8 @@ class InventoryBatchView(BatchMasterView): if batch.executed: return self.redirect(self.get_action_url('view', batch)) - use_buefy = self.get_use_buefy() schema = DesktopForm().bind(session=self.Session()) - form = forms.Form(schema=schema, request=self.request, use_buefy=use_buefy) + form = forms.Form(schema=schema, request=self.request) if self.request.method == 'POST': if form.validate(newstyle=True): @@ -273,7 +268,7 @@ class InventoryBatchView(BatchMasterView): else: dform = form.make_deform_form() - msg = "Form did not validate: {}".format(six.text_type(dform.error)) + msg = "Form did not validate: {}".format(str(dform.error)) self.request.session.flash(msg, 'error') title = self.get_instance_title(batch) @@ -345,7 +340,7 @@ class InventoryBatchView(BatchMasterView): upc = re.sub(r'\D', '', entry.strip()) if upc: upc = GPC(upc) - result['upc'] = six.text_type(upc) + result['upc'] = str(upc) result['upc_pretty'] = upc.pretty() result['image_url'] = pod.get_image_url(self.rattail_config, upc) @@ -374,10 +369,10 @@ class InventoryBatchView(BatchMasterView): data = {} if product and (not product.deleted or self.request.has_perm('products.view_deleted')): data['uuid'] = product.uuid - data['upc'] = six.text_type(product.upc) + data['upc'] = str(product.upc) data['upc_pretty'] = product.upc.pretty() data['full_description'] = product.full_description - data['brand_name'] = six.text_type(product.brand or '') + data['brand_name'] = str(product.brand or '') data['description'] = product.description data['size'] = product.size data['case_quantity'] = 1 # default diff --git a/tailbone/views/batch/vendorcatalog.py b/tailbone/views/batch/vendorcatalog.py index ba4d3482..0808eed5 100644 --- a/tailbone/views/batch/vendorcatalog.py +++ b/tailbone/views/batch/vendorcatalog.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,12 +24,8 @@ Views for maintaining vendor catalogs """ -from __future__ import unicode_literals, absolute_import - import logging -import six - from rattail.db import model import colander @@ -196,9 +192,6 @@ class VendorCatalogView(FileBatchMasterView): values = [(p.key, p.display) for p in parsers] if len(values) == 1: f.set_default('parser_key', parsers[0].key) - use_buefy = self.get_use_buefy() - if not use_buefy: - values.insert(0, ('', "(please choose)")) f.set_widget('parser_key', dfwidget.SelectWidget(values=values)) else: f.set_readonly('parser_key') @@ -235,7 +228,7 @@ class VendorCatalogView(FileBatchMasterView): vendor = self.Session.query(model.Vendor).get( self.request.POST['vendor_uuid']) if vendor: - vendor_display = six.text_type(vendor) + vendor_display = str(vendor) f.set_widget('vendor_uuid', forms.widgets.JQueryAutocompleteWidget( field_display=vendor_display, @@ -275,35 +268,20 @@ class VendorCatalogView(FileBatchMasterView): return parser.display def template_kwargs_create(self, **kwargs): - use_buefy = self.get_use_buefy() app = self.get_rattail_app() vendor_handler = app.get_vendor_handler() parsers = self.get_parsers() parsers_data = {} for parser in parsers: - if use_buefy: - pdata = {'key': parser.key, - 'vendor_key': parser.vendor_key} - if parser.vendor_key: - vendor = vendor_handler.get_vendor(self.Session(), - parser.vendor_key) - if vendor: - pdata['vendor_uuid'] = vendor.uuid - pdata['vendor_name'] = vendor.name - parsers_data[parser.key] = pdata - else: - if parser.vendor_key: - vendor = vendor_handler.get_vendor(self.Session(), - parser.vendor_key) - if vendor: - parser.vendormap_value = "{{uuid: '{}', name: '{}'}}".format( - vendor.uuid, vendor.name.replace("'", "\\'")) - else: - log.warning("vendor '{}' not found for parser: {}".format( - parser.vendor_key, parser.key)) - parser.vendormap_value = 'null' - else: - parser.vendormap_value = 'null' + pdata = {'key': parser.key, + 'vendor_key': parser.vendor_key} + if parser.vendor_key: + vendor = vendor_handler.get_vendor(self.Session(), + parser.vendor_key) + if vendor: + pdata['vendor_uuid'] = vendor.uuid + pdata['vendor_name'] = vendor.name + parsers_data[parser.key] = pdata kwargs['parsers'] = parsers kwargs['parsers_data'] = parsers_data return kwargs diff --git a/tailbone/views/common.py b/tailbone/views/common.py index 5e3060f1..89abb9a9 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,7 @@ Various common views """ -from __future__ import unicode_literals, absolute_import - import os -import six from rattail.batch import consume_batch_id from rattail.util import OrderedDict, simple_error, import_module_path @@ -62,14 +59,11 @@ class CommonView(View): 'tailbone', 'main_image_url', default=self.request.static_url('tailbone:static/img/home_logo.png')) - use_buefy = self.get_use_buefy() context = { 'image_url': image_url, - 'use_buefy': use_buefy, + 'index_title': self.rattail_config.node_title(), 'help_url': global_help_url(self.rattail_config), } - if use_buefy: - context['index_title'] = self.rattail_config.node_title() if self.expose_quickie_search: context['quickie'] = self.get_quickie_context() @@ -83,12 +77,8 @@ class CommonView(View): with open(self.robots_txt_path, 'rt') as f: content = f.read() response = self.request.response - if six.PY3: - response.text = content - response.content_type = 'text/plain' - else: - response.body = content - response.content_type = b'text/plain' + response.text = content + response.content_type = 'text/plain' return response def get_project_title(self): @@ -114,16 +104,12 @@ class CommonView(View): """ Generic view to show "about project" info page. """ - use_buefy = self.get_use_buefy() - context = { + return { 'project_title': self.get_project_title(), 'project_version': self.get_project_version(), 'packages': self.get_packages(), - 'use_buefy': use_buefy, + 'index_title': self.rattail_config.node_title(), } - if use_buefy: - context['index_title'] = self.rattail_config.node_title() - return context def get_packages(self): """ @@ -203,7 +189,6 @@ class CommonView(View): if not self.request.is_root: raise self.forbidden() - use_buefy = self.get_use_buefy() app = self.get_rattail_app() app_title = self.rattail_config.app_title() poser_handler = app.get_poser_handler() @@ -253,7 +238,6 @@ class CommonView(View): poser_error = simple_error(error) return { - 'use_buefy': use_buefy, 'app_title': app_title, 'index_title': app_title, 'poser_dir': poser_dir, diff --git a/tailbone/views/core.py b/tailbone/views/core.py index c0f03e19..55b35487 100644 --- a/tailbone/views/core.py +++ b/tailbone/views/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,12 +24,8 @@ Base View Class """ -from __future__ import unicode_literals, absolute_import - import os -import six - from rattail.db import model from rattail.core import Object from rattail.util import progress_loop @@ -41,7 +37,6 @@ from pyramid.response import FileResponse from tailbone.db import Session from tailbone.auth import logout_user from tailbone.progress import SessionProgress -from tailbone.util import should_use_buefy from tailbone.config import protected_usernames @@ -94,13 +89,6 @@ class View(object): def notfound(self): return httpexceptions.HTTPNotFound() - def get_use_buefy(self): - """ - Returns a flag indicating whether or not the current theme supports - (and therefore should use) the Buefy JS library. - """ - return should_use_buefy(self.request) - def late_login_user(self): """ Returns the :class:`rattail:rattail.db.model.User` instance @@ -170,8 +158,6 @@ class View(object): if attachment: if not filename: filename = os.path.basename(path) - if six.PY2: - filename = filename.encode('ascii', 'replace') response.content_disposition = str('attachment; filename="{}"'.format(filename)) return response diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index 0ce0ad44..d4ab1a37 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,9 +24,6 @@ Customer Views """ -from __future__ import unicode_literals, absolute_import - -import six import sqlalchemy as sa import colander @@ -205,7 +202,6 @@ class CustomerView(MasterView): super(CustomerView, self).configure_common_form(f) customer = f.model_instance permission_prefix = self.get_permission_prefix() - use_buefy = self.get_use_buefy() f.set_renderer('default_email', self.render_default_email) if not self.creating and customer.emails: @@ -251,12 +247,7 @@ class CustomerView(MasterView): # people if self.viewing: - if use_buefy: - f.set_renderer('people', self.render_people_buefy) - elif self.people_detachable and self.has_perm('detach_person'): - f.set_renderer('people', self.render_people_removable) - else: - f.set_renderer('people', self.render_people) + f.set_renderer('people', self.render_people_buefy) else: f.remove('people') @@ -288,30 +279,28 @@ class CustomerView(MasterView): kwargs['show_profiles_helper'] = self.show_profiles_helper - use_buefy = self.get_use_buefy() - if use_buefy: - customer = kwargs['instance'] - people = [] - for person in customer.people: - data = { - 'uuid': person.uuid, - 'full_name': person.display_name, - 'first_name': person.first_name, - 'last_name': person.last_name, - '_action_url_view': self.request.route_url('people.view', - uuid=person.uuid), - } - if self.editable and self.request.has_perm('people.edit'): - data['_action_url_edit'] = self.request.route_url( - 'people.edit', - uuid=person.uuid) - if self.people_detachable and self.has_perm('detach_person'): - data['_action_url_detach'] = self.request.route_url( - 'customers.detach_person', - uuid=customer.uuid, - person_uuid=person.uuid) - people.append(data) - kwargs['people_data'] = people + customer = kwargs['instance'] + people = [] + for person in customer.people: + data = { + 'uuid': person.uuid, + 'full_name': person.display_name, + 'first_name': person.first_name, + 'last_name': person.last_name, + '_action_url_view': self.request.route_url('people.view', + uuid=person.uuid), + } + if self.editable and self.request.has_perm('people.edit'): + data['_action_url_edit'] = self.request.route_url( + 'people.edit', + uuid=person.uuid) + if self.people_detachable and self.has_perm('detach_person'): + data['_action_url_detach'] = self.request.route_url( + 'customers.detach_person', + uuid=customer.uuid, + person_uuid=person.uuid) + people.append(data) + kwargs['people_data'] = people return kwargs @@ -326,20 +315,20 @@ class CustomerView(MasterView): def render_default_address(self, customer, field): if customer.addresses: - return six.text_type(customer.addresses[0]) + return str(customer.addresses[0]) def grid_render_person(self, customer, field): person = getattr(customer, field) if not person: return "" - return six.text_type(person) + return str(person) def form_render_person(self, customer, field): person = getattr(customer, field) if not person: return "" - text = six.text_type(person) + text = str(person) url = self.request.route_url('people.view', uuid=person.uuid) return tags.link_to(text, url) @@ -350,12 +339,13 @@ class CustomerView(MasterView): items = [] for person in people: - text = six.text_type(person) + text = str(person) url = self.request.route_url('people.view', uuid=person.uuid) link = tags.link_to(text, url) items.append(HTML.tag('li', c=[link])) return HTML.tag('ul', c=items) + # TODO: remove if no longer used def render_people_removable(self, customer, field): people = customer.people if not people: @@ -431,7 +421,7 @@ class CustomerView(MasterView): return "" items = [] for member in members: - text = six.text_type(member) + text = str(member) url = self.request.route_url('members.view', uuid=member.uuid) items.append(HTML.tag('li', tags.link_to(text, url))) return HTML.tag('ul', HTML.literal('').join(items)) @@ -587,7 +577,7 @@ class PendingCustomerView(MasterView): g.set_enum('status_code', self.enum.PENDING_CUSTOMER_STATUS) g.filters['status_code'].default_active = True g.filters['status_code'].default_verb = 'not_equal' - g.filters['status_code'].default_value = six.text_type(self.enum.PENDING_CUSTOMER_STATUS_RESOLVED) + g.filters['status_code'].default_value = str(self.enum.PENDING_CUSTOMER_STATUS_RESOLVED) g.set_sort_defaults('display_name') g.set_link('id') diff --git a/tailbone/views/custorders/items.py b/tailbone/views/custorders/items.py index c780756a..8331c864 100644 --- a/tailbone/views/custorders/items.py +++ b/tailbone/views/custorders/items.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,11 +24,8 @@ Customer order item views """ -from __future__ import unicode_literals, absolute_import - import datetime -import six from sqlalchemy import orm from rattail.db import model @@ -156,7 +153,7 @@ class CustomerOrderItemView(MasterView): def render_person_text(self, item, field): person = item.order.person if person: - text = six.text_type(person) + text = str(person) return text def render_order_created(self, item, column): @@ -165,14 +162,13 @@ class CustomerOrderItemView(MasterView): def render_status_code_column(self, item, field): text = self.enum.CUSTORDER_ITEM_STATUS.get(item.status_code, - six.text_type(item.status_code)) + str(item.status_code)) if item.status_text: return HTML.tag('span', title=item.status_text, c=[text]) return text def configure_form(self, f): super(CustomerOrderItemView, self).configure_form(f) - use_buefy = self.get_use_buefy() item = f.model_instance # order @@ -222,10 +218,7 @@ class CustomerOrderItemView(MasterView): f.set_renderer('status_code', self.render_status_code) # notes - if use_buefy: - f.set_renderer('notes', self.render_notes) - else: - f.remove('notes') + f.set_renderer('notes', self.render_notes) def highlight_pending_field(self, item, field, value=None): if value is None: @@ -271,13 +264,12 @@ class CustomerOrderItemView(MasterView): return outer def render_status_code(self, item, field): - use_buefy = self.get_use_buefy() text = self.enum.CUSTORDER_ITEM_STATUS[item.status_code] if item.status_text: text = "{} ({})".format(text, item.status_text) items = [HTML.tag('span', c=[text])] - if use_buefy and self.has_perm('change_status'): + if self.has_perm('change_status'): button = HTML.tag('b-button', type='is-primary', c="Change Status", style='margin-left: 1rem;', icon_pack='fas', icon_left='edit', @@ -484,14 +476,14 @@ class CustomerOrderItemView(MasterView): order = item.order if not order: return "" - text = six.text_type(order) + text = str(order) url = self.request.route_url('custorders.view', uuid=order.uuid) return tags.link_to(text, url) def render_person(self, item, field): person = item.order.person if person: - text = six.text_type(person) + text = str(person) url = self.request.route_url('people.view', uuid=person.uuid) return tags.link_to(text, url) diff --git a/tailbone/views/departments.py b/tailbone/views/departments.py index 3e876c66..e71203ba 100644 --- a/tailbone/views/departments.py +++ b/tailbone/views/departments.py @@ -24,15 +24,10 @@ Department Views """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from webhelpers2.html import HTML -from tailbone import grids from tailbone.views import MasterView @@ -99,11 +94,10 @@ class DepartmentView(MasterView): def configure_form(self, f): super(DepartmentView, self).configure_form(f) - use_buefy = self.get_use_buefy() f.remove_field('subdepartments') - if not use_buefy or self.creating or self.editing: + if self.creating or self.editing: f.remove('employees') else: f.set_renderer('employees', self.render_employees) @@ -137,33 +131,20 @@ class DepartmentView(MasterView): def template_kwargs_view(self, **kwargs): kwargs = super(DepartmentView, self).template_kwargs_view(**kwargs) - use_buefy = self.get_use_buefy() department = kwargs['instance'] - department_employees = sorted(department.employees, key=six.text_type) + department_employees = sorted(department.employees, key=str) - if use_buefy: - employees = [] - for employee in department_employees: - person = employee.person - employees.append({ - 'uuid': employee.uuid, - 'first_name': person.first_name, - 'last_name': person.last_name, - '_action_url_view': self.request.route_url('employees.view', uuid=employee.uuid), - '_action_url_edit': self.request.route_url('employees.edit', uuid=employee.uuid), - }) - kwargs['employees_data'] = employees - - else: # not buefy - if department.employees: - actions = [ - grids.GridAction('view', icon='zoomin', - url=lambda r, i: self.request.route_url('employees.view', uuid=r.uuid)) - ] - kwargs['employees'] = grids.Grid(None, department_employees, ['display_name'], request=self.request, - model_class=model.Employee, main_actions=actions) - else: - kwargs['employees'] = None + employees = [] + for employee in department_employees: + person = employee.person + employees.append({ + 'uuid': employee.uuid, + 'first_name': person.first_name, + 'last_name': person.last_name, + '_action_url_view': self.request.route_url('employees.view', uuid=employee.uuid), + '_action_url_edit': self.request.route_url('employees.edit', uuid=employee.uuid), + }) + kwargs['employees_data'] = employees return kwargs @@ -218,33 +199,14 @@ class DepartmentView(MasterView): .distinct()\ .order_by(model.Department.name) - if self.get_use_buefy(): + def normalize(dept): + return { + 'uuid': dept.uuid, + 'number': dept.number, + 'name': dept.name, + } - def normalize(dept): - return { - 'uuid': dept.uuid, - 'number': dept.number, - 'name': dept.name, - } - - return self.json_response([normalize(d) for d in data]) - - # nb. the rest of this is legacy / not buefy - - def configure(g): - g.configure(include=[ - g.name, - ], readonly=True) - - def row_attrs(row, i): - return {'data-uuid': row.uuid} - - grid = self.make_grid(data=data, sortable=False, filterable=False, pageable=False, - configure=configure, width=None, checkboxes=True, - row_attrs=row_attrs, main_actions=[], more_actions=[]) - self.request.response.content_type = str('text/html') - self.request.response.text = grid.render_grid() - return self.request.response + return self.json_response([normalize(d) for d in data]) @classmethod def defaults(cls, config): diff --git a/tailbone/views/employees.py b/tailbone/views/employees.py index f07d319a..4f788532 100644 --- a/tailbone/views/employees.py +++ b/tailbone/views/employees.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,9 +24,6 @@ Employee Views """ -from __future__ import unicode_literals, absolute_import - -import six import sqlalchemy as sa from rattail.db import model @@ -84,7 +81,6 @@ class EmployeeView(MasterView): def configure_grid(self, g): super(EmployeeView, self).configure_grid(g) route_prefix = self.get_route_prefix() - use_buefy = self.get_use_buefy() # phone g.set_joiner('phone', lambda q: q.outerjoin(model.EmployeePhoneNumber, sa.and_( @@ -127,10 +123,7 @@ class EmployeeView(MasterView): g.set_enum('status', self.enum.EMPLOYEE_STATUS) g.filters['status'].default_active = True g.filters['status'].default_verb = 'equal' - if use_buefy: - g.filters['status'].default_value = six.text_type(self.enum.EMPLOYEE_STATUS_CURRENT) - else: - g.filters['status'].default_value = self.enum.EMPLOYEE_STATUS_CURRENT + g.filters['status'].default_value = str(self.enum.EMPLOYEE_STATUS_CURRENT) else: g.remove('status') del g.filters['status'] @@ -202,7 +195,7 @@ class EmployeeView(MasterView): f.set_label('stores', "Stores") # TODO: should not be necessary if self.creating or self.editing: stores = self.get_possible_stores().all() - store_values = [(s.uuid, six.text_type(s)) for s in stores] + store_values = [(s.uuid, str(s)) for s in stores] f.set_node('stores', colander.SchemaNode(colander.Set())) f.set_widget('stores', dfwidget.SelectWidget(multiple=True, size=len(stores), @@ -214,7 +207,7 @@ class EmployeeView(MasterView): f.set_label('departments', "Departments") # TODO: should not be necessary if self.creating or self.editing: departments = self.get_possible_departments().all() - dept_values = [(d.uuid, six.text_type(d)) for d in departments] + dept_values = [(d.uuid, str(d)) for d in departments] f.set_node('departments', colander.SchemaNode(colander.Set())) f.set_widget('departments', dfwidget.SelectWidget(multiple=True, size=len(departments), @@ -284,7 +277,7 @@ class EmployeeView(MasterView): person = employee.person if employee else None if not person: return "" - text = six.text_type(person) + text = str(person) url = self.request.route_url('people.view', uuid=person.uuid) return tags.link_to(text, url) @@ -293,8 +286,8 @@ class EmployeeView(MasterView): if not stores: return "" items = [] - for store in sorted(stores, key=six.text_type): - items.append(HTML.tag('li', c=six.text_type(store))) + for store in sorted(stores, key=str): + items.append(HTML.tag('li', c=str(store))) return HTML.tag('ul', c=items) def render_departments(self, employee, field): @@ -302,8 +295,8 @@ class EmployeeView(MasterView): if not departments: return "" items = [] - for department in sorted(departments, key=six.text_type): - items.append(HTML.tag('li', c=six.text_type(department))) + for department in sorted(departments, key=str): + items.append(HTML.tag('li', c=str(department))) return HTML.tag('ul', c=items) def touch_instance(self, employee): diff --git a/tailbone/views/features.py b/tailbone/views/features.py index d55be524..39f683d3 100644 --- a/tailbone/views/features.py +++ b/tailbone/views/features.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,9 +24,6 @@ Feature views """ -from __future__ import unicode_literals, absolute_import - -import six import colander import markdown @@ -49,20 +46,16 @@ class GenerateFeatureView(View): return handler def __call__(self): - use_buefy = self.get_use_buefy() - schema = self.handler.make_schema() - app_form = forms.Form(schema=schema, request=self.request, - use_buefy=use_buefy) - for key, value in six.iteritems(self.handler.get_defaults()): + app_form = forms.Form(schema=schema, request=self.request) + for key, value in self.handler.get_defaults().items(): app_form.set_default(key, value) feature_forms = {} for feature in self.handler.iter_features(): schema = feature.make_schema() - form = forms.Form(schema=schema, request=self.request, - use_buefy=use_buefy) - for key, value in six.iteritems(feature.get_defaults()): + form = forms.Form(schema=schema, request=self.request) + for key, value in feature.get_defaults().items(): form.set_default(key, value) feature_forms[feature.feature_key] = form @@ -86,7 +79,6 @@ class GenerateFeatureView(View): context = { 'index_title': "Generate Feature", 'handler': self.handler, - 'use_buefy': use_buefy, 'app_form': app_form, 'feature_type': feature_type, 'feature_forms': feature_forms, diff --git a/tailbone/views/importing.py b/tailbone/views/importing.py index b3358f23..bfbd82e9 100644 --- a/tailbone/views/importing.py +++ b/tailbone/views/importing.py @@ -24,8 +24,6 @@ View for running arbitrary import/export jobs """ -from __future__ import unicode_literals, absolute_import - import getpass import socket import sys @@ -34,7 +32,6 @@ import subprocess import time import json -import six import sqlalchemy as sa from rattail.exceptions import ConfigurationError @@ -222,7 +219,7 @@ class ImportingView(MasterView): try: return self.do_runjob(handler_info, form) except Exception as error: - self.request.session.flash(six.text_type(error), 'error') + self.request.session.flash(str(error), 'error') return self.redirect(self.request.current_route_url()) return self.render_to_response('runjob', { @@ -274,7 +271,6 @@ class ImportingView(MasterView): handler = handler_info['_handler'] defaults = { 'request': self.request, - 'use_buefy': self.get_use_buefy(), 'model_instance': handler, 'cancel_url': self.request.route_url('{}.view'.format(route_prefix), key=handler.get_key()), @@ -411,8 +407,7 @@ And here is the output: data = json.dumps({ 'everything_complete': True, }) - if six.PY3: - data = data.encode('utf_8') + data = data.encode('utf_8') cxn.send(data) cxn.send(suffix) cxn.close() diff --git a/tailbone/views/labels/profiles.py b/tailbone/views/labels/profiles.py index c392e510..f49902bb 100644 --- a/tailbone/views/labels/profiles.py +++ b/tailbone/views/labels/profiles.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,6 @@ Label Profile Views """ -from __future__ import unicode_literals, absolute_import - from rattail.db import model import colander @@ -93,7 +91,6 @@ class LabelProfileView(MasterView): pass def make_printer_settings_form(self, profile, printer): - use_buefy = self.get_use_buefy() schema = colander.Schema() for name, label in printer.required_settings.items(): @@ -104,7 +101,6 @@ class LabelProfileView(MasterView): schema.add(node) form = forms.Form(schema=schema, request=self.request, - use_buefy=use_buefy, model_instance=profile, # TODO: ugh, this is necessary to avoid some logic # which assumes a ColanderAlchemy schema i think? diff --git a/tailbone/views/luigi.py b/tailbone/views/luigi.py index d3bd7b43..568183ad 100644 --- a/tailbone/views/luigi.py +++ b/tailbone/views/luigi.py @@ -24,15 +24,12 @@ 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 @@ -81,7 +78,6 @@ class LuigiTaskView(MasterView): 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, @@ -169,7 +165,7 @@ class LuigiTaskView(MasterView): tasks = [] for task in tasks: if task['last_date']: - task['last_date'] = six.text_type(task['last_date']) + task['last_date'] = str(task['last_date']) return tasks def get_backfill_tasks(self): @@ -179,9 +175,9 @@ class LuigiTaskView(MasterView): tasks = [] for task in tasks: if task['last_date']: - task['last_date'] = six.text_type(task['last_date']) + task['last_date'] = str(task['last_date']) if task['target_date']: - task['target_date'] = six.text_type(task['target_date']) + task['target_date'] = str(task['target_date']) return tasks def configure_gather_settings(self, data): @@ -224,7 +220,7 @@ class LuigiTaskView(MasterView): {'name': 'rattail.luigi.backfill.task.{}.notes'.format(key), 'value': task['notes']}, {'name': 'rattail.luigi.backfill.task.{}.target_date'.format(key), - 'value': six.text_type(task['target_date'])}, + 'value': str(task['target_date'])}, ]) if keys: settings.append({'name': 'rattail.luigi.backfill.tasks', diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 1afbc639..2d6410e1 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -24,8 +24,6 @@ Model Master View """ -from __future__ import unicode_literals, absolute_import - import os import csv import datetime @@ -252,7 +250,7 @@ class MasterView(View): def set_labels(self, obj): labels = self.collect_labels() - for key, label in six.iteritems(labels): + for key, label in labels.items(): obj.set_label(key, label) def collect_labels(self): @@ -283,7 +281,7 @@ class MasterView(View): def set_row_labels(self, obj): labels = self.collect_row_labels() - for key, label in six.iteritems(labels): + for key, label in labels.items(): obj.set_label(key, label) def collect_row_labels(self): @@ -333,7 +331,6 @@ class MasterView(View): """ self.listing = True grid = self.make_grid() - use_buefy = self.get_use_buefy() # If user just refreshed the page with a reset instruction, issue a # redirect in order to clear out the query string. @@ -346,14 +343,9 @@ class MasterView(View): # return grid only, if partial page was requested if self.request.params.get('partial'): - if use_buefy: - # render grid data only, as JSON - return render_to_response('json', grid.get_buefy_data(), - request=self.request) - else: # just do traditional thing, render grid HTML - self.request.response.content_type = str('text/html') - self.request.response.text = grid.render_grid() - return self.request.response + # render grid data only, as JSON + return render_to_response('json', grid.get_buefy_data(), + request=self.request) context = { 'grid': grid, @@ -552,17 +544,16 @@ class MasterView(View): if self.has_rows and 'main_actions' not in defaults: actions = [] - use_buefy = self.get_use_buefy() # view action if self.rows_viewable: - icon = 'eye' if use_buefy else 'zoomin' - actions.append(self.make_action('view', icon=icon, url=self.row_view_action_url)) + actions.append(self.make_action('view', icon='eye', + url=self.row_view_action_url)) # edit action if self.rows_editable and self.has_perm('edit_row'): - icon = 'edit' if use_buefy else 'pencil' - actions.append(self.make_action('edit', icon=icon, url=self.row_edit_action_url)) + actions.append(self.make_action('edit', icon='edit', + url=self.row_edit_action_url)) # delete action if self.rows_deletable and self.has_perm('delete_row'): @@ -626,7 +617,6 @@ class MasterView(View): Return a dictionary of kwargs to be passed to the factory when constructing a new version grid. """ - use_buefy = self.get_use_buefy() instance = kwargs.get('instance') or self.get_instance() route = '{}.version'.format(self.get_route_prefix()) defaults = { @@ -638,7 +628,7 @@ class MasterView(View): if 'main_actions' not in kwargs: url = lambda txn, i: self.request.route_url(route, uuid=instance.uuid, txnid=txn.id) defaults['main_actions'] = [ - self.make_action('view', icon='eye' if use_buefy else 'zoomin', url=url), + self.make_action('view', icon='eye', url=url), ] defaults.update(kwargs) return defaults @@ -735,11 +725,10 @@ class MasterView(View): delete=False, schema=None, importer_host_title=None): handler = handler_factory(self.rattail_config) - use_buefy = self.get_use_buefy() if not schema: schema = forms.SimpleFileImport().bind(request=self.request) - form = forms.Form(schema=schema, request=self.request, use_buefy=use_buefy) + form = forms.Form(schema=schema, request=self.request) form.save_label = "Upload" form.cancel_url = self.get_index_url() if form.validate(newstyle=True): @@ -771,7 +760,7 @@ class MasterView(View): value = getattr(obj, field) if value is None: return "" - value = six.text_type(value) + value = str(value) if len(value) > 100: value = value[:100] + '...' return value @@ -841,7 +830,7 @@ class MasterView(View): product = getattr(obj, field) if not product: return "" - text = six.text_type(product) + text = str(product) url = self.request.route_url('products.view', uuid=product.uuid) return tags.link_to(text, url) @@ -849,7 +838,7 @@ class MasterView(View): pending = getattr(obj, field) if not pending: return - text = six.text_type(pending) + text = str(pending) url = self.request.route_url('pending_products.view', uuid=pending.uuid) return tags.link_to(text, url, class_='has-background-warning') @@ -862,7 +851,7 @@ class MasterView(View): if short: text = "({}) {}".format(short, vendor.name) else: - text = six.text_type(vendor) + text = str(vendor) url = self.request.route_url('vendors.view', uuid=vendor.uuid) return tags.link_to(text, url) @@ -918,7 +907,7 @@ class MasterView(View): person = getattr(obj, field) if not person: return "" - text = six.text_type(person) + text = str(person) url = self.request.route_url('people.view', uuid=person.uuid) return tags.link_to(text, url) @@ -926,7 +915,7 @@ class MasterView(View): person = getattr(obj, field) if not person: return "" - text = six.text_type(person) + text = str(person) url = self.request.route_url('people.view_profile', uuid=person.uuid) return tags.link_to(text, url) @@ -934,7 +923,7 @@ class MasterView(View): user = getattr(obj, field) if not user: return "" - text = six.text_type(user) + text = str(user) url = self.request.route_url('users.view', uuid=user.uuid) return tags.link_to(text, url) @@ -954,7 +943,7 @@ class MasterView(View): customer = getattr(obj, field) if not customer: return "" - text = six.text_type(customer) + text = str(customer) url = self.request.route_url('customers.view', uuid=customer.uuid) return tags.link_to(text, url) @@ -984,7 +973,7 @@ class MasterView(View): value = obj.status_code if value is None: return "" - status_code_text = enum.get(value, six.text_type(value)) + status_code_text = enum.get(value, str(value)) if obj.status_text: return HTML.tag('span', title=obj.status_text, c=status_code_text) return status_code_text @@ -1072,7 +1061,6 @@ class MasterView(View): View for viewing details of an existing model record. """ self.viewing = True - use_buefy = self.get_use_buefy() if instance is None: instance = self.get_instance() form = self.make_form(instance) @@ -1090,14 +1078,9 @@ class MasterView(View): # return grid only, if partial page was requested if self.request.params.get('partial'): - if use_buefy: - # render grid data only, as JSON - return render_to_response('json', grid.get_buefy_data(), - request=self.request) - else: # just do traditional thing, render grid HTML - self.request.response.content_type = str('text/html') - self.request.response.text = grid.render_grid() - return self.request.response + # render grid data only, as JSON + return render_to_response('json', grid.get_buefy_data(), + request=self.request) context = { 'instance': instance, @@ -1112,12 +1095,8 @@ class MasterView(View): context['dform'] = form.make_deform_form() if self.has_rows: - if use_buefy: - context['rows_grid'] = grid - context['rows_grid_tools'] = HTML(self.make_row_grid_tools(instance) or '').strip() - else: - context['rows_grid'] = grid.render_complete(allow_save_defaults=False, - tools=self.make_row_grid_tools(instance)) + context['rows_grid'] = grid + context['rows_grid_tools'] = HTML(self.make_row_grid_tools(instance) or '').strip() return self.render_to_response('view', context) @@ -1224,18 +1203,12 @@ class MasterView(View): instance = self.get_instance() instance_title = self.get_instance_title(instance) grid = self.make_version_grid(instance=instance) - use_buefy = self.get_use_buefy() # return grid only, if partial page was requested if self.request.params.get('partial'): - if use_buefy: - # render grid data only, as JSON - return render_to_response('json', grid.get_buefy_data(), - request=self.request) - else: # just do traditional thing, render grid HTML - self.request.response.content_type = str('text/html') - self.request.response.text = grid.render_grid() - return self.request.response + # render grid data only, as JSON + return render_to_response('json', grid.get_buefy_data(), + request=self.request) return self.render_to_response('versions', { 'instance': instance, @@ -1567,15 +1540,10 @@ class MasterView(View): response.content_length = os.path.getsize(path) content_type = self.download_content_type(path, filename) if content_type: - if six.PY3: - response.content_type = content_type - else: - response.content_type = six.binary_type(content_type) + response.content_type = content_type # content-disposition filename = os.path.basename(path) - if six.PY2: - filename = filename.encode('ascii', 'replace') response.content_disposition = str('attachment; filename="{}"'.format(filename)) return response @@ -1986,8 +1954,7 @@ class MasterView(View): # strip suffix, interpret data as JSON data = data[:-len(suffix)] - if six.PY3: - data = data.decode('utf_8') + data = data.decode('utf_8') data = json.loads(data) if data.get('everything_complete'): @@ -2056,7 +2023,7 @@ class MasterView(View): object_to_keep = self.Session.query(self.get_model_class()).get(uuids[1]) if object_to_remove and object_to_keep and self.request.POST.get('commit-merge') == 'yes': - msg = six.text_type(object_to_remove) + msg = str(object_to_remove) try: self.validate_merge(object_to_remove, object_to_keep) except Exception as error: @@ -2166,7 +2133,7 @@ class MasterView(View): """ if hasattr(cls, 'model_key'): keys = cls.model_key - if isinstance(keys, six.string_types): + if isinstance(keys, str): keys = [keys] else: keys = get_primary_keys(cls.get_model_class()) @@ -2399,7 +2366,6 @@ class MasterView(View): """ context = { 'master': self, - 'use_buefy': self.get_use_buefy(), 'model_title': self.get_model_title(), 'model_title_plural': self.get_model_title_plural(), 'route_prefix': self.get_route_prefix(), @@ -2699,7 +2665,7 @@ class MasterView(View): # nb. unfortunately HTML.tag() calls its first arg 'tag' and # so we can't pass a kwarg with that name...so instead we # patch that into place manually - button = six.text_type(button) + button = str(button) button = button.replace('<b-button ', '<b-button tag="a"') button = HTML.literal(button) @@ -2733,7 +2699,7 @@ class MasterView(View): btn_kw['icon_left'] = 'external-link-alt' btn_kw['target'] = '_blank' button = HTML.tag('b-button', **btn_kw) - button = six.text_type(button) + button = str(button) button = button.replace('<b-button ', '<b-button tag="a"') button = HTML.literal(button) @@ -2840,9 +2806,7 @@ class MasterView(View): return actions def make_grid_action_view(self): - use_buefy = self.get_use_buefy() - icon = 'eye' if use_buefy else 'zoomin' - return self.make_action('view', icon=icon, url=self.default_view_url()) + return self.make_action('view', icon='eye', url=self.default_view_url()) def default_view_url(self): if self.use_index_links: @@ -2869,18 +2833,15 @@ class MasterView(View): return actions def make_grid_action_edit(self): - use_buefy = self.get_use_buefy() - icon = 'edit' if use_buefy else 'pencil' - return self.make_action('edit', icon=icon, url=self.default_edit_url) + return self.make_action('edit', icon='edit', url=self.default_edit_url) def make_grid_action_clone(self): return self.make_action('clone', icon='object-ungroup', url=self.default_clone_url) def make_grid_action_delete(self): - use_buefy = self.get_use_buefy() kwargs = {} - if use_buefy and self.delete_confirm == 'simple': + if self.delete_confirm == 'simple': kwargs['click_handler'] = 'deleteObject' return self.make_action('delete', icon='trash', url=self.default_delete_url, **kwargs) @@ -2917,7 +2878,7 @@ class MasterView(View): mapper = orm.object_mapper(row) except orm.exc.UnmappedInstanceError: try: - if isinstance(self.model_key, six.string_types): + if isinstance(self.model_key, str): return {self.model_key: row[self.model_key]} return dict([(key, row[key]) for key in self.model_key]) @@ -3134,12 +3095,8 @@ class MasterView(View): """ if fmt == 'csv': - if six.PY2: - csv_file = open(path, 'wb') - writer = UnicodeDictWriter(csv_file, fields, encoding='utf_8') - else: # PY3 - csv_file = open(path, 'wt', encoding='utf_8') - writer = csv.DictWriter(csv_file, fields) + csv_file = open(path, 'wt', encoding='utf_8') + writer = csv.DictWriter(csv_file, fields) writer.writeheader() def write(obj, i): @@ -3229,7 +3186,7 @@ class MasterView(View): if value is None: value = '' else: - value = six.text_type(value) + value = str(value) csvrow[field] = value @@ -3297,13 +3254,8 @@ class MasterView(View): results = results.with_session(session).all() fields = self.get_csv_fields() - if six.PY2: - csv_file = open(path, 'wb') - writer = UnicodeDictWriter(csv_file, fields, encoding='utf_8') - else: # PY3 - csv_file = open(path, 'wt', encoding='utf_8') - writer = csv.DictWriter(csv_file, fields) - + csv_file = open(path, 'wt', encoding='utf_8') + writer = csv.DictWriter(csv_file, fields) writer.writeheader() def write(obj, i): @@ -3663,12 +3615,8 @@ class MasterView(View): if fmt == 'csv': - if six.PY2: - csv_file = open(path, 'wb') - writer = UnicodeDictWriter(csv_file, fields, encoding='utf_8') - else: # PY3 - csv_file = open(path, 'wt', encoding='utf_8') - writer = csv.DictWriter(csv_file, fields) + csv_file = open(path, 'wt', encoding='utf_8') + writer = csv.DictWriter(csv_file, fields) writer.writeheader() def write(obj, i): @@ -3737,7 +3685,7 @@ class MasterView(View): if value is None: value = '' else: - value = six.text_type(value) + value = str(value) csvrow[field] = value @@ -3814,7 +3762,7 @@ class MasterView(View): value = getattr(row, field, None) if isinstance(value, GPC): - value = six.text_type(value) + value = str(value) elif isinstance(value, datetime.datetime): # datetime values we provide to Excel must *not* have time zone info, @@ -3844,14 +3792,9 @@ class MasterView(View): writer.writerow(self.get_row_csv_row(row, fields)) response = self.request.response filename = self.get_row_results_csv_filename(obj) - if six.PY3: - response.text = data.getvalue() - response.content_type = 'text/csv' - response.content_disposition = 'attachment; filename={}'.format(filename) - else: - response.body = data.getvalue() - response.content_type = b'text/csv' - response.content_disposition = b'attachment; filename={}'.format(filename) + response.text = data.getvalue() + response.content_type = 'text/csv' + response.content_disposition = 'attachment; filename={}'.format(filename) data.close() response.content_length = len(response.body) return response @@ -3898,7 +3841,7 @@ class MasterView(View): if isinstance(value, datetime.datetime): # TODO: this assumes value is *always* naive UTC value = localtime(self.rattail_config, value, from_utc=True) - csvrow[field] = '' if value is None else six.text_type(value) + csvrow[field] = '' if value is None else str(value) return csvrow def get_row_csv_row(self, row, fields): @@ -3911,7 +3854,7 @@ class MasterView(View): if isinstance(value, datetime.datetime): # TODO: this assumes value is *always* naive UTC value = localtime(self.rattail_config, value, from_utc=True) - csvrow[field] = '' if value is None else six.text_type(value) + csvrow[field] = '' if value is None else str(value) return csvrow ############################## @@ -3966,7 +3909,7 @@ class MasterView(View): """ Return a "pretty" title for the instance, to be used in the page title etc. """ - return six.text_type(instance) + return str(instance) @classmethod def get_form_factory(cls): @@ -4078,7 +4021,6 @@ class MasterView(View): 'readonly': self.viewing, 'model_class': getattr(self, 'model_class', None), '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') @@ -4547,7 +4489,6 @@ class MasterView(View): 'readonly': self.viewing, 'model_class': getattr(self, 'model_row_class', None), 'action_url': self.request.current_route_url(_query=None), - 'use_buefy': self.get_use_buefy(), } if self.creating: kwargs.setdefault('cancel_url', self.request.get_referrer()) @@ -4644,7 +4585,7 @@ class MasterView(View): # collect any uploaded files uploads = {} - for key, value in six.iteritems(data): + for key, value in data.items(): if isinstance(value, cgi_FieldStorage): tempdir = tempfile.mkdtemp() filename = os.path.basename(value.filename) @@ -4829,13 +4770,13 @@ class MasterView(View): value = data.get(name) if simple.get('type') is bool: - value = six.text_type(bool(value)).lower() + value = str(bool(value)).lower() elif simple.get('type') is int: - value = six.text_type(int(value or '0')) + value = str(int(value or '0')) elif value is None: value = '' else: - value = six.text_type(value) + value = str(value) # only want to save this setting if we received a # value, or if empty values are okay to save diff --git a/tailbone/views/menus.py b/tailbone/views/menus.py index a25b1543..f60ad274 100644 --- a/tailbone/views/menus.py +++ b/tailbone/views/menus.py @@ -24,8 +24,6 @@ Base class for Config Views """ -from __future__ import unicode_literals, absolute_import - import json import sqlalchemy as sa @@ -62,7 +60,6 @@ class MenuConfigView(View): context = { 'config_title': "Menus", - 'use_buefy': True, 'index_title': "App Details", 'index_url': self.request.route_url('appinfo'), } diff --git a/tailbone/views/messages.py b/tailbone/views/messages.py index 29766b6b..10851913 100644 --- a/tailbone/views/messages.py +++ b/tailbone/views/messages.py @@ -24,10 +24,6 @@ Message Views """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from rattail.time import localtime @@ -139,7 +135,7 @@ class MessageView(MasterView): sender = message.sender if sender is self.request.user: return 'you' - return six.text_type(sender) + return str(sender) def render_subject_bold(self, message, field): if not message.subject: @@ -164,8 +160,6 @@ class MessageView(MasterView): if not recipients: return "" - use_buefy = self.get_use_buefy() - # remove current user from displayed list, even if they're a recipient recips = [r for r in recipients if r.recipient is not self.request.user] @@ -182,23 +176,17 @@ class MessageView(MasterView): # client-side JS allowing the user to view all if they want max_display = 5 if len(recips) > max_display: - if use_buefy: - basic = HTML.tag('span', c="{}, ".format(', '.join(recips[:max_display-1]))) - more = tags.link_to("({} more)".format(len(recips[max_display-1:])), '#', **{ - 'v-show': '!showingAllRecipients', - '@click.prevent': 'showMoreRecipients()', - }) - everyone = HTML.tag('span', c=', '.join(recips[max_display-1:]), **{ - 'v-show': 'showingAllRecipients', - '@click': 'hideMoreRecipients()', - 'class_': 'everyone', - }) - return HTML.tag('div', c=[basic, more, everyone]) - else: - basic = HTML.literal("{}, ".format(', '.join(recips[:max_display-1]))) - more = tags.link_to("({} more)".format(len(recips[max_display-1:])), '#', class_='more') - everyone = HTML.tag('span', class_='everyone', c=', '.join(recips[max_display-1:])) - return basic + more + everyone + basic = HTML.tag('span', c="{}, ".format(', '.join(recips[:max_display-1]))) + more = tags.link_to("({} more)".format(len(recips[max_display-1:])), '#', **{ + 'v-show': '!showingAllRecipients', + '@click.prevent': 'showMoreRecipients()', + }) + everyone = HTML.tag('span', c=', '.join(recips[max_display-1:]), **{ + 'v-show': 'showingAllRecipients', + '@click': 'hideMoreRecipients()', + 'class_': 'everyone', + }) + return HTML.tag('div', c=[basic, more, everyone]) # show the full list if there are few enough recipients for that return ', '.join(recips) @@ -214,15 +202,9 @@ class MessageView(MasterView): def configure_form(self, f): super(MessageView, self).configure_form(f) - use_buefy = self.get_use_buefy() f.submit_label = "Send Message" - if not use_buefy: - # we have custom logic to disable submit button - f.auto_disable = False - f.auto_disable_save = False - # TODO: A fair amount of this still seems hacky... f.set_renderer('sender', self.render_sender) @@ -235,13 +217,12 @@ class MessageView(MasterView): f.set_label('recipients', "To") # subject - if use_buefy: - f.set_renderer('subject', self.render_subject_bold) - if self.creating: - f.set_widget('subject', dfwidget.TextInputWidget( - placeholder="please enter a subject", - autocomplete='off')) - f.set_required('subject') + f.set_renderer('subject', self.render_subject_bold) + if self.creating: + f.set_widget('subject', dfwidget.TextInputWidget( + placeholder="please enter a subject", + autocomplete='off')) + f.set_required('subject') # body f.set_widget('body', dfwidget.TextAreaWidget(cols=50, rows=15)) @@ -253,10 +234,7 @@ class MessageView(MasterView): f.insert_after('recipients', 'set_recipients') f.remove('recipients') f.set_node('set_recipients', colander.SchemaNode(colander.Set())) - if use_buefy: - f.set_widget('set_recipients', RecipientsWidgetBuefy()) - else: - f.set_widget('set_recipients', RecipientsWidget()) + f.set_widget('set_recipients', RecipientsWidgetBuefy()) f.set_label('set_recipients', "To") if self.replying: @@ -278,17 +256,11 @@ class MessageView(MasterView): value = [r[0] for r in value] if old_message.sender is not self.request.user and old_message.sender.active: value.insert(0, old_message.sender_uuid) - if use_buefy: - f.set_default('set_recipients', value) - else: - f.set_default('set_recipients', ','.join(value)) + f.set_default('set_recipients', value) # Just a normal reply, to sender only. elif self.filter_reply_recipient(old_message.sender): - if use_buefy: - f.set_default('set_recipients', [old_message.sender.uuid]) - else: - f.set_default('set_recipients', old_message.sender.uuid) + f.set_default('set_recipients', [old_message.sender.uuid]) # TODO? # # Set focus to message body instead of recipients, when replying. @@ -347,11 +319,9 @@ class MessageView(MasterView): return recipient def template_kwargs_create(self, **kwargs): - use_buefy = self.get_use_buefy() recips = self.get_available_recipients() - if use_buefy: - kwargs['recipient_display_map'] = recips + kwargs['recipient_display_map'] = recips recips = list(recips.items()) recips.sort(key=self.recipient_sortkey) kwargs['available_recipients'] = recips @@ -359,9 +329,8 @@ class MessageView(MasterView): if self.replying: kwargs['original_message'] = self.get_instance() - if use_buefy: - kwargs['index_url'] = None - kwargs['index_title'] = "New Message" + kwargs['index_url'] = None + kwargs['index_title'] = "New Message" return kwargs def recipient_sortkey(self, recip): @@ -538,20 +507,6 @@ class SentView(MessageView): default_active=True, default_verb='contains') -class RecipientsWidget(dfwidget.TextInputWidget): - - def deserialize(self, field, pstruct): - if pstruct is colander.null: - return [] - elif not isinstance(pstruct, six.string_types): - raise colander.Invalid(field.schema, "Pstruct is not a string") - if self.strip: - pstruct = pstruct.strip() - if not pstruct: - return [] - return pstruct.split(',') - - class RecipientsWidgetBuefy(dfwidget.Widget): """ Custom "message recipients" widget, for use with Buefy / Vue.js themes. @@ -561,7 +516,7 @@ class RecipientsWidgetBuefy(dfwidget.Widget): def deserialize(self, field, pstruct): if pstruct is colander.null: return colander.null - if not isinstance(pstruct, six.string_types): + if not isinstance(pstruct, str): raise colander.Invalid(field.schema, "Pstruct is not a string") if not pstruct: return colander.null diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 6ae184f9..bb43102e 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,12 +24,9 @@ Person Views """ -from __future__ import unicode_literals, absolute_import - import datetime import logging -import six import sqlalchemy as sa from sqlalchemy import orm @@ -165,13 +162,10 @@ class PersonView(MasterView): .filter(model.MergePeopleRequest.merged == None)\ .first() if merge_request: - use_buefy = self.get_use_buefy() - if use_buefy: - return HTML.tag('span', - class_='has-text-danger has-text-weight-bold', - title="A merge has been requested for this person.", - c="MR") - return "MR" + return HTML.tag('span', + class_='has-text-danger has-text-weight-bold', + title="A merge has been requested for this person.", + c="MR") def get_instance(self): # TODO: I don't recall why this fallback check for a vendor contact @@ -335,7 +329,7 @@ class PersonView(MasterView): employee = person.employee if not employee: return "" - text = six.text_type(employee) + text = str(employee) url = self.request.route_url('employees.view', uuid=employee.uuid) return tags.link_to(text, url) @@ -346,7 +340,7 @@ class PersonView(MasterView): items = [] for customer in customers: customer = customer.customer - text = six.text_type(customer) + text = str(customer) if customer.number: text = "(#{}) {}".format(customer.number, text) elif customer.id: @@ -361,7 +355,7 @@ class PersonView(MasterView): return "" items = [] for member in members: - text = six.text_type(member) + text = str(member) if member.number: text = "(#{}) {}".format(member.number, text) elif member.id: @@ -371,7 +365,6 @@ class PersonView(MasterView): return HTML.tag('ul', c=items) def render_users(self, person, field): - use_buefy = self.get_use_buefy() users = person.users items = [] for user in users: @@ -381,11 +374,8 @@ class PersonView(MasterView): if items: return HTML.tag('ul', c=items) elif self.viewing and self.request.has_perm('users.create'): - if use_buefy: - return HTML.tag('b-button', type='is-primary', c="Make User", - **{'@click': 'clickMakeUser()'}) - else: - return HTML.tag('button', type='button', id='make-user', c="Make User") + return HTML.tag('b-button', type='is-primary', c="Make User", + **{'@click': 'clickMakeUser()'}) else: return "" @@ -428,8 +418,7 @@ class PersonView(MasterView): 'dynamic_content_title': self.get_context_content_title(person), } - use_buefy = self.get_use_buefy() - template = 'view_profile_buefy' if use_buefy else 'view_profile' + template = 'view_profile_buefy' return self.render_to_response(template, context) def get_customer_xref_buttons(self, person): @@ -524,7 +513,7 @@ class PersonView(MasterView): return context def get_context_content_title(self, person): - return six.text_type(person) + return str(person) def get_context_address(self, address): context = { @@ -534,7 +523,7 @@ class PersonView(MasterView): 'city': address.city, 'state': address.state, 'zipcode': address.zipcode, - 'display': six.text_type(address), + 'display': str(address), } model = self.model @@ -587,12 +576,12 @@ class PersonView(MasterView): 'number': member.number, 'id': member.id, 'active': member.active, - 'joined': six.text_type(member.joined) if member.joined else None, - 'withdrew': six.text_type(member.withdrew) if member.withdrew else None, + 'joined': str(member.joined) if member.joined else None, + 'withdrew': str(member.withdrew) if member.withdrew else None, 'customer_uuid': member.customer_uuid, 'customer_name': member.customer.name if member.customer else None, 'person_uuid': member.person_uuid, - 'display': six.text_type(member), + 'display': str(member), 'person_display_name': member.person.display_name if member.person else None, 'view_url': self.request.route_url('members.view', uuid=member.uuid), 'view_profile_url': profile_url, @@ -614,8 +603,8 @@ class PersonView(MasterView): for history in employee.sorted_history(reverse=True): data.append({ 'uuid': history.uuid, - 'start_date': six.text_type(history.start_date), - 'end_date': six.text_type(history.end_date or ''), + 'start_date': str(history.start_date), + 'end_date': str(history.end_date or ''), }) return data @@ -911,7 +900,7 @@ class PersonView(MasterView): 'success': True, 'employee': self.get_context_employee(employee), 'employee_view_url': self.request.route_url('employees.view', uuid=employee.uuid), - 'start_date': six.text_type(start_date), + 'start_date': str(start_date), 'employee_history_data': self.get_context_employee_history(employee), 'dynamic_content_title': self.get_context_content_title(person), } @@ -942,7 +931,7 @@ class PersonView(MasterView): 'success': True, 'employee': self.get_context_employee(employee), 'employee_view_url': self.request.route_url('employees.view', uuid=employee.uuid), - 'end_date': six.text_type(end_date), + 'end_date': str(end_date), 'employee_history_data': self.get_context_employee_history(employee), 'dynamic_content_title': self.get_context_content_title(person), } @@ -975,8 +964,8 @@ class PersonView(MasterView): return { 'success': True, 'employee': self.get_context_employee(employee), - 'start_date': six.text_type(current_history.start_date), - 'end_date': six.text_type(current_history.end_date or ''), + 'start_date': str(current_history.start_date), + 'end_date': str(current_history.end_date or ''), 'employee_history_data': self.get_context_employee_history(employee), } @@ -1438,7 +1427,7 @@ class MergePeopleRequestView(MasterView): uuid = getattr(merge_request, field) person = self.Session.query(self.model.Person).get(uuid) if person: - return six.text_type(person) + return str(person) return "(person not found)" def get_instance_title(self, merge_request): @@ -1459,7 +1448,7 @@ class MergePeopleRequestView(MasterView): uuid = getattr(merge_request, field) person = self.Session.query(self.model.Person).get(uuid) if person: - text = six.text_type(person) + text = str(person) url = self.request.route_url('people.view', uuid=person.uuid) return tags.link_to(text, url) return "(person not found)" diff --git a/tailbone/views/principal.py b/tailbone/views/principal.py index 0012adc8..04fe97ad 100644 --- a/tailbone/views/principal.py +++ b/tailbone/views/principal.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2021 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,6 @@ "Principal" master view """ -from __future__ import unicode_literals, absolute_import - import copy from rattail.core import Object @@ -85,12 +83,11 @@ class PrincipalMasterView(MasterView): context = {'form': form, 'permissions': sorted_perms, 'principals': principals} - if self.get_use_buefy(): - perms = self.get_buefy_perms_data(sorted_perms) - context['buefy_perms'] = perms - context['buefy_sorted_groups'] = list(perms) - context['selected_group'] = self.request.POST.get('permission_group', 'common') - context['selected_permission'] = self.request.POST.get('permission', None) + perms = self.get_buefy_perms_data(sorted_perms) + context['buefy_perms'] = perms + context['buefy_sorted_groups'] = list(perms) + context['selected_group'] = self.request.POST.get('permission_group', 'common') + context['selected_permission'] = self.request.POST.get('permission', None) return self.render_to_response('find_by_perm', context) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 9a143dc7..072097ec 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -24,12 +24,9 @@ Product Views """ -from __future__ import unicode_literals, absolute_import - import re import logging -import six import humanize import sqlalchemy as sa from sqlalchemy import orm @@ -212,7 +209,6 @@ class ProductView(MasterView): super(ProductView, self).configure_grid(g) app = self.get_rattail_app() model = self.model - use_buefy = self.get_use_buefy() def join_vendor(q): return q.outerjoin(self.ProductVendorCost, @@ -257,9 +253,6 @@ class ProductView(MasterView): departments = self.get_departments() department_choices = OrderedDict([('', "(any)")] + [(d.uuid, d.name) for d in departments]) - if not use_buefy: - department_choices = [tags.Option(name, uuid) - for uuid, name in six.iteritems(department_choices)] g.set_filter('department', model.Department.uuid, value_enum=department_choices, verbs=['equal', 'not_equal', 'is_null', 'is_not_null', 'is_any'], @@ -378,12 +371,9 @@ class ProductView(MasterView): g.set_filter('report_code_name', model.ReportCode.name) if self.expose_label_printing and self.has_perm('print_labels'): - if use_buefy: - g.more_actions.append(self.make_action( - 'print_label', icon='print', url='#', - click_handler='quickLabelPrint(props.row)')) - else: - g.more_actions.append(grids.GridAction('print_label', icon='print')) + g.more_actions.append(self.make_action( + 'print_label', icon='print', url='#', + click_handler='quickLabelPrint(props.row)')) g.set_renderer('regular_price', self.render_price) g.set_renderer('on_hand', self.render_on_hand) @@ -572,10 +562,7 @@ class ProductView(MasterView): if not self.has_perm('versions'): return text - if self.get_use_buefy(): - kwargs = {'@click.prevent': 'showPriceHistory_{}()'.format(typ)} - else: - kwargs = {'id': 'view-{}-price-history'.format(typ)} + kwargs = {'@click.prevent': 'showPriceHistory_{}()'.format(typ)} history = tags.link_to("(view history)", '#', **kwargs) if not text: return history @@ -815,7 +802,7 @@ class ProductView(MasterView): if 'upc' in data: if isinstance(data['upc'], GPC): - data['upc'] = six.text_type(data['upc']) + data['upc'] = str(data['upc']) return data @@ -971,9 +958,9 @@ class ProductView(MasterView): if self.request.POST.get('brand_uuid'): brand = self.Session.query(model.Brand).get(self.request.POST['brand_uuid']) if brand: - brand_display = six.text_type(brand) + brand_display = str(brand) elif self.editing: - brand_display = six.text_type(product.brand or '') + brand_display = str(product.brand or '') brands_url = self.request.route_url('brands.autocomplete') f.set_widget('brand_uuid', forms.widgets.JQueryAutocompleteWidget( field_display=brand_display, service_url=brands_url)) @@ -1139,11 +1126,11 @@ class ProductView(MasterView): history['price'] = float(price) history['price_display'] = app.render_currency(price) changed = localtime(self.rattail_config, history['changed'], from_utc=True) - history['changed'] = six.text_type(changed) + history['changed'] = str(changed) history['changed_display_html'] = raw_datetime(self.rattail_config, changed) user = history.pop('changed_by') history['changed_by_uuid'] = user.uuid if user else None - history['changed_by_display'] = six.text_type(user or "??") + history['changed_by_display'] = str(user or "??") jsdata.append(history) return jsdata @@ -1165,18 +1152,17 @@ class ProductView(MasterView): else: history['cost_display'] = None changed = localtime(self.rattail_config, history['changed'], from_utc=True) - history['changed'] = six.text_type(changed) + history['changed'] = str(changed) history['changed_display_html'] = raw_datetime(self.rattail_config, changed) user = history.pop('changed_by') history['changed_by_uuid'] = user.uuid - history['changed_by_display'] = six.text_type(user) + history['changed_by_display'] = str(user) jsdata.append(history) return jsdata def template_kwargs_view(self, **kwargs): kwargs = super(ProductView, self).template_kwargs_view(**kwargs) product = kwargs['instance'] - use_buefy = self.get_use_buefy() kwargs['image_url'] = self.products_handler.get_image_url(product) @@ -1184,10 +1170,7 @@ class ProductView(MasterView): if self.rattail_config.versioning_enabled() and self.has_perm('versions'): # regular price - if use_buefy: - data = [] # defer fetching until user asks for it - else: - data = self.get_regular_price_history(product) + data = [] # defer fetching until user asks for it grid = grids.Grid('products.regular_price_history', data, request=self.request, columns=[ @@ -1201,10 +1184,7 @@ class ProductView(MasterView): kwargs['regular_price_history_grid'] = grid # current price - if use_buefy: - data = [] # defer fetching until user asks for it - else: - data = self.get_current_price_history(product) + data = [] # defer fetching until user asks for it grid = grids.Grid('products.current_price_history', data, request=self.request, columns=[ @@ -1222,10 +1202,7 @@ class ProductView(MasterView): kwargs['current_price_history_grid'] = grid # suggested price - if use_buefy: - data = [] # defer fetching until user asks for it - else: - data = self.get_suggested_price_history(product) + data = [] # defer fetching until user asks for it grid = grids.Grid('products.suggested_price_history', data, request=self.request, columns=[ @@ -1239,10 +1216,7 @@ class ProductView(MasterView): kwargs['suggested_price_history_grid'] = grid # cost history - if use_buefy: - data = [] # defer fetching until user asks for it - else: - data = self.get_cost_history(product) + data = [] # defer fetching until user asks for it grid = grids.Grid('products.cost_history', data, request=self.request, columns=[ @@ -1264,9 +1238,8 @@ class ProductView(MasterView): kwargs['costs_label_code'] = "Order Code" kwargs['costs_label_case_size'] = "Case Size" - if use_buefy: - kwargs['vendor_sources'] = self.get_context_vendor_sources(product) - kwargs['lookup_codes'] = self.get_context_lookup_codes(product) + kwargs['vendor_sources'] = self.get_context_vendor_sources(product) + kwargs['lookup_codes'] = self.get_context_lookup_codes(product) kwargs['panel_fields'] = self.get_panel_fields(product) @@ -1362,7 +1335,7 @@ class ProductView(MasterView): source['case_size'] = app.render_quantity(cost.case_size) source['case_cost'] = app.render_currency(cost.case_cost) - text = six.text_type(cost.vendor) + text = str(cost.vendor) if link_vendor: url = self.request.route_url('vendors.view', uuid=cost.vendor.uuid) source['vendor'] = tags.link_to(text, url) @@ -1768,8 +1741,6 @@ class ProductView(MasterView): # TODO: how to properly detect image type? # content_type = 'image/png' content_type = 'image/jpeg' - if not six.PY3: - content_type = six.binary_type(content_type) self.request.response.content_type = content_type self.request.response.body = product.image.bytes return self.request.response @@ -1802,7 +1773,7 @@ class ProductView(MasterView): printer.print_labels([({'product': product}, quantity)]) except Exception as error: log.warning("error occurred while printing labels", exc_info=True) - return {'error': six.text_type(error)} + return {'error': str(error)} return {'ok': True} def search(self): @@ -1923,7 +1894,7 @@ class ProductView(MasterView): if product and (not product.deleted or self.request.has_perm('products.view_deleted')): data = { 'uuid': product.uuid, - 'upc': six.text_type(product.upc), + 'upc': str(product.upc), 'upc_pretty': product.upc.pretty(), 'full_description': product.full_description, 'image_url': pod.get_image_url(self.rattail_config, product.upc), @@ -2282,7 +2253,7 @@ class PendingProductView(MasterView): g.set_enum('status_code', self.enum.PENDING_PRODUCT_STATUS) g.filters['status_code'].default_active = True g.filters['status_code'].default_verb = 'not_equal' - g.filters['status_code'].default_value = six.text_type(self.enum.PENDING_PRODUCT_STATUS_RESOLVED) + g.filters['status_code'].default_value = str(self.enum.PENDING_PRODUCT_STATUS_RESOLVED) g.set_sort_defaults('created', 'desc') @@ -2317,9 +2288,9 @@ class PendingProductView(MasterView): if self.request.POST.get('brand_uuid'): brand = self.Session.query(model.Brand).get(self.request.POST['brand_uuid']) if brand: - brand_display = six.text_type(brand) + brand_display = str(brand) elif self.editing: - brand_display = six.text_type(pending.brand or '') + brand_display = str(pending.brand or '') brands_url = self.request.route_url('brands.autocomplete') f.set_widget('brand_uuid', forms.widgets.JQueryAutocompleteWidget( field_display=brand_display, service_url=brands_url)) @@ -2344,7 +2315,7 @@ class PendingProductView(MasterView): if self.request.POST.get('vendor_uuid'): vendor = self.Session.query(model.Vendor).get(self.request.POST['vendor_uuid']) if vendor: - vendor_display = six.text_type(vendor) + vendor_display = str(vendor) f.set_widget('vendor_uuid', forms.widgets.JQueryAutocompleteWidget( field_display=vendor_display, service_url=self.request.route_url('vendors.autocomplete'))) diff --git a/tailbone/views/projects.py b/tailbone/views/projects.py index bc768d05..60b531c9 100644 --- a/tailbone/views/projects.py +++ b/tailbone/views/projects.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,6 @@ Project views """ -from __future__ import unicode_literals, absolute_import - import os import zipfile # from collections import OrderedDict @@ -160,7 +158,6 @@ class GenerateProjectView(View): return RattailProjectHandler(self.rattail_config) def __call__(self): - use_buefy = self.get_use_buefy() # choices = OrderedDict([ # ('has_db', {'prompt': "Does project need its own Rattail DB?", @@ -183,8 +180,7 @@ class GenerateProjectView(View): schema = GenerateTailboneIntegrationProject else: schema = GenerateProject - form = forms.Form(schema=schema(), request=self.request, - use_buefy=use_buefy) + form = forms.Form(schema=schema(), request=self.request) if form.validate(newstyle=True): zipped = self.generate_project(project_type, form) return self.file_response(zipped) @@ -195,7 +191,6 @@ class GenerateProjectView(View): 'index_title': "Generate Project", 'handler': self.handler, # 'choices': choices, - 'use_buefy': use_buefy, } def generate_project(self, project_type, form): diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index ee460192..8cc14ef4 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Base class for purchasing batch views """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model, api import colander @@ -230,7 +226,6 @@ class PurchasingBatchView(BatchMasterView): batch = f.model_instance app = self.get_rattail_app() today = app.localtime().date() - use_buefy = self.get_use_buefy() # mode f.set_enum('mode', self.enum.PURCHASE_BATCH_MODE) @@ -278,7 +273,7 @@ class PurchasingBatchView(BatchMasterView): if self.request.POST.get('vendor_uuid'): vendor = self.Session.query(model.Vendor).get(self.request.POST['vendor_uuid']) if vendor: - vendor_display = six.text_type(vendor) + vendor_display = str(vendor) vendors_url = self.request.route_url('vendors.autocomplete') f.set_widget('vendor_uuid', forms.widgets.JQueryAutocompleteWidget( field_display=vendor_display, service_url=vendors_url)) @@ -311,14 +306,14 @@ class PurchasingBatchView(BatchMasterView): if self.request.POST.get('buyer_uuid'): buyer = self.Session.query(model.Employee).get(self.request.POST['buyer_uuid']) if buyer: - buyer_display = six.text_type(buyer) + buyer_display = str(buyer) elif self.creating: buyer = self.request.user.employee if buyer: - buyer_display = six.text_type(buyer) + buyer_display = str(buyer) f.set_default('buyer_uuid', buyer.uuid) elif self.editing: - buyer_display = six.text_type(batch.buyer or '') + buyer_display = str(batch.buyer or '') buyers_url = self.request.route_url('employees.autocomplete') f.set_widget('buyer_uuid', forms.widgets.JQueryAutocompleteWidget( field_display=buyer_display, service_url=buyers_url)) @@ -346,11 +341,7 @@ class PurchasingBatchView(BatchMasterView): if len(parsers) == 1: f.set_default('invoice_parser_key', parsers[0].key) - if use_buefy: - f.set_widget('invoice_parser_key', dfwidget.SelectWidget(values=parser_values)) - else: - parser_values.insert(0, ('', "(please choose)")) - f.set_widget('invoice_parser_key', forms.widgets.JQuerySelectWidget(values=parser_values)) + f.set_widget('invoice_parser_key', dfwidget.SelectWidget(values=parser_values)) else: f.remove_field('invoice_parser_key') @@ -422,7 +413,7 @@ class PurchasingBatchView(BatchMasterView): purchase = batch.purchase if not purchase: return "" - text = six.text_type(purchase) + text = str(purchase) url = self.request.route_url('purchases.view', uuid=purchase.uuid) return tags.link_to(text, url) @@ -435,7 +426,7 @@ class PurchasingBatchView(BatchMasterView): def render_vendor_contact(self, batch, field): if batch.vendor.contact: - return six.text_type(batch.vendor.contact) + return str(batch.vendor.contact) def render_vendor_phone(self, batch, field): return self.get_vendor_phone_number(batch) @@ -455,7 +446,7 @@ class PurchasingBatchView(BatchMasterView): employee = batch.buyer if not employee: return "" - text = six.text_type(employee) + text = str(employee) if self.request.has_perm('employees.view'): url = self.request.route_url('employees.view', uuid=employee.uuid) return tags.link_to(text, url) @@ -484,7 +475,7 @@ class PurchasingBatchView(BatchMasterView): def get_buyer_values(self): buyers = self.get_buyers() - return [(b.uuid, six.text_type(b)) + return [(b.uuid, str(b)) for b in buyers] def get_department_options(self): @@ -802,13 +793,12 @@ class PurchasingBatchView(BatchMasterView): return app.render_cases_units(cases, units) def make_row_credits_grid(self, row): - use_buefy = self.get_use_buefy() route_prefix = self.get_route_prefix() factory = self.get_grid_factory() g = factory( key='{}.row_credits'.format(route_prefix), - data=[] if use_buefy else row.credits, + data=[], columns=[ 'credit_type', 'shorted', @@ -837,17 +827,9 @@ class PurchasingBatchView(BatchMasterView): return g def render_row_credits(self, row, field): - use_buefy = self.get_use_buefy() - if not use_buefy and not row.credits: - return - g = self.make_row_credits_grid(row) - - if use_buefy: - return HTML.literal( - g.render_buefy_table_element(data_prop='rowData.credits')) - else: - return HTML.literal(g.render_grid()) + return HTML.literal( + g.render_buefy_table_element(data_prop='rowData.credits')) # def item_lookup(self, value, field=None): # """ diff --git a/tailbone/views/purchasing/costing.py b/tailbone/views/purchasing/costing.py index 2d62d6e1..45391fe0 100644 --- a/tailbone/views/purchasing/costing.py +++ b/tailbone/views/purchasing/costing.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Views for 'costing' (purchasing) batches """ -from __future__ import unicode_literals, absolute_import - -import six - import colander from deform import widget as dfwidget @@ -188,14 +184,12 @@ class CostingBatchView(PurchasingBatchView): # okay, at this point we need the user to select a vendor and workflow self.creating = True - use_buefy = self.get_use_buefy() model = self.model context = {} # form to accept user choice of vendor/workflow schema = NewCostingBatch().bind(valid_workflows=valid_workflows) - form = forms.Form(schema=schema, request=self.request, - use_buefy=use_buefy) + form = forms.Form(schema=schema, request=self.request) if len(valid_workflows) == 1: form.set_default('workflow', valid_workflows[0]) @@ -208,17 +202,14 @@ class CostingBatchView(PurchasingBatchView): .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)) + form.set_widget('vendor', dfwidget.SelectWidget(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) + vendor_display = str(vendor) vendors_url = self.request.route_url('vendors.autocomplete') form.set_widget('vendor', forms.widgets.JQueryAutocompleteWidget( field_display=vendor_display, service_url=vendors_url)) @@ -226,12 +217,8 @@ class CostingBatchView(PurchasingBatchView): # configure workflow field values = [(workflow['workflow_key'], workflow['display']) for workflow in workflows] - if use_buefy: - form.set_widget('workflow', - dfwidget.SelectWidget(values=values)) - else: - form.set_widget('workflow', - forms.widgets.JQuerySelectWidget(values=values)) + form.set_widget('workflow', + dfwidget.SelectWidget(values=values)) form.submit_label = "Continue" form.cancel_url = self.get_index_url() @@ -254,7 +241,6 @@ class CostingBatchView(PurchasingBatchView): def configure_form(self, f): super(CostingBatchView, self).configure_form(f) route_prefix = self.get_route_prefix() - use_buefy = self.get_use_buefy() model = self.model workflow = self.request.matchdict.get('workflow_key') @@ -306,15 +292,14 @@ class CostingBatchView(PurchasingBatchView): # purchase if (self.creating and workflow == 'invoice_with_po' and self.purchase_order_fieldname == 'purchase'): - if use_buefy: - f.replace('purchase', 'purchase_uuid') - purchases = self.handler.get_eligible_purchases( - vendor, self.enum.PURCHASE_BATCH_MODE_COSTING) - values = [(p.uuid, self.handler.render_eligible_purchase(p)) - for p in purchases] - f.set_widget('purchase_uuid', dfwidget.SelectWidget(values=values)) - f.set_label('purchase_uuid', "Purchase Order") - f.set_required('purchase_uuid') + f.replace('purchase', 'purchase_uuid') + purchases = self.handler.get_eligible_purchases( + vendor, self.enum.PURCHASE_BATCH_MODE_COSTING) + values = [(p.uuid, self.handler.render_eligible_purchase(p)) + for p in purchases] + f.set_widget('purchase_uuid', dfwidget.SelectWidget(values=values)) + f.set_label('purchase_uuid', "Purchase Order") + f.set_required('purchase_uuid') def render_costing_workflow(self, batch, field): key = self.request.matchdict['workflow_key'] diff --git a/tailbone/views/purchasing/ordering.py b/tailbone/views/purchasing/ordering.py index f4820783..a407d6ae 100644 --- a/tailbone/views/purchasing/ordering.py +++ b/tailbone/views/purchasing/ordering.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,12 +24,9 @@ Views for 'ordering' (purchasing) batches """ -from __future__ import unicode_literals, absolute_import - import os import json -import six import openpyxl from sqlalchemy import orm @@ -313,9 +310,7 @@ class OrderingBatchView(PurchasingBatchView): if not order_date: order_date = localtime(self.rattail_config).date() - buefy_data = None - if self.get_use_buefy(): - buefy_data = self.get_worksheet_buefy_data(departments) + buefy_data = self.get_worksheet_buefy_data(departments) return self.render_to_response('worksheet', { 'batch': batch, @@ -334,8 +329,8 @@ class OrderingBatchView(PurchasingBatchView): def get_worksheet_buefy_data(self, departments): data = {} - for department in six.itervalues(departments): - for subdepartment in six.itervalues(department._order_subdepartments): + for department in departments.values(): + for subdepartment in department._order_subdepartments.values(): for i, cost in enumerate(subdepartment._order_costs, 1): cases = int(cost._batchrow.cases_ordered or 0) if cost._batchrow else None units = int(cost._batchrow.units_ordered or 0) if cost._batchrow else None @@ -433,7 +428,7 @@ class OrderingBatchView(PurchasingBatchView): self.handler.update_row_quantity(row, cases_ordered=cases_ordered, units_ordered=units_ordered) except Exception as error: - return {'error': six.text_type(error)} + return {'error': str(error)} else: # empty order quantities @@ -469,7 +464,7 @@ class OrderingBatchView(PurchasingBatchView): worksheet.append([]) worksheet.append(['vendor_code', 'upc', 'brand_name', 'description', 'cases_ordered', 'units_ordered']) for row in batch.active_rows(): - worksheet.append([row.vendor_code, six.text_type(row.upc), row.brand_name, + worksheet.append([row.vendor_code, str(row.upc), row.brand_name, '{} {}'.format(row.description, row.size), row.cases_ordered, row.units_ordered]) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index cf1e802e..4efe494c 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -24,14 +24,11 @@ Views for 'receiving' (purchasing) batches """ -from __future__ import unicode_literals, absolute_import - import os import re import decimal import logging -import six import humanize import sqlalchemy as sa @@ -302,13 +299,11 @@ class ReceivingBatchView(PurchasingBatchView): # okay, at this point we need the user to select a vendor and workflow self.creating = True - use_buefy = self.get_use_buefy() context = {} # form to accept user choice of vendor/workflow schema = NewReceivingBatch().bind(valid_workflows=valid_workflows) - form = forms.Form(schema=schema, request=self.request, - use_buefy=use_buefy) + form = forms.Form(schema=schema, request=self.request) # configure vendor field app = self.get_rattail_app() @@ -325,10 +320,7 @@ class ReceivingBatchView(PurchasingBatchView): 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)) + form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values)) else: # user may choose *any* available vendor use_dropdown = vendor_handler.choice_uses_dropdown() @@ -338,10 +330,7 @@ class ReceivingBatchView(PurchasingBatchView): .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)) + form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values)) if len(vendors) == 1: form.set_default('vendor', vendors[0].uuid) else: @@ -350,7 +339,7 @@ class ReceivingBatchView(PurchasingBatchView): 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) + vendor_display = str(vendor) vendors_url = self.request.route_url('vendors.autocomplete') form.set_widget('vendor', forms.widgets.JQueryAutocompleteWidget( field_display=vendor_display, service_url=vendors_url)) @@ -359,12 +348,8 @@ class ReceivingBatchView(PurchasingBatchView): # configure workflow field values = [(workflow['workflow_key'], workflow['display']) for workflow in workflows] - if use_buefy: - form.set_widget('workflow', - dfwidget.SelectWidget(values=values)) - else: - form.set_widget('workflow', - forms.widgets.JQuerySelectWidget(values=values)) + form.set_widget('workflow', + dfwidget.SelectWidget(values=values)) if len(workflows) == 1: form.set_default('workflow', workflows[0]['workflow_key']) @@ -427,7 +412,6 @@ class ReceivingBatchView(PurchasingBatchView): allow_truck_dump = self.batch_handler.allow_truck_dump_receiving() workflow = self.request.matchdict.get('workflow_key') route_prefix = self.get_route_prefix() - use_buefy = self.get_use_buefy() # tweak some things if we are in "step 2" of creating new batch if self.creating and workflow: @@ -437,7 +421,7 @@ class ReceivingBatchView(PurchasingBatchView): self.request.matchdict['vendor_uuid']) assert vendor f.set_readonly('vendor_uuid') - f.set_default('vendor_uuid', six.text_type(vendor)) + f.set_default('vendor_uuid', str(vendor)) # cancel should take us back to choosing a workflow f.cancel_url = self.request.route_url('{}.create'.format(route_prefix)) @@ -532,15 +516,14 @@ class ReceivingBatchView(PurchasingBatchView): # purchase if (self.creating and workflow in ('from_po', 'from_po_with_invoice') and self.purchase_order_fieldname == 'purchase'): - if use_buefy: - f.replace('purchase', 'purchase_uuid') - purchases = self.batch_handler.get_eligible_purchases( - vendor, self.enum.PURCHASE_BATCH_MODE_RECEIVING) - values = [(p.uuid, self.batch_handler.render_eligible_purchase(p)) - for p in purchases] - f.set_widget('purchase_uuid', dfwidget.SelectWidget(values=values)) - f.set_label('purchase_uuid', "Purchase Order") - f.set_required('purchase_uuid') + f.replace('purchase', 'purchase_uuid') + purchases = self.batch_handler.get_eligible_purchases( + vendor, self.enum.PURCHASE_BATCH_MODE_RECEIVING) + values = [(p.uuid, self.batch_handler.render_eligible_purchase(p)) + for p in purchases] + f.set_widget('purchase_uuid', dfwidget.SelectWidget(values=values)) + f.set_label('purchase_uuid', "Purchase Order") + f.set_required('purchase_uuid') elif self.creating or not batch.purchase: f.remove_field('purchase') @@ -774,26 +757,18 @@ class ReceivingBatchView(PurchasingBatchView): def template_kwargs_view(self, **kwargs): kwargs = super(ReceivingBatchView, self).template_kwargs_view(**kwargs) batch = kwargs['instance'] - use_buefy = self.get_use_buefy() if self.handler.has_purchase_order(batch) and self.handler.has_invoice_file(batch): breakdown = self.make_po_vs_invoice_breakdown(batch) factory = self.get_grid_factory() - if use_buefy: - g = factory('batch_po_vs_invoice_breakdown', [], - columns=['title', 'count']) - g.set_click_handler('title', "autoFilterPoVsInvoice(props.row)") - kwargs['po_vs_invoice_breakdown_data'] = breakdown - kwargs['po_vs_invoice_breakdown_grid'] = HTML.literal( - g.render_buefy_table_element(data_prop='poVsInvoiceBreakdownData', - empty_labels=True)) - - else: - kwargs['po_vs_invoice_breakdown_grid'] = factory( - 'batch_po_vs_invoice_breakdown', - data=breakdown, - columns=['title', 'count']) + g = factory('batch_po_vs_invoice_breakdown', [], + columns=['title', 'count']) + g.set_click_handler('title', "autoFilterPoVsInvoice(props.row)") + kwargs['po_vs_invoice_breakdown_data'] = breakdown + kwargs['po_vs_invoice_breakdown_grid'] = HTML.literal( + g.render_buefy_table_element(data_prop='poVsInvoiceBreakdownData', + empty_labels=True)) kwargs['allow_edit_catalog_unit_cost'] = self.allow_edit_catalog_unit_cost(batch) kwargs['allow_edit_invoice_unit_cost'] = self.allow_edit_invoice_unit_cost(batch) @@ -807,7 +782,7 @@ class ReceivingBatchView(PurchasingBatchView): credits_data.append({ 'uuid': credit.uuid, 'credit_type': credit.credit_type, - 'expiration_date': six.text_type(credit.expiration_date) if credit.expiration_date else None, + 'expiration_date': str(credit.expiration_date) if credit.expiration_date else None, 'cases_shorted': app.render_quantity(credit.cases_shorted), 'units_shorted': app.render_quantity(credit.units_shorted), 'shorted': app.render_cases_units(credit.cases_shorted, @@ -822,7 +797,6 @@ class ReceivingBatchView(PurchasingBatchView): def template_kwargs_view_row(self, **kwargs): kwargs = super(ReceivingBatchView, self).template_kwargs_view_row(**kwargs) - use_buefy = self.get_use_buefy() app = self.get_rattail_app() products_handler = app.get_products_handler() row = kwargs['instance'] @@ -834,18 +808,17 @@ class ReceivingBatchView(PurchasingBatchView): elif row.upc: kwargs['image_url'] = products_handler.get_image_url(upc=row.upc) - if use_buefy: - kwargs['row_context'] = self.get_context_row(row) + kwargs['row_context'] = self.get_context_row(row) - 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 + 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 @@ -962,7 +935,7 @@ class ReceivingBatchView(PurchasingBatchView): def render_truck_dump_parent(self, batch, field): truck_dump = self.get_instance() - text = six.text_type(truck_dump) + text = str(truck_dump) url = self.request.route_url('receiving.view', uuid=truck_dump.uuid) return tags.link_to(text, url) @@ -992,7 +965,6 @@ 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 @@ -1003,13 +975,13 @@ 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): + elif 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)') # invoice_unit_cost - if use_buefy and self.allow_edit_invoice_unit_cost(batch): + if self.allow_edit_invoice_unit_cost(batch): g.set_raw_renderer('invoice_unit_cost', self.render_invoice_unit_cost) g.set_click_handler('invoice_unit_cost', 'invoiceUnitCostClicked(props.row)') @@ -1102,7 +1074,7 @@ class ReceivingBatchView(PurchasingBatchView): def get_row_instance_title(self, row): if row.product: - return six.text_type(row.product) + return str(row.product) if row.upc: return row.upc.pretty() return super(ReceivingBatchView, self).get_row_instance_title(row) @@ -1119,8 +1091,7 @@ class ReceivingBatchView(PurchasingBatchView): # first make grid like normal g = super(ReceivingBatchView, self).make_row_credits_grid(row) - if (self.get_use_buefy() - and self.has_perm('edit_row') + if (self.has_perm('edit_row') and self.row_editable(row)): # add the Un-Declare action @@ -1153,56 +1124,52 @@ class ReceivingBatchView(PurchasingBatchView): # simply invoke this method and return the result. however we're not # there yet...for now it's only tested for desktop self.viewing = True - use_buefy = self.get_use_buefy() row = self.get_row_instance() - # things are a bit different now w/ buefy support.. - if use_buefy: + # don't even bother showing this page if that's all the + # request was about + if self.request.method == 'GET': + return self.redirect(self.get_row_action_url('view', row)) - # don't even bother showing this page if that's all the - # request was about - if self.request.method == 'GET': - return self.redirect(self.get_row_action_url('view', row)) + # make sure edit is allowed + if not (self.has_perm('edit_row') and self.row_editable(row)): + raise self.forbidden() - # make sure edit is allowed - if not (self.has_perm('edit_row') and self.row_editable(row)): - raise self.forbidden() + # check for JSON POST, which is submitted via AJAX from + # the "view row" page + if self.request.method == 'POST' and not self.request.POST: + data = self.request.json_body + kwargs = dict(data) - # check for JSON POST, which is submitted via AJAX from - # the "view row" page - if self.request.method == 'POST' and not self.request.POST: - data = self.request.json_body - kwargs = dict(data) + # TODO: for some reason quantities can come through as strings? + cases = kwargs['quantity']['cases'] + if cases is not None: + if cases == '': + cases = None + else: + cases = decimal.Decimal(cases) + kwargs['cases'] = cases + units = kwargs['quantity']['units'] + if units is not None: + if units == '': + units = None + else: + units = decimal.Decimal(units) + kwargs['units'] = units + del kwargs['quantity'] - # TODO: for some reason quantities can come through as strings? - cases = kwargs['quantity']['cases'] - if cases is not None: - if cases == '': - cases = None - else: - cases = decimal.Decimal(cases) - kwargs['cases'] = cases - units = kwargs['quantity']['units'] - if units is not None: - if units == '': - units = None - else: - units = decimal.Decimal(units) - kwargs['units'] = units - del kwargs['quantity'] + # handler takes care of the receiving logic for us + try: + self.batch_handler.receive_row(row, **kwargs) - # handler takes care of the receiving logic for us - try: - self.batch_handler.receive_row(row, **kwargs) + except Exception as error: + return self.json_response({'error': str(error)}) - except Exception as error: - return self.json_response({'error': six.text_type(error)}) - - self.Session.flush() - self.Session.refresh(row) - return self.json_response({ - 'ok': True, - 'row': self.get_context_row(row)}) + self.Session.flush() + self.Session.refresh(row) + return self.json_response({ + 'ok': True, + 'row': self.get_context_row(row)}) batch = row.batch permission_prefix = self.get_permission_prefix() @@ -1226,15 +1193,12 @@ class ReceivingBatchView(PurchasingBatchView): } schema = ReceiveRowForm().bind(session=self.Session()) - form = forms.Form(schema=schema, request=self.request, use_buefy=use_buefy) + form = forms.Form(schema=schema, request=self.request) form.cancel_url = self.get_row_action_url('view', row) # mode mode_values = [(mode, mode) for mode in possible_modes] - if use_buefy: - mode_widget = dfwidget.SelectWidget(values=mode_values) - else: - mode_widget = forms.widgets.JQuerySelectWidget(values=mode_values) + mode_widget = dfwidget.SelectWidget(values=mode_values) form.set_widget('mode', mode_widget) # quantity @@ -1354,59 +1318,55 @@ class ReceivingBatchView(PurchasingBatchView): View for declaring a credit, i.e. converting some "received" or similar quantity, to a credit of some sort. """ - use_buefy = self.get_use_buefy() row = self.get_row_instance() - # things are a bit different now w/ buefy support.. - if use_buefy: + # don't even bother showing this page if that's all the + # request was about + if self.request.method == 'GET': + return self.redirect(self.get_row_action_url('view', row)) - # don't even bother showing this page if that's all the - # request was about - if self.request.method == 'GET': - return self.redirect(self.get_row_action_url('view', row)) + # make sure edit is allowed + if not (self.has_perm('edit_row') and self.row_editable(row)): + raise self.forbidden() - # make sure edit is allowed - if not (self.has_perm('edit_row') and self.row_editable(row)): - raise self.forbidden() + # check for JSON POST, which is submitted via AJAX from + # the "view row" page + if self.request.method == 'POST' and not self.request.POST: + data = self.request.json_body + kwargs = dict(data) - # check for JSON POST, which is submitted via AJAX from - # the "view row" page - if self.request.method == 'POST' and not self.request.POST: - data = self.request.json_body - kwargs = dict(data) + # TODO: for some reason quantities can come through as strings? + if kwargs['cases'] is not None: + if kwargs['cases'] == '': + kwargs['cases'] = None + else: + kwargs['cases'] = decimal.Decimal(kwargs['cases']) + if kwargs['units'] is not None: + if kwargs['units'] == '': + kwargs['units'] = None + else: + kwargs['units'] = decimal.Decimal(kwargs['units']) - # TODO: for some reason quantities can come through as strings? - if kwargs['cases'] is not None: - if kwargs['cases'] == '': - kwargs['cases'] = None - else: - kwargs['cases'] = decimal.Decimal(kwargs['cases']) - if kwargs['units'] is not None: - if kwargs['units'] == '': - kwargs['units'] = None - else: - kwargs['units'] = decimal.Decimal(kwargs['units']) + try: + result = self.handler.can_declare_credit(row, **kwargs) - try: - result = self.handler.can_declare_credit(row, **kwargs) + except Exception as error: + return self.json_response({'error': str(error)}) - except Exception as error: - return self.json_response({'error': six.text_type(error)}) + else: + if result: + self.handler.declare_credit(row, **kwargs) else: - if result: - self.handler.declare_credit(row, **kwargs) + return self.json_response({ + 'error': "Handler says you can't declare that credit; " + "not sure why"}) - else: - return self.json_response({ - 'error': "Handler says you can't declare that credit; " - "not sure why"}) - - self.Session.flush() - self.Session.refresh(row) - return self.json_response({ - 'ok': True, - 'row': self.get_context_row(row)}) + self.Session.flush() + self.Session.refresh(row) + return self.json_response({ + 'ok': True, + 'row': self.get_context_row(row)}) batch = row.batch context = { @@ -1422,16 +1382,12 @@ class ReceivingBatchView(PurchasingBatchView): } schema = DeclareCreditForm() - form = forms.Form(schema=schema, request=self.request, - use_buefy=use_buefy) + form = forms.Form(schema=schema, request=self.request) form.cancel_url = self.get_row_action_url('view', row) # credit_type values = [(m, m) for m in POSSIBLE_CREDIT_TYPES] - if use_buefy: - widget = dfwidget.SelectWidget(values=values) - else: - widget = forms.widgets.JQuerySelectWidget(values=values) + widget = dfwidget.SelectWidget(values=values) form.set_widget('credit_type', widget) # quantity @@ -1896,7 +1852,7 @@ class ReceivingBatchView(PurchasingBatchView): if cost == '': return {'error': "You must specify a cost"} try: - cost = decimal.Decimal(six.text_type(cost)) + cost = decimal.Decimal(str(cost)) except decimal.InvalidOperation: return {'error': "Cost is not valid!"} else: diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index 0054569d..7664331c 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -24,16 +24,12 @@ Reporting views """ -from __future__ import unicode_literals, absolute_import - import calendar import json import re import datetime import logging -import six - import rattail from rattail.db import model, Session as RattailSession from rattail.files import resource_path @@ -64,13 +60,13 @@ def get_upc(product): UPC formatter. Strips PLUs to bare number, and adds "minus check digit" for non-PLU UPCs. """ - upc = six.text_type(product.upc) + upc = str(product.upc) m = plu_upc_pattern.match(upc) if m: - return six.text_type(int(m.group(1))) + return str(int(m.group(1))) m = weighted_upc_pattern.match(upc) if m: - return six.text_type(int(m.group(1))) + return str(int(m.group(1))) return '{0}-{1}'.format(upc[:-1], upc[-1]) @@ -101,7 +97,7 @@ class OrderingWorksheet(View): response.headers['Content-Disposition'] = 'attachment; filename=ordering.html' response.text = body return response - return {'use_buefy': self.get_use_buefy()} + return {} def write_report(self, vendor, departments, preferred_only): """ @@ -174,8 +170,7 @@ class InventoryWorksheet(View): departments = departments.order_by(model.Department.name) departments = departments.all() - return{'departments': departments, - 'use_buefy': self.get_use_buefy()} + return{'departments': departments} def write_report(self, department): """ @@ -313,11 +308,8 @@ class ReportOutputView(ExportMasterView): columns=['key', 'value'], labels={'key': "Name"}, ) - if self.get_use_buefy(): - return HTML.literal( - g.render_buefy_table_element(data_prop='paramsData')) - else: - return HTML.literal(g.render_grid()) + return HTML.literal( + g.render_buefy_table_element(data_prop='paramsData')) def get_params_context(self, report): params_data = [] @@ -332,8 +324,7 @@ class ReportOutputView(ExportMasterView): kwargs = super(ReportOutputView, self).template_kwargs_view(**kwargs) output = kwargs['instance'] - if self.get_use_buefy(): - kwargs['params_data'] = self.get_params_context(output) + kwargs['params_data'] = self.get_params_context(output) # build custom URL to re-build this report url = None @@ -348,9 +339,8 @@ class ReportOutputView(ExportMasterView): def template_kwargs_delete(self, **kwargs): kwargs = super(ReportOutputView, self).template_kwargs_delete(**kwargs) - if self.get_use_buefy(): - report = kwargs['instance'] - kwargs['params_data'] = self.get_params_context(report) + report = kwargs['instance'] + kwargs['params_data'] = self.get_params_context(report) return kwargs @@ -359,8 +349,6 @@ class ReportOutputView(ExportMasterView): View which allows user to choose which type of report they wish to generate. """ - use_buefy = self.get_use_buefy() - # handler is responsible for determining which report types are valid reports = self.report_handler.get_reports() if isinstance(reports, OrderedDict): @@ -370,7 +358,7 @@ class ReportOutputView(ExportMasterView): # make form to accept user choice of report type schema = NewReport().bind(valid_report_types=sorted_reports) - form = forms.Form(schema=schema, request=self.request, use_buefy=use_buefy) + form = forms.Form(schema=schema, request=self.request) form.submit_label = "Continue" form.cancel_url = self.request.route_url('report_output') @@ -378,11 +366,8 @@ class ReportOutputView(ExportMasterView): # e.g. some for customers/membership, others for product movement etc. values = [(r.type_key, r.name) for r in reports.values()] values.sort(key=lambda r: r[1]) - if use_buefy: - form.set_widget('report_type', forms.widgets.CustomSelectWidget(values=values)) - form.widgets['report_type'].set_template_values(input_handler='reportTypeChanged') - else: - form.set_widget('report_type', forms.widgets.PlainSelectWidget(values=values, size=10)) + form.set_widget('report_type', forms.widgets.CustomSelectWidget(values=values)) + form.widgets['report_type'].set_template_values(input_handler='reportTypeChanged') # if form validates, that means user has chosen a report type, so we # just redirect to the appropriate "new report" page @@ -409,7 +394,6 @@ class ReportOutputView(ExportMasterView): and redirects user to view the output. """ app = self.get_rattail_app() - use_buefy = self.get_use_buefy() type_key = self.request.matchdict['type_key'] report = self.report_handler.get_report(type_key) if not report: @@ -449,8 +433,7 @@ class ReportOutputView(ExportMasterView): schema.add(node) - form = forms.Form(schema=schema, request=self.request, - use_buefy=use_buefy, helptext=helptext) + form = forms.Form(schema=schema, request=self.request, helptext=helptext) form.submit_label = "Generate this Report" form.cancel_url = self.request.get_referrer( default=self.request.route_url('{}.create'.format(route_prefix))) @@ -637,8 +620,8 @@ class ProblemReportView(MasterView): reports = self.handler.get_all_problem_reports() organized = self.handler.organize_problem_reports(reports) - for system_key, reports in six.iteritems(organized): - for report in six.itervalues(reports): + for system_key, reports in organized.items(): + for report in reports.values(): data.append(self.normalize(report)) return data @@ -741,12 +724,12 @@ class ProblemReportView(MasterView): report['problem_key']) app.save_setting(session, 'rattail.problems.{}.enabled'.format(key), - six.text_type(data['enabled']).lower()) + str(data['enabled']).lower()) for i in range(7): daykey = 'day{}'.format(i) app.save_setting(session, 'rattail.problems.{}.{}'.format(key, daykey), - six.text_type(data['days'][daykey]).lower()) + str(data['days'][daykey]).lower()) def execute_instance(self, report_info, user, progress=None, **kwargs): report = report_info['_report'] diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py index 29bb2ef4..2be47415 100644 --- a/tailbone/views/roles.py +++ b/tailbone/views/roles.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,11 +24,8 @@ Role Views """ -from __future__ import unicode_literals, absolute_import - import os -import six from sqlalchemy import orm from openpyxl.styles import Font, PatternFill @@ -172,7 +169,6 @@ class RoleView(PrincipalMasterView): def configure_form(self, f): super(RoleView, self).configure_form(f) role = f.model_instance - use_buefy = self.get_use_buefy() app = self.get_rattail_app() auth = app.get_auth_handler() @@ -213,7 +209,7 @@ class RoleView(PrincipalMasterView): f.set_type('notes', 'text_wrapped') # users - if use_buefy and self.viewing: + if self.viewing: f.set_renderer('users', self.render_users) else: f.remove('users') @@ -224,8 +220,7 @@ class RoleView(PrincipalMasterView): permissions=self.tailbone_permissions)) f.set_node('permissions', colander.Set()) f.set_widget('permissions', PermissionsWidget( - permissions=self.tailbone_permissions, - use_buefy=use_buefy)) + permissions=self.tailbone_permissions)) if self.editing: granted = [] for groupkey in self.tailbone_permissions: @@ -298,8 +293,8 @@ class RoleView(PrincipalMasterView): # TODO: it seems a bit ugly, to "rebuild" permission groups like this, # but not sure if there's a better way? available = {} - for gkey, group in six.iteritems(permissions): - for pkey, perm in six.iteritems(group['perms']): + for gkey, group in permissions.items(): + for pkey, perm in group['perms'].items(): if self.request.has_perm(pkey): if gkey not in available: available[gkey] = { @@ -315,7 +310,7 @@ class RoleView(PrincipalMasterView): return "(not applicable)" if role.session_timeout is None: return "" - return six.text_type(role.session_timeout) + return str(role.session_timeout) def objectify(self, form, data=None): """ @@ -342,8 +337,8 @@ class RoleView(PrincipalMasterView): auth = app.get_auth_handler() available = self.tailbone_permissions - for gkey, group in six.iteritems(available): - for pkey, perm in six.iteritems(group['perms']): + for gkey, group in available.items(): + for pkey, perm in group['perms'].items(): if pkey in permissions: auth.grant_permission(role, pkey) else: @@ -367,23 +362,21 @@ class RoleView(PrincipalMasterView): kwargs['guest_role'] = guest_role(self.Session()) kwargs['authenticated_role'] = authenticated_role(self.Session()) - use_buefy = self.get_use_buefy() - if use_buefy: - role = kwargs['instance'] - if role not in (kwargs['guest_role'], kwargs['authenticated_role']): - users_data = [] - for user in role.users: - users_data.append({ - 'uuid': user.uuid, - 'full_name': user.display_name, - 'username': user.username, - 'active': "Yes" if user.active else "No", - '_action_url_view': self.request.route_url('users.view', - uuid=user.uuid), - '_action_url_edit': self.request.route_url('users.edit', - uuid=user.uuid), - }) - kwargs['users_data'] = users_data + role = kwargs['instance'] + if role not in (kwargs['guest_role'], kwargs['authenticated_role']): + users_data = [] + for user in role.users: + users_data.append({ + 'uuid': user.uuid, + 'full_name': user.display_name, + 'username': user.username, + 'active': "Yes" if user.active else "No", + '_action_url_view': self.request.route_url('users.view', + uuid=user.uuid), + '_action_url_edit': self.request.route_url('users.edit', + uuid=user.uuid), + }) + kwargs['users_data'] = users_data return kwargs diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index 190b8b78..f1d26846 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -24,22 +24,18 @@ Settings Views """ -from __future__ import unicode_literals, absolute_import - import os import re import subprocess import sys import json -import six from rattail.db import model from rattail.settings import Setting from rattail.util import import_module_path, OrderedDict import colander -from webhelpers2.html import tags from tailbone import forms from tailbone.db import Session @@ -312,25 +308,18 @@ class AppSettingsView(View): option['url'] = self.request.route_url(option['route']) config_options.append(option) - use_buefy = self.get_use_buefy() context = { 'index_title': "App Settings", 'form': form, 'dform': form.make_deform_form(), 'groups': groups, 'settings': settings, - 'use_buefy': use_buefy, 'config_options': config_options, } - if use_buefy: - context['buefy_data'] = self.get_buefy_data(form, groups, settings) - # TODO: this seems hacky, and probably only needed if theme changes? - if current_group == '(All)': - current_group = '' - else: - group_options = [tags.Option(group, group) for group in groups] - group_options.insert(0, tags.Option("(All)", "(All)")) - context['group_options'] = group_options + context['buefy_data'] = self.get_buefy_data(form, groups, settings) + # TODO: this seems hacky, and probably only needed if theme changes? + if current_group == '(All)': + current_group = '' context['current_group'] = current_group return context @@ -457,7 +446,7 @@ class AppSettingsView(View): for entry in value.split('\n')] value = ', '.join(entries) else: - value = six.text_type(value) + value = str(value) app = self.get_rattail_app() app.save_setting(Session(), legacy_name, value) diff --git a/tailbone/views/tempmon/core.py b/tailbone/views/tempmon/core.py index 3f16860d..3f4df128 100644 --- a/tailbone/views/tempmon/core.py +++ b/tailbone/views/tempmon/core.py @@ -24,8 +24,6 @@ Common stuff for tempmon views """ -from __future__ import unicode_literals, absolute_import - from webhelpers2.html import HTML from tailbone import views, grids @@ -72,50 +70,15 @@ class MasterView(views.MasterView): return "" route_prefix = self.get_route_prefix() - use_buefy = self.get_use_buefy() - if use_buefy: - actions = [self.make_grid_action_view()] - if self.request.has_perm('tempmon.probes.edit'): - actions.append(self.make_grid_action_edit()) - - factory = self.get_grid_factory() - g = factory( - key='{}.probes'.format(route_prefix), - data=[], - columns=[ - 'description', - 'critical_temp_min', - 'good_temp_min', - 'good_temp_max', - 'critical_temp_max', - 'status', - 'enabled', - ], - labels={ - 'critical_temp_min': "Crit. Min", - 'good_temp_min': "Good Min", - 'good_temp_max': "Good Max", - 'critical_temp_max': "Crit. Max", - }, - linked_columns=['description'], - main_actions=actions, - ) - return HTML.literal( - g.render_buefy_table_element(data_prop='probesData')) - - # not buefy! - view_url = lambda p, i: self.request.route_url('tempmon.probes.view', uuid=p.uuid) - actions = [ - grids.GridAction('view', icon='zoomin', url=view_url), - ] + actions = [self.make_grid_action_view()] if self.request.has_perm('tempmon.probes.edit'): - url = lambda p, i: self.request.route_url('tempmon.probes.edit', uuid=p.uuid) - actions.append(grids.GridAction('edit', icon='pencil', url=url)) + actions.append(self.make_grid_action_edit()) - g = grids.Grid( + factory = self.get_grid_factory() + g = factory( key='{}.probes'.format(route_prefix), - data=obj.probes, + data=[], columns=[ 'description', 'critical_temp_min', @@ -131,10 +94,8 @@ class MasterView(views.MasterView): 'good_temp_max': "Good Max", 'critical_temp_max': "Crit. Max", }, - url=lambda p: self.request.route_url('tempmon.probes.view', uuid=p.uuid), linked_columns=['description'], main_actions=actions, ) - g.set_enum('status', self.enum.TEMPMON_PROBE_STATUS) - g.set_type('enabled', 'boolean') - return HTML.literal(g.render_grid()) + return HTML.literal( + g.render_buefy_table_element(data_prop='probesData')) diff --git a/tailbone/views/tempmon/dashboard.py b/tailbone/views/tempmon/dashboard.py index c2b925a8..1cf40617 100644 --- a/tailbone/views/tempmon/dashboard.py +++ b/tailbone/views/tempmon/dashboard.py @@ -24,15 +24,11 @@ Tempmon "Dashboard" View """ -from __future__ import unicode_literals, absolute_import - import datetime from rattail.time import localtime, make_utc from rattail_tempmon.db import model as tempmon -from webhelpers2.html import tags - from tailbone.views import View from tailbone.db import TempmonSession @@ -44,7 +40,6 @@ class TempmonDashboardView(View): session_key = 'tempmon.dashboard.appliance_uuid' def dashboard(self): - use_buefy = self.get_use_buefy() if self.request.method == 'POST': appliance = None @@ -77,7 +72,6 @@ class TempmonDashboardView(View): context = { 'index_url': self.request.route_url('tempmon.appliances'), 'index_title': "TempMon Appliances", - 'use_buefy': use_buefy, 'appliance': selected_appliance, } @@ -85,17 +79,9 @@ class TempmonDashboardView(View): .order_by(tempmon.Appliance.name)\ .all() - if use_buefy: - context['appliances_data'] = [{'uuid': a.uuid, - 'name': a.name} - for a in appliances] - - else: - appliance_options = tags.Options([ - tags.Option(appliance.name, appliance.uuid) - for appliance in appliances]) - context['appliance_select'] = tags.select( - 'appliance_uuid', selected_uuid, appliance_options) + context['appliances_data'] = [{'uuid': a.uuid, + 'name': a.name} + for a in appliances] return context diff --git a/tailbone/views/tempmon/probes.py b/tailbone/views/tempmon/probes.py index 218caafa..6d12a3d2 100644 --- a/tailbone/views/tempmon/probes.py +++ b/tailbone/views/tempmon/probes.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,12 +24,8 @@ Views for tempmon probes """ -from __future__ import unicode_literals, absolute_import - import datetime -import six - from rattail.time import make_utc, localtime from rattail_tempmon.db import model as tempmon @@ -206,7 +202,7 @@ class TempmonProbeView(MasterView): client = probe.client if not client: return "" - text = six.text_type(client) + text = str(client) url = self.request.route_url('tempmon.clients.view', uuid=client.uuid) return tags.link_to(text, url) @@ -214,7 +210,7 @@ class TempmonProbeView(MasterView): appliance = probe.appliance if not appliance: return "" - text = six.text_type(appliance) + text = str(appliance) url = self.request.route_url('tempmon.appliances.view', uuid=appliance.uuid) return tags.link_to(text, url) @@ -255,7 +251,6 @@ class TempmonProbeView(MasterView): def graph(self): probe = self.get_instance() - use_buefy = self.get_use_buefy() key = 'tempmon.probe.{}.graph_time_range'.format(probe.uuid) selected = self.request.params.get('time-range') @@ -270,16 +265,13 @@ class TempmonProbeView(MasterView): tags.Option("Last Week", 'last week'), ]) - if use_buefy: - time_range = HTML.tag('b-select', c=[range_options.render()], - **{'v-model': 'currentTimeRange', - '@input': 'timeRangeChanged'}) - else: - time_range = tags.select('time-range', selected, range_options) + time_range = HTML.tag('b-select', c=[range_options.render()], + **{'v-model': 'currentTimeRange', + '@input': 'timeRangeChanged'}) context = { 'probe': probe, - 'parent_title': six.text_type(probe), + 'parent_title': str(probe), 'parent_url': self.get_action_url('view', probe), 'time_range': time_range, 'current_time_range': selected, diff --git a/tailbone/views/trainwreck/base.py b/tailbone/views/trainwreck/base.py index 509b4398..82a0b2b1 100644 --- a/tailbone/views/trainwreck/base.py +++ b/tailbone/views/trainwreck/base.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Trainwreck views """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.time import localtime from webhelpers2.html import HTML, tags @@ -163,7 +159,7 @@ class TransactionView(MasterView): g.filters['end_time'].default_active = True g.filters['end_time'].default_verb = 'equal' - g.filters['end_time'].default_value = six.text_type(localtime(self.rattail_config).date()) + g.filters['end_time'].default_value = str(localtime(self.rattail_config).date()) g.set_sort_defaults('end_time', 'desc') g.set_enum('system', self.enum.TRAINWRECK_SYSTEM) @@ -216,35 +212,29 @@ class TransactionView(MasterView): route_prefix = self.get_route_prefix() factory = self.get_grid_factory() - use_buefy = self.get_use_buefy() g = factory( key='{}.custorder_xref_markers'.format(route_prefix), - data=[] if use_buefy else txn.custorder_xref_markers, + data=[], columns=['custorder_xref', 'custorder_item_xref'], request=self.request) - if use_buefy: - return HTML.literal( - g.render_buefy_table_element(data_prop='custorderXrefMarkersData')) - else: - return HTML.literal(g.render_grid()) + return HTML.literal( + g.render_buefy_table_element(data_prop='custorderXrefMarkersData')) def template_kwargs_view(self, **kwargs): kwargs = super(TransactionView, self).template_kwargs_view(**kwargs) - use_buefy = self.get_use_buefy() - if use_buefy: - form = kwargs['form'] - if 'custorder_xref_markers' in form: - txn = kwargs['instance'] - markers = [] - for marker in txn.custorder_xref_markers: - markers.append({ - 'custorder_xref': marker.custorder_xref, - 'custorder_item_xref': marker.custorder_item_xref, - }) - kwargs['custorder_xref_markers_data'] = markers + form = kwargs['form'] + if 'custorder_xref_markers' in form: + txn = kwargs['instance'] + markers = [] + for marker in txn.custorder_xref_markers: + markers.append({ + 'custorder_xref': marker.custorder_xref, + 'custorder_item_xref': marker.custorder_item_xref, + }) + kwargs['custorder_xref_markers_data'] = markers return kwargs @@ -296,7 +286,7 @@ class TransactionView(MasterView): def render_transaction(self, item, field): txn = getattr(item, field) - text = six.text_type(txn) + text = str(txn) url = self.get_action_url('view', txn) return tags.link_to(text, url) @@ -306,38 +296,31 @@ class TransactionView(MasterView): route_prefix = self.get_route_prefix() factory = self.get_grid_factory() - use_buefy = self.get_use_buefy() g = factory( key='{}.discounts'.format(route_prefix), - data=[] if use_buefy else item.discounts, + data=[], columns=['discount_type', 'description', 'amount'], labels={'discount_type': "Type"}, request=self.request) - if use_buefy: - return HTML.literal( - g.render_buefy_table_element(data_prop='discountsData')) - else: - g.set_type('amount', 'currency') - return HTML.literal(g.render_grid()) + return HTML.literal( + g.render_buefy_table_element(data_prop='discountsData')) def template_kwargs_view_row(self, **kwargs): - use_buefy = self.get_use_buefy() - if use_buefy: - form = kwargs['form'] - if 'discounts' in form: + form = kwargs['form'] + if 'discounts' in form: - app = self.get_rattail_app() - item = kwargs['instance'] - discounts_data = [] - for discount in item.discounts: - discounts_data.append({ - 'discount_type': discount.discount_type, - 'description': discount.description, - 'amount': app.render_currency(discount.amount), - }) - kwargs['discounts_data'] = discounts_data + app = self.get_rattail_app() + item = kwargs['instance'] + discounts_data = [] + for discount in item.discounts: + discounts_data.append({ + 'discount_type': discount.discount_type, + 'description': discount.description, + 'amount': app.render_currency(discount.amount), + }) + kwargs['discounts_data'] = discounts_data return kwargs @@ -352,7 +335,7 @@ class TransactionView(MasterView): # find oldest and newest dates for each database engines_data = [] - for key, engine in six.iteritems(trainwreck_engines): + for key, engine in trainwreck_engines.items(): if key == 'default': session = self.Session() diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index 73b4461b..0b085ba7 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,15 +24,12 @@ Views for app upgrades """ -from __future__ import unicode_literals, absolute_import - import json import os import re import logging import warnings -import six import sqlalchemy as sa from rattail.core import Object @@ -251,25 +248,24 @@ class UpgradeView(MasterView): code = getattr(upgrade, field) text = self.enum.UPGRADE_STATUS[code] - if self.get_use_buefy(): - if code == self.enum.UPGRADE_STATUS_EXECUTING: + if code == self.enum.UPGRADE_STATUS_EXECUTING: - text = HTML.tag('span', c=[text]) + 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'}) + 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]), - ]), - ]) + 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 @@ -302,14 +298,12 @@ class UpgradeView(MasterView): return filename def render_package_diff(self, upgrade, fieldname): - use_buefy = self.get_use_buefy() try: before = self.parse_requirements(upgrade, 'before') after = self.parse_requirements(upgrade, 'after') kwargs = {} - if use_buefy: - kwargs['extra_row_attrs'] = self.get_extra_diff_row_attrs + kwargs['extra_row_attrs'] = self.get_extra_diff_row_attrs diff = self.make_diff(before, after, columns=["package", "old version", "new version"], render_field=self.render_diff_field, @@ -317,24 +311,16 @@ class UpgradeView(MasterView): **kwargs) kwargs = {} - if use_buefy: - kwargs['@click.prevent'] = "showingPackages = 'all'" - kwargs[':style'] = "{'font-weight': showingPackages == 'all' ? 'bold' : null}" - else: - kwargs['class_'] = 'all' + kwargs['@click.prevent'] = "showingPackages = 'all'" + kwargs[':style'] = "{'font-weight': showingPackages == 'all' ? 'bold' : null}" all_link = tags.link_to("all", '#', **kwargs) kwargs = {} - if use_buefy: - kwargs['@click.prevent'] = "showingPackages = 'diffs'" - kwargs[':style'] = "{'font-weight': showingPackages == 'diffs' ? 'bold' : null}" - else: - kwargs['class_'] = 'diffs' + kwargs['@click.prevent'] = "showingPackages = 'diffs'" + kwargs[':style'] = "{'font-weight': showingPackages == 'diffs' ? 'bold' : null}" diffs_link = tags.link_to("diffs only", '#', **kwargs) kwargs = {} - if not use_buefy: - kwargs['class_'] = 'showing' showing = HTML.tag('div', c=["showing: " + all_link + " / " diff --git a/tailbone/views/workorders.py b/tailbone/views/workorders.py index dff57e96..a53037bc 100644 --- a/tailbone/views/workorders.py +++ b/tailbone/views/workorders.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,6 @@ Work Order Views """ -from __future__ import unicode_literals, absolute_import - import sqlalchemy as sa from rattail.db.model import WorkOrder, WorkOrderEvent @@ -117,7 +115,6 @@ class WorkOrderView(MasterView): 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 @@ -198,11 +195,7 @@ class WorkOrderView(MasterView): 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 HTML.tag('span', class_='has-text-danger', c=text) return text return str(status_code) From f0880785a9c58202e58219214ac6bb0ce4108aad Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 3 Feb 2023 15:18:39 -0600 Subject: [PATCH 1006/1681] Add new Buefy-specific upgrade template since that was broken.. --- .../templates/themes/falafel/progress.mako | 71 +++++++++++++------ .../templates/themes/falafel/upgrade.mako | 69 ++++++++++++++++++ tailbone/templates/upgrades/view.mako | 8 ++- tailbone/views/master.py | 6 +- tailbone/views/upgrades.py | 1 + 5 files changed, 129 insertions(+), 26 deletions(-) create mode 100644 tailbone/templates/themes/falafel/upgrade.mako diff --git a/tailbone/templates/themes/falafel/progress.mako b/tailbone/templates/themes/falafel/progress.mako index f1973e81..ad0a1371 100644 --- a/tailbone/templates/themes/falafel/progress.mako +++ b/tailbone/templates/themes/falafel/progress.mako @@ -8,6 +8,7 @@ <title>${initial_msg or "Working"}...</title> ${core_javascript()} ${core_styles()} + ${self.extra_styles()} </head> <body style="height: 100%;"> @@ -61,6 +62,8 @@ <div style="flex-grow: 1;"></div> </div> + ${self.after_progress()} + </div> </div> </section> @@ -72,23 +75,6 @@ let WholePage = { template: '#whole-page-template', - data() { - return { - progressURL: '${url('progress', key=progress.key, _query={'sessiontype': progress.session.type})}', - progressMessage: "${(initial_msg or "Working").replace('"', '\\"')} (please wait)", - progressMax: null, - progressMaxDisplay: null, - progressValue: null, - stillInProgress: true, - - % if can_cancel: - canCancel: true, - cancelURL: '${url('progress.cancel', key=progress.key, _query={'sessiontype': progress.session.type})}', - cancelingProgress: false, - % endif - } - }, - computed: { totalDisplay() { @@ -113,10 +99,15 @@ setTimeout(() => { this.updateProgress() }, 1000) + + // custom logic if applicable + this.mountedCustom() }, methods: { + mountedCustom() {}, + updateProgress() { this.$http.get(this.progressURL).then(response => { @@ -146,6 +137,9 @@ } } + // custom logic if applicable + this.updateProgressCustom(response) + if (this.stillInProgress) { // fetch progress data again, in one second from now @@ -157,6 +151,8 @@ }) }, + updateProgressCustom(response) {}, + % if can_cancel: cancelProgress() { @@ -178,13 +174,46 @@ } } - Vue.component('whole-page', WholePage) + let WholePageData = { - new Vue({ - el: '#whole-page-app' - }) + progressURL: '${url('progress', key=progress.key, _query={'sessiontype': progress.session.type})}', + progressMessage: "${(initial_msg or "Working").replace('"', '\\"')} (please wait)", + progressMax: null, + progressMaxDisplay: null, + progressValue: null, + stillInProgress: true, + + % if can_cancel: + canCancel: true, + cancelURL: '${url('progress.cancel', key=progress.key, _query={'sessiontype': progress.session.type})}', + cancelingProgress: false, + % endif + } </script> + ${self.modify_whole_page_vars()} + ${self.make_whole_page_app()} + </body> </html> + +<%def name="extra_styles()"></%def> + +<%def name="after_progress()"></%def> + +<%def name="modify_whole_page_vars()"></%def> + +<%def name="make_whole_page_app()"> + <script type="text/javascript"> + + WholePage.data = function() { return WholePageData } + + Vue.component('whole-page', WholePage) + + new Vue({ + el: '#whole-page-app' + }) + + </script> +</%def> diff --git a/tailbone/templates/themes/falafel/upgrade.mako b/tailbone/templates/themes/falafel/upgrade.mako new file mode 100644 index 00000000..7cc73941 --- /dev/null +++ b/tailbone/templates/themes/falafel/upgrade.mako @@ -0,0 +1,69 @@ +## -*- coding: utf-8; -*- +<%inherit file="/progress.mako" /> + +<%def name="extra_styles()"> + ${parent.extra_styles()} + <style type="text/css"> + + .progress-with-textout { + border: 1px solid Black; + line-height: 1.2; + margin-top: 1rem; + overflow: auto; + padding: 1rem; + } + + </style> +</%def> + +<%def name="after_progress()"> + <!-- <div ref="stdout" class="stdout"></div> --> + + <div ref="textout" + class="progress-with-textout is-family-monospace is-size-7"> + <span v-for="line in progressOutput" + :key="line.key" + v-html="line.text"> + </span> + + ## nb. we auto-scroll down to "see" this element + <div ref="seeme"></div> + </div> + +</%def> + +<%def name="modify_whole_page_vars()"> + <script type="text/javascript"> + + WholePageData.progressURL = '${url('upgrades.execute_progress', uuid=instance.uuid)}' + WholePageData.progressOutput = [] + WholePageData.progressOutputCounter = 0 + + WholePage.methods.mountedCustom = function() { + + // grow the textout area to fill most of screen + let textout = this.$refs.textout + let height = window.innerHeight - textout.offsetTop - 100 + textout.style.height = height + 'px' + } + + WholePage.methods.updateProgressCustom = function(response) { + if (response.data.stdout) { + + // add lines to textout area + this.progressOutput.push({ + key: ++this.progressOutputCounter, + text: response.data.stdout}) + + // scroll down to end of textout area + this.$nextTick(() => { + this.$refs.seeme.scrollIntoView({behavior: 'smooth'}) + }) + } + } + + </script> +</%def> + + +${parent.body()} diff --git a/tailbone/templates/upgrades/view.mako b/tailbone/templates/upgrades/view.mako index 530b8757..c5419574 100644 --- a/tailbone/templates/upgrades/view.mako +++ b/tailbone/templates/upgrades/view.mako @@ -78,13 +78,15 @@ <%def name="render_buefy_form()"> <div class="form"> <${form.component} - % if expose_websockets and master.has_perm('execute'): + % if master.has_perm('execute'): + @declare-failure-click="declareFailureClick" + :declare-failure-submitting="declareFailureSubmitting" + % if expose_websockets: % if instance_executable: @execute-upgrade-click="executeUpgrade" % endif :upgrade-executing="upgradeExecuting" - @declare-failure-click="declareFailureClick" - :declare-failure-submitting="declareFailureSubmitting" + % endif % endif > </${form.component}> diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 2d6410e1..01a3405a 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -24,6 +24,7 @@ Model Master View """ +import io import os import csv import datetime @@ -33,7 +34,6 @@ import tempfile import logging import json -import six import sqlalchemy as sa from sqlalchemy import orm import sqlalchemy_continuum as continuum @@ -115,6 +115,7 @@ class MasterView(View): executable = False execute_progress_template = None execute_progress_initial_msg = None + execute_can_cancel = True supports_prev_next = False supports_import_batch_from_file = False has_input_file_templates = False @@ -1839,6 +1840,7 @@ class MasterView(View): return self.render_progress(progress, { 'instance': obj, 'initial_msg': self.execute_progress_initial_msg, + 'can_cancel': self.execute_can_cancel, 'cancel_url': self.get_action_url('view', obj), 'cancel_msg': "{} execution was canceled".format(model_title), }, template=self.execute_progress_template) @@ -3785,7 +3787,7 @@ class MasterView(View): """ obj = self.get_instance() fields = self.get_row_csv_fields() - data = six.StringIO() + data = io.StringIO() writer = UnicodeDictWriter(data, fields) writer.writeheader() for row in self.get_effective_row_data(sort=True): diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index 0b085ba7..f6df80d3 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.py @@ -60,6 +60,7 @@ class UpgradeView(MasterView): executable = True execute_progress_template = '/upgrade.mako' execute_progress_initial_msg = "Upgrading" + execute_can_cancel = False labels = { 'executed_by': "Executed by", From 320aaab4b36ab9b62364e4e9648ebb1f294aa83a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 3 Feb 2023 15:26:02 -0600 Subject: [PATCH 1007/1681] Replace 'default' theme to match 'falafel' falafel is now an empty wrapper around default hell yeah --- tailbone/templates/base.mako | 1015 ++++++++++++++--- tailbone/templates/progress.mako | 326 ++++-- .../templates/themes/excite-bike/base.mako | 8 - tailbone/templates/themes/falafel/base.mako | 928 +-------------- .../templates/themes/falafel/progress.mako | 219 +--- .../templates/themes/falafel/upgrade.mako | 67 +- tailbone/templates/upgrade.mako | 123 +- 7 files changed, 1094 insertions(+), 1592 deletions(-) delete mode 100644 tailbone/templates/themes/excite-bike/base.mako diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index 43f3a1dd..c2970193 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -1,8 +1,10 @@ ## -*- coding: utf-8; -*- -<%namespace file="/menu.mako" import="main_menu_items" /> <%namespace file="/grids/nav.mako" import="grid_index_nav" /> -<%namespace file="/feedback_dialog.mako" import="feedback_dialog" /> +<%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" /> +<%namespace name="multi_file_upload" file="/multi_file_upload.mako" /> <!DOCTYPE html> <html lang="en"> <head> @@ -13,13 +15,17 @@ % if background_color: <style type="text/css"> - body { background-color: ${background_color}; } + body, .navbar, .footer { + background-color: ${background_color}; + } </style> % endif % if not request.rattail_config.production(): <style type="text/css"> - body { background-image: url(${request.static_url('tailbone:static/img/testing.png')}); } + body, .navbar, .footer { + background-image: url(${request.static_url('tailbone:static/img/testing.png')}); + } </style> % endif @@ -27,154 +33,17 @@ </head> <body> - <div id="body-wrapper"> + ${declare_formposter_mixin()} - <header> - <nav> - <ul class="menubar"> - ${main_menu_items()} - </ul> - </nav> + ${self.body()} - <div class="global"> - <a class="home" href="${url('home')}"> - ${base_meta.header_logo()} - <span class="global-title">${base_meta.global_title()}</span> - </a> - % if master: - <span class="global">»</span> - % if master.listing: - <span class="global">${index_title}</span> - % else: - ${h.link_to(index_title, index_url, class_='global')} - % if parent_url is not Undefined: - <span class="global">»</span> - ${h.link_to(parent_title, parent_url, class_='global')} - % elif instance_url is not Undefined: - <span class="global">»</span> - ${h.link_to(instance_title, instance_url, class_='global')} - % endif - % if master.viewing and grid_index: - ${grid_index_nav()} - % endif - % endif - % elif index_title: - <span class="global">»</span> - % if index_url: - ${h.link_to(index_title, index_url, class_='global')} - % else: - <span class="global">${index_title}</span> - % endif - % endif + <div id="whole-page-app"> + <whole-page></whole-page> + </div> - <div class="feedback"> - % if help_url is not Undefined and help_url: - ${h.link_to("Help", help_url, target='_blank', class_='button')} - % endif - % if request.has_perm('common.feedback'): - <button type="button" id="feedback">Feedback</button> - % endif - </div> - - % if expose_theme_picker and request.has_perm('common.change_app_theme'): - <div class="after-feedback"> - ${h.form(url('change_theme'), name="theme_changer", method="post")} - ${h.csrf_token(request)} - Theme: - ${h.select('theme', theme, options=theme_picker_options, id='theme-picker')} - ${h.end_form()} - </div> - % endif - - % if quickie is not Undefined and quickie and request.has_perm(quickie.perm): - <div class="after-feedback"> - ${h.form(quickie.url, name="quickie", method="get")} - ${h.text('entry', placeholder=quickie.placeholder, autocomplete='off')} - <button type="submit" id="submit-quickie">Lookup</button> - ${h.end_form()} - </div> - % endif - - </div><!-- global --> - - <div class="page"> - % if capture(self.content_title): - - % if show_prev_next is not Undefined and show_prev_next: - <div style="float: right;"> - ## NOTE: the u"" literals seem to be required for python2..not sure why - % if prev_url: - ${h.link_to(u"« Older", prev_url, class_='button autodisable')} - % else: - ${h.link_to(u"« Older", '#', class_='button', disabled='disabled')} - % endif - % if next_url: - ${h.link_to(u"Newer »", next_url, class_='button autodisable')} - % else: - ${h.link_to(u"Newer »", '#', class_='button', disabled='disabled')} - % endif - </div> - % endif - - <h1>${self.content_title()}</h1> - % endif - </div> - </header> - - <div class="content-wrapper"> - - <div id="scrollpane"> - <div id="content"> - <div class="inner-content"> - - % if request.session.peek_flash('error'): - <div class="error-messages"> - % for error in request.session.pop_flash('error'): - <div class="ui-state-error ui-corner-all"> - <span style="float: left; margin-right: .3em;" class="ui-icon ui-icon-alert"></span> - ${error} - </div> - % endfor - </div> - % endif - - % if request.session.peek_flash('warning'): - <div class="error-messages"> - % for msg in request.session.pop_flash('warning'): - <div class="ui-state-error ui-corner-all"> - <span style="float: left; margin-right: .3em;" class="ui-icon ui-icon-alert"></span> - ${msg} - </div> - % endfor - </div> - % endif - - % if request.session.peek_flash(): - <div class="flash-messages"> - % for msg in request.session.pop_flash(): - <div class="ui-state-highlight ui-corner-all"> - <span style="float: left; margin-right: .3em;" class="ui-icon ui-icon-info"></span> - ${msg|n} - </div> - % endfor - </div> - % endif - - ${self.body()} - - </div><!-- inner-content --> - </div><!-- content --> - </div><!-- scrollpane --> - - </div><!-- content-wrapper --> - - <div id="footer"> - ${base_meta.footer()} - </div> - - </div><!-- body-wrapper --> - - ${feedback_dialog()} + ${self.render_whole_page_template()} + ${self.make_whole_page_component()} + ${self.make_whole_page_app()} </body> </html> @@ -185,6 +54,7 @@ </%def> <%def name="header_core()"> + ${self.core_javascript()} ${self.extra_javascript()} ${self.core_styles()} @@ -204,63 +74,837 @@ <%def name="core_javascript()"> ${self.jquery()} - ${h.javascript_link(request.static_url('tailbone:static/js/lib/jquery.ui.menubar.js'))} - ${h.javascript_link(request.static_url('tailbone:static/js/lib/jquery.loadmask.min.js'))} - ${h.javascript_link(request.static_url('tailbone:static/js/lib/jquery.ui.timepicker.js'))} + ${self.vuejs()} + ${self.buefy()} + ${self.fontawesome()} + + ## some commonly-useful logic for detecting (non-)numeric input + ${h.javascript_link(request.static_url('tailbone:static/js/numeric.js') + '?ver={}'.format(tailbone.__version__))} + + ## debounce, for better autocomplete performance + ${h.javascript_link(request.static_url('tailbone:static/js/debounce.js') + '?ver={}'.format(tailbone.__version__))} + + ## Tailbone / Buefy stuff + ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.datepicker.js') + '?ver={}'.format(tailbone.__version__))} + ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.numericinput.js') + '?ver={}'.format(tailbone.__version__))} + ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.oncebutton.js') + '?ver={}'.format(tailbone.__version__))} + ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.timepicker.js') + '?ver={}'.format(tailbone.__version__))} + ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.grid.js') + '?ver={}'.format(tailbone.__version__))} + <script type="text/javascript"> var session_timeout = ${request.get_session_timeout() or 'null'}; var logout_url = '${request.route_url('logout')}'; var noop_url = '${request.route_url('noop')}'; $(function() { - $('ul.menubar').menubar({ - buttons: true, - menuIcon: true, - autoExpand: true + ## NOTE: this code was copied from + ## https://bulma.io/documentation/components/navbar/#navbar-menu + $('.navbar-burger').click(function() { + $('.navbar-burger').toggleClass('is-active'); + $('.navbar-menu').toggleClass('is-active'); }); }); - % if expose_theme_picker and request.has_perm('common.change_app_theme'): - $(function() { - $('#theme-picker').change(function() { - $(this).parents('form:first').submit(); - }); - }); - % endif </script> - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.js') + '?ver={}'.format(tailbone.__version__))} - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.feedback.js') + '?ver={}'.format(tailbone.__version__))} - ${h.javascript_link(request.static_url('tailbone:static/js/jquery.ui.tailbone.js') + '?ver={}'.format(tailbone.__version__))} </%def> <%def name="jquery()"> - ${h.javascript_link('https://code.jquery.com/jquery-1.12.4.min.js')} - ${h.javascript_link('https://code.jquery.com/ui/{}/jquery-ui.min.js'.format(request.rattail_config.get('tailbone', 'jquery_ui.version', default='1.11.4')))} + ${h.javascript_link(h.get_liburl(request, 'jquery'))} +</%def> + +<%def name="vuejs()"> + ${h.javascript_link(h.get_liburl(request, 'vue'))} + ${h.javascript_link(h.get_liburl(request, 'vue_resource'))} +</%def> + +<%def name="buefy()"> + ${h.javascript_link(h.get_liburl(request, 'buefy'))} +</%def> + +<%def name="fontawesome()"> + <script defer src="${h.get_liburl(request, 'fontawesome')}"></script> </%def> <%def name="extra_javascript()"></%def> <%def name="core_styles()"> - ${h.stylesheet_link(request.static_url('tailbone:static/css/normalize.css'))} - ${self.jquery_theme()} - ${h.stylesheet_link(request.static_url('tailbone:static/css/jquery.ui.menubar.css'))} - ${h.stylesheet_link(request.static_url('tailbone:static/css/jquery.loadmask.css'))} - ${h.stylesheet_link(request.static_url('tailbone:static/css/jquery.ui.timepicker.css'))} - ${h.stylesheet_link(request.static_url('tailbone:static/css/jquery.ui.tailbone.css') + '?ver={}'.format(tailbone.__version__))} - ${h.stylesheet_link(request.static_url('tailbone:static/css/base.css') + '?ver={}'.format(tailbone.__version__))} - ${h.stylesheet_link(request.static_url('tailbone:static/css/layout.css') + '?ver={}'.format(tailbone.__version__))} + + ${self.buefy_styles()} + + ${h.stylesheet_link(request.static_url('tailbone:static/themes/falafel/css/base.css') + '?ver={}'.format(tailbone.__version__))} + ${h.stylesheet_link(request.static_url('tailbone:static/themes/falafel/css/layout.css') + '?ver={}'.format(tailbone.__version__))} ${h.stylesheet_link(request.static_url('tailbone:static/css/grids.css') + '?ver={}'.format(tailbone.__version__))} - ${h.stylesheet_link(request.static_url('tailbone:static/css/filters.css') + '?ver={}'.format(tailbone.__version__))} - ${h.stylesheet_link(request.static_url('tailbone:static/css/forms.css') + '?ver={}'.format(tailbone.__version__))} + ${h.stylesheet_link(request.static_url('tailbone:static/themes/falafel/css/grids.css') + '?ver={}'.format(tailbone.__version__))} + ${h.stylesheet_link(request.static_url('tailbone:static/themes/falafel/css/grids.rowstatus.css') + '?ver={}'.format(tailbone.__version__))} +## ${h.stylesheet_link(request.static_url('tailbone:static/css/filters.css') + '?ver={}'.format(tailbone.__version__))} + ${h.stylesheet_link(request.static_url('tailbone:static/themes/falafel/css/filters.css') + '?ver={}'.format(tailbone.__version__))} + ${h.stylesheet_link(request.static_url('tailbone:static/themes/falafel/css/forms.css') + '?ver={}'.format(tailbone.__version__))} ${h.stylesheet_link(request.static_url('tailbone:static/css/diffs.css') + '?ver={}'.format(tailbone.__version__))} + + ${h.stylesheet_link(request.static_url('tailbone:static/css/codehilite.css') + '?ver={}'.format(tailbone.__version__))} + + <style type="text/css"> + .filters .filter-fieldname { + min-width: ${filter_fieldname_width}; + justify-content: left; + } + .filters .filter-verb { + min-width: ${filter_verb_width}; + } + </style> </%def> +<%def name="buefy_styles()"> + % if buefy_css: + ## custom Buefy CSS + ${h.stylesheet_link(buefy_css)} + % else: + ## upstream Buefy CSS + ${h.stylesheet_link(h.get_liburl(request, 'buefy.css'))} + % endif +</%def> + +## TODO: this is only being referenced by the progress template i think? +## (so, should make a Buefy progress page at least) <%def name="jquery_theme()"> - ${h.stylesheet_link('https://code.jquery.com/ui/1.11.4/themes/dark-hive/jquery-ui.css')} + ${h.stylesheet_link(h.get_liburl(request, 'jquery_ui'))} </%def> <%def name="extra_styles()"></%def> <%def name="head_tags()"></%def> +<%def name="render_whole_page_template()"> + <script type="text/x-template" id="whole-page-template"> + <div> + <header> + + <nav class="navbar" role="navigation" aria-label="main navigation"> + + <div class="navbar-brand"> + <a class="navbar-item" href="${url('home')}" + v-show="!globalSearchActive"> + ${base_meta.header_logo()} + <div id="global-header-title"> + ${base_meta.global_title()} + </div> + </a> + <div v-show="globalSearchActive" + class="navbar-item"> + <b-autocomplete ref="globalSearchAutocomplete" + v-model="globalSearchTerm" + :data="globalSearchFilteredData" + field="label" + open-on-focus + keep-first + icon-pack="fas" + clearable + @keydown.native="globalSearchKeydown" + @select="globalSearchSelect"> + </b-autocomplete> + </div> + <a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false"> + <span aria-hidden="true"></span> + <span aria-hidden="true"></span> + <span aria-hidden="true"></span> + </a> + </div> + + <div class="navbar-menu"> + <div class="navbar-start"> + + <div v-if="globalSearchData.length" + class="navbar-item"> + <b-button type="is-primary" + size="is-small" + @click="globalSearchInit()"> + <span><i class="fa fa-search"></i></span> + </b-button> + </div> + + % for topitem in menus: + % if topitem['is_link']: + ${h.link_to(topitem['title'], topitem['url'], target=topitem['target'], class_='navbar-item')} + % else: + <div class="navbar-item has-dropdown is-hoverable"> + <a class="navbar-link">${topitem['title']}</a> + <div class="navbar-dropdown"> + % for item in topitem['items']: + % if item['is_menu']: + <% item_hash = id(item) %> + <% toggle = 'menu_{}_shown'.format(item_hash) %> + <div> + <a class="navbar-link" @click.prevent="toggleNestedMenu('${item_hash}')"> + ${item['title']} + </a> + </div> + % for subitem in item['items']: + % if subitem['is_sep']: + <hr class="navbar-divider" v-show="${toggle}"> + % else: + ${h.link_to("{}".format(subitem['title']), subitem['url'], class_='navbar-item nested', target=subitem['target'], **{'v-show': toggle})} + % endif + % endfor + % else: + % if item['is_sep']: + <hr class="navbar-divider"> + % else: + ${h.link_to(item['title'], item['url'], class_='navbar-item', target=item['target'])} + % endif + % endif + % endfor + </div> + </div> + % endif + % endfor + + </div><!-- navbar-start --> + ${self.render_navbar_end()} + </div> + </nav> + + <nav class="level" style="margin: 0.5rem auto;"> + <div class="level-left"> + + ## Current Context + <div id="current-context" class="level-item"> + % if master: + % if master.listing: + <span class="header-text"> + ${index_title} + </span> + % if master.creatable and master.show_create_link and master.has_perm('create'): + <once-button type="is-primary" + tag="a" href="${url('{}.create'.format(route_prefix))}" + icon-left="plus" + style="margin-left: 1rem;" + text="Create New"> + </once-button> + % endif + % elif index_url: + <span class="header-text"> + ${h.link_to(index_title, index_url)} + </span> + % if parent_url is not Undefined: + <span class="header-text"> + » + </span> + <span class="header-text"> + ${h.link_to(parent_title, parent_url)} + </span> + % elif instance_url is not Undefined: + <span class="header-text"> + » + </span> + <span class="header-text"> + ${h.link_to(instance_title, instance_url)} + </span> + % elif master.creatable and master.show_create_link and master.has_perm('create'): + % if not request.matched_route.name.endswith('.create'): + <once-button type="is-primary" + tag="a" href="${url('{}.create'.format(route_prefix))}" + icon-left="plus" + style="margin-left: 1rem;" + text="Create New"> + </once-button> + % endif + % endif + % if master.viewing and grid_index: + ${grid_index_nav()} + % endif + % else: + <span class="header-text"> + ${index_title} + </span> + % endif + % elif index_title: + % if index_url: + <span class="header-text"> + ${h.link_to(index_title, index_url)} + </span> + % else: + <span class="header-text"> + ${index_title} + </span> + % endif + % endif + </div> + + % if expose_db_picker is not Undefined and expose_db_picker: + <div class="level-item"> + <p>DB:</p> + </div> + <div class="level-item"> + ${h.form(url('change_db_engine'), ref='dbPickerForm')} + ${h.csrf_token(request)} + ${h.hidden('engine_type', value=master.engine_type_key)} + <div class="select"> + ${h.select('dbkey', db_picker_selected, db_picker_options, **{'@change': 'changeDB()'})} + </div> + ${h.end_form()} + </div> + % endif + + </div><!-- level-left --> + <div class="level-right"> + + ## Quickie Lookup + % if quickie is not Undefined and quickie and request.has_perm(quickie.perm): + <div class="level-item"> + ${h.form(quickie.url, method="get")} + <div class="level"> + <div class="level-right"> + <div class="level-item"> + <b-input name="entry" + placeholder="${quickie.placeholder}" + autocomplete="off"> + </b-input> + </div> + <div class="level-item"> + <button type="submit" class="button is-primary"> + <span class="icon is-small"> + <i class="fas fa-search"></i> + </span> + <span>Lookup</span> + </button> + </div> + </div> + </div> + ${h.end_form()} + </div> + % endif + + % if master and master.configurable and master.has_perm('configure'): + % if not request.matched_route.name.endswith('.configure'): + <div class="level-item"> + <once-button type="is-primary" + tag="a" + href="${url('{}.configure'.format(route_prefix))}" + icon-left="cog" + text="${(configure_button_title or "Configure") if configure_button_title is not Undefined else "Configure"}"> + </once-button> + </div> + % endif + % endif + + ## Theme Picker + % if expose_theme_picker and request.has_perm('common.change_app_theme'): + <div class="level-item"> + ${h.form(url('change_theme'), method="post", ref='themePickerForm')} + ${h.csrf_token(request)} + Theme: + <div class="theme-picker"> + <div class="select"> + ${h.select('theme', theme, theme_picker_options, **{'@change': 'changeTheme()'})} + </div> + </div> + ${h.end_form()} + </div> + % endif + + <div class="level-item"> + <page-help + % if can_edit_help: + @configure-fields-help="configureFieldsHelp = true" + % endif + > + </page-help> + </div> + + ## Feedback Button / Dialog + % if request.has_perm('common.feedback'): + <feedback-form + action="${url('feedback')}" + :message="feedbackMessage"> + </feedback-form> + % endif + + </div><!-- level-right --> + </nav><!-- level --> + </header> + + ## Page Title + % if capture(self.content_title): + <section id="content-title" class="hero is-primary"> + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <h1 class="title" v-html="contentTitleHTML"></h1> + </div> + </div> + <div class="level-right"> + ${self.render_instance_header_buttons()} + </div> + </div> + </section> + % endif + + <div class="content-wrapper"> + + ## Page Body + <section id="page-body"> + + % if request.session.peek_flash('error'): + % for error in request.session.pop_flash('error'): + <b-notification type="is-warning"> + ${error} + </b-notification> + % endfor + % endif + + % if request.session.peek_flash('warning'): + % for msg in request.session.pop_flash('warning'): + <b-notification type="is-warning"> + ${msg} + </b-notification> + % endfor + % endif + + % if request.session.peek_flash(): + % for msg in request.session.pop_flash(): + <b-notification type="is-info"> + ${msg} + </b-notification> + % endfor + % endif + + ${self.render_this_page_component()} + </section> + + ## Footer + <footer class="footer"> + <div class="content"> + ${base_meta.footer()} + </div> + </footer> + + </div><!-- content-wrapper --> + </div> + </script> + + ${page_help.render_template()} + + <script type="text/x-template" id="feedback-template"> + <div> + + <div class="level-item"> + <b-button type="is-primary" + @click="showFeedback()" + icon-pack="fas" + icon-left="fas fa-comment"> + Feedback + </b-button> + </div> + + <b-modal has-modal-card + :active.sync="showDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">User Feedback</p> + </header> + + <section class="modal-card-body"> + <p class="block"> + Questions, suggestions, comments, complaints, etc. + <span class="red">regarding this website</span> are + welcome and may be submitted below. + </p> + + <b-field label="User Name"> + <b-input v-model="userName" + % if request.user: + disabled + % endif + > + </b-input> + </b-field> + + <b-field label="Referring URL"> + <b-input + v-model="referrer" + disabled="true"> + </b-input> + </b-field> + + <b-field label="Message"> + <b-input type="textarea" + v-model="message" + ref="textarea"> + </b-input> + </b-field> + + % if request.rattail_config.getbool('tailbone', 'feedback_allows_reply'): + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <b-checkbox v-model="pleaseReply" + @input="pleaseReplyChanged"> + Please email me back{{ pleaseReply ? " at: " : "" }} + </b-checkbox> + </div> + <div class="level-item" v-show="pleaseReply"> + <b-input v-model="userEmail" + ref="userEmail"> + </b-input> + </div> + </div> + </div> + % endif + + </section> + + <footer class="modal-card-foot"> + <b-button @click="showDialog = false"> + Cancel + </b-button> + <once-button type="is-primary" + @click="sendFeedback()" + :disabled="!message.trim()" + text="Send Message"> + </once-button> + </footer> + </div> + </b-modal> + + </div> + </script> + + ${tailbone_autocomplete_template()} + ${multi_file_upload.render_template()} +</%def> + +<%def name="render_this_page_component()"> + <this-page @change-content-title="changeContentTitle" + % if can_edit_help: + :configure-fields-help="configureFieldsHelp" + % endif + > + </this-page> +</%def> + +<%def name="render_navbar_end()"> + <div class="navbar-end"> + ${self.render_user_menu()} + </div> +</%def> + +<%def name="render_user_menu()"> + % if request.user: + <div class="navbar-item has-dropdown is-hoverable"> + % if messaging_enabled: + <a class="navbar-link ${'root-user' if request.is_root else ''}">${request.user}${" ({})".format(inbox_count) if inbox_count else ''}</a> + % else: + <a class="navbar-link ${'root-user' if request.is_root else ''}">${request.user}</a> + % endif + <div class="navbar-dropdown"> + % if request.is_root: + ${h.link_to("Stop being root", url('stop_root'), class_='navbar-item root-user')} + % elif request.is_admin: + ${h.link_to("Become root", url('become_root'), class_='navbar-item root-user')} + % endif + % if messaging_enabled: + ${h.link_to("Messages{}".format(" ({})".format(inbox_count) if inbox_count else ''), url('messages.inbox'), class_='navbar-item')} + % endif + ${h.link_to("Change Password", url('change_password'), class_='navbar-item')} + ${h.link_to("Edit Preferences", url('my.preferences'), class_='navbar-item')} + ${h.link_to("Logout", url('logout'), class_='navbar-item')} + </div> + </div> + % else: + ${h.link_to("Login", url('login'), class_='navbar-item')} + % endif +</%def> + +<%def name="render_instance_header_buttons()"> + ${self.render_crud_header_buttons()} + ${self.render_prevnext_header_buttons()} +</%def> + +<%def name="render_crud_header_buttons()"> + % if master and master.viewing: + ## TODO: is there a better way to check if viewing parent? + % if parent_instance is Undefined: + % if master.editable and instance_editable and master.has_perm('edit'): + <div class="level-item"> + <once-button tag="a" href="${action_url('edit', instance)}" + icon-left="edit" + text="Edit This"> + </once-button> + </div> + % endif + % if master.cloneable and master.has_perm('clone'): + <div class="level-item"> + <once-button tag="a" href="${action_url('clone', instance)}" + icon-left="object-ungroup" + text="Clone This"> + </once-button> + </div> + % endif + % if master.deletable and instance_deletable and master.has_perm('delete'): + <div class="level-item"> + <once-button tag="a" href="${action_url('delete', instance)}" + type="is-danger" + icon-left="trash" + text="Delete This"> + </once-button> + </div> + % endif + % else: + ## viewing row + % if instance_deletable and master.has_perm('delete_row'): + <div class="level-item"> + <once-button tag="a" href="${action_url('delete', instance)}" + type="is-danger" + icon-left="trash" + text="Delete This"> + </once-button> + </div> + % endif + % endif + % elif master and master.editing: + % if master.viewable and master.has_perm('view'): + <div class="level-item"> + <once-button tag="a" href="${action_url('view', instance)}" + icon-left="eye" + text="View This"> + </once-button> + </div> + % endif + % if master.deletable and instance_deletable and master.has_perm('delete'): + <div class="level-item"> + <once-button tag="a" href="${action_url('delete', instance)}" + type="is-danger" + icon-left="trash" + text="Delete This"> + </once-button> + </div> + % endif + % elif master and master.deleting: + % if master.viewable and master.has_perm('view'): + <div class="level-item"> + <once-button tag="a" href="${action_url('view', instance)}" + icon-left="eye" + text="View This"> + </once-button> + </div> + % endif + % if master.editable and instance_editable and master.has_perm('edit'): + <div class="level-item"> + <once-button tag="a" href="${action_url('edit', instance)}" + icon-left="edit" + text="Edit This"> + </once-button> + </div> + % endif + % endif +</%def> + +<%def name="render_prevnext_header_buttons()"> + % if show_prev_next is not Undefined and show_prev_next: + % if prev_url: + <div class="level-item"> + <b-button tag="a" href="${prev_url}" + icon-pack="fas" + icon-left="arrow-left"> + Older + </b-button> + </div> + % else: + <div class="level-item"> + <b-button tag="a" href="#" + disabled + icon-pack="fas" + icon-left="arrow-left"> + Older + </b-button> + </div> + % endif + % if next_url: + <div class="level-item"> + <b-button tag="a" href="${next_url}" + icon-pack="fas" + icon-left="arrow-right"> + Newer + </b-button> + </div> + % else: + <div class="level-item"> + <b-button tag="a" href="#" + disabled + icon-pack="fas" + icon-left="arrow-right"> + Newer + </b-button> + </div> + % endif + % endif +</%def> + +<%def name="declare_whole_page_vars()"> + ${page_help.declare_vars()} + ${multi_file_upload.declare_vars()} + ${h.javascript_link(request.static_url('tailbone:static/themes/falafel/js/tailbone.feedback.js') + '?ver={}'.format(tailbone.__version__))} + <script type="text/javascript"> + + let WholePage = { + template: '#whole-page-template', + mixins: [FormPosterMixin], + computed: { + + globalSearchFilteredData() { + if (!this.globalSearchTerm.length) { + return this.globalSearchData + } + return this.globalSearchData.filter((option) => { + return option.label.toLowerCase().indexOf(this.globalSearchTerm.toLowerCase()) >= 0 + }) + }, + + }, + + mounted() { + window.addEventListener('keydown', this.globalKey) + for (let hook of this.mountedHooks) { + hook(this) + } + }, + beforeDestroy() { + window.removeEventListener('keydown', this.globalKey) + }, + + methods: { + + changeContentTitle(newTitle) { + this.contentTitleHTML = newTitle + }, + + % if expose_db_picker is not Undefined and expose_db_picker: + changeDB() { + this.$refs.dbPickerForm.submit() + }, + % endif + + % if expose_theme_picker and request.has_perm('common.change_app_theme'): + changeTheme() { + this.$refs.themePickerForm.submit() + }, + % endif + + globalKey(event) { + + // Ctrl+8 opens global search + if (event.target.tagName == 'BODY') { + if (event.ctrlKey && event.key == '8') { + this.globalSearchInit() + } + } + }, + + globalSearchInit() { + this.globalSearchTerm = '' + this.globalSearchActive = true + this.$nextTick(() => { + this.$refs.globalSearchAutocomplete.focus() + }) + }, + + globalSearchKeydown(event) { + + // ESC will dismiss searchbox + if (event.which == 27) { + this.globalSearchActive = false + } + }, + + globalSearchSelect(option) { + location.href = option.url + }, + + toggleNestedMenu(hash) { + const key = 'menu_' + hash + '_shown' + this[key] = !this[key] + }, + }, + } + + let WholePageData = { + contentTitleHTML: ${json.dumps(capture(self.content_title))|n}, + feedbackMessage: "", + + % if can_edit_help: + configureFieldsHelp: false, + % endif + + globalSearchActive: false, + globalSearchTerm: '', + globalSearchData: ${json.dumps(global_search_data)|n}, + + mountedHooks: [], + } + + ## declare nested menu visibility toggle flags + % for topitem in menus: + % if topitem['is_menu']: + % for item in topitem['items']: + % if item['is_menu']: + WholePageData.menu_${id(item)}_shown = false + % endif + % endfor + % endif + % endfor + + </script> +</%def> + +<%def name="modify_whole_page_vars()"> + <script type="text/javascript"> + + FeedbackFormData.referrer = location.href + + % if request.user: + FeedbackFormData.userUUID = ${json.dumps(request.user.uuid)|n} + FeedbackFormData.userName = ${json.dumps(six.text_type(request.user))|n} + % endif + + </script> +</%def> + +<%def name="finalize_whole_page_vars()"> + ## NOTE: if you override this, must use <script> tags +</%def> + +<%def name="make_whole_page_component()"> + ${self.declare_whole_page_vars()} + ${self.modify_whole_page_vars()} + ${self.finalize_whole_page_vars()} + + ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.autocomplete.js') + '?ver={}'.format(tailbone.__version__))} + + ${page_help.make_component()} + ${multi_file_upload.make_component()} + + <script type="text/javascript"> + + FeedbackForm.data = function() { return FeedbackFormData } + + Vue.component('feedback-form', FeedbackForm) + + WholePage.data = function() { return WholePageData } + + Vue.component('whole-page', WholePage) + + </script> +</%def> + +<%def name="make_whole_page_app()"> + <script type="text/javascript"> + + new Vue({ + el: '#whole-page-app' + }) + + </script> +</%def> + <%def name="wtfield(form, name, **kwargs)"> <div class="field-wrapper${' error' if form[name].errors else ''}"> <label for="${name}">${form[name].label}</label> @@ -269,3 +913,16 @@ </div> </div> </%def> + +<%def name="simple_field(label, value)"> + ## TODO: keep this? only used by personal profile view currently + ## (although could be useful for any readonly scenario) + <div class="field-wrapper"> + <div class="field-row"> + <label>${label}</label> + <div class="field"> + ${'' if value is None else value} + </div> + </div> + </div> +</%def> diff --git a/tailbone/templates/progress.mako b/tailbone/templates/progress.mako index 331e8e1a..ad0a1371 100644 --- a/tailbone/templates/progress.mako +++ b/tailbone/templates/progress.mako @@ -1,145 +1,219 @@ ## -*- coding: utf-8; -*- -<%namespace file="tailbone:templates/base.mako" import="core_javascript" /> -<%namespace file="/base.mako" import="jquery_theme" /> -<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> -<html style="direction: ltr;" xmlns="http://www.w3.org/1999/xhtml" lang="en-us"> +<%namespace file="/base.mako" import="core_javascript" /> +<%namespace file="/base.mako" import="core_styles" /> +<!DOCTYPE html> +<html lang="en"> <head> <meta http-equiv="content-type" content="text/html; charset=UTF-8" /> <title>${initial_msg or "Working"}...</title> ${core_javascript()} - ${h.stylesheet_link(request.static_url('tailbone:static/css/normalize.css'))} - ${jquery_theme()} - ${h.stylesheet_link(request.static_url('tailbone:static/css/base.css'))} - ${h.stylesheet_link(request.static_url('tailbone:static/css/layout.css'))} - ${h.stylesheet_link(request.static_url('tailbone:static/css/progress.css'))} - ${self.update_progress_func()} + ${core_styles()} ${self.extra_styles()} + </head> + <body style="height: 100%;"> + + <div id="whole-page-app"> + <whole-page></whole-page> + </div> + + <script type="text/x-template" id="whole-page-template"> + + <section class="hero is-fullheight"> + <div class="hero-body"> + <div class="container"> + + <div style="display: flex;"> + <div style="flex-grow: 1;"></div> + <div> + + <p class="block"> + {{ progressMessage }} ... {{ totalDisplay }} + </p> + + <div class="level"> + + <div class="level-item"> + <b-progress size="is-large" + style="width: 400px;" + :max="progressMax" + :value="progressValue" + show-value + format="percent" + precision="0"> + </b-progress> + </div> + + % if can_cancel: + <div class="level-item" + style="margin-left: 2rem;"> + <b-button v-show="canCancel" + @click="cancelProgress()" + :disabled="cancelingProgress" + icon-pack="fas" + icon-left="ban"> + {{ cancelingProgress ? "Canceling, please wait..." : "Cancel" }} + </b-button> + </div> + % endif + + </div> + + </div> + <div style="flex-grow: 1;"></div> + </div> + + ${self.after_progress()} + + </div> + </div> + </section> + + </script> + <script type="text/javascript"> - var stillInProgress = true; + let WholePage = { + template: '#whole-page-template', - // fetch first progress data, one second from now - setTimeout(function() { - update_progress(); - }, 1000); + computed: { - % if can_cancel: - $(function() { + totalDisplay() { - $('#cancel button').click(function() { - if (confirm("Do you really wish to cancel this operation?")) { - stillInProgress = false; - $(this).button('disable').button('option', 'label', "Canceling, please wait..."); - $.ajax({ - url: '${url('progress.cancel', key=progress.key)}?sessiontype=${progress.session.type}', - data: { - 'cancel_msg': '${cancel_msg}', - }, - success: function(data) { - location.href = '${cancel_url}'; - }, - }); - } - }); + % if can_cancel: + if (!this.stillInProgress && !this.cancelingProgress) { + % else: + if (!this.stillInProgress) { + % endif + return "done!" + } - }); - % endif - - </script> - </head> - <body> - <div id="body-wrapper"> - - <div id="wrapper"> - - <p><span id="message">${initial_msg or "Working"} (please wait)</span> ... <span id="total"></span></p> - - <table id="progress-wrapper"> - <tr> - <td> - <table id="progress"> - <tr> - <td id="complete"></td> - <td id="remaining"></td> - </tr> - </table><!-- #progress --> - </td> - <td id="percentage"></td> - % if can_cancel: - <td id="cancel"> - <button type="button" style="display: none;">Cancel</button> - </td> - % endif - </tr> - </table><!-- #progress-wrapper --> - - </div><!-- #wrapper --> - - ${self.after_progress()} - - </div><!-- #body-wrapper --> - </body> -</html> - -<%def name="update_progress_func()"> - <script type="text/javascript"> - - function update_progress() { - $.ajax({ - url: '${url('progress', key=progress.key)}?sessiontype=${progress.session.type}', - - success: function(data) { - - if (data.error) { - // errors stop the show, we redirect to "cancel" page - location.href = '${cancel_url}'; - - } else { - if (data.complete || data.maximum) { - $('#message').html(data.message); - $('#total').html('('+data.maximum_display+' total)'); - % if can_cancel: - $('#cancel button').show(); - % endif - if (data.complete) { - stillInProgress = false; - % if can_cancel: - $('#cancel button').hide(); - % endif - $('#total').html('done!'); - $('#complete').css('width', '100%'); - $('#remaining').hide(); - $('#percentage').html('100 %'); - location.href = data.success_url; - - } else { - // got progress data, so update display - var width = parseInt(data.value) / parseInt(data.maximum); - width = Math.round(100 * width); - if (width) { - $('#complete').css('width', width+'%'); - $('#percentage').html(width+' %'); - } else { - $('#complete').css('width', '0.01%'); - $('#percentage').html('0 %'); - } - $('#remaining').css('width', 'auto'); - } - } - - if (stillInProgress) { - // fetch progress data again, in one second from now - setTimeout(function() { - update_progress(); - }, 1000); - } + if (this.progressMaxDisplay) { + return `(${'$'}{this.progressMaxDisplay} total)` } }, - }); + }, + + mounted() { + + // fetch first progress data, one second from now + setTimeout(() => { + this.updateProgress() + }, 1000) + + // custom logic if applicable + this.mountedCustom() + }, + + methods: { + + mountedCustom() {}, + + updateProgress() { + + this.$http.get(this.progressURL).then(response => { + + if (response.data.error) { + // errors stop the show, we redirect to "cancel" page + location.href = '${cancel_url}' + + } else { + + if (response.data.complete || response.data.maximum) { + this.progressMessage = response.data.message + this.progressMaxDisplay = response.data.maximum_display + + if (response.data.complete) { + this.progressValue = this.progressMax + this.stillInProgress = false + % if can_cancel: + this.canCancel = false + % endif + + location.href = response.data.success_url + + } else { + this.progressValue = response.data.value + this.progressMax = response.data.maximum + } + } + + // custom logic if applicable + this.updateProgressCustom(response) + + if (this.stillInProgress) { + + // fetch progress data again, in one second from now + setTimeout(() => { + this.updateProgress() + }, 1000) + } + } + }) + }, + + updateProgressCustom(response) {}, + + % if can_cancel: + + cancelProgress() { + + if (confirm("Do you really wish to cancel this operation?")) { + + this.cancelingProgress = true + this.stillInProgress = false + + let params = {cancel_msg: ${json.dumps(cancel_msg)|n}} + this.$http.get(this.cancelURL, {params: params}).then(response => { + location.href = ${json.dumps(cancel_url)|n} + }) + } + + }, + + % endif + } } - </script> -</%def> + + let WholePageData = { + + progressURL: '${url('progress', key=progress.key, _query={'sessiontype': progress.session.type})}', + progressMessage: "${(initial_msg or "Working").replace('"', '\\"')} (please wait)", + progressMax: null, + progressMaxDisplay: null, + progressValue: null, + stillInProgress: true, + + % if can_cancel: + canCancel: true, + cancelURL: '${url('progress.cancel', key=progress.key, _query={'sessiontype': progress.session.type})}', + cancelingProgress: false, + % endif + } + + </script> + + ${self.modify_whole_page_vars()} + ${self.make_whole_page_app()} + + </body> +</html> <%def name="extra_styles()"></%def> <%def name="after_progress()"></%def> + +<%def name="modify_whole_page_vars()"></%def> + +<%def name="make_whole_page_app()"> + <script type="text/javascript"> + + WholePage.data = function() { return WholePageData } + + Vue.component('whole-page', WholePage) + + new Vue({ + el: '#whole-page-app' + }) + + </script> +</%def> diff --git a/tailbone/templates/themes/excite-bike/base.mako b/tailbone/templates/themes/excite-bike/base.mako deleted file mode 100644 index d4328621..00000000 --- a/tailbone/templates/themes/excite-bike/base.mako +++ /dev/null @@ -1,8 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="tailbone:templates/base.mako" /> - -<%def name="jquery_theme()"> - ${h.stylesheet_link('https://code.jquery.com/ui/1.11.4/themes/excite-bike/jquery-ui.css')} -</%def> - -${parent.body()} diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index c2970193..1869043b 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -1,928 +1,4 @@ ## -*- coding: utf-8; -*- -<%namespace file="/grids/nav.mako" import="grid_index_nav" /> -<%namespace file="/autocomplete.mako" import="tailbone_autocomplete_template" /> -<%namespace name="base_meta" file="/base_meta.mako" /> -<%namespace file="/formposter.mako" import="declare_formposter_mixin" /> -<%namespace name="page_help" file="/page_help.mako" /> -<%namespace name="multi_file_upload" file="/multi_file_upload.mako" /> -<!DOCTYPE html> -<html lang="en"> - <head> - <meta http-equiv="content-type" content="text/html; charset=UTF-8" /> - <title>${base_meta.global_title()} » ${capture(self.title)|n}</title> - ${base_meta.favicon()} - ${self.header_core()} +<%inherit file="tailbone:templates/base.mako" /> - % if background_color: - <style type="text/css"> - body, .navbar, .footer { - background-color: ${background_color}; - } - </style> - % endif - - % if not request.rattail_config.production(): - <style type="text/css"> - body, .navbar, .footer { - background-image: url(${request.static_url('tailbone:static/img/testing.png')}); - } - </style> - % endif - - ${self.head_tags()} - </head> - - <body> - ${declare_formposter_mixin()} - - ${self.body()} - - <div id="whole-page-app"> - <whole-page></whole-page> - </div> - - ${self.render_whole_page_template()} - ${self.make_whole_page_component()} - ${self.make_whole_page_app()} - </body> -</html> - -<%def name="title()"></%def> - -<%def name="content_title()"> - ${self.title()} -</%def> - -<%def name="header_core()"> - - ${self.core_javascript()} - ${self.extra_javascript()} - ${self.core_styles()} - ${self.extra_styles()} - - ## TODO: should this be elsewhere / more customizable? - % if dform is not Undefined: - <% resources = dform.get_widget_resources() %> - % for path in resources['js']: - ${h.javascript_link(request.static_url(path))} - % endfor - % for path in resources['css']: - ${h.stylesheet_link(request.static_url(path))} - % endfor - % endif -</%def> - -<%def name="core_javascript()"> - ${self.jquery()} - ${self.vuejs()} - ${self.buefy()} - ${self.fontawesome()} - - ## some commonly-useful logic for detecting (non-)numeric input - ${h.javascript_link(request.static_url('tailbone:static/js/numeric.js') + '?ver={}'.format(tailbone.__version__))} - - ## debounce, for better autocomplete performance - ${h.javascript_link(request.static_url('tailbone:static/js/debounce.js') + '?ver={}'.format(tailbone.__version__))} - - ## Tailbone / Buefy stuff - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.datepicker.js') + '?ver={}'.format(tailbone.__version__))} - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.numericinput.js') + '?ver={}'.format(tailbone.__version__))} - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.oncebutton.js') + '?ver={}'.format(tailbone.__version__))} - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.timepicker.js') + '?ver={}'.format(tailbone.__version__))} - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.grid.js') + '?ver={}'.format(tailbone.__version__))} - - <script type="text/javascript"> - var session_timeout = ${request.get_session_timeout() or 'null'}; - var logout_url = '${request.route_url('logout')}'; - var noop_url = '${request.route_url('noop')}'; - $(function() { - ## NOTE: this code was copied from - ## https://bulma.io/documentation/components/navbar/#navbar-menu - $('.navbar-burger').click(function() { - $('.navbar-burger').toggleClass('is-active'); - $('.navbar-menu').toggleClass('is-active'); - }); - }); - </script> -</%def> - -<%def name="jquery()"> - ${h.javascript_link(h.get_liburl(request, 'jquery'))} -</%def> - -<%def name="vuejs()"> - ${h.javascript_link(h.get_liburl(request, 'vue'))} - ${h.javascript_link(h.get_liburl(request, 'vue_resource'))} -</%def> - -<%def name="buefy()"> - ${h.javascript_link(h.get_liburl(request, 'buefy'))} -</%def> - -<%def name="fontawesome()"> - <script defer src="${h.get_liburl(request, 'fontawesome')}"></script> -</%def> - -<%def name="extra_javascript()"></%def> - -<%def name="core_styles()"> - - ${self.buefy_styles()} - - ${h.stylesheet_link(request.static_url('tailbone:static/themes/falafel/css/base.css') + '?ver={}'.format(tailbone.__version__))} - ${h.stylesheet_link(request.static_url('tailbone:static/themes/falafel/css/layout.css') + '?ver={}'.format(tailbone.__version__))} - ${h.stylesheet_link(request.static_url('tailbone:static/css/grids.css') + '?ver={}'.format(tailbone.__version__))} - ${h.stylesheet_link(request.static_url('tailbone:static/themes/falafel/css/grids.css') + '?ver={}'.format(tailbone.__version__))} - ${h.stylesheet_link(request.static_url('tailbone:static/themes/falafel/css/grids.rowstatus.css') + '?ver={}'.format(tailbone.__version__))} -## ${h.stylesheet_link(request.static_url('tailbone:static/css/filters.css') + '?ver={}'.format(tailbone.__version__))} - ${h.stylesheet_link(request.static_url('tailbone:static/themes/falafel/css/filters.css') + '?ver={}'.format(tailbone.__version__))} - ${h.stylesheet_link(request.static_url('tailbone:static/themes/falafel/css/forms.css') + '?ver={}'.format(tailbone.__version__))} - ${h.stylesheet_link(request.static_url('tailbone:static/css/diffs.css') + '?ver={}'.format(tailbone.__version__))} - - ${h.stylesheet_link(request.static_url('tailbone:static/css/codehilite.css') + '?ver={}'.format(tailbone.__version__))} - - <style type="text/css"> - .filters .filter-fieldname { - min-width: ${filter_fieldname_width}; - justify-content: left; - } - .filters .filter-verb { - min-width: ${filter_verb_width}; - } - </style> -</%def> - -<%def name="buefy_styles()"> - % if buefy_css: - ## custom Buefy CSS - ${h.stylesheet_link(buefy_css)} - % else: - ## upstream Buefy CSS - ${h.stylesheet_link(h.get_liburl(request, 'buefy.css'))} - % endif -</%def> - -## TODO: this is only being referenced by the progress template i think? -## (so, should make a Buefy progress page at least) -<%def name="jquery_theme()"> - ${h.stylesheet_link(h.get_liburl(request, 'jquery_ui'))} -</%def> - -<%def name="extra_styles()"></%def> - -<%def name="head_tags()"></%def> - -<%def name="render_whole_page_template()"> - <script type="text/x-template" id="whole-page-template"> - <div> - <header> - - <nav class="navbar" role="navigation" aria-label="main navigation"> - - <div class="navbar-brand"> - <a class="navbar-item" href="${url('home')}" - v-show="!globalSearchActive"> - ${base_meta.header_logo()} - <div id="global-header-title"> - ${base_meta.global_title()} - </div> - </a> - <div v-show="globalSearchActive" - class="navbar-item"> - <b-autocomplete ref="globalSearchAutocomplete" - v-model="globalSearchTerm" - :data="globalSearchFilteredData" - field="label" - open-on-focus - keep-first - icon-pack="fas" - clearable - @keydown.native="globalSearchKeydown" - @select="globalSearchSelect"> - </b-autocomplete> - </div> - <a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false"> - <span aria-hidden="true"></span> - <span aria-hidden="true"></span> - <span aria-hidden="true"></span> - </a> - </div> - - <div class="navbar-menu"> - <div class="navbar-start"> - - <div v-if="globalSearchData.length" - class="navbar-item"> - <b-button type="is-primary" - size="is-small" - @click="globalSearchInit()"> - <span><i class="fa fa-search"></i></span> - </b-button> - </div> - - % for topitem in menus: - % if topitem['is_link']: - ${h.link_to(topitem['title'], topitem['url'], target=topitem['target'], class_='navbar-item')} - % else: - <div class="navbar-item has-dropdown is-hoverable"> - <a class="navbar-link">${topitem['title']}</a> - <div class="navbar-dropdown"> - % for item in topitem['items']: - % if item['is_menu']: - <% item_hash = id(item) %> - <% toggle = 'menu_{}_shown'.format(item_hash) %> - <div> - <a class="navbar-link" @click.prevent="toggleNestedMenu('${item_hash}')"> - ${item['title']} - </a> - </div> - % for subitem in item['items']: - % if subitem['is_sep']: - <hr class="navbar-divider" v-show="${toggle}"> - % else: - ${h.link_to("{}".format(subitem['title']), subitem['url'], class_='navbar-item nested', target=subitem['target'], **{'v-show': toggle})} - % endif - % endfor - % else: - % if item['is_sep']: - <hr class="navbar-divider"> - % else: - ${h.link_to(item['title'], item['url'], class_='navbar-item', target=item['target'])} - % endif - % endif - % endfor - </div> - </div> - % endif - % endfor - - </div><!-- navbar-start --> - ${self.render_navbar_end()} - </div> - </nav> - - <nav class="level" style="margin: 0.5rem auto;"> - <div class="level-left"> - - ## Current Context - <div id="current-context" class="level-item"> - % if master: - % if master.listing: - <span class="header-text"> - ${index_title} - </span> - % if master.creatable and master.show_create_link and master.has_perm('create'): - <once-button type="is-primary" - tag="a" href="${url('{}.create'.format(route_prefix))}" - icon-left="plus" - style="margin-left: 1rem;" - text="Create New"> - </once-button> - % endif - % elif index_url: - <span class="header-text"> - ${h.link_to(index_title, index_url)} - </span> - % if parent_url is not Undefined: - <span class="header-text"> - » - </span> - <span class="header-text"> - ${h.link_to(parent_title, parent_url)} - </span> - % elif instance_url is not Undefined: - <span class="header-text"> - » - </span> - <span class="header-text"> - ${h.link_to(instance_title, instance_url)} - </span> - % elif master.creatable and master.show_create_link and master.has_perm('create'): - % if not request.matched_route.name.endswith('.create'): - <once-button type="is-primary" - tag="a" href="${url('{}.create'.format(route_prefix))}" - icon-left="plus" - style="margin-left: 1rem;" - text="Create New"> - </once-button> - % endif - % endif - % if master.viewing and grid_index: - ${grid_index_nav()} - % endif - % else: - <span class="header-text"> - ${index_title} - </span> - % endif - % elif index_title: - % if index_url: - <span class="header-text"> - ${h.link_to(index_title, index_url)} - </span> - % else: - <span class="header-text"> - ${index_title} - </span> - % endif - % endif - </div> - - % if expose_db_picker is not Undefined and expose_db_picker: - <div class="level-item"> - <p>DB:</p> - </div> - <div class="level-item"> - ${h.form(url('change_db_engine'), ref='dbPickerForm')} - ${h.csrf_token(request)} - ${h.hidden('engine_type', value=master.engine_type_key)} - <div class="select"> - ${h.select('dbkey', db_picker_selected, db_picker_options, **{'@change': 'changeDB()'})} - </div> - ${h.end_form()} - </div> - % endif - - </div><!-- level-left --> - <div class="level-right"> - - ## Quickie Lookup - % if quickie is not Undefined and quickie and request.has_perm(quickie.perm): - <div class="level-item"> - ${h.form(quickie.url, method="get")} - <div class="level"> - <div class="level-right"> - <div class="level-item"> - <b-input name="entry" - placeholder="${quickie.placeholder}" - autocomplete="off"> - </b-input> - </div> - <div class="level-item"> - <button type="submit" class="button is-primary"> - <span class="icon is-small"> - <i class="fas fa-search"></i> - </span> - <span>Lookup</span> - </button> - </div> - </div> - </div> - ${h.end_form()} - </div> - % endif - - % if master and master.configurable and master.has_perm('configure'): - % if not request.matched_route.name.endswith('.configure'): - <div class="level-item"> - <once-button type="is-primary" - tag="a" - href="${url('{}.configure'.format(route_prefix))}" - icon-left="cog" - text="${(configure_button_title or "Configure") if configure_button_title is not Undefined else "Configure"}"> - </once-button> - </div> - % endif - % endif - - ## Theme Picker - % if expose_theme_picker and request.has_perm('common.change_app_theme'): - <div class="level-item"> - ${h.form(url('change_theme'), method="post", ref='themePickerForm')} - ${h.csrf_token(request)} - Theme: - <div class="theme-picker"> - <div class="select"> - ${h.select('theme', theme, theme_picker_options, **{'@change': 'changeTheme()'})} - </div> - </div> - ${h.end_form()} - </div> - % endif - - <div class="level-item"> - <page-help - % if can_edit_help: - @configure-fields-help="configureFieldsHelp = true" - % endif - > - </page-help> - </div> - - ## Feedback Button / Dialog - % if request.has_perm('common.feedback'): - <feedback-form - action="${url('feedback')}" - :message="feedbackMessage"> - </feedback-form> - % endif - - </div><!-- level-right --> - </nav><!-- level --> - </header> - - ## Page Title - % if capture(self.content_title): - <section id="content-title" class="hero is-primary"> - <div class="level"> - <div class="level-left"> - <div class="level-item"> - <h1 class="title" v-html="contentTitleHTML"></h1> - </div> - </div> - <div class="level-right"> - ${self.render_instance_header_buttons()} - </div> - </div> - </section> - % endif - - <div class="content-wrapper"> - - ## Page Body - <section id="page-body"> - - % if request.session.peek_flash('error'): - % for error in request.session.pop_flash('error'): - <b-notification type="is-warning"> - ${error} - </b-notification> - % endfor - % endif - - % if request.session.peek_flash('warning'): - % for msg in request.session.pop_flash('warning'): - <b-notification type="is-warning"> - ${msg} - </b-notification> - % endfor - % endif - - % if request.session.peek_flash(): - % for msg in request.session.pop_flash(): - <b-notification type="is-info"> - ${msg} - </b-notification> - % endfor - % endif - - ${self.render_this_page_component()} - </section> - - ## Footer - <footer class="footer"> - <div class="content"> - ${base_meta.footer()} - </div> - </footer> - - </div><!-- content-wrapper --> - </div> - </script> - - ${page_help.render_template()} - - <script type="text/x-template" id="feedback-template"> - <div> - - <div class="level-item"> - <b-button type="is-primary" - @click="showFeedback()" - icon-pack="fas" - icon-left="fas fa-comment"> - Feedback - </b-button> - </div> - - <b-modal has-modal-card - :active.sync="showDialog"> - <div class="modal-card"> - - <header class="modal-card-head"> - <p class="modal-card-title">User Feedback</p> - </header> - - <section class="modal-card-body"> - <p class="block"> - Questions, suggestions, comments, complaints, etc. - <span class="red">regarding this website</span> are - welcome and may be submitted below. - </p> - - <b-field label="User Name"> - <b-input v-model="userName" - % if request.user: - disabled - % endif - > - </b-input> - </b-field> - - <b-field label="Referring URL"> - <b-input - v-model="referrer" - disabled="true"> - </b-input> - </b-field> - - <b-field label="Message"> - <b-input type="textarea" - v-model="message" - ref="textarea"> - </b-input> - </b-field> - - % if request.rattail_config.getbool('tailbone', 'feedback_allows_reply'): - <div class="level"> - <div class="level-left"> - <div class="level-item"> - <b-checkbox v-model="pleaseReply" - @input="pleaseReplyChanged"> - Please email me back{{ pleaseReply ? " at: " : "" }} - </b-checkbox> - </div> - <div class="level-item" v-show="pleaseReply"> - <b-input v-model="userEmail" - ref="userEmail"> - </b-input> - </div> - </div> - </div> - % endif - - </section> - - <footer class="modal-card-foot"> - <b-button @click="showDialog = false"> - Cancel - </b-button> - <once-button type="is-primary" - @click="sendFeedback()" - :disabled="!message.trim()" - text="Send Message"> - </once-button> - </footer> - </div> - </b-modal> - - </div> - </script> - - ${tailbone_autocomplete_template()} - ${multi_file_upload.render_template()} -</%def> - -<%def name="render_this_page_component()"> - <this-page @change-content-title="changeContentTitle" - % if can_edit_help: - :configure-fields-help="configureFieldsHelp" - % endif - > - </this-page> -</%def> - -<%def name="render_navbar_end()"> - <div class="navbar-end"> - ${self.render_user_menu()} - </div> -</%def> - -<%def name="render_user_menu()"> - % if request.user: - <div class="navbar-item has-dropdown is-hoverable"> - % if messaging_enabled: - <a class="navbar-link ${'root-user' if request.is_root else ''}">${request.user}${" ({})".format(inbox_count) if inbox_count else ''}</a> - % else: - <a class="navbar-link ${'root-user' if request.is_root else ''}">${request.user}</a> - % endif - <div class="navbar-dropdown"> - % if request.is_root: - ${h.link_to("Stop being root", url('stop_root'), class_='navbar-item root-user')} - % elif request.is_admin: - ${h.link_to("Become root", url('become_root'), class_='navbar-item root-user')} - % endif - % if messaging_enabled: - ${h.link_to("Messages{}".format(" ({})".format(inbox_count) if inbox_count else ''), url('messages.inbox'), class_='navbar-item')} - % endif - ${h.link_to("Change Password", url('change_password'), class_='navbar-item')} - ${h.link_to("Edit Preferences", url('my.preferences'), class_='navbar-item')} - ${h.link_to("Logout", url('logout'), class_='navbar-item')} - </div> - </div> - % else: - ${h.link_to("Login", url('login'), class_='navbar-item')} - % endif -</%def> - -<%def name="render_instance_header_buttons()"> - ${self.render_crud_header_buttons()} - ${self.render_prevnext_header_buttons()} -</%def> - -<%def name="render_crud_header_buttons()"> - % if master and master.viewing: - ## TODO: is there a better way to check if viewing parent? - % if parent_instance is Undefined: - % if master.editable and instance_editable and master.has_perm('edit'): - <div class="level-item"> - <once-button tag="a" href="${action_url('edit', instance)}" - icon-left="edit" - text="Edit This"> - </once-button> - </div> - % endif - % if master.cloneable and master.has_perm('clone'): - <div class="level-item"> - <once-button tag="a" href="${action_url('clone', instance)}" - icon-left="object-ungroup" - text="Clone This"> - </once-button> - </div> - % endif - % if master.deletable and instance_deletable and master.has_perm('delete'): - <div class="level-item"> - <once-button tag="a" href="${action_url('delete', instance)}" - type="is-danger" - icon-left="trash" - text="Delete This"> - </once-button> - </div> - % endif - % else: - ## viewing row - % if instance_deletable and master.has_perm('delete_row'): - <div class="level-item"> - <once-button tag="a" href="${action_url('delete', instance)}" - type="is-danger" - icon-left="trash" - text="Delete This"> - </once-button> - </div> - % endif - % endif - % elif master and master.editing: - % if master.viewable and master.has_perm('view'): - <div class="level-item"> - <once-button tag="a" href="${action_url('view', instance)}" - icon-left="eye" - text="View This"> - </once-button> - </div> - % endif - % if master.deletable and instance_deletable and master.has_perm('delete'): - <div class="level-item"> - <once-button tag="a" href="${action_url('delete', instance)}" - type="is-danger" - icon-left="trash" - text="Delete This"> - </once-button> - </div> - % endif - % elif master and master.deleting: - % if master.viewable and master.has_perm('view'): - <div class="level-item"> - <once-button tag="a" href="${action_url('view', instance)}" - icon-left="eye" - text="View This"> - </once-button> - </div> - % endif - % if master.editable and instance_editable and master.has_perm('edit'): - <div class="level-item"> - <once-button tag="a" href="${action_url('edit', instance)}" - icon-left="edit" - text="Edit This"> - </once-button> - </div> - % endif - % endif -</%def> - -<%def name="render_prevnext_header_buttons()"> - % if show_prev_next is not Undefined and show_prev_next: - % if prev_url: - <div class="level-item"> - <b-button tag="a" href="${prev_url}" - icon-pack="fas" - icon-left="arrow-left"> - Older - </b-button> - </div> - % else: - <div class="level-item"> - <b-button tag="a" href="#" - disabled - icon-pack="fas" - icon-left="arrow-left"> - Older - </b-button> - </div> - % endif - % if next_url: - <div class="level-item"> - <b-button tag="a" href="${next_url}" - icon-pack="fas" - icon-left="arrow-right"> - Newer - </b-button> - </div> - % else: - <div class="level-item"> - <b-button tag="a" href="#" - disabled - icon-pack="fas" - icon-left="arrow-right"> - Newer - </b-button> - </div> - % endif - % endif -</%def> - -<%def name="declare_whole_page_vars()"> - ${page_help.declare_vars()} - ${multi_file_upload.declare_vars()} - ${h.javascript_link(request.static_url('tailbone:static/themes/falafel/js/tailbone.feedback.js') + '?ver={}'.format(tailbone.__version__))} - <script type="text/javascript"> - - let WholePage = { - template: '#whole-page-template', - mixins: [FormPosterMixin], - computed: { - - globalSearchFilteredData() { - if (!this.globalSearchTerm.length) { - return this.globalSearchData - } - return this.globalSearchData.filter((option) => { - return option.label.toLowerCase().indexOf(this.globalSearchTerm.toLowerCase()) >= 0 - }) - }, - - }, - - mounted() { - window.addEventListener('keydown', this.globalKey) - for (let hook of this.mountedHooks) { - hook(this) - } - }, - beforeDestroy() { - window.removeEventListener('keydown', this.globalKey) - }, - - methods: { - - changeContentTitle(newTitle) { - this.contentTitleHTML = newTitle - }, - - % if expose_db_picker is not Undefined and expose_db_picker: - changeDB() { - this.$refs.dbPickerForm.submit() - }, - % endif - - % if expose_theme_picker and request.has_perm('common.change_app_theme'): - changeTheme() { - this.$refs.themePickerForm.submit() - }, - % endif - - globalKey(event) { - - // Ctrl+8 opens global search - if (event.target.tagName == 'BODY') { - if (event.ctrlKey && event.key == '8') { - this.globalSearchInit() - } - } - }, - - globalSearchInit() { - this.globalSearchTerm = '' - this.globalSearchActive = true - this.$nextTick(() => { - this.$refs.globalSearchAutocomplete.focus() - }) - }, - - globalSearchKeydown(event) { - - // ESC will dismiss searchbox - if (event.which == 27) { - this.globalSearchActive = false - } - }, - - globalSearchSelect(option) { - location.href = option.url - }, - - toggleNestedMenu(hash) { - const key = 'menu_' + hash + '_shown' - this[key] = !this[key] - }, - }, - } - - let WholePageData = { - contentTitleHTML: ${json.dumps(capture(self.content_title))|n}, - feedbackMessage: "", - - % if can_edit_help: - configureFieldsHelp: false, - % endif - - globalSearchActive: false, - globalSearchTerm: '', - globalSearchData: ${json.dumps(global_search_data)|n}, - - mountedHooks: [], - } - - ## declare nested menu visibility toggle flags - % for topitem in menus: - % if topitem['is_menu']: - % for item in topitem['items']: - % if item['is_menu']: - WholePageData.menu_${id(item)}_shown = false - % endif - % endfor - % endif - % endfor - - </script> -</%def> - -<%def name="modify_whole_page_vars()"> - <script type="text/javascript"> - - FeedbackFormData.referrer = location.href - - % if request.user: - FeedbackFormData.userUUID = ${json.dumps(request.user.uuid)|n} - FeedbackFormData.userName = ${json.dumps(six.text_type(request.user))|n} - % endif - - </script> -</%def> - -<%def name="finalize_whole_page_vars()"> - ## NOTE: if you override this, must use <script> tags -</%def> - -<%def name="make_whole_page_component()"> - ${self.declare_whole_page_vars()} - ${self.modify_whole_page_vars()} - ${self.finalize_whole_page_vars()} - - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.autocomplete.js') + '?ver={}'.format(tailbone.__version__))} - - ${page_help.make_component()} - ${multi_file_upload.make_component()} - - <script type="text/javascript"> - - FeedbackForm.data = function() { return FeedbackFormData } - - Vue.component('feedback-form', FeedbackForm) - - WholePage.data = function() { return WholePageData } - - Vue.component('whole-page', WholePage) - - </script> -</%def> - -<%def name="make_whole_page_app()"> - <script type="text/javascript"> - - new Vue({ - el: '#whole-page-app' - }) - - </script> -</%def> - -<%def name="wtfield(form, name, **kwargs)"> - <div class="field-wrapper${' error' if form[name].errors else ''}"> - <label for="${name}">${form[name].label}</label> - <div class="field"> - ${form[name](**kwargs)} - </div> - </div> -</%def> - -<%def name="simple_field(label, value)"> - ## TODO: keep this? only used by personal profile view currently - ## (although could be useful for any readonly scenario) - <div class="field-wrapper"> - <div class="field-row"> - <label>${label}</label> - <div class="field"> - ${'' if value is None else value} - </div> - </div> - </div> -</%def> +${parent.body()} diff --git a/tailbone/templates/themes/falafel/progress.mako b/tailbone/templates/themes/falafel/progress.mako index ad0a1371..4bb27014 100644 --- a/tailbone/templates/themes/falafel/progress.mako +++ b/tailbone/templates/themes/falafel/progress.mako @@ -1,219 +1,4 @@ ## -*- coding: utf-8; -*- -<%namespace file="/base.mako" import="core_javascript" /> -<%namespace file="/base.mako" import="core_styles" /> -<!DOCTYPE html> -<html lang="en"> - <head> - <meta http-equiv="content-type" content="text/html; charset=UTF-8" /> - <title>${initial_msg or "Working"}...</title> - ${core_javascript()} - ${core_styles()} - ${self.extra_styles()} - </head> - <body style="height: 100%;"> +<%inherit file="tailbone:templates/progress.mako" /> - <div id="whole-page-app"> - <whole-page></whole-page> - </div> - - <script type="text/x-template" id="whole-page-template"> - - <section class="hero is-fullheight"> - <div class="hero-body"> - <div class="container"> - - <div style="display: flex;"> - <div style="flex-grow: 1;"></div> - <div> - - <p class="block"> - {{ progressMessage }} ... {{ totalDisplay }} - </p> - - <div class="level"> - - <div class="level-item"> - <b-progress size="is-large" - style="width: 400px;" - :max="progressMax" - :value="progressValue" - show-value - format="percent" - precision="0"> - </b-progress> - </div> - - % if can_cancel: - <div class="level-item" - style="margin-left: 2rem;"> - <b-button v-show="canCancel" - @click="cancelProgress()" - :disabled="cancelingProgress" - icon-pack="fas" - icon-left="ban"> - {{ cancelingProgress ? "Canceling, please wait..." : "Cancel" }} - </b-button> - </div> - % endif - - </div> - - </div> - <div style="flex-grow: 1;"></div> - </div> - - ${self.after_progress()} - - </div> - </div> - </section> - - </script> - - <script type="text/javascript"> - - let WholePage = { - template: '#whole-page-template', - - computed: { - - totalDisplay() { - - % if can_cancel: - if (!this.stillInProgress && !this.cancelingProgress) { - % else: - if (!this.stillInProgress) { - % endif - return "done!" - } - - if (this.progressMaxDisplay) { - return `(${'$'}{this.progressMaxDisplay} total)` - } - }, - }, - - mounted() { - - // fetch first progress data, one second from now - setTimeout(() => { - this.updateProgress() - }, 1000) - - // custom logic if applicable - this.mountedCustom() - }, - - methods: { - - mountedCustom() {}, - - updateProgress() { - - this.$http.get(this.progressURL).then(response => { - - if (response.data.error) { - // errors stop the show, we redirect to "cancel" page - location.href = '${cancel_url}' - - } else { - - if (response.data.complete || response.data.maximum) { - this.progressMessage = response.data.message - this.progressMaxDisplay = response.data.maximum_display - - if (response.data.complete) { - this.progressValue = this.progressMax - this.stillInProgress = false - % if can_cancel: - this.canCancel = false - % endif - - location.href = response.data.success_url - - } else { - this.progressValue = response.data.value - this.progressMax = response.data.maximum - } - } - - // custom logic if applicable - this.updateProgressCustom(response) - - if (this.stillInProgress) { - - // fetch progress data again, in one second from now - setTimeout(() => { - this.updateProgress() - }, 1000) - } - } - }) - }, - - updateProgressCustom(response) {}, - - % if can_cancel: - - cancelProgress() { - - if (confirm("Do you really wish to cancel this operation?")) { - - this.cancelingProgress = true - this.stillInProgress = false - - let params = {cancel_msg: ${json.dumps(cancel_msg)|n}} - this.$http.get(this.cancelURL, {params: params}).then(response => { - location.href = ${json.dumps(cancel_url)|n} - }) - } - - }, - - % endif - } - } - - let WholePageData = { - - progressURL: '${url('progress', key=progress.key, _query={'sessiontype': progress.session.type})}', - progressMessage: "${(initial_msg or "Working").replace('"', '\\"')} (please wait)", - progressMax: null, - progressMaxDisplay: null, - progressValue: null, - stillInProgress: true, - - % if can_cancel: - canCancel: true, - cancelURL: '${url('progress.cancel', key=progress.key, _query={'sessiontype': progress.session.type})}', - cancelingProgress: false, - % endif - } - - </script> - - ${self.modify_whole_page_vars()} - ${self.make_whole_page_app()} - - </body> -</html> - -<%def name="extra_styles()"></%def> - -<%def name="after_progress()"></%def> - -<%def name="modify_whole_page_vars()"></%def> - -<%def name="make_whole_page_app()"> - <script type="text/javascript"> - - WholePage.data = function() { return WholePageData } - - Vue.component('whole-page', WholePage) - - new Vue({ - el: '#whole-page-app' - }) - - </script> -</%def> +${parent.body()} diff --git a/tailbone/templates/themes/falafel/upgrade.mako b/tailbone/templates/themes/falafel/upgrade.mako index 7cc73941..b7562653 100644 --- a/tailbone/templates/themes/falafel/upgrade.mako +++ b/tailbone/templates/themes/falafel/upgrade.mako @@ -1,69 +1,4 @@ ## -*- coding: utf-8; -*- -<%inherit file="/progress.mako" /> - -<%def name="extra_styles()"> - ${parent.extra_styles()} - <style type="text/css"> - - .progress-with-textout { - border: 1px solid Black; - line-height: 1.2; - margin-top: 1rem; - overflow: auto; - padding: 1rem; - } - - </style> -</%def> - -<%def name="after_progress()"> - <!-- <div ref="stdout" class="stdout"></div> --> - - <div ref="textout" - class="progress-with-textout is-family-monospace is-size-7"> - <span v-for="line in progressOutput" - :key="line.key" - v-html="line.text"> - </span> - - ## nb. we auto-scroll down to "see" this element - <div ref="seeme"></div> - </div> - -</%def> - -<%def name="modify_whole_page_vars()"> - <script type="text/javascript"> - - WholePageData.progressURL = '${url('upgrades.execute_progress', uuid=instance.uuid)}' - WholePageData.progressOutput = [] - WholePageData.progressOutputCounter = 0 - - WholePage.methods.mountedCustom = function() { - - // grow the textout area to fill most of screen - let textout = this.$refs.textout - let height = window.innerHeight - textout.offsetTop - 100 - textout.style.height = height + 'px' - } - - WholePage.methods.updateProgressCustom = function(response) { - if (response.data.stdout) { - - // add lines to textout area - this.progressOutput.push({ - key: ++this.progressOutputCounter, - text: response.data.stdout}) - - // scroll down to end of textout area - this.$nextTick(() => { - this.$refs.seeme.scrollIntoView({behavior: 'smooth'}) - }) - } - } - - </script> -</%def> - +<%inherit file="tailbone:templates/upgrade.mako" /> ${parent.body()} diff --git a/tailbone/templates/upgrade.mako b/tailbone/templates/upgrade.mako index a12361c5..7cc73941 100644 --- a/tailbone/templates/upgrade.mako +++ b/tailbone/templates/upgrade.mako @@ -1,86 +1,69 @@ ## -*- coding: utf-8; -*- <%inherit file="/progress.mako" /> -<%def name="update_progress_func()"> - <script type="text/javascript"> - - function update_progress() { - $.ajax({ - url: '${url('upgrades.execute_progress', uuid=instance.uuid)}', - success: function(data) { - if (data.error) { - location.href = '${cancel_url}'; - } else { - - if (data.stdout) { - var stdout = $('.stdout'); - var height = $(window).height() - stdout.offset().top - 50; - stdout.height(height); - stdout.append(data.stdout); - stdout.animate({scrollTop: stdout.get(0).scrollHeight - height}, 250); - } - - if (data.complete || data.maximum) { - $('#message').html(data.message); - $('#total').html('('+data.maximum_display+' total)'); - $('#cancel button').show(); - if (data.complete) { - stillInProgress = false; - $('#cancel button').hide(); - $('#total').html('done!'); - $('#complete').css('width', '100%'); - $('#remaining').hide(); - $('#percentage').html('100 %'); - location.href = data.success_url; - } else { - var width = parseInt(data.value) / parseInt(data.maximum); - width = Math.round(100 * width); - if (width) { - $('#complete').css('width', width+'%'); - $('#percentage').html(width+' %'); - } else { - $('#complete').css('width', '0.01%'); - $('#percentage').html('0 %'); - } - $('#remaining').css('width', 'auto'); - } - } - - if (stillInProgress) { - // fetch progress data again, in one second from now - setTimeout(function() { - update_progress(); - }, 1000); - } - } - } - }); - } - </script> -</%def> - <%def name="extra_styles()"> ${parent.extra_styles()} <style type="text/css"> - #wrapper { - top: 6em; - } - .stdout { + + .progress-with-textout { border: 1px solid Black; - height: 500px; - margin-left: 4.5%; + line-height: 1.2; + margin-top: 1rem; overflow: auto; - padding: 4px; - position: absolute; - top: 10em; - white-space: nowrap; - width: 90%; + padding: 1rem; } + </style> </%def> <%def name="after_progress()"> - <div class="stdout"></div> + <!-- <div ref="stdout" class="stdout"></div> --> + + <div ref="textout" + class="progress-with-textout is-family-monospace is-size-7"> + <span v-for="line in progressOutput" + :key="line.key" + v-html="line.text"> + </span> + + ## nb. we auto-scroll down to "see" this element + <div ref="seeme"></div> + </div> + </%def> +<%def name="modify_whole_page_vars()"> + <script type="text/javascript"> + + WholePageData.progressURL = '${url('upgrades.execute_progress', uuid=instance.uuid)}' + WholePageData.progressOutput = [] + WholePageData.progressOutputCounter = 0 + + WholePage.methods.mountedCustom = function() { + + // grow the textout area to fill most of screen + let textout = this.$refs.textout + let height = window.innerHeight - textout.offsetTop - 100 + textout.style.height = height + 'px' + } + + WholePage.methods.updateProgressCustom = function(response) { + if (response.data.stdout) { + + // add lines to textout area + this.progressOutput.push({ + key: ++this.progressOutputCounter, + text: response.data.stdout}) + + // scroll down to end of textout area + this.$nextTick(() => { + this.$refs.seeme.scrollIntoView({behavior: 'smooth'}) + }) + } + } + + </script> +</%def> + + ${parent.body()} From eddbfcab36097f54b117afd5d2d59fd929da3345 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 3 Feb 2023 16:00:14 -0600 Subject: [PATCH 1008/1681] Allow editing the Department field for a Subdepartment --- tailbone/views/subdepartments.py | 37 +++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/tailbone/views/subdepartments.py b/tailbone/views/subdepartments.py index 84a34dee..43648ea6 100644 --- a/tailbone/views/subdepartments.py +++ b/tailbone/views/subdepartments.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,12 @@ Subdepartment Views """ -from __future__ import unicode_literals, absolute_import +import sqlalchemy as sa from rattail.db import model +from deform import widget as dfwidget + from tailbone.db import Session from tailbone.views import MasterView @@ -104,9 +106,34 @@ class SubdepartmentView(MasterView): super(SubdepartmentView, self).configure_form(f) f.remove_field('products') - # TODO: figure out this dang department situation.. - f.remove_field('department_uuid') - f.set_readonly('department') + # department + if self.creating or self.editing: + if 'department' in f.fields: + f.replace('department', 'department_uuid') + departments = self.get_departments() + dept_values = [(d.uuid, "{} {}".format(d.number, d.name)) + for d in departments] + require_department = False + if not require_department: + dept_values.insert(0, ('', "(none)")) + f.set_widget('department_uuid', + dfwidget.SelectWidget(values=dept_values)) + f.set_label('department_uuid', "Department") + else: + f.set_readonly('department') + f.set_renderer('department', self.render_department) + + def get_departments(self): + """ + Returns the list of departments to be exposed in a drop-down. + """ + model = self.model + return self.Session.query(model.Department)\ + .filter(sa.or_( + model.Department.product == True, + model.Department.product == None))\ + .order_by(model.Department.name)\ + .all() def get_merge_data(self, subdept): return { From 2ebae17839947751bcc0d027ea965d387f48325b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 3 Feb 2023 16:08:29 -0600 Subject: [PATCH 1009/1681] Refactor the Ordering Worksheet generator, per Buefy --- tailbone/menus.py | 15 +- tailbone/reports/ordering_worksheet.mako | 2 +- tailbone/templates/reports/base.mako | 4 - tailbone/templates/reports/inventory.mako | 4 +- tailbone/templates/reports/ordering.mako | 175 +++++++++++++--------- tailbone/views/reports.py | 3 +- 6 files changed, 121 insertions(+), 82 deletions(-) delete mode 100644 tailbone/templates/reports/base.mako diff --git a/tailbone/menus.py b/tailbone/menus.py index 62cdbfe4..1732c084 100644 --- a/tailbone/menus.py +++ b/tailbone/menus.py @@ -24,8 +24,6 @@ App Menus """ -from __future__ import unicode_literals, absolute_import - import re import logging import warnings @@ -527,6 +525,19 @@ class MenuHandler(GenericHandler): }, ]) + if kwargs.get('include_worksheets', False): + items.extend([ + {'type': 'sep'}, + { + 'title': "Ordering Worksheet", + 'route': 'reports.ordering', + }, + { + 'title': "Inventory Worksheet", + 'route': 'reports.inventory', + }, + ]) + if kwargs.get('include_trainwreck', False): items.extend([ {'type': 'sep'}, diff --git a/tailbone/reports/ordering_worksheet.mako b/tailbone/reports/ordering_worksheet.mako index f6a97dc6..fe3f53e8 100644 --- a/tailbone/reports/ordering_worksheet.mako +++ b/tailbone/reports/ordering_worksheet.mako @@ -111,7 +111,7 @@ <td class="brand">${cost.product.brand or ''}</td> <td class="desc">${cost.product.description}</td> <td class="size">${cost.product.size or ''}</td> - <td class="case-qty">${cost.case_size} ${"LB" if cost.product.weighed else "EA"}</td> + <td class="case-qty">${app.render_quantity(cost.case_size)} ${"LB" if cost.product.weighed else "EA"}</td> <td class="code">${cost.code or ''}</td> <td class="preferred">${'X' if cost.preference == 1 else ''}</td> % for i in range(14): diff --git a/tailbone/templates/reports/base.mako b/tailbone/templates/reports/base.mako deleted file mode 100644 index cc379506..00000000 --- a/tailbone/templates/reports/base.mako +++ /dev/null @@ -1,4 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/page.mako" /> - -${parent.body()} diff --git a/tailbone/templates/reports/inventory.mako b/tailbone/templates/reports/inventory.mako index 74f378fa..6c6e739f 100644 --- a/tailbone/templates/reports/inventory.mako +++ b/tailbone/templates/reports/inventory.mako @@ -1,7 +1,7 @@ ## -*- coding: utf-8 -*- -<%inherit file="/reports/base.mako" /> +<%inherit file="/page.mako" /> -<%def name="title()">Report : Inventory Worksheet</%def> +<%def name="title()">Inventory Worksheet</%def> <%def name="page_content()"> diff --git a/tailbone/templates/reports/ordering.mako b/tailbone/templates/reports/ordering.mako index 1b8d555c..84e9b819 100644 --- a/tailbone/templates/reports/ordering.mako +++ b/tailbone/templates/reports/ordering.mako @@ -1,89 +1,120 @@ ## -*- coding: utf-8 -*- -<%inherit file="/reports/base.mako" /> +<%inherit file="/page.mako" /> -<%def name="title()">Report : Ordering Worksheet</%def> +<%def name="title()">Ordering Worksheet</%def> -<%def name="head_tags()"> - ${parent.head_tags()} - <style type="text/css"> +<%def name="page_content()"> - div.grid { - clear: none; - } + <p class="block"> + Please provide the following criteria to generate your report: + </p> + + <div style="max-width: 50%;"> + ${h.form(request.current_route_url(), **{'@submit': 'validateForm'})} + ${h.csrf_token(request)} + ${h.hidden('departments', **{':value': 'departmentUUIDs'})} + + <b-field label="Vendor"> + <tailbone-autocomplete v-model="vendorUUID" + service-url="${url('vendors.autocomplete')}" + name="vendor" + @input="vendorChanged"> + </tailbone-autocomplete> + </b-field> + + <b-field label="Departments"> + <b-table v-if="fetchedDepartments" + :data="departments" + narrowed + checkable + :checked-rows.sync="checkedDepartments" + :loading="fetchingDepartments"> + + <b-table-column field="number" + label="Number" + v-slot="props"> + {{ props.row.number }} + </b-table-column> + + <b-table-column field="name" + label="Name" + v-slot="props"> + {{ props.row.name }} + </b-table-column> + + </b-table> + </b-field> + + <b-field> + <b-checkbox name="preferred_only" :value="true" + native-value="1"> + Only include products for which this vendor is preferred. + </b-checkbox> + </b-field> + + ${self.extra_fields()} + + <div class="buttons"> + <b-button type="is-primary" + native-type="submit" + icon-pack="fas" + icon-left="arrow-circle-right"> + Generate Report + </b-button> + </div> + + ${h.end_form()} + </div> - </style> </%def> -<p>Please provide the following criteria to generate your report:</p> -<br /> +<%def name="extra_fields()"></%def> -${h.form(request.current_route_url())} -${h.hidden('departments', value='')} +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> -<div class="field-wrapper"> - ${h.hidden('vendor', value='')} - <label for="vendor-name">Vendor:</label> - ${h.text('vendor-name', size='40', value='')} - <div id="vendor-display" style="display: none;"> - <span>(no vendor)</span> - <button type="button" id="change-vendor">Change</button> - </div> -</div> + ThisPageData.vendorUUID = null + ThisPageData.departments = [] + ThisPageData.checkedDepartments = [] + ThisPageData.fetchingDepartments = false + ThisPageData.fetchedDepartments = false -<div class="field-wrapper"> - <label>Departments:</label> - <div class="grid"></div> -</div> + ThisPage.computed.departmentUUIDs = function() { + let uuids = [] + for (let dept of this.checkedDepartments) { + uuids.push(dept.uuid) + } + return uuids.join(',') + } -<div class="field-wrapper"> - ${h.checkbox('preferred_only', label="Include only those products for which this vendor is preferred.", checked=True)} -</div> + ThisPage.methods.vendorChanged = function(uuid) { + if (uuid) { + this.fetchingDepartments = true -<div class="buttons"> - ${h.submit('submit', "Generate Report")} -</div> + let url = '${url('departments.by_vendor')}' + let params = {uuid: uuid} + this.$http.get(url, {params: params}).then(response => { + this.departments = response.data + this.fetchingDepartments = false + this.fetchedDepartments = true + }) -${h.end_form()} + } else { + this.departments = [] + this.fetchedDepartments = false + } + } -<script type="text/javascript"> + ThisPage.methods.validateForm = function(event) { + if (!this.departmentUUIDs.length) { + alert("You must select at least one Department.") + event.preventDefault() + } + } -$(function() { + </script> +</%def> - var autocompleter = $('#vendor-name').autocomplete({ - serviceUrl: '${url('vendors.autocomplete')}', - width: 300, - onSelect: function(value, data) { - $('#vendor').val(data); - $('#vendor-name').hide(); - $('#vendor-name').val(''); - $('#vendor-display span').html(value); - $('#vendor-display').show(); - loading($('div.grid')); - $('div.grid').load('${url('departments.by_vendor')}', {'uuid': data}); - }, - }); - $('#vendor-name').focus(); - - $('#change-vendor').click(function() { - $('#vendor').val(''); - $('#vendor-display').hide(); - $('#vendor-name').show(); - $('#vendor-name').focus(); - $('div.grid').empty(); - }); - - $('form').submit(function() { - var depts = []; - $('div.grid table tbody tr').each(function() { - if ($(this).find('td.checkbox input[type=checkbox]').is(':checked')) { - depts.push(get_uuid(this)); - } - $('#departments').val(depts.toString()); - return true; - }); - }); - -}); - -</script> +${parent.body()} diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index 7664331c..a96ac52e 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -135,7 +135,8 @@ class OrderingWorksheet(View): time=now.strftime('%I:%M %p'), get_upc=self.upc_getter, rattail=rattail, - ) + app=self.get_rattail_app(), + ) template_path = resource_path(self.report_template_path) template = Template(filename=template_path) From 976a5836a90bb20b91d905d42b3b88f996165ca9 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 3 Feb 2023 17:08:33 -0600 Subject: [PATCH 1010/1681] Purge even more jquery stuff and related static files etc. from old themes this might be the end of it..?? --- tailbone/static/css/base.css | 120 +- tailbone/static/css/filters.css | 28 +- tailbone/static/css/forms.css | 90 +- tailbone/static/css/grids.css | 4 + .../falafel => }/css/grids.rowstatus.css | 0 tailbone/static/css/jquery.loadmask.css | 40 - tailbone/static/css/jquery.ui.menubar.css | 15 - tailbone/static/css/jquery.ui.tailbone.css | 14 - tailbone/static/css/jquery.ui.timepicker.css | 57 - tailbone/static/css/layout.css | 291 ++-- tailbone/static/js/jquery.ui.tailbone.js | 452 ----- tailbone/static/js/lib/jquery.loadmask.min.js | 10 - tailbone/static/js/lib/jquery.ui.menubar.js | 331 ---- .../static/js/lib/jquery.ui.timepicker.js | 1496 ----------------- tailbone/static/js/tailbone.edit-shifts.js | 193 --- tailbone/static/js/tailbone.feedback.js | 98 +- tailbone/static/js/tailbone.js | 386 ----- tailbone/static/js/tailbone.timesheet.edit.js | 267 --- tailbone/static/themes/falafel/css/base.css | 14 - .../static/themes/falafel/css/filters.css | 22 - tailbone/static/themes/falafel/css/forms.css | 61 - tailbone/static/themes/falafel/css/grids.css | 15 - tailbone/static/themes/falafel/css/layout.css | 150 -- .../themes/falafel/js/tailbone.feedback.js | 54 - tailbone/templates/autocomplete.mako | 58 - tailbone/templates/base.mako | 65 +- tailbone/templates/customers/view.mako | 17 - tailbone/templates/email-bounces/view.mako | 2 - tailbone/templates/forms/deform.mako | 99 -- tailbone/templates/forms/form_readonly.mako | 8 - tailbone/templates/grids/search.mako | 37 - tailbone/templates/master/versions.mako | 11 - tailbone/templates/people/view_profile.mako | 402 ----- .../templates/purchases/batches/create.mako | 95 +- .../templates/purchases/credits/index.mako | 156 +- tailbone/templates/shifts/base.mako | 99 +- tailbone/templates/shifts/schedule_edit.mako | 129 +- tailbone/templates/shifts/timesheet.mako | 4 +- tailbone/templates/shifts/timesheet_edit.mako | 82 +- tailbone/templates/tempmon/probes/create.mako | 17 - tailbone/templates/tempmon/probes/edit.mako | 17 - tailbone/views/purchases/credits.py | 12 +- 42 files changed, 366 insertions(+), 5152 deletions(-) rename tailbone/static/{themes/falafel => }/css/grids.rowstatus.css (100%) delete mode 100644 tailbone/static/css/jquery.loadmask.css delete mode 100644 tailbone/static/css/jquery.ui.menubar.css delete mode 100644 tailbone/static/css/jquery.ui.tailbone.css delete mode 100644 tailbone/static/css/jquery.ui.timepicker.css delete mode 100644 tailbone/static/js/jquery.ui.tailbone.js delete mode 100644 tailbone/static/js/lib/jquery.loadmask.min.js delete mode 100644 tailbone/static/js/lib/jquery.ui.menubar.js delete mode 100644 tailbone/static/js/lib/jquery.ui.timepicker.js delete mode 100644 tailbone/static/js/tailbone.edit-shifts.js delete mode 100644 tailbone/static/js/tailbone.js delete mode 100644 tailbone/static/js/tailbone.timesheet.edit.js delete mode 100644 tailbone/static/themes/falafel/css/base.css delete mode 100644 tailbone/static/themes/falafel/css/filters.css delete mode 100644 tailbone/static/themes/falafel/css/forms.css delete mode 100644 tailbone/static/themes/falafel/css/grids.css delete mode 100644 tailbone/static/themes/falafel/css/layout.css delete mode 100644 tailbone/static/themes/falafel/js/tailbone.feedback.js delete mode 100644 tailbone/templates/forms/deform.mako delete mode 100644 tailbone/templates/forms/form_readonly.mako delete mode 100644 tailbone/templates/grids/search.mako delete mode 100644 tailbone/templates/people/view_profile.mako delete mode 100644 tailbone/templates/tempmon/probes/create.mako delete mode 100644 tailbone/templates/tempmon/probes/edit.mako diff --git a/tailbone/static/css/base.css b/tailbone/static/css/base.css index 689fb000..0fa02dbb 100644 --- a/tailbone/static/css/base.css +++ b/tailbone/static/css/base.css @@ -1,122 +1,14 @@ -/****************************** - * General - ******************************/ - -* { - margin: 0px; -} - -body { - font-family: Verdana, Arial, sans-serif; - font-size: 11pt; -} - -a { - color: #0972a5; - text-decoration: none; -} - -a:hover { - text-decoration: underline; -} - -h1 { - margin-bottom: 15px; -} - -h2 { - font-size: 12pt; - margin: 20px auto 10px auto; -} - -li { - line-height: 2em; -} - -p { - margin-bottom: 5px; -} - -.left { - float: left; - text-align: left; -} - -.right { - text-align: right; -} - -.wrapper { - overflow: auto; -} - -div.buttons { - clear: both; - margin-top: 10px; -} - -div.dialog { - display: none; -} - -div.flash-message { - background-color: #dddddd; - margin-bottom: 8px; - padding: 3px; -} - -div.flash-messages div.ui-state-highlight { - padding: .3em; - margin-bottom: 8px; -} - -div.error-messages div.ui-state-error { - padding: .3em; - margin-bottom: 8px; -} - -.flash-messages, -.error-messages { - margin: 0.5em 0 0 0; -} - -ul.error { - color: #dd6666; - font-weight: bold; - padding: 0px; -} - -ul.error li { - list-style-type: none; -} - -pre.is-family-sans-serif { - background-color: white; - font-family: Verdana, Arial, sans-serif; - font-size: 11pt; - padding: 1em; -} - -/****************************** - * jQuery UI tweaks - ******************************/ - -ul.ui-menu { - max-height: 30em; -} - /****************************** * tweaks for root user ******************************/ -.menubar .root-user .ui-button-text, -.menubar .root-user.ui-menu-item a { +.navbar .navbar-end .navbar-link.root-user, +.navbar .navbar-end .navbar-link.root-user:hover, +.navbar .navbar-end .navbar-link.root-user.is_active, +.navbar .navbar-end .navbar-item.root-user, +.navbar .navbar-end .navbar-item.root-user:hover, +.navbar .navbar-end .navbar-item.root-user.is_active { background-color: red; - color: black; font-weight: bold; } - -.menubar .root-user.ui-menu-item a { - padding-left: 1em; -} diff --git a/tailbone/static/css/filters.css b/tailbone/static/css/filters.css index c4d59025..6deff7b0 100644 --- a/tailbone/static/css/filters.css +++ b/tailbone/static/css/filters.css @@ -1,28 +1,22 @@ /****************************** - * Filters + * Grid Filters ******************************/ -div.filters form { - margin-bottom: 10px; +.filters .filter { + margin-bottom: 0.5rem; } -div.filters div.filter { - margin-bottom: 10px; +.filters .filter-fieldname .field, +.filters .filter-fieldname .field label { + width: 100%; } -div.filters div.filter label { - margin-right: 8px; +.filters .filter-fieldname .field label { + justify-content: left; } -div.filters div.filter select.filter-type { - margin-right: 8px; -} - -div.filters div.filter div.value { - display: inline; -} - -div.filters div.buttons * { - margin-right: 8px; +.filters .filter-verb .select, +.filters .filter-verb .select select { + width: 100%; } diff --git a/tailbone/static/css/forms.css b/tailbone/static/css/forms.css index 42364b14..de4b1ebe 100644 --- a/tailbone/static/css/forms.css +++ b/tailbone/static/css/forms.css @@ -1,34 +1,37 @@ /****************************** - * Form Wrapper + * forms ******************************/ -div.form-wrapper { - overflow: auto; -} - - -/****************************** - * Forms - ******************************/ - -div.form, -div.fieldset-form, -div.fieldset { - clear: left; - float: left; - margin-top: 10px; -} - +/* note that this should only apply to "normal" primary forms */ +/* TODO: replace this with bulma equivalent */ .form { padding-left: 5em; } +/* note that this should only apply to "normal" primary forms */ +.form-wrapper .form .field.is-horizontal .field-label .label { + text-align: left; + white-space: nowrap; + width: 18em; +} + +/* note that this should only apply to "normal" primary forms */ +.form-wrapper .form .field.is-horizontal .field-body { + min-width: 30em; +} + +/* note that this should only apply to "normal" primary forms */ +.form-wrapper .form .field.is-horizontal .field-body .select, +.form-wrapper .form .field.is-horizontal .field-body .select select { + width: 100%; +} /****************************** - * Fieldsets + * field-wrappers ******************************/ +/* TODO: replace this with bulma equivalent */ .field-wrapper { clear: both; min-height: 30px; @@ -36,16 +39,12 @@ div.fieldset { margin: 15px; } -.field-wrapper.with-error { - background-color: #ddcccc; - border: 2px solid #dd6666; - padding-bottom: 1em; -} - +/* TODO: replace this with bulma equivalent */ .field-wrapper .field-row { display: table-row; } +/* TODO: replace this with bulma equivalent */ .field-wrapper label { display: table-cell; vertical-align: top; @@ -55,47 +54,8 @@ div.fieldset { white-space: nowrap; } -.field-wrapper.with-error label { - padding-left: 1em; -} - -.field-wrapper .field-error { - padding: 1em 0 0.5em 1em; -} - -.field-wrapper .field-error .error-msg { - color: #dd6666; - font-weight: bold; -} - +/* TODO: replace this with bulma equivalent */ .field-wrapper .field { display: table-cell; line-height: 25px; } - -.field-wrapper .field input[type=text], -.field-wrapper .field input[type=password], -.field-wrapper .field select, -.field-wrapper .field textarea { - width: 320px; -} - -label input[type="checkbox"], -label input[type="radio"] { - margin-right: 0.5em; -} - -.field ul { - margin: 0px; - padding-left: 15px; -} - - -/****************************** - * Buttons - ******************************/ - -div.buttons { - clear: both; - margin: 10px 0px; -} diff --git a/tailbone/static/css/grids.css b/tailbone/static/css/grids.css index 3725c8e3..da5814c4 100644 --- a/tailbone/static/css/grids.css +++ b/tailbone/static/css/grids.css @@ -261,6 +261,10 @@ * main actions ******************************/ +a.grid-action { + white-space: nowrap; +} + .grid .actions { width: 1px; } diff --git a/tailbone/static/themes/falafel/css/grids.rowstatus.css b/tailbone/static/css/grids.rowstatus.css similarity index 100% rename from tailbone/static/themes/falafel/css/grids.rowstatus.css rename to tailbone/static/css/grids.rowstatus.css diff --git a/tailbone/static/css/jquery.loadmask.css b/tailbone/static/css/jquery.loadmask.css deleted file mode 100644 index 6aa1caa1..00000000 --- a/tailbone/static/css/jquery.loadmask.css +++ /dev/null @@ -1,40 +0,0 @@ -.loadmask { - z-index: 100; - position: absolute; - top:0; - left:0; - -moz-opacity: 0.5; - opacity: .50; - filter: alpha(opacity=50); - background-color: #CCC; - width: 100%; - height: 100%; - zoom: 1; -} -.loadmask-msg { - z-index: 20001; - position: absolute; - top: 0; - left: 0; - border:1px solid #6593cf; - background: #c3daf9; - padding:2px; -} -.loadmask-msg div { - padding:5px 10px 5px 25px; - background: #fbfbfb url('../img/loading.gif') no-repeat 5px 5px; - line-height: 16px; - border:1px solid #a3bad9; - color:#222; - font:normal 11px tahoma, arial, helvetica, sans-serif; - cursor:wait; -} -.masked { - overflow: hidden !important; -} -.masked-relative { - position: relative !important; -} -.masked-hidden { - visibility: hidden !important; -} \ No newline at end of file diff --git a/tailbone/static/css/jquery.ui.menubar.css b/tailbone/static/css/jquery.ui.menubar.css deleted file mode 100644 index 8b175f28..00000000 --- a/tailbone/static/css/jquery.ui.menubar.css +++ /dev/null @@ -1,15 +0,0 @@ -/* - * jQuery UI Menubar @VERSION - * - * Copyright 2011, AUTHORS.txt (http://jqueryui.com/about) - * Dual licensed under the MIT or GPL Version 2 licenses. - * http://jquery.org/license - */ -.ui-menubar { list-style: none; margin: 0; padding-left: 0; } - -.ui-menubar-item { float: left; } - -.ui-menubar .ui-button { float: left; font-weight: normal; border-top-width: 0 !important; border-bottom-width: 0 !important; margin: 0; outline: none; } -.ui-menubar .ui-menubar-link { border-right: 1px dashed transparent; border-left: 1px dashed transparent; } - -.ui-menubar .ui-menu { width: 200px; position: absolute; z-index: 9999; font-weight: normal; } diff --git a/tailbone/static/css/jquery.ui.tailbone.css b/tailbone/static/css/jquery.ui.tailbone.css deleted file mode 100644 index b6ce1023..00000000 --- a/tailbone/static/css/jquery.ui.tailbone.css +++ /dev/null @@ -1,14 +0,0 @@ - -/********************************************************************** - * jquery.ui.tailbone.css - * - * jQuery UI tweaks for Tailbone - **********************************************************************/ - -.ui-widget { - font-size: 1em; -} - -.ui-menu-item a { - display: block; -} diff --git a/tailbone/static/css/jquery.ui.timepicker.css b/tailbone/static/css/jquery.ui.timepicker.css deleted file mode 100644 index b5930fb7..00000000 --- a/tailbone/static/css/jquery.ui.timepicker.css +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Timepicker stylesheet - * Highly inspired from datepicker - * FG - Nov 2010 - Web3R - * - * version 0.0.3 : Fixed some settings, more dynamic - * version 0.0.4 : Removed width:100% on tables - * version 0.1.1 : set width 0 on tables to fix an ie6 bug - */ - -.ui-timepicker-inline { display: inline; } - -#ui-timepicker-div { padding: 0.2em; } -.ui-timepicker-table { display: inline-table; width: 0; } -.ui-timepicker-table table { margin:0.15em 0 0 0; border-collapse: collapse; } - -.ui-timepicker-hours, .ui-timepicker-minutes { padding: 0.2em; } - -.ui-timepicker-table .ui-timepicker-title { line-height: 1.8em; text-align: center; } -.ui-timepicker-table td { padding: 0.1em; width: 2.2em; } -.ui-timepicker-table th.periods { padding: 0.1em; width: 2.2em; } - -/* span for disabled cells */ -.ui-timepicker-table td span { - display:block; - padding:0.2em 0.3em 0.2em 0.5em; - width: 1.2em; - - text-align:right; - text-decoration:none; -} -/* anchors for clickable cells */ -.ui-timepicker-table td a { - display:block; - padding:0.2em 0.3em 0.2em 0.5em; - width: 1.2em; - cursor: pointer; - text-align:right; - text-decoration:none; -} - - -/* buttons and button pane styling */ -.ui-timepicker .ui-timepicker-buttonpane { - background-image: none; margin: .7em 0 0 0; padding:0 .2em; border-left: 0; border-right: 0; border-bottom: 0; -} -.ui-timepicker .ui-timepicker-buttonpane button { margin: .5em .2em .4em; cursor: pointer; padding: .2em .6em .3em .6em; width:auto; overflow:visible; } -/* The close button */ -.ui-timepicker .ui-timepicker-close { float: right } - -/* the now button */ -.ui-timepicker .ui-timepicker-now { float: left; } - -/* the deselect button */ -.ui-timepicker .ui-timepicker-deselect { float: left; } - - diff --git a/tailbone/static/css/layout.css b/tailbone/static/css/layout.css index 6c1b926f..cc4d0015 100644 --- a/tailbone/static/css/layout.css +++ b/tailbone/static/css/layout.css @@ -1,152 +1,90 @@ /****************************** - * Main Layout + * main layout ******************************/ -html, body, #body-wrapper { - height: 100%; -} - -body > #body-wrapper { - height: auto; - min-height: 100%; -} - -#body-wrapper { - margin: 0 1em; - width: auto; -} - -#header { - height: 50px; - line-height: 50px; -} - -#body { - padding-top: 10px; - padding-bottom: 5em; -} - -#footer { - clear: both; - margin-top: -4em; - text-align: center; -} - - -/****************************** - * Header - ******************************/ - -#header h1 { - float: left; - font-size: 25px; - margin: 0px; -} - -#header div.login { - float: right; -} - -/* new stuff from 'better' theme begins here */ - -header .global { - background-color: #eaeaea; - height: 60px; -} - -header .global a.home, -header .global a.global, -header .global span.global { - display: block; - float: left; - font-size: 2em; - font-weight: bold; - line-height: 60px; - margin-left: 10px; -} - -header .global a.home img { - display: block; - float: left; - padding: 5px 5px 5px 30px; -} - -header .global .grid-nav { - display: inline-block; - font-size: 16px; - font-weight: bold; - line-height: 60px; - margin-left: 5em; -} - -header .global .grid-nav .ui-button, -header .global .grid-nav span.viewing { - margin-left: 1em; -} - -header .global .feedback { - float: right; - line-height: 60px; - margin-right: 1em; -} - -header .global .after-feedback { - float: right; - line-height: 60px; - margin-right: 1em; -} - -header .page { - border-bottom: 1px solid lightgrey; - padding: 0.5em; -} - -header .page h1 { - margin: 0; - padding: 0 0 0 0.5em; -} - -/****************************** - * Logo - ******************************/ - -#logo { - display: block; - margin: 40px auto; -} - - -/**************************************** - * content - ****************************************/ - -body > #body-wrapper { - margin: 0px; - position: relative; +body { + display: flex; + flex-direction: column; + min-height: 100vh; } .content-wrapper { - height: 100%; - padding-bottom: 30px; + display: flex; + flex: 1; + flex-direction: column; + justify-content: space-between; } -#scrollpane { - height: 100%; + +/****************************** + * header + ******************************/ + +/* this is the one in the very top left of screen, next to logo and linked to +the home page */ +#global-header-title { + margin-left: 0.3rem; } -#scrollpane .inner-content { - padding: 0 0.5em 0.5em 0.5em; +header .level { + /* TODO: not sure what this 60px was supposed to do? but it broke the */ + /* styles for the feedback dialog, so disabled it is. + /* height: 60px; */ + /* line-height: 60px; */ + padding-left: 0.5em; + padding-right: 0.5em; } +header .level #header-logo { + display: inline-block; +} + +header .level .global-title, +header .level-left .global-title { + font-size: 2em; + font-weight: bold; +} + +/* indent nested menu items a bit */ +header .navbar-item.nested { + padding-left: 2.5rem; +} + +header span.header-text { + font-size: 2em; + font-weight: bold; + margin-right: 10px; +} + +header .level .theme-picker { + display: inline-flex; +} + +#content-title { + padding: 0.3rem; +} + +#content-title h1 { + font-size: 2rem; + margin-left: 1rem; +} + +/****************************** + * content + ******************************/ + +#page-body { + padding: 0.4em; +} /****************************** * context menu ******************************/ #context-menu { - list-style-type: none; - margin: 0.5em; + margin-bottom: 1em; + margin-left: 1em; text-align: right; white-space: nowrap; } @@ -155,11 +93,19 @@ body > #body-wrapper { * "object helper" panel ******************************/ +.object-helpers .panel-heading { + white-space: nowrap; +} + +.object-helpers a { + white-space: nowrap; +} + .object-helper { border: 1px solid black; margin: 1em; padding: 1em; - min-width: 20em; + width: 20em; } .object-helper-content { @@ -167,87 +113,38 @@ body > #body-wrapper { } /****************************** - * Panels + * markdown ******************************/ -.panel, -.panel-grid { - border-left: 1px solid Black; - margin-bottom: 1em; +.rendered-markdown p, +.rendered-markdown ul { + margin-bottom: 1rem; } -.panel { - border-bottom: 1px solid Black; - border-right: 1px solid Black; - padding: 0px; +.rendered-markdown .codehilite { + margin-bottom: 2rem; } -.panel h2, -.panel-grid h2 { - border-bottom: 1px solid Black; - border-top: 1px solid Black; - padding: 5px; - margin: 0px; +/****************************** + * fix datepicker within modals + * TODO: someday this may not be necessary? cf. + * https://github.com/buefy/buefy/issues/292#issuecomment-347365637 + ******************************/ + +.modal .animation-content .modal-card { + overflow: visible !important; } -.panel-grid h2 { - border-right: 1px solid Black; +.modal-card-body { + overflow: visible !important; } -.panel-body { - overflow: auto; - padding: 5px; -} - -/**************************************** - * footer - ****************************************/ - -#footer { - border-top: 1px solid lightgray; - bottom: 0; - font-size: 9pt; - height: 20px; - left: 0; - line-height: 20px; - margin: 0; - position: absolute; - width: 100%; -} /****************************** * feedback ******************************/ -#feedback-dialog { - display: none; -} - -#feedback-dialog p { - margin-top: 1em; -} - -#feedback-dialog .red { +.feedback-dialog .red { color: red; font-weight: bold; } - -#feedback-dialog .field-wrapper { - margin-top: 1em; - padding: 0; -} - -#feedback-dialog .field { - margin-bottom: 0; - margin-top: 0.5em; -} - -#feedback-dialog .referrer .field { - clear: both; - float: none; - margin-top: 1em; -} - -#feedback-dialog textarea { - width: auto; -} diff --git a/tailbone/static/js/jquery.ui.tailbone.js b/tailbone/static/js/jquery.ui.tailbone.js deleted file mode 100644 index 06904e59..00000000 --- a/tailbone/static/js/jquery.ui.tailbone.js +++ /dev/null @@ -1,452 +0,0 @@ - -/********************************************************************** - * jQuery UI plugins for Tailbone - **********************************************************************/ - -/********************************************************************** - * gridcore plugin - **********************************************************************/ - -(function($) { - - $.widget('tailbone.gridcore', { - - _create: function() { - - var that = this; - - // Add hover highlight effect to grid rows during mouse-over. - // this.element.on('mouseenter', 'tbody tr:not(.header)', function() { - this.element.on('mouseenter', 'tr:not(.header)', function() { - $(this).addClass('hovering'); - }); - // this.element.on('mouseleave', 'tbody tr:not(.header)', function() { - this.element.on('mouseleave', 'tr:not(.header)', function() { - $(this).removeClass('hovering'); - }); - - // do some extra stuff for grids with checkboxes - - // mark rows selected on page load, as needed - this.element.find('tr:not(.header) td.checkbox :checkbox:checked').each(function() { - $(this).parents('tr:first').addClass('selected'); - }); - - // (un-)check all rows when clicking check-all box in header - if (this.element.find('tr.header td.checkbox :checkbox').length) { - this.element.on('click', 'tr.header td.checkbox :checkbox', function() { - var checked = $(this).prop('checked'); - var rows = that.element.find('tr:not(.header)'); - rows.find('td.checkbox :checkbox').prop('checked', checked); - if (checked) { - rows.addClass('selected'); - } else { - rows.removeClass('selected'); - } - that.element.trigger('gridchecked', that.count_selected()); - }); - } - - // when row with checkbox is clicked, toggle selected status, - // unless clicking checkbox (since that already toggles it) or a - // link (since that does something completely different) - this.element.on('click', 'tr:not(.header)', function(event) { - var el = $(event.target); - if (!el.is('a') && !el.is(':checkbox')) { - $(this).find('td.checkbox :checkbox').click(); - } - }); - - this.element.on('change', 'tr:not(.header) td.checkbox :checkbox', function() { - if (this.checked) { - $(this).parents('tr:first').addClass('selected'); - } else { - $(this).parents('tr:first').removeClass('selected'); - } - that.element.trigger('gridchecked', that.count_selected()); - }); - - // Show 'more' actions when user hovers over 'more' link. - this.element.on('mouseenter', '.actions a.more', function() { - that.element.find('.actions div.more').hide(); - $(this).siblings('div.more') - .show() - .position({my: 'left-5 top-4', at: 'left top', of: $(this)}); - }); - this.element.on('mouseleave', '.actions div.more', function() { - $(this).hide(); - }); - - // Add speed bump for "Delete Row" action, if grid is so configured. - if (this.element.data('delete-speedbump')) { - this.element.on('click', 'tr:not(.header) .actions a.delete', function() { - return confirm("Are you sure you wish to delete this object?"); - }); - } - }, - - count_selected: function() { - return this.element.find('tr:not(.header) td.checkbox :checkbox:checked').length; - }, - - // TODO: deprecate / remove this? - count_checked: function() { - return this.count_selected(); - }, - - selected_rows: function() { - return this.element.find('tr:not(.header) td.checkbox :checkbox:checked').parents('tr:first'); - }, - - all_uuids: function() { - var uuids = []; - this.element.find('tr:not(.header)').each(function() { - uuids.push($(this).data('uuid')); - }); - return uuids; - }, - - selected_uuids: function() { - var uuids = []; - this.element.find('tr:not(.header) td.checkbox :checkbox:checked').each(function() { - uuids.push($(this).parents('tr:first').data('uuid')); - }); - return uuids; - } - - }); - -})( jQuery ); - - -/********************************************************************** - * gridwrapper plugin - **********************************************************************/ - -(function($) { - - $.widget('tailbone.gridwrapper', { - - _create: function() { - - var that = this; - - // Snag some element references. - this.filters = this.element.find('.newfilters'); - this.filters_form = this.filters.find('form'); - this.add_filter = this.filters.find('#add-filter'); - this.apply_filters = this.filters.find('#apply-filters'); - this.default_filters = this.filters.find('#default-filters'); - this.clear_filters = this.filters.find('#clear-filters'); - this.save_defaults = this.filters.find('#save-defaults'); - this.grid = this.element.find('.grid'); - - // add standard grid behavior - this.grid.gridcore(); - - // Enhance filters etc. - this.filters.find('.filter').gridfilter(); - this.apply_filters.button('option', 'icons', {primary: 'ui-icon-search'}); - this.default_filters.button('option', 'icons', {primary: 'ui-icon-home'}); - this.clear_filters.button('option', 'icons', {primary: 'ui-icon-trash'}); - this.save_defaults.button('option', 'icons', {primary: 'ui-icon-disk'}); - if (! this.filters.find('.active:checked').length) { - this.apply_filters.button('disable'); - } - this.add_filter.selectmenu({ - width: '15em', - - // Initially disabled if contains no enabled filter options. - disabled: this.add_filter.find('option:enabled').length == 1, - - // When add-filter choice is made, show/focus new filter value input, - // and maybe hide the add-filter selection or show the apply button. - change: function (event, ui) { - var filter = that.filters.find('#filter-' + ui.item.value); - var select = $(this); - var option = ui.item.element; - filter.gridfilter('active', true); - filter.gridfilter('focus'); - select.val(''); - option.attr('disabled', 'disabled'); - select.selectmenu('refresh'); - if (select.find('option:enabled').length == 1) { // prompt is always enabled - select.selectmenu('disable'); - } - that.apply_filters.button('enable'); - } - }); - - this.add_filter.on('selectmenuopen', function(event, ui) { - show_all_options($(this)); - }); - - // Intercept filters form submittal, and submit via AJAX instead. - this.filters_form.on('submit', function() { - var settings = {filter: true, partial: true}; - if (that.filters_form.find('input[name="save-current-filters-as-defaults"]').val() == 'true') { - settings['save-current-filters-as-defaults'] = true; - } - that.filters.find('.filter').each(function() { - - // currently active filters will be included in form data - if ($(this).gridfilter('active')) { - settings[$(this).data('key')] = $(this).gridfilter('value'); - settings[$(this).data('key') + '.verb'] = $(this).gridfilter('verb'); - - // others will be hidden from view - } else { - $(this).gridfilter('hide'); - } - }); - - // if no filters are visible, disable submit button - if (! that.filters.find('.filter:visible').length) { - that.apply_filters.button('disable'); - } - - // okay, submit filters to server and refresh grid - that.refresh(settings); - return false; - }); - - // When user clicks Default Filters button, refresh page with - // instructions for the server to reset filters to default settings. - this.default_filters.click(function() { - that.filters_form.off('submit'); - that.filters_form.find('input[name="reset-to-default-filters"]').val('true'); - that.element.mask("Refreshing data..."); - that.filters_form.get(0).submit(); - }); - - // When user clicks Save Defaults button, refresh the grid as with - // Apply Filters, but add an instruction for the server to save - // current settings as defaults for the user. - this.save_defaults.click(function() { - that.filters_form.find('input[name="save-current-filters-as-defaults"]').val('true'); - that.filters_form.submit(); - that.filters_form.find('input[name="save-current-filters-as-defaults"]').val('false'); - }); - - // When user clicks Clear Filters button, deactivate all filters - // and refresh the grid. - this.clear_filters.click(function() { - that.filters.find('.filter').each(function() { - if ($(this).gridfilter('active')) { - $(this).gridfilter('active', false); - } - }); - that.filters_form.submit(); - }); - - // Refresh data when user clicks a sortable column header. - this.element.on('click', 'tr.header a', function() { - var td = $(this).parent(); - var data = { - sortkey: $(this).data('sortkey'), - sortdir: (td.hasClass('asc')) ? 'desc' : 'asc', - page: 1, - partial: true - }; - that.refresh(data); - return false; - }); - - // Refresh data when user chooses a new page size setting. - this.element.on('change', '.pager #pagesize', function() { - var settings = { - partial: true, - pagesize: $(this).val() - }; - that.refresh(settings); - }); - - // Refresh data when user clicks a pager link. - this.element.on('click', '.pager a', function() { - that.refresh(this.search.substring(1)); // remove leading '?' - return false; - }); - }, - - // Refreshes the visible data within the grid, according to the given settings. - refresh: function(settings) { - var that = this; - this.element.mask("Refreshing data..."); - $.get(this.grid.data('url'), settings, function(data) { - that.grid.replaceWith(data); - that.grid = that.element.find('.grid'); - that.grid.gridcore(); - that.element.unmask(); - }); - }, - - results_count: function(as_text) { - var count = null; - var match = /showing \d+ thru \d+ of (\S+)/.exec(this.element.find('.pager .showing').text()); - if (match) { - count = match[1]; - if (!as_text) { - count = parseInt(count, 10); - } - } - return count; - }, - - all_uuids: function() { - return this.grid.gridcore('all_uuids'); - }, - - selected_uuids: function() { - return this.grid.gridcore('selected_uuids'); - } - - }); - -})( jQuery ); - - -/********************************************************************** - * gridfilter plugin - **********************************************************************/ - -(function($) { - - $.widget('tailbone.gridfilter', { - - _create: function() { - - var that = this; - - // Track down some important elements. - this.checkbox = this.element.find('input[name$="-active"]'); - this.label = this.element.find('label'); - this.inputs = this.element.find('.inputs'); - this.add_filter = this.element.parents('.grid-wrapper').find('#add-filter'); - - // Hide the checkbox and label, and add button for toggling active status. - this.checkbox.addClass('ui-helper-hidden-accessible'); - this.label.hide(); - this.activebutton = $('<button type="button" class="toggle" />') - .insertAfter(this.label) - .text(this.label.text()) - .button({ - icons: {primary: 'ui-icon-blank'} - }); - - // Enhance verb dropdown as selectmenu. - this.verb_select = this.inputs.find('.verb'); - this.valueless_verbs = {}; - $.each(this.verb_select.data('hide-value-for').split(' '), function(index, value) { - that.valueless_verbs[value] = true; - }); - this.verb_select.selectmenu({ - width: '15em', - change: function(event, ui) { - if (ui.item.value in that.valueless_verbs) { - that.inputs.find('.value').hide(); - } else { - that.inputs.find('.value').show(); - that.focus(); - that.select(); - } - } - }); - - this.verb_select.on('selectmenuopen', function(event, ui) { - show_all_options($(this)); - }); - - // Enhance any date values with datepicker widget. - this.inputs.find('.value input[data-datepicker="true"]').datepicker({ - dateFormat: 'yy-mm-dd', - changeYear: true, - changeMonth: true - }); - - // Enhance any choice/dropdown values with selectmenu. - this.inputs.find('.value select').selectmenu({ - // provide sane width for value dropdown - width: '15em' - }); - - this.inputs.find('.value select').on('selectmenuopen', function(event, ui) { - show_all_options($(this)); - }); - - // Listen for button click, to keep checkbox in sync. - this._on(this.activebutton, { - click: function(e) { - var checked = !this.checkbox.is(':checked'); - this.checkbox.prop('checked', checked); - this.refresh(); - if (checked) { - this.focus(); - } - } - }); - - // Update the initial state of the button according to checkbox. - this.refresh(); - }, - - refresh: function() { - if (this.checkbox.is(':checked')) { - this.activebutton.button('option', 'icons', {primary: 'ui-icon-check'}); - if (this.verb() in this.valueless_verbs) { - this.inputs.find('.value').hide(); - } else { - this.inputs.find('.value').show(); - } - this.inputs.show(); - } else { - this.activebutton.button('option', 'icons', {primary: 'ui-icon-blank'}); - this.inputs.hide(); - } - }, - - active: function(value) { - if (value === undefined) { - return this.checkbox.is(':checked'); - } - if (value) { - if (!this.checkbox.is(':checked')) { - this.checkbox.prop('checked', true); - this.refresh(); - this.element.show(); - } - } else if (this.checkbox.is(':checked')) { - this.checkbox.prop('checked', false); - this.refresh(); - } - }, - - hide: function() { - this.active(false); - this.element.hide(); - var option = this.add_filter.find('option[value="' + this.element.data('key') + '"]'); - option.attr('disabled', false); - if (this.add_filter.selectmenu('option', 'disabled')) { - this.add_filter.selectmenu('enable'); - } - this.add_filter.selectmenu('refresh'); - }, - - focus: function() { - this.inputs.find('.value input').focus(); - }, - - select: function() { - this.inputs.find('.value input').select(); - }, - - value: function() { - return this.inputs.find('.value input, .value select').val(); - }, - - verb: function() { - return this.inputs.find('.verb').val(); - } - - }); - -})( jQuery ); diff --git a/tailbone/static/js/lib/jquery.loadmask.min.js b/tailbone/static/js/lib/jquery.loadmask.min.js deleted file mode 100644 index d77373c8..00000000 --- a/tailbone/static/js/lib/jquery.loadmask.min.js +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Copyright (c) 2009 Sergiy Kovalchuk (serg472@gmail.com) - * - * Dual licensed under the MIT (http://www.opensource.org/licenses/mit-license.php) - * and GPL (http://www.opensource.org/licenses/gpl-license.php) licenses. - * - * Following code is based on Element.mask() implementation from ExtJS framework (http://extjs.com/) - * - */ -(function(a){a.fn.mask=function(c,b){a(this).each(function(){if(b!==undefined&&b>0){var d=a(this);d.data("_mask_timeout",setTimeout(function(){a.maskElement(d,c)},b))}else{a.maskElement(a(this),c)}})};a.fn.unmask=function(){a(this).each(function(){a.unmaskElement(a(this))})};a.fn.isMasked=function(){return this.hasClass("masked")};a.maskElement=function(d,c){if(d.data("_mask_timeout")!==undefined){clearTimeout(d.data("_mask_timeout"));d.removeData("_mask_timeout")}if(d.isMasked()){a.unmaskElement(d)}if(d.css("position")=="static"){d.addClass("masked-relative")}d.addClass("masked");var e=a('<div class="loadmask"></div>');if(navigator.userAgent.toLowerCase().indexOf("msie")>-1){e.height(d.height()+parseInt(d.css("padding-top"))+parseInt(d.css("padding-bottom")));e.width(d.width()+parseInt(d.css("padding-left"))+parseInt(d.css("padding-right")))}if(navigator.userAgent.toLowerCase().indexOf("msie 6")>-1){d.find("select").addClass("masked-hidden")}d.append(e);if(c!==undefined){var b=a('<div class="loadmask-msg" style="display:none;"></div>');b.append("<div>"+c+"</div>");d.append(b);b.css("top",Math.round(d.height()/2-(b.height()-parseInt(b.css("padding-top"))-parseInt(b.css("padding-bottom")))/2)+"px");b.css("left",Math.round(d.width()/2-(b.width()-parseInt(b.css("padding-left"))-parseInt(b.css("padding-right")))/2)+"px");b.show()}};a.unmaskElement=function(b){if(b.data("_mask_timeout")!==undefined){clearTimeout(b.data("_mask_timeout"));b.removeData("_mask_timeout")}b.find(".loadmask-msg,.loadmask").remove();b.removeClass("masked");b.removeClass("masked-relative");b.find("select").removeClass("masked-hidden")}})(jQuery); \ No newline at end of file diff --git a/tailbone/static/js/lib/jquery.ui.menubar.js b/tailbone/static/js/lib/jquery.ui.menubar.js deleted file mode 100644 index a1559091..00000000 --- a/tailbone/static/js/lib/jquery.ui.menubar.js +++ /dev/null @@ -1,331 +0,0 @@ -/* - * jQuery UI Menubar @VERSION - * - * Copyright 2011, AUTHORS.txt (http://jqueryui.com/about) - * Dual licensed under the MIT or GPL Version 2 licenses. - * http://jquery.org/license - * - * http://docs.jquery.com/UI/Menubar - * - * Depends: - * jquery.ui.core.js - * jquery.ui.widget.js - * jquery.ui.position.js - * jquery.ui.menu.js - */ -(function( $ ) { - - // TODO when mixing clicking menus and keyboard navigation, focus handling is broken - // there has to be just one item that has tabindex - $.widget( "ui.menubar", { - version: "@VERSION", - options: { - autoExpand: false, - buttons: false, - items: "li", - menuElement: "ul", - menuIcon: false, - position: { - my: "left top", - at: "left bottom" - } - }, - _create: function() { - var that = this; - this.menuItems = this.element.children( this.options.items ); - this.items = this.menuItems.children( "button, a" ); - - this.menuItems - .addClass( "ui-menubar-item" ) - .attr( "role", "presentation" ); - // let only the first item receive focus - this.items.slice(1).attr( "tabIndex", -1 ); - - this.element - .addClass( "ui-menubar ui-widget-header ui-helper-clearfix" ) - .attr( "role", "menubar" ); - this._focusable( this.items ); - this._hoverable( this.items ); - this.items.siblings( this.options.menuElement ) - .menu({ - position: { - within: this.options.position.within - }, - select: function( event, ui ) { - ui.item.parents( "ul.ui-menu:last" ).hide(); - that._close(); - // TODO what is this targetting? there's probably a better way to access it - $(event.target).prev().focus(); - that._trigger( "select", event, ui ); - }, - menus: that.options.menuElement - }) - .hide() - .attr({ - "aria-hidden": "true", - "aria-expanded": "false" - }) - // TODO use _on - .bind( "keydown.menubar", function( event ) { - var menu = $( this ); - if ( menu.is( ":hidden" ) ) { - return; - } - switch ( event.keyCode ) { - case $.ui.keyCode.LEFT: - that.previous( event ); - event.preventDefault(); - break; - case $.ui.keyCode.RIGHT: - that.next( event ); - event.preventDefault(); - break; - } - }); - this.items.each(function() { - var input = $(this), - // TODO menu var is only used on two places, doesn't quite justify the .each - menu = input.next( that.options.menuElement ); - - // might be a non-menu button - if ( menu.length ) { - // TODO use _on - input.bind( "click.menubar focus.menubar mouseenter.menubar", function( event ) { - // ignore triggered focus event - if ( event.type === "focus" && !event.originalEvent ) { - return; - } - event.preventDefault(); - // TODO can we simplify or extractthis check? especially the last two expressions - // there's a similar active[0] == menu[0] check in _open - if ( event.type === "click" && menu.is( ":visible" ) && that.active && that.active[0] === menu[0] ) { - that._close(); - return; - } - if ( ( that.open && event.type === "mouseenter" ) || event.type === "click" || that.options.autoExpand ) { - if( that.options.autoExpand ) { - clearTimeout( that.closeTimer ); - } - - that._open( event, menu ); - } - }) - // TODO use _on - .bind( "keydown", function( event ) { - switch ( event.keyCode ) { - case $.ui.keyCode.SPACE: - case $.ui.keyCode.UP: - case $.ui.keyCode.DOWN: - that._open( event, $( this ).next() ); - event.preventDefault(); - break; - case $.ui.keyCode.LEFT: - that.previous( event ); - event.preventDefault(); - break; - case $.ui.keyCode.RIGHT: - that.next( event ); - event.preventDefault(); - break; - } - }) - .attr( "aria-haspopup", "true" ); - - // TODO review if these options (menuIcon and buttons) are a good choice, maybe they can be merged - if ( that.options.menuIcon ) { - input.addClass( "ui-state-default" ).append( "<span class='ui-button-icon-secondary ui-icon ui-icon-triangle-1-s'></span>" ); - input.removeClass( "ui-button-text-only" ).addClass( "ui-button-text-icon-secondary" ); - } - } else { - // TODO use _on - input.bind( "click.menubar mouseenter.menubar", function( event ) { - if ( ( that.open && event.type === "mouseenter" ) || event.type === "click" ) { - that._close(); - } - }); - } - - input - .addClass( "ui-button ui-widget ui-button-text-only ui-menubar-link" ) - .attr( "role", "menuitem" ) - .wrapInner( "<span class='ui-button-text'></span>" ); - - if ( that.options.buttons ) { - input.removeClass( "ui-menubar-link" ).addClass( "ui-state-default" ); - } - }); - that._on( { - keydown: function( event ) { - if ( event.keyCode === $.ui.keyCode.ESCAPE && that.active && that.active.menu( "collapse", event ) !== true ) { - var active = that.active; - that.active.blur(); - that._close( event ); - active.prev().focus(); - } - }, - focusin: function( event ) { - clearTimeout( that.closeTimer ); - }, - focusout: function( event ) { - that.closeTimer = setTimeout( function() { - that._close( event ); - }, 150); - }, - "mouseleave .ui-menubar-item": function( event ) { - if ( that.options.autoExpand ) { - that.closeTimer = setTimeout( function() { - that._close( event ); - }, 150); - } - }, - "mouseenter .ui-menubar-item": function( event ) { - clearTimeout( that.closeTimer ); - } - }); - - // Keep track of open submenus - this.openSubmenus = 0; - }, - - _destroy : function() { - this.menuItems - .removeClass( "ui-menubar-item" ) - .removeAttr( "role" ); - - this.element - .removeClass( "ui-menubar ui-widget-header ui-helper-clearfix" ) - .removeAttr( "role" ) - .unbind( ".menubar" ); - - this.items - .unbind( ".menubar" ) - .removeClass( "ui-button ui-widget ui-button-text-only ui-menubar-link ui-state-default" ) - .removeAttr( "role" ) - .removeAttr( "aria-haspopup" ) - // TODO unwrap? - .children( "span.ui-button-text" ).each(function( i, e ) { - var item = $( this ); - item.parent().html( item.html() ); - }) - .end() - .children( ".ui-icon" ).remove(); - - this.element.find( ":ui-menu" ) - .menu( "destroy" ) - .show() - .removeAttr( "aria-hidden" ) - .removeAttr( "aria-expanded" ) - .removeAttr( "tabindex" ) - .unbind( ".menubar" ); - }, - - _close: function() { - if ( !this.active || !this.active.length ) { - return; - } - this.active - .menu( "collapseAll" ) - .hide() - .attr({ - "aria-hidden": "true", - "aria-expanded": "false" - }); - this.active - .prev() - .removeClass( "ui-state-active" ) - .removeAttr( "tabIndex" ); - this.active = null; - this.open = false; - this.openSubmenus = 0; - }, - - _open: function( event, menu ) { - // on a single-button menubar, ignore reopening the same menu - if ( this.active && this.active[0] === menu[0] ) { - return; - } - // TODO refactor, almost the same as _close above, but don't remove tabIndex - if ( this.active ) { - this.active - .menu( "collapseAll" ) - .hide() - .attr({ - "aria-hidden": "true", - "aria-expanded": "false" - }); - this.active - .prev() - .removeClass( "ui-state-active" ); - } - // set tabIndex -1 to have the button skipped on shift-tab when menu is open (it gets focus) - var button = menu.prev().addClass( "ui-state-active" ).attr( "tabIndex", -1 ); - this.active = menu - .show() - .position( $.extend({ - of: button - }, this.options.position ) ) - .removeAttr( "aria-hidden" ) - .attr( "aria-expanded", "true" ) - .menu("focus", event, menu.children( ".ui-menu-item" ).first() ) - // TODO need a comment here why both events are triggered - // TODO: heh well given the above comment i'm not sure what the - // implications might be for disabling the focus() call..but it - // messes with text input focus in undesirable ways..so disable it - // we will..until we know why we shouldn't - // .focus() - .focusin(); - this.open = true; - }, - - next: function( event ) { - if ( this.open && this.active.data( "menu" ).active.has( ".ui-menu" ).length ) { - // Track number of open submenus and prevent moving to next menubar item - this.openSubmenus++; - return; - } - this.openSubmenus = 0; - this._move( "next", "first", event ); - }, - - previous: function( event ) { - if ( this.open && this.openSubmenus ) { - // Track number of open submenus and prevent moving to previous menubar item - this.openSubmenus--; - return; - } - this.openSubmenus = 0; - this._move( "prev", "last", event ); - }, - - _move: function( direction, filter, event ) { - var next, - wrapItem; - if ( this.open ) { - next = this.active.closest( ".ui-menubar-item" )[ direction + "All" ]( this.options.items ).first().children( ".ui-menu" ).eq( 0 ); - wrapItem = this.menuItems[ filter ]().children( ".ui-menu" ).eq( 0 ); - } else { - if ( event ) { - next = $( event.target ).closest( ".ui-menubar-item" )[ direction + "All" ]( this.options.items ).children( ".ui-menubar-link" ).eq( 0 ); - wrapItem = this.menuItems[ filter ]().children( ".ui-menubar-link" ).eq( 0 ); - } else { - next = wrapItem = this.menuItems.children( "a" ).eq( 0 ); - } - } - - if ( next.length ) { - if ( this.open ) { - this._open( event, next ); - } else { - next.removeAttr( "tabIndex")[0].focus(); - } - } else { - if ( this.open ) { - this._open( event, wrapItem ); - } else { - wrapItem.removeAttr( "tabIndex")[0].focus(); - } - } - } - }); - -}( jQuery )); diff --git a/tailbone/static/js/lib/jquery.ui.timepicker.js b/tailbone/static/js/lib/jquery.ui.timepicker.js deleted file mode 100644 index d8a0cfb7..00000000 --- a/tailbone/static/js/lib/jquery.ui.timepicker.js +++ /dev/null @@ -1,1496 +0,0 @@ -/* - * jQuery UI Timepicker - * - * Copyright 2010-2013, Francois Gelinas - * Dual licensed under the MIT or GPL Version 2 licenses. - * http://jquery.org/license - * - * http://fgelinas.com/code/timepicker - * - * Depends: - * jquery.ui.core.js - * jquery.ui.position.js (only if position settings are used) - * - * Change version 0.1.0 - moved the t-rex up here - * - ____ - ___ .-~. /_"-._ - `-._~-. / /_ "~o\ :Y - \ \ / : \~x. ` ') - ] Y / | Y< ~-.__j - / ! _.--~T : l l< /.-~ - / / ____.--~ . ` l /~\ \<|Y - / / .-~~" /| . ',-~\ \L| - / / / .^ \ Y~Y \.^>/l_ "--' - / Y .-"( . l__ j_j l_/ /~_.-~ . - Y l / \ ) ~~~." / `/"~ / \.__/l_ - | \ _.-" ~-{__ l : l._Z~-.___.--~ - | ~---~ / ~~"---\_ ' __[> - l . _.^ ___ _>-y~ - \ \ . .-~ .-~ ~>--" / - \ ~---" / ./ _.-' - "-.,_____.,_ _.--~\ _.-~ - ~~ ( _} -Row - `. ~( - ) \ - /,`--'~\--'~\ - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - ->T-Rex<- -*/ - -(function ($) { - - $.extend($.ui, { timepicker: { version: "0.3.3"} }); - - var PROP_NAME = 'timepicker', - tpuuid = new Date().getTime(); - - /* Time picker manager. - Use the singleton instance of this class, $.timepicker, to interact with the time picker. - Settings for (groups of) time pickers are maintained in an instance object, - allowing multiple different settings on the same page. */ - - function Timepicker() { - this.debug = true; // Change this to true to start debugging - this._curInst = null; // The current instance in use - this._disabledInputs = []; // List of time picker inputs that have been disabled - this._timepickerShowing = false; // True if the popup picker is showing , false if not - this._inDialog = false; // True if showing within a "dialog", false if not - this._dialogClass = 'ui-timepicker-dialog'; // The name of the dialog marker class - this._mainDivId = 'ui-timepicker-div'; // The ID of the main timepicker division - this._inlineClass = 'ui-timepicker-inline'; // The name of the inline marker class - this._currentClass = 'ui-timepicker-current'; // The name of the current hour / minutes marker class - this._dayOverClass = 'ui-timepicker-days-cell-over'; // The name of the day hover marker class - - this.regional = []; // Available regional settings, indexed by language code - this.regional[''] = { // Default regional settings - hourText: 'Hour', // Display text for hours section - minuteText: 'Minute', // Display text for minutes link - amPmText: ['AM', 'PM'], // Display text for AM PM - closeButtonText: 'Done', // Text for the confirmation button (ok button) - nowButtonText: 'Now', // Text for the now button - deselectButtonText: 'Deselect' // Text for the deselect button - }; - this._defaults = { // Global defaults for all the time picker instances - showOn: 'focus', // 'focus' for popup on focus, - // 'button' for trigger button, or 'both' for either (not yet implemented) - button: null, // 'button' element that will trigger the timepicker - showAnim: 'fadeIn', // Name of jQuery animation for popup - showOptions: {}, // Options for enhanced animations - appendText: '', // Display text following the input box, e.g. showing the format - - beforeShow: null, // Define a callback function executed before the timepicker is shown - onSelect: null, // Define a callback function when a hour / minutes is selected - onClose: null, // Define a callback function when the timepicker is closed - - timeSeparator: ':', // The character to use to separate hours and minutes. - periodSeparator: ' ', // The character to use to separate the time from the time period. - showPeriod: false, // Define whether or not to show AM/PM with selected time - showPeriodLabels: true, // Show the AM/PM labels on the left of the time picker - showLeadingZero: true, // Define whether or not to show a leading zero for hours < 10. [true/false] - showMinutesLeadingZero: true, // Define whether or not to show a leading zero for minutes < 10. - altField: '', // Selector for an alternate field to store selected time into - defaultTime: 'now', // Used as default time when input field is empty or for inline timePicker - // (set to 'now' for the current time, '' for no highlighted time) - myPosition: 'left top', // Position of the dialog relative to the input. - // see the position utility for more info : http://jqueryui.com/demos/position/ - atPosition: 'left bottom', // Position of the input element to match - // Note : if the position utility is not loaded, the timepicker will attach left top to left bottom - //NEW: 2011-02-03 - onHourShow: null, // callback for enabling / disabling on selectable hours ex : function(hour) { return true; } - onMinuteShow: null, // callback for enabling / disabling on time selection ex : function(hour,minute) { return true; } - - hours: { - starts: 0, // first displayed hour - ends: 23 // last displayed hour - }, - minutes: { - starts: 0, // first displayed minute - ends: 55, // last displayed minute - interval: 5, // interval of displayed minutes - manual: [] // optional extra manual entries for minutes - }, - rows: 4, // number of rows for the input tables, minimum 2, makes more sense if you use multiple of 2 - // 2011-08-05 0.2.4 - showHours: true, // display the hours section of the dialog - showMinutes: true, // display the minute section of the dialog - optionalMinutes: false, // optionally parse inputs of whole hours with minutes omitted - - // buttons - showCloseButton: false, // shows an OK button to confirm the edit - showNowButton: false, // Shows the 'now' button - showDeselectButton: false, // Shows the deselect time button - - maxTime: { - hour: null, - minute: null - }, - minTime: { - hour: null, - minute: null - } - - }; - $.extend(this._defaults, this.regional['']); - - this.tpDiv = $('<div id="' + this._mainDivId + '" class="ui-timepicker ui-widget ui-helper-clearfix ui-corner-all " style="display: none"></div>'); - } - - $.extend(Timepicker.prototype, { - /* Class name added to elements to indicate already configured with a time picker. */ - markerClassName: 'hasTimepicker', - - /* Debug logging (if enabled). */ - log: function () { - if (this.debug) - console.log.apply('', arguments); - }, - - _widgetTimepicker: function () { - return this.tpDiv; - }, - - /* Override the default settings for all instances of the time picker. - @param settings object - the new settings to use as defaults (anonymous object) - @return the manager object */ - setDefaults: function (settings) { - extendRemove(this._defaults, settings || {}); - return this; - }, - - /* Attach the time picker to a jQuery selection. - @param target element - the target input field or division or span - @param settings object - the new settings to use for this time picker instance (anonymous) */ - _attachTimepicker: function (target, settings) { - // check for settings on the control itself - in namespace 'time:' - var inlineSettings = null; - for (var attrName in this._defaults) { - var attrValue = target.getAttribute('time:' + attrName); - if (attrValue) { - inlineSettings = inlineSettings || {}; - try { - inlineSettings[attrName] = eval(attrValue); - } catch (err) { - inlineSettings[attrName] = attrValue; - } - } - } - var nodeName = target.nodeName.toLowerCase(); - var inline = (nodeName == 'div' || nodeName == 'span'); - - if (!target.id) { - this.uuid += 1; - target.id = 'tp' + this.uuid; - } - var inst = this._newInst($(target), inline); - inst.settings = $.extend({}, settings || {}, inlineSettings || {}); - if (nodeName == 'input') { - this._connectTimepicker(target, inst); - // init inst.hours and inst.minutes from the input value - this._setTimeFromField(inst); - } else if (inline) { - this._inlineTimepicker(target, inst); - } - - - }, - - /* Create a new instance object. */ - _newInst: function (target, inline) { - var id = target[0].id.replace(/([^A-Za-z0-9_-])/g, '\\\\$1'); // escape jQuery meta chars - return { - id: id, input: target, // associated target - inline: inline, // is timepicker inline or not : - tpDiv: (!inline ? this.tpDiv : // presentation div - $('<div class="' + this._inlineClass + ' ui-timepicker ui-widget ui-helper-clearfix"></div>')) - }; - }, - - /* Attach the time picker to an input field. */ - _connectTimepicker: function (target, inst) { - var input = $(target); - inst.append = $([]); - inst.trigger = $([]); - if (input.hasClass(this.markerClassName)) { return; } - this._attachments(input, inst); - input.addClass(this.markerClassName). - keydown(this._doKeyDown). - keyup(this._doKeyUp). - bind("setData.timepicker", function (event, key, value) { - inst.settings[key] = value; - }). - bind("getData.timepicker", function (event, key) { - return this._get(inst, key); - }); - $.data(target, PROP_NAME, inst); - }, - - /* Handle keystrokes. */ - _doKeyDown: function (event) { - var inst = $.timepicker._getInst(event.target); - var handled = true; - inst._keyEvent = true; - if ($.timepicker._timepickerShowing) { - switch (event.keyCode) { - case 9: $.timepicker._hideTimepicker(); - handled = false; - break; // hide on tab out - case 13: - $.timepicker._updateSelectedValue(inst); - $.timepicker._hideTimepicker(); - - return false; // don't submit the form - break; // select the value on enter - case 27: $.timepicker._hideTimepicker(); - break; // hide on escape - default: handled = false; - } - } - else if (event.keyCode == 36 && event.ctrlKey) { // display the time picker on ctrl+home - $.timepicker._showTimepicker(this); - } - else { - handled = false; - } - if (handled) { - event.preventDefault(); - event.stopPropagation(); - } - }, - - /* Update selected time on keyUp */ - /* Added verion 0.0.5 */ - _doKeyUp: function (event) { - var inst = $.timepicker._getInst(event.target); - $.timepicker._setTimeFromField(inst); - $.timepicker._updateTimepicker(inst); - }, - - /* Make attachments based on settings. */ - _attachments: function (input, inst) { - var appendText = this._get(inst, 'appendText'); - var isRTL = this._get(inst, 'isRTL'); - if (inst.append) { inst.append.remove(); } - if (appendText) { - inst.append = $('<span class="' + this._appendClass + '">' + appendText + '</span>'); - input[isRTL ? 'before' : 'after'](inst.append); - } - input.unbind('focus.timepicker', this._showTimepicker); - input.unbind('click.timepicker', this._adjustZIndex); - - if (inst.trigger) { inst.trigger.remove(); } - - var showOn = this._get(inst, 'showOn'); - if (showOn == 'focus' || showOn == 'both') { // pop-up time picker when in the marked field - input.bind("focus.timepicker", this._showTimepicker); - input.bind("click.timepicker", this._adjustZIndex); - } - if (showOn == 'button' || showOn == 'both') { // pop-up time picker when 'button' element is clicked - var button = this._get(inst, 'button'); - - // Add button if button element is not set - if(button == null) { - button = $('<button class="ui-timepicker-trigger" type="button">...</button>'); - input.after(button); - } - - $(button).bind("click.timepicker", function () { - if ($.timepicker._timepickerShowing && $.timepicker._lastInput == input[0]) { - $.timepicker._hideTimepicker(); - } else if (!inst.input.is(':disabled')) { - $.timepicker._showTimepicker(input[0]); - } - return false; - }); - - } - }, - - - /* Attach an inline time picker to a div. */ - _inlineTimepicker: function(target, inst) { - var divSpan = $(target); - if (divSpan.hasClass(this.markerClassName)) - return; - divSpan.addClass(this.markerClassName).append(inst.tpDiv). - bind("setData.timepicker", function(event, key, value){ - inst.settings[key] = value; - }).bind("getData.timepicker", function(event, key){ - return this._get(inst, key); - }); - $.data(target, PROP_NAME, inst); - - this._setTimeFromField(inst); - this._updateTimepicker(inst); - inst.tpDiv.show(); - }, - - _adjustZIndex: function(input) { - input = input.target || input; - var inst = $.timepicker._getInst(input); - inst.tpDiv.css('zIndex', $.timepicker._getZIndex(input) +1); - }, - - /* Pop-up the time picker for a given input field. - @param input element - the input field attached to the time picker or - event - if triggered by focus */ - _showTimepicker: function (input) { - input = input.target || input; - if (input.nodeName.toLowerCase() != 'input') { input = $('input', input.parentNode)[0]; } // find from button/image trigger - - if ($.timepicker._isDisabledTimepicker(input) || $.timepicker._lastInput == input) { return; } // already here - - // fix v 0.0.8 - close current timepicker before showing another one - $.timepicker._hideTimepicker(); - - var inst = $.timepicker._getInst(input); - if ($.timepicker._curInst && $.timepicker._curInst != inst) { - $.timepicker._curInst.tpDiv.stop(true, true); - } - var beforeShow = $.timepicker._get(inst, 'beforeShow'); - extendRemove(inst.settings, (beforeShow ? beforeShow.apply(input, [input, inst]) : {})); - inst.lastVal = null; - $.timepicker._lastInput = input; - - $.timepicker._setTimeFromField(inst); - - // calculate default position - if ($.timepicker._inDialog) { input.value = ''; } // hide cursor - if (!$.timepicker._pos) { // position below input - $.timepicker._pos = $.timepicker._findPos(input); - $.timepicker._pos[1] += input.offsetHeight; // add the height - } - var isFixed = false; - $(input).parents().each(function () { - isFixed |= $(this).css('position') == 'fixed'; - return !isFixed; - }); - - var offset = { left: $.timepicker._pos[0], top: $.timepicker._pos[1] }; - - $.timepicker._pos = null; - // determine sizing offscreen - inst.tpDiv.css({ position: 'absolute', display: 'block', top: '-1000px' }); - $.timepicker._updateTimepicker(inst); - - - // position with the ui position utility, if loaded - if ( ( ! inst.inline ) && ( typeof $.ui.position == 'object' ) ) { - inst.tpDiv.position({ - of: inst.input, - my: $.timepicker._get( inst, 'myPosition' ), - at: $.timepicker._get( inst, 'atPosition' ), - // offset: $( "#offset" ).val(), - // using: using, - collision: 'flip' - }); - var offset = inst.tpDiv.offset(); - $.timepicker._pos = [offset.top, offset.left]; - } - - - // reset clicked state - inst._hoursClicked = false; - inst._minutesClicked = false; - - // fix width for dynamic number of time pickers - // and adjust position before showing - offset = $.timepicker._checkOffset(inst, offset, isFixed); - inst.tpDiv.css({ position: ($.timepicker._inDialog && $.blockUI ? - 'static' : (isFixed ? 'fixed' : 'absolute')), display: 'none', - left: offset.left + 'px', top: offset.top + 'px' - }); - if ( ! inst.inline ) { - var showAnim = $.timepicker._get(inst, 'showAnim'); - var duration = $.timepicker._get(inst, 'duration'); - - var postProcess = function () { - $.timepicker._timepickerShowing = true; - var borders = $.timepicker._getBorders(inst.tpDiv); - inst.tpDiv.find('iframe.ui-timepicker-cover'). // IE6- only - css({ left: -borders[0], top: -borders[1], - width: inst.tpDiv.outerWidth(), height: inst.tpDiv.outerHeight() - }); - }; - - // Fixed the zIndex problem for real (I hope) - FG - v 0.2.9 - $.timepicker._adjustZIndex(input); - //inst.tpDiv.css('zIndex', $.timepicker._getZIndex(input) +1); - - if ($.effects && $.effects[showAnim]) { - inst.tpDiv.show(showAnim, $.timepicker._get(inst, 'showOptions'), duration, postProcess); - } - else { - inst.tpDiv.show((showAnim ? duration : null), postProcess); - } - if (!showAnim || !duration) { postProcess(); } - if (inst.input.is(':visible') && !inst.input.is(':disabled')) { inst.input.focus(); } - $.timepicker._curInst = inst; - } - }, - - // This is an enhanced copy of the zIndex function of UI core 1.8.?? For backward compatibility. - // Enhancement returns maximum zindex value discovered while traversing parent elements, - // rather than the first zindex value found. Ensures the timepicker popup will be in front, - // even in funky scenarios like non-jq dialog containers with large fixed zindex values and - // nested zindex-influenced elements of their own. - _getZIndex: function (target) { - var elem = $(target); - var maxValue = 0; - var position, value; - while (elem.length && elem[0] !== document) { - position = elem.css("position"); - if (position === "absolute" || position === "relative" || position === "fixed") { - value = parseInt(elem.css("zIndex"), 10); - if (!isNaN(value) && value !== 0) { - if (value > maxValue) { maxValue = value; } - } - } - elem = elem.parent(); - } - - return maxValue; - }, - - /* Refresh the time picker - @param target element - The target input field or inline container element. */ - _refreshTimepicker: function(target) { - var inst = this._getInst(target); - if (inst) { - this._updateTimepicker(inst); - } - }, - - - /* Generate the time picker content. */ - _updateTimepicker: function (inst) { - inst.tpDiv.empty().append(this._generateHTML(inst)); - this._rebindDialogEvents(inst); - - }, - - _rebindDialogEvents: function (inst) { - var borders = $.timepicker._getBorders(inst.tpDiv), - self = this; - inst.tpDiv - .find('iframe.ui-timepicker-cover') // IE6- only - .css({ left: -borders[0], top: -borders[1], - width: inst.tpDiv.outerWidth(), height: inst.tpDiv.outerHeight() - }) - .end() - // after the picker html is appended bind the click & double click events (faster in IE this way - // then letting the browser interpret the inline events) - // the binding for the minute cells also exists in _updateMinuteDisplay - .find('.ui-timepicker-minute-cell') - .unbind() - .bind("click", { fromDoubleClick:false }, $.proxy($.timepicker.selectMinutes, this)) - .bind("dblclick", { fromDoubleClick:true }, $.proxy($.timepicker.selectMinutes, this)) - .end() - .find('.ui-timepicker-hour-cell') - .unbind() - .bind("click", { fromDoubleClick:false }, $.proxy($.timepicker.selectHours, this)) - .bind("dblclick", { fromDoubleClick:true }, $.proxy($.timepicker.selectHours, this)) - .end() - .find('.ui-timepicker td a') - .unbind() - .bind('mouseout', function () { - $(this).removeClass('ui-state-hover'); - if (this.className.indexOf('ui-timepicker-prev') != -1) $(this).removeClass('ui-timepicker-prev-hover'); - if (this.className.indexOf('ui-timepicker-next') != -1) $(this).removeClass('ui-timepicker-next-hover'); - }) - .bind('mouseover', function () { - if ( ! self._isDisabledTimepicker(inst.inline ? inst.tpDiv.parent()[0] : inst.input[0])) { - $(this).parents('.ui-timepicker-calendar').find('a').removeClass('ui-state-hover'); - $(this).addClass('ui-state-hover'); - if (this.className.indexOf('ui-timepicker-prev') != -1) $(this).addClass('ui-timepicker-prev-hover'); - if (this.className.indexOf('ui-timepicker-next') != -1) $(this).addClass('ui-timepicker-next-hover'); - } - }) - .end() - .find('.' + this._dayOverClass + ' a') - .trigger('mouseover') - .end() - .find('.ui-timepicker-now').bind("click", function(e) { - $.timepicker.selectNow(e); - }).end() - .find('.ui-timepicker-deselect').bind("click",function(e) { - $.timepicker.deselectTime(e); - }).end() - .find('.ui-timepicker-close').bind("click",function(e) { - $.timepicker._hideTimepicker(); - }).end(); - }, - - /* Generate the HTML for the current state of the time picker. */ - _generateHTML: function (inst) { - - var h, m, row, col, html, hoursHtml, minutesHtml = '', - showPeriod = (this._get(inst, 'showPeriod') == true), - showPeriodLabels = (this._get(inst, 'showPeriodLabels') == true), - showLeadingZero = (this._get(inst, 'showLeadingZero') == true), - showHours = (this._get(inst, 'showHours') == true), - showMinutes = (this._get(inst, 'showMinutes') == true), - amPmText = this._get(inst, 'amPmText'), - rows = this._get(inst, 'rows'), - amRows = 0, - pmRows = 0, - amItems = 0, - pmItems = 0, - amFirstRow = 0, - pmFirstRow = 0, - hours = Array(), - hours_options = this._get(inst, 'hours'), - hoursPerRow = null, - hourCounter = 0, - hourLabel = this._get(inst, 'hourText'), - showCloseButton = this._get(inst, 'showCloseButton'), - closeButtonText = this._get(inst, 'closeButtonText'), - showNowButton = this._get(inst, 'showNowButton'), - nowButtonText = this._get(inst, 'nowButtonText'), - showDeselectButton = this._get(inst, 'showDeselectButton'), - deselectButtonText = this._get(inst, 'deselectButtonText'), - showButtonPanel = showCloseButton || showNowButton || showDeselectButton; - - - - // prepare all hours and minutes, makes it easier to distribute by rows - for (h = hours_options.starts; h <= hours_options.ends; h++) { - hours.push (h); - } - hoursPerRow = Math.ceil(hours.length / rows); // always round up - - if (showPeriodLabels) { - for (hourCounter = 0; hourCounter < hours.length; hourCounter++) { - if (hours[hourCounter] < 12) { - amItems++; - } - else { - pmItems++; - } - } - hourCounter = 0; - - amRows = Math.floor(amItems / hours.length * rows); - pmRows = Math.floor(pmItems / hours.length * rows); - - // assign the extra row to the period that is more densely populated - if (rows != amRows + pmRows) { - // Make sure: AM Has Items and either PM Does Not, AM has no rows yet, or AM is more dense - if (amItems && (!pmItems || !amRows || (pmRows && amItems / amRows >= pmItems / pmRows))) { - amRows++; - } else { - pmRows++; - } - } - amFirstRow = Math.min(amRows, 1); - pmFirstRow = amRows + 1; - - if (amRows == 0) { - hoursPerRow = Math.ceil(pmItems / pmRows); - } else if (pmRows == 0) { - hoursPerRow = Math.ceil(amItems / amRows); - } else { - hoursPerRow = Math.ceil(Math.max(amItems / amRows, pmItems / pmRows)); - } - } - - - html = '<table class="ui-timepicker-table ui-widget-content ui-corner-all"><tr>'; - - if (showHours) { - - html += '<td class="ui-timepicker-hours">' + - '<div class="ui-timepicker-title ui-widget-header ui-helper-clearfix ui-corner-all">' + - hourLabel + - '</div>' + - '<table class="ui-timepicker">'; - - for (row = 1; row <= rows; row++) { - html += '<tr>'; - // AM - if (row == amFirstRow && showPeriodLabels) { - html += '<th rowspan="' + amRows.toString() + '" class="periods" scope="row">' + amPmText[0] + '</th>'; - } - // PM - if (row == pmFirstRow && showPeriodLabels) { - html += '<th rowspan="' + pmRows.toString() + '" class="periods" scope="row">' + amPmText[1] + '</th>'; - } - for (col = 1; col <= hoursPerRow; col++) { - if (showPeriodLabels && row < pmFirstRow && hours[hourCounter] >= 12) { - html += this._generateHTMLHourCell(inst, undefined, showPeriod, showLeadingZero); - } else { - html += this._generateHTMLHourCell(inst, hours[hourCounter], showPeriod, showLeadingZero); - hourCounter++; - } - } - html += '</tr>'; - } - html += '</table>' + // Close the hours cells table - '</td>'; // Close the Hour td - } - - if (showMinutes) { - html += '<td class="ui-timepicker-minutes">'; - html += this._generateHTMLMinutes(inst); - html += '</td>'; - } - - html += '</tr>'; - - - if (showButtonPanel) { - var buttonPanel = '<tr><td colspan="3"><div class="ui-timepicker-buttonpane ui-widget-content">'; - if (showNowButton) { - buttonPanel += '<button type="button" class="ui-timepicker-now ui-state-default ui-corner-all" ' - + ' data-timepicker-instance-id="#' + inst.id.replace(/\\\\/g,"\\") + '" >' - + nowButtonText + '</button>'; - } - if (showDeselectButton) { - buttonPanel += '<button type="button" class="ui-timepicker-deselect ui-state-default ui-corner-all" ' - + ' data-timepicker-instance-id="#' + inst.id.replace(/\\\\/g,"\\") + '" >' - + deselectButtonText + '</button>'; - } - if (showCloseButton) { - buttonPanel += '<button type="button" class="ui-timepicker-close ui-state-default ui-corner-all" ' - + ' data-timepicker-instance-id="#' + inst.id.replace(/\\\\/g,"\\") + '" >' - + closeButtonText + '</button>'; - } - - html += buttonPanel + '</div></td></tr>'; - } - html += '</table>'; - - return html; - }, - - /* Special function that update the minutes selection in currently visible timepicker - * called on hour selection when onMinuteShow is defined */ - _updateMinuteDisplay: function (inst) { - var newHtml = this._generateHTMLMinutes(inst); - inst.tpDiv.find('td.ui-timepicker-minutes').html(newHtml); - this._rebindDialogEvents(inst); - // after the picker html is appended bind the click & double click events (faster in IE this way - // then letting the browser interpret the inline events) - // yes I know, duplicate code, sorry -/* .find('.ui-timepicker-minute-cell') - .bind("click", { fromDoubleClick:false }, $.proxy($.timepicker.selectMinutes, this)) - .bind("dblclick", { fromDoubleClick:true }, $.proxy($.timepicker.selectMinutes, this)); -*/ - - }, - - /* - * Generate the minutes table - * This is separated from the _generateHTML function because is can be called separately (when hours changes) - */ - _generateHTMLMinutes: function (inst) { - - var m, row, html = '', - rows = this._get(inst, 'rows'), - minutes = Array(), - minutes_options = this._get(inst, 'minutes'), - minutesPerRow = null, - minuteCounter = 0, - showMinutesLeadingZero = (this._get(inst, 'showMinutesLeadingZero') == true), - onMinuteShow = this._get(inst, 'onMinuteShow'), - minuteLabel = this._get(inst, 'minuteText'); - - if ( ! minutes_options.starts) { - minutes_options.starts = 0; - } - if ( ! minutes_options.ends) { - minutes_options.ends = 59; - } - if ( ! minutes_options.manual) { - minutes_options.manual = []; - } - for (m = minutes_options.starts; m <= minutes_options.ends; m += minutes_options.interval) { - minutes.push(m); - } - for (i = 0; i < minutes_options.manual.length;i++) { - var currMin = minutes_options.manual[i]; - - // Validate & filter duplicates of manual minute input - if (typeof currMin != 'number' || currMin < 0 || currMin > 59 || $.inArray(currMin, minutes) >= 0) { - continue; - } - minutes.push(currMin); - } - - // Sort to get correct order after adding manual minutes - // Use compare function to sort by number, instead of string (default) - minutes.sort(function(a, b) { - return a-b; - }); - - minutesPerRow = Math.round(minutes.length / rows + 0.49); // always round up - - /* - * The minutes table - */ - // if currently selected minute is not enabled, we have a problem and need to select a new minute. - if (onMinuteShow && - (onMinuteShow.apply((inst.input ? inst.input[0] : null), [inst.hours , inst.minutes]) == false) ) { - // loop minutes and select first available - for (minuteCounter = 0; minuteCounter < minutes.length; minuteCounter += 1) { - m = minutes[minuteCounter]; - if (onMinuteShow.apply((inst.input ? inst.input[0] : null), [inst.hours, m])) { - inst.minutes = m; - break; - } - } - } - - - - html += '<div class="ui-timepicker-title ui-widget-header ui-helper-clearfix ui-corner-all">' + - minuteLabel + - '</div>' + - '<table class="ui-timepicker">'; - - minuteCounter = 0; - for (row = 1; row <= rows; row++) { - html += '<tr>'; - while (minuteCounter < row * minutesPerRow) { - var m = minutes[minuteCounter]; - var displayText = ''; - if (m !== undefined ) { - displayText = (m < 10) && showMinutesLeadingZero ? "0" + m.toString() : m.toString(); - } - html += this._generateHTMLMinuteCell(inst, m, displayText); - minuteCounter++; - } - html += '</tr>'; - } - - html += '</table>'; - - return html; - }, - - /* Generate the content of a "Hour" cell */ - _generateHTMLHourCell: function (inst, hour, showPeriod, showLeadingZero) { - - var displayHour = hour; - if ((hour > 12) && showPeriod) { - displayHour = hour - 12; - } - if ((displayHour == 0) && showPeriod) { - displayHour = 12; - } - if ((displayHour < 10) && showLeadingZero) { - displayHour = '0' + displayHour; - } - - var html = ""; - var enabled = true; - var onHourShow = this._get(inst, 'onHourShow'); //custom callback - var maxTime = this._get(inst, 'maxTime'); - var minTime = this._get(inst, 'minTime'); - - if (hour == undefined) { - html = '<td><span class="ui-state-default ui-state-disabled"> </span></td>'; - return html; - } - - if (onHourShow) { - enabled = onHourShow.apply((inst.input ? inst.input[0] : null), [hour]); - } - - if (enabled) { - if ( !isNaN(parseInt(maxTime.hour)) && hour > maxTime.hour ) enabled = false; - if ( !isNaN(parseInt(minTime.hour)) && hour < minTime.hour ) enabled = false; - } - - if (enabled) { - html = '<td class="ui-timepicker-hour-cell" data-timepicker-instance-id="#' + inst.id.replace(/\\\\/g,"\\") + '" data-hour="' + hour.toString() + '">' + - '<a class="ui-state-default ' + - (hour == inst.hours ? 'ui-state-active' : '') + - '">' + - displayHour.toString() + - '</a></td>'; - } - else { - html = - '<td>' + - '<span class="ui-state-default ui-state-disabled ' + - (hour == inst.hours ? ' ui-state-active ' : ' ') + - '">' + - displayHour.toString() + - '</span>' + - '</td>'; - } - return html; - }, - - /* Generate the content of a "Hour" cell */ - _generateHTMLMinuteCell: function (inst, minute, displayText) { - var html = ""; - var enabled = true; - var hour = inst.hours; - var onMinuteShow = this._get(inst, 'onMinuteShow'); //custom callback - var maxTime = this._get(inst, 'maxTime'); - var minTime = this._get(inst, 'minTime'); - - if (onMinuteShow) { - //NEW: 2011-02-03 we should give the hour as a parameter as well! - enabled = onMinuteShow.apply((inst.input ? inst.input[0] : null), [inst.hours,minute]); //trigger callback - } - - if (minute == undefined) { - html = '<td><span class="ui-state-default ui-state-disabled"> </span></td>'; - return html; - } - - if (enabled && hour !== null) { - if ( !isNaN(parseInt(maxTime.hour)) && !isNaN(parseInt(maxTime.minute)) && hour >= maxTime.hour && minute > maxTime.minute ) enabled = false; - if ( !isNaN(parseInt(minTime.hour)) && !isNaN(parseInt(minTime.minute)) && hour <= minTime.hour && minute < minTime.minute ) enabled = false; - } - - if (enabled) { - html = '<td class="ui-timepicker-minute-cell" data-timepicker-instance-id="#' + inst.id.replace(/\\\\/g,"\\") + '" data-minute="' + minute.toString() + '" >' + - '<a class="ui-state-default ' + - (minute == inst.minutes ? 'ui-state-active' : '') + - '" >' + - displayText + - '</a></td>'; - } - else { - - html = '<td>' + - '<span class="ui-state-default ui-state-disabled" >' + - displayText + - '</span>' + - '</td>'; - } - return html; - }, - - - /* Detach a timepicker from its control. - @param target element - the target input field or division or span */ - _destroyTimepicker: function(target) { - var $target = $(target); - var inst = $.data(target, PROP_NAME); - if (!$target.hasClass(this.markerClassName)) { - return; - } - var nodeName = target.nodeName.toLowerCase(); - $.removeData(target, PROP_NAME); - if (nodeName == 'input') { - inst.append.remove(); - inst.trigger.remove(); - $target.removeClass(this.markerClassName) - .unbind('focus.timepicker', this._showTimepicker) - .unbind('click.timepicker', this._adjustZIndex); - } else if (nodeName == 'div' || nodeName == 'span') - $target.removeClass(this.markerClassName).empty(); - }, - - /* Enable the date picker to a jQuery selection. - @param target element - the target input field or division or span */ - _enableTimepicker: function(target) { - var $target = $(target), - target_id = $target.attr('id'), - inst = $.data(target, PROP_NAME); - - if (!$target.hasClass(this.markerClassName)) { - return; - } - var nodeName = target.nodeName.toLowerCase(); - if (nodeName == 'input') { - target.disabled = false; - var button = this._get(inst, 'button'); - $(button).removeClass('ui-state-disabled').disabled = false; - inst.trigger.filter('button'). - each(function() { this.disabled = false; }).end(); - } - else if (nodeName == 'div' || nodeName == 'span') { - var inline = $target.children('.' + this._inlineClass); - inline.children().removeClass('ui-state-disabled'); - inline.find('button').each( - function() { this.disabled = false } - ) - } - this._disabledInputs = $.map(this._disabledInputs, - function(value) { return (value == target_id ? null : value); }); // delete entry - }, - - /* Disable the time picker to a jQuery selection. - @param target element - the target input field or division or span */ - _disableTimepicker: function(target) { - var $target = $(target); - var inst = $.data(target, PROP_NAME); - if (!$target.hasClass(this.markerClassName)) { - return; - } - var nodeName = target.nodeName.toLowerCase(); - if (nodeName == 'input') { - var button = this._get(inst, 'button'); - - $(button).addClass('ui-state-disabled').disabled = true; - target.disabled = true; - - inst.trigger.filter('button'). - each(function() { this.disabled = true; }).end(); - - } - else if (nodeName == 'div' || nodeName == 'span') { - var inline = $target.children('.' + this._inlineClass); - inline.children().addClass('ui-state-disabled'); - inline.find('button').each( - function() { this.disabled = true } - ) - - } - this._disabledInputs = $.map(this._disabledInputs, - function(value) { return (value == target ? null : value); }); // delete entry - this._disabledInputs[this._disabledInputs.length] = $target.attr('id'); - }, - - /* Is the first field in a jQuery collection disabled as a timepicker? - @param target_id element - the target input field or division or span - @return boolean - true if disabled, false if enabled */ - _isDisabledTimepicker: function (target_id) { - if ( ! target_id) { return false; } - for (var i = 0; i < this._disabledInputs.length; i++) { - if (this._disabledInputs[i] == target_id) { return true; } - } - return false; - }, - - /* Check positioning to remain on screen. */ - _checkOffset: function (inst, offset, isFixed) { - var tpWidth = inst.tpDiv.outerWidth(); - var tpHeight = inst.tpDiv.outerHeight(); - var inputWidth = inst.input ? inst.input.outerWidth() : 0; - var inputHeight = inst.input ? inst.input.outerHeight() : 0; - var viewWidth = document.documentElement.clientWidth + $(document).scrollLeft(); - var viewHeight = document.documentElement.clientHeight + $(document).scrollTop(); - - offset.left -= (this._get(inst, 'isRTL') ? (tpWidth - inputWidth) : 0); - offset.left -= (isFixed && offset.left == inst.input.offset().left) ? $(document).scrollLeft() : 0; - offset.top -= (isFixed && offset.top == (inst.input.offset().top + inputHeight)) ? $(document).scrollTop() : 0; - - // now check if timepicker is showing outside window viewport - move to a better place if so. - offset.left -= Math.min(offset.left, (offset.left + tpWidth > viewWidth && viewWidth > tpWidth) ? - Math.abs(offset.left + tpWidth - viewWidth) : 0); - offset.top -= Math.min(offset.top, (offset.top + tpHeight > viewHeight && viewHeight > tpHeight) ? - Math.abs(tpHeight + inputHeight) : 0); - - return offset; - }, - - /* Find an object's position on the screen. */ - _findPos: function (obj) { - var inst = this._getInst(obj); - var isRTL = this._get(inst, 'isRTL'); - while (obj && (obj.type == 'hidden' || obj.nodeType != 1)) { - obj = obj[isRTL ? 'previousSibling' : 'nextSibling']; - } - var position = $(obj).offset(); - return [position.left, position.top]; - }, - - /* Retrieve the size of left and top borders for an element. - @param elem (jQuery object) the element of interest - @return (number[2]) the left and top borders */ - _getBorders: function (elem) { - var convert = function (value) { - return { thin: 1, medium: 2, thick: 3}[value] || value; - }; - return [parseFloat(convert(elem.css('border-left-width'))), - parseFloat(convert(elem.css('border-top-width')))]; - }, - - - /* Close time picker if clicked elsewhere. */ - _checkExternalClick: function (event) { - if (!$.timepicker._curInst) { return; } - var $target = $(event.target); - if ($target[0].id != $.timepicker._mainDivId && - $target.parents('#' + $.timepicker._mainDivId).length == 0 && - !$target.hasClass($.timepicker.markerClassName) && - !$target.hasClass($.timepicker._triggerClass) && - $.timepicker._timepickerShowing && !($.timepicker._inDialog && $.blockUI)) - $.timepicker._hideTimepicker(); - }, - - /* Hide the time picker from view. - @param input element - the input field attached to the time picker */ - _hideTimepicker: function (input) { - var inst = this._curInst; - if (!inst || (input && inst != $.data(input, PROP_NAME))) { return; } - if (this._timepickerShowing) { - var showAnim = this._get(inst, 'showAnim'); - var duration = this._get(inst, 'duration'); - var postProcess = function () { - $.timepicker._tidyDialog(inst); - this._curInst = null; - }; - if ($.effects && $.effects[showAnim]) { - inst.tpDiv.hide(showAnim, $.timepicker._get(inst, 'showOptions'), duration, postProcess); - } - else { - inst.tpDiv[(showAnim == 'slideDown' ? 'slideUp' : - (showAnim == 'fadeIn' ? 'fadeOut' : 'hide'))]((showAnim ? duration : null), postProcess); - } - if (!showAnim) { postProcess(); } - - this._timepickerShowing = false; - - this._lastInput = null; - if (this._inDialog) { - this._dialogInput.css({ position: 'absolute', left: '0', top: '-100px' }); - if ($.blockUI) { - $.unblockUI(); - $('body').append(this.tpDiv); - } - } - this._inDialog = false; - - var onClose = this._get(inst, 'onClose'); - if (onClose) { - onClose.apply( - (inst.input ? inst.input[0] : null), - [(inst.input ? inst.input.val() : ''), inst]); // trigger custom callback - } - - } - }, - - - - /* Tidy up after a dialog display. */ - _tidyDialog: function (inst) { - inst.tpDiv.removeClass(this._dialogClass).unbind('.ui-timepicker'); - }, - - /* Retrieve the instance data for the target control. - @param target element - the target input field or division or span - @return object - the associated instance data - @throws error if a jQuery problem getting data */ - _getInst: function (target) { - try { - return $.data(target, PROP_NAME); - } - catch (err) { - throw 'Missing instance data for this timepicker'; - } - }, - - /* Get a setting value, defaulting if necessary. */ - _get: function (inst, name) { - return inst.settings[name] !== undefined ? - inst.settings[name] : this._defaults[name]; - }, - - /* Parse existing time and initialise time picker. */ - _setTimeFromField: function (inst) { - if (inst.input.val() == inst.lastVal) { return; } - var defaultTime = this._get(inst, 'defaultTime'); - - var timeToParse = defaultTime == 'now' ? this._getCurrentTimeRounded(inst) : defaultTime; - if ((inst.inline == false) && (inst.input.val() != '')) { timeToParse = inst.input.val() } - - if (timeToParse instanceof Date) { - inst.hours = timeToParse.getHours(); - inst.minutes = timeToParse.getMinutes(); - } else { - var timeVal = inst.lastVal = timeToParse; - if (timeToParse == '') { - inst.hours = -1; - inst.minutes = -1; - } else { - var time = this.parseTime(inst, timeVal); - inst.hours = time.hours; - inst.minutes = time.minutes; - } - } - - - $.timepicker._updateTimepicker(inst); - }, - - /* Update or retrieve the settings for an existing time picker. - @param target element - the target input field or division or span - @param name object - the new settings to update or - string - the name of the setting to change or retrieve, - when retrieving also 'all' for all instance settings or - 'defaults' for all global defaults - @param value any - the new value for the setting - (omit if above is an object or to retrieve a value) */ - _optionTimepicker: function(target, name, value) { - var inst = this._getInst(target); - if (arguments.length == 2 && typeof name == 'string') { - return (name == 'defaults' ? $.extend({}, $.timepicker._defaults) : - (inst ? (name == 'all' ? $.extend({}, inst.settings) : - this._get(inst, name)) : null)); - } - var settings = name || {}; - if (typeof name == 'string') { - settings = {}; - settings[name] = value; - } - if (inst) { - extendRemove(inst.settings, settings); - if (this._curInst == inst) { - this._hideTimepicker(); - this._updateTimepicker(inst); - } - if (inst.inline) { - this._updateTimepicker(inst); - } - } - }, - - - /* Set the time for a jQuery selection. - @param target element - the target input field or division or span - @param time String - the new time */ - _setTimeTimepicker: function(target, time) { - var inst = this._getInst(target); - if (inst) { - this._setTime(inst, time); - this._updateTimepicker(inst); - this._updateAlternate(inst, time); - } - }, - - /* Set the time directly. */ - _setTime: function(inst, time, noChange) { - var origHours = inst.hours; - var origMinutes = inst.minutes; - if (time instanceof Date) { - inst.hours = time.getHours(); - inst.minutes = time.getMinutes(); - } else { - var time = this.parseTime(inst, time); - inst.hours = time.hours; - inst.minutes = time.minutes; - } - - if ((origHours != inst.hours || origMinutes != inst.minutes) && !noChange) { - inst.input.trigger('change'); - } - this._updateTimepicker(inst); - this._updateSelectedValue(inst); - }, - - /* Return the current time, ready to be parsed, rounded to the closest minute by interval */ - _getCurrentTimeRounded: function (inst) { - var currentTime = new Date(), - currentMinutes = currentTime.getMinutes(), - minutes_options = this._get(inst, 'minutes'), - // round to closest interval - adjustedMinutes = Math.round(currentMinutes / minutes_options.interval) * minutes_options.interval; - currentTime.setMinutes(adjustedMinutes); - return currentTime; - }, - - /* - * Parse a time string into hours and minutes - */ - parseTime: function (inst, timeVal) { - var retVal = new Object(); - retVal.hours = -1; - retVal.minutes = -1; - - if(!timeVal) - return ''; - - var timeSeparator = this._get(inst, 'timeSeparator'), - amPmText = this._get(inst, 'amPmText'), - showHours = this._get(inst, 'showHours'), - showMinutes = this._get(inst, 'showMinutes'), - optionalMinutes = this._get(inst, 'optionalMinutes'), - showPeriod = (this._get(inst, 'showPeriod') == true), - p = timeVal.indexOf(timeSeparator); - - // check if time separator found - if (p != -1) { - retVal.hours = parseInt(timeVal.substr(0, p), 10); - retVal.minutes = parseInt(timeVal.substr(p + 1), 10); - } - // check for hours only - else if ( (showHours) && ( !showMinutes || optionalMinutes ) ) { - retVal.hours = parseInt(timeVal, 10); - } - // check for minutes only - else if ( ( ! showHours) && (showMinutes) ) { - retVal.minutes = parseInt(timeVal, 10); - } - - if (showHours) { - var timeValUpper = timeVal.toUpperCase(); - if ((retVal.hours < 12) && (showPeriod) && (timeValUpper.indexOf(amPmText[1].toUpperCase()) != -1)) { - retVal.hours += 12; - } - // fix for 12 AM - if ((retVal.hours == 12) && (showPeriod) && (timeValUpper.indexOf(amPmText[0].toUpperCase()) != -1)) { - retVal.hours = 0; - } - } - - return retVal; - }, - - selectNow: function(event) { - var id = $(event.target).attr("data-timepicker-instance-id"), - $target = $(id), - inst = this._getInst($target[0]); - //if (!inst || (input && inst != $.data(input, PROP_NAME))) { return; } - var currentTime = new Date(); - inst.hours = currentTime.getHours(); - inst.minutes = currentTime.getMinutes(); - this._updateSelectedValue(inst); - this._updateTimepicker(inst); - this._hideTimepicker(); - }, - - deselectTime: function(event) { - var id = $(event.target).attr("data-timepicker-instance-id"), - $target = $(id), - inst = this._getInst($target[0]); - inst.hours = -1; - inst.minutes = -1; - this._updateSelectedValue(inst); - this._hideTimepicker(); - }, - - - selectHours: function (event) { - var $td = $(event.currentTarget), - id = $td.attr("data-timepicker-instance-id"), - newHours = parseInt($td.attr("data-hour")), - fromDoubleClick = event.data.fromDoubleClick, - $target = $(id), - inst = this._getInst($target[0]), - showMinutes = (this._get(inst, 'showMinutes') == true); - - // don't select if disabled - if ( $.timepicker._isDisabledTimepicker($target.attr('id')) ) { return false } - - $td.parents('.ui-timepicker-hours:first').find('a').removeClass('ui-state-active'); - $td.children('a').addClass('ui-state-active'); - inst.hours = newHours; - - // added for onMinuteShow callback - var onMinuteShow = this._get(inst, 'onMinuteShow'), - maxTime = this._get(inst, 'maxTime'), - minTime = this._get(inst, 'minTime'); - if (onMinuteShow || maxTime.minute || minTime.minute) { - // this will trigger a callback on selected hour to make sure selected minute is allowed. - this._updateMinuteDisplay(inst); - } - - this._updateSelectedValue(inst); - - inst._hoursClicked = true; - if ((inst._minutesClicked) || (fromDoubleClick) || (showMinutes == false)) { - $.timepicker._hideTimepicker(); - } - // return false because if used inline, prevent the url to change to a hashtag - return false; - }, - - selectMinutes: function (event) { - var $td = $(event.currentTarget), - id = $td.attr("data-timepicker-instance-id"), - newMinutes = parseInt($td.attr("data-minute")), - fromDoubleClick = event.data.fromDoubleClick, - $target = $(id), - inst = this._getInst($target[0]), - showHours = (this._get(inst, 'showHours') == true); - - // don't select if disabled - if ( $.timepicker._isDisabledTimepicker($target.attr('id')) ) { return false } - - $td.parents('.ui-timepicker-minutes:first').find('a').removeClass('ui-state-active'); - $td.children('a').addClass('ui-state-active'); - - inst.minutes = newMinutes; - this._updateSelectedValue(inst); - - inst._minutesClicked = true; - if ((inst._hoursClicked) || (fromDoubleClick) || (showHours == false)) { - $.timepicker._hideTimepicker(); - // return false because if used inline, prevent the url to change to a hashtag - return false; - } - - // return false because if used inline, prevent the url to change to a hashtag - return false; - }, - - _updateSelectedValue: function (inst) { - var newTime = this._getParsedTime(inst); - if (inst.input) { - inst.input.val(newTime); - inst.input.trigger('change'); - } - var onSelect = this._get(inst, 'onSelect'); - if (onSelect) { onSelect.apply((inst.input ? inst.input[0] : null), [newTime, inst]); } // trigger custom callback - this._updateAlternate(inst, newTime); - return newTime; - }, - - /* this function process selected time and return it parsed according to instance options */ - _getParsedTime: function(inst) { - - if (inst.hours == -1 && inst.minutes == -1) { - return ''; - } - - // default to 0 AM if hours is not valid - if ((inst.hours < inst.hours.starts) || (inst.hours > inst.hours.ends )) { inst.hours = 0; } - // default to 0 minutes if minute is not valid - if ((inst.minutes < inst.minutes.starts) || (inst.minutes > inst.minutes.ends)) { inst.minutes = 0; } - - var period = "", - showPeriod = (this._get(inst, 'showPeriod') == true), - showLeadingZero = (this._get(inst, 'showLeadingZero') == true), - showHours = (this._get(inst, 'showHours') == true), - showMinutes = (this._get(inst, 'showMinutes') == true), - optionalMinutes = (this._get(inst, 'optionalMinutes') == true), - amPmText = this._get(inst, 'amPmText'), - selectedHours = inst.hours ? inst.hours : 0, - selectedMinutes = inst.minutes ? inst.minutes : 0, - displayHours = selectedHours ? selectedHours : 0, - parsedTime = ''; - - // fix some display problem when hours or minutes are not selected yet - if (displayHours == -1) { displayHours = 0 } - if (selectedMinutes == -1) { selectedMinutes = 0 } - - if (showPeriod) { - if (inst.hours == 0) { - displayHours = 12; - } - if (inst.hours < 12) { - period = amPmText[0]; - } - else { - period = amPmText[1]; - if (displayHours > 12) { - displayHours -= 12; - } - } - } - - var h = displayHours.toString(); - if (showLeadingZero && (displayHours < 10)) { h = '0' + h; } - - var m = selectedMinutes.toString(); - if (selectedMinutes < 10) { m = '0' + m; } - - if (showHours) { - parsedTime += h; - } - if (showHours && showMinutes && (!optionalMinutes || m != 0)) { - parsedTime += this._get(inst, 'timeSeparator'); - } - if (showMinutes && (!optionalMinutes || m != 0)) { - parsedTime += m; - } - if (showHours) { - if (period.length > 0) { parsedTime += this._get(inst, 'periodSeparator') + period; } - } - - return parsedTime; - }, - - /* Update any alternate field to synchronise with the main field. */ - _updateAlternate: function(inst, newTime) { - var altField = this._get(inst, 'altField'); - if (altField) { // update alternate field too - $(altField).each(function(i,e) { - $(e).val(newTime); - }); - } - }, - - _getTimeAsDateTimepicker: function(input) { - var inst = this._getInst(input); - if (inst.hours == -1 && inst.minutes == -1) { - return ''; - } - - // default to 0 AM if hours is not valid - if ((inst.hours < inst.hours.starts) || (inst.hours > inst.hours.ends )) { inst.hours = 0; } - // default to 0 minutes if minute is not valid - if ((inst.minutes < inst.minutes.starts) || (inst.minutes > inst.minutes.ends)) { inst.minutes = 0; } - - return new Date(0, 0, 0, inst.hours, inst.minutes, 0); - }, - /* This might look unused but it's called by the $.fn.timepicker function with param getTime */ - /* added v 0.2.3 - gitHub issue #5 - Thanks edanuff */ - _getTimeTimepicker : function(input) { - var inst = this._getInst(input); - return this._getParsedTime(inst); - }, - _getHourTimepicker: function(input) { - var inst = this._getInst(input); - if ( inst == undefined) { return -1; } - return inst.hours; - }, - _getMinuteTimepicker: function(input) { - var inst= this._getInst(input); - if ( inst == undefined) { return -1; } - return inst.minutes; - } - - }); - - - - /* Invoke the timepicker functionality. - @param options string - a command, optionally followed by additional parameters or - Object - settings for attaching new timepicker functionality - @return jQuery object */ - $.fn.timepicker = function (options) { - /* Initialise the time picker. */ - if (!$.timepicker.initialized) { - $(document).mousedown($.timepicker._checkExternalClick); - $.timepicker.initialized = true; - } - - /* Append timepicker main container to body if not exist. */ - if ($("#"+$.timepicker._mainDivId).length === 0) { - $('body').append($.timepicker.tpDiv); - } - - var otherArgs = Array.prototype.slice.call(arguments, 1); - if (typeof options == 'string' && (options == 'getTime' || options == 'getTimeAsDate' || options == 'getHour' || options == 'getMinute' )) - return $.timepicker['_' + options + 'Timepicker']. - apply($.timepicker, [this[0]].concat(otherArgs)); - if (options == 'option' && arguments.length == 2 && typeof arguments[1] == 'string') - return $.timepicker['_' + options + 'Timepicker']. - apply($.timepicker, [this[0]].concat(otherArgs)); - return this.each(function () { - typeof options == 'string' ? - $.timepicker['_' + options + 'Timepicker']. - apply($.timepicker, [this].concat(otherArgs)) : - $.timepicker._attachTimepicker(this, options); - }); - }; - - /* jQuery extend now ignores nulls! */ - function extendRemove(target, props) { - $.extend(target, props); - for (var name in props) - if (props[name] == null || props[name] == undefined) - target[name] = props[name]; - return target; - }; - - $.timepicker = new Timepicker(); // singleton instance - $.timepicker.initialized = false; - $.timepicker.uuid = new Date().getTime(); - $.timepicker.version = "0.3.3"; - - // Workaround for #4055 - // Add another global to avoid noConflict issues with inline event handlers - window['TP_jQuery_' + tpuuid] = $; - -})(jQuery); diff --git a/tailbone/static/js/tailbone.edit-shifts.js b/tailbone/static/js/tailbone.edit-shifts.js deleted file mode 100644 index 87dc4a21..00000000 --- a/tailbone/static/js/tailbone.edit-shifts.js +++ /dev/null @@ -1,193 +0,0 @@ - -/************************************************************ - * - * tailbone.edit-shifts.js - * - * Common logic for editing time sheet / schedule data. - * - ************************************************************/ - - -var editing_day = null; -var new_shift_id = 1; - -function add_shift(focus, uuid, start_time, end_time) { - var shift = $('#snippets .shift').clone(); - if (! uuid) { - uuid = 'new-' + (new_shift_id++).toString(); - } - shift.attr('data-uuid', uuid); - shift.children('input').each(function() { - var name = $(this).attr('name') + '-' + uuid; - $(this).attr('name', name); - $(this).attr('id', name); - }); - shift.children('input[name|="edit_start_time"]').val(start_time || ''); - shift.children('input[name|="edit_end_time"]').val(end_time || ''); - $('#day-editor .shifts').append(shift); - shift.children('input').timepicker({showPeriod: true}); - if (focus) { - shift.children('input:first').focus(); - } -} - -function calc_minutes(start_time, end_time) { - var start = parseTime(start_time); - start = new Date(2000, 0, 1, start.hh, start.mm); - var end = parseTime(end_time); - end = new Date(2000, 0, 1, end.hh, end.mm); - return Math.floor((end - start) / 1000 / 60); -} - -function format_minutes(minutes) { - var hours = Math.floor(minutes / 60); - if (hours) { - minutes -= hours * 60; - } - return hours.toString() + ':' + (minutes < 10 ? '0' : '') + minutes.toString(); -} - -// stolen from http://stackoverflow.com/a/1788084 -function parseTime(s) { - var part = s.match(/(\d+):(\d+)(?: )?(am|pm)?/i); - var hh = parseInt(part[1], 10); - var mm = parseInt(part[2], 10); - var ap = part[3] ? part[3].toUpperCase() : null; - if (ap == 'AM') { - if (hh == 12) { - hh = 0; - } - } else if (ap == 'PM') { - if (hh != 12) { - hh += 12; - } - } - return { hh: hh, mm: mm }; -} - -function time_input(shift, type) { - var input = shift.children('input[name|="' + type + '_time"]'); - if (! input.length) { - input = $('<input type="hidden" name="' + type + '_time-' + shift.data('uuid') + '" />'); - shift.append(input); - } - return input; -} - -function update_row_hours(row) { - var minutes = 0; - row.find('.day .shift:not(.deleted)').each(function() { - var time_range = $.trim($(this).children('span').text()).split(' - '); - minutes += calc_minutes(time_range[0], time_range[1]); - }); - row.children('.total').text(minutes ? format_minutes(minutes) : '0'); -} - -$(function() { - - $('.timesheet').on('click', '.day', function() { - editing_day = $(this); - var editor = $('#day-editor'); - var employee = editing_day.siblings('.employee').text(); - var date = weekdays[editing_day.get(0).cellIndex - 1]; - var shifts = editor.children('.shifts'); - shifts.empty(); - editing_day.children('.shift:not(.deleted)').each(function() { - var uuid = $(this).data('uuid'); - var time_range = $.trim($(this).children('span').text()).split(' - '); - add_shift(false, uuid, time_range[0], time_range[1]); - }); - if (! shifts.children('.shift').length) { - add_shift(); - } - editor.dialog({ - modal: true, - title: employee + ' - ' + date, - position: {my: 'center', at: 'center', of: editing_day}, - width: 'auto', - autoResize: true, - buttons: [ - { - text: "Update", - click: function() { - - // TODO: is this hacky? invoking timepicker to format the time values - // in all cases, to avoid "invalid format" from user input - editor.find('.shifts .shift').each(function() { - var start_time = $(this).children('input[name|="edit_start_time"]'); - var end_time = $(this).children('input[name|="edit_end_time"]'); - $.timepicker._setTime(start_time.data('timepicker'), start_time.val()); - $.timepicker._setTime(end_time.data('timepicker'), end_time.val()); - }); - - // create / update shifts in time table, as needed - editor.find('.shifts .shift').each(function() { - var uuid = $(this).data('uuid'); - var start_time = $(this).children('input[name|="edit_start_time"]').val(); - var end_time = $(this).children('input[name|="edit_end_time"]').val(); - var shift = editing_day.children('.shift[data-uuid="' + uuid + '"]'); - if (! shift.length) { - shift = $('<p class="shift" data-uuid="' + uuid + '"><span></span></p>'); - shift.append($('<input type="hidden" name="employee_uuid-' + uuid + '" value="' - + editing_day.parents('tr:first').data('employee-uuid') + '" />')); - editing_day.append(shift); - } - shift.children('span').text(start_time + ' - ' + end_time); - time_input(shift, 'start').val(date + ' ' + start_time); - time_input(shift, 'end').val(date + ' ' + end_time); - }); - - // remove shifts from time table, as needed - editing_day.children('.shift').each(function() { - var uuid = $(this).data('uuid'); - if (! editor.find('.shifts .shift[data-uuid="' + uuid + '"]').length) { - if (uuid.match(/^new-/)) { - $(this).remove(); - } else { - $(this).addClass('deleted'); - $(this).append($('<input type="hidden" name="delete-' + uuid + '" value="delete" />')); - } - } - }); - - // mark day as modified, close dialog - editing_day.addClass('modified'); - $('.save-changes').button('enable'); - $('.undo-changes').button('enable'); - update_row_hours(editing_day.parents('tr:first')); - editor.dialog('close'); - data_modified = true; - okay_to_leave = false; - } - }, - { - text: "Cancel", - click: function() { - editor.dialog('close'); - } - } - ] - }); - }); - - $('#day-editor #add-shift').click(function() { - add_shift(true); - }); - - $('#day-editor').on('click', '.shifts button', function() { - $(this).parents('.shift:first').remove(); - }); - - $('.save-changes').click(function() { - $(this).button('disable').button('option', 'label', "Saving Changes..."); - okay_to_leave = true; - $('#timetable-form').submit(); - }); - - $('.undo-changes').click(function() { - $(this).button('disable').button('option', 'label', "Refreshing..."); - okay_to_leave = true; - location.href = location.href; - }); - -}); diff --git a/tailbone/static/js/tailbone.feedback.js b/tailbone/static/js/tailbone.feedback.js index f6d44875..6f687b80 100644 --- a/tailbone/static/js/tailbone.feedback.js +++ b/tailbone/static/js/tailbone.feedback.js @@ -1,58 +1,54 @@ -$(function() { +let FeedbackForm = { + props: ['action', 'message'], + template: '#feedback-template', + mixins: [FormPosterMixin], + methods: { - $('#feedback').click(function() { - var dialog = $('#feedback-dialog'); - var form = dialog.find('form'); - var textarea = form.find('textarea'); - dialog.find('.referrer .field').html(location.href); - textarea.val(''); - dialog.dialog({ - title: "User Feedback", - width: 600, - modal: true, - buttons: [ - { - text: "Send", - click: function(event) { + pleaseReplyChanged(value) { + this.$nextTick(() => { + this.$refs.userEmail.focus() + }) + }, - var msg = $.trim(textarea.val()); - if (! msg) { - alert("Please enter a message."); - textarea.select(); - textarea.focus(); - return; - } + showFeedback() { + this.showDialog = true + this.$nextTick(function() { + this.$refs.textarea.focus() + }) + }, - disable_button(dialog_button(event)); + sendFeedback() { - var data = { - _csrf: form.find('input[name="_csrf"]').val(), - referrer: location.href, - user: form.find('input[name="user"]').val(), - user_name: form.find('input[name="user_name"]').val(), - message: msg - }; + let params = { + referrer: this.referrer, + user: this.userUUID, + user_name: this.userName, + please_reply_to: this.pleaseReply ? this.userEmail : null, + message: this.message.trim(), + } - $.ajax(form.attr('action'), { - method: 'POST', - data: data, - success: function(data) { - dialog.dialog('close'); - alert("Message successfully sent.\n\nThank you for your feedback."); - } - }); + this.submitForm(this.action, params, response => { - } - }, - { - text: "Cancel", - click: function() { - dialog.dialog('close'); - } - } - ] - }); - }); - -}); + this.$buefy.toast.open({ + message: "Message sent! Thank you for your feedback.", + type: 'is-info', + duration: 4000, // 4 seconds + }) + + this.showDialog = false + // clear out message, in case they need to send another + this.message = "" + }) + }, + } +} + +let FeedbackFormData = { + referrer: null, + userUUID: null, + userName: null, + pleaseReply: false, + userEmail: null, + showDialog: false, +} diff --git a/tailbone/static/js/tailbone.js b/tailbone/static/js/tailbone.js deleted file mode 100644 index 4d3212df..00000000 --- a/tailbone/static/js/tailbone.js +++ /dev/null @@ -1,386 +0,0 @@ - -/************************************************************ - * - * tailbone.js - * - ************************************************************/ - - -/* - * Initialize the disabled filters array. This is populated from within the - * /grids/search.mako template. - */ -var filters_to_disable = []; - - -/* - * Disables options within the "add filter" dropdown which correspond to those - * filters already being displayed. Called from /grids/search.mako template. - */ -function disable_filter_options() { - while (filters_to_disable.length) { - var filter = filters_to_disable.shift(); - var option = $('#add-filter option[value="' + filter + '"]'); - option.attr('disabled', 'disabled'); - } -} - - -/* - * Convenience function to disable a UI button. - */ -function disable_button(button, label) { - $(button).button('disable'); - if (label === undefined) { - label = $(button).data('working-label') || "Working, please wait..."; - } - if (label) { - if (label.slice(-3) != '...') { - label += '...'; - } - $(button).button('option', 'label', label); - } -} - - -function disable_submit_button(form, label) { - // for some reason chrome requires us to do things this way... - // https://stackoverflow.com/questions/16867080/onclick-javascript-stops-form-submit-in-chrome - // https://stackoverflow.com/questions/5691054/disable-submit-button-on-form-submit - var submit = $(form).find('input[type="submit"]'); - if (! submit.length) { - submit = $(form).find('button[type="submit"]'); - } - if (submit.length) { - disable_button(submit, label); - } -} - - -/* - * Load next / previous page of results to grid. This function is called on - * the click event from the pager links, via inline script code. - */ -function grid_navigate_page(link, url) { - var wrapper = $(link).parents('div.grid-wrapper'); - var grid = wrapper.find('div.grid'); - wrapper.mask("Loading..."); - $.get(url, function(data) { - wrapper.unmask(); - grid.replaceWith(data); - }); -} - - -/* - * Fetch the UUID value associated with a table row. - */ -function get_uuid(obj) { - obj = $(obj); - if (obj.attr('uuid')) { - return obj.attr('uuid'); - } - var tr = obj.parents('tr:first'); - if (tr.attr('uuid')) { - return tr.attr('uuid'); - } - return undefined; -} - - -/* - * Return a jQuery object containing a button from a dialog. This is a - * convenience function to help with browser differences. It is assumed - * that it is being called from within the relevant button click handler. - * @param {event} event - Click event object. - */ -function dialog_button(event) { - var button = $(event.target); - - // TODO: not sure why this workaround is needed for Chrome..? - if (! button.hasClass('ui-button')) { - button = button.parents('.ui-button:first'); - } - - return button; -} - - -/** - * Scroll screen as needed to ensure all options are visible, for the given - * select menu widget. - */ -function show_all_options(select) { - if (! select.is(':visible')) { - /* - * Note that the following code was largely stolen from - * http://brianseekford.com/2013/06/03/how-to-scroll-a-container-or-element-into-view-using-jquery-javascript-in-your-html/ - */ - - var docViewTop = $(window).scrollTop(); - var docViewBottom = docViewTop + $(window).height(); - - var widget = select.selectmenu('menuWidget'); - var elemTop = widget.offset().top; - var elemBottom = elemTop + widget.height(); - - var isScrolled = ((elemBottom <= docViewBottom) && (elemTop >= docViewTop)); - - if (!isScrolled) { - if (widget.height() > $(window).height()) { //then just bring to top of the container - $(window).scrollTop(elemTop) - } else { //try and and bring bottom of container to bottom of screen - $(window).scrollTop(elemTop - ($(window).height() - widget.height())); - } - } - } -} - - -/* - * reference to existing timeout warning dialog, if any - */ -var session_timeout_warning = null; - - -/** - * Warn user of impending session timeout. - */ -function timeout_warning() { - if (! session_timeout_warning) { - session_timeout_warning = $('<div id="session-timeout-warning">' + - 'You will be logged out in <span class="seconds"></span> ' + - 'seconds...</div>'); - } - session_timeout_warning.find('.seconds').text('60'); - session_timeout_warning.dialog({ - title: "Session Timeout Warning", - modal: true, - buttons: { - "Stay Logged In": function() { - session_timeout_warning.dialog('close'); - $.get(noop_url, set_timeout_warning_timer); - }, - "Logout Now": function() { - location.href = logout_url; - } - } - }); - window.setTimeout(timeout_warning_update, 1000); -} - - -/** - * Decrement the 'seconds' counter for the current timeout warning - */ -function timeout_warning_update() { - if (session_timeout_warning.is(':visible')) { - var span = session_timeout_warning.find('.seconds'); - var seconds = parseInt(span.text()) - 1; - if (seconds) { - span.text(seconds.toString()); - window.setTimeout(timeout_warning_update, 1000); - } else { - location.href = logout_url; - } - } -} - - -/** - * Warn user of impending session timeout. - */ -function set_timeout_warning_timer() { - // timout dialog says we're 60 seconds away, but we actually trigger when - // 70 seconds away from supposed timeout, in case of timer drift? - window.setTimeout(timeout_warning, session_timeout * 1000 - 70000); -} - - -/* - * set initial timer for timeout warning, if applicable - */ -if (session_timeout) { - set_timeout_warning_timer(); -} - - -$(function() { - - /* - * enhance buttons - */ - $('button, a.button').button(); - $('input[type=submit]').button(); - $('input[type=reset]').button(); - $('a.button.autodisable').click(function() { - disable_button(this); - }); - $('form.autodisable').submit(function() { - disable_submit_button(this); - }); - - // quickie button - $('#submit-quickie').button('option', 'icons', {primary: 'ui-icon-zoomin'}); - - /* - * enhance dropdowns - */ - $('select[auto-enhance="true"]').selectmenu(); - $('select[auto-enhance="true"]').on('selectmenuopen', function(event, ui) { - show_all_options($(this)); - }); - - /* Also automatically disable any buttons marked for that. */ - $('a.button[disabled=disabled]').button('option', 'disabled', true); - - /* - * Apply timepicker behavior to text inputs which are marked for it. - */ - $('input[type=text].timepicker').timepicker({ - showPeriod: true - }); - - /* - * When filter labels are clicked, (un)check the associated checkbox. - */ - $('body').on('click', '.grid-wrapper .filter label', function() { - var checkbox = $(this).prev('input[type="checkbox"]'); - if (checkbox.prop('checked')) { - checkbox.prop('checked', false); - return false; - } - checkbox.prop('checked', true); - }); - - /* - * When a new filter is selected in the "add filter" dropdown, show it in - * the UI. This selects the filter's checkbox and puts focus to its input - * element. If all available filters have been displayed, the "add filter" - * dropdown will be hidden. - */ - $('body').on('change', '#add-filter', function() { - var select = $(this); - var filters = select.parents('div.filters:first'); - var filter = filters.find('#filter-' + select.val()); - var checkbox = filter.find('input[type="checkbox"]:first'); - var input = filter.find(':last-child'); - - checkbox.prop('checked', true); - filter.show(); - input.select(); - input.focus(); - - filters.find('input[type="submit"]').show(); - filters.find('button[type="reset"]').show(); - - select.find('option:selected').attr('disabled', true); - select.val('add a filter'); - if (select.find('option:enabled').length == 1) { - select.hide(); - } - }); - - /* - * When user clicks the grid filters search button, perform the search in - * the background and reload the grid in-place. - */ - $('body').on('submit', '.filters form', function() { - var form = $(this); - var wrapper = form.parents('div.grid-wrapper'); - var grid = wrapper.find('div.grid'); - var data = form.serializeArray(); - data.push({name: 'partial', value: true}); - wrapper.mask("Loading..."); - $.get(grid.attr('url'), data, function(data) { - wrapper.unmask(); - grid.replaceWith(data); - }); - return false; - }); - - /* - * When user clicks the grid filters reset button, manually clear all - * filter input elements, and submit a new search. - */ - $('body').on('click', '.filters form button[type="reset"]', function() { - var form = $(this).parents('form'); - form.find('div.filter').each(function() { - $(this).find('div.value input').val(''); - }); - form.submit(); - return false; - }); - - $('body').on('click', '.grid thead th.sortable a', function() { - var th = $(this).parent(); - var wrapper = th.parents('div.grid-wrapper'); - var grid = wrapper.find('div.grid'); - var data = { - sort: th.attr('field'), - dir: (th.hasClass('sorted') && th.hasClass('asc')) ? 'desc' : 'asc', - page: 1, - partial: true - }; - wrapper.mask("Loading..."); - $.get(grid.attr('url'), data, function(data) { - wrapper.unmask(); - grid.replaceWith(data); - }); - return false; - }); - - $('body').on('mouseenter', '.grid.hoverable tbody tr', function() { - $(this).addClass('hovering'); - }); - - $('body').on('mouseleave', '.grid.hoverable tbody tr', function() { - $(this).removeClass('hovering'); - }); - - $('body').on('click', '.grid tbody td.view', function() { - var url = $(this).attr('url'); - if (url) { - location.href = url; - } - }); - - $('body').on('click', '.grid tbody td.edit', function() { - var url = $(this).attr('url'); - if (url) { - location.href = url; - } - }); - - $('body').on('click', '.grid tbody td.delete', function() { - var url = $(this).attr('url'); - if (url) { - if (confirm("Do you really wish to delete this object?")) { - location.href = url; - } - } - }); - - // $('div.grid-wrapper').on('change', 'div.grid div.pager select#grid-page-count', function() { - $('body').on('change', '.grid .pager #grid-page-count', function() { - var select = $(this); - var wrapper = select.parents('div.grid-wrapper'); - var grid = wrapper.find('div.grid'); - var data = { - per_page: select.val(), - partial: true - }; - wrapper.mask("Loading..."); - $.get(grid.attr('url'), data, function(data) { - wrapper.unmask(); - grid.replaceWith(data); - }); - - }); - - $('body').on('click', 'div.dialog button.close', function() { - var dialog = $(this).parents('div.dialog:first'); - dialog.dialog('close'); - }); - -}); diff --git a/tailbone/static/js/tailbone.timesheet.edit.js b/tailbone/static/js/tailbone.timesheet.edit.js deleted file mode 100644 index f2fcb271..00000000 --- a/tailbone/static/js/tailbone.timesheet.edit.js +++ /dev/null @@ -1,267 +0,0 @@ - -/************************************************************ - * - * tailbone.timesheet.edit.js - * - * Common logic for editing time sheet / schedule data. - * - ************************************************************/ - - -var editing_day = null; -var new_shift_id = 1; -var show_timepicker = true; - - -/* - * Add a new shift entry to the editor dialog. - * @param {boolean} focus - Whether to set focus to the start_time input - * element after adding the shift. - * @param {string} uuid - UUID value for the shift, if applicable. - * @param {string} start_time - Value for start_time input element. - * @param {string} end_time - Value for end_time input element. - */ - -function add_shift(focus, uuid, start_time, end_time) { - var shift = $('#snippets .shift').clone(); - if (! uuid) { - uuid = 'new-' + (new_shift_id++).toString(); - } - shift.attr('data-uuid', uuid); - shift.children('input').each(function() { - var name = $(this).attr('name') + '-' + uuid; - $(this).attr('name', name); - $(this).attr('id', name); - }); - shift.children('input[name|="edit_start_time"]').val(start_time); - shift.children('input[name|="edit_end_time"]').val(end_time); - $('#day-editor .shifts').append(shift); - - // maybe trick timepicker into never showing itself - var args = {showPeriod: true}; - if (! show_timepicker) { - args.showOn = 'button'; - args.button = '#nevershow'; - } - shift.children('input').timepicker(args); - - if (focus) { - shift.children('input:first').focus(); - } -} - - -/** - * Calculate the number of minutes between given the times. - * @param {string} start_time - Value from start_time input element. - * @param {string} end_time - Value from end_time input element. - */ -function calc_minutes(start_time, end_time) { - var start = parseTime(start_time); - var end = parseTime(end_time); - if (start && end) { - start = new Date(2000, 0, 1, start.hh, start.mm); - end = new Date(2000, 0, 1, end.hh, end.mm); - return Math.floor((end - start) / 1000 / 60); - } -} - - -/** - * Converts a number of minutes into string of HH:MM format. - * @param {number} minutes - Number of minutes to be converted. - */ -function format_minutes(minutes) { - var hours = Math.floor(minutes / 60); - if (hours) { - minutes -= hours * 60; - } - return hours.toString() + ':' + (minutes < 10 ? '0' : '') + minutes.toString(); -} - - -/** - * NOTE: most of this logic was stolen from http://stackoverflow.com/a/1788084 - * - * Parse a time string and convert to simple object with hh and mm keys. - * @param {string} time - Time value in 'HH:MM PP' format, or close enough. - */ -function parseTime(time) { - if (time) { - var part = time.match(/(\d+):(\d+)(?: )?(am|pm)?/i); - if (part) { - var hh = parseInt(part[1], 10); - var mm = parseInt(part[2], 10); - var ap = part[3] ? part[3].toUpperCase() : null; - if (ap == 'AM') { - if (hh == 12) { - hh = 0; - } - } else if (ap == 'PM') { - if (hh != 12) { - hh += 12; - } - } - return { hh: hh, mm: mm }; - } - } -} - - -/** - * Return a jQuery object containing the hidden start or end time input element - * for the shift (i.e. within the *main* timesheet form). This will create the - * input if necessary. - * @param {jQuery} shift - A jQuery object for the shift itself. - * @param {string} type - Should be 'start' or 'end' only. - */ -function time_input(shift, type) { - var input = shift.children('input[name|="' + type + '_time"]'); - if (! input.length) { - input = $('<input type="hidden" name="' + type + '_time-' + shift.data('uuid') + '" />'); - shift.append(input); - } - return input; -} - - -/** - * Update the weekly hour total for a given row (employee). - * @param {jQuery} row - A jQuery object for the row to be updated. - */ -function update_row_hours(row) { - var minutes = 0; - row.find('.day .shift:not(.deleted)').each(function() { - var time_range = $.trim($(this).children('span').text()).split(' - '); - minutes += calc_minutes(time_range[0], time_range[1]); - }); - row.children('.total').text(minutes ? format_minutes(minutes) : '0'); -} - - -/** - * Clean up user input within the editor dialog, e.g. '8:30am' => '08:30 AM'. - * This also should ensure invalid input will become empty string. - */ -function cleanup_editor_input() { - // TODO: is this hacky? invoking timepicker to format the time values - // in all cases, to avoid "invalid format" from user input - var backward = false; - $('#day-editor .shifts .shift').each(function() { - var start_time = $(this).children('input[name|="edit_start_time"]'); - var end_time = $(this).children('input[name|="edit_end_time"]'); - $.timepicker._setTime(start_time.data('timepicker'), start_time.val() || '??'); - $.timepicker._setTime(end_time.data('timepicker'), end_time.val() || '??'); - var t_start = parseTime(start_time.val()); - var t_end = parseTime(end_time.val()); - if (t_start && t_end) { - if ((t_start.hh > t_end.hh) || ((t_start.hh == t_end.hh) && (t_start.mm > t_end.mm))) { - alert("Start time falls *after* end time! Please fix..."); - start_time.focus().select(); - backward = true; - return false; - } - } - }); - return !backward; -} - - -/** - * Update the main timesheet table based on editor dialog input. This updates - * both the displayed timesheet, as well as any hidden input elements on the - * main form. - */ -function update_timetable() { - - var date = weekdays[editing_day.get(0).cellIndex - 1]; - - // add or update - $('#day-editor .shifts .shift').each(function() { - var uuid = $(this).data('uuid'); - var start_time = $(this).children('input[name|="edit_start_time"]').val(); - var end_time = $(this).children('input[name|="edit_end_time"]').val(); - var shift = editing_day.children('.shift[data-uuid="' + uuid + '"]'); - if (! shift.length) { - if (! (start_time || end_time)) { - return; - } - shift = $('<p class="shift" data-uuid="' + uuid + '"><span></span></p>'); - shift.append($('<input type="hidden" name="employee_uuid-' + uuid + '" value="' - + editing_day.parents('tr:first').data('employee-uuid') + '" />')); - editing_day.append(shift); - } - shift.children('span').text((start_time || '??') + ' - ' + (end_time || '??')); - start_time = start_time ? (date + ' ' + start_time) : ''; - end_time = end_time ? (date + ' ' + end_time) : ''; - time_input(shift, 'start').val(start_time); - time_input(shift, 'end').val(end_time); - }); - - - // remove / mark for deletion - editing_day.children('.shift').each(function() { - var uuid = $(this).data('uuid'); - if (! $('#day-editor .shifts .shift[data-uuid="' + uuid + '"]').length) { - if (uuid.match(/^new-/)) { - $(this).remove(); - } else { - $(this).addClass('deleted'); - $(this).append($('<input type="hidden" name="delete-' + uuid + '" value="delete" />')); - } - } - }); - -} - - -/** - * Perform full "save" action for time sheet form, direct from day editor dialog. - */ -function save_dialog() { - if (! cleanup_editor_input()) { - return false; - } - var save = $('#day-editor').parents('.ui-dialog').find('.ui-dialog-buttonpane button:first'); - save.button('disable').button('option', 'label', "Saving..."); - update_timetable(); - $('#timetable-form').submit(); - return true; -} - - -/* - * on document load... - */ -$(function() { - - /* - * Within editor dialog, clicking Add Shift button will create a new/empty - * shift and set focus to its start_time input. - */ - $('#day-editor #add-shift').click(function() { - add_shift(true); - }); - - /* - * Within editor dialog, clicking a shift's "trash can" button will remove - * the shift. - */ - $('#day-editor').on('click', '.shifts button', function() { - $(this).parents('.shift:first').remove(); - }); - - /* - * Within editor dialog, Enter press within time field "might" trigger - * save. Note that this is only done for timesheet editing, not schedule. - */ - $('#day-editor').on('keydown', '.shifts input[type="text"]', function(event) { - if (!show_timepicker) { // TODO: this implies too much, should be cleaner - if (event.which == 13) { - save_dialog(); - return false; - } - } - }); - -}); diff --git a/tailbone/static/themes/falafel/css/base.css b/tailbone/static/themes/falafel/css/base.css deleted file mode 100644 index 0fa02dbb..00000000 --- a/tailbone/static/themes/falafel/css/base.css +++ /dev/null @@ -1,14 +0,0 @@ - -/****************************** - * tweaks for root user - ******************************/ - -.navbar .navbar-end .navbar-link.root-user, -.navbar .navbar-end .navbar-link.root-user:hover, -.navbar .navbar-end .navbar-link.root-user.is_active, -.navbar .navbar-end .navbar-item.root-user, -.navbar .navbar-end .navbar-item.root-user:hover, -.navbar .navbar-end .navbar-item.root-user.is_active { - background-color: red; - font-weight: bold; -} diff --git a/tailbone/static/themes/falafel/css/filters.css b/tailbone/static/themes/falafel/css/filters.css deleted file mode 100644 index 6deff7b0..00000000 --- a/tailbone/static/themes/falafel/css/filters.css +++ /dev/null @@ -1,22 +0,0 @@ - -/****************************** - * Grid Filters - ******************************/ - -.filters .filter { - margin-bottom: 0.5rem; -} - -.filters .filter-fieldname .field, -.filters .filter-fieldname .field label { - width: 100%; -} - -.filters .filter-fieldname .field label { - justify-content: left; -} - -.filters .filter-verb .select, -.filters .filter-verb .select select { - width: 100%; -} diff --git a/tailbone/static/themes/falafel/css/forms.css b/tailbone/static/themes/falafel/css/forms.css deleted file mode 100644 index de4b1ebe..00000000 --- a/tailbone/static/themes/falafel/css/forms.css +++ /dev/null @@ -1,61 +0,0 @@ - -/****************************** - * forms - ******************************/ - -/* note that this should only apply to "normal" primary forms */ -/* TODO: replace this with bulma equivalent */ -.form { - padding-left: 5em; -} - -/* note that this should only apply to "normal" primary forms */ -.form-wrapper .form .field.is-horizontal .field-label .label { - text-align: left; - white-space: nowrap; - width: 18em; -} - -/* note that this should only apply to "normal" primary forms */ -.form-wrapper .form .field.is-horizontal .field-body { - min-width: 30em; -} - -/* note that this should only apply to "normal" primary forms */ -.form-wrapper .form .field.is-horizontal .field-body .select, -.form-wrapper .form .field.is-horizontal .field-body .select select { - width: 100%; -} - -/****************************** - * field-wrappers - ******************************/ - -/* TODO: replace this with bulma equivalent */ -.field-wrapper { - clear: both; - min-height: 30px; - overflow: auto; - margin: 15px; -} - -/* TODO: replace this with bulma equivalent */ -.field-wrapper .field-row { - display: table-row; -} - -/* TODO: replace this with bulma equivalent */ -.field-wrapper label { - display: table-cell; - vertical-align: top; - width: 18em; - font-weight: bold; - padding-top: 2px; - white-space: nowrap; -} - -/* TODO: replace this with bulma equivalent */ -.field-wrapper .field { - display: table-cell; - line-height: 25px; -} diff --git a/tailbone/static/themes/falafel/css/grids.css b/tailbone/static/themes/falafel/css/grids.css deleted file mode 100644 index b24e9cb0..00000000 --- a/tailbone/static/themes/falafel/css/grids.css +++ /dev/null @@ -1,15 +0,0 @@ - -/******************************************************************************** - * grids.css - * - * Style tweaks for the Buefy grids. - ********************************************************************************/ - - -/****************************** - * actions column - ******************************/ - -a.grid-action { - white-space: nowrap; -} diff --git a/tailbone/static/themes/falafel/css/layout.css b/tailbone/static/themes/falafel/css/layout.css deleted file mode 100644 index cc4d0015..00000000 --- a/tailbone/static/themes/falafel/css/layout.css +++ /dev/null @@ -1,150 +0,0 @@ - -/****************************** - * main layout - ******************************/ - -body { - display: flex; - flex-direction: column; - min-height: 100vh; -} - -.content-wrapper { - display: flex; - flex: 1; - flex-direction: column; - justify-content: space-between; -} - - -/****************************** - * header - ******************************/ - -/* this is the one in the very top left of screen, next to logo and linked to -the home page */ -#global-header-title { - margin-left: 0.3rem; -} - -header .level { - /* TODO: not sure what this 60px was supposed to do? but it broke the */ - /* styles for the feedback dialog, so disabled it is. - /* height: 60px; */ - /* line-height: 60px; */ - padding-left: 0.5em; - padding-right: 0.5em; -} - -header .level #header-logo { - display: inline-block; -} - -header .level .global-title, -header .level-left .global-title { - font-size: 2em; - font-weight: bold; -} - -/* indent nested menu items a bit */ -header .navbar-item.nested { - padding-left: 2.5rem; -} - -header span.header-text { - font-size: 2em; - font-weight: bold; - margin-right: 10px; -} - -header .level .theme-picker { - display: inline-flex; -} - -#content-title { - padding: 0.3rem; -} - -#content-title h1 { - font-size: 2rem; - margin-left: 1rem; -} - -/****************************** - * content - ******************************/ - -#page-body { - padding: 0.4em; -} - -/****************************** - * context menu - ******************************/ - -#context-menu { - margin-bottom: 1em; - margin-left: 1em; - text-align: right; - white-space: nowrap; -} - -/****************************** - * "object helper" panel - ******************************/ - -.object-helpers .panel-heading { - white-space: nowrap; -} - -.object-helpers a { - white-space: nowrap; -} - -.object-helper { - border: 1px solid black; - margin: 1em; - padding: 1em; - width: 20em; -} - -.object-helper-content { - margin-top: 1em; -} - -/****************************** - * markdown - ******************************/ - -.rendered-markdown p, -.rendered-markdown ul { - margin-bottom: 1rem; -} - -.rendered-markdown .codehilite { - margin-bottom: 2rem; -} - -/****************************** - * fix datepicker within modals - * TODO: someday this may not be necessary? cf. - * https://github.com/buefy/buefy/issues/292#issuecomment-347365637 - ******************************/ - -.modal .animation-content .modal-card { - overflow: visible !important; -} - -.modal-card-body { - overflow: visible !important; -} - - -/****************************** - * feedback - ******************************/ - -.feedback-dialog .red { - color: red; - font-weight: bold; -} diff --git a/tailbone/static/themes/falafel/js/tailbone.feedback.js b/tailbone/static/themes/falafel/js/tailbone.feedback.js deleted file mode 100644 index 6f687b80..00000000 --- a/tailbone/static/themes/falafel/js/tailbone.feedback.js +++ /dev/null @@ -1,54 +0,0 @@ - -let FeedbackForm = { - props: ['action', 'message'], - template: '#feedback-template', - mixins: [FormPosterMixin], - methods: { - - pleaseReplyChanged(value) { - this.$nextTick(() => { - this.$refs.userEmail.focus() - }) - }, - - showFeedback() { - this.showDialog = true - this.$nextTick(function() { - this.$refs.textarea.focus() - }) - }, - - sendFeedback() { - - let params = { - referrer: this.referrer, - user: this.userUUID, - user_name: this.userName, - please_reply_to: this.pleaseReply ? this.userEmail : null, - message: this.message.trim(), - } - - this.submitForm(this.action, params, response => { - - this.$buefy.toast.open({ - message: "Message sent! Thank you for your feedback.", - type: 'is-info', - duration: 4000, // 4 seconds - }) - - this.showDialog = false - // clear out message, in case they need to send another - this.message = "" - }) - }, - } -} - -let FeedbackFormData = { - referrer: null, - userUUID: null, - userName: null, - pleaseReply: false, - userEmail: null, - showDialog: false, -} diff --git a/tailbone/templates/autocomplete.mako b/tailbone/templates/autocomplete.mako index 8c84aedd..4c413757 100644 --- a/tailbone/templates/autocomplete.mako +++ b/tailbone/templates/autocomplete.mako @@ -1,63 +1,5 @@ ## -*- coding: utf-8; -*- -## TODO: This function signature is getting out of hand... -<%def name="autocomplete(field_name, service_url, field_value=None, field_display=None, width='300px', select=None, selected=None, cleared=None, change_clicked=None, options={})"> - <div id="${field_name}-container" class="autocomplete-container"> - ${h.hidden(field_name, id=field_name, value=field_value)} - ${h.text(field_name+'-textbox', id=field_name+'-textbox', value=field_display, - class_='autocomplete-textbox', style='display: none;' if field_value else '')} - <div id="${field_name}-display" class="autocomplete-display"${'' if field_value else ' style="display: none;"'|n}> - <span>${field_display or ''}</span> - <button type="button" id="${field_name}-change" class="autocomplete-change">Change</button> - </div> - </div> - <script type="text/javascript"> - $(function() { - $('#${field_name}-textbox').autocomplete({ - source: '${service_url}', - autoFocus: true, - % for key, value in options.items(): - ${key}: ${value}, - % endfor - focus: function(event, ui) { - return false; - }, - % if select: - select: ${select} - % else: - select: function(event, ui) { - $('#${field_name}').val(ui.item.value); - $('#${field_name}-display span:first').text(ui.item.label); - $('#${field_name}-textbox').hide(); - $('#${field_name}-display').show(); - % if selected: - ${selected}(ui.item.value, ui.item.label); - % endif - return false; - } - % endif - }); - $('#${field_name}-change').click(function() { - % if change_clicked: - if (! ${change_clicked}()) { - return false; - } - % endif - $('#${field_name}').val(''); - $('#${field_name}-display').hide(); - with ($('#${field_name}-textbox')) { - val(''); - show(); - focus(); - } - % if cleared: - ${cleared}(); - % endif - }); - }); - </script> -</%def> - <%def name="tailbone_autocomplete_template()"> <script type="text/x-template" id="tailbone-autocomplete-template"> <div> diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index c2970193..150b052c 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -73,7 +73,6 @@ </%def> <%def name="core_javascript()"> - ${self.jquery()} ${self.vuejs()} ${self.buefy()} ${self.fontawesome()} @@ -92,22 +91,32 @@ ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.grid.js') + '?ver={}'.format(tailbone.__version__))} <script type="text/javascript"> - var session_timeout = ${request.get_session_timeout() or 'null'}; - var logout_url = '${request.route_url('logout')}'; - var noop_url = '${request.route_url('noop')}'; - $(function() { - ## NOTE: this code was copied from - ## https://bulma.io/documentation/components/navbar/#navbar-menu - $('.navbar-burger').click(function() { - $('.navbar-burger').toggleClass('is-active'); - $('.navbar-menu').toggleClass('is-active'); - }); - }); - </script> -</%def> -<%def name="jquery()"> - ${h.javascript_link(h.get_liburl(request, 'jquery'))} + ## NOTE: this code was copied from + ## https://bulma.io/documentation/components/navbar/#navbar-menu + + document.addEventListener('DOMContentLoaded', () => { + + // Get all "navbar-burger" elements + const $navbarBurgers = Array.prototype.slice.call(document.querySelectorAll('.navbar-burger'), 0) + + // Add a click event on each of them + $navbarBurgers.forEach( el => { + el.addEventListener('click', () => { + + // Get the target from the "data-target" attribute + const target = el.dataset.target + const $target = document.getElementById(target) + + // Toggle the "is-active" class on both the "navbar-burger" and the "navbar-menu" + el.classList.toggle('is-active') + $target.classList.toggle('is-active') + + }) + }) + }) + + </script> </%def> <%def name="vuejs()"> @@ -129,14 +138,12 @@ ${self.buefy_styles()} - ${h.stylesheet_link(request.static_url('tailbone:static/themes/falafel/css/base.css') + '?ver={}'.format(tailbone.__version__))} - ${h.stylesheet_link(request.static_url('tailbone:static/themes/falafel/css/layout.css') + '?ver={}'.format(tailbone.__version__))} + ${h.stylesheet_link(request.static_url('tailbone:static/css/base.css') + '?ver={}'.format(tailbone.__version__))} + ${h.stylesheet_link(request.static_url('tailbone:static/css/layout.css') + '?ver={}'.format(tailbone.__version__))} ${h.stylesheet_link(request.static_url('tailbone:static/css/grids.css') + '?ver={}'.format(tailbone.__version__))} - ${h.stylesheet_link(request.static_url('tailbone:static/themes/falafel/css/grids.css') + '?ver={}'.format(tailbone.__version__))} - ${h.stylesheet_link(request.static_url('tailbone:static/themes/falafel/css/grids.rowstatus.css') + '?ver={}'.format(tailbone.__version__))} -## ${h.stylesheet_link(request.static_url('tailbone:static/css/filters.css') + '?ver={}'.format(tailbone.__version__))} - ${h.stylesheet_link(request.static_url('tailbone:static/themes/falafel/css/filters.css') + '?ver={}'.format(tailbone.__version__))} - ${h.stylesheet_link(request.static_url('tailbone:static/themes/falafel/css/forms.css') + '?ver={}'.format(tailbone.__version__))} + ${h.stylesheet_link(request.static_url('tailbone:static/css/grids.rowstatus.css') + '?ver={}'.format(tailbone.__version__))} + ${h.stylesheet_link(request.static_url('tailbone:static/css/filters.css') + '?ver={}'.format(tailbone.__version__))} + ${h.stylesheet_link(request.static_url('tailbone:static/css/forms.css') + '?ver={}'.format(tailbone.__version__))} ${h.stylesheet_link(request.static_url('tailbone:static/css/diffs.css') + '?ver={}'.format(tailbone.__version__))} ${h.stylesheet_link(request.static_url('tailbone:static/css/codehilite.css') + '?ver={}'.format(tailbone.__version__))} @@ -162,12 +169,6 @@ % endif </%def> -## TODO: this is only being referenced by the progress template i think? -## (so, should make a Buefy progress page at least) -<%def name="jquery_theme()"> - ${h.stylesheet_link(h.get_liburl(request, 'jquery_ui'))} -</%def> - <%def name="extra_styles()"></%def> <%def name="head_tags()"></%def> @@ -201,14 +202,14 @@ @select="globalSearchSelect"> </b-autocomplete> </div> - <a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false"> + <a role="button" class="navbar-burger" data-target="navbar-menu" aria-label="menu" aria-expanded="false"> <span aria-hidden="true"></span> <span aria-hidden="true"></span> <span aria-hidden="true"></span> </a> </div> - <div class="navbar-menu"> + <div class="navbar-menu" id="navbar-menu"> <div class="navbar-start"> <div v-if="globalSearchData.length" @@ -742,7 +743,7 @@ <%def name="declare_whole_page_vars()"> ${page_help.declare_vars()} ${multi_file_upload.declare_vars()} - ${h.javascript_link(request.static_url('tailbone:static/themes/falafel/js/tailbone.feedback.js') + '?ver={}'.format(tailbone.__version__))} + ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.feedback.js') + '?ver={}'.format(tailbone.__version__))} <script type="text/javascript"> let WholePage = { diff --git a/tailbone/templates/customers/view.mako b/tailbone/templates/customers/view.mako index 81e05aaa..e35cc635 100644 --- a/tailbone/templates/customers/view.mako +++ b/tailbone/templates/customers/view.mako @@ -2,23 +2,6 @@ <%inherit file="/master/view.mako" /> <%namespace file="/util.mako" import="view_profiles_helper" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if master.people_detachable and request.has_perm('{}.detach_person'.format(permission_prefix)): - <script type="text/javascript"> - - $(function() { - $('.people .grid .actions a.detach').click(function() { - if (! confirm("Are you sure you wish to detach this Person from the Customer?")) { - return false; - } - }); - }); - - </script> - % endif -</%def> - <%def name="object_helpers()"> ${parent.object_helpers()} % if show_profiles_helper and instance.people: diff --git a/tailbone/templates/email-bounces/view.mako b/tailbone/templates/email-bounces/view.mako index df5f7842..610118ed 100644 --- a/tailbone/templates/email-bounces/view.mako +++ b/tailbone/templates/email-bounces/view.mako @@ -1,8 +1,6 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -## TODO: this page still uses jQuery but should use Vue.js - <%def name="extra_styles()"> ${parent.extra_styles()} <style type="text/css"> diff --git a/tailbone/templates/forms/deform.mako b/tailbone/templates/forms/deform.mako deleted file mode 100644 index ede55f12..00000000 --- a/tailbone/templates/forms/deform.mako +++ /dev/null @@ -1,99 +0,0 @@ -## -*- coding: utf-8; -*- - -% if not readonly: -<% _focus_rendered = False %> -${h.form(form.action_url, id=dform.formid, method='post', enctype='multipart/form-data', **form_kwargs)} -${h.csrf_token(request)} -% endif - -% if dform.error: - <div class="error-messages"> - <div class="ui-state-error ui-corner-all"> - <span style="float: left; margin-right: .3em;" class="ui-icon ui-icon-alert"></span> - Please see errors below. - </div> - <div class="ui-state-error ui-corner-all"> - <span style="float: left; margin-right: .3em;" class="ui-icon ui-icon-alert"></span> - ${dform.error} - </div> - </div> -% endif - -% for field in form.fields: - - ## % if readonly or field.name in readonly_fields: - % if readonly: - ${render_field_readonly(field)|n} - % elif field not in dform and field in form.readonly_fields: - ${render_field_readonly(field)|n} - % elif field in dform: - <% field = dform[field] %> - - % if form.field_visible(field.name): - <div class="field-wrapper ${field.name} ${'with-error' if field.error else ''}"> - % if field.error: - <div class="field-error"> - % for msg in field.error.messages(): - <span class="error-msg">${msg}</span> - % endfor - </div> - % endif - <div class="field-row"> - <label for="${field.oid}">${form.get_label(field.name)}</label> - <div class="field"> - ${field.serialize()|n} - </div> - </div> - % if form.has_helptext(field.name): - <span class="instructions">${form.render_helptext(field.name)}</span> - % endif - </div> - - ## % if not _focus_rendered and (fieldset.focus is True or fieldset.focus is field): - % if not readonly and not _focus_rendered: - ## % if not field.is_readonly() and getattr(field.renderer, 'needs_focus', True): - % if not field.widget.readonly: - <script type="text/javascript"> - $(function() { - ## % if hasattr(field.renderer, 'focus_name'): - ## $('#${field.renderer.focus_name}').focus(); - ## % else: - ## $('#${field.renderer.name}').focus(); - ## % endif - $('#${field.oid}').focus(); - }); - </script> - <% _focus_rendered = True %> - % endif - % endif - - % else: - ## hidden field - ${field.serialize()|n} - % endif - - % endif - -% endfor - -% if buttons: - ${buttons|n} -% elif not readonly and (buttons is Undefined or (buttons is not None and buttons is not False)): - <div class="buttons"> - ## ${h.submit('create', form.create_label if form.creating else form.update_label)} - ${h.submit('save', getattr(form, 'submit_label', getattr(form, 'save_label', "Submit")), class_='button is-primary')} -## % if form.creating and form.allow_successive_creates: -## ${h.submit('create_and_continue', form.successive_create_label)} -## % endif - % if getattr(form, 'show_reset', False): - <input type="reset" value="Reset" class="button" /> - % endif - % if getattr(form, 'show_cancel', True): - ${h.link_to("Cancel", form.cancel_url, class_='cancel button{}'.format(' autodisable' if form.auto_disable_cancel else ''))} - % endif - </div> -% endif - -% if not readonly: -${h.end_form()} -% endif diff --git a/tailbone/templates/forms/form_readonly.mako b/tailbone/templates/forms/form_readonly.mako deleted file mode 100644 index 306282e9..00000000 --- a/tailbone/templates/forms/form_readonly.mako +++ /dev/null @@ -1,8 +0,0 @@ -## -*- coding: utf-8; -*- - -<div class="form"> - ${form.render_deform(readonly=True)|n} - % if buttons: - ${buttons|n} - % endif -</div><!-- form --> diff --git a/tailbone/templates/grids/search.mako b/tailbone/templates/grids/search.mako deleted file mode 100644 index fbb030f9..00000000 --- a/tailbone/templates/grids/search.mako +++ /dev/null @@ -1,37 +0,0 @@ -## -*- coding: utf-8 -*- -<div class="filters" url="${search.request.current_route_url()}"> - ${search.begin()} - ${search.hidden('filters', 'true')} - <% visible = [] %> - % for f in search.sorted_filters(): - <div class="filter" id="filter-${f.name}"${' style="display: none;"' if not search.config.get('include_filter_'+f.name) else ''|n}> - ${search.checkbox('include_filter_'+f.name)} - <label for="${f.name}">${f.label}</label> - ${f.types_select()} - <div class="value"> - ${f.value_control()} - </div> - </div> - % if search.config.get('include_filter_'+f.name): - <% visible.append(f.name) %> - % endif - % endfor - <div class="buttons"> - ${search.add_filter(visible)} - ${search.submit('submit', "Search", style='display: none;' if not visible else None)} - <button type="reset"${' style="display: none;"' if not visible else ''|n}>Reset</button> - </div> - ${search.end()} - % if visible: - <script language="javascript" type="text/javascript"> - filters_to_disable = [ - % for field in visible: - '${field}', - % endfor - ]; - $(function() { - disable_filter_options(); - }); - </script> - % endif -</div> diff --git a/tailbone/templates/master/versions.mako b/tailbone/templates/master/versions.mako index e6574356..bfec39b7 100644 --- a/tailbone/templates/master/versions.mako +++ b/tailbone/templates/master/versions.mako @@ -6,19 +6,8 @@ ## ############################################################################## <%inherit file="/page.mako" /> -## TODO: this page still uses old-style grid but should use Buefy grid - <%def name="title()">${model_title_plural} » ${instance_title} » history</%def> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - <script type="text/javascript"> - $(function() { - $('.grid-wrapper').gridwrapper(); - }); - </script> -</%def> - <%def name="content_title()"> Version History </%def> diff --git a/tailbone/templates/people/view_profile.mako b/tailbone/templates/people/view_profile.mako deleted file mode 100644 index 07448b73..00000000 --- a/tailbone/templates/people/view_profile.mako +++ /dev/null @@ -1,402 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/master/view.mako" /> - -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - <script type="text/javascript"> - - ## NOTE: we must delay activation of accordions, otherwise they do not - ## seem to "resize" correctly - var customer_accordion_activated = false; - var user_accordion_activated = false; - - $(function() { - $('#profile-tabs').tabs({ - activate: function(event, ui) { - ## activate accordion, first time tab is activated - if (ui.newPanel.selector == '#customer-tab') { - if (! customer_accordion_activated) { - $('#customers-accordion').accordion(); - customer_accordion_activated = true; - } - } else if (ui.newPanel.selector == '#user-tab') { - if (! user_accordion_activated) { - $('#users-accordion').accordion(); - user_accordion_activated = true; - } - } - } - }); - }); - </script> -</%def> - -<div id="profile-tabs"> - <ul> - <li><a href="#personal-tab">Personal</a></li> - <li><a href="#customer-tab">Customer</a></li> - <li><a href="#employee-tab">Employee</a></li> - <li><a href="#user-tab">User</a></li> - </ul> - - <div id="personal-tab"> - - <div style="display: flex; justify-content: space-between;"> - - <div> - - <div class="field-wrapper first_name"> - <div class="field-row"> - <label>First Name</label> - <div class="field"> - ${person.first_name} - </div> - </div> - </div> - - <div class="field-wrapper middle_name"> - <div class="field-row"> - <label>Middle Name</label> - <div class="field"> - ${person.middle_name} - </div> - </div> - </div> - - <div class="field-wrapper last_name"> - <div class="field-row"> - <label>Last Name</label> - <div class="field"> - ${person.last_name} - </div> - </div> - </div> - - <div class="field-wrapper street"> - <div class="field-row"> - <label>Street 1</label> - <div class="field"> - ${person.address.street if person.address else ''} - </div> - </div> - </div> - - <div class="field-wrapper street2"> - <div class="field-row"> - <label>Street 2</label> - <div class="field"> - ${person.address.street2 if person.address else ''} - </div> - </div> - </div> - - <div class="field-wrapper city"> - <div class="field-row"> - <label>City</label> - <div class="field"> - ${person.address.city if person.address else ''} - </div> - </div> - </div> - - <div class="field-wrapper state"> - <div class="field-row"> - <label>State</label> - <div class="field"> - ${person.address.state if person.address else ''} - </div> - </div> - </div> - - <div class="field-wrapper zipcode"> - <div class="field-row"> - <label>Zipcode</label> - <div class="field"> - ${person.address.zipcode if person.address else ''} - </div> - </div> - </div> - - % if person.phones: - % for phone in person.phones: - <div class="field-wrapper"> - <div class="field-row"> - <label>Phone Number</label> - <div class="field"> - ${phone.number} (type: ${phone.type}) - </div> - </div> - </div> - % endfor - % else: - <div class="field-wrapper"> - <div class="field-row"> - <label>Phone Number</label> - <div class="field"> - (none on file) - </div> - </div> - </div> - % endif - - % if person.emails: - % for email in person.emails: - <div class="field-wrapper"> - <div class="field-row"> - <label>Email Address</label> - <div class="field"> - ${email.address} (type: ${email.type}) - </div> - </div> - </div> - % endfor - % else: - <div class="field-wrapper"> - <div class="field-row"> - <label>Email Address</label> - <div class="field"> - (none on file) - </div> - </div> - </div> - % endif - - </div> - - <div> - % if request.has_perm('people.view'): - ${h.link_to("View Person", url('people.view', uuid=person.uuid), class_='button')} - % endif - </div> - - </div> - </div><!-- personal-tab --> - - <div id="customer-tab"> - % if person.customers: - <p>${person} is associated with ${len(person.customers)} customer account(s)</p> - <br /> - <div id="customers-accordion"> - % for customer in person.customers: - <h3>${customer.id} - ${customer.name}</h3> - <div> - - <div style="display: flex; justify-content: space-between;"> - - <div> - - <div class="field-wrapper id"> - <div class="field-row"> - <label>ID</label> - <div class="field"> - ${customer.id or ''} - </div> - </div> - </div> - - <div class="field-wrapper name"> - <div class="field-row"> - <label>Name</label> - <div class="field"> - ${customer.name} - </div> - </div> - </div> - - % if customer.phones: - % for phone in customer.phones: - <div class="field-wrapper"> - <div class="field-row"> - <label>Phone Number</label> - <div class="field"> - ${phone.number} (type: ${phone.type}) - </div> - </div> - </div> - % endfor - % else: - <div class="field-wrapper"> - <div class="field-row"> - <label>Phone Number</label> - <div class="field"> - (none on file) - </div> - </div> - </div> - % endif - - % if customer.emails: - % for email in customer.emails: - <div class="field-wrapper"> - <div class="field-row"> - <label>Email Address</label> - <div class="field"> - ${email.address} (type: ${email.type}) - </div> - </div> - </div> - % endfor - % else: - <div class="field-wrapper"> - <div class="field-row"> - <label>Email Address</label> - <div class="field"> - (none on file) - </div> - </div> - </div> - % endif - - </div> - - <div> - % if request.has_perm('customers.view'): - ${h.link_to("View Customer", url('customers.view', uuid=customer.uuid), class_='button')} - % endif - </div> - - </div> - - </div> - % endfor - </div> - - % else: - <p>${person} has never been a customer.</p> - % endif - </div><!-- customer-tab --> - - <div id="employee-tab"> - % if employee: - <div style="display: flex; justify-content: space-between;"> - - <div> - - <div class="field-wrapper id"> - <div class="field-row"> - <label>ID</label> - <div class="field"> - ${employee.id or ''} - </div> - </div> - </div> - - <div class="field-wrapper display_name"> - <div class="field-row"> - <label>Display Name</label> - <div class="field"> - ${employee.display_name or ''} - </div> - </div> - </div> - - <div class="field-wrapper status"> - <div class="field-row"> - <label>Status</label> - <div class="field"> - ${enum.EMPLOYEE_STATUS.get(employee.status, '')} - </div> - </div> - </div> - - % if employee.phones: - % for phone in employee.phones: - <div class="field-wrapper"> - <div class="field-row"> - <label>Phone Number</label> - <div class="field"> - ${phone.number} (type: ${phone.type}) - </div> - </div> - </div> - % endfor - % else: - <div class="field-wrapper"> - <div class="field-row"> - <label>Phone Number</label> - <div class="field"> - (none on file) - </div> - </div> - </div> - % endif - - % if employee.emails: - % for email in employee.emails: - <div class="field-wrapper"> - <div class="field-row"> - <label>Email Address</label> - <div class="field"> - ${email.address} (type: ${email.type}) - </div> - </div> - </div> - % endfor - % else: - <div class="field-wrapper"> - <div class="field-row"> - <label>Email Address</label> - <div class="field"> - (none on file) - </div> - </div> - </div> - % endif - - </div> - - <div> - % if request.has_perm('employees.view'): - ${h.link_to("View Employee", url('employees.view', uuid=employee.uuid), class_='button')} - % endif - </div> - - </div> - - % else: - <p>${person} has never been an employee.</p> - % endif - </div><!-- employee-tab --> - - <div id="user-tab"> - % if person.users: - <p>${person} is associated with ${len(person.users)} user account(s)</p> - <br /> - <div id="users-accordion"> - % for user in person.users: - <h3>${user.username}</h3> - <div> - - <div style="display: flex; justify-content: space-between;"> - - <div> - - <div class="field-wrapper id"> - <div class="field-row"> - <label>Username</label> - <div class="field"> - ${user.username} - </div> - </div> - </div> - - </div> - - <div> - % if request.has_perm('users.view'): - ${h.link_to("View User", url('users.view', uuid=user.uuid), class_='button')} - % endif - </div> - - </div> - - </div> - % endfor - </div> - - % else: - <p>${person} has never been a user.</p> - % endif - </div><!-- user-tab --> - -</div><!-- profile-tabs --> diff --git a/tailbone/templates/purchases/batches/create.mako b/tailbone/templates/purchases/batches/create.mako index 3b94f7d0..35ee878a 100644 --- a/tailbone/templates/purchases/batches/create.mako +++ b/tailbone/templates/purchases/batches/create.mako @@ -1,99 +1,6 @@ ## -*- coding: utf-8; -*- <%inherit file="/batch/create.mako" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - ${self.func_show_mode()} - <script type="text/javascript"> - - var purchases_field = '${purchases_field}'; - var purchases = null; // TODO: where is this used? - - function vendor_selected(uuid, name) { - var mode = $('.mode select').val(); - if (mode == ${enum.PURCHASE_BATCH_MODE_RECEIVING} || mode == ${enum.PURCHASE_BATCH_MODE_COSTING}) { - var purchases = $('.purchase_uuid select'); - purchases.empty(); - - var data = {'vendor_uuid': uuid, 'mode': mode}; - $.get('${url('purchases.batch.eligible_purchases')}', data, function(data) { - if (data.error) { - alert(data.error); - } else { - $.each(data.purchases, function(i, purchase) { - purchases.append($('<option value="' + purchase.key + '">' + purchase.display + '</option>')); - }); - } - }); - - // TODO: apparently refresh doesn't work right? - // http://stackoverflow.com/a/10280078 - // purchases.selectmenu('refresh'); - purchases.selectmenu('destroy').selectmenu(); - } - } - - function vendor_cleared() { - var purchases = $('.purchase_uuid select'); - purchases.empty(); - - // TODO: apparently refresh doesn't work right? - // http://stackoverflow.com/a/10280078 - // purchases.selectmenu('refresh'); - purchases.selectmenu('destroy').selectmenu(); - } - - $(function() { - - $('.field-wrapper.mode select').selectmenu({ - change: function(event, ui) { - show_mode(ui.item.value); - } - }); - - show_mode(${batch.mode or enum.PURCHASE_BATCH_MODE_ORDERING}); - - }); - - </script> -</%def> - -<%def name="func_show_mode()"> - <script type="text/javascript"> - - function show_mode(mode) { - if (mode == ${enum.PURCHASE_BATCH_MODE_ORDERING}) { - $('.field-wrapper.store_uuid').show(); - $('.field-wrapper.' + purchases_field).hide(); - $('.field-wrapper.department_uuid').show(); - $('.field-wrapper.buyer_uuid').show(); - $('.field-wrapper.date_ordered').show(); - $('.field-wrapper.date_received').hide(); - $('.field-wrapper.po_number').show(); - $('.field-wrapper.invoice_date').hide(); - $('.field-wrapper.invoice_number').hide(); - } else if (mode == ${enum.PURCHASE_BATCH_MODE_RECEIVING}) { - $('.field-wrapper.store_uuid').hide(); - $('.field-wrapper.purchase_uuid').show(); - $('.field-wrapper.department_uuid').hide(); - $('.field-wrapper.buyer_uuid').hide(); - $('.field-wrapper.date_ordered').hide(); - $('.field-wrapper.date_received').show(); - $('.field-wrapper.invoice_date').show(); - $('.field-wrapper.invoice_number').show(); - } else if (mode == ${enum.PURCHASE_BATCH_MODE_COSTING}) { - $('.field-wrapper.store_uuid').hide(); - $('.field-wrapper.purchase_uuid').show(); - $('.field-wrapper.department_uuid').hide(); - $('.field-wrapper.buyer_uuid').hide(); - $('.field-wrapper.date_ordered').hide(); - $('.field-wrapper.date_received').hide(); - $('.field-wrapper.invoice_date').show(); - $('.field-wrapper.invoice_number').show(); - } - } - - </script> -</%def> +## TODO: deprecate / remove this ${parent.body()} diff --git a/tailbone/templates/purchases/credits/index.mako b/tailbone/templates/purchases/credits/index.mako index db59b939..4248d4ad 100644 --- a/tailbone/templates/purchases/credits/index.mako +++ b/tailbone/templates/purchases/credits/index.mako @@ -1,97 +1,85 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/index.mako" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} +<%def name="grid_tools()"> + ${parent.grid_tools()} + + <b-button type="is-primary" + @click="changeStatusInit()" + :disabled="!selected_uuids.length"> + Change Status + </b-button> + + <b-modal has-modal-card + :active.sync="changeStatusShowDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Change Status</p> + </header> + + <section class="modal-card-body"> + + <p class="block"> + Please choose the appropriate status for the selected credits. + </p> + + <b-field label="Status"> + <b-select v-model="changeStatusValue"> + <option v-for="status in changeStatusOptions" + :key="status.value" + :value="status.value"> + {{ status.label }} + </option> + </b-select> + </b-field> + + </section> + + <footer class="modal-card-foot"> + <b-button @click="changeStatusShowDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + @click="changeStatusSubmit()" + :disabled="changeStatusSubmitting || !changeStatusValue" + icon-pack="fas" + icon-left="save"> + {{ changeStatusSubmitting ? "Working, please wait..." : "Save" }} + </b-button> + </footer> + </div> + </b-modal> + + ${h.form(url('purchases.credits.change_status'), ref='changeStatusForm')} + ${h.csrf_token(request)} + ${h.hidden('uuids', **{':value': 'selected_uuids'})} + ${h.hidden('status', **{':value': 'changeStatusValue'})} + ${h.end_form()} + +</%def> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} <script type="text/javascript"> - function update_change_status_button() { - var count = $('.grid tr:not(.header) td.checkbox input:checked').length; - $('button.change-status').button('option', 'disabled', count < 1); + ${grid.component_studly}Data.changeStatusShowDialog = false + ${grid.component_studly}Data.changeStatusOptions = ${json.dumps(status_options)|n} + ${grid.component_studly}Data.changeStatusValue = null + ${grid.component_studly}Data.changeStatusSubmitting = false + + ${grid.component_studly}.methods.changeStatusInit = function() { + this.changeStatusValue = null + this.changeStatusShowDialog = true } - $(function() { + ${grid.component_studly}.methods.changeStatusSubmit = function() { + this.changeStatusSubmitting = true + this.$refs.changeStatusForm.submit() + } - $('.grid-wrapper').on('click', 'tr.header td.checkbox input', function() { - update_change_status_button(); - }); - - $('.grid-wrapper').on('click', '.grid tr:not(.header) td.checkbox input', function() { - update_change_status_button(); - }); - $('.grid-wrapper').on('click', '.grid tr:not(.header)', function() { - update_change_status_button(); - }); - - $('button.change-status').click(function() { - var uuids = []; - $('.grid tr:not(.header) td.checkbox input:checked').each(function() { - uuids.push($(this).parents('tr:first').data('uuid')); - }); - if (! uuids.length) { - alert("You must first select one or more credits."); - return false; - } - - var form = $('form[name="change-status"]'); - form.find('[name="uuids"]').val(uuids.toString()); - - $('#change-status-dialog').dialog({ - title: "Change Credit Status", - width: 500, - height: 300, - modal: true, - open: function() { - // TODO: why must we do this here instead of using auto-enhance ? - $('#change-status-dialog select[name="status"]').selectmenu(); - }, - buttons: [ - { - text: "Submit", - click: function(event) { - disable_button(dialog_button(event)); - form.submit(); - } - }, - { - text: "Cancel", - click: function() { - $(this).dialog('close'); - } - } - ] - }); - }); - - }); </script> </%def> -<%def name="grid_tools()"> - ${parent.grid_tools()} - <button type="button" class="change-status" disabled="disabled">Change Status</button> -</%def> ${parent.body()} - -<div id="change-status-dialog" style="display: none;"> - ${h.form(url('purchases.credits.change_status'), name='change-status')} - ${h.csrf_token(request)} - ${h.hidden('uuids')} - - <br /> - <p>Please choose the appropriate status for the selected credits.</p> - - <div class="fieldset"> - - <div class="field-wrapper status"> - <label for="status">Status</label> - <div class="field"> - ${h.select('status', None, status_options)} - </div> - </div> - - </div> - - ${h.end_form()} -</div> diff --git a/tailbone/templates/shifts/base.mako b/tailbone/templates/shifts/base.mako index 7543712f..4bae5ebf 100644 --- a/tailbone/templates/shifts/base.mako +++ b/tailbone/templates/shifts/base.mako @@ -1,102 +1,13 @@ -## -*- coding: utf-8 -*- -<%inherit file="/base.mako" /> -<%namespace file="/autocomplete.mako" import="autocomplete" /> +## -*- coding: utf-8; -*- +<%inherit file="/page.mako" /> <%def name="title()">${page_title}</%def> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - <script type="text/javascript"> - - var data_modified = false; - var okay_to_leave = true; - var previous_selections = {}; - % if weekdays is not Undefined: - var weekdays = [ - % for i, day in enumerate(weekdays, 1): - '${day.strftime('%a %d %b %Y')}'${',' if i < len(weekdays) else ''} - % endfor - ]; - % endif - - window.onbeforeunload = function() { - if (! okay_to_leave) { - return "If you leave this page, you will lose all unsaved changes!"; - } - } - - function employee_selected(uuid, name) { - $('#filter-form').submit(); - } - - function confirm_leave() { - if (data_modified) { - if (confirm("If you navigate away from this page now, you will lose " + - "unsaved changes.\n\nAre you sure you wish to do this?")) { - okay_to_leave = true; - return true; - } - return false; - } - return true; - } - - function date_selected(dateText, inst) { - if (confirm_leave()) { - $('#filter-form').submit(); - } else { - // revert date value - $('.week-picker input[name="date"]').val($('.week-picker').data('week')); - } - } - - $(function() { - - $('#filter-form').submit(function() { - $('.timesheet-header').mask("Fetching data"); - }); - - $('.timesheet-header select').each(function() { - previous_selections[$(this).attr('name')] = $(this).val(); - }); - - $('.timesheet-header select').selectmenu({ - change: function(event, ui) { - if (confirm_leave()) { - $('#filter-form').submit(); - } else { - var select = ui.item.element.parents('select'); - select.val(previous_selections[select.attr('name')]); - select.selectmenu('refresh'); - } - } - }); - - $('.timesheet-header a.goto').click(function() { - $('.timesheet-header').mask("Fetching data"); - }); - - $('.week-picker button.nav').click(function() { - if (confirm_leave()) { - $('.week-picker input[name="date"]').val($(this).data('date')); - $('#filter-form').submit(); - } - }); - - }); - - </script> -</%def> - <%def name="extra_styles()"> ${parent.extra_styles()} ${h.stylesheet_link(request.static_url('tailbone:static/css/timesheet.css'))} </%def> -<%def name="edit_timetable_javascript()"> - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.edit-shifts.js'))} -</%def> - <%def name="edit_timetable_styles()"> <style type="text/css"> .timesheet .day { @@ -304,5 +215,9 @@ <%def name="render_extra_totals(employee)"></%def> +<%def name="page_content()"> + ${self.timesheet_wrapper()} +</%def> -${self.timesheet_wrapper()} + +${parent.body()} diff --git a/tailbone/templates/shifts/schedule_edit.mako b/tailbone/templates/shifts/schedule_edit.mako index 7157ee27..4455c74d 100644 --- a/tailbone/templates/shifts/schedule_edit.mako +++ b/tailbone/templates/shifts/schedule_edit.mako @@ -1,60 +1,6 @@ -## -*- coding: utf-8 -*- +## -*- coding: utf-8; -*- <%inherit file="/shifts/base.mako" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - ${self.edit_timetable_javascript()} - <script type="text/javascript"> - - $(function() { - - % if allow_clear: - $('.clear-schedule').click(function() { - if (confirm("This will remove all shifts from the schedule you're " + - "currently viewing.\n\nAre you sure you wish to do this?")) { - $(this).button('disable').button('option', 'label', "Clearing..."); - okay_to_leave = true; - $('#clear-schedule-form').submit(); - } - }); - % endif - - $('#copy-week').datepicker({ - dateFormat: 'mm/dd/yy' - }); - - $('.copy-schedule').click(function() { - $('#copy-details').dialog({ - modal: true, - title: "Copy from Another Week", - width: '500px', - buttons: [ - { - text: "Copy Schedule", - click: function(event) { - if (! $('#copy-week').val()) { - alert("You must specify the week from which to copy shift data."); - $('#copy-week').focus(); - return; - } - disable_button(dialog_button(event), "Copying Schedule"); - $('#copy-schedule-form').submit(); - } - }, - { - text: "Cancel", - click: function() { - $('#copy-details').dialog('close'); - } - } - ] - }); - }); - - }); - </script> -</%def> - <%def name="extra_styles()"> ${parent.extra_styles()} ${self.edit_timetable_styles()} @@ -97,43 +43,50 @@ </div> </%def> +<%def name="page_content()"> -${self.timesheet_wrapper(with_edit_form=True)} + ${self.timesheet_wrapper(with_edit_form=True)} -${edit_tools()} + ${edit_tools()} -% if allow_clear: -${h.form(url('schedule.edit'), id="clear-schedule-form")} -${h.csrf_token(request)} -${h.hidden('clear-schedule', value='clear')} -${h.end_form()} -% endif - -<div id="day-editor" style="display: none;"> - <div class="shifts"></div> - <button type="button" id="add-shift">Add Shift</button> -</div> - -<div id="copy-details" style="display: none;"> - <p> - This tool will replace the currently visible schedule, with one from - another week. - </p> - <p> - <strong>NOTE:</strong> If you do this, all shifts in the current - schedule will be <em>removed</em>, - and then new shifts will be created based on the week you specify. - </p> - ${h.form(url('schedule.edit'), id='copy-schedule-form')} + % if allow_clear: + ${h.form(url('schedule.edit'), id="clear-schedule-form")} ${h.csrf_token(request)} - <label for="copy-week">Copy from week:</label> - ${h.text('copy-week')} + ${h.hidden('clear-schedule', value='clear')} ${h.end_form()} -</div> + % endif -<div id="snippets"> - <div class="shift" data-uuid=""> - ${h.text('edit_start_time')} thru ${h.text('edit_end_time')} - <button type="button"><span class="ui-icon ui-icon-trash"></span></button> + <div id="day-editor" style="display: none;"> + <div class="shifts"></div> + <button type="button" id="add-shift">Add Shift</button> </div> -</div> + + <div id="copy-details" style="display: none;"> + <p> + This tool will replace the currently visible schedule, with one from + another week. + </p> + <p> + <strong>NOTE:</strong> If you do this, all shifts in the current + schedule will be <em>removed</em>, + and then new shifts will be created based on the week you specify. + </p> + ${h.form(url('schedule.edit'), id='copy-schedule-form')} + ${h.csrf_token(request)} + <label for="copy-week">Copy from week:</label> + ${h.text('copy-week')} + ${h.end_form()} + </div> + + <div id="snippets"> + <div class="shift" data-uuid=""> + ${h.text('edit_start_time')} thru ${h.text('edit_end_time')} + <button type="button"><span class="ui-icon ui-icon-trash"></span></button> + </div> + </div> + +</%def> + + +${parent.body()} + diff --git a/tailbone/templates/shifts/timesheet.mako b/tailbone/templates/shifts/timesheet.mako index 562cdb35..b93de6ac 100644 --- a/tailbone/templates/shifts/timesheet.mako +++ b/tailbone/templates/shifts/timesheet.mako @@ -1,4 +1,4 @@ -## -*- coding: utf-8 -*- +## -*- coding: utf-8; -*- <%inherit file="/shifts/base.mako" /> <%def name="context_menu()"> @@ -25,4 +25,4 @@ </%def> -${self.timesheet_wrapper()} +${parent.body()} diff --git a/tailbone/templates/shifts/timesheet_edit.mako b/tailbone/templates/shifts/timesheet_edit.mako index 96035663..c4dc7a6b 100644 --- a/tailbone/templates/shifts/timesheet_edit.mako +++ b/tailbone/templates/shifts/timesheet_edit.mako @@ -1,58 +1,6 @@ -## -*- coding: utf-8 -*- +## -*- coding: utf-8; -*- <%inherit file="/shifts/base.mako" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.timesheet.edit.js'))} - <script type="text/javascript"> - - show_timepicker = false; - - $(function() { - - $('.timesheet').on('click', '.day', function() { - editing_day = $(this); - var editor = $('#day-editor'); - var employee = editing_day.siblings('.employee').text(); - var date = weekdays[editing_day.get(0).cellIndex - 1]; - var shifts = editor.children('.shifts'); - shifts.empty(); - editing_day.children('.shift:not(.deleted)').each(function() { - var uuid = $(this).data('uuid'); - var times = $.trim($(this).children('span').text()).split(' - '); - times[0] = times[0] == '??' ? '' : times[0]; - times[1] = times[1] == '??' ? '' : times[1]; - add_shift(false, uuid, times[0], times[1]); - }); - if (! shifts.children('.shift').length) { - add_shift(); - } - editor.dialog({ - modal: true, - title: employee + ' - ' + date, - position: {my: 'center', at: 'center', of: editing_day}, - width: 'auto', - autoResize: true, - buttons: [ - { - text: "Save Changes", - click: save_dialog - }, - { - text: "Cancel", - click: function() { - editor.dialog('close'); - } - } - ] - }); - }); - - }); - - </script> -</%def> - <%def name="extra_styles()"> ${parent.extra_styles()} ${self.edit_timetable_styles()} @@ -88,17 +36,23 @@ ${h.csrf_token(request)} </%def> +<%def name="page_content()"> -${self.timesheet_wrapper(with_edit_form=True, change_employee='confirm_leave')} + ${self.timesheet_wrapper(with_edit_form=True, change_employee='confirm_leave')} -<div id="day-editor" style="display: none;"> - <div class="shifts"></div> - <button type="button" id="add-shift">Add Shift</button> -</div> - -<div id="snippets"> - <div class="shift" data-uuid=""> - ${h.text('edit_start_time')} thru ${h.text('edit_end_time')} - <button type="button"><span class="ui-icon ui-icon-trash"></span></button> + <div id="day-editor" style="display: none;"> + <div class="shifts"></div> + <button type="button" id="add-shift">Add Shift</button> </div> -</div> + + <div id="snippets"> + <div class="shift" data-uuid=""> + ${h.text('edit_start_time')} thru ${h.text('edit_end_time')} + <button type="button"><span class="ui-icon ui-icon-trash"></span></button> + </div> + </div> + +</%def> + + +${parent.body()} diff --git a/tailbone/templates/tempmon/probes/create.mako b/tailbone/templates/tempmon/probes/create.mako deleted file mode 100644 index 062997d7..00000000 --- a/tailbone/templates/tempmon/probes/create.mako +++ /dev/null @@ -1,17 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/master/create.mako" /> - -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - <script type="text/javascript"> - $(function() { - - $('.field-wrapper.client_uuid select').selectmenu(); - - $('.field-wrapper.appliance_type select').selectmenu(); - - }); - </script> -</%def> - -${parent.body()} diff --git a/tailbone/templates/tempmon/probes/edit.mako b/tailbone/templates/tempmon/probes/edit.mako deleted file mode 100644 index b9f2a6b2..00000000 --- a/tailbone/templates/tempmon/probes/edit.mako +++ /dev/null @@ -1,17 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/master/edit.mako" /> - -<%def name="head_tags()"> - ${parent.head_tags()} - <script type="text/javascript"> - $(function() { - - $('.field-wrapper.client_uuid select').selectmenu(); - - $('.field-wrapper.appliance_type select').selectmenu(); - - }); - </script> -</%def> - -${parent.body()} diff --git a/tailbone/views/purchases/credits.py b/tailbone/views/purchases/credits.py index 71902426..c3dffc57 100644 --- a/tailbone/views/purchases/credits.py +++ b/tailbone/views/purchases/credits.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Views for "true" purchase credits """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from webhelpers2.html import tags @@ -112,7 +108,7 @@ class PurchaseCreditView(MasterView): g.filters['status'].default_active = True g.filters['status'].default_verb = 'not_equal' # TODO: should not have to convert value to string! - g.filters['status'].default_value = six.text_type(self.enum.PURCHASE_CREDIT_STATUS_SATISFIED) + g.filters['status'].default_value = str(self.enum.PURCHASE_CREDIT_STATUS_SATISFIED) # g.set_type('upc', 'gpc') g.set_type('cases_shorted', 'quantity') @@ -175,7 +171,9 @@ class PurchaseCreditView(MasterView): def status_options(self): options = [] for value in sorted(self.enum.PURCHASE_CREDIT_STATUS): - options.append(tags.Option(self.enum.PURCHASE_CREDIT_STATUS[value], value)) + options.append({ + 'value': value, + 'label': self.enum.PURCHASE_CREDIT_STATUS[value]}) return options @classmethod From b67df1328b28a7ae29a11f97e5dd6338de41c835 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 3 Feb 2023 17:32:39 -0600 Subject: [PATCH 1011/1681] Remove liburl logic, config for jquery --- tailbone/util.py | 12 ------------ tailbone/views/settings.py | 10 ---------- 2 files changed, 22 deletions(-) diff --git a/tailbone/util.py b/tailbone/util.py index 7414a4c4..7015ad49 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -151,12 +151,6 @@ def get_libver(request, key, fallback=True, default_only=False): elif key == 'fontawesome': return '5.3.1' - elif key == 'jquery': - return '1.12.4' - - elif key == 'jquery_ui': - return '1.11.4' - def get_liburl(request, key, fallback=True): """ @@ -188,12 +182,6 @@ def get_liburl(request, key, fallback=True): elif key == 'fontawesome': return 'https://use.fontawesome.com/releases/v{}/js/all.js'.format(version) - elif key == 'jquery': - return 'https://code.jquery.com/jquery-{}.min.js'.format(version) - - elif key == 'jquery_ui': - return 'https://code.jquery.com/ui/{}/themes/dark-hive/jquery-ui.css'.format(version) - def pretty_datetime(config, value): """ diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index f1d26846..3d05f0a9 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -106,8 +106,6 @@ class AppInfoView(MasterView): ('buefy', "Buefy"), ('buefy.css', "Buefy CSS"), ('fontawesome', "FontAwesome"), - ('jquery', "jQuery"), - ('jquery_ui', "jQuery UI"), ]) for key in weblibs: @@ -181,14 +179,6 @@ class AppInfoView(MasterView): 'option': 'libver.fontawesome'}, {'section': 'tailbone', 'option': 'liburl.fontawesome'}, - {'section': 'tailbone', - 'option': 'libver.jquery'}, - {'section': 'tailbone', - 'option': 'liburl.jquery'}, - {'section': 'tailbone', - 'option': 'libver.jquery_ui'}, - {'section': 'tailbone', - 'option': 'liburl.jquery_ui'}, # nb. these are no longer used (deprecated), but we keep # them defined here so the tool auto-deletes them From eb1351d108afbc55782d2517bca42f4230467958 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 3 Feb 2023 17:39:28 -0600 Subject: [PATCH 1012/1681] 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 6068b107..5f13ceb2 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,22 @@ CHANGELOG ========= +0.9.0 (2023-02-03) +------------------ + +* Officially drop support for python2. + +* Remove all deprecated jquery and ``use_buefy`` logic. + +* Add new Buefy-specific upgrade template. + +* Replace 'default' theme to match 'falafel'. + +* Allow editing the Department field for a Subdepartment. + +* Refactor the Ordering Worksheet generator, per Buefy. + + 0.8.292 (2023-02-02) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index b0708fd4..ffd353aa 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.292' +__version__ = '0.9.0' From 49122d940d242242850d27bd46254a9e0634b9ca Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 3 Feb 2023 18:06:40 -0600 Subject: [PATCH 1013/1681] Stop including deform JS static files although maybe we *should* be using that method, for some things? can revisit later if desired --- tailbone/app.py | 19 +++++++++++++------ tailbone/templates/base.mako | 22 ++++++++++++---------- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/tailbone/app.py b/tailbone/app.py index 9e8348bc..4d4f435c 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -24,12 +24,9 @@ Application Entry Point """ -from __future__ import unicode_literals, absolute_import - import os import warnings -import six import sqlalchemy as sa from sqlalchemy.orm import sessionmaker, scoped_session @@ -148,6 +145,16 @@ def make_pyramid_config(settings, configure_csrf=True): config.include('pyramid_mako') config.include('pyramid_tm') + # TODO: this may be a good idea some day, if wanting to leverage + # deform resources for component JS? cf. also base.mako template + # # override default script mapping for deform + # from deform import Field + # from deform.widget import ResourceRegistry, default_resources + # registry = ResourceRegistry(use_defaults=False) + # for key in default_resources: + # registry.set_js_resources(key, None, {'js': []}) + # Field.set_default_resource_registry(registry) + # bring in the pyramid_retry logic, if available # TODO: pretty soon we can require this package, hopefully.. try: @@ -159,7 +166,7 @@ def make_pyramid_config(settings, configure_csrf=True): # fetch all tailbone providers providers = get_all_providers(rattail_config) - for provider in six.itervalues(providers): + for provider in providers.values(): # configure DB sessions associated with transaction manager provider.configure_db_sessions(rattail_config, config) @@ -193,7 +200,7 @@ def add_websocket(config, name, view, attr=None): rattail_config = config.registry.settings['rattail_config'] rattail_app = rattail_config.get_app() - if isinstance(view, six.string_types): + if isinstance(view, str): view_callable = rattail_app.load_object(view) else: view_callable = view @@ -278,7 +285,7 @@ def establish_theme(settings): settings['tailbone.theme'] = theme directories = settings['mako.directories'] - if isinstance(directories, six.string_types): + if isinstance(directories, str): directories = parse_list(directories) path = get_theme_template_path(rattail_config) diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index 150b052c..f4935113 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -60,16 +60,18 @@ ${self.core_styles()} ${self.extra_styles()} - ## TODO: should this be elsewhere / more customizable? - % if dform is not Undefined: - <% resources = dform.get_widget_resources() %> - % for path in resources['js']: - ${h.javascript_link(request.static_url(path))} - % endfor - % for path in resources['css']: - ${h.stylesheet_link(request.static_url(path))} - % endfor - % endif + ## TODO: should leverage deform resources for component JS? + ## cf. also tailbone.app.make_pyramid_config() +## ## TODO: should this be elsewhere / more customizable? +## % if dform is not Undefined: +## <% resources = dform.get_widget_resources() %> +## % for path in resources['js']: +## ${h.javascript_link(request.static_url(path))} +## % endfor +## % for path in resources['css']: +## ${h.stylesheet_link(request.static_url(path))} +## % endfor +## % endif </%def> <%def name="core_javascript()"> From f71eadd409fd517d3a2d13e147bcabc93b5e3990 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 3 Feb 2023 18:07:50 -0600 Subject: [PATCH 1014/1681] 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 5f13ceb2..35b4a339 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.9.1 (2023-02-03) +------------------ + +* Stop including deform JS static files. + + 0.9.0 (2023-02-03) ------------------ diff --git a/tailbone/_version.py b/tailbone/_version.py index ffd353aa..6c1756f7 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.0' +__version__ = '0.9.1' From 15fb7f45b85f8d984233bf8fa0227da949d976c5 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 3 Feb 2023 19:51:50 -0600 Subject: [PATCH 1015/1681] Fix auto-focus username for login form --- tailbone/templates/login.mako | 10 ++++++++++ tailbone/views/auth.py | 4 ++++ 2 files changed, 14 insertions(+) diff --git a/tailbone/templates/login.mako b/tailbone/templates/login.mako index e8f06848..f53de560 100644 --- a/tailbone/templates/login.mako +++ b/tailbone/templates/login.mako @@ -57,5 +57,15 @@ </div> </%def> +<%def name="modify_this_page_vars()"> + <script type="text/javascript"> + + TailboneForm.mounted = function() { + this.$refs.username.focus() + } + + </script> +</%def> + ${parent.body()} diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index b16ff539..1a1c406d 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -122,6 +122,10 @@ class AuthenticationView(View): 'tailbone', 'main_image_url', default=self.request.static_url('tailbone:static/img/home_logo.png')) + # nb. hacky..but necessary, to add the ref, for autofocus + dform = form.make_deform_form() + dform['username'].widget.attributes = {'ref': 'username'} + return { 'form': form, 'referrer': referrer, From f17ff59ba6d47b1115cf9b771dd3633727b9efc3 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 3 Feb 2023 19:52:26 -0600 Subject: [PATCH 1016/1681] 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 35b4a339..f2178fa6 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.9.2 (2023-02-03) +------------------ + +* Fix auto-focus username for login form. + + 0.9.1 (2023-02-03) ------------------ diff --git a/tailbone/_version.py b/tailbone/_version.py index 6c1756f7..a3a82d24 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.1' +__version__ = '0.9.2' From 9263dd4ddb0c009041a4c620058aeaad98acf08d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 4 Feb 2023 19:03:05 -0600 Subject: [PATCH 1017/1681] Add dependency for pyramid_retry --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index d4af2ce7..107f051a 100644 --- a/setup.py +++ b/setup.py @@ -97,6 +97,7 @@ requires = [ 'pyramid_deform', # 0.2 'pyramid_exclog', # 0.6 'pyramid_mako', # 1.0.2 + 'pyramid_retry', # 2.1.1 'pyramid_tm', # 0.3 'rattail[db,bouncer]', # 0.5.0 'six', # 1.10.0 From 5f70a524e981e8f0192bc57ceed809634ce3974a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 7 Feb 2023 12:20:22 -0600 Subject: [PATCH 1018/1681] Use latest zope.sqlalchemy package session / transaction registration modified per upstream changes, but previous logic kept to support older versions of zope.sqlalchemy - for now, although probably should require minimum version soon? --- setup.py | 6 +--- tailbone/db.py | 81 ++++++++++++++++++++++++++++++++++++++------------ 2 files changed, 63 insertions(+), 24 deletions(-) diff --git a/setup.py b/setup.py index 107f051a..1930f2cd 100644 --- a/setup.py +++ b/setup.py @@ -62,11 +62,6 @@ requires = [ # # package # low high - # TODO: previously was capping this to pre-1.0 although i'm not sure why. - # however the 1.2 release has some breaking changes which require refactor. - # cf. https://pypi.org/project/zope.sqlalchemy/#id3 - 'zope.sqlalchemy<1.2', # 0.7 1.1 - # TODO: apparently they jumped from 0.1 to 0.9 and that broke us... # (0.1 was released on 2014-09-14 and then 0.9 came out on 2018-09-27) # (i've cached 0.1 at pypi.rattailproject.org just in case it disappears) @@ -106,6 +101,7 @@ requires = [ 'waitress', # 0.8.1 'WebHelpers2', # 2.0 'WTForms', # 2.1 + 'zope.sqlalchemy', # 0.7 2.0 ] diff --git a/tailbone/db.py b/tailbone/db.py index ae919e49..4a6821f9 100644 --- a/tailbone/db.py +++ b/tailbone/db.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2021 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,6 @@ Database Stuff """ -from __future__ import unicode_literals, absolute_import - import sqlalchemy as sa from zope.sqlalchemy import datamanager import sqlalchemy_continuum as continuum @@ -111,20 +109,51 @@ def join_transaction(session, initial_state=datamanager.STATUS_ACTIVE, transacti DataManager(session, initial_state, transaction_manager, keep_session=keep_session) -class ZopeTransactionExtension(datamanager.ZopeTransactionExtension): - """Record that a flush has occurred on a session's connection. This allows - the DataManager to rollback rather than commit on read only transactions. +if zope_sqlalchemy_version_parsed >= parse_version('1.2'): # 1.2+ - .. note:: - This class is copied from upstream, and tweaked so that our custom - :func:`join_transaction()` will be used. - """ + class ZopeTransactionEvents(datamanager.ZopeTransactionEvents): + """ + Record that a flush has occurred on a session's + connection. This allows the DataManager to rollback rather + than commit on read only transactions. - def after_begin(self, session, transaction, connection): - join_transaction(session, self.initial_state, self.transaction_manager, self.keep_session) + .. note:: + This class is copied from upstream, and tweaked so that our + custom :func:`join_transaction()` will be used. + """ - def after_attach(self, session, instance): - join_transaction(session, self.initial_state, self.transaction_manager, self.keep_session) + def after_begin(self, session, transaction, connection): + join_transaction(session, self.initial_state, + self.transaction_manager, self.keep_session) + + def after_attach(self, session, instance): + join_transaction(session, self.initial_state, + self.transaction_manager, self.keep_session) + + def join_transaction(self, session): + join_transaction(session, self.initial_state, + self.transaction_manager, self.keep_session) + +else: # pre-1.2 + + class ZopeTransactionExtension(datamanager.ZopeTransactionExtension): + """ + Record that a flush has occurred on a session's + connection. This allows the DataManager to rollback rather + than commit on read only transactions. + + .. note:: + This class is copied from upstream, and tweaked so that our + custom :func:`join_transaction()` will be used. + """ + + def after_begin(self, session, transaction, connection): + join_transaction(session, self.initial_state, + self.transaction_manager, self.keep_session) + + def after_attach(self, session, instance): + join_transaction(session, self.initial_state, + self.transaction_manager, self.keep_session) def register(session, initial_state=datamanager.STATUS_ACTIVE, @@ -147,11 +176,21 @@ def register(session, initial_state=datamanager.STATUS_ACTIVE, """ from sqlalchemy import event - ext = ZopeTransactionExtension( - initial_state=initial_state, - transaction_manager=transaction_manager, - keep_session=keep_session, - ) + if zope_sqlalchemy_version_parsed >= parse_version('1.2'): # 1.2+ + + ext = ZopeTransactionEvents( + initial_state=initial_state, + transaction_manager=transaction_manager, + keep_session=keep_session, + ) + + else: # pre-1.2 + + ext = ZopeTransactionExtension( + initial_state=initial_state, + transaction_manager=transaction_manager, + keep_session=keep_session, + ) event.listen(session, "after_begin", ext.after_begin) event.listen(session, "after_attach", ext.after_attach) @@ -160,6 +199,10 @@ def register(session, initial_state=datamanager.STATUS_ACTIVE, event.listen(session, "after_bulk_delete", ext.after_bulk_delete) event.listen(session, "before_commit", ext.before_commit) + if zope_sqlalchemy_version_parsed >= parse_version('1.5'): # 1.5+ + if datamanager.SA_GE_14: + event.listen(session, "do_orm_execute", ext.do_orm_execute) + register(Session) register(TempmonSession) From 32fc0415da81e8f0657efd523e8c799efb8cd21d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 7 Feb 2023 16:12:09 -0600 Subject: [PATCH 1019/1681] Fix auto-advance on ENTER for login form if user hits ENTER while focused on username field, just set focus to password field but do not submit form. if user hits ENTER on while the password field is focused, then submit form this has long been the behavior but it was broken when removing jquery --- tailbone/templates/deform/password.pt | 3 ++- tailbone/templates/login.mako | 7 +++++++ tailbone/views/auth.py | 9 +++++++-- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/tailbone/templates/deform/password.pt b/tailbone/templates/deform/password.pt index d81b570f..b74d763a 100644 --- a/tailbone/templates/deform/password.pt +++ b/tailbone/templates/deform/password.pt @@ -9,7 +9,8 @@ tal:omit-tag=""> <b-input name="${name}" v-model="${vmodel}" - type="password"> + type="password" + tal:attributes="attributes|field.widget.attributes|{};"> </b-input> </div> </span> diff --git a/tailbone/templates/login.mako b/tailbone/templates/login.mako index f53de560..6e6e347f 100644 --- a/tailbone/templates/login.mako +++ b/tailbone/templates/login.mako @@ -64,6 +64,13 @@ this.$refs.username.focus() } + TailboneForm.methods.usernameKeydown = function(event) { + if (event.which == 13) { + event.preventDefault() + this.$refs.password.focus() + } + } + </script> </%def> diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index 1a1c406d..9bcb644f 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -122,9 +122,14 @@ class AuthenticationView(View): 'tailbone', 'main_image_url', default=self.request.static_url('tailbone:static/img/home_logo.png')) - # nb. hacky..but necessary, to add the ref, for autofocus + # nb. hacky..but necessary, to add the refs, for autofocus + # (also add key handler, so ENTER acts like TAB) dform = form.make_deform_form() - dform['username'].widget.attributes = {'ref': 'username'} + dform['username'].widget.attributes = { + 'ref': 'username', + '@keydown.native': 'usernameKeydown', + } + dform['password'].widget.attributes = {'ref': 'password'} return { 'form': form, From ad5dec3dc6b54cc19ec5620637d0b9245ed5ef4e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 8 Feb 2023 20:19:15 -0600 Subject: [PATCH 1020/1681] Use label handler to avoid deprecated logic --- tailbone/templates/labels/profiles/view.mako | 2 +- tailbone/views/labels/profiles.py | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/labels/profiles/view.mako b/tailbone/templates/labels/profiles/view.mako index 2609ffbf..b93570af 100644 --- a/tailbone/templates/labels/profiles/view.mako +++ b/tailbone/templates/labels/profiles/view.mako @@ -35,7 +35,7 @@ % for name, display in printer.required_settings.items(): <div class="field-wrapper"> <label>${display}</label> - <div class="field">${instance.get_printer_setting(name) or ''}</div> + <div class="field">${label_handler.get_printer_setting(instance, name) or ''}</div> </div> % endfor </div> diff --git a/tailbone/views/labels/profiles.py b/tailbone/views/labels/profiles.py index f49902bb..fa878448 100644 --- a/tailbone/views/labels/profiles.py +++ b/tailbone/views/labels/profiles.py @@ -78,6 +78,13 @@ class LabelProfileView(MasterView): # format f.set_type('format', 'codeblock') + def template_kwargs_view(self, **kwargs): + kwargs = super(LabelProfileView, self).template_kwargs_view(**kwargs) + + kwargs['label_handler'] = self.label_handler + + return kwargs + def after_create(self, profile): self.after_edit(profile) @@ -97,7 +104,7 @@ class LabelProfileView(MasterView): node = colander.SchemaNode(colander.String(), name=name, title=label, - default=profile.get_printer_setting(name)) + default=self.label_handler.get_printer_setting(profile, name)) schema.add(node) form = forms.Form(schema=schema, request=self.request, From 21669b5f4adb0c3b15d525d3475eb16e926268ea Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 10 Feb 2023 11:39:10 -0600 Subject: [PATCH 1021/1681] Remove legacy vendor sources grid for product view whoops, missed that when purging jquery theme --- tailbone/templates/products/view.mako | 32 --------------------------- 1 file changed, 32 deletions(-) diff --git a/tailbone/templates/products/view.mako b/tailbone/templates/products/view.mako index a6df1e7a..131e2dbc 100644 --- a/tailbone/templates/products/view.mako +++ b/tailbone/templates/products/view.mako @@ -138,38 +138,6 @@ <%def name="sources_grid()"> ${vendor_sources['grid'].render_buefy_table_element(data_prop='vendorSourcesData')|n} - <div class="grid full no-border"> - <table> - <thead> - <th>${costs_label_preferred}</th> - <th>${costs_label_vendor}</th> - <th>${costs_label_code}</th> - <th>${costs_label_case_size}</th> - <th>Case Cost</th> - <th>Unit Cost</th> - <th>Status</th> - </thead> - <tbody> - % for i, cost in enumerate(instance.costs, 1): - <tr class="${'even' if i % 2 == 0 else 'odd'}"> - <td class="center">${'X' if cost.preference == 1 else ''}</td> - <td> - % if request.has_perm('vendors.view'): - ${h.link_to(cost.vendor, request.route_url('vendors.view', uuid=cost.vendor_uuid))} - % else: - ${cost.vendor} - % endif - </td> - <td class="center">${cost.code or ''}</td> - <td class="center">${h.pretty_quantity(cost.case_size)}</td> - <td class="right">${'$ %0.2f' % cost.case_cost if cost.case_cost is not None else ''}</td> - <td class="right">${'$ %0.4f' % cost.unit_cost if cost.unit_cost is not None else ''}</td> - <td>${"discontinued" if cost.discontinued else "available"}</td> - </tr> - % endfor - </tbody> - </table> - </div> </%def> <%def name="sources_panel()"> From 2d2c94e4d75e5ecf4ccfddc0ffb21459c7e94e11 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 10 Feb 2023 12:21:55 -0600 Subject: [PATCH 1022/1681] Expose setting for POD image URL --- tailbone/templates/products/configure.mako | 9 +++++++++ tailbone/views/products.py | 2 ++ 2 files changed, 11 insertions(+) diff --git a/tailbone/templates/products/configure.mako b/tailbone/templates/products/configure.mako index 9f895280..a8caeac7 100644 --- a/tailbone/templates/products/configure.mako +++ b/tailbone/templates/products/configure.mako @@ -36,6 +36,15 @@ </b-checkbox> </b-field> + <b-field label="POD Image Base URL" + style="max-width: 50%;"> + <b-input name="rattail.pod.pictures.gtin.root_url" + v-model="simpleSettings['rattail.pod.pictures.gtin.root_url']" + :disabled="!simpleSettings['tailbone.products.show_pod_image']" + @input="settingsNeedSaved = true"> + </b-input> + </b-field> + </div> <h3 class="block is-size-3">Handling</h3> diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 072097ec..f6deabf4 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -2119,6 +2119,8 @@ class ProductView(MasterView): {'section': 'tailbone', 'option': 'products.show_pod_image', 'type': bool}, + {'section': 'rattail.pod', + 'option': 'pictures.gtin.root_url'}, # handling {'section': 'rattail', From 8fc3a71e0f1eb658f728d231bc8d6c983eb9b401 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 10 Feb 2023 12:40:23 -0600 Subject: [PATCH 1023/1681] Fix multi-file upload widget bug happened when only one file was being uploaded --- tailbone/forms/widgets.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/tailbone/forms/widgets.py b/tailbone/forms/widgets.py index 02fcdb76..d02d12d1 100644 --- a/tailbone/forms/widgets.py +++ b/tailbone/forms/widgets.py @@ -24,14 +24,10 @@ Form Widgets """ -from __future__ import unicode_literals, absolute_import, division - import json import datetime import decimal -import six - import colander from deform import widget as dfwidget from webhelpers2.html import tags, HTML @@ -86,7 +82,7 @@ class PercentInputWidget(dfwidget.TextInputWidget): # convert "traditional" value to "human-friendly" value = decimal.Decimal(cstruct) * 100 value = value.quantize(decimal.Decimal('0.001')) - cstruct = six.text_type(value) + cstruct = str(value) return super(PercentInputWidget, self).serialize(field, cstruct, **kw) def deserialize(self, field, pstruct): @@ -100,7 +96,7 @@ class PercentInputWidget(dfwidget.TextInputWidget): raise colander.Invalid(field.schema, "Invalid decimal string: {}".format(pstruct)) value = value.quantize(decimal.Decimal('0.00001')) value /= 100 - return six.text_type(value) + return str(value) class CasesUnitsWidget(dfwidget.Widget): @@ -301,7 +297,7 @@ class MultiFileUploadWidget(dfwidget.FileUploadWidget): return colander.null # TODO: why is this a thing? pstruct == [b''] - if len(pstruct) == 1 and not pstruct[0]: + if len(pstruct) == 1 and pstruct[0] == b'': return colander.null files_data = [] @@ -416,7 +412,7 @@ class CustomerAutocompleteWidget(JQueryAutocompleteWidget): model = self.request.rattail_config.get_model() customer = Session.query(model.Customer).get(cstruct) if customer: - self.field_display = six.text_type(customer) + self.field_display = str(customer) return super(CustomerAutocompleteWidget, self).serialize( field, cstruct, **kw) @@ -469,7 +465,7 @@ class DepartmentWidget(dfwidget.SelectWidget): model = request.rattail_config.get_model() departments = Session.query(model.Department)\ .order_by(model.Department.number) - values = [(dept.uuid, six.text_type(dept)) + values = [(dept.uuid, str(dept)) for dept in departments] if not kwargs.pop('required', True): values.insert(0, ('', "(none)")) From de4667cc714c3760720275d313ced4711b76ece3 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 10 Feb 2023 20:25:02 -0600 Subject: [PATCH 1024/1681] 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 f2178fa6..04571d5d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,24 @@ CHANGELOG ========= +0.9.3 (2023-02-10) +------------------ + +* Add dependency for pyramid_retry. + +* Use latest zope.sqlalchemy package. + +* Fix auto-advance on ENTER for login form. + +* Use label handler to avoid deprecated logic. + +* Remove legacy vendor sources grid for product view. + +* Expose setting for POD image URL. + +* Fix multi-file upload widget bug. + + 0.9.2 (2023-02-03) ------------------ diff --git a/tailbone/_version.py b/tailbone/_version.py index a3a82d24..0a93caa4 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.2' +__version__ = '0.9.3' From e879102768b983b2fa30e2ca75d2cbc2d6ca79af Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 10 Feb 2023 20:42:36 -0600 Subject: [PATCH 1025/1681] Remove python2 stuff from `tox.ini` --- tox.ini | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/tox.ini b/tox.ini index 401b5e62..27ae213e 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] -envlist = py27, py35, py37 +envlist = py35, py37 [testenv] commands = @@ -9,13 +9,6 @@ commands = 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[tests] rattail[auth,bouncer,db] rattail-tempmon SQLAlchemy-Utils<0.36.7 SQLAlchemy-Continuum<1.3.12 - pytest {posargs} - [testenv:coverage] basepython = python3 commands = From 10162b378a04482c68e30c95cf6729fc9b24bab8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 10 Feb 2023 21:23:57 -0600 Subject: [PATCH 1026/1681] Remove legacy grid for alt codes in product view whoops missed this in jquery purge --- tailbone/templates/products/view.mako | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/tailbone/templates/products/view.mako b/tailbone/templates/products/view.mako index 131e2dbc..5de6d099 100644 --- a/tailbone/templates/products/view.mako +++ b/tailbone/templates/products/view.mako @@ -109,22 +109,6 @@ <%def name="lookup_codes_grid()"> ${lookup_codes['grid'].render_buefy_table_element(data_prop='lookupCodesData')|n} - <div class="grid full no-border"> - <table> - <thead> - <th>Seq</th> - <th>Code</th> - </thead> - <tbody> - % for code in instance._codes: - <tr> - <td>${code.ordinal}</td> - <td>${code.code}</td> - </tr> - % endfor - </tbody> - </table> - </div> </%def> <%def name="lookup_codes_panel()"> From c87c50bfb9e26aab265c1543241f57db8019e533 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 11 Feb 2023 09:59:45 -0600 Subject: [PATCH 1027/1681] 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 04571d5d..4fc164a3 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.9.4 (2023-02-11) +------------------ + +* Remove legacy grid for alt codes in product view. + + 0.9.3 (2023-02-10) ------------------ diff --git a/tailbone/_version.py b/tailbone/_version.py index 0a93caa4..bed348fd 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.3' +__version__ = '0.9.4' From 5736faf24c6742883652b26e7e73abf88b39e871 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 11 Feb 2023 11:51:43 -0600 Subject: [PATCH 1028/1681] Use sa-filters instead of sqlalchemy-filters for API queries latter was abandoned it seems; former has support for SQLAlchemy 1.4 and looks to be a drop-in replacement another option, if needed at some point, though i like the looks of it less, is https://sqlalchemy-filters-plus.readthedocs.io/ see also: https://github.com/juliotrigo/sqlalchemy-filters/pull/69 https://github.com/juliotrigo/sqlalchemy-filters/issues/72 --- setup.py | 2 +- tailbone/api/master.py | 8 ++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/setup.py b/setup.py index 1930f2cd..f200f89d 100644 --- a/setup.py +++ b/setup.py @@ -96,7 +96,7 @@ requires = [ 'pyramid_tm', # 0.3 'rattail[db,bouncer]', # 0.5.0 'six', # 1.10.0 - 'sqlalchemy-filters', # 0.8.0 + 'sa-filters', # 1.2.0 'transaction', # 1.2.0 'waitress', # 0.8.1 'WebHelpers2', # 2.0 diff --git a/tailbone/api/master.py b/tailbone/api/master.py index 3d21cfbe..8cb2cdee 100644 --- a/tailbone/api/master.py +++ b/tailbone/api/master.py @@ -24,12 +24,8 @@ Tailbone Web API - Master View """ -from __future__ import unicode_literals, absolute_import - import json -import six - from rattail.config import parse_bool from rattail.db.util import get_fieldnames @@ -279,7 +275,7 @@ class APIMasterView(APIView): return self._fieldnames def normalize(self, obj): - data = {'_str': six.text_type(obj)} + data = {'_str': str(obj)} for field in self.get_fieldnames(): data[field] = getattr(obj, field) @@ -287,7 +283,7 @@ class APIMasterView(APIView): return data def _collection_get(self): - from sqlalchemy_filters import apply_filters, apply_sort, apply_pagination + from sa_filters import apply_filters, apply_sort, apply_pagination query = self.base_query() context = {} From 81aa0ae1097541edfec8559cbd061ac68b669fde Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 11 Feb 2023 11:55:43 -0600 Subject: [PATCH 1029/1681] 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 4fc164a3..53733dae 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.9.5 (2023-02-11) +------------------ + +* Use sa-filters instead of sqlalchemy-filters for API queries. + + 0.9.4 (2023-02-11) ------------------ diff --git a/tailbone/_version.py b/tailbone/_version.py index bed348fd..82db9101 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.4' +__version__ = '0.9.5' From f611a5a5215b769505a3d0e0f747a310617c59c3 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 11 Feb 2023 22:05:45 -0600 Subject: [PATCH 1030/1681] Refactor `Query.get()` => `Session.get()` per SQLAlchemy 1.4 --- tailbone/api/batch/core.py | 28 ++++++++---------- tailbone/api/batch/receiving.py | 15 ++++------ tailbone/api/common.py | 6 ++-- tailbone/api/master.py | 4 +-- tailbone/api/products.py | 11 +++----- tailbone/auth.py | 6 ++-- tailbone/forms/common.py | 6 ++-- tailbone/forms/receiving.py | 6 ++-- tailbone/forms/types.py | 14 ++++----- tailbone/forms/widgets.py | 2 +- tailbone/subscribers.py | 2 +- tailbone/views/asgi/__init__.py | 4 +-- tailbone/views/batch/core.py | 19 ++++++------- tailbone/views/batch/importer.py | 2 +- tailbone/views/batch/inventory.py | 6 ++-- tailbone/views/batch/vendorcatalog.py | 4 +-- tailbone/views/common.py | 2 +- tailbone/views/core.py | 2 +- tailbone/views/customers.py | 10 +++---- tailbone/views/custorders/items.py | 2 +- tailbone/views/custorders/orders.py | 39 ++++++++++++-------------- tailbone/views/employees.py | 4 +-- tailbone/views/master.py | 14 ++++----- tailbone/views/messages.py | 2 +- tailbone/views/people.py | 34 +++++++++++----------- tailbone/views/products.py | 20 ++++++------- tailbone/views/purchases/credits.py | 2 +- tailbone/views/purchasing/batch.py | 14 ++++----- tailbone/views/purchasing/costing.py | 6 ++-- tailbone/views/purchasing/ordering.py | 4 +-- tailbone/views/purchasing/receiving.py | 18 ++++++------ tailbone/views/reports.py | 6 ++-- tailbone/views/settings.py | 2 +- tailbone/views/shifts/lib.py | 17 +++++------ tailbone/views/shifts/schedule.py | 8 ++---- tailbone/views/shifts/timesheet.py | 8 ++---- tailbone/views/tempmon/dashboard.py | 4 +-- tailbone/views/users.py | 21 ++++++-------- 38 files changed, 169 insertions(+), 205 deletions(-) diff --git a/tailbone/api/batch/core.py b/tailbone/api/batch/core.py index 5b6102ed..a1c06ee6 100644 --- a/tailbone/api/batch/core.py +++ b/tailbone/api/batch/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,13 +24,9 @@ Tailbone Web API - Batch Views """ -from __future__ import unicode_literals, absolute_import - import logging import warnings -import six - from cornice import Service from tailbone.api import APIMasterView @@ -104,25 +100,25 @@ class APIBatchView(APIBatchMixin, APIMasterView): return { 'uuid': batch.uuid, - '_str': six.text_type(batch), + '_str': str(batch), 'id': batch.id, 'id_str': batch.id_str, 'description': batch.description, 'notes': batch.notes, 'params': batch.params or {}, 'rowcount': batch.rowcount, - 'created': six.text_type(created), + 'created': str(created), 'created_display': self.pretty_datetime(created), 'created_by_uuid': batch.created_by.uuid, - 'created_by_display': six.text_type(batch.created_by), + 'created_by_display': str(batch.created_by), 'complete': batch.complete, 'status_code': batch.status_code, 'status_display': batch.STATUS.get(batch.status_code, - six.text_type(batch.status_code)), - 'executed': six.text_type(executed) if executed else None, + str(batch.status_code)), + 'executed': str(executed) if executed else None, '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 ''), + 'executed_by_display': str(batch.executed_by or ''), 'mutable': self.batch_handler.is_mutable(batch), } @@ -273,8 +269,8 @@ class APIBatchRowView(APIBatchMixin, APIMasterView): batch = row.batch return { 'uuid': row.uuid, - '_str': six.text_type(row), - '_parent_str': six.text_type(batch), + '_str': str(row), + '_parent_str': str(batch), '_parent_uuid': batch.uuid, 'batch_uuid': batch.uuid, 'batch_id': batch.id, @@ -285,7 +281,7 @@ class APIBatchRowView(APIBatchMixin, APIMasterView): '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)), + 'status_display': row.STATUS.get(row.status_code, str(row.status_code)), } def update_object(self, row, data): @@ -320,7 +316,7 @@ class APIBatchRowView(APIBatchMixin, APIMasterView): data = self.request.json_body uuid = data['batch_uuid'] - batch = self.Session.query(self.get_batch_class()).get(uuid) + batch = self.Session.get(self.get_batch_class(), uuid) if not batch: raise self.notfound() @@ -332,7 +328,7 @@ class APIBatchRowView(APIBatchMixin, APIMasterView): log.warning("quick entry failed for '%s' batch %s: %s", self.batch_handler.batch_key, batch.id_str, entry, exc_info=True) - msg = six.text_type(error) + msg = str(error) if not msg and isinstance(error, NotImplementedError): msg = "Feature is not implemented" return {'error': msg} diff --git a/tailbone/api/batch/receiving.py b/tailbone/api/batch/receiving.py index c755de65..339fc43f 100644 --- a/tailbone/api/batch/receiving.py +++ b/tailbone/api/batch/receiving.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,11 +24,8 @@ Tailbone Web API - Receiving Batches """ -from __future__ import unicode_literals, absolute_import - import logging -import six import humanize from rattail.db import model @@ -64,10 +61,10 @@ class ReceivingBatchViews(APIBatchView): data = super(ReceivingBatchViews, self).normalize(batch) data['vendor_uuid'] = batch.vendor.uuid - data['vendor_display'] = six.text_type(batch.vendor) + data['vendor_display'] = str(batch.vendor) data['department_uuid'] = batch.department_uuid - data['department_display'] = six.text_type(batch.department) if batch.department else None + data['department_display'] = str(batch.department) if batch.department else None data['po_total'] = batch.po_total data['invoice_total'] = batch.invoice_total @@ -115,7 +112,7 @@ class ReceivingBatchViews(APIBatchView): def eligible_purchases(self): uuid = self.request.params.get('vendor_uuid') - vendor = self.Session.query(model.Vendor).get(uuid) if uuid else None + vendor = self.Session.get(model.Vendor, uuid) if uuid else None if not vendor: return {'error': "Vendor not found"} @@ -289,7 +286,7 @@ class ReceivingBatchRowViews(APIBatchRowView): data['product_uuid'] = row.product_uuid data['item_id'] = row.item_id - data['upc'] = six.text_type(row.upc) + data['upc'] = str(row.upc) data['upc_pretty'] = row.upc.pretty() if row.upc else None data['brand_name'] = row.brand_name data['description'] = row.description @@ -415,7 +412,7 @@ class ReceivingBatchRowViews(APIBatchRowView): return {'error': "Form did not validate"} # fetch / validate row object - row = self.Session.query(model.PurchaseBatchRow).get(form.validated['row']) + row = self.Session.get(model.PurchaseBatchRow, form.validated['row']) if row is not self.get_object(): return {'error': "Specified row does not match the route!"} diff --git a/tailbone/api/common.py b/tailbone/api/common.py index 3e96609a..b82bafd0 100644 --- a/tailbone/api/common.py +++ b/tailbone/api/common.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,6 @@ Tailbone Web API - "Common" Views """ -from __future__ import unicode_literals, absolute_import - import rattail from rattail.db import model from rattail.mail import send_email @@ -97,7 +95,7 @@ class CommonView(APIView): if self.request.user: data['user'] = self.request.user elif data['user']: - data['user'] = Session.query(model.User).get(data['user']) + data['user'] = Session.get(model.User, data['user']) # TODO: should provide URL to view user if data['user']: diff --git a/tailbone/api/master.py b/tailbone/api/master.py index 8cb2cdee..dabc31ff 100644 --- a/tailbone/api/master.py +++ b/tailbone/api/master.py @@ -339,7 +339,7 @@ class APIMasterView(APIView): if not uuid: uuid = self.request.matchdict['uuid'] - obj = self.Session.query(self.get_model_class()).get(uuid) + obj = self.Session.get(self.get_model_class(), uuid) if obj: return obj @@ -390,7 +390,7 @@ class APIMasterView(APIView): """ if not uuid: uuid = self.request.matchdict['uuid'] - obj = self.Session.query(self.get_model_class()).get(uuid) + obj = self.Session.get(self.get_model_class(), uuid) if not obj: raise self.notfound() diff --git a/tailbone/api/products.py b/tailbone/api/products.py index 4c3df983..a2e2db73 100644 --- a/tailbone/api/products.py +++ b/tailbone/api/products.py @@ -24,11 +24,8 @@ Tailbone Web API - Product Views """ -from __future__ import unicode_literals, absolute_import - import logging -import six import sqlalchemy as sa from sqlalchemy import orm @@ -84,7 +81,7 @@ class ProductView(APIMasterView): # but must supplement cost = product.cost data.update({ - 'upc': six.text_type(product.upc), + 'upc': str(product.upc), 'scancode': product.scancode, 'item_id': product.item_id, 'item_type': product.item_type, @@ -135,12 +132,12 @@ class ProductView(APIMasterView): data = self.request.json_body uuid = data.get('label_profile_uuid') - profile = self.Session.query(model.LabelProfile).get(uuid) if uuid else None + profile = self.Session.get(model.LabelProfile, uuid) if uuid else None if not profile: return {'error': "Label profile not found"} uuid = data.get('product_uuid') - product = self.Session.query(model.Product).get(uuid) if uuid else None + product = self.Session.get(model.Product, uuid) if uuid else None if not product: return {'error': "Product not found"} @@ -157,7 +154,7 @@ class ProductView(APIMasterView): printer.print_labels([({'product': product}, quantity)]) except Exception as error: log.warning("error occurred while printing labels", exc_info=True) - return {'error': six.text_type(error)} + return {'error': str(error)} return {'ok': True} diff --git a/tailbone/auth.py b/tailbone/auth.py index 88fbab0b..0c90003a 100644 --- a/tailbone/auth.py +++ b/tailbone/auth.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2021 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,6 @@ Authentication & Authorization """ -from __future__ import unicode_literals, absolute_import - import logging from rattail import enum @@ -107,7 +105,7 @@ class TailboneAuthorizationPolicy(object): # re-creating the database, which means new user uuids. # TODO: the odds of this query returning a user in that # case, are probably nil, and we should just skip this bit? - user = Session.query(model.User).get(userid) + user = Session.get(model.User, userid) if user: if auth.has_permission(Session(), user, permission): return True diff --git a/tailbone/forms/common.py b/tailbone/forms/common.py index 4d58b943..6183d17f 100644 --- a/tailbone/forms/common.py +++ b/tailbone/forms/common.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,6 @@ Common Forms """ -from __future__ import unicode_literals, absolute_import - from rattail.db import model import colander @@ -35,7 +33,7 @@ import colander def validate_user(node, kw): session = kw['session'] def validate(node, value): - user = session.query(model.User).get(value) + user = session.get(model.User, value) if not user: raise colander.Invalid(node, "User not found") return user.uuid diff --git a/tailbone/forms/receiving.py b/tailbone/forms/receiving.py index 40fa35fe..20c4774f 100644 --- a/tailbone/forms/receiving.py +++ b/tailbone/forms/receiving.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,6 @@ Forms for Receiving """ -from __future__ import unicode_literals, absolute_import - from rattail.db import model import colander @@ -35,7 +33,7 @@ import colander def valid_purchase_batch_row(node, kw): session = kw['session'] def validate(node, value): - row = session.query(model.PurchaseBatchRow).get(value) + row = session.get(model.PurchaseBatchRow, value) if not row: raise colander.Invalid(node, "Batch row not found") if row.batch.executed: diff --git a/tailbone/forms/types.py b/tailbone/forms/types.py index d9f7e828..0d87ae3f 100644 --- a/tailbone/forms/types.py +++ b/tailbone/forms/types.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,13 +24,9 @@ Form Schema Types """ -from __future__ import unicode_literals, absolute_import - import re import datetime -import six - from rattail.db import model from rattail.gpc import GPC @@ -84,7 +80,7 @@ class GPCType(colander.SchemaType): def serialize(self, node, appstruct): if appstruct is colander.null: return colander.null - return six.text_type(appstruct) + return str(appstruct) def deserialize(self, node, cstruct): if not cstruct: @@ -95,7 +91,7 @@ class GPCType(colander.SchemaType): try: return GPC(digits) except Exception as err: - raise colander.Invalid(node, six.text_type(err)) + raise colander.Invalid(node, str(err)) class ProductQuantity(colander.MappingSchema): @@ -133,12 +129,12 @@ class ModelType(colander.SchemaType): def serialize(self, node, appstruct): if appstruct is colander.null: return colander.null - return six.text_type(appstruct) + return str(appstruct) def deserialize(self, node, cstruct): if not cstruct: return None - obj = self.session.query(self.model_class).get(cstruct) + obj = self.session.get(self.model_class, cstruct) if not obj: raise colander.Invalid(node, "{} not found".format(self.model_title)) return obj diff --git a/tailbone/forms/widgets.py b/tailbone/forms/widgets.py index d02d12d1..28b24678 100644 --- a/tailbone/forms/widgets.py +++ b/tailbone/forms/widgets.py @@ -410,7 +410,7 @@ class CustomerAutocompleteWidget(JQueryAutocompleteWidget): # 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) + customer = Session.get(model.Customer, cstruct) if customer: self.field_display = str(customer) diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 229296ab..60fba60d 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -74,7 +74,7 @@ def new_request(event): uuid = request.authenticated_userid if uuid: model = request.rattail_config.get_model() - user = Session.query(model.User).get(uuid) + user = Session.get(model.User, uuid) if user: Session().set_continuum_user(user) return user diff --git a/tailbone/views/asgi/__init__.py b/tailbone/views/asgi/__init__.py index d0c12d9c..bebe16f3 100644 --- a/tailbone/views/asgi/__init__.py +++ b/tailbone/views/asgi/__init__.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -89,7 +89,7 @@ class WebsocketView(object): session=session) as s: # load user proper - return s.query(model.User).get(user_uuid) + return s.get(model.User, user_uuid) def get_user_session(self, scope): settings = self.registry.settings diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 54fe9b0c..2ba7e6da 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -32,7 +32,6 @@ import logging import socket import subprocess import tempfile -from six import StringIO import json import markdown @@ -236,7 +235,7 @@ class BatchMasterView(MasterView): Thread target for updating a batch from worksheet. """ session = self.make_isolated_session() - batch = session.query(self.model_class).get(batch_uuid) + batch = session.get(self.model_class, batch_uuid) try: self.handler.update_from_worksheet(batch, path, progress=progress) @@ -1020,7 +1019,7 @@ class BatchMasterView(MasterView): def catchup_versions(self, port, batch_uuid, username, *models): with short_session() as s: - batch = s.query(self.model_class).get(batch_uuid) + batch = s.get(self.model_class, batch_uuid) batch_id = batch.id_str description = str(batch) @@ -1047,8 +1046,8 @@ class BatchMasterView(MasterView): """ # mustn't use tailbone web session here session = RattailSession() - batch = session.query(self.model_class).get(batch_uuid) - user = session.query(model.User).get(user_uuid) + batch = session.get(self.model_class, batch_uuid) + user = session.get(model.User, user_uuid) try: self.handler.do_populate(batch, user, progress=progress) session.flush() @@ -1104,8 +1103,8 @@ class BatchMasterView(MasterView): # rattail session here; can't use tailbone because it has web request # transaction binding etc. session = RattailSession() - batch = session.query(self.model_class).get(batch_uuid) - cognizer = session.query(model.User).get(user_uuid) if user_uuid else None + batch = session.get(self.model_class, batch_uuid) + cognizer = session.get(model.User, user_uuid) if user_uuid else None try: self.refresh_data(session, batch, cognizer, progress=progress) session.flush() @@ -1158,7 +1157,7 @@ class BatchMasterView(MasterView): """ session = RattailSession() batches = batches.with_session(session).all() - user = session.query(model.User).get(user_uuid) + user = session.get(model.User, user_uuid) try: self.handler.refresh_many(batches, user=user, progress=progress) @@ -1298,7 +1297,7 @@ class BatchMasterView(MasterView): # transaction binding etc. session = RattailSession() batch = self.get_instance_for_key(key, session) - user = session.query(model.User).get(user_uuid) + user = session.get(model.User, user_uuid) try: result = self.handler.do_execute(batch, user=user, progress=progress, **kwargs) @@ -1373,7 +1372,7 @@ class BatchMasterView(MasterView): """ session = RattailSession() batches = batches.with_session(session).all() - user = session.query(model.User).get(user_uuid) + user = session.get(model.User, user_uuid) try: result = self.handler.execute_many(batches, user=user, progress=progress, **kwargs) diff --git a/tailbone/views/batch/importer.py b/tailbone/views/batch/importer.py index ef22a429..f0b76bf6 100644 --- a/tailbone/views/batch/importer.py +++ b/tailbone/views/batch/importer.py @@ -190,7 +190,7 @@ class ImporterBatchView(BatchMasterView): def get_parent(self, row): uuid = self.current_row_table.name - return self.Session.query(model.ImporterBatch).get(uuid) + return self.Session.get(model.ImporterBatch, uuid) def get_row_instance_title(self, row): if row.object_str: diff --git a/tailbone/views/batch/inventory.py b/tailbone/views/batch/inventory.py index 657d5758..e13dacca 100644 --- a/tailbone/views/batch/inventory.py +++ b/tailbone/views/batch/inventory.py @@ -214,7 +214,7 @@ class InventoryBatchView(BatchMasterView): return super(InventoryBatchView, self).save_edit_row_form(form) def delete_row(self): - row = self.Session.query(model.InventoryBatchRow).get(self.request.matchdict['row_uuid']) + row = self.Session.get(model.InventoryBatchRow, self.request.matchdict['row_uuid']) if not row: raise self.notfound() batch = row.batch @@ -235,7 +235,7 @@ class InventoryBatchView(BatchMasterView): if self.request.method == 'POST': if form.validate(newstyle=True): - product = self.Session.query(model.Product).get(form.validated['product']) + product = self.Session.get(model.Product, form.validated['product']) row = None if self.should_aggregate_products(batch): @@ -515,7 +515,7 @@ class InventoryBatchView(BatchMasterView): def valid_product(node, kw): session = kw['session'] def validate(node, value): - product = session.query(model.Product).get(value) + product = session.get(model.Product, value) if not product: raise colander.Invalid(node, "Product not found") return product.uuid diff --git a/tailbone/views/batch/vendorcatalog.py b/tailbone/views/batch/vendorcatalog.py index 0808eed5..01fa85ea 100644 --- a/tailbone/views/batch/vendorcatalog.py +++ b/tailbone/views/batch/vendorcatalog.py @@ -225,8 +225,8 @@ class VendorCatalogView(FileBatchMasterView): vendor_display = "" if self.request.method == 'POST': if self.request.POST.get('vendor_uuid'): - vendor = self.Session.query(model.Vendor).get( - self.request.POST['vendor_uuid']) + vendor = self.Session.get(model.Vendor, + self.request.POST['vendor_uuid']) if vendor: vendor_display = str(vendor) f.set_widget('vendor_uuid', diff --git a/tailbone/views/common.py b/tailbone/views/common.py index 89abb9a9..6de6bc2b 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -163,7 +163,7 @@ class CommonView(View): if form.validate(newstyle=True): data = dict(form.validated) if data['user']: - data['user'] = Session.query(model.User).get(data['user']) + data['user'] = Session.get(model.User, data['user']) data['user_url'] = self.request.route_url('users.view', uuid=data['user'].uuid) data['client_ip'] = self.request.client_addr app.send_email('user_feedback', data=data) diff --git a/tailbone/views/core.py b/tailbone/views/core.py index 55b35487..3920b93b 100644 --- a/tailbone/views/core.py +++ b/tailbone/views/core.py @@ -97,7 +97,7 @@ class View(object): if self.request.method == 'POST': uuid = self.request.POST.get('late-login-user') if uuid: - return Session.query(model.User).get(uuid) + return Session.get(model.User, uuid) def user_is_protected(self, user): """ diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index d4ab1a37..50b93d59 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -187,12 +187,12 @@ class CustomerView(MasterView): return instance # search by CustomerPerson.uuid - instance = self.Session.query(model.CustomerPerson).get(key) + instance = self.Session.get(model.CustomerPerson, key) if instance: return instance.customer # search by CustomerGroupAssignment.uuid - instance = self.Session.query(model.CustomerGroupAssignment).get(key) + instance = self.Session.get(model.CustomerGroupAssignment, key) if instance: return instance.customer @@ -438,7 +438,7 @@ class CustomerView(MasterView): def detach_person(self): customer = self.get_instance() - person = self.Session.query(model.Person).get(self.request.matchdict['person_uuid']) + person = self.Session.get(model.Person, self.request.matchdict['person_uuid']) if not person: return self.notfound() @@ -612,7 +612,7 @@ class PendingCustomerView(MasterView): redirect = self.redirect(self.get_action_url('view', pending)) uuid = self.request.POST['person_uuid'] - person = self.Session.query(model.Person).get(uuid) + person = self.Session.get(model.Person, uuid) if not person: self.request.session.flash("Person not found!", 'error') return redirect @@ -670,7 +670,7 @@ def customer_info(request): View which returns simple dictionary of info for a particular customer. """ uuid = request.params.get('uuid') - customer = Session.query(model.Customer).get(uuid) if uuid else None + customer = Session.get(model.Customer, uuid) if uuid else None if not customer: return {} return { diff --git a/tailbone/views/custorders/items.py b/tailbone/views/custorders/items.py index 8331c864..5dc61e4d 100644 --- a/tailbone/views/custorders/items.py +++ b/tailbone/views/custorders/items.py @@ -408,7 +408,7 @@ class CustomerOrderItemView(MasterView): uuids = self.request.POST['uuids'] if uuids: for uuid in uuids.split(','): - item = self.Session.query(model.CustomerOrderItem).get(uuid) + item = self.Session.get(model.CustomerOrderItem, uuid) if item: order_items.append(item) diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index 8bc53a67..563739ea 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -24,12 +24,9 @@ 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 @@ -195,7 +192,7 @@ class CustomerOrderView(MasterView): person = order.person if not person: return "" - text = six.text_type(person) + text = str(person) url = self.request.route_url('people.view', uuid=person.uuid) return tags.link_to(text, url) @@ -203,7 +200,7 @@ class CustomerOrderView(MasterView): pending = batch.pending_customer if not pending: return - text = six.text_type(pending) + text = str(pending) url = self.request.route_url('pending_customers.view', uuid=pending.uuid) return tags.link_to(text, url, class_='has-background-warning') @@ -275,7 +272,7 @@ class CustomerOrderView(MasterView): def render_row_status_code(self, item, field): text = self.enum.CUSTORDER_ITEM_STATUS.get(item.status_code, - six.text_type(item.status_code)) + str(item.status_code)) if item.status_text: return HTML.tag('span', title=item.status_text, c=[text]) return text @@ -445,7 +442,7 @@ class CustomerOrderView(MasterView): if not uuid: return {'error': "Must specify a customer UUID"} - customer = self.Session.query(model.Customer).get(uuid) + customer = self.Session.get(model.Customer, uuid) if not customer: return {'error': "Customer not found"} @@ -472,14 +469,14 @@ class CustomerOrderView(MasterView): if self.batch_handler.new_order_requires_customer(): - customer = self.Session.query(model.Customer).get(uuid) + customer = self.Session.get(model.Customer, uuid) if not customer: return {'error': "Customer not found"} kwargs['customer'] = customer else: - person = self.Session.query(model.Person).get(uuid) + person = self.Session.get(model.Person, uuid) if not person: return {'error': "Person not found"} kwargs['person'] = person @@ -488,7 +485,7 @@ class CustomerOrderView(MasterView): try: self.batch_handler.assign_contact(batch, **kwargs) except ValueError as error: - return {'error': six.text_type(error)} + return {'error': str(error)} self.Session.flush() context = self.get_context_contact(batch) @@ -590,7 +587,7 @@ class CustomerOrderView(MasterView): self.batch_handler.update_pending_customer(batch, self.request.user, data) except Exception as error: - return {'error': six.text_type(error)} + return {'error': str(error)} self.Session.flush() context = self.get_context_contact(batch) @@ -619,7 +616,7 @@ class CustomerOrderView(MasterView): if not uuid: return {'error': "Must specify a product UUID"} - product = self.Session.query(model.Product).get(uuid) + product = self.Session.get(model.Product, uuid) if not product: return {'error': "Product not found"} @@ -635,7 +632,7 @@ class CustomerOrderView(MasterView): try: info = self.batch_handler.get_product_info(batch, product) except Exception as error: - return {'error': six.text_type(error)} + return {'error': str(error)} else: info['url'] = self.request.route_url('products.view', uuid=info['uuid']) app = self.get_rattail_app() @@ -661,7 +658,7 @@ class CustomerOrderView(MasterView): def normalize_batch(self, batch): return { 'uuid': batch.uuid, - 'total_price': six.text_type(batch.total_price or 0), + 'total_price': str(batch.total_price or 0), 'total_price_display': "${:0.2f}".format(batch.total_price or 0), 'status_code': batch.status_code, 'status_text': batch.status_text, @@ -690,7 +687,7 @@ class CustomerOrderView(MasterView): 'sequence': row.sequence, 'item_entry': row.item_entry, 'product_uuid': row.product_uuid, - 'product_upc': six.text_type(row.product_upc or ''), + 'product_upc': str(row.product_upc or ''), 'product_item_id': row.product_item_id, 'product_scancode': row.product_scancode, 'product_upc_pretty': row.product_upc.pretty() if row.product_upc else None, @@ -727,7 +724,7 @@ class CustomerOrderView(MasterView): data['unit_sale_price_display'] = app.render_currency(row.unit_sale_price) if row.sale_ends: sale_ends = app.localtime(row.sale_ends, from_utc=True).date() - data['sale_ends'] = six.text_type(sale_ends) + data['sale_ends'] = str(sale_ends) data['sale_ends_display'] = app.render_date(sale_ends) if row.unit_sale_price and row.unit_price == row.unit_sale_price: @@ -748,7 +745,7 @@ class CustomerOrderView(MasterView): pending = row.pending_product data['pending_product'] = { 'uuid': pending.uuid, - 'upc': six.text_type(pending.upc) if pending.upc is not None else None, + 'upc': str(pending.upc) if pending.upc is not None else None, 'item_id': pending.item_id, 'scancode': pending.scancode, 'brand_name': pending.brand_name, @@ -826,7 +823,7 @@ class CustomerOrderView(MasterView): if not uuid: return {'error': "Must specify a product UUID"} - product = self.Session.query(model.Product).get(uuid) + product = self.Session.get(model.Product, uuid) if not product: return {'error': "Product not found"} @@ -871,7 +868,7 @@ class CustomerOrderView(MasterView): if not uuid: return {'error': "Must specify a row UUID"} - row = self.Session.query(model.CustomerOrderBatchRow).get(uuid) + row = self.Session.get(model.CustomerOrderBatchRow, uuid) if not row: return {'error': "Row not found"} @@ -888,7 +885,7 @@ class CustomerOrderView(MasterView): if not uuid: return {'error': "Must specify a product UUID"} - product = self.Session.query(model.Product).get(uuid) + product = self.Session.get(model.Product, uuid) if not product: return {'error': "Product not found"} @@ -929,7 +926,7 @@ class CustomerOrderView(MasterView): if not uuid: return {'error': "Must specify a row UUID"} - row = self.Session.query(model.CustomerOrderBatchRow).get(uuid) + row = self.Session.get(model.CustomerOrderBatchRow, uuid) if not row: return {'error': "Row not found"} diff --git a/tailbone/views/employees.py b/tailbone/views/employees.py index 4f788532..37692996 100644 --- a/tailbone/views/employees.py +++ b/tailbone/views/employees.py @@ -249,7 +249,7 @@ class EmployeeView(MasterView): employee._stores.append(model.EmployeeStore(store_uuid=uuid)) for uuid in old_stores: if uuid not in new_stores: - store = self.Session.query(model.Store).get(uuid) + store = self.Session.get(model.Store, uuid) employee.stores.remove(store) def update_departments(self, employee, data): @@ -262,7 +262,7 @@ class EmployeeView(MasterView): employee._departments.append(model.EmployeeDepartment(department_uuid=uuid)) for uuid in old_depts: if uuid not in new_depts: - dept = self.Session.query(model.Department).get(uuid) + dept = self.Session.get(model.Department, uuid) employee.departments.remove(dept) def get_possible_stores(self): diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 01a3405a..e9f35687 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -1024,7 +1024,7 @@ class MasterView(View): """ # mustn't use tailbone web session here session = RattailSession() - obj = session.query(self.model_class).get(uuid) + obj = session.get(self.model_class, uuid) try: self.populate_object(session, obj, progress=progress) except Exception as error: @@ -1727,7 +1727,7 @@ class MasterView(View): if uuids: uuids = uuids.split(',') # TODO: probably need to allow override of fetcher callable - fetcher = lambda uuid: self.Session.query(self.model_class).get(uuid) + fetcher = lambda uuid: self.Session.get(self.model_class, uuid) objects = [] for uuid in uuids: obj = fetcher(uuid) @@ -1856,7 +1856,7 @@ class MasterView(View): model_key = self.get_model_key(as_tuple=True) if len(model_key) == 1 and model_key[0] == 'uuid': uuid = key[0] - return session.query(self.model_class).get(uuid) + return session.get(self.model_class, uuid) raise NotImplementedError def execute_thread(self, key, user_uuid, progress=None, **kwargs): @@ -1865,7 +1865,7 @@ class MasterView(View): """ session = RattailSession() obj = self.get_instance_for_key(key, session) - user = session.query(model.User).get(user_uuid) + user = session.get(model.User, user_uuid) try: success_msg = self.execute_instance(obj, user, progress=progress, @@ -2021,8 +2021,8 @@ class MasterView(View): if self.request.method == 'POST': uuids = self.request.POST.get('uuids', '').split(',') if len(uuids) == 2: - object_to_remove = self.Session.query(self.get_model_class()).get(uuids[0]) - object_to_keep = self.Session.query(self.get_model_class()).get(uuids[1]) + object_to_remove = self.Session.get(self.get_model_class(), uuids[0]) + object_to_keep = self.Session.get(self.get_model_class(), uuids[1]) if object_to_remove and object_to_keep and self.request.POST.get('commit-merge') == 'yes': msg = str(object_to_remove) @@ -4447,7 +4447,7 @@ class MasterView(View): # TODO: is this right..? # key = self.request.matchdict[self.get_model_key()] key = self.request.matchdict['row_uuid'] - instance = self.Session.query(self.model_row_class).get(key) + instance = self.Session.get(self.model_row_class, key) if not instance: raise self.notfound() return instance diff --git a/tailbone/views/messages.py b/tailbone/views/messages.py index 10851913..6aaf342e 100644 --- a/tailbone/views/messages.py +++ b/tailbone/views/messages.py @@ -279,7 +279,7 @@ class MessageView(MasterView): message.sender = self.request.user for uuid in data['set_recipients']: - user = self.Session.query(model.User).get(uuid) + user = self.Session.get(model.User, uuid) if user: message.add_recipient(user, status=self.enum.MESSAGE_STATUS_INBOX) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index bb43102e..9556f66d 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -171,10 +171,10 @@ class PersonView(MasterView): # TODO: I don't recall why this fallback check for a vendor contact # exists here, but leaving it intact for now. key = self.request.matchdict['uuid'] - instance = self.Session.query(model.Person).get(key) + instance = self.Session.get(model.Person, key) if instance: return instance - instance = self.Session.query(model.VendorContact).get(key) + instance = self.Session.get(model.VendorContact, key) if instance: return instance.person raise HTTPNotFound @@ -677,7 +677,7 @@ class PersonView(MasterView): person = self.get_instance() data = dict(self.request.json_body) - phone = self.Session.query(model.PersonPhoneNumber).get(data['phone_uuid']) + phone = self.Session.get(model.PersonPhoneNumber, data['phone_uuid']) if not phone: return {'error': "Phone not found."} @@ -708,7 +708,7 @@ class PersonView(MasterView): data = dict(self.request.json_body) # validate phone - phone = self.Session.query(model.PersonPhoneNumber).get(data['phone_uuid']) + phone = self.Session.get(model.PersonPhoneNumber, data['phone_uuid']) if not phone: return {'error': "Phone not found."} if phone not in person.phones: @@ -731,7 +731,7 @@ class PersonView(MasterView): data = dict(self.request.json_body) # validate phone - phone = self.Session.query(model.PersonPhoneNumber).get(data['phone_uuid']) + phone = self.Session.get(model.PersonPhoneNumber, data['phone_uuid']) if not phone: return {'error': "Phone not found."} if phone not in person.phones: @@ -792,7 +792,7 @@ class PersonView(MasterView): person = self.get_instance() data = dict(self.request.json_body) - email = self.Session.query(model.PersonEmailAddress).get(data['email_uuid']) + email = self.Session.get(model.PersonEmailAddress, data['email_uuid']) if not email: return {'error': "Email not found."} @@ -819,7 +819,7 @@ class PersonView(MasterView): data = dict(self.request.json_body) # validate email - email = self.Session.query(model.PersonEmailAddress).get(data['email_uuid']) + email = self.Session.get(model.PersonEmailAddress, data['email_uuid']) if not email: return {'error': "Email not found."} if email not in person.emails: @@ -843,7 +843,7 @@ class PersonView(MasterView): data = dict(self.request.json_body) # validate email - email = self.Session.query(model.PersonEmailAddress).get(data['email_uuid']) + email = self.Session.get(model.PersonEmailAddress, data['email_uuid']) if not email: return {'error': "Email not found."} if email not in person.emails: @@ -944,7 +944,7 @@ class PersonView(MasterView): employee = person.employee uuid = self.request.json_body['uuid'] - history = self.Session.query(model.EmployeeHistory).get(uuid) + history = self.Session.get(model.EmployeeHistory, uuid) if not history or history not in employee.history: return {'error': "Must specify a valid Employee History record for this Person."} @@ -1032,7 +1032,7 @@ class PersonView(MasterView): return self.profile_edit_note_failure(person, form) def update_note(self, person, form): - note = self.Session.query(model.PersonNote).get(form.validated['uuid']) + note = self.Session.get(model.PersonNote, form.validated['uuid']) note.subject = form.validated['note_subject'] note.text = form.validated['note_text'] return note @@ -1054,7 +1054,7 @@ class PersonView(MasterView): return self.profile_delete_note_failure(person, form) def delete_note(self, person, form): - note = self.Session.query(model.PersonNote).get(form.validated['uuid']) + note = self.Session.get(model.PersonNote, form.validated['uuid']) self.Session.delete(note) def profile_delete_note_success(self, person): @@ -1065,7 +1065,7 @@ class PersonView(MasterView): def make_user(self): uuid = self.request.POST['person_uuid'] - person = self.Session.query(model.Person).get(uuid) + person = self.Session.get(model.Person, uuid) if not person: return self.notfound() if person.users: @@ -1355,7 +1355,7 @@ def valid_note_uuid(node, kw): session = kw['session'] person_uuid = kw['person_uuid'] def validate(node, value): - note = session.query(model.PersonNote).get(value) + note = session.get(model.PersonNote, value) if not note: raise colander.Invalid(node, "Note not found") if note.person.uuid != person_uuid: @@ -1425,15 +1425,15 @@ class MergePeopleRequestView(MasterView): def render_referenced_person_name(self, merge_request, field): uuid = getattr(merge_request, field) - person = self.Session.query(self.model.Person).get(uuid) + person = self.Session.get(self.model.Person, uuid) if person: return str(person) return "(person not found)" def get_instance_title(self, merge_request): model = self.model - removing = self.Session.query(model.Person).get(merge_request.removing_uuid) - keeping = self.Session.query(model.Person).get(merge_request.keeping_uuid) + removing = self.Session.get(model.Person, merge_request.removing_uuid) + keeping = self.Session.get(model.Person, merge_request.keeping_uuid) return "{} -> {}".format( removing or "(not found)", keeping or "(not found)") @@ -1446,7 +1446,7 @@ class MergePeopleRequestView(MasterView): def render_referenced_person(self, merge_request, field): uuid = getattr(merge_request, field) - person = self.Session.query(self.model.Person).get(uuid) + person = self.Session.get(self.model.Person, uuid) if person: text = str(person) url = self.request.route_url('people.view', uuid=person.uuid) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index f6deabf4..bace8421 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -808,10 +808,10 @@ class ProductView(MasterView): def get_instance(self): key = self.request.matchdict['uuid'] - product = self.Session.query(model.Product).get(key) + product = self.Session.get(model.Product, key) if product: return product - price = self.Session.query(model.ProductPrice).get(key) + price = self.Session.get(model.ProductPrice, key) if price: return price.product raise self.notfound() @@ -956,7 +956,7 @@ class ProductView(MasterView): brand_display = "" if self.request.method == 'POST': if self.request.POST.get('brand_uuid'): - brand = self.Session.query(model.Brand).get(self.request.POST['brand_uuid']) + brand = self.Session.get(model.Brand, self.request.POST['brand_uuid']) if brand: brand_display = str(brand) elif self.editing: @@ -1751,12 +1751,12 @@ class ProductView(MasterView): model = self.model profile = self.request.params.get('profile') - profile = self.Session.query(model.LabelProfile).get(profile) if profile else None + profile = self.Session.get(model.LabelProfile, profile) if profile else None if not profile: return {'error': "Label profile not found"} product = self.request.params.get('product') - product = self.Session.query(model.Product).get(product) if product else None + product = self.Session.get(model.Product, product) if product else None if not product: return {'error': "Product not found"} @@ -1901,7 +1901,7 @@ class ProductView(MasterView): } uuid = self.request.GET.get('with_vendor_cost') if uuid: - vendor = self.Session.query(model.Vendor).get(uuid) + vendor = self.Session.get(model.Vendor, uuid) if not vendor: return {'error': "Vendor not found"} cost = product.cost_for_vendor(vendor) @@ -2068,7 +2068,7 @@ class ProductView(MasterView): Threat target for making a batch from current products query. """ session = RattailSession() - user = session.query(model.User).get(user_uuid) + user = session.get(model.User, user_uuid) assert user params['created_by'] = user try: @@ -2288,7 +2288,7 @@ class PendingProductView(MasterView): brand_display = "" if self.request.method == 'POST': if self.request.POST.get('brand_uuid'): - brand = self.Session.query(model.Brand).get(self.request.POST['brand_uuid']) + brand = self.Session.get(model.Brand, self.request.POST['brand_uuid']) if brand: brand_display = str(brand) elif self.editing: @@ -2315,7 +2315,7 @@ class PendingProductView(MasterView): vendor_display = "" if self.request.method == 'POST': if self.request.POST.get('vendor_uuid'): - vendor = self.Session.query(model.Vendor).get(self.request.POST['vendor_uuid']) + vendor = self.Session.get(model.Vendor, self.request.POST['vendor_uuid']) if vendor: vendor_display = str(vendor) f.set_widget('vendor_uuid', forms.widgets.JQueryAutocompleteWidget( @@ -2414,7 +2414,7 @@ class PendingProductView(MasterView): redirect = self.redirect(self.get_action_url('view', pending)) uuid = self.request.POST['product_uuid'] - product = self.Session.query(model.Product).get(uuid) + product = self.Session.get(model.Product, uuid) if not product: self.request.session.flash("Product not found!", 'error') return redirect diff --git a/tailbone/views/purchases/credits.py b/tailbone/views/purchases/credits.py index c3dffc57..ad1079a6 100644 --- a/tailbone/views/purchases/credits.py +++ b/tailbone/views/purchases/credits.py @@ -150,7 +150,7 @@ class PurchaseCreditView(MasterView): for uuid in self.request.POST.get('uuids', '').split(','): uuid = uuid.strip() if uuid: - credit = self.Session.query(model.PurchaseCredit).get(uuid) + credit = self.Session.get(model.PurchaseCredit, uuid) if credit: credits_.append(credit) if not credits_: diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index 8cc14ef4..fdbfe38c 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -271,7 +271,7 @@ class PurchasingBatchView(BatchMasterView): vendor_display = "" if self.request.method == 'POST': if self.request.POST.get('vendor_uuid'): - vendor = self.Session.query(model.Vendor).get(self.request.POST['vendor_uuid']) + vendor = self.Session.get(model.Vendor, self.request.POST['vendor_uuid']) if vendor: vendor_display = str(vendor) vendors_url = self.request.route_url('vendors.autocomplete') @@ -304,7 +304,7 @@ class PurchasingBatchView(BatchMasterView): buyer_display = "" if self.request.method == 'POST': if self.request.POST.get('buyer_uuid'): - buyer = self.Session.query(model.Employee).get(self.request.POST['buyer_uuid']) + buyer = self.Session.get(model.Employee, self.request.POST['buyer_uuid']) if buyer: buyer_display = str(buyer) elif self.creating: @@ -331,8 +331,8 @@ class PurchasingBatchView(BatchMasterView): kwargs = {} if 'vendor_uuid' in self.request.matchdict: - vendor = self.Session.query(model.Vendor).get( - self.request.matchdict['vendor_uuid']) + vendor = self.Session.get(model.Vendor, + self.request.matchdict['vendor_uuid']) if vendor: kwargs['vendor'] = vendor @@ -397,7 +397,7 @@ class PurchasingBatchView(BatchMasterView): def valid_vendor_uuid(self, node, value): model = self.model - vendor = self.Session.query(model.Vendor).get(value) + vendor = self.Session.get(model.Vendor, value) if not vendor: raise colander.Invalid(node, "Invalid vendor selection") @@ -495,7 +495,7 @@ class PurchasingBatchView(BatchMasterView): def eligible_purchases(self, vendor_uuid=None, mode=None): if not vendor_uuid: vendor_uuid = self.request.GET.get('vendor_uuid') - vendor = self.Session.query(model.Vendor).get(vendor_uuid) if vendor_uuid else None + vendor = self.Session.get(model.Vendor, vendor_uuid) if vendor_uuid else None if not vendor: return {'error': "Must specify a vendor."} @@ -572,7 +572,7 @@ class PurchasingBatchView(BatchMasterView): self.enum.PURCHASE_BATCH_MODE_COSTING): purchase = batch.purchase if not purchase and batch.purchase_uuid: - purchase = self.Session.query(model.Purchase).get(batch.purchase_uuid) + purchase = self.Session.get(model.Purchase, batch.purchase_uuid) assert purchase if purchase: kwargs['purchase'] = purchase diff --git a/tailbone/views/purchasing/costing.py b/tailbone/views/purchasing/costing.py index 45391fe0..d5c86908 100644 --- a/tailbone/views/purchasing/costing.py +++ b/tailbone/views/purchasing/costing.py @@ -207,7 +207,7 @@ class CostingBatchView(PurchasingBatchView): vendor_display = "" if self.request.method == 'POST': if self.request.POST.get('vendor'): - vendor = self.Session.query(model.Vendor).get(self.request.POST['vendor']) + vendor = self.Session.get(model.Vendor, self.request.POST['vendor']) if vendor: vendor_display = str(vendor) vendors_url = self.request.route_url('vendors.autocomplete') @@ -258,8 +258,8 @@ class CostingBatchView(PurchasingBatchView): if self.creating and workflow: # display vendor but do not allow changing - vendor = self.Session.query(model.Vendor).get( - self.request.matchdict['vendor_uuid']) + vendor = self.Session.get(model.Vendor, + self.request.matchdict['vendor_uuid']) assert vendor f.set_hidden('vendor_uuid') diff --git a/tailbone/views/purchasing/ordering.py b/tailbone/views/purchasing/ordering.py index a407d6ae..b0b00402 100644 --- a/tailbone/views/purchasing/ordering.py +++ b/tailbone/views/purchasing/ordering.py @@ -241,7 +241,7 @@ class OrderingBatchView(PurchasingBatchView): assert not (batch.executed or batch.complete) uuid = data.get('row_uuid') - row = self.Session.query(self.model_row_class).get(uuid) if uuid else None + row = self.Session.get(self.model_row_class, uuid) if uuid else None if not row: return {'error': "Row not found"} if row.batch is not batch or row.removed: @@ -401,7 +401,7 @@ class OrderingBatchView(PurchasingBatchView): return {'error': "Invalid value for units ordered: {}".format(units_ordered)} uuid = data.get('product_uuid') - product = self.Session.query(model.Product).get(uuid) if uuid else None + product = self.Session.get(model.Product, uuid) if uuid else None if not product: return {'error': "Product not found"} diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 4efe494c..b180a9a7 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -280,7 +280,7 @@ class ReceivingBatchView(PurchasingBatchView): # someone e.g. navigates to a URL by accident etc. we want # to gracefully handle and redirect uuid = self.request.matchdict['vendor_uuid'] - vendor = self.Session.query(model.Vendor).get(uuid) + vendor = self.Session.get(model.Vendor, uuid) if not vendor: self.request.session.flash("Invalid vendor selection. " "Please choose an existing vendor.", @@ -337,7 +337,7 @@ class ReceivingBatchView(PurchasingBatchView): vendor_display = "" if self.request.method == 'POST': if self.request.POST.get('vendor'): - vendor = self.Session.query(model.Vendor).get(self.request.POST['vendor']) + vendor = self.Session.get(model.Vendor, self.request.POST['vendor']) if vendor: vendor_display = str(vendor) vendors_url = self.request.route_url('vendors.autocomplete') @@ -417,8 +417,8 @@ class ReceivingBatchView(PurchasingBatchView): if self.creating and workflow: # display vendor but do not allow changing - vendor = self.Session.query(model.Vendor).get( - self.request.matchdict['vendor_uuid']) + vendor = self.Session.get(model.Vendor, + self.request.matchdict['vendor_uuid']) assert vendor f.set_readonly('vendor_uuid') f.set_default('vendor_uuid', str(vendor)) @@ -944,7 +944,7 @@ class ReceivingBatchView(PurchasingBatchView): def validate_purchase(node, kw): session = kw['session'] def validate(node, value): - purchase = session.query(model.Purchase).get(value) + purchase = session.get(model.Purchase, value) if not purchase: raise colander.Invalid(node, "Purchase not found") return purchase.uuid @@ -1439,7 +1439,7 @@ class ReceivingBatchView(PurchasingBatchView): credit = None uuid = data.get('uuid') if uuid: - credit = self.Session.query(model.PurchaseBatchCredit).get(uuid) + credit = self.Session.get(model.PurchaseBatchCredit, uuid) if not credit: return {'error': "Credit not found"} @@ -1479,7 +1479,7 @@ class ReceivingBatchView(PurchasingBatchView): batch = self.get_instance() row_uuid = self.request.params.get('row_uuid') - row = self.Session.query(model.PurchaseBatchRow).get(row_uuid) if row_uuid else None + row = self.Session.get(model.PurchaseBatchRow, row_uuid) if row_uuid else None if row and row.batch is batch and not row.removed: pass # we're good else: @@ -1841,7 +1841,7 @@ class ReceivingBatchView(PurchasingBatchView): # validate row uuid = data.get('row_uuid') - row = self.Session.query(model.PurchaseBatchRow).get(uuid) if uuid else None + row = self.Session.get(model.PurchaseBatchRow, uuid) if uuid else None if not row or row.batch is not batch: return {'error': "Row not found"} @@ -1910,7 +1910,7 @@ class ReceivingBatchView(PurchasingBatchView): Thread target for receiving all items on the given batch. """ session = RattailSession() - batch = session.query(model.PurchaseBatch).get(uuid) + batch = session.get(model.PurchaseBatch, uuid) # user = session.query(model.User).get(user_uuid) try: self.handler.auto_receive_all_items(batch, progress=progress) diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index a96ac52e..d3345b75 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -81,13 +81,13 @@ class OrderingWorksheet(View): def __call__(self): if self.request.params.get('vendor'): - vendor = Session.query(model.Vendor).get(self.request.params['vendor']) + vendor = Session.get(model.Vendor, self.request.params['vendor']) if vendor: departments = [] uuids = self.request.params.get('departments') if uuids: for uuid in uuids.split(','): - dept = Session.query(model.Department).get(uuid) + dept = Session.get(model.Department, uuid) if dept: departments.append(dept) preferred_only = self.request.params.get('preferred_only') == '1' @@ -495,7 +495,7 @@ class ReportOutputView(ExportMasterView): object. """ session = RattailSession() - user = session.query(model.User).get(user_uuid) + user = session.get(model.User, user_uuid) try: output = self.report_handler.generate_output(session, report, params, user, progress=progress) diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index 3d05f0a9..5677f579 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -218,7 +218,7 @@ class SettingView(MasterView): f.set_validator('name', self.unique_name) def unique_name(self, node, value): - setting = self.Session.query(model.Setting).get(value) + setting = self.Session.get(model.Setting, value) if setting: raise colander.Invalid(node, "Setting name must be unique") diff --git a/tailbone/views/shifts/lib.py b/tailbone/views/shifts/lib.py index 73d9603a..8cb75f33 100644 --- a/tailbone/views/shifts/lib.py +++ b/tailbone/views/shifts/lib.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,11 +24,8 @@ Base views for time sheets """ -from __future__ import unicode_literals, absolute_import - import datetime -import six import sqlalchemy as sa from rattail import enum @@ -105,10 +102,10 @@ class TimeSheetView(View): if store_key in self.request.session or department_key in self.request.session: store_uuid = self.request.session.get(store_key) if store_uuid: - store = Session.query(model.Store).get(store_uuid) if store_uuid else None + store = Session.get(model.Store, store_uuid) if store_uuid else None department_uuid = self.request.session.get(department_key) if department_uuid: - department = Session.query(model.Department).get(department_uuid) + department = Session.get(model.Department, department_uuid) else: # no store/department in session if self.default_filter_store: store = self.rattail_config.get('rattail', 'store') @@ -151,7 +148,7 @@ class TimeSheetView(View): employee_key = 'timesheet.{}.employee'.format(self.key) if employee_key in self.request.session: employee_uuid = self.request.session[employee_key] - employee = Session.query(model.Employee).get(employee_uuid) if employee_uuid else None + employee = Session.get(model.Employee, employee_uuid) if employee_uuid else None if not employee: employee = self.request.user.employee @@ -238,7 +235,7 @@ class TimeSheetView(View): form = forms.Form(schema=EmployeeShiftFilter(), request=self.request) if self.request.has_perm('{}.viewall'.format(permission_prefix)): - employee_display = six.text_type(context['employee'] or '') + employee_display = str(context['employee'] or '') employees_url = self.request.route_url('employees.autocomplete') form.set_widget('employee', forms.widgets.JQueryAutocompleteWidget( field_display=employee_display, service_url=employees_url)) @@ -470,7 +467,7 @@ class TimeSheetView(View): if hours_style == 'pretty': display = pretty_hours(hours) else: # decimal - display = six.text_type(hours_as_decimal(hours)) + display = str(hours_as_decimal(hours)) if empday['hours_incomplete']: display = '{} ?'.format(display) empday['{}_hours_display'.format(shift_type)] = display @@ -481,7 +478,7 @@ class TimeSheetView(View): if hours_style == 'pretty': display = pretty_hours(hours) else: # decimal - display = six.text_type(hours_as_decimal(hours)) + display = str(hours_as_decimal(hours)) if hours_incomplete: display = '{} ?'.format(display) setattr(employee, '{}_hours_display'.format(shift_type), display) diff --git a/tailbone/views/shifts/schedule.py b/tailbone/views/shifts/schedule.py index 7a1ccbe5..c8b82724 100644 --- a/tailbone/views/shifts/schedule.py +++ b/tailbone/views/shifts/schedule.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,6 @@ Views for employee schedules """ -from __future__ import unicode_literals, absolute_import - import datetime from rattail.db import model @@ -81,7 +79,7 @@ class ScheduleView(TimeSheetView): deleted = [] for uuid, value in data['delete'].items(): if value == 'delete': - shift = Session.query(model.ScheduledShift).get(uuid) + shift = Session.get(model.ScheduledShift, uuid) if shift: Session.delete(shift) deleted.append(uuid) @@ -103,7 +101,7 @@ class ScheduleView(TimeSheetView): Session.add(shift) created[uuid] = shift else: - shift = Session.query(model.ScheduledShift).get(uuid) + shift = Session.get(model.ScheduledShift, uuid) assert shift updated[uuid] = shift start_time = datetime.datetime.strptime(data['start_time'][uuid], time_format) diff --git a/tailbone/views/shifts/timesheet.py b/tailbone/views/shifts/timesheet.py index 9898cd04..a8874127 100644 --- a/tailbone/views/shifts/timesheet.py +++ b/tailbone/views/shifts/timesheet.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,6 @@ Views for employee time sheets """ -from __future__ import unicode_literals, absolute_import - import datetime from rattail.db import model @@ -74,7 +72,7 @@ class TimeSheetView(BaseTimeSheetView): deleted = [] for uuid, value in list(data['delete'].items()): assert value == 'delete' - shift = Session.query(model.WorkedShift).get(uuid) + shift = Session.get(model.WorkedShift, uuid) assert shift Session.delete(shift) deleted.append(uuid) @@ -93,7 +91,7 @@ class TimeSheetView(BaseTimeSheetView): Session.add(shift) created[uuid] = shift else: - shift = Session.query(model.WorkedShift).get(uuid) + shift = Session.get(model.WorkedShift, uuid) assert shift updated[uuid] = shift diff --git a/tailbone/views/tempmon/dashboard.py b/tailbone/views/tempmon/dashboard.py index 1cf40617..f4d6ed16 100644 --- a/tailbone/views/tempmon/dashboard.py +++ b/tailbone/views/tempmon/dashboard.py @@ -45,7 +45,7 @@ class TempmonDashboardView(View): appliance = None uuid = self.request.POST.get('appliance_uuid') if uuid: - appliance = TempmonSession.query(tempmon.Appliance).get(uuid) + appliance = TempmonSession.get(tempmon.Appliance, uuid) if appliance: self.request.session[self.session_key] = appliance.uuid if not appliance: @@ -91,7 +91,7 @@ class TempmonDashboardView(View): uuid = self.request.params.get('appliance_uuid') if not uuid: return {'error': "Must specify valid appliance_uuid"} - appliance = TempmonSession.query(tempmon.Appliance).get(uuid) + appliance = TempmonSession.get(tempmon.Appliance, uuid) if not appliance: return {'error': "Must specify valid appliance_uuid"} diff --git a/tailbone/views/users.py b/tailbone/views/users.py index 31842d0b..4f3a0070 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,9 +24,6 @@ User Views """ -from __future__ import unicode_literals, absolute_import - -import six import sqlalchemy as sa from sqlalchemy import orm @@ -168,7 +165,7 @@ class UserView(PrincipalMasterView): """ if value: model = self.model - person = self.Session.query(model.Person).get(value) + person = self.Session.get(model.Person, value) if not person: raise colander.Invalid(node, "Person not found (you must *select* a record)") @@ -189,11 +186,11 @@ class UserView(PrincipalMasterView): person_display = "" if self.request.method == 'POST': if self.request.POST.get('person_uuid'): - person = self.Session.query(model.Person).get(self.request.POST['person_uuid']) + person = self.Session.get(model.Person, self.request.POST['person_uuid']) if person: - person_display = six.text_type(person) + person_display = str(person) elif self.editing: - person_display = six.text_type(user.person or '') + person_display = str(user.person or '') people_url = self.request.route_url('people.autocomplete') f.set_widget('person_uuid', forms.widgets.JQueryAutocompleteWidget( field_display=person_display, service_url=people_url)) @@ -224,7 +221,7 @@ class UserView(PrincipalMasterView): f.remove_field('roles') else: roles = self.get_possible_roles().all() - role_values = [(s.uuid, six.text_type(s)) for s in roles] + role_values = [(s.uuid, str(s)) for s in roles] f.set_node('roles', colander.Set()) size = len(roles) if size < 3: @@ -358,7 +355,7 @@ class UserView(PrincipalMasterView): for uuid in old_roles: if uuid not in new_roles: if self.request.is_root or uuid != admin.uuid: - role = self.Session.query(model.Role).get(uuid) + role = self.Session.get(model.Role, uuid) user.roles.remove(role) # also record a change to the role, for datasync. @@ -373,7 +370,7 @@ class UserView(PrincipalMasterView): person = user.person if not person: return "" - text = six.text_type(person) + text = str(person) url = self.request.route_url('people.view', uuid=person.uuid) return tags.link_to(person, url) @@ -383,7 +380,7 @@ class UserView(PrincipalMasterView): name = getattr(user, field[:-1], None) if not name: return "" - return six.text_type(name) + return str(name) def render_roles(self, user, field): roles = sorted(user.roles, key=lambda r: r.name) From f1496c771ea0e3170e0ec10718d72eaad9af2c31 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 12 Feb 2023 09:29:21 -0600 Subject: [PATCH 1031/1681] Stop running tests for python 3.5; do run for 3.6, 3.9 --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 27ae213e..70767e56 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] -envlist = py35, py37 +envlist = py36, py37, py39 [testenv] commands = From b434fa108d2e5c65054380183130d168a5ae63f5 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 12 Feb 2023 09:34:38 -0600 Subject: [PATCH 1032/1681] More refactoring, `Query.get()` => `Session.get()` --- tailbone/views/custorders/batch.py | 24 ++++++++++-------------- tailbone/views/tempmon/dashboard.py | 3 +-- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/tailbone/views/custorders/batch.py b/tailbone/views/custorders/batch.py index 26ac5cde..38d2eda7 100644 --- a/tailbone/views/custorders/batch.py +++ b/tailbone/views/custorders/batch.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2021 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Base class for customer order batch views """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model import colander @@ -152,12 +148,12 @@ class CustomerOrderBatchView(BatchMasterView): customer_display = "" if self.request.method == 'POST': if self.request.POST.get('customer_uuid'): - customer = self.Session.query(model.Customer)\ - .get(self.request.POST['customer_uuid']) + customer = self.Session.get(model.Customer, + self.request.POST['customer_uuid']) if customer: - customer_display = six.text_type(customer) + customer_display = str(customer) elif self.editing: - customer_display = six.text_type(order.customer or "") + customer_display = str(order.customer or "") customers_url = self.request.route_url('customers.autocomplete') f.set_widget('customer_uuid', forms.widgets.JQueryAutocompleteWidget( field_display=customer_display, service_url=customers_url)) @@ -172,12 +168,12 @@ class CustomerOrderBatchView(BatchMasterView): person_display = "" if self.request.method == 'POST': if self.request.POST.get('person_uuid'): - person = self.Session.query(model.Person)\ - .get(self.request.POST['person_uuid']) + person = self.Session.get(model.Person, + self.request.POST['person_uuid']) if person: - person_display = six.text_type(person) + person_display = str(person) elif self.editing: - person_display = six.text_type(order.person or "") + person_display = str(order.person or "") people_url = self.request.route_url('people.autocomplete') f.set_widget('person_uuid', forms.widgets.JQueryAutocompleteWidget( field_display=person_display, service_url=people_url)) @@ -194,7 +190,7 @@ class CustomerOrderBatchView(BatchMasterView): pending = batch.pending_customer if not pending: return - text = six.text_type(pending) + text = str(pending) url = self.request.route_url('pending_customers.view', uuid=pending.uuid) return tags.link_to(text, url) diff --git a/tailbone/views/tempmon/dashboard.py b/tailbone/views/tempmon/dashboard.py index f4d6ed16..515eabc9 100644 --- a/tailbone/views/tempmon/dashboard.py +++ b/tailbone/views/tempmon/dashboard.py @@ -66,8 +66,7 @@ class TempmonDashboardView(View): self.request.session[self.session_key] = selected_uuid if not selected_appliance and selected_uuid: - selected_appliance = TempmonSession.query(tempmon.Appliance)\ - .get(selected_uuid) + selected_appliance = TempmonSession.get(tempmon.Appliance, selected_uuid) context = { 'index_url': self.request.route_url('tempmon.appliances'), From ac57ddbb164c7f89ddbc9475c171e0b9d52114d9 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 12 Feb 2023 10:04:27 -0600 Subject: [PATCH 1033/1681] 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 53733dae..2646de22 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.9.6 (2023-02-12) +------------------ + +* Refactor ``Query.get()`` => ``Session.get()`` per SQLAlchemy 1.4. + + 0.9.5 (2023-02-11) ------------------ diff --git a/tailbone/_version.py b/tailbone/_version.py index 82db9101..1420ad2f 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.5' +__version__ = '0.9.6' From 7b2faf90f2a393a068bd16221d0cea3c619ca88c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 13 Feb 2023 20:29:27 -0600 Subject: [PATCH 1034/1681] Add dedicated view config methods for "view" and "edit help" so they can be invoked explicitly from elsewhere, keeping same logic cf. Catapult Worksheets --- tailbone/views/master.py | 152 +++++++++++++++++++++++++++------------ 1 file changed, 106 insertions(+), 46 deletions(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index e9f35687..4f0411ac 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -4906,21 +4906,7 @@ class MasterView(View): default=True) # edit help info - config.add_tailbone_permission(permission_prefix, - '{}.edit_help'.format(permission_prefix), - "Edit help info for {}".format(model_title_plural)) - config.add_route('{}.edit_help'.format(route_prefix), - '{}/edit-help'.format(url_prefix), - request_method='POST') - 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') + cls._defaults_edit_help(config) # list/search if cls.listable: @@ -5096,37 +5082,7 @@ class MasterView(View): # view if cls.viewable: - config.add_tailbone_permission(permission_prefix, '{}.view'.format(permission_prefix), - "View details for {}".format(model_title)) - if cls.has_pk_fields: - config.add_tailbone_permission(permission_prefix, '{}.view_pk_fields'.format(permission_prefix), - "View all PK-type fields for {}".format(model_title_plural)) - if cls.secure_global_objects: - config.add_tailbone_permission(permission_prefix, '{}.view_global'.format(permission_prefix), - "View *global* {}".format(model_title_plural)) - - # view by grid index - config.add_route('{}.view_index'.format(route_prefix), '{}/view'.format(url_prefix)) - config.add_view(cls, attr='view_index', route_name='{}.view_index'.format(route_prefix), - permission='{}.view'.format(permission_prefix)) - - # view by record key - config.add_route('{}.view'.format(route_prefix), instance_url_prefix) - kwargs = {'http_cache': 0} if prevent_cache and cls.has_rows else {} - config.add_view(cls, attr='view', route_name='{}.view'.format(route_prefix), - permission='{}.view'.format(permission_prefix), - **kwargs) - - # version history - if cls.has_versions and rattail_config and rattail_config.versioning_enabled(): - config.add_tailbone_permission(permission_prefix, '{}.versions'.format(permission_prefix), - "View version history for {}".format(model_title)) - config.add_route('{}.versions'.format(route_prefix), '{}/versions/'.format(instance_url_prefix)) - config.add_view(cls, attr='versions', route_name='{}.versions'.format(route_prefix), - permission='{}.versions'.format(permission_prefix)) - config.add_route('{}.version'.format(route_prefix), '{}/versions/{{txnid}}'.format(instance_url_prefix)) - config.add_view(cls, attr='view_version', route_name='{}.version'.format(route_prefix), - permission='{}.versions'.format(permission_prefix)) + cls._defaults_view(config) # image if cls.has_image: @@ -5272,6 +5228,110 @@ class MasterView(View): config.add_view(cls, attr='delete_row', route_name='{}.delete_row'.format(route_prefix), permission='{}.delete_row'.format(permission_prefix)) + @classmethod + def _defaults_view(cls, config, **kwargs): + """ + Provide default "view" configuration, i.e. for "viewable" things. + """ + rattail_config = config.registry.settings.get('rattail_config') + route_prefix = cls.get_route_prefix() + url_prefix = cls.get_url_prefix() + instance_url_prefix = cls.get_instance_url_prefix() + model_title = cls.get_model_title() + model_title_plural = cls.get_model_title_plural() + + # on windows/chrome we are seeing some caching when e.g. user + # applies some filters, then views a record, then clicks back + # button, filters no longer are applied. so by default we + # instruct browser to never cache certain pages which contain + # a grid. at this point only /index and /view + # cf. https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/viewconfig.html#non-predicate-arguments + prevent_cache = rattail_config.getbool('tailbone', + 'prevent_cache_for_index_views', + default=True) + + # nb. if caller specifies permission prefix, it's assumed they + # have registered it elsewhere + if 'permission_prefix' in kwargs: + permission_prefix = kwargs['permission_prefix'] + else: + permission_prefix = cls.get_permission_prefix() + config.add_tailbone_permission(permission_prefix, + '{}.view'.format(permission_prefix), + "View details for {}".format(model_title)) + + if cls.has_pk_fields: + config.add_tailbone_permission(permission_prefix, + '{}.view_pk_fields'.format(permission_prefix), + "View all PK-type fields for {}".format(model_title_plural)) + if cls.secure_global_objects: + config.add_tailbone_permission(permission_prefix, + '{}.view_global'.format(permission_prefix), + "View *global* {}".format(model_title_plural)) + + # view by grid index + config.add_route('{}.view_index'.format(route_prefix), + '{}/view'.format(url_prefix)) + config.add_view(cls, attr='view_index', + route_name='{}.view_index'.format(route_prefix), + permission='{}.view'.format(permission_prefix)) + + # view by record key + config.add_route('{}.view'.format(route_prefix), + instance_url_prefix) + kwargs = {'http_cache': 0} if prevent_cache and cls.has_rows else {} + config.add_view(cls, attr='view', route_name='{}.view'.format(route_prefix), + permission='{}.view'.format(permission_prefix), + **kwargs) + + # version history + if cls.has_versions and rattail_config and rattail_config.versioning_enabled(): + config.add_tailbone_permission(permission_prefix, + '{}.versions'.format(permission_prefix), + "View version history for {}".format(model_title)) + config.add_route('{}.versions'.format(route_prefix), + '{}/versions/'.format(instance_url_prefix)) + config.add_view(cls, attr='versions', + route_name='{}.versions'.format(route_prefix), + permission='{}.versions'.format(permission_prefix)) + config.add_route('{}.version'.format(route_prefix), + '{}/versions/{{txnid}}'.format(instance_url_prefix)) + config.add_view(cls, attr='view_version', + route_name='{}.version'.format(route_prefix), + permission='{}.versions'.format(permission_prefix)) + + @classmethod + def _defaults_edit_help(cls, config, **kwargs): + route_prefix = cls.get_route_prefix() + url_prefix = cls.get_url_prefix() + model_title_plural = cls.get_model_title_plural() + + # nb. if caller specifies permission prefix, it's assumed they + # have registered it elsewhere + if 'permission_prefix' in kwargs: + permission_prefix = kwargs['permission_prefix'] + else: + permission_prefix = cls.get_permission_prefix() + config.add_tailbone_permission(permission_prefix, + '{}.edit_help'.format(permission_prefix), + "Edit help info for {}".format(model_title_plural)) + + # edit page help + config.add_route('{}.edit_help'.format(route_prefix), + '{}/edit-help'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='edit_help', + route_name='{}.edit_help'.format(route_prefix), + renderer='json') + + # edit field help + 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') + class ViewSupplement(object): """ From 539f4a5c311ef7a1f526766ccf6dd3d1154407e6 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 14 Feb 2023 16:07:23 -0600 Subject: [PATCH 1035/1681] 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 2646de22..e41df9a5 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.9.7 (2023-02-14) +------------------ + +* Add dedicated view config methods for "view" and "edit help". + + 0.9.6 (2023-02-12) ------------------ diff --git a/tailbone/_version.py b/tailbone/_version.py index 1420ad2f..fd713f2d 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.6' +__version__ = '0.9.7' From ad4ec41e153eac67920a6fc83acbb484ea8b1a48 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 14 Feb 2023 17:32:04 -0600 Subject: [PATCH 1036/1681] Make `config` param more explicit, for GridFilter constructor i.e. the rattail config object --- tailbone/grids/core.py | 21 ++++++++++----------- tailbone/grids/filters.py | 17 ++++++++--------- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 59ab6018..f1f00904 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -24,12 +24,9 @@ Core Grid Classes """ -from __future__ import unicode_literals, absolute_import - import warnings import logging -import six from six.moves import urllib import sqlalchemy as sa from sqlalchemy import orm @@ -493,8 +490,8 @@ class Grid(object): return "" enum = self.enums.get(column_name) if enum and value in enum: - return six.text_type(enum[value]) - return six.text_type(value) + return str(enum[value]) + return str(value) def render_gpc(self, obj, column_name): value = self.obtain_value(obj, column_name) @@ -687,14 +684,16 @@ class Grid(object): factory = gridfilters.AlchemyDateTimeFilter elif isinstance(column.type, GPCType): factory = gridfilters.AlchemyGPCFilter + kwargs['column'] = column + kwargs.setdefault('config', self.request.rattail_config) kwargs.setdefault('encode_values', self.use_byte_string_filters) - return factory(key, column=column, config=self.request.rattail_config, **kwargs) + return factory(key, **kwargs) def iter_filters(self): """ Iterate over all filters available to the grid. """ - return six.itervalues(self.filters) + return self.filters.values() def iter_active_filters(self): """ @@ -1002,7 +1001,7 @@ class Grid(object): else: # source = session settings['{}.active'.format(prefix)] = self.get_setting( source, settings, '{}.active'.format(prefix), - normalize=lambda v: six.text_type(v).lower() == 'true', default=False) + normalize=lambda v: str(v).lower() == 'true', default=False) settings['{}.verb'.format(prefix)] = self.get_setting( source, settings, '{}.verb'.format(prefix), default='') settings['{}.value'.format(prefix)] = self.get_setting( @@ -1071,7 +1070,7 @@ class Grid(object): if self.filterable: for filtr in self.iter_filters(): - persist('filter.{}.active'.format(filtr.key), value=lambda k: six.text_type(settings[k]).lower()) + persist('filter.{}.active'.format(filtr.key), value=lambda k: str(settings[k]).lower()) persist('filter.{}.verb'.format(filtr.key)) persist('filter.{}.value'.format(filtr.key)) @@ -1305,7 +1304,7 @@ class Grid(object): 'multiple_value_verbs': multiple_values, 'verb_labels': filtr.verb_labels, 'verb': filtr.verb or filtr.default_verb or filtr.verbs[0], - 'value': six.text_type(filtr.value) if filtr.value is not None else "", + 'value': str(filtr.value) if filtr.value is not None else "", 'data_type': filtr.data_type, 'choices': choices, 'choice_labels': choice_labels, @@ -1478,7 +1477,7 @@ class Grid(object): value = self.obtain_value(rowobj, name) if value is None: value = "" - row[name] = six.text_type(value) + row[name] = str(value) # maybe add UUID for convenience if 'uuid' not in self.columns: diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py index 2818b78a..695326fd 100644 --- a/tailbone/grids/filters.py +++ b/tailbone/grids/filters.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,13 +24,10 @@ Grid Filters """ -from __future__ import unicode_literals, absolute_import - import re import datetime import logging -import six import sqlalchemy as sa from rattail.gpc import GPC @@ -117,7 +114,7 @@ class EnumValueRenderer(ChoiceValueRenderer): sorted_keys = list(enum.keys()) else: sorted_keys = sorted(enum, key=lambda k: enum[k].lower()) - self.options = [tags.Option(enum[k], six.text_type(k)) for k in sorted_keys] + self.options = [tags.Option(enum[k], str(k)) for k in sorted_keys] class GridFilter(object): @@ -173,10 +170,12 @@ class GridFilter(object): data_type = 'string' # default, but will be set from value renderer choices = {} - def __init__(self, key, label=None, verbs=None, value_enum=None, value_renderer=None, + def __init__(self, key, config=None, label=None, verbs=None, + value_enum=None, value_renderer=None, default_active=False, default_verb=None, default_value=None, encode_values=False, value_encoding='utf-8', **kwargs): self.key = key + self.config = config self.label = label or prettify(key) self.verbs = verbs or self.get_default_verbs() if value_renderer: @@ -279,7 +278,7 @@ class GridFilter(object): return value if value is not UNSPECIFIED else self.value def encode_value(self, value): - if self.encode_values and isinstance(value, six.string_types): + if self.encode_values and isinstance(value, str): return value.encode('utf-8') return value @@ -536,7 +535,7 @@ class AlchemyByteStringFilter(AlchemyStringFilter): def get_value(self, value=UNSPECIFIED): value = super(AlchemyByteStringFilter, self).get_value(value) - if isinstance(value, six.text_type): + if isinstance(value, str): value = value.encode(self.value_encoding) return value @@ -590,7 +589,7 @@ class AlchemyNumericFilter(AlchemyGridFilter): except ValueError: return True - return bool(value and len(six.text_type(value)) > 8) + return bool(value and len(str(value)) > 8) def filter_equal(self, query, value): if self.value_invalid(value): From 2fa62acbbd9f49601cb5091c3a8d403281a63a9a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 20 Feb 2023 21:50:44 -0600 Subject: [PATCH 1037/1681] 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 e41df9a5..e9e6317d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.9.8 (2023-02-20) +------------------ + +* Make ``config`` param more explicit, for GridFilter constructor. + + 0.9.7 (2023-02-14) ------------------ diff --git a/tailbone/_version.py b/tailbone/_version.py index fd713f2d..75d8babf 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.7' +__version__ = '0.9.8' From d1fc5d5c382ac6351ca38d9c88a736d7ac204906 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 21 Feb 2023 17:35:47 -0600 Subject: [PATCH 1038/1681] Validate vendor for catalog batch upload --- tailbone/views/batch/vendorcatalog.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tailbone/views/batch/vendorcatalog.py b/tailbone/views/batch/vendorcatalog.py index 01fa85ea..1bd5eed7 100644 --- a/tailbone/views/batch/vendorcatalog.py +++ b/tailbone/views/batch/vendorcatalog.py @@ -202,6 +202,8 @@ class VendorCatalogView(FileBatchMasterView): if self.creating and 'vendor' in f: f.replace('vendor', 'vendor_uuid') f.set_label('vendor_uuid', "Vendor") + f.set_required('vendor_uuid') + f.set_validator('vendor_uuid', self.valid_vendor_uuid) # should we use dropdown or autocomplete? note that if # autocomplete is to be used, we also must make sure we @@ -258,6 +260,13 @@ class VendorCatalogView(FileBatchMasterView): else: f.remove('cache_products') + def valid_vendor_uuid(self, node, value): + model = self.model + if value: + vendor = self.Session.get(model.Vendor, value) + if not vendor: + raise colander.Invalid(node, "Vendor not found") + def render_parser_key(self, batch, field): key = getattr(batch, field) if not key: From e77650c997d12193b2cdd9474813b2d3811be702 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 21 Feb 2023 19:14:19 -0600 Subject: [PATCH 1039/1681] 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 e9e6317d..9f210884 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.9.9 (2023-02-21) +------------------ + +* Validate vendor for catalog batch upload. + + 0.9.8 (2023-02-20) ------------------ diff --git a/tailbone/_version.py b/tailbone/_version.py index 75d8babf..1c6ba0d4 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.8' +__version__ = '0.9.9' From 743a2ccd075d8c8d1f440ed8b3bfaf0addce66f7 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 22 Feb 2023 22:00:05 -0600 Subject: [PATCH 1040/1681] Add views for sample vendor files --- tailbone/forms/widgets.py | 89 +++++++++++++++ tailbone/menus.py | 6 + tailbone/views/vendors/__init__.py | 5 +- tailbone/views/vendors/samplefiles.py | 157 ++++++++++++++++++++++++++ 4 files changed, 254 insertions(+), 3 deletions(-) create mode 100644 tailbone/views/vendors/samplefiles.py diff --git a/tailbone/forms/widgets.py b/tailbone/forms/widgets.py index 28b24678..f672ab47 100644 --- a/tailbone/forms/widgets.py +++ b/tailbone/forms/widgets.py @@ -472,3 +472,92 @@ class DepartmentWidget(dfwidget.SelectWidget): kwargs['values'] = values super(DepartmentWidget, self).__init__(**kwargs) + + +def make_vendor_widget(request, **kwargs): + """ + Make a vendor widget; will be either autocomplete or dropdown + depending on config. + """ + # use autocomplete widget by default + factory = VendorAutocompleteWidget + + # caller may request dropdown widget + if kwargs.pop('dropdown', False): + factory = VendorDropdownWidget + + else: # or, config may say to use dropdown + app = request.rattail_config.get_app() + vendor_handler = app.get_vendor_handler() + if vendor_handler.choice_uses_dropdown(): + factory = VendorDropdownWidget + + # instantiate whichever + return factory(request, **kwargs) + + +class VendorAutocompleteWidget(JQueryAutocompleteWidget): + """ + Autocomplete widget for a Vendor reference field. + """ + + def __init__(self, request, *args, **kwargs): + super(VendorAutocompleteWidget, 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('vendors.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 vendor to provide button label, if we have a value + if cstruct: + model = self.request.rattail_config.get_model() + vendor = Session.get(model.Vendor, cstruct) + if vendor: + self.field_display = str(vendor) + + return super(VendorAutocompleteWidget, self).serialize( + field, cstruct, **kw) + + +class VendorDropdownWidget(dfwidget.SelectWidget): + """ + Dropdown widget for a Vendor reference field. + """ + + def __init__(self, request, *args, **kwargs): + super(VendorDropdownWidget, 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 'vendors' in kwargs: + vendors = kwargs['vendors'] + if callable(vendors): + vendors = vendors() + + else: # default vendor list + model = self.request.rattail_config.get_model() + vendors = Session.query(model.Vendor)\ + .order_by(model.Vendor.name)\ + .all() + + # convert vendor list to option values + self.values = [(c.uuid, c.name) + for c in vendors] diff --git a/tailbone/menus.py b/tailbone/menus.py index 1732c084..98006c00 100644 --- a/tailbone/menus.py +++ b/tailbone/menus.py @@ -464,6 +464,12 @@ class MenuHandler(GenericHandler): 'route': 'vendorcatalogs', 'perm': 'vendorcatalogs.list', }, + {'type': 'sep'}, + { + 'title': "Sample Files", + 'route': 'vendorsamplefiles', + 'perm': 'vendorsamplefiles.list', + }, ], } diff --git a/tailbone/views/vendors/__init__.py b/tailbone/views/vendors/__init__.py index 7d35780e..210df39e 100644 --- a/tailbone/views/vendors/__init__.py +++ b/tailbone/views/vendors/__init__.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,6 @@ Views pertaining to vendors """ -from __future__ import unicode_literals, absolute_import - from .core import VendorView @@ -36,3 +34,4 @@ def defaults(config, **kwargs): def includeme(config): config.include('tailbone.views.vendors.core') + config.include('tailbone.views.vendors.samplefiles') diff --git a/tailbone/views/vendors/samplefiles.py b/tailbone/views/vendors/samplefiles.py new file mode 100644 index 00000000..41982259 --- /dev/null +++ b/tailbone/views/vendors/samplefiles.py @@ -0,0 +1,157 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2023 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Model View for Vendor Sample Files +""" + +from rattail.db.model import VendorSampleFile + +from webhelpers2.html import tags + +from tailbone import forms +from tailbone.views import MasterView + + +class VendorSampleFileView(MasterView): + """ + Master model view for Vendor Sample Files + """ + model_class = VendorSampleFile + route_prefix = 'vendorsamplefiles' + url_prefix = '/vendors/sample-files' + downloadable = True + has_versions = True + + grid_columns = [ + 'vendor', + 'file_type', + 'effective_date', + 'filename', + 'created_by', + ] + + form_fields = [ + 'vendor', + 'file_type', + 'filename', + 'effective_date', + 'notes', + 'created_by', + ] + + def configure_grid(self, g): + super(VendorSampleFileView, self).configure_grid(g) + + # vendor + g.set_link('vendor') + + # filename + g.set_link('filename') + + # effective_date + g.set_sort_defaults('effective_date', 'desc') + + def configure_form(self, f): + super(VendorSampleFileView, self).configure_form(f) + + # vendor + f.set_renderer('vendor', self.render_vendor) + if self.creating: + f.replace('vendor', 'vendor_uuid') + f.set_label('vendor_uuid', "Vendor") + f.set_widget('vendor_uuid', + forms.widgets.make_vendor_widget(self.request)) + else: + f.set_readonly('vendor') + + # filename + if self.creating: + f.replace('filename', 'file') + f.set_type('file', 'file') + else: + f.set_readonly('filename') + f.set_renderer('filename', self.render_filename) + + # effective_date + f.set_type('effective_date', 'date_jquery') + + # notes + f.set_type('notes', 'text') + + # created_by + if self.creating or self.editing: + f.remove('created_by') + else: + f.set_readonly('created_by') + f.set_renderer('created_by', self.render_user) + + def objectify(self, form, data=None): + if data is None: + data = form.validated + + sample = super(VendorSampleFileView, self).objectify(form, data=data) + + if self.creating: + sample.filename = data['file']['filename'] + data['file']['fp'].seek(0) + sample.bytes = data['file']['fp'].read() + sample.created_by = self.request.user + + return sample + + def render_filename(self, sample, field): + filename = getattr(sample, field) + if not filename: + return + + size = self.readable_size(None, size=len(sample.bytes)) + text = "{} ({})".format(filename, size) + url = self.get_action_url('download', sample) + return tags.link_to(text, url) + + def download(self): + """ + View for downloading a sample file. + + We override default logic to send raw bytes from DB, and avoid + writing file to disk. + """ + sample = self.get_instance() + + response = self.request.response + response.content_length = len(sample.bytes) + response.content_disposition = 'attachment; filename="{}"'.format( + sample.filename) + response.body = sample.bytes + return response + + +def defaults(config, **kwargs): + base = globals() + + VendorSampleFileView = kwargs.get('VendorSampleFileView', base['VendorSampleFileView']) + VendorSampleFileView.defaults(config) + + +def includeme(config): + defaults(config) From cf7e3c23025ebe35fb4f88fd84a33ae230a77fb5 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 22 Feb 2023 22:00:36 -0600 Subject: [PATCH 1041/1681] 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 9f210884..fbe2ac03 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.9.10 (2023-02-22) +------------------- + +* Add views for sample vendor files. + + 0.9.9 (2023-02-21) ------------------ diff --git a/tailbone/_version.py b/tailbone/_version.py index 1c6ba0d4..274ef7a4 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.9' +__version__ = '0.9.10' From a81e121ffd53d7d1ee0a5be30d0e9c886fbd1417 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 22 Feb 2023 22:41:12 -0600 Subject: [PATCH 1042/1681] Allow sort/filter by vendor for sample files grid --- tailbone/views/vendors/samplefiles.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tailbone/views/vendors/samplefiles.py b/tailbone/views/vendors/samplefiles.py index 41982259..a75bc1fb 100644 --- a/tailbone/views/vendors/samplefiles.py +++ b/tailbone/views/vendors/samplefiles.py @@ -61,8 +61,13 @@ class VendorSampleFileView(MasterView): def configure_grid(self, g): super(VendorSampleFileView, self).configure_grid(g) + model = self.model # vendor + g.set_joiner('vendor', lambda q: q.join(model.Vendor)) + g.set_sorter('vendor', model.Vendor.name) + g.set_filter('vendor', model.Vendor.name, + default_active=True, default_verb='contains') g.set_link('vendor') # filename From 01af73502a72c6c9aff471120b9883b67ee27fdd Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 24 Feb 2023 20:04:14 -0600 Subject: [PATCH 1043/1681] 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 fbe2ac03..4c2025d6 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.9.11 (2023-02-24) +------------------- + +* Allow sort/filter by vendor for sample files grid. + + 0.9.10 (2023-02-22) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 274ef7a4..915c3b59 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.10' +__version__ = '0.9.11' From ad311e9e7e3de418ffc100389b4cd37db15b37b3 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 28 Feb 2023 14:30:25 -0600 Subject: [PATCH 1044/1681] Add "equal to any of" verb for string-type grid filters --- tailbone/grids/filters.py | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py index 695326fd..e4b522f5 100644 --- a/tailbone/grids/filters.py +++ b/tailbone/grids/filters.py @@ -331,6 +331,38 @@ class AlchemyGridFilter(GridFilter): self.column != self.encode_value(value), )) + def filter_equal_any_of(self, query, value): + """ + This filter expects "multiple values" separated by newline + character, and will add an "OR" condition with each value + being checked separately. For instance if the user submits a + "value" like this: + + .. code-block:: none + + foo bar + baz + + This will result in SQL condition like this: + + .. code-block:: sql + + name = 'foo bar' OR name = 'baz' + """ + if not value: + return query + + values = value.split('\n') + values = [value for value in values if value] + if not values: + return query + + conditions = [] + for value in values: + conditions.append(self.column == self.encode_value(value)) + + return query.filter(sa.or_(*conditions)) + def filter_is_null(self, query, value): """ Filter data with an 'IS NULL' query. Note that this filter does not @@ -430,7 +462,7 @@ class AlchemyStringFilter(AlchemyGridFilter): """ return ['contains', 'does_not_contain', 'contains_any_of', - 'equal', 'not_equal', + 'equal', 'not_equal', 'equal_any_of', 'is_empty', 'is_not_empty', 'is_null', 'is_not_null', 'is_empty_or_null', From e8f235e4f792994941f4aabe9c3428573fb3c026 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 28 Feb 2023 15:05:38 -0600 Subject: [PATCH 1045/1681] Allow download results for Trainwreck just basic transaction headers so far.. --- tailbone/views/trainwreck/base.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/tailbone/views/trainwreck/base.py b/tailbone/views/trainwreck/base.py index 82a0b2b1..8ac243a0 100644 --- a/tailbone/views/trainwreck/base.py +++ b/tailbone/views/trainwreck/base.py @@ -24,8 +24,6 @@ Trainwreck views """ -from rattail.time import localtime - from webhelpers2.html import HTML, tags from tailbone.db import Session, TrainwreckSession, ExtraTrainwreckSessions @@ -44,6 +42,7 @@ class TransactionView(MasterView): creatable = False editable = False deletable = False + results_downloadable = True supports_multiple_engines = True engine_type_key = 'trainwreck' @@ -152,14 +151,29 @@ class TransactionView(MasterView): trainwreck_handler = app.get_trainwreck_handler() return trainwreck_handler.get_trainwreck_engines(include_hidden=False) + def make_isolated_session(self): + from rattail.trainwreck.db import Session as TrainwreckSession + + dbkey = self.get_current_engine_dbkey() + if dbkey != 'default': + app = self.get_rattail_app() + trainwreck_handler = app.get_trainwreck_handler() + trainwreck_engines = trainwreck_handler.get_trainwreck_engines() + if dbkey in trainwreck_engines: + return TrainwreckSesssion(bind=trainwreck_engines[dbkey]) + + return TrainwreckSession() + def configure_grid(self, g): super(TransactionView, self).configure_grid(g) + app = self.get_rattail_app() + g.filters['receipt_number'].default_active = True g.filters['receipt_number'].default_verb = 'equal' g.filters['end_time'].default_active = True g.filters['end_time'].default_verb = 'equal' - g.filters['end_time'].default_value = str(localtime(self.rattail_config).date()) + g.filters['end_time'].default_value = str(app.today()) g.set_sort_defaults('end_time', 'desc') g.set_enum('system', self.enum.TRAINWRECK_SYSTEM) From a9c4d37819b03f8452fc2f60a36667b650566d70 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 2 Mar 2023 11:05:20 -0600 Subject: [PATCH 1046/1681] 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 4c2025d6..75453965 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.9.12 (2023-03-02) +------------------- + +* Add "equal to any of" verb for string-type grid filters. + +* Allow download results for Trainwreck. + + 0.9.11 (2023-02-24) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 915c3b59..b405ecf7 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.11' +__version__ = '0.9.12' From 46c7ef42dea68f1ec9ff86b4408d06b196966818 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 8 Mar 2023 20:38:16 -0600 Subject: [PATCH 1047/1681] Remove version cap for cornice, now that we require python3 --- setup.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/setup.py b/setup.py index f200f89d..2cf45c4f 100644 --- a/setup.py +++ b/setup.py @@ -68,18 +68,16 @@ requires = [ # (still, probably a better idea is to refactor so we can use 0.9) 'webhelpers2_grid==0.1', # 0.1 - # TODO: remove version cap once we can drop support for python 2.x - 'cornice<5.0', # 3.4.2 4.0.1 - # TODO: remove once their bug is fixed? idk what this is about yet... 'deform<2.0.15', # 2.0.14 - # TODO: cornice<5 requires pyramid<2 (see above) + # TODO: remove this cap and address warnings that follow 'pyramid<2', # 1.3b2 1.10.8 'asgiref', # 3.2.3 'colander', # 1.7.0 'ColanderAlchemy', # 0.3.3 + 'cornice', # 3.4.2 'humanize', # 0.5.1 'Mako', # 0.6.2 'markdown', # 3.3.3 From 5aa982c95f0b7984147c748061a1d8304252152f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 8 Mar 2023 20:39:39 -0600 Subject: [PATCH 1048/1681] 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 75453965..aa46a8b0 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.9.13 (2023-03-08) +------------------- + +* Remove version cap for cornice, now that we require python3. + + 0.9.12 (2023-03-02) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index b405ecf7..026493ca 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.12' +__version__ = '0.9.13' From 2ebe0401c3b7201d19c456ed4d28c8b2b5cba6de Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 9 Mar 2023 14:07:10 -0600 Subject: [PATCH 1049/1681] Fix JSON rendering for Cornice API views also make sure we use Cornice for all API views --- setup.py | 1 + tailbone/api/batch/core.py | 11 ++++---- tailbone/api/batch/receiving.py | 45 +++++++++++++++++---------------- tailbone/webapi.py | 11 ++++++-- 4 files changed, 38 insertions(+), 30 deletions(-) diff --git a/setup.py b/setup.py index 2cf45c4f..7cc8f867 100644 --- a/setup.py +++ b/setup.py @@ -95,6 +95,7 @@ requires = [ 'rattail[db,bouncer]', # 0.5.0 'six', # 1.10.0 'sa-filters', # 1.2.0 + 'simplejson', # 3.18.3 'transaction', # 1.2.0 'waitress', # 0.8.1 'WebHelpers2', # 2.0 diff --git a/tailbone/api/batch/core.py b/tailbone/api/batch/core.py index a1c06ee6..f239aaaf 100644 --- a/tailbone/api/batch/core.py +++ b/tailbone/api/batch/core.py @@ -348,13 +348,12 @@ class APIBatchRowView(APIBatchMixin, APIMasterView): 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() if cls.supports_quick_entry: # quick entry - config.add_route('{}.quick_entry'.format(route_prefix), '{}/quick-entry'.format(collection_url_prefix), - request_method=('OPTIONS', 'POST')) - config.add_view(cls, attr='quick_entry', route_name='{}.quick_entry'.format(route_prefix), - permission='{}.edit'.format(permission_prefix), - renderer='json') + quick_entry = Service(name='{}.quick_entry'.format(route_prefix), + path='{}/quick-entry'.format(collection_url_prefix)) + quick_entry.add_view('POST', 'quick_entry', klass=cls, + permission='{}.edit'.format(permission_prefix)) + config.add_cornice_service(quick_entry) diff --git a/tailbone/api/batch/receiving.py b/tailbone/api/batch/receiving.py index 339fc43f..53d5f98a 100644 --- a/tailbone/api/batch/receiving.py +++ b/tailbone/api/batch/receiving.py @@ -31,6 +31,7 @@ import humanize from rattail.db import model from rattail.util import pretty_quantity +from cornice import Service from deform import widget as dfwidget from tailbone import forms @@ -143,26 +144,26 @@ 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') + # auto_receive + auto_receive = Service(name='{}.auto_receive'.format(route_prefix), + path='{}/{{uuid}}/auto-receive'.format(object_url_prefix)) + auto_receive.add_view('GET', 'auto_receive', klass=cls, + permission='{}.auto_receive'.format(permission_prefix)) + config.add_cornice_service(auto_receive) - # 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), - permission='{}.edit'.format(permission_prefix), - renderer='json') + # mark_receiving_complete + mark_receiving_complete = Service(name='{}.mark_receiving_complete'.format(route_prefix), + path='{}/{{uuid}}/mark-receiving-complete'.format(object_url_prefix)) + mark_receiving_complete.add_view('POST', 'mark_receiving_complete', klass=cls, + permission='{}.edit'.format(permission_prefix)) + config.add_cornice_service(mark_receiving_complete) # eligible purchases - config.add_route('{}.eligible_purchases'.format(route_prefix), '{}/eligible-purchases'.format(collection_url_prefix), - request_method='GET') - config.add_view(cls, attr='eligible_purchases', route_name='{}.eligible_purchases'.format(route_prefix), - permission='{}.create'.format(permission_prefix), - renderer='json') + eligible_purchases = Service(name='{}.eligible_purchases'.format(route_prefix), + path='{}/eligible-purchases'.format(collection_url_prefix)) + eligible_purchases.add_view('GET', 'eligible_purchases', klass=cls, + permission='{}.create'.format(permission_prefix)) + config.add_cornice_service(eligible_purchases) class ReceivingBatchRowViews(APIBatchRowView): @@ -437,11 +438,11 @@ class ReceivingBatchRowViews(APIBatchRowView): object_url_prefix = cls.get_object_url_prefix() # receive (row) - config.add_route('{}.receive'.format(route_prefix), '{}/{{uuid}}/receive'.format(object_url_prefix), - request_method=('OPTIONS', 'POST')) - config.add_view(cls, attr='receive', route_name='{}.receive'.format(route_prefix), - permission='{}.edit_row'.format(permission_prefix), - renderer='json') + receive = Service(name='{}.receive'.format(route_prefix), + path='{}/{{uuid}}/receive'.format(object_url_prefix)) + receive.add_view('POST', 'receive', klass=cls, + permission='{}.edit_row'.format(permission_prefix)) + config.add_cornice_service(receive) def defaults(config, **kwargs): diff --git a/tailbone/webapi.py b/tailbone/webapi.py index 10c3460b..b623bd70 100644 --- a/tailbone/webapi.py +++ b/tailbone/webapi.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2021 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,9 @@ Tailbone Web API """ -from __future__ import unicode_literals, absolute_import +import simplejson +from cornice.renderer import CorniceRenderer from pyramid.config import Configurator from pyramid.authentication import SessionAuthenticationPolicy @@ -61,6 +62,12 @@ def make_pyramid_config(settings): pyramid_config.include('pyramid_tm') pyramid_config.include('cornice') + # use simplejson to serialize cornice view context; cf. + # https://cornice.readthedocs.io/en/latest/upgrading.html#x-to-5-x + # https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/renderers.html + json_renderer = CorniceRenderer(serializer=simplejson.dumps) + pyramid_config.add_renderer('cornicejson', json_renderer) + # bring in the pyramid_retry logic, if available # TODO: pretty soon we can require this package, hopefully.. try: From 9ee46107d25681797ce5b44f6a80142c470de88d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 9 Mar 2023 14:10:31 -0600 Subject: [PATCH 1050/1681] 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 aa46a8b0..0903b107 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.9.14 (2023-03-09) +------------------- + +* Fix JSON rendering for Cornice API views. + + 0.9.13 (2023-03-08) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 026493ca..74531a6d 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.13' +__version__ = '0.9.14' From e19adf89071bd72df22dc5e75755ae1fda502755 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 9 Mar 2023 15:26:34 -0600 Subject: [PATCH 1051/1681] Remove version workaround for sphinx no longer needed --- setup.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/setup.py b/setup.py index 7cc8f867..90383def 100644 --- a/setup.py +++ b/setup.py @@ -110,9 +110,7 @@ extras = { # # package # low high - # 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', # 1.2 'sphinx-rtd-theme', # 0.2.4 ], From 1ce67953dfe84ebc618006d2508660043f02b2b2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 15 Mar 2023 09:33:20 -0500 Subject: [PATCH 1052/1681] Let providers do DB connection setup for web API --- tailbone/webapi.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tailbone/webapi.py b/tailbone/webapi.py index b623bd70..a437f0c3 100644 --- a/tailbone/webapi.py +++ b/tailbone/webapi.py @@ -32,6 +32,7 @@ from pyramid.authentication import SessionAuthenticationPolicy from tailbone import app from tailbone.auth import TailboneAuthorizationPolicy +from tailbone.providers import get_all_providers def make_rattail_config(settings): @@ -46,6 +47,7 @@ def make_pyramid_config(settings): """ Make a Pyramid config object from the given settings. """ + rattail_config = settings['rattail_config'] pyramid_config = Configurator(settings=settings, root_factory=app.Root) # configure user authorization / authentication @@ -77,6 +79,13 @@ def make_pyramid_config(settings): else: pyramid_config.include('pyramid_retry') + # fetch all tailbone providers + providers = get_all_providers(rattail_config) + for provider in providers.values(): + + # configure DB sessions associated with transaction manager + provider.configure_db_sessions(rattail_config, pyramid_config) + # add some permissions magic pyramid_config.add_directive('add_tailbone_permission_group', 'tailbone.auth.add_permission_group') pyramid_config.add_directive('add_tailbone_permission', 'tailbone.auth.add_permission') From 9125d7ef74e8b2390c9363d6cead15843a2723d7 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 15 Mar 2023 09:43:21 -0500 Subject: [PATCH 1053/1681] 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 0903b107..8aaaf62d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.9.15 (2023-03-15) +------------------- + +* Remove version workaround for sphinx. + +* Let providers do DB connection setup for web API. + + 0.9.14 (2023-03-09) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 74531a6d..f6936902 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.14' +__version__ = '0.9.15' From 714c0a6cfd3368820753335bf1f1c8d5a9c00ffe Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 23 Mar 2023 10:23:19 -0500 Subject: [PATCH 1054/1681] Avoid accidental auto-submit of new msg form, for subject field --- tailbone/templates/deform/textarea.pt | 3 ++- tailbone/templates/messages/create.mako | 13 +++++++++++++ tailbone/views/messages.py | 6 ++++-- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/tailbone/templates/deform/textarea.pt b/tailbone/templates/deform/textarea.pt index f705c652..bb9b6c84 100644 --- a/tailbone/templates/deform/textarea.pt +++ b/tailbone/templates/deform/textarea.pt @@ -9,7 +9,8 @@ <div tal:define="vmodel vmodel|'field_model_' + name;"> <b-input type="textarea" name="${name}" - v-model="${vmodel}"> + v-model="${vmodel}" + tal:attributes="attributes|field.widget.attributes|{};"> </b-input> </div> </div> diff --git a/tailbone/templates/messages/create.mako b/tailbone/templates/messages/create.mako index 10729590..4a15573b 100644 --- a/tailbone/templates/messages/create.mako +++ b/tailbone/templates/messages/create.mako @@ -44,6 +44,19 @@ TailboneFormData.possibleRecipients = new Map(${json.dumps(available_recipients)|n}) TailboneFormData.recipientDisplayMap = ${json.dumps(recipient_display_map)|n} + TailboneForm.methods.subjectKeydown = function(event) { + + // do not auto-submit form when user presses enter in subject field + if (event.which == 13) { + event.preventDefault() + + // set focus to msg body input if possible + if (this.$refs.messageBody && this.$refs.messageBody.focus) { + this.$refs.messageBody.focus() + } + } + } + </script> </%def> diff --git a/tailbone/views/messages.py b/tailbone/views/messages.py index 6aaf342e..4c83da34 100644 --- a/tailbone/views/messages.py +++ b/tailbone/views/messages.py @@ -221,11 +221,13 @@ class MessageView(MasterView): if self.creating: f.set_widget('subject', dfwidget.TextInputWidget( placeholder="please enter a subject", - autocomplete='off')) + autocomplete='off', + attributes={'@keydown.native': 'subjectKeydown'})) f.set_required('subject') # body - f.set_widget('body', dfwidget.TextAreaWidget(cols=50, rows=15)) + f.set_widget('body', dfwidget.TextAreaWidget( + cols=50, rows=15, attributes={'ref': 'messageBody'})) if self.creating: f.remove('sender', 'sent') From 2f8411ba2f92dbff042f4c228ee6c133cdd86fa4 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 25 Mar 2023 01:01:52 -0500 Subject: [PATCH 1055/1681] Add `has_perm()` etc. to request during the NewRequest event still get the occasional server error when handling what should be a simple 404 request e.g. for /wp-login.php error indicates there is no `request.has_perm()` at the time, so hoping this moves it earlier in the life cycle so it *will* exist.. --- tailbone/subscribers.py | 51 ++++++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 60fba60d..b724a4c5 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -62,6 +62,16 @@ def new_request(event): This of course assumes that a Rattail ``config`` object *has* in fact already been placed in the application registry settings. If this is not the case, this function will do nothing. + + Also, attach some goodies to the request object: + + * The currently logged-in user instance (if any), as ``user``. + + * ``is_admin`` flag indicating whether user has the Administrator role. + + * ``is_root`` flag indicating whether user is currently elevated to root. + + * A shortcut method for permission checking, as ``has_perm()``. """ request = event.request rattail_config = request.registry.settings.get('rattail_config') @@ -87,12 +97,27 @@ def new_request(event): request.is_admin = bool(request.user) and request.user.is_admin() request.is_root = request.is_admin and request.session.get('is_root', False) + # TODO: why would this ever be null? if rattail_config: + app = rattail_config.get_app() auth = app.get_auth_handler() request.tailbone_cached_permissions = auth.get_permissions( Session(), request.user) + def has_perm(name): + if name in request.tailbone_cached_permissions: + return True + return request.is_root + request.has_perm = has_perm + + def has_any_perm(*names): + for name in names: + if has_perm(name): + return True + return False + request.has_any_perm = has_any_perm + def before_render(event): """ @@ -206,36 +231,16 @@ def add_inbox_count(event): def context_found(event): """ - Attach some goodies to the request object. + Attach some more goodies to the request object: The following is attached to the request: - * The currently logged-in user instance (if any), as ``user``. + * ``get_referrer()`` function - * ``is_admin`` flag indicating whether user has the Administrator role. - - * ``is_root`` flag indicating whether user is currently elevated to root. - - * A shortcut method for permission checking, as ``has_perm()``. - - * A shortcut method for fetching the referrer, as ``get_referrer()``. + * ``get_session_timeout()`` function """ - request = event.request - def has_perm(name): - if name in request.tailbone_cached_permissions: - return True - return request.is_root - request.has_perm = has_perm - - def has_any_perm(*names): - for name in names: - if has_perm(name): - return True - return False - request.has_any_perm = has_any_perm - def get_referrer(default=None, **kwargs): if request.params.get('referrer'): return request.params['referrer'] From 45b8d9fb8456dfccf3cefaf42876e6d64d15b00d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 25 Mar 2023 11:28:53 -0500 Subject: [PATCH 1056/1681] Fix table sorting for FK reference column in new table wizard also add LargeBinary data type option --- tailbone/templates/tables/create.mako | 1 + tailbone/views/tables.py | 9 +++------ 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/tailbone/templates/tables/create.mako b/tailbone/templates/tables/create.mako index 3ebad9d1..dfe6cc45 100644 --- a/tailbone/templates/tables/create.mako +++ b/tailbone/templates/tables/create.mako @@ -183,6 +183,7 @@ <option value="Date">Date</option> <option value="DateTime">DateTime</option> <option value="Text">Text</option> + <option value="LargeBinary">LargeBinary</option> <option value="_fk_uuid_">FK/UUID</option> <option value="_other_">Other</option> </b-select> diff --git a/tailbone/views/tables.py b/tailbone/views/tables.py index d11a2923..75a61086 100644 --- a/tailbone/views/tables.py +++ b/tailbone/views/tables.py @@ -24,13 +24,10 @@ Views with info about the underlying Rattail tables """ -from __future__ import unicode_literals, absolute_import - import os import sys import warnings -import six from sqlalchemy_utils import get_mapper from rattail.util import simple_error @@ -203,8 +200,8 @@ class TableView(MasterView): branch_name = None kwargs['branch_name'] = branch_name - kwargs['existing_tables'] = [{'name': table.name} - for table in model.Base.metadata.sorted_tables] + kwargs['existing_tables'] = [{'name': table} + for table in sorted(model.Base.metadata.tables)] kwargs['model_dir'] = (os.path.dirname(model.__file__) + os.sep) @@ -294,7 +291,7 @@ class TableView(MasterView): 'column': column, 'sequence': i, 'column_name': column.name, - 'data_type': six.text_type(repr(column.type)), + 'data_type': str(repr(column.type)), 'nullable': column.nullable, 'description': column.doc, }) From e96f8844e2e83312346635ba355d7494f452a4ae Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 25 Mar 2023 13:03:47 -0500 Subject: [PATCH 1057/1681] Overhaul the "find by perm" feature a bit use GET instead of POST on form submit, so can more easily share URL for a particular result also get rid of WTForms dependency! sheesh results table is still not pretty but..feeling lazy --- setup.py | 1 - .../templates/principal/find_by_perm.mako | 72 +++++++++---------- tailbone/views/principal.py | 44 +++++------- 3 files changed, 52 insertions(+), 65 deletions(-) diff --git a/setup.py b/setup.py index 90383def..b295f062 100644 --- a/setup.py +++ b/setup.py @@ -99,7 +99,6 @@ requires = [ 'transaction', # 1.2.0 'waitress', # 0.8.1 'WebHelpers2', # 2.0 - 'WTForms', # 2.1 'zope.sqlalchemy', # 0.7 2.0 ] diff --git a/tailbone/templates/principal/find_by_perm.mako b/tailbone/templates/principal/find_by_perm.mako index 24b43e36..097597fc 100644 --- a/tailbone/templates/principal/find_by_perm.mako +++ b/tailbone/templates/principal/find_by_perm.mako @@ -4,6 +4,7 @@ <%def name="title()">Find ${model_title_plural} by Permission</%def> <%def name="page_content()"> + <br /> <find-principals :permission-groups="permissionGroups" :sorted-groups="sortedGroups"> </find-principals> @@ -12,46 +13,45 @@ <%def name="render_this_page_template()"> ${parent.render_this_page_template()} <script type="text/x-template" id="find-principals-template"> - <div class="app-wrapper"> + <div> - ${h.form(request.current_route_url(), **{'@submit': 'submitForm'})} - ${h.csrf_token(request)} + ${h.form(request.current_route_url(), method='GET', **{'@submit': 'formSubmitting = true'})} - <div class="field-wrapper"> - <label for="permission_group">${form['permission_group'].label}</label> - <div class="field"> - <b-select name="permission_group" - id="permission_group" - v-model="selectedGroup" - @input="selectGroup"> - <option v-for="groupkey in sortedGroups" - :key="groupkey" - :value="groupkey"> - {{ permissionGroups[groupkey].label }} - </option> - </b-select> - </div> - </div> + <b-field label="Permission Group" horizontal> + <b-select name="permission_group" + v-model="selectedGroup" + @input="selectGroup"> + <option v-for="groupkey in sortedGroups" + :key="groupkey" + :value="groupkey"> + {{ permissionGroups[groupkey].label }} + </option> + </b-select> + </b-field> - <div class="field-wrapper"> - <label for="permission">${form['permission'].label}</label> - <div class="field"> - <b-select name="permission" - v-model="selectedPermission"> - <option v-for="perm in groupPermissions" - :key="perm.permkey" - :value="perm.permkey"> - {{ perm.label }} - </option> - </b-select> - </div> - </div> + <b-field label="Permission" horizontal> + <b-select name="permission" + v-model="selectedPermission"> + <option v-for="perm in groupPermissions" + :key="perm.permkey" + :value="perm.permkey"> + {{ perm.label }} + </option> + </b-select> + </b-field> <div class="buttons"> + <once-button tag="a" + href="${request.current_route_url(_query=None)}" + icon-left="ban" + text="Reset"> + </once-button> <b-button type="is-primary" native-type="submit" + icon-pack="fas" + icon-left="search" :disabled="formSubmitting"> - {{ formButtonText }} + {{ formSubmitting ? "Working, please wait..." : "Find ${model_title_plural}" }} </b-button> </div> @@ -65,7 +65,7 @@ </div> % endif - </div><!-- app-wrapper --> + </div> </script> </%def> @@ -100,7 +100,6 @@ % else: selectedPermission: null, % endif - formButtonText: "Find ${model_title_plural}", formSubmitting: false, } }, @@ -112,11 +111,6 @@ this.groupPermissions = this.permissionGroups[groupkey].permissions this.selectedPermission = this.groupPermissions[0].permkey }, - - submitForm() { - this.formSubmitting = true - this.formButtonText = "Working, please wait..." - } } }) diff --git a/tailbone/views/principal.py b/tailbone/views/principal.py index 04fe97ad..9effd2af 100644 --- a/tailbone/views/principal.py +++ b/tailbone/views/principal.py @@ -29,7 +29,6 @@ import copy from rattail.core import Object from rattail.util import OrderedDict -import wtforms from webhelpers2.html import HTML from tailbone.db import Session @@ -54,40 +53,32 @@ class PrincipalMasterView(MasterView): """ View for finding all users who have been granted a given permission """ - permissions = copy.deepcopy(self.request.registry.settings.get('tailbone_permissions', {})) + permissions = copy.deepcopy( + self.request.registry.settings.get('tailbone_permissions', {})) # sort groups, and permissions for each group, for UI's sake sorted_perms = sorted(permissions.items(), key=self.perm_sortkey) for key, group in sorted_perms: group['perms'] = sorted(group['perms'].items(), key=self.perm_sortkey) - # group options are stable, permission options may depend on submitted group - group_choices = [(gkey, group['label']) for gkey, group in sorted_perms] - permission_choices = [('_any_', "(any)")] - if self.request.method == 'POST': - if self.request.POST.get('permission_group') in permissions: - permission_choices.extend([ - (pkey, perm['label']) - for pkey, perm in permissions[self.request.POST['permission_group']]['perms'] - ]) - - class PermissionForm(wtforms.Form): - permission_group = wtforms.SelectField(choices=group_choices) - permission = wtforms.SelectField(choices=permission_choices) - + # if both field values are in query string, do lookup principals = None - form = PermissionForm(self.request.POST) - if self.request.method == 'POST' and form.validate(): - permission = form.permission.data - principals = self.find_principals_with_permission(self.Session(), permission) + permission_group = self.request.GET.get('permission_group') + permission = self.request.GET.get('permission') + if permission_group and permission: + principals = self.find_principals_with_permission(self.Session(), + permission) + else: # otherwise clear both values + permission_group = None + permission = None - context = {'form': form, 'permissions': sorted_perms, 'principals': principals} + context = {'permissions': sorted_perms, 'principals': principals} perms = self.get_buefy_perms_data(sorted_perms) context['buefy_perms'] = perms context['buefy_sorted_groups'] = list(perms) - context['selected_group'] = self.request.POST.get('permission_group', 'common') - context['selected_permission'] = self.request.POST.get('permission', None) + context['selected_group'] = permission_group or 'common' + context['selected_permission'] = permission return self.render_to_response('find_by_perm', context) @@ -123,8 +114,11 @@ class PrincipalMasterView(MasterView): model_title_plural = cls.get_model_title_plural() # find principal by permission - config.add_route('{}.find_by_perm'.format(route_prefix), '{}/find-by-perm'.format(url_prefix)) - config.add_view(cls, attr='find_by_perm', route_name='{}.find_by_perm'.format(route_prefix), + config.add_route('{}.find_by_perm'.format(route_prefix), + '{}/find-by-perm'.format(url_prefix), + request_method='GET') + config.add_view(cls, attr='find_by_perm', + route_name='{}.find_by_perm'.format(route_prefix), permission='{}.find_by_perm'.format(permission_prefix)) config.add_tailbone_permission(permission_prefix, '{}.find_by_perm'.format(permission_prefix), "Find all {} with permission X".format(model_title_plural)) From efb8f8f3157e84a2e87b98cc213a1a9909e80e8a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 27 Mar 2023 12:53:16 -0500 Subject: [PATCH 1058/1681] 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 8aaaf62d..0b047179 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,18 @@ CHANGELOG ========= +0.9.16 (2023-03-27) +------------------- + +* Avoid accidental auto-submit of new msg form, for subject field. + +* Add ``has_perm()`` etc. to request during the NewRequest event. + +* Fix table sorting for FK reference column in new table wizard. + +* Overhaul the "find by perm" feature a bit. + + 0.9.15 (2023-03-15) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index f6936902..30c5daef 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.15' +__version__ = '0.9.16' From 6ab3898f27907d49808e3f29b11198f748b4f2fc Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 31 Mar 2023 12:55:05 -0500 Subject: [PATCH 1059/1681] Allow bulk-delete for products grid --- tailbone/views/products.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index bace8421..cc474840 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -79,6 +79,7 @@ class ProductView(MasterView): has_versions = True results_downloadable_xlsx = True supports_autocomplete = True + bulk_deletable = True mergeable = True configurable = True From 18f8577005bba7dae921090ae18534a663612300 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 31 Mar 2023 14:02:09 -0500 Subject: [PATCH 1060/1681] Improve global menu search behavior for multiple terms --- tailbone/templates/base.mako | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index f4935113..df4451c6 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -757,8 +757,27 @@ if (!this.globalSearchTerm.length) { return this.globalSearchData } + + let terms = [] + for (let term of this.globalSearchTerm.toLowerCase().split(' ')) { + term = term.trim() + if (term) { + terms.push(term) + } + } + if (!terms.length) { + return this.globalSearchData + } + + // all terms must match return this.globalSearchData.filter((option) => { - return option.label.toLowerCase().indexOf(this.globalSearchTerm.toLowerCase()) >= 0 + let label = option.label.toLowerCase() + for (let term of terms) { + if (label.indexOf(term) < 0) { + return false + } + } + return true }) }, From eb31fa9ab788905cd2bc28bf8d9361eaa0689f9f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 17 Apr 2023 16:10:37 -0500 Subject: [PATCH 1061/1681] 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 0b047179..667f2c70 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.9.17 (2023-04-17) +------------------- + +* Allow bulk-delete for products grid. + +* Improve global menu search behavior for multiple terms. + + 0.9.16 (2023-03-27) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 30c5daef..d929f9e2 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.16' +__version__ = '0.9.17' From 4993b349ef197e19c9cc2b40bb83ba1639aba1ac Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 21 Apr 2023 12:04:36 -0500 Subject: [PATCH 1062/1681] Avoid error if tempmon probe has invalid status --- tailbone/views/tempmon/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/tempmon/core.py b/tailbone/views/tempmon/core.py index 3f4df128..62ace028 100644 --- a/tailbone/views/tempmon/core.py +++ b/tailbone/views/tempmon/core.py @@ -55,7 +55,7 @@ class MasterView(views.MasterView): 'good_temp_min': probe.good_temp_min, 'good_temp_max': probe.good_temp_max, 'critical_temp_max': probe.critical_temp_max, - 'status': self.enum.TEMPMON_PROBE_STATUS[probe.status], + 'status': self.enum.TEMPMON_PROBE_STATUS.get(probe.status, '??'), 'enabled': "Yes" if probe.enabled else "No", }) app = self.get_rattail_app() From 2863ff7a5cbc26cf9a113cdcc11cc4f94e29ad37 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 27 Apr 2023 09:22:48 -0500 Subject: [PATCH 1063/1681] Remove references to deprecated extra in `tox.ini` --- tox.ini | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tox.ini b/tox.ini index 70767e56..8681465d 100644 --- a/tox.ini +++ b/tox.ini @@ -6,14 +6,14 @@ envlist = py36, py37, py39 commands = pip install --upgrade pip pip install --upgrade setuptools wheel - pip install --upgrade --upgrade-strategy eager Tailbone[tests] rattail[auth,bouncer,db] rattail-tempmon + pip install --upgrade --upgrade-strategy eager Tailbone[tests] rattail[bouncer,db] rattail-tempmon pytest {posargs} [testenv:coverage] basepython = python3 commands = pip install --upgrade pip - pip install --upgrade --upgrade-strategy eager Tailbone[tests] rattail[auth,bouncer,db] rattail-tempmon + pip install --upgrade --upgrade-strategy eager Tailbone[tests] rattail[bouncer,db] rattail-tempmon pytest --cov=tailbone --cov-report=html [testenv:docs] @@ -21,5 +21,5 @@ basepython = python3 changedir = docs commands = pip install --upgrade pip - pip install --upgrade --upgrade-strategy eager Tailbone[docs] rattail[auth,bouncer,db] rattail-tempmon + pip install --upgrade --upgrade-strategy eager Tailbone[docs] rattail[bouncer,db] rattail-tempmon sphinx-build -b html -d {envtmpdir}/doctrees -W -T . {envtmpdir}/docs From f913ed8332055e9165bc7f6aa5b8ed841307bbfd Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 2 May 2023 19:13:28 -0500 Subject: [PATCH 1064/1681] Expose, honor the `prevent_password_change` flag for Users --- tailbone/api/auth.py | 7 ++++--- tailbone/api/users.py | 8 +++++--- tailbone/templates/base.mako | 4 +++- tailbone/views/auth.py | 8 ++++++-- tailbone/views/users.py | 8 ++++++-- 5 files changed, 24 insertions(+), 11 deletions(-) diff --git a/tailbone/api/auth.py b/tailbone/api/auth.py index 867c15a8..1b347b21 100644 --- a/tailbone/api/auth.py +++ b/tailbone/api/auth.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,6 @@ Tailbone Web API - Auth Views """ -from __future__ import unicode_literals, absolute_import - from rattail.db.auth import set_user_password from cornice import Service @@ -168,6 +166,9 @@ class AuthenticationView(APIView): if not self.request.user: raise self.forbidden() + if self.request.user.prevent_password_change and not self.request.is_root: + raise self.forbidden() + data = self.request.json_body # first make sure "current" password is accurate diff --git a/tailbone/api/users.py b/tailbone/api/users.py index 2b6476a2..a6bcad57 100644 --- a/tailbone/api/users.py +++ b/tailbone/api/users.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,6 @@ Tailbone Web API - User Views """ -from __future__ import unicode_literals, absolute_import - from rattail.db import model from tailbone.api import APIMasterView @@ -57,6 +55,10 @@ class UserView(APIMasterView): query = query.outerjoin(model.Person) return query + def update_object(self, user, data): + # TODO: should ensure prevent_password_change is respected + return super(UserView, self).update_object(user, data) + def defaults(config, **kwargs): base = globals() diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index df4451c6..91589990 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -607,7 +607,9 @@ % if messaging_enabled: ${h.link_to("Messages{}".format(" ({})".format(inbox_count) if inbox_count else ''), url('messages.inbox'), class_='navbar-item')} % endif - ${h.link_to("Change Password", url('change_password'), class_='navbar-item')} + % if request.is_root or not request.user.prevent_password_change: + ${h.link_to("Change Password", url('change_password'), class_='navbar-item')} + % endif ${h.link_to("Edit Preferences", url('my.preferences'), class_='navbar-item')} ${h.link_to("Logout", url('logout'), class_='navbar-item')} </div> diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index 9bcb644f..fbae397b 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -175,8 +175,12 @@ class AuthenticationView(View): if not self.request.user: return self.redirect(self.request.route_url('home')) - if self.user_is_protected(self.request.user) and not self.request.is_root: - self.request.session.flash("Cannot change password for user: {}".format(self.request.user)) + if ((self.request.user.prevent_password_change + or self.user_is_protected(self.request.user)) + and not self.request.is_root): + + self.request.session.flash("Cannot change password for user: {}".format( + self.request.user)) return self.redirect(self.request.get_referrer()) schema = ChangePassword().bind(user=self.request.user, request=self.request) diff --git a/tailbone/views/users.py b/tailbone/views/users.py index 4f3a0070..ff614460 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -67,6 +67,7 @@ class UserView(PrincipalMasterView): 'active', 'active_sticky', 'set_password', + 'prevent_password_change', 'roles', 'permissions', ] @@ -210,7 +211,10 @@ class UserView(PrincipalMasterView): f.set_renderer('display_name_', self.render_person_name) # set_password - f.set_widget('set_password', dfwidget.CheckedPasswordWidget()) + if self.editing and user.prevent_password_change and not self.request.is_root: + f.remove('set_password') + else: + f.set_widget('set_password', dfwidget.CheckedPasswordWidget()) # if self.creating: # f.set_required('password') @@ -316,7 +320,7 @@ class UserView(PrincipalMasterView): user.person.local_only = True # maybe set user password - if data['set_password']: + if 'set_password' in form and data['set_password']: set_user_password(user, data['set_password']) # update roles for user From 026d98551cd640aada6d4be4e0c9eac8402e9e20 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 3 May 2023 10:55:15 -0500 Subject: [PATCH 1065/1681] 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 667f2c70..8cb0fd98 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.9.18 (2023-05-03) +------------------- + +* Avoid error if tempmon probe has invalid status. + +* Expose, honor the ``prevent_password_change`` flag for Users. + + 0.9.17 (2023-04-17) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index d929f9e2..a1a9c6bb 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.17' +__version__ = '0.9.18' From 2ed63b1c1a29e2d681beb27d0f62a84ca2b0ca8b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 5 May 2023 00:18:16 -0500 Subject: [PATCH 1066/1681] Massive overhaul of "generate project" feature previous incarnation was woefully lacking. new feature is much more extensible. still need to remove old POS integration specifics in some places. and a couple of unrelated things that snuck in.. - deprecate `rattail.util.OrderedDict` - deprecate `rattail.util.import_module_path()` - deprecate `rattail.util.import_reload()` --- tailbone/api/common.py | 3 +- tailbone/forms/core.py | 17 +- tailbone/grids/filters.py | 2 +- tailbone/helpers.py | 6 +- tailbone/menus.py | 19 +- tailbone/templates/forms/deform_buefy.mako | 19 +- tailbone/templates/generate_project.mako | 480 ----------------- .../templates/generated-projects/create.mako | 24 + tailbone/views/batch/handheld.py | 5 +- tailbone/views/batch/inventory.py | 3 +- tailbone/views/batch/product.py | 5 +- tailbone/views/common.py | 3 +- tailbone/views/master.py | 20 +- tailbone/views/people.py | 3 +- tailbone/views/principal.py | 2 +- tailbone/views/products.py | 4 +- tailbone/views/projects.py | 493 ++++++++++++------ tailbone/views/purchasing/receiving.py | 3 +- tailbone/views/reports.py | 3 +- tailbone/views/settings.py | 3 +- tailbone/views/tables.py | 5 +- tailbone/views/upgrades.py | 2 +- 22 files changed, 424 insertions(+), 700 deletions(-) delete mode 100644 tailbone/templates/generate_project.mako create mode 100644 tailbone/templates/generated-projects/create.mako diff --git a/tailbone/api/common.py b/tailbone/api/common.py index b82bafd0..6d8e9344 100644 --- a/tailbone/api/common.py +++ b/tailbone/api/common.py @@ -24,10 +24,11 @@ Tailbone Web API - "Common" Views """ +from collections import OrderedDict + import rattail from rattail.db import model from rattail.mail import send_email -from rattail.util import OrderedDict from cornice import Service diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 161bfa25..9f30512b 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -26,6 +26,7 @@ Forms Core import json import logging +from collections import OrderedDict import sqlalchemy as sa from sqlalchemy import orm @@ -346,6 +347,7 @@ class Form(object): self.schema = schema if self.fields is None and self.schema: self.set_fields([f.name for f in self.schema]) + self.grouping = None self.request = request self.readonly = readonly self.readonly_fields = set(readonly_fields or []) @@ -371,6 +373,7 @@ class Form(object): self.validators = validators or {} self.required = required or {} self.helptext = helptext or {} + self.dynamic_helptext = {} self.focus_spec = focus_spec self.action_url = action_url self.cancel_url = cancel_url @@ -404,6 +407,9 @@ class Form(object): return get_fieldnames(self.request.rattail_config, self.model_class, columns=True, proxies=True, relations=True) + def set_grouping(self, items): + self.grouping = OrderedDict(items) + def make_renderers(self): """ Return a default set of field renderers, based on :attr:`model_class`. @@ -728,11 +734,15 @@ class Form(object): """ self.defaults[key] = value - def set_helptext(self, key, value): + def set_helptext(self, key, value, dynamic=False): """ Set the help text for a given field. """ self.helptext[key] = value + if value and dynamic: + self.dynamic_helptext[key] = True + else: + self.dynamic_helptext.pop(key, None) def has_helptext(self, key): """ @@ -935,7 +945,10 @@ class Form(object): # TODO: older logic did this only if field was *not* # readonly, perhaps should add that back.. if self.has_helptext(fieldname): - attrs['message'] = self.render_helptext(fieldname) + msgkey = 'message' + if self.dynamic_helptext.get(fieldname): + msgkey = ':message' + attrs[msgkey] = self.render_helptext(fieldname) # show errors if present error_messages = self.get_error_messages(field) if field else None diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py index e4b522f5..26ef4f59 100644 --- a/tailbone/grids/filters.py +++ b/tailbone/grids/filters.py @@ -27,11 +27,11 @@ Grid Filters import re import datetime import logging +from collections import OrderedDict import sqlalchemy as sa from rattail.gpc import GPC -from rattail.util import OrderedDict from rattail.core import UNSPECIFIED from rattail.time import localtime, make_utc from rattail.util import prettify diff --git a/tailbone/helpers.py b/tailbone/helpers.py index aeb6aa01..d4065cc5 100644 --- a/tailbone/helpers.py +++ b/tailbone/helpers.py @@ -24,15 +24,13 @@ Template Context Helpers """ -from __future__ import unicode_literals, absolute_import - import os import datetime from decimal import Decimal +from collections import OrderedDict from rattail.time import localtime, make_utc -from rattail.util import (pretty_quantity, pretty_hours, hours_as_decimal, - OrderedDict) +from rattail.util import pretty_quantity, pretty_hours, hours_as_decimal from rattail.db.util import maxlen from webhelpers2.html import * diff --git a/tailbone/menus.py b/tailbone/menus.py index 98006c00..9a0ba066 100644 --- a/tailbone/menus.py +++ b/tailbone/menus.py @@ -667,11 +667,18 @@ class MenuHandler(GenericHandler): 'route': 'appinfo', 'perm': 'appinfo.list', }, - { - 'title': "Label Settings", - 'route': 'labelprofiles', - 'perm': 'labelprofiles.list', - }, + ]) + + if kwargs.get('include_label_settings', False): + items.extend([ + { + 'title': "Label Settings", + 'route': 'labelprofiles', + 'perm': 'labelprofiles.list', + }, + ]) + + items.extend([ { 'title': "Raw Settings", 'route': 'settings', @@ -807,7 +814,7 @@ def make_menu_entry(request, item): try: entry['url'] = request.route_url(entry['route']) except KeyError: # happens if no such route - log.debug("invalid route name for menu entry: %s", entry) + log.warning("invalid route name for menu entry: %s", entry) entry['url'] = entry['route'] entry['key'] = entry['route'] else: diff --git a/tailbone/templates/forms/deform_buefy.mako b/tailbone/templates/forms/deform_buefy.mako index 4ff9c0b5..39633117 100644 --- a/tailbone/templates/forms/deform_buefy.mako +++ b/tailbone/templates/forms/deform_buefy.mako @@ -11,10 +11,23 @@ <section> % if form_body is not Undefined and form_body: ${form_body|n} + % elif form.grouping: + % for group in form.grouping: + <nav class="panel"> + <p class="panel-heading">${group}</p> + <div class="panel-block"> + <div> + % for field in form.grouping[group]: + ${form.render_buefy_field(field)} + % endfor + </div> + </div> + </nav> + % endfor % else: - % for field in form.fields: - ${form.render_buefy_field(field)} - % endfor + % for field in form.fields: + ${form.render_buefy_field(field)} + % endfor % endif </section> diff --git a/tailbone/templates/generate_project.mako b/tailbone/templates/generate_project.mako deleted file mode 100644 index f2b67cb3..00000000 --- a/tailbone/templates/generate_project.mako +++ /dev/null @@ -1,480 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/page.mako" /> - -<%def name="title()">Generate Project</%def> - -<%def name="content_title()"></%def> - -<%def name="page_content()"> - <b-field horizontal label="Project Type"> - <b-select v-model="projectType"> - <option value="rattail">rattail</option> - <option value="rattail_integration">rattail-integration</option> - <option value="tailbone_integration">tailbone-integration</option> - ## <option value="byjove">byjove</option> - <option value="fabric">fabric</option> - </b-select> - </b-field> - - <div v-if="projectType == 'rattail'"> - ${h.form(request.current_route_url(), ref='rattailForm')} - ${h.csrf_token(request)} - ${h.hidden('project_type', value='rattail')} - <br /> - <div class="card"> - <header class="card-header"> - <p class="card-header-title">Naming</p> - </header> - <div class="card-content"> - <div class="content"> - - <b-field horizontal label="Name" - message="The "canonical" name generally used to refer to this project"> - <b-input name="name" v-model="rattail.name"></b-input> - </b-field> - - <b-field horizontal label="Slug" - message="Used for e.g. naming the project source code folder"> - <b-input name="slug" v-model="rattail.slug"></b-input> - </b-field> - - <b-field horizontal label="Organization" - message="For use with "branding" etc."> - <b-input name="organization" v-model="rattail.organization"></b-input> - </b-field> - - <b-field horizontal label="Package Name for PyPI" - message="It's a good idea to use org name as namespace prefix here"> - <b-input name="python_project_name" v-model="rattail.python_project_name"></b-input> - </b-field> - - <b-field horizontal label="Package Name in Python" - :message="`For example, ~/src/${'$'}{rattail.slug}/${'$'}{rattail.python_package_name}/__init__.py`"> - <b-input name="python_name" v-model="rattail.python_package_name"></b-input> - </b-field> - - </div> - </div> - </div> - <br /> - <div class="card"> - <header class="card-header"> - <p class="card-header-title">Database</p> - </header> - <div class="card-content"> - <div class="content"> - - <b-field horizontal label="Has Rattail DB" - message="Note that a DB is required for the Web App"> - <b-checkbox name="has_db" - v-model="rattail.has_rattail_db" - native-value="true"> - </b-checkbox> - </b-field> - - <b-field horizontal label="Extends Rattail DB Schema" - message="For adding custom tables/columns to the core schema"> - <b-checkbox name="extends_db" - v-model="rattail.extends_rattail_db_schema" - native-value="true"> - </b-checkbox> - </b-field> - - <b-field horizontal label="Uses Rattail Batch Schema" - v-show="false" - message="Needed for "dynamic" (e.g. import/export) batches"> - <b-checkbox name="has_batch_schema" - v-model="rattail.uses_rattail_batch_schema" - native-value="true"> - </b-checkbox> - </b-field> - - </div> - </div> - </div> - <br /> - <div class="card"> - <header class="card-header"> - <p class="card-header-title">Web App</p> - </header> - <div class="card-content"> - <div class="content"> - - <b-field horizontal label="Has Tailbone Web App"> - <b-checkbox name="has_web" - v-model="rattail.has_tailbone_web_app" - native-value="true"> - </b-checkbox> - </b-field> - - <b-field horizontal label="Has Tailbone Web API" - v-show="false" - message="Needed for e.g. Vue.js SPA mobile apps"> - <b-checkbox name="has_web_api" - v-model="rattail.has_tailbone_web_api" - native-value="true"> - </b-checkbox> - </b-field> - - </div> - </div> - </div> - <br /> - <div class="card"> - <header class="card-header"> - <p class="card-header-title">Integrations</p> - </header> - <div class="card-content"> - <div class="content"> - - <b-field horizontal label="Integrates w/ Catapult" - message="Add schema, import/export logic etc. for ECRS Catapult"> - <b-checkbox name="integrates_catapult" - v-model="rattail.integrates_with_catapult" - native-value="true"> - </b-checkbox> - </b-field> - - <b-field horizontal label="Integrates w/ CORE-POS" - v-show="false"> - <b-checkbox name="integrates_corepos" - v-model="rattail.integrates_with_corepos" - native-value="true"> - </b-checkbox> - </b-field> - - <b-field horizontal label="Integrates w/ LOC SMS" - message="Add schema, import/export logic etc. for LOC SMS"> - <b-checkbox name="integrates_locsms" - v-model="rattail.integrates_with_locsms" - native-value="true"> - </b-checkbox> - </b-field> - - <b-field horizontal label="Has DataSync Service" - v-show="false"> - <b-checkbox name="has_datasync" - v-model="rattail.has_datasync_service" - native-value="true"> - </b-checkbox> - </b-field> - - </div> - </div> - </div> - <br /> - <div class="card"> - <header class="card-header"> - <p class="card-header-title">Deployment</p> - </header> - <div class="card-content"> - <div class="content"> - - <b-field horizontal label="Uses Fabric"> - <b-checkbox name="uses_fabric" - v-model="rattail.uses_fabric" - native-value="true"> - </b-checkbox> - </b-field> - - </div> - </div> - </div> - ${h.end_form()} - </div> - - <div v-if="projectType == 'rattail_integration'"> - ${h.form(request.current_route_url(), ref='rattail_integrationForm')} - ${h.csrf_token(request)} - ${h.hidden('project_type', value='rattail_integration')} - <br /> - <div class="card"> - <header class="card-header"> - <p class="card-header-title">Naming</p> - </header> - <div class="card-content"> - <div class="content"> - - <b-field horizontal label="Integration Name" - message="Name of the system to be integrated"> - <b-input name="integration_name" v-model="rattail_integration.integration_name"></b-input> - </b-field> - - <b-field horizontal label="Integration URL" - message="Reference URL for the system to be integrated"> - <b-input name="integration_url" v-model="rattail_integration.integration_url"></b-input> - </b-field> - - <b-field horizontal label="Package Name for PyPI" - message="Also will be used as slug, e.g. for folder name"> - <b-input name="python_project_name" v-model="rattail_integration.python_project_name"></b-input> - </b-field> - - ${h.hidden('slug', **{'v-model': 'rattail_integration.python_project_name'})} - - <b-field horizontal label="Package Name in Python" - :message="`For example, ~/src/${'$'}{rattail_integration.python_project_name}/${'$'}{rattail_integration.python_package_name}/__init__.py`"> - <b-input name="python_name" v-model="rattail_integration.python_package_name"></b-input> - </b-field> - - </div> - </div> - </div> - <br /> - <div class="card"> - <header class="card-header"> - <p class="card-header-title">Options</p> - </header> - <div class="card-content"> - <div class="content"> - - <b-field horizontal label="Extends Config" - message="Adds custom config extension"> - <b-checkbox name="extends_config" - v-model="rattail_integration.extends_config" - native-value="true"> - </b-checkbox> - </b-field> - - <b-field horizontal label="Extends Rattail Schema" - message="Adds custom tables/columns to the Rattail DB schema"> - <b-checkbox name="extends_db" - v-model="rattail_integration.extends_db" - native-value="true"> - </b-checkbox> - </b-field> - - </div> - </div> - </div> - ${h.end_form()} - </div> - - <div v-if="projectType == 'tailbone_integration'"> - ${h.form(request.current_route_url(), ref='tailbone_integrationForm')} - ${h.csrf_token(request)} - ${h.hidden('project_type', value='tailbone_integration')} - <br /> - <div class="card"> - <header class="card-header"> - <p class="card-header-title">Naming</p> - </header> - <div class="card-content"> - <div class="content"> - - <b-field horizontal label="Integration Name" - message="Name of the system to be integrated"> - <b-input name="integration_name" v-model="tailbone_integration.integration_name"></b-input> - </b-field> - - <b-field horizontal label="Integration URL" - message="Reference URL for the system to be integrated"> - <b-input name="integration_url" v-model="tailbone_integration.integration_url"></b-input> - </b-field> - - <b-field horizontal label="Package Name for PyPI" - message="Also will be used as slug, e.g. for folder name"> - <b-input name="python_project_name" v-model="tailbone_integration.python_project_name"></b-input> - </b-field> - - ${h.hidden('slug', **{'v-model': 'tailbone_integration.python_project_name'})} - - <b-field horizontal label="Package Name in Python" - :message="`For example, ~/src/${'$'}{tailbone_integration.python_project_name}/${'$'}{tailbone_integration.python_package_name}/__init__.py`"> - <b-input name="python_name" v-model="tailbone_integration.python_package_name"></b-input> - </b-field> - - </div> - </div> - </div> - <br /> - <div class="card"> - <header class="card-header"> - <p class="card-header-title">Options</p> - </header> - <div class="card-content"> - <div class="content"> - - <b-field horizontal label="Has Static Files" - message="Register a subfolder for static files (images etc.)"> - <b-checkbox name="has_static_files" - v-model="tailbone_integration.has_static_files" - native-value="true"> - </b-checkbox> - </b-field> - - </div> - </div> - </div> - ${h.end_form()} - </div> - - <div v-if="projectType == 'byjove'"> - ${h.form(request.current_route_url(), ref='byjoveForm')} - ${h.csrf_token(request)} - ${h.hidden('project_type', value='byjove')} - - <br /> - <div class="card"> - <header class="card-header"> - <p class="card-header-title">Naming</p> - </header> - <div class="card-content"> - <div class="content"> - - <b-field horizontal label="Name"> - <b-input name="name" v-model="byjove.name"></b-input> - </b-field> - - <b-field horizontal label="Slug"> - <b-input name="slug" v-model="byjove.slug"></b-input> - </b-field> - - </div> - </div> - </div> - - ${h.end_form()} - </div> - - <div v-if="projectType == 'fabric'"> - ${h.form(request.current_route_url(), ref='fabricForm')} - ${h.csrf_token(request)} - ${h.hidden('project_type', value='fabric')} - - <br /> - <div class="card"> - <header class="card-header"> - <p class="card-header-title">Naming</p> - </header> - <div class="card-content"> - <div class="content"> - - <b-field horizontal label="Name" - message="The "canonical" name generally used to refer to this project"> - <b-input name="name" v-model="fabric.name"></b-input> - </b-field> - - <b-field horizontal label="Slug" - message="Used for e.g. naming the project source code folder"> - <b-input name="slug" v-model="fabric.slug"></b-input> - </b-field> - - <b-field horizontal label="Organization" - message="For use with "branding" etc."> - <b-input name="organization" v-model="fabric.organization"></b-input> - </b-field> - - <b-field horizontal label="Package Name for PyPI" - message="It's a good idea to use org name as namespace prefix here"> - <b-input name="python_project_name" v-model="fabric.python_project_name"></b-input> - </b-field> - - <b-field horizontal label="Package Name in Python" - :message="`For example, ~/src/${'$'}{fabric.slug}/${'$'}{fabric.python_package_name}/__init__.py`"> - <b-input name="python_name" v-model="fabric.python_package_name"></b-input> - </b-field> - - </div> - </div> - </div> - - <br /> - <div class="card"> - <header class="card-header"> - <p class="card-header-title">Theo</p> - </header> - <div class="card-content"> - <div class="content"> - - <b-field horizontal label="Integrates With" - message="Which POS system should Theo integrate with, if any"> - <b-select name="integrates_with" v-model="fabric.integrates_with"> - <option value="">(nothing)</option> - <option value="catapult">ECRS Catapult</option> - <option value="corepos">CORE-POS</option> - ## <option value="locsms">LOC SMS</option> - </b-select> - </b-field> - - </div> - </div> - </div> - - ${h.end_form()} - </div> - - <br /> - <div class="buttons" style="padding-left: 8rem;"> - <b-button type="is-primary" - @click="submitProjectForm()"> - Generate Project - </b-button> - </div> - -</%def> - -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - - ThisPageData.projectType = 'rattail' - - ThisPageData.rattail = { - name: "Okay-Then", - slug: "okay-then", - organization: "Acme Foods", - python_project_name: "Acme-Okay-Then", - python_package_name: "okay_then", - has_rattail_db: true, - extends_rattail_db_schema: true, - uses_rattail_batch_schema: false, - has_tailbone_web_app: true, - has_tailbone_web_api: false, - has_datasync_service: false, - integrates_with_catapult: false, - integrates_with_corepos: false, - integrates_with_locsms: false, - uses_fabric: true, - } - - ThisPageData.rattail_integration = { - integration_name: "Foo", - integration_url: "https://www.example.com/", - python_project_name: "rattail-foo", - python_package_name: "rattail_foo", - extends_config: true, - extends_db: true, - } - - ThisPageData.tailbone_integration = { - integration_name: "Foo", - integration_url: "https://www.example.com/", - python_project_name: "tailbone-foo", - python_package_name: "tailbone_foo", - } - - ThisPageData.byjove = { - name: "Okay-Then-Mobile", - slug: "okay-then-mobile", - } - - ThisPageData.fabric = { - name: "AcmeFab", - slug: "acmefab", - organization: "Acme Foods", - python_project_name: "Acme-Fabric", - python_package_name: "acmefab", - integrates_with: '', - } - - ThisPage.methods.submitProjectForm = function() { - let form = this.$refs[this.projectType + 'Form'] - form.submit() - } - - </script> -</%def> - - -${parent.body()} diff --git a/tailbone/templates/generated-projects/create.mako b/tailbone/templates/generated-projects/create.mako new file mode 100644 index 00000000..32d205a0 --- /dev/null +++ b/tailbone/templates/generated-projects/create.mako @@ -0,0 +1,24 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/create.mako" /> + +<%def name="title()">${index_title}</%def> + +<%def name="content_title()"></%def> + +<%def name="page_content()"> + % if project_type: + <b-field grouped> + <b-field horizontal expanded label="Project Type"> + ${project_type} + </b-field> + <once-button type="is-primary" + tag="a" href="${url('generated_projects.create')}" + text="Start Over"> + </once-button> + </b-field> + % endif + ${parent.page_content()} +</%def> + + +${parent.body()} diff --git a/tailbone/views/batch/handheld.py b/tailbone/views/batch/handheld.py index d4f15ffd..03b9a441 100644 --- a/tailbone/views/batch/handheld.py +++ b/tailbone/views/batch/handheld.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,9 @@ Views for handheld batches """ -from __future__ import unicode_literals, absolute_import +from collections import OrderedDict from rattail.db import model -from rattail.util import OrderedDict import colander from webhelpers2.html import tags diff --git a/tailbone/views/batch/inventory.py b/tailbone/views/batch/inventory.py index e13dacca..b41a995e 100644 --- a/tailbone/views/batch/inventory.py +++ b/tailbone/views/batch/inventory.py @@ -27,12 +27,13 @@ Views for inventory batches import re import decimal import logging +from collections import OrderedDict from rattail import pod from rattail.db import model from rattail.db.util import make_full_description from rattail.gpc import GPC -from rattail.util import pretty_quantity, OrderedDict +from rattail.util import pretty_quantity import colander from deform import widget as dfwidget diff --git a/tailbone/views/batch/product.py b/tailbone/views/batch/product.py index 50b18953..dfe8d890 100644 --- a/tailbone/views/batch/product.py +++ b/tailbone/views/batch/product.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,9 @@ Views for generic product batches """ -from __future__ import unicode_literals, absolute_import +from collections import OrderedDict from rattail.db import model -from rattail.util import OrderedDict import colander from webhelpers2.html import HTML diff --git a/tailbone/views/common.py b/tailbone/views/common.py index 6de6bc2b..3882f357 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -25,9 +25,10 @@ Various common views """ import os +from collections import OrderedDict from rattail.batch import consume_batch_id -from rattail.util import OrderedDict, simple_error, import_module_path +from rattail.util import simple_error, import_module_path from rattail.files import resource_path from pyramid import httpexceptions diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 4f0411ac..ed0ed009 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -32,6 +32,7 @@ import getpass import shutil import tempfile import logging +from collections import OrderedDict import json import sqlalchemy as sa @@ -41,7 +42,7 @@ from sqlalchemy_utils.functions import get_primary_keys, get_columns from rattail.db import model, Session as RattailSession from rattail.db.continuum import model_transaction_query -from rattail.util import prettify, OrderedDict, simple_error +from rattail.util import prettify, simple_error, get_class_hierarchy from rattail.time import localtime from rattail.threads import Thread from rattail.csvutil import UnicodeDictWriter @@ -268,17 +269,7 @@ class MasterView(View): return labels def get_class_hierarchy(self): - hierarchy = [] - - def traverse(cls): - if cls is not object: - hierarchy.append(cls) - for parent in cls.__bases__: - traverse(parent) - - traverse(self.__class__) - hierarchy.reverse() - return hierarchy + return get_class_hierarchy(self.__class__) def set_row_labels(self, obj): labels = self.collect_row_labels() @@ -2215,8 +2206,9 @@ class MasterView(View): """ Returns the master view's index URL. """ - route = self.get_route_prefix() - return self.request.route_url(route, **kwargs) + if self.listable: + route = self.get_route_prefix() + return self.request.route_url(route, **kwargs) # TODO: this should not be class method, if possible # (pretty sure overriding as instance method works fine) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 9556f66d..3761941a 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -26,6 +26,7 @@ Person Views import datetime import logging +from collections import OrderedDict import sqlalchemy as sa from sqlalchemy import orm @@ -33,7 +34,7 @@ from sqlalchemy import orm from rattail.db import model, api from rattail.db.util import maxlen from rattail.time import localtime -from rattail.util import OrderedDict, simple_error +from rattail.util import simple_error import colander from pyramid.httpexceptions import HTTPFound, HTTPNotFound diff --git a/tailbone/views/principal.py b/tailbone/views/principal.py index 9effd2af..5d477677 100644 --- a/tailbone/views/principal.py +++ b/tailbone/views/principal.py @@ -25,9 +25,9 @@ """ import copy +from collections import OrderedDict from rattail.core import Object -from rattail.util import OrderedDict from webhelpers2.html import HTML diff --git a/tailbone/views/products.py b/tailbone/views/products.py index cc474840..ebec578e 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -26,7 +26,7 @@ Product Views import re import logging - +from collections import OrderedDict import humanize import sqlalchemy as sa from sqlalchemy import orm @@ -37,7 +37,7 @@ from rattail.db import model, api, auth, Session as RattailSession from rattail.gpc import GPC from rattail.threads import Thread from rattail.exceptions import LabelPrintingError -from rattail.util import load_object, pretty_quantity, OrderedDict, simple_error +from rattail.util import load_object, pretty_quantity, simple_error from rattail.time import localtime, make_utc import colander diff --git a/tailbone/views/projects.py b/tailbone/views/projects.py index 60b531c9..0cfcd349 100644 --- a/tailbone/views/projects.py +++ b/tailbone/views/projects.py @@ -24,207 +24,358 @@ Project views """ -import os -import zipfile -# from collections import OrderedDict +from collections import OrderedDict import colander +from deform import widget as dfwidget + +from rattail.projects import PythonProjectGenerator, PoserProjectGenerator from tailbone import forms -from tailbone.views import View +from tailbone.views import MasterView -class GenerateProject(colander.MappingSchema): - """ - Base schema for the "generate project" form - """ - name = colander.SchemaNode(colander.String()) - - slug = colander.SchemaNode(colander.String()) - - organization = colander.SchemaNode(colander.String()) - - python_project_name = colander.SchemaNode(colander.String()) - - python_name = colander.SchemaNode(colander.String()) - - has_db = colander.SchemaNode(colander.Boolean()) - - extends_db = colander.SchemaNode(colander.Boolean()) - - has_batch_schema = colander.SchemaNode(colander.Boolean()) - - has_web = colander.SchemaNode(colander.Boolean()) - - has_web_api = colander.SchemaNode(colander.Boolean()) - - has_datasync = colander.SchemaNode(colander.Boolean()) - - # has_filemon = colander.SchemaNode(colander.Boolean()) - - # has_tempmon = colander.SchemaNode(colander.Boolean()) - - # has_bouncer = colander.SchemaNode(colander.Boolean()) - - integrates_catapult = colander.SchemaNode(colander.Boolean()) - - integrates_corepos = colander.SchemaNode(colander.Boolean()) - - # integrates_instacart = colander.SchemaNode(colander.Boolean()) - - integrates_locsms = colander.SchemaNode(colander.Boolean()) - - # integrates_mailchimp = colander.SchemaNode(colander.Boolean()) - - uses_fabric = colander.SchemaNode(colander.Boolean()) - - -class GenerateRattailIntegrationProject(colander.MappingSchema): - """ - Schema to generate new rattail-integration project - """ - integration_name = colander.SchemaNode(colander.String()) - - integration_url = colander.SchemaNode(colander.String()) - - slug = colander.SchemaNode(colander.String()) - - python_project_name = colander.SchemaNode(colander.String()) - - python_name = colander.SchemaNode(colander.String()) - - extends_config = colander.SchemaNode(colander.Boolean()) - - extends_db = colander.SchemaNode(colander.Boolean()) - - -class GenerateTailboneIntegrationProject(colander.MappingSchema): - """ - Schema to generate new tailbone-integration project - """ - integration_name = colander.SchemaNode(colander.String()) - - integration_url = colander.SchemaNode(colander.String()) - - slug = colander.SchemaNode(colander.String()) - - python_project_name = colander.SchemaNode(colander.String()) - - python_name = colander.SchemaNode(colander.String()) - - has_static_files = colander.SchemaNode(colander.Boolean()) - - -class GenerateByjoveProject(colander.MappingSchema): - """ - Schema for generating a new 'byjove' project - """ - name = colander.SchemaNode(colander.String()) - - slug = colander.SchemaNode(colander.String()) - - -class GenerateFabricProject(colander.MappingSchema): - """ - Schema for generating a new 'fabric' project - """ - name = colander.SchemaNode(colander.String()) - - slug = colander.SchemaNode(colander.String()) - - organization = colander.SchemaNode(colander.String()) - - python_project_name = colander.SchemaNode(colander.String()) - - python_name = colander.SchemaNode(colander.String()) - - integrates_with = colander.SchemaNode(colander.String(), - missing=colander.null) - - -class GenerateProjectView(View): +class GeneratedProjectView(MasterView): """ View for generating new project source code """ + model_title = "Generated Project" + model_key = 'folder' + route_prefix = 'generated_projects' + url_prefix = '/generated-projects' + listable = False + viewable = False + editable = False + deletable = False def __init__(self, request): - super(GenerateProjectView, self).__init__(request) - self.project_handler = self.get_handler() - # TODO: deprecate / remove this - self.handler = self.project_handler + super(GeneratedProjectView, self).__init__(request) + self.project_handler = self.get_project_handler() - def get_handler(self): - from rattail.projects.handler import RattailProjectHandler - return RattailProjectHandler(self.rattail_config) + def get_project_handler(self): + app = self.get_rattail_app() + return app.get_project_handler() - def __call__(self): + def create(self): + supported = self.project_handler.get_supported_project_generators() + supported_keys = list(supported) - # choices = OrderedDict([ - # ('has_db', {'prompt': "Does project need its own Rattail DB?", - # 'type': 'bool'}), - # ]) + project_type = self.request.matchdict.get('project_type') + if project_type: + form = self.make_project_form(project_type) + if form.validate(newstyle=True): + zipped = self.generate_project(project_type, form) + return self.file_response(zipped) - project_type = 'rattail' - if self.request.method == 'POST': - project_type = self.request.POST.get('project_type', 'rattail') - if project_type not in self.project_handler.get_supported_project_types(): - raise ValueError("Unknown project type: {}".format(project_type)) + else: # no project_type - if project_type == 'byjove': - schema = GenerateByjoveProject - elif project_type == 'fabric': - schema = GenerateFabricProject - elif project_type == 'rattail_integration': - schema = GenerateRattailIntegrationProject - elif project_type == 'tailbone_integration': - schema = GenerateTailboneIntegrationProject - else: - schema = GenerateProject - form = forms.Form(schema=schema(), request=self.request) - if form.validate(newstyle=True): - zipped = self.generate_project(project_type, form) - return self.file_response(zipped) - # self.request.session.flash("New project was generated: {}".format(form.validated['name'])) - # return self.redirect(self.request.current_route_url()) + # make form to accept user choice of report type + schema = colander.Schema() + values = [(typ, typ) for typ in supported_keys] + schema.add(colander.SchemaNode(name='project_type', + typ=colander.String(), + validator=colander.OneOf(supported_keys), + widget=dfwidget.SelectWidget(values=values))) + form = forms.Form(schema=schema, request=self.request) + form.submit_label = "Continue" - return { + # if form validates, then user has chosen a project type, so + # we redirect to the appropriate "generate project" page + if form.validate(newstyle=True): + raise self.redirect(self.request.route_url( + 'generate_specific_project', + project_type=form.validated['project_type'])) + + return self.render_to_response('create', { 'index_title': "Generate Project", - 'handler': self.handler, - # 'choices': choices, - } + 'project_type': project_type, + 'form': form, + }) def generate_project(self, project_type, form): - options = form.validated - slug = options['slug'] - path = self.handler.generate_project(project_type, slug, options) + context = dict(form.validated) + output = self.project_handler.generate_project(project_type, + context=context) + return self.project_handler.zip_output(output) - zipped = '{}.zip'.format(path) - with zipfile.ZipFile(zipped, 'w', zipfile.ZIP_DEFLATED) as z: - self.zipdir(z, path, slug) - return zipped + def make_project_form(self, project_type): - def zipdir(self, zipf, path, slug): - for root, dirs, files in os.walk(path): - relative_root = os.path.join(slug, root[len(path)+1:]) - for fname in files: - zipf.write(os.path.join(root, fname), - arcname=os.path.join(relative_root, fname)) + # make form + schema = self.project_handler.make_project_schema(project_type) + form = forms.Form(schema=schema, request=self.request) + form.auto_disable = False + form.auto_disable_save = False + form.submit_label = "Generate Project" + form.cancel_url = self.request.route_url('generated_projects.create') + + # apply normal config + self.configure_form_common(form, project_type) + + # let supplemental views further configure form + for supp in self.iter_view_supplements(): + configure = getattr(supp, 'configure_form_{}'.format(project_type), None) + if configure: + configure(form) + + # if master view has more configure logic, do that too + configure = getattr(self, 'configure_form_{}'.format(project_type), None) + if configure: + configure(form) + + return form + + def configure_form_common(self, form, project_type): + generator = self.project_handler.get_project_generator(project_type, + require=True) + + # python-based projects + if isinstance(generator, PythonProjectGenerator): + self.configure_form_python(form) + + # poser-based projects + if isinstance(generator, PoserProjectGenerator): + self.configure_form_poser(form) + + def configure_form_python(self, f): + + f.set_grouping([ + ("Naming", [ + 'name', + 'pkg_name', + 'pypi_name', + ]), + ]) + + # name + f.set_label('name', "Project Name") + f.set_helptext('name', "Human-friendly name generally used to refer to this project.") + f.set_default('name', "Poser Plus") + + # pkg_name + f.set_label('pkg_name', "Package Name in Python") + f.set_helptext('pkg_name', "`For example, ~/src/${field_model_pkg_name.replace(/_/g, '-')}/${field_model_pkg_name}/__init__.py`", + dynamic=True) + f.set_default('pkg_name', "poser_plus") + + # pypi_name + f.set_label('pypi_name', "Package Name for PyPI") + f.set_helptext('pypi_name', "It's a good idea to use org name as namespace prefix here") + f.set_default('pypi_name', "Acme-Poser-Plus") + + def configure_form_poser(self, f): + + # extends_config + f.set_label('extends_config', "Extend Config") + f.set_helptext('extends_config', "Needed to customize default config values etc.") + f.set_default('extends_config', True) + + # has_cli + f.set_label('has_cli', "Use Separate CLI") + f.set_helptext('has_cli', "`Needed for e.g. '${field_model_pkg_name} install' command.`", + dynamic=True) + f.set_default('has_cli', True) + + # organization + f.set_helptext('organization', 'For use with branding etc.') + f.set_default('organization', "Acme Foods") + + # has_db + f.set_label('has_db', "Use Rattail DB") + f.set_helptext('has_db', "Note that a DB is required for the Web App") + f.set_default('has_db', True) + + # extends_db + f.set_label('extends_db', "Extend DB Schema") + f.set_helptext('extends_db', "For adding custom tables/columns to the core schema") + f.set_default('extends_db', True) + + # has_batch_schema + f.set_label('has_batch_schema', "Add Batch Schema") + f.set_helptext('has_batch_schema', 'Usually not needed - it\'s for "dynamic" (e.g. import/export) batches') + + # has_web + f.set_label('has_web', "Use Tailbone Web App") + f.set_default('has_web', True) + + # has_web_api + f.set_label('has_web_api', "Use Tailbone Web API") + f.set_helptext('has_web_api', "Needed for e.g. Vue.js SPA mobile apps") + + # has_datasync + f.set_label('has_datasync', "Use DataSync Service") + + # uses_fabric + f.set_label('uses_fabric', "Use Fabric") + f.set_default('uses_fabric', True) + + def configure_form_rattail(self, f): + + f.set_grouping([ + ("Naming", [ + 'name', + 'pkg_name', + 'pypi_name', + 'organization', + ]), + ("Core", [ + 'extends_config', + 'has_cli', + ]), + ("Database", [ + 'has_db', + 'extends_db', + 'has_batch_schema', + ]), + ("Web", [ + 'has_web', + 'has_web_api', + ]), + ("Integrations", [ + # 'integrates_catapult', + # 'integrates_corepos', + # 'integrates_locsms', + 'has_datasync', + ]), + ("Deployment", [ + 'uses_fabric', + ]), + ]) + + # # integrates_catapult + # f.set_label('integrates_catapult', "Integrate w/ Catapult") + # f.set_helptext('integrates_catapult', "Add schema, import/export logic etc. for ECRS Catapult") + + # # integrates_corepos + # f.set_label('integrates_corepos', "Integrate w/ CORE-POS") + # f.set_helptext('integrates_corepos', "Add schema, import/export logic etc. for CORE-POS") + + # # integrates_locsms + # f.set_label('integrates_locsms', "Integrate w/ LOC SMS") + # f.set_helptext('integrates_locsms', "Add schema, import/export logic etc. for LOC SMS") + + def configure_form_rattail_integration(self, f): + + f.set_grouping([ + ("Naming", [ + 'integration_name', + 'integration_url', + 'name', + 'pkg_name', + 'pypi_name', + ]), + ("Options", [ + 'extends_config', + 'extends_db', + ]), + ]) + + # integration_name + f.set_helptext('integration_name', "Name of the system to be integrated") + f.set_default('integration_name', "Foo") + + # integration_url + f.set_label('integration_url', "Integration URL") + f.set_helptext('integration_url', "Reference URL for the system to be integrated") + f.set_default('integration_url', "https://www.example.com/") + + def configure_form_tailbone_integration(self, f): + + f.set_grouping([ + ("Naming", [ + 'integration_name', + 'integration_url', + 'name', + 'pkg_name', + 'pypi_name', + ]), + ("Options", [ + 'has_static_files', + ]), + ]) + + # integration_name + f.set_helptext('integration_name', "Name of the system to be integrated") + f.set_default('integration_name', "Foo") + + # integration_url + f.set_label('integration_url', "Integration URL") + f.set_helptext('integration_url', "Reference URL for the system to be integrated") + f.set_default('integration_url', "https://www.example.com/") + + # has_static_files + f.set_helptext('has_static_files', "Register a subfolder for static files (images etc.)") + + def configure_form_byjove(self, f): + + f.set_grouping([ + ("Naming", [ + 'name', + 'slug', + ]), + ]) + + # name + f.set_default('name', "Okay Then Mobile") + + # slug + f.set_default('slug', "okay-then-mobile") + + def configure_form_fabric(self, f): + + f.set_grouping([ + ("Naming", [ + 'name', + 'pkg_name', + 'pypi_name', + 'organization', + ]), + ("Theo", [ + 'integrates_with', + ]), + ]) + + # naming defaults + f.set_default('name', "Acme Fabric") + f.set_default('pkg_name', "acmefab") + f.set_default('pypi_name', "Acme-Fabric") + + # organization + f.set_helptext('organization', 'For use with branding etc.') + f.set_default('organization', "Acme Foods") + + # integrates_with + f.set_helptext('integrates_with', "Which POS system should Theo integrate with, if any") + f.set_enum('integrates_with', OrderedDict([ + ('', "(nothing)"), + ('catapult', "ECRS Catapult"), + ('corepos', "CORE-POS"), + ('locsms', "LOC SMS") + ])) + f.set_default('integrates_with', '') @classmethod def defaults(cls, config): - config.add_tailbone_permission('common', 'common.generate_project', - "Generate new project source code") - config.add_route('generate_project', '/generate-project') - config.add_view(cls, route_name='generate_project', - permission='common.generate_project', - renderer='/generate_project.mako') + cls._defaults(config) + cls._generated_project_defaults(config) + + @classmethod + def _generated_project_defaults(cls, config): + url_prefix = cls.get_url_prefix() + permission_prefix = cls.get_permission_prefix() + + # generate project (accept custom params, truly create) + config.add_route('generate_specific_project', + '{}/new/{{project_type}}'.format(url_prefix)) + config.add_view(cls, attr='create', + route_name='generate_specific_project', + permission='{}.create'.format(permission_prefix)) def defaults(config, **kwargs): base = globals() - GenerateProjectView = kwargs.get('GenerateProjectView', base['GenerateProjectView']) - GenerateProjectView.defaults(config) + GeneratedProjectView = kwargs.get('GeneratedProjectView', base['GeneratedProjectView']) + GeneratedProjectView.defaults(config) def includeme(config): diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index b180a9a7..511f8164 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -28,6 +28,7 @@ import os import re import decimal import logging +from collections import OrderedDict import humanize import sqlalchemy as sa @@ -35,7 +36,7 @@ import sqlalchemy as sa from rattail import pod from rattail.db import model, Session as RattailSession from rattail.time import localtime, make_utc -from rattail.util import pretty_quantity, prettify, OrderedDict, simple_error +from rattail.util import pretty_quantity, prettify, simple_error from rattail.threads import Thread import colander diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index d3345b75..5ded5c5f 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -29,13 +29,14 @@ import json import re import datetime import logging +from collections import OrderedDict import rattail from rattail.db import model, Session as RattailSession from rattail.files import resource_path from rattail.time import localtime from rattail.threads import Thread -from rattail.util import simple_error, OrderedDict +from rattail.util import simple_error import colander from deform import widget as dfwidget diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index 5677f579..472ea199 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -28,12 +28,13 @@ import os import re import subprocess import sys +from collections import OrderedDict import json from rattail.db import model from rattail.settings import Setting -from rattail.util import import_module_path, OrderedDict +from rattail.util import import_module_path import colander diff --git a/tailbone/views/tables.py b/tailbone/views/tables.py index 75a61086..d4b9ee8b 100644 --- a/tailbone/views/tables.py +++ b/tailbone/views/tables.py @@ -28,6 +28,7 @@ import os import sys import warnings +import sqlalchemy as sa from sqlalchemy_utils import get_mapper from rattail.util import simple_error @@ -96,8 +97,8 @@ class TableView(MasterView): where schemaname = 'public' order by n_live_tup desc; """ - result = self.Session.execute(sql) - return [dict(table_name=row['relname'], row_count=row['n_live_tup']) + result = self.Session.execute(sa.text(sql)) + return [dict(table_name=row.relname, row_count=row.n_live_tup) for row in result] def configure_grid(self, g): diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index f6df80d3..eddd677c 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.py @@ -29,6 +29,7 @@ import os import re import logging import warnings +from collections import OrderedDict import sqlalchemy as sa @@ -36,7 +37,6 @@ 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.util import OrderedDict from deform import widget as dfwidget from webhelpers2.html import tags, HTML From 62bdf8262718b2ea01ea4100570778b1ec560141 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 5 May 2023 10:39:29 -0500 Subject: [PATCH 1067/1681] Include project views by default, in "essential" views --- tailbone/views/essentials.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tailbone/views/essentials.py b/tailbone/views/essentials.py index a8ded812..08d2e0c4 100644 --- a/tailbone/views/essentials.py +++ b/tailbone/views/essentials.py @@ -24,8 +24,6 @@ Essential views for convenient includes """ -from __future__ import unicode_literals, absolute_import - def defaults(config, **kwargs): mod = lambda spec: kwargs.get(spec, spec) @@ -48,6 +46,14 @@ def defaults(config, **kwargs): config.include(mod('tailbone.views.users')) config.include(mod('tailbone.views.views')) + # include project views by default, but let caller avoid that by + # passing False + projects = kwargs.get('tailbone.views.projects', True) + if projects: + if projects is True: + projects = 'tailbone.views.projects' + config.include(projects) + def includeme(config): defaults(config) From 50d1bbbe4d2458f6d7b289cfc04212c30f42ed34 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 5 May 2023 13:30:17 -0500 Subject: [PATCH 1068/1681] Add "rattail-adjacent" logic for generating projects --- tailbone/views/projects.py | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/tailbone/views/projects.py b/tailbone/views/projects.py index 0cfcd349..8dc119f1 100644 --- a/tailbone/views/projects.py +++ b/tailbone/views/projects.py @@ -29,7 +29,9 @@ from collections import OrderedDict import colander from deform import widget as dfwidget -from rattail.projects import PythonProjectGenerator, PoserProjectGenerator +from rattail.projects import (PythonProjectGenerator, + PoserProjectGenerator, + RattailAdjacentProjectGenerator) from tailbone import forms from tailbone.views import MasterView @@ -132,6 +134,10 @@ class GeneratedProjectView(MasterView): if isinstance(generator, PythonProjectGenerator): self.configure_form_python(form) + # rattail-adjacent projects + if isinstance(generator, RattailAdjacentProjectGenerator): + self.configure_form_rattail_adjacent(form) + # poser-based projects if isinstance(generator, PoserProjectGenerator): self.configure_form_poser(form) @@ -162,7 +168,7 @@ class GeneratedProjectView(MasterView): f.set_helptext('pypi_name', "It's a good idea to use org name as namespace prefix here") f.set_default('pypi_name', "Acme-Poser-Plus") - def configure_form_poser(self, f): + def configure_form_rattail_adjacent(self, f): # extends_config f.set_label('extends_config', "Extend Config") @@ -175,6 +181,13 @@ class GeneratedProjectView(MasterView): dynamic=True) f.set_default('has_cli', True) + # extends_db + f.set_label('extends_db', "Extend DB Schema") + f.set_helptext('extends_db', "For adding custom tables/columns to the core schema") + f.set_default('extends_db', True) + + def configure_form_poser(self, f): + # organization f.set_helptext('organization', 'For use with branding etc.') f.set_default('organization', "Acme Foods") @@ -184,11 +197,6 @@ class GeneratedProjectView(MasterView): f.set_helptext('has_db', "Note that a DB is required for the Web App") f.set_default('has_db', True) - # extends_db - f.set_label('extends_db', "Extend DB Schema") - f.set_helptext('extends_db', "For adding custom tables/columns to the core schema") - f.set_default('extends_db', True) - # has_batch_schema f.set_label('has_batch_schema', "Add Batch Schema") f.set_helptext('has_batch_schema', 'Usually not needed - it\'s for "dynamic" (e.g. import/export) batches') @@ -266,9 +274,16 @@ class GeneratedProjectView(MasterView): ("Options", [ 'extends_config', 'extends_db', + 'has_cli', ]), ]) + # default settings + f.set_default('name', 'rattail-foo') + f.set_default('pkg_name', 'rattail_foo') + f.set_default('pypi_name', 'rattail-foo') + f.set_default('has_cli', False) + # integration_name f.set_helptext('integration_name', "Name of the system to be integrated") f.set_default('integration_name', "Foo") From 2f5e01c9e9e190261ab1acbd016c9cc315343b78 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 5 May 2023 19:10:54 -0500 Subject: [PATCH 1069/1681] 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 8cb0fd98..3fcf9b30 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.9.19 (2023-05-05) +------------------- + +* Massive overhaul of "generate project" feature. + +* Include project views by default, in "essential" views. + + 0.9.18 (2023-05-03) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index a1a9c6bb..31ec307b 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.18' +__version__ = '0.9.19' From 8fcef1fb4d586f76c2329dc06b2f6c4e64889706 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 8 May 2023 21:43:19 -0500 Subject: [PATCH 1070/1681] Add form config for generating 'shopfoo' projects --- tailbone/views/projects.py | 46 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tailbone/views/projects.py b/tailbone/views/projects.py index 8dc119f1..a412c388 100644 --- a/tailbone/views/projects.py +++ b/tailbone/views/projects.py @@ -293,6 +293,31 @@ class GeneratedProjectView(MasterView): f.set_helptext('integration_url', "Reference URL for the system to be integrated") f.set_default('integration_url', "https://www.example.com/") + def configure_form_rattail_shopfoo(self, f): + + # first do normal integration setup + self.configure_form_rattail_integration(f) + + f.set_grouping([ + ("Naming", [ + 'integration_name', + 'integration_url', + 'name', + 'pkg_name', + 'pypi_name', + ]), + ("Options", [ + 'has_cli', + ]), + ]) + + # default settings + f.set_default('integration_name', 'Shopfoo') + f.set_default('name', 'rattail-shopfoo') + f.set_default('pkg_name', 'rattail_shopfoo') + f.set_default('pypi_name', 'rattail-shopfoo') + f.set_default('has_cli', False) + def configure_form_tailbone_integration(self, f): f.set_grouping([ @@ -320,6 +345,27 @@ class GeneratedProjectView(MasterView): # has_static_files f.set_helptext('has_static_files', "Register a subfolder for static files (images etc.)") + def configure_form_tailbone_shopfoo(self, f): + + # first do normal integration setup + self.configure_form_tailbone_integration(f) + + f.set_grouping([ + ("Naming", [ + 'integration_name', + 'integration_url', + 'name', + 'pkg_name', + 'pypi_name', + ]), + ]) + + # default settings + f.set_default('integration_name', 'Shopfoo') + f.set_default('name', 'tailbone-shopfoo') + f.set_default('pkg_name', 'tailbone_shopfoo') + f.set_default('pypi_name', 'tailbone-shopfoo') + def configure_form_byjove(self, f): f.set_grouping([ From dcc78194662fa00b0d30785a7c701f9647e3707a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 9 May 2023 20:25:05 -0500 Subject: [PATCH 1071/1681] Misc. tweaks for "run import job" form --- tailbone/views/importing.py | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/tailbone/views/importing.py b/tailbone/views/importing.py index bfbd82e9..acfddbf8 100644 --- a/tailbone/views/importing.py +++ b/tailbone/views/importing.py @@ -295,15 +295,42 @@ class ImportingView(MasterView): f.set_widget('models', dfwidget.SelectWidget(values=[(k, k) for k in keys], multiple=True, size=len(keys))) - # f.set_default('models', keys) - f.set_default('create', True) - f.set_default('update', True) - f.set_default('delete', False) + allow_create = True + allow_update = True + allow_delete = True + if len(keys) == 1: + importers = handler.get_importers().values() + importer = list(importers)[0] + allow_create = importer.allow_create + allow_update = importer.allow_update + allow_delete = importer.allow_delete + + if allow_create: + f.set_default('create', True) + else: + f.remove('create') + + if allow_update: + f.set_default('update', True) + else: + f.remove('update') + + if allow_delete: + f.set_default('delete', False) + else: + f.remove('delete') + # f.set_default('runas', self.rattail_config.get('rattail', 'runas.default') or '') + f.set_default('versioning', True) + f.set_helptext('versioning', "If set, version history will be updated as appropriate") + f.set_default('dry_run', False) + f.set_helptext('dry_run', "If set, data will not actually be written") + f.set_default('warnings', False) + f.set_helptext('warnings', "If set, will send an email if any diffs") def do_runjob(self, handler_info, form): handler = handler_info['_handler'] From f942716bf90c1586407ab71bb4f90df7a82fd5da Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 9 May 2023 20:31:43 -0500 Subject: [PATCH 1072/1681] 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 3fcf9b30..c9de169c 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.9.20 (2023-05-09) +------------------- + +* Add form config for generating 'shopfoo' projects. + +* Misc. tweaks for "run import job" form. + + 0.9.19 (2023-05-05) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 31ec307b..08844f91 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.19' +__version__ = '0.9.20' From 82656f263d39f32df93c59587d5000a45f0cc16c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 10 May 2023 18:47:11 -0500 Subject: [PATCH 1073/1681] Move row delete check logic for receiving to batch handler --- tailbone/views/purchasing/receiving.py | 21 ++------------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 511f8164..4632723d 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -84,7 +84,6 @@ class ReceivingBatchView(PurchasingBatchView): rows_editable = False rows_editable_but_not_directly = True - rows_deletable = True default_uom_is_case = True @@ -379,24 +378,8 @@ class ReceivingBatchView(PurchasingBatchView): if not super(ReceivingBatchView, self).row_deletable(row): return False - batch = row.batch - - # can always delete rows from truck dump parent - if batch.is_truck_dump_parent(): - return True - - # can always delete rows from truck dump child - elif batch.is_truck_dump_child(): - return True - - else: # okay, normal batch - if batch.order_quantities_known: - return False - else: # allow delete if receiving rom scratch - return True - - # cannot delete row by default - return False + # otherwise let handler decide + return self.batch_handler.is_row_deletable(row) def get_instance_title(self, batch): title = super(ReceivingBatchView, self).get_instance_title(batch) From f49b4d1b8bdc3eb30a49901026511bd9e7dacbd4 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 10 May 2023 20:20:30 -0500 Subject: [PATCH 1074/1681] 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 c9de169c..aae80e4b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.9.21 (2023-05-10) +------------------- + +* Move row delete check logic for receiving to batch handler. + + 0.9.20 (2023-05-09) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 08844f91..a5e0fde2 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.20' +__version__ = '0.9.21' From f5f973dc3a188ddb0b3f70449678739dc44429ea Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 12 May 2023 19:21:48 -0500 Subject: [PATCH 1075/1681] Tweak button wording in "find role by perm" form --- tailbone/templates/principal/find_by_perm.mako | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tailbone/templates/principal/find_by_perm.mako b/tailbone/templates/principal/find_by_perm.mako index 097597fc..9cc5aa05 100644 --- a/tailbone/templates/principal/find_by_perm.mako +++ b/tailbone/templates/principal/find_by_perm.mako @@ -43,8 +43,7 @@ <div class="buttons"> <once-button tag="a" href="${request.current_route_url(_query=None)}" - icon-left="ban" - text="Reset"> + text="Reset Form"> </once-button> <b-button type="is-primary" native-type="submit" From 29817653ed66b442f10c959c13153f83b2d87437 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 12 May 2023 21:27:15 -0500 Subject: [PATCH 1076/1681] Warn user if DB not up to date, in new table wizard also start adding 'dirty' page behavior, to warn user if navigating away that changes will be lost also improve steps in wizard, so page header is scrolled into view when prev/next buttons are clicked. unfortunately it still does not work right if user clicks the step number on left of screen.. --- tailbone/templates/tables/create.mako | 96 +++++++++++++++++------ tailbone/templates/tables/index.mako | 12 +++ tailbone/templates/tables/migrations.mako | 11 +++ tailbone/views/tables.py | 18 +++++ 4 files changed, 115 insertions(+), 22 deletions(-) create mode 100644 tailbone/templates/tables/index.mako create mode 100644 tailbone/templates/tables/migrations.mako diff --git a/tailbone/templates/tables/create.mako b/tailbone/templates/tables/create.mako index dfe6cc45..4fc2eb96 100644 --- a/tailbone/templates/tables/create.mako +++ b/tailbone/templates/tables/create.mako @@ -11,8 +11,25 @@ </%def> <%def name="render_this_page()"> + + ## scroll target used when navigating prev/next + <div ref="showme"></div> + + % if not alembic_current_head: + <b-notification type="is-warning" + :closable="false"> + <p class="block"> + DB is not up to date! There are + ${h.link_to("pending migrations", url('{}.migrations'.format(route_prefix)))}. + </p> + <p class="block"> + (This will be a problem if you wish to auto-generate a migration for a new table.) + </p> + </b-notification> + % endif + <b-steps v-model="activeStep" - :animated="false" + animated rounded :has-navigation="false" vertical @@ -28,7 +45,8 @@ <b-field label="Schema Branch" message="Leave this set to your custom app branch, unless you know what you're doing."> - <b-select v-model="alembicBranch"> + <b-select v-model="alembicBranch" + @input="dirty = true"> <option v-for="branch in alembicBranchOptions" :key="branch" :value="branch"> @@ -41,13 +59,15 @@ <b-field label="Table Name" message="Should be singular in nature, i.e. 'widget' not 'widgets'"> - <b-input v-model="tableName"> + <b-input v-model="tableName" + @input="dirty = true"> </b-input> </b-field> <b-field label="Model/Class Name" message="Should be singular in nature, i.e. 'Widget' not 'Widgets'"> - <b-input v-model="tableModelName"> + <b-input v-model="tableModelName" + @input="dirty = true"> </b-input> </b-field> @@ -57,13 +77,15 @@ <b-field label="Model Title" message="Human-friendly singular model title."> - <b-input v-model="tableModelTitle"> + <b-input v-model="tableModelTitle" + @input="dirty = true"> </b-input> </b-field> <b-field label="Model Title Plural" message="Human-friendly plural model title."> - <b-input v-model="tableModelTitlePlural"> + <b-input v-model="tableModelTitlePlural" + @input="dirty = true"> </b-input> </b-field> @@ -71,12 +93,14 @@ <b-field label="Description" message="Brief description of what a record in this table represents."> - <b-input v-model="tableDescription"> + <b-input v-model="tableDescription" + @input="dirty = true"> </b-input> </b-field> <b-field> - <b-checkbox v-model="tableVersioned"> + <b-checkbox v-model="tableVersioned" + @input="dirty = true"> Record version data for this table </b-checkbox> </b-field> @@ -285,7 +309,7 @@ <b-button type="is-primary" icon-pack="fas" icon-left="check" - @click="activeStep = 'write-model'"> + @click="showStep('write-model')"> Details are complete </b-button> </div> @@ -325,7 +349,7 @@ <div class="buttons"> <b-button icon-pack="fas" icon-left="arrow-left" - @click="activeStep = 'enter-details'"> + @click="showStep('enter-details')"> Back </b-button> <b-button type="is-primary" @@ -337,7 +361,7 @@ </b-button> <b-button icon-pack="fas" icon-left="arrow-right" - @click="activeStep = 'review-model'"> + @click="showStep('review-model')"> Skip </b-button> </div> @@ -435,19 +459,19 @@ <div class="buttons"> <b-button icon-pack="fas" icon-left="arrow-left" - @click="activeStep = 'write-model'"> + @click="showStep('write-model')"> Back </b-button> <b-button type="is-primary" icon-pack="fas" icon-left="check" - @click="activeStep = 'write-revision'" + @click="showStep('write-revision')" :disabled="!modelImported"> Model class looks good! </b-button> <b-button icon-pack="fas" icon-left="arrow-right" - @click="activeStep = 'write-revision'"> + @click="showStep('write-revision')"> Skip </b-button> </div> @@ -486,7 +510,7 @@ <div class="buttons"> <b-button icon-pack="fas" icon-left="arrow-left" - @click="activeStep = 'review-model'"> + @click="showStep('review-model')"> Back </b-button> <b-button type="is-primary" @@ -498,7 +522,7 @@ </b-button> <b-button icon-pack="fas" icon-left="arrow-right" - @click="activeStep = 'review-revision'"> + @click="showStep('review-revision')"> Skip </b-button> </div> @@ -526,13 +550,13 @@ <div class="buttons"> <b-button icon-pack="fas" icon-left="arrow-left" - @click="activeStep = 'write-revision'"> + @click="showStep('write-revision')"> Back </b-button> <b-button type="is-primary" icon-pack="fas" icon-left="check" - @click="activeStep = 'upgrade-db'"> + @click="showStep('upgrade-db')"> Revision script looks good! </b-button> </div> @@ -553,7 +577,7 @@ <div class="buttons"> <b-button icon-pack="fas" icon-left="arrow-left" - @click="activeStep = 'review-revision'"> + @click="showStep('review-revision')"> Back </b-button> <b-button type="is-primary" @@ -627,13 +651,13 @@ <div class="buttons"> <b-button icon-pack="fas" icon-left="arrow-left" - @click="activeStep = 'upgrade-db'"> + @click="showStep('upgrade-db')"> Back </b-button> <b-button type="is-primary" icon-pack="fas" icon-left="check" - @click="activeStep = 'commit-code'" + @click="showStep('commit-code')" :disabled="!tableCheckAttempted || tableCheckProblem"> DB looks good! </b-button> @@ -658,7 +682,7 @@ <div class="buttons"> <b-button icon-pack="fas" icon-left="arrow-left" - @click="activeStep = 'review-db'"> + @click="showStep('review-db')"> Back </b-button> <once-button type="is-primary" @@ -675,6 +699,9 @@ ${parent.modify_this_page_vars()} <script type="text/javascript"> + // nb. for warning user they may lose changes if leaving page + ThisPageData.dirty = false + ThisPageData.activeStep = null ThisPageData.alembicBranchOptions = ${json.dumps(branch_name_options)|n} @@ -713,6 +740,15 @@ ThisPageData.editingColumnVersioned = true ThisPageData.editingColumnRelationship = null + ThisPage.methods.showStep = function(step) { + this.activeStep = step + + // scroll so top of page is shown + this.$nextTick(() => { + this.$refs['showme'].scrollIntoView(true) + }) + } + ThisPage.methods.tableAddColumn = function() { this.editingColumn = null this.editingColumnName = null @@ -801,12 +837,14 @@ column.versioned = this.editingColumnVersioned column.relationship = this.editingColumnRelationship + this.dirty = true this.editingColumnShowDialog = false } ThisPage.methods.tableDeleteColumn = function(index) { if (confirm("Really delete this column?")) { this.tableColumns.splice(index, 1) + this.dirty = true } } @@ -929,6 +967,20 @@ }) } + // cf. https://stackoverflow.com/a/56551646 + ThisPage.methods.beforeWindowUnload = function(e) { + + // warn user if navigating away would lose changes + if (this.dirty) { + e.preventDefault() + e.returnValue = '' + } + } + + ThisPage.created = function() { + window.addEventListener('beforeunload', this.beforeWindowUnload) + } + </script> </%def> diff --git a/tailbone/templates/tables/index.mako b/tailbone/templates/tables/index.mako new file mode 100644 index 00000000..b13f0785 --- /dev/null +++ b/tailbone/templates/tables/index.mako @@ -0,0 +1,12 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/index.mako" /> + +<%def name="context_menu_items()"> + ${parent.context_menu_items()} + % if master.has_perm('migrations'): + <li>${h.link_to("View / Apply Migrations", url('{}.migrations'.format(route_prefix)))}</li> + % endif +</%def> + + +${parent.body()} diff --git a/tailbone/templates/tables/migrations.mako b/tailbone/templates/tables/migrations.mako new file mode 100644 index 00000000..af1734eb --- /dev/null +++ b/tailbone/templates/tables/migrations.mako @@ -0,0 +1,11 @@ +## -*- coding: utf-8; -*- +<%inherit file="/page.mako" /> + +<%def name="title()">Schema Migrations</%def> + +<%def name="render_this_page()"> + <h3 class="is-size-3">TODO: show current revisions and allow DB upgrades</h3> +</%def> + + +${parent.body()} diff --git a/tailbone/views/tables.py b/tailbone/views/tables.py index d4b9ee8b..962dbf50 100644 --- a/tailbone/views/tables.py +++ b/tailbone/views/tables.py @@ -194,6 +194,8 @@ class TableView(MasterView): app = self.get_rattail_app() model = self.model + kwargs['alembic_current_head'] = self.db_handler.check_alembic_current_head() + kwargs['branch_name_options'] = self.db_handler.get_alembic_branch_names() branch_name = app.get_table_prefix() @@ -331,6 +333,11 @@ class TableView(MasterView): return HTML.tag('span', title=text, c="{} ...".format(text[:max_length])) + def migrations(self): + # TODO: allow alembic upgrade on POST + # TODO: pass current revisions to page context + return self.render_to_response('migrations', {}) + @classmethod def defaults(cls, config): rattail_config = config.registry.settings.get('rattail_config') @@ -348,6 +355,17 @@ class TableView(MasterView): url_prefix = cls.get_url_prefix() permission_prefix = cls.get_permission_prefix() + # migrations + config.add_tailbone_permission(permission_prefix, + '{}.migrations'.format(permission_prefix), + "View / apply Alembic migrations") + config.add_route('{}.migrations'.format(route_prefix), + '{}/migrations'.format(url_prefix)) + config.add_view(cls, attr='migrations', + route_name='{}.migrations'.format(route_prefix), + renderer='json', + permission='{}.migrations'.format(permission_prefix)) + if cls.creatable: # write model class to file From a991dc068450a130ae7b704382a4e4a1ba6d12c7 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 13 May 2023 16:57:36 -0500 Subject: [PATCH 1077/1681] 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 aae80e4b..25ca1da5 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.9.22 (2023-05-13) +------------------- + +* Tweak button wording in "find role by perm" form. + +* Warn user if DB not up to date, in new table wizard. + + 0.9.21 (2023-05-10) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index a5e0fde2..b6cfd77e 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.21' +__version__ = '0.9.22' From 85947878c4bd6ade54fd60872252dd9279fc813b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 14 May 2023 20:28:48 -0500 Subject: [PATCH 1078/1681] Get rid of `newstyle` flag for `Form.validate()` method we always/only use "new style" now --- tailbone/api/batch/receiving.py | 2 +- tailbone/api/common.py | 2 +- tailbone/forms/core.py | 81 +++++++++++++------------- tailbone/views/auth.py | 4 +- tailbone/views/batch/core.py | 6 +- tailbone/views/batch/inventory.py | 2 +- tailbone/views/common.py | 2 +- tailbone/views/features.py | 4 +- tailbone/views/master.py | 12 ++-- tailbone/views/people.py | 6 +- tailbone/views/products.py | 4 +- tailbone/views/projects.py | 4 +- tailbone/views/purchasing/costing.py | 2 +- tailbone/views/purchasing/receiving.py | 6 +- tailbone/views/reports.py | 4 +- tailbone/views/settings.py | 2 +- tailbone/views/shifts/lib.py | 4 +- 17 files changed, 75 insertions(+), 72 deletions(-) diff --git a/tailbone/api/batch/receiving.py b/tailbone/api/batch/receiving.py index 53d5f98a..9a6864db 100644 --- a/tailbone/api/batch/receiving.py +++ b/tailbone/api/batch/receiving.py @@ -407,7 +407,7 @@ class ReceivingBatchRowViews(APIBatchRowView): form = forms.Form(schema=schema, request=self.request) # TODO: this seems hacky, but avoids "complex" date value parsing form.set_widget('expiration_date', dfwidget.TextInputWidget()) - if not form.validate(newstyle=True): + if not form.validate(): log.debug("form did not validate: %s", form.make_deform_form().error) return {'error': "Form did not validate"} diff --git a/tailbone/api/common.py b/tailbone/api/common.py index 6d8e9344..cd663d53 100644 --- a/tailbone/api/common.py +++ b/tailbone/api/common.py @@ -89,7 +89,7 @@ class CommonView(APIView): # identical; perhaps should merge somehow? schema = Feedback().bind(session=Session()) form = forms.Form(schema=schema, request=self.request) - if form.validate(newstyle=True): + if form.validate(): data = dict(form.validated) # figure out who the sending user is, if any diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 9f30512b..04cbb64a 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -26,6 +26,7 @@ Forms Core import json import logging +import warnings from collections import OrderedDict import sqlalchemy as sa @@ -1167,49 +1168,51 @@ class Form(object): return self.defaults[field_name] def validate(self, *args, **kwargs): - if kwargs.pop('newstyle', False): - # yay, new behavior! - if hasattr(self, 'validated'): - del self.validated - if self.request.method != 'POST': - return False + """ + Try to validate the form. - controls = get_form_data(self.request).items() + This should work whether data was submitted as classic POST + data, or as JSON body. - # unfortunately the normal form logic (i.e. peppercorn) is - # expecting all values to be strings, whereas if our data - # came from JSON body, may have given us some Pythonic - # objects. so here we must convert them *back* to strings - # TODO: this seems like a hack, i must be missing something - # TODO: also this uses same "JSON" check as get_form_data() - if self.request.is_xhr and not self.request.POST: - controls = [[key, val] for key, val in controls] - for i in range(len(controls)): - key, value = controls[i] - if value is None: - controls[i][1] = '' - elif value is True: - controls[i][1] = 'true' - elif value is False: - controls[i][1] = 'false' - elif not isinstance(value, str): - controls[i][1] = str(value) + :returns: ``True`` if form data is valid, otherwise ``False``. + """ + if 'newstyle' in kwargs: + warnings.warn("the `newstyle` kwarg is no longer used " + "for Form.validate()", + DeprecationWarning, stacklevel=2) - dform = self.make_deform_form() - try: - self.validated = dform.validate(controls) - return True - except deform.ValidationFailure: - return False + if hasattr(self, 'validated'): + del self.validated + if self.request.method != 'POST': + return False - else: # legacy behavior - raise_error = kwargs.pop('raise_error', True) - dform = self.make_deform_form() - try: - return dform.validate(*args, **kwargs) - except deform.ValidationFailure: - if raise_error: - raise + controls = get_form_data(self.request).items() + + # unfortunately the normal form logic (i.e. peppercorn) is + # expecting all values to be strings, whereas if our data + # came from JSON body, may have given us some Pythonic + # objects. so here we must convert them *back* to strings + # TODO: this seems like a hack, i must be missing something + # TODO: also this uses same "JSON" check as get_form_data() + if self.request.is_xhr and not self.request.POST: + controls = [[key, val] for key, val in controls] + for i in range(len(controls)): + key, value = controls[i] + if value is None: + controls[i][1] = '' + elif value is True: + controls[i][1] = 'true' + elif value is False: + controls[i][1] = 'false' + elif not isinstance(value, str): + controls[i][1] = str(value) + + dform = self.make_deform_form() + try: + self.validated = dform.validate(controls) + return True + except deform.ValidationFailure: + return False class FieldList(list): diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index fbae397b..f8d71d34 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -105,7 +105,7 @@ class AuthenticationView(View): form.auto_disable = False # TODO: deprecate / remove this form.show_reset = True form.show_cancel = False - if form.validate(newstyle=True): + if form.validate(): user = self.authenticate_user(form.validated['username'], form.validated['password']) if user: @@ -185,7 +185,7 @@ class AuthenticationView(View): schema = ChangePassword().bind(user=self.request.user, request=self.request) form = forms.Form(schema=schema, request=self.request) - if form.validate(newstyle=True): + if form.validate(): set_user_password(self.request.user, form.validated['new_password']) self.request.session.flash("Your password has been changed.") return self.redirect(self.request.get_referrer()) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 2ba7e6da..e2eeeda4 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -566,7 +566,7 @@ class BatchMasterView(MasterView): self.request.session.flash("Request ignored, since batch has already been executed") else: form = forms.Form(schema=ToggleComplete(), request=self.request) - if form.validate(newstyle=True): + if form.validate(): if form.validated['complete']: self.mark_batch_complete(batch) else: @@ -1273,7 +1273,7 @@ class BatchMasterView(MasterView): batch = self.get_instance() self.executing = True form = self.make_execute_form(batch) - if form.validate(newstyle=True): + if form.validate(): kwargs = dict(form.validated) # cache options to use as defaults next time @@ -1344,7 +1344,7 @@ class BatchMasterView(MasterView): indicator page. """ form = self.make_execute_form() - if form.validate(newstyle=True): + if form.validate(): kwargs = dict(form.validated) # cache options to use as defaults next time diff --git a/tailbone/views/batch/inventory.py b/tailbone/views/batch/inventory.py index b41a995e..92f0b2d4 100644 --- a/tailbone/views/batch/inventory.py +++ b/tailbone/views/batch/inventory.py @@ -234,7 +234,7 @@ class InventoryBatchView(BatchMasterView): schema = DesktopForm().bind(session=self.Session()) form = forms.Form(schema=schema, request=self.request) if self.request.method == 'POST': - if form.validate(newstyle=True): + if form.validate(): product = self.Session.get(model.Product, form.validated['product']) diff --git a/tailbone/views/common.py b/tailbone/views/common.py index 3882f357..e8d37904 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -161,7 +161,7 @@ class CommonView(View): model = self.model schema = Feedback().bind(session=Session()) form = forms.Form(schema=schema, request=self.request) - if form.validate(newstyle=True): + if form.validate(): data = dict(form.validated) if data['user']: data['user'] = Session.get(model.User, data['user']) diff --git a/tailbone/views/features.py b/tailbone/views/features.py index 39f683d3..d9417452 100644 --- a/tailbone/views/features.py +++ b/tailbone/views/features.py @@ -62,7 +62,7 @@ class GenerateFeatureView(View): result = rendered_result = None feature_type = 'new-report' if self.request.method == 'POST': - if app_form.validate(newstyle=True): + if app_form.validate(): feature_type = self.request.POST['feature_type'] feature = self.handler.get_feature(feature_type) @@ -70,7 +70,7 @@ class GenerateFeatureView(View): raise ValueError("Unknown feature type: {}".format(feature_type)) feature_form = feature_forms[feature.feature_key] - if feature_form.validate(newstyle=True): + if feature_form.validate(): context = dict(app_form.validated) context.update(feature_form.validated) result = self.handler.do_generate(feature, **context) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index ed0ed009..5e2f539c 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -723,7 +723,7 @@ class MasterView(View): form = forms.Form(schema=schema, request=self.request) form.save_label = "Upload" form.cancel_url = self.get_index_url() - if form.validate(newstyle=True): + if form.validate(): uploads = self.normalize_uploads(form) filepath = uploads['filename']['temp_path'] @@ -1408,7 +1408,7 @@ class MasterView(View): pass def validate_quick_row_form(self, form): - return form.validate(newstyle=True) + return form.validate() def make_default_row_grid_tools(self, obj): if self.rows_creatable: @@ -2301,7 +2301,7 @@ class MasterView(View): factory = self.get_form_factory() form = factory(schema=schema, request=self.request) - if not form.validate(newstyle=True): + if not form.validate(): return {'error': "Form did not validate"} # nb. self.Session may differ, so use tailbone.db.Session @@ -2334,7 +2334,7 @@ class MasterView(View): factory = self.get_form_factory() form = factory(schema=schema, request=self.request) - if not form.validate(newstyle=True): + if not form.validate(): return {'error': "Form did not validate"} # nb. self.Session may differ, so use tailbone.db.Session @@ -4057,7 +4057,7 @@ class MasterView(View): supp.configure_form(form) def validate_form(self, form): - if form.validate(newstyle=True): + if form.validate(): self.form_deserialized = form.validated return True return False @@ -4514,7 +4514,7 @@ class MasterView(View): self.configure_field_product_key(form) def validate_row_form(self, form): - if form.validate(newstyle=True): + if form.validate(): self.form_deserialized = form.validated return True return False diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 3761941a..c0d0c86f 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -1000,7 +1000,7 @@ class PersonView(MasterView): def profile_add_note(self): person = self.get_instance() form = self.make_note_form('create', person) - if form.validate(newstyle=True): + if form.validate(): note = self.create_note(person, form) self.Session.flush() return self.profile_add_note_success(note) @@ -1025,7 +1025,7 @@ class PersonView(MasterView): def profile_edit_note(self): person = self.get_instance() form = self.make_note_form('edit', person) - if form.validate(newstyle=True): + if form.validate(): note = self.update_note(person, form) self.Session.flush() return self.profile_edit_note_success(note) @@ -1047,7 +1047,7 @@ class PersonView(MasterView): def profile_delete_note(self): person = self.get_instance() form = self.make_note_form('delete', person) - if form.validate(newstyle=True): + if form.validate(): self.delete_note(person, form) self.Session.flush() return self.profile_delete_note_success(person) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index ebec578e..9700424b 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -1972,7 +1972,7 @@ class ProductView(MasterView): params_forms[key] = forms.Form(schema=schema, request=self.request) if self.request.method == 'POST': - if form.validate(newstyle=True): + if form.validate(): data = form.validated fully_validated = True @@ -1985,7 +1985,7 @@ class ProductView(MasterView): # collect batch-type-specific params pform = params_forms.get(batch_key) if pform: - if pform.validate(newstyle=True): + if pform.validate(): pdata = pform.validated for field in pform.schema: param_name = pform.schema[field.name].param_name diff --git a/tailbone/views/projects.py b/tailbone/views/projects.py index a412c388..99103101 100644 --- a/tailbone/views/projects.py +++ b/tailbone/views/projects.py @@ -65,7 +65,7 @@ class GeneratedProjectView(MasterView): project_type = self.request.matchdict.get('project_type') if project_type: form = self.make_project_form(project_type) - if form.validate(newstyle=True): + if form.validate(): zipped = self.generate_project(project_type, form) return self.file_response(zipped) @@ -83,7 +83,7 @@ class GeneratedProjectView(MasterView): # if form validates, then user has chosen a project type, so # we redirect to the appropriate "generate project" page - if form.validate(newstyle=True): + if form.validate(): raise self.redirect(self.request.route_url( 'generate_specific_project', project_type=form.validated['project_type'])) diff --git a/tailbone/views/purchasing/costing.py b/tailbone/views/purchasing/costing.py index d5c86908..294b29ef 100644 --- a/tailbone/views/purchasing/costing.py +++ b/tailbone/views/purchasing/costing.py @@ -225,7 +225,7 @@ class CostingBatchView(PurchasingBatchView): # if form validates, that means user has chosen a creation type, so we # just redirect to the appropriate "new batch of type X" page - if form.validate(newstyle=True): + if form.validate(): workflow_key = form.validated['workflow'] vendor_uuid = form.validated['vendor'] url = self.request.route_url('{}.create_workflow'.format(route_prefix), diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 4632723d..cdc69fe5 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -358,7 +358,7 @@ class ReceivingBatchView(PurchasingBatchView): # if form validates, that means user has chosen a creation type, so we # just redirect to the appropriate "new batch of type X" page - if form.validate(newstyle=True): + if form.validate(): workflow_key = form.validated['workflow'] vendor_uuid = form.validated['vendor'] url = self.request.route_url('{}.create_workflow'.format(route_prefix), @@ -1196,7 +1196,7 @@ class ReceivingBatchView(PurchasingBatchView): # TODO: what is this one about again? form.remove_field('quick_receive') - if form.validate(newstyle=True): + if form.validate(): # handler takes care of the row receiving logic for us kwargs = dict(form.validated) @@ -1382,7 +1382,7 @@ class ReceivingBatchView(PurchasingBatchView): # expiration_date form.set_type('expiration_date', 'date_jquery') - if form.validate(newstyle=True): + if form.validate(): # handler takes care of the row receiving logic for us kwargs = dict(form.validated) diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index 5ded5c5f..a1c737b6 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -373,7 +373,7 @@ class ReportOutputView(ExportMasterView): # if form validates, that means user has chosen a report type, so we # just redirect to the appropriate "new report" page - if form.validate(newstyle=True): + if form.validate(): raise self.redirect(self.request.route_url('generate_specific_report', type_key=form.validated['report_type'])) @@ -465,7 +465,7 @@ class ReportOutputView(ExportMasterView): form.set_default(param.name, value) # if form validates, start generating new report output; show progress page - if form.validate(newstyle=True): + if form.validate(): key = 'report_output.generate' progress = self.make_progress(key) kwargs = {'progress': progress} diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index 472ea199..47cca0c5 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -274,7 +274,7 @@ class AppSettingsView(View): form = self.make_form(settings) form.cancel_url = self.request.current_route_url() - if form.validate(newstyle=True): + if form.validate(): self.save_form(form) group = self.request.POST.get('settings-group') if group is not None: diff --git a/tailbone/views/shifts/lib.py b/tailbone/views/shifts/lib.py index 8cb75f33..d32a1309 100644 --- a/tailbone/views/shifts/lib.py +++ b/tailbone/views/shifts/lib.py @@ -164,7 +164,7 @@ class TimeSheetView(View): Process a "shift filter" form if one was in fact POST'ed. If it was then we store new context in session and redirect to display as normal. """ - if form.validate(newstyle=True): + if form.validate(): store = form.validated['store'] self.request.session['timesheet.{}.store'.format(self.key)] = store.uuid if store else None department = form.validated['department'] @@ -178,7 +178,7 @@ class TimeSheetView(View): Process an "employee shift filter" form if one was in fact POST'ed. If it was then we store new context in session and redirect to display as normal. """ - if form.validate(newstyle=True): + if form.validate(): employee = form.validated['employee'] self.request.session['timesheet.{}.employee'.format(self.key)] = employee.uuid if employee else None date = form.validated['date'] From c002d3d182d32712d188b9aa6443849a611f91f5 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 14 May 2023 20:10:05 -0500 Subject: [PATCH 1079/1681] Add basic support for managing, and accepting API tokens also various other changes in pursuit of that. so far tokens are only accepted by web API and not traditional web app --- tailbone/auth.py | 33 ++++++++ tailbone/forms/core.py | 22 +++++- tailbone/grids/core.py | 17 ++++ tailbone/templates/form.mako | 7 +- tailbone/templates/page.mako | 2 +- tailbone/templates/users/view.mako | 120 +++++++++++++++++++++++++++++ tailbone/views/master.py | 33 ++++---- tailbone/views/users.py | 105 +++++++++++++++++++++++++ tailbone/webapi.py | 5 +- 9 files changed, 318 insertions(+), 26 deletions(-) diff --git a/tailbone/auth.py b/tailbone/auth.py index 0c90003a..1f057404 100644 --- a/tailbone/auth.py +++ b/tailbone/auth.py @@ -25,6 +25,7 @@ Authentication & Authorization """ import logging +import re from rattail import enum from rattail.util import prettify, NOTSET @@ -32,6 +33,7 @@ from rattail.util import prettify, NOTSET from zope.interface import implementer from pyramid.interfaces import IAuthorizationPolicy from pyramid.security import remember, forget, Everyone, Authenticated +from pyramid.authentication import SessionAuthenticationPolicy from tailbone.db import Session @@ -87,6 +89,37 @@ def set_session_timeout(request, timeout): request.session['_timeout'] = timeout or None +class TailboneAuthenticationPolicy(SessionAuthenticationPolicy): + """ + Custom authentication policy for Tailbone. + + This is mostly Pyramid's built-in session-based policy, but adds + logic to accept Rattail User API Tokens in lieu of current user + being identified via the session. + + Note that the traditional Tailbone web app does *not* use this + policy, only the Tailbone web API uses it by default. + """ + + def unauthenticated_userid(self, request): + + # figure out userid from header token if present + credentials = request.headers.get('Authorization') + if credentials: + match = re.match(r'^Bearer (\S+)$', credentials) + if match: + token = match.group(1) + rattail_config = request.registry.settings.get('rattail_config') + app = rattail_config.get_app() + auth = app.get_auth_handler() + user = auth.authenticate_user_token(Session(), token) + if user: + return user.uuid + + # otherwise do normal session-based logic + return super(TailboneAuthenticationPolicy, self).unauthenticated_userid(request) + + @implementer(IAuthorizationPolicy) class TailboneAuthorizationPolicy(object): diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 04cbb64a..c4a7b0ea 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -338,7 +338,7 @@ class Form(object): assume_local_times=False, renderers=None, renderer_kwargs={}, hidden={}, widgets={}, defaults={}, validators={}, required={}, helptext={}, focus_spec=None, action_url=None, cancel_url=None, component='tailbone-form', - vuejs_field_converters={}, + vuejs_component_kwargs=None, vuejs_field_converters={}, # TODO: ugh this is getting out hand! can_edit_help=False, edit_help_url=None, route_prefix=None, ): @@ -379,6 +379,7 @@ class Form(object): self.action_url = action_url self.cancel_url = cancel_url self.component = component + self.vuejs_component_kwargs = vuejs_component_kwargs or {} self.vuejs_field_converters = vuejs_field_converters or {} self.can_edit_help = can_edit_help self.edit_help_url = edit_help_url @@ -913,6 +914,25 @@ class Form(object): return False return True + def set_vuejs_component_kwargs(self, **kwargs): + self.vuejs_component_kwargs.update(kwargs) + + def render_vuejs_component(self): + """ + Render the Vue.js component HTML for the form. + + Most typically this is something like: + + .. code-block:: html + + <tailbone-form :configure-fields-help="configureFieldsHelp"> + </tailbone-form> + """ + kwargs = dict(self.vuejs_component_kwargs) + if self.can_edit_help: + kwargs.setdefault(':configure-fields-help', 'configureFieldsHelp') + return HTML.tag(self.component, **kwargs) + def render_buefy_field(self, fieldname, bfield_attrs={}): """ Render the given field in a Buefy-compatible way. Note that diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index f1f00904..230bd061 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -1613,6 +1613,23 @@ class GridAction(object): """ Represents an action available to a grid. This is used to construct the 'actions' column when rendering the grid. + + :param key: Key for the action (e.g. ``'edit'``), unique within + the grid. + + :param label: Label to be displayed for the action. If not set, + will be a capitalized version of ``key``. + + :param icon: Icon name for the action. + + :param click_handler: Optional JS click handler for the action. + This value will be rendered as-is within the final grid + template, hence the JS string must be callable code. Note + that ``props.row`` will be available in the calling context, + so a couple of examples: + + * ``deleteThisThing(props.row)`` + * ``$emit('do-something', props.row)`` """ def __init__(self, key, label=None, url='#', icon=None, target=None, diff --git a/tailbone/templates/form.mako b/tailbone/templates/form.mako index cb6ef9c1..5878e030 100644 --- a/tailbone/templates/form.mako +++ b/tailbone/templates/form.mako @@ -11,12 +11,7 @@ <%def name="render_buefy_form()"> <div class="form"> - <${form.component} - % if can_edit_help: - :configure-fields-help="configureFieldsHelp" - % endif - > - </${form.component}> + ${form.render_vuejs_component()} </div> </%def> diff --git a/tailbone/templates/page.mako b/tailbone/templates/page.mako index 94147a04..b5ac8773 100644 --- a/tailbone/templates/page.mako +++ b/tailbone/templates/page.mako @@ -32,7 +32,7 @@ let ThisPage = { template: '#this-page-template', - mixins: [FormPosterMixin], + mixins: [SimpleRequestMixin], props: { configureFieldsHelp: Boolean, }, diff --git a/tailbone/templates/users/view.mako b/tailbone/templates/users/view.mako index b34902a1..f65b6d1c 100644 --- a/tailbone/templates/users/view.mako +++ b/tailbone/templates/users/view.mako @@ -21,5 +21,125 @@ % endif </%def> +<%def name="render_this_page()"> + ${parent.render_this_page()} + + % if master.has_perm('manage_api_tokens'): + + <b-modal :active.sync="apiNewTokenShowDialog" + has-modal-card> + <div class="modal-card"> + <header class="modal-card-head"> + <p class="modal-card-title"> + New API Token + </p> + </header> + <section class="modal-card-body"> + + <div v-if="!apiNewTokenSaved"> + <b-field label="Description" + :type="{'is-danger': !apiNewTokenDescription}"> + <b-input v-model.trim="apiNewTokenDescription" + ref="apiNewTokenDescription"> + </b-input> + </b-field> + </div> + + <div v-if="apiNewTokenSaved"> + <p class="block"> + Your new API token is shown below. + </p> + <p class="block"> + IMPORTANT: You must record this token elsewhere + for later reference. You will NOT be able to + recover the value if you lose it. + </p> + <b-field horizontal label="API Token"> + {{ apiNewTokenRaw }} + </b-field> + <b-field horizontal label="Description"> + {{ apiNewTokenDescription }} + </b-field> + </div> + + </section> + <footer class="modal-card-foot"> + <b-button @click="apiNewTokenShowDialog = false"> + {{ apiNewTokenSaved ? "Close" : "Cancel" }} + </b-button> + <b-button v-if="!apiNewTokenSaved" + type="is-primary" + icon-pack="fas" + icon-left="save" + @click="apiNewTokenSave()" + :disabled="!apiNewTokenDescription || apiNewTokenSaving"> + Save + </b-button> + </footer> + </div> + </b-modal> + + % endif +</%def> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + % if master.has_perm('manage_api_tokens'): + <script type="text/javascript"> + + ${form.component_studly}.props.apiTokens = null + + ThisPageData.apiTokens = ${json.dumps(api_tokens_data)|n} + + ThisPageData.apiNewTokenShowDialog = false + ThisPageData.apiNewTokenDescription = null + + ThisPage.methods.apiNewToken = function() { + this.apiNewTokenDescription = null + this.apiNewTokenSaved = false + this.apiNewTokenShowDialog = true + this.$nextTick(() => { + this.$refs.apiNewTokenDescription.focus() + }) + } + + ThisPageData.apiNewTokenSaving = false + ThisPageData.apiNewTokenSaved = false + ThisPageData.apiNewTokenRaw = null + + ThisPage.methods.apiNewTokenSave = function() { + this.apiNewTokenSaving = true + + let url = '${master.get_action_url('add_api_token', instance)}' + let params = { + description: this.apiNewTokenDescription, + } + + this.simplePOST(url, params, response => { + this.apiTokens = response.data.tokens + this.apiNewTokenSaving = false + this.apiNewTokenRaw = response.data.raw_token + this.apiNewTokenSaved = true + }, response => { + this.apiNewTokenSaving = false + }) + } + + ThisPage.methods.apiTokenDelete = function(token) { + if (!confirm("Really delete this API token?")) { + return + } + + let url = '${master.get_action_url('delete_api_token', instance)}' + let params = {uuid: token.uuid} + this.simplePOST(url, params, response => { + this.apiTokens = response.data.tokens + }) + } + + </script> + % endif +</%def> + ${parent.body()} diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 5e2f539c..2d6bae16 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2282,9 +2282,15 @@ class MasterView(View): if info and info.markdown_text: return info.markdown_text + def can_edit_help(self): + if self.has_perm('edit_help'): + return True + if self.request.has_perm('common.edit_help'): + return True + return False + def edit_help(self): - if (not self.has_perm('edit_help') - and not self.request.has_perm('common.edit_help')): + if not self.can_edit_help(): raise self.forbidden() model = self.model @@ -2317,8 +2323,7 @@ class MasterView(View): return {'ok': True} def edit_field_help(self): - if (not self.has_perm('edit_help') - and not self.request.has_perm('common.edit_help')): + if not self.can_edit_help(): raise self.forbidden() model = self.model @@ -2371,8 +2376,7 @@ class MasterView(View): 'grid_index': self.grid_index, 'help_url': self.get_help_url(), 'help_markdown': self.get_help_markdown(), - 'can_edit_help': (self.has_perm('edit_help') - or self.request.has_perm('common.edit_help')), + 'can_edit_help': self.can_edit_help(), 'quickie': None, } @@ -2638,16 +2642,16 @@ class MasterView(View): elif is_primary: btn_kw['type'] = 'is-primary' + if icon_left: + btn_kw['icon_left'] = icon_left + elif is_external: + btn_kw['icon_left'] = 'external-link-alt' + elif url: + btn_kw['icon_left'] = 'eye' + if url: btn_kw['href'] = url - if icon_left: - btn_kw['icon_left'] = icon_left - elif is_external: - btn_kw['icon_left'] = 'external-link-alt' - else: - btn_kw['icon_left'] = 'eye' - if target: btn_kw['target'] = target elif is_external: @@ -4017,8 +4021,7 @@ class MasterView(View): 'action_url': self.request.current_route_url(_query=None), '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')), + 'can_edit_help': self.can_edit_help(), } if defaults['can_edit_help']: diff --git a/tailbone/views/users.py b/tailbone/views/users.py index ff614460..833c6cf5 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -38,6 +38,7 @@ from webhelpers2.html import HTML, tags from tailbone import forms from tailbone.views import MasterView, View from tailbone.views.principal import PrincipalMasterView, PermissionsRenderer +from tailbone.util import raw_datetime class UserView(PrincipalMasterView): @@ -51,6 +52,10 @@ class UserView(PrincipalMasterView): touchable = True mergeable = True + labels = { + 'api_tokens': "API Tokens", + } + grid_columns = [ 'username', 'person', @@ -68,6 +73,7 @@ class UserView(PrincipalMasterView): 'active_sticky', 'set_password', 'prevent_password_change', + 'api_tokens', 'roles', 'permissions', ] @@ -218,6 +224,17 @@ class UserView(PrincipalMasterView): # if self.creating: # f.set_required('password') + # api_tokens + if self.creating or self.editing: + f.remove('api_tokens') + elif self.has_perm('manage_api_tokens'): + f.set_renderer('api_tokens', self.render_api_tokens) + f.set_vuejs_component_kwargs(**{':apiTokens': 'apiTokens', + '@api-new-token': 'apiNewToken', + '@api-token-delete': 'apiTokenDelete'}) + else: + f.remove('api_tokens') + # roles f.set_renderer('roles', self.render_roles) if self.creating or self.editing: @@ -260,6 +277,75 @@ class UserView(PrincipalMasterView): if self.viewing or self.deleting: f.remove('set_password') + def render_api_tokens(self, user, field): + route_prefix = self.get_route_prefix() + permission_prefix = self.get_permission_prefix() + + factory = self.get_grid_factory() + g = factory( + key='{}.api_tokens'.format(route_prefix), + data=[], + columns=['description', 'created'], + main_actions=[ + self.make_action('delete', icon='trash', + click_handler="$emit('api-token-delete', props.row)")]) + + button = self.make_buefy_button("New", is_primary=True, + icon_left='plus', + **{'@click': "$emit('api-new-token')"}) + + table = HTML.literal( + g.render_buefy_table_element(data_prop='apiTokens')) + + return HTML.tag('div', c=[button, table]) + + def add_api_token(self): + user = self.get_instance() + data = self.request.json_body + + token = self.auth_handler.add_api_token(user, data['description']) + self.Session.flush() + + return {'ok': True, + 'raw_token': token.token_string, + 'tokens': self.get_api_tokens(user)} + + def delete_api_token(self): + model = self.model + user = self.get_instance() + data = self.request.json_body + + token = self.Session.get(model.UserAPIToken, data['uuid']) + if not token: + return {'error': "API token not found"} + + if token.user is not user: + return {'error': "API token not found"} + + self.auth_handler.delete_api_token(token) + self.Session.flush() + + return {'ok': True, + 'tokens': self.get_api_tokens(user)} + + def template_kwargs_view(self, **kwargs): + kwargs = super(UserView, self).template_kwargs_view(**kwargs) + user = kwargs['instance'] + + kwargs['api_tokens_data'] = self.get_api_tokens(user) + + return kwargs + + def get_api_tokens(self, user): + tokens = [] + for token in reversed(user.api_tokens): + tokens.append({ + 'uuid': token.uuid, + 'description': token.description, + 'created': raw_datetime(self.rattail_config, token.created), + }) + return tokens + def get_possible_roles(self): model = self.model @@ -554,6 +640,25 @@ class UserView(PrincipalMasterView): config.add_tailbone_permission(permission_prefix, '{}.edit_roles'.format(permission_prefix), "Edit the Roles to which a {} belongs".format(model_title)) + # manage API tokens + config.add_tailbone_permission(permission_prefix, + '{}.manage_api_tokens'.format(permission_prefix), + "Manage API tokens for any {}".format(model_title)) + config.add_route('{}.add_api_token'.format(route_prefix), + '{}/add-api-token'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='add_api_token', + route_name='{}.add_api_token'.format(route_prefix), + permission='{}.manage_api_tokens'.format(permission_prefix), + renderer='json') + config.add_route('{}.delete_api_token'.format(route_prefix), + '{}/delete-api-token'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='delete_api_token', + route_name='{}.delete_api_token'.format(route_prefix), + permission='{}.manage_api_tokens'.format(permission_prefix), + renderer='json') + # edit preferences for any user config.add_tailbone_permission(permission_prefix, '{}.preferences'.format(permission_prefix), diff --git a/tailbone/webapi.py b/tailbone/webapi.py index a437f0c3..7a2c81b4 100644 --- a/tailbone/webapi.py +++ b/tailbone/webapi.py @@ -28,10 +28,9 @@ import simplejson from cornice.renderer import CorniceRenderer from pyramid.config import Configurator -from pyramid.authentication import SessionAuthenticationPolicy from tailbone import app -from tailbone.auth import TailboneAuthorizationPolicy +from tailbone.auth import TailboneAuthenticationPolicy, TailboneAuthorizationPolicy from tailbone.providers import get_all_providers @@ -51,8 +50,8 @@ def make_pyramid_config(settings): pyramid_config = Configurator(settings=settings, root_factory=app.Root) # configure user authorization / authentication + pyramid_config.set_authentication_policy(TailboneAuthenticationPolicy()) pyramid_config.set_authorization_policy(TailboneAuthorizationPolicy()) - pyramid_config.set_authentication_policy(SessionAuthenticationPolicy()) # always require CSRF token protection pyramid_config.set_default_csrf_options(require_csrf=True, From d90cab40a668be342526dc7f1f0941e10a88ec59 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 15 May 2023 08:49:01 -0500 Subject: [PATCH 1080/1681] 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 25ca1da5..8af59029 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.9.23 (2023-05-15) +------------------- + +* Get rid of ``newstyle`` flag for ``Form.validate()`` method. + +* Add basic support for managing, and accepting API tokens. + + 0.9.22 (2023-05-13) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index b6cfd77e..63846565 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.22' +__version__ = '0.9.23' From 5f6b3895564ab5fee83c3b214b13e2b69e3c6c7a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 16 May 2023 15:02:39 -0500 Subject: [PATCH 1081/1681] Replace `setup.py` contents with `setup.cfg` --- setup.cfg | 101 ++++++++++++++++++++++++++++++++++ setup.py | 158 +----------------------------------------------------- 2 files changed, 103 insertions(+), 156 deletions(-) diff --git a/setup.cfg b/setup.cfg index 7712ec72..420b9983 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,107 @@ +# -*- coding: utf-8; -*- + [nosetests] nocapture = 1 cover-package = tailbone cover-erase = 1 cover-html = 1 cover-html-dir = htmlcov + +[metadata] +name = Tailbone +version = attr: tailbone.__version__ +author = Lance Edgar +author_email = lance@edbob.org +url = http://rattailproject.org/ +license = GNU GPL v3 +description = Backoffice Web Application for Rattail +long_description = file: README.rst +classifiers = + Development Status :: 4 - Beta + Environment :: Web Environment + Framework :: Pyramid + Intended Audience :: Developers + License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+) + Natural Language :: English + Operating System :: OS Independent + Programming Language :: Python + Programming Language :: Python :: 3 + Topic :: Internet :: WWW/HTTP + Topic :: Office/Business + Topic :: Software Development :: Libraries :: Python Modules + + +[options] +install_requires = + + # TODO: apparently they jumped from 0.1 to 0.9 and that broke us... + # (0.1 was released on 2014-09-14 and then 0.9 came out on 2018-09-27) + # (i've cached 0.1 at pypi.rattailproject.org just in case it disappears) + # (still, probably a better idea is to refactor so we can use 0.9) + webhelpers2_grid==0.1 + + # TODO: remove once their bug is fixed? idk what this is about yet... + deform<2.0.15 + + # TODO: remove this cap and address warnings that follow + pyramid<2 + + asgiref + colander + ColanderAlchemy + cornice + humanize + Mako + markdown + openpyxl + paginate + paginate_sqlalchemy + passlib + Pillow + pyramid_beaker>=0.6 + pyramid_deform + pyramid_exclog + pyramid_mako + pyramid_retry + pyramid_tm + rattail[db,bouncer] + six + sa-filters + simplejson + transaction + waitress + WebHelpers2 + zope.sqlalchemy + +tests_require = Tailbone[tests] +test_suite = nose.collector +packages = find: +include_package_data = True +zip_safe = False + + +[options.packages.find] +exclude = + tests.* + tests + + +[options.extras_require] +docs = Sphinx; sphinx-rtd-theme +tests = coverage; fixture; mock; nose; pytest; pytest-cov + + +[options.entry_points] + +paste.app_factory = + main = tailbone.app:main + webapi = tailbone.webapi:main + +rattail.cleaners = + beaker = tailbone.cleanup:BeakerCleaner + +rattail.config.extensions = + tailbone = tailbone.config:ConfigExtension + +pyramid.scaffold = + rattail = tailbone.scaffolds:RattailTemplate diff --git a/setup.py b/setup.py index b295f062..5645ddff 100644 --- a/setup.py +++ b/setup.py @@ -24,160 +24,6 @@ Setup script for Tailbone """ -import os.path -from setuptools import setup, find_packages +from setuptools import setup - -here = os.path.abspath(os.path.dirname(__file__)) -exec(open(os.path.join(here, 'tailbone', '_version.py')).read()) -README = open(os.path.join(here, 'README.rst')).read() - - -requires = [ - # - # Version numbers within comments below have specific meanings. - # Basically the 'low' value is a "soft low," and 'high' a "soft high." - # In other words: - # - # If either a 'low' or 'high' value exists, the primary point to be - # made about the value is that it represents the most current (stable) - # version available for the package (assuming typical public access - # methods) whenever this project was started and/or documented. - # Therefore: - # - # If a 'low' version is present, you should know that attempts to use - # versions of the package significantly older than the 'low' version - # may not yield happy results. (A "hard" high limit may or may not be - # indicated by a true version requirement.) - # - # Similarly, if a 'high' version is present, and especially if this - # project has laid dormant for a while, you may need to refactor a bit - # when attempting to support a more recent version of the package. (A - # "hard" low limit should be indicated by a true version requirement - # when a 'high' version is present.) - # - # In any case, developers and other users are encouraged to play - # outside the lines with regard to these soft limits. If bugs are - # encountered then they should be filed as such. - # - # package # low high - - # TODO: apparently they jumped from 0.1 to 0.9 and that broke us... - # (0.1 was released on 2014-09-14 and then 0.9 came out on 2018-09-27) - # (i've cached 0.1 at pypi.rattailproject.org just in case it disappears) - # (still, probably a better idea is to refactor so we can use 0.9) - 'webhelpers2_grid==0.1', # 0.1 - - # TODO: remove once their bug is fixed? idk what this is about yet... - 'deform<2.0.15', # 2.0.14 - - # TODO: remove this cap and address warnings that follow - 'pyramid<2', # 1.3b2 1.10.8 - - 'asgiref', # 3.2.3 - 'colander', # 1.7.0 - 'ColanderAlchemy', # 0.3.3 - 'cornice', # 3.4.2 - 'humanize', # 0.5.1 - 'Mako', # 0.6.2 - 'markdown', # 3.3.3 - 'openpyxl', # 2.4.7 - 'paginate', # 0.5.6 - 'paginate_sqlalchemy', # 0.2.0 - 'passlib', # 1.7.1 - 'Pillow', # 5.3.0 - 'pyramid_beaker>=0.6', # 0.6.1 - 'pyramid_deform', # 0.2 - 'pyramid_exclog', # 0.6 - 'pyramid_mako', # 1.0.2 - 'pyramid_retry', # 2.1.1 - 'pyramid_tm', # 0.3 - 'rattail[db,bouncer]', # 0.5.0 - 'six', # 1.10.0 - 'sa-filters', # 1.2.0 - 'simplejson', # 3.18.3 - 'transaction', # 1.2.0 - 'waitress', # 0.8.1 - 'WebHelpers2', # 2.0 - 'zope.sqlalchemy', # 0.7 2.0 -] - - -extras = { - - 'docs': [ - # - # package # low high - - 'Sphinx', # 1.2 - 'sphinx-rtd-theme', # 0.2.4 - ], - - 'tests': [ - # - # package # low high - - 'coverage', # 3.6 - 'fixture', # 1.5 - 'mock', # 1.0.1 - 'nose', # 1.3.0 - 'pytest', # 4.6.11 - 'pytest-cov', # 2.12.1 - ], -} - - -setup( - name = "Tailbone", - version = __version__, - author = "Lance Edgar", - author_email = "lance@edbob.org", - url = "http://rattailproject.org/", - license = "GNU GPL v3", - description = "Backoffice Web Application for Rattail", - long_description = README, - - classifiers = [ - 'Development Status :: 4 - Beta', - 'Environment :: Web Environment', - 'Framework :: Pyramid', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)', - 'Natural Language :: English', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - 'Topic :: Internet :: WWW/HTTP', - 'Topic :: Office/Business', - 'Topic :: Software Development :: Libraries :: Python Modules', - ], - - install_requires = requires, - extras_require = extras, - tests_require = ['Tailbone[tests]'], - test_suite = 'nose.collector', - - packages = find_packages(exclude=['tests.*', 'tests']), - include_package_data = True, - zip_safe = False, - - entry_points = { - - 'paste.app_factory': [ - 'main = tailbone.app:main', - 'webapi = tailbone.webapi:main', - ], - - 'rattail.cleaners': [ - 'beaker = tailbone.cleanup:BeakerCleaner', - ], - - 'rattail.config.extensions': [ - 'tailbone = tailbone.config:ConfigExtension', - ], - - 'pyramid.scaffold': [ - 'rattail = tailbone.scaffolds:RattailTemplate', - ], - }, -) +setup() From 93bce5788813c8c07083e578e350916ecb983c25 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 16 May 2023 17:33:07 -0500 Subject: [PATCH 1082/1681] Prevent error in old product search logic when no POD image URL is configured --- tailbone/views/products.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 9700424b..8988538b 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -1876,6 +1876,7 @@ class ProductView(MasterView): ]) # TODO: deprecate / remove this? not sure if/where it is used + # (hm, still used by the old Instacart -> Configure page..) def search_v1(self): """ Locate a product(s) by UPC. @@ -1898,7 +1899,8 @@ class ProductView(MasterView): 'upc': str(product.upc), 'upc_pretty': product.upc.pretty(), 'full_description': product.full_description, - 'image_url': pod.get_image_url(self.rattail_config, product.upc), + 'image_url': pod.get_image_url(self.rattail_config, product.upc, + require=False), } uuid = self.request.GET.get('with_vendor_cost') if uuid: From 26a6a4d991ee851489ebb3ef7a72884197aa252d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 16 May 2023 17:33:55 -0500 Subject: [PATCH 1083/1681] 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 8af59029..e8d0ac4c 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.9.24 (2023-05-16) +------------------- + +* Replace ``setup.py`` contents with ``setup.cfg``. + +* Prevent error in old product search logic. + + 0.9.23 (2023-05-15) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 63846565..6e9597a5 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.23' +__version__ = '0.9.24' From c18367739ff8c8c24f58fe88eeb075abc9ae6a60 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 16 May 2023 23:10:54 -0500 Subject: [PATCH 1084/1681] Add initial swagger.json endpoint for API probably this needs more, but good enough to test with --- setup.cfg | 1 + tailbone/api/common.py | 20 ++++++++++++++++++++ tailbone/views/common.py | 5 +++++ 3 files changed, 26 insertions(+) diff --git a/setup.cfg b/setup.cfg index 420b9983..85501357 100644 --- a/setup.cfg +++ b/setup.cfg @@ -50,6 +50,7 @@ install_requires = colander ColanderAlchemy cornice + cornice-swagger humanize Mako markdown diff --git a/tailbone/api/common.py b/tailbone/api/common.py index cd663d53..30dfeab1 100644 --- a/tailbone/api/common.py +++ b/tailbone/api/common.py @@ -31,6 +31,8 @@ from rattail.db import model from rattail.mail import send_email from cornice import Service +from cornice.service import get_services +from cornice_swagger import CorniceSwagger import tailbone from tailbone import forms @@ -109,12 +111,22 @@ class CommonView(APIView): return {'error': "Form did not validate!"} + def swagger(self): + doc = CorniceSwagger(get_services()) + app = self.get_rattail_app() + spec = doc.generate(f"{app.get_node_title()} API docs", + app.get_version(), + base_path='/api') # TODO + return spec + @classmethod def defaults(cls, config): cls._common_defaults(config) @classmethod def _common_defaults(cls, config): + rattail_config = config.registry.settings.get('rattail_config') + app = rattail_config.get_app() # about about = Service(name='about', path='/about') @@ -127,6 +139,14 @@ class CommonView(APIView): permission='common.feedback') config.add_cornice_service(feedback) + # swagger + swagger = Service(name='swagger', + path='/swagger.json', + description=f"OpenAPI documentation for {app.get_title()}") + swagger.add_view('GET', 'swagger', klass=cls, + permission='common.api_swagger') + config.add_cornice_service(swagger) + def defaults(config, **kwargs): base = globals() diff --git a/tailbone/views/common.py b/tailbone/views/common.py index e8d37904..7d1cd402 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -275,6 +275,11 @@ class CommonView(View): config.add_tailbone_permission('common', 'common.edit_help', "Edit help info for *any* page") + # API swagger + if rattail_config.getbool('tailbone', 'expose_api_swagger'): + config.add_tailbone_permission('common', 'common.api_swagger', + "Explore the API with Swagger tools") + # home config.add_route('home', '/') config.add_view(cls, attr='home', route_name='home', renderer='/home.mako') From 8d880fc9dd71288231b7695a3329fa550c29b1ec Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 18 May 2023 13:48:22 -0500 Subject: [PATCH 1085/1681] Add workaround for "share grid link" on insecure sites --- tailbone/templates/grids/buefy.mako | 33 ++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako index 98de939d..48c3a081 100644 --- a/tailbone/templates/grids/buefy.mako +++ b/tailbone/templates/grids/buefy.mako @@ -315,6 +315,12 @@ </template> </b-table> + + ## dummy input field needed for sharing links on *insecure* sites + % if request.scheme == 'http': + <b-input v-model="shareLink" ref="shareLink" v-show="shareLink"></b-input> + % endif + </div> </script> @@ -349,6 +355,11 @@ filters: ${json.dumps(filters_data if grid.filterable else None)|n}, filtersSequence: ${json.dumps(filters_sequence if grid.filterable else None)|n}, selectedFilter: null, + + ## dummy input value needed for sharing links on *insecure* sites + % if request.scheme == 'http': + shareLink: null, + % endif } let ${grid.component_studly} = { @@ -382,7 +393,27 @@ methods: { copyDirectLink() { - navigator.clipboard.writeText(this.directLink) + + if (navigator.clipboard) { + // this is the way forward, but requires HTTPS + navigator.clipboard.writeText(this.directLink) + + } else { + // use deprecated 'copy' command, but this just + // tells the browser to copy currently-selected + // text..which means we first must "add" some text + // to screen, and auto-select that, before copying + // to clipboard + this.shareLink = this.directLink + this.$nextTick(() => { + let input = this.$refs.shareLink.$el.firstChild + input.select() + document.execCommand('copy') + // re-hide the dummy input + this.shareLink = null + }) + } + this.$buefy.toast.open({ message: "Link was copied to clipboard", type: 'is-info', From af405cfd1003842d6d5d75fadf07d6df0aa2392c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 18 May 2023 13:51:59 -0500 Subject: [PATCH 1086/1681] 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 e8d0ac4c..e95389db 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.9.25 (2023-05-18) +------------------- + +* Add initial swagger.json endpoint for API. + +* Add workaround for "share grid link" on insecure sites. + + 0.9.24 (2023-05-16) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 6e9597a5..2e241d54 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.24' +__version__ = '0.9.25' From 05bb3849a2c7535a207f631f0f8d8ba3d072cbca Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 18 May 2023 19:56:55 -0500 Subject: [PATCH 1087/1681] Prevent bug in upgrade diff for empty new version apparently this is quite the rare case..but can happen --- tailbone/views/upgrades.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index eddd677c..f7c83eec 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.py @@ -400,6 +400,10 @@ class UpgradeView(MasterView): return projects def get_changelog_url(self, project, old_version, new_version): + # cannot generate URL if new version is unknown + if not new_version: + return + projects = self.get_changelog_projects() project_name = project From de13e48aa5763818e324b8e7fa3ee0bbd4d994ca Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 19 May 2023 17:16:19 -0500 Subject: [PATCH 1088/1681] Expose basic way to send test email most of the mechanics of sending email could already be tested by sending a "preview" email of any type, or e.g. via Feedback. but it seemed like the Configure Email Settings page should have a dedicated way to test sending --- .../templates/settings/email/configure.mako | 74 +++++++++++++++---- tailbone/views/email.py | 26 +++++++ 2 files changed, 84 insertions(+), 16 deletions(-) diff --git a/tailbone/templates/settings/email/configure.mako b/tailbone/templates/settings/email/configure.mako index 13bceb3e..f0e5d4d9 100644 --- a/tailbone/templates/settings/email/configure.mako +++ b/tailbone/templates/settings/email/configure.mako @@ -26,30 +26,72 @@ </div> - % if request.has_perm('errors.bogus'): - <h3 class="block is-size-3">Testing</h3> - <div class="block" style="padding-left: 2rem;"> + <h3 class="block is-size-3">Testing</h3> + <div class="block" style="padding-left: 2rem;"> - <b-field grouped> - <p class="control"> - You can raise a "bogus" error to test if/how it generates email: - </p> + <b-field grouped> + <b-field horizontal label="Recipient"> + <b-input v-model="testRecipient"></b-input> + </b-field> + <b-button type="is-primary" + @click="sendTest()" + :disabled="sendingTest"> + {{ sendingTest ? "Working, please wait..." : "Send Test Email" }} + </b-button> + </b-field> + + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <p>You can raise a "bogus" error to test if/how that generates email:</p> + </div> + <div class="level-item"> <b-button type="is-primary" + % if request.has_perm('errors.bogus'): @click="raiseBogusError()" - :disabled="raisingBogusError"> + :disabled="raisingBogusError" + % else: + disabled + title="your permissions do not allow this" + % endif + > + % if request.has_perm('errors.bogus'): {{ raisingBogusError ? "Working, please wait..." : "Raise Bogus Error" }} + % else: + Raise Bogus Error + % endif </b-button> - </b-field> - + </div> </div> - </h3> - % endif + </div> + + </div> </%def> <%def name="modify_this_page_vars()"> ${parent.modify_this_page_vars()} - % if request.has_perm('errors.bogus'): - <script type="text/javascript"> + <script type="text/javascript"> + + ThisPageData.testRecipient = ${json.dumps(request.user.email_address)|n} + ThisPageData.sendingTest = false + + ThisPage.methods.sendTest = function() { + this.sendingTest = true + let url = '${url('emailprofiles.send_test')}' + let params = {recipient: this.testRecipient} + this.simplePOST(url, params, response => { + this.$buefy.toast.open({ + message: "Test email was sent!", + type: 'is-success', + duration: 4000, // 4 seconds + }) + this.sendingTest = false + }, response => { + this.sendingTest = false + }) + } + + % if request.has_perm('errors.bogus'): ThisPageData.raisingBogusError = false @@ -74,8 +116,8 @@ }) } - </script> - % endif + % endif + </script> </%def> diff --git a/tailbone/views/email.py b/tailbone/views/email.py index 536bf6ed..428e8484 100644 --- a/tailbone/views/email.py +++ b/tailbone/views/email.py @@ -297,6 +297,22 @@ class EmailSettingView(MasterView): 'true' if data['hidden'] else 'false') return {'ok': True} + def send_test(self): + """ + AJAX view for sending a test email. + """ + data = self.request.json_body + + recip = data.get('recipient') + if not recip: + return {'error': "Must specify recipient"} + + app = self.get_rattail_app() + app.send_email('hello', to=[recip], cc=None, bcc=None, + default_subject="Hello world") + + return {'ok': True} + @classmethod def defaults(cls, config): cls._email_defaults(config) @@ -318,6 +334,16 @@ class EmailSettingView(MasterView): permission='{}.configure'.format(permission_prefix), renderer='json') + # send test + config.add_route('{}.send_test'.format(route_prefix), + '{}/send-test'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='send_test', + route_name='{}.send_test'.format(route_prefix), + permission='{}.configure'.format(permission_prefix), + renderer='json') + + # TODO: deprecate / remove this ProfilesView = EmailSettingView From ae38e09d1b8c8d755869d3b9628ef17448f79635 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 19 May 2023 17:43:31 -0500 Subject: [PATCH 1089/1681] Avoid error when filter params not valid --- tailbone/grids/filters.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py index 26ef4f59..59e20d78 100644 --- a/tailbone/grids/filters.py +++ b/tailbone/grids/filters.py @@ -271,7 +271,8 @@ class GridFilter(object): value = self.get_value(value) filtr = getattr(self, 'filter_{0}'.format(verb), None) if not filtr: - raise ValueError("Unknown filter verb: {0}".format(repr(verb))) + log.warning("unknown filter verb: %s", verb) + return data return filtr(data, value) def get_value(self, value=UNSPECIFIED): From dd3f91cf0c62dfdadb2cd8dd85445655286e5d1a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 19 May 2023 19:45:41 -0500 Subject: [PATCH 1090/1681] Tweak byjove project generator form --- tailbone/views/projects.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tailbone/views/projects.py b/tailbone/views/projects.py index 99103101..bcc4cb5d 100644 --- a/tailbone/views/projects.py +++ b/tailbone/views/projects.py @@ -370,16 +370,25 @@ class GeneratedProjectView(MasterView): f.set_grouping([ ("Naming", [ + 'system_name', 'name', 'slug', ]), ]) + # system_name + f.set_default('system_name', "Okay Then") + f.set_helptext('system_name', + "Name of overall system to which mobile app belongs.") + # name + f.set_label('name', "Mobile App Name") f.set_default('name', "Okay Then Mobile") + f.set_helptext('name', "Display name for the mobile app.") # slug f.set_default('slug', "okay-then-mobile") + f.set_helptext('slug', "Used for NPM-compatible project name etc.") def configure_form_fabric(self, f): From 29767dfcfba596ca811e5df8fb1cc52bc79002a3 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 19 May 2023 19:46:18 -0500 Subject: [PATCH 1091/1681] Define essential views for API --- tailbone/api/essentials.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 tailbone/api/essentials.py diff --git a/tailbone/api/essentials.py b/tailbone/api/essentials.py new file mode 100644 index 00000000..7b151578 --- /dev/null +++ b/tailbone/api/essentials.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2023 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Essential views for convenient includes +""" + + +def defaults(config, **kwargs): + mod = lambda spec: kwargs.get(spec, spec) + + config.include(mod('tailbone.api.auth')) + config.include(mod('tailbone.api.common')) + + +def includeme(config): + defaults(config) From b840ae75138386d177d48bc3b690b8d3dc7138ae Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 25 May 2023 12:21:04 -0500 Subject: [PATCH 1092/1681] 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 e95389db..cb50228b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,20 @@ CHANGELOG ========= +0.9.26 (2023-05-25) +------------------- + +* Prevent bug in upgrade diff for empty new version. + +* Expose basic way to send test email. + +* Avoid error when filter params not valid. + +* Tweak byjove project generator form. + +* Define essential views for API. + + 0.9.25 (2023-05-18) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 2e241d54..f676a7c2 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.25' +__version__ = '0.9.26' From 0d9a502801801dd5c652929aa0f8794f4581fd40 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 25 May 2023 14:55:41 -0500 Subject: [PATCH 1093/1681] Fix test for config object --- tests/test_app.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_app.py b/tests/test_app.py index 6434aa0e..2523c424 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,6 +1,4 @@ -# -*- coding: utf-8 -*- - -from __future__ import unicode_literals, absolute_import +# -*- coding: utf-8; -*- import os from unittest import TestCase @@ -29,4 +27,6 @@ class TestRattailConfig(TestCase): self.assertRaises(ConfigurationError, app.make_rattail_config, {}) # get a config object if path provided result = app.make_rattail_config({'rattail.config': self.config_path}) - self.assertTrue(isinstance(result, RattailConfig)) + # nb. cannot test isinstance(RattailConfig) b/c now uses wrapper! + self.assertIsNotNone(result) + self.assertTrue(hasattr(result, 'get')) From b4816c6289fbd4256956f9109f12bf4a1d1f6db2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 30 May 2023 13:25:20 -0500 Subject: [PATCH 1094/1681] Share some code for validating vendor field and add validation for new Ordering batch --- tailbone/views/batch/vendorcatalog.py | 7 ------- tailbone/views/master.py | 7 +++++++ tailbone/views/purchasing/batch.py | 7 +------ 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/tailbone/views/batch/vendorcatalog.py b/tailbone/views/batch/vendorcatalog.py index 1bd5eed7..ec8da979 100644 --- a/tailbone/views/batch/vendorcatalog.py +++ b/tailbone/views/batch/vendorcatalog.py @@ -260,13 +260,6 @@ class VendorCatalogView(FileBatchMasterView): else: f.remove('cache_products') - def valid_vendor_uuid(self, node, value): - model = self.model - if value: - vendor = self.Session.get(model.Vendor, value) - if not vendor: - raise colander.Invalid(node, "Vendor not found") - def render_parser_key(self, batch, field): key = getattr(batch, field) if not key: diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 2d6bae16..25543cb2 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -847,6 +847,13 @@ class MasterView(View): url = self.request.route_url('vendors.view', uuid=vendor.uuid) return tags.link_to(text, url) + def valid_vendor_uuid(self, node, value): + if value: + model = self.model + vendor = self.Session.get(model.Vendor, value) + if not vendor: + node.raise_invalid("Vendor not found") + def render_department(self, obj, field): department = getattr(obj, field) if not department: diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index fdbfe38c..16153f64 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -277,6 +277,7 @@ class PurchasingBatchView(BatchMasterView): vendors_url = self.request.route_url('vendors.autocomplete') f.set_widget('vendor_uuid', forms.widgets.JQueryAutocompleteWidget( field_display=vendor_display, service_url=vendors_url)) + f.set_validator('vendor_uuid', self.valid_vendor_uuid) elif self.editing: f.set_readonly('vendor') @@ -395,12 +396,6 @@ class PurchasingBatchView(BatchMasterView): 'vendor_contact', 'status_code') - def valid_vendor_uuid(self, node, value): - model = self.model - vendor = self.Session.get(model.Vendor, value) - if not vendor: - raise colander.Invalid(node, "Invalid vendor selection") - def render_store(self, batch, field): store = batch.store if not store: From fd2b290fd08120234aa576f2a7eec66c3732b5f6 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 1 Jun 2023 11:12:31 -0500 Subject: [PATCH 1095/1681] Save datasync config with new keys, per RattailConfiguration --- tailbone/views/datasync.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py index e6c31721..3a691218 100644 --- a/tailbone/views/datasync.py +++ b/tailbone/views/datasync.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,13 +24,10 @@ DataSync Views """ -from __future__ import unicode_literals, absolute_import - import json import subprocess import logging -import six import sqlalchemy as sa from rattail.db import model @@ -117,7 +114,7 @@ class DataSyncThreadView(MasterView): watcher_data = [] consumer_data = [] now = app.localtime() - for key, profile in six.iteritems(profiles): + for key, profile in profiles.items(): watcher = profile.watcher lastrun = self.datasync_handler.get_watcher_lastrun( @@ -258,7 +255,7 @@ class DataSyncThreadView(MasterView): watch.append(pkey) settings.extend([ - {'name': 'rattail.datasync.{}.watcher'.format(pkey), + {'name': 'rattail.datasync.{}.watcher.spec'.format(pkey), 'value': profile['watcher_spec']}, {'name': 'rattail.datasync.{}.watcher.db'.format(pkey), 'value': profile['watcher_dbkey']}, @@ -289,7 +286,7 @@ class DataSyncThreadView(MasterView): if consumer['enabled']: consumers.append(ckey) settings.extend([ - {'name': 'rattail.datasync.{}.consumer.{}'.format(pkey, ckey), + {'name': 'rattail.datasync.{}.consumer.spec.{}'.format(pkey, ckey), 'value': consumer['consumer_spec']}, {'name': 'rattail.datasync.{}.consumer.{}.db'.format(pkey, ckey), 'value': consumer['consumer_dbkey']}, @@ -304,7 +301,7 @@ class DataSyncThreadView(MasterView): ]) settings.extend([ - {'name': 'rattail.datasync.{}.consumers'.format(pkey), + {'name': 'rattail.datasync.{}.consumers.list'.format(pkey), 'value': ', '.join(consumers)}, ]) From 90cb25446bf323da97de6220be70f9972386b156 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 1 Jun 2023 11:37:26 -0500 Subject: [PATCH 1096/1681] Fix datasync consumer setting save logic --- 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 3a691218..ac0fec52 100644 --- a/tailbone/views/datasync.py +++ b/tailbone/views/datasync.py @@ -286,7 +286,7 @@ class DataSyncThreadView(MasterView): if consumer['enabled']: consumers.append(ckey) settings.extend([ - {'name': 'rattail.datasync.{}.consumer.spec.{}'.format(pkey, ckey), + {'name': f'rattail.datasync.{pkey}.consumer.{ckey}.spec', 'value': consumer['consumer_spec']}, {'name': 'rattail.datasync.{}.consumer.{}.db'.format(pkey, ckey), 'value': consumer['consumer_dbkey']}, From e1685231c23e260b14c75cd6364acf1d3967c31a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 1 Jun 2023 12:17:19 -0500 Subject: [PATCH 1097/1681] 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 cb50228b..e93be9f3 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.9.27 (2023-06-01) +------------------- + +* Share some code for validating vendor field. + +* Save datasync config with new keys, per RattailConfiguration. + + 0.9.26 (2023-05-25) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index f676a7c2..55910f0a 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.26' +__version__ = '0.9.27' From 93b03c95620bc4b2d41c404cbb55222ac519f5ee Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 2 Jun 2023 14:14:33 -0500 Subject: [PATCH 1098/1681] Expose mail handler and template paths in email config page --- .../templates/settings/email/configure.mako | 18 ++++++++++++ tailbone/views/email.py | 28 +++++++++++++------ 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/tailbone/templates/settings/email/configure.mako b/tailbone/templates/settings/email/configure.mako index f0e5d4d9..50a3d483 100644 --- a/tailbone/templates/settings/email/configure.mako +++ b/tailbone/templates/settings/email/configure.mako @@ -3,6 +3,24 @@ <%def name="form_content()"> + <h3 class="block is-size-3">General</h3> + <div class="block" style="padding-left: 2rem;"> + <b-field label="Mail Handler" + message="Leave blank for default handler."> + <b-input name="rattail.mail.handler" + v-model="simpleSettings['rattail.mail.handler']" + @input="settingsNeedSaved = true"> + </b-input> + </b-field> + <b-field label="Template Paths" + message="Leave blank for default paths."> + <b-input name="rattail.mail.templates" + v-model="simpleSettings['rattail.mail.templates']" + @input="settingsNeedSaved = true"> + </b-input> + </b-field> + </div> + <h3 class="block is-size-3">Sending</h3> <div class="block" style="padding-left: 2rem;"> diff --git a/tailbone/views/email.py b/tailbone/views/email.py index 428e8484..9c3d2268 100644 --- a/tailbone/views/email.py +++ b/tailbone/views/email.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,13 +24,9 @@ Email Views """ -from __future__ import unicode_literals, absolute_import - import re import warnings -import six - from rattail import mail from rattail.db import model from rattail.config import parse_list @@ -105,7 +101,7 @@ class EmailSettingView(MasterView): emails = self.email_handler.get_all_emails() else: emails = self.email_handler.get_available_emails() - for key, Email in six.iteritems(emails): + for key, Email in emails.items(): email = Email(self.rattail_config, key) data.append(self.normalize(email)) return data @@ -266,9 +262,9 @@ class EmailSettingView(MasterView): 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()) + app.save_setting(session, 'rattail.mail.{}.enabled'.format(key), str(data['enabled']).lower()) if self.has_perm('configure'): - app.save_setting(session, 'rattail.mail.{}.hidden'.format(key), six.text_type(data['hidden']).lower()) + app.save_setting(session, 'rattail.mail.{}.hidden'.format(key), str(data['hidden']).lower()) return data def template_kwargs_view(self, **kwargs): @@ -280,6 +276,12 @@ class EmailSettingView(MasterView): config = self.rattail_config return [ + # general + {'section': 'rattail.mail', + 'option': 'handler'}, + {'section': 'rattail.mail', + 'option': 'templates'}, + # sending {'section': 'rattail.mail', 'option': 'record_attempts', @@ -289,6 +291,16 @@ class EmailSettingView(MasterView): 'type': bool}, ] + def configure_get_context(self, *args, **kwargs): + context = super().configure_get_context(*args, **kwargs) + + # prettify list of template paths + templates = self.rattail_config.parse_list( + context['simple_settings']['rattail.mail.templates']) + context['simple_settings']['rattail.mail.templates'] = ', '.join(templates) + + return context + def toggle_hidden(self): app = self.get_rattail_app() data = self.request.json_body From 13ac33bb27eccd3fa17c32666fe6ab62f9874b43 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 2 Jun 2023 14:19:53 -0500 Subject: [PATCH 1099/1681] 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 e93be9f3..53422091 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.9.28 (2023-06-02) +------------------- + +* Expose mail handler and template paths in email config page. + + 0.9.27 (2023-06-01) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 55910f0a..4ad67fa6 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.27' +__version__ = '0.9.28' From 4318f03bd628790d8f94ebb5e61ed42cbe1af392 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 5 Jun 2023 20:18:11 -0500 Subject: [PATCH 1100/1681] Add "typical" view config, for e.g. Theo and the like bring in all normal views for backoffice retail --- tailbone/views/typical.py | 57 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 tailbone/views/typical.py diff --git a/tailbone/views/typical.py b/tailbone/views/typical.py new file mode 100644 index 00000000..018794f5 --- /dev/null +++ b/tailbone/views/typical.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2023 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Typical views for convenient includes +""" + + +def defaults(config, **kwargs): + mod = lambda spec: kwargs.get(spec, spec) + + # main tables + config.include(mod('tailbone.views.brands')) + config.include(mod('tailbone.views.categories')) + config.include(mod('tailbone.views.customergroups')) + config.include(mod('tailbone.views.customers')) + config.include(mod('tailbone.views.custorders')) + config.include(mod('tailbone.views.departments')) + config.include(mod('tailbone.views.employees')) + config.include(mod('tailbone.views.families')) + config.include(mod('tailbone.views.members')) + config.include(mod('tailbone.views.products')) + config.include(mod('tailbone.views.purchases')) + config.include(mod('tailbone.views.reportcodes')) + config.include(mod('tailbone.views.stores')) + config.include(mod('tailbone.views.subdepartments')) + config.include(mod('tailbone.views.uoms')) + config.include(mod('tailbone.views.vendors')) + + # batches + config.include(mod('tailbone.views.batch.handheld')) + config.include(mod('tailbone.views.batch.importer')) + config.include(mod('tailbone.views.batch.inventory')) + config.include(mod('tailbone.views.purchasing')) + + +def includeme(config): + defaults(config) From 488126b92c42e2c59b9bed755caf5a7e6b59c4ad Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 5 Jun 2023 20:18:57 -0500 Subject: [PATCH 1101/1681] Add customer number filter for People grid --- tailbone/views/people.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index c0d0c86f..0a471f46 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -112,6 +112,7 @@ class PersonView(MasterView): def configure_grid(self, g): super(PersonView, self).configure_grid(g) + model = self.model g.joiners['email'] = lambda q: q.outerjoin(model.PersonEmailAddress, sa.and_( model.PersonEmailAddress.parent_uuid == model.Person.uuid, @@ -124,8 +125,17 @@ class PersonView(MasterView): g.set_filter('phone', model.PersonPhoneNumber.number, factory=grids.filters.AlchemyPhoneNumberFilter) - g.joiners['customer_id'] = lambda q: q.outerjoin(model.CustomerPerson).outerjoin(model.Customer) - g.filters['customer_id'] = g.make_filter('customer_id', model.Customer.id) + Customer_ID = orm.aliased(model.Customer) + CustomerPerson_ID = orm.aliased(model.CustomerPerson) + + Customer_Number = orm.aliased(model.Customer) + CustomerPerson_Number = orm.aliased(model.CustomerPerson) + + g.joiners['customer_id'] = lambda q: q.outerjoin(CustomerPerson_ID).outerjoin(Customer_ID) + g.filters['customer_id'] = g.make_filter('customer_id', Customer_ID.id) + + g.joiners['customer_number'] = lambda q: q.outerjoin(CustomerPerson_Number).outerjoin(Customer_Number) + g.filters['customer_number'] = g.make_filter('customer_number', Customer_Number.number) g.filters['first_name'].default_active = True g.filters['first_name'].default_verb = 'contains' From 6f02e1b18e0ce880ae88537e168f379002095d66 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 6 Jun 2023 09:39:02 -0500 Subject: [PATCH 1102/1681] Tweak logic for `MasterView.get_action_route_kwargs()` hopefully this improves default handling when model keys are composite, and if we can confirm the "secondary" (previous) logic no longer happens, then can remove that altogether..? --- docs/api/views/master.rst | 15 +++++ tailbone/views/master.py | 113 +++++++++++++++++++++++++++++++------- 2 files changed, 109 insertions(+), 19 deletions(-) diff --git a/docs/api/views/master.rst b/docs/api/views/master.rst index bf505b6c..44278e0a 100644 --- a/docs/api/views/master.rst +++ b/docs/api/views/master.rst @@ -88,6 +88,8 @@ Methods to Override The following is a list of methods which you can override when defining your subclass. + .. automethod:: MasterView.editable_instance + .. .. automethod:: MasterView.get_settings .. automethod:: MasterView.get_csv_fields @@ -95,3 +97,16 @@ subclass. .. automethod:: MasterView.get_csv_row .. automethod:: MasterView.get_help_url + + .. automethod:: MasterView.get_model_key + + +Support Methods +--------------- + +The following is a list of methods you should (probably) not need to +override, but may find useful: + + .. automethod:: MasterView.default_edit_url + + .. automethod:: MasterView.get_action_route_kwargs diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 25543cb2..394424a2 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2126,10 +2126,30 @@ class MasterView(View): @classmethod def get_model_key(cls, as_tuple=False): """ - Returns the primary key(s) for the model class. Note that this will - return a *string* value unless a tuple is requested. If the model has - a composite key then the string result would be a comma-delimited list - of names, e.g. ``foo_id,bar_id``. + Returns the primary model key(s) for the master view. + + Internally, model keys are a sequence of one or more keys. + Most typically it's just one, so e.g. ``('uuid',)``, but + composite keys are possible too, e.g. ``('parent_id', + 'child_id')``. + + Despite that, this method will return a *string* + representation of the keys, unless ``as_tuple=True`` in which + case it returns a tuple. For example:: + + # for model keys: ('uuid',) + + cls.get_model_key() # => 'uuid' + cls.get_model_key(as_tuple=True) # => ('uuid',) + + # for model keys: ('parent_id', 'child_id') + + cls.get_model_key() # => 'parent_id,child_id' + cls.get_model_key(as_tuple=True) # => ('parent_id', 'child_id') + + :param as_tuple: Whether to return a tuple instead of string. + + :returns: Either a string or tuple of model keys. """ if hasattr(cls, 'model_key'): keys = cls.model_key @@ -2850,10 +2870,23 @@ class MasterView(View): kwargs['click_handler'] = 'deleteObject' return self.make_action('delete', icon='trash', url=self.default_delete_url, **kwargs) - def default_edit_url(self, row, i=None): - if self.editable_instance(row): + def default_edit_url(self, obj, i=None): + """ + Return the default "edit" URL for the given object, if + applicable. This first checks :meth:`editable_instance()` for + the object, and will only return a URL if the object is deemed + editable. + + :param obj: A top-level record/object, of the type normally + handled by this master view. + + :param i: Optional row index within a grid. + + :returns: The "edit object" URL as string, or ``None``. + """ + if self.editable_instance(obj): return self.request.route_url('{}.edit'.format(self.get_route_prefix()), - **self.get_action_route_kwargs(row)) + **self.get_action_route_kwargs(obj)) def default_clone_url(self, row, i=None): return self.request.route_url('{}.clone'.format(self.get_route_prefix()), @@ -2875,24 +2908,61 @@ class MasterView(View): factory = grids.GridAction return factory(key, url=url, **kwargs) - def get_action_route_kwargs(self, row): + def get_action_route_kwargs(self, obj): """ - Hopefully generic kwarg generator for basic action routes. + Get a dict of route kwargs for the given object. + + This is called from various other "convenience" URL + generators, e.g. :meth:`default_edit_url()`. + + It inspects the given object, as well as the "model key" (as + returned by :meth:`get_model_key()`), and returns a dict of + appropriate route kwargs for the object. + + Most typically, the model key is just ``uuid`` and so this + would effectively return ``{'uuid': obj.uuid}``. + + But composite model keys are supported too, so if the model + key is ``(parent_id, child_id)`` this might instead return + ``{'parent_id': obj.parent_id, 'child_id': obj.child_id}``. + + Such kwargs would then be fed into ``route_url()`` as needed, + for example to get a "view product URL":: + + kw = self.get_action_route_kwargs(product) + url = self.request.route_url('products.view', **kw) + + :param obj: A top-level record/object, of the type normally + handled by this master view. + + :returns: A dict of route kwargs for the object. """ + keys = self.get_model_key(as_tuple=True) + if keys: + try: + return dict([(key, obj[key]) + for key in keys]) + except TypeError: + return dict([(key, getattr(obj, key)) + for key in keys]) + + # TODO: sanity check, is the above all we need..? + log.warning("yes we still do the code below sometimes") + try: - mapper = orm.object_mapper(row) + mapper = orm.object_mapper(obj) except orm.exc.UnmappedInstanceError: try: if isinstance(self.model_key, str): - return {self.model_key: row[self.model_key]} - return dict([(key, row[key]) + return {self.model_key: obj[self.model_key]} + return dict([(key, obj[key]) for key in self.model_key]) except TypeError: - return {self.model_key: getattr(row, self.model_key)} + return {self.model_key: getattr(obj, self.model_key)} else: - pkeys = get_primary_keys(row) + pkeys = get_primary_keys(obj) keys = list(pkeys) - values = [getattr(row, k) for k in keys] + values = [getattr(obj, k) for k in keys] return dict(zip(keys, values)) def get_data(self, session=None): @@ -4160,11 +4230,16 @@ class MasterView(View): Event hook, called just after a new instance is saved. """ - def editable_instance(self, instance): + def editable_instance(self, obj): """ - Returns boolean indicating whether or not the given instance can be - considered "editable". Returns ``True`` by default; override as - necessary. + Returns boolean indicating whether or not the given object + should be considered "editable". Returns ``True`` by default; + override as necessary. + + :param obj: A top-level record/object, of the type normally + handled by this master view. + + :returns: ``True`` if object is editable, else ``False``. """ return True From 9b59b44609623a2d9aa81f51533d12a62676d5c5 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 6 Jun 2023 09:40:14 -0500 Subject: [PATCH 1103/1681] Add "touch" support for Members --- tailbone/views/members.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/tailbone/views/members.py b/tailbone/views/members.py index a0157649..28265061 100644 --- a/tailbone/views/members.py +++ b/tailbone/views/members.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,9 +24,6 @@ Member Views """ -from __future__ import unicode_literals, absolute_import - -import six import sqlalchemy as sa from rattail.db import model @@ -43,6 +40,7 @@ class MemberView(MasterView): """ model_class = model.Member is_contact = True + touchable = True has_versions = True labels = { @@ -134,7 +132,7 @@ class MemberView(MasterView): f.replace('person', 'person_uuid') people = self.Session.query(model.Person)\ .order_by(model.Person.display_name) - values = [(p.uuid, six.text_type(p)) + values = [(p.uuid, str(p)) for p in people] require = False if not require: @@ -151,7 +149,7 @@ class MemberView(MasterView): f.replace('customer', 'customer_uuid') customers = self.Session.query(model.Customer)\ .order_by(model.Customer.name) - values = [(c.uuid, six.text_type(c)) + values = [(c.uuid, str(c)) for c in customers] require = False if not require: From 0d97ff29369fe01b9bc81e3223566f88b274937c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 6 Jun 2023 11:06:16 -0500 Subject: [PATCH 1104/1681] Add support for "configured customer/member key" also improve product key support, same patterns --- tailbone/templates/customers/configure.mako | 44 +++++++++ tailbone/templates/members/configure.mako | 57 ++++++++++++ .../templates/people/view_profile_buefy.mako | 22 ++--- tailbone/views/customers.py | 20 +++-- tailbone/views/master.py | 90 ++++++++++++++++--- tailbone/views/members.py | 26 ++++-- tailbone/views/people.py | 4 + 7 files changed, 224 insertions(+), 39 deletions(-) create mode 100644 tailbone/templates/members/configure.mako diff --git a/tailbone/templates/customers/configure.mako b/tailbone/templates/customers/configure.mako index f465fdf5..708d0b17 100644 --- a/tailbone/templates/customers/configure.mako +++ b/tailbone/templates/customers/configure.mako @@ -6,6 +6,26 @@ <h3 class="block is-size-3">General</h3> <div class="block" style="padding-left: 2rem;"> + <b-field grouped> + + <b-field label="Key Field"> + <b-select name="rattail.customers.key_field" + v-model="simpleSettings['rattail.customers.key_field']" + @input="updateKeyLabel()"> + <option value="id">id</option> + <option value="number">number</option> + </b-select> + </b-field> + + <b-field label="Key Field Label"> + <b-input name="rattail.customers.key_label" + v-model="simpleSettings['rattail.customers.key_label']" + @input="settingsNeedSaved = true"> + </b-input> + </b-field> + + </b-field> + <b-field message="If not set, customer chooser is an autocomplete field."> <b-checkbox name="rattail.customers.choice_uses_dropdown" v-model="simpleSettings['rattail.customers.choice_uses_dropdown']" @@ -33,5 +53,29 @@ </%def> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + ThisPage.methods.getLabelForKey = function(key) { + switch (key) { + case 'id': + return "ID" + case 'number': + return "Number" + default: + return "Key" + } + } + + ThisPage.methods.updateKeyLabel = function() { + this.simpleSettings['rattail.customers.key_label'] = this.getLabelForKey( + this.simpleSettings['rattail.customers.key_field']) + this.settingsNeedSaved = true + } + + </script> +</%def> + ${parent.body()} diff --git a/tailbone/templates/members/configure.mako b/tailbone/templates/members/configure.mako new file mode 100644 index 00000000..07d67970 --- /dev/null +++ b/tailbone/templates/members/configure.mako @@ -0,0 +1,57 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="form_content()"> + + <h3 class="block is-size-3">General</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field grouped> + + <b-field label="Key Field"> + <b-select name="rattail.members.key_field" + v-model="simpleSettings['rattail.members.key_field']" + @input="updateKeyLabel()"> + <option value="id">id</option> + <option value="number">number</option> + </b-select> + </b-field> + + <b-field label="Key Field Label"> + <b-input name="rattail.members.key_label" + v-model="simpleSettings['rattail.members.key_label']" + @input="settingsNeedSaved = true"> + </b-input> + </b-field> + + </b-field> + + </div> +</%def> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + ThisPage.methods.getLabelForKey = function(key) { + switch (key) { + case 'id': + return "ID" + case 'number': + return "Number" + default: + return "Key" + } + } + + ThisPage.methods.updateKeyLabel = function() { + this.simpleSettings['rattail.members.key_label'] = this.getLabelForKey( + this.simpleSettings['rattail.members.key_field']) + this.settingsNeedSaved = true + } + + </script> +</%def> + + +${parent.body()} diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako index 6937f592..075735cc 100644 --- a/tailbone/templates/people/view_profile_buefy.mako +++ b/tailbone/templates/people/view_profile_buefy.mako @@ -540,19 +540,15 @@ <b-icon pack="fas" icon="caret-right"> </b-icon> - <strong>#{{ member.number }} {{ member.display }}</strong> + <strong>{{ member._key }} - {{ member.display }}</strong> </div> <div class="panel-block"> <div style="display: flex; justify-content: space-between; width: 100%;"> <div style="flex-grow: 1;"> - <b-field horizontal label="Number"> - {{ member.number }} - </b-field> - - <b-field horizontal label="ID"> - {{ member.id }} + <b-field horizontal label="${member_key_label}"> + {{ member._key }} </b-field> <b-field horizontal label="Active"> @@ -630,19 +626,15 @@ <b-icon pack="fas" icon="caret-right"> </b-icon> - <strong>#{{ customer.number }} {{ customer.name }}</strong> + <strong>{{ customer._key }} - {{ customer.name }}</strong> </div> <div class="panel-block"> <div style="display: flex; justify-content: space-between; width: 100%;"> <div style="flex-grow: 1;"> - <b-field horizontal label="Number"> - {{ customer.number }} - </b-field> - - <b-field horizontal label="ID"> - {{ customer.id }} + <b-field horizontal label="${customer_key_label}"> + {{ customer._key }} </b-field> <b-field horizontal label="Name"> @@ -1011,8 +1003,8 @@ <%def name="render_profile_tabs()"> ${self.render_personal_tab()} - ${self.render_customer_tab()} ${self.render_member_tab()} + ${self.render_customer_tab()} ${self.render_employee_tab()} ${self.render_user_tab()} </%def> diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index 50b93d59..02071ab4 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -63,16 +63,14 @@ class CustomerView(MasterView): } grid_columns = [ - 'id', - 'number', + '_customer_key_', 'name', 'phone', 'email', ] form_fields = [ - 'id', - 'number', + '_customer_key_', 'name', 'default_phone', 'default_address', @@ -114,13 +112,16 @@ class CustomerView(MasterView): super(CustomerView, self).configure_grid(g) model = self.model - # number - g.set_link('number') + # customer key + field = self.get_customer_key_field() + g.filters[field].default_active = True + g.filters[field].default_verb = 'equal' + g.set_sort_defaults(field) + g.set_link(field) # name g.filters['name'].default_active = True g.filters['name'].default_verb = 'contains' - g.set_sort_defaults('name') # phone g.set_joiner('phone', lambda q: q.outerjoin(model.CustomerPhoneNumber, sa.and_( @@ -158,7 +159,6 @@ class CustomerView(MasterView): g.filters['active_in_pos'].default_active = True g.filters['active_in_pos'].default_verb = 'is_true' - g.set_link('id') g.set_link('name') g.set_link('person') g.set_link('email') @@ -485,6 +485,10 @@ class CustomerView(MasterView): return [ # General + {'section': 'rattail', + 'option': 'customers.key_field'}, + {'section': 'rattail', + 'option': 'customers.key_label'}, {'section': 'rattail', 'option': 'customers.choice_uses_dropdown', 'type': bool}, diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 394424a2..0993ac7d 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -163,6 +163,8 @@ class MasterView(View): labels = {'uuid': "UUID"} + customer_key_fields = {} + member_key_fields = {} product_key_fields = {} # ROW-RELATED ATTRS FOLLOW: @@ -463,6 +465,8 @@ class MasterView(View): grid.remove('local_only') grid.remove_filter('local_only') + self.configure_column_customer_key(grid) + self.configure_column_member_key(grid) self.configure_column_product_key(grid) for supp in self.iter_view_supplements(): @@ -561,6 +565,8 @@ class MasterView(View): # super(MasterView, self).configure_row_grid(grid) self.set_row_labels(grid) + self.configure_column_customer_key(grid) + self.configure_column_member_key(grid) self.configure_column_product_key(grid) def row_grid_extra_class(self, obj, i): @@ -2407,8 +2413,14 @@ class MasterView(View): 'quickie': None, } - key = self.rattail_config.product_key() - context['product_key_field'] = self.product_key_fields.get(key, key) + context['customer_key_field'] = self.get_customer_key_field() + context['customer_key_label'] = self.get_customer_key_label() + + context['member_key_field'] = self.get_member_key_field() + context['member_key_label'] = self.get_member_key_label() + + context['product_key_field'] = self.get_product_key_field() + context['product_key_label'] = self.get_product_key_label() if self.expose_quickie_search: context['quickie'] = self.get_quickie_context() @@ -4131,6 +4143,8 @@ class MasterView(View): """ self.configure_common_form(form) + self.configure_field_customer_key(form) + self.configure_field_member_key(form) self.configure_field_product_key(form) for supp in self.iter_view_supplements(): @@ -4596,6 +4610,8 @@ class MasterView(View): self.set_row_labels(form) + self.configure_field_customer_key(form) + self.configure_field_member_key(form) self.configure_field_product_key(form) def validate_row_form(self, form): @@ -4604,23 +4620,77 @@ class MasterView(View): return True return False + def get_customer_key_field(self): + app = self.get_rattail_app() + key = app.get_customer_key_field() + return self.customer_key_fields.get(key, key) + + def get_customer_key_label(self): + app = self.get_rattail_app() + field = self.get_customer_key_field() + return app.get_customer_key_label(field=field) + + def configure_column_customer_key(self, g): + if '_customer_key_' in g.columns: + field = self.get_customer_key_field() + g.replace('_customer_key_', field) + g.set_label(field, self.get_customer_key_label()) + g.set_link(field) + + def configure_field_customer_key(self, f): + if '_customer_key_' in f: + field = self.get_customer_key_field() + f.replace('_customer_key_', field) + f.set_label(field, self.get_customer_key_label()) + + def get_member_key_field(self): + app = self.get_rattail_app() + key = app.get_member_key_field() + return self.member_key_fields.get(key, key) + + def get_member_key_label(self): + app = self.get_rattail_app() + field = self.get_member_key_field() + return app.get_member_key_label(field=field) + + def configure_column_member_key(self, g): + if '_member_key_' in g.columns: + field = self.get_member_key_field() + g.replace('_member_key_', field) + g.set_label(field, self.get_member_key_label()) + g.set_link(field) + + def configure_field_member_key(self, f): + if '_member_key_' in f: + field = self.get_member_key_field() + f.replace('_member_key_', field) + f.set_label(field, self.get_member_key_label()) + + def get_product_key_field(self): + app = self.get_rattail_app() + key = app.get_product_key_field() + return self.product_key_fields.get(key, key) + + def get_product_key_label(self): + app = self.get_rattail_app() + field = self.get_product_key_field() + return app.get_product_key_label(field=field) + def configure_column_product_key(self, g): if '_product_key_' in g.columns: - key = self.rattail_config.product_key() - field = self.product_key_fields.get(key, key) + field = self.get_product_key_field() g.replace('_product_key_', field) - g.set_label(field, self.rattail_config.product_key_title(key)) + g.set_label(field, self.get_product_key_label()) g.set_link(field) - if key == 'upc': + if field == 'upc': g.set_renderer(field, self.render_upc) def configure_field_product_key(self, f): if '_product_key_' in f: - key = self.rattail_config.product_key() - field = self.product_key_fields.get(key, key) + field = self.get_product_key_field() f.replace('_product_key_', field) - f.set_label(field, self.rattail_config.product_key_title(key)) - if key == 'upc': + f.set_label(field, self.get_product_key_label()) + if field == 'upc': f.set_renderer(field, self.render_upc) def get_row_action_url(self, action, row, **kwargs): diff --git a/tailbone/views/members.py b/tailbone/views/members.py index 28265061..955a217f 100644 --- a/tailbone/views/members.py +++ b/tailbone/views/members.py @@ -42,14 +42,14 @@ class MemberView(MasterView): is_contact = True touchable = True has_versions = True + configurable = True labels = { 'id': "ID", } grid_columns = [ - 'number', - 'id', + '_member_key_', 'person', 'customer', 'email', @@ -61,8 +61,7 @@ class MemberView(MasterView): ] form_fields = [ - 'number', - 'id', + '_member_key_', 'person', 'customer', 'default_email', @@ -77,6 +76,13 @@ class MemberView(MasterView): def configure_grid(self, g): super(MemberView, self).configure_grid(g) + # member key + field = self.get_member_key_field() + g.filters[field].default_active = True + g.filters[field].default_verb = 'equal' + g.set_sort_defaults(field) + g.set_link(field) + g.set_joiner('person', lambda q: q.outerjoin(model.Person)) g.set_sorter('person', model.Person.display_name) g.set_filter('person', model.Person.display_name) @@ -105,8 +111,6 @@ class MemberView(MasterView): g.set_filter('email', model.MemberEmailAddress.address) g.set_label('email', "Email Address") - g.set_sort_defaults('number') - g.set_link('person') g.set_link('customer') @@ -186,6 +190,16 @@ class MemberView(MasterView): if member.phones: return member.phones[0].number + def configure_get_simple_settings(self): + return [ + + # General + {'section': 'rattail', + 'option': 'members.key_field'}, + {'section': 'rattail', + 'option': 'members.key_label'}, + ] + def defaults(config, **kwargs): base = globals() diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 0a471f46..dc75b8aa 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -545,12 +545,14 @@ class PersonView(MasterView): return context def get_context_customers(self, person): + key = self.get_customer_key_field() data = [] for cp in person._customers: customer = cp.customer data.append({ 'uuid': customer.uuid, 'ordinal': cp.ordinal, + '_key': getattr(customer, key), 'id': customer.id, 'number': customer.number, 'name': customer.name, @@ -582,8 +584,10 @@ class PersonView(MasterView): profile_url = self.request.route_url('people.view_profile', uuid=member.person_uuid) + key = self.get_member_key_field() return { 'uuid': member.uuid, + '_key': getattr(member, key), 'number': member.number, 'id': member.id, 'active': member.active, From c38dc8b84295f9d48047e0113f30749b66be9afc Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 6 Jun 2023 11:54:58 -0500 Subject: [PATCH 1105/1681] Use *actual* current URL for user feedback msg was using current URL as of page load, but #hash can change after that, e.g. on profile view --- tailbone/static/js/tailbone.feedback.js | 1 + tailbone/templates/base.mako | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tailbone/static/js/tailbone.feedback.js b/tailbone/static/js/tailbone.feedback.js index 6f687b80..648c9695 100644 --- a/tailbone/static/js/tailbone.feedback.js +++ b/tailbone/static/js/tailbone.feedback.js @@ -12,6 +12,7 @@ let FeedbackForm = { }, showFeedback() { + this.referrer = location.href this.showDialog = true this.$nextTick(function() { this.$refs.textarea.focus() diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index 91589990..723e106c 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -485,6 +485,7 @@ ${page_help.render_template()} + % if request.has_perm('common.feedback'): <script type="text/x-template" id="feedback-template"> <div> @@ -570,6 +571,7 @@ </div> </script> + % endif ${tailbone_autocomplete_template()} ${multi_file_upload.render_template()} @@ -882,8 +884,6 @@ <%def name="modify_whole_page_vars()"> <script type="text/javascript"> - FeedbackFormData.referrer = location.href - % if request.user: FeedbackFormData.userUUID = ${json.dumps(request.user.uuid)|n} FeedbackFormData.userName = ${json.dumps(six.text_type(request.user))|n} From 027d44e04a2051871a97cd4661059899a11f7d0b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 6 Jun 2023 11:57:20 -0500 Subject: [PATCH 1106/1681] Remove old/unused feedback templates --- tailbone/templates/feedback.mako | 57 ------------ tailbone/templates/feedback_dialog.mako | 39 -------- tailbone/templates/feedback_dialog_buefy.mako | 88 ------------------- 3 files changed, 184 deletions(-) delete mode 100644 tailbone/templates/feedback.mako delete mode 100644 tailbone/templates/feedback_dialog.mako delete mode 100644 tailbone/templates/feedback_dialog_buefy.mako diff --git a/tailbone/templates/feedback.mako b/tailbone/templates/feedback.mako deleted file mode 100644 index e82a6068..00000000 --- a/tailbone/templates/feedback.mako +++ /dev/null @@ -1,57 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/base.mako" /> - -<%def name="title()">User Feedback</%def> - -<%def name="head_tags()"> - ${parent.head_tags()} - <style type="text/css"> - .form p { - margin: 1em 0; - } - div.field-wrapper div.field input[type=text] { - width: 25em; - } - div.field-wrapper div.field textarea { - width: 50em; - } - div.buttons { - margin-left: 15em; - } - </style> -</%def> - -<div class="form"> - ${form.begin()} - ${form.csrf_token()} - ${form.hidden('user', value=request.user.uuid if request.user else None)} - - <p> - Questions, suggestions, comments, complaints, etc. regarding this website - are welcome and may be submitted below. - </p> - <p> - Messages will be delivered to the local IT department, and possibly others. - </p> - -## % if error: -## <div class="error">${error}</div> -## % endif - - % if request.user: - ${form.field_div('user_name', form.hidden('user_name', value=six.text_type(request.user)) + six.text_type(request.user), label="Your Name")} - % else: - ${form.field_div('user_name', form.text('user_name'), label="Your Name")} - % endif - - ${form.field_div('referrer', form.hidden('referrer', value=request.get_referrer()) + request.get_referrer(), label="Referring URL")} - - ${form.field_div('message', form.textarea('message', rows=15))} - - <div class="buttons"> - ${form.submit('send', "Send Message")} - ${h.link_to("Cancel", request.get_referrer(), class_='button')} - </div> - - ${form.end()} -</div> diff --git a/tailbone/templates/feedback_dialog.mako b/tailbone/templates/feedback_dialog.mako deleted file mode 100644 index 2892a688..00000000 --- a/tailbone/templates/feedback_dialog.mako +++ /dev/null @@ -1,39 +0,0 @@ -## -*- coding: utf-8; -*- - -<%def name="feedback_dialog()"> - <div id="feedback-dialog" style="display: none;"> - ${h.form(url('feedback'))} - ${h.csrf_token(request)} - ${h.hidden('user', value=request.user.uuid if request.user else None)} - - <p> - Questions, suggestions, comments, complaints, etc. <span class="red">regarding this website</span> - are welcome and may be submitted below. - </p> - - <div class="field-wrapper referrer"> - <label for="referrer">Referring URL</label> - <div class="field"></div> - </div> - - % if request.user: - ${h.hidden('user_name', value=six.text_type(request.user))} - % else: - <div class="field-wrapper"> - <label for="user_name">Your Name</label> - <div class="field"> - ${h.text('user_name')} - </div> - </div> - % endif - - <div class="field-wrapper"> - <label for="referrer">Message</label> - <div class="field"> - ${h.textarea('message', cols=45, rows=15)} - </div> - </div> - - ${h.end_form()} - </div> -</%def> diff --git a/tailbone/templates/feedback_dialog_buefy.mako b/tailbone/templates/feedback_dialog_buefy.mako deleted file mode 100644 index 7507bd91..00000000 --- a/tailbone/templates/feedback_dialog_buefy.mako +++ /dev/null @@ -1,88 +0,0 @@ -## -*- coding: utf-8; -*- - -<%def name="feedback_dialog()"> - <script type="text/x-template" id="feedback-template"> - <div> - - <div class="level-item"> - <b-button type="is-primary" - @click="showFeedback()" - icon-pack="fas" - icon-left="fas fa-comment"> - Feedback - </b-button> - </div> - - <b-modal has-modal-card - :active.sync="showDialog"> - <div class="modal-card"> - - <header class="modal-card-head"> - <p class="modal-card-title">User Feedback</p> - </header> - - <section class="modal-card-body"> - <p> - Questions, suggestions, comments, complaints, etc. - <span class="red">regarding this website</span> are - welcome and may be submitted below. - </p> - - <b-field label="User Name"> - <b-input v-model="userName" - % if request.user: - disabled - % endif - > - </b-input> - </b-field> - - <b-field label="Referring URL"> - <b-input - ## :value="referrer" - v-model="referrer" - disabled="true"> - </b-input> - </b-field> - - <b-field label="Message"> - <b-input type="textarea" - v-model="message" - ref="textarea"> - </b-input> - </b-field> - - </section> - - <footer class="modal-card-foot"> - <b-button @click="showDialog = false"> - Cancel - </b-button> - <once-button type="is-primary" - @click="sendFeedback()" - :disabled="!message.trim()" - text="Send Message"> - </once-button> - </footer> - </div> - </b-modal> - - </div> - </script> - - <script type="text/javascript"> - - FeedbackFormData.csrftoken = ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n} - FeedbackFormData.referrer = location.href - - % if request.user: - FeedbackFormData.userUUID = ${json.dumps(request.user.uuid)|n} - FeedbackFormData.userName = ${json.dumps(six.text_type(request.user))|n} - % endif - - FeedbackForm.data = function() { return FeedbackFormData } - - Vue.component('feedback-form', FeedbackForm) - - </script> -</%def> From 816e6523571ae6c54244b35ac0bda97c6a68c516 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 6 Jun 2023 13:13:19 -0500 Subject: [PATCH 1107/1681] Add basic support for membership types --- tailbone/menus.py | 19 +++-- .../templates/people/view_profile_buefy.mako | 10 +++ tailbone/views/members.py | 83 ++++++++++++++++++- tailbone/views/people.py | 14 +++- 4 files changed, 116 insertions(+), 10 deletions(-) diff --git a/tailbone/menus.py b/tailbone/menus.py index 9a0ba066..8aebf043 100644 --- a/tailbone/menus.py +++ b/tailbone/menus.py @@ -332,6 +332,12 @@ class MenuHandler(GenericHandler): 'route': 'members', 'perm': 'members.list', }, + { + 'title': "Membership Types", + 'route': 'membership_types', + 'perm': 'membership_types.list', + }, + {'type': 'sep'}, { 'title': "Customers", 'route': 'customers', @@ -342,22 +348,23 @@ class MenuHandler(GenericHandler): 'route': 'customergroups', 'perm': 'customergroups.list', }, + { + 'title': "Pending Customers", + 'route': 'pending_customers', + 'perm': 'pending_customers.list', + }, + {'type': 'sep'}, { 'title': "Employees", 'route': 'employees', 'perm': 'employees.list', }, + {'type': 'sep'}, { 'title': "All People", 'route': 'people', 'perm': 'people.list', }, - {'type': 'sep'}, - { - 'title': "Pending Customers", - 'route': 'pending_customers', - 'perm': 'pending_customers.list', - }, ], } diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako index 075735cc..f21c021e 100644 --- a/tailbone/templates/people/view_profile_buefy.mako +++ b/tailbone/templates/people/view_profile_buefy.mako @@ -551,6 +551,16 @@ {{ member._key }} </b-field> + <b-field horizontal label="Membership Type"> + <a v-if="member.view_membership_type_url" + :href="member.view_membership_type_url"> + {{ member.membership_type_name }} + </a> + <span v-if="!member.view_membership_type_url"> + {{ member.membership_type_name }} + </span> + </b-field> + <b-field horizontal label="Active"> {{ member.active }} </b-field> diff --git a/tailbone/views/members.py b/tailbone/views/members.py index 955a217f..9f96e667 100644 --- a/tailbone/views/members.py +++ b/tailbone/views/members.py @@ -27,13 +27,70 @@ Member Views import sqlalchemy as sa from rattail.db import model +from rattail.db.model import MembershipType, Member from deform import widget as dfwidget +from webhelpers2.html import tags from tailbone import grids from tailbone.views import MasterView +class MembershipTypeView(MasterView): + """ + Master view for Membership Types + """ + model_class = MembershipType + route_prefix = 'membership_types' + url_prefix = '/membership-types' + has_versions = True + + labels = { + 'id': "ID", + } + + grid_columns = [ + 'number', + 'name', + ] + + has_rows = True + model_row_class = Member + rows_title = "Members" + + row_grid_columns = [ + '_member_key_', + 'person', + 'active', + 'equity_current', + 'equity_total', + 'joined', + 'withdrew', + ] + + def configure_grid(self, g): + super().configure_grid(g) + + g.set_sort_defaults('number') + + g.set_link('number') + g.set_link('name') + + def get_row_data(self, memtype): + model = self.model + return self.Session.query(model.Member)\ + .filter(model.Member.membership_type == memtype) + + def get_parent(self, member): + return member.membership_type + + def configure_row_grid(self, g): + super().configure_row_grid(g) + + g.filters['active'].default_active = True + g.filters['active'].default_verb = 'is_true' + + class MemberView(MasterView): """ Master view for the Member class. @@ -51,9 +108,7 @@ class MemberView(MasterView): grid_columns = [ '_member_key_', 'person', - 'customer', - 'email', - 'phone', + 'membership_type', 'active', 'equity_current', 'joined', @@ -66,6 +121,7 @@ class MemberView(MasterView): 'customer', 'default_email', 'default_phone', + 'membership_type', 'active', 'equity_current', 'equity_payment_due', @@ -75,6 +131,7 @@ class MemberView(MasterView): def configure_grid(self, g): super(MemberView, self).configure_grid(g) + model = self.model # member key field = self.get_member_key_field() @@ -111,6 +168,12 @@ class MemberView(MasterView): g.set_filter('email', model.MemberEmailAddress.address) g.set_label('email', "Email Address") + # membership_type + g.set_joiner('membership_type', lambda q: q.outerjoin(model.MembershipType)) + g.set_sorter('membership_type', model.MembershipType.name) + g.set_filter('membership_type', model.MembershipType.name, + label="Membership Type Name") + g.set_link('person') g.set_link('customer') @@ -174,6 +237,9 @@ class MemberView(MasterView): if not self.creating and member.phones: f.set_default('default_phone', member.phones[0].number) + # membership_type + f.set_renderer('membership_type', self.render_membership_type) + if self.creating: f.remove_fields( 'equity_total', @@ -190,6 +256,14 @@ class MemberView(MasterView): if member.phones: return member.phones[0].number + def render_membership_type(self, member, field): + memtype = getattr(member, field) + if not memtype: + return + text = str(memtype) + url = self.request.route_url('membership_types.view', uuid=memtype.uuid) + return tags.link_to(text, url) + def configure_get_simple_settings(self): return [ @@ -204,6 +278,9 @@ class MemberView(MasterView): def defaults(config, **kwargs): base = globals() + MembershipTypeView = kwargs.get('MembershipTypeView', base['MembershipTypeView']) + MembershipTypeView.defaults(config) + MemberView = kwargs.get('MemberView', base['MemberView']) MemberView.defaults(config) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index dc75b8aa..89b857f1 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -585,7 +585,7 @@ class PersonView(MasterView): uuid=member.person_uuid) key = self.get_member_key_field() - return { + data = { 'uuid': member.uuid, '_key': getattr(member, key), 'number': member.number, @@ -602,6 +602,18 @@ class PersonView(MasterView): 'view_profile_url': profile_url, } + membership_type = member.membership_type + if membership_type: + data.update({ + 'membership_type_uuid': membership_type.uuid, + 'membership_type_number': membership_type.number, + 'membership_type_name': membership_type.name, + 'view_membership_type_url': self.request.route_url( + 'membership_types.view', uuid=membership_type.uuid), + }) + + return data + def get_context_employee(self, employee): """ Return a dict of context data for the given employee. From cfdb4923494e7e4d03d6db7e4dfe73220371d6e7 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 6 Jun 2023 16:37:58 -0500 Subject: [PATCH 1108/1681] Add support for version history in person profile view yay, finally --- .../templates/people/view_profile_buefy.mako | 261 +++++++++++++++++- tailbone/views/people.py | 199 +++++++++++++ 2 files changed, 448 insertions(+), 12 deletions(-) diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako index f21c021e..c1799c16 100644 --- a/tailbone/templates/people/view_profile_buefy.mako +++ b/tailbone/templates/people/view_profile_buefy.mako @@ -18,11 +18,59 @@ ${dynamic_content_title} </%def> +<%def name="render_instance_header_buttons()"> + % if request.has_perm('people_profile.view_versions'): + <b-button v-if="!viewingHistory" + icon-pack="fas" + icon-left="history" + @click="viewHistory()"> + View History + </b-button> + <div v-if="viewingHistory" + class="buttons"> + <b-button icon-pack="fas" + icon-left="redo" + @click="refreshHistory()" + :disabled="gettingRevisions"> + {{ gettingRevisions ? "Working, please wait..." : "Refresh History" }} + </b-button> + <b-button icon-pack="fas" + icon-left="user" + @click="viewingHistory = false"> + View Profile + </b-button> + </div> + % endif +</%def> + <%def name="page_content()"> - <profile-info @change-content-title="changeContentTitle"> + <profile-info @change-content-title="changeContentTitle" + % if request.has_perm('people_profile.view_versions'): + :viewing-history="viewingHistory" + :getting-revisions="gettingRevisions" + :revisions="revisions" + :revision-version-map="revisionVersionMap" + % endif + > </profile-info> </%def> +<%def name="render_this_page_component()"> + ## TODO: should override this in a cleaner way! too much duplicate code w/ parent template + <this-page @change-content-title="changeContentTitle" + % if can_edit_help: + :configure-fields-help="configureFieldsHelp" + % endif + % if request.has_perm('people_profile.view_versions'): + :viewing-history="viewingHistory" + :getting-revisions="gettingRevisions" + :revisions="revisions" + :revision-version-map="revisionVersionMap" + % endif + > + </this-page> +</%def> + <%def name="render_this_page()"> ${self.page_content()} </%def> @@ -551,6 +599,16 @@ {{ member._key }} </b-field> + <b-field horizontal label="Person"> + <a v-if="member.person_uuid != person.uuid" + :href="member.view_profile_url"> + {{ member.person_display_name }} + </a> + <span v-if="member.person_uuid == person.uuid"> + {{ member.person_display_name }} + </span> + </b-field> + <b-field horizontal label="Membership Type"> <a v-if="member.view_membership_type_url" :href="member.view_membership_type_url"> @@ -562,7 +620,7 @@ </b-field> <b-field horizontal label="Active"> - {{ member.active }} + {{ member.active ? "Yes" : "No" }} </b-field> <b-field horizontal label="Joined"> @@ -574,16 +632,6 @@ {{ member.withdrew }} </b-field> - <b-field horizontal label="Person"> - <a v-if="member.person_uuid != person.uuid" - :href="member.view_profile_url"> - {{ member.person_display_name }} - </a> - <span v-if="member.person_uuid == person.uuid"> - {{ member.person_display_name }} - </span> - </b-field> - </div> <div class="buttons" style="align-items: start;"> ${self.render_member_panel_buttons(member)} @@ -1019,14 +1067,112 @@ ${self.render_user_tab()} </%def> +<%def name="render_profile_info_extra_buttons()"></%def> + <%def name="render_profile_info_template()"> <script type="text/x-template" id="profile-info-template"> <div> + + ${self.render_profile_info_extra_buttons()} + <b-tabs v-model="activeTab" + % if request.has_perm('people_profile.view_versions'): + v-show="!viewingHistory" + % endif type="is-boxed" @input="activeTabChanged"> ${self.render_profile_tabs()} </b-tabs> + + % if request.has_perm('people_profile.view_versions'): + + ${revisions_grid.render_buefy_table_element(data_prop='revisions', + show_footer=True, + vshow='viewingHistory', + loading='gettingRevisions')|n} + + <b-modal :active.sync="showingRevisionDialog"> + + <div class="card"> + <div class="card-content"> + + <div style="display: flex; justify-content: space-between;"> + + <div> + <b-field horizontal label="Changed"> + <div v-html="revision.changed"></div> + </b-field> + <b-field horizontal label="Changed by"> + <div v-html="revision.changed_by"></div> + </b-field> + <b-field horizontal label="IP Address"> + <div v-html="revision.remote_addr"></div> + </b-field> + <b-field horizontal label="Comment"> + <div v-html="revision.comment"></div> + </b-field> + <b-field horizontal label="TXN ID"> + <div v-html="revision.txnid"></div> + </b-field> + </div> + + <div> + <div> + <b-button @click="viewPrevRevision()" + :disabled="!revision.prev_txnid"> + « Prev + </b-button> + <b-button @click="viewNextRevision()" + :disabled="!revision.next_txnid"> + » Next + </b-button> + </div> + <br /> + <b-button @click="toggleVersionFields()"> + {{ revisionShowAllFields ? "Show Diffs Only" : "Show All Fields" }} + </b-button> + </div> + + </div> + + <br /> + + <div v-for="version in revision.versions" + :key="version.key"> + + <p class="block has-text-weight-bold"> + {{ version.model_title }} + </p> + + <table class="diff monospace is-size-7" + :class="version.diff_class"> + <thead> + <tr> + <th>field name</th> + <th>old value</th> + <th>new value</th> + </tr> + </thead> + <tbody> + <tr v-for="field in version.fields" + :key="field" + :class="{diff: version.values[field].after != version.values[field].before}" + v-show="revisionShowAllFields || version.values[field].after != version.values[field].before"> + <td class="field">{{ field }}</td> + <td class="old-value">{{ version.values[field].before }}</td> + <td class="new-value">{{ version.values[field].after }}</td> + </tr> + </tbody> + </table> + + <br /> + </div> + + </div> + </div> + </b-modal> + % endif + </div> </script> </%def> @@ -1611,11 +1757,28 @@ phoneTypeOptions: ${json.dumps(phone_type_options)|n}, emailTypeOptions: ${json.dumps(email_type_options)|n}, maxLengths: ${json.dumps(max_lengths)|n}, + + % if request.has_perm('people_profile.view_versions'): + loadingRevisions: false, + showingRevisionDialog: false, + revision: {}, + revisionShowAllFields: false, + % endif } let ProfileInfo = { template: '#profile-info-template', mixins: [FormPosterMixin], + + % if request.has_perm('people_profile.view_versions'): + props: { + viewingHistory: Boolean, + gettingRevisions: Boolean, + revisions: Array, + revisionVersionMap: null, + }, + % endif + computed: {}, methods: { @@ -1641,6 +1804,29 @@ }, activeTabChangedExtra(value) {}, + + % if request.has_perm('people_profile.view_versions'): + + viewRevision(row) { + this.revision = this.revisionVersionMap[row.txnid] + this.showingRevisionDialog = true + }, + + viewPrevRevision() { + let txnid = this.revision.prev_txnid + this.revision = this.revisionVersionMap[txnid] + }, + + viewNextRevision() { + let txnid = this.revision.next_txnid + this.revision = this.revisionVersionMap[txnid] + }, + + toggleVersionFields() { + this.revisionShowAllFields = !this.revisionShowAllFields + }, + + % endif }, } @@ -1662,6 +1848,13 @@ ${parent.modify_this_page_vars()} <script type="text/javascript"> + % if request.has_perm('people_profile.view_versions'): + ThisPage.props.viewingHistory = Boolean + ThisPage.props.gettingRevisions = Boolean + ThisPage.props.revisions = Array + ThisPage.props.revisionVersionMap = null + % endif + ThisPage.methods.changeContentTitle = function(newTitle) { this.$emit('change-content-title', newTitle) } @@ -1717,5 +1910,49 @@ ${self.make_profile_info_component()} </%def> +<%def name="modify_whole_page_vars()"> + ${parent.modify_whole_page_vars()} + + % if request.has_perm('people_profile.view_versions'): + <script type="text/javascript"> + + WholePageData.viewingHistory = false + WholePageData.gettingRevisions = false + WholePageData.gotRevisions = false + WholePageData.revisions = [] + WholePageData.revisionVersionMap = null + + WholePage.methods.viewHistory = function() { + this.viewingHistory = true + + if (!this.gotRevisions && !this.gettingRevisions) { + this.getRevisions() + } + } + + WholePage.methods.refreshHistory = function() { + if (!this.gettingRevisions) { + this.getRevisions() + } + } + + WholePage.methods.getRevisions = function() { + this.gettingRevisions = true + + let url = '${url('people.view_profile_revisions', uuid=person.uuid)}' + this.simpleGET(url, {}, response => { + this.revisions = response.data.data + this.revisionVersionMap = response.data.vmap + this.gotRevisions = true + this.gettingRevisions = false + }, response => { + this.gettingRevisions = false + }) + } + + </script> + % endif +</%def> + ${parent.body()} diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 89b857f1..ce15e48a 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -30,6 +30,7 @@ from collections import OrderedDict import sqlalchemy as sa from sqlalchemy import orm +import sqlalchemy_continuum as continuum from rattail.db import model, api from rattail.db.util import maxlen @@ -42,6 +43,7 @@ from webhelpers2.html import HTML, tags from tailbone import forms, grids from tailbone.views import MasterView +from tailbone.util import raw_datetime log = logging.getLogger(__name__) @@ -429,6 +431,9 @@ class PersonView(MasterView): 'dynamic_content_title': self.get_context_content_title(person), } + if self.request.has_perm('people_profile.view_versions'): + context['revisions_grid'] = self.profile_revisions_grid(person) + template = 'view_profile_buefy' return self.render_to_response(template, context) @@ -1015,6 +1020,188 @@ class PersonView(MasterView): 'employee': self.get_context_employee(employee), } + def profile_revisions_grid(self, person): + route_prefix = self.get_route_prefix() + factory = self.get_grid_factory() + g = factory( + '{}.profile.revisions'.format(route_prefix), + [], # start with empty data! + request=self.request, + columns=[ + 'changed', + 'changed_by', + 'remote_addr', + 'comment', + ], + labels={ + 'remote_addr': "IP Address", + }, + linked_columns=[ + 'changed', + 'changed_by', + 'comment', + ], + main_actions=[ + self.make_action('view', icon='eye', url='#', + click_handler='viewRevision(props.row)'), + ], + ) + return g + + def profile_revisions_collect(self, person, versions=None): + model = self.model + versions = versions or [] + + # Person + cls = continuum.version_class(model.Person) + query = self.Session.query(cls)\ + .filter(cls.uuid == person.uuid) + versions.extend(query.all()) + + # User + cls = continuum.version_class(model.User) + query = self.Session.query(cls)\ + .filter(cls.person_uuid == person.uuid) + versions.extend(query.all()) + + # Member + cls = continuum.version_class(model.Member) + query = self.Session.query(cls)\ + .filter(cls.person_uuid == person.uuid) + versions.extend(query.all()) + + # Employee + cls = continuum.version_class(model.Employee) + query = self.Session.query(cls)\ + .filter(cls.person_uuid == person.uuid) + versions.extend(query.all()) + + # EmployeeHistory + cls = continuum.version_class(model.EmployeeHistory) + query = self.Session.query(cls)\ + .join(model.Employee, + model.Employee.uuid == cls.employee_uuid)\ + .filter(model.Employee.person_uuid == person.uuid) + versions.extend(query.all()) + + # PersonPhoneNumber + cls = continuum.version_class(model.PersonPhoneNumber) + query = self.Session.query(cls)\ + .filter(cls.parent_uuid == person.uuid) + versions.extend(query.all()) + + # PersonEmailAddress + cls = continuum.version_class(model.PersonEmailAddress) + query = self.Session.query(cls)\ + .filter(cls.parent_uuid == person.uuid) + versions.extend(query.all()) + + # PersonMailingAddress + cls = continuum.version_class(model.PersonMailingAddress) + query = self.Session.query(cls)\ + .filter(cls.parent_uuid == person.uuid) + versions.extend(query.all()) + + # CustomerPerson + cls = continuum.version_class(model.CustomerPerson) + query = self.Session.query(cls)\ + .filter(cls.person_uuid == person.uuid) + versions.extend(query.all()) + + # Customer + cls = continuum.version_class(model.Customer) + query = self.Session.query(cls)\ + .join(model.CustomerPerson, model.CustomerPerson.customer_uuid == cls.uuid)\ + .filter(model.CustomerPerson.person_uuid == person.uuid) + versions.extend(query.all()) + + # PersonNote + cls = continuum.version_class(model.PersonNote) + query = self.Session.query(cls)\ + .filter(cls.parent_uuid == person.uuid) + versions.extend(query.all()) + + return versions + + def profile_revisions_data(self): + """ + View which locates and organizes all relevant "transaction" + (version) history data for a given Person. Returns JSON, for + use with the Buefy table element on the full profile view. + """ + person = self.get_instance() + versions = self.profile_revisions_collect(person) + + # organize final table data + data = [] + all_txns = set([v.transaction for v in versions]) + for i, txn in enumerate( + sorted(all_txns, key=lambda txn: txn.issued_at, reverse=True), + 1): + data.append({ + 'txnid': txn.id, + 'changed': raw_datetime(self.rattail_config, txn.issued_at), + 'changed_by': str(txn.user or '') or None, + 'remote_addr': txn.remote_addr, + 'comment': txn.meta.get('comment'), + }) + # also stash the sequential index for this transaction, for use later + txn._sequential_index = i + + # also organize final transaction/versions (diff) map + vmap = {} + for version in versions: + + if version.previous and version.operation_type == continuum.Operation.DELETE: + diff_class = 'deleted' + elif version.previous: + diff_class = 'dirty' + else: + diff_class = 'new' + + # collect before/after field values for version + fields = self.fields_for_version(version) + values = {} + for field in fields: + before = '' + after = '' + if diff_class != 'new': + before = repr(getattr(version.previous, field)) + if diff_class != 'deleted': + after = repr(getattr(version, field)) + values[field] = {'before': before, 'after': after} + + if version.transaction_id not in vmap: + txn = version.transaction + prev_txnid = None + next_txnid = None + if txn._sequential_index < len(data): + prev_txnid = data[txn._sequential_index]['txnid'] + if txn._sequential_index > 1: + next_txnid = data[txn._sequential_index - 2]['txnid'] + vmap[txn.id] = { + 'index': txn._sequential_index, + 'txnid': txn.id, + 'prev_txnid': prev_txnid, + 'next_txnid': next_txnid, + 'changed': raw_datetime(self.rattail_config, txn.issued_at, + verbose=True), + 'changed_by': str(txn.user or '') or None, + 'remote_addr': txn.remote_addr, + 'comment': txn.meta.get('comment'), + 'versions': [], + } + + vmap[version.transaction_id]['versions'].append({ + 'key': id(version), + 'model_title': self.title_for_version(version), + 'diff_class': diff_class, + 'fields': fields, + 'values': values, + }) + + return {'data': data, 'vmap': vmap} + def make_note_form(self, mode, person): schema = NoteSchema().bind(session=self.Session(), person_uuid=person.uuid) @@ -1269,6 +1456,18 @@ class PersonView(MasterView): renderer='json', permission='employees.edit') + # profile - revisions data + config.add_tailbone_permission('people_profile', + 'people_profile.view_versions', + "View full version history for a profile") + config.add_route(f'{route_prefix}.view_profile_revisions', + f'{instance_url_prefix}/profile/revisions', + request_method='GET') + config.add_view(cls, attr='profile_revisions_data', + route_name=f'{route_prefix}.view_profile_revisions', + permission='people_profile.view_versions', + renderer='json') + # manage notes from profile view if cls.manage_notes_from_profile_view: From afd5c3a5fd3481a6b1452aa129da4adf4cf7aac2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 6 Jun 2023 19:29:47 -0500 Subject: [PATCH 1109/1681] Update changelog --- CHANGES.rst | 22 ++++++++++++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 53422091..11705544 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,28 @@ CHANGELOG ========= +0.9.29 (2023-06-06) +------------------- + +* Add "typical" view config, for e.g. Theo and the like. + +* Add customer number filter for People grid. + +* Tweak logic for ``MasterView.get_action_route_kwargs()``. + +* Add "touch" support for Members. + +* Add support for "configured customer/member key". + +* Use *actual* current URL for user feedback msg. + +* Remove old/unused feedback templates. + +* Add basic support for membership types. + +* Add support for version history in person profile view. + + 0.9.28 (2023-06-02) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 4ad67fa6..d32eee0d 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.28' +__version__ = '0.9.29' From 3fde80f9918675476f1627f15b288e8bc33ca020 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 7 Jun 2023 16:27:10 -0500 Subject: [PATCH 1110/1681] Add basic support for exposing `Customer.shoppers` now there is a Shoppers field when viewing a Customer, unless configured otherwise also tweaked some logic for navigating Customer/Person relationships, to handle implications of Shoppers being (maybe) present --- tailbone/templates/customers/configure.mako | 22 ++- tailbone/templates/customers/view.mako | 9 +- tailbone/views/customers.py | 159 ++++++++++++++++---- tailbone/views/master.py | 2 +- tailbone/views/people.py | 55 +++++-- 5 files changed, 201 insertions(+), 46 deletions(-) diff --git a/tailbone/templates/customers/configure.mako b/tailbone/templates/customers/configure.mako index 708d0b17..9013bd5b 100644 --- a/tailbone/templates/customers/configure.mako +++ b/tailbone/templates/customers/configure.mako @@ -26,12 +26,30 @@ </b-field> - <b-field message="If not set, customer chooser is an autocomplete field."> + <b-field message="Set this to show the Shoppers field when viewing a Customer record."> + <b-checkbox name="rattail.customers.expose_shoppers" + v-model="simpleSettings['rattail.customers.expose_shoppers']" + native-value="true" + @input="settingsNeedSaved = true"> + Show the Shoppers field + </b-checkbox> + </b-field> + + <b-field message="Set this to show the People field when viewing a Customer record."> + <b-checkbox name="rattail.customers.expose_people" + v-model="simpleSettings['rattail.customers.expose_people']" + native-value="true" + @input="settingsNeedSaved = true"> + Show the People field + </b-checkbox> + </b-field> + + <b-field message="If not set, Customer chooser is an autocomplete field."> <b-checkbox name="rattail.customers.choice_uses_dropdown" v-model="simpleSettings['rattail.customers.choice_uses_dropdown']" native-value="true" @input="settingsNeedSaved = true"> - Show customer chooser as dropdown (select) element + Use dropdown (select element) for Customer chooser </b-checkbox> </b-field> diff --git a/tailbone/templates/customers/view.mako b/tailbone/templates/customers/view.mako index e35cc635..85ec0055 100644 --- a/tailbone/templates/customers/view.mako +++ b/tailbone/templates/customers/view.mako @@ -4,8 +4,8 @@ <%def name="object_helpers()"> ${parent.object_helpers()} - % if show_profiles_helper and instance.people: - ${view_profiles_helper(instance.people)} + % if show_profiles_helper and show_profiles_people: + ${view_profiles_helper(show_profiles_people)} % endif </%def> @@ -20,7 +20,12 @@ ${parent.modify_this_page_vars()} <script type="text/javascript"> + % if expose_shoppers: + ${form.component_studly}Data.shoppers = ${json.dumps(shoppers_data)|n} + % endif + % if expose_people: ${form.component_studly}Data.peopleData = ${json.dumps(people_data)|n} + % endif ThisPage.methods.detachPerson = function(url) { ## TODO: this should require POST, but we will add that once diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index 02071ab4..601f8423 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -24,6 +24,8 @@ Customer Views """ +from collections import OrderedDict + import sqlalchemy as sa import colander @@ -84,6 +86,7 @@ class CustomerView(MasterView): 'wholesale', 'active_in_pos', 'active_in_pos_sticky', + 'shoppers', 'people', 'groups', 'members', @@ -108,6 +111,16 @@ class CustomerView(MasterView): default=False) return self._expose_active_in_pos + def should_expose_shoppers(self): + return self.rattail_config.getbool('rattail', + 'customers.expose_shoppers', + default=True) + + def should_expose_people(self): + return self.rattail_config.getbool('rattail', + 'customers.expose_people', + default=True) + def configure_grid(self, g): super(CustomerView, self).configure_grid(g) model = self.model @@ -198,15 +211,17 @@ class CustomerView(MasterView): raise HTTPNotFound - def configure_common_form(self, f): - super(CustomerView, self).configure_common_form(f) + def configure_form(self, f): + super(CustomerView, self).configure_form(f) customer = f.model_instance permission_prefix = self.get_permission_prefix() + # default_email f.set_renderer('default_email', self.render_default_email) if not self.creating and customer.emails: f.set_default('default_email', customer.emails[0].address) + # default_phone f.set_renderer('default_phone', self.render_default_phone) if not self.creating and customer.phones: f.set_default('default_phone', customer.phones[0].number) @@ -233,6 +248,7 @@ class CustomerView(MasterView): f.set_default('address_state', addr.state) f.set_default('address_zipcode', addr.zipcode) + # email_preference f.set_enum('email_preference', self.enum.EMAIL_PREFERENCE) preferences = list(self.enum.EMAIL_PREFERENCE.items()) preferences.insert(0, ('', "(no preference)")) @@ -245,9 +261,21 @@ class CustomerView(MasterView): f.set_readonly('person') f.set_renderer('person', self.form_render_person) + # shoppers + if self.should_expose_shoppers(): + if self.viewing: + f.set_renderer('shoppers', self.render_shoppers) + else: + f.remove('shoppers') + else: + f.remove('shoppers') + # people - if self.viewing: - f.set_renderer('people', self.render_people_buefy) + if self.should_expose_people(): + if self.viewing: + f.set_renderer('people', self.render_people_buefy) + else: + f.remove('people') else: f.remove('people') @@ -258,11 +286,7 @@ class CustomerView(MasterView): f.set_renderer('groups', self.render_groups) f.set_readonly('groups') - def configure_form(self, f): - super(CustomerView, self).configure_form(f) - customer = f.model_instance - permission_prefix = self.get_permission_prefix() - + # active_in_pos* if not self.get_expose_active_in_pos(): f.remove('active_in_pos', 'active_in_pos_sticky') @@ -275,32 +299,66 @@ class CustomerView(MasterView): f.set_readonly('members') def template_kwargs_view(self, **kwargs): - kwargs = super(CustomerView, self).template_kwargs_view(**kwargs) + kwargs = super().template_kwargs_view(**kwargs) + customer = kwargs['instance'] + + kwargs['expose_shoppers'] = self.should_expose_shoppers() + if kwargs['expose_shoppers']: + shoppers = [] + for shopper in customer.shoppers: + person = shopper.person + active = None + if shopper.active is not None: + active = "Yes" if shopper.active else "No" + data = { + 'uuid': shopper.uuid, + 'shopper_number': shopper.shopper_number, + 'first_name': person.first_name, + 'last_name': person.last_name, + 'full_name': person.display_name, + 'phone': person.first_phone_number(), + 'email': person.first_email_address(), + 'active': active, + } + shoppers.append(data) + kwargs['shoppers_data'] = shoppers + + kwargs['expose_people'] = self.should_expose_people() + if kwargs['expose_people']: + people = [] + for person in customer.people: + data = { + 'uuid': person.uuid, + 'full_name': person.display_name, + 'first_name': person.first_name, + 'last_name': person.last_name, + '_action_url_view': self.request.route_url('people.view', + uuid=person.uuid), + } + if self.editable and self.request.has_perm('people.edit'): + data['_action_url_edit'] = self.request.route_url( + 'people.edit', + uuid=person.uuid) + if self.people_detachable and self.has_perm('detach_person'): + data['_action_url_detach'] = self.request.route_url( + 'customers.detach_person', + uuid=customer.uuid, + person_uuid=person.uuid) + people.append(data) + kwargs['people_data'] = people kwargs['show_profiles_helper'] = self.show_profiles_helper + if kwargs['show_profiles_helper']: + people = OrderedDict() - customer = kwargs['instance'] - people = [] - for person in customer.people: - data = { - 'uuid': person.uuid, - 'full_name': person.display_name, - 'first_name': person.first_name, - 'last_name': person.last_name, - '_action_url_view': self.request.route_url('people.view', - uuid=person.uuid), - } - if self.editable and self.request.has_perm('people.edit'): - data['_action_url_edit'] = self.request.route_url( - 'people.edit', - uuid=person.uuid) - if self.people_detachable and self.has_perm('detach_person'): - data['_action_url_detach'] = self.request.route_url( - 'customers.detach_person', - uuid=customer.uuid, - person_uuid=person.uuid) - people.append(data) - kwargs['people_data'] = people + for shopper in customer.shoppers: + person = shopper.person + people.setdefault(person.uuid, person) + + for person in customer.people: + people.setdefault(person.uuid, person) + + kwargs['show_profiles_people'] = list(people.values()) return kwargs @@ -375,6 +433,35 @@ class CustomerView(MasterView): main_actions=actions) return HTML.literal(g.render_grid()) + def render_shoppers(self, customer, field): + route_prefix = self.get_route_prefix() + permission_prefix = self.get_permission_prefix() + + factory = self.get_grid_factory() + g = factory( + key='{}.people'.format(route_prefix), + data=[], + columns=[ + 'shopper_number', + 'first_name', + 'last_name', + 'phone', + 'email', + 'active', + ], + sortable=True, + sorters={'shopper_number': True, + 'first_name': True, + 'last_name': True, + 'phone': True, + 'email': True, + 'active': True}, + labels={'shopper_number': "Shopper #"}, + ) + + return HTML.literal( + g.render_buefy_table_element(data_prop='shoppers')) + def render_people_buefy(self, customer, field): route_prefix = self.get_route_prefix() permission_prefix = self.get_permission_prefix() @@ -492,6 +579,14 @@ class CustomerView(MasterView): {'section': 'rattail', 'option': 'customers.choice_uses_dropdown', 'type': bool}, + {'section': 'rattail', + 'option': 'customers.expose_shoppers', + 'type': bool, + 'default': True}, + {'section': 'rattail', + 'option': 'customers.expose_people', + 'type': bool, + 'default': True}, # POS {'section': 'rattail', diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 0993ac7d..5a2e6aa6 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -4878,7 +4878,7 @@ class MasterView(View): elif simple.get('type') is bool: value = config.getbool(simple['section'], simple['option'], - default=False) + default=simple.get('default', False)) else: value = config.get(simple['section'], simple['option']) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index ce15e48a..75ddde05 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -550,13 +550,16 @@ class PersonView(MasterView): return context def get_context_customers(self, person): + app = self.get_rattail_app() + clientele = app.get_clientele_handler() + + customers = clientele.get_customers_for_account_holder(person) key = self.get_customer_key_field() data = [] - for cp in person._customers: - customer = cp.customer + + for customer in customers: data.append({ 'uuid': customer.uuid, - 'ordinal': cp.ordinal, '_key': getattr(customer, key), 'id': customer.id, 'number': customer.number, @@ -568,19 +571,19 @@ class PersonView(MasterView): 'addresses': [self.get_context_address(a) for a in customer.addresses], }) + return data def get_context_members(self, person): + app = self.get_rattail_app() + membership = app.get_membership_handler() + data = OrderedDict() - for member in person.members: + members = membership.get_members_for_account_holder(person) + for member in members: data[member.uuid] = self.get_context_member(member) - for customer in person.customers: - for member in customer.members: - if member.uuid not in data: - data[member.uuid] = self.get_context_member(member) - return list(data.values()) def get_context_member(self, member): @@ -1115,6 +1118,40 @@ class PersonView(MasterView): .filter(model.CustomerPerson.person_uuid == person.uuid) versions.extend(query.all()) + # nb. this is used in some queries below + FirstShopper = orm.aliased(model.CustomerShopper) + + # CustomerShopper (from Customer perspective) + cls = continuum.version_class(model.CustomerShopper) + query = self.Session.query(cls)\ + .join(model.Customer, + model.Customer.uuid == cls.customer_uuid)\ + .filter(model.Customer.account_holder_uuid == person.uuid) + versions.extend(query.all()) + + # CustomerShopperHistory (from Customer perspective) + cls = continuum.version_class(model.CustomerShopperHistory) + query = self.Session.query(cls)\ + .join(model.CustomerShopper, + model.CustomerShopper.uuid == cls.shopper_uuid)\ + .join(model.Customer)\ + .filter(model.Customer.account_holder_uuid == person.uuid) + versions.extend(query.all()) + + # CustomerShopper (from Shopper perspective) + cls = continuum.version_class(model.CustomerShopper) + query = self.Session.query(cls)\ + .filter(cls.person_uuid == person.uuid) + versions.extend(query.all()) + + # CustomerShopperHistory (from Shopper perspective) + cls = continuum.version_class(model.CustomerShopperHistory) + query = self.Session.query(cls)\ + .join(model.CustomerShopper, + model.CustomerShopper.uuid == cls.shopper_uuid)\ + .filter(model.CustomerShopper.person_uuid == person.uuid) + versions.extend(query.all()) + # PersonNote cls = continuum.version_class(model.PersonNote) query = self.Session.query(cls)\ From e2b91dca23406cb98b5930b0eb32c042c3cc93e4 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 10 Jun 2023 14:22:21 -0500 Subject: [PATCH 1111/1681] Move "view history" and related buttons, for person profile view need those to be more front-and-center --- tailbone/templates/base.mako | 3 ++ .../templates/people/view_profile_buefy.mako | 40 ++++++++++--------- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index 723e106c..0e767353 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -432,6 +432,7 @@ <div class="level-item"> <h1 class="title" v-html="contentTitleHTML"></h1> </div> + ${self.render_instance_header_title_extras()} </div> <div class="level-right"> ${self.render_instance_header_buttons()} @@ -621,6 +622,8 @@ % endif </%def> +<%def name="render_instance_header_title_extras()"></%def> + <%def name="render_instance_header_buttons()"> ${self.render_crud_header_buttons()} ${self.render_prevnext_header_buttons()} diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako index c1799c16..eb24cc29 100644 --- a/tailbone/templates/people/view_profile_buefy.mako +++ b/tailbone/templates/people/view_profile_buefy.mako @@ -18,27 +18,29 @@ ${dynamic_content_title} </%def> -<%def name="render_instance_header_buttons()"> +<%def name="render_instance_header_title_extras()"> % if request.has_perm('people_profile.view_versions'): - <b-button v-if="!viewingHistory" - icon-pack="fas" - icon-left="history" - @click="viewHistory()"> - View History - </b-button> - <div v-if="viewingHistory" - class="buttons"> - <b-button icon-pack="fas" - icon-left="redo" - @click="refreshHistory()" - :disabled="gettingRevisions"> - {{ gettingRevisions ? "Working, please wait..." : "Refresh History" }} - </b-button> - <b-button icon-pack="fas" - icon-left="user" - @click="viewingHistory = false"> - View Profile + <div class="level-item" style="margin-left: 2rem;"> + <b-button v-if="!viewingHistory" + icon-pack="fas" + icon-left="history" + @click="viewHistory()"> + View History </b-button> + <div v-if="viewingHistory" + class="buttons"> + <b-button icon-pack="fas" + icon-left="user" + @click="viewingHistory = false"> + View Profile + </b-button> + <b-button icon-pack="fas" + icon-left="redo" + @click="refreshHistory()" + :disabled="gettingRevisions"> + {{ gettingRevisions ? "Working, please wait..." : "Refresh History" }} + </b-button> + </div> </div> % endif </%def> From 40ae14bd7a0f61320bccb994b2bf460b19e64160 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 10 Jun 2023 18:59:53 -0500 Subject: [PATCH 1112/1681] Consider vendor catalog batch views "typical" --- tailbone/views/typical.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/typical.py b/tailbone/views/typical.py index 018794f5..8b5c9a07 100644 --- a/tailbone/views/typical.py +++ b/tailbone/views/typical.py @@ -50,6 +50,7 @@ def defaults(config, **kwargs): config.include(mod('tailbone.views.batch.handheld')) config.include(mod('tailbone.views.batch.importer')) config.include(mod('tailbone.views.batch.inventory')) + config.include(mod('tailbone.views.batch.vendorcatalog')) config.include(mod('tailbone.views.purchasing')) From 9e1b83cbbef7da95db06b7fc945a2984940b95f1 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 10 Jun 2023 20:12:33 -0500 Subject: [PATCH 1113/1681] Let external customer link buttons be more dynamic, for profile view need to copy this pattern elsewhere yet i'm sure.. --- tailbone/templates/people/view_profile_buefy.mako | 11 ++++++++--- tailbone/views/people.py | 13 +++++++++++-- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako index eb24cc29..79e51435 100644 --- a/tailbone/templates/people/view_profile_buefy.mako +++ b/tailbone/templates/people/view_profile_buefy.mako @@ -739,9 +739,14 @@ </%def> <%def name="render_customer_panel_buttons(customer)"> - % for button in customer_xref_buttons: - ${button} - % endfor + <b-button v-for="link in customer.external_links" + :key="link.url" + type="is-primary" + tag="a" :href="link.url" target="_blank" + icon-pack="fas" + icon-left="external-link-alt"> + {{ link.label }} + </b-button> % if request.has_perm('customers.view'): <b-button tag="a" :href="customer.view_url"> View Customer diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 75ddde05..2d85e5a7 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -420,6 +420,7 @@ class PersonView(MasterView): 'email_type_options': self.get_email_type_options(), 'max_lengths': self.get_max_lengths(), 'customers_data': self.get_context_customers(person), + # TODO: deprecate / remove this 'customer_xref_buttons': self.get_customer_xref_buttons(person), 'members_data': self.get_context_members(person), 'member_xref_buttons': self.get_member_xref_buttons(person), @@ -437,6 +438,7 @@ class PersonView(MasterView): template = 'view_profile_buefy' return self.render_to_response(template, context) + # TODO: deprecate / remove this def get_customer_xref_buttons(self, person): buttons = [] for supp in self.iter_view_supplements(): @@ -558,7 +560,7 @@ class PersonView(MasterView): data = [] for customer in customers: - data.append({ + context = { 'uuid': customer.uuid, '_key': getattr(customer, key), 'id': customer.id, @@ -570,7 +572,14 @@ class PersonView(MasterView): for p in customer.people], 'addresses': [self.get_context_address(a) for a in customer.addresses], - }) + 'external_links': [], + } + + for supp in self.iter_view_supplements(): + if hasattr(supp, 'get_context_for_customer'): + context = supp.get_context_for_customer(customer, context) + + data.append(context) return data From f1a8b8df7f2629511d9fa28c30d374bf891ad190 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 10 Jun 2023 21:09:35 -0500 Subject: [PATCH 1114/1681] Include version history for CustomerShopper, in profile view --- tailbone/views/people.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 2d85e5a7..0c80b8a4 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -1114,19 +1114,33 @@ class PersonView(MasterView): .filter(cls.parent_uuid == person.uuid) versions.extend(query.all()) - # CustomerPerson - cls = continuum.version_class(model.CustomerPerson) + # Customer (account_holder) + cls = continuum.version_class(model.Customer) query = self.Session.query(cls)\ - .filter(cls.person_uuid == person.uuid) + .filter(cls.account_holder_uuid == person.uuid) versions.extend(query.all()) - # Customer + # Customer (new-style via CustomerShopper) + cls = continuum.version_class(model.Customer) + query = self.Session.query(cls)\ + .join(model.CustomerShopper, + model.CustomerShopper.customer_uuid == cls.uuid)\ + .filter(model.CustomerShopper.person_uuid == person.uuid) + versions.extend(query.all()) + + # Customer (old-style via CustomerPerson) cls = continuum.version_class(model.Customer) query = self.Session.query(cls)\ .join(model.CustomerPerson, model.CustomerPerson.customer_uuid == cls.uuid)\ .filter(model.CustomerPerson.person_uuid == person.uuid) versions.extend(query.all()) + # CustomerPerson + cls = continuum.version_class(model.CustomerPerson) + query = self.Session.query(cls)\ + .filter(cls.person_uuid == person.uuid) + versions.extend(query.all()) + # nb. this is used in some queries below FirstShopper = orm.aliased(model.CustomerShopper) From 0d52d554e726920140b06c61118c41f2d349179a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 10 Jun 2023 23:19:52 -0500 Subject: [PATCH 1115/1681] Add options for grid results to link straight to Profile view probably should have done this a long time ago... --- tailbone/templates/customers/configure.mako | 9 +++++ tailbone/templates/employees/configure.mako | 22 +++++++++++ tailbone/templates/members/configure.mako | 9 +++++ tailbone/templates/people/configure.mako | 22 +++++++++++ tailbone/views/customers.py | 36 ++++++++++++++++++ tailbone/views/employees.py | 42 +++++++++++++++++++++ tailbone/views/members.py | 36 ++++++++++++++++++ tailbone/views/people.py | 34 +++++++++++++++++ 8 files changed, 210 insertions(+) create mode 100644 tailbone/templates/employees/configure.mako create mode 100644 tailbone/templates/people/configure.mako diff --git a/tailbone/templates/customers/configure.mako b/tailbone/templates/customers/configure.mako index 9013bd5b..93a72327 100644 --- a/tailbone/templates/customers/configure.mako +++ b/tailbone/templates/customers/configure.mako @@ -26,6 +26,15 @@ </b-field> + <b-field message="If set, grid links are to Customer tab of Profile view."> + <b-checkbox name="rattail.customers.straight_to_profile" + v-model="simpleSettings['rattail.customers.straight_to_profile']" + native-value="true" + @input="settingsNeedSaved = true"> + Link directly to Profile when applicable + </b-checkbox> + </b-field> + <b-field message="Set this to show the Shoppers field when viewing a Customer record."> <b-checkbox name="rattail.customers.expose_shoppers" v-model="simpleSettings['rattail.customers.expose_shoppers']" diff --git a/tailbone/templates/employees/configure.mako b/tailbone/templates/employees/configure.mako new file mode 100644 index 00000000..dd01a006 --- /dev/null +++ b/tailbone/templates/employees/configure.mako @@ -0,0 +1,22 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="form_content()"> + + <h3 class="block is-size-3">General</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field message="If set, grid links are to Employee tab of Profile view."> + <b-checkbox name="rattail.employees.straight_to_profile" + v-model="simpleSettings['rattail.employees.straight_to_profile']" + native-value="true" + @input="settingsNeedSaved = true"> + Link directly to Profile when applicable + </b-checkbox> + </b-field> + + </div> +</%def> + + +${parent.body()} diff --git a/tailbone/templates/members/configure.mako b/tailbone/templates/members/configure.mako index 07d67970..c0e0355d 100644 --- a/tailbone/templates/members/configure.mako +++ b/tailbone/templates/members/configure.mako @@ -26,6 +26,15 @@ </b-field> + <b-field message="If set, grid links are to Member tab of Profile view."> + <b-checkbox name="rattail.members.straight_to_profile" + v-model="simpleSettings['rattail.members.straight_to_profile']" + native-value="true" + @input="settingsNeedSaved = true"> + Link directly to Profile when applicable + </b-checkbox> + </b-field> + </div> </%def> diff --git a/tailbone/templates/people/configure.mako b/tailbone/templates/people/configure.mako new file mode 100644 index 00000000..c936b2f9 --- /dev/null +++ b/tailbone/templates/people/configure.mako @@ -0,0 +1,22 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="form_content()"> + + <h3 class="block is-size-3">General</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field message="If set, grid links are to Personal tab of Profile view."> + <b-checkbox name="rattail.people.straight_to_profile" + v-model="simpleSettings['rattail.people.straight_to_profile']" + native-value="true" + @input="settingsNeedSaved = true"> + Link directly to Profile when applicable + </b-checkbox> + </b-field> + + </div> +</%def> + + +${parent.body()} diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index 601f8423..217d0770 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -124,6 +124,7 @@ class CustomerView(MasterView): def configure_grid(self, g): super(CustomerView, self).configure_grid(g) model = self.model + route_prefix = self.get_route_prefix() # customer key field = self.get_customer_key_field() @@ -172,10 +173,42 @@ class CustomerView(MasterView): g.filters['active_in_pos'].default_active = True g.filters['active_in_pos'].default_verb = 'is_true' + if (self.request.has_perm('people.view_profile') + and self.should_link_straight_to_profile()): + + # add View Raw action + url = lambda r, i: self.request.route_url( + f'{route_prefix}.view', **self.get_action_route_kwargs(r)) + # nb. insert to slot 1, just after normal View action + g.main_actions.insert(1, self.make_action( + 'view_raw', url=url, icon='eye')) + g.set_link('name') g.set_link('person') g.set_link('email') + def default_view_url(self): + if (self.request.has_perm('people.view_profile') + and self.should_link_straight_to_profile()): + app = self.get_rattail_app() + + def url(customer, i): + person = app.get_person(customer) + if person: + return self.request.route_url( + 'people.view_profile', uuid=person.uuid, + _anchor='customer') + return self.get_action_url('view', customer) + + return url + + return super().default_view_url() + + def should_link_straight_to_profile(self): + return self.rattail_config.getbool('rattail', + 'customers.straight_to_profile', + default=False) + def grid_extra_class(self, customer, i): if self.get_expose_active_in_pos(): if not customer.active_in_pos: @@ -579,6 +612,9 @@ class CustomerView(MasterView): {'section': 'rattail', 'option': 'customers.choice_uses_dropdown', 'type': bool}, + {'section': 'rattail', + 'option': 'customers.straight_to_profile', + 'type': bool}, {'section': 'rattail', 'option': 'customers.expose_shoppers', 'type': bool, diff --git a/tailbone/views/employees.py b/tailbone/views/employees.py index 37692996..cba75fb9 100644 --- a/tailbone/views/employees.py +++ b/tailbone/views/employees.py @@ -45,6 +45,7 @@ class EmployeeView(MasterView): touchable = True supports_autocomplete = True results_downloadable = True + configurable = True labels = { 'id': "ID", @@ -143,9 +144,41 @@ class EmployeeView(MasterView): g.set_label('email', "Email Address") + if (self.request.has_perm('people.view_profile') + and self.should_link_straight_to_profile()): + + # add View Raw action + url = lambda r, i: self.request.route_url( + f'{route_prefix}.view', **self.get_action_route_kwargs(r)) + # nb. insert to slot 1, just after normal View action + g.main_actions.insert(1, self.make_action( + 'view_raw', url=url, icon='eye')) + g.set_link('first_name') g.set_link('last_name') + def default_view_url(self): + if (self.request.has_perm('people.view_profile') + and self.should_link_straight_to_profile()): + app = self.get_rattail_app() + + def url(employee, i): + person = app.get_person(employee) + if person: + return self.request.route_url( + 'people.view_profile', uuid=person.uuid, + _anchor='employee') + return self.get_action_url('view', employee) + + return url + + return super().default_view_url() + + def should_link_straight_to_profile(self): + return self.rattail_config.getbool('rattail', + 'employees.straight_to_profile', + default=False) + def query(self, session): query = super(EmployeeView, self).query(session) query = query.join(model.Person) @@ -313,6 +346,15 @@ class EmployeeView(MasterView): (model.EmployeeDepartment, 'employee_uuid'), ] + def configure_get_simple_settings(self): + return [ + + # General + {'section': 'rattail', + 'option': 'employees.straight_to_profile', + 'type': bool}, + ] + @classmethod def defaults(cls, config): cls._defaults(config) diff --git a/tailbone/views/members.py b/tailbone/views/members.py index 9f96e667..2a02ea5f 100644 --- a/tailbone/views/members.py +++ b/tailbone/views/members.py @@ -131,6 +131,7 @@ class MemberView(MasterView): def configure_grid(self, g): super(MemberView, self).configure_grid(g) + route_prefix = self.get_route_prefix() model = self.model # member key @@ -174,9 +175,41 @@ class MemberView(MasterView): g.set_filter('membership_type', model.MembershipType.name, label="Membership Type Name") + if (self.request.has_perm('people.view_profile') + and self.should_link_straight_to_profile()): + + # add View Raw action + url = lambda r, i: self.request.route_url( + f'{route_prefix}.view', **self.get_action_route_kwargs(r)) + # nb. insert to slot 1, just after normal View action + g.main_actions.insert(1, self.make_action( + 'view_raw', url=url, icon='eye')) + g.set_link('person') g.set_link('customer') + def default_view_url(self): + if (self.request.has_perm('people.view_profile') + and self.should_link_straight_to_profile()): + app = self.get_rattail_app() + + def url(member, i): + person = app.get_person(member) + if person: + return self.request.route_url( + 'people.view_profile', uuid=person.uuid, + _anchor='member') + return self.get_action_url('view', member) + + return url + + return super().default_view_url() + + def should_link_straight_to_profile(self): + return self.rattail_config.getbool('rattail', + 'members.straight_to_profile', + default=False) + def grid_extra_class(self, member, i): if not member.active: return 'warning' @@ -272,6 +305,9 @@ class MemberView(MasterView): 'option': 'members.key_field'}, {'section': 'rattail', 'option': 'members.key_label'}, + {'section': 'rattail', + 'option': 'members.straight_to_profile', + 'type': bool}, ] diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 0c80b8a4..0f50e7fc 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -62,6 +62,7 @@ class PersonView(MasterView): is_contact = True manage_notes_from_profile_view = False supports_autocomplete = True + configurable = True labels = { 'default_phone': "Phone Number", @@ -114,6 +115,7 @@ class PersonView(MasterView): def configure_grid(self, g): super(PersonView, self).configure_grid(g) + route_prefix = self.get_route_prefix() model = self.model g.joiners['email'] = lambda q: q.outerjoin(model.PersonEmailAddress, sa.and_( @@ -162,10 +164,32 @@ class PersonView(MasterView): g.set_label('email', "Email Address") g.set_label('customer_id', "Customer ID") + if (self.has_perm('view_profile') + and self.should_link_straight_to_profile()): + + # add View Raw action + url = lambda r, i: self.request.route_url( + f'{route_prefix}.view', **self.get_action_route_kwargs(r)) + # nb. insert to slot 1, just after normal View action + g.main_actions.insert(1, self.make_action( + 'view_raw', url=url, icon='eye')) + g.set_link('display_name') g.set_link('first_name') g.set_link('last_name') + def default_view_url(self): + if (self.has_perm('view_profile') + and self.should_link_straight_to_profile()): + return lambda p, i: self.get_action_url('view_profile', p) + + return super().default_view_url() + + def should_link_straight_to_profile(self): + return self.rattail_config.getbool('rattail', + 'people.straight_to_profile', + default=False) + def render_merge_requested(self, person, field): model = self.model merge_request = self.Session.query(model.MergePeopleRequest)\ @@ -1363,6 +1387,16 @@ class PersonView(MasterView): self.request.POST['keeping_uuid']) return self.redirect(self.get_index_url()) + def configure_get_simple_settings(self): + return [ + + # General + {'section': 'rattail', + 'option': 'people.straight_to_profile', + 'type': bool}, + + ] + @classmethod def defaults(cls, config): cls._people_defaults(config) From edd5d49e36d26569970edb6761cb7b81773b42ca Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 11 Jun 2023 14:52:07 -0500 Subject: [PATCH 1116/1681] Improve shoppers/people display for Customer tab in profile view also expose settings for people/clientele handlers --- tailbone/templates/customers/configure.mako | 8 +++ tailbone/templates/people/configure.mako | 8 +++ .../templates/people/view_profile_buefy.mako | 58 ++++++++++++++----- tailbone/views/customers.py | 13 +++++ tailbone/views/people.py | 39 +++++++++++++ 5 files changed, 112 insertions(+), 14 deletions(-) diff --git a/tailbone/templates/customers/configure.mako b/tailbone/templates/customers/configure.mako index 93a72327..e68f4543 100644 --- a/tailbone/templates/customers/configure.mako +++ b/tailbone/templates/customers/configure.mako @@ -62,6 +62,14 @@ </b-checkbox> </b-field> + <b-field label="Clientele Handler" + message="Leave blank for default handler."> + <b-input name="rattail.clientele.handler" + v-model="simpleSettings['rattail.clientele.handler']" + @input="settingsNeedSaved = true"> + </b-input> + </b-field> + </div> <h3 class="block is-size-3">POS</h3> diff --git a/tailbone/templates/people/configure.mako b/tailbone/templates/people/configure.mako index c936b2f9..c39e49d1 100644 --- a/tailbone/templates/people/configure.mako +++ b/tailbone/templates/people/configure.mako @@ -15,6 +15,14 @@ </b-checkbox> </b-field> + <b-field label="People Handler" + message="Leave blank for default handler."> + <b-input name="rattail.people.handler" + v-model="simpleSettings['rattail.people.handler']" + @input="settingsNeedSaved = true"> + </b-input> + </b-field> + </div> </%def> diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako index 79e51435..84aecd30 100644 --- a/tailbone/templates/people/view_profile_buefy.mako +++ b/tailbone/templates/people/view_profile_buefy.mako @@ -697,25 +697,55 @@ {{ customer._key }} </b-field> - <b-field horizontal label="Name"> + <b-field horizontal label="Account Name"> {{ customer.name }} </b-field> - <b-field horizontal label="People"> - <ul> - <li v-for="p in customer.people" - :key="p.uuid"> - <a v-if="p.uuid != person.uuid" - :href="p.view_profile_url"> - {{ p.display_name }} - </a> - <span v-if="p.uuid == person.uuid"> - {{ p.display_name }} - </span> - </li> - </ul> + <b-field horizontal label="Account Holder"> + <span v-if="customer.account_holder && customer.account_holder.uuid == person.uuid"> + {{ customer.account_holder.display_name }} + </span> + <a v-if="customer.account_holder && customer.account_holder.uuid != person.uuid" + :href="customer.account_holder.view_profile_url"> + {{ customer.account_holder.display_name }} + </a> + <span v-if="!customer.account_holder"></span> </b-field> + % if expose_customer_shoppers: + <b-field horizontal label="Shoppers"> + <ul> + <li v-for="shopper in customer.shoppers" + :key="shopper.uuid"> + <a v-if="shopper.person_uuid != person.uuid" + :href="shopper.view_profile_url"> + {{ shopper.display_name }} + </a> + <span v-if="shopper.person_uuid == person.uuid"> + {{ shopper.display_name }} + </span> + </li> + </ul> + </b-field> + % endif + + % if expose_customer_people: + <b-field horizontal label="People"> + <ul> + <li v-for="p in customer.people" + :key="p.uuid"> + <a v-if="p.uuid != person.uuid" + :href="p.view_profile_url"> + {{ p.display_name }} + </a> + <span v-if="p.uuid == person.uuid"> + {{ p.display_name }} + </span> + </li> + </ul> + </b-field> + % endif + <b-field horizontal label="Address" v-for="address in customer.addresses" :key="address.uuid"> diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index 217d0770..af90f0f5 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -57,6 +57,7 @@ class CustomerView(MasterView): labels = { 'id': "ID", + 'name': "Account Name", 'default_phone': "Phone Number", 'default_email': "Email Address", 'default_address': "Physical Address", @@ -74,6 +75,7 @@ class CustomerView(MasterView): form_fields = [ '_customer_key_', 'name', + 'account_holder', 'default_phone', 'default_address', 'address_street', @@ -111,11 +113,13 @@ class CustomerView(MasterView): default=False) return self._expose_active_in_pos + # TODO: this is duplicated in people view module def should_expose_shoppers(self): return self.rattail_config.getbool('rattail', 'customers.expose_shoppers', default=True) + # TODO: this is duplicated in people view module def should_expose_people(self): return self.rattail_config.getbool('rattail', 'customers.expose_people', @@ -249,6 +253,13 @@ class CustomerView(MasterView): customer = f.model_instance permission_prefix = self.get_permission_prefix() + # account_holder + if self.creating: + f.remove_field('account_holder') + else: + f.set_readonly('account_holder') + f.set_renderer('account_holder', self.render_person) + # default_email f.set_renderer('default_email', self.render_default_email) if not self.creating and customer.emails: @@ -623,6 +634,8 @@ class CustomerView(MasterView): 'option': 'customers.expose_people', 'type': bool, 'default': True}, + {'section': 'rattail', + 'option': 'clientele.handler'}, # POS {'section': 'rattail', diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 0f50e7fc..71403efd 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -446,6 +446,8 @@ class PersonView(MasterView): 'customers_data': self.get_context_customers(person), # TODO: deprecate / remove this 'customer_xref_buttons': self.get_customer_xref_buttons(person), + 'expose_customer_people': self.customers_should_expose_people(), + 'expose_customer_shoppers': self.customers_should_expose_shoppers(), 'members_data': self.get_context_members(person), 'member_xref_buttons': self.get_member_xref_buttons(person), 'employee': employee, @@ -554,6 +556,20 @@ class PersonView(MasterView): return context + def get_context_shopper(self, shopper): + person = shopper.person + return { + 'uuid': shopper.uuid, + 'person_uuid': person.uuid, + 'first_name': person.first_name, + 'middle_name': person.middle_name, + 'last_name': person.last_name, + 'display_name': person.display_name, + 'view_profile_url': self.get_action_url('view_profile', person), + 'phones': self.get_context_phones(person), + 'emails': self.get_context_emails(person), + } + def get_context_content_title(self, person): return str(person) @@ -578,6 +594,7 @@ class PersonView(MasterView): def get_context_customers(self, person): app = self.get_rattail_app() clientele = app.get_clientele_handler() + expose_shoppers = self.customers_should_expose_shoppers() customers = clientele.get_customers_for_account_holder(person) key = self.get_customer_key_field() @@ -599,6 +616,14 @@ class PersonView(MasterView): 'external_links': [], } + if customer.account_holder: + context['account_holder'] = self.get_context_person( + customer.account_holder) + + if expose_shoppers: + context['shoppers'] = [self.get_context_shopper(s) + for s in customer.shoppers] + for supp in self.iter_view_supplements(): if hasattr(supp, 'get_context_for_customer'): context = supp.get_context_for_customer(customer, context) @@ -607,6 +632,18 @@ class PersonView(MasterView): return data + # TODO: this is duplicated in customers view module + def customers_should_expose_shoppers(self): + return self.rattail_config.getbool('rattail', + 'customers.expose_shoppers', + default=True) + + # TODO: this is duplicated in customers view module + def customers_should_expose_people(self): + return self.rattail_config.getbool('rattail', + 'customers.expose_people', + default=True) + def get_context_members(self, person): app = self.get_rattail_app() membership = app.get_membership_handler() @@ -1394,6 +1431,8 @@ class PersonView(MasterView): {'section': 'rattail', 'option': 'people.straight_to_profile', 'type': bool}, + {'section': 'rattail', + 'option': 'people.handler'}, ] From 5f4d393db30bb5f39e62ed8e9289dcfe1b826a32 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 11 Jun 2023 15:42:14 -0500 Subject: [PATCH 1117/1681] Change label for Member.person to "Account Holder" probably should rename table column etc. too but that can wait --- tailbone/templates/people/view_profile_buefy.mako | 2 +- tailbone/views/members.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako index 84aecd30..dacbe083 100644 --- a/tailbone/templates/people/view_profile_buefy.mako +++ b/tailbone/templates/people/view_profile_buefy.mako @@ -601,7 +601,7 @@ {{ member._key }} </b-field> - <b-field horizontal label="Person"> + <b-field horizontal label="Account Holder"> <a v-if="member.person_uuid != person.uuid" :href="member.view_profile_url"> {{ member.person_display_name }} diff --git a/tailbone/views/members.py b/tailbone/views/members.py index 2a02ea5f..bdb04c56 100644 --- a/tailbone/views/members.py +++ b/tailbone/views/members.py @@ -103,6 +103,7 @@ class MemberView(MasterView): labels = { 'id': "ID", + 'person': "Account Holder", } grid_columns = [ From 92538b87ad2b51f6e9de0fbb2cbecbf41a99296e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 11 Jun 2023 20:52:24 -0500 Subject: [PATCH 1118/1681] Add master view for CustomerShopper --- tailbone/menus.py | 5 +++ tailbone/views/customers.py | 61 +++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/tailbone/menus.py b/tailbone/menus.py index 8aebf043..8f98b91b 100644 --- a/tailbone/menus.py +++ b/tailbone/menus.py @@ -343,6 +343,11 @@ class MenuHandler(GenericHandler): 'route': 'customers', 'perm': 'customers.list', }, + { + 'title': "Customer Shoppers", + 'route': 'customer_shoppers', + 'perm': 'customer_shoppers.list', + }, { 'title': "Customer Groups", 'route': 'customergroups', diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index af90f0f5..2f00bc2e 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -675,6 +675,63 @@ class CustomerView(MasterView): permission='{}.detach_person'.format(permission_prefix)) +class CustomerShopperView(MasterView): + """ + Master view for the CustomerShopper class. + """ + model_class = model.CustomerShopper + route_prefix = 'customer_shoppers' + url_prefix = '/customer-shoppers' + + grid_columns = [ + 'customer_key', + 'customer', + 'shopper_number', + 'person', + 'active', + ] + + form_fields = [ + 'customer', + 'shopper_number', + 'person', + 'active', + ] + + def query(self, session): + query = super().query(session) + model = self.model + return query.join(model.Customer) + + def configure_grid(self, g): + super().configure_grid(g) + app = self.get_rattail_app() + model = self.model + + # customer_key + key = app.get_customer_key_field() + label = app.get_customer_key_label() + g.set_label('customer_key', label) + g.set_renderer('customer_key', + lambda shopper, field: getattr(shopper.customer, key)) + g.set_sorter('customer_key', getattr(model.Customer, key)) + g.set_filter('customer_key', getattr(model.Customer, key), + label=f"Customer {label}", + default_active=True, + default_verb='equal') + + # customer (name) + g.set_sorter('customer', model.Customer.name) + g.set_filter('customer', model.Customer.name, + label="Customer Name") + + def configure_form(self, f): + super().configure_form(f) + + f.set_renderer('customer', self.render_customer) + f.set_renderer('person', self.render_person) + + class PendingCustomerView(MasterView): """ Master view for the Pending Customer class. @@ -841,6 +898,10 @@ def defaults(config, **kwargs): base['CustomerView']) CustomerView.defaults(config) + CustomerShopperView = kwargs.get('CustomerShopperView', + base['CustomerShopperView']) + CustomerShopperView.defaults(config) + PendingCustomerView = kwargs.get('PendingCustomerView', base['PendingCustomerView']) PendingCustomerView.defaults(config) From eab3b75ae573af6311d1043f2c811ab88b99639a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 12 Jun 2023 20:35:00 -0500 Subject: [PATCH 1119/1681] 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 11705544..05d3cafa 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,22 @@ CHANGELOG ========= +0.9.30 (2023-06-12) +------------------- + +* Add basic support for exposing ``Customer.shoppers``. + +* Move "view history" and related buttons, for person profile view. + +* Consider vendor catalog batch views "typical". + +* Let external customer link buttons be more dynamic, for profile view. + +* Add options for grid results to link straight to Profile view. + +* Change label for Member.person to "Account Holder". + + 0.9.29 (2023-06-06) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index d32eee0d..b98cbc62 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.29' +__version__ = '0.9.30' From 961cf803f2f91d5c24468270c405df6dd07fb9f1 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 14 Jun 2023 14:28:01 -0500 Subject: [PATCH 1120/1681] Prefer account holder, shoppers over legacy `Customers.people` but until all are migrated, support both --- tailbone/templates/members/view.mako | 11 +------- tailbone/views/customers.py | 38 ++++++++++++++++++++++------ tailbone/views/members.py | 19 ++++++++++++++ tailbone/views/people.py | 17 +++++++++---- 4 files changed, 62 insertions(+), 23 deletions(-) diff --git a/tailbone/templates/members/view.mako b/tailbone/templates/members/view.mako index 3f2b6c14..071e37d3 100644 --- a/tailbone/templates/members/view.mako +++ b/tailbone/templates/members/view.mako @@ -4,16 +4,7 @@ <%def name="object_helpers()"> ${parent.object_helpers()} - <% people = h.OrderedDict() %> - % if instance.person: - <% people[instance.person.uuid] = instance.person %> - % endif - % if instance.customer: - % for person in instance.customer.people: - <% people[person.uuid] = person %> - % endfor - % endif - ${view_profiles_helper(people.values())} + ${view_profiles_helper(show_profiles_people)} </%def> ${parent.body()} diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index 2f00bc2e..d57d2ab7 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -27,6 +27,7 @@ Customer Views from collections import OrderedDict import sqlalchemy as sa +from sqlalchemy import orm import colander from pyramid.httpexceptions import HTTPNotFound @@ -125,10 +126,18 @@ class CustomerView(MasterView): 'customers.expose_people', default=True) + def query(self, session): + query = super().query(session) + model = self.model + return query.outerjoin(model.Person, + model.Person.uuid == model.Customer.account_holder_uuid) + def configure_grid(self, g): - super(CustomerView, self).configure_grid(g) + super().configure_grid(g) + app = self.get_rattail_app() model = self.model route_prefix = self.get_route_prefix() + legacy = app.get_clientele_handler().should_use_legacy_people() # customer key field = self.get_customer_key_field() @@ -162,15 +171,23 @@ class CustomerView(MasterView): # email_preference g.set_enum('email_preference', self.enum.EMAIL_PREFERENCE) + # account_holder_*_name + g.set_filter('account_holder_first_name', model.Person.first_name) + g.set_filter('account_holder_last_name', model.Person.last_name) + # person - g.set_joiner('person', lambda q: - q.outerjoin(model.CustomerPerson, - sa.and_( - model.CustomerPerson.customer_uuid == model.Customer.uuid, - model.CustomerPerson.ordinal == 1))\ - .outerjoin(model.Person)) - g.set_sorter('person', model.Person.display_name) g.set_renderer('person', self.grid_render_person) + if legacy: + LegacyPerson = orm.aliased(model.Person) + g.set_joiner('person', lambda q: + q.outerjoin(model.CustomerPerson, + sa.and_( + model.CustomerPerson.customer_uuid == model.Customer.uuid, + model.CustomerPerson.ordinal == 1))\ + .outerjoin(LegacyPerson)) + g.set_sorter('person', LegacyPerson.display_name) + else: + g.set_sorter('person', model.Person.display_name) # active_in_pos if self.get_expose_active_in_pos(): @@ -395,6 +412,10 @@ class CustomerView(MasterView): if kwargs['show_profiles_helper']: people = OrderedDict() + if customer.account_holder: + person = customer.account_holder + people.setdefault(person.uuid, person) + for shopper in customer.shoppers: person = shopper.person people.setdefault(person.uuid, person) @@ -434,6 +455,7 @@ class CustomerView(MasterView): url = self.request.route_url('people.view', uuid=person.uuid) return tags.link_to(text, url) + # TODO: remove if no longer used def render_people(self, customer, field): people = customer.people if not people: diff --git a/tailbone/views/members.py b/tailbone/views/members.py index bdb04c56..89c720b5 100644 --- a/tailbone/views/members.py +++ b/tailbone/views/members.py @@ -282,6 +282,25 @@ class MemberView(MasterView): 'withdrew', ) + def template_kwargs_view(self, **kwargs): + kwargs = super().template_kwargs_view(**kwargs) + member = kwargs['instance'] + + people = OrderedDict() + if member.person: + person = member.person + people.setdefault(person.uuid, person) + if member.customer: + customer = member.customer + if customer.account_holder: + person = customer.account_holder + people.setdefault(person.uuid, person) + for person in customer.people: + people.setdefault(person.uuid, person) + kwargs['show_profiles_people'] = list(people.values()) + + return kwargs + def render_default_email(self, member, field): if member.emails: return member.emails[0].address diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 71403efd..5b7ffdc4 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -371,12 +371,15 @@ class PersonView(MasterView): return tags.link_to(text, url) def render_customers(self, person, field): - customers = person._customers + app = self.get_rattail_app() + clientele = app.get_clientele_handler() + + customers = clientele.get_customers_for_account_holder(person) if not customers: - return "" + return + items = [] for customer in customers: - customer = customer.customer text = str(customer) if customer.number: text = "(#{}) {}".format(customer.number, text) @@ -384,6 +387,7 @@ class PersonView(MasterView): text = "({}) {}".format(customer.id, text) url = self.request.route_url('customers.view', uuid=customer.uuid) items.append(HTML.tag('li', c=[tags.link_to(text, url)])) + return HTML.tag('ul', c=items) def render_members(self, person, field): @@ -595,6 +599,7 @@ class PersonView(MasterView): app = self.get_rattail_app() clientele = app.get_clientele_handler() expose_shoppers = self.customers_should_expose_shoppers() + expose_people = self.customers_should_expose_people() customers = clientele.get_customers_for_account_holder(person) key = self.get_customer_key_field() @@ -609,8 +614,6 @@ class PersonView(MasterView): 'name': customer.name, 'view_url': self.request.route_url('customers.view', uuid=customer.uuid), - 'people': [self.get_context_person(p) - for p in customer.people], 'addresses': [self.get_context_address(a) for a in customer.addresses], 'external_links': [], @@ -624,6 +627,10 @@ class PersonView(MasterView): context['shoppers'] = [self.get_context_shopper(s) for s in customer.shoppers] + if expose_people: + context['people'] = [self.get_context_person(p) + for p in customer.people] + for supp in self.iter_view_supplements(): if hasattr(supp, 'get_context_for_customer'): context = supp.get_context_for_customer(customer, context) From c2227b306b59ec0468eeb4fd63b57cc4a9c323db Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 15 Jun 2023 10:47:38 -0500 Subject: [PATCH 1121/1681] 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 05d3cafa..7cd214ba 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.9.31 (2023-06-15) +------------------- + +* Prefer account holder, shoppers over legacy ``Customers.people``. + + 0.9.30 (2023-06-12) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index b98cbc62..afb3c8ca 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.30' +__version__ = '0.9.31' From c1f72e0d1126c3bece739610d37db0dc976df427 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 16 Jun 2023 12:21:51 -0500 Subject: [PATCH 1122/1681] Fix grid filter bug when switching from 'equal' to 'between' verbs and vice versa --- tailbone/static/js/tailbone.buefy.grid.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tailbone/static/js/tailbone.buefy.grid.js b/tailbone/static/js/tailbone.buefy.grid.js index 75037448..6be28f41 100644 --- a/tailbone/static/js/tailbone.buefy.grid.js +++ b/tailbone/static/js/tailbone.buefy.grid.js @@ -28,6 +28,17 @@ const GridFilterNumericValue = { this.startValue = this.value } }, + watch: { + // when changing from e.g. 'equal' to 'between' filter verbs, + // must proclaim new filter value, to reflect (lack of) range + wantsRange(val) { + if (val) { + this.$emit('input', this.startValue + '|' + this.endValue) + } else { + this.$emit('input', this.startValue) + } + }, + }, methods: { focus() { this.$refs.startValue.focus() From bf1726a52b1b0744373012b9214ca6a4fd19cb02 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 16 Jun 2023 17:04:39 -0500 Subject: [PATCH 1123/1681] Add users context data for profile view instead of using server-side data/logic for users tab --- .../templates/people/view_profile_buefy.mako | 86 +++++++++---------- tailbone/views/people.py | 18 ++++ 2 files changed, 59 insertions(+), 45 deletions(-) diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako index dacbe083..eaaf9422 100644 --- a/tailbone/templates/people/view_profile_buefy.mako +++ b/tailbone/templates/people/view_profile_buefy.mako @@ -1038,61 +1038,56 @@ <%def name="render_user_tab()"> <b-tab-item label="User" value="user" - ${'icon="check" icon-pack="fas"' if person.users else ''|n}> - % if person.users: - <p>${person} is associated with <strong>${len(person.users)}</strong> user account(s)</p> - <br /> - <div id="users-accordion"> - % for user in person.users: + icon-pack="fas" + :icon="users.length ? 'check' : null"> - <b-collapse class="panel" - ## TODO: what's up with aria-id here? - ## aria-id="contentIdForA11y2" - > + <div v-if="users.length"> - <div - slot="trigger" - class="panel-heading" - role="button" - ## TODO: what's up with aria-id here? - ## aria-controls="contentIdForA11y2" - > - <strong>${user.username}</strong> - </div> + <p>{{ person.display_name }} is associated with <strong>{{ users.length }}</strong> user account(s)</p> + <br /> + <div id="users-accordion"> - <div class="panel-block"> + <b-collapse class="panel" + v-for="user in users" + :key="user.uuid"> - <div style="display: flex; justify-content: space-between; width: 100%;"> + <div slot="trigger" + class="panel-heading" + role="button"> + <strong>{{ user.username }}</strong> + </div> - <div> - - <div class="field-wrapper id"> - <div class="field-row"> - <label>Username</label> - <div class="field"> - ${user.username} - </div> - </div> - </div> + <div class="panel-block"> + <div style="display: flex; justify-content: space-between; width: 100%;"> + <div> + <div class="field-wrapper id"> + <div class="field-row"> + <label>Username</label> + <div class="field"> + {{ user.username }} </div> - - <div> - % if request.has_perm('users.view'): - ${h.link_to("View User", url('users.view', uuid=user.uuid), class_='button')} - % endif - </div> - </div> - </div> - </b-collapse> - % endfor - </div> + </div> - % else: - <p>${person} has never been a user.</p> - % endif + <div> + % if request.has_perm('users.view'): + <b-button tag="a" :href="user.view_url"> + View User + </b-button> + % endif + </div> + + </div> + </div> + </b-collapse> + </div> + </div> + + <div v-if="!users.length"> + <p>{{ person.display_name }} has never been a user.</p> + </div> </b-tab-item><!-- User --> </%def> @@ -1791,6 +1786,7 @@ members: ${json.dumps(members_data)|n}, employee: ${json.dumps(employee_data)|n}, employeeHistory: ${json.dumps(employee_history_data)|n}, + users: ${json.dumps(users_data)|n}, phoneTypeOptions: ${json.dumps(phone_type_options)|n}, emailTypeOptions: ${json.dumps(email_type_options)|n}, maxLengths: ${json.dumps(max_lengths)|n}, diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 5b7ffdc4..48e445e9 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -459,6 +459,7 @@ class PersonView(MasterView): 'employee_view_url': self.request.route_url('employees.view', uuid=employee.uuid) if employee else None, 'employee_history': employee.get_current_history() if employee else None, 'employee_history_data': self.get_context_employee_history(employee), + 'users_data': self.get_context_users(person), 'dynamic_content_title': self.get_context_content_title(person), } @@ -720,6 +721,23 @@ class PersonView(MasterView): }) return data + def get_context_users(self, person): + data = [] + users = person.users + for user in users: + data.append(self.get_context_user(user)) + return data + + def get_context_user(self, user): + app = self.get_rattail_app() + return { + 'uuid': user.uuid, + 'username': user.username, + 'display_name': user.display_name, + 'email_address': app.get_contact_email_address(user), + 'view_url': self.request.route_url('users.view', uuid=user.uuid), + } + def ensure_customer(self, person): """ Return the `Customer` record for the given person, establishing it From 5a03f5c23e9345d75e609c34b774b57bc5acf819 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 16 Jun 2023 20:08:27 -0500 Subject: [PATCH 1124/1681] Join the Person model for Customers grid differently based on config --- tailbone/views/customers.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index d57d2ab7..660c186c 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -128,9 +128,19 @@ class CustomerView(MasterView): def query(self, session): query = super().query(session) + app = self.get_rattail_app() model = self.model - return query.outerjoin(model.Person, - model.Person.uuid == model.Customer.account_holder_uuid) + if app.get_clientele_handler().should_use_legacy_people(): + query = query.outerjoin(model.CustomerPerson, + sa.and_( + model.CustomerPerson.customer_uuid == model.Customer.uuid, + model.CustomerPerson.ordinal == 1))\ + .outerjoin(model.Person, + model.Person.uuid == model.CustomerPerson.person_uuid) + else: + query = query.outerjoin(model.Person, + model.Person.uuid == model.Customer.account_holder_uuid) + return query def configure_grid(self, g): super().configure_grid(g) From 17ae06f9c1273b526a7d9132d4261afd2cfc0191 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 16 Jun 2023 20:43:00 -0500 Subject: [PATCH 1125/1681] 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 7cd214ba..eb4fc525 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.9.32 (2023-06-16) +------------------- + +* Fix grid filter bug when switching from 'equal' to 'between' verbs. + +* Add users context data for profile view. + +* Join the Person model for Customers grid differently based on config. + + 0.9.31 (2023-06-15) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index afb3c8ca..bf4c8f18 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.31' +__version__ = '0.9.32' From 51cad13f5ab65ff2cd6aa383d1a5c7a5f2bf6cea Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 16 Jun 2023 22:15:52 -0500 Subject: [PATCH 1126/1681] Update usage of app handler per upstream changes --- tailbone/api/core.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/tailbone/api/core.py b/tailbone/api/core.py index 613b1566..b278d4af 100644 --- a/tailbone/api/core.py +++ b/tailbone/api/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,6 @@ Tailbone Web API - Core Views """ -from __future__ import unicode_literals, absolute_import - from tailbone.views import View @@ -101,20 +99,20 @@ class APIView(View): return info """ app = self.get_rattail_app() - auth_handler = app.get_auth_handler() + auth = app.get_auth_handler() # basic / default info is_admin = user.is_admin() - employee = user.employee + employee = app.get_employee(user) info = { 'uuid': user.uuid, 'username': user.username, 'display_name': user.display_name, - 'short_name': user.get_short_name(), + 'short_name': auth.get_short_display_name(user), 'is_admin': is_admin, 'is_root': is_admin and self.request.session.get('is_root', False), 'employee_uuid': employee.uuid if employee else None, - 'email_address': auth_handler.get_email_address(user), + 'email_address': app.get_contact_email_address(user), } # maybe get/use "extra" info From c601d46970fa9f9249478415387556e207f29293 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 16 Jun 2023 22:22:03 -0500 Subject: [PATCH 1127/1681] 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 eb4fc525..3eaa0919 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.9.33 (2023-06-16) +------------------- + +* Update usage of app handler per upstream changes. + + 0.9.32 (2023-06-16) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index bf4c8f18..fc883fb3 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.32' +__version__ = '0.9.33' From b1489c56e25ef5284cd3dc44f983f7be969f2980 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 17 Jun 2023 02:22:18 -0500 Subject: [PATCH 1128/1681] Add basic Shopper tab for profile view --- .../templates/people/view_profile_buefy.mako | 85 ++++++++++++++++--- tailbone/views/people.py | 35 +++++++- 2 files changed, 106 insertions(+), 14 deletions(-) diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako index eaaf9422..adb2e00e 100644 --- a/tailbone/templates/people/view_profile_buefy.mako +++ b/tailbone/templates/people/view_profile_buefy.mako @@ -670,7 +670,7 @@ <div v-if="customers.length"> <div style="display: flex; justify-content: space-between;"> - <p>{{ person.display_name }} is associated with <strong>{{ customers.length }}</strong> customer account(s)</p> + <p>{{ person.display_name }} has <strong>{{ customers.length }}</strong> customer account(s)</p> </div> <br /> @@ -701,17 +701,6 @@ {{ customer.name }} </b-field> - <b-field horizontal label="Account Holder"> - <span v-if="customer.account_holder && customer.account_holder.uuid == person.uuid"> - {{ customer.account_holder.display_name }} - </span> - <a v-if="customer.account_holder && customer.account_holder.uuid != person.uuid" - :href="customer.account_holder.view_profile_url"> - {{ customer.account_holder.display_name }} - </a> - <span v-if="!customer.account_holder"></span> - </b-field> - % if expose_customer_shoppers: <b-field horizontal label="Shoppers"> <ul> @@ -784,6 +773,72 @@ % endif </%def> +<%def name="render_shopper_tab()"> + <b-tab-item label="Shopper" + value="shopper" + icon-pack="fas" + :icon="shoppers.length ? 'check' : null"> + + <div v-if="shoppers.length"> + + <div style="display: flex; justify-content: space-between;"> + <p>{{ person.display_name }} is shopper for <strong>{{ shoppers.length }}</strong> customer account(s)</p> + </div> + + <br /> + <b-collapse v-for="shopper in shoppers" + :key="shopper.uuid" + class="panel" + :open="shoppers.length == 1"> + + <div slot="trigger" + slot-scope="props" + class="panel-heading" + role="button"> + <b-icon pack="fas" + icon="caret-right"> + </b-icon> + <strong>{{ shopper.customer_key }} - {{ shopper.customer_name }}</strong> + </div> + + <div class="panel-block"> + <div style="display: flex; justify-content: space-between; width: 100%;"> + <div style="flex-grow: 1;"> + + <b-field horizontal label="${customer_key_label}"> + {{ shopper.customer_key }} + </b-field> + + <b-field horizontal label="Account Name"> + {{ shopper.customer_name }} + </b-field> + + <b-field horizontal label="Account Holder"> + <span v-if="!shopper.account_holder_view_profile_url"> + {{ shopper.account_holder_name }} + </span> + <a v-if="shopper.account_holder_view_profile_url" + :href="shopper.account_holder_view_profile_url"> + {{ shopper.account_holder_name }} + </a> + </b-field> + + </div> +## <div class="buttons" style="align-items: start;"> +## ${self.render_shopper_panel_buttons(shopper)} +## </div> + </div> + </div> + </b-collapse> + </div> + + <div v-if="!shoppers.length"> + <p>{{ person.display_name }} is not a shopper.</p> + </div> + + </b-tab-item> <!-- Shopper --> +</%def> + <%def name="render_employee_tab_template()"> <script type="text/x-template" id="employee-tab-template"> <div> @@ -1095,6 +1150,9 @@ ${self.render_personal_tab()} ${self.render_member_tab()} ${self.render_customer_tab()} + % if expose_customer_shoppers: + ${self.render_shopper_tab()} + % endif ${self.render_employee_tab()} ${self.render_user_tab()} </%def> @@ -1782,6 +1840,9 @@ activeTab: location.hash ? location.hash.substring(1) : undefined, person: ${json.dumps(person_data)|n}, customers: ${json.dumps(customers_data)|n}, + % if expose_customer_shoppers: + shoppers: ${json.dumps(shoppers_data)|n}, + % endif member: null, // TODO members: ${json.dumps(members_data)|n}, employee: ${json.dumps(employee_data)|n}, diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 48e445e9..5ac17beb 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -436,8 +436,9 @@ class PersonView(MasterView): related customer, employee, user info etc. """ self.viewing = True + app = self.get_rattail_app() person = self.get_instance() - employee = person.employee + employee = app.get_employee(person) context = { 'person': person, 'instance': person, @@ -463,6 +464,13 @@ class PersonView(MasterView): 'dynamic_content_title': self.get_context_content_title(person), } + if context['expose_customer_shoppers']: + shoppers = person.customer_shoppers + # TODO: what a hack! surely this belongs in handler at least..? + shoppers = [shopper for shopper in shoppers + if shopper.shopper_number != 1] + context['shoppers_data'] = self.get_context_shoppers(shoppers) + if self.request.has_perm('people_profile.view_versions'): context['revisions_grid'] = self.profile_revisions_grid(person) @@ -561,10 +569,24 @@ class PersonView(MasterView): return context + def get_context_shoppers(self, shoppers): + data = [] + for shopper in shoppers: + data.append(self.get_context_shopper(shopper)) + return data + def get_context_shopper(self, shopper): + app = self.get_rattail_app() + customer = shopper.customer person = shopper.person - return { + customer_key = self.get_customer_key_field() + account_holder = app.get_person(customer) + context = { 'uuid': shopper.uuid, + 'customer_uuid': customer.uuid, + 'customer_key': getattr(customer, customer_key), + 'customer_name': customer.name, + 'account_holder_uuid': customer.account_holder_uuid, 'person_uuid': person.uuid, 'first_name': person.first_name, 'middle_name': person.middle_name, @@ -575,6 +597,15 @@ class PersonView(MasterView): 'emails': self.get_context_emails(person), } + if account_holder: + context.update({ + 'account_holder_name': account_holder.display_name, + 'account_holder_view_profile_url': self.get_action_url( + 'view_profile', account_holder), + }) + + return context + def get_context_content_title(self, person): return str(person) From ba2b4bf12c83c6fadbb25e9f66443298ad559287 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 17 Jun 2023 02:27:09 -0500 Subject: [PATCH 1129/1681] Cleanup some wording in profile view template --- .../templates/people/view_profile_buefy.mako | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako index adb2e00e..81e8d3ac 100644 --- a/tailbone/templates/people/view_profile_buefy.mako +++ b/tailbone/templates/people/view_profile_buefy.mako @@ -574,7 +574,7 @@ <div v-if="members.length"> <div style="display: flex; justify-content: space-between;"> - <p>{{ person.display_name }} is associated with <strong>{{ members.length }}</strong> member account(s)</p> + <p>{{ person.display_name }} has <strong>{{ members.length }}</strong> member account{{ members.length == 1 ? '' : 's' }}</p> </div> <br /> @@ -644,7 +644,7 @@ </div> <div v-if="!members.length"> - <p>{{ person.display_name }} has never had a member account.</p> + <p>{{ person.display_name }} does not have a member account.</p> </div> </b-tab-item> @@ -670,7 +670,7 @@ <div v-if="customers.length"> <div style="display: flex; justify-content: space-between;"> - <p>{{ person.display_name }} has <strong>{{ customers.length }}</strong> customer account(s)</p> + <p>{{ person.display_name }} has <strong>{{ customers.length }}</strong> customer account{{ customers.length == 1 ? '' : 's' }}</p> </div> <br /> @@ -751,7 +751,7 @@ </div> <div v-if="!customers.length"> - <p>{{ person.display_name }} has never had a customer account.</p> + <p>{{ person.display_name }} does not have a customer account.</p> </div> </b-tab-item> <!-- Customer --> @@ -782,7 +782,7 @@ <div v-if="shoppers.length"> <div style="display: flex; justify-content: space-between;"> - <p>{{ person.display_name }} is shopper for <strong>{{ shoppers.length }}</strong> customer account(s)</p> + <p>{{ person.display_name }} is shopper for <strong>{{ shoppers.length }}</strong> customer account{{ shoppers.length == 1 ? '' : 's' }}</p> </div> <br /> @@ -942,7 +942,7 @@ </div> <p v-if="!employee.uuid"> - ${person} has never been an employee. + ${person} is not an employee. </p> </div> @@ -1098,7 +1098,7 @@ <div v-if="users.length"> - <p>{{ person.display_name }} is associated with <strong>{{ users.length }}</strong> user account(s)</p> + <p>{{ person.display_name }} has <strong>{{ users.length }}</strong> user account{{ users.length == 1 ? '' : 's' }}</p> <br /> <div id="users-accordion"> @@ -1141,7 +1141,7 @@ </div> <div v-if="!users.length"> - <p>{{ person.display_name }} has never been a user.</p> + <p>{{ person.display_name }} does not have a user account.</p> </div> </b-tab-item><!-- User --> </%def> From 105dab7a3dcb34290da25956d406f09d2b85aa7e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 17 Jun 2023 14:13:37 -0500 Subject: [PATCH 1130/1681] Tweak `SimpleRequestMixin` to not rely on `response.data.ok` instead just assume ok unless `response.data.error` is set --- tailbone/templates/formposter.mako | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tailbone/templates/formposter.mako b/tailbone/templates/formposter.mako index 5c695eb2..ab9c720d 100644 --- a/tailbone/templates/formposter.mako +++ b/tailbone/templates/formposter.mako @@ -47,10 +47,7 @@ this.$http.post(action, params, {headers: headers}).then(response => { - if (response.data.ok) { - success(response) - - } else { + if (response.data.error) { this.$buefy.toast.open({ message: "Submit failed: " + (response.data.error || "(unknown error)"), @@ -60,6 +57,9 @@ if (failure) { failure(response) } + + } else { + success(response) } }, response => { From d77de76c97ab25cb4a67e9cb1fcf4a80830f84aa Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 17 Jun 2023 14:18:43 -0500 Subject: [PATCH 1131/1681] Add support for Notes tab in profile view --- .../templates/people/view_profile_buefy.mako | 286 ++++++++++++++++++ tailbone/views/people.py | 103 +++++-- 2 files changed, 357 insertions(+), 32 deletions(-) diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako index 81e8d3ac..28b6e1d4 100644 --- a/tailbone/templates/people/view_profile_buefy.mako +++ b/tailbone/templates/people/view_profile_buefy.mako @@ -1090,6 +1090,153 @@ </b-tab-item> </%def> +<%def name="render_notes_tab_template()"> + <script type="text/x-template" id="notes-tab-template"> + <div> + + % if request.has_perm('people_profile.add_note'): + <b-button type="is-primary" + class="control" + @click="noteNew()" + icon-pack="fas" + icon-left="plus"> + Add Note + </b-button> + % endif + + <b-table :data="notes"> + + <b-table-column field="note_type" + label="Type" + v-slot="props"> + {{ props.row.note_type_display }} + </b-table-column> + + <b-table-column field="subject" + label="Subject" + v-slot="props"> + {{ props.row.subject }} + </b-table-column> + + <b-table-column field="text" + label="Text" + v-slot="props"> + {{ props.row.text }} + </b-table-column> + + <b-table-column field="created" + label="Created" + v-slot="props"> + <span v-html="props.row.created_display"></span> + </b-table-column> + + <b-table-column field="created_by" + label="Created By" + v-slot="props"> + {{ props.row.created_by_display }} + </b-table-column> + + % if request.has_any_perm('people_profile.edit_note', 'people_profile.delete_note'): + <b-table-column label="Actions" + v-slot="props"> + % if request.has_perm('people_profile.edit_note'): + <a href="#" @click.prevent="noteEdit(props.row)"> + <i class="fas fa-edit"></i> + Edit + </a> + % endif + % if request.has_perm('people_profile.delete_note'): + <a href="#" @click.prevent="noteDelete(props.row)" + class="has-text-danger"> + <i class="fas fa-trash"></i> + Delete + </a> + % endif + </b-table-column> + % endif + + </b-table> + + <b-modal :active.sync="noteShowDialog" + has-modal-card> + + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title"> + {{ noteDialogTitle }} + </p> + </header> + + <section class="modal-card-body"> + + <b-field label="Type" + :type="!noteDeleting && !noteType ? 'is-danger' : null"> + <b-select v-model="noteType" + :disabled="noteUUID"> + <option v-for="option in noteTypeOptions" + :key="option.value" + :value="option.value"> + {{ option.label }} + </option> + </b-select> + </b-field> + + <b-field label="Subject"> + <b-input v-model.trim="noteSubject" + :disabled="noteDeleting"> + </b-input> + </b-field> + + <b-field label="Text"> + <b-input v-model.trim="noteText" + type="textarea" + :disabled="noteDeleting"> + </b-input> + </b-field> + + <b-notification v-if="noteDeleting" + type="is-danger" + :closable="false"> + Are you sure you wish to delete this note? + </b-notification> + + </section> + + <footer class="modal-card-foot"> + <b-button :type="noteDeleting ? 'is-danger' : 'is-primary'" + @click="noteSave()" + :disabled="noteSaving || (!noteDeleting && !noteType)" + icon-pack="fas" + icon-left="save"> + {{ noteSaving ? "Working, please wait..." : noteSaveText }} + </b-button> + <b-button @click="noteShowDialog = false"> + Cancel + </b-button> + </footer> + + </div> + </b-modal> + + </div> + </script> +</%def> + +<%def name="render_notes_tab()"> + <b-tab-item label="Notes" + value="notes" + icon-pack="fas" + :icon="notes.length ? 'check' : null"> + + <notes-tab :notes="notes" + :note-type-options="noteTypeOptions" + @new-notes-data="newNotesData"> + </notes-tab> + + </b-tab-item> +</%def> + <%def name="render_user_tab()"> <b-tab-item label="User" value="user" @@ -1154,6 +1301,7 @@ ${self.render_shopper_tab()} % endif ${self.render_employee_tab()} + ${self.render_notes_tab()} ${self.render_user_tab()} </%def> @@ -1271,6 +1419,7 @@ ${parent.render_this_page_template()} ${self.render_personal_tab_template()} ${self.render_employee_tab_template()} + ${self.render_notes_tab_template()} ${self.render_profile_info_template()} </%def> @@ -1833,6 +1982,136 @@ </script> </%def> +<%def name="declare_notes_tab_vars()"> + <script type="text/javascript"> + + let NotesTabData = { + noteShowDialog: false, + noteUUID: null, + noteType: null, + noteSubject: null, + noteText: null, + noteDeleting: false, + noteSaving: false, + } + + let NotesTab = { + template: '#notes-tab-template', + mixins: [SimpleRequestMixin], + props: { + notes: Array, + noteTypeOptions: Array, + }, + + computed: { + + noteDialogTitle() { + if (this.noteUUID) { + if (this.noteDeleting) { + return "Delete Note" + } + return "Edit Note" + } + return "New Note" + }, + + noteSaveText() { + if (this.noteDeleting) { + return "Delete Note" + } + return "Save Note" + }, + + }, + + methods: { + + % if request.has_perm('people_profile.add_note'): + + noteNew() { + this.noteUUID = null + this.noteType = null + this.noteSubject = null + this.noteText = null + this.noteDeleting = false + this.noteShowDialog = true + }, + + % endif + + % if request.has_perm('people_profile.edit_note'): + + noteEdit(note) { + this.noteUUID = note.uuid + this.noteType = note.note_type + this.noteSubject = note.subject + this.noteText = note.text + this.noteDeleting = false + this.noteShowDialog = true + }, + + % endif + + % if request.has_perm('people_profile.delete_note'): + + noteDelete(note) { + this.noteUUID = note.uuid + this.noteType = note.note_type + this.noteSubject = note.subject + this.noteText = note.text + this.noteDeleting = true + this.noteShowDialog = true + }, + + % endif + + % if request.has_any_perm('people_profile.add_note', 'people_profile.edit_note', 'people_profile.delete_note'): + + noteSave() { + this.noteSaving = true + + let url = null + if (!this.noteUUID) { + url = '${master.get_action_url('profile_add_note', instance)}' + } else if (this.noteDeleting) { + url = '${master.get_action_url('profile_delete_note', instance)}' + } else { + url = '${master.get_action_url('profile_edit_note', instance)}' + } + + let params = { + uuid: this.noteUUID, + note_type: this.noteType, + note_subject: this.noteSubject, + note_text: this.noteText, + } + + this.simplePOST(url, params, response => { + this.$emit('new-notes-data', response.data.notes) + this.noteSaving = false + this.noteShowDialog = false + }, response => { + this.notesSaving = false + }) + }, + + % endif + }, + } + + </script> +</%def> + +<%def name="make_notes_tab_component()"> + ${self.declare_notes_tab_vars()} + <script type="text/javascript"> + + NotesTab.data = function() { return NotesTabData } + Vue.component('notes-tab', NotesTab) + + </script> +</%def> + <%def name="declare_profile_info_vars()"> <script type="text/javascript"> @@ -1847,6 +2126,8 @@ members: ${json.dumps(members_data)|n}, employee: ${json.dumps(employee_data)|n}, employeeHistory: ${json.dumps(employee_history_data)|n}, + notes: ${json.dumps(notes_data)|n}, + noteTypeOptions: ${json.dumps(note_type_options)|n}, users: ${json.dumps(users_data)|n}, phoneTypeOptions: ${json.dumps(phone_type_options)|n}, emailTypeOptions: ${json.dumps(email_type_options)|n}, @@ -1876,6 +2157,10 @@ computed: {}, methods: { + newNotesData(notes) { + this.notes = notes + }, + personUpdated(person) { this.person = person }, @@ -2001,6 +2286,7 @@ ${parent.make_this_page_component()} ${self.make_personal_tab_component()} ${self.make_employee_tab_component()} + ${self.make_notes_tab_component()} ${self.make_profile_info_component()} </%def> diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 5ac17beb..03e3cbc4 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -60,7 +60,6 @@ class PersonView(MasterView): has_versions = True bulk_deletable = True is_contact = True - manage_notes_from_profile_view = False supports_autocomplete = True configurable = True @@ -460,6 +459,8 @@ class PersonView(MasterView): 'employee_view_url': self.request.route_url('employees.view', uuid=employee.uuid) if employee else None, 'employee_history': employee.get_current_history() if employee else None, 'employee_history_data': self.get_context_employee_history(employee), + 'notes_data': self.get_context_notes(person), + 'note_type_options': self.get_note_type_options(), 'users_data': self.get_context_users(person), 'dynamic_content_title': self.get_context_content_title(person), } @@ -752,6 +753,29 @@ class PersonView(MasterView): }) return data + def get_context_notes(self, person): + data = [] + notes = sorted(person.notes, key=lambda n: n.created, reverse=True) + for note in notes: + data.append(self.get_context_note(note)) + return data + + def get_context_note(self, note): + app = self.get_rattail_app() + return { + 'uuid': note.uuid, + 'note_type': note.type, + 'note_type_display': self.enum.PERSON_NOTE_TYPE.get(note.type, note.type), + 'subject': note.subject, + 'text': note.text, + 'created_display': raw_datetime(self.rattail_config, note.created), + 'created_by_display': str(note.created_by), + } + + def get_note_type_options(self): + return [{'value': k, 'label': v} + for k, v in self.enum.PERSON_NOTE_TYPE.items()] + def get_context_users(self, person): data = [] users = person.users @@ -1385,6 +1409,8 @@ class PersonView(MasterView): if mode == 'create': del schema['uuid'] form = forms.Form(schema=schema, request=self.request) + if mode != 'delete': + form.set_validator('note_type', colander.OneOf(self.enum.PERSON_NOTE_TYPE)) return form def profile_add_note(self): @@ -1406,11 +1432,15 @@ class PersonView(MasterView): person.notes.append(note) return note - def profile_add_note_success(self, note): - return self.redirect(self.get_action_url('view_profile', person)) + def profile_add_note_success(self, note, person=None): + return { + 'notes': self.get_context_notes(person or note.person), + } def profile_add_note_failure(self, person, form): - return self.redirect(self.get_action_url('view_profile', person)) + return { + 'error': str(form.make_deform_form().error), + } def profile_edit_note(self): person = self.get_instance() @@ -1429,10 +1459,10 @@ class PersonView(MasterView): return note def profile_edit_note_success(self, note): - return self.redirect(self.get_action_url('view_profile', person)) + return self.profile_add_note_success(note) def profile_edit_note_failure(self, person, form): - return self.redirect(self.get_action_url('view_profile', person)) + return self.profile_add_note_failure(person, form) def profile_delete_note(self): person = self.get_instance() @@ -1449,10 +1479,10 @@ class PersonView(MasterView): self.Session.delete(note) def profile_delete_note_success(self, person): - return self.redirect(self.get_action_url('view_profile', person)) + return self.profile_add_note_success(None, person=person) def profile_delete_note_failure(self, person, form): - return self.redirect(self.get_action_url('view_profile', person)) + return self.profile_add_note_failure(person, form) def make_user(self): uuid = self.request.POST['person_uuid'] @@ -1657,32 +1687,41 @@ class PersonView(MasterView): permission='people_profile.view_versions', renderer='json') - # manage notes from profile view - if cls.manage_notes_from_profile_view: + # profile - add note + config.add_tailbone_permission('people_profile', + 'people_profile.add_note', + "Add new Note records") + config.add_route(f'{route_prefix}.profile_add_note', + f'{instance_url_prefix}/profile/new-note', + request_method='POST') + config.add_view(cls, attr='profile_add_note', + route_name=f'{route_prefix}.profile_add_note', + permission='people_profile.add_note', + renderer='json') - # add note - config.add_tailbone_permission('people_profile', 'people_profile.add_note', - "Add new {} Note records".format(model_title)) - config.add_route('{}.profile_add_note'.format(route_prefix), '{}/{{{}}}/profile/new-note'.format(url_prefix, model_key), - request_method='POST') - config.add_view(cls, attr='profile_add_note', route_name='{}.profile_add_note'.format(route_prefix), - permission='people_profile.add_note') + # profile - edit note + config.add_tailbone_permission('people_profile', + 'people_profile.edit_note', + "Edit Note records") + config.add_route(f'{route_prefix}.profile_edit_note', + f'{instance_url_prefix}/profile/edit-note', + request_method='POST') + config.add_view(cls, attr='profile_edit_note', + route_name=f'{route_prefix}.profile_edit_note', + permission='people_profile.edit_note', + renderer='json') - # edit note - config.add_tailbone_permission('people_profile', 'people_profile.edit_note', - "Edit {} Note records".format(model_title)) - config.add_route('{}.profile_edit_note'.format(route_prefix), '{}/{{{}}}/profile/edit-note'.format(url_prefix, model_key), - request_method='POST') - config.add_view(cls, attr='profile_edit_note', route_name='{}.profile_edit_note'.format(route_prefix), - permission='people_profile.edit_note') - - # delete note - config.add_tailbone_permission('people_profile', 'people_profile.delete_note', - "Delete {} Note records".format(model_title)) - config.add_route('{}.profile_delete_note'.format(route_prefix), '{}/{{{}}}/profile/delete-note'.format(url_prefix, model_key), - request_method='POST') - config.add_view(cls, attr='profile_delete_note', route_name='{}.profile_delete_note'.format(route_prefix), - permission='people_profile.delete_note') + # profile - delete note + config.add_tailbone_permission('people_profile', + 'people_profile.delete_note', + "Delete Note records") + config.add_route(f'{route_prefix}.profile_delete_note', + f'{instance_url_prefix}/profile/delete-note', + request_method='POST') + config.add_view(cls, attr='profile_delete_note', + route_name=f'{route_prefix}.profile_delete_note', + permission='people_profile.delete_note', + renderer='json') # make user for person config.add_route('{}.make_user'.format(route_prefix), '{}/make-user'.format(url_prefix), From 12eeb5df9798b83f6fc0038c5026a34e02c64e6e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 17 Jun 2023 15:11:13 -0500 Subject: [PATCH 1132/1681] Add basic support for Person quickie lookup shows profile view if person is found --- tailbone/templates/people/configure.mako | 9 ++++++++ tailbone/views/common.py | 20 +++++++++++++++- tailbone/views/core.py | 3 +++ tailbone/views/customers.py | 16 +++++++++++++ tailbone/views/employees.py | 16 +++++++++++++ tailbone/views/master.py | 29 ++++++++++++++++++++---- tailbone/views/people.py | 21 +++++++++++++++++ 7 files changed, 109 insertions(+), 5 deletions(-) diff --git a/tailbone/templates/people/configure.mako b/tailbone/templates/people/configure.mako index c39e49d1..9e6ce5fb 100644 --- a/tailbone/templates/people/configure.mako +++ b/tailbone/templates/people/configure.mako @@ -15,6 +15,15 @@ </b-checkbox> </b-field> + <b-field message="Allows quick profile lookup using e.g. customer number."> + <b-checkbox name="rattail.people.expose_quickie_search" + v-model="simpleSettings['rattail.people.expose_quickie_search']" + native-value="true" + @input="settingsNeedSaved = true"> + Show "quickie search" lookup + </b-checkbox> + </b-field> + <b-field label="People Handler" message="Leave blank for default handler."> <b-input name="rattail.people.handler" diff --git a/tailbone/views/common.py b/tailbone/views/common.py index 7d1cd402..4632a285 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -66,11 +66,29 @@ class CommonView(View): 'help_url': global_help_url(self.rattail_config), } - if self.expose_quickie_search: + if self.should_expose_quickie_search(): context['quickie'] = self.get_quickie_context() return context + # nb. this is only invoked from home() view + def should_expose_quickie_search(self): + if self.expose_quickie_search: + return True + # TODO: for now we are assuming *people* search + app = self.get_rattail_app() + return app.get_people_handler().should_expose_quickie_search() + + def get_quickie_perm(self): + return 'people.quickie' + + def get_quickie_url(self): + return self.request.route_url('people.quickie') + + def get_quickie_placeholder(self): + app = self.get_rattail_app() + return app.get_people_handler().get_quickie_search_placeholder() + def robots_txt(self): """ Returns a basic 'robots.txt' response diff --git a/tailbone/views/core.py b/tailbone/views/core.py index 3920b93b..97b59c10 100644 --- a/tailbone/views/core.py +++ b/tailbone/views/core.py @@ -161,6 +161,9 @@ class View(object): response.content_disposition = str('attachment; filename="{}"'.format(filename)) return response + def should_expose_quickie_search(self): + return self.expose_quickie_search + def get_quickie_context(self): return Object( url=self.get_quickie_url(), diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index 660c186c..70ad4602 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -107,6 +107,22 @@ class CustomerView(MasterView): 'name', ] + def should_expose_quickie_search(self): + if self.expose_quickie_search: + return True + app = self.get_rattail_app() + return app.get_people_handler().should_expose_quickie_search() + + def get_quickie_perm(self): + return 'people.quickie' + + def get_quickie_url(self): + return self.request.route_url('people.quickie') + + def get_quickie_placeholder(self): + app = self.get_rattail_app() + return app.get_people_handler().get_quickie_search_placeholder() + def get_expose_active_in_pos(self): if not hasattr(self, '_expose_active_in_pos'): self._expose_active_in_pos = self.rattail_config.getbool( diff --git a/tailbone/views/employees.py b/tailbone/views/employees.py index cba75fb9..973075b6 100644 --- a/tailbone/views/employees.py +++ b/tailbone/views/employees.py @@ -79,6 +79,22 @@ class EmployeeView(MasterView): 'departments', ] + def should_expose_quickie_search(self): + if self.expose_quickie_search: + return True + app = self.get_rattail_app() + return app.get_people_handler().should_expose_quickie_search() + + def get_quickie_perm(self): + return 'people.quickie' + + def get_quickie_url(self): + return self.request.route_url('people.quickie') + + def get_quickie_placeholder(self): + app = self.get_rattail_app() + return app.get_people_handler().get_quickie_search_placeholder() + def configure_grid(self, g): super(EmployeeView, self).configure_grid(g) route_prefix = self.get_route_prefix() diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 5a2e6aa6..c8324176 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -478,9 +478,6 @@ class MasterView(View): the given object, or ``None``. """ - def quickie(self): - raise NotImplementedError - def get_quickie_url(self): route_prefix = self.get_route_prefix() return self.request.route_url('{}.quickie'.format(route_prefix)) @@ -492,6 +489,30 @@ class MasterView(View): def get_quickie_placeholder(self): pass + def quickie(self): + """ + Quickie search - tries to do a simple lookup based on a key + value. If a record is found, user is redirected to its view. + """ + entry = self.request.params.get('entry', '').strip() + if not entry: + self.request.session.flash("No search criteria specified", 'error') + return self.redirect(self.request.get_referrer()) + + obj = self.do_quickie_lookup(entry) + if not obj: + model_title = self.get_model_title() + self.request.session.flash(f"{model_title} not found: {entry}", 'error') + return self.redirect(self.request.get_referrer()) + + return self.redirect(self.get_quickie_result_url(obj)) + + def do_quickie_lookup(self, entry): + pass + + def get_quickie_result_url(self, obj): + return self.get_action_url('view', obj) + def make_row_grid(self, factory=None, key=None, data=None, columns=None, **kwargs): """ Make and return a new (configured) rows grid instance. @@ -2422,7 +2443,7 @@ class MasterView(View): context['product_key_field'] = self.get_product_key_field() context['product_key_label'] = self.get_product_key_label() - if self.expose_quickie_search: + if self.should_expose_quickie_search(): context['quickie'] = self.get_quickie_context() if self.grid_index: diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 03e3cbc4..595f843d 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -61,6 +61,7 @@ class PersonView(MasterView): bulk_deletable = True is_contact = True supports_autocomplete = True + supports_quickie_search = True configurable = True labels = { @@ -429,6 +430,23 @@ class PersonView(MasterView): (model.VendorContact, 'person_uuid'), ] + def should_expose_quickie_search(self): + if self.expose_quickie_search: + return True + app = self.get_rattail_app() + return app.get_people_handler().should_expose_quickie_search() + + def do_quickie_lookup(self, entry): + app = self.get_rattail_app() + return app.get_people_handler().quickie_lookup(entry, self.Session()) + + def get_quickie_placeholder(self): + app = self.get_rattail_app() + return app.get_people_handler().get_quickie_search_placeholder() + + def get_quickie_result_url(self, person): + return self.get_action_url('view_profile', person) + def view_profile(self): """ View which exposes the "full profile" for a given person, i.e. all @@ -1517,6 +1535,9 @@ class PersonView(MasterView): {'section': 'rattail', 'option': 'people.straight_to_profile', 'type': bool}, + {'section': 'rattail', + 'option': 'people.expose_quickie_search', + 'type': bool}, {'section': 'rattail', 'option': 'people.handler'}, From b6cb119e8913d1ac7440c722af22b2924fd2ca2f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 17 Jun 2023 16:50:39 -0500 Subject: [PATCH 1133/1681] Remove unwanted revisions for CustomerPerson etc. --- tailbone/views/people.py | 41 ---------------------------------------- 1 file changed, 41 deletions(-) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 595f843d..77e4eaff 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -1279,47 +1279,6 @@ class PersonView(MasterView): .filter(cls.account_holder_uuid == person.uuid) versions.extend(query.all()) - # Customer (new-style via CustomerShopper) - cls = continuum.version_class(model.Customer) - query = self.Session.query(cls)\ - .join(model.CustomerShopper, - model.CustomerShopper.customer_uuid == cls.uuid)\ - .filter(model.CustomerShopper.person_uuid == person.uuid) - versions.extend(query.all()) - - # Customer (old-style via CustomerPerson) - cls = continuum.version_class(model.Customer) - query = self.Session.query(cls)\ - .join(model.CustomerPerson, model.CustomerPerson.customer_uuid == cls.uuid)\ - .filter(model.CustomerPerson.person_uuid == person.uuid) - versions.extend(query.all()) - - # CustomerPerson - cls = continuum.version_class(model.CustomerPerson) - query = self.Session.query(cls)\ - .filter(cls.person_uuid == person.uuid) - versions.extend(query.all()) - - # nb. this is used in some queries below - FirstShopper = orm.aliased(model.CustomerShopper) - - # CustomerShopper (from Customer perspective) - cls = continuum.version_class(model.CustomerShopper) - query = self.Session.query(cls)\ - .join(model.Customer, - model.Customer.uuid == cls.customer_uuid)\ - .filter(model.Customer.account_holder_uuid == person.uuid) - versions.extend(query.all()) - - # CustomerShopperHistory (from Customer perspective) - cls = continuum.version_class(model.CustomerShopperHistory) - query = self.Session.query(cls)\ - .join(model.CustomerShopper, - model.CustomerShopper.uuid == cls.shopper_uuid)\ - .join(model.Customer)\ - .filter(model.Customer.account_holder_uuid == person.uuid) - versions.extend(query.all()) - # CustomerShopper (from Shopper perspective) cls = continuum.version_class(model.CustomerShopper) query = self.Session.query(cls)\ From 9572fbf584d5f99b2acc4e0fea01141e1ead97ec Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 17 Jun 2023 16:56:30 -0500 Subject: [PATCH 1134/1681] Fix some things for viewing a member --- tailbone/views/members.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/tailbone/views/members.py b/tailbone/views/members.py index 89c720b5..92c213ae 100644 --- a/tailbone/views/members.py +++ b/tailbone/views/members.py @@ -24,6 +24,8 @@ Member Views """ +from collections import OrderedDict + import sqlalchemy as sa from rattail.db import model @@ -90,6 +92,11 @@ class MembershipTypeView(MasterView): g.filters['active'].default_active = True g.filters['active'].default_verb = 'is_true' + g.set_link('person') + + def row_view_action_url(self, member, i): + return self.request.route_url('members.view', uuid=member.uuid) + class MemberView(MasterView): """ @@ -284,18 +291,17 @@ class MemberView(MasterView): def template_kwargs_view(self, **kwargs): kwargs = super().template_kwargs_view(**kwargs) + app = self.get_rattail_app() member = kwargs['instance'] people = OrderedDict() - if member.person: - person = member.person + person = app.get_person(member) + if person: people.setdefault(person.uuid, person) - if member.customer: - customer = member.customer - if customer.account_holder: - person = customer.account_holder - people.setdefault(person.uuid, person) - for person in customer.people: + customer = app.get_customer(member) + if customer: + person = app.get_person(customer) + if person: people.setdefault(person.uuid, person) kwargs['show_profiles_people'] = list(people.values()) From aa5e44efb5aac90a39e0c2c2f7f6d003db36f3c1 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 17 Jun 2023 18:12:30 -0500 Subject: [PATCH 1135/1681] 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 3eaa0919..d0b5399b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,24 @@ CHANGELOG ========= +0.9.34 (2023-06-17) +------------------- + +* Add basic Shopper tab for profile view. + +* Cleanup some wording in profile view template. + +* Tweak ``SimpleRequestMixin`` to not rely on ``response.data.ok``. + +* Add support for Notes tab in profile view. + +* Add basic support for Person quickie lookup. + +* Hide unwanted revisions for CustomerPerson etc. + +* Fix some things for viewing a member. + + 0.9.33 (2023-06-16) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index fc883fb3..5d664bcd 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.33' +__version__ = '0.9.34' From 58354e7adff947b1c29892c45133e70f617368cc Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 18 Jun 2023 14:08:36 -0500 Subject: [PATCH 1136/1681] Add views etc. for member equity payments --- tailbone/menus.py | 5 + .../templates/people/view_profile_buefy.mako | 4 + tailbone/views/master.py | 8 + tailbone/views/members.py | 148 +++++++++++++++++- tailbone/views/people.py | 3 + 5 files changed, 164 insertions(+), 4 deletions(-) diff --git a/tailbone/menus.py b/tailbone/menus.py index 8f98b91b..c26484f0 100644 --- a/tailbone/menus.py +++ b/tailbone/menus.py @@ -332,6 +332,11 @@ class MenuHandler(GenericHandler): 'route': 'members', 'perm': 'members.list', }, + { + 'title': "Member Equity Payments", + 'route': 'member_equity_payments', + 'perm': 'member_equity_payments.list', + }, { 'title': "Membership Types", 'route': 'membership_types', diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako index 28b6e1d4..e1da8661 100644 --- a/tailbone/templates/people/view_profile_buefy.mako +++ b/tailbone/templates/people/view_profile_buefy.mako @@ -634,6 +634,10 @@ {{ member.withdrew }} </b-field> + <b-field horizontal label="Equity Total"> + {{ member.equity_total_display }} + </b-field> + </div> <div class="buttons" style="align-items: start;"> ${self.render_member_panel_buttons(member)} diff --git a/tailbone/views/master.py b/tailbone/views/master.py index c8324176..f90f8151 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -973,6 +973,14 @@ class MasterView(View): url = self.request.route_url('customers.view', uuid=customer.uuid) return tags.link_to(text, url) + def render_member(self, obj, field): + member = getattr(obj, field) + if not member: + return + text = str(member) + url = self.request.route_url('members.view', uuid=member.uuid) + return tags.link_to(text, url) + def render_email_key(self, obj, field): if hasattr(obj, field): email_key = getattr(obj, field) diff --git a/tailbone/views/members.py b/tailbone/views/members.py index 92c213ae..197efa41 100644 --- a/tailbone/views/members.py +++ b/tailbone/views/members.py @@ -29,12 +29,12 @@ from collections import OrderedDict import sqlalchemy as sa from rattail.db import model -from rattail.db.model import MembershipType, Member +from rattail.db.model import MembershipType, Member, MemberEquityPayment from deform import widget as dfwidget from webhelpers2.html import tags -from tailbone import grids +from tailbone import grids, forms from tailbone.views import MasterView @@ -107,6 +107,7 @@ class MemberView(MasterView): touchable = True has_versions = True configurable = True + supports_autocomplete = True labels = { 'id': "ID", @@ -131,12 +132,40 @@ class MemberView(MasterView): 'default_phone', 'membership_type', 'active', + 'equity_total', 'equity_current', 'equity_payment_due', 'joined', 'withdrew', ] + has_rows = True + model_row_class = MemberEquityPayment + rows_title = "Equity Payments" + + row_grid_columns = [ + 'amount', + 'received', + 'description', + 'transaction_identifier', + ] + + def should_expose_quickie_search(self): + if self.expose_quickie_search: + return True + app = self.get_rattail_app() + return app.get_people_handler().should_expose_quickie_search() + + def get_quickie_perm(self): + return 'people.quickie' + + def get_quickie_url(self): + return self.request.route_url('people.quickie') + + def get_quickie_placeholder(self): + app = self.get_rattail_app() + return app.get_people_handler().get_quickie_search_placeholder() + def configure_grid(self, g): super(MemberView, self).configure_grid(g) route_prefix = self.get_route_prefix() @@ -225,14 +254,17 @@ class MemberView(MasterView): return 'notice' def configure_form(self, f): - super(MemberView, self).configure_form(f) + super().configure_form(f) member = f.model_instance # date fields f.set_type('joined', 'date_jquery') + f.set_type('withdrew', 'date_jquery') + + # equity fields + f.set_renderer('equity_total', self.render_equity_total) f.set_type('equity_payment_due', 'date_jquery') f.set_type('equity_last_paid', 'date_jquery') - f.set_type('withdrew', 'date_jquery') # person if self.creating or self.editing: @@ -289,6 +321,11 @@ class MemberView(MasterView): 'withdrew', ) + def render_equity_total(self, member, field): + app = self.get_rattail_app() + total = sum([payment.amount for payment in member.equity_payments]) + return app.render_currency(total) + def template_kwargs_view(self, **kwargs): kwargs = super().template_kwargs_view(**kwargs) app = self.get_rattail_app() @@ -323,6 +360,25 @@ class MemberView(MasterView): url = self.request.route_url('membership_types.view', uuid=memtype.uuid) return tags.link_to(text, url) + def get_row_data(self, member): + model = self.model + return self.Session.query(model.MemberEquityPayment)\ + .filter(model.MemberEquityPayment.member == member) + + def get_parent(self, payment): + return payment.member + + def configure_row_grid(self, g): + super().configure_row_grid(g) + + g.set_type('amount', 'currency') + + g.set_sort_defaults('received', 'desc') + + def row_view_action_url(self, payment, i): + return self.request.route_url('member_equity_payments.view', + uuid=payment.uuid) + def configure_get_simple_settings(self): return [ @@ -337,6 +393,87 @@ class MemberView(MasterView): ] +class MemberEquityPaymentView(MasterView): + """ + Master view for the MemberEquityPayment class. + """ + model_class = model.MemberEquityPayment + route_prefix = 'member_equity_payments' + url_prefix = '/member-equity-payments' + has_versions = True + + grid_columns = [ + 'member', + 'amount', + 'received', + 'description', + 'transaction_identifier', + ] + + form_fields = [ + 'member', + 'amount', + 'received', + 'description', + 'transaction_identifier', + ] + + def query(self, session): + query = super().query(session) + model = self.model + + query = query.join(model.Member) + + return query + + def configure_grid(self, g): + super().configure_grid(g) + model = self.model + + g.set_joiner('member', lambda q: q.outerjoin(model.Person)) + g.set_sorter('member', model.Person.display_name) + g.set_link('member') + + g.set_type('amount', 'currency') + + g.set_sort_defaults('received', 'desc') + g.set_link('received') + + def configure_form(self, f): + super().configure_form(f) + model = self.model + payment = f.model_instance + + # member + if self.creating: + f.replace('member', 'member_uuid') + member_display = "" + if self.request.method == 'POST': + if self.request.POST.get('member_uuid'): + member = self.Session.get(model.Member, + self.request.POST['member_uuid']) + if member: + member_display = str(member) + elif self.editing: + member_display = str(payment.member or '') + members_url = self.request.route_url('members.autocomplete') + f.set_widget('member_uuid', forms.widgets.JQueryAutocompleteWidget( + field_display=member_display, service_url=members_url)) + f.set_label('member_uuid', "Member") + else: + f.set_readonly('member') + f.set_renderer('member', self.render_member) + + # amount + f.set_type('amount', 'currency') + + # received + if self.creating: + f.set_type('received', 'date_jquery') + else: + f.set_readonly('received') + + def defaults(config, **kwargs): base = globals() @@ -346,6 +483,9 @@ def defaults(config, **kwargs): MemberView = kwargs.get('MemberView', base['MemberView']) MemberView.defaults(config) + MemberEquityPaymentView = kwargs.get('MemberEquityPaymentView', base['MemberEquityPaymentView']) + MemberEquityPaymentView.defaults(config) + def includeme(config): defaults(config) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 77e4eaff..29b93b9a 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -715,12 +715,14 @@ class PersonView(MasterView): return list(data.values()) def get_context_member(self, member): + app = self.get_rattail_app() profile_url = None if member.person: profile_url = self.request.route_url('people.view_profile', uuid=member.person_uuid) key = self.get_member_key_field() + equity_total = sum([payment.amount for payment in member.equity_payments]) data = { 'uuid': member.uuid, '_key': getattr(member, key), @@ -736,6 +738,7 @@ class PersonView(MasterView): 'person_display_name': member.person.display_name if member.person else None, 'view_url': self.request.route_url('members.view', uuid=member.uuid), 'view_profile_url': profile_url, + 'equity_total_display': app.render_currency(equity_total), } membership_type = member.membership_type From 214f3d9b1e0d83890755a81610f223c4ca07e8bf Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 18 Jun 2023 18:50:01 -0500 Subject: [PATCH 1137/1681] Improve merge support for records with no uuid for now we "pretend" they have a uuid still, custom view is responsible for determining the value for each row if needed --- tailbone/grids/core.py | 33 +++++++++++++++-- tailbone/templates/grids/buefy.mako | 2 +- tailbone/templates/master/merge.mako | 4 +-- tailbone/views/master.py | 53 +++++++++++++++++++++------- 4 files changed, 74 insertions(+), 18 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 230bd061..abbac793 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -170,6 +170,21 @@ class Grid(object): 'myfield': myrender, }, ) + + .. attribute row_uuid_getter:: + + Optional callable to obtain the "UUID" (sic) value for each + data row. The default assumption as that each row object has a + ``uuid`` attribute, but when that isn't the case, *and* the + grid needs to support checkboxes, we must "pretend" by + injecting some custom value to the ``uuid`` of the row data. + + If necssary, set this to a callable like so:: + + def fake_uuid(row): + return row.some_custom_key + + grid.row_uuid_getter = fake_uuid """ def __init__(self, key, data, columns=None, width='auto', request=None, @@ -182,7 +197,7 @@ class Grid(object): sortable=False, sorters={}, default_sortkey=None, default_sortdir='asc', pageable=False, default_pagesize=None, default_page=1, checkboxes=False, checked=None, check_handler=None, check_all_handler=None, - checkable=None, + checkable=None, row_uuid_getter=None, clicking_row_checks_box=False, click_handlers=None, main_actions=[], more_actions=[], delete_speedbump=False, ajax_data_url=None, component='tailbone-grid', @@ -243,6 +258,7 @@ class Grid(object): self.check_handler = check_handler self.check_all_handler = check_all_handler self.checkable = checkable + self.row_uuid_getter = row_uuid_getter self.clicking_row_checks_box = clicking_row_checks_box self.click_handlers = click_handlers or {} @@ -1425,6 +1441,16 @@ class Grid(object): }) return columns + def get_uuid_for_row(self, rowobj): + + # use custom getter if set + if self.row_uuid_getter: + return self.row_uuid_getter(rowobj) + + # otherwise fallback to normal uuid, if present + if hasattr(rowobj, 'uuid'): + return rowobj.uuid + def get_buefy_data(self): """ Returns a list of data rows for the grid, for use with Buefy table. @@ -1481,8 +1507,9 @@ class Grid(object): # maybe add UUID for convenience if 'uuid' not in self.columns: - if hasattr(rowobj, 'uuid'): - row['uuid'] = rowobj.uuid + uuid = self.get_uuid_for_row(rowobj) + if uuid: + row['uuid'] = uuid # set action URL(s) for row, as needed self.set_action_urls(row, rowobj, i) diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako index 48c3a081..d96358d5 100644 --- a/tailbone/templates/grids/buefy.mako +++ b/tailbone/templates/grids/buefy.mako @@ -192,7 +192,7 @@ % if grid.check_all_handler: @check-all="${grid.check_all_handler}" % endif - % if isinstance(grid.checkable, six.string_types): + % if isinstance(grid.checkable, str): :is-row-checkable="${grid.row_checkable}" % elif grid.checkable: :is-row-checkable="row => row._checkable" diff --git a/tailbone/templates/master/merge.mako b/tailbone/templates/master/merge.mako index 565dece3..6727dc5c 100644 --- a/tailbone/templates/master/merge.mako +++ b/tailbone/templates/master/merge.mako @@ -123,7 +123,7 @@ <div class="level-item"> ${h.form(request.current_route_url(), **{'@submit': 'submitSwapForm'})} ${h.csrf_token(request)} - ${h.hidden('uuids', value='{},{}'.format(object_to_keep.uuid, object_to_remove.uuid))} + ${h.hidden('uuids', value=f'{keeping_uuid},{removing_uuid}')} <b-button native-type="submit" :disabled="swapFormSubmitting"> {{ swapFormButtonText }} @@ -134,7 +134,7 @@ <div class="level-item"> ${h.form(request.current_route_url(), **{'@submit': 'submitMergeForm'})} ${h.csrf_token(request)} - ${h.hidden('uuids', value='{},{}'.format(object_to_remove.uuid, object_to_keep.uuid))} + ${h.hidden('uuids', value=f'{removing_uuid},{keeping_uuid}')} ${h.hidden('commit-merge', value='yes')} <b-button type="is-primary" native-type="submit" diff --git a/tailbone/views/master.py b/tailbone/views/master.py index f90f8151..e0c42e6e 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -441,6 +441,7 @@ class MasterView(View): 'checkable': self.checkbox, 'clicking_row_checks_box': self.clicking_row_checks_box, 'assume_local_times': self.has_local_times, + 'row_uuid_getter': self.get_uuid_for_grid_row, } if self.sortable or self.pageable or self.filterable: @@ -453,6 +454,16 @@ class MasterView(View): defaults.update(kwargs) return defaults + def get_uuid_for_grid_row(self, obj): + """ + If possible, this should return a "UUID" value to uniquely + identify the given object. Default of course is to use the + actual ``uuid`` attribute of the object, if present. This + value is needed by grids when checkboxes are used. + """ + if hasattr(obj, 'uuid'): + return obj.uuid + def configure_grid(self, grid): """ Perform "final" configuration for the main data grid. @@ -2046,17 +2057,31 @@ class MasterView(View): return [] + def get_merge_objects(self): + """ + Must return 2 objects, obtained somehow from the request, + which are to be (potentially) merged. + + :returns: 2-tuple of ``(object_to_remove, object_to_keep)``, + or ``None``. + """ + uuids = self.request.POST.get('uuids', '').split(',') + if len(uuids) == 2: + cls = self.get_model_class() + object_to_remove = self.Session.get(cls, uuids[0]) + object_to_keep = self.Session.get(cls, uuids[1]) + if object_to_remove and object_to_keep: + return object_to_remove, object_to_keep + def merge(self): """ Preview and execute a merge of two records. """ object_to_remove = object_to_keep = None if self.request.method == 'POST': - uuids = self.request.POST.get('uuids', '').split(',') - if len(uuids) == 2: - object_to_remove = self.Session.get(self.get_model_class(), uuids[0]) - object_to_keep = self.Session.get(self.get_model_class(), uuids[1]) - + objects = self.get_merge_objects() + if objects: + object_to_remove, object_to_keep = objects if object_to_remove and object_to_keep and self.request.POST.get('commit-merge') == 'yes': msg = str(object_to_remove) try: @@ -2073,13 +2098,17 @@ class MasterView(View): remove = self.get_merge_data(object_to_remove) keep = self.get_merge_data(object_to_keep) - return self.render_to_response('merge', {'object_to_remove': object_to_remove, - 'object_to_keep': object_to_keep, - 'view_url': lambda obj: self.get_action_url('view', obj), - 'merge_fields': self.get_merge_fields(), - 'remove_data': remove, - 'keep_data': keep, - 'resulting_data': self.get_merge_resulting_data(remove, keep)}) + return self.render_to_response('merge', { + 'object_to_remove': object_to_remove, + 'object_to_keep': object_to_keep, + 'removing_uuid': self.get_uuid_for_grid_row(object_to_remove), + 'keeping_uuid': self.get_uuid_for_grid_row(object_to_keep), + 'view_url': lambda obj: self.get_action_url('view', obj), + 'merge_fields': self.get_merge_fields(), + 'remove_data': remove, + 'keep_data': keep, + 'resulting_data': self.get_merge_resulting_data(remove, keep), + }) def validate_merge(self, removing, keeping): """ From 69bda79baf58f537a486a2b1975e56803e3c612a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 18 Jun 2023 18:50:55 -0500 Subject: [PATCH 1138/1681] Turn on quickie person search for CustomerShopper views also set default sort for that grid --- tailbone/views/customers.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index 70ad4602..078cda58 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -746,10 +746,28 @@ class CustomerShopperView(MasterView): 'active', ] + def should_expose_quickie_search(self): + if self.expose_quickie_search: + return True + app = self.get_rattail_app() + return app.get_people_handler().should_expose_quickie_search() + + def get_quickie_perm(self): + return 'people.quickie' + + def get_quickie_url(self): + return self.request.route_url('people.quickie') + + def get_quickie_placeholder(self): + app = self.get_rattail_app() + return app.get_people_handler().get_quickie_search_placeholder() + def query(self, session): query = super().query(session) model = self.model - return query.join(model.Customer) + return query.join(model.Customer)\ + .join(model.Person, + model.Person.uuid == model.CustomerShopper.person_uuid) def configure_grid(self, g): super().configure_grid(g) @@ -763,6 +781,7 @@ class CustomerShopperView(MasterView): g.set_renderer('customer_key', lambda shopper, field: getattr(shopper.customer, key)) g.set_sorter('customer_key', getattr(model.Customer, key)) + g.set_sort_defaults('customer_key') g.set_filter('customer_key', getattr(model.Customer, key), label=f"Customer {label}", default_active=True, @@ -771,7 +790,12 @@ class CustomerShopperView(MasterView): # customer (name) g.set_sorter('customer', model.Customer.name) g.set_filter('customer', model.Customer.name, - label="Customer Name") + label="Customer Account Name") + + # person (name) + g.set_sorter('person', model.Person.display_name) + g.set_filter('person', model.Person.display_name, + label="Person Name") def configure_form(self, f): super().configure_form(f) From 8932b512166286aa3b9edfd44cec5af3ba5a2f2d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 20 Jun 2023 11:54:09 -0500 Subject: [PATCH 1139/1681] 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 d0b5399b..1f64e4ea 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.9.35 (2023-06-20) +------------------- + +* Add views etc. for member equity payments. + +* Improve merge support for records with no uuid. + +* Turn on quickie person search for CustomerShopper views. + + 0.9.34 (2023-06-17) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 5d664bcd..89a33ad0 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.34' +__version__ = '0.9.35' From 70ee7848187ca5b2dc63eb286a744719222852ee Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 20 Jun 2023 17:06:20 -0500 Subject: [PATCH 1140/1681] Include user "active" flag in profile view context whoops, missed that one.. --- tailbone/views/people.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 29b93b9a..8dc96037 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -811,6 +811,7 @@ class PersonView(MasterView): 'username': user.username, 'display_name': user.display_name, 'email_address': app.get_contact_email_address(user), + 'active': user.active, 'view_url': self.request.route_url('users.view', uuid=user.uuid), } From 8cc6def93ea2b990a7df0361ac73da71b998231c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 20 Jun 2023 17:06:54 -0500 Subject: [PATCH 1141/1681] Update changelog --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 1f64e4ea..6225b749 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.9.36 (2023-06-20) +------------------- + +* Include user "active" flag in profile view context. + + 0.9.35 (2023-06-20) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 89a33ad0..d43dbe86 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.35' +__version__ = '0.9.36' From 08a75f6e9f38d3103d9e1ffbf01989953f93bab0 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 27 Jun 2023 12:37:00 -0500 Subject: [PATCH 1142/1681] Avoid deprecated product key field getter --- tailbone/views/products.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 8988538b..1cfa528a 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -238,8 +238,7 @@ class ProductView(MasterView): ProductCostCodeAny.product_uuid == model.Product.uuid) # product key - key = self.rattail_config.product_key() - field = self.product_key_fields.get(key, key) + field = self.get_product_key_field() g.filters[field].default_active = True g.filters[field].default_verb = 'equal' g.set_sort_defaults(field) @@ -1253,8 +1252,7 @@ class ProductView(MasterView): } def get_panel_fields_main(self, product): - key = self.rattail_config.product_key() - product_key_field = self.product_key_fields.get(key, key) + product_key_field = self.get_product_key_field() fields = [ product_key_field, 'brand', From 1be26b7f33bc11d1446771044bd3a1f07005bbdf Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 27 Jun 2023 12:37:16 -0500 Subject: [PATCH 1143/1681] Allow "arbitrary" PO attachment to purchase batch for sake of other POS integration etc. --- tailbone/views/purchasing/batch.py | 142 +++++++++---------------- tailbone/views/purchasing/costing.py | 6 +- tailbone/views/purchasing/ordering.py | 1 - tailbone/views/purchasing/receiving.py | 36 ++++--- 4 files changed, 74 insertions(+), 111 deletions(-) diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index 16153f64..8960a522 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -24,7 +24,7 @@ Base class for purchasing batch views """ -from rattail.db import model, api +from rattail.db.model import PurchaseBatch, PurchaseBatchRow import colander from deform import widget as dfwidget @@ -40,8 +40,8 @@ class PurchasingBatchView(BatchMasterView): Master view base class, for purchase batches. The views for both "ordering" and "receiving" batches will inherit from this. """ - model_class = model.PurchaseBatch - model_row_class = model.PurchaseBatchRow + model_class = PurchaseBatch + model_row_class = PurchaseBatchRow default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler' supports_new_product = False cloneable = True @@ -160,11 +160,13 @@ class PurchasingBatchView(BatchMasterView): raise NotImplementedError("Please define `batch_mode` for your purchasing batch view") def query(self, session): + model = self.model return session.query(model.PurchaseBatch)\ .filter(model.PurchaseBatch.mode == self.batch_mode) def configure_grid(self, g): - super(PurchasingBatchView, self).configure_grid(g) + super().configure_grid(g) + model = self.model g.joiners['vendor'] = lambda q: q.join(model.Vendor) g.filters['vendor'] = g.make_filter('vendor', model.Vendor.name, @@ -309,7 +311,7 @@ class PurchasingBatchView(BatchMasterView): if buyer: buyer_display = str(buyer) elif self.creating: - buyer = self.request.user.employee + buyer = app.get_employee(self.request.user) if buyer: buyer_display = str(buyer) f.set_default('buyer_uuid', buyer.uuid) @@ -405,12 +407,30 @@ class PurchasingBatchView(BatchMasterView): return tags.link_to(text, url) def render_purchase(self, batch, field): - purchase = batch.purchase + model = self.model + + # default logic can only render the "normal" (built-in) + # purchase field; anything else must be handled by view + # supplement if possible + if field != 'purchase': + for supp in self.iter_view_supplements(): + renderer = getattr(supp, f'render_purchase_{field}', None) + if renderer: + return renderer(batch) + + # nothing to render if no purchase found + purchase = getattr(batch, field) if not purchase: - return "" + return + + # render link to native purchase, if possible text = str(purchase) - url = self.request.route_url('purchases.view', uuid=purchase.uuid) - return tags.link_to(text, url) + if isinstance(purchase, model.Purchase): + url = self.request.route_url('purchases.view', uuid=purchase.uuid) + return tags.link_to(text, url) + + # otherwise just render purchase as-is + return text def render_vendor_email(self, batch, field): if batch.vendor.email: @@ -448,12 +468,14 @@ class PurchasingBatchView(BatchMasterView): return text def get_store_values(self): + model = self.model stores = self.Session.query(model.Store)\ .order_by(model.Store.id) return [(s.uuid, "({}) {}".format(s.id, s.name)) for s in stores] def get_vendors(self): + model = self.model return self.Session.query(model.Vendor)\ .order_by(model.Vendor.name) @@ -463,6 +485,7 @@ class PurchasingBatchView(BatchMasterView): for v in vendors] def get_buyers(self): + model = self.model return self.Session.query(model.Employee)\ .join(model.Person)\ .filter(model.Employee.status == self.enum.EMPLOYEE_STATUS_CURRENT)\ @@ -474,6 +497,7 @@ class PurchasingBatchView(BatchMasterView): for b in buyers] def get_department_options(self): + model = self.model departments = self.Session.query(model.Department).order_by(model.Department.number) return [('{} {}'.format(d.number, d.name), d.uuid) for d in departments] @@ -487,39 +511,10 @@ class PurchasingBatchView(BatchMasterView): if phone.type == 'Fax': return phone.number - def eligible_purchases(self, vendor_uuid=None, mode=None): - if not vendor_uuid: - vendor_uuid = self.request.GET.get('vendor_uuid') - vendor = self.Session.get(model.Vendor, vendor_uuid) if vendor_uuid else None - if not vendor: - return {'error': "Must specify a vendor."} - - if mode is None: - mode = self.request.GET.get('mode') - mode = int(mode) if mode and mode.isdigit() else None - if not mode or mode not in self.enum.PURCHASE_BATCH_MODE: - return {'error': "Unknown mode: {}".format(mode)} - - purchases = self.handler.get_eligible_purchases(vendor, mode) - return self.get_eligible_purchases_data(purchases) - - def get_eligible_purchases_data(self, purchases): - return {'purchases': [{'key': p.uuid, - 'department_uuid': p.department_uuid or '', - 'display': self.render_eligible_purchase(p)} - for p in purchases]} - - def render_eligible_purchase(self, purchase): - if purchase.status == self.enum.PURCHASE_STATUS_ORDERED: - date = purchase.date_ordered - total = purchase.po_total - elif purchase.status == self.enum.PURCHASE_STATUS_RECEIVED: - date = purchase.date_received - total = purchase.invoice_total - return '{} for ${:0,.2f} ({})'.format(date, total or 0, purchase.department or purchase.buyer) - def get_batch_kwargs(self, batch, **kwargs): - kwargs = super(PurchasingBatchView, self).get_batch_kwargs(batch, **kwargs) + kwargs = super().get_batch_kwargs(batch, **kwargs) + model = self.model + kwargs['mode'] = self.batch_mode kwargs['truck_dump'] = batch.truck_dump kwargs['invoice_parser_key'] = batch.invoice_parser_key @@ -565,16 +560,20 @@ class PurchasingBatchView(BatchMasterView): if self.batch_mode in (self.enum.PURCHASE_BATCH_MODE_RECEIVING, self.enum.PURCHASE_BATCH_MODE_COSTING): - purchase = batch.purchase - if not purchase and batch.purchase_uuid: - purchase = self.Session.get(model.Purchase, batch.purchase_uuid) - assert purchase - if purchase: - kwargs['purchase'] = purchase - kwargs['buyer'] = purchase.buyer - kwargs['buyer_uuid'] = purchase.buyer_uuid - kwargs['date_ordered'] = purchase.date_ordered - kwargs['po_total'] = purchase.po_total + field = self.batch_handler.get_purchase_order_fieldname() + if field == 'purchase': + purchase = batch.purchase + if not purchase and batch.purchase_uuid: + purchase = self.Session.get(model.Purchase, batch.purchase_uuid) + assert purchase + if purchase: + kwargs['purchase'] = purchase + kwargs['buyer'] = purchase.buyer + kwargs['buyer_uuid'] = purchase.buyer_uuid + kwargs['date_ordered'] = purchase.date_ordered + kwargs['po_total'] = purchase.po_total + elif hasattr(batch, field): + kwargs[field] = getattr(batch, field) return kwargs @@ -826,25 +825,6 @@ class PurchasingBatchView(BatchMasterView): return HTML.literal( g.render_buefy_table_element(data_prop='rowData.credits')) -# def item_lookup(self, value, field=None): -# """ -# Try to locate a single product using ``value`` as a lookup code. -# """ -# batch = self.get_instance() -# product = api.get_product_by_vendor_code(Session(), value, vendor=batch.vendor) -# if product: -# return product.uuid -# if value.isdigit(): -# product = api.get_product_by_upc(Session(), GPC(value)) -# if not product: -# product = api.get_product_by_upc(Session(), GPC(value, calc_check_digit='upc')) -# if product: -# if not product.cost_for_vendor(batch.vendor): -# raise fa.ValidationError("Product {} exists but has no cost for vendor {}".format( -# product.upc.pretty(), batch.vendor)) -# return product.uuid -# raise fa.ValidationError("Product not found") - # def before_create_row(self, form): # row = form.fieldset.model # batch = self.get_instance() @@ -937,28 +917,6 @@ class PurchasingBatchView(BatchMasterView): # return self.get_action_url('view', batch) - @classmethod - def _purchasing_defaults(cls, config): - rattail_config = config.registry.settings.get('rattail_config') - route_prefix = cls.get_route_prefix() - url_prefix = cls.get_url_prefix() - permission_prefix = cls.get_permission_prefix() - model_key = cls.get_model_key() - model_title = cls.get_model_title() - - # eligible purchases (AJAX) - config.add_route('{}.eligible_purchases'.format(route_prefix), '{}/eligible-purchases'.format(url_prefix)) - config.add_view(cls, attr='eligible_purchases', route_name='{}.eligible_purchases'.format(route_prefix), - renderer='json', permission='{}.view'.format(permission_prefix)) - - - @classmethod - def defaults(cls, config): - cls._purchasing_defaults(config) - cls._batch_defaults(config) - cls._defaults(config) - - class NewProduct(colander.Schema): item_id = colander.SchemaNode(colander.String()) diff --git a/tailbone/views/purchasing/costing.py b/tailbone/views/purchasing/costing.py index 294b29ef..ec4e3ee3 100644 --- a/tailbone/views/purchasing/costing.py +++ b/tailbone/views/purchasing/costing.py @@ -43,8 +43,6 @@ class CostingBatchView(PurchasingBatchView): downloadable = True bulk_deletable = True - purchase_order_fieldname = 'purchase' - labels = { 'invoice_parser_key': "Invoice Parser", } @@ -290,8 +288,9 @@ class CostingBatchView(PurchasingBatchView): f.remove_field('batch_type') # purchase + field = self.batch_handler.get_purchase_order_fieldname() if (self.creating and workflow == 'invoice_with_po' - and self.purchase_order_fieldname == 'purchase'): + and field == 'purchase'): f.replace('purchase', 'purchase_uuid') purchases = self.handler.get_eligible_purchases( vendor, self.enum.PURCHASE_BATCH_MODE_COSTING) @@ -317,7 +316,6 @@ class CostingBatchView(PurchasingBatchView): @classmethod def defaults(cls, config): cls._costing_defaults(config) - cls._purchasing_defaults(config) cls._batch_defaults(config) cls._defaults(config) diff --git a/tailbone/views/purchasing/ordering.py b/tailbone/views/purchasing/ordering.py index b0b00402..03308d07 100644 --- a/tailbone/views/purchasing/ordering.py +++ b/tailbone/views/purchasing/ordering.py @@ -486,7 +486,6 @@ class OrderingBatchView(PurchasingBatchView): @classmethod def defaults(cls, config): cls._ordering_defaults(config) - cls._purchasing_defaults(config) cls._batch_defaults(config) cls._defaults(config) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index cdc69fe5..e659123a 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -87,8 +87,6 @@ class ReceivingBatchView(PurchasingBatchView): default_uom_is_case = True - purchase_order_fieldname = 'purchase' - labels = { 'truck_dump_batch': "Truck Dump Parent", 'invoice_parser_key': "Invoice Parser", @@ -390,7 +388,7 @@ class ReceivingBatchView(PurchasingBatchView): return title def configure_form(self, f): - super(ReceivingBatchView, self).configure_form(f) + super().configure_form(f) model = self.model batch = f.model_instance allow_truck_dump = self.batch_handler.allow_truck_dump_receiving() @@ -498,18 +496,28 @@ class ReceivingBatchView(PurchasingBatchView): f.set_widget('store_uuid', dfwidget.HiddenWidget()) # purchase - if (self.creating and workflow in ('from_po', 'from_po_with_invoice') - and self.purchase_order_fieldname == 'purchase'): - f.replace('purchase', 'purchase_uuid') + field = self.batch_handler.get_purchase_order_fieldname() + if field == 'purchase': + field = 'purchase_uuid' + # TODO: workflow "invoice_with_po" is for costing mode, should rename? + if self.creating and workflow in ( + 'from_po', 'from_po_with_invoice', 'invoice_with_po'): + f.replace('purchase', field) purchases = self.batch_handler.get_eligible_purchases( - vendor, self.enum.PURCHASE_BATCH_MODE_RECEIVING) - values = [(p.uuid, self.batch_handler.render_eligible_purchase(p)) + vendor, self.batch_mode) + values = [(self.batch_handler.get_eligible_purchase_key(p), + self.batch_handler.render_eligible_purchase(p)) for p in purchases] - f.set_widget('purchase_uuid', dfwidget.SelectWidget(values=values)) - f.set_label('purchase_uuid', "Purchase Order") - f.set_required('purchase_uuid') - elif self.creating or not batch.purchase: + f.set_widget(field, dfwidget.SelectWidget(values=values)) + if field == 'purchase_uuid': + f.set_label(field, "Purchase Order") + f.set_required(field) + elif self.creating: f.remove_field('purchase') + else: # not creating + if field != 'purchase_uuid': + f.replace('purchase', field) + f.set_renderer(field, self.render_purchase) # department if self.creating: @@ -939,8 +947,9 @@ class ReceivingBatchView(PurchasingBatchView): Assign the original purchase order to the given batch. Default behavior assumes a Rattail Purchase object is what we're after. """ + field = self.batch_handler.get_purchase_order_fieldname() purchase = self.handler.assign_purchase_order( - batch, po_form.validated[self.purchase_order_fieldname], + batch, po_form.validated[field], session=self.Session()) department = self.department_for_purchase(purchase) @@ -1992,7 +2001,6 @@ class ReceivingBatchView(PurchasingBatchView): @classmethod def defaults(cls, config): cls._receiving_defaults(config) - cls._purchasing_defaults(config) cls._batch_defaults(config) cls._defaults(config) From 8742a03e18c80463b489b3f1f8f8bd22a0333493 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 3 Jul 2023 09:52:42 -0500 Subject: [PATCH 1144/1681] Update changelog --- CHANGES.rst | 8 ++++++++ tailbone/_version.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 6225b749..d71eac97 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.9.37 (2023-07-03) +------------------- + +* Avoid deprecated product key field getter. + +* Allow "arbitrary" PO attachment to purchase batch. + + 0.9.36 (2023-06-20) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index d43dbe86..d57f5f68 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.36' +__version__ = '0.9.37' From 58f9b3ce2a5f560ca567d0860c2417751c77f8cf Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 6 Jul 2023 21:23:44 -0500 Subject: [PATCH 1145/1681] Optimize "auto-receive" batch process disable versioning when doing "auto-receive" for a receiving batch --- tailbone/views/batch/core.py | 8 ++- tailbone/views/purchasing/receiving.py | 85 +++++++------------------- 2 files changed, 29 insertions(+), 64 deletions(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index e2eeeda4..1f5e2be9 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -931,7 +931,7 @@ class BatchMasterView(MasterView): prefix = self.rattail_config.get('rattail', 'command_prefix', default=sys.prefix) cmd = [os.path.join(prefix, 'bin/{}'.format(command))] - for path in self.rattail_config.files_read: + for path in reversed(self.rattail_config.files_read): cmd.extend(['--config', path]) if username: cmd.extend(['--runas', username]) @@ -969,6 +969,10 @@ class BatchMasterView(MasterView): batch_uuid = key[0] # figure out the (sub)command args we'll be passing + if handler_action == 'auto_receive': + subcommand = 'auto-receive' + else: + subcommand = f'{handler_action}-batch' subargs = [ '--batch-type', self.handler.batch_key, @@ -987,7 +991,7 @@ class BatchMasterView(MasterView): command_args=[ '--no-versioning', ], - subcommand='{}-batch'.format(handler_action), + subcommand=subcommand, subcommand_args=subargs) except Exception as error: log.warning("%s of '%s' batch failed: %s", handler_action, self.handler.batch_key, batch_uuid, exc_info=True) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index e659123a..1d1479d6 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -34,10 +34,8 @@ import humanize import sqlalchemy as sa from rattail import pod -from rattail.db import model, Session as RattailSession from rattail.time import localtime, make_utc from rattail.util import pretty_quantity, prettify, simple_error -from rattail.threads import Thread import colander from deform import widget as dfwidget @@ -252,6 +250,7 @@ class ReceivingBatchView(PurchasingBatchView): :meth:`tailbone.views.purchasing.costing:CostingBatchView.create()` which uses similar logic. """ + model = self.model route_prefix = self.get_route_prefix() workflows = self.handler.supported_receiving_workflows() valid_workflows = [workflow['workflow_key'] @@ -642,7 +641,8 @@ class ReceivingBatchView(PurchasingBatchView): return params def template_kwargs_create(self, **kwargs): - kwargs = super(ReceivingBatchView, self).template_kwargs_create(**kwargs) + kwargs = super().template_kwargs_create(**kwargs) + model = self.model if self.handler.allow_truck_dump_receiving(): vmap = {} batches = self.Session.query(model.PurchaseBatch)\ @@ -931,16 +931,17 @@ class ReceivingBatchView(PurchasingBatchView): url = self.request.route_url('receiving.view', uuid=truck_dump.uuid) return tags.link_to(text, url) - @staticmethod - @colander.deferred - def validate_purchase(node, kw): - session = kw['session'] - def validate(node, value): - purchase = session.get(model.Purchase, value) - if not purchase: - raise colander.Invalid(node, "Purchase not found") - return purchase.uuid - return validate + # TODO: is this actually used? wait to see if something breaks.. + # @staticmethod + # @colander.deferred + # def validate_purchase(node, kw): + # session = kw['session'] + # def validate(node, value): + # purchase = session.get(model.Purchase, value) + # if not purchase: + # raise colander.Invalid(node, "Purchase not found") + # return purchase.uuid + # return validate def assign_purchase_order(self, batch, po_form): """ @@ -957,7 +958,8 @@ class ReceivingBatchView(PurchasingBatchView): batch.department_uuid = department.uuid def configure_row_grid(self, g): - super(ReceivingBatchView, self).configure_row_grid(g) + super().configure_row_grid(g) + model = self.model batch = self.get_instance() # vendor_code @@ -1469,6 +1471,7 @@ class ReceivingBatchView(PurchasingBatchView): a "pack" item, such that it instead associates with the "unit" item, with quantities adjusted accordingly. """ + model = self.model batch = self.get_instance() row_uuid = self.request.params.get('row_uuid') @@ -1513,7 +1516,8 @@ class ReceivingBatchView(PurchasingBatchView): }) def configure_row_form(self, f): - super(ReceivingBatchView, self).configure_row_form(f) + super().configure_row_form(f) + model = self.model batch = self.get_instance() # when viewing a row which has no product reference, enable @@ -1690,6 +1694,7 @@ class ReceivingBatchView(PurchasingBatchView): return True def save_edit_row_form(self, form): + model = self.model batch = self.get_instance() row = self.objectify(form) @@ -1829,6 +1834,7 @@ class ReceivingBatchView(PurchasingBatchView): """ AJAX view for updating various cost fields in a data row. """ + model = self.model batch = self.get_instance() data = dict(get_form_data(self.request)) @@ -1882,55 +1888,10 @@ class ReceivingBatchView(PurchasingBatchView): def auto_receive(self): """ - View which can "auto-receive" all items in the batch. Meant only as a - convenience for developers. + View which can "auto-receive" all items in the batch. """ batch = self.get_instance() - key = '{}.receive_all'.format(self.get_grid_key()) - progress = self.make_progress(key) - kwargs = {'progress': progress} - thread = Thread(target=self.auto_receive_thread, args=(batch.uuid, self.request.user.uuid), kwargs=kwargs) - thread.start() - - return self.render_progress(progress, { - 'instance': batch, - 'cancel_url': self.get_action_url('view', batch), - 'cancel_msg': "Auto-receive was canceled", - }) - - def auto_receive_thread(self, uuid, user_uuid, progress=None): - """ - Thread target for receiving all items on the given batch. - """ - session = RattailSession() - batch = session.get(model.PurchaseBatch, uuid) - # user = session.query(model.User).get(user_uuid) - try: - self.handler.auto_receive_all_items(batch, progress=progress) - - # if anything goes wrong, rollback and log the error etc. - except Exception as error: - session.rollback() - log.exception("auto-receive failed for: %s".format(batch)) - session.close() - if progress: - progress.session.load() - progress.session['error'] = True - progress.session['error_msg'] = "Auto-receive failed: {}".format( - simple_error(error)) - progress.session.save() - - # if no error, check result flag (false means user canceled) - else: - session.commit() - session.refresh(batch) - success_url = self.get_action_url('view', batch) - session.close() - if progress: - progress.session.load() - progress.session['complete'] = True - progress.session['success_url'] = success_url - progress.session.save() + return self.handler_action(batch, 'auto_receive') def configure_get_simple_settings(self): config = self.rattail_config From 6b6e358dbe8e497622a1cdb2aa4b5b35997a147f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 7 Jul 2023 15:38:08 -0500 Subject: [PATCH 1146/1681] Update changelog --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index d71eac97..727bd250 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.9.38 (2023-07-07) +------------------- + +* Optimize "auto-receive" batch process. + + 0.9.37 (2023-07-03) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index d57f5f68..aa6a7d76 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.37' +__version__ = '0.9.38' From 4729785b0506a2aafb5e77ce79999cafaae02f74 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 7 Jul 2023 17:19:08 -0500 Subject: [PATCH 1147/1681] Show invoice number for each row in receiving --- tailbone/templates/receiving/view_row.mako | 1 + tailbone/views/purchasing/receiving.py | 1 + 2 files changed, 2 insertions(+) diff --git a/tailbone/templates/receiving/view_row.mako b/tailbone/templates/receiving/view_row.mako index 4d596391..2341cd3e 100644 --- a/tailbone/templates/receiving/view_row.mako +++ b/tailbone/templates/receiving/view_row.mako @@ -444,6 +444,7 @@ <p class="panel-heading">Invoice</p> <div class="panel-block"> <div> + ${form.render_field_readonly('invoice_number')} ${form.render_field_readonly('invoice_line_number')} ${form.render_field_readonly('invoice_unit_cost')} ${form.render_field_readonly('invoice_case_size')} diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 1d1479d6..d4bed60a 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -206,6 +206,7 @@ class ReceivingBatchView(PurchasingBatchView): 'po_unit_cost', 'po_case_size', 'po_total', + 'invoice_number', 'invoice_line_number', 'invoice_unit_cost', 'invoice_cost_confirmed', From a84bcf688bb90591e4bc7820c6c7fc67eb3375d4 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 7 Jul 2023 17:56:45 -0500 Subject: [PATCH 1148/1681] Tweak display options for tempmon probe readings graph --- tailbone/templates/tempmon/probes/graph.mako | 12 ++++++++++-- tailbone/views/tempmon/probes.py | 20 ++++---------------- 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/tailbone/templates/tempmon/probes/graph.mako b/tailbone/templates/tempmon/probes/graph.mako index 795af145..412f25dd 100644 --- a/tailbone/templates/tempmon/probes/graph.mako +++ b/tailbone/templates/tempmon/probes/graph.mako @@ -39,7 +39,13 @@ </b-field> <b-field horizontal label="Showing"> - ${time_range} + <b-select v-model="currentTimeRange" + @input="timeRangeChanged"> + <option value="last hour">Last Hour</option> + <option value="last 6 hours">Last 6 Hours</option> + <option value="last day">Last Day</option> + <option value="last week">Last Week</option> + </b-select> </b-field> </div> @@ -86,7 +92,9 @@ this.chart.destroy() } - this.$http.get('${url('{}.graph_readings'.format(route_prefix), uuid=probe.uuid)}', {params: {'time-range': timeRange}}).then(({ data }) => { + let url = '${url(f'{route_prefix}.graph_readings', uuid=probe.uuid)}' + let params = {'time-range': timeRange} + this.$http.get(url, {params: params}).then(({ data }) => { this.chart = new Chart(this.$refs.tempchart, { type: 'scatter', diff --git a/tailbone/views/tempmon/probes.py b/tailbone/views/tempmon/probes.py index 6d12a3d2..381a9f4a 100644 --- a/tailbone/views/tempmon/probes.py +++ b/tailbone/views/tempmon/probes.py @@ -26,12 +26,11 @@ Views for tempmon probes import datetime -from rattail.time import make_utc, localtime from rattail_tempmon.db import model as tempmon import colander from deform import widget as dfwidget -from webhelpers2.html import tags, HTML +from webhelpers2.html import tags from tailbone import forms, grids from tailbone.views.tempmon import MasterView @@ -258,27 +257,16 @@ class TempmonProbeView(MasterView): selected = self.request.session.get(key, 'last hour') self.request.session[key] = selected - range_options = tags.Options([ - tags.Option("Last Hour", 'last hour'), - tags.Option("Last 6 Hours", 'last 6 hours'), - tags.Option("Last Day", 'last day'), - tags.Option("Last Week", 'last week'), - ]) - - time_range = HTML.tag('b-select', c=[range_options.render()], - **{'v-model': 'currentTimeRange', - '@input': 'timeRangeChanged'}) - context = { 'probe': probe, 'parent_title': str(probe), 'parent_url': self.get_action_url('view', probe), - 'time_range': time_range, 'current_time_range': selected, } return self.render_to_response('graph', context) def graph_readings(self): + app = self.get_rattail_app() probe = self.get_instance() key = 'tempmon.probe.{}.graph_time_range'.format(probe.uuid) @@ -299,7 +287,7 @@ class TempmonProbeView(MasterView): raise NotImplementedError("Unknown time range: {}".format(selected)) # figure out which readings we need to graph - cutoff = make_utc() - datetime.timedelta(seconds=cutoff) + cutoff = app.make_utc() - datetime.timedelta(seconds=cutoff) readings = self.Session.query(tempmon.Reading)\ .filter(tempmon.Reading.probe == probe)\ .filter(tempmon.Reading.taken >= cutoff)\ @@ -308,7 +296,7 @@ class TempmonProbeView(MasterView): # convert readings to data for scatter plot data = [{ - 'x': localtime(self.rattail_config, reading.taken, from_utc=True).isoformat(), + 'x': app.localtime(reading.taken, from_utc=True).isoformat(), 'y': float(reading.degrees_f), } for reading in readings] return data From 1f3b5a49c4b6789198c6f06fe86cacba943d7c0b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 15 Jul 2023 19:32:04 -0500 Subject: [PATCH 1149/1681] Update changelog --- CHANGES.rst | 8 ++++++++ tailbone/_version.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 727bd250..eeec3c57 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.9.39 (2023-07-15) +------------------- + +* Show invoice number for each row in receiving. + +* Tweak display options for tempmon probe readings graph. + + 0.9.38 (2023-07-07) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index aa6a7d76..48ba66dd 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.38' +__version__ = '0.9.39' From 9f0cfc68c1f886de4c877dadc9982a0057a363a4 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 2 Aug 2023 21:59:52 -0500 Subject: [PATCH 1150/1681] Make system key searchable for problem report grid --- tailbone/views/reports.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index a1c737b6..5a945f0c 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -629,7 +629,9 @@ class ProblemReportView(MasterView): return data def configure_grid(self, g): - super(ProblemReportView, self).configure_grid(g) + super().configure_grid(g) + + g.set_searchable('system_key') g.set_renderer('email_recipients', self.render_email_recipients) From ec7b0cdda178a68b656838d146a86824d3ac1a24 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 3 Aug 2023 22:42:34 -0500 Subject: [PATCH 1151/1681] Update changelog --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index eeec3c57..08bff3b8 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.9.40 (2023-08-03) +------------------- + +* Make system key searchable for problem report grid. + + 0.9.39 (2023-07-15) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 48ba66dd..6d32d447 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.39' +__version__ = '0.9.40' From d504da19c5197ee0d6d2c37c09bbc1e94470f264 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 7 Aug 2023 12:36:07 -0500 Subject: [PATCH 1152/1681] Add common logic to validate employee reference field --- tailbone/views/master.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index e0c42e6e..eeae4dae 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -856,6 +856,13 @@ class MasterView(View): url = self.request.route_url('stores.view', uuid=store.uuid) return tags.link_to(text, url) + def valid_employee_uuid(self, node, value): + if value: + model = self.model + employee = self.Session.get(model.Employee, value) + if not employee: + node.raise_invalid("Employee not found") + def render_product(self, obj, field): product = getattr(obj, field) if not product: From f2915afda4dd94ad95facd4c23e2f100e9e94909 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 8 Aug 2023 14:11:54 -0500 Subject: [PATCH 1153/1681] Fix HTML rendering for UOM choice options also avoid deprecated config methods --- tailbone/templates/deform/select_dynamic.pt | 2 +- tailbone/views/custorders/orders.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tailbone/templates/deform/select_dynamic.pt b/tailbone/templates/deform/select_dynamic.pt index a0ee1daf..712830d1 100644 --- a/tailbone/templates/deform/select_dynamic.pt +++ b/tailbone/templates/deform/select_dynamic.pt @@ -26,7 +26,7 @@ <option v-for="item in ${name}_options" tal:attributes=":key 'item.value'; :value 'item.value';"> - {{ item.label }} + <span v-html="item.label"></span> </option> </b-select> diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index 563739ea..cdf765a6 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -341,7 +341,7 @@ class CustomerOrderView(MasterView): 'allow_contact_info_choice': self.batch_handler.allow_contact_info_choice(), 'allow_contact_info_create': self.batch_handler.allow_contact_info_creation(), 'order_items': items, - 'product_key_label': self.rattail_config.product_key_title(), + 'product_key_label': app.get_product_key_label(), 'allow_unknown_product': self.batch_handler.allow_unknown_product(), 'department_options': self.get_department_options(), 'default_uom_choices': self.batch_handler.uom_choices_for_product(None), @@ -767,7 +767,7 @@ class CustomerOrderView(MasterView): if self.batch_handler.product_price_may_be_questionable(): data['price_needs_confirmation'] = row.price_needs_confirmation - key = self.rattail_config.product_key() + key = app.get_product_key_field() if key == 'upc': data['product_key'] = data['product_upc_pretty'] elif key == 'item_id': From 845b5cda1a6730fe27028d180b246e2c221b3f40 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 8 Aug 2023 18:06:22 -0500 Subject: [PATCH 1154/1681] Fix custom cell click handlers in main buefy grid tables just used for editing catalog/invoice cost in receiving thus far.. --- tailbone/templates/grids/buefy.mako | 20 +++++++++++++++++--- tailbone/templates/receiving/view.mako | 3 ++- tailbone/views/purchasing/receiving.py | 9 +++------ 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako index d96358d5..42451597 100644 --- a/tailbone/templates/grids/buefy.mako +++ b/tailbone/templates/grids/buefy.mako @@ -180,18 +180,21 @@ % endif :checkable="checkable" + % if grid.checkboxes: :checked-rows.sync="checkedRows" % if grid.clicking_row_checks_box: @click="rowClick" % endif % endif + % if grid.check_handler: @check="${grid.check_handler}" % endif % if grid.check_all_handler: @check-all="${grid.check_all_handler}" % endif + % if isinstance(grid.checkable, str): :is-row-checkable="${grid.row_checkable}" % elif grid.checkable: @@ -204,6 +207,10 @@ @sort="onSort" % endif + % if grid.click_handlers: + @cellclick="cellClick" + % endif + :paginated="paginated" :per-page="perPage" :current-page="currentPage" @@ -227,9 +234,6 @@ searchable % endif cell-class="c_${column['field']}" - % if grid.has_click_handler(column['field']): - @click.native="${grid.click_handlers[column['field']]}" - % endif :visible="${json.dumps(column['visible'])}"> % if column['field'] in grid.raw_renderers: ${grid.raw_renderers[column['field']]()} @@ -392,6 +396,16 @@ methods: { + % if grid.click_handlers: + cellClick(row, column, rowIndex, columnIndex) { + % for key in grid.click_handlers: + if (column._props.field == '${key}') { + ${grid.click_handlers[key]}(row) + } + % endfor + }, + % endif + copyDirectLink() { if (navigator.clipboard) { diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako index 463fdf6c..b4de37f1 100644 --- a/tailbone/templates/receiving/view.mako +++ b/tailbone/templates/receiving/view.mako @@ -103,7 +103,8 @@ ref="input" v-show="editing" @keydown.native="inputKeyDown" - @blur="inputBlur"> + @blur="inputBlur" + style="width: 6rem;"> </b-input> </div> </script> diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index d4bed60a..35e1d6b4 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -968,19 +968,16 @@ class ReceivingBatchView(PurchasingBatchView): g.filters['vendor_code'].default_verb = 'contains' # catalog_unit_cost - if (self.handler.has_purchase_order(batch) - or self.handler.has_invoice_file(batch)): - g.remove('catalog_unit_cost') - elif self.allow_edit_catalog_unit_cost(batch): + if self.allow_edit_catalog_unit_cost(batch): g.set_raw_renderer('catalog_unit_cost', self.render_catalog_unit_cost) g.set_click_handler('catalog_unit_cost', - 'catalogUnitCostClicked(props.row)') + 'this.catalogUnitCostClicked') # invoice_unit_cost if self.allow_edit_invoice_unit_cost(batch): g.set_raw_renderer('invoice_unit_cost', self.render_invoice_unit_cost) g.set_click_handler('invoice_unit_cost', - 'invoiceUnitCostClicked(props.row)') + 'this.invoiceUnitCostClicked') # nb. only show PO *or* invoice cost; prefer the latter unless # we have a PO and no invoice From 4ecea891b3347a878b710569e3a07c120a5a922a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 8 Aug 2023 18:42:50 -0500 Subject: [PATCH 1155/1681] Update changelog --- CHANGES.rst | 10 ++++++++++ tailbone/_version.py | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 08bff3b8..f43e669b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.9.41 (2023-08-08) +------------------- + +* Add common logic to validate employee reference field. + +* Fix HTML rendering for UOM choice options. + +* Fix custom cell click handlers in main buefy grid tables. + + 0.9.40 (2023-08-03) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 6d32d447..07ccc0e9 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.40' +__version__ = '0.9.41' From 90075b3b6539d554ccca6915fe6fcab14b7df7fe Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 9 Aug 2023 18:04:51 -0500 Subject: [PATCH 1156/1681] When bulk-deleting, skip objects which are not "deletable" whatever that means in context --- tailbone/views/master.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index eeae4dae..107870cd 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -1728,7 +1728,8 @@ class MasterView(View): def bulk_delete_objects(self, session, objects, progress=None): def delete(obj, i): - self.delete_instance(obj) + if self.deletable_instance(obj): + self.delete_instance(obj) if i % 1000 == 0: session.flush() From a007606863ab386578018c765a388f50a9bf8d0f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 17 Aug 2023 18:12:42 -0500 Subject: [PATCH 1157/1681] Declare "from PO" receiving workflow if applicable, in API --- tailbone/api/batch/receiving.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tailbone/api/batch/receiving.py b/tailbone/api/batch/receiving.py index 9a6864db..b02215d2 100644 --- a/tailbone/api/batch/receiving.py +++ b/tailbone/api/batch/receiving.py @@ -77,9 +77,15 @@ class ReceivingBatchViews(APIBatchView): def create_object(self, data): data = dict(data) + + # all about receiving mode here data['mode'] = self.enum.PURCHASE_BATCH_MODE_RECEIVING - batch = super(ReceivingBatchViews, self).create_object(data) - return batch + + # assume "receive from PO" if given a PO key + if data['purchase_key']: + data['receiving_workflow'] = 'from_po' + + return super().create_object(data) def auto_receive(self): """ From b2aea57da6933d84b79d049f10c07dff20d56579 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 18 Aug 2023 15:04:52 -0500 Subject: [PATCH 1158/1681] Auto-select text when editing costs for receiving --- tailbone/templates/receiving/view.mako | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako index b4de37f1..77560ac1 100644 --- a/tailbone/templates/receiving/view.mako +++ b/tailbone/templates/receiving/view.mako @@ -103,6 +103,7 @@ ref="input" v-show="editing" @keydown.native="inputKeyDown" + @focus="selectAll" @blur="inputBlur" style="width: 6rem;"> </b-input> @@ -189,6 +190,12 @@ }, methods: { + selectAll() { + // nb. must traverse into the <b-input> element + let trueInput = this.$refs.input.$el.firstChild + trueInput.select() + }, + startEdit() { this.inputValue = this.value this.editing = true From 8be7dac33b7020b3ae59db15ace1c74a9b9524cb Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 24 Aug 2023 22:00:11 -0500 Subject: [PATCH 1159/1681] Include shopper history from parent customer account perspective ..right? or should this be hidden? configurable etc.? --- tailbone/views/people.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 8dc96037..54d00ca7 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -1283,6 +1283,22 @@ class PersonView(MasterView): .filter(cls.account_holder_uuid == person.uuid) versions.extend(query.all()) + # CustomerShopper (from Customer perspective) + cls = continuum.version_class(model.CustomerShopper) + query = self.Session.query(cls)\ + .join(model.Customer, model.Customer.uuid == cls.customer_uuid)\ + .filter(model.Customer.account_holder_uuid == person.uuid) + versions.extend(query.all()) + + # CustomerShopperHistory (from Customer perspective) + cls = continuum.version_class(model.CustomerShopperHistory) + query = self.Session.query(cls)\ + .join(model.CustomerShopper, + model.CustomerShopper.uuid == cls.shopper_uuid)\ + .join(model.Customer)\ + .filter(model.Customer.account_holder_uuid == person.uuid) + versions.extend(query.all()) + # CustomerShopper (from Shopper perspective) cls = continuum.version_class(model.CustomerShopper) query = self.Session.query(cls)\ From bc8b5a8d324b3d30410ef8222e068714cdb7b84a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 25 Aug 2023 09:08:33 -0500 Subject: [PATCH 1160/1681] Link to product record, for New Product batch row also fix a typo --- tailbone/templates/products/configure.mako | 2 +- tailbone/views/batch/newproduct.py | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/tailbone/templates/products/configure.mako b/tailbone/templates/products/configure.mako index a8caeac7..10f3c0e5 100644 --- a/tailbone/templates/products/configure.mako +++ b/tailbone/templates/products/configure.mako @@ -50,7 +50,7 @@ <h3 class="block is-size-3">Handling</h3> <div class="block" style="padding-left: 2rem;"> - <b-field message="If set, GPC values like 002XXXXXYYYYY-Z will be converted to 002XXXXX00000-Z for lokkup"> + <b-field message="If set, GPC values like 002XXXXXYYYYY-Z will be converted to 002XXXXX00000-Z for lookup"> <b-checkbox name="rattail.products.convert_type2_for_gpc_lookup" v-model="simpleSettings['rattail.products.convert_type2_for_gpc_lookup']" native-value="true" diff --git a/tailbone/views/batch/newproduct.py b/tailbone/views/batch/newproduct.py index 03ca638b..d58357d0 100644 --- a/tailbone/views/batch/newproduct.py +++ b/tailbone/views/batch/newproduct.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,6 @@ Views for new product batches """ -from __future__ import unicode_literals, absolute_import - from rattail.db import model from tailbone.views.batch import BatchMasterView @@ -165,7 +163,7 @@ class NewProductBatchView(BatchMasterView): return 'notice' def configure_row_form(self, f): - super(NewProductBatchView, self).configure_row_form(f) + super().configure_row_form(f) f.set_readonly('product') f.set_readonly('vendor') @@ -177,6 +175,7 @@ class NewProductBatchView(BatchMasterView): f.set_type('upc', 'gpc') + f.set_renderer('product', self.render_product) f.set_renderer('vendor', self.render_vendor) f.set_renderer('department', self.render_department) f.set_renderer('subdepartment', self.render_subdepartment) From a40b44b6e33aa68f557cd14d9e125d484c68cc9e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 25 Aug 2023 10:41:20 -0500 Subject: [PATCH 1161/1681] Fix profile history to show when a CustomerShopperHistory is deleted --- tailbone/views/people.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 54d00ca7..48391f63 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -1307,10 +1307,10 @@ class PersonView(MasterView): # CustomerShopperHistory (from Shopper perspective) cls = continuum.version_class(model.CustomerShopperHistory) + standin = continuum.version_class(model.CustomerShopper) query = self.Session.query(cls)\ - .join(model.CustomerShopper, - model.CustomerShopper.uuid == cls.shopper_uuid)\ - .filter(model.CustomerShopper.person_uuid == person.uuid) + .join(standin, standin.uuid == cls.shopper_uuid)\ + .filter(standin.person_uuid == person.uuid) versions.extend(query.all()) # PersonNote From 844c629a6a013ce57ff01f896fa9cce442cd6426 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 25 Aug 2023 13:59:58 -0500 Subject: [PATCH 1162/1681] Fix profile history to show when a CustomerShopperHistory is deleted --- tailbone/views/people.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 48391f63..d7f84849 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -1292,10 +1292,10 @@ class PersonView(MasterView): # CustomerShopperHistory (from Customer perspective) cls = continuum.version_class(model.CustomerShopperHistory) + standin = continuum.version_class(model.CustomerShopper) query = self.Session.query(cls)\ - .join(model.CustomerShopper, - model.CustomerShopper.uuid == cls.shopper_uuid)\ - .join(model.Customer)\ + .join(standin, standin.uuid == cls.shopper_uuid)\ + .join(model.Customer, model.Customer.uuid == standin.customer_uuid)\ .filter(model.Customer.account_holder_uuid == person.uuid) versions.extend(query.all()) From 12e477909305a1f2ed4b7e4ba2b421ab727c782e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 28 Aug 2023 20:43:31 -0500 Subject: [PATCH 1163/1681] Fairly massive overhaul of the Profile view; standardize tabs etc. much cleaner and more consistent interface now, between the main ProfileInfo component, and various *Tab components also cleaner interface between client-side JS and server view methods to my knowledge this is complete and breaks nothing..we'll see! --- tailbone/templates/members/configure.mako | 14 + tailbone/templates/page.mako | 7 +- .../templates/people/view_profile_buefy.mako | 1830 +++++++++-------- tailbone/views/members.py | 5 + tailbone/views/people.py | 397 ++-- 5 files changed, 1234 insertions(+), 1019 deletions(-) diff --git a/tailbone/templates/members/configure.mako b/tailbone/templates/members/configure.mako index c0e0355d..465bf611 100644 --- a/tailbone/templates/members/configure.mako +++ b/tailbone/templates/members/configure.mako @@ -36,6 +36,20 @@ </b-field> </div> + + <h3 class="block is-size-3">Relationships</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field message="By default a Person may have multiple Member accounts."> + <b-checkbox name="rattail.members.max_one_per_person" + v-model="simpleSettings['rattail.members.max_one_per_person']" + native-value="true" + @input="settingsNeedSaved = true"> + Limit one (1) Member account per Person + </b-checkbox> + </b-field> + + </div> </%def> <%def name="modify_this_page_vars()"> diff --git a/tailbone/templates/page.mako b/tailbone/templates/page.mako index b5ac8773..bf799440 100644 --- a/tailbone/templates/page.mako +++ b/tailbone/templates/page.mako @@ -38,7 +38,12 @@ }, computed: {}, watch: {}, - methods: {}, + methods: { + + changeContentTitle(newTitle) { + this.$emit('change-content-title', newTitle) + }, + }, } let ThisPageData = { diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako index e1da8661..5574088e 100644 --- a/tailbone/templates/people/view_profile_buefy.mako +++ b/tailbone/templates/people/view_profile_buefy.mako @@ -119,17 +119,17 @@ <section class="modal-card-body"> <b-field label="First Name"> - <b-input v-model.trim="personFirstName" + <b-input v-model.trim="editNameFirst" :maxlength="maxLengths.person_first_name || null"> </b-input> </b-field> <b-field label="Middle Name"> - <b-input v-model.trim="personMiddleName" + <b-input v-model.trim="editNameMiddle" :maxlength="maxLengths.person_middle_name || null"> </b-input> </b-field> <b-field label="Last Name"> - <b-input v-model.trim="personLastName" + <b-input v-model.trim="editNameLast" :maxlength="maxLengths.person_last_name || null"> </b-input> </b-field> @@ -210,38 +210,38 @@ <section class="modal-card-body"> <b-field label="Street 1" expanded> - <b-input v-model.trim="personStreet1" + <b-input v-model.trim="editAddressStreet1" :maxlength="maxLengths.address_street || null"> </b-input> </b-field> <b-field label="Street 2" expanded> - <b-input v-model.trim="personStreet2" + <b-input v-model.trim="editAddressStreet2" :maxlength="maxLengths.address_street2 || null"> </b-input> </b-field> <b-field label="Zipcode"> - <b-input v-model.trim="personZipcode" + <b-input v-model.trim="editAddressZipcode" :maxlength="maxLengths.address_zipcode || null"> </b-input> </b-field> <b-field grouped> <b-field label="City"> - <b-input v-model.trim="personCity" + <b-input v-model.trim="editAddressCity" :maxlength="maxLengths.address_city || null"> </b-input> </b-field> <b-field label="State"> - <b-input v-model.trim="personState" + <b-input v-model.trim="editAddressState" :maxlength="maxLengths.address_state || null"> </b-input> </b-field> </b-field> <b-field label="Invalid"> - <b-checkbox v-model="personInvalidAddress" + <b-checkbox v-model="editAddressInvalid" type="is-danger"> </b-checkbox> </b-field> @@ -298,7 +298,7 @@ <header class="modal-card-head"> <p class="modal-card-title"> - {{ phoneUUID ? "Edit Phone" : "Add Phone" }} + {{ editPhoneUUID ? "Edit" : "Add" }} Phone </p> </header> @@ -306,7 +306,7 @@ <b-field grouped> <b-field label="Type" expanded> - <b-select v-model="phoneType" expanded> + <b-select v-model="editPhoneType" expanded> <option v-for="option in phoneTypeOptions" :key="option.value" :value="option.value"> @@ -316,14 +316,14 @@ </b-field> <b-field label="Number" expanded> - <b-input v-model.trim="phoneNumber" + <b-input v-model.trim="editPhoneNumber" ref="editPhoneInput"> </b-input> </b-field> </b-field> <b-field label="Preferred?"> - <b-checkbox v-model="phonePreferred"> + <b-checkbox v-model="editPhonePreferred"> </b-checkbox> </b-field> @@ -335,7 +335,7 @@ :disabled="editPhoneSaveDisabled" icon-pack="fas" icon-left="save"> - {{ editPhoneSaveText }} + {{ editPhoneSaving ? "Working..." : "Save" }} </b-button> <b-button @click="editPhoneShowDialog = false"> Cancel @@ -372,12 +372,12 @@ <i class="fas fa-edit"></i> Edit </a> - <a href="#" @click.prevent="deletePhone(props.row)" + <a href="#" @click.prevent="deletePhoneInit(props.row)" class="has-text-danger"> <i class="fas fa-trash"></i> Delete </a> - <a href="#" @click.prevent="setPreferredPhone(props.row)" + <a href="#" @click.prevent="preferPhoneInit(props.row)" v-if="!props.row.preferred"> <i class="fas fa-star"></i> Set Preferred @@ -415,7 +415,7 @@ <header class="modal-card-head"> <p class="modal-card-title"> - {{ emailUUID ? "Edit Email" : "Add Email" }} + {{ editEmailUUID ? "Edit" : "Add" }} Email </p> </header> @@ -423,7 +423,7 @@ <b-field grouped> <b-field label="Type" expanded> - <b-select v-model="emailType" expanded> + <b-select v-model="editEmailType" expanded> <option v-for="option in emailTypeOptions" :key="option.value" :value="option.value"> @@ -433,23 +433,23 @@ </b-field> <b-field label="Address" expanded> - <b-input v-model.trim="emailAddress" + <b-input v-model.trim="editEmailAddress" ref="editEmailInput"> </b-input> </b-field> </b-field> - <b-field v-if="!emailUUID" + <b-field v-if="!editEmailUUID" label="Preferred?"> - <b-checkbox v-model="emailPreferred"> + <b-checkbox v-model="editEmailPreferred"> </b-checkbox> </b-field> - <b-field v-if="emailUUID" + <b-field v-if="editEmailUUID" label="Invalid?"> - <b-checkbox v-model="emailInvalid" - :type="emailInvalid ? 'is-danger': null"> + <b-checkbox v-model="editEmailInvalid" + :type="editEmailInvalid ? 'is-danger': null"> </b-checkbox> </b-field> @@ -461,7 +461,7 @@ :disabled="editEmailSaveDisabled" icon-pack="fas" icon-left="save"> - {{ editEmailSaveText }} + {{ editEmailSaving ? "Working, please wait..." : "Save" }} </b-button> <b-button @click="editEmailShowDialog = false"> Cancel @@ -504,12 +504,12 @@ <i class="fas fa-edit"></i> Edit </a> - <a href="#" @click.prevent="deleteEmail(props.row)" + <a href="#" @click.prevent="deleteEmailInit(props.row)" class="has-text-danger"> <i class="fas fa-trash"></i> Delete </a> - <a href="#" @click.prevent="setPreferredEmail(props.row)" + <a href="#" @click.prevent="preferEmailInit(props.row)" v-if="!props.row.preferred"> <i class="fas fa-star"></i> Set Preferred @@ -541,10 +541,12 @@ <div> % if request.has_perm('people.view'): - ${h.link_to("View Person", url('people.view', uuid=person.uuid), class_='button')} + <b-button tag="a" :href="person.view_url"> + View Person + </b-button> % endif </div> - + <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading> </div> </script> </%def> @@ -553,294 +555,336 @@ <b-tab-item label="Personal" value="personal" icon-pack="fas" - icon="check"> - <personal-tab :person="person" - :member="member" - :max-lengths="maxLengths" + :icon="tabchecks.personal ? 'check' : null"> + <personal-tab ref="tab_personal" + :person="person" + @profile-changed="profileChanged" :phone-type-options="phoneTypeOptions" :email-type-options="emailTypeOptions" - @person-updated="personUpdated" - @change-content-title="changeContentTitle"> + :max-lengths="maxLengths"> </personal-tab> </b-tab-item> </%def> +<%def name="render_member_tab_template()"> + <script type="text/x-template" id="member-tab-template"> + <div> + % if max_one_member: + <p class="block"> + TODO: UI not yet implemented for "max one member per person" + </p + + % else: + ## nb. multiple members allowed per person + <div v-if="members.length"> + + <div style="display: flex; justify-content: space-between;"> + <p>{{ person.display_name }} has <strong>{{ members.length }}</strong> member account{{ members.length == 1 ? '' : 's' }}</p> + </div> + + <br /> + <b-collapse v-for="member in members" + :key="member.uuid" + class="panel" + :open="members.length == 1"> + + <div slot="trigger" + slot-scope="props" + class="panel-heading" + role="button"> + <b-icon pack="fas" + icon="caret-right"> + </b-icon> + <strong>{{ member._key }} - {{ member.display }}</strong> + </div> + + <div class="panel-block"> + <div style="display: flex; justify-content: space-between; width: 100%;"> + <div style="flex-grow: 1;"> + + <b-field horizontal label="${member_key_label}"> + {{ member._key }} + </b-field> + + <b-field horizontal label="Account Holder"> + <a v-if="member.person_uuid != person.uuid" + :href="member.view_profile_url"> + {{ member.person_display_name }} + </a> + <span v-if="member.person_uuid == person.uuid"> + {{ member.person_display_name }} + </span> + </b-field> + + <b-field horizontal label="Membership Type"> + <a v-if="member.view_membership_type_url" + :href="member.view_membership_type_url"> + {{ member.membership_type_name }} + </a> + <span v-if="!member.view_membership_type_url"> + {{ member.membership_type_name }} + </span> + </b-field> + + <b-field horizontal label="Active"> + {{ member.active ? "Yes" : "No" }} + </b-field> + + <b-field horizontal label="Joined"> + {{ member.joined }} + </b-field> + + <b-field horizontal label="Withdrew" + v-if="member.withdrew"> + {{ member.withdrew }} + </b-field> + + <b-field horizontal label="Equity Total"> + {{ member.equity_total_display }} + </b-field> + + </div> + <div class="buttons" style="align-items: start;"> + + <b-button v-for="link in member.external_links" + :key="link.url" + type="is-primary" + tag="a" :href="link.url" target="_blank" + icon-pack="fas" + icon-left="external-link-alt"> + {{ link.label }} + </b-button> + + % if request.has_perm('members.view'): + <b-button tag="a" :href="member.view_url"> + View Member + </b-button> + % endif + + </div> + </div> + </div> + </b-collapse> + </div> + + <div v-if="!members.length"> + <p>{{ person.display_name }} does not have a member account.</p> + </div> + % endif + + <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading> + </div> + </script> +</%def> + <%def name="render_member_tab()"> <b-tab-item label="Member" value="member" icon-pack="fas" - :icon="members.length ? 'check' : null"> - - <div v-if="members.length"> - - <div style="display: flex; justify-content: space-between;"> - <p>{{ person.display_name }} has <strong>{{ members.length }}</strong> member account{{ members.length == 1 ? '' : 's' }}</p> - </div> - - <br /> - <b-collapse v-for="member in members" - :key="member.uuid" - class="panel" - :open="members.length == 1"> - - <div slot="trigger" - slot-scope="props" - class="panel-heading" - role="button"> - <b-icon pack="fas" - icon="caret-right"> - </b-icon> - <strong>{{ member._key }} - {{ member.display }}</strong> - </div> - - <div class="panel-block"> - <div style="display: flex; justify-content: space-between; width: 100%;"> - <div style="flex-grow: 1;"> - - <b-field horizontal label="${member_key_label}"> - {{ member._key }} - </b-field> - - <b-field horizontal label="Account Holder"> - <a v-if="member.person_uuid != person.uuid" - :href="member.view_profile_url"> - {{ member.person_display_name }} - </a> - <span v-if="member.person_uuid == person.uuid"> - {{ member.person_display_name }} - </span> - </b-field> - - <b-field horizontal label="Membership Type"> - <a v-if="member.view_membership_type_url" - :href="member.view_membership_type_url"> - {{ member.membership_type_name }} - </a> - <span v-if="!member.view_membership_type_url"> - {{ member.membership_type_name }} - </span> - </b-field> - - <b-field horizontal label="Active"> - {{ member.active ? "Yes" : "No" }} - </b-field> - - <b-field horizontal label="Joined"> - {{ member.joined }} - </b-field> - - <b-field horizontal label="Withdrew" - v-if="member.withdrew"> - {{ member.withdrew }} - </b-field> - - <b-field horizontal label="Equity Total"> - {{ member.equity_total_display }} - </b-field> - - </div> - <div class="buttons" style="align-items: start;"> - ${self.render_member_panel_buttons(member)} - </div> - </div> - </div> - </b-collapse> - </div> - - <div v-if="!members.length"> - <p>{{ person.display_name }} does not have a member account.</p> - </div> - + :icon="tabchecks.member ? 'check' : null"> + <member-tab ref="tab_member" + :person="person" + @profile-changed="profileChanged" + :phone-type-options="phoneTypeOptions"> + </member-tab> </b-tab-item> </%def> -<%def name="render_member_panel_buttons(member)"> - % for button in member_xref_buttons: - ${button} - % endfor - % if request.has_perm('members.view'): - <b-button tag="a" :href="member.view_url"> - View Member - </b-button> - % endif +<%def name="render_customer_tab_template()"> + <script type="text/x-template" id="customer-tab-template"> + <div> + <div v-if="customers.length"> + + <div style="display: flex; justify-content: space-between;"> + <p>{{ person.display_name }} has <strong>{{ customers.length }}</strong> customer account{{ customers.length == 1 ? '' : 's' }}</p> + </div> + + <br /> + <b-collapse v-for="customer in customers" + :key="customer.uuid" + class="panel" + :open="customers.length == 1"> + + <div slot="trigger" + slot-scope="props" + class="panel-heading" + role="button"> + <b-icon pack="fas" + icon="caret-right"> + </b-icon> + <strong>{{ customer._key }} - {{ customer.name }}</strong> + </div> + + <div class="panel-block"> + <div style="display: flex; justify-content: space-between; width: 100%;"> + <div style="flex-grow: 1;"> + + <b-field horizontal label="${customer_key_label}"> + {{ customer._key }} + </b-field> + + <b-field horizontal label="Account Name"> + {{ customer.name }} + </b-field> + + % if expose_customer_shoppers: + <b-field horizontal label="Shoppers"> + <ul> + <li v-for="shopper in customer.shoppers" + :key="shopper.uuid"> + <a v-if="shopper.person_uuid != person.uuid" + :href="shopper.view_profile_url"> + {{ shopper.display_name }} + </a> + <span v-if="shopper.person_uuid == person.uuid"> + {{ shopper.display_name }} + </span> + </li> + </ul> + </b-field> + % endif + + % if expose_customer_people: + <b-field horizontal label="People"> + <ul> + <li v-for="p in customer.people" + :key="p.uuid"> + <a v-if="p.uuid != person.uuid" + :href="p.view_profile_url"> + {{ p.display_name }} + </a> + <span v-if="p.uuid == person.uuid"> + {{ p.display_name }} + </span> + </li> + </ul> + </b-field> + % endif + + <b-field horizontal label="Address" + v-for="address in customer.addresses" + :key="address.uuid"> + {{ address.display }} + </b-field> + + </div> + <div class="buttons" style="align-items: start;"> + + <b-button v-for="link in customer.external_links" + :key="link.url" + type="is-primary" + tag="a" :href="link.url" target="_blank" + icon-pack="fas" + icon-left="external-link-alt"> + {{ link.label }} + </b-button> + + % if request.has_perm('customers.view'): + <b-button tag="a" :href="customer.view_url"> + View Customer + </b-button> + % endif + + </div> + </div> + </div> + </b-collapse> + </div> + + <div v-if="!customers.length"> + <p>{{ person.display_name }} does not have a customer account.</p> + </div> + <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading> + </div> + </script> </%def> <%def name="render_customer_tab()"> <b-tab-item label="Customer" value="customer" icon-pack="fas" - :icon="customers.length ? 'check' : null"> - - <div v-if="customers.length"> - - <div style="display: flex; justify-content: space-between;"> - <p>{{ person.display_name }} has <strong>{{ customers.length }}</strong> customer account{{ customers.length == 1 ? '' : 's' }}</p> - </div> - - <br /> - <b-collapse v-for="customer in customers" - :key="customer.uuid" - class="panel" - :open="customers.length == 1"> - - <div slot="trigger" - slot-scope="props" - class="panel-heading" - role="button"> - <b-icon pack="fas" - icon="caret-right"> - </b-icon> - <strong>{{ customer._key }} - {{ customer.name }}</strong> - </div> - - <div class="panel-block"> - <div style="display: flex; justify-content: space-between; width: 100%;"> - <div style="flex-grow: 1;"> - - <b-field horizontal label="${customer_key_label}"> - {{ customer._key }} - </b-field> - - <b-field horizontal label="Account Name"> - {{ customer.name }} - </b-field> - - % if expose_customer_shoppers: - <b-field horizontal label="Shoppers"> - <ul> - <li v-for="shopper in customer.shoppers" - :key="shopper.uuid"> - <a v-if="shopper.person_uuid != person.uuid" - :href="shopper.view_profile_url"> - {{ shopper.display_name }} - </a> - <span v-if="shopper.person_uuid == person.uuid"> - {{ shopper.display_name }} - </span> - </li> - </ul> - </b-field> - % endif - - % if expose_customer_people: - <b-field horizontal label="People"> - <ul> - <li v-for="p in customer.people" - :key="p.uuid"> - <a v-if="p.uuid != person.uuid" - :href="p.view_profile_url"> - {{ p.display_name }} - </a> - <span v-if="p.uuid == person.uuid"> - {{ p.display_name }} - </span> - </li> - </ul> - </b-field> - % endif - - <b-field horizontal label="Address" - v-for="address in customer.addresses" - :key="address.uuid"> - {{ address.display }} - </b-field> - - </div> - <div class="buttons" style="align-items: start;"> - ${self.render_customer_panel_buttons(customer)} - </div> - </div> - </div> - </b-collapse> - </div> - - <div v-if="!customers.length"> - <p>{{ person.display_name }} does not have a customer account.</p> - </div> - - </b-tab-item> <!-- Customer --> + :icon="tabchecks.customer ? 'check' : null"> + <customer-tab ref="tab_customer" + :person="person" + @profile-changed="profileChanged"> + </customer-tab> + </b-tab-item> </%def> -<%def name="render_customer_panel_buttons(customer)"> - <b-button v-for="link in customer.external_links" - :key="link.url" - type="is-primary" - tag="a" :href="link.url" target="_blank" - icon-pack="fas" - icon-left="external-link-alt"> - {{ link.label }} - </b-button> - % if request.has_perm('customers.view'): - <b-button tag="a" :href="customer.view_url"> - View Customer - </b-button> - % endif +<%def name="render_shopper_tab_template()"> + <script type="text/x-template" id="shopper-tab-template"> + <div> + <div v-if="shoppers.length"> + + <div style="display: flex; justify-content: space-between;"> + <p>{{ person.display_name }} is shopper for <strong>{{ shoppers.length }}</strong> customer account{{ shoppers.length == 1 ? '' : 's' }}</p> + </div> + + <br /> + <b-collapse v-for="shopper in shoppers" + :key="shopper.uuid" + class="panel" + :open="shoppers.length == 1"> + + <div slot="trigger" + slot-scope="props" + class="panel-heading" + role="button"> + <b-icon pack="fas" + icon="caret-right"> + </b-icon> + <strong>{{ shopper.customer_key }} - {{ shopper.customer_name }}</strong> + </div> + + <div class="panel-block"> + <div style="display: flex; justify-content: space-between; width: 100%;"> + <div style="flex-grow: 1;"> + + <b-field horizontal label="${customer_key_label}"> + {{ shopper.customer_key }} + </b-field> + + <b-field horizontal label="Account Name"> + {{ shopper.customer_name }} + </b-field> + + <b-field horizontal label="Account Holder"> + <span v-if="!shopper.account_holder_view_profile_url"> + {{ shopper.account_holder_name }} + </span> + <a v-if="shopper.account_holder_view_profile_url" + :href="shopper.account_holder_view_profile_url"> + {{ shopper.account_holder_name }} + </a> + </b-field> + + </div> + ## <div class="buttons" style="align-items: start;"> + ## ${self.render_shopper_panel_buttons(shopper)} + ## </div> + </div> + </div> + </b-collapse> + </div> + + <div v-if="!shoppers.length"> + <p>{{ person.display_name }} is not a shopper.</p> + </div> + <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading> + </div> + </script> </%def> <%def name="render_shopper_tab()"> <b-tab-item label="Shopper" value="shopper" icon-pack="fas" - :icon="shoppers.length ? 'check' : null"> - - <div v-if="shoppers.length"> - - <div style="display: flex; justify-content: space-between;"> - <p>{{ person.display_name }} is shopper for <strong>{{ shoppers.length }}</strong> customer account{{ shoppers.length == 1 ? '' : 's' }}</p> - </div> - - <br /> - <b-collapse v-for="shopper in shoppers" - :key="shopper.uuid" - class="panel" - :open="shoppers.length == 1"> - - <div slot="trigger" - slot-scope="props" - class="panel-heading" - role="button"> - <b-icon pack="fas" - icon="caret-right"> - </b-icon> - <strong>{{ shopper.customer_key }} - {{ shopper.customer_name }}</strong> - </div> - - <div class="panel-block"> - <div style="display: flex; justify-content: space-between; width: 100%;"> - <div style="flex-grow: 1;"> - - <b-field horizontal label="${customer_key_label}"> - {{ shopper.customer_key }} - </b-field> - - <b-field horizontal label="Account Name"> - {{ shopper.customer_name }} - </b-field> - - <b-field horizontal label="Account Holder"> - <span v-if="!shopper.account_holder_view_profile_url"> - {{ shopper.account_holder_name }} - </span> - <a v-if="shopper.account_holder_view_profile_url" - :href="shopper.account_holder_view_profile_url"> - {{ shopper.account_holder_name }} - </a> - </b-field> - - </div> -## <div class="buttons" style="align-items: start;"> -## ${self.render_shopper_panel_buttons(shopper)} -## </div> - </div> - </div> - </b-collapse> - </div> - - <div v-if="!shoppers.length"> - <p>{{ person.display_name }} is not a shopper.</p> - </div> - - </b-tab-item> <!-- Shopper --> + :icon="tabchecks.shopper ? 'check' : null"> + <shopper-tab ref="tab_shopper" + :person="person" + @profile-changed="profileChanged"> + </shopper-tab> + </b-tab-item> </%def> <%def name="render_employee_tab_template()"> @@ -863,11 +907,11 @@ <b-button type="is-primary" icon-pack="fas" icon-left="edit" - @click="initEditEmployeeID()"> + @click="editEmployeeIdInit()"> Edit ID </b-button> <b-modal has-modal-card - :active.sync="showEditEmployeeIDDialog"> + :active.sync="editEmployeeIdShowDialog"> <div class="modal-card"> <header class="modal-card-head"> @@ -876,20 +920,20 @@ <section class="modal-card-body"> <b-field label="Employee ID"> - <b-input v-model="newEmployeeID"></b-input> + <b-input v-model="editEmployeeIdValue"></b-input> </b-field> </section> <footer class="modal-card-foot"> - <b-button @click="showEditEmployeeIDDialog = false"> + <b-button @click="editEmployeeIdShowDialog = false"> Cancel </b-button> <b-button type="is-primary" icon-pack="fas" icon-left="save" - :disabled="updatingEmployeeID" - @click="updateEmployeeID()"> - {{ editEmployeeIDSaveButtonText }} + :disabled="editEmployeeIdSaving" + @click="editEmployeeIdSave()"> + {{ editEmployeeIdSaving ? "Working, please wait..." : "Save" }} </b-button> </footer> </div> @@ -934,7 +978,7 @@ <b-table-column field="actions" label="Actions" v-slot="props"> - <a href="#" @click.prevent="editEmployeeHistory(props.row)"> + <a href="#" @click.prevent="editEmployeeHistoryInit(props.row)"> <i class="fas fa-edit"></i> Edit </a> @@ -964,7 +1008,7 @@ <b-button v-if="employee.current" type="is-primary" - @click="showStopEmployeeDialog = true"> + @click="stopEmployeeInit()"> ${person} is no longer an Employee </b-button> @@ -978,10 +1022,10 @@ <section class="modal-card-body"> <b-field label="Employee Number"> - <b-input v-model="employeeID"></b-input> + <b-input v-model="startEmployeeID"></b-input> </b-field> <b-field label="Start Date"> - <tailbone-datepicker v-model="employeeStartDate"></tailbone-datepicker> + <tailbone-datepicker v-model="startEmployeeStartDate"></tailbone-datepicker> </b-field> </section> @@ -990,8 +1034,8 @@ Cancel </b-button> <once-button type="is-primary" - @click="startEmployee()" - :disabled="!employeeStartDate" + @click="startEmployeeSave()" + :disabled="!startEmployeeStartDate" text="Save"> </once-button> </footer> @@ -999,7 +1043,7 @@ </b-modal> <b-modal has-modal-card - :active.sync="showStopEmployeeDialog"> + :active.sync="stopEmployeeShowDialog"> <div class="modal-card"> <header class="modal-card-head"> @@ -1008,22 +1052,22 @@ <section class="modal-card-body"> <b-field label="End Date" - :type="employeeEndDate ? null : 'is-danger'"> - <tailbone-datepicker v-model="employeeEndDate"></tailbone-datepicker> + :type="stopEmployeeEndDate ? null : 'is-danger'"> + <tailbone-datepicker v-model="stopEmployeeEndDate"></tailbone-datepicker> </b-field> <b-field label="Revoke Internal App Access"> - <b-checkbox v-model="employeeRevokeAccess"> + <b-checkbox v-model="stopEmployeeRevokeAccess"> </b-checkbox> </b-field> </section> <footer class="modal-card-foot"> - <b-button @click="showStopEmployeeDialog = false"> + <b-button @click="stopEmployeeShowDialog = false"> Cancel </b-button> <once-button type="is-primary" - @click="endEmployee()" - :disabled="!employeeEndDate" + @click="stopEmployeeSave()" + :disabled="!stopEmployeeEndDate" text="Save"> </once-button> </footer> @@ -1033,7 +1077,7 @@ % if request.has_perm('people_profile.edit_employee_history'): <b-modal has-modal-card - :active.sync="showEditEmployeeHistoryDialog"> + :active.sync="editEmployeeHistoryShowDialog"> <div class="modal-card"> <header class="modal-card-head"> @@ -1042,22 +1086,22 @@ <section class="modal-card-body"> <b-field label="Start Date"> - <tailbone-datepicker v-model="employeeHistoryStartDate"></tailbone-datepicker> + <tailbone-datepicker v-model="editEmployeeHistoryStartDate"></tailbone-datepicker> </b-field> <b-field label="End Date"> - <tailbone-datepicker v-model="employeeHistoryEndDate" - :disabled="!employeeHistoryEndDateRequired"> + <tailbone-datepicker v-model="editEmployeeHistoryEndDate" + :disabled="!editEmployeeHistoryEndDateRequired"> </tailbone-datepicker> </b-field> </section> <footer class="modal-card-foot"> - <b-button @click="showEditEmployeeHistoryDialog = false"> + <b-button @click="editEmployeeHistoryShowDialog = false"> Cancel </b-button> <once-button type="is-primary" - @click="saveEmployeeHistory()" - :disabled="!employeeHistoryStartDate || (employeeHistoryEndDateRequired && !employeeHistoryEndDate)" + @click="editEmployeeHistorySave()" + :disabled="!editEmployeeHistoryStartDate || (editEmployeeHistoryEndDateRequired && !editEmployeeHistoryEndDate)" text="Save"> </once-button> </footer> @@ -1076,6 +1120,7 @@ </div> </div> + <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading> </div> </script> </%def> @@ -1084,12 +1129,10 @@ <b-tab-item label="Employee" value="employee" icon-pack="fas" - :icon="employee.current ? 'check' : null"> - <employee-tab :employee="employee" - :employee-history="employeeHistory" - @employee-updated="employeeUpdated" - @employee-history-updated="employeeHistoryUpdated" - @change-content-title="changeContentTitle"> + :icon="tabchecks.employee ? 'check' : null"> + <employee-tab ref="tab_employee" + :person="person" + @profile-changed="profileChanged"> </employee-tab> </b-tab-item> </%def> @@ -1101,7 +1144,7 @@ % if request.has_perm('people_profile.add_note'): <b-button type="is-primary" class="control" - @click="noteNew()" + @click="addNoteInit()" icon-pack="fas" icon-left="plus"> Add Note @@ -1144,13 +1187,13 @@ <b-table-column label="Actions" v-slot="props"> % if request.has_perm('people_profile.edit_note'): - <a href="#" @click.prevent="noteEdit(props.row)"> + <a href="#" @click.prevent="editNoteInit(props.row)"> <i class="fas fa-edit"></i> Edit </a> % endif % if request.has_perm('people_profile.delete_note'): - <a href="#" @click.prevent="noteDelete(props.row)" + <a href="#" @click.prevent="deleteNoteInit(props.row)" class="has-text-danger"> <i class="fas fa-trash"></i> Delete @@ -1161,68 +1204,71 @@ </b-table> - <b-modal :active.sync="noteShowDialog" - has-modal-card> + % if request.has_any_perm('people_profile.add_note', 'people_profile.edit_note', 'people_profile.delete_note'): + <b-modal :active.sync="editNoteShowDialog" + has-modal-card> - <div class="modal-card"> + <div class="modal-card"> - <header class="modal-card-head"> - <p class="modal-card-title"> - {{ noteDialogTitle }} - </p> - </header> + <header class="modal-card-head"> + <p class="modal-card-title"> + {{ editNoteUUID ? (editNoteDelete ? "Delete" : "Edit") : "New" }} Note + </p> + </header> - <section class="modal-card-body"> + <section class="modal-card-body"> - <b-field label="Type" - :type="!noteDeleting && !noteType ? 'is-danger' : null"> - <b-select v-model="noteType" - :disabled="noteUUID"> - <option v-for="option in noteTypeOptions" - :key="option.value" - :value="option.value"> - {{ option.label }} - </option> - </b-select> - </b-field> + <b-field label="Type" + :type="!editNoteDelete && !editNoteType ? 'is-danger' : null"> + <b-select v-model="editNoteType" + :disabled="editNoteUUID"> + <option v-for="option in noteTypeOptions" + :key="option.value" + :value="option.value"> + {{ option.label }} + </option> + </b-select> + </b-field> - <b-field label="Subject"> - <b-input v-model.trim="noteSubject" - :disabled="noteDeleting"> - </b-input> - </b-field> + <b-field label="Subject"> + <b-input v-model.trim="editNoteSubject" + :disabled="editNoteDelete"> + </b-input> + </b-field> - <b-field label="Text"> - <b-input v-model.trim="noteText" - type="textarea" - :disabled="noteDeleting"> - </b-input> - </b-field> + <b-field label="Text"> + <b-input v-model.trim="editNoteText" + type="textarea" + :disabled="editNoteDelete"> + </b-input> + </b-field> - <b-notification v-if="noteDeleting" - type="is-danger" - :closable="false"> - Are you sure you wish to delete this note? - </b-notification> + <b-notification v-if="editNoteDelete" + type="is-danger" + :closable="false"> + Are you sure you wish to delete this note? + </b-notification> - </section> + </section> - <footer class="modal-card-foot"> - <b-button :type="noteDeleting ? 'is-danger' : 'is-primary'" - @click="noteSave()" - :disabled="noteSaving || (!noteDeleting && !noteType)" - icon-pack="fas" - icon-left="save"> - {{ noteSaving ? "Working, please wait..." : noteSaveText }} - </b-button> - <b-button @click="noteShowDialog = false"> - Cancel - </b-button> - </footer> + <footer class="modal-card-foot"> + <b-button :type="editNoteDelete ? 'is-danger' : 'is-primary'" + @click="editNoteSave()" + :disabled="editNoteSaving || (!editNoteDelete && !editNoteType)" + icon-pack="fas" + icon-left="save"> + {{ editNoteSaving ? "Working..." : (editNoteDelete ? "Delete" : "Save") }} + </b-button> + <b-button @click="editNoteShowDialog = false"> + Cancel + </b-button> + </footer> - </div> - </b-modal> + </div> + </b-modal> + % endif + <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading> </div> </script> </%def> @@ -1231,70 +1277,79 @@ <b-tab-item label="Notes" value="notes" icon-pack="fas" - :icon="notes.length ? 'check' : null"> - - <notes-tab :notes="notes" - :note-type-options="noteTypeOptions" - @new-notes-data="newNotesData"> + :icon="tabchecks.notes ? 'check' : null"> + <notes-tab ref="tab_notes" + :person="person" + @profile-changed="profileChanged"> </notes-tab> - </b-tab-item> </%def> +<%def name="render_user_tab_template()"> + <script type="text/x-template" id="user-tab-template"> + <div> + <div v-if="users.length"> + + <p>{{ person.display_name }} has <strong>{{ users.length }}</strong> user account{{ users.length == 1 ? '' : 's' }}</p> + <br /> + <div id="users-accordion"> + + <b-collapse class="panel" + v-for="user in users" + :key="user.uuid"> + + <div slot="trigger" + class="panel-heading" + role="button"> + <strong>{{ user.username }}</strong> + </div> + + <div class="panel-block"> + <div style="display: flex; justify-content: space-between; width: 100%;"> + + <div> + <div class="field-wrapper id"> + <div class="field-row"> + <label>Username</label> + <div class="field"> + {{ user.username }} + </div> + </div> + </div> + </div> + + <div> + % if request.has_perm('users.view'): + <b-button tag="a" :href="user.view_url"> + View User + </b-button> + % endif + </div> + + </div> + </div> + </b-collapse> + </div> + </div> + + <div v-if="!users.length"> + <p>{{ person.display_name }} does not have a user account.</p> + </div> + <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading> + </div> + </script> +</%def> + <%def name="render_user_tab()"> <b-tab-item label="User" value="user" icon-pack="fas" - :icon="users.length ? 'check' : null"> - - <div v-if="users.length"> - - <p>{{ person.display_name }} has <strong>{{ users.length }}</strong> user account{{ users.length == 1 ? '' : 's' }}</p> - <br /> - <div id="users-accordion"> - - <b-collapse class="panel" - v-for="user in users" - :key="user.uuid"> - - <div slot="trigger" - class="panel-heading" - role="button"> - <strong>{{ user.username }}</strong> - </div> - - <div class="panel-block"> - <div style="display: flex; justify-content: space-between; width: 100%;"> - - <div> - <div class="field-wrapper id"> - <div class="field-row"> - <label>Username</label> - <div class="field"> - {{ user.username }} - </div> - </div> - </div> - </div> - - <div> - % if request.has_perm('users.view'): - <b-button tag="a" :href="user.view_url"> - View User - </b-button> - % endif - </div> - - </div> - </div> - </b-collapse> - </div> - </div> - - <div v-if="!users.length"> - <p>{{ person.display_name }} does not have a user account.</p> - </div> - </b-tab-item><!-- User --> + :icon="tabchecks.user ? 'check' : null"> + <user-tab ref="tab_user" + :person="person" + @profile-changed="profileChanged"> + </user-tab> + </b-tab-item> </%def> <%def name="render_profile_tabs()"> @@ -1302,7 +1357,7 @@ ${self.render_member_tab()} ${self.render_customer_tab()} % if expose_customer_shoppers: - ${self.render_shopper_tab()} + ${self.render_shopper_tab()} % endif ${self.render_employee_tab()} ${self.render_notes_tab()} @@ -1422,8 +1477,14 @@ <%def name="render_this_page_template()"> ${parent.render_this_page_template()} ${self.render_personal_tab_template()} + ${self.render_member_tab_template()} + ${self.render_customer_tab_template()} + % if expose_customer_shoppers: + ${self.render_shopper_tab_template()} + % endif ${self.render_employee_tab_template()} ${self.render_notes_tab_template()} + ${self.render_user_tab_template()} ${self.render_profile_info_template()} </%def> @@ -1431,127 +1492,95 @@ <script type="text/javascript"> let PersonalTabData = { + refreshTabURL: '${url('people.profile_tab_personal', uuid=person.uuid)}', - editNameShowDialog: false, - personFirstName: null, - personMiddleName: null, - personLastName: null, + % if request.has_perm('people_profile.edit_person'): + editNameShowDialog: false, + editNameFirst: null, + editNameMiddle: null, + editNameLast: null, - editAddressShowDialog: false, - personStreet1: null, - personStreet2: null, - personCity: null, - personState: null, - personZipcode: null, - personInvalidAddress: false, + editAddressShowDialog: false, + editAddressStreet1: null, + editAddressStreet2: null, + editAddressCity: null, + editAddressState: null, + editAddressZipcode: null, + editAddressInvalid: false, - editPhoneShowDialog: false, - phoneUUID: null, - phoneType: null, - phoneNumber: null, - phonePreferred: false, - savingPhone: false, + editPhoneShowDialog: false, + editPhoneUUID: null, + editPhoneType: null, + editPhoneNumber: null, + editPhonePreferred: false, + editPhoneSaving: false, - editEmailShowDialog: false, - emailUUID: null, - emailType: null, - emailAddress: null, - emailPreferred: null, - emailInvalid: false, - editEmailSaving: false, + editEmailShowDialog: false, + editEmailUUID: null, + editEmailType: null, + editEmailAddress: null, + editEmailPreferred: null, + editEmailInvalid: false, + editEmailSaving: false, + % endif } let PersonalTab = { template: '#personal-tab-template', - mixins: [SubmitMixin], + mixins: [TabMixin, SimpleRequestMixin], props: { person: Object, - member: Object, phoneTypeOptions: Array, emailTypeOptions: Array, maxLengths: Object, }, computed: { - % if request.has_perm('people_profile.edit_person'): - editNameSaveDisabled: function() { - // first and last name are required - if (!this.personFirstName || !this.personLastName) { + % if request.has_perm('people_profile.edit_person'): + + editNameSaveDisabled: function() { + if (!this.editNameFirst || !this.editNameLast) { return true } - - // otherwise don't disable; let user save return false }, editAddressSaveDisabled: function() { - // TODO: should require anything here? - - // otherwise don't disable; let user save return false }, - editPhoneSaveText() { - if (this.savingPhone) { - return "Working..." - } - return "Save" - }, - editPhoneSaveDisabled: function() { - if (this.savingPhone) { + if (this.editPhoneSaving) { return true } - - // phone type is required - if (!this.phoneType) { + if (!this.editPhoneType) { return true } - - // phone number is required - if (!this.phoneNumber) { + if (!this.editPhoneNumber) { return true } - - // otherwise don't disable; let user save return false }, - editEmailSaveText() { - if (this.editEmailSaving) { - return "Working, please wait..." - } - return "Save" - }, - editEmailSaveDisabled: function() { - - // disable if currently submitting form if (this.editEmailSaving) { return true } - - // email type is required - if (!this.emailType) { + if (!this.editEmailType) { return true } - - // email address is required - if (!this.emailAddress) { + if (!this.editEmailAddress) { return true } - - // otherwise don't disable; let user save return false }, + % endif }, methods: { - changeContentTitle(newTitle) { - this.$emit('change-content-title', newTitle) - }, + // refreshTabSuccess(response) {}, % if request.has_perm('people_profile.edit_person'): @@ -1560,59 +1589,53 @@ }, editNameInit() { - this.personFirstName = this.person.first_name - this.personMiddleName = this.person.middle_name - this.personLastName = this.person.last_name + this.editNameFirst = this.person.first_name + this.editNameMiddle = this.person.middle_name + this.editNameLast = this.person.last_name this.editNameShowDialog = true }, editNameSave() { let url = '${url('people.profile_edit_name', uuid=person.uuid)}' - let params = { - first_name: this.personFirstName, - middle_name: this.personMiddleName, - last_name: this.personLastName, + first_name: this.editNameFirst, + middle_name: this.editNameMiddle, + last_name: this.editNameLast, } - let that = this - this.submitData(url, params, function(response) { - that.$emit('person-updated', response.data.person) - that.editNameShowDialog = false - // TODO: not sure this is standard upstream, or just in bespoke? - if (response.data.dynamic_content_title) { - that.$emit('change-content-title', response.data.dynamic_content_title) - } + this.simplePOST(url, params, response => { + this.$emit('profile-changed', response.data) + this.editNameShowDialog = false + this.refreshTab() }) }, editAddressInit() { let address = this.person.address - this.personStreet1 = address ? address.street : null - this.personStreet2 = address ? address.street2 : null - this.personCity = address ? address.city : null - this.personState = address ? address.state : null - this.personZipcode = address ? address.zipcode : null - this.personInvalidAddress = address ? address.invalid : false + this.editAddressStreet1 = address ? address.street : null + this.editAddressStreet2 = address ? address.street2 : null + this.editAddressCity = address ? address.city : null + this.editAddressState = address ? address.state : null + this.editAddressZipcode = address ? address.zipcode : null + this.editAddressInvalid = address ? address.invalid : false this.editAddressShowDialog = true }, editAddressSave() { let url = '${url('people.profile_edit_address', uuid=person.uuid)}' - let params = { - street: this.personStreet1, - street2: this.personStreet2, - city: this.personCity, - state: this.personState, - zipcode: this.personZipcode, - invalid: this.personInvalidAddress, + street: this.editAddressStreet1, + street2: this.editAddressStreet2, + city: this.editAddressCity, + state: this.editAddressState, + zipcode: this.editAddressZipcode, + invalid: this.editAddressInvalid, } - let that = this - this.submitData(url, params, function(response) { - that.$emit('person-updated', response.data.person) - that.editAddressShowDialog = false + this.simplePOST(url, params, response => { + this.$emit('profile-changed', response.data) + this.editAddressShowDialog = false + this.refreshTab() }) }, @@ -1626,10 +1649,10 @@ }, editPhoneInit(phone) { - this.phoneUUID = phone.uuid - this.phoneType = phone.type - this.phoneNumber = phone.number - this.phonePreferred = phone.preferred + this.editPhoneUUID = phone.uuid + this.editPhoneType = phone.type + this.editPhoneNumber = phone.number + this.editPhonePreferred = phone.preferred this.editPhoneShowDialog = true this.$nextTick(function() { this.$refs.editPhoneInput.focus() @@ -1637,63 +1660,54 @@ }, editPhoneSave() { - this.savingPhone = true + this.editPhoneSaving = true let url let params = { - phone_number: this.phoneNumber, - phone_type: this.phoneType, - phone_preferred: this.phonePreferred, + phone_number: this.editPhoneNumber, + phone_type: this.editPhoneType, + phone_preferred: this.editPhonePreferred, } - if (this.phoneUUID) { + // nb. create or update + if (this.editPhoneUUID) { url = '${url('people.profile_update_phone', uuid=person.uuid)}' - params.phone_uuid = this.phoneUUID + params.phone_uuid = this.editPhoneUUID } else { url = '${url('people.profile_add_phone', uuid=person.uuid)}' } - let that = this - this.submitData(url, params, function(response) { - that.$emit('person-updated', response.data.person) - that.editPhoneShowDialog = false - that.savingPhone = false + this.simplePOST(url, params, response => { + this.$emit('profile-changed', response.data) + this.editPhoneShowDialog = false + this.editPhoneSaving = false + this.refreshTab() + }, response => { + this.editPhoneSaving = false }) }, - deletePhone(phone) { + deletePhoneInit(phone) { let url = '${url('people.profile_delete_phone', uuid=person.uuid)}' - let params = { phone_uuid: phone.uuid, } - let that = this - this.submitData(url, params, function(response) { - that.$emit('person-updated', response.data.person) - that.$buefy.toast.open({ - message: "Phone number was deleted.", - type: 'is-info', - duration: 3000, // 3 seconds - }) + this.simplePOST(url, params, response => { + this.$emit('profile-changed', response.data) + this.refreshTab() }) }, - setPreferredPhone(phone) { + preferPhoneInit(phone) { let url = '${url('people.profile_set_preferred_phone', uuid=person.uuid)}' - let params = { phone_uuid: phone.uuid, } - let that = this - this.submitData(url, params, function(response) { - that.$emit('person-updated', response.data.person) - that.$buefy.toast.open({ - message: "Phone preference updated!", - type: 'is-info', - duration: 3000, // 3 seconds - }) + this.simplePOST(url, params, response => { + this.$emit('profile-changed', response.data) + this.refreshTab() }) }, @@ -1708,11 +1722,11 @@ }, editEmailInit(email) { - this.emailUUID = email.uuid - this.emailType = email.type - this.emailAddress = email.address - this.emailInvalid = email.invalid - this.emailPreferred = email.preferred + this.editEmailUUID = email.uuid + this.editEmailType = email.type + this.editEmailAddress = email.address + this.editEmailInvalid = email.invalid + this.editEmailPreferred = email.preferred this.editEmailShowDialog = true this.$nextTick(function() { this.$refs.editEmailInput.focus() @@ -1724,62 +1738,50 @@ let url = null let params = { - email_address: this.emailAddress, - email_type: this.emailType, + email_address: this.editEmailAddress, + email_type: this.editEmailType, } - if (this.emailUUID) { + if (this.editEmailUUID) { url = '${url('people.profile_update_email', uuid=person.uuid)}' - params.email_uuid = this.emailUUID - params.email_invalid = this.emailInvalid + params.email_uuid = this.editEmailUUID + params.email_invalid = this.editEmailInvalid } else { url = '${url('people.profile_add_email', uuid=person.uuid)}' - params.email_preferred = this.emailPreferred + params.email_preferred = this.editEmailPreferred } - let that = this - this.submitData(url, params, function(response) { - that.$emit('person-updated', response.data.person) - that.editEmailShowDialog = false - that.editEmailSaving = false - }, function(error) { - that.editEmailSaving = false + this.simplePOST(url, params, response => { + this.$emit('profile-changed', response.data) + this.editEmailShowDialog = false + this.editEmailSaving = false + this.refreshTab() + }, response => { + this.editEmailSaving = false }) }, - deleteEmail(email) { + deleteEmailInit(email) { let url = '${url('people.profile_delete_email', uuid=person.uuid)}' - let params = { email_uuid: email.uuid, } - let that = this - this.submitData(url, params, function(response) { - that.$emit('person-updated', response.data.person) - that.$buefy.toast.open({ - message: "Email address was deleted.", - type: 'is-info', - duration: 3000, // 3 seconds - }) + this.simplePOST(url, params, response => { + this.$emit('profile-changed', response.data) + this.refreshTab() }) }, - setPreferredEmail(email) { + preferEmailInit(email) { let url = '${url('people.profile_set_preferred_email', uuid=person.uuid)}' - let params = { email_uuid: email.uuid, } - let that = this - this.submitData(url, params, function(response) { - that.$emit('person-updated', response.data.person) - that.$buefy.toast.open({ - message: "Email preference updated!", - type: 'is-info', - duration: 3000, // 3 seconds - }) + this.simplePOST(url, params, response => { + this.$emit('profile-changed', response.data) + this.refreshTab() }) }, @@ -1800,80 +1802,190 @@ </script> </%def> +<%def name="declare_member_tab_vars()"> + <script type="text/javascript"> + + let MemberTabData = { + refreshTabURL: '${url('people.profile_tab_member', uuid=person.uuid)}', + % if max_one_member: + member: {}, + % else: + members: [], + % endif + } + + let MemberTab = { + template: '#member-tab-template', + mixins: [TabMixin, SimpleRequestMixin], + props: { + person: Object, + phoneTypeOptions: Array, + }, + computed: {}, + methods: { + + refreshTabSuccess(response) { + % if max_one_member: + this.member = response.data.member + % else: + this.members = response.data.members + % endif + }, + }, + } + + </script> +</%def> + +<%def name="make_member_tab_component()"> + ${self.declare_member_tab_vars()} + <script type="text/javascript"> + + MemberTab.data = function() { return MemberTabData } + Vue.component('member-tab', MemberTab) + + </script> +</%def> + +<%def name="declare_customer_tab_vars()"> + <script type="text/javascript"> + + let CustomerTabData = { + refreshTabURL: '${url('people.profile_tab_customer', uuid=person.uuid)}', + customers: [], + } + + let CustomerTab = { + template: '#customer-tab-template', + mixins: [TabMixin, SimpleRequestMixin], + props: { + person: Object, + }, + computed: {}, + methods: { + + refreshTabSuccess(response) { + this.customers = response.data.customers + }, + }, + } + + </script> +</%def> + +<%def name="make_customer_tab_component()"> + ${self.declare_customer_tab_vars()} + <script type="text/javascript"> + + CustomerTab.data = function() { return CustomerTabData } + Vue.component('customer-tab', CustomerTab) + + </script> +</%def> + +<%def name="declare_shopper_tab_vars()"> + <script type="text/javascript"> + + let ShopperTabData = { + refreshTabURL: '${url('people.profile_tab_shopper', uuid=person.uuid)}', + shoppers: [], + } + + let ShopperTab = { + template: '#shopper-tab-template', + mixins: [TabMixin, SimpleRequestMixin], + props: { + person: Object, + }, + computed: {}, + methods: { + + refreshTabSuccess(response) { + this.shoppers = response.data.shoppers + }, + }, + } + + </script> +</%def> + +<%def name="make_shopper_tab_component()"> + ${self.declare_shopper_tab_vars()} + <script type="text/javascript"> + + ShopperTab.data = function() { return ShopperTabData } + Vue.component('shopper-tab', ShopperTab) + + </script> +</%def> + <%def name="declare_employee_tab_vars()"> <script type="text/javascript"> let EmployeeTabData = { - - startEmployeeShowDialog: false, - employeeID: null, - employeeStartDate: null, - showStopEmployeeDialog: false, - employeeEndDate: null, - employeeRevokeAccess: false, - showEditEmployeeHistoryDialog: false, - employeeHistoryUUID: null, - employeeHistoryStartDate: null, - employeeHistoryEndDate: null, - employeeHistoryEndDateRequired: false, + refreshTabURL: '${url('people.profile_tab_employee', uuid=person.uuid)}', + employee: {}, + employeeHistory: [], % if request.has_perm('employees.edit'): - showEditEmployeeIDDialog: false, - newEmployeeID: null, - updatingEmployeeID: false, + editEmployeeIdShowDialog: false, + editEmployeeIdValue: null, + editEmployeeIdSaving: false, + % endif + + % if request.has_perm('people_profile.toggle_employee'): + startEmployeeShowDialog: false, + startEmployeeID: null, + startEmployeeStartDate: null, + + stopEmployeeShowDialog: false, + stopEmployeeEndDate: null, + stopEmployeeRevokeAccess: false, + % endif + + % if request.has_perm('people_profile.edit_employee_history'): + editEmployeeHistoryShowDialog: false, + editEmployeeHistoryUUID: null, + editEmployeeHistoryStartDate: null, + editEmployeeHistoryEndDate: null, + editEmployeeHistoryEndDateRequired: false, % endif } let EmployeeTab = { template: '#employee-tab-template', - mixins: [SubmitMixin], + mixins: [TabMixin, SimpleRequestMixin], props: { - employee: Object, - employeeHistory: Array, + person: Object, }, - - computed: { - - % if request.has_perm('employees.edit'): - - editEmployeeIDSaveButtonText() { - if (this.updatingEmployeeID) { - return "Working, please wait..." - } - return "Save" - }, - - % endif - }, - + computed: {}, methods: { - changeContentTitle(newTitle) { - this.$emit('change-content-title', newTitle) + refreshTabSuccess(response) { + this.employee = response.data.employee + this.employeeHistory = response.data.employee_history }, % if request.has_perm('employees.edit'): - initEditEmployeeID() { - this.newEmployeeID = this.employee.id - this.updatingEmployeeID = false - this.showEditEmployeeIDDialog = true + editEmployeeIdInit() { + this.editEmployeeIdValue = this.employee.id + this.editEmployeeIdShowDialog = true }, - updateEmployeeID() { - this.updatingEmployeeID = true - + editEmployeeIdSave() { + this.editEmployeeIdSaving = true let url = '${url('people.profile_update_employee_id', uuid=instance.uuid)}' - let params = { - 'employee_id': this.newEmployeeID, + 'employee_id': this.editEmployeeIdValue, } - - let that = this - this.submitData(url, params, function(response) { - that.$emit('employee-updated', response.data.employee) - that.showEditEmployeeIDDialog = false - that.updatingEmployeeID = false + this.simplePOST(url, params, response => { + this.$emit('profile-changed', response.data) + this.editEmployeeIdShowDialog = false + this.editEmployeeIdSaving = false + this.refreshTab() + }, response => { + this.editEmployeeIdSaving = false }) }, @@ -1882,90 +1994,67 @@ % if request.has_perm('people_profile.toggle_employee'): startEmployeeInit() { - this.employeeID = this.employee.id || null + this.startEmployeeID = this.employee.id || null + this.startEmployeeStartDate = null this.startEmployeeShowDialog = true }, - startEmployee() { + startEmployeeSave() { let url = '${url('people.profile_start_employee', uuid=person.uuid)}' - let params = { - id: this.employeeID, - start_date: this.employeeStartDate, + id: this.startEmployeeID, + start_date: this.startEmployeeStartDate, } - let that = this - this.submitData(url, params, function(response) { - that.startEmployeeSuccess(response.data) + this.simplePOST(url, params, response => { + this.$emit('profile-changed', response.data) + this.startEmployeeShowDialog = false + this.refreshTab() }) }, - startEmployeeSuccess(data) { - this.$emit('employee-updated', data.employee) - this.$emit('employee-history-updated', data.employee_history_data) - this.$emit('change-content-title', data.dynamic_content_title) - - // let derived component do more here if needed - this.startEmployeeSuccessExtra(data) - - this.startEmployeeShowDialog = false + stopEmployeeInit() { + this.stopEmployeeShowDialog = true }, - startEmployeeSuccessExtra(data) {}, - - endEmployee() { + stopEmployeeSave() { let url = '${url('people.profile_end_employee', uuid=person.uuid)}' - let params = { - end_date: this.employeeEndDate, - revoke_access: this.employeeRevokeAccess, + end_date: this.stopEmployeeEndDate, + revoke_access: this.stopEmployeeRevokeAccess, } - let that = this - this.submitData(url, params, function(response) { - that.endEmployeeSuccess(response.data) + this.simplePOST(url, params, response => { + this.$emit('profile-changed', response.data) + this.stopEmployeeShowDialog = false + this.refreshTab() }) }, - endEmployeeSuccess(data) { - this.$emit('employee-updated', data.employee) - this.$emit('employee-history-updated', data.employee_history_data) - this.$emit('change-content-title', data.dynamic_content_title) - - // let derived component do more here if needed - this.startEmployeeSuccessExtra(data) - - this.showStopEmployeeDialog = false - }, - - endEmployeeSuccessExtra(data) {}, - % endif % if request.has_perm('people_profile.edit_employee_history'): - editEmployeeHistory(row) { - this.employeeHistoryUUID = row.uuid - this.employeeHistoryStartDate = row.start_date - this.employeeHistoryEndDate = row.end_date - this.employeeHistoryEndDateRequired = !!row.end_date - this.showEditEmployeeHistoryDialog = true + editEmployeeHistoryInit(row) { + this.editEmployeeHistoryUUID = row.uuid + this.editEmployeeHistoryStartDate = row.start_date + this.editEmployeeHistoryEndDate = row.end_date + this.editEmployeeHistoryEndDateRequired = !!row.end_date + this.editEmployeeHistoryShowDialog = true }, - saveEmployeeHistory() { + editEmployeeHistorySave() { let url = '${url('people.profile_edit_employee_history', uuid=person.uuid)}' - let params = { - uuid: this.employeeHistoryUUID, - start_date: this.employeeHistoryStartDate, - end_date: this.employeeHistoryEndDate, + uuid: this.editEmployeeHistoryUUID, + start_date: this.editEmployeeHistoryStartDate, + end_date: this.editEmployeeHistoryEndDate, } - let that = this - this.submitData(url, params, function(response) { - that.$emit('employee-updated', response.data.employee) - that.$emit('employee-history-updated', response.data.employee_history_data) - that.showEditEmployeeHistoryDialog = false + this.simplePOST(url, params, response => { + this.$emit('profile-changed', response.data) + this.editEmployeeHistoryShowDialog = false + this.refreshTab() }) }, @@ -1990,112 +2079,102 @@ <script type="text/javascript"> let NotesTabData = { - noteShowDialog: false, - noteUUID: null, - noteType: null, - noteSubject: null, - noteText: null, - noteDeleting: false, - noteSaving: false, + refreshTabURL: '${url('people.profile_tab_notes', uuid=person.uuid)}', + notes: [], + noteTypeOptions: [], + + % if request.has_any_perm('people_profile.add_note', 'people_profile.edit_note', 'people_profile.delete_note'): + editNoteShowDialog: false, + editNoteUUID: null, + editNoteDelete: false, + editNoteType: null, + editNoteSubject: null, + editNoteText: null, + editNoteSaving: false, + % endif } let NotesTab = { template: '#notes-tab-template', - mixins: [SimpleRequestMixin], + mixins: [TabMixin, SimpleRequestMixin], props: { - notes: Array, - noteTypeOptions: Array, + person: Object, }, - - computed: { - - noteDialogTitle() { - if (this.noteUUID) { - if (this.noteDeleting) { - return "Delete Note" - } - return "Edit Note" - } - return "New Note" - }, - - noteSaveText() { - if (this.noteDeleting) { - return "Delete Note" - } - return "Save Note" - }, - - }, - + computed: {}, methods: { + refreshTabSuccess(response) { + this.notes = response.data.notes + this.noteTypeOptions = response.data.note_types + }, + % if request.has_perm('people_profile.add_note'): - noteNew() { - this.noteUUID = null - this.noteType = null - this.noteSubject = null - this.noteText = null - this.noteDeleting = false - this.noteShowDialog = true + addNoteInit() { + this.editNoteUUID = null + this.editNoteType = null + this.editNoteSubject = null + this.editNoteText = null + this.editNoteDelete = false + this.editNoteShowDialog = true }, % endif % if request.has_perm('people_profile.edit_note'): - noteEdit(note) { - this.noteUUID = note.uuid - this.noteType = note.note_type - this.noteSubject = note.subject - this.noteText = note.text - this.noteDeleting = false - this.noteShowDialog = true + editNoteInit(note) { + this.editNoteUUID = note.uuid + this.editNoteType = note.note_type + this.editNoteSubject = note.subject + this.editNoteText = note.text + this.editNoteDelete = false + this.editNoteShowDialog = true }, % endif % if request.has_perm('people_profile.delete_note'): - noteDelete(note) { - this.noteUUID = note.uuid - this.noteType = note.note_type - this.noteSubject = note.subject - this.noteText = note.text - this.noteDeleting = true - this.noteShowDialog = true + deleteNoteInit(note) { + this.editNoteUUID = note.uuid + this.editNoteType = note.note_type + this.editNoteSubject = note.subject + this.editNoteText = note.text + this.editNoteDelete = true + this.editNoteShowDialog = true }, % endif % if request.has_any_perm('people_profile.add_note', 'people_profile.edit_note', 'people_profile.delete_note'): - noteSave() { - this.noteSaving = true + editNoteSave() { + this.editNoteSaving = true let url = null - if (!this.noteUUID) { + if (!this.editNoteUUID) { url = '${master.get_action_url('profile_add_note', instance)}' - } else if (this.noteDeleting) { + } else if (this.editNoteDelete) { url = '${master.get_action_url('profile_delete_note', instance)}' } else { url = '${master.get_action_url('profile_edit_note', instance)}' } let params = { - uuid: this.noteUUID, - note_type: this.noteType, - note_subject: this.noteSubject, - note_text: this.noteText, + uuid: this.editNoteUUID, + note_type: this.editNoteType, + note_subject: this.editNoteSubject, + note_text: this.editNoteText, } this.simplePOST(url, params, response => { - this.$emit('new-notes-data', response.data.notes) - this.noteSaving = false - this.noteShowDialog = false + this.$emit('profile-changed', response.data) + this.editNoteSaving = false + this.editNoteShowDialog = false + this.refreshTab() }, response => { - this.notesSaving = false + this.editNoteSaving = false }) }, @@ -2116,76 +2195,106 @@ </script> </%def> +<%def name="declare_user_tab_vars()"> + <script type="text/javascript"> + + let UserTabData = { + refreshTabURL: '${url('people.profile_tab_user', uuid=person.uuid)}', + users: [], + } + + let UserTab = { + template: '#user-tab-template', + mixins: [TabMixin, SimpleRequestMixin], + props: { + person: Object, + }, + computed: {}, + methods: { + + refreshTabSuccess(response) { + this.users = response.data.users + }, + }, + } + + </script> +</%def> + +<%def name="make_user_tab_component()"> + ${self.declare_user_tab_vars()} + <script type="text/javascript"> + + UserTab.data = function() { return UserTabData } + Vue.component('user-tab', UserTab) + + </script> +</%def> + <%def name="declare_profile_info_vars()"> <script type="text/javascript"> let ProfileInfoData = { activeTab: location.hash ? location.hash.substring(1) : undefined, + tabchecks: ${json.dumps(tabchecks)|n}, + today: '${rattail_app.today()}', + profileLastChanged: Date.now(), person: ${json.dumps(person_data)|n}, - customers: ${json.dumps(customers_data)|n}, - % if expose_customer_shoppers: - shoppers: ${json.dumps(shoppers_data)|n}, - % endif - member: null, // TODO - members: ${json.dumps(members_data)|n}, - employee: ${json.dumps(employee_data)|n}, - employeeHistory: ${json.dumps(employee_history_data)|n}, - notes: ${json.dumps(notes_data)|n}, - noteTypeOptions: ${json.dumps(note_type_options)|n}, - users: ${json.dumps(users_data)|n}, phoneTypeOptions: ${json.dumps(phone_type_options)|n}, emailTypeOptions: ${json.dumps(email_type_options)|n}, maxLengths: ${json.dumps(max_lengths)|n}, % if request.has_perm('people_profile.view_versions'): - loadingRevisions: false, - showingRevisionDialog: false, - revision: {}, - revisionShowAllFields: false, + loadingRevisions: false, + showingRevisionDialog: false, + revision: {}, + revisionShowAllFields: false, % endif } let ProfileInfo = { template: '#profile-info-template', - mixins: [FormPosterMixin], - - % if request.has_perm('people_profile.view_versions'): props: { - viewingHistory: Boolean, - gettingRevisions: Boolean, - revisions: Array, - revisionVersionMap: null, + % if request.has_perm('people_profile.view_versions'): + viewingHistory: Boolean, + gettingRevisions: Boolean, + revisions: Array, + revisionVersionMap: null, + % endif }, - % endif - computed: {}, + mounted() { + + // auto-refresh whichever tab is shown first + ## TODO: how to not assume 'personal' is the default tab? + let tab = this.$refs['tab_' + (this.activeTab || 'personal')] + if (tab && tab.refreshTab) { + tab.refreshTab() + } + }, methods: { - newNotesData(notes) { - this.notes = notes - }, - - personUpdated(person) { - this.person = person - }, - - employeeUpdated(employee) { - this.employee = employee - }, - - employeeHistoryUpdated(employeeHistory) { - this.employeeHistory = employeeHistory - }, - - changeContentTitle(newTitle) { - this.$emit('change-content-title', newTitle) + profileChanged(data) { + this.$emit('change-content-title', data.person.dynamic_content_title) + this.person = data.person + this.tabchecks = data.tabchecks + this.profileLastChanged = Date.now() }, activeTabChanged(value) { location.hash = value + this.refreshTabIfNeeded(value) this.activeTabChangedExtra(value) }, + refreshTabIfNeeded(key) { + // TODO: this is *always* refreshing, should be more selective (?) + let tab = this.$refs['tab_' + key] + if (tab && tab.refreshIfNeeded) { + tab.refreshIfNeeded(this.profileLastChanged) + } + }, + activeTabChangedExtra(value) {}, % if request.has_perm('people_profile.view_versions'): @@ -2221,7 +2330,6 @@ <script type="text/javascript"> ProfileInfo.data = function() { return ProfileInfoData } - Vue.component('profile-info', ProfileInfo) </script> @@ -2232,54 +2340,48 @@ <script type="text/javascript"> % if request.has_perm('people_profile.view_versions'): - ThisPage.props.viewingHistory = Boolean - ThisPage.props.gettingRevisions = Boolean - ThisPage.props.revisions = Array - ThisPage.props.revisionVersionMap = null + ThisPage.props.viewingHistory = Boolean + ThisPage.props.gettingRevisions = Boolean + ThisPage.props.revisions = Array + ThisPage.props.revisionVersionMap = null % endif - ThisPage.methods.changeContentTitle = function(newTitle) { - this.$emit('change-content-title', newTitle) - } + let TabMixin = { - var SubmitMixin = { data() { return { - csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}, + refreshed: null, + refreshTabURL: null, + refreshingTab: false, } }, - methods: { - submitData(url, params, success, failure) { - let headers = { - 'X-CSRF-TOKEN': this.csrftoken, + + refreshIfNeeded(time) { + if (this.refreshed && time && this.refreshed > time) { + return } - this.$http.post(url, params, {headers: headers}).then((response) => { - if (response.data.success) { - if (success) { - success(response) - } - } else { - this.$buefy.toast.open({ - message: "Save failed: " + (response.data.error || "(unknown error)"), - type: 'is-danger', - duration: 4000, // 4 seconds - }) - if (failure) { - failure() - } - } - }).catch((error) => { - this.$buefy.toast.open({ - message: "Save failed: (unknown error)", - type: 'is-danger', - duration: 4000, // 4 seconds - }) - if (failure) { - failure() - } - }) + this.refreshTab() }, + + refreshTab() { + + if (this.refreshTabURL) { + this.refreshingTab = true + this.simpleGET(this.refreshTabURL, {}, response => { + this.refreshTabSuccess(response) + this.refreshTabSuccessExtra(response) + this.refreshed = Date.now() + this.refreshingTab = false + }) + } + }, + + // nb. subclass must define this as needed + refreshTabSuccess(response) {}, + + // nb. subclass may define this if needed + refreshTabSuccessExtra(response) {}, }, } @@ -2289,8 +2391,14 @@ <%def name="make_this_page_component()"> ${parent.make_this_page_component()} ${self.make_personal_tab_component()} + ${self.make_member_tab_component()} + ${self.make_customer_tab_component()} + % if expose_customer_shoppers: + ${self.make_shopper_tab_component()} + % endif ${self.make_employee_tab_component()} ${self.make_notes_tab_component()} + ${self.make_user_tab_component()} ${self.make_profile_info_component()} </%def> diff --git a/tailbone/views/members.py b/tailbone/views/members.py index 197efa41..a004b5a3 100644 --- a/tailbone/views/members.py +++ b/tailbone/views/members.py @@ -390,6 +390,11 @@ class MemberView(MasterView): {'section': 'rattail', 'option': 'members.straight_to_profile', 'type': bool}, + + # Relationships + {'section': 'rattail', + 'option': 'members.max_one_per_person', + 'type': bool}, ] diff --git a/tailbone/views/people.py b/tailbone/views/people.py index d7f84849..0aaf4c26 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -455,63 +455,81 @@ class PersonView(MasterView): self.viewing = True app = self.get_rattail_app() person = self.get_instance() - employee = app.get_employee(person) + context = { 'person': person, 'instance': person, 'instance_title': self.get_instance_title(person), - 'today': localtime(self.rattail_config).date(), + 'dynamic_content_title': self.get_context_content_title(person), + 'tabchecks': self.get_context_tabchecks(person), 'person_data': self.get_context_person(person), 'phone_type_options': self.get_phone_type_options(), 'email_type_options': self.get_email_type_options(), 'max_lengths': self.get_max_lengths(), - 'customers_data': self.get_context_customers(person), - # TODO: deprecate / remove this - 'customer_xref_buttons': self.get_customer_xref_buttons(person), 'expose_customer_people': self.customers_should_expose_people(), 'expose_customer_shoppers': self.customers_should_expose_shoppers(), - 'members_data': self.get_context_members(person), - 'member_xref_buttons': self.get_member_xref_buttons(person), - 'employee': employee, - 'employee_data': self.get_context_employee(employee) if employee else {}, - 'employee_view_url': self.request.route_url('employees.view', uuid=employee.uuid) if employee else None, - 'employee_history': employee.get_current_history() if employee else None, - 'employee_history_data': self.get_context_employee_history(employee), - 'notes_data': self.get_context_notes(person), - 'note_type_options': self.get_note_type_options(), - 'users_data': self.get_context_users(person), - 'dynamic_content_title': self.get_context_content_title(person), + 'max_one_member': app.get_membership_handler().max_one_per_person(), } - if context['expose_customer_shoppers']: - shoppers = person.customer_shoppers - # TODO: what a hack! surely this belongs in handler at least..? - shoppers = [shopper for shopper in shoppers - if shopper.shopper_number != 1] - context['shoppers_data'] = self.get_context_shoppers(shoppers) - if self.request.has_perm('people_profile.view_versions'): context['revisions_grid'] = self.profile_revisions_grid(person) - template = 'view_profile_buefy' - return self.render_to_response(template, context) + return self.render_to_response('view_profile_buefy', context) - # TODO: deprecate / remove this - def get_customer_xref_buttons(self, person): - buttons = [] - for supp in self.iter_view_supplements(): - if hasattr(supp, 'get_customer_xref_buttons'): - buttons.extend(supp.get_customer_xref_buttons(person) or []) - buttons = self.normalize_xref_buttons(buttons) - return buttons + def get_context_tabchecks(self, person): + app = self.get_rattail_app() + membership = app.get_membership_handler() + clientele = app.get_clientele_handler() + tabchecks = {} - def get_member_xref_buttons(self, person): - buttons = [] - for supp in self.iter_view_supplements(): - if hasattr(supp, 'get_member_xref_buttons'): - buttons.extend(supp.get_member_xref_buttons(person) or []) - buttons = self.normalize_xref_buttons(buttons) - return buttons + # TODO: for efficiency, should only calculate checks for tabs + # actually in use by app..(how) should that be configurable? + + # personal + tabchecks['personal'] = True + + # member + if membership.max_one_per_person(): + member = app.get_member(person) + tabchecks['member'] = bool(member and member.active) + else: + members = membership.get_members_for_account_holder(person) + tabchecks['member'] = any([m.active for m in members]) + + # customer + customers = clientele.get_customers_for_account_holder(person) + tabchecks['customer'] = bool(customers) + + # shopper + # TODO: what a hack! surely some of this belongs in handler + shoppers = person.customer_shoppers + shoppers = [shopper for shopper in shoppers + if shopper.shopper_number != 1] + tabchecks['shopper'] = bool(shoppers) + + # employee + employee = app.get_employee(person) + tabchecks['employee'] = bool(employee and employee.status == self.enum.EMPLOYEE_STATUS_CURRENT) + + # notes + tabchecks['notes'] = bool(person.notes) + + # user + tabchecks['user'] = bool(person.users) + + return tabchecks + + def profile_changed_response(self, person): + """ + Return common context result for all AJAX views which may + change the profile details. This is enough to update the + page-wide things, and let other tabs know they should be + refreshed when next displayed. + """ + return { + 'person': self.get_context_person(person), + 'tabchecks': self.get_context_tabchecks(person), + } def template_kwargs_view_profile(self, **kwargs): """ @@ -716,10 +734,12 @@ class PersonView(MasterView): def get_context_member(self, member): app = self.get_rattail_app() + person = app.get_person(member) + profile_url = None - if member.person: + if person: profile_url = self.request.route_url('people.view_profile', - uuid=member.person_uuid) + uuid=person.uuid) key = self.get_member_key_field() equity_total = sum([payment.amount for payment in member.equity_payments]) @@ -739,6 +759,7 @@ class PersonView(MasterView): 'view_url': self.request.route_url('members.view', uuid=member.uuid), 'view_profile_url': profile_url, 'equity_total_display': app.render_currency(equity_total), + 'external_links': [], } membership_type = member.membership_type @@ -825,6 +846,19 @@ class PersonView(MasterView): customer = handler.ensure_customer(person) return customer + def profile_tab_personal(self): + """ + Fetch personal tab data for profile view. + """ + # TODO: no need to return primary person data, since that + # always comes back via normal profile_changed_response() + # ..so for now this is a no-op.. + + # person = self.get_instance() + return { + # 'person': self.get_context_person(person), + } + def profile_edit_name(self): """ View which allows a person's name to be updated. @@ -838,11 +872,7 @@ class PersonView(MasterView): last=data['last_name']) self.Session.flush() - return { - 'success': True, - 'person': self.get_context_person(person), - 'dynamic_content_title': self.get_context_content_title(person), - } + return self.profile_changed_response(person) def get_context_phones(self, person): data = [] @@ -872,10 +902,7 @@ class PersonView(MasterView): return {'error': simple_error(error)} self.Session.flush() - return { - 'success': True, - 'person': self.get_context_person(person), - } + return self.profile_changed_response(person) def profile_update_phone(self): """ @@ -902,10 +929,7 @@ class PersonView(MasterView): return {'error': simple_error(error)} self.Session.flush() - return { - 'success': True, - 'person': self.get_context_person(person), - } + return self.profile_changed_response(person) def profile_delete_phone(self): """ @@ -925,10 +949,7 @@ class PersonView(MasterView): person.remove_phone(phone) self.Session.flush() - return { - 'success': True, - 'person': self.get_context_person(person), - } + return self.profile_changed_response(person) def profile_set_preferred_phone(self): """ @@ -948,10 +969,7 @@ class PersonView(MasterView): person.set_primary_phone(phone) self.Session.flush() - return { - 'success': True, - 'person': self.get_context_person(person), - } + return self.profile_changed_response(person) def get_context_emails(self, person): data = [] @@ -987,10 +1005,7 @@ class PersonView(MasterView): return {'error': simple_error(error)} self.Session.flush() - return { - 'success': True, - 'person': self.get_context_person(person), - } + return self.profile_changed_response(person) def profile_update_email(self): """ @@ -1013,10 +1028,7 @@ class PersonView(MasterView): return {'error': simple_error(error)} self.Session.flush() - return { - 'success': True, - 'person': self.get_context_person(person), - } + return self.profile_changed_response(person) def profile_delete_email(self): """ @@ -1036,11 +1048,7 @@ class PersonView(MasterView): person.remove_email(email) self.Session.flush() - - return { - 'success': True, - 'person': self.get_context_person(person), - } + return self.profile_changed_response(person) def profile_set_preferred_email(self): """ @@ -1060,10 +1068,7 @@ class PersonView(MasterView): person.set_primary_email(email) self.Session.flush() - return { - 'success': True, - 'person': self.get_context_person(person), - } + return self.profile_changed_response(person) def profile_edit_address(self): """ @@ -1077,9 +1082,66 @@ class PersonView(MasterView): self.people_handler.update_address(person, address, **data) self.Session.flush() + return self.profile_changed_response(person) + + def profile_tab_member(self): + """ + Fetch member tab data for profile view. + """ + app = self.get_rattail_app() + membership = app.get_membership_handler() + person = self.get_instance() + + max_one_member = membership.max_one_per_person() + + context = { + 'max_one_member': max_one_member, + } + + if max_one_member: + member = app.get_member(person) + context['member'] = {'exists': bool(member)} + if member: + context['member'].update(self.get_context_member(member)) + else: + context['members'] = self.get_context_members(person) + + return context + + def profile_tab_customer(self): + """ + Fetch customer tab data for profile view. + """ + person = self.get_instance() return { - 'success': True, - 'person': self.get_context_person(person), + 'customers': self.get_context_customers(person), + } + + def profile_tab_shopper(self): + """ + Fetch shopper tab data for profile view. + """ + person = self.get_instance() + + # TODO: what a hack! surely some of this belongs in handler + shoppers = person.customer_shoppers + shoppers = [shopper for shopper in shoppers + if shopper.shopper_number != 1] + + return { + 'shoppers': self.get_context_shoppers(shoppers), + } + + def profile_tab_employee(self): + """ + Fetch employee tab data for profile view. + """ + app = self.get_rattail_app() + person = self.get_instance() + employee = app.get_employee(person) + return { + 'employee': self.get_context_employee(employee) if employee else {}, + 'employee_history': self.get_context_employee_history(employee), } def profile_start_employee(self): @@ -1099,18 +1161,7 @@ class PersonView(MasterView): employee = handler.begin_employment(person, start_date, employee_id=data['id']) self.Session.flush() - return self.profile_start_employee_result(employee, start_date) - - def profile_start_employee_result(self, employee, start_date): - person = employee.person - return { - 'success': True, - 'employee': self.get_context_employee(employee), - 'employee_view_url': self.request.route_url('employees.view', uuid=employee.uuid), - 'start_date': str(start_date), - 'employee_history_data': self.get_context_employee_history(employee), - 'dynamic_content_title': self.get_context_content_title(person), - } + return self.profile_changed_response(person) def profile_end_employee(self): """ @@ -1130,18 +1181,7 @@ class PersonView(MasterView): handler.end_employment(employee, end_date, revoke_access=data.get('revoke_access')) self.Session.flush() - return self.profile_end_employee_result(employee, end_date) - - def profile_end_employee_result(self, employee, end_date): - person = employee.person - return { - 'success': True, - 'employee': self.get_context_employee(employee), - 'employee_view_url': self.request.route_url('employees.view', uuid=employee.uuid), - 'end_date': str(end_date), - 'employee_history_data': self.get_context_employee_history(employee), - 'dynamic_content_title': self.get_context_content_title(person), - } + return self.profile_changed_response(person) def profile_edit_employee_history(self): """ @@ -1167,14 +1207,7 @@ class PersonView(MasterView): history.end_date = end_date self.Session.flush() - current_history = employee.get_current_history() - return { - 'success': True, - 'employee': self.get_context_employee(employee), - 'start_date': str(current_history.start_date), - 'end_date': str(current_history.end_date or ''), - 'employee_history_data': self.get_context_employee_history(employee), - } + return self.profile_changed_response(person) def profile_update_employee_id(self): """ @@ -1188,11 +1221,27 @@ class PersonView(MasterView): data = self.request.json_body employee.id = data['employee_id'] - self.Session.flush() + self.Session.flush() + return self.profile_changed_response(person) + + def profile_tab_notes(self): + """ + Fetch notes tab data for profile view. + """ + person = self.get_instance() return { - 'success': True, - 'employee': self.get_context_employee(employee), + 'notes': self.get_context_notes(person), + 'note_types': self.get_note_type_options(), + } + + def profile_tab_user(self): + """ + Fetch user tab data for profile view. + """ + person = self.get_instance() + return { + 'users': self.get_context_users(person), } def profile_revisions_grid(self, person): @@ -1413,12 +1462,12 @@ class PersonView(MasterView): def profile_add_note(self): person = self.get_instance() form = self.make_note_form('create', person) - if form.validate(): - note = self.create_note(person, form) - self.Session.flush() - return self.profile_add_note_success(note) - else: - return self.profile_add_note_failure(person, form) + if not form.validate(): + return {'error': str(form.make_deform_form().error)} + + note = self.create_note(person, form) + self.Session.flush() + return self.profile_changed_response(person) def create_note(self, person, form): note = model.PersonNote() @@ -1429,25 +1478,15 @@ class PersonView(MasterView): person.notes.append(note) return note - def profile_add_note_success(self, note, person=None): - return { - 'notes': self.get_context_notes(person or note.person), - } - - def profile_add_note_failure(self, person, form): - return { - 'error': str(form.make_deform_form().error), - } - def profile_edit_note(self): person = self.get_instance() form = self.make_note_form('edit', person) - if form.validate(): - note = self.update_note(person, form) - self.Session.flush() - return self.profile_edit_note_success(note) - else: - return self.profile_edit_note_failure(person, form) + if not form.validate(): + return {'error': str(form.make_deform_form().error)} + + note = self.update_note(person, form) + self.Session.flush() + return self.profile_changed_response(person) def update_note(self, person, form): note = self.Session.get(model.PersonNote, form.validated['uuid']) @@ -1455,32 +1494,20 @@ class PersonView(MasterView): note.text = form.validated['note_text'] return note - def profile_edit_note_success(self, note): - return self.profile_add_note_success(note) - - def profile_edit_note_failure(self, person, form): - return self.profile_add_note_failure(person, form) - def profile_delete_note(self): person = self.get_instance() form = self.make_note_form('delete', person) - if form.validate(): - self.delete_note(person, form) - self.Session.flush() - return self.profile_delete_note_success(person) - else: - return self.profile_delete_note_failure(person, form) + if not form.validate(): + return {'error': str(form.make_deform_form().error)} + + self.delete_note(person, form) + self.Session.flush() + return self.profile_changed_response(person) def delete_note(self, person, form): note = self.Session.get(model.PersonNote, form.validated['uuid']) self.Session.delete(note) - def profile_delete_note_success(self, person): - return self.profile_add_note_success(None, person=person) - - def profile_delete_note_failure(self, person, form): - return self.profile_add_note_failure(person, form) - def make_user(self): uuid = self.request.POST['person_uuid'] person = self.Session.get(model.Person, uuid) @@ -1553,6 +1580,14 @@ class PersonView(MasterView): config.add_view(cls, attr='view_profile', route_name='{}.view_profile'.format(route_prefix), permission='{}.view_profile'.format(permission_prefix)) + # profile - refresh personal tab + config.add_route(f'{route_prefix}.profile_tab_personal', + f'{instance_url_prefix}/profile/tab-personal', + request_method='GET') + config.add_view(cls, attr='profile_tab_personal', + route_name=f'{route_prefix}.profile_tab_personal', + renderer='json') + # profile - edit personal details config.add_tailbone_permission('people_profile', 'people_profile.edit_person', @@ -1648,6 +1683,38 @@ class PersonView(MasterView): renderer='json', permission='people_profile.edit_person') + # profile - refresh member tab + config.add_route(f'{route_prefix}.profile_tab_member', + f'{instance_url_prefix}/profile/tab-member', + request_method='GET') + config.add_view(cls, attr='profile_tab_member', + route_name=f'{route_prefix}.profile_tab_member', + renderer='json') + + # profile - refresh customer tab + config.add_route(f'{route_prefix}.profile_tab_customer', + f'{instance_url_prefix}/profile/tab-customer', + request_method='GET') + config.add_view(cls, attr='profile_tab_customer', + route_name=f'{route_prefix}.profile_tab_customer', + renderer='json') + + # profile - refresh shopper tab + config.add_route(f'{route_prefix}.profile_tab_shopper', + f'{instance_url_prefix}/profile/tab-shopper', + request_method='GET') + config.add_view(cls, attr='profile_tab_shopper', + route_name=f'{route_prefix}.profile_tab_shopper', + renderer='json') + + # profile - refresh employee tab + config.add_route(f'{route_prefix}.profile_tab_employee', + f'{instance_url_prefix}/profile/tab-employee', + request_method='GET') + config.add_view(cls, attr='profile_tab_employee', + route_name=f'{route_prefix}.profile_tab_employee', + renderer='json') + # profile - start employee config.add_route('{}.profile_start_employee'.format(route_prefix), '{}/profile/start-employee'.format(instance_url_prefix), request_method='POST') @@ -1675,6 +1742,22 @@ class PersonView(MasterView): renderer='json', permission='employees.edit') + # profile - refresh notes tab + config.add_route(f'{route_prefix}.profile_tab_notes', + f'{instance_url_prefix}/profile/tab-notes', + request_method='GET') + config.add_view(cls, attr='profile_tab_notes', + route_name=f'{route_prefix}.profile_tab_notes', + renderer='json') + + # profile - refresh user tab + config.add_route(f'{route_prefix}.profile_tab_user', + f'{instance_url_prefix}/profile/tab-user', + request_method='GET') + config.add_view(cls, attr='profile_tab_user', + route_name=f'{route_prefix}.profile_tab_user', + renderer='json') + # profile - revisions data config.add_tailbone_permission('people_profile', 'people_profile.view_versions', From 4e2125d613df71169100d13abed55fd6373e30e7 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 29 Aug 2023 16:10:14 -0500 Subject: [PATCH 1164/1681] Add support for "missing" credit in mobile receiving --- tailbone/api/batch/receiving.py | 4 ++-- tailbone/forms/receiving.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tailbone/api/batch/receiving.py b/tailbone/api/batch/receiving.py index b02215d2..a0b61f38 100644 --- a/tailbone/api/batch/receiving.py +++ b/tailbone/api/batch/receiving.py @@ -414,8 +414,8 @@ class ReceivingBatchRowViews(APIBatchRowView): # TODO: this seems hacky, but avoids "complex" date value parsing form.set_widget('expiration_date', dfwidget.TextInputWidget()) if not form.validate(): - log.debug("form did not validate: %s", - form.make_deform_form().error) + log.warning("form did not validate: %s", + form.make_deform_form().error) return {'error': "Form did not validate"} # fetch / validate row object diff --git a/tailbone/forms/receiving.py b/tailbone/forms/receiving.py index 20c4774f..9f5706c7 100644 --- a/tailbone/forms/receiving.py +++ b/tailbone/forms/receiving.py @@ -52,6 +52,7 @@ class ReceiveRow(colander.MappingSchema): 'received', 'damaged', 'expired', + 'missing', # 'mispick', ])) From 74678882eea520db2d4bc60c08b5301216017552 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 29 Aug 2023 22:21:20 -0500 Subject: [PATCH 1165/1681] Update changelog --- CHANGES.rst | 20 ++++++++++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index f43e669b..5cf50519 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,26 @@ CHANGELOG ========= +0.9.42 (2023-08-29) +------------------- + +* When bulk-deleting, skip objects which are not "deletable". + +* Declare "from PO" receiving workflow if applicable, in API. + +* Auto-select text when editing costs for receiving. + +* Include shopper history from parent customer account perspective. + +* Link to product record, for New Product batch row. + +* Fix profile history to show when a CustomerShopperHistory is deleted. + +* Fairly massive overhaul of the Profile view; standardize tabs etc.. + +* Add support for "missing" credit in mobile receiving. + + 0.9.41 (2023-08-08) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 07ccc0e9..fdf05e34 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.41' +__version__ = '0.9.42' From f4267737c37e8539db17b73812232f12ba49bc4f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 30 Aug 2023 20:10:10 -0500 Subject: [PATCH 1166/1681] Let "new product" batch override type-2 UPC lookup behavior --- tailbone/views/batch/newproduct.py | 38 +++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/tailbone/views/batch/newproduct.py b/tailbone/views/batch/newproduct.py index d58357d0..bd46ad52 100644 --- a/tailbone/views/batch/newproduct.py +++ b/tailbone/views/batch/newproduct.py @@ -26,6 +26,8 @@ Views for new product batches from rattail.db import model +from deform import widget as dfwidget + from tailbone.views.batch import BatchMasterView @@ -47,11 +49,17 @@ class NewProductBatchView(BatchMasterView): configurable = True has_input_file_templates = True + labels = { + 'type2_lookup': "Type-2 UPC Lookups", + } + form_fields = [ 'id', 'input_filename', 'description', 'notes', + 'type2_lookup', + 'params', 'created', 'created_by', 'rowcount', @@ -127,7 +135,7 @@ class NewProductBatchView(BatchMasterView): ] def configure_form(self, f): - super(NewProductBatchView, self).configure_form(f) + super().configure_form(f) # input_filename if self.creating: @@ -136,6 +144,34 @@ class NewProductBatchView(BatchMasterView): f.set_readonly('input_filename') f.set_renderer('input_filename', self.render_downloadable_file) + # type2_lookup + if self.creating: + values = [ + ('', "(use default behavior)"), + ('always', "Always try Type-2 lookup, when applicable"), + ('never', "Never try Type-2 lookup"), + ] + f.set_widget('type2_lookup', dfwidget.SelectWidget(values=values)) + f.set_default('type2_lookup', '') + else: + f.remove('type2_lookup') + + def save_create_form(self, form): + batch = super().save_create_form(form) + + if 'type2_lookup' in form: + type2_lookup = form.validated['type2_lookup'] + if type2_lookup == 'always': + type2_lookup = True + elif type2_lookup == 'never': + type2_lookup = False + else: + type2_lookup = None + if type2_lookup is not None: + batch.set_param('type2_lookup', type2_lookup) + + return batch + def configure_row_grid(self, g): super(NewProductBatchView, self).configure_row_grid(g) From 9f65de2ba605128e54767ddbdc2a6562d5264a4d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 30 Aug 2023 22:08:50 -0500 Subject: [PATCH 1167/1681] Update changelog --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 5cf50519..83d6a298 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.9.43 (2023-08-30) +------------------- + +* Let "new product" batch override type-2 UPC lookup behavior. + + 0.9.42 (2023-08-29) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index fdf05e34..8e29a48e 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.42' +__version__ = '0.9.43' From 625982d63968c2528f007a295e18bb8d4f0e2785 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 30 Aug 2023 23:32:09 -0500 Subject: [PATCH 1168/1681] Avoid deprecated `User.email_address` property --- tailbone/templates/settings/email/configure.mako | 2 +- tailbone/templates/settings/email/view.mako | 2 +- tailbone/views/email.py | 9 +++++++++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/settings/email/configure.mako b/tailbone/templates/settings/email/configure.mako index 50a3d483..ef487809 100644 --- a/tailbone/templates/settings/email/configure.mako +++ b/tailbone/templates/settings/email/configure.mako @@ -90,7 +90,7 @@ ${parent.modify_this_page_vars()} <script type="text/javascript"> - ThisPageData.testRecipient = ${json.dumps(request.user.email_address)|n} + ThisPageData.testRecipient = ${json.dumps(user_email_address)|n} ThisPageData.sendingTest = false ThisPage.methods.sendTest = function() { diff --git a/tailbone/templates/settings/email/view.mako b/tailbone/templates/settings/email/view.mako index 59842498..1d292c69 100644 --- a/tailbone/templates/settings/email/view.mako +++ b/tailbone/templates/settings/email/view.mako @@ -84,7 +84,7 @@ return { previewFormButtonText: "Send Preview Email", previewFormSubmitting: false, - userEmailAddress: ${json.dumps(request.user.email_address)|n}, + userEmailAddress: ${json.dumps(user_email_address)|n}, } }, methods: { diff --git a/tailbone/views/email.py b/tailbone/views/email.py index 9c3d2268..8d227a1e 100644 --- a/tailbone/views/email.py +++ b/tailbone/views/email.py @@ -268,8 +268,14 @@ class EmailSettingView(MasterView): return data def template_kwargs_view(self, **kwargs): + kwargs = super().template_kwargs_view(**kwargs) + app = self.get_rattail_app() + key = self.request.matchdict['key'] kwargs['email'] = self.email_handler.get_email(key) + + kwargs['user_email_address'] = app.get_contact_email_address(self.request.user) + return kwargs def configure_get_simple_settings(self): @@ -293,12 +299,15 @@ class EmailSettingView(MasterView): def configure_get_context(self, *args, **kwargs): context = super().configure_get_context(*args, **kwargs) + app = self.get_rattail_app() # prettify list of template paths templates = self.rattail_config.parse_list( context['simple_settings']['rattail.mail.templates']) context['simple_settings']['rattail.mail.templates'] = ', '.join(templates) + context['user_email_address'] = app.get_contact_email_address(self.request.user) + return context def toggle_hidden(self): From 62aa0c59657ca6a4305a272369bbcd718ae165ba Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 30 Aug 2023 23:51:18 -0500 Subject: [PATCH 1169/1681] Preserve URL hash when redirecting in grid "reset to defaults" --- tailbone/templates/grids/buefy.mako | 11 ++++++++++- tailbone/views/master.py | 12 ++++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako index 42451597..25b8abca 100644 --- a/tailbone/templates/grids/buefy.mako +++ b/tailbone/templates/grids/buefy.mako @@ -542,7 +542,16 @@ resetView() { this.loading = true - location.href = '?reset-to-default-filters=true' + + // use current url proper, plus reset param + let url = '?reset-to-default-filters=true' + + // add current hash, to preserve that in redirect + if (location.hash) { + url += '&hash=' + location.hash.slice(1) + } + + location.href = url }, addFilter(filter_key) { diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 107870cd..98408420 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -329,7 +329,11 @@ class MasterView(View): # If user just refreshed the page with a reset instruction, issue a # redirect in order to clear out the query string. if self.request.GET.get('reset-to-default-filters') == 'true': - return self.redirect(self.request.current_route_url(_query=None)) + kw = {'_query': None} + hash_ = self.request.GET.get('hash') + if hash_: + kw['_anchor'] = hash_ + return self.redirect(self.request.current_route_url(**kw)) # Stash some grid stats, for possible use when generating URLs. if grid.pageable and hasattr(grid, 'pager'): @@ -1126,7 +1130,11 @@ class MasterView(View): # If user just refreshed the page with a reset instruction, issue a # redirect in order to clear out the query string. if self.request.GET.get('reset-to-default-filters') == 'true': - return self.redirect(self.request.current_route_url(_query=None)) + kw = {'_query': None} + hash_ = self.request.GET.get('hash') + if hash_: + kw['_anchor'] = hash_ + return self.redirect(self.request.current_route_url(**kw)) # return grid only, if partial page was requested if self.request.params.get('partial'): From 5ab47aeead13fc65f5cbe6dfd14c80d458488924 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 31 Aug 2023 10:08:20 -0500 Subject: [PATCH 1170/1681] Update changelog --- CHANGES.rst | 8 ++++++++ tailbone/_version.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 83d6a298..ca3c5342 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.9.44 (2023-08-31) +------------------- + +* Avoid deprecated ``User.email_address`` property. + +* Preserve URL hash when redirecting in grid "reset to defaults". + + 0.9.43 (2023-08-30) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 8e29a48e..da259c44 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.43' +__version__ = '0.9.44' From de373a683bdb2e0917d763671ea11867b5f7d56e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 1 Sep 2023 11:20:30 -0500 Subject: [PATCH 1171/1681] Add grid filter type for BigInteger columns so we can filter by larger values --- tailbone/grids/core.py | 2 ++ tailbone/grids/filters.py | 15 ++++++++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index abbac793..a6ba34d1 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -689,6 +689,8 @@ class Grid(object): factory = gridfilters.AlchemyStringFilter elif isinstance(column.type, sa.Numeric): factory = gridfilters.AlchemyNumericFilter + elif isinstance(column.type, sa.BigInteger): + factory = gridfilters.AlchemyBigIntegerFilter elif isinstance(column.type, sa.Integer): factory = gridfilters.AlchemyIntegerFilter elif isinstance(column.type, sa.Boolean): diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py index 59e20d78..c8815f9f 100644 --- a/tailbone/grids/filters.py +++ b/tailbone/grids/filters.py @@ -659,6 +659,7 @@ class AlchemyIntegerFilter(AlchemyNumericFilter): """ Integer filter for SQLAlchemy. """ + bigint = False def value_invalid(self, value): if value: @@ -666,9 +667,10 @@ class AlchemyIntegerFilter(AlchemyNumericFilter): return True if not value.isdigit(): return True - # TODO: this one is to avoid DataError from PG, but perhaps that - # isn't a good enough reason to make this global logic? - if int(value) > 2147483647: + # normal Integer columns have a max value, beyond which PG + # will throw an error if we try to query for larger values + # TODO: this seems hacky, how to better handle it? + if not self.bigint and int(value) > 2147483647: return True return False @@ -678,6 +680,13 @@ class AlchemyIntegerFilter(AlchemyNumericFilter): return int(value) +class AlchemyBigIntegerFilter(AlchemyIntegerFilter): + """ + BigInteger filter for SQLAlchemy. + """ + bigint = True + + class AlchemyBooleanFilter(AlchemyGridFilter): """ Boolean filter for SQLAlchemy. From 75caface6b8b184411583cfa6a65cca907488ec8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 2 Sep 2023 10:56:06 -0500 Subject: [PATCH 1172/1681] Add products API route to fetch label profiles for use w/ printing --- tailbone/api/products.py | 25 +++++++++++++++++++++++++ tailbone/views/products.py | 11 ++++------- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/tailbone/api/products.py b/tailbone/api/products.py index a2e2db73..3f29ff54 100644 --- a/tailbone/api/products.py +++ b/tailbone/api/products.py @@ -125,6 +125,24 @@ class ProductView(APIMasterView): return {'ok': True, 'product': self.normalize(product)} + def label_profiles(self): + """ + Returns the set of label profiles available for use with + printing label for product. + """ + app = self.get_rattail_app() + label_handler = app.get_label_handler() + model = self.model + + profiles = [] + for profile in label_handler.get_label_profiles(self.Session()): + profiles.append({ + 'uuid': profile.uuid, + 'description': profile.description, + }) + + return {'label_profiles': profiles} + def print_labels(self): app = self.get_rattail_app() label_handler = app.get_label_handler() @@ -176,6 +194,13 @@ class ProductView(APIMasterView): permission='{}.list'.format(permission_prefix)) config.add_cornice_service(quick_lookup) + # label profiles + label_profiles = Service(name=f'{route_prefix}.label_profiles', + path=f'{collection_url_prefix}/label-profiles') + label_profiles.add_view('GET', 'label_profiles', klass=cls, + permission=f'{permission_prefix}.print_labels') + config.add_cornice_service(label_profiles) + # print labels print_labels = Service(name='{}.print_labels'.format(route_prefix), path='{}/print-labels'.format(collection_url_prefix)) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 1cfa528a..92c99c34 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -658,16 +658,13 @@ class ProductView(MasterView): return pretty_quantity(inventory.on_order) def template_kwargs_index(self, **kwargs): - kwargs = super(ProductView, self).template_kwargs_index(**kwargs) + kwargs = super().template_kwargs_index(**kwargs) + app = self.get_rattail_app() + label_handler = app.get_label_handler() model = self.model if self.expose_label_printing: - - kwargs['label_profiles'] = self.Session.query(model.LabelProfile)\ - .filter(model.LabelProfile.visible == True)\ - .order_by(model.LabelProfile.ordinal)\ - .all() - + kwargs['label_profiles'] = label_handler.get_label_profiles(self.Session()) kwargs['quick_label_speedbump_threshold'] = self.rattail_config.getint( 'tailbone', 'products.quick_labels.speedbump_threshold') From bd7e6f9f8a2bc932e47f0d74c8884ceaaeccf48c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 2 Sep 2023 11:39:49 -0500 Subject: [PATCH 1173/1681] Tweaks for cost editing within a receiving batch never show PO Cost column in row grid, since Invoice Cost is what receiving is most concerned with add "zig-zag" entry behavior when both catalog and invoice costs are editable --- tailbone/templates/receiving/view.mako | 82 ++++++++++++++------------ tailbone/views/purchasing/receiving.py | 9 --- 2 files changed, 44 insertions(+), 47 deletions(-) diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako index 77560ac1..b01436ba 100644 --- a/tailbone/templates/receiving/view.mako +++ b/tailbone/templates/receiving/view.mako @@ -177,6 +177,7 @@ let ReceivingCostEditor = { template: '#receiving-cost-editor-template', + mixins: [SimpleRequestMixin], props: { row: Object, 'field': String, @@ -232,41 +233,21 @@ submitEdit() { let url = '${url('{}.update_row_cost'.format(route_prefix), uuid=batch.uuid)}' - // TODO: should get csrf token from parent component? - let csrftoken = ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n} - let headers = {'${csrf_header_name}': csrftoken} - let params = { row_uuid: this.$props.row.uuid, } params[this.$props.field] = this.inputValue - this.$http.post(url, params, {headers: headers}).then(response => { - if (!response.data.error) { + this.simplePOST(url, params, response => { - // let parent know cost value has changed - // (this in turn will update data in *this* - // component, and display will refresh) - this.$emit('input', response.data.row[this.$props.field], - this.$props.row._index) + // let parent know cost value has changed + // (this in turn will update data in *this* + // component, and display will refresh) + this.$emit('input', response.data.row[this.$props.field], + this.$props.row._index) - // and hide the input box - this.editing = false - - } else { - this.$buefy.toast.open({ - message: "Submit failed: " + response.data.error, - type: 'is-warning', - duration: 4000, // 4 seconds - }) - } - - }, response => { - this.$buefy.toast.open({ - message: "Submit failed: (unknown error)", - type: 'is-warning', - duration: 4000, // 4 seconds - }) + // and hide the input box + this.editing = false }) }, }, @@ -289,11 +270,23 @@ // update display to indicate cost was confirmed this.addRowClass(index, 'catalog_cost_confirmed') - // start editing next row, unless there are no more - let nextRow = index + 1 - if (this.data.length > nextRow) { - nextRow = this.data[nextRow] - this.$refs['catalogUnitCost_' + nextRow.uuid].startEdit() + // advance to next editable cost input... + + // first try invoice cost within same row + let thisRow = this.data[index] + let cost = this.$refs['invoiceUnitCost_' + thisRow.uuid] + if (!cost) { + + // or, try catalog cost from next row + let nextRow = this.data[index + 1] + if (nextRow) { + cost = this.$refs['catalogUnitCost_' + nextRow.uuid] + } + } + + // start editing next cost if found + if (cost) { + cost.startEdit() } } @@ -312,11 +305,24 @@ // update display to indicate cost was confirmed this.addRowClass(index, 'invoice_cost_confirmed') - // start editing next row, unless there are no more - let nextRow = index + 1 - if (this.data.length > nextRow) { - nextRow = this.data[nextRow] - this.$refs['invoiceUnitCost_' + nextRow.uuid].startEdit() + // advance to next editable cost input... + + // nb. always advance to next row, regardless of field + let nextRow = this.data[index + 1] + if (nextRow) { + + // first try catalog cost from next row + let cost = this.$refs['catalogUnitCost_' + nextRow.uuid] + if (!cost) { + + // or, try invoice cost from next row + cost = this.$refs['invoiceUnitCost_' + nextRow.uuid] + } + + // start editing next cost if found + if (cost) { + cost.startEdit() + } } } diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 35e1d6b4..909ded3f 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -162,7 +162,6 @@ class ReceivingBatchView(PurchasingBatchView): 'cases_received', 'units_received', 'catalog_unit_cost', - 'po_unit_cost', 'invoice_unit_cost', 'invoice_total_calculated', 'credits', @@ -979,14 +978,6 @@ class ReceivingBatchView(PurchasingBatchView): g.set_click_handler('invoice_unit_cost', 'this.invoiceUnitCostClicked') - # nb. only show PO *or* invoice cost; prefer the latter unless - # we have a PO and no invoice - if (self.batch_handler.has_purchase_order(batch) - and not self.batch_handler.has_invoice_file(batch)): - g.remove('invoice_unit_cost') - else: - g.remove('po_unit_cost') - # credits # note that sorting by credits involves a subquery with group by clause. # seems likely there may be a better way? but this seems to work fine From b1ec1b881706b4ef460122871f3223fe4cfc9965 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 2 Sep 2023 13:56:10 -0500 Subject: [PATCH 1174/1681] Update changelog --- CHANGES.rst | 10 ++++++++++ tailbone/_version.py | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index ca3c5342..2c3388b4 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.9.45 (2023-09-02) +------------------- + +* Add grid filter type for BigInteger columns. + +* Add products API route to fetch label profiles for use w/ printing. + +* Tweaks for cost editing within a receiving batch. + + 0.9.44 (2023-08-31) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index da259c44..f9f23ea3 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.44' +__version__ = '0.9.45' From ecf46fa6fed1d817e9c8329395d7cb4c16143a11 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 7 Sep 2023 17:41:47 -0500 Subject: [PATCH 1175/1681] Improve display for member equity payments --- tailbone/views/members.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/tailbone/views/members.py b/tailbone/views/members.py index a004b5a3..0cacaf04 100644 --- a/tailbone/views/members.py +++ b/tailbone/views/members.py @@ -144,9 +144,10 @@ class MemberView(MasterView): rows_title = "Equity Payments" row_grid_columns = [ - 'amount', 'received', + 'amount', 'description', + 'source', 'transaction_identifier', ] @@ -408,18 +409,22 @@ class MemberEquityPaymentView(MasterView): has_versions = True grid_columns = [ + 'received', + '_member_key_', 'member', 'amount', - 'received', 'description', + 'source', 'transaction_identifier', ] form_fields = [ + '_member_key_', 'member', 'amount', 'received', 'description', + 'source', 'transaction_identifier', ] @@ -435,14 +440,31 @@ class MemberEquityPaymentView(MasterView): super().configure_grid(g) model = self.model + # member_key + field = self.get_member_key_field() + attr = getattr(model.Member, field) + g.set_renderer(field, self.render_member_key) + g.set_filter(field, attr, + label=self.get_member_key_label(), + default_active=True) + g.set_sorter(field, attr) + + # member (name) g.set_joiner('member', lambda q: q.outerjoin(model.Person)) g.set_sorter('member', model.Person.display_name) g.set_link('member') + g.set_filter('member', model.Person.display_name, + label="Member Name") g.set_type('amount', 'currency') g.set_sort_defaults('received', 'desc') g.set_link('received') + g.set_link('transaction_identifier') + + def render_member_key(self, payment, field): + key = getattr(payment.member, field) + return key def configure_form(self, f): super().configure_form(f) From f732e04f49b0d802f568c93256ba5b7cdfc3a386 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 7 Sep 2023 18:36:02 -0500 Subject: [PATCH 1176/1681] Update changelog --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 2c3388b4..16b70517 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.9.46 (2023-09-07) +------------------- + +* Improve display for member equity payments. + + 0.9.45 (2023-09-02) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index f9f23ea3..f089641a 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.45' +__version__ = '0.9.46' From f717bc47e56d48b210079a4ce7dce389ff4cff04 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 7 Sep 2023 20:57:33 -0500 Subject: [PATCH 1177/1681] Fallback to None when getting values for merge preview --- tailbone/views/master.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 98408420..5027f230 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2141,7 +2141,7 @@ class MasterView(View): if self.merge_handler: return self.merge_handler.get_merge_preview_data(obj) - return dict([(f, getattr(obj, f)) + return dict([(f, getattr(obj, f, None)) for f in self.get_merge_fields()]) def get_merge_resulting_data(self, remove, keep): From 84de5e09a2f81c878007dd00e22f2583e9caea7e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 7 Sep 2023 21:00:40 -0500 Subject: [PATCH 1178/1681] Update changelog --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 16b70517..2668119c 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.9.47 (2023-09-07) +------------------- + +* Fallback to None when getting values for merge preview. + + 0.9.46 (2023-09-07) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index f089641a..bedab6b6 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.46' +__version__ = '0.9.47' From 6e50288bd4f55921b682e5f29a0173611b7f43ad Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 8 Sep 2023 08:49:43 -0500 Subject: [PATCH 1179/1681] Add grid link for equity payment description --- tailbone/views/members.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tailbone/views/members.py b/tailbone/views/members.py index 0cacaf04..85ffa99c 100644 --- a/tailbone/views/members.py +++ b/tailbone/views/members.py @@ -460,6 +460,10 @@ class MemberEquityPaymentView(MasterView): g.set_sort_defaults('received', 'desc') g.set_link('received') + + # description + g.set_link('description') + g.set_link('transaction_identifier') def render_member_key(self, payment, field): From 7221400b8850710cc80a9220a545101a48986908 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 8 Sep 2023 10:56:25 -0500 Subject: [PATCH 1180/1681] Fix msg body display, download link for email bounces --- tailbone/templates/email-bounces/view.mako | 4 +- tailbone/views/bouncer.py | 45 ++++++++-------------- tailbone/views/master.py | 4 +- 3 files changed, 18 insertions(+), 35 deletions(-) diff --git a/tailbone/templates/email-bounces/view.mako b/tailbone/templates/email-bounces/view.mako index 610118ed..f8372c88 100644 --- a/tailbone/templates/email-bounces/view.mako +++ b/tailbone/templates/email-bounces/view.mako @@ -48,9 +48,7 @@ <%def name="render_this_page()"> ${parent.render_this_page()} - <pre class="email-message-body"> - ${message} - </pre> + <pre class="email-message-body">${message}</pre> </%def> diff --git a/tailbone/views/bouncer.py b/tailbone/views/bouncer.py index 628ed07c..3416bbed 100644 --- a/tailbone/views/bouncer.py +++ b/tailbone/views/bouncer.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,18 +24,12 @@ Views for Email Bounces """ -from __future__ import unicode_literals, absolute_import - import os import datetime -import six - from rattail.db import model -from rattail.bouncer import get_handler from rattail.bouncer.config import get_profile_keys -from pyramid.response import FileResponse from webhelpers2.html import HTML, tags from tailbone.views import MasterView @@ -50,6 +44,7 @@ class EmailBounceView(MasterView): url_prefix = '/email-bounces' creatable = False editable = False + downloadable = True labels = { 'config_key': "Source", @@ -70,7 +65,8 @@ class EmailBounceView(MasterView): self.handler_options = sorted(get_profile_keys(self.rattail_config)) def get_handler(self, bounce): - return get_handler(self.rattail_config, bounce.config_key) + app = self.get_rattail_app() + return app.get_bounce_handler(bounce.config_key) def configure_grid(self, g): super(EmailBounceView, self).configure_grid(g) @@ -142,11 +138,16 @@ class EmailBounceView(MasterView): path = handler.msgpath(bounce) if os.path.exists(path): with open(path, 'rb') as f: - kwargs['message'] = f.read() + # TODO: how to determine encoding? (is utf_8 guaranteed?) + kwargs['message'] = f.read().decode('utf_8') else: kwargs['message'] = "(file not found)" return kwargs + def download_path(self, bounce, filename): + handler = self.get_handler(bounce) + return handler.msgpath(bounce) + # TODO: should require POST here def process(self): """ @@ -169,20 +170,13 @@ class EmailBounceView(MasterView): self.request.session.flash("Email bounce has been marked UN-processed.") return self.redirect(self.get_action_url('view', bounce)) - def download(self): - """ - View for downloading the message file associated with a bounce. - """ - bounce = self.get_instance() - handler = self.get_handler(bounce) - path = handler.msgpath(bounce) - response = FileResponse(path, request=self.request) - response.headers[b'Content-Length'] = six.binary_type(os.path.getsize(path)) - response.headers[b'Content-Disposition'] = b'attachment; filename="bounce.eml"' - return response - @classmethod def defaults(cls, config): + cls._bounce_defaults(config) + cls._defaults(config) + + @classmethod + def _bounce_defaults(cls, config): config.add_tailbone_permission_group('emailbounces', "Email Bounces", overwrite=False) @@ -200,15 +194,6 @@ class EmailBounceView(MasterView): config.add_tailbone_permission('emailbounces', 'emailbounces.unprocess', "Mark Email Bounce as UN-processed") - # download raw email - config.add_route('emailbounces.download', '/email-bounces/{uuid}/download') - config.add_view(cls, attr='download', route_name='emailbounces.download', - permission='emailbounces.download') - config.add_tailbone_permission('emailbounces', 'emailbounces.download', - "Download raw message of Email Bounce") - - cls._defaults(config) - def defaults(config, **kwargs): base = globals() diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 5027f230..4aacc9f1 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -1593,9 +1593,9 @@ class MasterView(View): """ obj = self.get_instance() filename = self.request.GET.get('filename', None) - if not filename: - raise self.notfound() path = self.download_path(obj, filename) + if not path or not os.path.exists(path): + raise self.notfound() response = FileResponse(path, request=self.request) response.content_length = os.path.getsize(path) content_type = self.download_content_type(path, filename) From 669e50e40658caca5dd66913739aa13a50fa3b8c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 8 Sep 2023 19:53:10 -0500 Subject: [PATCH 1181/1681] Fix member key display for equity payment form --- tailbone/views/members.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tailbone/views/members.py b/tailbone/views/members.py index 85ffa99c..d2a0e455 100644 --- a/tailbone/views/members.py +++ b/tailbone/views/members.py @@ -475,6 +475,10 @@ class MemberEquityPaymentView(MasterView): model = self.model payment = f.model_instance + # member_key + field = self.get_member_key_field() + f.set_renderer(field, self.render_member_key) + # member if self.creating: f.replace('member', 'member_uuid') From c5344d2df62284bb5b12fbf99dd0f1331bcc122f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 8 Sep 2023 19:55:14 -0500 Subject: [PATCH 1182/1681] Update changelog --- CHANGES.rst | 10 ++++++++++ tailbone/_version.py | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 2668119c..fc858f6b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.9.48 (2023-09-08) +------------------- + +* Add grid link for equity payment description. + +* Fix msg body display, download link for email bounces. + +* Fix member key display for equity payment form. + + 0.9.47 (2023-09-07) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index bedab6b6..014d9357 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.47' +__version__ = '0.9.48' From ccb4661b39641f52881f4fc6d6e4ae921be36212 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 9 Sep 2023 14:14:23 -0500 Subject: [PATCH 1183/1681] Add custom hook for grid "apply filters" so a page can know when the data set changes.. this seems a bit hacky, may need a better solution some day --- tailbone/templates/grids/buefy.mako | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako index 25b8abca..519c16d8 100644 --- a/tailbone/templates/grids/buefy.mako +++ b/tailbone/templates/grids/buefy.mako @@ -604,8 +604,11 @@ params = new URLSearchParams(params) this.loadAsyncData(params) + this.appliedFiltersHook() }, + appliedFiltersHook() {}, + clearFilters() { // explicitly deactivate all filters From a9fbf480531ebea8ee2e0ea9283c568b9dfd457f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 9 Sep 2023 16:18:39 -0500 Subject: [PATCH 1184/1681] Use common POST logic for submitting new customer order --- tailbone/templates/custorders/create.mako | 28 +++-------------------- 1 file changed, 3 insertions(+), 25 deletions(-) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index 77129fb8..055957bb 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -1091,6 +1091,7 @@ const CustomerOrderCreator = { template: '#customer-order-creator-template', + mixins: [SimpleRequestMixin], data() { let defaultUnitChoices = ${json.dumps(default_uom_choices)|n} @@ -1198,9 +1199,6 @@ pendingProduct: {}, departmentOptions: ${json.dumps(department_options)|n}, - ## TODO: should find a better way to handle CSRF token - csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}, - submittingOrder: false, } }, @@ -1500,31 +1498,11 @@ submitBatchData(params, success, failure) { let url = ${json.dumps(request.current_route_url())|n} - let headers = { - ## TODO: should find a better way to handle CSRF token - 'X-CSRF-TOKEN': this.csrftoken, - } - - ## TODO: should find a better way to handle CSRF token - this.$http.post(url, params, {headers: headers}).then((response) => { - if (response.data.error) { - this.$buefy.toast.open({ - message: response.data.error, - type: 'is-danger', - duration: 2000, // 2 seconds - }) - if (failure) { - failure(response) - } - } else if (success) { + this.simplePOST(url, params, response => { + if (success) { success(response) } }, response => { - this.$buefy.toast.open({ - message: "Unexpected error occurred", - type: 'is-danger', - duration: 2000, // 2 seconds - }) if (failure) { failure(response) } From 64c58a3cf8357624567eb6084c421e618fabcb40 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 10 Sep 2023 07:44:13 -0500 Subject: [PATCH 1185/1681] Optionally configure SQLAlchemy Session with `future=True` this avoids the need for setting `cascade_backrefs=False` everywhere https://docs.sqlalchemy.org/en/14/errors.html#error-s9r1 https://docs.sqlalchemy.org/en/14/orm/session_api.html#sqlalchemy.orm.Session.params.future --- tailbone/app.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tailbone/app.py b/tailbone/app.py index 4d4f435c..6f41a8de 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -70,6 +70,10 @@ def make_rattail_config(settings): if hasattr(rattail_config, 'tempmon_engine'): tailbone.db.TempmonSession.configure(bind=rattail_config.tempmon_engine) + # maybe set "future" behavior for SQLAlchemy + if rattail_config.getbool('rattail.db', 'sqlalchemy_future_mode', usedb=False): + tailbone.db.Session.configure(future=True) + # create session wrappers for each "extra" Trainwreck engine for key, engine in rattail_config.trainwreck_engines.items(): if key != 'default': From 48daa042d17827e133f7107b139251ce460e85b0 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 10 Sep 2023 07:45:25 -0500 Subject: [PATCH 1186/1681] Show related customer orders for Pending Product view and similar tweaks --- tailbone/templates/products/pending/view.mako | 22 +++---- tailbone/views/products.py | 64 ++++++++++++++++--- 2 files changed, 65 insertions(+), 21 deletions(-) diff --git a/tailbone/templates/products/pending/view.mako b/tailbone/templates/products/pending/view.mako index 90d9c687..be61a44f 100644 --- a/tailbone/templates/products/pending/view.mako +++ b/tailbone/templates/products/pending/view.mako @@ -53,24 +53,22 @@ <section class="modal-card-body"> <p class="block"> - If this Product already exists, you can declare that by + If this product already exists, you can declare that by identifying the record below. </p> <p class="block"> The app will take care of updating any Customer Orders etc. as needed once you declare the match. </p> - <b-field grouped> - <b-field label="Pending"> - <span>${instance.full_description}</span> - </b-field> - <b-field label="Actual Product" expanded> - <tailbone-autocomplete name="product_uuid" - v-model="resolveProductUUID" - ref="resolveProductAutocomplete" - service-url="${url('products.autocomplete')}"> - </tailbone-autocomplete> - </b-field> + <b-field label="Pending Product"> + <span>${instance.full_description}</span> + </b-field> + <b-field label="Actual Product" expanded> + <tailbone-autocomplete name="product_uuid" + v-model="resolveProductUUID" + ref="resolveProductAutocomplete" + service-url="${url('products.autocomplete')}"> + </tailbone-autocomplete> </b-field> </section> diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 92c99c34..e0183d14 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -2229,6 +2229,7 @@ class PendingProductView(MasterView): form_fields = [ '_product_key_', + 'product', 'brand_name', 'brand', 'description', @@ -2237,14 +2238,34 @@ class PendingProductView(MasterView): 'department', 'vendor_name', 'vendor', + 'vendor_item_code', 'unit_cost', 'case_size', 'regular_price_amount', 'special_order', 'notes', + 'status_code', 'created', 'user', - 'status_code', + 'resolved', + 'resolved_by', + ] + + has_rows = True + model_row_class = model.CustomerOrderItem + rows_title = "Customer Orders" + # TODO: add support for this someday + rows_viewable = False + + # TODO: this clearly needs help + row_grid_columns = [ + # 'upc', + 'brand_name', + 'description', + 'size', + 'vendor_name', + # 'regular_price', + # 'current_price', ] def configure_grid(self, g): @@ -2264,6 +2285,10 @@ class PendingProductView(MasterView): model = self.model pending = f.model_instance + # product + f.set_readonly('product') # TODO + f.set_renderer('product', self.render_product) + # department if self.creating or self.editing: if 'department' in f: @@ -2342,13 +2367,6 @@ class PendingProductView(MasterView): else: f.set_readonly('created') - # user - if self.creating: - f.remove('user') - else: - f.set_readonly('user') - f.set_renderer('user', self.render_user) - # status_code if self.creating: f.remove('status_code') @@ -2356,7 +2374,23 @@ class PendingProductView(MasterView): # f.set_readonly('status_code') f.set_enum('status_code', self.enum.PENDING_PRODUCT_STATUS) + # user + if self.creating: + f.remove('user') + else: + f.set_readonly('user') + f.set_renderer('user', self.render_user) + + # resolved* + if self.creating: + f.remove('resolved', 'resolved_by') + else: + if not pending.resolved: + f.remove('resolved', 'resolved_by') + def editable_instance(self, pending): + if self.request.is_root: + return True if pending.status_code == self.enum.PENDING_PRODUCT_STATUS_RESOLVED: return False return True @@ -2419,9 +2453,21 @@ class PendingProductView(MasterView): app = self.get_rattail_app() products_handler = app.get_products_handler() - products_handler.resolve_product(pending, product, self.request.user) + kwargs = self.get_resolve_product_kwargs() + products_handler.resolve_product(pending, product, self.request.user, **kwargs) return redirect + def get_resolve_product_kwargs(self, **kwargs): + return kwargs + + def get_row_data(self, pending): + model = self.model + return self.Session.query(model.CustomerOrderItem)\ + .filter(model.CustomerOrderItem.pending_product == pending) + + def get_parent(self, item): + return item.pending_product + @classmethod def defaults(cls, config): cls._defaults(config) From e255c35e8663f78f12f3c231c2d3a625fdf63a10 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 10 Sep 2023 13:51:11 -0500 Subject: [PATCH 1187/1681] Set stacklevel for all deprecation warnings --- tailbone/grids/core.py | 2 +- tailbone/views/handheld.py | 6 ++---- tailbone/views/labels/batch.py | 6 ++---- tailbone/views/vendors/catalogs.py | 6 ++---- tailbone/views/vendors/invoices.py | 6 ++---- 5 files changed, 9 insertions(+), 17 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index a6ba34d1..4a748536 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -309,7 +309,7 @@ class Grid(object): """ warnings.warn("Grid.hide_column() is deprecated; please use " "Grid.remove() instead.", - DeprecationWarning) + DeprecationWarning, stacklevel=2) self.remove(key) def hide_columns(self, *keys): diff --git a/tailbone/views/handheld.py b/tailbone/views/handheld.py index 4d702c92..34211c30 100644 --- a/tailbone/views/handheld.py +++ b/tailbone/views/handheld.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,6 @@ (DEPRECATED) Views for handheld batches """ -from __future__ import unicode_literals, absolute_import - import warnings # nb. this is imported only for sake of legacy callers @@ -35,5 +33,5 @@ from tailbone.views.batch.handheld import HandheldBatchView def includeme(config): warnings.warn("tailbone.views.handheld is a deprecated module; " "please use tailbone.views.batch.handheld instead", - DeprecationWarning) + DeprecationWarning, stacklevel=2) config.include('tailbone.views.batch.handheld') diff --git a/tailbone/views/labels/batch.py b/tailbone/views/labels/batch.py index b4910466..e9d2971b 100644 --- a/tailbone/views/labels/batch.py +++ b/tailbone/views/labels/batch.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -26,8 +26,6 @@ Please use `tailbone.views.batch.labels` instead. """ -from __future__ import unicode_literals, absolute_import - import warnings @@ -35,6 +33,6 @@ def includeme(config): warnings.warn("The `tailbone.views.labels.batch` module is deprecated, " "please use `tailbone.views.batch.labels` instead.", - DeprecationWarning) + DeprecationWarning, stacklevel=2) config.include('tailbone.views.batch.labels') diff --git a/tailbone/views/vendors/catalogs.py b/tailbone/views/vendors/catalogs.py index e021a88a..2471ad47 100644 --- a/tailbone/views/vendors/catalogs.py +++ b/tailbone/views/vendors/catalogs.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -26,13 +26,11 @@ Please use `tailbone.views.batch.vendorcatalog` instead. """ -from __future__ import unicode_literals, absolute_import - import warnings def includeme(config): warnings.warn("The `tailbone.views.vendors.catalogs` module is deprecated, " "please use `tailbone.views.batch.vendorcatalog` instead.", - DeprecationWarning) + DeprecationWarning, stacklevel=2) config.include('tailbone.views.batch.vendorcatalog') diff --git a/tailbone/views/vendors/invoices.py b/tailbone/views/vendors/invoices.py index e61329f6..40fe0365 100644 --- a/tailbone/views/vendors/invoices.py +++ b/tailbone/views/vendors/invoices.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -26,13 +26,11 @@ Please use `tailbone.views.batch.vendorinvoice` instead. """ -from __future__ import unicode_literals, absolute_import - import warnings def includeme(config): warnings.warn("The `tailbone.views.vendors.invoices` module is deprecated, " "please use `tailbone.views.batch.vendorinvoice` instead.", - DeprecationWarning) + DeprecationWarning, stacklevel=2) config.include('tailbone.views.batch.vendorinvoice') From e49e0edc5719386cf688c5e10b957ee52937d27b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 10 Sep 2023 17:06:14 -0500 Subject: [PATCH 1188/1681] Misc. improvements for Customer Orders view --- tailbone/templates/custorders/view.mako | 3 + tailbone/views/custorders/items.py | 32 ++++++--- tailbone/views/custorders/orders.py | 93 +++++++++++++++++++++++-- 3 files changed, 113 insertions(+), 15 deletions(-) create mode 100644 tailbone/templates/custorders/view.mako diff --git a/tailbone/templates/custorders/view.mako b/tailbone/templates/custorders/view.mako new file mode 100644 index 00000000..e2af7bf4 --- /dev/null +++ b/tailbone/templates/custorders/view.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> +${parent.body()} diff --git a/tailbone/views/custorders/items.py b/tailbone/views/custorders/items.py index 5dc61e4d..5d4f6049 100644 --- a/tailbone/views/custorders/items.py +++ b/tailbone/views/custorders/items.py @@ -85,8 +85,10 @@ class CustomerOrderItemView(MasterView): form_fields = [ 'order', - 'sequence', + 'customer', 'person', + 'sequence', + '_product_key_', 'product', 'pending_product', 'product_brand', @@ -97,9 +99,11 @@ class CustomerOrderItemView(MasterView): 'case_quantity', 'unit_price', 'total_price', + 'special_order', 'price_needs_confirmation', 'paid_amount', 'status_code', + 'flagged', 'notes', ] @@ -167,13 +171,30 @@ class CustomerOrderItemView(MasterView): return HTML.tag('span', title=item.status_text, c=[text]) return text + def get_batch_handler(self): + app = self.get_rattail_app() + return app.get_batch_handler( + 'custorder', + default='rattail.batch.custorder:CustomerOrderBatchHandler') + def configure_form(self, f): - super(CustomerOrderItemView, self).configure_form(f) + super().configure_form(f) item = f.model_instance # order f.set_renderer('order', self.render_order) + # contact + batch_handler = self.get_batch_handler() + if batch_handler.new_order_requires_customer(): + f.remove('person') + else: + f.remove('customer') + + # product key + key = self.get_product_key_field() + f.set_renderer(key, lambda item, field: getattr(item, f'product_{key}')) + # (pending) product f.set_renderer('product', self.render_product) f.set_renderer('pending_product', self.render_pending_product) @@ -192,13 +213,6 @@ class CustomerOrderItemView(MasterView): f.set_renderer('product_size', self.highlight_pending_field) f.set_renderer('case_quantity', self.highlight_pending_field_quantity) - 'unit_price', - 'total_price', - 'price_needs_confirmation', - 'paid_amount', - 'status_code', - 'notes', - # quantity fields f.set_type('cases_ordered', 'quantity') f.set_type('units_ordered', 'quantity') diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index cdf765a6..abbcf87c 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -52,7 +52,7 @@ class CustomerOrderView(MasterView): configurable = True labels = { - 'id': "ID", + 'id': "Order ID", 'status_code': "Status", } @@ -60,8 +60,9 @@ class CustomerOrderView(MasterView): 'id', 'customer', 'person', - 'created', 'status_code', + 'created', + 'created_by', ] form_fields = [ @@ -88,14 +89,17 @@ class CustomerOrderView(MasterView): row_grid_columns = [ 'sequence', + '_product_key_', 'product_brand', 'product_description', 'product_size', 'order_quantity', 'order_uom', 'case_quantity', + 'department_name', 'total_price', 'status_code', + 'flagged', ] def __init__(self, request): @@ -107,11 +111,19 @@ class CustomerOrderView(MasterView): .options(orm.joinedload(model.CustomerOrder.customer)) def configure_grid(self, g): - super(CustomerOrderView, self).configure_grid(g) + super().configure_grid(g) + + # id + g.set_link('id') + g.filters['id'].default_active = True + g.filters['id'].default_verb = 'equal' + + # import ipdb; ipdb.set_trace() # customer or person if self.batch_handler.new_order_requires_customer(): g.remove('person') + g.set_link('customer') g.set_joiner('customer', lambda q: q.outerjoin(model.Customer)) g.set_sorter('customer', model.Customer.name) g.filters['customer'] = g.make_filter('customer', model.Customer.name, @@ -120,6 +132,7 @@ class CustomerOrderView(MasterView): default_verb='contains') else: g.remove('customer') + g.set_link('person') g.set_joiner('person', lambda q: q.outerjoin(model.Person)) g.set_sorter('person', model.Person.display_name) g.filters['person'] = g.make_filter('person', model.Person.display_name, @@ -127,13 +140,14 @@ class CustomerOrderView(MasterView): default_active=True, default_verb='contains') + # status_code g.set_enum('status_code', self.enum.CUSTORDER_STATUS) + # created g.set_sort_defaults('created', 'desc') - g.set_link('id') - g.set_link('customer') - g.set_link('person') + def get_instance_title(self, order): + return f"#{order.id} for {order.customer or order.person}" def configure_form(self, f): super(CustomerOrderView, self).configure_form(f) @@ -232,6 +246,10 @@ class CustomerOrderView(MasterView): 'custorder', default='rattail.batch.custorder:CustomerOrderBatchHandler') + # product key + key = self.get_product_key_field() + g.set_renderer(key, lambda item, field: getattr(item, f'product_{key}')) + g.set_type('case_quantity', 'quantity') g.set_type('order_quantity', 'quantity') g.set_type('cases_ordered', 'quantity') @@ -962,6 +980,61 @@ class CustomerOrderView(MasterView): def execute_new_order_batch(self, batch, data): return self.batch_handler.do_execute(batch, self.request.user) + def fetch_order_data(self): + app = self.get_rattail_app() + model = self.model + + order = None + uuid = self.request.GET.get('uuid') + if uuid: + order = self.Session.get(model.CustomerOrder, uuid) + if not order: + # raise self.notfound() + return {'error': "Customer order not found"} + + address = None + if self.batch_handler.new_order_requires_customer(): + contact = order.customer + else: + contact = order.person + if contact and contact.address: + a = contact.address + address = { + 'street_1': a.street, + 'street_2': a.street2, + 'city': a.city, + 'state': a.state, + 'zip': a.zipcode, + } + + # gather all the order items + items = [] + grand_total = 0 + for item in order.items: + item_data = { + 'uuid': item.uuid, + 'special_order': False, # TODO + 'product_description': item.product_description, + 'order_quantity': app.render_quantity(item.order_quantity), + 'department': item.department_name, + 'price': app.render_currency(item.unit_price), + 'total': app.render_currency(item.total_price), + } + items.append(item_data) + grand_total += item.total_price + + return { + 'uuid': order.uuid, + 'id': order.id, + 'created_display': app.render_datetime(app.localtime(order.created, from_utc=True)), + 'contact_display': str(contact or ''), + 'address': address, + 'phone_display': str(contact.phone) if contact and contact.phone else "", + 'email_display': str(contact.email) if contact and contact.email else "", + 'items': items, + 'grand_total_display': app.render_currency(grand_total), + } + def configure_get_simple_settings(self): return [ @@ -1048,6 +1121,14 @@ class CustomerOrderView(MasterView): renderer='json', permission='products.list') + # fetch order data + config.add_route(f'{route_prefix}.fetch_order_data', + f'{url_prefix}/fetch-order-data') + config.add_view(cls, attr='fetch_order_data', + route_name=f'{route_prefix}.fetch_order_data', + renderer='json', + permission=f'{permission_prefix}.view') + # TODO: deprecate / remove this CustomerOrdersView = CustomerOrderView From ddb8e3656fe10c4b1f7d83dd8113029b3f035c1f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 10 Sep 2023 17:49:29 -0500 Subject: [PATCH 1189/1681] Add support for toggling custorder item "flagged" --- tailbone/templates/custorders/items/view.mako | 6 ++ tailbone/views/custorders/items.py | 62 +++++++++++++++++-- 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/tailbone/templates/custorders/items/view.mako b/tailbone/templates/custorders/items/view.mako index 9eb239ed..e82b567f 100644 --- a/tailbone/templates/custorders/items/view.mako +++ b/tailbone/templates/custorders/items/view.mako @@ -324,6 +324,12 @@ this.$refs.changeStatusForm.submit() } + ${form.component_studly}Data.changeFlaggedSubmitting = false + + ${form.component_studly}.methods.changeFlaggedSubmit = function() { + this.changeFlaggedSubmitting = true + } + % endif % if master.has_perm('add_note'): diff --git a/tailbone/views/custorders/items.py b/tailbone/views/custorders/items.py index 5d4f6049..baec4151 100644 --- a/tailbone/views/custorders/items.py +++ b/tailbone/views/custorders/items.py @@ -34,7 +34,7 @@ from rattail.time import localtime from webhelpers2.html import HTML, tags from tailbone.views import MasterView -from tailbone.util import raw_datetime +from tailbone.util import raw_datetime, csrf_token class CustomerOrderItemView(MasterView): @@ -231,9 +231,53 @@ class CustomerOrderItemView(MasterView): # status_code f.set_renderer('status_code', self.render_status_code) + # flagged + f.set_renderer('flagged', self.render_flagged) + # notes f.set_renderer('notes', self.render_notes) + def render_flagged(self, item, field): + text = "Yes" if item.flagged else "No" + items = [HTML.tag('span', c=text)] + + if self.has_perm('change_status'): + button_text = "Un-Flag This" if item.flagged else "Flag This" + form = [ + tags.form(self.get_action_url('change_flagged', item), + **{'@submit': 'changeFlaggedSubmit'}), + csrf_token(self.request), + tags.hidden('new_flagged', + value='false' if item.flagged else 'true'), + HTML.tag('b-button', + type='is-warning' if item.flagged else 'is-primary', + c=f"{{{{ changeFlaggedSubmitting ? 'Working, please wait...' : '{button_text}' }}}}", + native_type='submit', + style='margin-left: 1rem;', + icon_pack='fas', icon_left='flag', + **{':disabled': 'changeFlaggedSubmitting'}), + tags.end_form(), + ] + items.append(HTML.literal('').join(form)) + + left = HTML.tag('div', class_='level-left', c=items) + outer = HTML.tag('div', class_='level', c=[left]) + return outer + + def change_flagged(self): + """ + View for changing "flagged" status of one or more order products. + """ + item = self.get_instance() + redirect = self.redirect(self.get_action_url('view', item)) + + new_flagged = self.request.POST['new_flagged'] == 'true' + item.flagged = new_flagged + + flagged = "FLAGGED" if new_flagged else "UN-FLAGGED" + self.request.session.flash(f"Order item has been {flagged}") + return redirect + def highlight_pending_field(self, item, field, value=None): if value is None: value = getattr(item, field) @@ -299,14 +343,16 @@ class CustomerOrderItemView(MasterView): factory = self.get_grid_factory() g = factory( - key='{}.notes'.format(route_prefix), + key=f'{route_prefix}.notes', data=[], columns=[ - 'text', - 'created_by', 'created', + 'created_by', + 'text', ], labels={ + 'created': "Date/Time", + 'created_by': "Added by", 'text': "Note", }, ) @@ -555,6 +601,14 @@ class CustomerOrderItemView(MasterView): route_name='{}.change_status'.format(route_prefix), permission='{}.change_status'.format(permission_prefix)) + # change flagged + config.add_route(f'{route_prefix}.change_flagged', + f'{instance_url_prefix}/change-flagged', + request_method='POST') + config.add_view(cls, attr='change_flagged', + route_name=f'{route_prefix}.change_flagged', + permission=f'{permission_prefix}.change_status') + # add note config.add_tailbone_permission(permission_prefix, '{}.add_note'.format(permission_prefix), From 67ec6f7773147fbe2be0cd5c8b9315c0875f71b4 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 10 Sep 2023 19:55:48 -0500 Subject: [PATCH 1190/1681] Add support for "mark received" when viewing custorder item --- tailbone/templates/custorders/items/view.mako | 118 ++++++++++++++---- tailbone/views/custorders/items.py | 57 ++++++++- 2 files changed, 149 insertions(+), 26 deletions(-) diff --git a/tailbone/templates/custorders/items/view.mako b/tailbone/templates/custorders/items/view.mako index e82b567f..c1aaf970 100644 --- a/tailbone/templates/custorders/items/view.mako +++ b/tailbone/templates/custorders/items/view.mako @@ -9,6 +9,7 @@ % endif % if master.has_perm('change_status'): @change-status="showChangeStatus" + @mark-received="markReceivedInit" % endif % if master.has_perm('add_note'): @add-note="showAddNote" @@ -61,6 +62,67 @@ % endif % if master.has_perm('change_status'): + + ## TODO ## + <% contact = instance.order.person %> + <% email_address = rattail_app.get_contact_email_address(contact) %> + + <b-modal has-modal-card + :active.sync="markReceivedShowDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Mark Received</p> + </header> + + <section class="modal-card-body"> + <p class="block"> + new status will be: + <span class="has-text-weight-bold"> + % if email_address: + ${enum.CUSTORDER_ITEM_STATUS[enum.CUSTORDER_ITEM_STATUS_CONTACTED]} + % else: + ${enum.CUSTORDER_ITEM_STATUS[enum.CUSTORDER_ITEM_STATUS_RECEIVED]} + % endif + </span> + </p> + % if email_address: + <p class="block"> + This customer has an email address on file, which + means that we will automatically send them an email + notification, and advance the Order Product status to + "${enum.CUSTORDER_ITEM_STATUS[enum.CUSTORDER_ITEM_STATUS_CONTACTED]}". + </p> + % else: + <p class="block"> + This customer does *not* have an email address on + file, which means that we will *not* automatically + send them an email notification, so the Order + Product status will become + "${enum.CUSTORDER_ITEM_STATUS[enum.CUSTORDER_ITEM_STATUS_RECEIVED]}". + </p> + % endif + </section> + + <footer class="modal-card-foot"> + <b-button type="is-primary" + @click="markReceivedSubmit()" + :disabled="markReceivedSubmitting" + icon-pack="fas" + icon-left="check"> + {{ markReceivedSubmitting ? "Working, please wait..." : "Mark Received" }} + </b-button> + <b-button @click="markReceivedShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </b-modal> + ${h.form(url(f'{route_prefix}.mark_received'), ref='markReceivedForm')} + ${h.csrf_token(request)} + ${h.hidden('order_item_uuids', value=instance.uuid)} + ${h.end_form()} + <b-modal :active.sync="showChangeStatusDialog"> <div class="card"> <div class="card-content"> @@ -106,21 +168,30 @@ :checked-rows.sync="changeStatusCheckedRows" narrowed class="is-size-7"> - <b-table-column field="product_brand" label="Brand" + <b-table-column field="product_key" label="${rattail_app.get_product_key_label()}" v-slot="props"> - <span v-html="props.row.product_brand"></span> + {{ props.row.product_key }} </b-table-column> - <b-table-column field="product_description" label="Product" + <b-table-column field="brand_name" label="Brand" + v-slot="props"> + <span v-html="props.row.brand_name"></span> + </b-table-column> + <b-table-column field="product_description" label="Description" v-slot="props"> <span v-html="props.row.product_description"></span> </b-table-column> - <!-- <b-table-column field="quantity" label="Quantity"> --> - <!-- <span v-html="props.row.quantity"></span> --> - <!-- </b-table-column> --> + <b-table-column field="product_size" label="Size" + v-slot="props"> + <span v-html="props.row.product_size"></span> + </b-table-column> <b-table-column field="product_case_quantity" label="cPack" v-slot="props"> <span v-html="props.row.product_case_quantity"></span> </b-table-column> + <b-table-column field="department_name" label="Department" + v-slot="props"> + <span v-html="props.row.department_name"></span> + </b-table-column> <b-table-column field="order_quantity" label="oQty" v-slot="props"> <span v-html="props.row.order_quantity"></span> @@ -129,33 +200,18 @@ v-slot="props"> <span v-html="props.row.order_uom"></span> </b-table-column> - <b-table-column field="department_name" label="Department" - v-slot="props"> - <span v-html="props.row.department_name"></span> - </b-table-column> - <b-table-column field="product_barcode" label="Product Barcode" - v-slot="props"> - <span v-html="props.row.product_barcode"></span> - </b-table-column> - <b-table-column field="unit_price" label="Unit $" - v-slot="props"> - <span v-html="props.row.unit_price"></span> - </b-table-column> <b-table-column field="total_price" label="Total $" v-slot="props"> <span v-html="props.row.total_price"></span> </b-table-column> - <b-table-column field="order_date" label="Order Date" - v-slot="props"> - <span v-html="props.row.order_date"></span> - </b-table-column> <b-table-column field="status_code" label="Status" v-slot="props"> <span v-html="props.row.status_code"></span> </b-table-column> - <!-- <b-table-column field="flagged" label="Flagged"> --> - <!-- <span v-html="props.row.flagged"></span> --> - <!-- </b-table-column> --> + <b-table-column field="flagged" label="Flagged" + v-slot="props"> + {{ props.row.flagged ? "FLAG" : "" }} + </b-table-column> </b-table> <br /> @@ -278,6 +334,18 @@ % if master.has_perm('change_status'): + ThisPageData.markReceivedShowDialog = false + ThisPageData.markReceivedSubmitting = false + + ThisPage.methods.markReceivedInit = function() { + this.markReceivedShowDialog = true + } + + ThisPage.methods.markReceivedSubmit = function() { + this.markReceivedSubmitting = true + this.$refs.markReceivedForm.submit() + } + ThisPageData.orderItemStatuses = ${json.dumps(enum.CUSTORDER_ITEM_STATUS)|n} ThisPageData.orderItemStatusOptions = ${json.dumps([dict(key=k, label=v) for k, v in six.iteritems(enum.CUSTORDER_ITEM_STATUS)])|n} diff --git a/tailbone/views/custorders/items.py b/tailbone/views/custorders/items.py index baec4151..84fe615a 100644 --- a/tailbone/views/custorders/items.py +++ b/tailbone/views/custorders/items.py @@ -328,6 +328,16 @@ class CustomerOrderItemView(MasterView): items = [HTML.tag('span', c=[text])] if self.has_perm('change_status'): + + # Mark Received + if self.can_be_received(item): + button = HTML.tag('b-button', type='is-primary', c="Mark Received", + style='margin-left: 1rem;', + icon_pack='fas', icon_left='check', + **{'@click': "$emit('mark-received')"}) + items.append(button) + + # Change Status button = HTML.tag('b-button', type='is-primary', c="Change Status", style='margin-left: 1rem;', icon_pack='fas', icon_left='edit', @@ -338,6 +348,16 @@ class CustomerOrderItemView(MasterView): outer = HTML.tag('div', class_='level', c=[left]) return outer + def can_be_received(self, item): + + # TODO: is this generic enough? probably belongs in handler anyway.. + if item.status_code in (self.enum.CUSTORDER_ITEM_STATUS_INITIATED, + self.enum.CUSTORDER_ITEM_STATUS_READY, + self.enum.CUSTORDER_ITEM_STATUS_PLACED): + return True + + return False + def render_notes(self, item, field): route_prefix = self.get_route_prefix() @@ -389,6 +409,7 @@ class CustomerOrderItemView(MasterView): .filter(model.CustomerOrderItem.uuid != item.uuid)\ .all() other_data = [] + product_key_field = self.get_product_key_field() for other in other_items: order_date = None @@ -397,8 +418,10 @@ class CustomerOrderItemView(MasterView): other_data.append({ 'uuid': other.uuid, + 'product_key': getattr(other, f'product_{product_key_field}'), 'brand_name': other.product_brand, 'product_description': other.product_description, + 'product_size': other.product_size, 'product_case_quantity': app.render_quantity(other.case_quantity), 'order_quantity': app.render_quantity(other.order_quantity), 'order_uom': self.enum.UNIT_OF_MEASURE[other.order_uom], @@ -408,6 +431,7 @@ class CustomerOrderItemView(MasterView): 'total_price': app.render_currency(other.total_price), 'order_date': app.render_date(order_date), 'status_code': self.enum.CUSTORDER_ITEM_STATUS[other.status_code], + 'flagged': other.flagged, }) kwargs['other_order_items_data'] = other_data @@ -450,6 +474,28 @@ class CustomerOrderItemView(MasterView): self.request.session.flash("Price has been confirmed.") return redirect + def mark_received(self): + """ + View to mark some order item(s) as having been received. + """ + app = self.get_rattail_app() + model = self.model + uuids = self.request.POST['order_item_uuids'].split(',') + + order_items = self.Session.query(model.CustomerOrderItem)\ + .filter(model.CustomerOrderItem.uuid.in_(uuids))\ + .all() + + handler = app.get_custorder_handler() + handler.mark_received(order_items, self.request.user) + + msg = self.mark_received_get_flash(order_items) + self.request.session.flash(msg) + return self.redirect(self.request.get_referrer(default=self.get_index_url())) + + def mark_received_get_flash(self, order_items): + return "Order item statuses have been updated." + def change_status(self): """ View for changing status of one or more order items. @@ -550,7 +596,7 @@ class CustomerOrderItemView(MasterView): def get_row_data(self, item): return self.Session.query(model.CustomerOrderItemEvent)\ .filter(model.CustomerOrderItemEvent.item == item)\ - .order_by(model.CustomerOrderItemEvent.occurred.desc(), + .order_by(model.CustomerOrderItemEvent.occurred, model.CustomerOrderItemEvent.type_code) def configure_row_grid(self, g): @@ -571,6 +617,7 @@ class CustomerOrderItemView(MasterView): @classmethod def _order_item_defaults(cls, config): route_prefix = cls.get_route_prefix() + url_prefix = cls.get_url_prefix() instance_url_prefix = cls.get_instance_url_prefix() permission_prefix = cls.get_permission_prefix() model_title = cls.get_model_title() @@ -590,6 +637,14 @@ class CustomerOrderItemView(MasterView): route_name='{}.confirm_price'.format(route_prefix), permission='{}.confirm_price'.format(permission_prefix)) + # mark received + config.add_route(f'{route_prefix}.mark_received', + f'{url_prefix}/mark-received', + request_method='POST') + config.add_view(cls, attr='mark_received', + route_name=f'{route_prefix}.mark_received', + permission=f'{permission_prefix}.change_status') + # change status config.add_tailbone_permission(permission_prefix, '{}.change_status'.format(permission_prefix), From e793ba66308cbc8120dcd1a947815dc54aee6798 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 11 Sep 2023 15:24:00 -0500 Subject: [PATCH 1191/1681] Improve grids for custorder items main grid as well as rows grid for Pending Product --- tailbone/templates/products/pending/view.mako | 21 ------ tailbone/views/custorders/items.py | 75 ++++++++++++------- tailbone/views/products.py | 65 ++++++++++++++-- 3 files changed, 103 insertions(+), 58 deletions(-) diff --git a/tailbone/templates/products/pending/view.mako b/tailbone/templates/products/pending/view.mako index be61a44f..2b9852d9 100644 --- a/tailbone/templates/products/pending/view.mako +++ b/tailbone/templates/products/pending/view.mako @@ -3,27 +3,6 @@ <%def name="object_helpers()"> ${parent.object_helpers()} - % if instance.custorder_item_records: - <nav class="panel"> - <p class="panel-heading">Cross-Reference</p> - <div class="panel-block"> - <div style="display: flex; flex-direction: column;"> - <p class="block"> - This ${model_title} is referenced by the following<br /> - Customer Order Items: - </p> - <ul class="list"> - % for item in instance.custorder_item_records: - <li class="list-item"> - ${h.link_to('#{}-{}'.format(item.order.id, item.sequence), url('custorders.items.view', uuid=item.uuid))} - </li> - % endfor - </ul> - </div> - </div> - </nav> - % endif - % if instance.status_code == enum.PENDING_PRODUCT_STATUS_PENDING and master.has_perm('resolve_product'): <nav class="panel"> <p class="panel-heading">Tools</p> diff --git a/tailbone/views/custorders/items.py b/tailbone/views/custorders/items.py index 84fe615a..a6853399 100644 --- a/tailbone/views/custorders/items.py +++ b/tailbone/views/custorders/items.py @@ -58,14 +58,18 @@ class CustomerOrderItemView(MasterView): grid_columns = [ 'order_id', 'person', + '_product_key_', 'product_brand', 'product_description', 'product_size', + 'department_name', + 'case_quantity', 'order_quantity', 'order_uom', - 'case_quantity', + 'total_price', 'order_created', 'status_code', + 'flagged', ] has_rows = True @@ -114,42 +118,55 @@ class CustomerOrderItemView(MasterView): .joinedload(model.CustomerOrder.person)) def configure_grid(self, g): - super(CustomerOrderItemView, self).configure_grid(g) + super().configure_grid(g) + batch_handler = self.get_batch_handler() + # order_id g.set_renderer('order_id', self.render_order_id) + g.set_link('order_id') - g.set_joiner('person', lambda q: q.outerjoin(model.Person)) - - g.filters['person'] = g.make_filter('person', model.Person.display_name, - default_active=True, default_verb='contains') - - g.set_sorter('person', model.Person.display_name) - g.set_sorter('order_created', model.CustomerOrder.created) - - g.set_sort_defaults('order_created', 'desc') - - g.set_type('case_quantity', 'quantity') - g.set_type('cases_ordered', 'quantity') - g.set_type('units_ordered', 'quantity') - g.set_type('total_price', 'currency') - g.set_type('order_quantity', 'quantity') - - g.set_enum('order_uom', self.enum.UNIT_OF_MEASURE) - - g.set_renderer('person', self.render_person_text) - g.set_renderer('order_created', self.render_order_created) - - g.set_renderer('status_code', self.render_status_code_column) - + # person g.set_label('person', "Person Name") + g.set_renderer('person', self.render_person_text) + g.set_link('person') + g.set_joiner('person', lambda q: q.outerjoin(model.Person)) + g.set_sorter('person', model.Person.display_name) + g.set_filter('person', model.Person.display_name, + default_active=True, default_verb='contains') + + # product_key + field = self.get_product_key_field() + g.set_renderer(field, lambda item, field: getattr(item, f'product_{field}')) + + # product_* g.set_label('product_brand', "Brand") + g.set_link('product_brand') g.set_label('product_description', "Description") + g.set_link('product_description') g.set_label('product_size', "Size") - g.set_link('order_id') - g.set_link('person') - g.set_link('product_brand') - g.set_link('product_description') + # "numbers" + g.set_type('case_quantity', 'quantity') + g.set_type('order_quantity', 'quantity') + g.set_type('total_price', 'currency') + # TODO: deprecate / remove these + g.set_type('cases_ordered', 'quantity') + g.set_type('units_ordered', 'quantity') + + # order_uom + # nb. this is not relevant if "case orders only" + if not batch_handler.allow_unit_orders(): + g.remove('order_uom') + else: + g.set_enum('order_uom', self.enum.UNIT_OF_MEASURE) + + # order_created + g.set_renderer('order_created', self.render_order_created) + g.set_sorter('order_created', model.CustomerOrder.created) + g.set_sort_defaults('order_created', 'desc') + + # status_code + g.set_renderer('status_code', self.render_status_code_column) def render_order_id(self, item, field): return item.order.id diff --git a/tailbone/views/products.py b/tailbone/views/products.py index e0183d14..2b03871b 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -2257,15 +2257,28 @@ class PendingProductView(MasterView): # TODO: add support for this someday rows_viewable = False - # TODO: this clearly needs help + row_labels = { + 'order_id': "Order ID", + 'product_brand': "Brand", + 'product_description': "Description", + 'product_size': "Size", + 'order_created': "Ordered", + 'status_code': "Status", + } + row_grid_columns = [ - # 'upc', - 'brand_name', - 'description', - 'size', - 'vendor_name', - # 'regular_price', - # 'current_price', + 'order_id', + 'customer', + 'person', + '_product_key_', + 'product_brand', + 'product_description', + 'product_size', + 'order_quantity', + 'total_price', + 'order_created', + 'status_code', + 'flagged', ] def configure_grid(self, g): @@ -2468,6 +2481,42 @@ class PendingProductView(MasterView): def get_parent(self, item): return item.pending_product + def configure_row_grid(self, g): + super().configure_row_grid(g) + app = self.get_rattail_app() + + # order_id + g.set_renderer('order_id', lambda item, field: item.order.id) + + # contact + handler = app.get_batch_handler('custorder') + if handler.new_order_requires_customer(): + g.remove('person') + g.set_renderer('customer', lambda item, field: item.order.customer) + else: + g.remove('customer') + g.set_renderer('person', lambda item, field: item.order.person) + + # product_key + field = self.get_product_key_field() + if not self.rows_viewable: + g.set_link(field, False) + g.set_renderer(field, lambda item, field: getattr(item, f'product_{field}')) + + # "numbers" + g.set_type('order_quantity', 'quantity') + g.set_type('total_price', 'currency') + + # order_created + g.set_renderer('order_created', + lambda item, field: raw_datetime(self.rattail_config, + app.localtime(item.order.created, + from_utc=True), + as_date=True)) + + # status_code + g.set_enum('status_code', self.enum.CUSTORDER_ITEM_STATUS) + @classmethod def defaults(cls, config): cls._defaults(config) From 60044d5cdf741b578ebef7b041048296feba887d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 11 Sep 2023 15:58:35 -0500 Subject: [PATCH 1192/1681] Update changelog --- CHANGES.rst | 20 ++++++++++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index fc858f6b..c01f7418 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,26 @@ CHANGELOG ========= +0.9.49 (2023-09-11) +------------------- + +* Add custom hook for grid "apply filters". + +* Use common POST logic for submitting new customer order. + +* Optionally configure SQLAlchemy Session with ``future=True``. + +* Show related customer orders for Pending Product view. + +* Set stacklevel for all deprecation warnings. + +* Add support for toggling custorder item "flagged". + +* Add support for "mark received" when viewing custorder item. + +* Misc. improvements for custorder views. + + 0.9.48 (2023-09-08) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 014d9357..75375549 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.48' +__version__ = '0.9.49' From e930199f83183ef3e82ca64962f5e53b9b3ce687 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 11 Sep 2023 17:13:07 -0500 Subject: [PATCH 1193/1681] Avoid legacy logic for `Customer.people` schema --- tailbone/views/customers.py | 25 +++---------------------- 1 file changed, 3 insertions(+), 22 deletions(-) diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index 078cda58..0860fc31 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -146,16 +146,8 @@ class CustomerView(MasterView): query = super().query(session) app = self.get_rattail_app() model = self.model - if app.get_clientele_handler().should_use_legacy_people(): - query = query.outerjoin(model.CustomerPerson, - sa.and_( - model.CustomerPerson.customer_uuid == model.Customer.uuid, - model.CustomerPerson.ordinal == 1))\ - .outerjoin(model.Person, - model.Person.uuid == model.CustomerPerson.person_uuid) - else: - query = query.outerjoin(model.Person, - model.Person.uuid == model.Customer.account_holder_uuid) + query = query.outerjoin(model.Person, + model.Person.uuid == model.Customer.account_holder_uuid) return query def configure_grid(self, g): @@ -163,7 +155,6 @@ class CustomerView(MasterView): app = self.get_rattail_app() model = self.model route_prefix = self.get_route_prefix() - legacy = app.get_clientele_handler().should_use_legacy_people() # customer key field = self.get_customer_key_field() @@ -203,17 +194,7 @@ class CustomerView(MasterView): # person g.set_renderer('person', self.grid_render_person) - if legacy: - LegacyPerson = orm.aliased(model.Person) - g.set_joiner('person', lambda q: - q.outerjoin(model.CustomerPerson, - sa.and_( - model.CustomerPerson.customer_uuid == model.Customer.uuid, - model.CustomerPerson.ordinal == 1))\ - .outerjoin(LegacyPerson)) - g.set_sorter('person', LegacyPerson.display_name) - else: - g.set_sorter('person', model.Person.display_name) + g.set_sorter('person', model.Person.display_name) # active_in_pos if self.get_expose_active_in_pos(): From 1cad8b24810443467ea79e999ab8d6f9b2208dc1 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 12 Sep 2023 12:35:53 -0500 Subject: [PATCH 1194/1681] Show events instead of notes, in field subgrid for custorder item --- tailbone/templates/custorders/items/view.mako | 37 +--- tailbone/views/custorders/items.py | 163 ++++++++---------- 2 files changed, 77 insertions(+), 123 deletions(-) diff --git a/tailbone/templates/custorders/items/view.mako b/tailbone/templates/custorders/items/view.mako index c1aaf970..592095ff 100644 --- a/tailbone/templates/custorders/items/view.mako +++ b/tailbone/templates/custorders/items/view.mako @@ -280,7 +280,7 @@ :disabled="addNoteSaveDisabled" icon-pack="fas" icon-left="save"> - {{ addNoteSubmitText }} + {{ addNoteSubmitting ? "Working, please wait..." : "Save Note" }} </b-button> <b-button @click="showAddNoteDialog = false"> Cancel @@ -295,7 +295,7 @@ ${parent.modify_this_page_vars()} <script type="text/javascript"> - ${form.component_studly}Data.notesData = ${json.dumps(notes_data)|n} + ${form.component_studly}Data.eventsData = ${json.dumps(events_data)|n} % if master.has_perm('confirm_price'): @@ -406,7 +406,6 @@ ThisPageData.newNoteText = null ThisPageData.newNoteApplyAll = false ThisPageData.addNoteSubmitting = false - ThisPageData.addNoteSubmitText = "Save Note" ThisPage.computed.addNoteSaveDisabled = function() { if (!this.newNoteText) { @@ -429,43 +428,19 @@ ThisPage.methods.addNoteSave = function() { this.addNoteSubmitting = true - this.addNoteSubmitText = "Working, please wait..." let url = '${url('{}.add_note'.format(route_prefix), uuid=instance.uuid)}' - let params = { note: this.newNoteText, apply_all: this.newNoteApplyAll, } - let headers = { - ## TODO: should find a better way to handle CSRF token - 'X-CSRF-TOKEN': this.csrftoken, - } - - ## TODO: should find a better way to handle CSRF token - this.$http.post(url, params, {headers: headers}).then(({ data }) => { - if (data.success) { - this.$refs.mainForm.notesData = data.notes - this.showAddNoteDialog = false - } else { - this.$buefy.toast.open({ - message: "Save failed: " + (data.error || "(unknown error)"), - type: 'is-danger', - duration: 4000, // 4 seconds - }) - } + this.simplePOST(url, params, response => { + this.$refs.mainForm.eventsData = response.data.events + this.showAddNoteDialog = false this.addNoteSubmitting = false - this.addNoteSubmitText = "Save Note" - }).catch((error) => { - // TODO: should handle this better somehow..? - this.$buefy.toast.open({ - message: "Save failed: (unknown error)", - type: 'is-danger', - duration: 4000, // 4 seconds - }) + }, response => { this.addNoteSubmitting = false - this.addNoteSubmitText = "Save Note" }) } diff --git a/tailbone/views/custorders/items.py b/tailbone/views/custorders/items.py index a6853399..91976196 100644 --- a/tailbone/views/custorders/items.py +++ b/tailbone/views/custorders/items.py @@ -28,8 +28,7 @@ import datetime from sqlalchemy import orm -from rattail.db import model -from rattail.time import localtime +from rattail.db.model import CustomerOrderItem from webhelpers2.html import HTML, tags @@ -41,7 +40,7 @@ class CustomerOrderItemView(MasterView): """ Master view for customer order items """ - model_class = model.CustomerOrderItem + model_class = CustomerOrderItem route_prefix = 'custorders.items' url_prefix = '/custorders/items' creatable = False @@ -72,21 +71,6 @@ class CustomerOrderItemView(MasterView): 'flagged', ] - has_rows = True - model_row_class = model.CustomerOrderItemEvent - rows_title = "Event History" - rows_filterable = False - rows_sortable = False - rows_pageable = False - rows_viewable = False - - row_grid_columns = [ - 'occurred', - 'type_code', - 'user', - 'note', - ] - form_fields = [ 'order', 'customer', @@ -98,20 +82,32 @@ class CustomerOrderItemView(MasterView): 'product_brand', 'product_description', 'product_size', + 'case_quantity', 'order_quantity', 'order_uom', - 'case_quantity', 'unit_price', 'total_price', 'special_order', 'price_needs_confirmation', 'paid_amount', + 'payment_transaction_number', 'status_code', 'flagged', - 'notes', + 'contact_attempts', + 'last_contacted', + 'events', ] + def __init__(self, request): + super().__init__(request) + app = self.get_rattail_app() + self.custorder_handler = app.get_custorder_handler() + self.batch_handler = app.get_batch_handler( + 'custorder', + default='rattail.batch.custorder:CustomerOrderBatchHandler') + def query(self, session): + model = self.model return session.query(model.CustomerOrderItem)\ .join(model.CustomerOrder)\ .options(orm.joinedload(model.CustomerOrderItem.order)\ @@ -119,7 +115,7 @@ class CustomerOrderItemView(MasterView): def configure_grid(self, g): super().configure_grid(g) - batch_handler = self.get_batch_handler() + model = self.model # order_id g.set_renderer('order_id', self.render_order_id) @@ -155,7 +151,7 @@ class CustomerOrderItemView(MasterView): # order_uom # nb. this is not relevant if "case orders only" - if not batch_handler.allow_unit_orders(): + if not self.batch_handler.allow_unit_orders(): g.remove('order_uom') else: g.set_enum('order_uom', self.enum.UNIT_OF_MEASURE) @@ -168,6 +164,19 @@ class CustomerOrderItemView(MasterView): # status_code g.set_renderer('status_code', self.render_status_code_column) + # abbreviate some labels, only in grid header + g.set_label('case_quantity', "Case Qty") + g.filters['case_quantity'].label = "Case Quantity" + g.set_label('order_quantity', "Order Qty") + g.filters['order_quantity'].label = "Order Quantity" + g.set_label('department_name', "Department") + g.filters['department_name'].label = "Department Name" + g.set_label('total_price', "Total") + g.filters['total_price'].label = "Total Price" + g.set_label('order_created', "Ordered") + if 'order_created' in g.filters: + g.filters['order_created'].label = "Order Created" + def render_order_id(self, item, field): return item.order.id @@ -178,7 +187,8 @@ class CustomerOrderItemView(MasterView): return text def render_order_created(self, item, column): - value = localtime(self.rattail_config, item.order.created, from_utc=True) + app = self.get_rattail_app() + value = app.localtime(item.order.created, from_utc=True) return raw_datetime(self.rattail_config, value) def render_status_code_column(self, item, field): @@ -188,12 +198,6 @@ class CustomerOrderItemView(MasterView): return HTML.tag('span', title=item.status_text, c=[text]) return text - def get_batch_handler(self): - app = self.get_rattail_app() - return app.get_batch_handler( - 'custorder', - default='rattail.batch.custorder:CustomerOrderBatchHandler') - def configure_form(self, f): super().configure_form(f) item = f.model_instance @@ -202,8 +206,7 @@ class CustomerOrderItemView(MasterView): f.set_renderer('order', self.render_order) # contact - batch_handler = self.get_batch_handler() - if batch_handler.new_order_requires_customer(): + if self.batch_handler.new_order_requires_customer(): f.remove('person') else: f.remove('customer') @@ -221,7 +224,9 @@ class CustomerOrderItemView(MasterView): elif item.pending_product and not item.product: f.remove('product') - # product uom + # product* + if not self.creating and item.product: + f.remove('product_brand', 'product_description') f.set_enum('product_unit_of_measure', self.enum.UNIT_OF_MEASURE) # highlight pending fields @@ -251,8 +256,8 @@ class CustomerOrderItemView(MasterView): # flagged f.set_renderer('flagged', self.render_flagged) - # notes - f.set_renderer('notes', self.render_notes) + # events + f.set_renderer('events', self.render_events) def render_flagged(self, item, field): text = "Yes" if item.flagged else "No" @@ -375,27 +380,28 @@ class CustomerOrderItemView(MasterView): return False - def render_notes(self, item, field): + def render_events(self, item, field): route_prefix = self.get_route_prefix() factory = self.get_grid_factory() g = factory( - key=f'{route_prefix}.notes', + key=f'{route_prefix}.events', data=[], columns=[ - 'created', - 'created_by', - 'text', + 'occurred', + 'type_code', + 'user', + 'note', ], labels={ - 'created': "Date/Time", - 'created_by': "Added by", - 'text': "Note", + 'occurred': "When", + 'type_code': "What", + 'user': "Who", }, ) table = HTML.literal( - g.render_buefy_table_element(data_prop='notesData')) + g.render_buefy_table_element(data_prop='eventsData')) elements = [table] if self.has_perm('add_note'): @@ -412,12 +418,13 @@ class CustomerOrderItemView(MasterView): c=elements) def template_kwargs_view(self, **kwargs): - kwargs = super(CustomerOrderItemView, self).template_kwargs_view(**kwargs) + kwargs = super().template_kwargs_view(**kwargs) + model = self.model app = self.get_rattail_app() item = kwargs['instance'] - # fetch notes for current item - kwargs['notes_data'] = self.get_context_notes(item) + # fetch events for current item + kwargs['events_data'] = self.get_context_events(item) # fetch "other" order items, siblings of current one order = item.order @@ -431,7 +438,7 @@ class CustomerOrderItemView(MasterView): order_date = None if order.created: - order_date = localtime(self.rattail_config, order.created, from_utc=True).date() + order_date = app.localtime(order.created, from_utc=True).date() other_data.append({ 'uuid': other.uuid, @@ -454,16 +461,18 @@ class CustomerOrderItemView(MasterView): return kwargs - def get_context_notes(self, item): - notes = [] - for note in reversed(item.notes): - created = localtime(self.rattail_config, note.created, from_utc=True) - notes.append({ - 'created': raw_datetime(self.rattail_config, created), - 'created_by': note.created_by.display_name, - 'text': note.text, + def get_context_events(self, item): + app = self.get_rattail_app() + events = [] + for event in item.events: + occurred = app.localtime(event.occurred, from_utc=True) + events.append({ + 'occurred': raw_datetime(self.rattail_config, occurred), + 'type_code': self.enum.CUSTORDER_ITEM_EVENT.get(event.type_code, event.type_code), + 'user': str(event.user), + 'note': event.note, }) - return notes + return events def confirm_price(self): """ @@ -517,6 +526,7 @@ class CustomerOrderItemView(MasterView): """ View for changing status of one or more order items. """ + model = self.model order_item = self.get_instance() redirect = self.redirect(self.get_action_url('view', order_item)) @@ -570,30 +580,15 @@ class CustomerOrderItemView(MasterView): View for adding a new note to current order item, optinally also adding it to all other items under the parent order. """ - order_item = self.get_instance() + item = self.get_instance() data = self.request.json_body - new_note = data['note'] - apply_all = data['apply_all'] == True - user = self.request.user - if apply_all: - order_items = order_item.order.items - else: - order_items = [order_item] - - for item in order_items: - item.notes.append(model.CustomerOrderItemNote( - created_by=user, text=new_note)) - - # # attach event - # item.events.append(model.CustomerOrderItemEvent( - # type_code=self.enum.CUSTORDER_ITEM_EVENT_ADDED_NOTE, - # user=user, note=new_note)) + self.custorder_handler.add_note(item, data['note'], self.request.user, + apply_all=data['apply_all'] == True) self.Session.flush() - self.Session.refresh(order_item) - return {'success': True, - 'notes': self.get_context_notes(order_item)} + self.Session.refresh(item) + return {'events': self.get_context_events(item)} def render_order(self, item, field): order = item.order @@ -610,22 +605,6 @@ class CustomerOrderItemView(MasterView): url = self.request.route_url('people.view', uuid=person.uuid) return tags.link_to(text, url) - def get_row_data(self, item): - return self.Session.query(model.CustomerOrderItemEvent)\ - .filter(model.CustomerOrderItemEvent.item == item)\ - .order_by(model.CustomerOrderItemEvent.occurred, - model.CustomerOrderItemEvent.type_code) - - def configure_row_grid(self, g): - super(CustomerOrderItemView, self).configure_row_grid(g) - - g.set_enum('type_code', self.enum.CUSTORDER_ITEM_EVENT) - - g.set_label('occurred', "When") - g.set_label('type_code', "What") # TODO: enum renderer - g.set_label('user', "Who") - g.set_label('note', "Notes") - @classmethod def defaults(cls, config): cls._order_item_defaults(config) From 03fc301dec61336e540480facd809187f8db6adb Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 12 Sep 2023 18:31:18 -0500 Subject: [PATCH 1195/1681] Update changelog --- CHANGES.rst | 8 ++++++++ tailbone/_version.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index c01f7418..9a7316ef 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.9.50 (2023-09-12) +------------------- + +* Avoid legacy logic for ``Customer.people`` schema. + +* Show events instead of notes, in field subgrid for custorder item. + + 0.9.49 (2023-09-11) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 75375549..7725363d 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.49' +__version__ = '0.9.50' From 608da824d95379327282786ea5882aba3735e557 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 13 Sep 2023 13:14:00 -0500 Subject: [PATCH 1196/1681] Tweak default field list for batch views --- tailbone/views/batch/core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 1f5e2be9..79d3f581 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -100,10 +100,10 @@ class BatchMasterView(MasterView): 'description', 'notes', 'params', - 'created', - 'created_by', 'rowcount', 'status_code', + 'created', + 'created_by', 'executed', 'executed_by', ] From eed73eca81c6483939724ac69f684f7f10bcafbb Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 14 Sep 2023 12:56:15 -0500 Subject: [PATCH 1197/1681] Add `get_rattail_app()` method for view supplements --- tailbone/views/master.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 4aacc9f1..c515da7b 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -5585,6 +5585,9 @@ class ViewSupplement(object): self.rattail_config = master.rattail_config self.Session = master.Session + def get_rattail_app(self): + return self.master.get_rattail_app() + def get_grid_query(self, query): """ Return the "base" query for the grid. This is invoked from From ac6106ca697b3070f97a1fa5b7cecd5e3d5c6e84 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 15 Sep 2023 10:34:25 -0500 Subject: [PATCH 1198/1681] Update changelog --- CHANGES.rst | 8 ++++++++ tailbone/_version.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 9a7316ef..9389bb0e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.9.51 (2023-09-15) +------------------- + +* Tweak default field list for batch views. + +* Add ``get_rattail_app()`` method for view supplements. + + 0.9.50 (2023-09-12) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 7725363d..f51a34fd 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.50' +__version__ = '0.9.51' From 3968e40a0b9fb416ad7eedf0f6e07844f9debd1b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 15 Sep 2023 19:19:20 -0500 Subject: [PATCH 1199/1681] Add basic feature for "grid totals" --- tailbone/templates/master/index.mako | 35 ++++++++++++++++++++++++++++ tailbone/views/master.py | 13 +++++++++++ tailbone/views/members.py | 7 ++++++ 3 files changed, 55 insertions(+) diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako index d2215abe..b0ee17d6 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -28,6 +28,19 @@ <%def name="grid_tools()"> + ## grid totals + % if master.supports_grid_totals: + <b-button v-if="gridTotalsDisplay == null" + :disabled="gridTotalsFetching" + @click="gridTotalsFetch()"> + {{ gridTotalsFetching ? "Working, please wait..." : "Show Totals" }} + </b-button> + <div v-if="gridTotalsDisplay != null" + class="control"> + Totals: {{ gridTotalsDisplay }} + </div> + % endif + ## download search results % if master.results_downloadable and master.has_perm('download_results'): <b-button type="is-primary" @@ -321,6 +334,28 @@ ${parent.modify_this_page_vars()} <script type="text/javascript"> + % if master.supports_grid_totals: + ${grid.component_studly}Data.gridTotalsDisplay = null + ${grid.component_studly}Data.gridTotalsFetching = false + + ${grid.component_studly}.methods.gridTotalsFetch = function() { + this.gridTotalsFetching = true + + let url = '${url(f'{route_prefix}.fetch_grid_totals')}' + this.simpleGET(url, {}, response => { + this.gridTotalsDisplay = response.data.totals_display + this.gridTotalsFetching = false + }, response => { + this.gridTotalsFetching = false + }) + } + + ${grid.component_studly}.methods.appliedFiltersHook = function() { + this.gridTotalsDisplay = null + this.gridTotalsFetching = false + } + % endif + ## maybe auto-redirect to download latest results file % if download_results_path: ThisPage.methods.downloadResultsRedirect = function() { diff --git a/tailbone/views/master.py b/tailbone/views/master.py index c515da7b..04262124 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -107,6 +107,7 @@ class MasterView(View): set_deletable = False supports_autocomplete = False supports_set_enabled_toggle = False + supports_grid_totals = False populatable = False mergeable = False merge_handler = None @@ -1837,6 +1838,9 @@ class MasterView(View): self.request.session.flash("Deleted {} {}".format(len(objects), model_title_plural)) return self.redirect(self.get_index_url()) + def fetch_grid_totals(self): + return {'totals_display': "TODO: totals go here"} + def oneoff_import(self, importer, host_object=None): """ Basic helper method, to do a one-off import (or export, depending on @@ -5198,6 +5202,15 @@ class MasterView(View): config.add_view(cls, attr='download_results_rows', route_name='{}.download_results_rows'.format(route_prefix), permission='{}.download_results_rows'.format(permission_prefix)) + # fetch total hours + if cls.supports_grid_totals: + config.add_route(f'{route_prefix}.fetch_grid_totals', + f'{url_prefix}/fetch-grid-totals') + config.add_view(cls, attr='fetch_grid_totals', + route_name=f'{route_prefix}.fetch_grid_totals', + permission=f'{permission_prefix}.list', + renderer='json') + # configure if cls.configurable: config.add_tailbone_permission(permission_prefix, diff --git a/tailbone/views/members.py b/tailbone/views/members.py index d2a0e455..61b190c2 100644 --- a/tailbone/views/members.py +++ b/tailbone/views/members.py @@ -406,6 +406,7 @@ class MemberEquityPaymentView(MasterView): model_class = model.MemberEquityPayment route_prefix = 'member_equity_payments' url_prefix = '/member-equity-payments' + supports_grid_totals = True has_versions = True grid_columns = [ @@ -470,6 +471,12 @@ class MemberEquityPaymentView(MasterView): key = getattr(payment.member, field) return key + def fetch_grid_totals(self): + app = self.get_rattail_app() + results = self.get_effective_data() + total = sum([payment.amount for payment in results]) + return {'totals_display': app.render_currency(total)} + def configure_form(self, f): super().configure_form(f) model = self.model From 1cfc275eae3262ce9fb727a74de6c6328c86c003 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 15 Sep 2023 19:30:27 -0500 Subject: [PATCH 1200/1681] Update changelog --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 9389bb0e..4a84df53 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.9.52 (2023-09-15) +------------------- + +* Add basic feature for "grid totals". + + 0.9.51 (2023-09-15) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index f51a34fd..66e8068d 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.51' +__version__ = '0.9.52' From df897aef13add3501923d7522af2561a785bd9b2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 16 Sep 2023 13:06:26 -0500 Subject: [PATCH 1201/1681] Make member key field readonly when viewing equity payment --- tailbone/views/members.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/members.py b/tailbone/views/members.py index 61b190c2..0de8fa67 100644 --- a/tailbone/views/members.py +++ b/tailbone/views/members.py @@ -485,6 +485,7 @@ class MemberEquityPaymentView(MasterView): # member_key field = self.get_member_key_field() f.set_renderer(field, self.render_member_key) + f.set_readonly(field) # member if self.creating: From 99065548fff42d262c40f59a0a1dda94ff3e5648 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 16 Sep 2023 13:06:54 -0500 Subject: [PATCH 1202/1681] Update changelog --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 4a84df53..38d8afea 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.9.53 (2023-09-16) +------------------- + +* Make member key field readonly when viewing equity payment. + + 0.9.52 (2023-09-15) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 66e8068d..118085ee 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.52' +__version__ = '0.9.53' From a807a0f50c1e332b2a066b10f2a01a4b8bb6a134 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 16 Sep 2023 20:01:32 -0500 Subject: [PATCH 1203/1681] Add "falafel" custom date/time field type and widget finally able to edit datetime fields, but feels like a lot of assumptions to make, just to determine time zone..so keeping naive UTC on the backend still, and naive local on the frontend in general this needs more polish, but is a start.. --- tailbone/forms/__init__.py | 7 ++- tailbone/forms/core.py | 13 ++++++ tailbone/forms/types.py | 46 +++++++++++++++++++ tailbone/forms/widgets.py | 10 +++- .../static/js/tailbone.buefy.timepicker.js | 44 +++++++++++++++++- tailbone/templates/deform/datetime_falafel.pt | 23 ++++++++++ 6 files changed, 136 insertions(+), 7 deletions(-) create mode 100644 tailbone/templates/deform/datetime_falafel.pt diff --git a/tailbone/forms/__init__.py b/tailbone/forms/__init__.py index a368f2d1..34b34a6c 100644 --- a/tailbone/forms/__init__.py +++ b/tailbone/forms/__init__.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,7 @@ Forms Library """ -from __future__ import unicode_literals, absolute_import - -from . import types +# nb. import widgets before types, b/c types may refer to widgets from . import widgets +from . import types from .core import Form, SimpleFileImport diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index c4a7b0ea..245ee1e4 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -590,8 +590,18 @@ class Form(object): self.schema[key] = node def set_type(self, key, type_, **kwargs): + if type_ == 'datetime': self.set_renderer(key, self.render_datetime) + + elif type_ == 'datetime_falafel': + self.set_renderer(key, self.render_datetime) + self.set_node(key, types.FalafelDateTime(request=self.request)) + if kwargs.get('helptext'): + app = self.request.rattail_config.get_app() + timezone = app.get_timezone() + self.set_helptext(key, f"NOTE: all times are local to {timezone}") + elif type_ == 'datetime_local': self.set_renderer(key, self.render_datetime_local) elif type_ == 'date_plain': @@ -871,6 +881,9 @@ class Form(object): if field.cstruct is colander.null: return '[]' + if isinstance(field.schema.typ, types.FalafelDateTime): + return field.cstruct + try: return self.jsonify_value(field.cstruct) except Exception as error: diff --git a/tailbone/forms/types.py b/tailbone/forms/types.py index 0d87ae3f..173a83a2 100644 --- a/tailbone/forms/types.py +++ b/tailbone/forms/types.py @@ -26,6 +26,7 @@ Form Schema Types import re import datetime +import json from rattail.db import model from rattail.gpc import GPC @@ -33,6 +34,7 @@ from rattail.gpc import GPC import colander from tailbone.db import Session +from tailbone.forms import widgets class JQueryTime(colander.Time): @@ -72,6 +74,50 @@ class DateTimeBoolean(colander.Boolean): return datetime.datetime.utcnow() +class FalafelDateTime(colander.DateTime): + """ + Custom schema node type for rattail UTC datetimes + """ + widget_maker = widgets.FalafelDateTimeWidget + + def __init__(self, *args, **kwargs): + request = kwargs.pop('request') + super().__init__(*args, **kwargs) + self.request = request + + def serialize(self, node, appstruct): + if not appstruct: + return colander.null + + # cant use isinstance; dt subs date + if type(appstruct) is datetime.date: + appstruct = datetime.datetime.combine(appstruct, datetime.time()) + + if not isinstance(appstruct, datetime.datetime): + raise colander.Invalid(node, f'"{appstruct}" is not a datetime object') + + if appstruct.tzinfo is None: + appstruct = appstruct.replace(tzinfo=self.default_tzinfo) + + app = self.request.rattail_config.get_app() + dt = app.localtime(appstruct, from_utc=True) + + return json.dumps({ + 'date': str(dt.date()), + 'time': str(dt.time()), + }) + + def deserialize(self, node, cstruct): + if not cstruct: + return colander.null + + app = self.request.rattail_config.get_app() + result = datetime.datetime.strptime(cstruct, '%Y-%m-%dT%H:%M:%S') + result = app.localtime(result) + result = app.make_utc(result) + return result + + class GPCType(colander.SchemaType): """ Schema type for product GPC data. diff --git a/tailbone/forms/widgets.py b/tailbone/forms/widgets.py index f672ab47..69f57520 100644 --- a/tailbone/forms/widgets.py +++ b/tailbone/forms/widgets.py @@ -33,7 +33,6 @@ from deform import widget as dfwidget from webhelpers2.html import tags, HTML from tailbone.db import Session -from tailbone.forms.types import ProductQuantity class ReadonlyWidget(dfwidget.HiddenWidget): @@ -119,6 +118,8 @@ class CasesUnitsWidget(dfwidget.Widget): return field.renderer(template, **values) def deserialize(self, field, pstruct): + from tailbone.forms.types import ProductQuantity + if pstruct is colander.null: return colander.null @@ -235,6 +236,13 @@ class JQueryTimeWidget(dfwidget.TimeInputWidget): ) +class FalafelDateTimeWidget(dfwidget.DateTimeInputWidget): + """ + Custom widget for rattail UTC datetimes + """ + template = 'datetime_falafel' + + class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget): """ Uses the jQuery autocomplete plugin, instead of whatever it is deform uses diff --git a/tailbone/static/js/tailbone.buefy.timepicker.js b/tailbone/static/js/tailbone.buefy.timepicker.js index 6cca75f3..207a7940 100644 --- a/tailbone/static/js/tailbone.buefy.timepicker.js +++ b/tailbone/static/js/tailbone.buefy.timepicker.js @@ -9,15 +9,55 @@ const TailboneTimepicker = { 'placeholder="Click to select ..."', 'icon-pack="fas"', 'icon="clock"', + ':value="value ? parseTime(value) : null"', 'hour-format="12"', + '@input="timeChanged"', + ':time-formatter="formatTime"', '>', '</b-timepicker>' ].join(' '), props: { name: String, - id: String - } + id: String, + value: String, + }, + + methods: { + + formatTime(time) { + if (time === null) { + return null + } + + let h = time.getHours() + let m = time.getMinutes() + let s = time.getSeconds() + + h = h < 10 ? '0' + h : h + m = m < 10 ? '0' + m : m + s = s < 10 ? '0' + s : s + + return h + ':' + m + ':' + s + }, + + parseTime(time) { + + if (time.getHours) { + return time + } + + let found = time.match(/^(\d\d):(\d\d):\d\d$/) + if (found) { + return new Date(null, null, null, + parseInt(found[1]), parseInt(found[2])) + } + }, + + timeChanged(time) { + this.$emit('input', time) + }, + }, } Vue.component('tailbone-timepicker', TailboneTimepicker) diff --git a/tailbone/templates/deform/datetime_falafel.pt b/tailbone/templates/deform/datetime_falafel.pt new file mode 100644 index 00000000..17cfe6c3 --- /dev/null +++ b/tailbone/templates/deform/datetime_falafel.pt @@ -0,0 +1,23 @@ +<div tal:omit-tag="" + tal:define="name name|field.name; + vmodel vmodel|'field_model_' + name;"> + + <b-field grouped> + ${field.start_mapping()} + + <b-field label="Date"> + <tailbone-datepicker name="date" + v-model="${vmodel}.date"> + </tailbone-datepicker> + </b-field> + + <b-field label="Time"> + <tailbone-timepicker name="time" + v-model="${vmodel}.time"> + </tailbone-timepicker> + </b-field> + + ${field.end_mapping()} + </b-field> + +</div> From cc7b9ccb86739fbb0d50628883479a9a0c2da20d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 17 Sep 2023 17:23:59 -0500 Subject: [PATCH 1204/1681] Avoid error when history has blanks for ordering worksheet --- tailbone/api/batch/ordering.py | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/tailbone/api/batch/ordering.py b/tailbone/api/batch/ordering.py index 3c489fcd..1661d06f 100644 --- a/tailbone/api/batch/ordering.py +++ b/tailbone/api/batch/ordering.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -27,12 +27,8 @@ These views expose the basic CRUD interface to "ordering" batches, for the web API. """ -from __future__ import unicode_literals, absolute_import - import datetime -import six - from rattail.db import model from rattail.util import pretty_quantity @@ -67,10 +63,10 @@ class OrderingBatchViews(APIBatchView): data = super(OrderingBatchViews, self).normalize(batch) data['vendor_uuid'] = batch.vendor.uuid - data['vendor_display'] = six.text_type(batch.vendor) + data['vendor_display'] = str(batch.vendor) data['department_uuid'] = batch.department_uuid - data['department_display'] = six.text_type(batch.department) if batch.department else None + data['department_display'] = str(batch.department) if batch.department else None data['po_total_calculated_display'] = "${:0.2f}".format(batch.po_total_calculated or 0) data['ship_method'] = batch.ship_method @@ -152,7 +148,7 @@ class OrderingBatchViews(APIBatchView): product = cost.product subdept_costs.append({ 'uuid': cost.uuid, - 'upc': six.text_type(product.upc), + 'upc': str(product.upc), 'upc_pretty': product.upc.pretty() if product.upc else None, 'brand_name': product.brand.name if product.brand else None, 'description': product.description, @@ -173,8 +169,8 @@ class OrderingBatchViews(APIBatchView): # sort the (sub)department groupings sorted_departments = [] - for dept in sorted(six.itervalues(departments), key=lambda d: d['name']): - dept['subdepartments'] = sorted(six.itervalues(dept['subdepartments']), + for dept in sorted(departments.values(), key=lambda d: d['name']): + dept['subdepartments'] = sorted(dept['subdepartments'].values(), key=lambda s: s['name']) sorted_departments.append(dept) @@ -185,6 +181,8 @@ class OrderingBatchViews(APIBatchView): history = list(reversed(history)) # must convert some date objects to string, for JSON sake for h in history: + if not h: + continue purchase = h.get('purchase') if purchase: dt = purchase.get('date_ordered') @@ -237,7 +235,7 @@ class OrderingBatchRowViews(APIBatchRowView): data = super(OrderingBatchRowViews, self).normalize(row) data['item_id'] = row.item_id - data['upc'] = six.text_type(row.upc) + data['upc'] = str(row.upc) data['upc_pretty'] = row.upc.pretty() if row.upc else None data['brand_name'] = row.brand_name data['description'] = row.description @@ -262,7 +260,7 @@ class OrderingBatchRowViews(APIBatchRowView): data['po_total_calculated'] = row.po_total_calculated data['po_total_calculated_display'] = "${:0.2f}".format(row.po_total_calculated) if row.po_total_calculated is not None else None data['status_code'] = row.status_code - data['status_display'] = row.STATUS.get(row.status_code, six.text_type(row.status_code)) + data['status_display'] = row.STATUS.get(row.status_code, str(row.status_code)) return data From e894d1d1f4e4841e87d957b4a3de424f7a26d9ab Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 17 Sep 2023 18:03:30 -0500 Subject: [PATCH 1205/1681] Include PO number for receiving batch details via API --- tailbone/api/batch/receiving.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tailbone/api/batch/receiving.py b/tailbone/api/batch/receiving.py index a0b61f38..6c4302d2 100644 --- a/tailbone/api/batch/receiving.py +++ b/tailbone/api/batch/receiving.py @@ -59,7 +59,7 @@ class ReceivingBatchViews(APIBatchView): return query def normalize(self, batch): - data = super(ReceivingBatchViews, self).normalize(batch) + data = super().normalize(batch) data['vendor_uuid'] = batch.vendor.uuid data['vendor_display'] = str(batch.vendor) @@ -67,6 +67,7 @@ class ReceivingBatchViews(APIBatchView): data['department_uuid'] = batch.department_uuid data['department_display'] = str(batch.department) if batch.department else None + data['po_number'] = batch.po_number data['po_total'] = batch.po_total data['invoice_total'] = batch.invoice_total data['invoice_total_calculated'] = batch.invoice_total_calculated From 70956a2c476820494e2f781d207a4c592e241c77 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 17 Sep 2023 18:30:38 -0500 Subject: [PATCH 1206/1681] Tweaks to improve handling of "missing" items for receiving --- tailbone/api/batch/receiving.py | 12 ++++++++++++ tailbone/templates/receiving/configure.mako | 9 +++++++++ tailbone/views/purchases/credits.py | 4 +++- tailbone/views/purchasing/receiving.py | 3 +++ 4 files changed, 27 insertions(+), 1 deletion(-) diff --git a/tailbone/api/batch/receiving.py b/tailbone/api/batch/receiving.py index 6c4302d2..284d8fdb 100644 --- a/tailbone/api/batch/receiving.py +++ b/tailbone/api/batch/receiving.py @@ -280,6 +280,15 @@ class ReceivingBatchRowViews(APIBatchRowView): ]}, ]) + # is_missing + elif filtr['field'] == 'is_missing' and filtr['op'] == 'eq' and filtr['value'] is True: + filters.extend([ + {'or': [ + {'field': 'cases_missing', 'op': '!=', 'value': 0}, + {'field': 'units_missing', 'op': '!=', 'value': 0}, + ]}, + ]) + else: # just some filter, use as-is filters.append(filtr) @@ -326,6 +335,9 @@ class ReceivingBatchRowViews(APIBatchRowView): data['cases_expired'] = row.cases_expired data['units_expired'] = row.units_expired + data['cases_missing'] = row.cases_missing + data['units_missing'] = row.units_missing + cases, units = self.batch_handler.get_unconfirmed_counts(row) data['cases_unconfirmed'] = cases data['units_unconfirmed'] = units diff --git a/tailbone/templates/receiving/configure.mako b/tailbone/templates/receiving/configure.mako index faa13a24..92003fee 100644 --- a/tailbone/templates/receiving/configure.mako +++ b/tailbone/templates/receiving/configure.mako @@ -151,6 +151,15 @@ </b-checkbox> </b-field> + <b-field> + <b-checkbox name="rattail.batch.purchase.receiving.auto_missing_credits" + v-model="simpleSettings['rattail.batch.purchase.receiving.auto_missing_credits']" + native-value="true" + @input="settingsNeedSaved = true"> + Auto-generate "missing" (DNR) credits for items not accounted for + </b-checkbox> + </b-field> + </div> <h3 class="block is-size-3">Mobile Interface</h3> diff --git a/tailbone/views/purchases/credits.py b/tailbone/views/purchases/credits.py index ad1079a6..7da096eb 100644 --- a/tailbone/views/purchases/credits.py +++ b/tailbone/views/purchases/credits.py @@ -96,10 +96,12 @@ class PurchaseCreditView(MasterView): ] def configure_grid(self, g): - super(PurchaseCreditView, self).configure_grid(g) + super().configure_grid(g) + # vendor g.set_joiner('vendor', lambda q: q.outerjoin(model.Vendor)) g.set_sorter('vendor', model.Vendor.name) + g.set_filter('vendor', model.Vendor.name, label="Vendor Name") g.set_sort_defaults('date_received', 'desc') diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 909ded3f..e4c67af0 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -1935,6 +1935,9 @@ class ReceivingBatchView(PurchasingBatchView): {'section': 'rattail.batch', 'option': 'purchase.receiving.allow_edit_invoice_unit_cost', 'type': bool}, + {'section': 'rattail.batch', + 'option': 'purchase.receiving.auto_missing_credits', + 'type': bool}, # mobile interface {'section': 'rattail.batch', From a01fd628991c72847c2a03c00d2071623bf2cc33 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 17 Sep 2023 21:21:10 -0500 Subject: [PATCH 1207/1681] Update changelog --- CHANGES.rst | 12 ++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 38d8afea..3fba5f2a 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,18 @@ CHANGELOG ========= +0.9.54 (2023-09-17) +------------------- + +* Add "falafel" custom date/time field type and widget. + +* Avoid error when history has blanks for ordering worksheet. + +* Include PO number for receiving batch details via API. + +* Tweaks to improve handling of "missing" items for receiving. + + 0.9.53 (2023-09-16) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 118085ee..b67bad70 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.53' +__version__ = '0.9.54' From d1d69e94885531f7fd8413c000b477d68f141cb3 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 18 Sep 2023 18:28:11 -0500 Subject: [PATCH 1208/1681] Show user warning if receive quick lookup fails just b/c a UPC doesn't exist yet doesn't prevent the batch from (in some cases) adding a row for "unknown product" - but if the UPC is sufficiently invalid, that can't happen --- tailbone/api/batch/core.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tailbone/api/batch/core.py b/tailbone/api/batch/core.py index f239aaaf..c98e01f1 100644 --- a/tailbone/api/batch/core.py +++ b/tailbone/api/batch/core.py @@ -333,6 +333,9 @@ class APIBatchRowView(APIBatchMixin, APIMasterView): msg = "Feature is not implemented" return {'error': msg} + if not row: + return {'error': "Could not identify product"} + self.Session.flush() result = self._get(obj=row) result['ok'] = True From 4d8c8b199c31a55752b6c9ac2d9a33ae98f1b0f0 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 18 Sep 2023 18:37:41 -0500 Subject: [PATCH 1209/1681] Fix bug for new receiving from scratch via API --- tailbone/api/batch/receiving.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/api/batch/receiving.py b/tailbone/api/batch/receiving.py index 284d8fdb..57501a7d 100644 --- a/tailbone/api/batch/receiving.py +++ b/tailbone/api/batch/receiving.py @@ -83,7 +83,7 @@ class ReceivingBatchViews(APIBatchView): data['mode'] = self.enum.PURCHASE_BATCH_MODE_RECEIVING # assume "receive from PO" if given a PO key - if data['purchase_key']: + if data.get('purchase_key'): data['receiving_workflow'] = 'from_po' return super().create_object(data) From b566549d153ef9bdef08c336930cbb9b0a4d7217 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 18 Sep 2023 18:40:51 -0500 Subject: [PATCH 1210/1681] Update changelog --- CHANGES.rst | 8 ++++++++ tailbone/_version.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 3fba5f2a..e2d29b12 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.9.55 (2023-09-18) +------------------- + +* Show user warning if receive quick lookup fails. + +* Fix bug for new receiving from scratch via API. + + 0.9.54 (2023-09-17) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index b67bad70..028c7595 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.54' +__version__ = '0.9.55' From 1f97d4f5e5905218610849e0a0a8832b2e2fe874 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 19 Sep 2023 14:40:58 -0500 Subject: [PATCH 1211/1681] Add link to vendor name for receiving batches grid --- tailbone/views/purchasing/batch.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index 8960a522..96557d55 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -168,10 +168,12 @@ class PurchasingBatchView(BatchMasterView): super().configure_grid(g) model = self.model - g.joiners['vendor'] = lambda q: q.join(model.Vendor) - g.filters['vendor'] = g.make_filter('vendor', model.Vendor.name, - default_active=True, default_verb='contains') - g.sorters['vendor'] = g.make_sorter(model.Vendor.name) + # vendor + g.set_link('vendor') + g.set_joiner('vendor', lambda q: q.join(model.Vendor)) + g.set_sorter('vendor', model.Vendor.name) + g.set_filter('vendor', model.Vendor.name, + default_active=True, default_verb='contains') g.joiners['department'] = lambda q: q.join(model.Department) g.filters['department'] = g.make_filter('department', model.Department.name) From 6274e33a8c8f47958a05d251dd3554e3c4e40b6b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 19 Sep 2023 14:41:15 -0500 Subject: [PATCH 1212/1681] Prevent catalog/invoice cost edits if receiving batch is complete --- tailbone/views/purchasing/receiving.py | 34 +++++++++++++++++++++----- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index e4c67af0..23bb27fe 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -737,14 +737,36 @@ class ReceivingBatchView(PurchasingBatchView): return breakdown def allow_edit_catalog_unit_cost(self, batch): - return (not batch.executed - and self.has_perm('edit_row') - and self.batch_handler.allow_receiving_edit_catalog_unit_cost()) + + # batch must not yet be frozen + if batch.executed or batch.complete: + return False + + # user must have edit_row perm + if not self.has_perm('edit_row'): + return False + + # config must allow this generally + if not self.batch_handler.allow_receiving_edit_catalog_unit_cost(): + return False + + return True def allow_edit_invoice_unit_cost(self, batch): - return (not batch.executed - and self.has_perm('edit_row') - and self.batch_handler.allow_receiving_edit_invoice_unit_cost()) + + # batch must not yet be frozen + if batch.executed or batch.complete: + return False + + # user must have edit_row perm + if not self.has_perm('edit_row'): + return False + + # config must allow this generally + if not self.batch_handler.allow_receiving_edit_invoice_unit_cost(): + return False + + return True def template_kwargs_view(self, **kwargs): kwargs = super(ReceivingBatchView, self).template_kwargs_view(**kwargs) From 8b15f1304f808f6e4436b5af9fd2f1da0cdabd61 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 19 Sep 2023 14:45:48 -0500 Subject: [PATCH 1213/1681] Use small text input for receiving cost editor fields --- tailbone/templates/receiving/view.mako | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako index b01436ba..f6b3205a 100644 --- a/tailbone/templates/receiving/view.mako +++ b/tailbone/templates/receiving/view.mako @@ -102,6 +102,7 @@ <b-input v-model="inputValue" ref="input" v-show="editing" + size="is-small" @keydown.native="inputKeyDown" @focus="selectAll" @blur="inputBlur" From 510b8383a480426481e6f48a9e89f92542f2438d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 19 Sep 2023 15:03:16 -0500 Subject: [PATCH 1214/1681] Show catalog/invoice costs as 2-decimal currency in receiving --- tailbone/templates/receiving/view.mako | 4 +++- tailbone/views/purchasing/receiving.py | 21 +++++++++++++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako index f6b3205a..30bfd3a9 100644 --- a/tailbone/templates/receiving/view.mako +++ b/tailbone/templates/receiving/view.mako @@ -199,7 +199,9 @@ }, startEdit() { - this.inputValue = this.value + // nb. must strip $ sign etc. to get the real value + let value = this.value.replace(/[^\-\d\.]/g, '') + this.inputValue = parseFloat(value) || null this.editing = true this.$nextTick(() => { this.$refs.input.focus() diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 23bb27fe..0cef3a37 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -989,12 +989,14 @@ class ReceivingBatchView(PurchasingBatchView): g.filters['vendor_code'].default_verb = 'contains' # catalog_unit_cost + g.set_renderer('catalog_unit_cost', self.render_simple_unit_cost) if self.allow_edit_catalog_unit_cost(batch): g.set_raw_renderer('catalog_unit_cost', self.render_catalog_unit_cost) g.set_click_handler('catalog_unit_cost', 'this.catalogUnitCostClicked') # invoice_unit_cost + g.set_renderer('invoice_unit_cost', self.render_simple_unit_cost) if self.allow_edit_invoice_unit_cost(batch): g.set_raw_renderer('invoice_unit_cost', self.render_invoice_unit_cost) g.set_click_handler('invoice_unit_cost', @@ -1049,6 +1051,19 @@ class ReceivingBatchView(PurchasingBatchView): else: g.set_enum('truck_dump_status', model.PurchaseBatchRow.STATUS) + def render_simple_unit_cost(self, row, field): + value = getattr(row, field) + if value is None: + return + + # TODO: if anyone ever wants to see "raw" costs displayed, + # should make this configurable, b/c some folks already wanted + # the shorter 2-decimal display + #return str(value) + + app = self.get_rattail_app() + return app.render_currency(value) + def render_catalog_unit_cost(self): return HTML.tag('receiving-cost-editor', **{ 'field': 'catalog_unit_cost', @@ -1871,11 +1886,13 @@ class ReceivingBatchView(PurchasingBatchView): # okay, update our row self.handler.update_row_cost(row, **data) + self.Session.flush() + self.Session.refresh(row) return { 'row': { - 'catalog_unit_cost': '{:0.3f}'.format(row.catalog_unit_cost), + 'catalog_unit_cost': self.render_simple_unit_cost(row, 'catalog_unit_cost'), 'catalog_cost_confirmed': row.catalog_cost_confirmed, - 'invoice_unit_cost': '{:0.3f}'.format(row.invoice_unit_cost), + 'invoice_unit_cost': self.render_simple_unit_cost(row, 'invoice_unit_cost'), 'invoice_cost_confirmed': row.invoice_cost_confirmed, 'invoice_total_calculated': '{:0.2f}'.format(row.invoice_total_calculated), }, From 836fc0bf5b7de7db69336999a63c0f5cc90946a5 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 19 Sep 2023 16:37:05 -0500 Subject: [PATCH 1215/1681] Update changelog --- CHANGES.rst | 12 ++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index e2d29b12..a3fb5114 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,18 @@ CHANGELOG ========= +0.9.56 (2023-09-19) +------------------- + +* Add link to vendor name for receiving batches grid. + +* Prevent catalog/invoice cost edits if receiving batch is complete. + +* Use small text input for receiving cost editor fields. + +* Show catalog/invoice costs as 2-decimal currency in receiving. + + 0.9.55 (2023-09-18) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 028c7595..78a773b6 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.55' +__version__ = '0.9.56' From 3d6cc8a490a787fff68ade03c059e931a26b62fa Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 20 Sep 2023 18:13:52 -0500 Subject: [PATCH 1216/1681] Show yesterday by default for Trainwreck if so configured --- tailbone/views/trainwreck/base.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tailbone/views/trainwreck/base.py b/tailbone/views/trainwreck/base.py index 8ac243a0..82c5c163 100644 --- a/tailbone/views/trainwreck/base.py +++ b/tailbone/views/trainwreck/base.py @@ -165,16 +165,22 @@ class TransactionView(MasterView): return TrainwreckSession() def configure_grid(self, g): - super(TransactionView, self).configure_grid(g) + super().configure_grid(g) app = self.get_rattail_app() g.filters['receipt_number'].default_active = True g.filters['receipt_number'].default_verb = 'equal' + # end_time + g.set_sort_defaults('end_time', 'desc') g.filters['end_time'].default_active = True g.filters['end_time'].default_verb = 'equal' - g.filters['end_time'].default_value = str(app.today()) - g.set_sort_defaults('end_time', 'desc') + # TODO: should expose this setting somewhere + if self.rattail_config.getbool('trainwreck', 'show_yesterday_first'): + date = app.yesterday() + else: + date = app.today() + g.filters['end_time'].default_value = str(date) g.set_enum('system', self.enum.TRAINWRECK_SYSTEM) g.set_type('total', 'currency') From abca0115a62efa0d272e12d58c4f10604571010d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 21 Sep 2023 14:37:33 -0500 Subject: [PATCH 1217/1681] Add `remove_sorter()` method for grids --- tailbone/grids/core.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 4a748536..639eabd1 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -353,7 +353,13 @@ class Grid(object): self.joiners[key] = joiner def set_sorter(self, key, *args, **kwargs): - self.sorters[key] = self.make_sorter(*args, **kwargs) + if len(args) == 1 and args[0] is None: + self.remove_sorter(key) + else: + self.sorters[key] = self.make_sorter(*args, **kwargs) + + def remove_sorter(self, key): + self.sorters.pop(key, None) def set_sort_defaults(self, sortkey, sortdir='asc'): self.default_sortkey = sortkey From d329b2945ca5369ed233c2fc6cfbd7b3914439e5 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 21 Sep 2023 14:39:18 -0500 Subject: [PATCH 1218/1681] Show "true" (calculated) equity total in members grid pretty sure will need to tweak this but wanted something in place at least --- tailbone/views/members.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/tailbone/views/members.py b/tailbone/views/members.py index 0de8fa67..1b3735bd 100644 --- a/tailbone/views/members.py +++ b/tailbone/views/members.py @@ -122,6 +122,7 @@ class MemberView(MasterView): 'equity_current', 'joined', 'withdrew', + 'equity_total', ] form_fields = [ @@ -168,7 +169,7 @@ class MemberView(MasterView): return app.get_people_handler().get_quickie_search_placeholder() def configure_grid(self, g): - super(MemberView, self).configure_grid(g) + super().configure_grid(g) route_prefix = self.get_route_prefix() model = self.model @@ -179,10 +180,14 @@ class MemberView(MasterView): g.set_sort_defaults(field) g.set_link(field) + # person + g.set_link('person') g.set_joiner('person', lambda q: q.outerjoin(model.Person)) g.set_sorter('person', model.Person.display_name) g.set_filter('person', model.Person.display_name) + # customer + g.set_link('customer') g.set_joiner('customer', lambda q: q.outerjoin(model.Customer)) g.set_sorter('customer', model.Customer.name) g.set_filter('customer', model.Customer.name) @@ -223,8 +228,17 @@ class MemberView(MasterView): g.main_actions.insert(1, self.make_action( 'view_raw', url=url, icon='eye')) - g.set_link('person') - g.set_link('customer') + # equity_total + # TODO: should make this configurable + # g.set_type('equity_total', 'currency') + g.set_renderer('equity_total', self.render_equity_total) + g.remove_sorter('equity_total') + g.remove_filter('equity_total') + + def render_equity_total(self, member, field): + app = self.get_rattail_app() + equity = app.get_membership_handler().get_equity_total(member, cached=False) + return app.render_currency(equity) def default_view_url(self): if (self.request.has_perm('people.view_profile') From 53e8c15267cb275b36f7532db67d2a252df8e1f4 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 23 Sep 2023 11:14:43 -0500 Subject: [PATCH 1219/1681] Add basic views for POS batches --- tailbone/views/batch/pos.py | 148 ++++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 tailbone/views/batch/pos.py diff --git a/tailbone/views/batch/pos.py b/tailbone/views/batch/pos.py new file mode 100644 index 00000000..402a70b4 --- /dev/null +++ b/tailbone/views/batch/pos.py @@ -0,0 +1,148 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2023 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Views for POS batches +""" + +from rattail.db.model import POSBatch, POSBatchRow + +from tailbone.views.batch import BatchMasterView + + +class POSBatchView(BatchMasterView): + """ + Master view for POS batches + """ + model_class = POSBatch + model_row_class = POSBatchRow + default_handler_spec = 'rattail.batch.pos:POSBatchHandler' + route_prefix = 'batch.pos' + url_prefix = '/batch/pos' + creatable = False + + grid_columns = [ + 'id', + 'created', + 'created_by', + 'rowcount', + 'sales_total', + 'void', + 'status_code', + 'executed', + 'executed_by', + ] + + form_fields = [ + 'id', + 'description', + 'notes', + 'params', + 'rowcount', + 'sales_total', + 'tax1_total', + 'tax2_total', + 'status_code', + 'created', + 'created_by', + 'executed', + 'executed_by', + ] + + row_grid_columns = [ + 'sequence', + 'row_type', + 'product', + 'description', + 'reg_price', + 'txn_price', + 'quantity', + 'sales_total', + 'status_code', + ] + + row_form_fields = [ + 'sequence', + 'row_type', + 'item_entry', + 'product', + 'description', + 'reg_price', + 'txn_price', + 'quantity', + 'sales_total', + 'tax1_total', + 'tax2_total', + 'status_code', + ] + + def configure_grid(self, g): + super().configure_grid(g) + + g.set_type('sales_total', 'currency') + g.set_type('tax1_total', 'currency') + g.set_type('tax2_total', 'currency') + + g.set_link('created') + g.set_link('created_by') + + def grid_extra_class(self, batch, i): + if batch.void: + return 'warning' + + def configure_form(self, f): + super().configure_form(f) + + f.set_type('sales_total', 'currency') + f.set_type('tax1_total', 'currency') + f.set_type('tax2_total', 'currency') + + def configure_row_grid(self, g): + super().configure_row_grid(g) + + g.set_type('quantity', 'quantity') + g.set_type('reg_price', 'currency') + g.set_type('txn_price', 'currency') + g.set_type('sales_total', 'currency') + + g.set_link('product') + g.set_link('description') + + def configure_row_form(self, f): + super().configure_row_form(f) + + f.set_type('quantity', 'quantity') + f.set_type('reg_price', 'currency') + f.set_type('txn_price', 'currency') + f.set_type('sales_total', 'currency') + f.set_renderer('product', self.render_product) + + +def defaults(config, **kwargs): + base = globals() + + POSBatchView = kwargs.get('POSBatchView', base['POSBatchView']) + POSBatchView.defaults(config) + + +def includeme(config): + defaults(config) From 91ac1a9031c794d628f92fe64ff68c2ea33acceb Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 23 Sep 2023 20:01:29 -0500 Subject: [PATCH 1220/1681] Show customer for POS batches --- tailbone/views/batch/pos.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tailbone/views/batch/pos.py b/tailbone/views/batch/pos.py index 402a70b4..7d71a88a 100644 --- a/tailbone/views/batch/pos.py +++ b/tailbone/views/batch/pos.py @@ -42,6 +42,7 @@ class POSBatchView(BatchMasterView): grid_columns = [ 'id', + 'customer', 'created', 'created_by', 'rowcount', @@ -54,8 +55,7 @@ class POSBatchView(BatchMasterView): form_fields = [ 'id', - 'description', - 'notes', + 'customer', 'params', 'rowcount', 'sales_total', @@ -98,13 +98,15 @@ class POSBatchView(BatchMasterView): def configure_grid(self, g): super().configure_grid(g) - g.set_type('sales_total', 'currency') - g.set_type('tax1_total', 'currency') - g.set_type('tax2_total', 'currency') + g.set_link('customer') g.set_link('created') g.set_link('created_by') + g.set_type('sales_total', 'currency') + g.set_type('tax1_total', 'currency') + g.set_type('tax2_total', 'currency') + def grid_extra_class(self, batch, i): if batch.void: return 'warning' @@ -112,6 +114,8 @@ class POSBatchView(BatchMasterView): def configure_form(self, f): super().configure_form(f) + f.set_renderer('customer', self.render_customer) + f.set_type('sales_total', 'currency') f.set_type('tax1_total', 'currency') f.set_type('tax2_total', 'currency') From bda05aed86e13ed4d46f29e44a052646fa9a6c4b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 24 Sep 2023 08:37:50 -0500 Subject: [PATCH 1221/1681] Use header button instead of link for "touch" instance --- tailbone/templates/master/view.mako | 31 ++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako index 69485dd1..e6d0c8de 100644 --- a/tailbone/templates/master/view.mako +++ b/tailbone/templates/master/view.mako @@ -7,6 +7,18 @@ ${instance_title} </%def> +<%def name="render_instance_header_title_extras()"> + <span style="width: 2rem;"></span> + % if master.touchable and master.has_perm('touch'): + <b-button title=""Touch" this record to trigger sync" + icon-pack="fas" + icon-left="hand-pointer" + @click="touchRecord()" + :disabled="touchSubmitting"> + </b-button> + % endif +</%def> + <%def name="object_helpers()"> ${parent.object_helpers()} ${self.render_xref_helper()} @@ -37,9 +49,6 @@ % if master.has_versions and request.rattail_config.versioning_enabled() and request.has_perm('{}.versions'.format(permission_prefix)): <li>${h.link_to("Version History", action_url('versions', instance))}</li> % endif - % if master.touchable and request.has_perm('{}.touch'.format(permission_prefix)): - <li>${h.link_to("\"Touch\" this {}".format(model_title), master.get_action_url('touch', instance))}</li> - % endif </%def> <%def name="render_row_grid_tools()"> @@ -83,6 +92,22 @@ ${parent.render_this_page_template()} </%def> +<%def name="modify_whole_page_vars()"> + ${parent.modify_whole_page_vars()} + % if master.touchable and master.has_perm('touch'): + <script type="text/javascript"> + + WholePageData.touchSubmitting = false + + WholePage.methods.touchRecord = function() { + this.touchSubmitting = true + location.href = '${master.get_action_url('touch', instance)}' + } + + </script> + % endif +</%def> + <%def name="finalize_this_page_vars()"> ${parent.finalize_this_page_vars()} % if master.has_rows: From 5a2612acab2dc94271dfa5f1c315a5206c35588f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 24 Sep 2023 14:47:54 -0500 Subject: [PATCH 1222/1681] Update changelog --- CHANGES.rst | 16 ++++++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index a3fb5114..6b58e0e4 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,22 @@ CHANGELOG ========= +0.9.57 (2023-09-24) +------------------- + +* Show yesterday by default for Trainwreck if so configured. + +* Add ``remove_sorter()`` method for grids. + +* Show "true" (calculated) equity total in members grid. + +* Add basic views for POS batches. + +* Show customer for POS batches. + +* Use header button instead of link for "touch" instance. + + 0.9.56 (2023-09-19) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 78a773b6..6b1da83b 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.56' +__version__ = '0.9.57' From 3e56950872a125b7c5ac91cfc57af78ad26d82c5 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 24 Sep 2023 19:30:59 -0500 Subject: [PATCH 1223/1681] Expose POS batch views as "typical" --- tailbone/menus.py | 5 +++++ tailbone/views/typical.py | 1 + 2 files changed, 6 insertions(+) diff --git a/tailbone/menus.py b/tailbone/menus.py index c26484f0..b50233f8 100644 --- a/tailbone/menus.py +++ b/tailbone/menus.py @@ -513,6 +513,11 @@ class MenuHandler(GenericHandler): 'route': 'batch.importer', 'perm': 'batch.importer.list', }, + { + 'title': "POS", + 'route': 'batch.pos', + 'perm': 'batch.pos.list', + }, ], } diff --git a/tailbone/views/typical.py b/tailbone/views/typical.py index 8b5c9a07..d3450fbd 100644 --- a/tailbone/views/typical.py +++ b/tailbone/views/typical.py @@ -50,6 +50,7 @@ def defaults(config, **kwargs): config.include(mod('tailbone.views.batch.handheld')) config.include(mod('tailbone.views.batch.importer')) config.include(mod('tailbone.views.batch.inventory')) + config.include(mod('tailbone.views.batch.pos')) config.include(mod('tailbone.views.batch.vendorcatalog')) config.include(mod('tailbone.views.purchasing')) From 032d37194fcfe408b1470ebf1537678872504776 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 25 Sep 2023 18:06:16 -0500 Subject: [PATCH 1224/1681] Update changelog --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 6b58e0e4..2ee4ef21 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.9.58 (2023-09-25) +------------------- + +* Expose POS batch views as "typical". + + 0.9.57 (2023-09-24) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 6b1da83b..fdbfb1a9 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.57' +__version__ = '0.9.58' From e23b2f8711390f35a688a8a357c8b7ccf32c93c4 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 25 Sep 2023 19:22:02 -0500 Subject: [PATCH 1225/1681] Add custom form type/widget for time fields ugh this still isn't that great, but making progress overall --- tailbone/forms/core.py | 5 +++++ tailbone/forms/types.py | 12 ++++++++++++ tailbone/forms/widgets.py | 12 ++++++++++++ tailbone/templates/deform/time_falafel.pt | 7 +++++++ 4 files changed, 36 insertions(+) create mode 100644 tailbone/templates/deform/time_falafel.pt diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 245ee1e4..53c234db 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -610,9 +610,14 @@ class Form(object): # TODO: is this safe / a good idea? # self.set_node(key, colander.Date()) self.set_widget(key, JQueryDateWidget()) + elif type_ == 'time_jquery': self.set_node(key, types.JQueryTime()) self.set_widget(key, JQueryTimeWidget()) + + elif type_ == 'time_falafel': + self.set_node(key, types.FalafelTime(request=self.request)) + elif type_ == 'duration': self.set_renderer(key, self.render_duration) elif type_ == 'boolean': diff --git a/tailbone/forms/types.py b/tailbone/forms/types.py index 173a83a2..026bc598 100644 --- a/tailbone/forms/types.py +++ b/tailbone/forms/types.py @@ -118,6 +118,18 @@ class FalafelDateTime(colander.DateTime): return result +class FalafelTime(colander.Time): + """ + Custom schema node type for simple time fields + """ + widget_maker = widgets.FalafelTimeWidget + + def __init__(self, *args, **kwargs): + request = kwargs.pop('request') + super().__init__(*args, **kwargs) + self.request = request + + class GPCType(colander.SchemaType): """ Schema type for product GPC data. diff --git a/tailbone/forms/widgets.py b/tailbone/forms/widgets.py index 69f57520..a8810e69 100644 --- a/tailbone/forms/widgets.py +++ b/tailbone/forms/widgets.py @@ -243,6 +243,18 @@ class FalafelDateTimeWidget(dfwidget.DateTimeInputWidget): template = 'datetime_falafel' +class FalafelTimeWidget(dfwidget.TimeInputWidget): + """ + Custom widget for simple time fields + """ + template = 'time_falafel' + + def deserialize(self, field, pstruct): + if pstruct == '': + return colander.null + return pstruct + + class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget): """ Uses the jQuery autocomplete plugin, instead of whatever it is deform uses diff --git a/tailbone/templates/deform/time_falafel.pt b/tailbone/templates/deform/time_falafel.pt new file mode 100644 index 00000000..00ebc2f0 --- /dev/null +++ b/tailbone/templates/deform/time_falafel.pt @@ -0,0 +1,7 @@ +<div tal:omit-tag="" + tal:define="name name|field.name; + vmodel vmodel|'field_model_' + name;"> + <tailbone-timepicker name="${name}" + v-model="${vmodel}"> + </tailbone-timepicker> +</div> From a11be5a1e10df98145491705e8aac3f30a6f41ce Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 25 Sep 2023 19:41:59 -0500 Subject: [PATCH 1226/1681] Update changelog --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 2ee4ef21..2e17dc24 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.9.59 (2023-09-25) +------------------- + +* Add custom form type/widget for time fields. + + 0.9.58 (2023-09-25) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index fdbfb1a9..7b773591 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.58' +__version__ = '0.9.59' From a9e9474f5cfa577356bec123358b0a91de7e6035 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 26 Sep 2023 09:32:57 -0500 Subject: [PATCH 1227/1681] Do not allow executing custorder if no customer is set or really any reason, as defined by handler --- tailbone/views/custorders/orders.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index abbcf87c..f88886bb 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -956,6 +956,11 @@ class CustomerOrderView(MasterView): 'batch': self.normalize_batch(batch)} def submit_new_order(self, batch, data): + + reason = self.batch_handler.why_not_execute(batch, user=self.request.user) + if reason: + return {'error': reason} + try: result = self.execute_new_order_batch(batch, data) except Exception as error: From abcf1e1895097d75ece12010b267c6026523191c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 26 Sep 2023 17:52:17 -0500 Subject: [PATCH 1228/1681] Add clone support for POS batches just for testing of course.. --- tailbone/views/batch/pos.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tailbone/views/batch/pos.py b/tailbone/views/batch/pos.py index 7d71a88a..7c9d5586 100644 --- a/tailbone/views/batch/pos.py +++ b/tailbone/views/batch/pos.py @@ -39,9 +39,15 @@ class POSBatchView(BatchMasterView): route_prefix = 'batch.pos' url_prefix = '/batch/pos' creatable = False + cloneable = True + + labels = { + 'terminal_id': "Terminal ID", + } grid_columns = [ 'id', + 'terminal_id', 'customer', 'created', 'created_by', @@ -55,6 +61,7 @@ class POSBatchView(BatchMasterView): form_fields = [ 'id', + 'terminal_id', 'customer', 'params', 'rowcount', @@ -71,7 +78,7 @@ class POSBatchView(BatchMasterView): row_grid_columns = [ 'sequence', 'row_type', - 'product', + 'item_entry', 'description', 'reg_price', 'txn_price', @@ -98,6 +105,11 @@ class POSBatchView(BatchMasterView): def configure_grid(self, g): super().configure_grid(g) + # terminal_id + g.set_label('terminal_id', "Terminal") + if 'terminal_id' in g.filters: + g.filters['terminal_id'].label = self.labels.get('terminal_id', "Terminal ID") + g.set_link('customer') g.set_link('created') From f572757f0091fe09ecd5409e06eb44c94016d434 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 27 Sep 2023 17:13:49 -0500 Subject: [PATCH 1229/1681] Expose views for tenders, more columns for POS batch/rows --- tailbone/menus.py | 33 +++++++++++++----- tailbone/views/batch/pos.py | 29 +++++++++++++++- tailbone/views/tenders.py | 67 +++++++++++++++++++++++++++++++++++++ tailbone/views/typical.py | 1 + 4 files changed, 120 insertions(+), 10 deletions(-) create mode 100644 tailbone/views/tenders.py diff --git a/tailbone/menus.py b/tailbone/menus.py index b50233f8..36189b88 100644 --- a/tailbone/menus.py +++ b/tailbone/menus.py @@ -625,15 +625,30 @@ class MenuHandler(GenericHandler): """ items = [] - if kwargs.get('include_stores', True): - items.extend([ - { - 'title': "Stores", - 'route': 'stores', - 'perm': 'stores.list', - }, - {'type': 'sep'}, - ]) + include_stores = kwargs.get('include_stores', True) + include_tenders = kwargs.get('include_tenders', True) + + if include_stores or include_tenders: + + if include_stores: + items.extend([ + { + 'title': "Stores", + 'route': 'stores', + 'perm': 'stores.list', + }, + ]) + + if include_tenders: + items.extend([ + { + 'title': "Tenders", + 'route': 'tenders', + 'perm': 'tenders.list', + }, + ]) + + items.append({'type': 'sep'}) items.extend([ { diff --git a/tailbone/views/batch/pos.py b/tailbone/views/batch/pos.py index 7c9d5586..d2a38314 100644 --- a/tailbone/views/batch/pos.py +++ b/tailbone/views/batch/pos.py @@ -68,6 +68,10 @@ class POSBatchView(BatchMasterView): 'sales_total', 'tax1_total', 'tax2_total', + 'tender_total', + 'balance', + 'void', + 'training_mode', 'status_code', 'created', 'created_by', @@ -84,6 +88,7 @@ class POSBatchView(BatchMasterView): 'txn_price', 'quantity', 'sales_total', + 'tender_total', 'status_code', ] @@ -99,7 +104,10 @@ class POSBatchView(BatchMasterView): 'sales_total', 'tax1_total', 'tax2_total', + 'tender_total', 'status_code', + 'timestamp', + 'user', ] def configure_grid(self, g): @@ -118,19 +126,33 @@ class POSBatchView(BatchMasterView): g.set_type('sales_total', 'currency') g.set_type('tax1_total', 'currency') g.set_type('tax2_total', 'currency') + g.set_type('tender_total', 'currency') + + # executed + # nb. default view should show "all recent" batches regardless + # of execution (i think..) + if 'executed' in g.filters: + g.filters['executed'].default_active = False def grid_extra_class(self, batch, i): if batch.void: return 'warning' + if batch.training_mode: + return 'notice' def configure_form(self, f): super().configure_form(f) + app = self.get_rattail_app() f.set_renderer('customer', self.render_customer) f.set_type('sales_total', 'currency') f.set_type('tax1_total', 'currency') f.set_type('tax2_total', 'currency') + f.set_type('tender_total', 'currency') + f.set_type('tender_total', 'currency') + + f.set_renderer('balance', lambda batch, field: app.render_currency(batch.get_balance())) def configure_row_grid(self, g): super().configure_row_grid(g) @@ -139,6 +161,7 @@ class POSBatchView(BatchMasterView): g.set_type('reg_price', 'currency') g.set_type('txn_price', 'currency') g.set_type('sales_total', 'currency') + g.set_type('tender_total', 'currency') g.set_link('product') g.set_link('description') @@ -146,11 +169,15 @@ class POSBatchView(BatchMasterView): def configure_row_form(self, f): super().configure_row_form(f) + f.set_renderer('product', self.render_product) + f.set_type('quantity', 'quantity') f.set_type('reg_price', 'currency') f.set_type('txn_price', 'currency') f.set_type('sales_total', 'currency') - f.set_renderer('product', self.render_product) + f.set_type('tender_total', 'currency') + + f.set_renderer('user', self.render_user) def defaults(config, **kwargs): diff --git a/tailbone/views/tenders.py b/tailbone/views/tenders.py new file mode 100644 index 00000000..a95773e3 --- /dev/null +++ b/tailbone/views/tenders.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2023 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Views for tenders +""" + +from rattail.db.model import Tender + +from tailbone.views import MasterView + + +class TenderView(MasterView): + """ + Master view for the Tender class. + """ + model_class = Tender + has_versions = True + + grid_columns = [ + 'code', + 'name', + ] + + form_fields = [ + 'code', + 'name', + 'notes', + ] + + def configure_grid(self, g): + super().configure_grid(g) + + g.set_link('code') + + g.set_link('name') + g.set_sort_defaults('name') + + +def defaults(config, **kwargs): + base = globals() + + TenderView = kwargs.get('TenderView', base['TenderView']) + TenderView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/typical.py b/tailbone/views/typical.py index d3450fbd..ed94d552 100644 --- a/tailbone/views/typical.py +++ b/tailbone/views/typical.py @@ -43,6 +43,7 @@ def defaults(config, **kwargs): config.include(mod('tailbone.views.reportcodes')) config.include(mod('tailbone.views.stores')) config.include(mod('tailbone.views.subdepartments')) + config.include(mod('tailbone.views.tenders')) config.include(mod('tailbone.views.uoms')) config.include(mod('tailbone.views.vendors')) From 0ee67251889e84435fb0348179ecddfa922c1957 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 28 Sep 2023 10:56:15 -0500 Subject: [PATCH 1230/1681] Tidy up logic for vendor filtering in products grid was hoping to "fix" count issue but alas.. refs #23 --- tailbone/views/products.py | 74 ++++++++++++++++++-------------------- 1 file changed, 35 insertions(+), 39 deletions(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 2b03871b..0ee53093 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -86,6 +86,8 @@ class ProductView(MasterView): labels = { 'item_id': "Item ID", 'upc': "UPC", + 'vendor': "Vendor (preferred)", + 'vendor_any': "Vendor (any)", 'status_code': "Status", 'tax1': "Tax 1", 'tax2': "Tax 2", @@ -158,13 +160,6 @@ class ProductView(MasterView): 'inventory_on_order', ] - # These aliases enable the grid queries to filter products which may be - # purchased from *any* vendor, and yet sort by only the "preferred" vendor - # (since that's what shows up in the grid column). - ProductVendorCost = orm.aliased(model.ProductCost) - ProductVendorCostAny = orm.aliased(model.ProductCost) - VendorAny = orm.aliased(model.Vendor) - # same, but for prices RegularPrice = orm.aliased(model.ProductPrice) CurrentPrice = orm.aliased(model.ProductPrice) @@ -184,14 +179,11 @@ class ProductView(MasterView): self.handler = self.products_handler def query(self, session): - query = super(ProductView, self).query(session) + query = super().query(session) if not self.has_perm('view_deleted'): query = query.filter(model.Product.deleted == False) - # TODO: surely this is not always needed - query = query.outerjoin(model.ProductInventory) - return query def get_departments(self): @@ -207,23 +199,10 @@ class ProductView(MasterView): .all() def configure_grid(self, g): - super(ProductView, self).configure_grid(g) + super().configure_grid(g) app = self.get_rattail_app() model = self.model - def join_vendor(q): - return q.outerjoin(self.ProductVendorCost, - sa.and_( - self.ProductVendorCost.product_uuid == model.Product.uuid, - self.ProductVendorCost.preference == 1))\ - .outerjoin(model.Vendor) - - def join_vendor_any(q): - return q.outerjoin(self.ProductVendorCostAny, - self.ProductVendorCostAny.product_uuid == model.Product.uuid)\ - .outerjoin(self.VendorAny, - self.VendorAny.uuid == self.ProductVendorCostAny.vendor_uuid) - ProductCostCode = orm.aliased(model.ProductCost) ProductCostCodeAny = orm.aliased(model.ProductCost) @@ -261,12 +240,33 @@ class ProductView(MasterView): g.joiners['subdepartment'] = lambda q: q.outerjoin(model.Subdepartment, model.Subdepartment.uuid == model.Product.subdepartment_uuid) g.joiners['code'] = lambda q: q.outerjoin(model.ProductCode) - g.joiners['vendor'] = join_vendor - g.joiners['vendor_any'] = join_vendor_any g.sorters['brand'] = g.make_sorter(model.Brand.name) g.sorters['subdepartment'] = g.make_sorter(model.Subdepartment.name) - g.sorters['vendor'] = g.make_sorter(model.Vendor.name) + + # vendor + ProductVendorCost = orm.aliased(model.ProductCost) + def join_vendor(q): + return q.outerjoin(ProductVendorCost, + sa.and_( + ProductVendorCost.product_uuid == model.Product.uuid, + ProductVendorCost.preference == 1))\ + .outerjoin(model.Vendor) + g.set_joiner('vendor', join_vendor) + g.set_sorter('vendor', model.Vendor.name) + g.set_filter('vendor', model.Vendor.name) + + # vendor_any + ProductVendorCostAny = orm.aliased(model.ProductCost) + VendorAny = orm.aliased(model.Vendor) + def join_vendor_any(q): + return q.outerjoin(ProductVendorCostAny, + ProductVendorCostAny.product_uuid == model.Product.uuid)\ + .outerjoin(VendorAny, + VendorAny.uuid == ProductVendorCostAny.vendor_uuid) + g.set_joiner('vendor_any', join_vendor_any) + g.set_filter('vendor_any', VendorAny.name) + # factory=VendorAnyFilter, joiner=join_vendor_any) ProductTrueCost = orm.aliased(model.ProductVolatile) ProductTrueMargin = orm.aliased(model.ProductVolatile) @@ -284,12 +284,15 @@ class ProductView(MasterView): g.set_renderer('true_margin', self.render_true_margin) # on_hand - g.set_sorter('on_hand', model.ProductInventory.on_hand) - g.set_filter('on_hand', model.ProductInventory.on_hand) + InventoryOnHand = orm.aliased(model.ProductInventory) + g.set_joiner('on_hand', lambda q: q.outerjoin(InventoryOnHand)) + g.set_sorter('on_hand', InventoryOnHand.on_hand) + g.set_filter('on_hand', InventoryOnHand.on_hand) # on_order - g.set_sorter('on_order', model.ProductInventory.on_order) - g.set_filter('on_order', model.ProductInventory.on_order) + InventoryOnOrder = orm.aliased(model.ProductInventory) + g.set_sorter('on_order', InventoryOnOrder.on_order) + g.set_filter('on_order', InventoryOnOrder.on_order) g.filters['description'].default_active = True g.filters['description'].default_verb = 'contains' @@ -297,9 +300,6 @@ class ProductView(MasterView): default_active=True, default_verb='contains') g.filters['subdepartment'] = g.make_filter('subdepartment', model.Subdepartment.name) g.filters['code'] = g.make_filter('code', model.ProductCode.code) - g.filters['vendor'] = g.make_filter('vendor', model.Vendor.name) - g.filters['vendor_any'] = g.make_filter('vendor_any', self.VendorAny.name) - # factory=VendorAnyFilter, joiner=join_vendor_any) # g.joiners['vendor_code_any'] = join_vendor_code_any # g.filters['vendor_code_any'] = g.make_filter('vendor_code_any', ProductCostCodeAny.code) @@ -382,10 +382,6 @@ class ProductView(MasterView): g.set_link('item_id') g.set_link('description') - g.set_label('vendor', "Vendor (preferred)") - g.set_label('vendor_any', "Vendor (any)") - g.set_label('vendor', "Vendor (preferred)") - def configure_common_form(self, f): super(ProductView, self).configure_common_form(f) product = f.model_instance From 9f7e70f240f27138bb05109f52131586d223d2a9 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 30 Sep 2023 21:08:01 -0500 Subject: [PATCH 1231/1681] Add support for void rows in POS batch --- tailbone/views/batch/pos.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tailbone/views/batch/pos.py b/tailbone/views/batch/pos.py index d2a38314..e4c787f9 100644 --- a/tailbone/views/batch/pos.py +++ b/tailbone/views/batch/pos.py @@ -105,6 +105,7 @@ class POSBatchView(BatchMasterView): 'tax1_total', 'tax2_total', 'tender_total', + 'void', 'status_code', 'timestamp', 'user', @@ -166,6 +167,10 @@ class POSBatchView(BatchMasterView): g.set_link('product') g.set_link('description') + def row_grid_extra_class(self, row, i): + if row.void: + return 'warning' + def configure_row_form(self, f): super().configure_row_form(f) From a6bc3fb793ca9ac3926e5dc7604b686bc7c62942 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 1 Oct 2023 12:09:32 -0500 Subject: [PATCH 1232/1681] Update changelog --- CHANGES.rst | 14 ++++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 2e17dc24..8cce23d1 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,20 @@ CHANGELOG ========= +0.9.60 (2023-10-01) +------------------- + +* Do not allow executing custorder if no customer is set. + +* Add clone support for POS batches. + +* Expose views for tenders, more columns for POS batch/rows. + +* Tidy up logic for vendor filtering in products grid. + +* Add support for void rows in POS batch. + + 0.9.59 (2023-09-25) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 7b773591..27e2acc7 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.59' +__version__ = '0.9.60' From b7ccc6ea0705ac863081c758fb93930f7ad7b8ad Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 1 Oct 2023 17:31:33 -0500 Subject: [PATCH 1233/1681] Use enum to display `POS_ROW_TYPE` --- tailbone/views/batch/pos.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tailbone/views/batch/pos.py b/tailbone/views/batch/pos.py index e4c787f9..c8ceede5 100644 --- a/tailbone/views/batch/pos.py +++ b/tailbone/views/batch/pos.py @@ -158,6 +158,8 @@ class POSBatchView(BatchMasterView): def configure_row_grid(self, g): super().configure_row_grid(g) + g.set_enum('row_type', self.enum.POS_ROW_TYPE) + g.set_type('quantity', 'quantity') g.set_type('reg_price', 'currency') g.set_type('txn_price', 'currency') @@ -174,6 +176,8 @@ class POSBatchView(BatchMasterView): def configure_row_form(self, f): super().configure_row_form(f) + g.set_enum('row_type', self.enum.POS_ROW_TYPE) + f.set_renderer('product', self.render_product) f.set_type('quantity', 'quantity') From 746e13d134d96747b0969baec883d9048e117bb5 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 1 Oct 2023 18:54:56 -0500 Subject: [PATCH 1234/1681] Expose cash-back flags for tenders --- tailbone/views/tenders.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tailbone/views/tenders.py b/tailbone/views/tenders.py index a95773e3..54a0cdba 100644 --- a/tailbone/views/tenders.py +++ b/tailbone/views/tenders.py @@ -39,11 +39,15 @@ class TenderView(MasterView): grid_columns = [ 'code', 'name', + 'is_cash', + 'allow_cash_back', ] form_fields = [ 'code', 'name', + 'is_cash', + 'allow_cash_back', 'notes', ] @@ -55,6 +59,11 @@ class TenderView(MasterView): g.set_link('name') g.set_sort_defaults('name') + def configure_form(self, f): + super().configure_form(f) + + f.set_type('notes', 'text') + def defaults(config, **kwargs): base = globals() From 4125be7e8d919fca2e8e1c1ce1f9fd509c5b1b11 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 2 Oct 2023 09:54:34 -0500 Subject: [PATCH 1235/1681] Re-work FalafelDateTime logic a bit need to be more "standard" in how (de)serialize works etc. also be sure to show error messages if present, not just field helptext --- tailbone/forms/core.py | 42 ++++++++++++++++++--------------------- tailbone/forms/types.py | 17 +++++++++++++--- tailbone/forms/widgets.py | 11 ++++++++++ 3 files changed, 44 insertions(+), 26 deletions(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 53c234db..97e23a25 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -886,9 +886,6 @@ class Form(object): if field.cstruct is colander.null: return '[]' - if isinstance(field.schema.typ, types.FalafelDateTime): - return field.cstruct - try: return self.jsonify_value(field.cstruct) except Exception as error: @@ -980,32 +977,31 @@ class Form(object): if field and isinstance(field.schema.typ, deform.FileData): attrs['class_'] = 'file' - # show helptext if present - # TODO: older logic did this only if field was *not* - # readonly, perhaps should add that back.. - if self.has_helptext(fieldname): - msgkey = 'message' - if self.dynamic_helptext.get(fieldname): - msgkey = ':message' - attrs[msgkey] = self.render_helptext(fieldname) + # next we will build array of messages to display..some + # fields always show a "helptext" msg, and some may have + # validation errors.. + field_type = None + messages = [] # show errors if present error_messages = self.get_error_messages(field) if field else None if error_messages: + field_type = 'is-danger' + messages.extend(error_messages) - # TODO: this surely can't be what we ought to do - # here..? seems like we must pass JS but not JSON, - # sort of, so we custom-write the JS code to ensure - # single instead of double quotes delimit strings - # within the code. - message = '[{}]'.format(', '.join([ + # show helptext if present + # TODO: older logic did this only if field was *not* + # readonly, perhaps should add that back.. + if self.has_helptext(fieldname): + messages.append(self.render_helptext(fieldname)) + + # ..okay now we can declare the field messages and type + if field_type: + attrs['type'] = field_type + if messages: + attrs[':message'] = '[{}]'.format(', '.join([ "'{}'".format(msg.replace("'", r"\'")) - for msg in error_messages])) - - attrs.update({ - 'type': 'is-danger', - ':message': message, - }) + for msg in messages])) # merge anything caller provided attrs.update(bfield_attrs) diff --git a/tailbone/forms/types.py b/tailbone/forms/types.py index 026bc598..3e4952e4 100644 --- a/tailbone/forms/types.py +++ b/tailbone/forms/types.py @@ -102,17 +102,28 @@ class FalafelDateTime(colander.DateTime): app = self.request.rattail_config.get_app() dt = app.localtime(appstruct, from_utc=True) - return json.dumps({ + return { 'date': str(dt.date()), 'time': str(dt.time()), - }) + } def deserialize(self, node, cstruct): if not cstruct: return colander.null + try: + date = datetime.datetime.strptime(cstruct['date'], '%Y-%m-%d').date() + except: + node.raise_invalid("Missing or invalid date") + + try: + time = datetime.datetime.strptime(cstruct['time'], '%H:%M:%S').time() + except: + node.raise_invalid("Missing or invalid time") + + result = datetime.datetime.combine(date, time) + app = self.request.rattail_config.get_app() - result = datetime.datetime.strptime(cstruct, '%Y-%m-%dT%H:%M:%S') result = app.localtime(result) result = app.make_utc(result) return result diff --git a/tailbone/forms/widgets.py b/tailbone/forms/widgets.py index a8810e69..23bbac00 100644 --- a/tailbone/forms/widgets.py +++ b/tailbone/forms/widgets.py @@ -242,6 +242,17 @@ class FalafelDateTimeWidget(dfwidget.DateTimeInputWidget): """ template = 'datetime_falafel' + def serialize(self, field, cstruct, **kw): + readonly = kw.get('readonly', self.readonly) + values = self.get_template_values(field, cstruct, kw) + template = self.readonly_template if readonly else self.template + return field.renderer(template, **values) + + def deserialize(self, field, pstruct): + if pstruct == '': + return colander.null + return pstruct + class FalafelTimeWidget(dfwidget.TimeInputWidget): """ From 0b7791070fb014c38c5259fd39c985035bf2a6bf Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 4 Oct 2023 10:59:54 -0500 Subject: [PATCH 1236/1681] Update changelog --- CHANGES.rst | 10 ++++++++++ tailbone/_version.py | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 8cce23d1..ca67318d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.9.61 (2023-10-04) +------------------- + +* Use enum to display ``POS_ROW_TYPE``. + +* Expose cash-back flags for tenders. + +* Re-work FalafelDateTime logic a bit. + + 0.9.60 (2023-10-01) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 27e2acc7..58d905cb 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.60' +__version__ = '0.9.61' From f3dddf0e401316421ad5aa6ff0025408e94086f6 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 4 Oct 2023 11:56:50 -0500 Subject: [PATCH 1237/1681] Avoid deprecated `pretty_hours()` function --- tailbone/grids/core.py | 5 +++-- tailbone/views/shifts/core.py | 41 ++++++++++++++++++----------------- tailbone/views/shifts/lib.py | 8 ++++--- 3 files changed, 29 insertions(+), 25 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 639eabd1..6373add6 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -32,7 +32,7 @@ import sqlalchemy as sa from sqlalchemy import orm from rattail.db.types import GPCType -from rattail.util import prettify, pretty_boolean, pretty_quantity, pretty_hours +from rattail.util import prettify, pretty_boolean, pretty_quantity from rattail.time import localtime import webhelpers2_grid @@ -541,7 +541,8 @@ class Grid(object): value = self.obtain_value(obj, field) if value is None: return "" - return pretty_hours(hours=value) + app = self.request.rattail_config.get_app() + return app.render_duration(hours=value) def set_url(self, url): self.url = url diff --git a/tailbone/views/shifts/core.py b/tailbone/views/shifts/core.py index b6d9aadf..8fa934ea 100644 --- a/tailbone/views/shifts/core.py +++ b/tailbone/views/shifts/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,31 +24,32 @@ Views for employee shifts """ -from __future__ import unicode_literals, absolute_import - import datetime -import six - from rattail.db import model from rattail.time import localtime -from rattail.util import pretty_hours, hours_as_decimal +from rattail.util import hours_as_decimal from webhelpers2.html import tags, HTML from tailbone.views import MasterView -def render_shift_length(shift, field): - if not shift.start_time or not shift.end_time: - return "" - if shift.end_time < shift.start_time: - return "??" - length = shift.end_time - shift.start_time - return HTML.tag('span', title="{} hrs".format(hours_as_decimal(length)), c=[pretty_hours(length)]) +class ShiftViewMixin: + + def render_shift_length(self, shift, field): + if not shift.start_time or not shift.end_time: + return "" + if shift.end_time < shift.start_time: + return "??" + app = self.get_rattail_app() + length = shift.end_time - shift.start_time + return HTML.tag('span', + title="{} hrs".format(hours_as_decimal(length)), + c=[app.render_duration(delta=length)]) -class ScheduledShiftView(MasterView): +class ScheduledShiftView(MasterView, ShiftViewMixin): """ Master view for employee scheduled shifts. """ @@ -78,20 +79,20 @@ class ScheduledShiftView(MasterView): g.set_sort_defaults('start_time', 'desc') - g.set_renderer('length', render_shift_length) + g.set_renderer('length', self.render_shift_length) g.set_label('employee', "Employee Name") def configure_form(self, f): super(ScheduledShiftView, self).configure_form(f) - f.set_renderer('length', render_shift_length) + f.set_renderer('length', self.render_shift_length) # TODO: deprecate / remove this ScheduledShiftsView = ScheduledShiftView -class WorkedShiftView(MasterView): +class WorkedShiftView(MasterView, ShiftViewMixin): """ Master view for employee worked shifts. """ @@ -136,7 +137,7 @@ class WorkedShiftView(MasterView): # (but we'll still have to set this) g.set_sort_defaults('start_time', 'desc') - g.set_renderer('length', render_shift_length) + g.set_renderer('length', self.render_shift_length) g.set_label('employee', "Employee Name") g.set_label('store', "Store Name") @@ -154,7 +155,7 @@ class WorkedShiftView(MasterView): f.set_readonly('employee') f.set_renderer('employee', self.render_employee) - f.set_renderer('length', render_shift_length) + f.set_renderer('length', self.render_shift_length) if self.editing: f.remove('length') @@ -162,7 +163,7 @@ class WorkedShiftView(MasterView): employee = shift.employee if not employee: return "" - text = six.text_type(employee) + text = str(employee) url = self.request.route_url('employees.view', uuid=employee.uuid) return tags.link_to(text, url) diff --git a/tailbone/views/shifts/lib.py b/tailbone/views/shifts/lib.py index d32a1309..8fc58264 100644 --- a/tailbone/views/shifts/lib.py +++ b/tailbone/views/shifts/lib.py @@ -31,7 +31,7 @@ import sqlalchemy as sa from rattail import enum from rattail.db import model, api from rattail.time import localtime, make_utc, get_sunday -from rattail.util import pretty_hours, hours_as_decimal +from rattail.util import hours_as_decimal import colander from deform import widget as dfwidget @@ -401,6 +401,8 @@ class TimeSheetView(View): Fetch all shift data of the given model class (``cls``), according to the given params. The cached shift data is attached to each employee. """ + app = self.get_rattail_app() + # TODO: a bit hacky, this? display hours as HH:MM by default, but # check config in order to display as HH.HH for certain users hours_style = 'pretty' @@ -465,7 +467,7 @@ class TimeSheetView(View): hours = empday['{}_hours'.format(shift_type)] if hours: if hours_style == 'pretty': - display = pretty_hours(hours) + display = app.render_duration(hours=hours) else: # decimal display = str(hours_as_decimal(hours)) if empday['hours_incomplete']: @@ -476,7 +478,7 @@ class TimeSheetView(View): hours = getattr(employee, '{}_hours'.format(shift_type)) if hours: if hours_style == 'pretty': - display = pretty_hours(hours) + display = app.render_duration(hours=hours) else: # decimal display = str(hours_as_decimal(hours)) if hours_incomplete: From 7bae01f03cb33e1402baf41b915fde4386197eef Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 4 Oct 2023 13:07:26 -0500 Subject: [PATCH 1238/1681] Improve master view `oneoff_import()` method be more flexible about what caller must provide --- tailbone/views/master.py | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 04262124..f9e2d150 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -1841,21 +1841,32 @@ class MasterView(View): def fetch_grid_totals(self): return {'totals_display': "TODO: totals go here"} - def oneoff_import(self, importer, host_object=None): + def oneoff_import(self, importer, host_object=None, local_object=None): """ Basic helper method, to do a one-off import (or export, depending on perspective) of the "current instance" object. Where the data "goes" depends on the importer you provide. """ - if not host_object: + if host_object is None and local_object is None: host_object = self.get_instance() - host_data = importer.normalize_host_object(host_object) - if not host_data: - return + if host_object is None: + local_data = importer.normalize_local_object(local_object) + key = importer.get_key(local_data) + host_object = importer.get_single_host_object(key) + if not host_object: + return + host_data = importer.normalize_host_object(host_object) + if not host_data: + return + + else: + host_data = importer.normalize_host_object(host_object) + if not host_data: + return + key = importer.get_key(host_data) + local_object = importer.get_local_object(key) - key = importer.get_key(host_data) - local_object = importer.get_local_object(key) if local_object: if importer.allow_update: local_data = importer.normalize_local_object(local_object) From 3dfab8e42d88510eb9dc9d7d1b48896f7596625e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 4 Oct 2023 13:56:22 -0500 Subject: [PATCH 1239/1681] Update changelog --- CHANGES.rst | 8 ++++++++ tailbone/_version.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index ca67318d..755b9e7d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.9.62 (2023-10-04) +------------------- + +* Avoid deprecated ``pretty_hours()`` function. + +* Improve master view ``oneoff_import()`` method. + + 0.9.61 (2023-10-04) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 58d905cb..9b2f1e6a 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.61' +__version__ = '0.9.62' From b30f6cdf3ac2de8914aac0c0f6f7fa9b3fc1cb41 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 5 Oct 2023 13:11:05 -0500 Subject: [PATCH 1240/1681] Fix CRUD pages for tempmon clients, probes for some reason if helptext had embedded newlines, it would now fail to render the form altogether. guess that is a result of recent change to e.g. `<b-field :message="['foo', 'bar']">` logic, somehow.. anyway hopefully this fixes and no more surprises --- tailbone/forms/core.py | 5 +- tailbone/templates/tempmon/probes/view.mako | 51 +++++++-------------- tailbone/views/tempmon/clients.py | 13 +++--- tailbone/views/tempmon/probes.py | 9 ++-- 4 files changed, 31 insertions(+), 47 deletions(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 97e23a25..06bf96e4 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -755,7 +755,8 @@ class Form(object): """ Set the help text for a given field. """ - self.helptext[key] = value + # nb. must avoid newlines, they cause some weird "blank page" error?! + self.helptext[key] = value.replace('\n', ' ') if value and dynamic: self.dynamic_helptext[key] = True else: @@ -1009,6 +1010,8 @@ class Form(object): # render the field widget or whatever if self.readonly or fieldname in self.readonly_fields: html = self.render_field_value(fieldname) or HTML.tag('span') + if type(html) is str: + html = HTML.tag('span', c=[html]) elif field: html = field.serialize(**self.get_renderer_kwargs(fieldname)) html = HTML.literal(html) diff --git a/tailbone/templates/tempmon/probes/view.mako b/tailbone/templates/tempmon/probes/view.mako index 207c48d4..7afd2427 100644 --- a/tailbone/templates/tempmon/probes/view.mako +++ b/tailbone/templates/tempmon/probes/view.mako @@ -1,48 +1,29 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -<%def name="render_form_complete()"> +<%def name="page_content()"> + <div class="form-wrapper"> + <div style="display: flex; flex-direction: column;"> - ## ${self.render_form()} - - <script type="text/x-template" id="form-page-template"> - - <div style="display: flex; justify-content: space-between;"> - - <div class="form-wrapper"> - - <div style="display: flex; flex-direction: column;"> - - <nav class="panel" id="probe-main"> - <p class="panel-heading">General</p> - <div class="panel-block"> - <div> - ${self.render_main_fields(form)} - </div> - </div> - </nav> - - <div style="display: flex;"> - <div class="panel-wrapper"> - ${self.left_column()} - </div> - <div class="panel-wrapper" style="margin-left: 1em;"> <!-- right column --> - ${self.right_column()} - </div> + <nav class="panel" id="probe-main"> + <p class="panel-heading">General</p> + <div class="panel-block"> + <div> + ${self.render_main_fields(form)} </div> + </div> + </nav> + <div style="display: flex;"> + <div class="panel-wrapper"> + ${self.left_column()} + </div> + <div class="panel-wrapper" style="margin-left: 1em;"> <!-- right column --> + ${self.right_column()} </div> </div> - <ul id="context-menu"> - ${self.context_menu_items()} - </ul> - </div> - </script> - - <div id="form-page-app"> - <form-page></form-page> </div> </%def> diff --git a/tailbone/views/tempmon/clients.py b/tailbone/views/tempmon/clients.py index 9edbd2ba..1b2d49d8 100644 --- a/tailbone/views/tempmon/clients.py +++ b/tailbone/views/tempmon/clients.py @@ -24,8 +24,6 @@ Views for tempmon clients """ -from __future__ import unicode_literals, absolute_import - import subprocess from rattail.config import parse_list @@ -51,6 +49,7 @@ class TempmonClientView(MasterView): has_rows = True model_row_class = tempmon.Reading + rows_title = "Readings" grid_columns = [ 'config_key', @@ -83,7 +82,7 @@ class TempmonClientView(MasterView): ] def configure_grid(self, g): - super(TempmonClientView, self).configure_grid(g) + super().configure_grid(g) # config_key g.set_label('config_key', "Key") @@ -116,7 +115,7 @@ class TempmonClientView(MasterView): return "No" def configure_form(self, f): - super(TempmonClientView, self).configure_form(f) + super().configure_form(f) # config_key f.set_validator('config_key', self.unique_config_key) @@ -160,7 +159,7 @@ class TempmonClientView(MasterView): f.set_helptext('archived', tempmon.Client.archived.__doc__) def template_kwargs_view(self, **kwargs): - kwargs = super(TempmonClientView, self).template_kwargs_view(**kwargs) + kwargs = super().template_kwargs_view(**kwargs) client = kwargs['instance'] kwargs['probes_data'] = self.normalize_probes(client.probes) @@ -177,7 +176,7 @@ class TempmonClientView(MasterView): if data['enabled'] and form.model_instance.enabled: data['enabled'] = form.model_instance.enabled - return super(TempmonClientView, self).objectify(form, data=data) + return super().objectify(form, data=data) def unique_config_key(self, node, value): query = self.Session.query(tempmon.Client)\ @@ -230,7 +229,7 @@ class TempmonClientView(MasterView): return reading.client def configure_row_grid(self, g): - super(TempmonClientView, self).configure_row_grid(g) + super().configure_row_grid(g) # probe g.set_filter('probe', tempmon.Probe.description) diff --git a/tailbone/views/tempmon/probes.py b/tailbone/views/tempmon/probes.py index 381a9f4a..dbf15dd1 100644 --- a/tailbone/views/tempmon/probes.py +++ b/tailbone/views/tempmon/probes.py @@ -49,6 +49,7 @@ class TempmonProbeView(MasterView): has_rows = True model_row_class = tempmon.Reading + rows_title = "Readings" labels = { 'critical_max_timeout': "Critical High Timeout", @@ -98,7 +99,7 @@ class TempmonProbeView(MasterView): ] def configure_grid(self, g): - super(TempmonProbeView, self).configure_grid(g) + super().configure_grid(g) g.joiners['client'] = lambda q: q.join(tempmon.Client) g.sorters['client'] = g.make_sorter(tempmon.Client.config_key) @@ -121,7 +122,7 @@ class TempmonProbeView(MasterView): return "No" def configure_form(self, f): - super(TempmonProbeView, self).configure_form(f) + super().configure_form(f) # config_key f.set_validator('config_key', self.unique_config_key) @@ -186,7 +187,7 @@ class TempmonProbeView(MasterView): if data['enabled'] and form.model_instance.enabled: data['enabled'] = form.model_instance.enabled - return super(TempmonProbeView, self).objectify(form, data=data) + return super().objectify(form, data=data) def unique_config_key(self, node, value): query = self.Session.query(tempmon.Probe)\ @@ -240,7 +241,7 @@ class TempmonProbeView(MasterView): return reading.client def configure_row_grid(self, g): - super(TempmonProbeView, self).configure_row_grid(g) + super().configure_row_grid(g) # # probe # g.set_filter('probe', tempmon.Probe.description) From e1a64de205c82b398f453265d08f0a8696f33742 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 5 Oct 2023 19:59:57 -0500 Subject: [PATCH 1241/1681] Fix bug in POS batch view --- tailbone/views/batch/pos.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/batch/pos.py b/tailbone/views/batch/pos.py index c8ceede5..42ea3a67 100644 --- a/tailbone/views/batch/pos.py +++ b/tailbone/views/batch/pos.py @@ -176,7 +176,7 @@ class POSBatchView(BatchMasterView): def configure_row_form(self, f): super().configure_row_form(f) - g.set_enum('row_type', self.enum.POS_ROW_TYPE) + f.set_enum('row_type', self.enum.POS_ROW_TYPE) f.set_renderer('product', self.render_product) From d45ee34b0cbb334b06770941e8fda1dcbc4da4e9 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 6 Oct 2023 08:56:22 -0500 Subject: [PATCH 1242/1681] Expose permissions for POS, if so configured --- tailbone/views/batch/pos.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/tailbone/views/batch/pos.py b/tailbone/views/batch/pos.py index 42ea3a67..71479391 100644 --- a/tailbone/views/batch/pos.py +++ b/tailbone/views/batch/pos.py @@ -89,7 +89,7 @@ class POSBatchView(BatchMasterView): 'quantity', 'sales_total', 'tender_total', - 'status_code', + 'user', ] row_form_fields = [ @@ -188,6 +188,32 @@ class POSBatchView(BatchMasterView): f.set_renderer('user', self.render_user) + @classmethod + def defaults(cls, config): + cls._batch_defaults(config) + cls._defaults(config) + cls._pos_batch_defaults(config) + + @classmethod + def _pos_batch_defaults(cls, config): + rattail_config = config.registry.settings.get('rattail_config') + + if rattail_config.getbool('tailbone', 'expose_pos_permissions', + default=False): + + config.add_tailbone_permission_group('pos', "POS", overwrite=False) + + config.add_tailbone_permission('pos', 'pos.ring_sales', + "Make transactions (ring up sales)") + # config.add_tailbone_permission('pos', 'pos.resume', + # "Resume previously-suspended transaction") + # config.add_tailbone_permission('pos', 'pos.suspend', + # "Suspend current transaction") + config.add_tailbone_permission('pos', 'pos.swap_customer', + "Swap customer for current transaction") + config.add_tailbone_permission('pos', 'pos.void_txn', + "Void current transaction") + def defaults(config, **kwargs): base = globals() From 53cf771c81a4c37c011116def272947a6a22fbc6 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 6 Oct 2023 10:00:37 -0500 Subject: [PATCH 1243/1681] Update changelog --- CHANGES.rst | 10 ++++++++++ tailbone/_version.py | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 755b9e7d..ef40368c 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.9.63 (2023-10-06) +------------------- + +* Fix CRUD pages for tempmon clients, probes. + +* Fix bug in POS batch view. + +* Expose permissions for POS, if so configured. + + 0.9.62 (2023-10-04) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 9b2f1e6a..f2d08dcc 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.62' +__version__ = '0.9.63' From d1d781966fc3c676813088b19d44ef2c6acabaa7 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 6 Oct 2023 10:12:38 -0500 Subject: [PATCH 1244/1681] Fix bug for param helptext in New Report page --- tailbone/views/reports.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index 5a945f0c..9bf30a88 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -431,7 +431,8 @@ class ReportOutputView(ExportMasterView): node.default = param.default # set docstring - helptext[param.name] = param.helptext + # nb. must avoid newlines, they cause some weird "blank page" error?! + helptext[param.name] = param.helptext.replace('\n', ' ') schema.add(node) From 2ae2cdc4bd25d7fd72487cefefd6486d04449b32 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 6 Oct 2023 10:13:18 -0500 Subject: [PATCH 1245/1681] Update changelog --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index ef40368c..aa1d68b9 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.9.64 (2023-10-06) +------------------- + +* Fix bug for param helptext in New Report page. + + 0.9.63 (2023-10-06) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index f2d08dcc..83562798 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.63' +__version__ = '0.9.64' From d84b98041f5a1717d8b6bc351872a56034111ee0 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 6 Oct 2023 15:03:17 -0500 Subject: [PATCH 1246/1681] Avoid deprecated logic for fetching vendor contact email/phone --- tailbone/views/vendors/core.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tailbone/views/vendors/core.py b/tailbone/views/vendors/core.py index 176afab2..743e1632 100644 --- a/tailbone/views/vendors/core.py +++ b/tailbone/views/vendors/core.py @@ -92,7 +92,8 @@ class VendorView(MasterView): g.set_link('abbreviation') def configure_form(self, f): - super(VendorView, self).configure_form(f) + super().configure_form(f) + app = self.get_rattail_app() vendor = f.model_instance f.set_type('lead_time_days', 'quantity') @@ -111,7 +112,7 @@ class VendorView(MasterView): # orders_email f.set_renderer('orders_email', self.render_orders_email) if not self.creating and vendor.emails: - f.set_default('orders_email', vendor.get_email_address(type_='Orders') or '') + f.set_default('orders_email', app.get_contact_email_address(vendor, type_='Orders') or '') # contact if self.creating: @@ -128,7 +129,7 @@ class VendorView(MasterView): if 'orders_email' in data: address = data['orders_email'] - email = vendor.get_email(type_='Orders') + email = app.get_contact_email(vendor, type_='Orders') if address: if email: if email.address != address: @@ -145,7 +146,8 @@ class VendorView(MasterView): return vendor.emails[0].address def render_orders_email(self, vendor, field): - return vendor.get_email_address(type_='Orders') + app = self.get_rattail_app() + return app.get_contact_email_address(vendor, type_='Orders') def render_default_phone(self, vendor, field): if vendor.phones: From 2f4877a264b4ee2ea9746fb16235cc0284b7a4d3 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 6 Oct 2023 15:53:17 -0500 Subject: [PATCH 1247/1681] Add "mark complete" button for inventory batch row entry page --- .../batch/inventory/desktop_form.mako | 65 +++++++++++++++---- tailbone/views/batch/inventory.py | 16 +++-- 2 files changed, 65 insertions(+), 16 deletions(-) diff --git a/tailbone/templates/batch/inventory/desktop_form.mako b/tailbone/templates/batch/inventory/desktop_form.mako index 2a853f4f..9f13cbf9 100644 --- a/tailbone/templates/batch/inventory/desktop_form.mako +++ b/tailbone/templates/batch/inventory/desktop_form.mako @@ -3,9 +3,35 @@ <%def name="title()">Inventory Form</%def> -<%def name="context_menu_items()"> - ${parent.context_menu_items()} - <li>${h.link_to("Back to Inventory Batch", url('batch.inventory.view', uuid=batch.uuid))}</li> +<%def name="object_helpers()"> + <nav class="panel"> + <p class="panel-heading">Batch</p> + <div class="panel-block buttons"> + <div style="display: flex; flex-direction: column;"> + + <once-button type="is-primary" + icon-left="eye" + tag="a" href="${url('batch.inventory.view', uuid=batch.uuid)}" + text="View Batch"> + </once-button> + + % if not batch.executed and master.has_perm('edit'): + ${h.form(master.get_action_url('toggle_complete', batch), **{'@submit': 'toggleCompleteSubmitting = true'})} + ${h.csrf_token(request)} + ${h.hidden('complete', value='true')} + <b-button type="is-primary" + native-type="submit" + icon-pack="fas" + icon-left="check" + :disabled="toggleCompleteSubmitting"> + {{ toggleCompleteSubmitting ? "Working, please wait..." : "Mark Complete" }} + </b-button> + ${h.end_form()} + % endif + + </div> + </div> + </nav> </%def> <%def name="render_form()"> @@ -123,6 +149,7 @@ let ${form.component_studly} = { template: '#${form.component}-template', + mixins: [SimpleRequestMixin], mounted() { this.$refs.productUPC.focus() @@ -195,15 +222,9 @@ let params = { upc: this.productUPC, } - this.$http.get(url, {params: params}).then(response => { + this.simpleGET(url, params, response => { - if (response.data.error) { - alert(response.data.error) - if (response.data.redirect) { - location.href = response.data.redirect - } - - } else if (response.data.product.uuid) { + if (response.data.product.uuid) { this.productUPC = response.data.product.upc_pretty this.productInfo = response.data.product @@ -238,6 +259,19 @@ } else { ## this.productNotFound = true alert("Product not found!") + + // focus/select UPC entry + this.$refs.productUPC.focus() + // nb. must traverse into the <b-input> element + this.$refs.productUPC.$el.firstChild.select() + } + + }, response => { + if (response.data.error) { + alert(response.data.error) + if (response.data.redirect) { + location.href = response.data.redirect + } } }) }, @@ -263,5 +297,14 @@ </script> </%def> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + ThisPageData.toggleCompleteSubmitting = false + + </script> +</%def> + ${parent.body()} diff --git a/tailbone/views/batch/inventory.py b/tailbone/views/batch/inventory.py index 92f0b2d4..e9f72ceb 100644 --- a/tailbone/views/batch/inventory.py +++ b/tailbone/views/batch/inventory.py @@ -228,7 +228,7 @@ class InventoryBatchView(BatchMasterView): Desktop workflow view for adding items to inventory batch. """ batch = self.get_instance() - if batch.executed: + if batch.executed or batch.complete: return self.redirect(self.get_action_url('view', batch)) schema = DesktopForm().bind(session=self.Session()) @@ -360,11 +360,17 @@ class InventoryBatchView(BatchMasterView): # TODO: deprecate / remove (?) def find_product(self, entry): - lookup_by_code = self.rattail_config.getbool( - 'tailbone', 'inventory.lookup_by_code', default=False) + lookup_fields = [ + 'uuid', + '_product_key_', + ] - return self.handler.locate_product_for_entry( - self.Session(), entry, lookup_by_code=lookup_by_code) + if self.rattail_config.getbool('tailbone', 'inventory.lookup_by_code', + default=False): + lookup_fields.append('alt_code') + + return self.handler.locate_product_for_entry(self.Session(), entry, + lookup_fields=lookup_fields) def product_info(self, product): data = {} From eccb855d09fbd1bc8f2f7b766b33f0b5172740bf Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 6 Oct 2023 20:34:14 -0500 Subject: [PATCH 1248/1681] Expose tender ref in POS batch rows; new tender flags --- tailbone/views/batch/pos.py | 2 ++ tailbone/views/master.py | 8 ++++++++ tailbone/views/tenders.py | 7 +++++++ 3 files changed, 17 insertions(+) diff --git a/tailbone/views/batch/pos.py b/tailbone/views/batch/pos.py index 71479391..8bc70b02 100644 --- a/tailbone/views/batch/pos.py +++ b/tailbone/views/batch/pos.py @@ -105,6 +105,7 @@ class POSBatchView(BatchMasterView): 'tax1_total', 'tax2_total', 'tender_total', + 'tender', 'void', 'status_code', 'timestamp', @@ -179,6 +180,7 @@ class POSBatchView(BatchMasterView): f.set_enum('row_type', self.enum.POS_ROW_TYPE) f.set_renderer('product', self.render_product) + f.set_renderer('tender', self.render_tender) f.set_type('quantity', 'quantity') f.set_type('reg_price', 'currency') diff --git a/tailbone/views/master.py b/tailbone/views/master.py index f9e2d150..e3a60eca 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -861,6 +861,14 @@ class MasterView(View): url = self.request.route_url('stores.view', uuid=store.uuid) return tags.link_to(text, url) + def render_tender(self, obj, field): + tender = getattr(obj, field) + if not tender: + return + text = str(tender) + url = self.request.route_url('tenders.view', uuid=tender.uuid) + return tags.link_to(text, url) + def valid_employee_uuid(self, node, value): if value: model = self.model diff --git a/tailbone/views/tenders.py b/tailbone/views/tenders.py index 54a0cdba..d5524e74 100644 --- a/tailbone/views/tenders.py +++ b/tailbone/views/tenders.py @@ -41,6 +41,7 @@ class TenderView(MasterView): 'name', 'is_cash', 'allow_cash_back', + 'kick_drawer', ] form_fields = [ @@ -48,7 +49,9 @@ class TenderView(MasterView): 'name', 'is_cash', 'allow_cash_back', + 'kick_drawer', 'notes', + 'disabled', ] def configure_grid(self, g): @@ -59,6 +62,10 @@ class TenderView(MasterView): g.set_link('name') g.set_sort_defaults('name') + def grid_extra_class(self, tender, i): + if tender.disabled: + return 'warning' + def configure_form(self, f): super().configure_form(f) From 07b1d0841efce1234052fc89e043388e5c8018d4 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 7 Oct 2023 16:26:33 -0500 Subject: [PATCH 1249/1681] Improve views for taxes, esp. in POS batches --- tailbone/grids/filters.py | 11 ++++- tailbone/templates/batch/pos/view.mako | 13 ++++++ tailbone/views/batch/pos.py | 60 ++++++++++++++++++++++---- tailbone/views/master.py | 8 ++++ tailbone/views/products.py | 13 +++++- tailbone/views/taxes.py | 24 ++++++++--- tailbone/views/typical.py | 1 + 7 files changed, 113 insertions(+), 17 deletions(-) create mode 100644 tailbone/templates/batch/pos/view.mako diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py index c8815f9f..61d29554 100644 --- a/tailbone/grids/filters.py +++ b/tailbone/grids/filters.py @@ -177,13 +177,18 @@ class GridFilter(object): self.key = key self.config = config self.label = label or prettify(key) - self.verbs = verbs or self.get_default_verbs() + if value_renderer: self.set_value_renderer(value_renderer) elif value_enum: self.set_choices(value_enum) else: self.set_value_renderer(self.value_renderer_factory) + + # nb. do this after setting choices, if applicable, since that + # could change default verbs + self.verbs = verbs or self.get_default_verbs() + self.default_active = default_active self.default_verb = default_verb self.default_value = default_value @@ -461,6 +466,10 @@ class AlchemyStringFilter(AlchemyGridFilter): """ Expose contains / does-not-contain verbs in addition to core. """ + + if self.choices: + return ['equal', 'not_equal', 'is_null', 'is_not_null', 'is_any'] + return ['contains', 'does_not_contain', 'contains_any_of', 'equal', 'not_equal', 'equal_any_of', diff --git a/tailbone/templates/batch/pos/view.mako b/tailbone/templates/batch/pos/view.mako new file mode 100644 index 00000000..0da755aa --- /dev/null +++ b/tailbone/templates/batch/pos/view.mako @@ -0,0 +1,13 @@ +## -*- coding: utf-8; -*- +<%inherit file="/batch/view.mako" /> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + ${form.component_studly}Data.taxesData = ${json.dumps(taxes_data)|n} + + </script> +</%def> + +${parent.body()} diff --git a/tailbone/views/batch/pos.py b/tailbone/views/batch/pos.py index 8bc70b02..00f1603f 100644 --- a/tailbone/views/batch/pos.py +++ b/tailbone/views/batch/pos.py @@ -26,6 +26,8 @@ Views for POS batches from rattail.db.model import POSBatch, POSBatchRow +from webhelpers2.html import HTML + from tailbone.views.batch import BatchMasterView @@ -39,7 +41,11 @@ class POSBatchView(BatchMasterView): route_prefix = 'batch.pos' url_prefix = '/batch/pos' creatable = False + editable = False cloneable = True + refreshable = False + rows_deletable = False + rows_bulk_deletable = False labels = { 'terminal_id': "Terminal ID", @@ -66,8 +72,7 @@ class POSBatchView(BatchMasterView): 'params', 'rowcount', 'sales_total', - 'tax1_total', - 'tax2_total', + 'taxes', 'tender_total', 'balance', 'void', @@ -89,6 +94,7 @@ class POSBatchView(BatchMasterView): 'quantity', 'sales_total', 'tender_total', + 'tax_code', 'user', ] @@ -102,8 +108,7 @@ class POSBatchView(BatchMasterView): 'txn_price', 'quantity', 'sales_total', - 'tax1_total', - 'tax2_total', + 'tax_code', 'tender_total', 'tender', 'void', @@ -126,8 +131,6 @@ class POSBatchView(BatchMasterView): g.set_link('created_by') g.set_type('sales_total', 'currency') - g.set_type('tax1_total', 'currency') - g.set_type('tax2_total', 'currency') g.set_type('tender_total', 'currency') # executed @@ -149,13 +152,54 @@ class POSBatchView(BatchMasterView): f.set_renderer('customer', self.render_customer) f.set_type('sales_total', 'currency') - f.set_type('tax1_total', 'currency') - f.set_type('tax2_total', 'currency') f.set_type('tender_total', 'currency') f.set_type('tender_total', 'currency') + f.set_renderer('taxes', self.render_taxes) + f.set_renderer('balance', lambda batch, field: app.render_currency(batch.get_balance())) + def render_taxes(self, batch, field): + route_prefix = self.get_route_prefix() + + factory = self.get_grid_factory() + g = factory( + key=f'{route_prefix}.taxes', + data=[], + columns=[ + 'code', + 'description', + 'rate', + 'total', + ], + ) + + return HTML.literal( + g.render_buefy_table_element(data_prop='taxesData')) + + def template_kwargs_view(self, **kwargs): + kwargs = super().template_kwargs_view(**kwargs) + app = self.get_rattail_app() + batch = kwargs['instance'] + + taxes = [] + for btax in batch.taxes.values(): + data = { + 'uuid': btax.uuid, + 'code': btax.tax_code, + 'description': btax.tax.description, + 'rate': app.render_percent(btax.tax_rate), + 'total': app.render_currency(btax.tax_total), + } + taxes.append(data) + taxes.sort(key=lambda t: t['code']) + kwargs['taxes_data'] = taxes + + kwargs['execute_enabled'] = False + kwargs['why_not_execute'] = "POS batch must be executed at POS" + + return kwargs + def configure_row_grid(self, g): super().configure_row_grid(g) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index e3a60eca..26936a71 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -861,6 +861,14 @@ class MasterView(View): url = self.request.route_url('stores.view', uuid=store.uuid) return tags.link_to(text, url) + def render_tax(self, obj, field): + tax = getattr(obj, field) + if not tax: + return + text = str(tax) + url = self.request.route_url('taxes.view', uuid=tax.uuid) + return tags.link_to(text, url) + def render_tender(self, obj, field): tender = getattr(obj, field) if not tender: diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 0ee53093..327b6366 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -366,6 +366,15 @@ class ProductView(MasterView): g.set_renderer('cost', self.render_cost) g.set_label('cost', "Unit Cost") + # tax + g.set_joiner('tax', lambda q: q.outerjoin(model.Tax)) + taxes = self.Session.query(model.Tax)\ + .order_by(model.Tax.code)\ + .all() + taxes = OrderedDict([(tax.uuid, tax.description) + for tax in taxes]) + g.set_filter('tax', model.Tax.uuid, value_enum=taxes) + # report_code_name g.set_joiner('report_code_name', lambda q: q.outerjoin(model.ReportCode)) g.set_filter('report_code_name', model.ReportCode.name) @@ -810,7 +819,7 @@ class ProductView(MasterView): raise self.notfound() def configure_form(self, f): - super(ProductView, self).configure_form(f) + super().configure_form(f) product = f.model_instance # department @@ -934,7 +943,7 @@ class ProductView(MasterView): f.set_label('tax_uuid', "Tax") else: f.set_readonly('tax') - # f.set_renderer('tax', self.render_tax) + f.set_renderer('tax', self.render_tax) # tax1/2/3 f.set_readonly('tax1') diff --git a/tailbone/views/taxes.py b/tailbone/views/taxes.py index 19a385ba..b2afaeb9 100644 --- a/tailbone/views/taxes.py +++ b/tailbone/views/taxes.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,6 @@ Tax Views """ -from __future__ import unicode_literals, absolute_import - from rattail.db import model from tailbone.views import MasterView @@ -53,12 +51,26 @@ class TaxView(MasterView): ] def configure_grid(self, g): - super(TaxView, self).configure_grid(g) - g.filters['description'].default_active = True - g.filters['description'].default_verb = 'contains' + super().configure_grid(g) + + # code g.set_sort_defaults('code') g.set_link('code') + + # description g.set_link('description') + g.filters['description'].default_active = True + g.filters['description'].default_verb = 'contains' + + # rate + g.set_type('rate', 'percent') + + def configure_form(self, f): + super().configure_form(f) + + # rate + f.set_type('rate', 'percent') + # TODO: deprecate / remove this TaxesView = TaxView diff --git a/tailbone/views/typical.py b/tailbone/views/typical.py index ed94d552..35259a14 100644 --- a/tailbone/views/typical.py +++ b/tailbone/views/typical.py @@ -43,6 +43,7 @@ def defaults(config, **kwargs): config.include(mod('tailbone.views.reportcodes')) config.include(mod('tailbone.views.stores')) config.include(mod('tailbone.views.subdepartments')) + config.include(mod('tailbone.views.taxes')) config.include(mod('tailbone.views.tenders')) config.include(mod('tailbone.views.uoms')) config.include(mod('tailbone.views.vendors')) From a201072a9d131e504324bc185ac24f1d1cf4f099 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 7 Oct 2023 18:57:03 -0500 Subject: [PATCH 1250/1681] Update changelog --- CHANGES.rst | 12 ++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index aa1d68b9..07addfcc 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,18 @@ CHANGELOG ========= +0.9.65 (2023-10-07) +------------------- + +* Avoid deprecated logic for fetching vendor contact email/phone. + +* Add "mark complete" button for inventory batch row entry page. + +* Expose tender ref in POS batch rows; new tender flags. + +* Improve views for taxes, esp. in POS batches. + + 0.9.64 (2023-10-06) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 83562798..466968d6 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.64' +__version__ = '0.9.65' From 4beca7af20f8b098684aca1a47ef6861d22697dd Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 7 Oct 2023 20:13:41 -0500 Subject: [PATCH 1251/1681] Make grid JS `loadAsyncData()` method truly async not sure what this does but it seems to work, we'll see --- tailbone/templates/grids/buefy.mako | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako index 519c16d8..f0dd2c59 100644 --- a/tailbone/templates/grids/buefy.mako +++ b/tailbone/templates/grids/buefy.mako @@ -484,7 +484,10 @@ ...this.getFilterParams()} }, - loadAsyncData(params, callback) { + ## TODO: i noticed buefy docs show using `async` keyword here, + ## so now i am too. knowing nothing at all of if/how this is + ## supposed to improve anything. we shall see i guess + async loadAsyncData(params, callback) { if (params === undefined || params === null) { params = new URLSearchParams(this.getBasicParams()) From 6d7754cf2ac7325d63158c621686ef5e158d699f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 8 Oct 2023 14:29:01 -0500 Subject: [PATCH 1252/1681] Add back-end support for multi-column grid sorting or very nearly, anyway. front-end still just supports 1 column yet --- tailbone/api/master.py | 8 +- tailbone/grids/core.py | 285 +++++++++++++++++-------- tailbone/templates/grids/buefy.mako | 16 +- tailbone/templates/grids/complete.mako | 38 ---- tailbone/templates/grids/grid.mako | 21 -- tailbone/util.py | 11 + tailbone/views/customers.py | 30 --- tailbone/views/master.py | 12 +- tailbone/views/members.py | 3 +- 9 files changed, 222 insertions(+), 202 deletions(-) delete mode 100644 tailbone/templates/grids/complete.mako delete mode 100644 tailbone/templates/grids/grid.mako diff --git a/tailbone/api/master.py b/tailbone/api/master.py index dabc31ff..70616484 100644 --- a/tailbone/api/master.py +++ b/tailbone/api/master.py @@ -33,13 +33,7 @@ from cornice import resource, Service from tailbone.api import APIView, api from tailbone.db import Session - - -class SortColumn(object): - - def __init__(self, field_name, model_name=None): - self.field_name = field_name - self.model_name = model_name +from tailbone.util import SortColumn class APIMasterView(APIView): diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 6373add6..984307b3 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -24,12 +24,13 @@ Core Grid Classes """ +from urllib.parse import urlencode import warnings import logging -from six.moves import urllib import sqlalchemy as sa from sqlalchemy import orm +from sa_filters import apply_sort from rattail.db.types import GPCType from rattail.util import prettify, pretty_boolean, pretty_quantity @@ -552,48 +553,6 @@ class Grid(object): return self.url(obj) return self.url - def make_webhelpers_grid(self): - kwargs = dict(self._whgrid_kwargs) - kwargs['request'] = self.request - kwargs['url'] = self.make_url - - columns = list(self.columns) - column_labels = kwargs.setdefault('column_labels', {}) - column_formats = kwargs.setdefault('column_formats', {}) - - for key, value in self.labels.items(): - column_labels.setdefault(key, value) - - if self.checkboxes: - columns.insert(0, 'checkbox') - column_labels['checkbox'] = tags.checkbox('check-all') - column_formats['checkbox'] = self.checkbox_column_format - - if self.renderers: - kwargs['renderers'] = self.renderers - if self.extra_row_class: - kwargs['extra_record_class'] = self.extra_row_class - if self.linked_columns: - kwargs['linked_columns'] = list(self.linked_columns) - - if self.main_actions or self.more_actions: - columns.append('actions') - column_formats['actions'] = self.actions_column_format - - # TODO: pretty sure this factory doesn't serve all use cases yet? - factory = CustomWebhelpersGrid - # factory = webhelpers2_grid.Grid - if self.sortable: - # factory = CustomWebhelpersGrid - kwargs['order_column'] = self.sortkey - kwargs['order_direction'] = 'dsc' if self.sortdir == 'desc' else 'asc' - - grid = factory(self.make_visible_data(), columns, **kwargs) - if self.sortable: - grid.exclude_ordering = list([key for key in grid.exclude_ordering - if key not in self.sorters]) - return grid - def make_default_renderers(self, renderers): """ Make the default set of column renderers for the grid. @@ -638,19 +597,6 @@ class Grid(object): def actions_column_format(self, column_number, row_number, item): return HTML.td(self.render_actions(item, row_number), class_='actions') - def render_grid(self, template='/grids/grid.mako', **kwargs): - context = kwargs - context['grid'] = self - context['request'] = self.request - grid_class = '' - if self.width == 'full': - grid_class = 'full' - elif self.width == 'half': - grid_class = 'half' - context['grid_class'] = '{} {}'.format(grid_class, context.get('grid_class', '')) - context.setdefault('grid_attrs', {}) - return render(template, context) - def get_default_filters(self): """ Returns the default set of filters provided by the grid. @@ -761,6 +707,9 @@ class Grid(object): return query return query.order_by(getattr(column, direction)()) + sorter._class = class_ + sorter._column = column + return sorter def make_simple_sorter(self, key, foldcase=False): @@ -801,8 +750,12 @@ class Grid(object): # initial default settings settings = {} if self.sortable: - settings['sortkey'] = self.default_sortkey - settings['sortdir'] = self.default_sortdir + if self.default_sortkey: + settings['sorters.length'] = 1 + settings['sorters.1.key'] = self.default_sortkey + settings['sorters.1.dir'] = self.default_sortdir + else: + settings['sorters.length'] = 0 if self.pageable: settings['pagesize'] = self.get_default_pagesize() settings['page'] = self.default_page @@ -875,8 +828,12 @@ class Grid(object): filtr.verb = settings['filter.{}.verb'.format(filtr.key)] filtr.value = settings['filter.{}.value'.format(filtr.key)] if self.sortable: - self.sortkey = settings['sortkey'] - self.sortdir = settings['sortdir'] + self.active_sorters = [] + for i in range(1, settings['sorters.length'] + 1): + self.active_sorters.append(( + settings[f'sorters.{i}.key'], + settings[f'sorters.{i}.dir'], + )) if self.pageable: self.pagesize = settings['pagesize'] self.page = settings['page'] @@ -895,21 +852,36 @@ class Grid(object): # anything... session = Session() if user not in session: - user = session.merge(user) + # TODO: pretty sure there is no need to *merge* here.. + # but we shall see if any breakage happens maybe + #user = session.merge(user) + user = session.get(user.__class__, user.uuid) - # User defaults should have all or nothing, so just check one key. - key = 'tailbone.{}.grid.{}.sortkey'.format(user.uuid, self.key) app = self.request.rattail_config.get_app() - return app.get_setting(Session(), key) is not None + + # user defaults should be all or nothing, so just check one key + key = f'tailbone.{user.uuid}.grid.{self.key}.sorters.length' + if app.get_setting(session, key) is not None: + return True + + # TODO: this is deprecated but should work its way out of the + # system in a little while (?)..then can remove this entirely + key = f'tailbone.{user.uuid}.grid.{self.key}.sortkey' + if app.get_setting(session, key) is not None: + return True + + return False def apply_user_defaults(self, settings): """ Update the given settings dict with user defaults, if any exist. """ + app = self.request.rattail_config.get_app() + session = Session() + prefix = f'tailbone.{self.request.user.uuid}.grid.{self.key}' + def merge(key, normalize=lambda v: v): - skey = 'tailbone.{}.grid.{}.{}'.format(self.request.user.uuid, self.key, key) - app = self.request.rattail_config.get_app() - value = app.get_setting(Session(), skey) + value = app.get_setting(session, f'{prefix}.{key}') settings[key] = normalize(value) if self.filterable: @@ -919,8 +891,52 @@ class Grid(object): merge('filter.{}.value'.format(filtr.key)) if self.sortable: - merge('sortkey') - merge('sortdir') + + # first clear existing settings for *sorting* only + # nb. this is because number of sort settings will vary + for key in list(settings): + if key.startswith('sorters.'): + del settings[key] + + # check for *deprecated* settings, and use those if present + # TODO: obviously should stop this, but must wait until + # all old settings have been flushed out. which in the + # case of user-persisted settings, could be a while... + sortkey = app.get_setting(session, f'{prefix}.sortkey') + if sortkey: + settings['sorters.length'] = 1 + settings['sorters.1.key'] = sortkey + settings['sorters.1.dir'] = app.get_setting(session, f'{prefix}.sortdir') + + # nb. re-persist these user settings per new + # convention, so deprecated settings go away and we + # can remove this logic after a while.. + app = self.request.rattail_config.get_app() + model = app.model + prefix = f'tailbone.{self.request.user.uuid}.grid.{self.key}' + query = Session.query(model.Setting)\ + .filter(sa.or_( + model.Setting.name.like(f'{prefix}.sorters.%'), + model.Setting.name == f'{prefix}.sortkey', + model.Setting.name == f'{prefix}.sortdir')) + for setting in query.all(): + Session.delete(setting) + Session.flush() + + def persist(key): + app.save_setting(Session(), + f'tailbone.{self.request.user.uuid}.grid.{self.key}.{key}', + settings[key]) + + persist('sorters.length') + persist('sorters.1.key') + persist('sorters.1.dir') + + else: # the future + merge('sorters.length', int) + for i in range(1, settings['sorters.length'] + 1): + merge(f'sorters.{i}.key') + merge(f'sorters.{i}.dir') if self.pageable: merge('pagesize', int) @@ -939,10 +955,16 @@ class Grid(object): return True elif type_ == 'sort': + + # TODO: remove this eventually, but some links in the wild + # may still include these params, so leave it for now for key in ['sortkey', 'sortdir']: if key in self.request.GET: return True + if 'sort1key' in self.request.GET: + return True + elif type_ == 'page': for key in ['pagesize', 'page']: if key in self.request.GET: @@ -956,10 +978,12 @@ class Grid(object): """ # session should have all or nothing, so just check a few keys which # should be guaranteed present if anything has been stashed - for key in ['page', 'sortkey']: - if 'grid.{}.{}'.format(self.key, key) in self.request.session: + prefix = f'grid.{self.key}' + for key in ['page', 'sorters.length']: + if f'{prefix}.{key}' in self.request.session: return True - return any([key.startswith('grid.{}.filter'.format(self.key)) for key in self.request.session]) + return any([key.startswith(f'{prefix}.filter') + for key in self.request.session]) def get_setting(self, source, settings, key, normalize=lambda v: v, default=None): """ @@ -1044,8 +1068,46 @@ class Grid(object): """ if not self.sortable: return - settings['sortkey'] = self.get_setting(source, settings, 'sortkey') - settings['sortdir'] = self.get_setting(source, settings, 'sortdir') + + if source == 'request': + + # TODO: remove this eventually, but some links in the wild + # may still include these params, so leave it for now + if 'sortkey' in self.request.GET: + settings['sorters.length'] = 1 + settings['sorters.1.key'] = self.get_setting(source, settings, 'sortkey') + settings['sorters.1.dir'] = self.get_setting(source, settings, 'sortdir') + + else: # the future + i = 1 + while True: + skey = f'sort{i}key' + if skey in self.request.GET: + settings[f'sorters.{i}.key'] = self.get_setting(source, settings, skey) + settings[f'sorters.{i}.dir'] = self.get_setting(source, settings, f'sort{i}dir') + else: + break + i += 1 + settings['sorters.length'] = i - 1 + + else: # session + + # TODO: definitely will remove this, but leave it for now + # so it doesn't monkey with current user sessions when + # next upgrade happens. so, remove after all are upgraded + sortkey = self.get_setting(source, settings, 'sortkey') + if sortkey: + settings['sorters.length'] = 1 + settings['sorters.1.key'] = sortkey + settings['sorters.1.dir'] = self.get_setting(source, settings, 'sortdir') + + else: # the future + settings['sorters.length'] = self.get_setting(source, settings, + 'sorters.length', int) + for i in range(1, settings['sorters.length'] + 1): + for key in ('key', 'dir'): + skey = f'sorters.{i}.{key}' + settings[skey] = self.get_setting(source, settings, skey) def update_page_settings(self, settings): """ @@ -1100,8 +1162,40 @@ class Grid(object): persist('filter.{}.value'.format(filtr.key)) if self.sortable: - persist('sortkey') - persist('sortdir') + + # first clear existing settings for *sorting* only + # nb. this is because number of sort settings will vary + if to == 'defaults': + model = self.request.rattail_config.get_model() + prefix = f'tailbone.{self.request.user.uuid}.grid.{self.key}' + query = Session.query(model.Setting)\ + .filter(sa.or_( + model.Setting.name.like(f'{prefix}.sorters.%'), + # TODO: remove these eventually, + # but probably should wait until + # all nodes have been upgraded for + # (quite) a while? + model.Setting.name == f'{prefix}.sortkey', + model.Setting.name == f'{prefix}.sortdir')) + for setting in query.all(): + Session.delete(setting) + Session.flush() + else: # session + prefix = f'grid.{self.key}' + for key in list(self.request.session): + if key.startswith(f'{prefix}.sorters.'): + del self.request.session[key] + # TODO: definitely will remove these, but leave for + # now so they don't monkey with current user sessions + # when next upgrade happens. so, remove after all are + # upgraded + self.request.session.pop(f'{prefix}.sortkey', None) + self.request.session.pop(f'{prefix}.sortdir', None) + + persist('sorters.length') + for i in range(1, settings['sorters.length'] + 1): + persist(f'sorters.{i}.key') + persist(f'sorters.{i}.dir') if self.pageable: persist('pagesize') @@ -1131,21 +1225,32 @@ class Grid(object): """ Sort the given query according to current settings, and return the result. """ - # Cannot sort unless we know which column to sort by. - if not self.sortkey: + # bail if no sort settings + if not self.active_sorters: return data - # Cannot sort unless we have a sort function. - sortfunc = self.sorters.get(self.sortkey) - if not sortfunc: - return data + # convert sort settings into a 'sortspec' for use with sa-filters + full_spec = [] + for sortkey, sortdir in self.active_sorters: + sortfunc = self.sorters.get(sortkey) + if sortfunc: + spec = { + 'sortkey': sortkey, + 'model': sortfunc._class.__name__, + 'field': sortfunc._column.name, + 'direction': sortdir or 'asc', + } + # spec.sortkey = sortkey + full_spec.append(spec) - # We can provide a default sort direction though. - sortdir = getattr(self, 'sortdir', 'asc') - if self.sortkey in self.joiners and self.sortkey not in self.joined: - data = self.joiners[self.sortkey](data) - self.joined.add(self.sortkey) - return sortfunc(data, sortdir) + # apply joins needed for this sort spec + for spec in full_spec: + sortkey = spec['sortkey'] + if sortkey in self.joiners and sortkey not in self.joined: + data = self.joiners[sortkey](data) + self.joined.add(sortkey) + + return apply_sort(data, full_spec) def paginate_data(self, data): """ @@ -1197,7 +1302,7 @@ class Grid(object): data = self.pager return data - def render_complete(self, template='/grids/complete.mako', **kwargs): + def render_complete(self, template='/grids/buefy.mako', **kwargs): """ Render the complete grid, including filters. """ @@ -1717,5 +1822,5 @@ class URLMaker(object): params = self.request.GET.copy() params["page"] = page params["partial"] = "1" - qs = urllib.parse.urlencode(params, True) + qs = urlencode(params, True) return '{}?{}'.format(self.request.path, qs) diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako index f0dd2c59..1203b9de 100644 --- a/tailbone/templates/grids/buefy.mako +++ b/tailbone/templates/grids/buefy.mako @@ -202,7 +202,7 @@ % endif % if grid.sortable: - :default-sort="[sortField, sortOrder]" + :default-sort="sortingPriority[0]" backend-sorting @sort="onSort" % endif @@ -352,8 +352,9 @@ firstItem: ${json.dumps(grid_data['first_item'] if grid.pageable else None)|n}, lastItem: ${json.dumps(grid_data['last_item'] if grid.pageable else None)|n}, - sortField: ${json.dumps(grid.sortkey if grid.sortable else None)|n}, - sortOrder: ${json.dumps(grid.sortdir if grid.sortable else None)|n}, + % if grid.sortable: + sortingPriority: ${json.dumps(grid.active_sorters)|n}, + % endif ## filterable: ${json.dumps(grid.filterable)|n}, filters: ${json.dumps(filters_data if grid.filterable else None)|n}, @@ -454,8 +455,10 @@ getBasicParams() { let params = {} % if grid.sortable: - params.sortkey = this.sortField - params.sortdir = this.sortOrder + for (let i = 1; i <= this.sortingPriority.length; i++) { + params['sort'+i+'key'] = this.sortingPriority[i-1][0] + params['sort'+i+'dir'] = this.sortingPriority[i-1][1] + } % endif % if grid.pageable: params.pagesize = this.perPage @@ -535,8 +538,7 @@ }, onSort(field, order) { - this.sortField = field - this.sortOrder = order + this.sortingPriority = [[field, order]] // always reset to first page when changing sort options // TODO: i mean..right? would we ever not want that? this.currentPage = 1 diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako deleted file mode 100644 index 169264c4..00000000 --- a/tailbone/templates/grids/complete.mako +++ /dev/null @@ -1,38 +0,0 @@ -## -*- coding: utf-8 -*- -<div class="grid-wrapper"> - - <table class="grid-header"> - <tbody> - <tr> - - <td class="filters" rowspan="2"> - % if grid.filterable: - ${grid.render_filters(allow_save_defaults=allow_save_defaults)|n} - % endif - </td> - - <td class="menu"> - % if context_menu: - <ul id="context-menu"> - ${context_menu|n} - </ul> - % endif - </td> - </tr> - - <tr> - <td class="tools"> - % if tools: - <div class="grid-tools"> - ${tools|n} - </div><!-- grid-tools --> - % endif - </td> - </tr> - - </tbody> - </table><!-- grid-header --> - - ${grid.render_grid()|n} - -</div><!-- grid-wrapper --> diff --git a/tailbone/templates/grids/grid.mako b/tailbone/templates/grids/grid.mako deleted file mode 100644 index 146fcab6..00000000 --- a/tailbone/templates/grids/grid.mako +++ /dev/null @@ -1,21 +0,0 @@ -## -*- coding: utf-8; -*- -<div class="grid ${grid_class}" data-delete-speedbump="${'true' if grid.delete_speedbump else 'false'}" ${h.HTML.render_attrs(grid_attrs)}> - <table> - ${grid.make_webhelpers_grid()} - </table> - % if grid.pageable and grid.pager: - <div class="pager"> - <p class="showing"> - ${"showing {} thru {} of {:,d}".format(grid.pager.first_item, grid.pager.last_item, grid.pager.item_count)} - % if grid.pager.page_count > 1: - ${"(page {} of {:,d})".format(grid.pager.page, grid.pager.page_count)} - % endif - </p> - <p class="page-links"> - ${h.select('pagesize', grid.pager.items_per_page, grid.get_pagesize_options())} - per page - ${grid.pager.pager('$link_first $link_previous ~1~ $link_next $link_last', symbol_next='next', symbol_previous='prev')|n} - </p> - </div> - % endif -</div> diff --git a/tailbone/util.py b/tailbone/util.py index 7015ad49..4c9c680e 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -44,6 +44,17 @@ from webhelpers2.html import HTML, tags log = logging.getLogger(__name__) +class SortColumn(object): + """ + Generic representation of a sort column, for use with sorting grid + data as well as with API. + """ + + def __init__(self, field_name, model_name=None): + self.field_name = field_name + self.model_name = model_name + + def get_csrf_token(request): """ Convenience function to retrieve the effective CSRF token for the given diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index 0860fc31..74f66458 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -476,36 +476,6 @@ class CustomerView(MasterView): items.append(HTML.tag('li', c=[link])) return HTML.tag('ul', c=items) - # TODO: remove if no longer used - def render_people_removable(self, customer, field): - people = customer.people - if not people: - return "" - - route_prefix = self.get_route_prefix() - permission_prefix = self.get_permission_prefix() - - view_url = lambda p, i: self.request.route_url('people.view', uuid=p.uuid) - actions = [ - grids.GridAction('view', icon='zoomin', url=view_url), - ] - if self.people_detachable and self.request.has_perm('{}.detach_person'.format(permission_prefix)): - url = lambda p, i: self.request.route_url('{}.detach_person'.format(route_prefix), - uuid=customer.uuid, person_uuid=p.uuid) - actions.append( - grids.GridAction('detach', icon='trash', url=url)) - - columns = ['first_name', 'last_name', 'display_name'] - g = grids.Grid( - key='{}.people'.format(route_prefix), - data=customer.people, - columns=columns, - labels={'display_name': "Full Name"}, - url=lambda p: self.request.route_url('people.view', uuid=p.uuid), - linked_columns=columns, - main_actions=actions) - return HTML.literal(g.render_grid()) - def render_shoppers(self, customer, field): route_prefix = self.get_route_prefix() permission_prefix = self.get_permission_prefix() diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 26936a71..ac68a02f 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -340,11 +340,9 @@ class MasterView(View): if grid.pageable and hasattr(grid, 'pager'): self.first_visible_grid_index = grid.pager.first_item - # return grid only, if partial page was requested + # return grid data only, if partial page was requested if self.request.params.get('partial'): - # render grid data only, as JSON - return render_to_response('json', grid.get_buefy_data(), - request=self.request) + return self.json_response(grid.get_buefy_data()) context = { 'grid': grid, @@ -1156,8 +1154,7 @@ class MasterView(View): # return grid only, if partial page was requested if self.request.params.get('partial'): # render grid data only, as JSON - return render_to_response('json', grid.get_buefy_data(), - request=self.request) + return self.json_response(grid.get_buefy_data()) context = { 'instance': instance, @@ -1284,8 +1281,7 @@ class MasterView(View): # return grid only, if partial page was requested if self.request.params.get('partial'): # render grid data only, as JSON - return render_to_response('json', grid.get_buefy_data(), - request=self.request) + return self.json_response(grid.get_buefy_data()) return self.render_to_response('versions', { 'instance': instance, diff --git a/tailbone/views/members.py b/tailbone/views/members.py index 1b3735bd..74b15512 100644 --- a/tailbone/views/members.py +++ b/tailbone/views/members.py @@ -461,7 +461,8 @@ class MemberEquityPaymentView(MasterView): g.set_renderer(field, self.render_member_key) g.set_filter(field, attr, label=self.get_member_key_label(), - default_active=True) + default_active=True, + default_verb='equal') g.set_sorter(field, attr) # member (name) From edb5393cdc4f64b830548cd180d59b69ea408c27 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 8 Oct 2023 16:38:13 -0500 Subject: [PATCH 1253/1681] Add front-end support for multi-column grid sorting user must ctrl-click column header to engage multi-sort --- tailbone/grids/core.py | 66 ++++++++++++++------- tailbone/templates/grids/buefy.mako | 92 ++++++++++++++++++++++++++--- 2 files changed, 128 insertions(+), 30 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 984307b3..e42f8714 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -830,10 +830,10 @@ class Grid(object): if self.sortable: self.active_sorters = [] for i in range(1, settings['sorters.length'] + 1): - self.active_sorters.append(( - settings[f'sorters.{i}.key'], - settings[f'sorters.{i}.dir'], - )) + self.active_sorters.append({ + 'field': settings[f'sorters.{i}.key'], + 'order': settings[f'sorters.{i}.dir'], + }) if self.pageable: self.pagesize = settings['pagesize'] self.page = settings['page'] @@ -1229,28 +1229,52 @@ class Grid(object): if not self.active_sorters: return data - # convert sort settings into a 'sortspec' for use with sa-filters - full_spec = [] - for sortkey, sortdir in self.active_sorters: - sortfunc = self.sorters.get(sortkey) - if sortfunc: - spec = { - 'sortkey': sortkey, - 'model': sortfunc._class.__name__, - 'field': sortfunc._column.name, - 'direction': sortdir or 'asc', - } - # spec.sortkey = sortkey - full_spec.append(spec) + # TODO: is there a better way to check for SA sorting? + if self.model_class: - # apply joins needed for this sort spec - for spec in full_spec: - sortkey = spec['sortkey'] + # convert sort settings into a 'sortspec' for use with sa-filters + full_spec = [] + for sorter in self.active_sorters: + sortkey = sorter['field'] + sortdir = sorter['order'] + sortfunc = self.sorters.get(sortkey) + if sortfunc: + spec = { + 'sortkey': sortkey, + 'model': sortfunc._class.__name__, + 'field': sortfunc._column.name, + 'direction': sortdir or 'asc', + } + full_spec.append(spec) + + # apply joins needed for this sort spec + for spec in full_spec: + sortkey = spec['sortkey'] + if sortkey in self.joiners and sortkey not in self.joined: + data = self.joiners[sortkey](data) + self.joined.add(sortkey) + + return apply_sort(data, full_spec) + + else: + # not a SQLAlchemy grid, custom sorter + + assert len(self.active_sorters) < 2 + + sortkey = self.active_sorters[0]['field'] + sortdir = self.active_sorters[0]['order'] or 'asc' + + # Cannot sort unless we have a sort function. + sortfunc = self.sorters.get(sortkey) + if not sortfunc: + return data + + # apply joins needed for this sorter if sortkey in self.joiners and sortkey not in self.joined: data = self.joiners[sortkey](data) self.joined.add(sortkey) - return apply_sort(data, full_spec) + return sortfunc(data, sortdir) def paginate_data(self, data): """ diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako index 1203b9de..5b21b42a 100644 --- a/tailbone/templates/grids/buefy.mako +++ b/tailbone/templates/grids/buefy.mako @@ -202,9 +202,25 @@ % endif % if grid.sortable: - :default-sort="sortingPriority[0]" - backend-sorting - @sort="onSort" + backend-sorting + @sort="onSort" + @sorting-priority-removed="sortingPriorityRemoved" + + ## TODO: there is a bug (?) which prevents the arrow from + ## displaying for simple default single-column sort. so to + ## work around that, we *disable* multi-sort until the + ## component is mounted. seems to work for now..see also + ## https://github.com/buefy/buefy/issues/2584 + :sort-multiple="allowMultiSort" + + ## nb. specify default sort only if single-column + :default-sort="backendSorters.length == 1 ? [backendSorters[0].field, backendSorters[0].order] : null" + + ## nb. otherwise there may be default multi-column sort + :sort-multiple-data="sortingPriority" + + ## user must ctrl-click column header to do multi-sort + sort-multiple-key="ctrlKey" % endif % if grid.click_handlers: @@ -353,7 +369,25 @@ lastItem: ${json.dumps(grid_data['last_item'] if grid.pageable else None)|n}, % if grid.sortable: - sortingPriority: ${json.dumps(grid.active_sorters)|n}, + + ## TODO: there is a bug (?) which prevents the arrow from + ## displaying for simple default single-column sort. so to + ## work around that, we *disable* multi-sort until the + ## component is mounted. seems to work for now..see also + ## https://github.com/buefy/buefy/issues/2584 + allowMultiSort: false, + + ## nb. this contains all truly active sorters + backendSorters: ${json.dumps(grid.active_sorters)|n}, + + ## nb. whereas this will only contain multi-column sorters, + ## but will be *empty* for single-column sorting + % if len(grid.active_sorters) > 1: + sortingPriority: ${json.dumps(grid.active_sorters)|n}, + % else: + sortingPriority: [], + % endif + % endif ## filterable: ${json.dumps(grid.filterable)|n}, @@ -395,6 +429,15 @@ }, }, + mounted() { + ## TODO: there is a bug (?) which prevents the arrow from + ## displaying for simple default single-column sort. so to + ## work around that, we *disable* multi-sort until the + ## component is mounted. seems to work for now..see also + ## https://github.com/buefy/buefy/issues/2584 + this.allowMultiSort = true + }, + methods: { % if grid.click_handlers: @@ -455,9 +498,9 @@ getBasicParams() { let params = {} % if grid.sortable: - for (let i = 1; i <= this.sortingPriority.length; i++) { - params['sort'+i+'key'] = this.sortingPriority[i-1][0] - params['sort'+i+'dir'] = this.sortingPriority[i-1][1] + for (let i = 1; i <= this.backendSorters.length; i++) { + params['sort'+i+'key'] = this.backendSorters[i-1].field + params['sort'+i+'dir'] = this.backendSorters[i-1].order } % endif % if grid.pageable: @@ -537,14 +580,45 @@ this.loadAsyncData() }, - onSort(field, order) { - this.sortingPriority = [[field, order]] + onSort(field, order, event) { + + if (event.ctrlKey) { + + // engage or enhance multi-column sorting + let sorter = this.backendSorters.filter(i => i.field === field)[0] + if (sorter) { + sorter.order = sorter.order === 'desc' ? 'asc' : 'desc' + } else { + this.backendSorters.push({field, order}) + } + this.sortingPriority = this.backendSorters + + } else { + + // sort by single column only + this.backendSorters = [{field, order}] + this.sortingPriority = [] + } + // always reset to first page when changing sort options // TODO: i mean..right? would we ever not want that? this.currentPage = 1 this.loadAsyncData() }, + sortingPriorityRemoved(field) { + + // prune field from active sorters + this.backendSorters = this.backendSorters.filter( + (sorter) => sorter.field !== field) + + // nb. must keep active sorter list "as-is" even if + // there is only one sorter; buefy seems to expect it + this.sortingPriority = this.backendSorters + + this.loadAsyncData() + }, + resetView() { this.loading = true From 9efe767654db3bffb03d9391c5e2a826e021b208 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 9 Oct 2023 00:19:29 -0500 Subject: [PATCH 1254/1681] Add smarts to show display text for some version diff fields e.g. show `str(customer)` along with `customer_uuid` since almost nobody will "care" about the uuid so much, they just want the name --- tailbone/diffs.py | 85 ++++++++++++++++++- tailbone/templates/diff.mako | 2 +- tailbone/templates/master/view_version.mako | 69 ++------------- .../templates/people/view_profile_buefy.mako | 4 +- tailbone/views/master.py | 21 ++++- tailbone/views/people.py | 32 ++----- 6 files changed, 118 insertions(+), 95 deletions(-) diff --git a/tailbone/diffs.py b/tailbone/diffs.py index d57aa9ac..431c2efe 100644 --- a/tailbone/diffs.py +++ b/tailbone/diffs.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,7 +24,8 @@ Tools for displaying data diffs """ -from __future__ import unicode_literals, absolute_import +import sqlalchemy as sa +import sqlalchemy_continuum as continuum from pyramid.renderers import render from webhelpers2.html import HTML @@ -36,7 +37,7 @@ class Diff(object): """ def __init__(self, old_data, new_data, columns=None, fields=None, - render_field=None, render_value=None, + render_field=None, render_value=None, nature='dirty', monospace=False, extra_row_attrs=None): """ Constructor. You must provide the old and new data sets, and @@ -64,6 +65,7 @@ class Diff(object): self.fields = fields or self.make_fields() self._render_field = render_field or self.render_field_default self.render_value = render_value or self.render_value_default + self.nature = nature self.monospace = monospace self.extra_row_attrs = extra_row_attrs @@ -126,3 +128,80 @@ class Diff(object): def render_new_value(self, field): value = self.new_value(field) return self.render_value(field, value) + + +class VersionDiff(Diff): + """ + Special diff class, for use with version history views + """ + + def __init__(self, version, *args, **kwargs): + self.title = kwargs.pop('title', None) + + if 'nature' not in kwargs: + if version.previous and version.operation_type == continuum.Operation.DELETE: + kwargs['nature'] = 'deleted' + elif version.previous: + kwargs['nature'] = 'dirty' + else: + kwargs['nature'] = 'new' + + super().__init__(*args, **kwargs) + + self.version = version + self.mapper = sa.inspect(continuum.parent_class(type(self.version))) + + def render_version_value(self, field, value, version): + text = HTML.tag('span', c=[repr(value)], + style='font-family: monospace;') + + for prop in self.mapper.relationships: + if prop.uselist: + continue + + for col in prop.local_columns: + if col.name != field: + continue + + if not hasattr(version, prop.key): + continue + + if col in self.mapper.primary_key: + continue + + ref = getattr(version, prop.key) + if ref: + ref = ref.version_parent + if ref: + return HTML.tag('span', c=[ + text, + HTML.tag('span', c=[str(ref)], + style='margin-left: 2rem; font-style: italic; font-weight: bold;'), + ]) + + return text + + def render_old_value(self, field): + if self.nature == 'new': + return '' + value = self.old_value(field) + return self.render_version_value(field, value, self.version.previous) + + def render_new_value(self, field): + if self.nature == 'deleted': + return '' + value = self.new_value(field) + return self.render_version_value(field, value, self.version) + + def as_struct(self): + values = {} + for field in self.fields: + values[field] = {'before': self.render_old_value(field), + 'after': self.render_new_value(field)} + return { + 'key': id(self.version), + 'model_title': self.title, + 'diff_class': self.nature, + 'fields': self.fields, + 'values': values, + } diff --git a/tailbone/templates/diff.mako b/tailbone/templates/diff.mako index 3e5ec99e..a78bd770 100644 --- a/tailbone/templates/diff.mako +++ b/tailbone/templates/diff.mako @@ -1,5 +1,5 @@ ## -*- coding: utf-8; -*- -<table class="diff dirty${' monospace' if diff.monospace else ''}"> +<table class="diff ${diff.nature} ${' monospace' if diff.monospace else ''}"> <thead> <tr> % for column in diff.columns: diff --git a/tailbone/templates/master/view_version.mako b/tailbone/templates/master/view_version.mako index 5dbcd15d..d29a3496 100644 --- a/tailbone/templates/master/view_version.mako +++ b/tailbone/templates/master/view_version.mako @@ -50,71 +50,12 @@ </div><!-- form-wrapper --> <div class="versions-wrapper"> -% for version in versions: - - <h2>${title_for_version(version)}</h2> - - % if version.previous and version.operation_type == continuum.Operation.DELETE: - <table class="diff monospace deleted"> - <thead> - <tr> - <th>field name</th> - <th>old value</th> - <th>new value</th> - </tr> - </thead> - <tbody> - % for field in fields_for_version(version): - <tr> - <td class="field">${field}</td> - <td class="value old-value">${render_old_value(version, field)}</td> - <td class="value new-value"> </td> - </tr> - % endfor - </tbody> - </table> - % elif version.previous: - <table class="diff monospace dirty"> - <thead> - <tr> - <th>field name</th> - <th>old value</th> - <th>new value</th> - </tr> - </thead> - <tbody> - % for field in fields_for_version(version): - <tr${' class="diff"' if getattr(version, field) != getattr(version.previous, field) else ''|n}> - <td class="field">${field}</td> - <td class="value old-value">${render_old_value(version, field)}</td> - <td class="value new-value">${render_new_value(version, field, 'dirty')}</td> - </tr> - % endfor - </tbody> - </table> - % else: - <table class="diff monospace new"> - <thead> - <tr> - <th>field name</th> - <th>old value</th> - <th>new value</th> - </tr> - </thead> - <tbody> - % for field in fields_for_version(version): - <tr> - <td class="field">${field}</td> - <td class="value old-value"> </td> - <td class="value new-value">${render_new_value(version, field, 'new')}</td> - </tr> - % endfor - </tbody> - </table> - % endif - -% endfor + % for diff in version_diffs: + <h2>${diff.title}</h2> + ${diff.render_html()} + % endfor </div> + </%def> diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako index 5574088e..4b1e089c 100644 --- a/tailbone/templates/people/view_profile_buefy.mako +++ b/tailbone/templates/people/view_profile_buefy.mako @@ -1456,8 +1456,8 @@ :class="{diff: version.values[field].after != version.values[field].before}" v-show="revisionShowAllFields || version.values[field].after != version.values[field].before"> <td class="field">{{ field }}</td> - <td class="old-value">{{ version.values[field].before }}</td> - <td class="new-value">{{ version.values[field].after }}</td> + <td class="old-value" v-html="version.values[field].before"></td> + <td class="new-value" v-html="version.values[field].after"></td> </tr> </tbody> </table> diff --git a/tailbone/views/master.py b/tailbone/views/master.py index ac68a02f..167bdace 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -1361,6 +1361,20 @@ class MasterView(View): if newer: next_url = self.request.route_url('{}.version'.format(route_prefix), uuid=instance.uuid, txnid=newer.id) + version_diffs = [] + versions = self.get_relevant_versions(transaction, instance) + for version in versions: + + old_data = {} + new_data = {} + fields = self.fields_for_version(version) + for field in fields: + if version.previous: + old_data[field] = getattr(version.previous, field) + new_data[field] = getattr(version, field) + diff = self.make_version_diff(version, old_data, new_data, fields=fields) + version_diffs.append(diff) + return self.render_to_response('view_version', { 'instance': instance, 'instance_title': "{} (history)".format(instance_title), @@ -1368,7 +1382,7 @@ class MasterView(View): 'instance_url': self.get_action_url('versions', instance), 'transaction': transaction, 'changed': localtime(self.rattail_config, transaction.issued_at, from_utc=True), - 'versions': self.get_relevant_versions(transaction, instance), + 'version_diffs': version_diffs, 'show_prev_next': True, 'prev_url': prev_url, 'next_url': next_url, @@ -4815,6 +4829,11 @@ class MasterView(View): def make_diff(self, old_data, new_data, **kwargs): return diffs.Diff(old_data, new_data, **kwargs) + def make_version_diff(self, version, old_data, new_data, **kwargs): + if 'title' not in kwargs: + kwargs['title'] = self.title_for_version(version) + return diffs.VersionDiff(version, old_data, new_data, **kwargs) + ############################## # Configuration Views ############################## diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 0aaf4c26..31760d2a 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -1398,25 +1398,15 @@ class PersonView(MasterView): # also organize final transaction/versions (diff) map vmap = {} for version in versions: - - if version.previous and version.operation_type == continuum.Operation.DELETE: - diff_class = 'deleted' - elif version.previous: - diff_class = 'dirty' - else: - diff_class = 'new' - - # collect before/after field values for version fields = self.fields_for_version(version) - values = {} + + old_data = {} + new_data = {} for field in fields: - before = '' - after = '' - if diff_class != 'new': - before = repr(getattr(version.previous, field)) - if diff_class != 'deleted': - after = repr(getattr(version, field)) - values[field] = {'before': before, 'after': after} + if version.previous: + old_data[field] = getattr(version.previous, field) + new_data[field] = getattr(version, field) + diff = self.make_version_diff(version, old_data, new_data, fields=fields) if version.transaction_id not in vmap: txn = version.transaction @@ -1439,13 +1429,7 @@ class PersonView(MasterView): 'versions': [], } - vmap[version.transaction_id]['versions'].append({ - 'key': id(version), - 'model_title': self.title_for_version(version), - 'diff_class': diff_class, - 'fields': fields, - 'values': values, - }) + vmap[version.transaction_id]['versions'].append(diff.as_struct()) return {'data': data, 'vmap': vmap} From 44112a3a4b5d2a13c559752fb7dd71d9be836713 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 9 Oct 2023 15:50:41 -0500 Subject: [PATCH 1255/1681] Allow null for FalafelDateTime form fields --- tailbone/forms/types.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tailbone/forms/types.py b/tailbone/forms/types.py index 3e4952e4..ac7f2d43 100644 --- a/tailbone/forms/types.py +++ b/tailbone/forms/types.py @@ -87,7 +87,7 @@ class FalafelDateTime(colander.DateTime): def serialize(self, node, appstruct): if not appstruct: - return colander.null + return {} # cant use isinstance; dt subs date if type(appstruct) is datetime.date: @@ -111,6 +111,9 @@ class FalafelDateTime(colander.DateTime): if not cstruct: return colander.null + if not cstruct['date'] and not cstruct['time']: + return colander.null + try: date = datetime.datetime.strptime(cstruct['date'], '%Y-%m-%d').date() except: From 4328b9e38510655a8d14f85ed82e4c28e8d9e804 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 10 Oct 2023 10:54:16 -0500 Subject: [PATCH 1256/1681] Show full version history within the "view" page avoid full page loads when navigating version history --- tailbone/diffs.py | 28 ++- tailbone/grids/core.py | 12 +- tailbone/static/css/layout.css | 13 +- tailbone/templates/base.mako | 157 ++++++------ tailbone/templates/grids/buefy.mako | 19 +- tailbone/templates/master/edit.mako | 3 +- tailbone/templates/master/view.mako | 255 ++++++++++++++++++-- tailbone/templates/master/view_version.mako | 7 +- tailbone/views/master.py | 134 +++++++++- 9 files changed, 498 insertions(+), 130 deletions(-) diff --git a/tailbone/diffs.py b/tailbone/diffs.py index 431c2efe..1c73635a 100644 --- a/tailbone/diffs.py +++ b/tailbone/diffs.py @@ -136,6 +136,9 @@ class VersionDiff(Diff): """ def __init__(self, version, *args, **kwargs): + self.version = version + self.mapper = sa.inspect(continuum.parent_class(type(self.version))) + self.version_mapper = sa.inspect(type(self.version)) self.title = kwargs.pop('title', None) if 'nature' not in kwargs: @@ -146,10 +149,31 @@ class VersionDiff(Diff): else: kwargs['nature'] = 'new' + if 'fields' not in kwargs: + kwargs['fields'] = self.get_default_fields() + + if not args: + old_data = {} + new_data = {} + for field in kwargs['fields']: + if version.previous: + old_data[field] = getattr(version.previous, field) + new_data[field] = getattr(version, field) + args = (old_data, new_data) + super().__init__(*args, **kwargs) - self.version = version - self.mapper = sa.inspect(continuum.parent_class(type(self.version))) + def get_default_fields(self): + fields = sorted(self.version_mapper.columns.keys()) + + unwanted = [ + 'transaction_id', + 'end_transaction_id', + 'operation_type', + ] + + return [field for field in fields + if field not in unwanted] def render_version_value(self, field, value, version): text = HTML.tag('span', c=[repr(value)], diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index e42f8714..dc1a5af0 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -1334,6 +1334,7 @@ class Grid(object): context['grid'] = self context['request'] = self.request context.setdefault('allow_save_defaults', True) + context.setdefault('view_click_handler', self.get_view_click_handler()) return render(template, context) def render_buefy(self, template='/grids/buefy.mako', **kwargs): @@ -1374,6 +1375,10 @@ class Grid(object): context.setdefault('paginated', False) if context['paginated']: context.setdefault('per_page', 20) + context['view_click_handler'] = self.get_view_click_handler() + return render(template, context) + + def get_view_click_handler(self): # locate the 'view' action # TODO: this should be easier, and/or moved elsewhere? @@ -1388,11 +1393,8 @@ class Grid(object): view = action break - context['view_click_handler'] = None - if view and view.click_handler: - context['view_click_handler'] = view.click_handler - - return render(template, context) + if view: + return view.click_handler def set_filters_sequence(self, filters, only=False): """ diff --git a/tailbone/static/css/layout.css b/tailbone/static/css/layout.css index cc4d0015..bdf35410 100644 --- a/tailbone/static/css/layout.css +++ b/tailbone/static/css/layout.css @@ -61,13 +61,14 @@ header .level .theme-picker { display: inline-flex; } -#content-title { - padding: 0.3rem; -} - #content-title h1 { - font-size: 2rem; - margin-left: 1rem; + margin-bottom: 0; + margin-right: 1rem; + max-width: 50%; + overflow: hidden; + padding: 0 0.3rem; + text-overflow: ellipsis; + white-space: nowrap; } /****************************** diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index 0e767353..8558eeb7 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -426,17 +426,22 @@ ## Page Title % if capture(self.content_title): - <section id="content-title" class="hero is-primary"> - <div class="level"> - <div class="level-left"> - <div class="level-item"> - <h1 class="title" v-html="contentTitleHTML"></h1> - </div> + <section id="content-title" + class="has-background-primary"> + <div style="display: flex; align-items: center; padding: 0.5rem;"> + + <h1 class="title has-text-white" + v-html="contentTitleHTML"> + </h1> + + <div style="flex-grow: 1; display: flex; gap: 0.5rem;"> ${self.render_instance_header_title_extras()} </div> - <div class="level-right"> + + <div style="display: flex; gap: 0.5rem;"> ${self.render_instance_header_buttons()} </div> + </div> </section> % endif @@ -634,76 +639,60 @@ ## TODO: is there a better way to check if viewing parent? % if parent_instance is Undefined: % if master.editable and instance_editable and master.has_perm('edit'): - <div class="level-item"> - <once-button tag="a" href="${action_url('edit', instance)}" - icon-left="edit" - text="Edit This"> - </once-button> - </div> + <once-button tag="a" href="${action_url('edit', instance)}" + icon-left="edit" + text="Edit This"> + </once-button> % endif % if master.cloneable and master.has_perm('clone'): - <div class="level-item"> - <once-button tag="a" href="${action_url('clone', instance)}" - icon-left="object-ungroup" - text="Clone This"> - </once-button> - </div> + <once-button tag="a" href="${action_url('clone', instance)}" + icon-left="object-ungroup" + text="Clone This"> + </once-button> % endif % if master.deletable and instance_deletable and master.has_perm('delete'): - <div class="level-item"> - <once-button tag="a" href="${action_url('delete', instance)}" - type="is-danger" - icon-left="trash" - text="Delete This"> - </once-button> - </div> + <once-button tag="a" href="${action_url('delete', instance)}" + type="is-danger" + icon-left="trash" + text="Delete This"> + </once-button> % endif % else: ## viewing row % if instance_deletable and master.has_perm('delete_row'): - <div class="level-item"> - <once-button tag="a" href="${action_url('delete', instance)}" - type="is-danger" - icon-left="trash" - text="Delete This"> - </once-button> - </div> + <once-button tag="a" href="${action_url('delete', instance)}" + type="is-danger" + icon-left="trash" + text="Delete This"> + </once-button> % endif % endif % elif master and master.editing: % if master.viewable and master.has_perm('view'): - <div class="level-item"> - <once-button tag="a" href="${action_url('view', instance)}" - icon-left="eye" - text="View This"> - </once-button> - </div> + <once-button tag="a" href="${action_url('view', instance)}" + icon-left="eye" + text="View This"> + </once-button> % endif % if master.deletable and instance_deletable and master.has_perm('delete'): - <div class="level-item"> - <once-button tag="a" href="${action_url('delete', instance)}" - type="is-danger" - icon-left="trash" - text="Delete This"> - </once-button> - </div> + <once-button tag="a" href="${action_url('delete', instance)}" + type="is-danger" + icon-left="trash" + text="Delete This"> + </once-button> % endif % elif master and master.deleting: % if master.viewable and master.has_perm('view'): - <div class="level-item"> - <once-button tag="a" href="${action_url('view', instance)}" - icon-left="eye" - text="View This"> - </once-button> - </div> + <once-button tag="a" href="${action_url('view', instance)}" + icon-left="eye" + text="View This"> + </once-button> % endif % if master.editable and instance_editable and master.has_perm('edit'): - <div class="level-item"> - <once-button tag="a" href="${action_url('edit', instance)}" - icon-left="edit" - text="Edit This"> - </once-button> - </div> + <once-button tag="a" href="${action_url('edit', instance)}" + icon-left="edit" + text="Edit This"> + </once-button> % endif % endif </%def> @@ -711,40 +700,32 @@ <%def name="render_prevnext_header_buttons()"> % if show_prev_next is not Undefined and show_prev_next: % if prev_url: - <div class="level-item"> - <b-button tag="a" href="${prev_url}" - icon-pack="fas" - icon-left="arrow-left"> - Older - </b-button> - </div> + <b-button tag="a" href="${prev_url}" + icon-pack="fas" + icon-left="arrow-left"> + Older + </b-button> % else: - <div class="level-item"> - <b-button tag="a" href="#" - disabled - icon-pack="fas" - icon-left="arrow-left"> - Older - </b-button> - </div> + <b-button tag="a" href="#" + disabled + icon-pack="fas" + icon-left="arrow-left"> + Older + </b-button> % endif % if next_url: - <div class="level-item"> - <b-button tag="a" href="${next_url}" - icon-pack="fas" - icon-left="arrow-right"> - Newer - </b-button> - </div> + <b-button tag="a" href="${next_url}" + icon-pack="fas" + icon-left="arrow-right"> + Newer + </b-button> % else: - <div class="level-item"> - <b-button tag="a" href="#" - disabled - icon-pack="fas" - icon-left="arrow-right"> - Newer - </b-button> - </div> + <b-button tag="a" href="#" + disabled + icon-pack="fas" + icon-left="arrow-right"> + Newer + </b-button> % endif % endif </%def> diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako index 5b21b42a..6fdcf77d 100644 --- a/tailbone/templates/grids/buefy.mako +++ b/tailbone/templates/grids/buefy.mako @@ -254,7 +254,12 @@ % if column['field'] in grid.raw_renderers: ${grid.raw_renderers[column['field']]()} % elif grid.is_linked(column['field']): - <a :href="props.row._action_url_view" v-html="props.row.${column['field']}"></a> + <a :href="props.row._action_url_view" + % if view_click_handler: + @click.prevent="${view_click_handler}" + % endif + v-html="props.row.${column['field']}"> + </a> % else: <span v-html="props.row.${column['field']}"></span> % endif @@ -274,6 +279,9 @@ % if action.click_handler: @click.prevent="${action.click_handler}" % endif + % if action.target: + target="${action.target}" + % endif > ${action.render_icon()|n} ${action.render_label()|n} @@ -533,7 +541,7 @@ ## TODO: i noticed buefy docs show using `async` keyword here, ## so now i am too. knowing nothing at all of if/how this is ## supposed to improve anything. we shall see i guess - async loadAsyncData(params, callback) { + async loadAsyncData(params, success, failure) { if (params === undefined || params === null) { params = new URLSearchParams(this.getBasicParams()) @@ -551,14 +559,17 @@ this.lastItem = data.last_item this.loading = false this.checkedRows = this.locateCheckedRows(data.checked_rows) - if (callback) { - callback() + if (success) { + success() } }) .catch((error) => { this.data = [] this.total = 0 this.loading = false + if (failure) { + failure() + } throw error }) }, diff --git a/tailbone/templates/master/edit.mako b/tailbone/templates/master/edit.mako index f1bc7318..a03912e6 100644 --- a/tailbone/templates/master/edit.mako +++ b/tailbone/templates/master/edit.mako @@ -1,7 +1,8 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/form.mako" /> -<%def name="title()">Edit: ${instance_title}</%def> +<%def name="title()">${index_title} » ${instance_title} » Edit</%def> +<%def name="content_title()">Edit: ${instance_title}</%def> ${parent.body()} diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako index e6d0c8de..b5930664 100644 --- a/tailbone/templates/master/view.mako +++ b/tailbone/templates/master/view.mako @@ -8,7 +8,6 @@ </%def> <%def name="render_instance_header_title_extras()"> - <span style="width: 2rem;"></span> % if master.touchable and master.has_perm('touch'): <b-button title=""Touch" this record to trigger sync" icon-pack="fas" @@ -17,6 +16,13 @@ :disabled="touchSubmitting"> </b-button> % endif + % if expose_versions: + <b-button icon-pack="fas" + icon-left="history" + @click="viewingHistory = !viewingHistory"> + {{ viewingHistory ? "View Current" : "View History" }} + </b-button> + % endif </%def> <%def name="object_helpers()"> @@ -46,9 +52,6 @@ ## TODO: either make this configurable, or just lose it. ## nobody seems to ever find it useful in practice. ## <li>${h.link_to("Permalink for this {}".format(model_title), action_url('view', instance))}</li> - % if master.has_versions and request.rattail_config.versioning_enabled() and request.has_perm('{}.versions'.format(permission_prefix)): - <li>${h.link_to("Version History", action_url('versions', instance))}</li> - % endif </%def> <%def name="render_row_grid_tools()"> @@ -69,14 +72,152 @@ % endif </%def> +<%def name="render_this_page_component()"> + ## TODO: should override this in a cleaner way! too much duplicate code w/ parent template + <this-page @change-content-title="changeContentTitle" + % if can_edit_help: + :configure-fields-help="configureFieldsHelp" + % endif + % if expose_versions: + :viewing-history="viewingHistory" + % endif + > + </this-page> +</%def> + <%def name="render_this_page()"> - ${parent.render_this_page()} - % if master.has_rows: - <br /> - % if rows_title: - <h4 class="block is-size-4">${rows_title}</h4> - % endif - ${self.render_row_grid_component()} + <div + % if expose_versions: + v-show="!viewingHistory" + % endif + > + + ## render main form + ${parent.render_this_page()} + + ## render row grid + % if master.has_rows: + <br /> + % if rows_title: + <h4 class="block is-size-4">${rows_title}</h4> + % endif + ${self.render_row_grid_component()} + % endif + </div> + + % if expose_versions: + <div v-show="viewingHistory"> + + <div style="display: flex; align-items: center; gap: 2rem;"> + <h3 class="is-size-3">Version History</h3> + <p class="block"> + <a href="${master.get_action_url('versions', instance)}" + target="_blank"> + <i class="fas fa-external-link-alt"></i> + View as separate page + </a> + </p> + </div> + + <versions-grid ref="versionsGrid" + @view-revision="viewRevision"> + </versions-grid> + + <b-modal :active.sync="viewVersionShowDialog" :width="1200"> + <div class="card"> + <div class="card-content"> + <div style="display: flex; flex-direction: column; gap: 1.5rem;"> + + <div style="display: flex; gap: 1rem;"> + + <div style="flex-grow: 1;"> + <b-field horizontal label="Changed"> + <div v-html="viewVersionData.changed"></div> + </b-field> + <b-field horizontal label="Changed by"> + <div v-html="viewVersionData.changed_by"></div> + </b-field> + <b-field horizontal label="IP Address"> + <div v-html="viewVersionData.remote_addr"></div> + </b-field> + <b-field horizontal label="Comment"> + <div v-html="viewVersionData.comment"></div> + </b-field> + <b-field horizontal label="TXN ID"> + <div v-html="viewVersionData.txnid"></div> + </b-field> + </div> + + <div style="display: flex; flex-direction: column; justify-content: space-between;"> + + <div class="buttons"> + <b-button @click="viewPrevRevision()" + type="is-primary" + icon-pack="fas" + icon-left="arrow-left" + :disabled="!viewVersionData.prev_txnid"> + Older + </b-button> + <b-button @click="viewNextRevision()" + type="is-primary" + icon-pack="fas" + icon-right="arrow-right" + :disabled="!viewVersionData.next_txnid"> + Newer + </b-button> + </div> + + <div> + <a :href="viewVersionData.url" + target="_blank"> + <i class="fas fa-external-link-alt"></i> + View as separate page + </a> + </div> + + <b-button @click="toggleVersionFields()"> + {{ viewVersionShowAllFields ? "Show Diffs Only" : "Show All Fields" }} + </b-button> + </div> + + </div> + + <div v-for="version in viewVersionData.versions" + :key="version.key"> + + <p class="block has-text-weight-bold"> + {{ version.model_title }} + </p> + + <table class="diff monospace is-size-7" + :class="version.diff_class"> + <thead> + <tr> + <th>field name</th> + <th>old value</th> + <th>new value</th> + </tr> + </thead> + <tbody> + <tr v-for="field in version.fields" + :key="field" + :class="{diff: version.values[field].after != version.values[field].before}" + v-show="viewVersionShowAllFields || version.values[field].after != version.values[field].before"> + <td class="field has-text-weight-bold">{{ field }}</td> + <td class="old-value" v-html="version.values[field].before"></td> + <td class="new-value" v-html="version.values[field].after"></td> + </tr> + </tbody> + </table> + + </div> + + </div> + <b-loading :active.sync="viewVersionLoading" :is-full-page="false"></b-loading> + </div> + </div> + </b-modal> + </div> % endif </%def> @@ -90,12 +231,79 @@ ${rows_grid.render_buefy(allow_save_defaults=False, tools=capture(self.render_row_grid_tools))|n} % endif ${parent.render_this_page_template()} + % if expose_versions: + ${versions_grid.render_buefy()|n} + % endif +</%def> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + % if expose_versions: + <script type="text/javascript"> + + ThisPage.props.viewingHistory = Boolean + + ThisPageData.gettingRevisions = false + ThisPageData.gotRevisions = false + + ThisPageData.viewVersionShowDialog = false + ThisPageData.viewVersionData = {} + ThisPageData.viewVersionShowAllFields = false + ThisPageData.viewVersionLoading = false + + // auto-fetch grid results when first viewing history + ThisPage.watch.viewingHistory = function(newval, oldval) { + if (!this.gotRevisions && !this.gettingRevisions) { + this.gettingRevisions = true + this.$refs.versionsGrid.loadAsyncData(null, () => { + this.gettingRevisions = false + this.gotRevisions = true + }, () => { + this.gettingRevisions = false + }) + } + } + + VersionsGrid.methods.viewRevision = function(row) { + this.$emit('view-revision', row) + } + + ThisPage.methods.viewRevision = function(row) { + this.viewVersionLoading = true + + let url = '${master.get_action_url('revisions_data', instance)}' + let params = {txnid: row.id} + this.simpleGET(url, params, response => { + this.viewVersionData = response.data + this.viewVersionLoading = false + }, response => { + this.viewVersionLoading = false + }) + + this.viewVersionShowDialog = true + } + + ThisPage.methods.viewPrevRevision = function() { + this.viewRevision({id: this.viewVersionData.prev_txnid}) + } + + ThisPage.methods.viewNextRevision = function() { + this.viewRevision({id: this.viewVersionData.next_txnid}) + } + + ThisPage.methods.toggleVersionFields = function() { + this.viewVersionShowAllFields = !this.viewVersionShowAllFields + } + + </script> + % endif </%def> <%def name="modify_whole_page_vars()"> ${parent.modify_whole_page_vars()} - % if master.touchable and master.has_perm('touch'): - <script type="text/javascript"> + <script type="text/javascript"> + + % if master.touchable and master.has_perm('touch'): WholePageData.touchSubmitting = false @@ -104,21 +312,30 @@ location.href = '${master.get_action_url('touch', instance)}' } - </script> - % endif + % endif + + % if expose_versions: + WholePageData.viewingHistory = false + % endif + + </script> </%def> <%def name="finalize_this_page_vars()"> ${parent.finalize_this_page_vars()} - % if master.has_rows: <script type="text/javascript"> - TailboneGrid.data = function() { return TailboneGridData } + % if master.has_rows: + TailboneGrid.data = function() { return TailboneGridData } + Vue.component('tailbone-grid', TailboneGrid) + % endif - Vue.component('tailbone-grid', TailboneGrid) + % if expose_versions: + VersionsGrid.data = function() { return VersionsGridData } + Vue.component('versions-grid', VersionsGrid) + % endif </script> - % endif </%def> diff --git a/tailbone/templates/master/view_version.mako b/tailbone/templates/master/view_version.mako index d29a3496..6417dfb7 100644 --- a/tailbone/templates/master/view_version.mako +++ b/tailbone/templates/master/view_version.mako @@ -45,13 +45,18 @@ <div class="field">${transaction.meta.get('comment') or ''}</div> </div> + <div class="field-wrapper"> + <label>TXN ID</label> + <div class="field">${transaction.id}</div> + </div> + </div> </div><!-- form-wrapper --> <div class="versions-wrapper"> % for diff in version_diffs: - <h2>${diff.title}</h2> + <h4 class="is-size-4 block">${diff.title}</h4> ${diff.render_html()} % endfor </div> diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 167bdace..21418521 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -1172,6 +1172,12 @@ class MasterView(View): context['rows_grid'] = grid context['rows_grid_tools'] = HTML(self.make_row_grid_tools(instance) or '').strip() + context['expose_versions'] = (self.has_versions + and self.request.rattail_config.versioning_enabled() + and self.has_perm('versions')) + if context['expose_versions']: + context['versions_grid'] = self.make_revisions_grid(instance, empty_data=True) + return self.render_to_response('view', context) def image(self): @@ -1300,7 +1306,7 @@ class MasterView(View): return cls.version_grid_key return '{}.history'.format(cls.get_route_prefix()) - def get_version_data(self, instance): + def get_version_data(self, instance, order_by=True): """ Generate the base data set for the version grid. """ @@ -1308,7 +1314,9 @@ class MasterView(View): transaction_class = continuum.transaction_class(model_class) query = model_transaction_query(self.Session(), instance, model_class, child_classes=self.normalize_version_child_classes()) - return query.order_by(transaction_class.issued_at.desc()) + if order_by: + query = query.order_by(transaction_class.issued_at.desc()) + return query def get_version_child_classes(self): """ @@ -1330,6 +1338,114 @@ class MasterView(View): classes.append(cls) return classes + def make_revisions_grid(self, obj, empty_data=False): + route_prefix = self.get_route_prefix() + row_url = lambda txn, i: self.request.route_url(f'{route_prefix}.version', + uuid=obj.uuid, + txnid=txn.id) + + kwargs = { + 'component': 'versions-grid', + 'ajax_data_url': self.get_action_url('revisions_data', obj), + 'sortable': True, + 'default_sortkey': 'changed', + 'default_sortdir': 'desc', + 'main_actions': [ + self.make_action('view', icon='eye', url='#', + click_handler='viewRevision(props.row)'), + self.make_action('view_separate', url=row_url, target='_blank', + icon='external-link-alt', ), + ], + } + + if empty_data: + + # TODO: surely there is a better way to have empty initial + # data..? but so much logic depends on a query, can't + # just pass empty list here + txn_class = continuum.transaction_class(self.get_model_class()) + meta_class = continuum.versioning_manager.transaction_meta_cls + kwargs['data'] = self.Session.query(txn_class)\ + .outerjoin(meta_class, + meta_class.transaction_id == txn_class.id)\ + .filter(txn_class.id == -1) + + else: + kwargs['data'] = self.get_version_data(obj, order_by=False) + + grid = self.make_version_grid(**kwargs) + + grid.set_joiner('user', lambda q: q.outerjoin(self.model.User)) + grid.set_sorter('user', self.model.User.username) + + grid.set_link('remote_addr') + + grid.append('id') + grid.set_label('id', "TXN ID") + grid.set_link('id') + + return grid + + def revisions_data(self): + """ + AJAX view to fetch revision data for current instance. + """ + txnid = self.request.GET.get('txnid') + if txnid: + # return single txn data + + app = self.get_rattail_app() + obj = self.get_instance() + cls = self.get_model_class() + txn_cls = continuum.transaction_class(cls) + route_prefix = self.get_route_prefix() + + transactions = model_transaction_query( + self.Session(), obj, cls, + child_classes=self.normalize_version_child_classes()) + + txn = transactions.filter(txn_cls.id == txnid).first() + if not txn: + return self.notfound() + + older = transactions.filter(txn_cls.issued_at <= txn.issued_at)\ + .filter(txn_cls.id != txnid)\ + .order_by(txn_cls.issued_at.desc())\ + .first() + newer = transactions.filter(txn_cls.issued_at >= txn.issued_at)\ + .filter(txn_cls.id != txnid)\ + .order_by(txn_cls.issued_at)\ + .first() + + version_diffs = [] + for version in self.get_relevant_versions(txn, obj): + diff = self.make_version_diff(version) + version_diffs.append(diff.as_struct()) + + changed_raw = app.render_datetime(app.localtime(txn.issued_at, from_utc=True)) + changed_ago = app.render_time_ago(app.make_utc() - txn.issued_at) + + changed_by = str(txn.user) + if self.request.has_perm('users.view'): + changed_by = tags.link_to(changed_by, self.request.route_url('users.view', uuid=txn.user.uuid)) + + return { + 'txnid': txn.id, + 'changed': f"{changed_raw} ({changed_ago})", + 'changed_by': changed_by, + 'remote_addr': txn.remote_addr, + 'comment': txn.meta.get('comment'), + 'versions': version_diffs, + 'url': self.request.route_url(f'{route_prefix}.version', uuid=obj.uuid, txnid=txnid), + 'prev_txnid': older.id if older else None, + 'next_txnid': newer.id if newer else None, + } + + else: # no txnid, return grid data + obj = self.get_instance() + grid = self.make_revisions_grid(obj) + return grid.get_buefy_data() + def view_version(self): """ View showing diff details of a particular object version. @@ -4829,10 +4945,10 @@ class MasterView(View): def make_diff(self, old_data, new_data, **kwargs): return diffs.Diff(old_data, new_data, **kwargs) - def make_version_diff(self, version, old_data, new_data, **kwargs): + def make_version_diff(self, version, *args, **kwargs): if 'title' not in kwargs: kwargs['title'] = self.title_for_version(version) - return diffs.VersionDiff(version, old_data, new_data, **kwargs) + return diffs.VersionDiff(version, *args, **kwargs) ############################## # Configuration Views @@ -5576,6 +5692,16 @@ class MasterView(View): route_name='{}.version'.format(route_prefix), permission='{}.versions'.format(permission_prefix)) + # revisions data (AJAX) + config.add_route(f'{route_prefix}.revisions_data', + f'{instance_url_prefix}/revisions-data', + request_method='GET') + config.add_view(cls, attr='revisions_data', + route_name=f'{route_prefix}.revisions_data', + permission=f'{permission_prefix}.versions', + renderer='json') + + @classmethod def _defaults_edit_help(cls, config, **kwargs): route_prefix = cls.get_route_prefix() From 78deb5d09a9395ef02287a14c64c44bc359b1208 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 10 Oct 2023 22:01:46 -0500 Subject: [PATCH 1257/1681] Use autocomplete instead of dropdown for grid "add filter" --- tailbone/templates/grids/buefy.mako | 64 ++++++++++++++++++--- tailbone/templates/grids/filters_buefy.mako | 35 +++++++---- 2 files changed, 80 insertions(+), 19 deletions(-) diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako index 6fdcf77d..a3e6e229 100644 --- a/tailbone/templates/grids/buefy.mako +++ b/tailbone/templates/grids/buefy.mako @@ -358,7 +358,6 @@ let ${grid.component_studly}Data = { loading: false, - selectedFilter: null, ajaxDataUrl: ${json.dumps(grid.ajax_data_url)|n}, data: ${grid.component_studly}CurrentData, @@ -401,7 +400,8 @@ ## filterable: ${json.dumps(grid.filterable)|n}, filters: ${json.dumps(filters_data if grid.filterable else None)|n}, filtersSequence: ${json.dumps(filters_sequence if grid.filterable else None)|n}, - selectedFilter: null, + addFilterTerm: '', + addFilterShow: false, ## dummy input value needed for sharing links on *insecure* sites % if request.scheme == 'http': @@ -420,6 +420,39 @@ computed: { + addFilterChoices() { + + // collect all filters, which are *not* already shown + let choices = [] + for (let field of this.filtersSequence) { + let filtr = this.filters[field] + if (!filtr.visible) { + choices.push(filtr) + } + } + + // parse list of search terms + let terms = [] + for (let term of this.addFilterTerm.toLowerCase().split(' ')) { + term = term.trim() + if (term) { + terms.push(term) + } + } + + // only filters matching all search terms are presented + // as choices to the user + return choices.filter(option => { + let label = option.label.toLowerCase() + for (let term of terms) { + if (label.indexOf(term) < 0) { + return false + } + } + return true + }) + }, + // note, can use this with v-model for hidden 'uuids' fields selected_uuids: function() { return this.checkedRowUUIDs().join(',') @@ -644,12 +677,29 @@ location.href = url }, - addFilter(filter_key) { - - // reset dropdown so user again sees "Add Filter" placeholder - this.$nextTick(function() { - this.selectedFilter = null + addFilterButton(event) { + this.addFilterShow = true + this.$nextTick(() => { + this.$refs.addFilterAutocomplete.focus() }) + }, + + addFilterKeydown(event) { + + // ESC will clear searchbox + if (event.which == 27) { + this.addFilterTerm = '' + this.addFilterShow = false + } + }, + + addFilterSelect(filtr) { + this.addFilter(filtr.key) + this.addFilterTerm = '' + this.addFilterShow = false + }, + + addFilter(filter_key) { // show corresponding grid filter this.filters[filter_key].visible = true diff --git a/tailbone/templates/grids/filters_buefy.mako b/tailbone/templates/grids/filters_buefy.mako index 3136a15f..5e1fef9b 100644 --- a/tailbone/templates/grids/filters_buefy.mako +++ b/tailbone/templates/grids/filters_buefy.mako @@ -18,18 +18,29 @@ Apply Filters </b-button> - <b-select @input="addFilter" - placeholder="Add Filter" - v-model="selectedFilter"> - <option v-for="key in filtersSequence" - :key="key" - :value="key" - ## TODO: previous code here was simpler; trying to track down - ## why disabled options don't appear so on Windows Chrome (?) - :disabled="filters[key].visible ? 'disabled' : null"> - {{ filters[key].label }} - </option> - </b-select> + <b-button v-if="!addFilterShow" + icon-pack="fas" + icon-left="plus" + class="control" + @click="addFilterButton"> + Add Filter + </b-button> + + <b-autocomplete v-if="addFilterShow" + ref="addFilterAutocomplete" + :data="addFilterChoices" + v-model="addFilterTerm" + placeholder="Add Filter" + field="key" + :custom-formatter="filtr => filtr.label" + open-on-focus + keep-first + icon-pack="fas" + clearable + clear-on-select + @select="addFilterSelect" + @keydown.native="addFilterKeydown"> + </b-autocomplete> <b-button @click="resetView()" icon-pack="fas" From cddec5158251a9e66227132ce8adb1bf2fbc9f58 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 11 Oct 2023 15:56:16 -0500 Subject: [PATCH 1258/1681] Update changelog --- CHANGES.rst | 16 ++++++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 07addfcc..dd1bbd70 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,22 @@ CHANGELOG ========= +0.9.66 (2023-10-11) +------------------- + +* Make grid JS ``loadAsyncData()`` method truly async. + +* Add support for multi-column grid sorting. + +* Add smarts to show display text for some version diff fields. + +* Allow null for FalafelDateTime form fields. + +* Show full version history within the "view" page. + +* Use autocomplete instead of dropdown for grid "add filter". + + 0.9.65 (2023-10-07) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 466968d6..7a7c683c 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.65' +__version__ = '0.9.66' From cd82f8927b69c65b7f9f76db0171017050a80036 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 11 Oct 2023 16:13:20 -0500 Subject: [PATCH 1259/1681] Fix grid sorting when column key/name differ --- tailbone/grids/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index dc1a5af0..a3d85006 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -1242,7 +1242,7 @@ class Grid(object): spec = { 'sortkey': sortkey, 'model': sortfunc._class.__name__, - 'field': sortfunc._column.name, + 'field': sortfunc._column.key, 'direction': sortdir or 'asc', } full_spec.append(spec) From 507a9ffc710b23eef3ec9c4bf891d3039de05f77 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 11 Oct 2023 18:35:35 -0500 Subject: [PATCH 1260/1681] Expose department tax, FS flag --- tailbone/views/batch/pos.py | 2 ++ tailbone/views/departments.py | 15 +++++++++++---- tailbone/views/master.py | 8 ++++++++ 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/tailbone/views/batch/pos.py b/tailbone/views/batch/pos.py index 00f1603f..09df6ddb 100644 --- a/tailbone/views/batch/pos.py +++ b/tailbone/views/batch/pos.py @@ -104,6 +104,8 @@ class POSBatchView(BatchMasterView): 'item_entry', 'product', 'description', + 'department_number', + 'department_name', 'reg_price', 'txn_price', 'quantity', diff --git a/tailbone/views/departments.py b/tailbone/views/departments.py index e71203ba..8115c5c3 100644 --- a/tailbone/views/departments.py +++ b/tailbone/views/departments.py @@ -46,6 +46,8 @@ class DepartmentView(MasterView): 'name', 'product', 'personnel', + 'tax', + 'food_stampable', 'exempt_from_gross_sales', ] @@ -54,6 +56,8 @@ class DepartmentView(MasterView): 'name', 'product', 'personnel', + 'tax', + 'food_stampable', 'exempt_from_gross_sales', 'allow_product_deletions', 'employees', @@ -78,7 +82,7 @@ class DepartmentView(MasterView): ] def configure_grid(self, g): - super(DepartmentView, self).configure_grid(g) + super().configure_grid(g) # number g.set_sort_defaults('number') @@ -93,7 +97,7 @@ class DepartmentView(MasterView): g.set_type('personnel', 'boolean') def configure_form(self, f): - super(DepartmentView, self).configure_form(f) + super().configure_form(f) f.remove_field('subdepartments') @@ -105,6 +109,9 @@ class DepartmentView(MasterView): f.set_type('product', 'boolean') f.set_type('personnel', 'boolean') + # tax + f.set_renderer('tax', self.render_tax) + def render_employees(self, department, field): route_prefix = self.get_route_prefix() permission_prefix = self.get_permission_prefix() @@ -130,7 +137,7 @@ class DepartmentView(MasterView): g.render_buefy_table_element(data_prop='employeesData')) def template_kwargs_view(self, **kwargs): - kwargs = super(DepartmentView, self).template_kwargs_view(**kwargs) + kwargs = super().template_kwargs_view(**kwargs) department = kwargs['instance'] department_employees = sorted(department.employees, key=str) @@ -169,7 +176,7 @@ class DepartmentView(MasterView): return product.department def configure_row_grid(self, g): - super(DepartmentView, self).configure_row_grid(g) + super().configure_row_grid(g) app = self.get_rattail_app() self.handler = app.get_products_handler() diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 21418521..9c814799 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -918,6 +918,14 @@ class MasterView(View): if not vendor: node.raise_invalid("Vendor not found") + def render_tax(self, obj, field): + tax = getattr(obj, field) + if not tax: + return + text = str(tax) + url = self.request.route_url('taxes.view', uuid=tax.uuid) + return tags.link_to(text, url) + def render_department(self, obj, field): department = getattr(obj, field) if not department: From d66dd5f199965c9f577c7a41762ebf99203bec2a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 11 Oct 2023 19:55:43 -0500 Subject: [PATCH 1261/1681] Add permission for testing error handling at POS --- tailbone/views/batch/pos.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tailbone/views/batch/pos.py b/tailbone/views/batch/pos.py index 09df6ddb..72d2e7ee 100644 --- a/tailbone/views/batch/pos.py +++ b/tailbone/views/batch/pos.py @@ -251,6 +251,8 @@ class POSBatchView(BatchMasterView): config.add_tailbone_permission_group('pos', "POS", overwrite=False) + config.add_tailbone_permission('pos', 'pos.test_error', + "Force error to test error handling") config.add_tailbone_permission('pos', 'pos.ring_sales', "Make transactions (ring up sales)") # config.add_tailbone_permission('pos', 'pos.resume', From 1a15d7056800f27dac137247adbb9ee3c37bfcf9 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 11 Oct 2023 23:11:23 -0500 Subject: [PATCH 1262/1681] Add some awareness of suspend/resume for POS batch --- tailbone/views/batch/pos.py | 35 +++++++++++++++++++++++++++-------- tailbone/views/master.py | 8 ++++++++ 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/tailbone/views/batch/pos.py b/tailbone/views/batch/pos.py index 72d2e7ee..b536521b 100644 --- a/tailbone/views/batch/pos.py +++ b/tailbone/views/batch/pos.py @@ -53,21 +53,21 @@ class POSBatchView(BatchMasterView): grid_columns = [ 'id', - 'terminal_id', - 'customer', 'created', - 'created_by', + 'terminal_id', + 'cashier', + 'customer', 'rowcount', 'sales_total', 'void', 'status_code', 'executed', - 'executed_by', ] form_fields = [ 'id', 'terminal_id', + 'cashier', 'customer', 'params', 'rowcount', @@ -121,13 +121,26 @@ class POSBatchView(BatchMasterView): def configure_grid(self, g): super().configure_grid(g) + model = self.model # terminal_id g.set_label('terminal_id', "Terminal") if 'terminal_id' in g.filters: g.filters['terminal_id'].label = self.labels.get('terminal_id', "Terminal ID") + # cashier + def join_cashier(q): + return q.outerjoin(model.Employee, + model.Employee.uuid == model.POSBatch.cashier_uuid)\ + .outerjoin(model.Person, + model.Person.uuid == model.Employee.person_uuid) + g.set_joiner('cashier', join_cashier) + g.set_sorter('cashier', model.Person.display_name) + + # customer g.set_link('customer') + g.set_joiner('customer', lambda q: q.outerjoin(model.Customer)) + g.set_sorter('customer', model.Customer.name) g.set_link('created') g.set_link('created_by') @@ -144,20 +157,26 @@ class POSBatchView(BatchMasterView): def grid_extra_class(self, batch, i): if batch.void: return 'warning' - if batch.training_mode: + if (batch.training_mode + or batch.status_code == batch.STATUS_SUSPENDED): return 'notice' def configure_form(self, f): super().configure_form(f) app = self.get_rattail_app() + # cashier + f.set_renderer('cashier', self.render_employee) + + # customer f.set_renderer('customer', self.render_customer) f.set_type('sales_total', 'currency') f.set_type('tender_total', 'currency') f.set_type('tender_total', 'currency') - f.set_renderer('taxes', self.render_taxes) + if self.viewing: + f.set_renderer('taxes', self.render_taxes) f.set_renderer('balance', lambda batch, field: app.render_currency(batch.get_balance())) @@ -257,8 +276,8 @@ class POSBatchView(BatchMasterView): "Make transactions (ring up sales)") # config.add_tailbone_permission('pos', 'pos.resume', # "Resume previously-suspended transaction") - # config.add_tailbone_permission('pos', 'pos.suspend', - # "Suspend current transaction") + config.add_tailbone_permission('pos', 'pos.suspend', + "Suspend current transaction") config.add_tailbone_permission('pos', 'pos.swap_customer', "Swap customer for current transaction") config.add_tailbone_permission('pos', 'pos.void_txn', diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 9c814799..176ff672 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -1010,6 +1010,14 @@ class MasterView(View): items.append(HTML.tag('li', c=[tags.link_to(text, url)])) return HTML.tag('ul', c=items) + def render_employee(self, obj, field): + employee = getattr(obj, field) + if not employee: + return "" + text = str(employee) + url = self.request.route_url('employees.view', uuid=employee.uuid) + return tags.link_to(text, url) + def render_customer(self, obj, field): customer = getattr(obj, field) if not customer: From 5940778189979be1d18bc031252628df85e91ff7 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 12 Oct 2023 10:33:44 -0500 Subject: [PATCH 1263/1681] Fix version child classes for Customers view must be sure to include any supplements --- tailbone/views/customers.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index 74f66458..dd8923e6 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -557,14 +557,16 @@ class CustomerView(MasterView): return HTML.tag('ul', HTML.literal('').join(items)) def get_version_child_classes(self): - return [ + classes = super().get_version_child_classes() + classes.extend([ (model.CustomerGroupAssignment, 'customer_uuid'), (model.CustomerPhoneNumber, 'parent_uuid'), (model.CustomerEmailAddress, 'parent_uuid'), (model.CustomerMailingAddress, 'parent_uuid'), (model.CustomerPerson, 'customer_uuid'), (model.CustomerNote, 'parent_uuid'), - ] + ]) + return classes def detach_person(self): customer = self.get_instance() From 115e95b9a82ba2c8a802f90014a227c99b4dd24c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 12 Oct 2023 10:37:12 -0500 Subject: [PATCH 1264/1681] Update changelog --- CHANGES.rst | 14 ++++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index dd1bbd70..8be310e7 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,20 @@ CHANGELOG ========= +0.9.67 (2023-10-12) +------------------- + +* Fix grid sorting when column key/name differ. + +* Expose department tax, FS flag. + +* Add permission for testing error handling at POS. + +* Add some awareness of suspend/resume for POS batch. + +* Fix version child classes for Customers view. + + 0.9.66 (2023-10-11) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 7a7c683c..8e69986c 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.66' +__version__ = '0.9.67' From 7525aaaa87ab547b5763834e92c8d9ebaeec23f3 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 12 Oct 2023 11:57:18 -0500 Subject: [PATCH 1265/1681] Expose more permissions for POS --- tailbone/views/batch/pos.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tailbone/views/batch/pos.py b/tailbone/views/batch/pos.py index b536521b..f1e2b0d9 100644 --- a/tailbone/views/batch/pos.py +++ b/tailbone/views/batch/pos.py @@ -274,6 +274,10 @@ class POSBatchView(BatchMasterView): "Force error to test error handling") config.add_tailbone_permission('pos', 'pos.ring_sales', "Make transactions (ring up sales)") + config.add_tailbone_permission('pos', 'pos.override_price', + "Override price for any item") + config.add_tailbone_permission('pos', 'pos.del_customer', + "Remove customer from current transaction") # config.add_tailbone_permission('pos', 'pos.resume', # "Resume previously-suspended transaction") config.add_tailbone_permission('pos', 'pos.suspend', From f86cc839965f94aaaebbe472795ee7edff3e042b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 17 Oct 2023 15:26:22 -0500 Subject: [PATCH 1266/1681] Fix order xlsx download if missing order date --- tailbone/views/purchasing/ordering.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tailbone/views/purchasing/ordering.py b/tailbone/views/purchasing/ordering.py index 03308d07..63c13517 100644 --- a/tailbone/views/purchasing/ordering.py +++ b/tailbone/views/purchasing/ordering.py @@ -460,7 +460,8 @@ class OrderingBatchView(PurchasingBatchView): worksheet = workbook.active worksheet.title = "Purchase Order" worksheet.append(["Store", "Vendor", "Date ordered"]) - worksheet.append([batch.store.name, batch.vendor.name, batch.date_ordered.strftime('%m/%d/%Y')]) + date_ordered = batch.date_ordered.strftime('%m/%d/%Y') if batch.date_ordered else None + worksheet.append([batch.store.name, batch.vendor.name, date_ordered]) worksheet.append([]) worksheet.append(['vendor_code', 'upc', 'brand_name', 'description', 'cases_ordered', 'units_ordered']) for row in batch.active_rows(): From 659f5a8fe18d75ba4d5f2e9658c090812c397d94 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 18 Oct 2023 17:35:14 -0500 Subject: [PATCH 1267/1681] Replace dropdowns with autocomplete, for "find principals by perm" --- .../templates/principal/find_by_perm.mako | 201 ++++++++++++++---- tailbone/templates/principal/index.mako | 4 +- tailbone/views/principal.py | 15 +- 3 files changed, 173 insertions(+), 47 deletions(-) diff --git a/tailbone/templates/principal/find_by_perm.mako b/tailbone/templates/principal/find_by_perm.mako index 9cc5aa05..e0536324 100644 --- a/tailbone/templates/principal/find_by_perm.mako +++ b/tailbone/templates/principal/find_by_perm.mako @@ -16,44 +16,67 @@ <div> ${h.form(request.current_route_url(), method='GET', **{'@submit': 'formSubmitting = true'})} + <div style="margin-left: 10rem; max-width: 50%;"> - <b-field label="Permission Group" horizontal> - <b-select name="permission_group" - v-model="selectedGroup" - @input="selectGroup"> - <option v-for="groupkey in sortedGroups" - :key="groupkey" - :value="groupkey"> - {{ permissionGroups[groupkey].label }} - </option> - </b-select> - </b-field> + ${h.hidden('permission_group', **{':value': 'selectedGroup'})} + <b-field label="Permission Group" horizontal> + <b-autocomplete v-if="!selectedGroup" + ref="permissionGroupAutocomplete" + v-model="permissionGroupTerm" + :data="permissionGroupChoices" + field="groupkey" + :custom-formatter="filtr => filtr.label" + open-on-focus + keep-first + icon-pack="fas" + clearable + clear-on-select + @select="permissionGroupSelect"> + </b-autocomplete> + <b-button v-if="selectedGroup" + @click="permissionGroupReset()"> + {{ permissionGroups[selectedGroup].label }} + </b-button> + </b-field> - <b-field label="Permission" horizontal> - <b-select name="permission" - v-model="selectedPermission"> - <option v-for="perm in groupPermissions" - :key="perm.permkey" - :value="perm.permkey"> - {{ perm.label }} - </option> - </b-select> - </b-field> + ${h.hidden('permission', **{':value': 'selectedPermission'})} + <b-field label="Permission" horizontal> + <b-autocomplete v-if="!selectedPermission" + ref="permissionAutocomplete" + v-model="permissionTerm" + :data="permissionChoices" + field="permkey" + :custom-formatter="filtr => filtr.label" + open-on-focus + keep-first + icon-pack="fas" + clearable + clear-on-select + @select="permissionSelect"> + </b-autocomplete> + <b-button v-if="selectedPermission" + @click="permissionReset()"> + {{ selectedPermissionLabel }} + </b-button> + </b-field> - <div class="buttons"> - <once-button tag="a" - href="${request.current_route_url(_query=None)}" - text="Reset Form"> - </once-button> - <b-button type="is-primary" - native-type="submit" - icon-pack="fas" - icon-left="search" - :disabled="formSubmitting"> - {{ formSubmitting ? "Working, please wait..." : "Find ${model_title_plural}" }} - </b-button> - </div> + <b-field horizontal> + <div class="buttons" style="margin-top: 1rem;"> + <once-button tag="a" + href="${request.current_route_url(_query=None)}" + text="Reset Form"> + </once-button> + <b-button type="is-primary" + native-type="submit" + icon-pack="fas" + icon-left="search" + :disabled="formSubmitting"> + {{ formSubmitting ? "Working, please wait..." : "Find ${model_title_plural}" }} + </b-button> + </div> + </b-field> + </div> ${h.end_form()} % if principals is not None: @@ -91,24 +114,114 @@ data() { return { groupPermissions: ${json.dumps(buefy_perms.get(selected_group, {}).get('permissions', []))|n}, + permissionGroupTerm: '', + permissionTerm: '', selectedGroup: ${json.dumps(selected_group)|n}, - % if selected_permission: selectedPermission: ${json.dumps(selected_permission)|n}, - % elif selected_group in buefy_perms: - selectedPermission: ${json.dumps(buefy_perms[selected_group]['permissions'][0]['permkey'])|n}, - % else: - selectedPermission: null, - % endif + selectedPermissionLabel: ${json.dumps(selected_permission_label or '')|n}, formSubmitting: false, } }, + + computed: { + + permissionGroupChoices() { + + // collect all groups + let choices = [] + for (let groupkey of this.sortedGroups) { + choices.push(this.permissionGroups[groupkey]) + } + + // parse list of search terms + let terms = [] + for (let term of this.permissionGroupTerm.toLowerCase().split(' ')) { + term = term.trim() + if (term) { + terms.push(term) + } + } + + // filter groups by search terms + choices = choices.filter(option => { + let label = option.label.toLowerCase() + for (let term of terms) { + if (label.indexOf(term) < 0) { + return false + } + } + return true + }) + + return choices + }, + + permissionChoices() { + + // collect all permissions for current group + let choices = this.groupPermissions + + // parse list of search terms + let terms = [] + for (let term of this.permissionTerm.toLowerCase().split(' ')) { + term = term.trim() + if (term) { + terms.push(term) + } + } + + // filter permissions by search terms + choices = choices.filter(option => { + let label = option.label.toLowerCase() + for (let term of terms) { + if (label.indexOf(term) < 0) { + return false + } + } + return true + }) + + return choices + }, + }, + methods: { - selectGroup(groupkey) { + permissionGroupSelect(option) { + this.selectedPermission = null + this.selectedPermissionLabel = null + if (option) { + this.selectedGroup = option.groupkey + this.groupPermissions = this.permissionGroups[option.groupkey].permissions + this.$nextTick(() => { + this.$refs.permissionAutocomplete.focus() + }) + } + }, - // re-populate Permission dropdown, auto-select first option - this.groupPermissions = this.permissionGroups[groupkey].permissions - this.selectedPermission = this.groupPermissions[0].permkey + permissionGroupReset() { + this.selectedGroup = null + this.selectedPermission = null + this.selectedPermissionLabel = '' + this.$nextTick(() => { + this.$refs.permissionGroupAutocomplete.focus() + }) + }, + + permissionSelect(option) { + if (option) { + this.selectedPermission = option.permkey + this.selectedPermissionLabel = option.label + } + }, + + permissionReset() { + this.selectedPermission = null + this.selectedPermissionLabel = null + this.permissionTerm = '' + this.$nextTick(() => { + this.$refs.permissionAutocomplete.focus() + }) }, } }) diff --git a/tailbone/templates/principal/index.mako b/tailbone/templates/principal/index.mako index 4ed3ba5b..fa806455 100644 --- a/tailbone/templates/principal/index.mako +++ b/tailbone/templates/principal/index.mako @@ -3,8 +3,8 @@ <%def name="context_menu_items()"> ${parent.context_menu_items()} - % if request.has_perm('{}.find_by_perm'.format(permission_prefix)): - <li>${h.link_to("Find {} with Permission X".format(model_title_plural), url('{}.find_by_perm'.format(route_prefix)))}</li> + % if master.has_perm('find_by_perm'): + <li>${h.link_to(f"Find {model_title_plural} by Permission", url(f'{route_prefix}.find_by_perm'))}</li> % endif </%def> diff --git a/tailbone/views/principal.py b/tailbone/views/principal.py index 5d477677..20f6b866 100644 --- a/tailbone/views/principal.py +++ b/tailbone/views/principal.py @@ -77,7 +77,20 @@ class PrincipalMasterView(MasterView): perms = self.get_buefy_perms_data(sorted_perms) context['buefy_perms'] = perms context['buefy_sorted_groups'] = list(perms) - context['selected_group'] = permission_group or 'common' + + if permission_group and permission_group not in perms: + permission_group = None + if permission: + if permission_group: + group = dict([(p['permkey'], p) for p in perms[permission_group]['permissions']]) + if permission in group: + context['selected_permission_label'] = group[permission]['label'] + else: + permission = None + else: + permission = None + + context['selected_group'] = permission_group context['selected_permission'] = permission return self.render_to_response('find_by_perm', context) From 919d8d109fa9c30a3686f1af2786cafa205755f9 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 18 Oct 2023 18:18:55 -0500 Subject: [PATCH 1268/1681] Use `Grid.make_sorter()` instead of legacy code b/c multi-column sorting relies on this --- tailbone/views/bouncer.py | 16 +++++--- tailbone/views/customers.py | 16 ++++---- tailbone/views/employees.py | 41 ++++++++++----------- tailbone/views/members.py | 8 ++-- tailbone/views/messages.py | 31 ++++++++++------ tailbone/views/people.py | 51 ++++++++++++++------------ tailbone/views/products.py | 40 ++++++++++---------- tailbone/views/purchases/core.py | 51 ++++++++++++++------------ tailbone/views/purchasing/batch.py | 17 +++++---- tailbone/views/purchasing/receiving.py | 32 ++++++++-------- tailbone/views/shifts/core.py | 29 ++++++++------- tailbone/views/tempmon/probes.py | 5 ++- tailbone/views/tempmon/readings.py | 31 ++++++++-------- tailbone/views/views.py | 6 +-- 14 files changed, 198 insertions(+), 176 deletions(-) diff --git a/tailbone/views/bouncer.py b/tailbone/views/bouncer.py index 3416bbed..7afcc567 100644 --- a/tailbone/views/bouncer.py +++ b/tailbone/views/bouncer.py @@ -61,7 +61,7 @@ class EmailBounceView(MasterView): ] def __init__(self, request): - super(EmailBounceView, self).__init__(request) + super().__init__(request) self.handler_options = sorted(get_profile_keys(self.rattail_config)) def get_handler(self, bounce): @@ -69,17 +69,21 @@ class EmailBounceView(MasterView): return app.get_bounce_handler(bounce.config_key) def configure_grid(self, g): - super(EmailBounceView, self).configure_grid(g) + super().configure_grid(g) + model = self.model g.filters['config_key'].set_choices(self.handler_options) g.filters['config_key'].default_active = True g.filters['config_key'].default_verb = 'equal' - g.joiners['processed_by'] = lambda q: q.outerjoin(model.User) g.filters['processed'].default_active = True g.filters['processed'].default_verb = 'is_null' - g.filters['processed_by'] = g.make_filter('processed_by', model.User.username) - g.sorters['processed_by'] = g.make_sorter(model.User.username) + + # processed_by + g.set_joiner('processed_by', lambda q: q.outerjoin(model.User)) + g.set_sorter('processed_by', model.User.username) + g.set_filter('processed_by', model.User.username) + g.set_sort_defaults('bounced', 'desc') g.set_label('bounce_recipient_address', "Bounced To") @@ -89,7 +93,7 @@ class EmailBounceView(MasterView): g.set_link('intended_recipient_address') def configure_form(self, f): - super(EmailBounceView, self).configure_form(f) + super().configure_form(f) bounce = f.model_instance f.set_renderer('message', self.render_message_file) f.set_renderer('links', self.render_links) diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index dd8923e6..668f4a2b 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -168,22 +168,22 @@ class CustomerView(MasterView): g.filters['name'].default_verb = 'contains' # phone + g.set_label('phone', "Phone Number") g.set_joiner('phone', lambda q: q.outerjoin(model.CustomerPhoneNumber, sa.and_( model.CustomerPhoneNumber.parent_uuid == model.Customer.uuid, model.CustomerPhoneNumber.preference == 1))) - g.sorters['phone'] = lambda q, d: q.order_by(getattr(model.CustomerPhoneNumber.number, d)()) + g.set_sorter('phone', model.CustomerPhoneNumber.number) g.set_filter('phone', model.CustomerPhoneNumber.number, # label="Phone Number", factory=grids.filters.AlchemyPhoneNumberFilter) - g.set_label('phone', "Phone Number") # email + g.set_label('email', "Email Address") g.set_joiner('email', lambda q: q.outerjoin(model.CustomerEmailAddress, sa.and_( model.CustomerEmailAddress.parent_uuid == model.Customer.uuid, model.CustomerEmailAddress.preference == 1))) - g.sorters['email'] = lambda q, d: q.order_by(getattr(model.CustomerEmailAddress.address, d)()) + g.set_sorter('email', model.CustomerEmailAddress.address) g.set_filter('email', model.CustomerEmailAddress.address)#, label="Email Address") - g.set_label('email', "Email Address") # email_preference g.set_enum('email_preference', self.enum.EMAIL_PREFERENCE) @@ -244,7 +244,7 @@ class CustomerView(MasterView): def get_instance(self): try: - instance = super(CustomerView, self).get_instance() + instance = super().get_instance() except HTTPNotFound: pass else: @@ -273,7 +273,7 @@ class CustomerView(MasterView): raise HTTPNotFound def configure_form(self, f): - super(CustomerView, self).configure_form(f) + super().configure_form(f) customer = f.model_instance permission_prefix = self.get_permission_prefix() @@ -802,7 +802,7 @@ class PendingCustomerView(MasterView): ] def configure_grid(self, g): - super(PendingCustomerView, self).configure_grid(g) + super().configure_grid(g) g.set_enum('status_code', self.enum.PENDING_CUSTOMER_STATUS) g.filters['status_code'].default_active = True @@ -814,7 +814,7 @@ class PendingCustomerView(MasterView): g.set_link('display_name') def configure_form(self, f): - super(PendingCustomerView, self).configure_form(f) + super().configure_form(f) f.set_enum('status_code', self.enum.PENDING_CUSTOMER_STATUS) diff --git a/tailbone/views/employees.py b/tailbone/views/employees.py index 973075b6..f4f99058 100644 --- a/tailbone/views/employees.py +++ b/tailbone/views/employees.py @@ -96,7 +96,7 @@ class EmployeeView(MasterView): return app.get_people_handler().get_quickie_search_placeholder() def configure_grid(self, g): - super(EmployeeView, self).configure_grid(g) + super().configure_grid(g) route_prefix = self.get_route_prefix() # phone @@ -115,9 +115,20 @@ class EmployeeView(MasterView): g.filters['email'] = g.make_filter('email', model.EmployeeEmailAddress.address, label="Email Address") - # first/last name - g.filters['first_name'] = g.make_filter('first_name', model.Person.first_name) - g.filters['last_name'] = g.make_filter('last_name', model.Person.last_name) + # first_name + g.set_link('first_name') + g.set_sorter('first_name', model.Person.first_name) + g.set_sort_defaults('first_name') + g.set_filter('first_name', model.Person.first_name, + default_active=True, + default_verb='contains') + + # last_name + g.set_link('last_name') + g.set_sorter('last_name', model.Person.last_name) + g.set_filter('last_name', model.Person.last_name, + default_active=True, + default_verb='contains') # username if self.request.has_perm('users.view'): @@ -145,18 +156,7 @@ class EmployeeView(MasterView): g.remove('status') del g.filters['status'] - g.filters['first_name'].default_active = True - g.filters['first_name'].default_verb = 'contains' - - g.filters['last_name'].default_active = True - g.filters['last_name'].default_verb = 'contains' - - g.sorters['first_name'] = lambda q, d: q.order_by(getattr(model.Person.first_name, d)()) - g.sorters['last_name'] = lambda q, d: q.order_by(getattr(model.Person.last_name, d)()) - - g.sorters['email'] = lambda q, d: q.order_by(getattr(model.EmployeeEmailAddress.address, d)()) - - g.set_sort_defaults('first_name') + g.set_sorter('email', model.EmployeeEmailAddress.address) g.set_label('email', "Email Address") @@ -170,9 +170,6 @@ class EmployeeView(MasterView): g.main_actions.insert(1, self.make_action( 'view_raw', url=url, icon='eye')) - g.set_link('first_name') - g.set_link('last_name') - def default_view_url(self): if (self.request.has_perm('people.view_profile') and self.should_link_straight_to_profile()): @@ -196,7 +193,7 @@ class EmployeeView(MasterView): default=False) def query(self, session): - query = super(EmployeeView, self).query(session) + query = super().query(session) query = query.join(model.Person) if not self.has_perm('view_all'): query = query.filter(model.Employee.status == self.enum.EMPLOYEE_STATUS_CURRENT) @@ -229,7 +226,7 @@ class EmployeeView(MasterView): return not self.is_employee_protected(employee) def configure_form(self, f): - super(EmployeeView, self).configure_form(f) + super().configure_form(f) employee = f.model_instance f.set_renderer('person', self.render_person) @@ -283,7 +280,7 @@ class EmployeeView(MasterView): def objectify(self, form, data=None): if data is None: data = form.validated - employee = super(EmployeeView, self).objectify(form, data) + employee = super().objectify(form, data) self.update_stores(employee, data) self.update_departments(employee, data) return employee diff --git a/tailbone/views/members.py b/tailbone/views/members.py index 74b15512..3a4ff0a1 100644 --- a/tailbone/views/members.py +++ b/tailbone/views/members.py @@ -196,21 +196,21 @@ class MemberView(MasterView): g.filters['active'].default_verb = 'is_true' # phone + g.set_label('phone', "Phone Number") g.set_joiner('phone', lambda q: q.outerjoin(model.MemberPhoneNumber, sa.and_( model.MemberPhoneNumber.parent_uuid == model.Member.uuid, model.MemberPhoneNumber.preference == 1))) - g.sorters['phone'] = lambda q, d: q.order_by(getattr(model.MemberPhoneNumber.number, d)()) + g.set_sorter('phone', model.MemberPhoneNumber.number) g.set_filter('phone', model.MemberPhoneNumber.number, factory=grids.filters.AlchemyPhoneNumberFilter) - g.set_label('phone', "Phone Number") # email + g.set_label('email', "Email Address") g.set_joiner('email', lambda q: q.outerjoin(model.MemberEmailAddress, sa.and_( model.MemberEmailAddress.parent_uuid == model.Member.uuid, model.MemberEmailAddress.preference == 1))) - g.sorters['email'] = lambda q, d: q.order_by(getattr(model.MemberEmailAddress.address, d)()) + g.set_sorter('email', model.MemberEmailAddress.address) g.set_filter('email', model.MemberEmailAddress.address) - g.set_label('email', "Email Address") # membership_type g.set_joiner('membership_type', lambda q: q.outerjoin(model.MembershipType)) diff --git a/tailbone/views/messages.py b/tailbone/views/messages.py index 4c83da34..d1509163 100644 --- a/tailbone/views/messages.py +++ b/tailbone/views/messages.py @@ -84,12 +84,12 @@ class MessageView(MasterView): def index(self): if not self.request.user: raise httpexceptions.HTTPForbidden - return super(MessageView, self).index() + return super().index() def get_instance(self): if not self.request.user: raise httpexceptions.HTTPForbidden - message = super(MessageView, self).get_instance() + message = super().get_instance() if not self.associated_with(message): raise httpexceptions.HTTPForbidden return message @@ -108,11 +108,18 @@ class MessageView(MasterView): .filter(model.MessageRecipient.recipient == self.request.user) def configure_grid(self, g): - - g.joiners['sender'] = lambda q: q.join(model.User, model.User.uuid == model.Message.sender_uuid).outerjoin(model.Person) - g.filters['sender'] = g.make_filter('sender', model.Person.display_name, - default_active=True, default_verb='contains') - g.sorters['sender'] = g.make_sorter(model.Person.display_name) + super().configure_grid(g) + model = self.model + + # sender + g.set_joiner('sender', + lambda q: q.join(model.User, + model.User.uuid == model.Message.sender_uuid)\ + .outerjoin(model.Person)) + g.set_sorter('sender', model.Person.display_name) + g.set_filter('sender', model.Person.display_name, + default_active=True, + default_verb='contains') g.filters['subject'].default_active = True g.filters['subject'].default_verb = 'contains' @@ -201,7 +208,7 @@ class MessageView(MasterView): # return form def configure_form(self, f): - super(MessageView, self).configure_form(f) + super().configure_form(f) f.submit_label = "Send Message" @@ -274,7 +281,7 @@ class MessageView(MasterView): def objectify(self, form, data=None): if data is None: data = form.validated - message = super(MessageView, self).objectify(form, data) + message = super().objectify(form, data) if self.creating: if self.request.user: @@ -463,7 +470,7 @@ class InboxView(MessageView): return self.request.route_url('messages.inbox') def query(self, session): - q = super(InboxView, self).query(session) + q = super().query(session) return q.filter(model.MessageRecipient.status == self.enum.MESSAGE_STATUS_INBOX) @@ -479,7 +486,7 @@ class ArchiveView(MessageView): return self.request.route_url('messages.archive') def query(self, session): - q = super(ArchiveView, self).query(session) + q = super().query(session) return q.filter(model.MessageRecipient.status == self.enum.MESSAGE_STATUS_ARCHIVE) @@ -500,7 +507,7 @@ class SentView(MessageView): .filter(model.Message.sender == self.request.user) def configure_grid(self, g): - super(SentView, self).configure_grid(g) + super().configure_grid(g) g.filters['sender'].default_active = False g.joiners['recipients'] = lambda q: q.join(model.MessageRecipient)\ .join(model.User, model.User.uuid == model.MessageRecipient.recipient_uuid)\ diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 31760d2a..7f786ace 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -95,7 +95,7 @@ class PersonView(MasterView): mergeable = True def __init__(self, request): - super(PersonView, self).__init__(request) + super().__init__(request) app = self.get_rattail_app() # always get a reference to the People Handler @@ -105,7 +105,7 @@ class PersonView(MasterView): self.handler = self.people_handler def make_grid_kwargs(self, **kwargs): - kwargs = super(PersonView, self).make_grid_kwargs(**kwargs) + kwargs = super().make_grid_kwargs(**kwargs) # turn on checkboxes if user can create a merge reqeust if self.mergeable and self.has_perm('request_merge'): @@ -114,18 +114,28 @@ class PersonView(MasterView): return kwargs def configure_grid(self, g): - super(PersonView, self).configure_grid(g) + super().configure_grid(g) route_prefix = self.get_route_prefix() model = self.model - g.joiners['email'] = lambda q: q.outerjoin(model.PersonEmailAddress, sa.and_( - model.PersonEmailAddress.parent_uuid == model.Person.uuid, - model.PersonEmailAddress.preference == 1)) - g.joiners['phone'] = lambda q: q.outerjoin(model.PersonPhoneNumber, sa.and_( - model.PersonPhoneNumber.parent_uuid == model.Person.uuid, - model.PersonPhoneNumber.preference == 1)) + # email + g.set_label('email', "Email Address") + g.set_joiner('email', lambda q: q.outerjoin( + model.PersonEmailAddress, + sa.and_( + model.PersonEmailAddress.parent_uuid == model.Person.uuid, + model.PersonEmailAddress.preference == 1))) + g.set_sorter('email', model.PersonEmailAddress.address) + g.set_filter('email', model.PersonEmailAddress.address) - g.filters['email'] = g.make_filter('email', model.PersonEmailAddress.address) + # phone + g.set_label('phone', "Phone Number") + g.set_joiner('phone', lambda q: q.outerjoin( + model.PersonPhoneNumber, + sa.and_( + model.PersonPhoneNumber.parent_uuid == model.Person.uuid, + model.PersonPhoneNumber.preference == 1))) + g.set_sorter('phone', model.PersonPhoneNumber.number) g.set_filter('phone', model.PersonPhoneNumber.number, factory=grids.filters.AlchemyPhoneNumberFilter) @@ -151,17 +161,12 @@ class PersonView(MasterView): g.set_filter('employee_status', model.Employee.status, value_enum=self.enum.EMPLOYEE_STATUS) - g.sorters['email'] = lambda q, d: q.order_by(getattr(model.PersonEmailAddress.address, d)()) - g.sorters['phone'] = lambda q, d: q.order_by(getattr(model.PersonPhoneNumber.number, d)()) - g.set_label('merge_requested', "MR") g.set_renderer('merge_requested', self.render_merge_requested) g.set_sort_defaults('display_name') g.set_label('display_name', "Full Name") - g.set_label('phone', "Phone Number") - g.set_label('email', "Email Address") g.set_label('customer_id', "Customer ID") if (self.has_perm('view_profile') @@ -237,7 +242,7 @@ class PersonView(MasterView): data = form.validated # do normal create/update - person = super(PersonView, self).objectify(form, data) + person = super().objectify(form, data) # collect data from all name fields names = {} @@ -278,7 +283,7 @@ class PersonView(MasterView): customer._people.reorder() # continue with normal logic - super(PersonView, self).delete_instance(person) + super().delete_instance(person) def touch_instance(self, person): """ @@ -288,7 +293,7 @@ class PersonView(MasterView): contact info record associated with them. """ # touch person, as per usual - super(PersonView, self).touch_instance(person) + super().touch_instance(person) def touch(obj): change = model.Change() @@ -310,7 +315,7 @@ class PersonView(MasterView): touch(address) def configure_common_form(self, f): - super(PersonView, self).configure_common_form(f) + super().configure_common_form(f) person = f.model_instance f.set_label('display_name', "Full Name") @@ -1836,7 +1841,7 @@ class PersonNoteView(MasterView): return note.subject or "(no subject)" def configure_grid(self, g): - super(PersonNoteView, self).configure_grid(g) + super().configure_grid(g) # person g.set_joiner('person', lambda q: q.join(model.Person, @@ -1857,7 +1862,7 @@ class PersonNoteView(MasterView): g.set_link('created') def configure_form(self, f): - super(PersonNoteView, self).configure_form(f) + super().configure_form(f) # person f.set_readonly('person') @@ -1931,7 +1936,7 @@ class MergePeopleRequestView(MasterView): ] def configure_grid(self, g): - super(MergePeopleRequestView, self).configure_grid(g) + super().configure_grid(g) g.set_renderer('removing_uuid', self.render_referenced_person_name) g.set_renderer('keeping_uuid', self.render_referenced_person_name) @@ -1960,7 +1965,7 @@ class MergePeopleRequestView(MasterView): keeping or "(not found)") def configure_form(self, f): - super(MergePeopleRequestView, self).configure_form(f) + super().configure_form(f) f.set_renderer('removing_uuid', self.render_referenced_person) f.set_renderer('keeping_uuid', self.render_referenced_person) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 327b6366..1ddf6ae0 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -167,7 +167,7 @@ class ProductView(MasterView): TPRPrice = orm.aliased(model.ProductPrice) def __init__(self, request): - super(ProductView, self).__init__(request) + super().__init__(request) self.expose_label_printing = self.rattail_config.getbool( 'tailbone', 'products.print_labels', default=False) @@ -224,7 +224,10 @@ class ProductView(MasterView): g.set_link(field) # brand - g.joiners['brand'] = lambda q: q.outerjoin(model.Brand) + g.set_joiner('brand', lambda q: q.outerjoin(model.Brand)) + g.set_sorter('brand', model.Brand.name) + g.set_filter('brand', model.Brand.name, + default_active=True, default_verb='contains') # department g.set_joiner('department', lambda q: q.outerjoin(model.Department)) @@ -237,12 +240,14 @@ class ProductView(MasterView): verbs=['equal', 'not_equal', 'is_null', 'is_not_null', 'is_any'], default_active=True, default_verb='equal') - g.joiners['subdepartment'] = lambda q: q.outerjoin(model.Subdepartment, - model.Subdepartment.uuid == model.Product.subdepartment_uuid) - g.joiners['code'] = lambda q: q.outerjoin(model.ProductCode) + # subdepartment + g.set_joiner('subdepartment', lambda q: q.outerjoin( + model.Subdepartment, + model.Subdepartment.uuid == model.Product.subdepartment_uuid)) + g.set_sorter('subdepartment', model.Subdepartment.name) + g.set_filter('subdepartment', model.Subdepartment.name) - g.sorters['brand'] = g.make_sorter(model.Brand.name) - g.sorters['subdepartment'] = g.make_sorter(model.Subdepartment.name) + g.joiners['code'] = lambda q: q.outerjoin(model.ProductCode) # vendor ProductVendorCost = orm.aliased(model.ProductCost) @@ -296,9 +301,6 @@ class ProductView(MasterView): g.filters['description'].default_active = True g.filters['description'].default_verb = 'contains' - g.filters['brand'] = g.make_filter('brand', model.Brand.name, - default_active=True, default_verb='contains') - g.filters['subdepartment'] = g.make_filter('subdepartment', model.Subdepartment.name) g.filters['code'] = g.make_filter('code', model.ProductCode.code) # g.joiners['vendor_code_any'] = join_vendor_code_any @@ -392,7 +394,7 @@ class ProductView(MasterView): g.set_link('description') def configure_common_form(self, f): - super(ProductView, self).configure_common_form(f) + super().configure_common_form(f) product = f.model_instance # unit_size @@ -687,7 +689,7 @@ class ProductView(MasterView): return ' '.join(classes) def get_xlsx_fields(self): - fields = super(ProductView, self).get_xlsx_fields() + fields = super().get_xlsx_fields() i = fields.index('department_uuid') fields.insert(i + 1, 'department_number') @@ -734,7 +736,7 @@ class ProductView(MasterView): return fields def get_xlsx_row(self, product, fields): - row = super(ProductView, self).get_xlsx_row(product, fields) + row = super().get_xlsx_row(product, fields) if 'upc' in fields and isinstance(row['upc'], GPC): row['upc'] = row['upc'].pretty() @@ -799,7 +801,7 @@ class ProductView(MasterView): return row def download_results_normalize(self, product, fields, **kwargs): - data = super(ProductView, self).download_results_normalize( + data = super().download_results_normalize( product, fields, **kwargs) if 'upc' in data: @@ -988,7 +990,7 @@ class ProductView(MasterView): def objectify(self, form, data=None): if data is None: data = form.validated - product = super(ProductView, self).objectify(form, data=data) + product = super().objectify(form, data=data) # regular_price_amount if (self.creating or self.editing) and 'regular_price_amount' in form.fields: @@ -1163,7 +1165,7 @@ class ProductView(MasterView): return jsdata def template_kwargs_view(self, **kwargs): - kwargs = super(ProductView, self).template_kwargs_view(**kwargs) + kwargs = super().template_kwargs_view(**kwargs) product = kwargs['instance'] kwargs['image_url'] = self.products_handler.get_image_url(product) @@ -2287,7 +2289,7 @@ class PendingProductView(MasterView): ] def configure_grid(self, g): - super(PendingProductView, self).configure_grid(g) + super().configure_grid(g) g.set_enum('status_code', self.enum.PENDING_PRODUCT_STATUS) g.filters['status_code'].default_active = True @@ -2299,7 +2301,7 @@ class PendingProductView(MasterView): g.set_link('description') def configure_form(self, f): - super(PendingProductView, self).configure_form(f) + super().configure_form(f) model = self.model pending = f.model_instance @@ -2417,7 +2419,7 @@ class PendingProductView(MasterView): if data is None: data = form.validated - pending = super(PendingProductView, self).objectify(form, data) + pending = super().objectify(form, data) if not pending.user: pending.user = self.request.user diff --git a/tailbone/views/purchases/core.py b/tailbone/views/purchases/core.py index 77b02501..e7bebdff 100644 --- a/tailbone/views/purchases/core.py +++ b/tailbone/views/purchases/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Views for "true" purchase orders """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from webhelpers2.html import HTML, tags @@ -143,28 +139,35 @@ class PurchaseView(MasterView): if purchase.date_ordered: return "{} (ordered {})".format(purchase.vendor, purchase.date_ordered.strftime('%Y-%m-%d')) return "{} (ordered)".format(purchase.vendor) - return six.text_type(purchase) + return str(purchase) def configure_grid(self, g): - super(PurchaseView, self).configure_grid(g) + super().configure_grid(g) + model = self.model - g.joiners['store'] = lambda q: q.join(model.Store) - g.filters['store'] = g.make_filter('store', model.Store.name) - g.sorters['store'] = g.make_sorter(model.Store.name) + # store + g.set_joiner('store', lambda q: q.join(model.Store)) + g.set_sorter('store', model.Store.name) + g.set_filter('store', model.Store.name) - g.joiners['vendor'] = lambda q: q.join(model.Vendor) - g.filters['vendor'] = g.make_filter('vendor', model.Vendor.name, - default_active=True, default_verb='contains') - g.sorters['vendor'] = g.make_sorter(model.Vendor.name) + # vendor + g.set_joiner('vendor', lambda q: q.join(model.Vendor)) + g.set_sorter('vendor', model.Vendor.name) + g.set_filter('vendor', model.Vendor.name, + default_active=True, + default_verb='contains') - g.joiners['department'] = lambda q: q.join(model.Department) - g.filters['department'] = g.make_filter('department', model.Department.name) - g.sorters['department'] = g.make_sorter(model.Department.name) + # department + g.set_joiner('department', lambda q: q.join(model.Department)) + g.set_sorter('department', model.Department.name) + g.set_filter('department', model.Department.name) - g.joiners['buyer'] = lambda q: q.join(model.Employee).join(model.Person) - g.filters['buyer'] = g.make_filter('buyer', model.Person.display_name, - default_active=True, default_verb='contains') - g.sorters['buyer'] = g.make_sorter(model.Person.display_name) + # buyer + g.set_joiner('buyer', lambda q: q.join(model.Employee).join(model.Person)) + g.set_sorter('buyer', model.Person.display_name) + g.set_filter('buyer', model.Person.display_name, + default_active=True, + default_verb='contains') # id g.set_renderer('id', self.render_id_str) @@ -198,7 +201,7 @@ class PurchaseView(MasterView): g.set_link('invoice_total') def configure_form(self, f): - super(PurchaseView, self).configure_form(f) + super().configure_form(f) # id f.set_renderer('id', self.render_id_str) @@ -322,7 +325,7 @@ class PurchaseView(MasterView): .filter(model.PurchaseItem.purchase == purchase) def configure_row_grid(self, g): - super(PurchaseView, self).configure_row_grid(g) + super().configure_row_grid(g) g.set_sort_defaults('sequence') @@ -353,7 +356,7 @@ class PurchaseView(MasterView): g.remove('po_total') def configure_row_form(self, f): - super(PurchaseView, self).configure_row_form(f) + super().configure_row_form(f) # quantity fields f.set_type('case_quantity', 'quantity') diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index 96557d55..e49a5dea 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -175,9 +175,10 @@ class PurchasingBatchView(BatchMasterView): g.set_filter('vendor', model.Vendor.name, default_active=True, default_verb='contains') - g.joiners['department'] = lambda q: q.join(model.Department) - g.filters['department'] = g.make_filter('department', model.Department.name) - g.sorters['department'] = g.make_sorter(model.Department.name) + # department + g.set_joiner('department', lambda q: q.join(model.Department)) + g.set_filter('department', model.Department.name) + g.set_sorter('department', model.Department.name) g.set_joiner('buyer', lambda q: q.join(model.Employee).join(model.Person)) g.set_filter('buyer', model.Person.display_name) @@ -212,7 +213,7 @@ class PurchasingBatchView(BatchMasterView): # return form def configure_common_form(self, f): - super(PurchasingBatchView, self).configure_common_form(f) + super().configure_common_form(f) # po_total if self.creating: @@ -225,7 +226,7 @@ class PurchasingBatchView(BatchMasterView): f.set_type('po_total_calculated', 'currency') def configure_form(self, f): - super(PurchasingBatchView, self).configure_form(f) + super().configure_form(f) model = self.model batch = f.model_instance app = self.get_rattail_app() @@ -598,7 +599,7 @@ class PurchasingBatchView(BatchMasterView): # return query.options(orm.joinedload(model.PurchaseBatchRow.credits)) def configure_row_grid(self, g): - super(PurchasingBatchView, self).configure_row_grid(g) + super().configure_row_grid(g) g.set_type('upc', 'gpc') g.set_type('cases_ordered', 'quantity') @@ -685,7 +686,7 @@ class PurchasingBatchView(BatchMasterView): return 'notice' def configure_row_form(self, f): - super(PurchasingBatchView, self).configure_row_form(f) + super().configure_row_form(f) row = f.model_instance if self.creating: batch = self.get_instance() @@ -894,7 +895,7 @@ class PurchasingBatchView(BatchMasterView): batch.invoice_total -= row.invoice_total # do the "normal" save logic... - row = super(PurchasingBatchView, self).save_edit_row_form(form) + row = super().save_edit_row_form(form) # TODO: is this needed? # self.handler.refresh_row(row) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 0cef3a37..3e78dfea 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -233,7 +233,7 @@ class ReceivingBatchView(PurchasingBatchView): return self.enum.PURCHASE_BATCH_MODE_RECEIVING def configure_grid(self, g): - super(ReceivingBatchView, self).configure_grid(g) + super().configure_grid(g) if not self.handler.allow_truck_dump_receiving(): g.remove('truck_dump') @@ -285,14 +285,14 @@ class ReceivingBatchView(PurchasingBatchView): raise redirect # okay now do the normal thing, per workflow - return super(ReceivingBatchView, self).create(**kwargs) + return super().create(**kwargs) # on the other hand, if caller provided a form, that means we are in # the middle of some other custom workflow, e.g. "add child to truck # dump parent" or some such. in which case we also defer to the normal # logic, so as to not interfere with that. if form: - return super(ReceivingBatchView, self).create(form=form, **kwargs) + return super().create(form=form, **kwargs) # okay, at this point we need the user to select a vendor and workflow self.creating = True @@ -372,14 +372,14 @@ class ReceivingBatchView(PurchasingBatchView): # first run it through the normal logic, if that doesn't like # it then we won't either - if not super(ReceivingBatchView, self).row_deletable(row): + if not super().row_deletable(row): return False # otherwise let handler decide return self.batch_handler.is_row_deletable(row) def get_instance_title(self, batch): - title = super(ReceivingBatchView, self).get_instance_title(batch) + title = super().get_instance_title(batch) if batch.is_truck_dump_parent(): title = "{} (TRUCK DUMP PARENT)".format(title) elif batch.is_truck_dump_child(): @@ -633,7 +633,7 @@ class ReceivingBatchView(PurchasingBatchView): return info['display'] def get_visible_params(self, batch): - params = super(ReceivingBatchView, self).get_visible_params(batch) + params = super().get_visible_params(batch) # remove this since we show it separately params.pop('invoice_files', None) @@ -655,7 +655,7 @@ class ReceivingBatchView(PurchasingBatchView): return kwargs def get_batch_kwargs(self, batch, **kwargs): - kwargs = super(ReceivingBatchView, self).get_batch_kwargs(batch, **kwargs) + kwargs = super().get_batch_kwargs(batch, **kwargs) batch_type = self.request.POST['batch_type'] # must pull vendor from URL if it was not in form data @@ -769,7 +769,7 @@ class ReceivingBatchView(PurchasingBatchView): return True def template_kwargs_view(self, **kwargs): - kwargs = super(ReceivingBatchView, self).template_kwargs_view(**kwargs) + kwargs = super().template_kwargs_view(**kwargs) batch = kwargs['instance'] if self.handler.has_purchase_order(batch) and self.handler.has_invoice_file(batch): @@ -810,7 +810,7 @@ class ReceivingBatchView(PurchasingBatchView): return credits_data def template_kwargs_view_row(self, **kwargs): - kwargs = super(ReceivingBatchView, self).template_kwargs_view_row(**kwargs) + kwargs = super().template_kwargs_view_row(**kwargs) app = self.get_rattail_app() products_handler = app.get_products_handler() row = kwargs['instance'] @@ -847,7 +847,7 @@ class ReceivingBatchView(PurchasingBatchView): if batch.is_truck_dump_parent(): for child in batch.truck_dump_children: self.delete_instance(child) - super(ReceivingBatchView, self).delete_instance(batch) + super().delete_instance(batch) if truck_dump: self.handler.refresh(truck_dump) @@ -1010,7 +1010,7 @@ class ReceivingBatchView(PurchasingBatchView): .group_by(model.PurchaseBatchCredit.row_uuid)\ .subquery() g.set_joiner('credits', lambda q: q.outerjoin(Credits)) - g.sorters['credits'] = lambda q, d: q.order_by(getattr(Credits.c.credit_count, d)()) + g.set_sorter('credits', Credits.c.credit_count) show_ordered = self.rattail_config.getbool( 'rattail.batch', 'purchase.receiving.show_ordered_column_in_grid', @@ -1083,7 +1083,7 @@ class ReceivingBatchView(PurchasingBatchView): }) def row_grid_extra_class(self, row, i): - css_class = super(ReceivingBatchView, self).row_grid_extra_class(row, i) + css_class = super().row_grid_extra_class(row, i) if row.catalog_cost_confirmed: css_class = '{} catalog_cost_confirmed'.format(css_class or '') @@ -1098,7 +1098,7 @@ class ReceivingBatchView(PurchasingBatchView): return str(row.product) if row.upc: return row.upc.pretty() - return super(ReceivingBatchView, self).get_row_instance_title(row) + return super().get_row_instance_title(row) def transform_unit_url(self, row, i): # grid action is shown only when we return a URL here @@ -1110,7 +1110,7 @@ class ReceivingBatchView(PurchasingBatchView): def make_row_credits_grid(self, row): # first make grid like normal - g = super(ReceivingBatchView, self).make_row_credits_grid(row) + g = super().make_row_credits_grid(row) if (self.has_perm('edit_row') and self.row_editable(row)): @@ -1616,7 +1616,7 @@ class ReceivingBatchView(PurchasingBatchView): def validate_row_form(self, form): # if normal validation fails, stop there - if not super(ReceivingBatchView, self).validate_row_form(form): + if not super().validate_row_form(form): return False # if user is editing row from truck dump child, then we must further @@ -2097,7 +2097,7 @@ class ReceiveRowForm(colander.MappingSchema): quick_receive = colander.SchemaNode(colander.Boolean()) def deserialize(self, *args): - result = super(ReceiveRowForm, self).deserialize(*args) + result = super().deserialize(*args) if result['mode'] == 'expired' and not result['expiration_date']: msg = "Expiration date is required for items with 'expired' mode." diff --git a/tailbone/views/shifts/core.py b/tailbone/views/shifts/core.py index 8fa934ea..53bfc446 100644 --- a/tailbone/views/shifts/core.py +++ b/tailbone/views/shifts/core.py @@ -84,7 +84,7 @@ class ScheduledShiftView(MasterView, ShiftViewMixin): g.set_label('employee', "Employee Name") def configure_form(self, f): - super(ScheduledShiftView, self).configure_form(f) + super().configure_form(f) f.set_renderer('length', self.render_shift_length) @@ -118,19 +118,22 @@ class WorkedShiftView(MasterView, ShiftViewMixin): ] def configure_grid(self, g): - super(WorkedShiftView, self).configure_grid(g) + super().configure_grid(g) + model = self.model - g.joiners['employee'] = lambda q: q.join(model.Employee).join(model.Person) - g.filters['employee'] = g.make_filter('employee', model.Person.display_name) - g.sorters['employee'] = g.make_sorter(model.Person.display_name) + # employee + g.set_joiner('employee', lambda q: q.join(model.Employee).join(model.Person)) + g.set_sorter('employee', model.Person.display_name) + g.set_filter('employee', model.Person.display_name) - g.joiners['store'] = lambda q: q.join(model.Store) - g.filters['store'] = g.make_filter('store', model.Store.name) - g.sorters['store'] = g.make_sorter(model.Store.name) + # store + g.set_joiner('store', lambda q: q.join(model.Store)) + g.set_sorter('store', model.Store.name) + g.set_filter('store', model.Store.name) # TODO: these sorters should be automatic once we fix the schema - g.sorters['start_time'] = g.make_sorter(model.WorkedShift.punch_in) - g.sorters['end_time'] = g.make_sorter(model.WorkedShift.punch_out) + g.set_sorter('start_time', model.WorkedShift.punch_in) + g.set_sorter('end_time', model.WorkedShift.punch_out) # TODO: same goes for these renderers g.set_type('start_time', 'datetime') g.set_type('end_time', 'datetime') @@ -150,7 +153,7 @@ class WorkedShiftView(MasterView, ShiftViewMixin): return "WorkedShift: {}, {}".format(shift.employee, date) def configure_form(self, f): - super(WorkedShiftView, self).configure_form(f) + super().configure_form(f) f.set_readonly('employee') f.set_renderer('employee', self.render_employee) @@ -168,7 +171,7 @@ class WorkedShiftView(MasterView, ShiftViewMixin): return tags.link_to(text, url) def get_xlsx_fields(self): - fields = super(WorkedShiftView, self).get_xlsx_fields() + fields = super().get_xlsx_fields() # add employee name i = fields.index('employee_uuid') @@ -180,7 +183,7 @@ class WorkedShiftView(MasterView, ShiftViewMixin): return fields def get_xlsx_row(self, shift, fields): - row = super(WorkedShiftView, self).get_xlsx_row(shift, fields) + row = super().get_xlsx_row(shift, fields) # localize start and end times (Excel requires time with no zone) if shift.punch_in: diff --git a/tailbone/views/tempmon/probes.py b/tailbone/views/tempmon/probes.py index dbf15dd1..573f9a2d 100644 --- a/tailbone/views/tempmon/probes.py +++ b/tailbone/views/tempmon/probes.py @@ -101,8 +101,9 @@ class TempmonProbeView(MasterView): def configure_grid(self, g): super().configure_grid(g) - g.joiners['client'] = lambda q: q.join(tempmon.Client) - g.sorters['client'] = g.make_sorter(tempmon.Client.config_key) + # client + g.set_joiner('client', lambda q: q.join(tempmon.Client)) + g.set_sorter('client', tempmon.Client.config_key) g.set_sort_defaults('client') g.set_enum('appliance_type', self.enum.TEMPMON_APPLIANCE_TYPE) diff --git a/tailbone/views/tempmon/readings.py b/tailbone/views/tempmon/readings.py index a8223dd2..02e3fc51 100644 --- a/tailbone/views/tempmon/readings.py +++ b/tailbone/views/tempmon/readings.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,9 +24,6 @@ Views for tempmon readings """ -from __future__ import unicode_literals, absolute_import - -import six from sqlalchemy import orm from rattail_tempmon.db import model as tempmon @@ -70,17 +67,21 @@ class TempmonReadingView(MasterView): .options(orm.joinedload(tempmon.Reading.client)) def configure_grid(self, g): - super(TempmonReadingView, self).configure_grid(g) + super().configure_grid(g) - g.sorters['client_key'] = g.make_sorter(tempmon.Client.config_key) - g.filters['client_key'] = g.make_filter('client_key', tempmon.Client.config_key) + # client_key + g.set_sorter('client_key', tempmon.Client.config_key) + g.set_filter('client_key', tempmon.Client.config_key) - g.sorters['client_host'] = g.make_sorter(tempmon.Client.hostname) - g.filters['client_host'] = g.make_filter('client_host', tempmon.Client.hostname) + # client_host + g.set_sorter('client_host', tempmon.Client.hostname) + g.set_filter('client_host', tempmon.Client.hostname) - g.joiners['probe'] = lambda q: q.join(tempmon.Probe, tempmon.Probe.uuid == tempmon.Reading.probe_uuid) - g.sorters['probe'] = g.make_sorter(tempmon.Probe.description) - g.filters['probe'] = g.make_filter('probe', tempmon.Probe.description) + # probe + g.set_joiner('probe', lambda q: q.join(tempmon.Probe, + tempmon.Probe.uuid == tempmon.Reading.probe_uuid)) + g.set_sorter('probe', tempmon.Probe.description) + g.set_filter('probe', tempmon.Probe.description) g.set_sort_defaults('taken', 'desc') g.set_type('taken', 'datetime') @@ -98,7 +99,7 @@ class TempmonReadingView(MasterView): return reading.client.hostname def configure_form(self, f): - super(TempmonReadingView, self).configure_form(f) + super().configure_form(f) # client f.set_renderer('client', self.render_client) @@ -112,7 +113,7 @@ class TempmonReadingView(MasterView): client = reading.client if not client: return "" - text = six.text_type(client) + text = str(client) url = self.request.route_url('tempmon.clients.view', uuid=client.uuid) return tags.link_to(text, url) @@ -120,7 +121,7 @@ class TempmonReadingView(MasterView): probe = reading.probe if not probe: return "" - text = six.text_type(probe) + text = str(probe) url = self.request.route_url('tempmon.probes.view', uuid=probe.uuid) return tags.link_to(text, url) diff --git a/tailbone/views/views.py b/tailbone/views/views.py index 25828cde..67cba2e2 100644 --- a/tailbone/views/views.py +++ b/tailbone/views/views.py @@ -24,8 +24,6 @@ Views for views """ -from __future__ import unicode_literals, absolute_import - import os import sys @@ -80,7 +78,7 @@ class ModelViewView(MasterView): return data def configure_grid(self, g): - super(ModelViewView, self).configure_grid(g) + super().configure_grid(g) # label g.sorters['label'] = g.make_simple_sorter('label') @@ -107,7 +105,7 @@ class ModelViewView(MasterView): return ModelViewSchema() def template_kwargs_create(self, **kwargs): - kwargs = super(ModelViewView, self).template_kwargs_create(**kwargs) + kwargs = super().template_kwargs_create(**kwargs) app = self.get_rattail_app() db_handler = app.get_db_handler() From 13565d1c455818897b9ad4ecc2439620abec9f31 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 18 Oct 2023 21:24:37 -0500 Subject: [PATCH 1269/1681] Avoid "None" when rendering product UOM field --- tailbone/views/products.py | 269 +++++++++++++++++++------------------ 1 file changed, 137 insertions(+), 132 deletions(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 1ddf6ae0..449e7473 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -393,138 +393,6 @@ class ProductView(MasterView): g.set_link('item_id') g.set_link('description') - def configure_common_form(self, f): - super().configure_common_form(f) - product = f.model_instance - - # unit_size - f.set_type('unit_size', 'quantity') - - # unit_of_measure - f.set_enum('unit_of_measure', self.enum.UNIT_OF_MEASURE) - f.set_label('unit_of_measure', "Unit of Measure") - - # packs - if self.creating: - f.remove_field('packs') - elif self.viewing and product.packs: - f.set_renderer('packs', self.render_packs) - f.set_label('packs', "Pack Items") - else: - f.remove_field('packs') - - # pack_size - if self.viewing and not product.is_pack_item(): - f.remove_field('pack_size') - else: - f.set_type('pack_size', 'quantity') - - # default_pack - if self.viewing and not product.is_pack_item(): - f.remove_field('default_pack') - - # unit - if self.creating: - f.remove_field('unit') - elif self.viewing and product.is_pack_item(): - f.set_renderer('unit', self.render_unit) - f.set_label('unit', "Unit Item") - else: - f.remove_field('unit') - - # suggested_price - if self.creating: - f.remove_field('suggested_price') - else: - f.set_readonly('suggested_price') - f.set_renderer('suggested_price', self.render_suggested_price) - - # regular_price - if self.creating: - f.remove_field('regular_price') - else: - f.set_readonly('regular_price') - f.set_renderer('regular_price', self.render_regular_price) - - # current_price - if self.creating: - f.remove_field('current_price') - else: - f.set_readonly('current_price') - f.set_renderer('current_price', self.render_current_price) - - # current_price_ends - if self.creating: - f.remove_field('current_price_ends') - else: - f.set_readonly('current_price_ends') - f.set_renderer('current_price_ends', self.render_current_price_ends) - - # sale_price - if self.creating: - f.remove_field('sale_price') - else: - f.set_readonly('sale_price') - f.set_renderer('sale_price', self.render_price) - - # sale_price_ends - if self.creating: - f.remove_field('sale_price_ends') - else: - f.set_readonly('sale_price_ends') - f.set_renderer('sale_price_ends', self.render_sale_price_ends) - - # tpr_price - if self.creating: - f.remove_field('tpr_price') - else: - f.set_readonly('tpr_price') - f.set_renderer('tpr_price', self.render_price) - - # tpr_price_ends - if self.creating: - f.remove_field('tpr_price_ends') - else: - f.set_readonly('tpr_price_ends') - f.set_renderer('tpr_price_ends', self.render_tpr_price_ends) - - # vendor - if self.creating: - f.remove_field('vendor') - else: - f.set_readonly('vendor') - f.set_label('vendor', "Preferred Vendor") - - # cost - if self.creating: - f.remove_field('cost') - else: - f.set_readonly('cost') - f.set_label('cost', "Preferred Unit Cost") - f.set_renderer('cost', self.render_cost) - - # last_sold - if self.creating: - f.remove_field('last_sold') - else: - f.set_readonly('last_sold') - - # inventory_on_hand - if self.creating: - f.remove_field('inventory_on_hand') - else: - f.set_readonly('inventory_on_hand') - f.set_renderer('inventory_on_hand', self.render_inventory_on_hand) - f.set_label('inventory_on_hand', "On Hand") - - # inventory_on_order - if self.creating: - f.remove_field('inventory_on_order') - else: - f.set_readonly('inventory_on_order') - f.set_renderer('inventory_on_order', self.render_inventory_on_order) - f.set_label('inventory_on_order', "On Order") - def render_cost(self, product, field): cost = getattr(product, field) if not cost: @@ -824,6 +692,135 @@ class ProductView(MasterView): super().configure_form(f) product = f.model_instance + # unit_size + f.set_type('unit_size', 'quantity') + + # unit_of_measure + f.set_enum('unit_of_measure', self.enum.UNIT_OF_MEASURE) + f.set_renderer('unit_of_measure', self.render_unit_of_measure) + f.set_label('unit_of_measure', "Unit of Measure") + + # packs + if self.creating: + f.remove_field('packs') + elif self.viewing and product.packs: + f.set_renderer('packs', self.render_packs) + f.set_label('packs', "Pack Items") + else: + f.remove_field('packs') + + # pack_size + if self.viewing and not product.is_pack_item(): + f.remove_field('pack_size') + else: + f.set_type('pack_size', 'quantity') + + # default_pack + if self.viewing and not product.is_pack_item(): + f.remove_field('default_pack') + + # unit + if self.creating: + f.remove_field('unit') + elif self.viewing and product.is_pack_item(): + f.set_renderer('unit', self.render_unit) + f.set_label('unit', "Unit Item") + else: + f.remove_field('unit') + + # suggested_price + if self.creating: + f.remove_field('suggested_price') + else: + f.set_readonly('suggested_price') + f.set_renderer('suggested_price', self.render_suggested_price) + + # regular_price + if self.creating: + f.remove_field('regular_price') + else: + f.set_readonly('regular_price') + f.set_renderer('regular_price', self.render_regular_price) + + # current_price + if self.creating: + f.remove_field('current_price') + else: + f.set_readonly('current_price') + f.set_renderer('current_price', self.render_current_price) + + # current_price_ends + if self.creating: + f.remove_field('current_price_ends') + else: + f.set_readonly('current_price_ends') + f.set_renderer('current_price_ends', self.render_current_price_ends) + + # sale_price + if self.creating: + f.remove_field('sale_price') + else: + f.set_readonly('sale_price') + f.set_renderer('sale_price', self.render_price) + + # sale_price_ends + if self.creating: + f.remove_field('sale_price_ends') + else: + f.set_readonly('sale_price_ends') + f.set_renderer('sale_price_ends', self.render_sale_price_ends) + + # tpr_price + if self.creating: + f.remove_field('tpr_price') + else: + f.set_readonly('tpr_price') + f.set_renderer('tpr_price', self.render_price) + + # tpr_price_ends + if self.creating: + f.remove_field('tpr_price_ends') + else: + f.set_readonly('tpr_price_ends') + f.set_renderer('tpr_price_ends', self.render_tpr_price_ends) + + # vendor + if self.creating: + f.remove_field('vendor') + else: + f.set_readonly('vendor') + f.set_label('vendor', "Preferred Vendor") + + # cost + if self.creating: + f.remove_field('cost') + else: + f.set_readonly('cost') + f.set_label('cost', "Preferred Unit Cost") + f.set_renderer('cost', self.render_cost) + + # last_sold + if self.creating: + f.remove_field('last_sold') + else: + f.set_readonly('last_sold') + + # inventory_on_hand + if self.creating: + f.remove_field('inventory_on_hand') + else: + f.set_readonly('inventory_on_hand') + f.set_renderer('inventory_on_hand', self.render_inventory_on_hand) + f.set_label('inventory_on_hand', "On Hand") + + # inventory_on_order + if self.creating: + f.remove_field('inventory_on_order') + else: + f.set_readonly('inventory_on_order') + f.set_renderer('inventory_on_order', self.render_inventory_on_order) + f.set_label('inventory_on_order', "On Order") + # department if self.creating or self.editing: if 'department' in f.fields: @@ -998,6 +995,14 @@ class ProductView(MasterView): return product + def render_unit_of_measure(self, product, field): + uom = getattr(product, field) + if uom is None: + return + if uom == self.enum.UNIT_OF_MEASURE_NONE: + return + return self.enum.UNIT_OF_MEASURE.get(uom, uom) + def render_department(self, product, field): department = product.department if not department: From 230a54cb99746009ac46701ad3242b4c0bd2b50c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 18 Oct 2023 21:25:13 -0500 Subject: [PATCH 1270/1681] Fix default grid filter when "local" date times are involved --- tailbone/grids/core.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index a3d85006..6177d3d0 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -652,7 +652,10 @@ class Grid(object): elif isinstance(column.type, sa.Date): factory = gridfilters.AlchemyDateFilter elif isinstance(column.type, sa.DateTime): - factory = gridfilters.AlchemyDateTimeFilter + if self.assume_local_times: + factory = gridfilters.AlchemyLocalDateTimeFilter + else: + factory = gridfilters.AlchemyDateTimeFilter elif isinstance(column.type, GPCType): factory = gridfilters.AlchemyGPCFilter kwargs['column'] = column From 954a2b78beff44dfcfd954c855deeea4e5905580 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 18 Oct 2023 21:25:32 -0500 Subject: [PATCH 1271/1681] Expose new price fields for POS batch row --- tailbone/views/batch/pos.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tailbone/views/batch/pos.py b/tailbone/views/batch/pos.py index f1e2b0d9..939879d2 100644 --- a/tailbone/views/batch/pos.py +++ b/tailbone/views/batch/pos.py @@ -107,7 +107,12 @@ class POSBatchView(BatchMasterView): 'department_number', 'department_name', 'reg_price', + 'cur_price', + 'cur_price_type', + 'cur_price_start', + 'cur_price_end', 'txn_price', + 'txn_price_adjusted', 'quantity', 'sales_total', 'tax_code', From aaf6f05820fc771c856da5d454f10bfbd91a6714 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 19 Oct 2023 13:02:17 -0500 Subject: [PATCH 1272/1681] Remove sorter for "Credits?" column in purchasing batch row grid too convoluted, and broken per recent sort overhaul --- tailbone/views/purchasing/receiving.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 3e78dfea..5ccf6081 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -31,7 +31,6 @@ import logging from collections import OrderedDict import humanize -import sqlalchemy as sa from rattail import pod from rattail.time import localtime, make_utc @@ -1002,16 +1001,6 @@ class ReceivingBatchView(PurchasingBatchView): g.set_click_handler('invoice_unit_cost', 'this.invoiceUnitCostClicked') - # credits - # note that sorting by credits involves a subquery with group by clause. - # seems likely there may be a better way? but this seems to work fine - Credits = self.Session.query(model.PurchaseBatchCredit.row_uuid, - sa.func.count().label('credit_count'))\ - .group_by(model.PurchaseBatchCredit.row_uuid)\ - .subquery() - g.set_joiner('credits', lambda q: q.outerjoin(Credits)) - g.set_sorter('credits', Credits.c.credit_count) - show_ordered = self.rattail_config.getbool( 'rattail.batch', 'purchase.receiving.show_ordered_column_in_grid', default=False) From 0d302473538ca9a9536e38f9b3f621b2b30db3a4 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 19 Oct 2023 14:03:25 -0500 Subject: [PATCH 1273/1681] Add validtion to prevent duplicate files for multi-invoice receiving by comparing sha256 hash values for each file --- tailbone/forms/core.py | 20 ++++++++++++++++++++ tailbone/forms/widgets.py | 15 +++++++++++++++ tailbone/views/purchasing/receiving.py | 2 +- 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 06bf96e4..2c23b126 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -24,6 +24,7 @@ Forms Core """ +import hashlib import json import logging import warnings @@ -659,11 +660,25 @@ class Form(object): 'widget': MultiFileUploadWidget(tmpstore)} # if 'required' in kwargs and not kwargs['required']: # kw['missing'] = colander.null + if kwargs.get('validate_unique'): + kw['validator'] = self.validate_multiple_files_unique files_node = colander.SequenceSchema(file_node, **kw) self.set_node(key, files_node) else: raise ValueError("unknown type for '{}' field: {}".format(key, type_)) + def validate_multiple_files_unique(self, node, value): + + # get SHA256 hash for each file; error if duplicates encountered + hashes = {} + for fileinfo in value: + fp = fileinfo['fp'] + fp.seek(0) + filehash = hashlib.sha256(fp.read()).hexdigest() + if filehash in hashes: + node.raise_invalid(f"Duplicate file detected: {fileinfo['filename']}") + hashes[filehash] = fileinfo + def set_enum(self, key, enum, empty=None): if enum: self.enums[key] = enum @@ -906,6 +921,11 @@ class Form(object): return json.dumps({'name': value['filename']}) return 'null' + elif isinstance(value, list) and all([isinstance(f, dfwidget.filedict) + for f in value]): + return json.dumps([{'name': f['filename']} + for f in value]) + app = self.request.rattail_config.get_app() value = app.json_friendly(value) return json.dumps(value) diff --git a/tailbone/forms/widgets.py b/tailbone/forms/widgets.py index 23bbac00..0b8d3dc9 100644 --- a/tailbone/forms/widgets.py +++ b/tailbone/forms/widgets.py @@ -323,6 +323,21 @@ class MultiFileUploadWidget(dfwidget.FileUploadWidget): template = 'multi_file_upload' requirements = () + def serialize(self, field, cstruct, **kw): + if cstruct in (colander.null, None): + cstruct = [] + + if cstruct: + for fileinfo in cstruct: + uid = fileinfo['uid'] + if uid not in self.tmpstore: + self.tmpstore[uid] = fileinfo + + readonly = kw.get("readonly", self.readonly) + template = readonly and self.readonly_template or self.template + values = self.get_template_values(field, cstruct, kw) + return field.renderer(template, **values) + def deserialize(self, field, pstruct): if pstruct is colander.null: return colander.null diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 5ccf6081..9de4baa3 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -570,7 +570,7 @@ class ReceivingBatchView(PurchasingBatchView): elif workflow == 'from_multi_invoice': if 'invoice_files' not in f: f.insert_before('invoice_file', 'invoice_files') - f.set_type('invoice_files', 'multi_file') + f.set_type('invoice_files', 'multi_file', validate_unique=True) f.set_required('invoice_parser_key') f.remove('truck_dump_batch_uuid', 'po_number', From 5e8ea6777393cd91760e8941cc331fd9622e2bf7 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 19 Oct 2023 14:57:06 -0500 Subject: [PATCH 1274/1681] Include invoice number for receiving batch row API --- tailbone/api/batch/receiving.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/api/batch/receiving.py b/tailbone/api/batch/receiving.py index 57501a7d..f8ce4a33 100644 --- a/tailbone/api/batch/receiving.py +++ b/tailbone/api/batch/receiving.py @@ -345,6 +345,7 @@ class ReceivingBatchRowViews(APIBatchRowView): data['po_unit_cost'] = row.po_unit_cost data['po_total'] = row.po_total + data['invoice_number'] = row.invoice_number data['invoice_unit_cost'] = row.invoice_unit_cost data['invoice_total'] = row.invoice_total data['invoice_total_calculated'] = row.invoice_total_calculated From dc99828b66cecde71a56777445c17ddc5ec739fb Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 19 Oct 2023 19:12:28 -0500 Subject: [PATCH 1275/1681] Show food stamp tender info for POS batch --- tailbone/views/batch/pos.py | 5 ++++- tailbone/views/tenders.py | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/tailbone/views/batch/pos.py b/tailbone/views/batch/pos.py index 939879d2..bb7fbb39 100644 --- a/tailbone/views/batch/pos.py +++ b/tailbone/views/batch/pos.py @@ -49,6 +49,7 @@ class POSBatchView(BatchMasterView): labels = { 'terminal_id': "Terminal ID", + 'fs_tender_total': "FS Tender Total", } grid_columns = [ @@ -74,6 +75,7 @@ class POSBatchView(BatchMasterView): 'sales_total', 'taxes', 'tender_total', + 'fs_tender_total', 'balance', 'void', 'training_mode', @@ -152,6 +154,7 @@ class POSBatchView(BatchMasterView): g.set_type('sales_total', 'currency') g.set_type('tender_total', 'currency') + g.set_type('fs_tender_total', 'currency') # executed # nb. default view should show "all recent" batches regardless @@ -178,7 +181,7 @@ class POSBatchView(BatchMasterView): f.set_type('sales_total', 'currency') f.set_type('tender_total', 'currency') - f.set_type('tender_total', 'currency') + f.set_type('fs_tender_total', 'currency') if self.viewing: f.set_renderer('taxes', self.render_taxes) diff --git a/tailbone/views/tenders.py b/tailbone/views/tenders.py index d5524e74..46f51c83 100644 --- a/tailbone/views/tenders.py +++ b/tailbone/views/tenders.py @@ -40,6 +40,7 @@ class TenderView(MasterView): 'code', 'name', 'is_cash', + 'is_foodstamp', 'allow_cash_back', 'kick_drawer', ] @@ -48,6 +49,7 @@ class TenderView(MasterView): 'code', 'name', 'is_cash', + 'is_foodstamp', 'allow_cash_back', 'kick_drawer', 'notes', From d87de1dc4f44520657ca304216d32d0d8a586749 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 19 Oct 2023 20:48:52 -0500 Subject: [PATCH 1276/1681] Expose permission for POS batch, toggle training mode --- tailbone/views/batch/pos.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tailbone/views/batch/pos.py b/tailbone/views/batch/pos.py index bb7fbb39..9062ec12 100644 --- a/tailbone/views/batch/pos.py +++ b/tailbone/views/batch/pos.py @@ -288,6 +288,8 @@ class POSBatchView(BatchMasterView): "Remove customer from current transaction") # config.add_tailbone_permission('pos', 'pos.resume', # "Resume previously-suspended transaction") + config.add_tailbone_permission('pos', 'pos.toggle_training', + "Start/end training mode") config.add_tailbone_permission('pos', 'pos.suspend', "Suspend current transaction") config.add_tailbone_permission('pos', 'pos.swap_customer', From 421266e70c53eb14f5e72d5ac99986ec683ea4fe Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 20 Oct 2023 14:29:45 -0500 Subject: [PATCH 1277/1681] Show more customer attrs for POS batch --- tailbone/views/batch/pos.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tailbone/views/batch/pos.py b/tailbone/views/batch/pos.py index 9062ec12..afda919e 100644 --- a/tailbone/views/batch/pos.py +++ b/tailbone/views/batch/pos.py @@ -70,6 +70,8 @@ class POSBatchView(BatchMasterView): 'terminal_id', 'cashier', 'customer', + 'customer_is_member', + 'customer_is_employee', 'params', 'rowcount', 'sales_total', From 6d79766b24e8873f568bcdac62793e5c9fc1abfa Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 21 Oct 2023 16:10:36 -0500 Subject: [PATCH 1278/1681] Stop using sa-filters for basic grid sorting this just breaks if we need to use "aliased" models e.g. when sorting and/or filtering by Product "regular price" column and similar. so now sorting more like we always used to, except for multi-column. nb. this still assumes callers use `Grid.make_sorter()` when declaring the sorters. if caller must specify more custom/explicit sort logic then it likely will not work and we'll have to add a workaround to allow avoiding the common logic..but that's another day --- tailbone/grids/core.py | 31 ++++++++++++++-------------- tailbone/views/products.py | 42 ++++++++++++++++++++------------------ 2 files changed, 37 insertions(+), 36 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 6177d3d0..5f28fca0 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -30,7 +30,6 @@ import logging import sqlalchemy as sa from sqlalchemy import orm -from sa_filters import apply_sort from rattail.db.types import GPCType from rattail.util import prettify, pretty_boolean, pretty_quantity @@ -1235,29 +1234,29 @@ class Grid(object): # TODO: is there a better way to check for SA sorting? if self.model_class: - # convert sort settings into a 'sortspec' for use with sa-filters - full_spec = [] + # collect actual column sorters for order_by clause + sorters = [] for sorter in self.active_sorters: sortkey = sorter['field'] - sortdir = sorter['order'] sortfunc = self.sorters.get(sortkey) - if sortfunc: - spec = { - 'sortkey': sortkey, - 'model': sortfunc._class.__name__, - 'field': sortfunc._column.key, - 'direction': sortdir or 'asc', - } - full_spec.append(spec) + if not sortfunc: + log.warning("unknown sorter: %s", sorter) + continue - # apply joins needed for this sort spec - for spec in full_spec: - sortkey = spec['sortkey'] + # join appropriate model if needed if sortkey in self.joiners and sortkey not in self.joined: data = self.joiners[sortkey](data) self.joined.add(sortkey) - return apply_sort(data, full_spec) + # add column/dir to collection + sortdir = sorter['order'] + sorters.append(getattr(sortfunc._column, sortdir)()) + + # apply sorting to query + if sorters: + data = data.order_by(*sorters) + + return data else: # not a SQLAlchemy grid, custom sorter diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 449e7473..e9e32a21 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -160,12 +160,6 @@ class ProductView(MasterView): 'inventory_on_order', ] - # same, but for prices - RegularPrice = orm.aliased(model.ProductPrice) - CurrentPrice = orm.aliased(model.ProductPrice) - SalePrice = orm.aliased(model.ProductPrice) - TPRPrice = orm.aliased(model.ProductPrice) - def __init__(self, request): super().__init__(request) self.expose_label_printing = self.rattail_config.getbool( @@ -332,28 +326,34 @@ class ProductView(MasterView): g.set_joiner('family', lambda q: q.outerjoin(model.Family)) g.set_filter('family', model.Family.name) + # regular_price g.set_label('regular_price', "Reg. Price") + RegularPrice = orm.aliased(model.ProductPrice) g.set_joiner('regular_price', lambda q: q.outerjoin( - self.RegularPrice, self.RegularPrice.uuid == model.Product.regular_price_uuid)) - g.set_sorter('regular_price', self.RegularPrice.price) - g.set_filter('regular_price', self.RegularPrice.price, label="Regular Price") + RegularPrice, RegularPrice.uuid == model.Product.regular_price_uuid)) + g.set_sorter('regular_price', RegularPrice.price) + g.set_filter('regular_price', RegularPrice.price, label="Regular Price") + # current_price g.set_label('current_price', "Cur. Price") g.set_renderer('current_price', self.render_current_price_for_grid) + CurrentPrice = orm.aliased(model.ProductPrice) g.set_joiner('current_price', lambda q: q.outerjoin( - self.CurrentPrice, self.CurrentPrice.uuid == model.Product.current_price_uuid)) - g.set_sorter('current_price', self.CurrentPrice.price) - g.set_filter('current_price', self.CurrentPrice.price, label="Current Price") + CurrentPrice, CurrentPrice.uuid == model.Product.current_price_uuid)) + g.set_sorter('current_price', CurrentPrice.price) + g.set_filter('current_price', CurrentPrice.price, label="Current Price") # tpr_price + TPRPrice = orm.aliased(model.ProductPrice) g.set_joiner('tpr_price', lambda q: q.outerjoin( - self.TPRPrice, self.TPRPrice.uuid == model.Product.tpr_price_uuid)) - g.set_filter('tpr_price', self.TPRPrice.price) + TPRPrice, TPRPrice.uuid == model.Product.tpr_price_uuid)) + g.set_filter('tpr_price', TPRPrice.price) # sale_price + SalePrice = orm.aliased(model.ProductPrice) g.set_joiner('sale_price', lambda q: q.outerjoin( - self.SalePrice, self.SalePrice.uuid == model.Product.sale_price_uuid)) - g.set_filter('sale_price', self.SalePrice.price) + SalePrice, SalePrice.uuid == model.Product.sale_price_uuid)) + g.set_filter('sale_price', SalePrice.price) # suggested_price g.set_renderer('suggested_price', self.render_grid_suggested_price) @@ -402,10 +402,12 @@ class ProductView(MasterView): return "${:0.2f}".format(cost.unit_cost) def render_price(self, product, field): - if not product.not_for_sale: - price = product[field] - if price: - return self.products_handler.render_price(price) + # TODO: previously this rendered null (empty string) if + # product was marked "not for sale" - but why? important? + #if not product.not_for_sale: + price = product[field] + if price: + return self.products_handler.render_price(price) def render_current_price_for_grid(self, product, field): text = self.render_price(product, field) or "" From ec8a8d5ddc21b88fbc8037f76b41ecb4258b264c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 23 Oct 2023 13:06:38 -0500 Subject: [PATCH 1279/1681] Update changelog --- CHANGES.rst | 28 ++++++++++++++++++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 8be310e7..fa562cde 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,34 @@ CHANGELOG ========= +0.9.68 (2023-10-23) +------------------- + +* Expose more permissions for POS. + +* Fix order xlsx download if missing order date. + +* Replace dropdowns with autocomplete, for "find principals by perm". + +* Use ``Grid.make_sorter()`` instead of legacy code. + +* Avoid "None" when rendering product UOM field. + +* Fix default grid filter when "local" date times are involved. + +* Expose new fields for POS batch/row. + +* Remove sorter for "Credits?" column in purchasing batch row grid. + +* Add validation to prevent duplicate files for multi-invoice receiving. + +* Include invoice number for receiving batch row API. + +* Show food stamp tender info for POS batch. + +* Stop using sa-filters for basic grid sorting. + + 0.9.67 (2023-10-12) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 8e69986c..fcf12c27 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.67' +__version__ = '0.9.68' From f70772fabc5bf9646690583ca19bfec728724158 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 23 Oct 2023 15:48:48 -0500 Subject: [PATCH 1280/1681] Allow override of version diff for master views and misc. other tweaks --- tailbone/templates/custorders/create.mako | 6 +++--- tailbone/templates/master/view.mako | 2 +- tailbone/views/master.py | 12 +++++++++--- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index 055957bb..663c4300 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -574,7 +574,7 @@ </b-field> <b-field label="Unit Size"> - <span>{{ productSize }}</span> + <span>{{ productSize || '' }}</span> </b-field> <b-field label="Case Size"> @@ -734,7 +734,7 @@ <b-field grouped> <b-field label="Product" horizontal> <span :class="productIsKnown ? null : 'has-text-success'"> - {{ productIsKnown ? productDisplay : pendingProduct.brand_name + ' ' + pendingProduct.description + ' ' + pendingProduct.size }} + {{ productIsKnown ? productDisplay : (pendingProduct.brand_name || '') + ' ' + (pendingProduct.description || '') + ' ' + (pendingProduct.size || '') }} </span> </b-field> </b-field> @@ -761,7 +761,7 @@ :class="productPriceNeedsConfirmation ? 'has-background-warning' : ''" % endif > - {{ productIsKnown ? productUnitPriceDisplay : '$' + pendingProduct.regular_price_amount }} + {{ productIsKnown ? productUnitPriceDisplay : (pendingProduct.regular_price_amount ? '$' + pendingProduct.regular_price_amount : '') }} </span> </b-field> diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako index b5930664..9a37b2bb 100644 --- a/tailbone/templates/master/view.mako +++ b/tailbone/templates/master/view.mako @@ -35,7 +35,7 @@ <nav class="panel"> <p class="panel-heading">Cross-Reference</p> <div class="panel-block buttons"> - <div style="display: flex; flex-direction: column;"> + <div style="display: flex; flex-direction: column; gap: 0.5rem;"> % for button in xref_buttons: ${button} % endfor diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 176ff672..7a1eff98 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -1441,8 +1441,8 @@ class MasterView(View): changed_raw = app.render_datetime(app.localtime(txn.issued_at, from_utc=True)) changed_ago = app.render_time_ago(app.make_utc() - txn.issued_at) - changed_by = str(txn.user) - if self.request.has_perm('users.view'): + changed_by = str(txn.user or '') + if self.request.has_perm('users.view') and txn.user: changed_by = tags.link_to(changed_by, self.request.route_url('users.view', uuid=txn.user.uuid)) return { @@ -4961,10 +4961,16 @@ class MasterView(View): def make_diff(self, old_data, new_data, **kwargs): return diffs.Diff(old_data, new_data, **kwargs) + def get_version_diff_factory(self, **kwargs): + if hasattr(self, 'version_diff_factory'): + return self.version_diff_factory + return diffs.VersionDiff + def make_version_diff(self, version, *args, **kwargs): if 'title' not in kwargs: kwargs['title'] = self.title_for_version(version) - return diffs.VersionDiff(version, *args, **kwargs) + factory = self.get_version_diff_factory() + return factory(version, *args, **kwargs) ############################## # Configuration Views From 756b4b9d18036fcdb491e4f0a7ee051704afeab5 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 23 Oct 2023 20:35:43 -0500 Subject: [PATCH 1281/1681] No need to configure logging since the rattail config object will do that when first made --- tailbone/app.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tailbone/app.py b/tailbone/app.py index 6f41a8de..ae10c9bc 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -60,7 +60,6 @@ def make_rattail_config(settings): "to the path of your config file. Lame, but necessary.") rattail_config = make_config(path) settings['rattail_config'] = rattail_config - rattail_config.configure_logging() # configure database sessions if hasattr(rattail_config, 'rattail_engine'): From 549976dcfbb88dbf0d6b02c8d6fcf66f486c685a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 24 Oct 2023 09:27:12 -0500 Subject: [PATCH 1282/1681] Update changelog --- CHANGES.rst | 8 ++++++++ tailbone/_version.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index fa562cde..bf395f94 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.9.69 (2023-10-24) +------------------- + +* Allow override of version diff for master views. + +* No need to configure logging. + + 0.9.68 (2023-10-23) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index fcf12c27..fa0bae73 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.68' +__version__ = '0.9.69' From 72e4daafc1d3d742de5c336572bcd9c88dd4eb97 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 24 Oct 2023 09:53:40 -0500 Subject: [PATCH 1283/1681] Fix config file priority for display, and batch subprocess commands --- tailbone/templates/appinfo/index.mako | 4 ++-- tailbone/views/batch/core.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tailbone/templates/appinfo/index.mako b/tailbone/templates/appinfo/index.mako index 40bf31ce..62a911ee 100644 --- a/tailbone/templates/appinfo/index.mako +++ b/tailbone/templates/appinfo/index.mako @@ -51,7 +51,7 @@ </b-icon> </span> - <strong>Configuration Files</strong> + <span>Configuration Files (style: ${request.rattail_config._style})</span> </div> </template> @@ -116,7 +116,7 @@ ${parent.modify_this_page_vars()} <script type="text/javascript"> - ThisPageData.configFiles = ${json.dumps([dict(path=p, priority=i) for i, p in enumerate(reversed(request.rattail_config.files_read), 1)])|n} + ThisPageData.configFiles = ${json.dumps([dict(path=p, priority=i) for i, p in enumerate(request.rattail_config.prioritized_files, 1)])|n} </script> </%def> diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 79d3f581..b9c28be7 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -931,7 +931,7 @@ class BatchMasterView(MasterView): prefix = self.rattail_config.get('rattail', 'command_prefix', default=sys.prefix) cmd = [os.path.join(prefix, 'bin/{}'.format(command))] - for path in reversed(self.rattail_config.files_read): + for path in self.rattail_config.prioritized_files: cmd.extend(['--config', path]) if username: cmd.extend(['--runas', username]) From 1f3877b7cb15712feb5700eb1b132fb885e27bc6 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 24 Oct 2023 09:54:31 -0500 Subject: [PATCH 1284/1681] Update changelog --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index bf395f94..06db3d61 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.9.70 (2023-10-24) +------------------- + +* Fix config file priority for display, and batch subprocess commands. + + 0.9.69 (2023-10-24) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index fa0bae73..deda170c 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.69' +__version__ = '0.9.70' From f708cb0b253b1b6fe4f23889bfba2cd2bba99be3 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 24 Oct 2023 17:44:02 -0500 Subject: [PATCH 1285/1681] Fix bug when editing vendor --- tailbone/views/vendors/core.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tailbone/views/vendors/core.py b/tailbone/views/vendors/core.py index 743e1632..8b9361b7 100644 --- a/tailbone/views/vendors/core.py +++ b/tailbone/views/vendors/core.py @@ -78,7 +78,7 @@ class VendorView(MasterView): ] def configure_grid(self, g): - super(VendorView, self).configure_grid(g) + super().configure_grid(g) g.filters['name'].default_active = True g.filters['name'].default_verb = 'contains' @@ -124,8 +124,9 @@ class VendorView(MasterView): def objectify(self, form, data=None): if data is None: data = form.validated - vendor = super(VendorView, self).objectify(form, data) + vendor = super().objectify(form, data) vendor = self.objectify_contact(vendor, data) + app = self.get_rattail_app() if 'orders_email' in data: address = data['orders_email'] @@ -169,7 +170,7 @@ class VendorView(MasterView): self.Session.delete(cost) def get_version_child_classes(self): - return super(VendorView, self).get_version_child_classes() + [ + return super().get_version_child_classes() + [ (model.VendorPhoneNumber, 'parent_uuid'), (model.VendorEmailAddress, 'parent_uuid'), (model.VendorContact, 'vendor_uuid'), @@ -186,14 +187,14 @@ class VendorView(MasterView): ] def configure_get_context(self, **kwargs): - context = super(VendorView, self).configure_get_context(**kwargs) + context = super().configure_get_context(**kwargs) context['supported_vendor_settings'] = self.configure_get_supported_vendor_settings() return context def configure_gather_settings(self, data, **kwargs): - settings = super(VendorView, self).configure_gather_settings( + settings = super().configure_gather_settings( data, **kwargs) supported_vendor_settings = self.configure_get_supported_vendor_settings() @@ -205,7 +206,7 @@ class VendorView(MasterView): return settings def configure_remove_settings(self, **kwargs): - super(VendorView, self).configure_remove_settings(**kwargs) + super().configure_remove_settings(**kwargs) app = self.get_rattail_app() names = [] From e308108bf761ab446c552255e5e175a640ae4f42 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 24 Oct 2023 17:48:08 -0500 Subject: [PATCH 1286/1681] Show user warning if "add item to custorder" fails specifically, if user enters alpha chars for cost/price fields --- tailbone/templates/custorders/create.mako | 2 ++ tailbone/views/custorders/orders.py | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index 663c4300..7d3b367f 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -2124,6 +2124,8 @@ this.itemDialogSaving = false this.showingItemDialog = false + }, response => { + this.itemDialogSaving = false }) }, }, diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index f88886bb..60949e8f 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -864,7 +864,10 @@ class CustomerOrderView(MasterView): for field in ('unit_cost', 'regular_price_amount', 'case_size'): if field in pending_info: - pending_info[field] = decimal.Decimal(pending_info[field]) + try: + pending_info[field] = decimal.Decimal(pending_info[field]) + except decimal.InvalidOperation: + return {'error': f"Invalid entry for field: {field}"} pending_info['user'] = self.request.user From 4247804707b69e0fe6f2291e027307060c42696e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 24 Oct 2023 19:17:36 -0500 Subject: [PATCH 1287/1681] Allow pending product fields to be required, for new custorder --- tailbone/templates/custorders/configure.mako | 44 +++++-- tailbone/templates/custorders/create.mako | 128 ++++++++++++------- tailbone/views/custorders/orders.py | 52 +++++++- 3 files changed, 167 insertions(+), 57 deletions(-) diff --git a/tailbone/templates/custorders/configure.mako b/tailbone/templates/custorders/configure.mako index ee1f06c5..3f7041d3 100644 --- a/tailbone/templates/custorders/configure.mako +++ b/tailbone/templates/custorders/configure.mako @@ -79,15 +79,6 @@ </b-checkbox> </b-field> - <b-field message="If set, user can enter details of an arbitrary new "pending" product."> - <b-checkbox name="rattail.custorders.allow_unknown_product" - v-model="simpleSettings['rattail.custorders.allow_unknown_product']" - native-value="true" - @input="settingsNeedSaved = true"> - Allow creating orders for "unknown" products - </b-checkbox> - </b-field> - <b-field> <b-checkbox name="rattail.custorders.allow_item_discounts" v-model="simpleSettings['rattail.custorders.allow_item_discounts']" @@ -130,6 +121,41 @@ </b-field> </div> + + <h3 class="block is-size-3">Unknown Products</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field message="If set, user can enter details of an arbitrary new "pending" product."> + <b-checkbox name="rattail.custorders.allow_unknown_product" + v-model="simpleSettings['rattail.custorders.allow_unknown_product']" + native-value="true" + @input="settingsNeedSaved = true"> + Allow creating orders for "unknown" products + </b-checkbox> + </b-field> + + <div v-if="simpleSettings['rattail.custorders.allow_unknown_product']"> + + <p class="block"> + Require these fields for new product: + </p> + + <div style="margin-left: 2rem;"> + % for field in pending_product_fields: + <b-field> + <b-checkbox name="rattail.custorders.unknown_product.fields.${field}.required" + v-model="simpleSettings['rattail.custorders.unknown_product.fields.${field}.required']" + native-value="true" + @input="settingsNeedSaved = true"> + ${field} + </b-checkbox> + </b-field> + % endfor + </div> + + </div> + + </div> </%def> diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index 7d3b367f..dbcd81b3 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -28,7 +28,7 @@ :disabled="submittingOrder" icon-pack="fas" icon-left="fas fa-upload"> - {{ submitOrderButtonText }} + {{ submittingOrder ? "Working, please wait..." : "Submit this Order" }} </b-button> <b-button @click="startOverEntirely()" icon-pack="fas" @@ -644,18 +644,29 @@ <b-field grouped> - <b-field label="Brand"> + <b-field label="Brand" + % if 'brand_name' in pending_product_required_fields: + :type="pendingProduct.brand_name ? null : 'is-danger'" + % endif + > <b-input v-model="pendingProduct.brand_name"> </b-input> </b-field> <b-field label="Description" - :type="pendingProduct.description ? null : 'is-danger'"> + % if 'description' in pending_product_required_fields: + :type="pendingProduct.description ? null : 'is-danger'" + % endif + > <b-input v-model="pendingProduct.description"> </b-input> </b-field> - <b-field label="Unit Size"> + <b-field label="Unit Size" + % if 'size' in pending_product_required_fields: + :type="pendingProduct.size ? null : 'is-danger'" + % endif + > <b-input v-model="pendingProduct.size"> </b-input> </b-field> @@ -664,12 +675,20 @@ <b-field grouped> - <b-field :label="productKeyLabel"> + <b-field :label="productKeyLabel" + % if 'key' in pending_product_required_fields: + :type="pendingProduct[productKeyField] ? null : 'is-danger'" + % endif + > <b-input v-model="pendingProduct[productKeyField]"> </b-input> </b-field> - <b-field label="Department"> + <b-field label="Department" + % if 'department_uuid' in pending_product_required_fields: + :type="pendingProduct.department_uuid ? null : 'is-danger'" + % endif + > <b-select v-model="pendingProduct.department_uuid"> <option :value="null">(not known)</option> <option v-for="option in departmentOptions" @@ -680,9 +699,36 @@ </b-select> </b-field> - <b-field label="Unit Reg. Price"> - <b-input v-model="pendingProduct.regular_price_amount" - type="number" step="0.01"> + </b-field> + + <b-field grouped> + + <b-field label="Vendor" + % if 'vendor_name' in pending_product_required_fields: + :type="pendingProduct.vendor_name ? null : 'is-danger'" + % endif + > + <b-input v-model="pendingProduct.vendor_name"> + </b-input> + </b-field> + + <b-field label="Vendor Item Code" + % if 'vendor_item_code' in pending_product_required_fields: + :type="pendingProduct.vendor_item_code ? null : 'is-danger'" + % endif + > + <b-input v-model="pendingProduct.vendor_item_code"> + </b-input> + </b-field> + + <b-field label="Case Size" + % if 'case_size' in pending_product_required_fields: + :type="pendingProduct.case_size ? null : 'is-danger'" + % endif + > + <b-input v-model="pendingProduct.case_size" + type="number" step="0.01" + style="width: 7rem;"> </b-input> </b-field> @@ -690,27 +736,24 @@ <b-field grouped> - <b-field label="Vendor"> - <b-input v-model="pendingProduct.vendor_name"> - </b-input> - </b-field> - - <b-field label="Vendor Item Code"> - <b-input v-model="pendingProduct.vendor_item_code"> - </b-input> - </b-field> - - <b-field label="Unit Cost"> + <b-field label="Unit Cost" + % if 'unit_cost' in pending_product_required_fields: + :type="pendingProduct.unit_cost ? null : 'is-danger'" + % endif + > <b-input v-model="pendingProduct.unit_cost" type="number" step="0.01" style="width: 10rem;"> </b-input> </b-field> - <b-field label="Case Size"> - <b-input v-model="pendingProduct.case_size" - type="number" step="0.01" - style="width: 7rem;"> + <b-field label="Unit Reg. Price" + % if 'regular_price_amount' in pending_product_required_fields: + :type="pendingProduct.regular_price_amount ? null : 'is-danger'" + % endif + > + <b-input v-model="pendingProduct.regular_price_amount" + type="number" step="0.01"> </b-input> </b-field> @@ -854,7 +897,7 @@ :disabled="itemDialogSaveDisabled" icon-pack="fas" icon-left="save"> - {{ itemDialogSaveButtonText }} + {{ itemDialogSaving ? "Working, please wait..." : (this.editingItem ? "Update Item" : "Add Item") }} </b-button> </div> @@ -1197,6 +1240,7 @@ % endif pendingProduct: {}, + pendingProductRequiredFields: ${json.dumps(pending_product_required_fields)|n}, departmentOptions: ${json.dumps(department_options)|n}, submittingOrder: false, @@ -1385,37 +1429,30 @@ % endif itemDialogSaveDisabled() { + if (this.itemDialogSaving) { return true } + if (this.productIsKnown) { if (!this.productUUID) { return true } + } else { - if (!this.pendingProduct.description) { - return true + for (let field of this.pendingProductRequiredFields) { + if (!this.pendingProduct[field]) { + return true + } } } + if (!this.productUOM) { return true } + return false }, - - itemDialogSaveButtonText() { - if (this.itemDialogSaving) { - return "Working, please wait..." - } - return this.editingItem ? "Update Item" : "Add Item" - }, - - submitOrderButtonText() { - if (this.submittingOrder) { - return "Working, please wait..." - } - return "Submit this Order" - }, }, mounted() { if (this.customerStatusType) { @@ -1925,11 +1962,14 @@ this.productIsKnown = !!row.product_uuid this.productUUID = row.product_uuid - this.pendingProduct = {} + + // nb. must construct new object before updating data + // (otherwise vue does not notice the changes?) + let pending = {} if (row.pending_product) { - this.copyPendingProductAttrs(row.pending_product, - this.pendingProduct) + this.copyPendingProductAttrs(row.pending_product, pending) } + this.pendingProduct = pending this.productDisplay = row.product_full_description this.productKey = row.product_key diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index 60949e8f..cc02f682 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -102,6 +102,19 @@ class CustomerOrderView(MasterView): 'flagged', ] + PENDING_PRODUCT_ENTRY_FIELDS = [ + 'key', + 'department_uuid', + 'brand_name', + 'description', + 'size', + 'vendor_name', + 'vendor_item_code', + 'unit_cost', + 'case_size', + 'regular_price_amount', + ] + def __init__(self, request): super(CustomerOrderView, self).__init__(request) self.batch_handler = self.get_batch_handler() @@ -361,6 +374,7 @@ class CustomerOrderView(MasterView): 'order_items': items, 'product_key_label': app.get_product_key_label(), 'allow_unknown_product': self.batch_handler.allow_unknown_product(), + 'pending_product_required_fields': self.get_pending_product_required_fields(), 'department_options': self.get_department_options(), 'default_uom_choices': self.batch_handler.uom_choices_for_product(None), 'default_uom': None, @@ -390,6 +404,17 @@ class CustomerOrderView(MasterView): 'value': department.uuid}) return options + def get_pending_product_required_fields(self): + required = [] + for field in self.PENDING_PRODUCT_ENTRY_FIELDS: + require = self.rattail_config.getbool('rattail.custorders', + f'unknown_product.fields.{field}.required') + if require is None and field == 'description': + require = True + if require: + required.append(field) + return required + def get_current_batch(self): user = self.request.user if not user: @@ -1044,7 +1069,7 @@ class CustomerOrderView(MasterView): } def configure_get_simple_settings(self): - return [ + settings = [ # customer handling {'section': 'rattail.custorders', @@ -1067,9 +1092,6 @@ class CustomerOrderView(MasterView): {'section': 'rattail.custorders', 'option': 'product_price_may_be_questionable', 'type': bool}, - {'section': 'rattail.custorders', - 'option': 'allow_unknown_product', - 'type': bool}, {'section': 'rattail.custorders', 'option': 'allow_item_discounts', 'type': bool}, @@ -1082,8 +1104,30 @@ class CustomerOrderView(MasterView): {'section': 'rattail.custorders', 'option': 'allow_past_item_reorder', 'type': bool}, + + # unknown products + {'section': 'rattail.custorders', + 'option': 'allow_unknown_product', + 'type': bool}, ] + for field in self.PENDING_PRODUCT_ENTRY_FIELDS: + setting = {'section': 'rattail.custorders', + 'option': f'unknown_product.fields.{field}.required', + 'type': bool} + if field == 'description': + setting['default'] = True + settings.append(setting) + + return settings + + def configure_get_context(self, **kwargs): + context = super().configure_get_context(**kwargs) + + context['pending_product_fields'] = self.PENDING_PRODUCT_ENTRY_FIELDS + + return context + @classmethod def defaults(cls, config): cls._order_defaults(config) From 72dda3771ede6c4ac0822a821f9ced57deca814a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 24 Oct 2023 19:51:27 -0500 Subject: [PATCH 1288/1681] Add price confirm prompt when adding unknown item to custorder optional, per config --- tailbone/templates/custorders/configure.mako | 12 ++- tailbone/templates/custorders/create.mako | 90 +++++++++++++++++++- tailbone/views/custorders/orders.py | 5 ++ 3 files changed, 105 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/custorders/configure.mako b/tailbone/templates/custorders/configure.mako index 3f7041d3..d2f6610d 100644 --- a/tailbone/templates/custorders/configure.mako +++ b/tailbone/templates/custorders/configure.mako @@ -140,7 +140,8 @@ Require these fields for new product: </p> - <div style="margin-left: 2rem;"> + <div class="block" + style="margin-left: 2rem;"> % for field in pending_product_fields: <b-field> <b-checkbox name="rattail.custorders.unknown_product.fields.${field}.required" @@ -153,6 +154,15 @@ % endfor </div> + <b-field message="If set, user is always prompted to confirm price when adding new product."> + <b-checkbox name="rattail.custorders.unknown_product.always_confirm_price" + v-model="simpleSettings['rattail.custorders.unknown_product.always_confirm_price']" + native-value="true" + @input="settingsNeedSaved = true"> + Require price confirmation + </b-checkbox> + </b-field> + </div> </div> diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index dbcd81b3..f666790e 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -757,6 +757,12 @@ </b-input> </b-field> + <b-field label="Gross Margin"> + <span class="control"> + {{ pendingProductGrossMargin }} + </span> + </b-field> + </b-field> <b-field label="Notes"> @@ -905,6 +911,52 @@ </div> </b-modal> + % if unknown_product_confirm_price: + <b-modal has-modal-card + :active.sync="confirmPriceShowDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Confirm Price</p> + </header> + + <section class="modal-card-body"> + <p class="block"> + Please confirm the price info before proceeding. + </p> + + <div style="white-space: nowrap;"> + + <b-field label="Unit Cost" horizontal> + <span>{{ pendingProduct.unit_cost }}</span> + </b-field> + + <b-field label="Unit Reg. Price" horizontal> + <span>{{ pendingProduct.regular_price_amount }}</span> + </b-field> + + <b-field label="Gross Margin" horizontal> + <span>{{ pendingProductGrossMargin }}</span> + </b-field> + + </div> + </section> + + <footer class="modal-card-foot"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="check" + @click="confirmPriceSave()"> + Confirm + </b-button> + <b-button @click="confirmPriceCancel()"> + Cancel + </b-button> + </footer> + </div> + </b-modal> + % endif + <tailbone-product-lookup ref="productLookup" @canceled="productLookupCanceled" @selected="productLookupSelected"> @@ -1242,6 +1294,9 @@ pendingProduct: {}, pendingProductRequiredFields: ${json.dumps(pending_product_required_fields)|n}, departmentOptions: ${json.dumps(department_options)|n}, + % if unknown_product_confirm_price: + confirmPriceShowDialog: false, + % endif submittingOrder: false, } @@ -1428,6 +1483,15 @@ % endif + pendingProductGrossMargin() { + let cost = this.pendingProduct.unit_cost + let price = this.pendingProduct.regular_price_amount + if (cost && price) { + let margin = (price - cost) / price + return (100 * margin).toFixed(2).toString() + " %" + } + }, + itemDialogSaveDisabled() { if (this.itemDialogSaving) { @@ -2116,7 +2180,7 @@ } }, - itemDialogSave() { + itemDialogAttemptSave() { this.itemDialogSaving = true let params = { @@ -2168,6 +2232,30 @@ this.itemDialogSaving = false }) }, + + itemDialogSave() { + + % if unknown_product_confirm_price: + if (!this.productIsKnown && !this.editingItem) { + this.showingItemDialog = false + this.confirmPriceShowDialog = true + return + } + % endif + + this.itemDialogAttemptSave() + }, + + confirmPriceCancel() { + this.confirmPriceShowDialog = false + this.showingItemDialog = true + }, + + confirmPriceSave() { + this.confirmPriceShowDialog = false + this.showingItemDialog = true + this.itemDialogAttemptSave() + }, }, } diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index cc02f682..c91ff4d2 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -375,6 +375,8 @@ class CustomerOrderView(MasterView): 'product_key_label': app.get_product_key_label(), 'allow_unknown_product': self.batch_handler.allow_unknown_product(), 'pending_product_required_fields': self.get_pending_product_required_fields(), + 'unknown_product_confirm_price': self.rattail_config.getbool( + 'rattail.custorders', 'unknown_product.always_confirm_price'), 'department_options': self.get_department_options(), 'default_uom_choices': self.batch_handler.uom_choices_for_product(None), 'default_uom': None, @@ -1109,6 +1111,9 @@ class CustomerOrderView(MasterView): {'section': 'rattail.custorders', 'option': 'allow_unknown_product', 'type': bool}, + {'section': 'rattail.custorders', + 'option': 'unknown_product.always_confirm_price', + 'type': bool}, ] for field in self.PENDING_PRODUCT_ENTRY_FIELDS: From 70cc754f3e871d0fff64f8cfaea8cb90fb4c266b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 25 Oct 2023 10:45:33 -0500 Subject: [PATCH 1289/1681] Use `<b-select>` for theme picker instead of webhelpers2.html.tags.select() which seems to break for me in dev now with python 3.10 --- tailbone/static/css/layout.css | 4 ---- tailbone/subscribers.py | 2 +- tailbone/templates/base.mako | 22 ++++++++++++++++------ 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/tailbone/static/css/layout.css b/tailbone/static/css/layout.css index bdf35410..20dbf6b7 100644 --- a/tailbone/static/css/layout.css +++ b/tailbone/static/css/layout.css @@ -57,10 +57,6 @@ header span.header-text { margin-right: 10px; } -header .level .theme-picker { - display: inline-flex; -} - #content-title h1 { margin-bottom: 0; margin-right: 1rem; diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index b724a4c5..1143b510 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -158,7 +158,7 @@ def before_render(event): default=['falafel']) if 'default' not in available: available.insert(0, 'default') - options = [tags.Option(theme) for theme in available] + options = [tags.Option(theme, value=theme) for theme in available] renderer_globals['theme_picker_options'] = options # heck while we're assuming the classic web app here... diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index 8558eeb7..53dc3423 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -392,13 +392,19 @@ % if expose_theme_picker and request.has_perm('common.change_app_theme'): <div class="level-item"> ${h.form(url('change_theme'), method="post", ref='themePickerForm')} - ${h.csrf_token(request)} - Theme: - <div class="theme-picker"> - <div class="select"> - ${h.select('theme', theme, theme_picker_options, **{'@change': 'changeTheme()'})} + ${h.csrf_token(request)} + <div style="display: flex; align-items: center; gap: 0.5rem;"> + <span>Theme:</span> + <b-select name="theme" + v-model="globalTheme" + @change="changeTheme()"> + % for option in theme_picker_options: + <option value="${option.value}"> + ${option.label} + </option> + % endfor + </b-select> </div> - </div> ${h.end_form()} </div> % endif @@ -840,6 +846,10 @@ contentTitleHTML: ${json.dumps(capture(self.content_title))|n}, feedbackMessage: "", + % if expose_theme_picker and request.has_perm('common.change_app_theme'): + globalTheme: ${json.dumps(theme)|n}, + % endif + % if can_edit_help: configureFieldsHelp: false, % endif From cf1ef2399626a46bf44efd6229a8427e4865304a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 25 Oct 2023 11:40:52 -0500 Subject: [PATCH 1290/1681] Add `column_only` kwarg for `Grid.set_label()` method pass True to affect only the column label and not the filter --- tailbone/grids/core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 5f28fca0..7a0d00e3 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -385,9 +385,9 @@ class Grid(object): def remove_filter(self, key): self.filters.pop(key, None) - def set_label(self, key, label): + def set_label(self, key, label, column_only=False): self.labels[key] = label - if key in self.filters: + if not column_only and key in self.filters: self.filters[key].label = label def get_label(self, key): From b5c68831b55d299f0d613626da2fed5fda791d09 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 25 Oct 2023 12:20:04 -0500 Subject: [PATCH 1291/1681] Do not show profile buttons for inactive customer shoppers --- tailbone/views/customers.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index 668f4a2b..0d4e3d7c 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -424,8 +424,9 @@ class CustomerView(MasterView): people.setdefault(person.uuid, person) for shopper in customer.shoppers: - person = shopper.person - people.setdefault(person.uuid, person) + if shopper.active: + person = shopper.person + people.setdefault(person.uuid, person) for person in customer.people: people.setdefault(person.uuid, person) From 441a6e5e0c00e3cbdc846648253a9442e3fa9483 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 25 Oct 2023 14:06:40 -0500 Subject: [PATCH 1292/1681] Add separate perm for making new custorder for unknown product --- tailbone/views/custorders/orders.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index c91ff4d2..f76d4d93 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -373,7 +373,8 @@ class CustomerOrderView(MasterView): 'allow_contact_info_create': self.batch_handler.allow_contact_info_creation(), 'order_items': items, 'product_key_label': app.get_product_key_label(), - 'allow_unknown_product': self.batch_handler.allow_unknown_product(), + 'allow_unknown_product': (self.batch_handler.allow_unknown_product() + and self.has_perm('create_unknown_product')), 'pending_product_required_fields': self.get_pending_product_required_fields(), 'unknown_product_confirm_price': self.rattail_config.getbool( 'rattail.custorders', 'unknown_product.always_confirm_price'), @@ -1143,8 +1144,15 @@ class CustomerOrderView(MasterView): route_prefix = cls.get_route_prefix() url_prefix = cls.get_url_prefix() model_title = cls.get_model_title() + model_title_plural = cls.get_model_title_plural() permission_prefix = cls.get_permission_prefix() + config.add_tailbone_permission_group(permission_prefix, model_title_plural, overwrite=False) + + config.add_tailbone_permission(permission_prefix, + f'{permission_prefix}.create_unknown_product', + f"Create new {model_title} for unknown product") + # add pseudo-index page for creating new custorder # (makes it available when building menus etc.) config.add_tailbone_index_page('{}.create'.format(route_prefix), From a8121814660665011404e970e19560139e24edda Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 25 Oct 2023 20:10:21 -0500 Subject: [PATCH 1293/1681] Expand the "product lookup" component to include autocomplete --- tailbone/templates/custorders/create.mako | 87 ++++++------- tailbone/templates/products/lookup.mako | 141 ++++++++++++++++++---- 2 files changed, 155 insertions(+), 73 deletions(-) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index f666790e..86a5e804 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -531,33 +531,10 @@ <p class="label control"> Product </p> - <b-field :expanded="!productUUID"> - <tailbone-autocomplete ref="productAutocomplete" - v-model="productUUID" - placeholder="Enter UPC or brand, description etc." - :assigned-label="productDisplay" - serviceUrl="${url('{}.product_autocomplete'.format(route_prefix))}" - @input="productChanged"> - </tailbone-autocomplete> - </b-field> - - <b-button type="is-primary" - v-if="!productUUID" - @click="productFullLookup()" - icon-pack="fas" - icon-left="search"> - Full Lookup - </b-button> - - <b-button v-if="productUUID" - type="is-primary" - tag="a" target="_blank" - :href="productURL" - :disabled="!productURL" - icon-pack="fas" - icon-left="external-link-alt"> - View Product - </b-button> + <tailbone-product-lookup ref="productLookup" + :selected-product="selectedProduct" + @selected="productLookupSelected"> + </tailbone-product-lookup> </b-field> <div v-if="productUUID"> @@ -565,7 +542,6 @@ <div class="is-pulled-right has-text-centered"> <img :src="productImageURL" style="max-height: 150px; max-width: 150px; "/> - ## <p>{{ productKey }}</p> </div> <b-field grouped> @@ -957,11 +933,6 @@ </b-modal> % endif - <tailbone-product-lookup ref="productLookup" - @canceled="productLookupCanceled" - @selected="productLookupSelected"> - </tailbone-product-lookup> - % if allow_past_item_reorder: <b-modal :active.sync="pastItemsShowDialog"> <div class="card"> @@ -1258,6 +1229,7 @@ pastItemsSelected: null, % endif productIsKnown: true, + selectedProduct: null, productUUID: null, productDisplay: null, productKey: null, @@ -1544,6 +1516,18 @@ this.$refs.contactAutocomplete.clearSelection() } }, + + productIsKnown(newval, oldval) { + // TODO: seems like this should be better somehow? + // e.g. maybe we should not be clearing *everything* + // in case user accidentally clicks, and then clicks + // "is known" again? and if we *should* clear all, + // why does that require 2 steps? + if (!newval) { + this.selectedProduct = null + this.clearProduct() + } + }, }, methods: { @@ -1894,20 +1878,12 @@ } }, - productFullLookup() { - this.showingItemDialog = false - let term = this.$refs.productAutocomplete.getUserInput() - this.$refs.productLookup.showDialog(term) - }, - - productLookupCanceled() { - this.showingItemDialog = true - }, - productLookupSelected(selected) { + // TODO: this still is a hack somehow, am sure of it. + // need to clean this up at some point + this.selectedProduct = selected this.clearProduct() - this.productChanged(selected.uuid) - this.showingItemDialog = true + this.productChanged(selected) }, copyPendingProductAttrs(from, to) { @@ -1930,6 +1906,7 @@ this.customerPanelOpen = false this.editingItem = null this.productIsKnown = true + this.selectedProduct = null this.productUUID = null this.productDisplay = null this.productKey = null @@ -1962,7 +1939,7 @@ this.itemDialogTabIndex = 0 this.showingItemDialog = true this.$nextTick(() => { - this.$refs.productAutocomplete.focus() + this.$refs.productLookup.focus() }) }, @@ -2027,6 +2004,16 @@ this.productIsKnown = !!row.product_uuid this.productUUID = row.product_uuid + if (row.product_uuid) { + this.selectedProduct = { + uuid: row.product_uuid, + full_description: row.product_full_description, + url: row.product_url, + } + } else { + this.selectedProduct = null + } + // nb. must construct new object before updating data // (otherwise vue does not notice the changes?) let pending = {} @@ -2131,11 +2118,11 @@ } }, - productChanged(uuid) { - if (uuid) { + productChanged(product) { + if (product) { let params = { action: 'get_product_info', - uuid: uuid, + uuid: product.uuid, } // nb. it is possible for the handler to "swap" // the product selection, i.e. user chooses a "per @@ -2144,6 +2131,8 @@ // received above is the correct one, but just use // whatever came back from handler this.submitBatchData(params, response => { + this.selectedProduct = response.data + this.productUUID = response.data.uuid this.productKey = response.data.key this.productDisplay = response.data.full_description diff --git a/tailbone/templates/products/lookup.mako b/tailbone/templates/products/lookup.mako index cdc4c565..42ee0742 100644 --- a/tailbone/templates/products/lookup.mako +++ b/tailbone/templates/products/lookup.mako @@ -2,8 +2,49 @@ <%def name="tailbone_product_lookup_template()"> <script type="text/x-template" id="tailbone-product-lookup-template"> - <div> - <b-modal :active.sync="showingDialog"> + <div style="width: 100%;"> + + <b-field grouped> + + <b-field :expanded="!selectedProduct"> + <b-autocomplete ref="productAutocomplete" + v-if="!selectedProduct" + v-model="autocompleteValue" + placeholder="Enter UPC or brand, description etc." + :data="autocompleteOptions" + field="value" + :custom-formatter="option => option.label" + @typing="getAutocompleteOptions" + @select="autocompleteSelected" + style="width: 100%;"> + </b-autocomplete> + <b-button v-if="selectedProduct" + @click="clearSelection(true)"> + {{ selectedProduct.full_description }} + </b-button> + </b-field> + + <b-button type="is-primary" + v-if="!selectedProduct" + @click="lookupInit()" + icon-pack="fas" + icon-left="search"> + Full Lookup + </b-button> + + <b-button v-if="selectedProduct" + type="is-primary" + tag="a" target="_blank" + :href="selectedProduct.url" + :disabled="!selectedProduct.url" + icon-pack="fas" + icon-left="external-link-alt"> + View Product + </b-button> + + </b-field> + + <b-modal :active.sync="lookupShowDialog"> <div class="card"> <div class="card-content"> @@ -157,6 +198,7 @@ </div> </div> </b-modal> + </div> </script> </%def> @@ -166,9 +208,17 @@ const TailboneProductLookup = { template: '#tailbone-product-lookup-template', + props: { + selectedProduct: { + type: Object, + }, + }, data() { return { - showingDialog: false, + autocompleteValue: '', + autocompleteOptions: [], + + lookupShowDialog: false, searchTerm: null, searchTermLastUsed: null, @@ -187,23 +237,67 @@ }, methods: { - showDialog(term) { + focus() { + if (!this.selectedProduct) { + this.$refs.productAutocomplete.focus() + } + }, + clearSelection(focus) { + + // clear data + this.autocompleteValue = '' + this.$emit('selected', null) + + // maybe set focus to our (autocomplete) component + if (focus) { + this.$nextTick(() => { + this.focus() + }) + } + }, + + getAutocompleteOptions: debounce(function (entry) { + + // since the `@typing` event from buefy component does not + // "self-regulate" in any way, we a) use `debounce` above, + // but also b) skip the search unless we have at least 3 + // characters of input from user + if (entry.length < 3) { + this.data = [] + return + } + + // and perform the search + let url = '${url(f'{route_prefix}.product_autocomplete')}' + this.$http.get(url + '?term=' + encodeURIComponent(entry)) + .then(({ data }) => { + this.autocompleteOptions = data + }).catch((error) => { + this.autocompleteOptions = [] + throw error + }) + }), + + autocompleteSelected(option) { + this.$emit('selected', { + uuid: option.value, + full_description: option.label, + }) + }, + + lookupInit() { this.searchResultSelected = null + this.lookupShowDialog = true - if (term !== undefined) { - this.searchTerm = term - // perform search if invoked with new term - if (term != this.searchTermLastUsed) { + this.$nextTick(() => { + + this.searchTerm = this.autocompleteValue + if (this.searchTerm != this.searchTermLastUsed) { this.searchTermLastUsed = null this.performSearch() } - } else { - this.searchTerm = this.searchTermLastUsed - } - this.showingDialog = true - this.$nextTick(() => { this.$refs.searchTermInput.focus() }) }, @@ -214,17 +308,6 @@ } }, - cancelDialog() { - this.searchResultSelected = null - this.showingDialog = false - this.$emit('canceled') - }, - - selectResult() { - this.showingDialog = false - this.$emit('selected', this.searchResultSelected) - }, - performSearch() { if (this.searchResultsLoading) { return @@ -255,6 +338,16 @@ this.searchResultsLoading = false }) }, + + selectResult() { + this.lookupShowDialog = false + this.$emit('selected', this.searchResultSelected) + }, + + cancelDialog() { + this.searchResultSelected = null + this.lookupShowDialog = false + }, }, } From 4809cf039e9925d64f19b75e6467cb8de1e74f72 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 25 Oct 2023 20:22:48 -0500 Subject: [PATCH 1294/1681] Update changelog --- CHANGES.rst | 22 ++++++++++++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 06db3d61..03c89807 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,28 @@ CHANGELOG ========= +0.9.71 (2023-10-25) +------------------- + +* Fix bug when editing vendor. + +* Show user warning if "add item to custorder" fails. + +* Allow pending product fields to be required, for new custorder. + +* Add price confirm prompt when adding unknown item to custorder. + +* Use ``<b-select>`` for theme picker. + +* Add ``column_only`` kwarg for ``Grid.set_label()`` method. + +* Do not show profile buttons for inactive customer shoppers. + +* Add separate perm for making new custorder for unknown product. + +* Expand the "product lookup" component to include autocomplete. + + 0.9.70 (2023-10-24) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index deda170c..4477c9fb 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.70' +__version__ = '0.9.71' From a5c1cba81bb68394f3b54d42a29da84d1fb25715 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 26 Oct 2023 10:06:00 -0500 Subject: [PATCH 1295/1681] Use product lookup component for "resolve pending product" tool --- tailbone/templates/custorders/create.mako | 5 +-- tailbone/templates/products/lookup.mako | 29 ++++++++++------- tailbone/templates/products/pending/view.mako | 31 +++++++++++++++---- 3 files changed, 45 insertions(+), 20 deletions(-) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index 86a5e804..399c1a6b 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -532,8 +532,9 @@ Product </p> <tailbone-product-lookup ref="productLookup" - :selected-product="selectedProduct" - @selected="productLookupSelected"> + :product="selectedProduct" + @selected="productLookupSelected" + autocomplete-url="${url(f'{route_prefix}.product_autocomplete')}"> </tailbone-product-lookup> </b-field> diff --git a/tailbone/templates/products/lookup.mako b/tailbone/templates/products/lookup.mako index 42ee0742..4e8c3a8b 100644 --- a/tailbone/templates/products/lookup.mako +++ b/tailbone/templates/products/lookup.mako @@ -6,9 +6,9 @@ <b-field grouped> - <b-field :expanded="!selectedProduct"> + <b-field :expanded="!product"> <b-autocomplete ref="productAutocomplete" - v-if="!selectedProduct" + v-if="!product" v-model="autocompleteValue" placeholder="Enter UPC or brand, description etc." :data="autocompleteOptions" @@ -18,25 +18,25 @@ @select="autocompleteSelected" style="width: 100%;"> </b-autocomplete> - <b-button v-if="selectedProduct" + <b-button v-if="product" @click="clearSelection(true)"> - {{ selectedProduct.full_description }} + {{ product.full_description }} </b-button> </b-field> <b-button type="is-primary" - v-if="!selectedProduct" + v-if="!product" @click="lookupInit()" icon-pack="fas" icon-left="search"> Full Lookup </b-button> - <b-button v-if="selectedProduct" + <b-button v-if="product" type="is-primary" tag="a" target="_blank" - :href="selectedProduct.url" - :disabled="!selectedProduct.url" + :href="product.url" + :disabled="!product.url" icon-pack="fas" icon-left="external-link-alt"> View Product @@ -209,9 +209,13 @@ const TailboneProductLookup = { template: '#tailbone-product-lookup-template', props: { - selectedProduct: { + product: { type: Object, }, + autocompleteUrl: { + type: String, + default: '${url('products.autocomplete')}', + }, }, data() { return { @@ -238,7 +242,7 @@ methods: { focus() { - if (!this.selectedProduct) { + if (!this.product) { this.$refs.productAutocomplete.focus() } }, @@ -269,8 +273,7 @@ } // and perform the search - let url = '${url(f'{route_prefix}.product_autocomplete')}' - this.$http.get(url + '?term=' + encodeURIComponent(entry)) + this.$http.get(this.autocompleteUrl + '?term=' + encodeURIComponent(entry)) .then(({ data }) => { this.autocompleteOptions = data }).catch((error) => { @@ -283,6 +286,8 @@ this.$emit('selected', { uuid: option.value, full_description: option.label, + url: option.url, + image_url: option.image_url, }) }, diff --git a/tailbone/templates/products/pending/view.mako b/tailbone/templates/products/pending/view.mako index 2b9852d9..e3740c71 100644 --- a/tailbone/templates/products/pending/view.mako +++ b/tailbone/templates/products/pending/view.mako @@ -1,5 +1,11 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> +<%namespace name="product_lookup" file="/products/lookup.mako" /> + +<%def name="render_this_page_template()"> + ${parent.render_this_page_template()} + ${product_lookup.tailbone_product_lookup_template()} +</%def> <%def name="object_helpers()"> ${parent.object_helpers()} @@ -43,12 +49,13 @@ <span>${instance.full_description}</span> </b-field> <b-field label="Actual Product" expanded> - <tailbone-autocomplete name="product_uuid" - v-model="resolveProductUUID" - ref="resolveProductAutocomplete" - service-url="${url('products.autocomplete')}"> - </tailbone-autocomplete> + <tailbone-product-lookup ref="productLookup" + autocomplete-url="${url('products.autocomplete_special', key='with_key')}" + :product="actualProduct" + @selected="productSelected"> + </tailbone-product-lookup> </b-field> + ${h.hidden('product_uuid', **{':value': 'resolveProductUUID'})} </section> <footer class="modal-card-foot"> @@ -91,7 +98,7 @@ this.resolveProductUUID = null this.resolveProductShowDialog = true this.$nextTick(() => { - this.$refs.resolveProductAutocomplete.focus() + this.$refs.productLookup.focus() }) } @@ -100,8 +107,20 @@ this.$refs.resolveProductForm.submit() } + ThisPageData.actualProduct = null + + ThisPage.methods.productSelected = function(product) { + this.actualProduct = product + this.resolveProductUUID = product ? product.uuid : null + } + </script> </%def> +<%def name="make_this_page_component()"> + ${parent.make_this_page_component()} + ${product_lookup.tailbone_product_lookup_component()} +</%def> + ${parent.body()} From 1fc17658ff74e4071f7f9ed0b302342ba490245e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 26 Oct 2023 18:44:38 -0500 Subject: [PATCH 1296/1681] Update changelog --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 03c89807..da77da7c 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.9.72 (2023-10-26) +------------------- + +* Use product lookup component for "resolve pending product" tool. + + 0.9.71 (2023-10-25) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 4477c9fb..e1fc06fd 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.71' +__version__ = '0.9.72' From fe4a178d43e6a112d6ca8a2fa20cf1930d79d28c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 26 Oct 2023 20:43:12 -0500 Subject: [PATCH 1297/1681] Add way to "ignore" a pending product and some related tweaks for sake of grid --- tailbone/grids/filters.py | 17 +++- tailbone/templates/products/pending/view.mako | 95 ++++++++++--------- tailbone/views/products.py | 87 +++++++++++++++-- 3 files changed, 143 insertions(+), 56 deletions(-) diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py index 61d29554..41a3c1fa 100644 --- a/tailbone/grids/filters.py +++ b/tailbone/grids/filters.py @@ -612,10 +612,11 @@ class AlchemyNumericFilter(AlchemyGridFilter): """ value_renderer_factory = NumericValueRenderer - # expose greater-than / less-than verbs in addition to core - default_verbs = ['equal', 'not_equal', 'greater_than', 'greater_equal', - 'less_than', 'less_equal', 'between', - 'is_null', 'is_not_null', 'is_any'] + def default_verbs(self): + # expose greater-than / less-than verbs in addition to core + return ['equal', 'not_equal', 'greater_than', 'greater_equal', + 'less_than', 'less_equal', 'between', + 'is_null', 'is_not_null', 'is_any'] # TODO: what follows "works" in that it prevents an error...but from the # user's perspective it still fails silently...need to improve on front-end @@ -670,6 +671,14 @@ class AlchemyIntegerFilter(AlchemyNumericFilter): """ bigint = False + def default_verbs(self): + + # limited verbs if choices are defined + if self.choices: + return ['equal', 'not_equal', 'is_null', 'is_not_null', 'is_any'] + + return super().default_verbs() + def value_invalid(self, value): if value: if isinstance(value, int): diff --git a/tailbone/templates/products/pending/view.mako b/tailbone/templates/products/pending/view.mako index e3740c71..765c8838 100644 --- a/tailbone/templates/products/pending/view.mako +++ b/tailbone/templates/products/pending/view.mako @@ -7,25 +7,16 @@ ${product_lookup.tailbone_product_lookup_template()} </%def> -<%def name="object_helpers()"> - ${parent.object_helpers()} - % if instance.status_code == enum.PENDING_PRODUCT_STATUS_PENDING and master.has_perm('resolve_product'): - <nav class="panel"> - <p class="panel-heading">Tools</p> - <div class="panel-block"> - <div style="display: flex; flex-direction: column;"> - <div class="buttons"> - <b-button type="is-primary" - @click="resolveProductInit()" - icon-pack="fas" - icon-left="object-ungroup"> - Resolve Product - </b-button> - </div> - </div> - </div> - </nav> +<%def name="page_content()"> + ${parent.page_content()} + % if master.has_perm('ignore_product') and instance.status_code in (enum.PENDING_PRODUCT_STATUS_PENDING, enum.PENDING_PRODUCT_STATUS_READY): + ${h.form(master.get_action_url('ignore_product', instance), ref='ignoreProductForm')} + ${h.csrf_token(request)} + ${h.end_form()} + % endif + + % if master.has_perm('resolve_product') and instance.status_code in (enum.PENDING_PRODUCT_STATUS_PENDING, enum.PENDING_PRODUCT_STATUS_READY, enum.PENDING_PRODUCT_STATUS_IGNORED): <b-modal has-modal-card :active.sync="resolveProductShowDialog"> <div class="modal-card"> @@ -80,39 +71,55 @@ ${parent.modify_this_page_vars()} <script type="text/javascript"> - ThisPageData.resolveProductShowDialog = false - ThisPageData.resolveProductUUID = null - ThisPageData.resolveProductSubmitting = false + % if master.has_perm('ignore_product') and instance.status_code in (enum.PENDING_PRODUCT_STATUS_PENDING, enum.PENDING_PRODUCT_STATUS_READY): - ThisPage.computed.resolveProductSubmitDisabled = function() { - if (this.resolveProductSubmitting) { - return true + ThisPage.methods.ignoreProductInit = function() { + if (!confirm("Really ignore this product?\n\n" + + "This will leave it unresolved, but hidden via default filters.")) { + return + } + this.$refs.ignoreProductForm.submit() } - if (!this.resolveProductUUID) { - return true + + % endif + + % if master.has_perm('resolve_product') and instance.status_code in (enum.PENDING_PRODUCT_STATUS_PENDING, enum.PENDING_PRODUCT_STATUS_READY, enum.PENDING_PRODUCT_STATUS_IGNORED): + + ThisPageData.resolveProductShowDialog = false + ThisPageData.resolveProductUUID = null + ThisPageData.resolveProductSubmitting = false + + ThisPage.computed.resolveProductSubmitDisabled = function() { + if (this.resolveProductSubmitting) { + return true + } + if (!this.resolveProductUUID) { + return true + } + return false } - return false - } - ThisPage.methods.resolveProductInit = function() { - this.resolveProductUUID = null - this.resolveProductShowDialog = true - this.$nextTick(() => { - this.$refs.productLookup.focus() - }) - } + ThisPage.methods.resolveProductInit = function() { + this.resolveProductUUID = null + this.resolveProductShowDialog = true + this.$nextTick(() => { + this.$refs.productLookup.focus() + }) + } - ThisPage.methods.resolveProductSubmit = function() { - this.resolveProductSubmitting = true - this.$refs.resolveProductForm.submit() - } + ThisPage.methods.resolveProductSubmit = function() { + this.resolveProductSubmitting = true + this.$refs.resolveProductForm.submit() + } - ThisPageData.actualProduct = null + ThisPageData.actualProduct = null - ThisPage.methods.productSelected = function(product) { - this.actualProduct = product - this.resolveProductUUID = product ? product.uuid : null - } + ThisPage.methods.productSelected = function(product) { + this.actualProduct = product + this.resolveProductUUID = product ? product.uuid : null + } + + % endif </script> </%def> diff --git a/tailbone/views/products.py b/tailbone/views/products.py index e9e32a21..16c65fdb 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -2297,16 +2297,22 @@ class PendingProductView(MasterView): def configure_grid(self, g): super().configure_grid(g) + model = self.model - g.set_enum('status_code', self.enum.PENDING_PRODUCT_STATUS) - g.filters['status_code'].default_active = True - g.filters['status_code'].default_verb = 'not_equal' - g.filters['status_code'].default_value = str(self.enum.PENDING_PRODUCT_STATUS_RESOLVED) - - g.set_sort_defaults('created', 'desc') - + # description g.set_link('description') + # status_code + g.set_enum('status_code', self.enum.PENDING_PRODUCT_STATUS) + g.set_filter('status_code', model.PendingProduct.status_code, + value_enum=self.enum.PENDING_PRODUCT_STATUS, + default_active=True, + default_verb='equal', + default_value=str(self.enum.PENDING_PRODUCT_STATUS_PENDING)) + + # created + g.set_sort_defaults('created', 'desc') + def configure_form(self, f): super().configure_form(f) model = self.model @@ -2398,8 +2404,20 @@ class PendingProductView(MasterView): if self.creating: f.remove('status_code') else: - # f.set_readonly('status_code') f.set_enum('status_code', self.enum.PENDING_PRODUCT_STATUS) + if self.viewing: + f.set_renderer('status_code', self.render_status_code) + + if (self.has_perm('ignore_product') + and pending.status_code in (self.enum.PENDING_PRODUCT_STATUS_PENDING, + self.enum.PENDING_PRODUCT_STATUS_READY)): + f.set_vuejs_component_kwargs(**{'@ignore-product': 'ignoreProductInit'}) + + if (self.has_perm('resolve_product') + and pending.status_code in (self.enum.PENDING_PRODUCT_STATUS_PENDING, + self.enum.PENDING_PRODUCT_STATUS_READY, + self.enum.PENDING_PRODUCT_STATUS_IGNORED)): + f.set_vuejs_component_kwargs(**{'@resolve-product': 'resolveProductInit'}) # user if self.creating: @@ -2415,6 +2433,42 @@ class PendingProductView(MasterView): if not pending.resolved: f.remove('resolved', 'resolved_by') + def render_status_code(self, pending, field): + status = pending.status_code + if not status: + return + + # will just show status text by default + text = self.enum.PENDING_PRODUCT_STATUS.get(status, str(status)) + html = text + + # but maybe also show buttons to change status + buttons = [] + + if (self.has_perm('ignore_product') + and status in (self.enum.PENDING_PRODUCT_STATUS_PENDING, + self.enum.PENDING_PRODUCT_STATUS_READY)): + buttons.append(self.make_buefy_button("Ignore Product", + type='is-warning', + icon_left='ban', + **{'@click': "$emit('ignore-product')"})) + + if (self.has_perm('resolve_product') + and status in (self.enum.PENDING_PRODUCT_STATUS_PENDING, + self.enum.PENDING_PRODUCT_STATUS_READY, + self.enum.PENDING_PRODUCT_STATUS_IGNORED)): + buttons.append(self.make_buefy_button("Resolve Product", + is_primary=True, + icon_left='object-ungroup', + **{'@click': "$emit('resolve-product')"})) + + if buttons: + text = HTML.tag('span', class_='control', c=[text]) + buttons = HTML.tag('div', class_='buttons', c=buttons) + html = HTML.tag('b-field', grouped='grouped', c=[text, buttons]) + + return html + def editable_instance(self, pending): if self.request.is_root: return True @@ -2487,6 +2541,12 @@ class PendingProductView(MasterView): def get_resolve_product_kwargs(self, **kwargs): return kwargs + def ignore_product(self): + model = self.model + pending = self.get_instance() + pending.status_code = self.enum.PENDING_PRODUCT_STATUS_IGNORED + return self.redirect(self.get_action_url('view', pending)) + def get_row_data(self, pending): model = self.model return self.Session.query(model.CustomerOrderItem)\ @@ -2554,6 +2614,17 @@ class PendingProductView(MasterView): route_name='{}.resolve_product'.format(route_prefix), permission='{}.resolve_product'.format(permission_prefix)) + # ignore product + config.add_tailbone_permission(permission_prefix, + f'{permission_prefix}.ignore_product', + f"Mark {model_title} as ignored") + config.add_route(f'{route_prefix}.ignore_product', + f'{instance_url_prefix}/ignore-product', + request_method='POST') + config.add_view(cls, attr='ignore_product', + route_name=f'{route_prefix}.ignore_product', + permission=f'{permission_prefix}.ignore_product') + def defaults(config, **kwargs): base = globals() From da13254caa1a181fb66fd5d1b21f4a414951203a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 29 Oct 2023 15:10:56 -0500 Subject: [PATCH 1298/1681] Tweak param docs for `Form.set_validator()` --- tailbone/forms/core.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 2c23b126..e04126a3 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -742,9 +742,8 @@ class Form(object): case the validator pertains to the form at large instead of one of the fields. - TODO: what should the validator look like? - - :param validator: Callable validator for the node. + :param validator: Callable which accepts ``(node, value)`` + args. """ self.validators[key] = validator From c1f2f84c7fe8f41a1983a8c9dc8dd55337c6ffcc Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 29 Oct 2023 15:46:18 -0500 Subject: [PATCH 1299/1681] Remove unused "simple menus" module approach now we always use a handler instead --- tailbone/menus.py | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/tailbone/menus.py b/tailbone/menus.py index 36189b88..50dd3f4a 100644 --- a/tailbone/menus.py +++ b/tailbone/menus.py @@ -29,7 +29,7 @@ import logging import warnings from rattail.app import GenericHandler -from rattail.util import import_module_path, prettify, simple_error +from rattail.util import prettify, simple_error from webhelpers2.html import tags, HTML @@ -70,19 +70,7 @@ class MenuHandler(GenericHandler): tags.link_to("Menu Config", request.route_url('configure_menus')))) request.session.flash(msg, 'warning') - # okay, no config, so menus must be built from code.. - - # first check for a "simple menus" module; use that if defined - menumod = self.config.get('tailbone', 'menus') - if menumod: - menumod = import_module_path(menumod) - if (not hasattr(menumod, 'simple_menus') - or not callable(menumod.simple_menus)): - raise RuntimeError("module does not have a simple_menus() " - "callable: {}".format(menumod)) - return menumod.simple_menus(request) - - # now we fallback to menu handler method + # okay, no config, so menus will be built from code return self.make_menus(request) def make_menus_from_config(self, request, **kwargs): From 8b072894528231876931e8d9e2d0c5cd8035d6a2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 29 Oct 2023 15:59:17 -0500 Subject: [PATCH 1300/1681] Update changelog --- CHANGES.rst | 10 ++++++++++ tailbone/_version.py | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index da77da7c..58539385 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.9.73 (2023-10-29) +------------------- + +* Add way to "ignore" a pending product. + +* Tweak param docs for ``Form.set_validator()``. + +* Remove unused "simple menus" module approach. + + 0.9.72 (2023-10-26) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index e1fc06fd..85ce4a36 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.72' +__version__ = '0.9.73' From a0075f6f78274dbd42226868a706b03efb242978 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 29 Oct 2023 22:22:16 -0500 Subject: [PATCH 1301/1681] Log warning / avoid error if email profile can't be normalized e.g. if some import error happens --- tailbone/views/email.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tailbone/views/email.py b/tailbone/views/email.py index 8d227a1e..22954782 100644 --- a/tailbone/views/email.py +++ b/tailbone/views/email.py @@ -24,6 +24,7 @@ Email Views """ +import logging import re import warnings @@ -41,6 +42,9 @@ from tailbone.db import Session from tailbone.views import View, MasterView +log = logging.getLogger(__name__) + + class EmailSettingView(MasterView): """ Master view for email admin (settings/preview). @@ -103,7 +107,13 @@ class EmailSettingView(MasterView): emails = self.email_handler.get_available_emails() for key, Email in emails.items(): email = Email(self.rattail_config, key) - data.append(self.normalize(email)) + try: + normalized = self.normalize(email) + except: + log.warning("cannot normalize email: %s", email, + exc_info=True) + else: + data.append(normalized) return data def configure_grid(self, g): From a9ab59eb9202cd9ed2e47e5a589bb914d971bfdf Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 30 Oct 2023 01:06:41 -0500 Subject: [PATCH 1302/1681] Update changelog --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 58539385..27908253 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.9.74 (2023-10-30) +------------------- + +* Log warning / avoid error if email profile can't be normalized. + + 0.9.73 (2023-10-29) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 85ce4a36..23ed7e0c 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.73' +__version__ = '0.9.74' From f47e45a928a7b4eb3a5a77fc3867bbab85519d78 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 1 Nov 2023 08:13:36 -0500 Subject: [PATCH 1303/1681] Add deprecation warnings for ambgiguous config keys --- tailbone/subscribers.py | 13 ++++++------- tailbone/util.py | 24 +++++++++++++++++++++--- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 1143b510..d05b8bd5 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -40,7 +40,7 @@ from tailbone import helpers from tailbone.db import Session from tailbone.config import csrf_header_name, should_expose_websockets from tailbone.menus import make_simple_menus -from tailbone.util import get_global_search_options +from tailbone.util import get_available_themes, get_global_search_options def new_request(event): @@ -152,12 +152,11 @@ def before_render(event): default=False) renderer_globals['expose_theme_picker'] = expose_picker if expose_picker: - # tailbone's config extension provides a default theme selection, - # so the default we specify here *probably* should not matter - available = request.rattail_config.getlist('tailbone', 'themes', - default=['falafel']) - if 'default' not in available: - available.insert(0, 'default') + + # TODO: should remove 'falafel' option altogether + available = get_available_themes(request.rattail_config, + include=['falafel']) + options = [tags.Option(theme, value=theme) for theme in available] renderer_globals['theme_picker_options'] = options diff --git a/tailbone/util.py b/tailbone/util.py index 4c9c680e..01efdce4 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -333,6 +333,26 @@ def get_theme_template_path(rattail_config, theme=None, session=None): return resource_path(theme_path) +def get_available_themes(rattail_config, include=None): + available = rattail_config.getlist('tailbone', 'themes.keys') + if not available: + available = rattail_config.getlist('tailbone', 'themes', + ignore_ambiguous=True) + if available: + warnings.warn(f"URGENT: instead of 'tailbone.themes', " + f"you should set 'tailbone.themes.keys'", + DeprecationWarning, stacklevel=2) + else: + available = [] + if 'default' not in available: + available.insert(0, 'default') + if include is not None: + for theme in include: + if theme not in available: + available.append(theme) + return available + + def get_effective_theme(rattail_config, theme=None, session=None): """ Validates and returns the "effective" theme. If you provide a theme, that @@ -350,9 +370,7 @@ def get_effective_theme(rattail_config, theme=None, session=None): session.close() # confirm requested theme is available - available = rattail_config.getlist('tailbone', 'themes', - default=['bobcat']) - available.append('default') + available = get_available_themes(rattail_config) if theme not in available: raise ValueError("theme not available: {}".format(theme)) From 7ac505f1f4627da301ff419a81f8c6b6de836f65 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 1 Nov 2023 08:14:09 -0500 Subject: [PATCH 1304/1681] Update changelog --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 27908253..cec422e1 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.9.75 (2023-11-01) +------------------- + +* Add deprecation warnings for ambgiguous config keys. + + 0.9.74 (2023-10-30) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 23ed7e0c..f03fa36c 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.74' +__version__ = '0.9.75' From 2f70ce2d5c2dff0f31b332540b4a689408d7657e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 1 Nov 2023 09:20:03 -0500 Subject: [PATCH 1305/1681] Fix missing import --- tailbone/util.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tailbone/util.py b/tailbone/util.py index 01efdce4..fdd36572 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -25,12 +25,12 @@ Utilities """ import datetime - -import pytz -import humanize import logging +import warnings +import humanize import markdown +import pytz from rattail.time import timezone, make_utc from rattail.files import resource_path From bae6bc213343181f40dcc9fc61e16c58da8c5214 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 1 Nov 2023 09:20:26 -0500 Subject: [PATCH 1306/1681] Update changelog --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index cec422e1..ed7e17a4 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.9.76 (2023-11-01) +------------------- + +* Fix missing import. + + 0.9.75 (2023-11-01) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index f03fa36c..00bb5697 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.75' +__version__ = '0.9.76' From 8522123cd3de9b6268364309e1b133313b7cdf8f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 1 Nov 2023 14:54:30 -0500 Subject: [PATCH 1307/1681] Encode values for "between" query filter --- tailbone/grids/filters.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py index 41a3c1fa..2585433e 100644 --- a/tailbone/grids/filters.py +++ b/tailbone/grids/filters.py @@ -447,12 +447,12 @@ class AlchemyGridFilter(GridFilter): if start_value: if self.value_invalid(start_value): return query - query = query.filter(self.column >= start_value) + query = query.filter(self.column >= self.encode_value(start_value)) if end_value: if self.value_invalid(end_value): return query - query = query.filter(self.column <= end_value) + query = query.filter(self.column <= self.encode_value(end_value)) return query From b5da5a46de71ae5f5d5a6ebb6f156eaca7af2b03 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 1 Nov 2023 17:47:07 -0500 Subject: [PATCH 1308/1681] Avoid error when rendering version diff can't always assume relationship entities are versioned --- tailbone/diffs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/diffs.py b/tailbone/diffs.py index 1c73635a..cdf35830 100644 --- a/tailbone/diffs.py +++ b/tailbone/diffs.py @@ -195,7 +195,7 @@ class VersionDiff(Diff): ref = getattr(version, prop.key) if ref: - ref = ref.version_parent + ref = getattr(ref, 'version_parent', None) if ref: return HTML.tag('span', c=[ text, From b231c194a4f5d23b556d79e0bafc17077aa94f1c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 1 Nov 2023 17:48:28 -0500 Subject: [PATCH 1309/1681] Update changelog --- CHANGES.rst | 8 ++++++++ tailbone/_version.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index ed7e17a4..85ec1448 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.9.77 (2023-11-01) +------------------- + +* Encode values for "between" query filter. + +* Avoid error when rendering version diff. + + 0.9.76 (2023-11-01) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 00bb5697..5c19859d 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.76' +__version__ = '0.9.77' From b13fc99e9583e2a541497264d8047cb4ddd26dd5 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 1 Nov 2023 19:43:46 -0500 Subject: [PATCH 1310/1681] Use shared logic to get batch handler --- tailbone/api/batch/core.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tailbone/api/batch/core.py b/tailbone/api/batch/core.py index c98e01f1..f7bc9333 100644 --- a/tailbone/api/batch/core.py +++ b/tailbone/api/batch/core.py @@ -66,9 +66,7 @@ class APIBatchMixin(object): """ app = self.get_rattail_app() key = self.get_batch_class().batch_key - spec = self.rattail_config.get('rattail.batch', '{}.handler'.format(key), - default=self.default_handler_spec) - return app.load_object(spec)(self.rattail_config) + return app.get_batch_handler(key, default=self.default_handler_spec) class APIBatchView(APIBatchMixin, APIMasterView): From 51d7c10bc5ddc3100403899bd448018407b58227 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 1 Nov 2023 19:44:44 -0500 Subject: [PATCH 1311/1681] Fix config key for default themes list --- tailbone/config.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tailbone/config.py b/tailbone/config.py index be8f2dc2..6106e87e 100644 --- a/tailbone/config.py +++ b/tailbone/config.py @@ -24,8 +24,6 @@ Rattail config extension for Tailbone """ -from __future__ import unicode_literals, absolute_import - import warnings from rattail.config import ConfigExtension as BaseExtension @@ -51,7 +49,7 @@ class ConfigExtension(BaseExtension): configure_session(config, Session) # provide default theme selection - config.setdefault('tailbone', 'themes', 'default, falafel') + config.setdefault('tailbone', 'themes.keys', 'default, falafel') config.setdefault('tailbone', 'themes.expose_picker', 'true') From 7ab3d2b635a94f925f979e0578c639baf3d846ee Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 1 Nov 2023 19:45:35 -0500 Subject: [PATCH 1312/1681] Update changelog --- CHANGES.rst | 8 ++++++++ tailbone/_version.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 85ec1448..25f12640 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.9.78 (2023-11-01) +------------------- + +* Use shared logic to get batch handler. + +* Fix config key for default themes list. + + 0.9.77 (2023-11-01) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 5c19859d..956a3695 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.77' +__version__ = '0.9.78' From 55a115e57aa162b3f98a0c91011fdb2c8cc09f2d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 1 Nov 2023 20:53:11 -0500 Subject: [PATCH 1313/1681] Add button to confirm all costs for receiving --- tailbone/templates/receiving/view.mako | 149 +++++++++++++++++-------- tailbone/views/purchasing/receiving.py | 54 +++++++++ 2 files changed, 156 insertions(+), 47 deletions(-) diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako index 30bfd3a9..d639ff24 100644 --- a/tailbone/templates/receiving/view.mako +++ b/tailbone/templates/receiving/view.mako @@ -36,57 +36,105 @@ % endif </%def> -<%def name="render_auto_receive_helper()"> - % if master.has_perm('auto_receive') and master.can_auto_receive(batch): +<%def name="render_tools_helper()"> + % if allow_confirm_all_costs or (master.has_perm('auto_receive') and master.can_auto_receive(batch)): <div class="object-helper"> <h3>Tools</h3> - <div class="object-helper-content"> - <b-button type="is-primary" - @click="autoReceiveShowDialog = true" - icon-pack="fas" - icon-left="check"> - Auto-Receive All Items - </b-button> + <div class="object-helper-content" + style="display: flex; flex-direction: column; gap: 1rem;"> + + % if allow_confirm_all_costs: + <b-button type="is-primary" + icon-pack="fas" + icon-left="check" + @click="confirmAllCostsShowDialog = true"> + Confirm All Costs + </b-button> + <b-modal has-modal-card + :active.sync="confirmAllCostsShowDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Confirm All Costs</p> + </header> + + <section class="modal-card-body"> + <p class="block"> + You can automatically mark all catalog and invoice + cost amounts as "confirmed" if you wish. + </p> + <p class="block"> + Would you like to do this? + </p> + </section> + + <footer class="modal-card-foot"> + <b-button @click="confirmAllCostsShowDialog = false"> + Cancel + </b-button> + ${h.form(url(f'{route_prefix}.confirm_all_costs', uuid=batch.uuid), **{'@submit': 'confirmAllCostsSubmitting = true'})} + ${h.csrf_token(request)} + <b-button type="is-primary" + native-type="submit" + :disabled="confirmAllCostsSubmitting" + icon-pack="fas" + icon-left="check"> + {{ confirmAllCostsSubmitting ? "Working, please wait..." : "Confirm All" }} + </b-button> + ${h.end_form()} + </footer> + </div> + </b-modal> + % endif + + % if master.has_perm('auto_receive') and master.can_auto_receive(batch): + <b-button type="is-primary" + @click="autoReceiveShowDialog = true" + icon-pack="fas" + icon-left="check"> + Auto-Receive All Items + </b-button> + <b-modal has-modal-card + :active.sync="autoReceiveShowDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Auto-Receive All Items</p> + </header> + + <section class="modal-card-body"> + <p class="block"> + You can automatically set the "received" quantity to + match the "shipped" quantity for all items, based on + the invoice. + </p> + <p class="block"> + Would you like to do so? + </p> + </section> + + <footer class="modal-card-foot"> + <b-button @click="autoReceiveShowDialog = false"> + Cancel + </b-button> + ${h.form(url('{}.auto_receive'.format(route_prefix), uuid=batch.uuid), **{'@submit': 'autoReceiveSubmitting = true'})} + ${h.csrf_token(request)} + <b-button type="is-primary" + native-type="submit" + :disabled="autoReceiveSubmitting" + icon-pack="fas" + icon-left="check"> + {{ autoReceiveSubmitting ? "Working, please wait..." : "Auto-Receive All Items" }} + </b-button> + ${h.end_form()} + </footer> + </div> + </b-modal> + % endif + </div> </div> - - <b-modal has-modal-card - :active.sync="autoReceiveShowDialog"> - <div class="modal-card"> - - <header class="modal-card-head"> - <p class="modal-card-title">Auto-Receive All Items</p> - </header> - - <section class="modal-card-body"> - <p class="block"> - You can automatically set the "received" quantity to - match the "shipped" quantity for all items, based on - the invoice. - </p> - <p class="block"> - Would you like to do so? - </p> - </section> - - <footer class="modal-card-foot"> - <b-button @click="autoReceiveShowDialog = false"> - Cancel - </b-button> - ${h.form(url('{}.auto_receive'.format(route_prefix), uuid=batch.uuid), **{'@submit': 'autoReceiveSubmitting = true'})} - ${h.csrf_token(request)} - <b-button type="is-primary" - native-type="submit" - :disabled="autoReceiveSubmitting" - icon-pack="fas" - icon-left="check"> - {{ autoReceiveSubmitting ? "Working, please wait..." : "Auto-Receive All Items" }} - </b-button> - ${h.end_form()} - </footer> - </div> - </b-modal> % endif </%def> @@ -117,13 +165,20 @@ ${self.render_status_breakdown()} ${self.render_po_vs_invoice_helper()} ${self.render_execute_helper()} - ${self.render_auto_receive_helper()} + ${self.render_tools_helper()} </%def> <%def name="modify_this_page_vars()"> ${parent.modify_this_page_vars()} <script type="text/javascript"> + % if allow_confirm_all_costs: + + ThisPageData.confirmAllCostsShowDialog = false + ThisPageData.confirmAllCostsSubmitting = false + + % endif + ThisPageData.autoReceiveShowDialog = false ThisPageData.autoReceiveSubmitting = false diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 9de4baa3..33f3cc53 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -786,6 +786,13 @@ class ReceivingBatchView(PurchasingBatchView): kwargs['allow_edit_catalog_unit_cost'] = self.allow_edit_catalog_unit_cost(batch) kwargs['allow_edit_invoice_unit_cost'] = self.allow_edit_invoice_unit_cost(batch) + if (kwargs['allow_edit_catalog_unit_cost'] + and kwargs['allow_edit_invoice_unit_cost'] + and not batch.get_param('confirmed_all_costs')): + kwargs['allow_confirm_all_costs'] = True + else: + kwargs['allow_confirm_all_costs'] = False + return kwargs def get_context_credits(self, row): @@ -1910,6 +1917,45 @@ class ReceivingBatchView(PurchasingBatchView): batch = self.get_instance() return self.handler_action(batch, 'auto_receive') + def confirm_all_costs(self): + """ + View which can "confirm all costs" for the batch. + """ + batch = self.get_instance() + return self.handler_action(batch, 'confirm_all_receiving_costs') + + def confirm_all_receiving_costs_thread(self, uuid, user_uuid, progress=None): + app = self.get_rattail_app() + model = self.model + session = app.make_session() + + batch = session.get(model.PurchaseBatch, uuid) + # user = session.query(model.User).get(user_uuid) + try: + self.handler.confirm_all_receiving_costs(batch, progress=progress) + + # if anything goes wrong, rollback and log the error etc. + except Exception as error: + session.rollback() + log.exception("failed to confirm all costs for batch: %s", batch) + session.close() + if progress: + progress.session.load() + progress.session['error'] = True + progress.session['error_msg'] = f"Failed to confirm costs: {simple_error(error)}" + progress.session.save() + + else: + session.commit() + session.refresh(batch) + success_url = self.get_action_url('view', batch) + session.close() + if progress: + progress.session.load() + progress.session['complete'] = True + progress.session['success_url'] = success_url + progress.session.save() + def configure_get_simple_settings(self): config = self.rattail_config return [ @@ -2034,6 +2080,14 @@ class ReceivingBatchView(PurchasingBatchView): config.add_view(cls, attr='transform_unit_row', route_name='{}.transform_unit_row'.format(route_prefix), permission='{}.edit_row'.format(permission_prefix), renderer='json') + # confirm all costs + config.add_route(f'{route_prefix}.confirm_all_costs', + f'{instance_url_prefix}/confirm-all-costs', + request_method='POST') + config.add_view(cls, attr='confirm_all_costs', + route_name=f'{route_prefix}.confirm_all_costs', + permission=f'{permission_prefix}.edit_row') + # auto-receive all items config.add_tailbone_permission(permission_prefix, '{}.auto_receive'.format(permission_prefix), From bbffe1dc822cce4dd798717cf285dc786a58b76c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 1 Nov 2023 20:54:39 -0500 Subject: [PATCH 1314/1681] Update changelog --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 25f12640..646f0d20 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.9.79 (2023-11-01) +------------------- + +* Add button to confirm all costs for receiving. + + 0.9.78 (2023-11-01) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 956a3695..9befc488 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.78' +__version__ = '0.9.79' From 9fa592c5d6e7f0d4ad0b247c41a2abd8b4dc6580 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 5 Nov 2023 16:57:14 -0600 Subject: [PATCH 1315/1681] Expose status code for equity payments --- tailbone/views/members.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tailbone/views/members.py b/tailbone/views/members.py index 3a4ff0a1..b1bb2a0d 100644 --- a/tailbone/views/members.py +++ b/tailbone/views/members.py @@ -423,6 +423,10 @@ class MemberEquityPaymentView(MasterView): supports_grid_totals = True has_versions = True + labels = { + 'status_code': "Status", + } + grid_columns = [ 'received', '_member_key_', @@ -431,6 +435,7 @@ class MemberEquityPaymentView(MasterView): 'description', 'source', 'transaction_identifier', + 'status_code', ] form_fields = [ @@ -441,6 +446,7 @@ class MemberEquityPaymentView(MasterView): 'description', 'source', 'transaction_identifier', + 'status_code', ] def query(self, session): @@ -482,6 +488,9 @@ class MemberEquityPaymentView(MasterView): g.set_link('transaction_identifier') + # status_code + g.set_enum('status_code', model.MemberEquityPayment.STATUS) + def render_member_key(self, payment, field): key = getattr(payment.member, field) return key @@ -531,6 +540,9 @@ class MemberEquityPaymentView(MasterView): else: f.set_readonly('received') + # status_code + f.set_enum('status_code', model.MemberEquityPayment.STATUS) + def defaults(config, **kwargs): base = globals() From 172fe6c49ca07be70a693ec1037929699c8c7dc5 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 5 Nov 2023 17:10:32 -0600 Subject: [PATCH 1316/1681] Update changelog --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 646f0d20..713a6fae 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.9.80 (2023-11-05) +------------------- + +* Expose status code for equity payments. + + 0.9.79 (2023-11-01) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 9befc488..6a8d8228 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.79' +__version__ = '0.9.80' From fc96fb40fbd283ac14c4929db6a643d544756eee Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 5 Nov 2023 18:31:43 -0600 Subject: [PATCH 1317/1681] Log warning instead of error for batch population error this is most typically caused by bad user input; a warning is shown on screen so they hopefully can guess what the problem is. no need to loop in the admins via email --- tailbone/views/batch/core.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index b9c28be7..f8b53d13 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -1057,7 +1057,8 @@ class BatchMasterView(MasterView): session.flush() except Exception as error: session.rollback() - log.exception("population failed for batch %s: %s", batch.uuid, batch) + log.warning("population failed for batch %s: %s", batch.uuid, batch, + exc_info=True) session.close() if progress: progress.session.load() From 853cc871f7f1c8e6e484cf7c3fea286d310b3fa8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 11 Nov 2023 21:26:11 -0600 Subject: [PATCH 1318/1681] Remove reference to `pytz` library --- tailbone/util.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tailbone/util.py b/tailbone/util.py index fdd36572..db6ce4a3 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -30,7 +30,6 @@ import warnings import humanize import markdown -import pytz from rattail.time import timezone, make_utc from rattail.files import resource_path @@ -207,10 +206,12 @@ def pretty_datetime(config, value): if not value: return '' + app = config.get_app() + # Make sure we're dealing with a tz-aware value. If we're given a naive # value, we assume it to be local to the UTC timezone. if not value.tzinfo: - value = pytz.utc.localize(value) + value = app.make_utc(value, tzinfo=True) # Calculate time diff using UTC. time_ago = datetime.datetime.utcnow() - make_utc(value) @@ -242,7 +243,7 @@ def raw_datetime(config, value, verbose=False, as_date=False): # Make sure we're dealing with a tz-aware value. If we're given a naive # value, we assume it to be local to the UTC timezone. if not value.tzinfo: - value = pytz.utc.localize(value) + value = app.make_utc(value, tzinfo=True) # Calculate time diff using UTC. time_ago = datetime.datetime.utcnow() - make_utc(value) From 97e7026cc95f0f73d0fa979e345abd7d50d61c15 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 15 Nov 2023 09:46:23 -0600 Subject: [PATCH 1319/1681] Avoid outright error if user scans barcode for inventory count --- tailbone/api/batch/inventory.py | 38 ++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/tailbone/api/batch/inventory.py b/tailbone/api/batch/inventory.py index 5e56fe46..22b67e54 100644 --- a/tailbone/api/batch/inventory.py +++ b/tailbone/api/batch/inventory.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,15 +24,12 @@ Tailbone Web API - Inventory Batches """ -from __future__ import unicode_literals, absolute_import - import decimal -import six +import sqlalchemy as sa from rattail import pod -from rattail.db import model -from rattail.util import pretty_quantity +from rattail.db.model import InventoryBatch, InventoryBatchRow from cornice import Service @@ -41,7 +38,7 @@ from tailbone.api.batch import APIBatchView, APIBatchRowView class InventoryBatchViews(APIBatchView): - model_class = model.InventoryBatch + model_class = InventoryBatch default_handler_spec = 'rattail.batch.inventory:InventoryBatchHandler' route_prefix = 'inventory' permission_prefix = 'batch.inventory' @@ -50,12 +47,12 @@ class InventoryBatchViews(APIBatchView): supports_toggle_complete = True def normalize(self, batch): - data = super(InventoryBatchViews, self).normalize(batch) + data = super().normalize(batch) data['mode'] = batch.mode data['mode_display'] = self.enum.INVENTORY_MODE.get(batch.mode) if data['mode_display'] is None and batch.mode is not None: - data['mode_display'] = six.text_type(batch.mode) + data['mode_display'] = str(batch.mode) data['reason_code'] = batch.reason_code @@ -119,7 +116,7 @@ class InventoryBatchViews(APIBatchView): class InventoryBatchRowViews(APIBatchRowView): - model_class = model.InventoryBatchRow + model_class = InventoryBatchRow default_handler_spec = 'rattail.batch.inventory:InventoryBatchHandler' route_prefix = 'inventory.rows' permission_prefix = 'batch.inventory' @@ -130,23 +127,24 @@ class InventoryBatchRowViews(APIBatchRowView): def normalize(self, row): batch = row.batch - data = super(InventoryBatchRowViews, self).normalize(row) + data = super().normalize(row) + app = self.get_rattail_app() data['item_id'] = row.item_id - data['upc'] = six.text_type(row.upc) + data['upc'] = str(row.upc) data['upc_pretty'] = row.upc.pretty() if row.upc else None data['brand_name'] = row.brand_name data['description'] = row.description data['size'] = row.size data['full_description'] = row.product.full_description if row.product else row.description data['image_url'] = pod.get_image_url(self.rattail_config, row.upc) if row.upc else None - data['case_quantity'] = pretty_quantity(row.case_quantity or 1) + data['case_quantity'] = app.render_quantity(row.case_quantity or 1) data['cases'] = row.cases data['units'] = row.units data['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA' data['quantity_display'] = "{} {}".format( - pretty_quantity(row.cases or row.units), + app.render_quantity(row.cases or row.units), 'CS' if row.cases else data['unit_uom']) data['allow_cases'] = self.batch_handler.allow_cases(batch) @@ -174,7 +172,17 @@ class InventoryBatchRowViews(APIBatchRowView): data['units'] = decimal.Decimal(data['units']) # update row per usual - row = super(InventoryBatchRowViews, self).update_object(row, data) + try: + row = super().update_object(row, data) + except sa.exc.DataError as error: + # detect when user scans barcode for cases/units field + if hasattr(error, 'orig'): + orig = type(error.orig) + if hasattr(orig, '__name__'): + # nb. this particular error is from psycopg2 + if orig.__name__ == 'NumericValueOutOfRange': + return {'error': "Numeric value out of range"} + raise return row From dd9e41f6512b1e522f0afd98fb38c8791d856baf Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 15 Nov 2023 11:42:07 -0600 Subject: [PATCH 1320/1681] Update changelog --- CHANGES.rst | 10 ++++++++++ tailbone/_version.py | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 713a6fae..0ef20867 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.9.81 (2023-11-15) +------------------- + +* Log warning instead of error for batch population error. + +* Remove reference to ``pytz`` library. + +* Avoid outright error if user scans barcode for inventory count. + + 0.9.80 (2023-11-05) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 6a8d8228..a08bcc20 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.80' +__version__ = '0.9.81' From e39581695f058d84cc5c082a5796d646f96de1fa Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 17 Nov 2023 17:00:50 -0600 Subject: [PATCH 1321/1681] Fix DB picker, theme picker per Buefy conventions --- tailbone/templates/base.mako | 14 ++++++++++---- tailbone/views/master.py | 2 +- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index 53dc3423..2a42af0b 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -339,9 +339,15 @@ ${h.form(url('change_db_engine'), ref='dbPickerForm')} ${h.csrf_token(request)} ${h.hidden('engine_type', value=master.engine_type_key)} - <div class="select"> - ${h.select('dbkey', db_picker_selected, db_picker_options, **{'@change': 'changeDB()'})} - </div> + <b-select name="dbkey" + value="${db_picker_selected}" + @input="changeDB()"> + % for option in db_picker_options: + <option value="${option.value}"> + ${option.label} + </option> + % endfor + </b-select> ${h.end_form()} </div> % endif @@ -397,7 +403,7 @@ <span>Theme:</span> <b-select name="theme" v-model="globalTheme" - @change="changeTheme()"> + @input="changeTheme()"> % for option in theme_picker_options: <option value="${option.value}"> ${option.label} diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 7a1eff98..cf001c36 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2783,7 +2783,7 @@ class MasterView(View): # would therefore share the "current" engine) selected = self.get_current_engine_dbkey() kwargs['expose_db_picker'] = True - kwargs['db_picker_options'] = [tags.Option(k) for k in engines] + kwargs['db_picker_options'] = [tags.Option(k, value=k) for k in engines] kwargs['db_picker_selected'] = selected # add info for downloadable input file templates, if any From e23998a88b14f917786e15a4688a00def033422e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 19 Nov 2023 22:24:15 -0600 Subject: [PATCH 1322/1681] Update changelog --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 0ef20867..16a0ed5f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.9.82 (2023-11-19) +------------------- + +* Fix DB picker, theme picker per Buefy conventions. + + 0.9.81 (2023-11-15) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index a08bcc20..ac5e3bac 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.81' +__version__ = '0.9.82' From f4cb1cb0976dbc6093f74ecb57f319ccf1e137c6 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 29 Nov 2023 15:03:08 -0600 Subject: [PATCH 1323/1681] Avoid error when editing a department just a temp hack, need to fix proper yet --- tailbone/views/departments.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tailbone/views/departments.py b/tailbone/views/departments.py index 8115c5c3..3d462b16 100644 --- a/tailbone/views/departments.py +++ b/tailbone/views/departments.py @@ -24,7 +24,7 @@ Department Views """ -from rattail.db import model +from rattail.db.model import Department, Product from webhelpers2.html import HTML @@ -35,7 +35,7 @@ class DepartmentView(MasterView): """ Master view for the Department class. """ - model_class = model.Department + model_class = Department touchable = True has_versions = True results_downloadable = True @@ -64,7 +64,7 @@ class DepartmentView(MasterView): ] has_rows = True - model_row_class = model.Product + model_row_class = Product rows_title = "Products" row_labels = { @@ -111,6 +111,8 @@ class DepartmentView(MasterView): # tax f.set_renderer('tax', self.render_tax) + # TODO: make this editable + f.set_readonly('tax') def render_employees(self, department, field): route_prefix = self.get_route_prefix() @@ -160,6 +162,7 @@ class DepartmentView(MasterView): Check to see if there are any products which belong to the department; if there are then we do not allow delete and redirect the user. """ + model = self.model count = self.Session.query(model.Product)\ .filter(model.Product.department == department)\ .count() @@ -169,6 +172,7 @@ class DepartmentView(MasterView): raise self.redirect(self.get_action_url('view', department)) def get_row_data(self, department): + model = self.model return self.Session.query(model.Product)\ .filter(model.Product.department == department) @@ -198,6 +202,7 @@ class DepartmentView(MasterView): """ View list of departments by vendor """ + model = self.model data = self.Session.query(model.Department)\ .outerjoin(model.Product)\ .join(model.ProductCost)\ From 2a9d5f74ce73afafa2b3afb72999f21cba7c7244 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 30 Nov 2023 15:17:01 -0600 Subject: [PATCH 1324/1681] Update changelog --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 16a0ed5f..cd356554 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.9.83 (2023-11-30) +------------------- + +* Avoid error when editing a department. + + 0.9.82 (2023-11-19) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index ac5e3bac..31167701 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.82' +__version__ = '0.9.83' From 35131c87326edc09d84772d04caeb12d90a52dd1 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 30 Nov 2023 18:23:47 -0600 Subject: [PATCH 1325/1681] Provide a way to show enum display text for some version diff fields master view must explicitly declare which enums for which fields --- docs/api/diffs.rst | 6 +++ docs/api/views/master.rst | 14 ++++++ docs/api/views/members.rst | 6 +++ docs/index.rst | 2 + tailbone/diffs.py | 94 ++++++++++++++++++++++++++++---------- tailbone/views/master.py | 49 +++++++++++++++++++- tailbone/views/members.py | 27 ++++++++++- 7 files changed, 172 insertions(+), 26 deletions(-) create mode 100644 docs/api/diffs.rst create mode 100644 docs/api/views/members.rst diff --git a/docs/api/diffs.rst b/docs/api/diffs.rst new file mode 100644 index 00000000..fb1bba71 --- /dev/null +++ b/docs/api/diffs.rst @@ -0,0 +1,6 @@ + +``tailbone.diffs`` +================== + +.. automodule:: tailbone.diffs + :members: diff --git a/docs/api/views/master.rst b/docs/api/views/master.rst index 44278e0a..e7de7170 100644 --- a/docs/api/views/master.rst +++ b/docs/api/views/master.rst @@ -81,6 +81,12 @@ override when defining your subclass. override this for certain views, if so that should be done within :meth:`get_help_url()`. + .. attribute:: MasterView.version_diff_factory + + Optional factory to use for version diff objects. By default + this is *not set* but a subclass is free to set it. See also + :meth:`get_version_diff_factory()`. + Methods to Override ------------------- @@ -100,6 +106,14 @@ subclass. .. automethod:: MasterView.get_model_key + .. automethod:: MasterView.get_version_diff_enums + + .. automethod:: MasterView.get_version_diff_factory + + .. automethod:: MasterView.make_version_diff + + .. automethod:: MasterView.title_for_version + Support Methods --------------- diff --git a/docs/api/views/members.rst b/docs/api/views/members.rst new file mode 100644 index 00000000..6a9e9168 --- /dev/null +++ b/docs/api/views/members.rst @@ -0,0 +1,6 @@ + +``tailbone.views.members`` +========================== + +.. automodule:: tailbone.views.members + :members: diff --git a/docs/index.rst b/docs/index.rst index b19d859f..4aa22f3e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -44,6 +44,7 @@ Package API: api/api/batch/core api/api/batch/ordering + api/diffs api/forms api/grids api/grids.core @@ -53,6 +54,7 @@ Package API: api/views/batch.vendorcatalog api/views/core api/views/master + api/views/members api/views/purchasing.batch api/views/purchasing.ordering diff --git a/tailbone/diffs.py b/tailbone/diffs.py index cdf35830..98253c57 100644 --- a/tailbone/diffs.py +++ b/tailbone/diffs.py @@ -34,35 +34,38 @@ from webhelpers2.html import HTML class Diff(object): """ Core diff class. In sore need of documentation. + + You must provide the old and new data sets, and the set of + relevant fields as well, if they cannot be easily introspected. + + :param old_data: Dict of "old" data values. + + :param new_data: Dict of "old" data values. + + :param fields: Sequence of relevant field names. Note that + both data dicts are expected to have keys which match these + field names. If you do not specify the fields then they + will (hopefully) be introspected from the old or new data + sets; however this will not work if they are both empty. + + :param monospace: If true, this flag will cause the value + columns to be rendered in monospace font. This is assumed + to be helpful when comparing "raw" data values which are + shown as e.g. ``repr(val)``. + + :param enums: Optional dict of enums for use when displaying field + values. If specified, keys should be field names and values + should be enum dicts. """ - def __init__(self, old_data, new_data, columns=None, fields=None, + def __init__(self, old_data, new_data, columns=None, fields=None, enums=None, render_field=None, render_value=None, nature='dirty', monospace=False, extra_row_attrs=None): - """ - Constructor. You must provide the old and new data sets, and - the set of relevant fields as well, if they cannot be easily - introspected. - - :param old_data: Dict of "old" data values. - - :param new_data: Dict of "old" data values. - - :param fields: Sequence of relevant field names. Note that - both data dicts are expected to have keys which match these - field names. If you do not specify the fields then they - will (hopefully) be introspected from the old or new data - sets; however this will not work if they are both empty. - - :param monospace: If true, this flag will cause the value - columns to be rendered in monospace font. This is assumed - to be helpful when comparing "raw" data values which are - shown as e.g. ``repr(val)``. - """ self.old_data = old_data self.new_data = new_data self.columns = columns or ["field name", "old value", "new value"] self.fields = fields or self.make_fields() + self.enums = enums or {} self._render_field = render_field or self.render_field_default self.render_value = render_value or self.render_value_default self.nature = nature @@ -92,7 +95,7 @@ class Diff(object): for the given field. May be an empty string, or a snippet of HTML attribute syntax, e.g.: - .. code-highlight:: none + .. code-block:: none class="diff" foo="bar" @@ -132,7 +135,21 @@ class Diff(object): class VersionDiff(Diff): """ - Special diff class, for use with version history views + Special diff class, for use with version history views. Note that + while based on :class:`Diff`, this class uses a different + signature for the constructor. + + :param version: Reference to a Continuum version record (object). + + :param \*args: Typical usage will not require positional args + beyond the ``version`` param, in which case ``old_data`` and + ``new_data`` params will be auto-determined based on the + ``version``. But if you specify positional args then nothing + automatic is done, they are passed as-is to the parent + :class:`Diff` constructor. + + :param \*\*kwargs: Remaining kwargs are passed as-is to the + :class:`Diff` constructor. """ def __init__(self, version, *args, **kwargs): @@ -176,9 +193,40 @@ class VersionDiff(Diff): if field not in unwanted] def render_version_value(self, field, value, version): + """ + Render the cell value text for the given version/field info. + + Note that this method is used to render both sides of the diff + (before and after values). + + :param field: Name of the field, as string. + + :param value: Raw value for the field, as obtained from ``version``. + + :param version: Reference to the Continuum version object. + + :returns: Rendered text as string, or ``None``. + """ text = HTML.tag('span', c=[repr(value)], style='font-family: monospace;') + # assume the enum display is all we need, if enum exists for the field + if field in self.enums: + + # but skip the enum display if None + display = self.enums[field].get(value) + if display is None and value is None: + return text + + # otherwise show enum display to the right of raw value + display = self.enums[field].get(value, str(value)) + return HTML.tag('span', c=[ + text, + HTML.tag('span', c=[display], + style='margin-left: 2rem; font-style: italic; font-weight: bold;'), + ]) + + # next we look for a relationship and may render the foreign object for prop in self.mapper.relationships: if prop.uselist: continue diff --git a/tailbone/views/master.py b/tailbone/views/master.py index cf001c36..cc2adcaf 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -597,7 +597,6 @@ class MasterView(View): return defaults def configure_row_grid(self, grid): - # super(MasterView, self).configure_row_grid(grid) self.set_row_labels(grid) self.configure_column_customer_key(grid) @@ -1528,6 +1527,15 @@ class MasterView(View): }) def title_for_version(self, version): + """ + Must return the title text for the given version. By default + this will be the :term:`rattail:model title` for the version's + data class. + + :param version: Reference to a Continuum version object. + + :returns: Title text for the version, as string. + """ cls = continuum.parent_class(version.__class__) return cls.get_model_title() @@ -4962,13 +4970,52 @@ class MasterView(View): return diffs.Diff(old_data, new_data, **kwargs) def get_version_diff_factory(self, **kwargs): + """ + Must return the factory to be used when creating version diff + objects. + + By default this returns the + :class:`tailbone.diffs.VersionDiff` class, unless + :attr:`version_diff_factory` is set, in which case that is + returned as-is. + + :returns: A factory which can produce + :class:`~tailbone.diffs.VersionDiff` objects. + """ if hasattr(self, 'version_diff_factory'): return self.version_diff_factory return diffs.VersionDiff + def get_version_diff_enums(self, version): + """ + This can optionally return a dict of field enums, to be passed + to the version diff factory. This method is called as part of + :meth:`make_version_diff()`. + """ + def make_version_diff(self, version, *args, **kwargs): + """ + Make a version diff object, using the factory returned by + :meth:`get_version_diff_factory()`. + + :param version: Reference to a Continuum version object. + + :param title: If specified, must be as a kwarg. Optional + override for the version title text. If not specified, + :meth:`title_for_version()` is called for the title. + + :param \*args: Additional args to pass to the factory. + + :param \*\*kwargs: Additional kwargs to pass to the factory. + + :returns: A :class:`~tailbone.diffs.VersionDiff` object. + """ if 'title' not in kwargs: kwargs['title'] = self.title_for_version(version) + + if 'enums' not in kwargs: + kwargs['enums'] = self.get_version_diff_enums(version) + factory = self.get_version_diff_factory() return factory(version, *args, **kwargs) diff --git a/tailbone/views/members.py b/tailbone/views/members.py index b1bb2a0d..de844eb7 100644 --- a/tailbone/views/members.py +++ b/tailbone/views/members.py @@ -27,6 +27,7 @@ Member Views from collections import OrderedDict import sqlalchemy as sa +import sqlalchemy_continuum as continuum from rattail.db import model from rattail.db.model import MembershipType, Member, MemberEquityPayment @@ -71,6 +72,7 @@ class MembershipTypeView(MasterView): ] def configure_grid(self, g): + """ """ super().configure_grid(g) g.set_sort_defaults('number') @@ -79,6 +81,7 @@ class MembershipTypeView(MasterView): g.set_link('name') def get_row_data(self, memtype): + """ """ model = self.model return self.Session.query(model.Member)\ .filter(model.Member.membership_type == memtype) @@ -102,7 +105,7 @@ class MemberView(MasterView): """ Master view for the Member class. """ - model_class = model.Member + model_class = Member is_contact = True touchable = True has_versions = True @@ -169,6 +172,7 @@ class MemberView(MasterView): return app.get_people_handler().get_quickie_search_placeholder() def configure_grid(self, g): + """ """ super().configure_grid(g) route_prefix = self.get_route_prefix() model = self.model @@ -263,13 +267,16 @@ class MemberView(MasterView): default=False) def grid_extra_class(self, member, i): + """ """ if not member.active: return 'warning' if member.equity_current is False: return 'notice' def configure_form(self, f): + """ """ super().configure_form(f) + model = self.model member = f.model_instance # date fields @@ -342,6 +349,7 @@ class MemberView(MasterView): return app.render_currency(total) def template_kwargs_view(self, **kwargs): + """ """ kwargs = super().template_kwargs_view(**kwargs) app = self.get_rattail_app() member = kwargs['instance'] @@ -360,10 +368,12 @@ class MemberView(MasterView): return kwargs def render_default_email(self, member, field): + """ """ if member.emails: return member.emails[0].address def render_default_phone(self, member, field): + """ """ if member.phones: return member.phones[0].number @@ -376,6 +386,7 @@ class MemberView(MasterView): return tags.link_to(text, url) def get_row_data(self, member): + """ """ model = self.model return self.Session.query(model.MemberEquityPayment)\ .filter(model.MemberEquityPayment.member == member) @@ -395,6 +406,7 @@ class MemberView(MasterView): uuid=payment.uuid) def configure_get_simple_settings(self): + """ """ return [ # General @@ -417,7 +429,7 @@ class MemberEquityPaymentView(MasterView): """ Master view for the MemberEquityPayment class. """ - model_class = model.MemberEquityPayment + model_class = MemberEquityPayment route_prefix = 'member_equity_payments' url_prefix = '/member-equity-payments' supports_grid_totals = True @@ -450,6 +462,7 @@ class MemberEquityPaymentView(MasterView): ] def query(self, session): + """ """ query = super().query(session) model = self.model @@ -458,6 +471,7 @@ class MemberEquityPaymentView(MasterView): return query def configure_grid(self, g): + """ """ super().configure_grid(g) model = self.model @@ -502,6 +516,7 @@ class MemberEquityPaymentView(MasterView): return {'totals_display': app.render_currency(total)} def configure_form(self, f): + """ """ super().configure_form(f) model = self.model payment = f.model_instance @@ -543,6 +558,14 @@ class MemberEquityPaymentView(MasterView): # status_code f.set_enum('status_code', model.MemberEquityPayment.STATUS) + def get_version_diff_enums(self, version): + """ """ + model = self.model + cls = continuum.parent_class(version.__class__) + + if cls is model.MemberEquityPayment: + return {'status_code': model.MemberEquityPayment.STATUS} + def defaults(config, **kwargs): base = globals() From faeb2cb7e29a9b70b5a2b28009a3aa75d14a7d01 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 30 Nov 2023 18:25:01 -0600 Subject: [PATCH 1326/1681] Update changelog --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index cd356554..45e5cc1b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.9.84 (2023-11-30) +------------------- + +* Provide a way to show enum display text for some version diff fields. + + 0.9.83 (2023-11-30) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 31167701..a44d3ed3 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.83' +__version__ = '0.9.84' From 3e4bbf7092fa0937d32ba5e8659a99a0a6d45242 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 1 Dec 2023 19:50:07 -0600 Subject: [PATCH 1327/1681] Use clientele handler to populate customer dropdown widget --- docs/api/forms.widgets.rst | 6 +++++ docs/index.rst | 1 + tailbone/forms/widgets.py | 52 +++++++++++++++++++++++++------------- 3 files changed, 41 insertions(+), 18 deletions(-) create mode 100644 docs/api/forms.widgets.rst diff --git a/docs/api/forms.widgets.rst b/docs/api/forms.widgets.rst new file mode 100644 index 00000000..33316903 --- /dev/null +++ b/docs/api/forms.widgets.rst @@ -0,0 +1,6 @@ + +``tailbone.forms.widgets`` +========================== + +.. automodule:: tailbone.forms.widgets + :members: diff --git a/docs/index.rst b/docs/index.rst index 4aa22f3e..351e910d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -46,6 +46,7 @@ Package API: api/api/batch/ordering api/diffs api/forms + api/forms.widgets api/grids api/grids.core api/progress diff --git a/tailbone/forms/widgets.py b/tailbone/forms/widgets.py index 0b8d3dc9..db57f4f0 100644 --- a/tailbone/forms/widgets.py +++ b/tailbone/forms/widgets.py @@ -40,6 +40,7 @@ class ReadonlyWidget(dfwidget.HiddenWidget): readonly = True def serialize(self, field, cstruct, **kw): + """ """ if cstruct in (colander.null, None): cstruct = '' # TODO: is this hacky? @@ -77,15 +78,17 @@ class PercentInputWidget(dfwidget.TextInputWidget): autocomplete = 'off' def serialize(self, field, cstruct, **kw): + """ """ if cstruct not in (colander.null, None): # convert "traditional" value to "human-friendly" value = decimal.Decimal(cstruct) * 100 value = value.quantize(decimal.Decimal('0.001')) cstruct = str(value) - return super(PercentInputWidget, self).serialize(field, cstruct, **kw) + return super().serialize(field, cstruct, **kw) def deserialize(self, field, pstruct): - pstruct = super(PercentInputWidget, self).deserialize(field, pstruct) + """ """ + pstruct = super().deserialize(field, pstruct) if pstruct is colander.null: return colander.null # convert "human-friendly" value to "traditional" @@ -108,6 +111,7 @@ class CasesUnitsWidget(dfwidget.Widget): one_amount_only = False def serialize(self, field, cstruct, **kw): + """ """ if cstruct in (colander.null, None): cstruct = '' readonly = kw.get('readonly', self.readonly) @@ -118,6 +122,7 @@ class CasesUnitsWidget(dfwidget.Widget): return field.renderer(template, **values) def deserialize(self, field, pstruct): + """ """ from tailbone.forms.types import ProductQuantity if pstruct is colander.null: @@ -166,7 +171,7 @@ class CustomSelectWidget(dfwidget.SelectWidget): self.extra_template_values.update(kw) def get_template_values(self, field, cstruct, kw): - values = super(CustomSelectWidget, self).get_template_values(field, cstruct, kw) + values = super().get_template_values(field, cstruct, kw) if hasattr(self, 'extra_template_values'): values.update(self.extra_template_values) return values @@ -209,6 +214,7 @@ class JQueryDateWidget(dfwidget.DateInputWidget): ) def serialize(self, field, cstruct, **kw): + """ """ if cstruct in (colander.null, None): cstruct = '' readonly = kw.get('readonly', self.readonly) @@ -243,12 +249,14 @@ class FalafelDateTimeWidget(dfwidget.DateTimeInputWidget): template = 'datetime_falafel' def serialize(self, field, cstruct, **kw): + """ """ readonly = kw.get('readonly', self.readonly) values = self.get_template_values(field, cstruct, kw) template = self.readonly_template if readonly else self.template return field.renderer(template, **values) def deserialize(self, field, pstruct): + """ """ if pstruct == '': return colander.null return pstruct @@ -261,6 +269,7 @@ class FalafelTimeWidget(dfwidget.TimeInputWidget): template = 'time_falafel' def deserialize(self, field, pstruct): + """ """ if pstruct == '': return colander.null return pstruct @@ -288,6 +297,7 @@ class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget): options = None def serialize(self, field, cstruct, **kw): + """ """ if 'delay' in kw or getattr(self, 'delay', None): raise ValueError( 'AutocompleteWidget does not support *delay* parameter ' @@ -324,6 +334,7 @@ class MultiFileUploadWidget(dfwidget.FileUploadWidget): requirements = () def serialize(self, field, cstruct, **kw): + """ """ if cstruct in (colander.null, None): cstruct = [] @@ -339,6 +350,7 @@ class MultiFileUploadWidget(dfwidget.FileUploadWidget): return field.renderer(template, **values) def deserialize(self, field, pstruct): + """ """ if pstruct is colander.null: return colander.null @@ -359,6 +371,7 @@ class MultiFileUploadWidget(dfwidget.FileUploadWidget): return files_data def deserialize_upload(self, upload): + """ """ # nb. this logic was copied from parent class and adapted # to allow for multiple files. needs some more love. @@ -428,11 +441,13 @@ def make_customer_widget(request, **kwargs): class CustomerAutocompleteWidget(JQueryAutocompleteWidget): """ - Autocomplete widget for a Customer reference field. + Autocomplete widget for a + :class:`~rattail:rattail.db.model.customers.Customer` reference + field. """ def __init__(self, request, *args, **kwargs): - super(CustomerAutocompleteWidget, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.request = request model = self.request.rattail_config.get_model() @@ -452,7 +467,7 @@ class CustomerAutocompleteWidget(JQueryAutocompleteWidget): self.input_callback = input_handler def serialize(self, field, cstruct, **kw): - + """ """ # fetch customer to provide button label, if we have a value if cstruct: model = self.request.rattail_config.get_model() @@ -460,18 +475,21 @@ class CustomerAutocompleteWidget(JQueryAutocompleteWidget): if customer: self.field_display = str(customer) - return super(CustomerAutocompleteWidget, self).serialize( + return super().serialize( field, cstruct, **kw) class CustomerDropdownWidget(dfwidget.SelectWidget): """ - Dropdown widget for a Customer reference field. + Dropdown widget for a + :class:`~rattail:rattail.db.model.customers.Customer` reference + field. """ def __init__(self, request, *args, **kwargs): - super(CustomerDropdownWidget, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.request = request + app = self.request.rattail_config.get_app() # must figure out dropdown values, if they weren't given if 'values' not in kwargs: @@ -483,10 +501,8 @@ class CustomerDropdownWidget(dfwidget.SelectWidget): customers = customers() else: # default customer list - model = self.request.rattail_config.get_model() - customers = Session.query(model.Customer)\ - .order_by(model.Customer.name)\ - .all() + customers = app.get_clientele_handler()\ + .get_all_customers(Session()) # convert customer list to option values self.values = [(c.uuid, c.name) @@ -517,7 +533,7 @@ class DepartmentWidget(dfwidget.SelectWidget): values.insert(0, ('', "(none)")) kwargs['values'] = values - super(DepartmentWidget, self).__init__(**kwargs) + super().__init__(**kwargs) def make_vendor_widget(request, **kwargs): @@ -548,7 +564,7 @@ class VendorAutocompleteWidget(JQueryAutocompleteWidget): """ def __init__(self, request, *args, **kwargs): - super(VendorAutocompleteWidget, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.request = request model = self.request.rattail_config.get_model() @@ -568,7 +584,7 @@ class VendorAutocompleteWidget(JQueryAutocompleteWidget): # self.input_callback = input_handler def serialize(self, field, cstruct, **kw): - + """ """ # fetch vendor to provide button label, if we have a value if cstruct: model = self.request.rattail_config.get_model() @@ -576,7 +592,7 @@ class VendorAutocompleteWidget(JQueryAutocompleteWidget): if vendor: self.field_display = str(vendor) - return super(VendorAutocompleteWidget, self).serialize( + return super().serialize( field, cstruct, **kw) @@ -586,7 +602,7 @@ class VendorDropdownWidget(dfwidget.SelectWidget): """ def __init__(self, request, *args, **kwargs): - super(VendorDropdownWidget, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.request = request # must figure out dropdown values, if they weren't given From d154986128a353e03bec4a0d18c55128bee43020 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 1 Dec 2023 21:57:20 -0600 Subject: [PATCH 1328/1681] Update changelog --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 45e5cc1b..7cffe70a 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.9.85 (2023-12-01) +------------------- + +* Use clientele handler to populate customer dropdown widget. + + 0.9.84 (2023-11-30) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index a44d3ed3..66aab6b4 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.84' +__version__ = '0.9.85' From 91e7001963148766997a7d349e7d9c40cfca90ce Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 4 Dec 2023 10:15:12 -0600 Subject: [PATCH 1329/1681] Overhaul tox config for more python versions --- setup.cfg | 6 ++++++ tox.ini | 31 +++++++++++++++++-------------- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/setup.cfg b/setup.cfg index 85501357..67541d96 100644 --- a/setup.cfg +++ b/setup.cfg @@ -26,6 +26,12 @@ classifiers = Operating System :: OS Independent Programming Language :: Python Programming Language :: Python :: 3 + Programming Language :: Python :: 3.6 + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 + Programming Language :: Python :: 3.11 Topic :: Internet :: WWW/HTTP Topic :: Office/Business Topic :: Software Development :: Libraries :: Python Modules diff --git a/tox.ini b/tox.ini index 8681465d..ea833b39 100644 --- a/tox.ini +++ b/tox.ini @@ -1,25 +1,28 @@ [tox] -envlist = py36, py37, py39 +envlist = py36, py37, py38, py39, py310, py311 + +# TODO: can remove this when we drop py36 support +# nb. need this for testing older python versions +# https://tox.wiki/en/latest/faq.html#testing-end-of-life-python-versions +requires = virtualenv<20.22.0 [testenv] -commands = - pip install --upgrade pip - pip install --upgrade setuptools wheel - pip install --upgrade --upgrade-strategy eager Tailbone[tests] rattail[bouncer,db] rattail-tempmon - pytest {posargs} +deps = rattail-tempmon +extras = tests +commands = pytest {posargs} + +[testenv:py37] +# nb. Chameleon 4.3 requires Python 3.9+ +deps = Chameleon<4.3 [testenv:coverage] basepython = python3 -commands = - pip install --upgrade pip - pip install --upgrade --upgrade-strategy eager Tailbone[tests] rattail[bouncer,db] rattail-tempmon - pytest --cov=tailbone --cov-report=html +extras = tests +commands = pytest --cov=tailbone --cov-report=html [testenv:docs] basepython = python3 changedir = docs -commands = - pip install --upgrade pip - pip install --upgrade --upgrade-strategy eager Tailbone[docs] rattail[bouncer,db] rattail-tempmon - sphinx-build -b html -d {envtmpdir}/doctrees -W -T . {envtmpdir}/docs +extras = docs +commands = sphinx-build -b html -d {envtmpdir}/doctrees -W -T . {envtmpdir}/docs From 98fc82acfd0fdad8e0c6f30bc90993f85007931a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 11 Dec 2023 13:50:02 -0600 Subject: [PATCH 1330/1681] Use `ltrim(rtrim())` instead of just `trim()` in grid filters apparently this is needed for older SQL Server compatibility, per https://stackoverflow.com/questions/54340470/trim-is-not-a-recognized-built-in-function-name --- tailbone/grids/filters.py | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py index 2585433e..f70670b6 100644 --- a/tailbone/grids/filters.py +++ b/tailbone/grids/filters.py @@ -313,7 +313,7 @@ class AlchemyGridFilter(GridFilter): def __init__(self, *args, **kwargs): self.column = kwargs.pop('column') - super(AlchemyGridFilter, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def filter_equal(self, query, value): """ @@ -538,17 +538,18 @@ class AlchemyStringFilter(AlchemyGridFilter): return query.filter(sa.or_(*conditions)) def filter_is_empty(self, query, value): - return query.filter(sa.func.trim(self.column) == self.encode_value('')) + return query.filter(sa.func.ltrim(sa.func.rtrim(self.column)) == self.encode_value('')) def filter_is_not_empty(self, query, value): - return query.filter(sa.func.trim(self.column) != self.encode_value('')) + return query.filter(sa.func.ltrim(sa.func.rtrim(self.column)) != self.encode_value('')) def filter_is_empty_or_null(self, query, value): return query.filter( sa.or_( - sa.func.trim(self.column) == self.encode_value(''), + sa.func.ltrim(sa.func.rtrim(self.column)) == self.encode_value(''), self.column == None)) + class AlchemyEmptyStringFilter(AlchemyStringFilter): """ String filter with special logic to treat empty string values as NULL @@ -558,13 +559,13 @@ class AlchemyEmptyStringFilter(AlchemyStringFilter): return query.filter( sa.or_( self.column == None, - sa.func.trim(self.column) == self.encode_value(''))) + sa.func.ltrim(sa.func.rtrim(self.column)) == self.encode_value(''))) def filter_is_not_null(self, query, value): return query.filter( sa.and_( self.column != None, - sa.func.trim(self.column) != self.encode_value(''))) + sa.func.ltrim(sa.func.rtrim(self.column)) != self.encode_value(''))) class AlchemyByteStringFilter(AlchemyStringFilter): @@ -576,7 +577,7 @@ class AlchemyByteStringFilter(AlchemyStringFilter): value_encoding = 'utf-8' def get_value(self, value=UNSPECIFIED): - value = super(AlchemyByteStringFilter, self).get_value(value) + value = super().get_value(value) if isinstance(value, str): value = value.encode(self.value_encoding) return value @@ -637,32 +638,32 @@ class AlchemyNumericFilter(AlchemyGridFilter): def filter_equal(self, query, value): if self.value_invalid(value): return query - return super(AlchemyNumericFilter, self).filter_equal(query, value) + return super().filter_equal(query, value) def filter_not_equal(self, query, value): if self.value_invalid(value): return query - return super(AlchemyNumericFilter, self).filter_not_equal(query, value) + return super().filter_not_equal(query, value) def filter_greater_than(self, query, value): if self.value_invalid(value): return query - return super(AlchemyNumericFilter, self).filter_greater_than(query, value) + return super().filter_greater_than(query, value) def filter_greater_equal(self, query, value): if self.value_invalid(value): return query - return super(AlchemyNumericFilter, self).filter_greater_equal(query, value) + return super().filter_greater_equal(query, value) def filter_less_than(self, query, value): if self.value_invalid(value): return query - return super(AlchemyNumericFilter, self).filter_less_than(query, value) + return super().filter_less_than(query, value) def filter_less_equal(self, query, value): if self.value_invalid(value): return query - return super(AlchemyNumericFilter, self).filter_less_equal(query, value) + return super().filter_less_equal(query, value) class AlchemyIntegerFilter(AlchemyNumericFilter): @@ -1193,7 +1194,7 @@ class AlchemyPhoneNumberFilter(AlchemyStringFilter): 'ILIKE' query with those parts. """ value = self.parse_value(value) - return super(AlchemyPhoneNumberFilter, self).filter_contains(query, value) + return super().filter_contains(query, value) def filter_does_not_contain(self, query, value): """ @@ -1201,7 +1202,7 @@ class AlchemyPhoneNumberFilter(AlchemyStringFilter): 'NOT ILIKE' query with those parts. """ value = self.parse_value(value) - return super(AlchemyPhoneNumberFilter, self).filter_does_not_contain(query, value) + return super().filter_does_not_contain(query, value) class GridFilterSet(OrderedDict): @@ -1245,7 +1246,7 @@ class GridFiltersForm(forms.Form): node = colander.SchemaNode(colander.String(), name=key) schema.add(node) kwargs['schema'] = schema - super(GridFiltersForm, self).__init__(**kwargs) + super().__init__(**kwargs) def iter_filters(self): return self.filters.values() From b6618c8ee5e48ada18e429b4ae85a674e91e18cb Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 12 Dec 2023 11:46:28 -0600 Subject: [PATCH 1331/1681] Update changelog --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 7cffe70a..40e3a0d1 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.9.86 (2023-12-12) +------------------- + +* Use ``ltrim(rtrim())`` instead of just ``trim()`` in grid filters. + + 0.9.85 (2023-12-01) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 66aab6b4..689b5c2b 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.85' +__version__ = '0.9.86' From 90630fe8523a9de739de4ec73076b69a24914d4b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 13 Dec 2023 12:05:42 -0600 Subject: [PATCH 1332/1681] Auto-disable submit button for login form not sure why i had explicitly disabled that before..? --- tailbone/views/auth.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index f8d71d34..7c4d26f0 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -101,8 +101,6 @@ class AuthenticationView(View): form = forms.Form(schema=UserLogin(), request=self.request) form.save_label = "Login" - form.auto_disable_save = False - form.auto_disable = False # TODO: deprecate / remove this form.show_reset = True form.show_cancel = False if form.validate(): From 90e35ee3dbcb35335437ae530c8a731305fec366 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 19 Dec 2023 12:49:33 -0600 Subject: [PATCH 1333/1681] Hide single invoice file field for multi-invoice receiving batch --- tailbone/views/purchasing/receiving.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 33f3cc53..8cf38aaf 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -533,6 +533,7 @@ class ReceivingBatchView(PurchasingBatchView): f.insert_before('invoice_file', 'invoice_files') f.set_renderer('invoice_files', self.render_invoice_files) f.set_readonly('invoice_files', True) + f.remove('invoice_file') # invoice totals f.set_label('invoice_total', "Invoice Total (Orig.)") From 3bdc7175a3fb52bf3c28f1d88c81cd647ee634b2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 20 Dec 2023 11:56:24 -0600 Subject: [PATCH 1334/1681] Use common logic to render invoice total for receiving and avoid error if total is none --- tailbone/views/purchasing/receiving.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 8cf38aaf..22fbc133 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -1857,6 +1857,7 @@ class ReceivingBatchView(PurchasingBatchView): """ AJAX view for updating various cost fields in a data row. """ + app = self.get_rattail_app() model = self.model batch = self.get_instance() data = dict(get_form_data(self.request)) @@ -1891,10 +1892,10 @@ class ReceivingBatchView(PurchasingBatchView): 'catalog_cost_confirmed': row.catalog_cost_confirmed, 'invoice_unit_cost': self.render_simple_unit_cost(row, 'invoice_unit_cost'), 'invoice_cost_confirmed': row.invoice_cost_confirmed, - 'invoice_total_calculated': '{:0.2f}'.format(row.invoice_total_calculated), + 'invoice_total_calculated': app.render_currency(row.invoice_total_calculated), }, 'batch': { - 'invoice_total_calculated': '{:0.2f}'.format(batch.invoice_total_calculated), + 'invoice_total_calculated': app.render_currency(batch.invoice_total_calculated), }, } From a40add8f413d44e3d34ca67204af441269dbda8f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 22 Dec 2023 11:50:05 -0600 Subject: [PATCH 1335/1681] Expose default custorder discount for Departments --- tailbone/views/departments.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tailbone/views/departments.py b/tailbone/views/departments.py index 3d462b16..a062b183 100644 --- a/tailbone/views/departments.py +++ b/tailbone/views/departments.py @@ -59,6 +59,7 @@ class DepartmentView(MasterView): 'tax', 'food_stampable', 'exempt_from_gross_sales', + 'default_custorder_discount', 'allow_product_deletions', 'employees', ] @@ -114,6 +115,9 @@ class DepartmentView(MasterView): # TODO: make this editable f.set_readonly('tax') + # default_custorder_discount + f.set_type('default_custorder_discount', 'percent') + def render_employees(self, department, field): route_prefix = self.get_route_prefix() permission_prefix = self.get_permission_prefix() From 25c48a97c55a0e8ea5909fe2b34dc0fb8407d1e4 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 26 Dec 2023 20:17:05 -0600 Subject: [PATCH 1336/1681] Update changelog --- CHANGES.rst | 12 ++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 40e3a0d1..174c06ae 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,18 @@ CHANGELOG ========= +0.9.87 (2023-12-26) +------------------- + +* Auto-disable submit button for login form. + +* Hide single invoice file field for multi-invoice receiving batch. + +* Use common logic to render invoice total for receiving. + +* Expose default custorder discount for Departments. + + 0.9.86 (2023-12-12) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 689b5c2b..5c813a10 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.86' +__version__ = '0.9.87' From 0b7d2f5aede8f5f6123326f87b78b04247632b70 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 26 Mar 2024 11:47:37 -0500 Subject: [PATCH 1337/1681] Fix how metadata/bind is used for importer batch table per changes coming in SQLAlchemy 2.0 --- tailbone/views/batch/importer.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/tailbone/views/batch/importer.py b/tailbone/views/batch/importer.py index f0b76bf6..a5916448 100644 --- a/tailbone/views/batch/importer.py +++ b/tailbone/views/batch/importer.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -26,7 +26,7 @@ Views for importer batches import sqlalchemy as sa -from rattail.db import model +from rattail.db.model import ImporterBatch import colander @@ -37,7 +37,7 @@ class ImporterBatchView(BatchMasterView): """ Master view for importer batches. """ - model_class = model.ImporterBatch + model_class = ImporterBatch default_handler_spec = 'rattail.batch.importer:ImporterBatchHandler' route_prefix = 'batch.importer' url_prefix = '/batches/importer' @@ -91,7 +91,7 @@ class ImporterBatchView(BatchMasterView): ] def configure_form(self, f): - super(ImporterBatchView, self).configure_form(f) + super().configure_form(f) # readonly fields f.set_readonly('import_handler_spec') @@ -110,21 +110,21 @@ class ImporterBatchView(BatchMasterView): self.make_row_table(batch.row_table) kwargs['rows'] = self.Session.query(self.current_row_table).all() kwargs.setdefault('status_enum', self.enum.IMPORTER_BATCH_ROW_STATUS) - breakdown = super(ImporterBatchView, self).make_status_breakdown( - batch, **kwargs) + breakdown = super().make_status_breakdown(batch, **kwargs) return breakdown def delete_instance(self, batch): self.make_row_table(batch.row_table) if self.current_row_table is not None: self.current_row_table.drop() - super(ImporterBatchView, self).delete_instance(batch) + super().delete_instance(batch) def make_row_table(self, name): if not hasattr(self, 'current_row_table'): - metadata = sa.MetaData(schema='batch', bind=self.Session.bind) + metadata = sa.MetaData(schema='batch') try: - self.current_row_table = sa.Table(name, metadata, autoload=True) + self.current_row_table = sa.Table(name, metadata, + autoload_with=self.Session.bind) except sa.exc.NoSuchTableError: self.current_row_table = None @@ -136,7 +136,7 @@ class ImporterBatchView(BatchMasterView): return self.enum.IMPORTER_BATCH_ROW_STATUS def configure_row_grid(self, g): - super(ImporterBatchView, self).configure_row_grid(g) + super().configure_row_grid(g) def make_filter(field, **kwargs): column = getattr(self.current_row_table.c, field) @@ -190,7 +190,7 @@ class ImporterBatchView(BatchMasterView): def get_parent(self, row): uuid = self.current_row_table.name - return self.Session.get(model.ImporterBatch, uuid) + return self.Session.get(ImporterBatch, uuid) def get_row_instance_title(self, row): if row.object_str: @@ -242,7 +242,7 @@ class ImporterBatchView(BatchMasterView): kwargs.setdefault('schema', colander.Schema()) kwargs.setdefault('cancel_url', None) - return super(ImporterBatchView, self).make_row_form(instance=row, **kwargs) + return super().make_row_form(instance=row, **kwargs) def configure_row_form(self, f): """ @@ -291,7 +291,7 @@ class ImporterBatchView(BatchMasterView): ] def get_row_xlsx_row(self, row, fields): - xlrow = super(ImporterBatchView, self).get_row_xlsx_row(row, fields) + xlrow = super().get_row_xlsx_row(row, fields) xlrow['status'] = self.enum.IMPORTER_BATCH_ROW_STATUS[row.status_code] From 27fce173cefef545e19bd834171d1c685d853fb8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 26 Mar 2024 11:48:52 -0500 Subject: [PATCH 1338/1681] Fix how row grid values are fetched, for row proxy objects per changes coming in SQLAlchemy 2.0 --- tailbone/grids/core.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 7a0d00e3..41964648 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -33,7 +33,6 @@ from sqlalchemy import orm from rattail.db.types import GPCType from rattail.util import prettify, pretty_boolean, pretty_quantity -from rattail.time import localtime import webhelpers2_grid from pyramid.renderers import render @@ -478,6 +477,11 @@ class Grid(object): :returns: The value, or ``None`` if no value was found. """ + # TODO: this seems a little hacky, is there a better way? + # nb. this may only be relevant for import/export batch view? + if isinstance(obj, sa.engine.Row): + return obj._mapping[column_name] + try: return obj[column_name] except KeyError: @@ -503,7 +507,8 @@ class Grid(object): value = self.obtain_value(obj, column_name) if value is None: return "" - value = localtime(self.request.rattail_config, value) + app = self.request.rattail_config.get_app() + value = app.localtime(value) return raw_datetime(self.request.rattail_config, value) def render_enum(self, obj, column_name): @@ -1724,7 +1729,7 @@ class CustomWebhelpersGrid(webhelpers2_grid.Grid): self.renderers = kwargs.pop('renderers', {}) self.linked_columns = kwargs.pop('linked_columns', []) self.extra_record_class = kwargs.pop('extra_record_class', None) - super(CustomWebhelpersGrid, self).__init__(itemlist, columns, **kwargs) + super().__init__(itemlist, columns, **kwargs) def generate_header_link(self, column_number, column, label_text): From 4363b7c5d738d951483a42947a1c38557dd50646 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 26 Mar 2024 12:53:20 -0500 Subject: [PATCH 1339/1681] Update changelog --- CHANGES.rst | 7 +++++++ tailbone/_version.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 174c06ae..4e96d2e1 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,13 @@ CHANGELOG ========= +0.9.88 (2024-03-26) +------------------- + +* Update some SQLAlchemy logic per upcoming 2.0 changes. + + + 0.9.87 (2023-12-26) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 5c813a10..86e8f57c 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.87' +__version__ = '0.9.88' From dfdb7a9b59e8c10a551e7003cebca50a50438846 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 27 Mar 2024 13:11:03 -0500 Subject: [PATCH 1340/1681] Fix bulk-delete rows for import/export batch per changes in SQLAlchemy 1.4 --- CHANGES.rst | 7 ++++++- tailbone/views/batch/importer.py | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 4e96d2e1..1717910f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,13 +2,18 @@ CHANGELOG ========= +Unreleased +---------- + +* Fix bulk-delete rows for import/export batch. + + 0.9.88 (2024-03-26) ------------------- * Update some SQLAlchemy logic per upcoming 2.0 changes. - 0.9.87 (2023-12-26) ------------------- diff --git a/tailbone/views/batch/importer.py b/tailbone/views/batch/importer.py index a5916448..962093da 100644 --- a/tailbone/views/batch/importer.py +++ b/tailbone/views/batch/importer.py @@ -277,7 +277,7 @@ class ImporterBatchView(BatchMasterView): query = self.get_effective_row_data(sort=False) batch.rowcount -= query.count() delete_query = self.current_row_table.delete().where(self.current_row_table.c.uuid.in_([row.uuid for row in query])) - delete_query.execute() + self.Session.bind.execute(delete_query) return self.redirect(self.get_action_url('view', batch)) def get_row_xlsx_fields(self): From cdc857065b42fd82eb0b29409e276819231f4b09 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 27 Mar 2024 13:14:23 -0500 Subject: [PATCH 1341/1681] Update changelog --- CHANGES.rst | 3 +++ tailbone/_version.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 1717910f..38c3b959 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,9 @@ CHANGELOG Unreleased ---------- +0.9.89 (2024-03-27) +------------------- + * Fix bulk-delete rows for import/export batch. diff --git a/tailbone/_version.py b/tailbone/_version.py index 86e8f57c..a8c7fe3a 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.88' +__version__ = '0.9.89' From 1889f7d2697c2741c90594ce55d8fb7c96daa2fa Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 1 Apr 2024 18:05:27 -0500 Subject: [PATCH 1342/1681] Add basic CRUD for Person "preferred first name" only shown if config flag says so --- CHANGES.rst | 3 + .../templates/people/view_profile_buefy.mako | 37 ++++++++-- tailbone/views/people.py | 68 +++++++++++++++---- 3 files changed, 91 insertions(+), 17 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 38c3b959..1d8d63d3 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,9 @@ CHANGELOG Unreleased ---------- +* Add basic CRUD for Person "preferred first name". + + 0.9.89 (2024-03-27) ------------------- diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako index 4b1e089c..81243464 100644 --- a/tailbone/templates/people/view_profile_buefy.mako +++ b/tailbone/templates/people/view_profile_buefy.mako @@ -91,6 +91,12 @@ <span>{{ person.first_name }}</span> </b-field> + % if use_preferred_first_name: + <b-field horizontal label="Preferred First Name"> + <span>{{ person.preferred_first_name }}</span> + </b-field> + % endif + <b-field horizontal label="Middle Name"> <span>{{ person.middle_name }}</span> </b-field> @@ -118,11 +124,25 @@ </header> <section class="modal-card-body"> - <b-field label="First Name"> - <b-input v-model.trim="editNameFirst" - :maxlength="maxLengths.person_first_name || null"> - </b-input> + + <b-field grouped> + + <b-field label="First Name" expanded> + <b-input v-model.trim="editNameFirst" + :maxlength="maxLengths.person_first_name || null"> + </b-input> + </b-field> + + % if use_preferred_first_name: + <b-field label="Preferred First Name" expanded> + <b-input v-model.trim="editNameFirstPreferred" + :maxlength="maxLengths.person_preferred_first_name || null"> + </b-input> + </b-field> + % endif + </b-field> + <b-field label="Middle Name"> <b-input v-model.trim="editNameMiddle" :maxlength="maxLengths.person_middle_name || null"> @@ -1497,6 +1517,9 @@ % if request.has_perm('people_profile.edit_person'): editNameShowDialog: false, editNameFirst: null, + % if use_preferred_first_name: + editNameFirstPreferred: null, + % endif editNameMiddle: null, editNameLast: null, @@ -1590,6 +1613,9 @@ editNameInit() { this.editNameFirst = this.person.first_name + % if use_preferred_first_name: + this.editNameFirstPreferred = this.person.preferred_first_name + % endif this.editNameMiddle = this.person.middle_name this.editNameLast = this.person.last_name this.editNameShowDialog = true @@ -1599,6 +1625,9 @@ let url = '${url('people.profile_edit_name', uuid=person.uuid)}' let params = { first_name: this.editNameFirst, + % if use_preferred_first_name: + preferred_first_name: this.editNameFirstPreferred, + % endif middle_name: this.editNameMiddle, last_name: this.editNameLast, } diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 7f786ace..071e58b5 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -32,7 +32,8 @@ import sqlalchemy as sa from sqlalchemy import orm import sqlalchemy_continuum as continuum -from rattail.db import model, api +from rattail.db import api +from rattail.db.model import Person, PersonNote, MergePeopleRequest from rattail.db.util import maxlen from rattail.time import localtime from rattail.util import simple_error @@ -53,7 +54,7 @@ class PersonView(MasterView): """ Master view for the Person class. """ - model_class = model.Person + model_class = Person model_title_plural = "People" route_prefix = 'people' touchable = True @@ -210,6 +211,7 @@ class PersonView(MasterView): c="MR") def get_instance(self): + model = self.model # TODO: I don't recall why this fallback check for a vendor contact # exists here, but leaving it intact for now. key = self.request.matchdict['uuid'] @@ -237,6 +239,13 @@ class PersonView(MasterView): return True return not self.is_person_protected(person) + def configure_form(self, f): + super().configure_form(f) + + # preferred_first_name + if self.people_handler.should_use_preferred_first_name(): + f.insert_after('first_name', 'preferred_first_name') + def objectify(self, form, data=None): if data is None: data = form.validated @@ -248,6 +257,9 @@ class PersonView(MasterView): names = {} if 'first_name' in form: names['first'] = data['first_name'] + if self.people_handler.should_use_preferred_first_name(): + if 'preferred_first_name' in form: + names['preferred_first'] = data['preferred_first_name'] if 'middle_name' in form: names['middle'] = data['middle_name'] if 'last_name' in form: @@ -292,6 +304,8 @@ class PersonView(MasterView): In addition to "touching" the person proper, we also "touch" each contact info record associated with them. """ + model = self.model + # touch person, as per usual super().touch_instance(person) @@ -426,6 +440,7 @@ class PersonView(MasterView): return "" def get_version_child_classes(self): + model = self.model return [ (model.PersonPhoneNumber, 'parent_uuid'), (model.PersonEmailAddress, 'parent_uuid'), @@ -474,6 +489,7 @@ class PersonView(MasterView): 'expose_customer_people': self.customers_should_expose_people(), 'expose_customer_shoppers': self.customers_should_expose_shoppers(), 'max_one_member': app.get_membership_handler().max_one_per_person(), + 'use_preferred_first_name': self.people_handler.should_use_preferred_first_name(), } if self.request.has_perm('people_profile.view_versions'): @@ -552,7 +568,7 @@ class PersonView(MasterView): def get_max_lengths(self): model = self.model - return { + lengths = { 'person_first_name': maxlen(model.Person.first_name), 'person_middle_name': maxlen(model.Person.middle_name), 'person_last_name': maxlen(model.Person.last_name), @@ -562,6 +578,9 @@ class PersonView(MasterView): 'address_state': maxlen(model.PersonMailingAddress.state), 'address_zipcode': maxlen(model.PersonMailingAddress.zipcode), } + if self.people_handler.should_use_preferred_first_name(): + lengths['person_preferred_first_name'] = maxlen(model.Person.preferred_first_name) + return lengths def get_phone_type_options(self): """ @@ -606,6 +625,9 @@ class PersonView(MasterView): 'dynamic_content_title': self.get_context_content_title(person), } + if self.people_handler.should_use_preferred_first_name(): + context['preferred_first_name'] = person.preferred_first_name + if person.address: context['address'] = self.get_context_address(person.address) @@ -871,10 +893,16 @@ class PersonView(MasterView): person = self.get_instance() data = dict(self.request.json_body) - self.handler.update_names(person, - first=data['first_name'], - middle=data['middle_name'], - last=data['last_name']) + kw = { + 'first': data['first_name'], + 'middle': data['middle_name'], + 'last': data['last_name'], + } + + if self.people_handler.should_use_preferred_first_name(): + kw['preferred_first'] = data['preferred_first_name'] + + self.handler.update_names(person, **kw) self.Session.flush() return self.profile_changed_response(person) @@ -913,6 +941,7 @@ class PersonView(MasterView): """ View which updates a phone number for the person. """ + model = self.model person = self.get_instance() data = dict(self.request.json_body) @@ -940,6 +969,7 @@ class PersonView(MasterView): """ View which allows a person's phone number to be deleted. """ + model = self.model person = self.get_instance() data = dict(self.request.json_body) @@ -960,6 +990,7 @@ class PersonView(MasterView): """ View which allows a person's "preferred" phone to be set. """ + model = self.model person = self.get_instance() data = dict(self.request.json_body) @@ -1016,6 +1047,7 @@ class PersonView(MasterView): """ View which updates an email address for the person. """ + model = self.model person = self.get_instance() data = dict(self.request.json_body) @@ -1039,6 +1071,7 @@ class PersonView(MasterView): """ View which allows a person's email address to be deleted. """ + model = self.model person = self.get_instance() data = dict(self.request.json_body) @@ -1059,6 +1092,7 @@ class PersonView(MasterView): """ View which allows a person's "preferred" email to be set. """ + model = self.model person = self.get_instance() data = dict(self.request.json_body) @@ -1192,6 +1226,7 @@ class PersonView(MasterView): """ AJAX view for updating an employee history record. """ + model = self.model person = self.get_instance() employee = person.employee @@ -1459,6 +1494,7 @@ class PersonView(MasterView): return self.profile_changed_response(person) def create_note(self, person, form): + model = self.model note = model.PersonNote() note.type = form.validated['note_type'] note.subject = form.validated['note_subject'] @@ -1478,6 +1514,7 @@ class PersonView(MasterView): return self.profile_changed_response(person) def update_note(self, person, form): + model = self.model note = self.Session.get(model.PersonNote, form.validated['uuid']) note.subject = form.validated['note_subject'] note.text = form.validated['note_text'] @@ -1494,10 +1531,12 @@ class PersonView(MasterView): return self.profile_changed_response(person) def delete_note(self, person, form): + model = self.model note = self.Session.get(model.PersonNote, form.validated['uuid']) self.Session.delete(note) def make_user(self): + model = self.model uuid = self.request.POST['person_uuid'] person = self.Session.get(model.Person, uuid) if not person: @@ -1815,7 +1854,7 @@ class PersonNoteView(MasterView): """ Master view for the PersonNote class. """ - model_class = model.PersonNote + model_class = PersonNote route_prefix = 'person_notes' url_prefix = '/people/notes' has_versions = True @@ -1842,6 +1881,7 @@ class PersonNoteView(MasterView): def configure_grid(self, g): super().configure_grid(g) + model = self.model # person g.set_joiner('person', lambda q: q.join(model.Person, @@ -1881,7 +1921,7 @@ def valid_note_uuid(node, kw): session = kw['session'] person_uuid = kw['person_uuid'] def validate(node, value): - note = session.get(model.PersonNote, value) + note = session.get(PersonNote, value) if not note: raise colander.Invalid(node, "Note not found") if note.person.uuid != person_uuid: @@ -1906,7 +1946,7 @@ class MergePeopleRequestView(MasterView): """ Master view for the MergePeopleRequest class. """ - model_class = model.MergePeopleRequest + model_class = MergePeopleRequest route_prefix = 'people_merge_requests' url_prefix = '/people/merge-requests' creatable = False @@ -1950,8 +1990,9 @@ class MergePeopleRequestView(MasterView): g.set_link('keeping_uuid') def render_referenced_person_name(self, merge_request, field): + model = self.model uuid = getattr(merge_request, field) - person = self.Session.get(self.model.Person, uuid) + person = self.Session.get(model.Person, uuid) if person: return str(person) return "(person not found)" @@ -1971,8 +2012,9 @@ class MergePeopleRequestView(MasterView): f.set_renderer('keeping_uuid', self.render_referenced_person) def render_referenced_person(self, merge_request, field): + model = self.model uuid = getattr(merge_request, field) - person = self.Session.get(self.model.Person, uuid) + person = self.Session.get(model.Person, uuid) if person: text = str(person) url = self.request.route_url('people.view', uuid=person.uuid) From e0dc858451646a6fef744f2e6c07a34632fe2191 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 1 Apr 2024 18:28:39 -0500 Subject: [PATCH 1343/1681] Update changelog --- CHANGES.rst | 3 +++ tailbone/_version.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 1d8d63d3..400557d3 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,9 @@ CHANGELOG Unreleased ---------- +0.9.90 (2024-04-01) +------------------- + * Add basic CRUD for Person "preferred first name". diff --git a/tailbone/_version.py b/tailbone/_version.py index a8c7fe3a..cff6f04f 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.89' +__version__ = '0.9.90' From a1b05540bed9e301de9936d67ff4722ce684bc9e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 10 Apr 2024 12:24:13 -0500 Subject: [PATCH 1344/1681] Avoid uncaught error when updating order batch row quantities --- tailbone/api/batch/ordering.py | 41 ++++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/tailbone/api/batch/ordering.py b/tailbone/api/batch/ordering.py index 1661d06f..1b11194e 100644 --- a/tailbone/api/batch/ordering.py +++ b/tailbone/api/batch/ordering.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -28,18 +28,23 @@ API. """ import datetime +import logging -from rattail.db import model -from rattail.util import pretty_quantity +import sqlalchemy as sa + +from rattail.db.model import PurchaseBatch, PurchaseBatchRow from cornice import Service from tailbone.api.batch import APIBatchView, APIBatchRowView +log = logging.getLogger(__name__) + + class OrderingBatchViews(APIBatchView): - model_class = model.PurchaseBatch + model_class = PurchaseBatch default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler' route_prefix = 'orderingbatchviews' permission_prefix = 'ordering' @@ -55,12 +60,13 @@ class OrderingBatchViews(APIBatchView): Adds a condition to the query, to ensure only purchase batches with "ordering" mode are returned. """ - query = super(OrderingBatchViews, self).base_query() + model = self.model + query = super().base_query() query = query.filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING) return query def normalize(self, batch): - data = super(OrderingBatchViews, self).normalize(batch) + data = super().normalize(batch) data['vendor_uuid'] = batch.vendor.uuid data['vendor_display'] = str(batch.vendor) @@ -81,7 +87,7 @@ class OrderingBatchViews(APIBatchView): """ data = dict(data) data['mode'] = self.enum.PURCHASE_BATCH_MODE_ORDERING - batch = super(OrderingBatchViews, self).create_object(data) + batch = super().create_object(data) return batch def worksheet(self): @@ -221,7 +227,7 @@ class OrderingBatchViews(APIBatchView): class OrderingBatchRowViews(APIBatchRowView): - model_class = model.PurchaseBatchRow + model_class = PurchaseBatchRow default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler' route_prefix = 'ordering.rows' permission_prefix = 'ordering' @@ -231,8 +237,9 @@ class OrderingBatchRowViews(APIBatchRowView): editable = True def normalize(self, row): + data = super().normalize(row) + app = self.get_rattail_app() batch = row.batch - data = super(OrderingBatchRowViews, self).normalize(row) data['item_id'] = row.item_id data['upc'] = str(row.upc) @@ -252,8 +259,8 @@ class OrderingBatchRowViews(APIBatchRowView): data['case_quantity'] = row.case_quantity data['cases_ordered'] = row.cases_ordered data['units_ordered'] = row.units_ordered - data['cases_ordered_display'] = pretty_quantity(row.cases_ordered or 0, empty_zero=False) - data['units_ordered_display'] = pretty_quantity(row.units_ordered or 0, empty_zero=False) + data['cases_ordered_display'] = app.render_quantity(row.cases_ordered or 0, empty_zero=False) + data['units_ordered_display'] = app.render_quantity(row.units_ordered or 0, empty_zero=False) data['po_unit_cost'] = row.po_unit_cost data['po_unit_cost_display'] = "${:0.2f}".format(row.po_unit_cost) if row.po_unit_cost is not None else None @@ -281,7 +288,17 @@ class OrderingBatchRowViews(APIBatchRowView): if not self.batch_handler.is_mutable(row.batch): return {'error': "Batch is not mutable"} - self.batch_handler.update_row_quantity(row, **data) + try: + self.batch_handler.update_row_quantity(row, **data) + self.Session.flush() + except Exception as error: + log.warning("update_row_quantity failed", exc_info=True) + if isinstance(error, sa.exc.DataError) and hasattr(error, 'orig'): + error = str(error.orig) + else: + error = str(error) + return {'error': error} + return row From de8751b86c6dffb5c32ca9e01d01139ef8de18b6 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 11 Apr 2024 14:14:27 -0500 Subject: [PATCH 1345/1681] Try to return JSON error when receiving API call fails although in my testing, the error still got raised somehow in the tweens or something? client then sees it as a 500 response and gets no JSON --- tailbone/api/batch/receiving.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/tailbone/api/batch/receiving.py b/tailbone/api/batch/receiving.py index f8ce4a33..daa4290f 100644 --- a/tailbone/api/batch/receiving.py +++ b/tailbone/api/batch/receiving.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -27,6 +27,7 @@ Tailbone Web API - Receiving Batches import logging import humanize +import sqlalchemy as sa from rattail.db import model from rattail.util import pretty_quantity @@ -440,9 +441,17 @@ class ReceivingBatchRowViews(APIBatchRowView): # handler takes care of the row receiving logic for us kwargs = dict(form.validated) del kwargs['row'] - self.batch_handler.receive_row(row, **kwargs) + try: + self.batch_handler.receive_row(row, **kwargs) + self.Session.flush() + except Exception as error: + log.warning("receive() failed", exc_info=True) + if isinstance(error, sa.exc.DataError) and hasattr(error, 'orig'): + error = str(error.orig) + else: + error = str(error) + return {'error': error} - self.Session.flush() return self._get(obj=row) @classmethod From aa500351edd6fa610c3395cdb08900bb0876a7ab Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 11 Apr 2024 16:37:55 -0500 Subject: [PATCH 1346/1681] Avoid error for tax field when creating new department someday should fix that for real.. --- tailbone/views/departments.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tailbone/views/departments.py b/tailbone/views/departments.py index a062b183..01d6f520 100644 --- a/tailbone/views/departments.py +++ b/tailbone/views/departments.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -111,9 +111,13 @@ class DepartmentView(MasterView): f.set_type('personnel', 'boolean') # tax - f.set_renderer('tax', self.render_tax) - # TODO: make this editable - f.set_readonly('tax') + if self.creating: + # TODO: make this editable instead + f.remove('tax') + else: + f.set_renderer('tax', self.render_tax) + # TODO: make this editable + f.set_readonly('tax') # default_custorder_discount f.set_type('default_custorder_discount', 'percent') From cbbd77c49c403e827c540ca21b1302ca0a18db08 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 11 Apr 2024 16:58:12 -0500 Subject: [PATCH 1347/1681] Show toast msg instead of silent error, when grid fetch fails specifically, if a user clicks "Save defaults" for the grid filters, but they aren't currently logged in, error will ensue. this is a bit of an edge case which IIUC would require multiple tabs etc. but still is worth avoiding an error email from it. --- tailbone/templates/grids/buefy.mako | 32 +++++++++++++++-------- tailbone/views/master.py | 40 +++++++++++++++++++---------- 2 files changed, 48 insertions(+), 24 deletions(-) diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako index a3e6e229..cbe33062 100644 --- a/tailbone/templates/grids/buefy.mako +++ b/tailbone/templates/grids/buefy.mako @@ -584,16 +584,28 @@ this.loading = true this.$http.get(`${'$'}{this.ajaxDataUrl}?${'$'}{params}`).then(({ data }) => { - ${grid.component_studly}CurrentData = data.data - this.data = ${grid.component_studly}CurrentData - this.rowStatusMap = data.row_status_map - this.total = data.total_items - this.firstItem = data.first_item - this.lastItem = data.last_item - this.loading = false - this.checkedRows = this.locateCheckedRows(data.checked_rows) - if (success) { - success() + if (!data.error) { + ${grid.component_studly}CurrentData = data.data + this.data = ${grid.component_studly}CurrentData + this.rowStatusMap = data.row_status_map + this.total = data.total_items + this.firstItem = data.first_item + this.lastItem = data.last_item + this.loading = false + this.checkedRows = this.locateCheckedRows(data.checked_rows) + if (success) { + success() + } + } else { + this.$buefy.toast.open({ + message: data.error, + type: 'is-danger', + duration: 2000, // 4 seconds + }) + this.loading = false + if (failure) { + failure() + } } }) .catch((error) => { diff --git a/tailbone/views/master.py b/tailbone/views/master.py index cc2adcaf..c9d6cd7c 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -42,8 +42,7 @@ from sqlalchemy_utils.functions import get_primary_keys, get_columns from rattail.db import model, Session as RattailSession from rattail.db.continuum import model_transaction_query -from rattail.util import prettify, simple_error, get_class_hierarchy -from rattail.time import localtime +from rattail.util import simple_error, get_class_hierarchy from rattail.threads import Thread from rattail.csvutil import UnicodeDictWriter from rattail.files import temp_path @@ -324,6 +323,13 @@ class MasterView(View): string, then the view will return the rendered grid only. Otherwise returns the full page. """ + # nb. normally this "save defaults" flag is checked within make_grid() + # but it returns JSON data so we can't just do a redirect when there + # is no user; must return JSON error message instead + if (self.request.GET.get('save-current-filters-as-defaults') == 'true' + and not self.request.user): + return self.json_response({'error': "User is not currently logged in"}) + self.listing = True grid = self.make_grid() @@ -1465,6 +1471,7 @@ class MasterView(View): """ View showing diff details of a particular object version. """ + app = self.get_rattail_app() instance = self.get_instance() model_class = self.get_model_class() route_prefix = self.get_route_prefix() @@ -1512,7 +1519,7 @@ class MasterView(View): 'instance_title_normal': instance_title, 'instance_url': self.get_action_url('versions', instance), 'transaction': transaction, - 'changed': localtime(self.rattail_config, transaction.issued_at, from_utc=True), + 'changed': app.localtime(transaction.issued_at, from_utc=True), 'version_diffs': version_diffs, 'show_prev_next': True, 'prev_url': prev_url, @@ -3502,14 +3509,14 @@ class MasterView(View): Normalize the given object into a data dict, for use when writing to the results file for download. """ + app = self.get_rattail_app() data = {} for field in fields: value = getattr(obj, field, None) # make timestamps zone-aware if isinstance(value, datetime.datetime): - value = localtime(self.rattail_config, value, - from_utc=not self.has_local_times) + value = app.localtime(value, from_utc=not self.has_local_times) data[field] = value @@ -3539,13 +3546,14 @@ class MasterView(View): Coerce the given data dict record, to a "row" dict suitable for use when writing directly to XLSX file. """ + app = self.get_rattail_app() data = dict(data) for key in data: value = data[key] # make timestamps local, "zone-naive" if isinstance(value, datetime.datetime): - value = localtime(self.rattail_config, value, tzinfo=False) + value = app.localtime(value, tzinfo=False) data[key] = value @@ -4001,14 +4009,14 @@ class MasterView(View): Normalize the given row object into a data dict, for use when writing to the results file for download. """ + app = self.get_rattail_app() data = {} for field in fields: value = getattr(row, field, None) # make timestamps zone-aware if isinstance(value, datetime.datetime): - value = localtime(self.rattail_config, value, - from_utc=not self.has_local_times) + value = app.localtime(value, from_utc=not self.has_local_times) data[field] = value @@ -4038,6 +4046,7 @@ class MasterView(View): Coerce the given data dict record, to a "row" dict suitable for use when writing directly to XLSX file. """ + app = self.get_rattail_app() data = dict(data) for key in data: value = data[key] @@ -4048,7 +4057,7 @@ class MasterView(View): # make timestamps local, "zone-naive" elif isinstance(value, datetime.datetime): - value = localtime(self.rattail_config, value, tzinfo=False) + value = app.localtime(value, tzinfo=False) data[key] = value @@ -4099,6 +4108,7 @@ class MasterView(View): """ Return a dict for use when writing the row's data to XLSX download. """ + app = self.get_rattail_app() xlrow = {} for field in fields: value = getattr(row, field, None) @@ -4111,9 +4121,9 @@ class MasterView(View): # but we should make sure they're in "local" time zone effectively. # note however, this assumes a "naive" time value is in UTC zone! if value.tzinfo: - value = localtime(self.rattail_config, value, tzinfo=False) + value = app.localtime(value, tzinfo=False) else: - value = localtime(self.rattail_config, value, from_utc=True, tzinfo=False) + value = app.localtime(value, from_utc=True, tzinfo=False) xlrow[field] = value return xlrow @@ -4177,12 +4187,13 @@ class MasterView(View): """ Return a dict for use when writing the row's data to CSV download. """ + app = self.get_rattail_app() csvrow = {} for field in fields: value = getattr(obj, field, None) if isinstance(value, datetime.datetime): # TODO: this assumes value is *always* naive UTC - value = localtime(self.rattail_config, value, from_utc=True) + value = app.localtime(value, from_utc=True) csvrow[field] = '' if value is None else str(value) return csvrow @@ -4190,12 +4201,13 @@ class MasterView(View): """ Return a dict for use when writing the row's data to CSV download. """ + app = self.get_rattail_app() csvrow = {} for field in fields: value = getattr(row, field, None) if isinstance(value, datetime.datetime): # TODO: this assumes value is *always* naive UTC - value = localtime(self.rattail_config, value, from_utc=True) + value = app.localtime(value, from_utc=True) csvrow[field] = '' if value is None else str(value) return csvrow From 1973614840f30b906b81e019e7f409695f6e37cf Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 13 Apr 2024 09:09:23 -0500 Subject: [PATCH 1348/1681] Rename people "view_profile" template (drop buefy suffix) --- ...w_profile_buefy.mako => view_profile.mako} | 0 tailbone/views/people.py | 34 +++++++------------ 2 files changed, 12 insertions(+), 22 deletions(-) rename tailbone/templates/people/{view_profile_buefy.mako => view_profile.mako} (100%) diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile.mako similarity index 100% rename from tailbone/templates/people/view_profile_buefy.mako rename to tailbone/templates/people/view_profile.mako diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 071e58b5..7b175e25 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -34,12 +34,9 @@ import sqlalchemy_continuum as continuum from rattail.db import api from rattail.db.model import Person, PersonNote, MergePeopleRequest -from rattail.db.util import maxlen -from rattail.time import localtime from rattail.util import simple_error import colander -from pyramid.httpexceptions import HTTPFound, HTTPNotFound from webhelpers2.html import HTML, tags from tailbone import forms, grids @@ -221,7 +218,7 @@ class PersonView(MasterView): instance = self.Session.get(model.VendorContact, key) if instance: return instance.person - raise HTTPNotFound + raise self.notfound() def is_person_protected(self, person): for user in person.users: @@ -495,7 +492,7 @@ class PersonView(MasterView): if self.request.has_perm('people_profile.view_versions'): context['revisions_grid'] = self.profile_revisions_grid(person) - return self.render_to_response('view_profile_buefy', context) + return self.render_to_response('view_profile', context) def get_context_tabchecks(self, person): app = self.get_rattail_app() @@ -558,28 +555,21 @@ class PersonView(MasterView): """ return kwargs - def template_kwargs_view_profile_buefy(self, **kwargs): - """ - Note that any subclass should not need to define this method. - It by default invokes :meth:`template_kwargs_view_profile()` - and returns that result. - """ - return self.template_kwargs_view_profile(**kwargs) - def get_max_lengths(self): + app = self.get_rattail_app() model = self.model lengths = { - 'person_first_name': maxlen(model.Person.first_name), - 'person_middle_name': maxlen(model.Person.middle_name), - 'person_last_name': maxlen(model.Person.last_name), - 'address_street': maxlen(model.PersonMailingAddress.street), - 'address_street2': maxlen(model.PersonMailingAddress.street2), - 'address_city': maxlen(model.PersonMailingAddress.city), - 'address_state': maxlen(model.PersonMailingAddress.state), - 'address_zipcode': maxlen(model.PersonMailingAddress.zipcode), + 'person_first_name': app.maxlen(model.Person.first_name), + 'person_middle_name': app.maxlen(model.Person.middle_name), + 'person_last_name': app.maxlen(model.Person.last_name), + 'address_street': app.maxlen(model.PersonMailingAddress.street), + 'address_street2': app.maxlen(model.PersonMailingAddress.street2), + 'address_city': app.maxlen(model.PersonMailingAddress.city), + 'address_state': app.maxlen(model.PersonMailingAddress.state), + 'address_zipcode': app.maxlen(model.PersonMailingAddress.zipcode), } if self.people_handler.should_use_preferred_first_name(): - lengths['person_preferred_first_name'] = maxlen(model.Person.preferred_first_name) + lengths['person_preferred_first_name'] = app.maxlen(model.Person.preferred_first_name) return lengths def get_phone_type_options(self): From cd7c1bba21292bc9d255e03d65694586bfbbd698 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 13 Apr 2024 09:21:48 -0500 Subject: [PATCH 1349/1681] Rename template for grid filters (drop buefy suffix) also remove some deprecated functions --- tailbone/config.py | 21 +---- tailbone/templates/grids/buefy.mako | 2 +- tailbone/templates/grids/filters.mako | 98 ++++++++++++++------- tailbone/templates/grids/filters_buefy.mako | 70 --------------- 4 files changed, 67 insertions(+), 124 deletions(-) delete mode 100644 tailbone/templates/grids/filters_buefy.mako diff --git a/tailbone/config.py b/tailbone/config.py index 6106e87e..9326a3cb 100644 --- a/tailbone/config.py +++ b/tailbone/config.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -61,25 +61,6 @@ def csrf_header_name(config): return config.get('tailbone', 'csrf_header_name', default='X-CSRF-TOKEN') -def get_buefy_version(config): - warnings.warn("get_buefy_version() is deprecated; please use " - "tailbone.util.get_libver() instead", - DeprecationWarning, stacklevel=2) - - version = config.get('tailbone', 'libver.buefy') - if version: - return version - - return config.get('tailbone', 'buefy_version', - default='latest') - - -def get_buefy_0_8(config, version=None): - warnings.warn("get_buefy_0_8() is no longer supported", - DeprecationWarning, stacklevel=2) - return False - - def global_help_url(config): return config.get('tailbone', 'global_help_url') diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako index cbe33062..73c7e415 100644 --- a/tailbone/templates/grids/buefy.mako +++ b/tailbone/templates/grids/buefy.mako @@ -136,7 +136,7 @@ <div class="filters"> % if grid.filterable: ## TODO: stop using |n filter - ${grid.render_filters(template='/grids/filters_buefy.mako', allow_save_defaults=allow_save_defaults)|n} + ${grid.render_filters(allow_save_defaults=allow_save_defaults)|n} % endif </div> </div> diff --git a/tailbone/templates/grids/filters.mako b/tailbone/templates/grids/filters.mako index 857f53b1..5e1fef9b 100644 --- a/tailbone/templates/grids/filters.mako +++ b/tailbone/templates/grids/filters.mako @@ -1,38 +1,70 @@ ## -*- coding: utf-8; -*- -<div class="newfilters"> - ${h.form(form.action_url, method='get')} - ${h.hidden('reset-to-default-filters', value='false')} - ${h.hidden('save-current-filters-as-defaults', value='false')} +<form action="${form.action_url}" method="GET" @submit.prevent="applyFilters()"> - <fieldset> - <legend>Filters</legend> - % for filtr in form.iter_filters(): - <div class="filter" id="filter-${filtr.key}" data-key="${filtr.key}"${' style="display: none;"' if not filtr.active else ''|n}> - ${h.checkbox('{}-active'.format(filtr.key), class_='active', id='filter-active-{}'.format(filtr.key), checked=filtr.active)} - <label for="filter-active-${filtr.key}">${filtr.label}</label> - <div class="inputs" style="display: inline-block;"> - ${form.filter_verb(filtr)} - ${form.filter_value(filtr)} - </div> - </div> - % endfor - </fieldset> + <grid-filter v-for="key in filtersSequence" + :key="key" + :filter="filters[key]" + ref="gridFilters"> + </grid-filter> - <div class="buttons"> - <button type="submit" id="apply-filters">Apply Filters</button> - <select id="add-filter"> - <option value="">Add a Filter</option> - % for filtr in form.iter_filters(): - <option value="${filtr.key}"${' disabled="disabled"' if filtr.active else ''|n}>${filtr.label}</option> - % endfor - </select> - <button type="button" id="default-filters">Default View</button> - <button type="button" id="clear-filters">No Filters</button> - % if allow_save_defaults and request.user: - <button type="button" id="save-defaults">Save Defaults</button> - % endif - </div> + <b-field grouped> - ${h.end_form()} -</div><!-- newfilters --> + <b-button type="is-primary" + native-type="submit" + icon-pack="fas" + icon-left="check" + class="control"> + Apply Filters + </b-button> + + <b-button v-if="!addFilterShow" + icon-pack="fas" + icon-left="plus" + class="control" + @click="addFilterButton"> + Add Filter + </b-button> + + <b-autocomplete v-if="addFilterShow" + ref="addFilterAutocomplete" + :data="addFilterChoices" + v-model="addFilterTerm" + placeholder="Add Filter" + field="key" + :custom-formatter="filtr => filtr.label" + open-on-focus + keep-first + icon-pack="fas" + clearable + clear-on-select + @select="addFilterSelect" + @keydown.native="addFilterKeydown"> + </b-autocomplete> + + <b-button @click="resetView()" + icon-pack="fas" + icon-left="home" + class="control"> + Default View + </b-button> + + <b-button @click="clearFilters()" + icon-pack="fas" + icon-left="trash" + class="control"> + No Filters + </b-button> + + % if allow_save_defaults and request.user: + <b-button @click="saveDefaults()" + icon-pack="fas" + icon-left="save" + class="control"> + Save Defaults + </b-button> + % endif + + </b-field> + +</form> diff --git a/tailbone/templates/grids/filters_buefy.mako b/tailbone/templates/grids/filters_buefy.mako deleted file mode 100644 index 5e1fef9b..00000000 --- a/tailbone/templates/grids/filters_buefy.mako +++ /dev/null @@ -1,70 +0,0 @@ -## -*- coding: utf-8; -*- - -<form action="${form.action_url}" method="GET" @submit.prevent="applyFilters()"> - - <grid-filter v-for="key in filtersSequence" - :key="key" - :filter="filters[key]" - ref="gridFilters"> - </grid-filter> - - <b-field grouped> - - <b-button type="is-primary" - native-type="submit" - icon-pack="fas" - icon-left="check" - class="control"> - Apply Filters - </b-button> - - <b-button v-if="!addFilterShow" - icon-pack="fas" - icon-left="plus" - class="control" - @click="addFilterButton"> - Add Filter - </b-button> - - <b-autocomplete v-if="addFilterShow" - ref="addFilterAutocomplete" - :data="addFilterChoices" - v-model="addFilterTerm" - placeholder="Add Filter" - field="key" - :custom-formatter="filtr => filtr.label" - open-on-focus - keep-first - icon-pack="fas" - clearable - clear-on-select - @select="addFilterSelect" - @keydown.native="addFilterKeydown"> - </b-autocomplete> - - <b-button @click="resetView()" - icon-pack="fas" - icon-left="home" - class="control"> - Default View - </b-button> - - <b-button @click="clearFilters()" - icon-pack="fas" - icon-left="trash" - class="control"> - No Filters - </b-button> - - % if allow_save_defaults and request.user: - <b-button @click="saveDefaults()" - icon-pack="fas" - icon-left="save" - class="control"> - Save Defaults - </b-button> - % endif - - </b-field> - -</form> From 1103b09a767936f69c02c5714717cf5d1d28bee9 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 13 Apr 2024 09:45:10 -0500 Subject: [PATCH 1350/1681] Rename forms/deform template (drop buefy suffix) for now, deprecate `form.render()` method and just use `render_deform()` - but probably should change that to something else eventually..? --- tailbone/forms/core.py | 17 ++++++++--------- tailbone/templates/batch/view.mako | 4 ++-- tailbone/templates/form.mako | 2 +- .../forms/{deform_buefy.mako => deform.mako} | 0 tailbone/templates/forms/form.mako | 2 -- tailbone/templates/products/batch.mako | 2 +- 6 files changed, 12 insertions(+), 15 deletions(-) rename tailbone/templates/forms/{deform_buefy.mako => deform.mako} (100%) delete mode 100644 tailbone/templates/forms/form.mako diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index e04126a3..aee85330 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -794,12 +794,11 @@ class Form(object): def set_vuejs_field_converter(self, field, converter): self.vuejs_field_converters[field] = converter - def render(self, template=None, **kwargs): - if not template: - template = '/forms/form.mako' - context = kwargs - context['form'] = self - return render(template, context) + def render(self, **kwargs): + warnings.warn("Form.render() is deprecated (for now?); " + "please use Form.render_deform() instead", + DeprecationWarning, stacklevel=2) + return self.render_deform(**kwargs) def make_deform_form(self): if not hasattr(self, 'deform_form'): @@ -841,14 +840,14 @@ class Form(object): def render_deform(self, dform=None, template=None, **kwargs): if not template: - template = '/forms/deform_buefy.mako' + template = '/forms/deform.mako' if dform is None: dform = self.make_deform_form() # TODO: would perhaps be nice to leverage deform's default rendering # someday..? i.e. using Chameleon *.pt templates - # return form.render() + # return dform.render() context = kwargs context['form'] = self diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako index fa8fa19f..aa9677b7 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -150,8 +150,8 @@ <%def name="render_form()"> ## TODO: should use self.render_form_buttons() - ## ${form.render(form_id='batch-form', buttons=capture(self.render_form_buttons))|n} - ${form.render(form_id='batch-form', buttons=capture(buttons))|n} + ## ${form.render_deform(form_id='batch-form', buttons=capture(self.render_form_buttons))|n} + ${form.render_deform(form_id='batch-form', buttons=capture(buttons))|n} </%def> <%def name="render_this_page()"> diff --git a/tailbone/templates/form.mako b/tailbone/templates/form.mako index 5878e030..c225bd3a 100644 --- a/tailbone/templates/form.mako +++ b/tailbone/templates/form.mako @@ -6,7 +6,7 @@ <%def name="render_form_buttons()"></%def> <%def name="render_form()"> - ${form.render(buttons=capture(self.render_form_buttons))|n} + ${form.render_deform(buttons=capture(self.render_form_buttons))|n} </%def> <%def name="render_buefy_form()"> diff --git a/tailbone/templates/forms/deform_buefy.mako b/tailbone/templates/forms/deform.mako similarity index 100% rename from tailbone/templates/forms/deform_buefy.mako rename to tailbone/templates/forms/deform.mako diff --git a/tailbone/templates/forms/form.mako b/tailbone/templates/forms/form.mako deleted file mode 100644 index cd8fecc8..00000000 --- a/tailbone/templates/forms/form.mako +++ /dev/null @@ -1,2 +0,0 @@ -## -*- coding: utf-8; -*- -${form.render_deform(buttons=buttons)|n} diff --git a/tailbone/templates/products/batch.mako b/tailbone/templates/products/batch.mako index 81af729b..868ad9b1 100644 --- a/tailbone/templates/products/batch.mako +++ b/tailbone/templates/products/batch.mako @@ -64,7 +64,7 @@ ${parent.modify_this_page_vars()} <script type="text/javascript"> - ## TODO: ugh, an awful lot of duplicated code here (from /forms/deform_buefy.mako) + ## TODO: ugh, an awful lot of duplicated code here (from /forms/deform.mako) let ${form.component_studly} = { template: '#${form.component}-template', From 96ba039299e7a2efd51bfc33a4ae9853c6b7fef3 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 13 Apr 2024 10:02:57 -0500 Subject: [PATCH 1351/1681] Rename grids/complete template (avoid buefy name) and rename grid methods accordingly --- CHANGES.rst | 11 ++++++++ tailbone/grids/core.py | 25 +++++++++---------- .../grids/{buefy.mako => complete.mako} | 0 tailbone/templates/master/index.mako | 2 +- tailbone/templates/master/versions.mako | 2 +- tailbone/templates/master/view.mako | 4 +-- 6 files changed, 27 insertions(+), 17 deletions(-) rename tailbone/templates/grids/{buefy.mako => complete.mako} (100%) diff --git a/CHANGES.rst b/CHANGES.rst index 400557d3..1edf8a2a 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,17 @@ CHANGELOG Unreleased ---------- +* Avoid uncaught error when updating order batch row quantities. + +* Try to return JSON error when receiving API call fails. + +* Avoid error for tax field when creating new department. + +* Show toast msg instead of silent error, when grid fetch fails. + +* Rename template files to avoid "buefy" names. + + 0.9.90 (2024-04-01) ------------------- diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 41964648..c03ac2c0 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -1333,18 +1333,7 @@ class Grid(object): data = self.pager return data - def render_complete(self, template='/grids/buefy.mako', **kwargs): - """ - Render the complete grid, including filters. - """ - context = kwargs - context['grid'] = self - context['request'] = self.request - context.setdefault('allow_save_defaults', True) - context.setdefault('view_click_handler', self.get_view_click_handler()) - return render(template, context) - - def render_buefy(self, template='/grids/buefy.mako', **kwargs): + def render_complete(self, template='/grids/complete.mako', **kwargs): """ Render the Buefy grid, complete with filters. Note that this also includes the context menu items and grid tools. @@ -1364,7 +1353,17 @@ class Grid(object): if self.filterable and 'filters_sequence' not in kwargs: kwargs['filters_sequence'] = self.get_filters_sequence() - return self.render_complete(template=template, **kwargs) + context = kwargs + context['grid'] = self + context['request'] = self.request + context.setdefault('allow_save_defaults', True) + context.setdefault('view_click_handler', self.get_view_click_handler()) + return render(template, context) + + def render_buefy(self, **kwargs): + warnings.warn("Grid.render_buefy() is deprecated; " + "please use Grid.render_complete() instead", + DeprecationWarning, stacklevel=2) def render_buefy_table_element(self, template='/grids/b-table.mako', data_prop='gridData', empty_labels=False, diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/complete.mako similarity index 100% rename from tailbone/templates/grids/buefy.mako rename to tailbone/templates/grids/complete.mako diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako index b0ee17d6..051a9ab6 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -327,7 +327,7 @@ ${parent.render_this_page_template()} ## TODO: stop using |n filter - ${grid.render_buefy(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())|n} + ${grid.render_complete(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())|n} </%def> <%def name="modify_this_page_vars()"> diff --git a/tailbone/templates/master/versions.mako b/tailbone/templates/master/versions.mako index bfec39b7..307674b8 100644 --- a/tailbone/templates/master/versions.mako +++ b/tailbone/templates/master/versions.mako @@ -31,7 +31,7 @@ ${parent.render_this_page_template()} ## TODO: stop using |n filter - ${grid.render_buefy()|n} + ${grid.render_complete()|n} </%def> <%def name="page_content()"> diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako index 9a37b2bb..dcf1f8ee 100644 --- a/tailbone/templates/master/view.mako +++ b/tailbone/templates/master/view.mako @@ -228,11 +228,11 @@ <%def name="render_this_page_template()"> % if master.has_rows: ## TODO: stop using |n filter - ${rows_grid.render_buefy(allow_save_defaults=False, tools=capture(self.render_row_grid_tools))|n} + ${rows_grid.render_complete(allow_save_defaults=False, tools=capture(self.render_row_grid_tools))|n} % endif ${parent.render_this_page_template()} % if expose_versions: - ${versions_grid.render_buefy()|n} + ${versions_grid.render_complete()|n} % endif </%def> From c036932ce482479c218b12c443a4e37bfba57fd3 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 14 Apr 2024 19:54:29 -0500 Subject: [PATCH 1352/1681] Remove several references to "buefy" name class methods, template filenames, etc. also made various edits per newer conventions --- tailbone/forms/core.py | 25 +++--- tailbone/forms/widgets.py | 3 +- tailbone/grids/core.py | 28 +++---- tailbone/templates/appsettings.mako | 2 +- .../templates/batch/importer/view_row.mako | 2 +- .../batch/inventory/desktop_form.mako | 2 +- .../batch/vendorcatalog/view_row.mako | 2 +- tailbone/templates/batch/view.mako | 4 +- tailbone/templates/customers/view.mako | 2 +- tailbone/templates/custorders/items/view.mako | 2 +- ...ipients_buefy.pt => message_recipients.pt} | 0 tailbone/templates/form.mako | 8 +- tailbone/templates/forms/deform.mako | 4 +- tailbone/templates/forms/util.mako | 7 -- tailbone/templates/master/clone.mako | 4 +- tailbone/templates/master/delete.mako | 4 +- tailbone/templates/people/view.mako | 2 +- tailbone/templates/people/view_profile.mako | 8 +- .../templates/principal/find_by_perm.mako | 6 +- tailbone/templates/products/batch.mako | 2 +- tailbone/templates/products/view.mako | 20 ++--- .../templates/receiving/declare_credit.mako | 17 ++-- tailbone/templates/receiving/receive_row.mako | 17 ++-- .../templates/reports/generated/choose.mako | 2 +- .../templates/reports/generated/generate.mako | 2 +- tailbone/templates/settings/email/view.mako | 4 +- tailbone/templates/upgrades/view.mako | 2 +- tailbone/templates/users/preferences.mako | 4 +- tailbone/views/batch/core.py | 78 +++++++++++-------- tailbone/views/batch/handheld.py | 24 +++--- tailbone/views/batch/pos.py | 4 +- tailbone/views/batch/product.py | 27 +++---- tailbone/views/customers.py | 22 +----- tailbone/views/custorders/items.py | 4 +- tailbone/views/departments.py | 2 +- tailbone/views/master.py | 58 +++++++------- tailbone/views/messages.py | 14 ++-- tailbone/views/principal.py | 12 +-- tailbone/views/products.py | 66 +++++++++------- tailbone/views/purchasing/batch.py | 7 +- tailbone/views/purchasing/ordering.py | 6 +- tailbone/views/purchasing/receiving.py | 15 ++-- tailbone/views/reports.py | 51 ++++++------ tailbone/views/roles.py | 18 +++-- tailbone/views/settings.py | 27 +++---- tailbone/views/shifts/lib.py | 36 +++++---- tailbone/views/tables.py | 24 +++--- tailbone/views/tempmon/core.py | 4 +- tailbone/views/trainwreck/base.py | 20 ++--- tailbone/views/users.py | 30 +++---- 50 files changed, 373 insertions(+), 361 deletions(-) rename tailbone/templates/deform/{message_recipients_buefy.pt => message_recipients.pt} (100%) delete mode 100644 tailbone/templates/forms/util.mako diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index aee85330..9ef8cb2b 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -33,10 +33,9 @@ from collections import OrderedDict import sqlalchemy as sa from sqlalchemy import orm from sqlalchemy.ext.associationproxy import AssociationProxy, ASSOCIATION_PROXY +from wuttjamaican.util import UNSPECIFIED -from rattail.time import localtime -from rattail.util import prettify, pretty_boolean, pretty_quantity -from rattail.core import UNSPECIFIED +from rattail.util import prettify, pretty_boolean from rattail.db.util import get_fieldnames import colander @@ -50,10 +49,10 @@ from webhelpers2.html import tags, HTML from tailbone.db import Session from tailbone.util import raw_datetime, get_form_data, render_markdown -from . import types -from .widgets import (ReadonlyWidget, PlainDateWidget, - JQueryDateWidget, JQueryTimeWidget, - MultiFileUploadWidget) +from tailbone.forms import types +from tailbone.forms.widgets import (ReadonlyWidget, PlainDateWidget, + JQueryDateWidget, JQueryTimeWidget, + MultiFileUploadWidget) from tailbone.exceptions import TailboneJSONFieldError @@ -225,7 +224,7 @@ class CustomSchemaNode(SQLAlchemySchemaNode): if excludes: overrides['excludes'] = excludes - return super(CustomSchemaNode, self).get_schema_from_relationship(prop, overrides) + return super().get_schema_from_relationship(prop, overrides) def dictify(self, obj): """ Return a dictified version of `obj` using schema information. @@ -234,7 +233,7 @@ class CustomSchemaNode(SQLAlchemySchemaNode): This method was copied from upstream and modified to add automatic handling of "association proxy" fields. """ - dict_ = super(CustomSchemaNode, self).dictify(obj) + dict_ = super().dictify(obj) for node in self: name = node.name @@ -967,7 +966,7 @@ class Form(object): kwargs.setdefault(':configure-fields-help', 'configureFieldsHelp') return HTML.tag(self.component, **kwargs) - def render_buefy_field(self, fieldname, bfield_attrs={}): + def render_field_complete(self, fieldname, bfield_attrs={}): """ Render the given field in a Buefy-compatible way. Note that this is meant to render *editable* fields, i.e. showing a @@ -1131,7 +1130,8 @@ class Form(object): value = self.obtain_value(record, field_name) if value is None: return "" - value = localtime(self.request.rattail_config, value) + app = self.get_rattail_app() + value = app.localtime(value) return raw_datetime(self.request.rattail_config, value) def render_duration(self, record, field_name): @@ -1160,7 +1160,8 @@ class Form(object): value = self.obtain_value(obj, field) if value is None: return "" - return pretty_quantity(value) + app = self.get_rattail_app() + return app.render_quantity(value) def render_percent(self, obj, field): app = self.request.rattail_config.get_app() diff --git a/tailbone/forms/widgets.py b/tailbone/forms/widgets.py index db57f4f0..63813452 100644 --- a/tailbone/forms/widgets.py +++ b/tailbone/forms/widgets.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -153,6 +153,7 @@ class DynamicCheckboxWidget(dfwidget.CheckboxWidget): template = 'checkbox_dynamic' +# TODO: deprecate / remove this class PlainSelectWidget(dfwidget.SelectWidget): template = 'select_plain' diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index c03ac2c0..b2f90204 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -1339,10 +1339,10 @@ class Grid(object): includes the context menu items and grid tools. """ if 'grid_columns' not in kwargs: - kwargs['grid_columns'] = self.get_buefy_columns() + kwargs['grid_columns'] = self.get_table_columns() if 'grid_data' not in kwargs: - kwargs['grid_data'] = self.get_buefy_data() + kwargs['grid_data'] = self.get_table_data() if 'static_data' not in kwargs: kwargs['static_data'] = self.has_static_data() @@ -1364,10 +1364,11 @@ class Grid(object): warnings.warn("Grid.render_buefy() is deprecated; " "please use Grid.render_complete() instead", DeprecationWarning, stacklevel=2) + return self.render_complete(**kwargs) - def render_buefy_table_element(self, template='/grids/b-table.mako', - data_prop='gridData', empty_labels=False, - **kwargs): + def render_table_element(self, template='/grids/b-table.mako', + data_prop='gridData', empty_labels=False, + **kwargs): """ This is intended for ad-hoc "small" grids with static data. Renders just a ``<b-table>`` element instead of the typical "full" grid. @@ -1377,7 +1378,7 @@ class Grid(object): context['data_prop'] = data_prop context['empty_labels'] = empty_labels if 'grid_columns' not in context: - context['grid_columns'] = self.get_buefy_columns() + context['grid_columns'] = self.get_table_columns() context.setdefault('paginated', False) if context['paginated']: context.setdefault('per_page', 20) @@ -1572,10 +1573,10 @@ class Grid(object): return True return False - def get_buefy_columns(self): + def get_table_columns(self): """ - Return a list of dicts representing all grid columns. Meant for use - with Buefy table. + Return a list of dicts representing all grid columns. Meant + for use with the client-side JS table. """ columns = [] for name in self.columns: @@ -1597,9 +1598,10 @@ class Grid(object): if hasattr(rowobj, 'uuid'): return rowobj.uuid - def get_buefy_data(self): + def get_table_data(self): """ - Returns a list of data rows for the grid, for use with Buefy table. + Returns a list of data rows for the grid, for use with + client-side JS table. """ # filter / sort / paginate to get "visible" data raw_data = self.make_visible_data() @@ -1635,8 +1637,8 @@ class Grid(object): # instance, when the "display" version is different than raw data. # here is the hack we use for that. columns = list(self.columns) - if hasattr(self, 'buefy_data_columns'): - columns.extend(self.buefy_data_columns) + if hasattr(self, 'raw_data_columns'): + columns.extend(self.raw_data_columns) # iterate over data fields for name in columns: diff --git a/tailbone/templates/appsettings.mako b/tailbone/templates/appsettings.mako index 46f4a7e3..4f935956 100644 --- a/tailbone/templates/appsettings.mako +++ b/tailbone/templates/appsettings.mako @@ -154,7 +154,7 @@ ${parent.modify_this_page_vars()} <script type="text/javascript"> - ThisPageData.groups = ${json.dumps(buefy_data)|n} + ThisPageData.groups = ${json.dumps(settings_data)|n} ThisPageData.showingGroup = ${json.dumps(current_group or '')|n} </script> diff --git a/tailbone/templates/batch/importer/view_row.mako b/tailbone/templates/batch/importer/view_row.mako index 9e08cf43..7d6f121f 100644 --- a/tailbone/templates/batch/importer/view_row.mako +++ b/tailbone/templates/batch/importer/view_row.mako @@ -68,7 +68,7 @@ % endif </%def> -<%def name="render_buefy_form()"> +<%def name="render_form()"> <div class="form"> <tailbone-form></tailbone-form> <br /> diff --git a/tailbone/templates/batch/inventory/desktop_form.mako b/tailbone/templates/batch/inventory/desktop_form.mako index 9f13cbf9..7e4795a8 100644 --- a/tailbone/templates/batch/inventory/desktop_form.mako +++ b/tailbone/templates/batch/inventory/desktop_form.mako @@ -34,7 +34,7 @@ </nav> </%def> -<%def name="render_form()"> +<%def name="render_form_template()"> <script type="text/x-template" id="${form.component}-template"> <div class="product-info"> diff --git a/tailbone/templates/batch/vendorcatalog/view_row.mako b/tailbone/templates/batch/vendorcatalog/view_row.mako index 6aaf9bf4..0128e3b3 100644 --- a/tailbone/templates/batch/vendorcatalog/view_row.mako +++ b/tailbone/templates/batch/vendorcatalog/view_row.mako @@ -1,7 +1,7 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view_row.mako" /> -<%def name="render_buefy_form()"> +<%def name="render_form()"> <div class="form"> <tailbone-form></tailbone-form> <br /> diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako index aa9677b7..a87b31a6 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -148,7 +148,7 @@ </div> </%def> -<%def name="render_form()"> +<%def name="render_form_template()"> ## TODO: should use self.render_form_buttons() ## ${form.render_deform(form_id='batch-form', buttons=capture(self.render_form_buttons))|n} ${form.render_deform(form_id='batch-form', buttons=capture(buttons))|n} @@ -206,7 +206,7 @@ % endif </%def> -<%def name="render_buefy_form()"> +<%def name="render_form()"> <div class="form"> <${form.component} @show-upload="showUploadDialog = true"> </${form.component}> diff --git a/tailbone/templates/customers/view.mako b/tailbone/templates/customers/view.mako index 85ec0055..2fa7c417 100644 --- a/tailbone/templates/customers/view.mako +++ b/tailbone/templates/customers/view.mako @@ -9,7 +9,7 @@ % endif </%def> -<%def name="render_buefy_form()"> +<%def name="render_form()"> <div class="form"> <tailbone-form @detach-person="detachPerson"> </tailbone-form> diff --git a/tailbone/templates/custorders/items/view.mako b/tailbone/templates/custorders/items/view.mako index 592095ff..41567d41 100644 --- a/tailbone/templates/custorders/items/view.mako +++ b/tailbone/templates/custorders/items/view.mako @@ -1,7 +1,7 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -<%def name="render_buefy_form()"> +<%def name="render_form()"> <div class="form"> <${form.component} ref="mainForm" % if master.has_perm('confirm_price'): diff --git a/tailbone/templates/deform/message_recipients_buefy.pt b/tailbone/templates/deform/message_recipients.pt similarity index 100% rename from tailbone/templates/deform/message_recipients_buefy.pt rename to tailbone/templates/deform/message_recipients.pt diff --git a/tailbone/templates/form.mako b/tailbone/templates/form.mako index c225bd3a..0352b04c 100644 --- a/tailbone/templates/form.mako +++ b/tailbone/templates/form.mako @@ -5,11 +5,11 @@ <%def name="render_form_buttons()"></%def> -<%def name="render_form()"> +<%def name="render_form_template()"> ${form.render_deform(buttons=capture(self.render_form_buttons))|n} </%def> -<%def name="render_buefy_form()"> +<%def name="render_form()"> <div class="form"> ${form.render_vuejs_component()} </div> @@ -18,7 +18,7 @@ <%def name="page_content()"> <div class="form-wrapper"> <br /> - ${self.render_buefy_form()} + ${self.render_form()} </div> </%def> @@ -49,7 +49,7 @@ <%def name="render_this_page_template()"> % if form is not Undefined: - ${self.render_form()} + ${self.render_form_template()} % endif ${parent.render_this_page_template()} </%def> diff --git a/tailbone/templates/forms/deform.mako b/tailbone/templates/forms/deform.mako index 39633117..db63a424 100644 --- a/tailbone/templates/forms/deform.mako +++ b/tailbone/templates/forms/deform.mako @@ -18,7 +18,7 @@ <div class="panel-block"> <div> % for field in form.grouping[group]: - ${form.render_buefy_field(field)} + ${form.render_field_complete(field)} % endfor </div> </div> @@ -26,7 +26,7 @@ % endfor % else: % for field in form.fields: - ${form.render_buefy_field(field)} + ${form.render_field_complete(field)} % endfor % endif </section> diff --git a/tailbone/templates/forms/util.mako b/tailbone/templates/forms/util.mako deleted file mode 100644 index 22e7f918..00000000 --- a/tailbone/templates/forms/util.mako +++ /dev/null @@ -1,7 +0,0 @@ -## -*- coding: utf-8; -*- - -## TODO: deprecate / remove this -## (tried to add deprecation warning here but it didn't seem to work) -<%def name="render_buefy_field(field, bfield_kwargs={})"> - ${form.render_buefy_field(field.name, bfield_attrs=bfield_kwargs)} -</%def> diff --git a/tailbone/templates/master/clone.mako b/tailbone/templates/master/clone.mako index 07784f74..59d6aea2 100644 --- a/tailbone/templates/master/clone.mako +++ b/tailbone/templates/master/clone.mako @@ -3,12 +3,12 @@ <%def name="title()">Clone ${model_title}: ${instance_title}</%def> -<%def name="render_buefy_form()"> +<%def name="render_form()"> <br /> <b-notification :closable="false"> You are about to clone the following ${model_title} as a new record: </b-notification> - ${parent.render_buefy_form()} + ${parent.render_form()} </%def> <%def name="render_form_buttons()"> diff --git a/tailbone/templates/master/delete.mako b/tailbone/templates/master/delete.mako index 0cb5b6c2..30bb50ab 100644 --- a/tailbone/templates/master/delete.mako +++ b/tailbone/templates/master/delete.mako @@ -3,12 +3,12 @@ <%def name="title()">Delete ${model_title}: ${instance_title}</%def> -<%def name="render_buefy_form()"> +<%def name="render_form()"> <br /> <b-notification type="is-danger" :closable="false"> You are about to delete the following ${model_title} and all associated data: </b-notification> - ${parent.render_buefy_form()} + ${parent.render_form()} </%def> <%def name="render_form_buttons()"> diff --git a/tailbone/templates/people/view.mako b/tailbone/templates/people/view.mako index 973a1da8..184f2b91 100644 --- a/tailbone/templates/people/view.mako +++ b/tailbone/templates/people/view.mako @@ -7,7 +7,7 @@ ${view_profiles_helper([instance])} </%def> -<%def name="render_buefy_form()"> +<%def name="render_form()"> <div class="form"> <tailbone-form v-on:make-user="makeUser"></tailbone-form> </div> diff --git a/tailbone/templates/people/view_profile.mako b/tailbone/templates/people/view_profile.mako index 81243464..65c96fd6 100644 --- a/tailbone/templates/people/view_profile.mako +++ b/tailbone/templates/people/view_profile.mako @@ -1403,10 +1403,10 @@ % if request.has_perm('people_profile.view_versions'): - ${revisions_grid.render_buefy_table_element(data_prop='revisions', - show_footer=True, - vshow='viewingHistory', - loading='gettingRevisions')|n} + ${revisions_grid.render_table_element(data_prop='revisions', + show_footer=True, + vshow='viewingHistory', + loading='gettingRevisions')|n} <b-modal :active.sync="showingRevisionDialog"> diff --git a/tailbone/templates/principal/find_by_perm.mako b/tailbone/templates/principal/find_by_perm.mako index e0536324..3bf47dc1 100644 --- a/tailbone/templates/principal/find_by_perm.mako +++ b/tailbone/templates/principal/find_by_perm.mako @@ -95,8 +95,8 @@ ${parent.modify_this_page_vars()} <script type="text/javascript"> - ThisPageData.permissionGroups = ${json.dumps(buefy_perms)|n} - ThisPageData.sortedGroups = ${json.dumps(buefy_sorted_groups)|n} + ThisPageData.permissionGroups = ${json.dumps(perms_data)|n} + ThisPageData.sortedGroups = ${json.dumps(sorted_groups_data)|n} </script> </%def> @@ -113,7 +113,7 @@ }, data() { return { - groupPermissions: ${json.dumps(buefy_perms.get(selected_group, {}).get('permissions', []))|n}, + groupPermissions: ${json.dumps(perms_data.get(selected_group, {}).get('permissions', []))|n}, permissionGroupTerm: '', permissionTerm: '', selectedGroup: ${json.dumps(selected_group)|n}, diff --git a/tailbone/templates/products/batch.mako b/tailbone/templates/products/batch.mako index 868ad9b1..e0b93bd6 100644 --- a/tailbone/templates/products/batch.mako +++ b/tailbone/templates/products/batch.mako @@ -54,7 +54,7 @@ ${h.end_form()} </%def> -<%def name="render_form()"> +<%def name="render_form_template()"> <script type="text/x-template" id="${form.component}-template"> ${self.render_form_innards()} </script> diff --git a/tailbone/templates/products/view.mako b/tailbone/templates/products/view.mako index 5de6d099..c4da08ba 100644 --- a/tailbone/templates/products/view.mako +++ b/tailbone/templates/products/view.mako @@ -108,7 +108,7 @@ </%def> <%def name="lookup_codes_grid()"> - ${lookup_codes['grid'].render_buefy_table_element(data_prop='lookupCodesData')|n} + ${lookup_codes['grid'].render_table_element(data_prop='lookupCodesData')|n} </%def> <%def name="lookup_codes_panel()"> @@ -121,7 +121,7 @@ </%def> <%def name="sources_grid()"> - ${vendor_sources['grid'].render_buefy_table_element(data_prop='vendorSourcesData')|n} + ${vendor_sources['grid'].render_table_element(data_prop='vendorSourcesData')|n} </%def> <%def name="sources_panel()"> @@ -175,7 +175,7 @@ </p> </header> <section class="modal-card-body"> - ${regular_price_history_grid.render_buefy_table_element(data_prop='regularPriceHistoryData', loading='regularPriceHistoryLoading', paginated=True, per_page=10)|n} + ${regular_price_history_grid.render_table_element(data_prop='regularPriceHistoryData', loading='regularPriceHistoryLoading', paginated=True, per_page=10)|n} </section> <footer class="modal-card-foot"> <b-button @click="showingPriceHistory_regular = false"> @@ -194,7 +194,7 @@ </p> </header> <section class="modal-card-body"> - ${current_price_history_grid.render_buefy_table_element(data_prop='currentPriceHistoryData', loading='currentPriceHistoryLoading', paginated=True, per_page=10)|n} + ${current_price_history_grid.render_table_element(data_prop='currentPriceHistoryData', loading='currentPriceHistoryLoading', paginated=True, per_page=10)|n} </section> <footer class="modal-card-foot"> <b-button @click="showingPriceHistory_current = false"> @@ -213,7 +213,7 @@ </p> </header> <section class="modal-card-body"> - ${suggested_price_history_grid.render_buefy_table_element(data_prop='suggestedPriceHistoryData', loading='suggestedPriceHistoryLoading', paginated=True, per_page=10)|n} + ${suggested_price_history_grid.render_table_element(data_prop='suggestedPriceHistoryData', loading='suggestedPriceHistoryLoading', paginated=True, per_page=10)|n} </section> <footer class="modal-card-foot"> <b-button @click="showingPriceHistory_suggested = false"> @@ -232,7 +232,7 @@ </p> </header> <section class="modal-card-body"> - ${cost_history_grid.render_buefy_table_element(data_prop='costHistoryData', loading='costHistoryLoading', paginated=True, per_page=10)|n} + ${cost_history_grid.render_table_element(data_prop='costHistoryData', loading='costHistoryLoading', paginated=True, per_page=10)|n} </section> <footer class="modal-card-foot"> <b-button @click="showingCostHistory = false"> @@ -289,7 +289,7 @@ % if request.rattail_config.versioning_enabled() and master.has_perm('versions'): ThisPageData.showingPriceHistory_regular = false - ThisPageData.regularPriceHistoryDataRaw = ${json.dumps(regular_price_history_grid.get_buefy_data()['data'])|n} + ThisPageData.regularPriceHistoryDataRaw = ${json.dumps(regular_price_history_grid.get_table_data()['data'])|n} ThisPageData.regularPriceHistoryLoading = false ThisPage.computed.regularPriceHistoryData = function() { @@ -318,7 +318,7 @@ } ThisPageData.showingPriceHistory_current = false - ThisPageData.currentPriceHistoryDataRaw = ${json.dumps(current_price_history_grid.get_buefy_data()['data'])|n} + ThisPageData.currentPriceHistoryDataRaw = ${json.dumps(current_price_history_grid.get_table_data()['data'])|n} ThisPageData.currentPriceHistoryLoading = false ThisPage.computed.currentPriceHistoryData = function() { @@ -348,7 +348,7 @@ } ThisPageData.showingPriceHistory_suggested = false - ThisPageData.suggestedPriceHistoryDataRaw = ${json.dumps(suggested_price_history_grid.get_buefy_data()['data'])|n} + ThisPageData.suggestedPriceHistoryDataRaw = ${json.dumps(suggested_price_history_grid.get_table_data()['data'])|n} ThisPageData.suggestedPriceHistoryLoading = false ThisPage.computed.suggestedPriceHistoryData = function() { @@ -377,7 +377,7 @@ } ThisPageData.showingCostHistory = false - ThisPageData.costHistoryDataRaw = ${json.dumps(cost_history_grid.get_buefy_data()['data'])|n} + ThisPageData.costHistoryDataRaw = ${json.dumps(cost_history_grid.get_table_data()['data'])|n} ThisPageData.costHistoryLoading = false ThisPage.computed.costHistoryData = function() { diff --git a/tailbone/templates/receiving/declare_credit.mako b/tailbone/templates/receiving/declare_credit.mako index 6224a539..a377e270 100644 --- a/tailbone/templates/receiving/declare_credit.mako +++ b/tailbone/templates/receiving/declare_credit.mako @@ -1,6 +1,5 @@ ## -*- coding: utf-8; -*- <%inherit file="/form.mako" /> -<%namespace file="/forms/util.mako" import="render_buefy_field" /> <%def name="title()">Declare Credit for Row #${row.sequence}</%def> @@ -11,7 +10,7 @@ % endif </%def> -<%def name="render_buefy_form()"> +<%def name="render_form()"> <p class="block"> Please select the "state" of the product, and enter the @@ -31,22 +30,22 @@ if you need to "receive" instead of "convert" the product. </p> - ${parent.render_buefy_form()} + ${parent.render_form()} </%def> -<%def name="buefy_form_body()"> +<%def name="form_body()"> - ${render_buefy_field(dform['credit_type'])} + ${form.render_field_complete('credit_type')} - ${render_buefy_field(dform['quantity'])} + ${form.render_field_complete('quantity')} - ${render_buefy_field(dform['expiration_date'], bfield_kwargs={'v-show': "field_model_credit_type == 'expired'"})} + ${form.render_field_complete('expiration_date', bfield_attrs={'v-show': "field_model_credit_type == 'expired'"})} </%def> -<%def name="render_form()"> - ${form.render_deform(buttons=capture(self.render_form_buttons), form_body=capture(self.buefy_form_body))|n} +<%def name="render_form_template()"> + ${form.render_deform(buttons=capture(self.render_form_buttons), form_body=capture(self.form_body))|n} </%def> diff --git a/tailbone/templates/receiving/receive_row.mako b/tailbone/templates/receiving/receive_row.mako index 7ef95ac4..48dc6755 100644 --- a/tailbone/templates/receiving/receive_row.mako +++ b/tailbone/templates/receiving/receive_row.mako @@ -1,6 +1,5 @@ ## -*- coding: utf-8; -*- <%inherit file="/form.mako" /> -<%namespace file="/forms/util.mako" import="render_buefy_field" /> <%def name="title()">Receive for Row #${row.sequence}</%def> @@ -11,7 +10,7 @@ % endif </%def> -<%def name="render_buefy_form()"> +<%def name="render_form()"> <p class="block"> Please select the "state" of the product, and enter the appropriate @@ -28,22 +27,22 @@ if you need to "convert" some already-received amount, into a credit. </p> - ${parent.render_buefy_form()} + ${parent.render_form()} </%def> -<%def name="buefy_form_body()"> +<%def name="form_body()"> - ${render_buefy_field(dform['mode'])} + ${form.render_field_complete('mode')} - ${render_buefy_field(dform['quantity'])} + ${form.render_field_complete('quantity')} - ${render_buefy_field(dform['expiration_date'], bfield_kwargs={'v-show': "field_model_mode == 'expired'"})} + ${form.render_field_complete('expiration_date', bfield_attrs={'v-show': "field_model_mode == 'expired'"})} </%def> -<%def name="render_form()"> - ${form.render_deform(buttons=capture(self.render_form_buttons), form_body=capture(self.buefy_form_body))|n} +<%def name="render_form_template()"> + ${form.render_deform(buttons=capture(self.render_form_buttons), form_body=capture(self.form_body))|n} </%def> diff --git a/tailbone/templates/reports/generated/choose.mako b/tailbone/templates/reports/generated/choose.mako index 55cf71dd..a952fb6a 100644 --- a/tailbone/templates/reports/generated/choose.mako +++ b/tailbone/templates/reports/generated/choose.mako @@ -23,7 +23,7 @@ </style> </%def> -<%def name="render_buefy_form()"> +<%def name="render_form()"> <div class="form"> <p>Please select the type of report you wish to generate.</p> <br /> diff --git a/tailbone/templates/reports/generated/generate.mako b/tailbone/templates/reports/generated/generate.mako index 9feb9f83..2b8fa66c 100644 --- a/tailbone/templates/reports/generated/generate.mako +++ b/tailbone/templates/reports/generated/generate.mako @@ -5,7 +5,7 @@ <%def name="content_title()">New Report: ${report.name}</%def> -<%def name="render_buefy_form()"> +<%def name="render_form()"> <div class="form"> <p class="block"> ${report.__doc__} diff --git a/tailbone/templates/settings/email/view.mako b/tailbone/templates/settings/email/view.mako index 1d292c69..2a29ce0e 100644 --- a/tailbone/templates/settings/email/view.mako +++ b/tailbone/templates/settings/email/view.mako @@ -1,8 +1,8 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -<%def name="render_buefy_form()"> - ${parent.render_buefy_form()} +<%def name="render_form()"> + ${parent.render_form()} <email-preview-tools></email-preview-tools> </%def> diff --git a/tailbone/templates/upgrades/view.mako b/tailbone/templates/upgrades/view.mako index c5419574..fe20c1e1 100644 --- a/tailbone/templates/upgrades/view.mako +++ b/tailbone/templates/upgrades/view.mako @@ -75,7 +75,7 @@ % endif </%def> -<%def name="render_buefy_form()"> +<%def name="render_form()"> <div class="form"> <${form.component} % if master.has_perm('execute'): diff --git a/tailbone/templates/users/preferences.mako b/tailbone/templates/users/preferences.mako index a44534dc..f1432676 100644 --- a/tailbone/templates/users/preferences.mako +++ b/tailbone/templates/users/preferences.mako @@ -30,7 +30,7 @@ <b-select name="tailbone.${user.uuid}.buefy_css" v-model="simpleSettings['tailbone.${user.uuid}.buefy_css']" @input="settingsNeedSaved = true"> - <option v-for="option in buefyCSSOptions" + <option v-for="option in themeStyleOptions" :key="option.value" :value="option.value"> {{ option.label }} @@ -46,7 +46,7 @@ ${parent.modify_this_page_vars()} <script type="text/javascript"> - ThisPageData.buefyCSSOptions = ${json.dumps(buefy_css_options)|n} + ThisPageData.themeStyleOptions = ${json.dumps(theme_style_options)|n} </script> </%def> diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index f8b53d13..46bdbb17 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -32,23 +32,18 @@ import logging import socket import subprocess import tempfile +import warnings import json import markdown import sqlalchemy as sa from sqlalchemy import orm -from rattail.db import model, Session as RattailSession -from rattail.db.util import short_session from rattail.threads import Thread -from rattail.util import prettify, simple_error -from rattail.progress import SocketProgress +from rattail.util import simple_error import colander -import deform from deform import widget as dfwidget -from pyramid.renderers import render_to_response -from pyramid.response import FileResponse from webhelpers2.html import HTML, tags from tailbone import forms, grids @@ -115,7 +110,7 @@ class BatchMasterView(MasterView): } def __init__(self, request): - super(BatchMasterView, self).__init__(request) + super().__init__(request) self.batch_handler = self.get_handler() # TODO: deprecate / remove this (?) self.handler = self.batch_handler @@ -167,7 +162,7 @@ class BatchMasterView(MasterView): return self.rattail_config.batch_filepath(batch.batch_key, batch.uuid, filename) def template_kwargs_view(self, **kwargs): - kwargs = super(BatchMasterView, self).template_kwargs_view(**kwargs) + kwargs = super().template_kwargs_view(**kwargs) batch = kwargs['instance'] kwargs['batch'] = batch kwargs['handler'] = self.handler @@ -195,8 +190,8 @@ class BatchMasterView(MasterView): g.set_click_handler('title', "autoFilterStatus(props.row)") kwargs['status_breakdown_data'] = breakdown kwargs['status_breakdown_grid'] = HTML.literal( - g.render_buefy_table_element(data_prop='statusBreakdownData', - empty_labels=True)) + g.render_table_element(data_prop='statusBreakdownData', + empty_labels=True)) return kwargs @@ -288,7 +283,8 @@ class BatchMasterView(MasterView): return not batch.executed and not batch.complete def configure_grid(self, g): - super(BatchMasterView, self).configure_grid(g) + super().configure_grid(g) + model = self.model # created_by CreatedBy = orm.aliased(model.User) @@ -337,7 +333,7 @@ class BatchMasterView(MasterView): return batch.id_str def configure_form(self, f): - super(BatchMasterView, self).configure_form(f) + super().configure_form(f) # id f.set_readonly('id') @@ -436,9 +432,9 @@ class BatchMasterView(MasterView): label = HTML.literal( '{{{{ togglingBatchComplete ? "Working, please wait..." : "{}" }}}}'.format(label)) - submit = self.make_buefy_button(label, is_primary=True, - native_type='submit', - **{':disabled': 'togglingBatchComplete'}) + submit = self.make_button(label, is_primary=True, + native_type='submit', + **{':disabled': 'togglingBatchComplete'}) form = [ begin_form, @@ -603,7 +599,7 @@ class BatchMasterView(MasterView): return True def configure_row_grid(self, g): - super(BatchMasterView, self).configure_row_grid(g) + super().configure_row_grid(g) g.set_sort_defaults('sequence') g.set_link('sequence') @@ -644,7 +640,7 @@ class BatchMasterView(MasterView): if batch.executed: self.request.session.flash("You cannot add new rows to a batch which has been executed") return self.redirect(self.get_action_url('view', batch)) - return super(BatchMasterView, self).create_row() + return super().create_row() def save_create_row_form(self, form): batch = self.get_instance() @@ -657,7 +653,7 @@ class BatchMasterView(MasterView): self.handler.refresh_row(row) def configure_row_form(self, f): - super(BatchMasterView, self).configure_row_form(f) + super().configure_row_form(f) # sequence f.set_readonly('sequence') @@ -681,9 +677,9 @@ class BatchMasterView(MasterView): permission_prefix = self.get_permission_prefix() if self.request.has_perm('{}.create_row'.format(permission_prefix)): url = self.get_action_url('create_row', batch) - return self.make_buefy_button("New Row", url=url, - is_primary=True, - icon_left='plus') + return self.make_button("New Row", url=url, + is_primary=True, + icon_left='plus') def make_batch_row_grid_tools(self, batch): pass @@ -719,7 +715,7 @@ class BatchMasterView(MasterView): kwargs['main_actions'] = actions - return super(BatchMasterView, self).make_row_grid_kwargs(**kwargs) + return super().make_row_grid_kwargs(**kwargs) def make_row_grid_tools(self, batch): return (self.make_default_row_grid_tools(batch) or '') + (self.make_batch_row_grid_tools(batch) or '') @@ -852,8 +848,11 @@ class BatchMasterView(MasterView): labels = kwargs.setdefault('labels', {}) labels[field.name] = field.title - # auto-convert select widgets for buefy theme + # auto-convert select widgets for theme if isinstance(field.widget, forms.widgets.PlainSelectWidget): + warnings.warn("PlainSelectWidget is deprecated; " + "please use deform.widget.SelectWidget instead", + DeprecationWarning) field.widget = dfwidget.SelectWidget(values=field.widget.values) if not schema: @@ -1022,7 +1021,8 @@ class BatchMasterView(MasterView): cxn.close() def catchup_versions(self, port, batch_uuid, username, *models): - with short_session() as s: + app = self.get_rattail_app() + with app.short_session() as s: batch = s.get(self.model_class, batch_uuid) batch_id = batch.id_str description = str(batch) @@ -1048,8 +1048,10 @@ class BatchMasterView(MasterView): """ Thread target for populating batch data with progress indicator. """ + app = self.get_rattail_app() + model = self.model # mustn't use tailbone web session here - session = RattailSession() + session = app.make_session() batch = session.get(self.model_class, batch_uuid) user = session.get(model.User, user_uuid) try: @@ -1107,7 +1109,9 @@ class BatchMasterView(MasterView): # Refresh data for the batch, with progress. Note that we must use the # rattail session here; can't use tailbone because it has web request # transaction binding etc. - session = RattailSession() + app = self.get_rattail_app() + model = self.model + session = app.make_session() batch = session.get(self.model_class, batch_uuid) cognizer = session.get(model.User, user_uuid) if user_uuid else None try: @@ -1160,7 +1164,9 @@ class BatchMasterView(MasterView): """ Thread target for refreshing multiple batches with progress indicator. """ - session = RattailSession() + app = self.get_rattail_app() + model = self.model + session = app.make_session() batches = batches.with_session(session).all() user = session.get(model.User, user_uuid) try: @@ -1257,7 +1263,7 @@ class BatchMasterView(MasterView): self.handler.do_remove_row(row) def delete_row_objects(self, rows): - deleted = super(BatchMasterView, self).delete_row_objects(rows) + deleted = super().delete_row_objects(rows) batch = self.get_instance() # decrement rowcount for batch @@ -1300,7 +1306,9 @@ class BatchMasterView(MasterView): # Execute the batch, with progress. Note that we must use the rattail # session here; can't use tailbone because it has web request # transaction binding etc. - session = RattailSession() + app = self.get_rattail_app() + model = self.model + session = app.make_session() batch = self.get_instance_for_key(key, session) user = session.get(model.User, user_uuid) try: @@ -1375,7 +1383,9 @@ class BatchMasterView(MasterView): """ Thread target for executing multiple batches with progress indicator. """ - session = RattailSession() + app = self.get_rattail_app() + model = self.model + session = app.make_session() batches = batches.with_session(session).all() user = session.get(model.User, user_uuid) try: @@ -1415,7 +1425,7 @@ class BatchMasterView(MasterView): return self.get_index_url() def get_row_csv_fields(self): - fields = super(BatchMasterView, self).get_row_csv_fields() + fields = super().get_row_csv_fields() fields = [field for field in fields if field not in ('uuid', 'batch_uuid', 'removed')] return fields @@ -1538,7 +1548,7 @@ class FileBatchMasterView(BatchMasterView): return uploads def configure_form(self, f): - super(FileBatchMasterView, self).configure_form(f) + super().configure_form(f) batch = f.model_instance # filename diff --git a/tailbone/views/batch/handheld.py b/tailbone/views/batch/handheld.py index 03b9a441..eb22f367 100644 --- a/tailbone/views/batch/handheld.py +++ b/tailbone/views/batch/handheld.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -26,12 +26,12 @@ Views for handheld batches from collections import OrderedDict -from rattail.db import model +from rattail.db.model import HandheldBatch, HandheldBatchRow import colander +from deform import widget as dfwidget from webhelpers2.html import tags -from tailbone import forms from tailbone.views.batch import FileBatchMasterView @@ -46,14 +46,14 @@ class ExecutionOptions(colander.Schema): action = colander.SchemaNode( colander.String(), validator=colander.OneOf(ACTION_OPTIONS), - widget=forms.widgets.PlainSelectWidget(values=ACTION_OPTIONS.items())) + widget=dfwidget.SelectWidget(values=ACTION_OPTIONS.items())) class HandheldBatchView(FileBatchMasterView): """ Master view for handheld batches. """ - model_class = model.HandheldBatch + model_class = HandheldBatch default_handler_spec = 'rattail.batch.handheld:HandheldBatchHandler' model_title_plural = "Handheld Batches" route_prefix = 'batch.handheld' @@ -61,7 +61,7 @@ class HandheldBatchView(FileBatchMasterView): execution_options_schema = ExecutionOptions editable = False - model_row_class = model.HandheldBatchRow + model_row_class = HandheldBatchRow rows_creatable = False rows_editable = True @@ -116,7 +116,7 @@ class HandheldBatchView(FileBatchMasterView): ] def configure_grid(self, g): - super(HandheldBatchView, self).configure_grid(g) + super().configure_grid(g) device_types = OrderedDict(sorted(self.enum.HANDHELD_DEVICE_TYPE.items(), key=lambda item: item[1])) g.set_enum('device_type', device_types) @@ -126,7 +126,7 @@ class HandheldBatchView(FileBatchMasterView): return 'notice' def configure_form(self, f): - super(HandheldBatchView, self).configure_form(f) + super().configure_form(f) batch = f.model_instance # device_type @@ -156,13 +156,13 @@ class HandheldBatchView(FileBatchMasterView): return tags.link_to(text, url) def get_batch_kwargs(self, batch): - kwargs = super(HandheldBatchView, self).get_batch_kwargs(batch) + kwargs = super().get_batch_kwargs(batch) kwargs['device_type'] = batch.device_type kwargs['device_name'] = batch.device_name return kwargs def configure_row_grid(self, g): - super(HandheldBatchView, self).configure_row_grid(g) + super().configure_row_grid(g) g.set_type('cases', 'quantity') g.set_type('units', 'quantity') g.set_label('brand_name', "Brand") @@ -172,7 +172,7 @@ class HandheldBatchView(FileBatchMasterView): return 'warning' def configure_row_form(self, f): - super(HandheldBatchView, self).configure_row_form(f) + super().configure_row_form(f) # readonly fields f.set_readonly('upc') @@ -188,7 +188,7 @@ class HandheldBatchView(FileBatchMasterView): return self.request.route_url('batch.inventory.view', uuid=result.uuid) elif kwargs['action'] == 'make_label_batch': return self.request.route_url('labels.batch.view', uuid=result.uuid) - return super(HandheldBatchView, self).get_execute_success_url(batch) + return super().get_execute_success_url(batch) def get_execute_results_success_url(self, result, **kwargs): if result is True: diff --git a/tailbone/views/batch/pos.py b/tailbone/views/batch/pos.py index afda919e..11031353 100644 --- a/tailbone/views/batch/pos.py +++ b/tailbone/views/batch/pos.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -206,7 +206,7 @@ class POSBatchView(BatchMasterView): ) return HTML.literal( - g.render_buefy_table_element(data_prop='taxesData')) + g.render_table_element(data_prop='taxesData')) def template_kwargs_view(self, **kwargs): kwargs = super().template_kwargs_view(**kwargs) diff --git a/tailbone/views/batch/product.py b/tailbone/views/batch/product.py index dfe8d890..af8374ac 100644 --- a/tailbone/views/batch/product.py +++ b/tailbone/views/batch/product.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -26,12 +26,12 @@ Views for generic product batches from collections import OrderedDict -from rattail.db import model +from rattail.db.model import ProductBatch, ProductBatchRow import colander +from deform import widget as dfwidget from webhelpers2.html import HTML -from tailbone import forms from tailbone.views.batch import BatchMasterView @@ -46,15 +46,15 @@ class ExecutionOptions(colander.Schema): action = colander.SchemaNode( colander.String(), validator=colander.OneOf(ACTION_OPTIONS), - widget=forms.widgets.PlainSelectWidget(values=ACTION_OPTIONS.items())) + widget=dfwidget.SelectWidget(values=ACTION_OPTIONS.items())) class ProductBatchView(BatchMasterView): """ Master view for product batches. """ - model_class = model.ProductBatch - model_row_class = model.ProductBatchRow + model_class = ProductBatch + model_row_class = ProductBatchRow default_handler_spec = 'rattail.batch.product:ProductBatchHandler' route_prefix = 'batch.product' url_prefix = '/batches/product' @@ -129,7 +129,7 @@ class ProductBatchView(BatchMasterView): ] def configure_form(self, f): - super(ProductBatchView, self).configure_form(f) + super().configure_form(f) # input_filename if self.creating: @@ -139,7 +139,8 @@ class ProductBatchView(BatchMasterView): f.set_renderer('input_filename', self.render_downloadable_file) def configure_row_grid(self, g): - super(ProductBatchView, self).configure_row_grid(g) + super().configure_row_grid(g) + model = self.model g.set_joiner('vendor', lambda q: q.outerjoin(model.Vendor)) g.set_sorter('vendor', model.Vendor.name) @@ -165,7 +166,7 @@ class ProductBatchView(BatchMasterView): return 'warning' def configure_row_form(self, f): - super(ProductBatchView, self).configure_row_form(f) + super().configure_row_form(f) f.set_type('upc', 'gpc') @@ -204,10 +205,10 @@ class ProductBatchView(BatchMasterView): return self.request.route_url('labels.batch.view', uuid=result.uuid) elif kwargs['action'] == 'make_pricing_batch': return self.request.route_url('batch.pricing.view', uuid=result.uuid) - return super(ProductBatchView, self).get_execute_success_url(batch) + return super().get_execute_success_url(batch) def get_row_csv_fields(self): - fields = super(ProductBatchView, self).get_row_csv_fields() + fields = super().get_row_csv_fields() if 'vendor_uuid' in fields: i = fields.index('vendor_uuid') @@ -273,12 +274,12 @@ class ProductBatchView(BatchMasterView): data['report_name'] = (report.name or '') if report else '' def get_row_csv_row(self, row, fields): - csvrow = super(ProductBatchView, self).get_row_csv_row(row, fields) + csvrow = super().get_row_csv_row(row, fields) self.supplement_row_data(row, fields, csvrow) return csvrow def get_row_xlsx_row(self, row, fields): - xlrow = super(ProductBatchView, self).get_row_xlsx_row(row, fields) + xlrow = super().get_row_xlsx_row(row, fields) self.supplement_row_data(row, fields, xlrow) return xlrow diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index 0d4e3d7c..dcd0e943 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -341,7 +341,7 @@ class CustomerView(MasterView): # people if self.should_expose_people(): if self.viewing: - f.set_renderer('people', self.render_people_buefy) + f.set_renderer('people', self.render_people) else: f.remove('people') else: @@ -463,20 +463,6 @@ class CustomerView(MasterView): url = self.request.route_url('people.view', uuid=person.uuid) return tags.link_to(text, url) - # TODO: remove if no longer used - def render_people(self, customer, field): - people = customer.people - if not people: - return "" - - items = [] - for person in people: - text = str(person) - url = self.request.route_url('people.view', uuid=person.uuid) - link = tags.link_to(text, url) - items.append(HTML.tag('li', c=[link])) - return HTML.tag('ul', c=items) - def render_shoppers(self, customer, field): route_prefix = self.get_route_prefix() permission_prefix = self.get_permission_prefix() @@ -504,9 +490,9 @@ class CustomerView(MasterView): ) return HTML.literal( - g.render_buefy_table_element(data_prop='shoppers')) + g.render_table_element(data_prop='shoppers')) - def render_people_buefy(self, customer, field): + def render_people(self, customer, field): route_prefix = self.get_route_prefix() permission_prefix = self.get_permission_prefix() @@ -533,7 +519,7 @@ class CustomerView(MasterView): click_handler="$emit('detach-person', props.row._action_url_detach)")) return HTML.literal( - g.render_buefy_table_element(data_prop='peopleData')) + g.render_table_element(data_prop='peopleData')) def render_groups(self, customer, field): groups = customer.groups diff --git a/tailbone/views/custorders/items.py b/tailbone/views/custorders/items.py index 91976196..d8e39f55 100644 --- a/tailbone/views/custorders/items.py +++ b/tailbone/views/custorders/items.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -401,7 +401,7 @@ class CustomerOrderItemView(MasterView): ) table = HTML.literal( - g.render_buefy_table_element(data_prop='eventsData')) + g.render_table_element(data_prop='eventsData')) elements = [table] if self.has_perm('add_note'): diff --git a/tailbone/views/departments.py b/tailbone/views/departments.py index 01d6f520..c6998105 100644 --- a/tailbone/views/departments.py +++ b/tailbone/views/departments.py @@ -144,7 +144,7 @@ class DepartmentView(MasterView): g.main_actions.append(self.make_action('edit', icon='edit')) return HTML.literal( - g.render_buefy_table_element(data_prop='employeesData')) + g.render_table_element(data_prop='employeesData')) def template_kwargs_view(self, **kwargs): kwargs = super().template_kwargs_view(**kwargs) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index c9d6cd7c..c6ce44e0 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -30,7 +30,6 @@ import csv import datetime import getpass import shutil -import tempfile import logging from collections import OrderedDict @@ -40,12 +39,10 @@ from sqlalchemy import orm import sqlalchemy_continuum as continuum from sqlalchemy_utils.functions import get_primary_keys, get_columns -from rattail.db import model, Session as RattailSession from rattail.db.continuum import model_transaction_query from rattail.util import simple_error, get_class_hierarchy from rattail.threads import Thread from rattail.csvutil import UnicodeDictWriter -from rattail.files import temp_path from rattail.excel import ExcelWriter from rattail.gpc import GPC @@ -54,7 +51,6 @@ import deform from deform import widget as dfwidget from pyramid import httpexceptions from pyramid.renderers import get_renderer, render_to_response, render -from pyramid.response import FileResponse from webhelpers2.html import HTML, tags from webob.compat import cgi_FieldStorage @@ -220,7 +216,8 @@ class MasterView(View): to the current thread (one per request), this method should instead return e.g. a new independent ``rattail.db.Session`` instance. """ - return RattailSession() + app = self.get_rattail_app() + return app.make_session() @classmethod def get_grid_factory(cls): @@ -348,7 +345,7 @@ class MasterView(View): # return grid data only, if partial page was requested if self.request.params.get('partial'): - return self.json_response(grid.get_buefy_data()) + return self.json_response(grid.get_table_data()) context = { 'grid': grid, @@ -719,10 +716,11 @@ class MasterView(View): return obj def normalize_uploads(self, form, skip=None): + app = self.get_rattail_app() uploads = {} def normalize(filedict): - tempdir = tempfile.mkdtemp() + tempdir = app.make_temp_dir() filepath = os.path.join(tempdir, filedict['filename']) tmpinfo = form.deform_form[node.name].widget.tmpstore.get(filedict['uid']) tmpdata = tmpinfo['fp'].read() @@ -1114,7 +1112,8 @@ class MasterView(View): Thread target for populating new object with progress indicator. """ # mustn't use tailbone web session here - session = RattailSession() + app = self.get_rattail_app() + session = app.make_session() obj = session.get(self.model_class, uuid) try: self.populate_object(session, obj, progress=progress) @@ -1175,7 +1174,7 @@ class MasterView(View): # return grid only, if partial page was requested if self.request.params.get('partial'): # render grid data only, as JSON - return self.json_response(grid.get_buefy_data()) + return self.json_response(grid.get_table_data()) context = { 'instance': instance, @@ -1308,7 +1307,7 @@ class MasterView(View): # return grid only, if partial page was requested if self.request.params.get('partial'): # render grid data only, as JSON - return self.json_response(grid.get_buefy_data()) + return self.json_response(grid.get_table_data()) return self.render_to_response('versions', { 'instance': instance, @@ -1360,6 +1359,7 @@ class MasterView(View): return classes def make_revisions_grid(self, obj, empty_data=False): + model = self.model route_prefix = self.get_route_prefix() row_url = lambda txn, i: self.request.route_url(f'{route_prefix}.version', uuid=obj.uuid, @@ -1396,8 +1396,8 @@ class MasterView(View): grid = self.make_version_grid(**kwargs) - grid.set_joiner('user', lambda q: q.outerjoin(self.model.User)) - grid.set_sorter('user', self.model.User.username) + grid.set_joiner('user', lambda q: q.outerjoin(model.User)) + grid.set_sorter('user', model.User.username) grid.set_link('remote_addr') @@ -1465,7 +1465,7 @@ class MasterView(View): else: # no txnid, return grid data obj = self.get_instance() grid = self.make_revisions_grid(obj) - return grid.get_buefy_data() + return grid.get_table_data() def view_version(self): """ @@ -1770,16 +1770,10 @@ class MasterView(View): path = self.download_path(obj, filename) if not path or not os.path.exists(path): raise self.notfound() - response = FileResponse(path, request=self.request) - response.content_length = os.path.getsize(path) + response = self.file_response(path) content_type = self.download_content_type(path, filename) if content_type: response.content_type = content_type - - # content-disposition - filename = os.path.basename(path) - response.content_disposition = str('attachment; filename="{}"'.format(filename)) - return response def download_content_type(self, path, filename): @@ -1856,7 +1850,7 @@ class MasterView(View): View for deleting an existing model record. """ if not self.deletable: - raise httpexceptions.HTTPForbidden() + raise self.forbidden() self.deleting = True instance = self.get_instance() @@ -2111,7 +2105,9 @@ class MasterView(View): """ Thread target for executing an object. """ - session = RattailSession() + app = self.get_rattail_app() + model = self.model + session = app.make_session() obj = self.get_instance_for_key(key, session) user = session.get(model.User, user_uuid) try: @@ -2926,11 +2922,11 @@ class MasterView(View): normal.append(button) return normal - def make_buefy_button(self, label, - type=None, is_primary=False, - url=None, target=None, is_external=False, - icon_left=None, - **kwargs): + def make_button(self, label, + type=None, is_primary=False, + url=None, target=None, is_external=False, + icon_left=None, + **kwargs): """ Make and return a HTML ``<b-button>`` literal. """ @@ -2983,7 +2979,7 @@ class MasterView(View): assumed to be external, which affects the icon and causes button click to open link in a new tab. """ - # TODO: this should call make_buefy_button() + # TODO: this should call make_button() # nb. unfortunately HTML.tag() calls its first arg 'tag' and # so we can't pass a kwarg with that name...so instead we @@ -4067,10 +4063,11 @@ class MasterView(View): """ Download current *row* results as XLSX. """ + app = self.get_rattail_app() obj = self.get_instance() results = self.get_effective_row_data(sort=True) fields = self.get_row_xlsx_fields() - path = temp_path(suffix='.xlsx') + path = app.make_temp_file(suffix='.xlsx') writer = ExcelWriter(path, fields, sheet_title=self.get_row_model_title_plural()) writer.write_header() @@ -5039,6 +5036,7 @@ class MasterView(View): """ Generic view for configuring some aspect of the software. """ + app = self.get_rattail_app() if self.request.method == 'POST': if self.request.POST.get('remove_settings'): self.configure_remove_settings() @@ -5053,7 +5051,7 @@ class MasterView(View): uploads = {} for key, value in data.items(): if isinstance(value, cgi_FieldStorage): - tempdir = tempfile.mkdtemp() + tempdir = app.make_temp_dir() filename = os.path.basename(value.filename) filepath = os.path.join(tempdir, filename) with open(filepath, 'wb') as f: diff --git a/tailbone/views/messages.py b/tailbone/views/messages.py index d1509163..bf460436 100644 --- a/tailbone/views/messages.py +++ b/tailbone/views/messages.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -29,10 +29,8 @@ from rattail.time import localtime import colander from deform import widget as dfwidget -from pyramid import httpexceptions from webhelpers2.html import tags, HTML -# from tailbone import forms from tailbone.db import Session from tailbone.views import MasterView from tailbone.util import raw_datetime @@ -83,15 +81,15 @@ class MessageView(MasterView): def index(self): if not self.request.user: - raise httpexceptions.HTTPForbidden + raise self.forbidden() return super().index() def get_instance(self): if not self.request.user: - raise httpexceptions.HTTPForbidden + raise self.forbidden() message = super().get_instance() if not self.associated_with(message): - raise httpexceptions.HTTPForbidden + raise self.forbidden() return message def associated_with(self, message): @@ -395,7 +393,7 @@ class MessageView(MasterView): message = self.get_instance() recipient = self.get_recipient(message) if not recipient: - raise httpexceptions.HTTPForbidden + raise self.forbidden() dest = self.request.GET.get('dest') if dest not in ('inbox', 'archive'): @@ -520,7 +518,7 @@ class RecipientsWidgetBuefy(dfwidget.Widget): """ Custom "message recipients" widget, for use with Buefy / Vue.js themes. """ - template = 'message_recipients_buefy' + template = 'message_recipients' def deserialize(self, field, pstruct): if pstruct is colander.null: diff --git a/tailbone/views/principal.py b/tailbone/views/principal.py index 20f6b866..6bb623d1 100644 --- a/tailbone/views/principal.py +++ b/tailbone/views/principal.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -43,7 +43,7 @@ class PrincipalMasterView(MasterView): def get_fallback_templates(self, template, **kwargs): return [ '/principal/{}.mako'.format(template), - ] + super(PrincipalMasterView, self).get_fallback_templates(template, **kwargs) + ] + super().get_fallback_templates(template, **kwargs) def perm_sortkey(self, item): key, value = item @@ -74,9 +74,9 @@ class PrincipalMasterView(MasterView): context = {'permissions': sorted_perms, 'principals': principals} - perms = self.get_buefy_perms_data(sorted_perms) - context['buefy_perms'] = perms - context['buefy_sorted_groups'] = list(perms) + perms = self.get_perms_data(sorted_perms) + context['perms_data'] = perms + context['sorted_groups_data'] = list(perms) if permission_group and permission_group not in perms: permission_group = None @@ -95,7 +95,7 @@ class PrincipalMasterView(MasterView): return self.render_to_response('find_by_perm', context) - def get_buefy_perms_data(self, sorted_perms): + def get_perms_data(self, sorted_perms): data = OrderedDict() for gkey, group in sorted_perms: diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 16c65fdb..1a928d67 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -37,8 +37,7 @@ from rattail.db import model, api, auth, Session as RattailSession from rattail.gpc import GPC from rattail.threads import Thread from rattail.exceptions import LabelPrintingError -from rattail.util import load_object, pretty_quantity, simple_error -from rattail.time import localtime, make_utc +from rattail.util import simple_error import colander from deform import widget as dfwidget @@ -417,13 +416,13 @@ class ProductView(MasterView): app = self.get_rattail_app() if price.starts: - starts = localtime(self.rattail_config, price.starts, from_utc=True) + starts = app.localtime(price.starts, from_utc=True) starts = app.render_date(starts.date()) else: starts = "??" if price.ends: - ends = localtime(self.rattail_config, price.ends, from_utc=True) + ends = app.localtime(price.ends, from_utc=True) ends = app.render_date(ends.date()) else: ends = "??" @@ -456,23 +455,25 @@ class ProductView(MasterView): default=True) def render_regular_price(self, product, field): + app = self.get_rattail_app() text = self.render_price(product, field) if text and self.show_price_effective_dates(): history = self.get_regular_price_history(product) if history: - date = localtime(self.rattail_config, history[0]['changed'], from_utc=True).date() + date = app.localtime(history[0]['changed'], from_utc=True).date() text = "{} (as of {})".format(text, date) return self.add_price_history_link(text, 'regular') def render_current_price(self, product, field): + app = self.get_rattail_app() text = self.render_price(product, field) if text and self.show_price_effective_dates(): history = self.get_current_price_history(product) if history: - date = localtime(self.rattail_config, history[0]['changed'], from_utc=True).date() + date = app.localtime(history[0]['changed'], from_utc=True).date() text = "{} (as of {})".format(text, date) return self.add_price_history_link(text, 'current') @@ -489,10 +490,11 @@ class ProductView(MasterView): if not text: return + app = self.get_rattail_app() if self.show_price_effective_dates(): history = self.get_suggested_price_history(product) if history: - date = localtime(self.rattail_config, history[0]['changed'], from_utc=True).date() + date = app.localtime(history[0]['changed'], from_utc=True).date() text = "{} (as of {})".format(text, date) text = self.warn_if_regprice_more_than_srp(product, text) @@ -526,13 +528,15 @@ class ProductView(MasterView): inventory = product.inventory if not inventory: return "" - return pretty_quantity(inventory.on_hand) + app = self.get_rattail_app() + return app.render_quantity(inventory.on_hand) def render_on_order(self, product, column): inventory = product.inventory if not inventory: return "" - return pretty_quantity(inventory.on_order) + app = self.get_rattail_app() + return app.render_quantity(inventory.on_order) def template_kwargs_index(self, **kwargs): kwargs = super().template_kwargs_index(**kwargs) @@ -1105,7 +1109,8 @@ class ProductView(MasterView): value = product.inventory.on_hand if not value: return "" - return pretty_quantity(value) + app = self.get_rattail_app() + return app.render_quantity(value) def render_inventory_on_order(self, product, field): if not product.inventory: @@ -1113,7 +1118,8 @@ class ProductView(MasterView): value = product.inventory.on_order if not value: return "" - return pretty_quantity(value) + app = self.get_rattail_app() + return app.render_quantity(value) def price_history(self): """ @@ -1136,7 +1142,7 @@ class ProductView(MasterView): if price is not None: history['price'] = float(price) history['price_display'] = app.render_currency(price) - changed = localtime(self.rattail_config, history['changed'], from_utc=True) + changed = app.localtime(history['changed'], from_utc=True) history['changed'] = str(changed) history['changed_display_html'] = raw_datetime(self.rattail_config, changed) user = history.pop('changed_by') @@ -1149,6 +1155,7 @@ class ProductView(MasterView): """ AJAX view for fetching cost history for a product. """ + app = self.get_rattail_app() product = self.get_instance() data = self.get_cost_history(product) @@ -1162,7 +1169,7 @@ class ProductView(MasterView): history['cost_display'] = "${:0.2f}".format(cost) else: history['cost_display'] = None - changed = localtime(self.rattail_config, history['changed'], from_utc=True) + changed = app.localtime(history['changed'], from_utc=True) history['changed'] = str(changed) history['changed_display_html'] = raw_datetime(self.rattail_config, changed) user = history.pop('changed_by') @@ -1388,10 +1395,11 @@ class ProductView(MasterView): Returns a sequence of "records" which corresponds to the given product's regular price history. """ + app = self.get_rattail_app() Transaction = continuum.transaction_class(model.Product) ProductVersion = continuum.version_class(model.Product) ProductPriceVersion = continuum.version_class(model.ProductPrice) - now = make_utc() + now = app.make_utc() history = [] # first we find all relevant ProductVersion records @@ -1457,10 +1465,11 @@ class ProductView(MasterView): Returns a sequence of "records" which corresponds to the given product's current price history. """ + app = self.get_rattail_app() Transaction = continuum.transaction_class(model.Product) ProductVersion = continuum.version_class(model.Product) ProductPriceVersion = continuum.version_class(model.ProductPrice) - now = make_utc() + now = app.make_utc() history = [] # first we find all relevant ProductVersion records @@ -1599,10 +1608,11 @@ class ProductView(MasterView): Returns a sequence of "records" which corresponds to the given product's SRP history. """ + app = self.get_rattail_app() Transaction = continuum.transaction_class(model.Product) ProductVersion = continuum.version_class(model.Product) ProductPriceVersion = continuum.version_class(model.ProductPrice) - now = make_utc() + now = app.make_utc() history = [] # first we find all relevant ProductVersion records @@ -1668,10 +1678,11 @@ class ProductView(MasterView): Returns a sequence of "records" which corresponds to the given product's cost history. """ + app = self.get_rattail_app() Transaction = continuum.transaction_class(model.Product) ProductVersion = continuum.version_class(model.Product) ProductCostVersion = continuum.version_class(model.ProductCost) - now = make_utc() + now = app.make_utc() history = [] # we just find all relevant (preferred!) ProductCostVersion records @@ -1948,10 +1959,11 @@ class ProductView(MasterView): """ View for making a new batch from current product grid query. """ + app = self.get_rattail_app() supported = self.get_supported_batches() batch_options = [] for key, info in list(supported.items()): - handler = load_object(info['spec'])(self.rattail_config) + handler = app.load_object(info['spec'])(self.rattail_config) handler.spec = info['spec'] handler.option_key = key handler.option_title = info.get('title', handler.get_model_title()) @@ -2448,19 +2460,19 @@ class PendingProductView(MasterView): if (self.has_perm('ignore_product') and status in (self.enum.PENDING_PRODUCT_STATUS_PENDING, self.enum.PENDING_PRODUCT_STATUS_READY)): - buttons.append(self.make_buefy_button("Ignore Product", - type='is-warning', - icon_left='ban', - **{'@click': "$emit('ignore-product')"})) + buttons.append(self.make_button("Ignore Product", + type='is-warning', + icon_left='ban', + **{'@click': "$emit('ignore-product')"})) if (self.has_perm('resolve_product') and status in (self.enum.PENDING_PRODUCT_STATUS_PENDING, self.enum.PENDING_PRODUCT_STATUS_READY, self.enum.PENDING_PRODUCT_STATUS_IGNORED)): - buttons.append(self.make_buefy_button("Resolve Product", - is_primary=True, - icon_left='object-ungroup', - **{'@click': "$emit('resolve-product')"})) + buttons.append(self.make_button("Resolve Product", + is_primary=True, + icon_left='object-ungroup', + **{'@click': "$emit('resolve-product')"})) if buttons: text = HTML.tag('span', class_='control', c=[text]) diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index e49a5dea..cd369f0a 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -28,10 +28,9 @@ from rattail.db.model import PurchaseBatch, PurchaseBatchRow import colander from deform import widget as dfwidget -from pyramid import httpexceptions from webhelpers2.html import tags, HTML -from tailbone import forms, grids +from tailbone import forms from tailbone.views.batch import BatchMasterView @@ -826,7 +825,7 @@ class PurchasingBatchView(BatchMasterView): def render_row_credits(self, row, field): g = self.make_row_credits_grid(row) return HTML.literal( - g.render_buefy_table_element(data_prop='rowData.credits')) + g.render_table_element(data_prop='rowData.credits')) # def before_create_row(self, form): # row = form.fieldset.model diff --git a/tailbone/views/purchasing/ordering.py b/tailbone/views/purchasing/ordering.py index 63c13517..2e24eebb 100644 --- a/tailbone/views/purchasing/ordering.py +++ b/tailbone/views/purchasing/ordering.py @@ -310,8 +310,6 @@ class OrderingBatchView(PurchasingBatchView): if not order_date: order_date = localtime(self.rattail_config).date() - buefy_data = self.get_worksheet_buefy_data(departments) - return self.render_to_response('worksheet', { 'batch': batch, 'order_date': order_date, @@ -324,10 +322,10 @@ class OrderingBatchView(PurchasingBatchView): 'get_upc': lambda p: p.upc.pretty() if p.upc else '', 'header_columns': self.order_form_header_columns, 'ignore_cases': not self.handler.allow_cases(), - 'worksheet_data': buefy_data, + 'worksheet_data': self.get_worksheet_data(departments), }) - def get_worksheet_buefy_data(self, departments): + def get_worksheet_data(self, departments): data = {} for department in departments.values(): for subdepartment in department._order_subdepartments.values(): diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 22fbc133..739fe0bd 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -33,12 +33,10 @@ from collections import OrderedDict import humanize from rattail import pod -from rattail.time import localtime, make_utc -from rattail.util import pretty_quantity, prettify, simple_error +from rattail.util import prettify, simple_error import colander from deform import widget as dfwidget -from pyramid import httpexceptions from webhelpers2.html import tags, HTML from tailbone import forms, grids @@ -781,8 +779,8 @@ class ReceivingBatchView(PurchasingBatchView): g.set_click_handler('title', "autoFilterPoVsInvoice(props.row)") kwargs['po_vs_invoice_breakdown_data'] = breakdown kwargs['po_vs_invoice_breakdown_grid'] = HTML.literal( - g.render_buefy_table_element(data_prop='poVsInvoiceBreakdownData', - empty_labels=True)) + g.render_table_element(data_prop='poVsInvoiceBreakdownData', + empty_labels=True)) kwargs['allow_edit_catalog_unit_cost'] = self.allow_edit_catalog_unit_cost(batch) kwargs['allow_edit_invoice_unit_cost'] = self.allow_edit_invoice_unit_cost(batch) @@ -1137,6 +1135,7 @@ class ReceivingBatchView(PurchasingBatchView): """ Primary desktop view for row-level receiving. """ + app = self.get_rattail_app() # TODO: this code was largely copied from mobile_receive_row() but it # tries to pave the way for shared logic, i.e. where the latter would # simply invoke this method and return the result. however we're not @@ -1270,7 +1269,7 @@ class ReceivingBatchView(PurchasingBatchView): if accounted_for: # some product accounted for; button should receive "remainder" only if remainder: - remainder = pretty_quantity(remainder) + remainder = app.render_quantity(remainder) context['quick_receive_quantity'] = remainder context['quick_receive_text'] = "Receive Remainder ({} {})".format(remainder, context['unit_uom']) else: @@ -1280,7 +1279,7 @@ class ReceivingBatchView(PurchasingBatchView): else: # nothing yet accounted for, button should receive "all" if not remainder: raise ValueError("why is remainder empty?") - remainder = pretty_quantity(remainder) + remainder = app.render_quantity(remainder) context['quick_receive_quantity'] = remainder context['quick_receive_text'] = "Receive ALL ({} {})".format(remainder, context['unit_uom']) diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index 9bf30a88..aedda61c 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -32,9 +32,8 @@ import logging from collections import OrderedDict import rattail -from rattail.db import model, Session as RattailSession +from rattail.db.model import ReportOutput from rattail.files import resource_path -from rattail.time import localtime from rattail.threads import Thread from rattail.util import simple_error @@ -81,6 +80,7 @@ class OrderingWorksheet(View): upc_getter = staticmethod(get_upc) def __call__(self): + model = self.model if self.request.params.get('vendor'): vendor = Session.get(model.Vendor, self.request.params['vendor']) if vendor: @@ -104,7 +104,8 @@ class OrderingWorksheet(View): """ Rendering engine for the ordering worksheet report. """ - + app = self.get_rattail_app() + model = self.model q = Session.query(model.ProductCost) q = q.join(model.Product) q = q.filter(model.Product.deleted == False) @@ -127,7 +128,7 @@ class OrderingWorksheet(View): key = '{0} {1}'.format(brand, product.description) return key - now = localtime(self.request.rattail_config) + now = app.localtime() data = dict( vendor=vendor, costs=costs, @@ -157,7 +158,7 @@ class InventoryWorksheet(View): """ This is the "Inventory Worksheet" report. """ - + model = self.model departments = Session.query(model.Department) if self.request.params.get('department'): @@ -178,6 +179,8 @@ class InventoryWorksheet(View): """ Generates the Inventory Worksheet report. """ + app = self.get_rattail_app() + model = self.model def get_products(subdepartment): q = Session.query(model.Product) @@ -191,7 +194,7 @@ class InventoryWorksheet(View): q = q.order_by(model.Brand.name, model.Product.description) return q.all() - now = localtime(self.request.rattail_config) + now = app.localtime() data = dict( date=now.strftime('%a %d %b %Y'), time=now.strftime('%I:%M %p'), @@ -209,7 +212,7 @@ class ReportOutputView(ExportMasterView): """ Master view for report output """ - model_class = model.ReportOutput + model_class = ReportOutput route_prefix = 'report_output' url_prefix = '/reports/generated' creatable = True @@ -238,7 +241,7 @@ class ReportOutputView(ExportMasterView): ] def __init__(self, request): - super(ReportOutputView, self).__init__(request) + super().__init__(request) self.report_handler = self.get_report_handler() def get_report_handler(self): @@ -246,7 +249,7 @@ class ReportOutputView(ExportMasterView): return app.get_report_handler() def configure_grid(self, g): - super(ReportOutputView, self).configure_grid(g) + super().configure_grid(g) g.filters['report_name'].default_active = True g.filters['report_name'].default_verb = 'contains' @@ -254,7 +257,7 @@ class ReportOutputView(ExportMasterView): g.set_link('filename') def configure_form(self, f): - super(ReportOutputView, self).configure_form(f) + super().configure_form(f) # report_type f.set_renderer('report_type', self.render_report_type) @@ -282,10 +285,10 @@ class ReportOutputView(ExportMasterView): # add help button if report has a link report = self.report_handler.get_report(type_key) if report and report.help_url: - button = self.make_buefy_button("Help for this report", - url=report.help_url, - is_external=True, - icon_left='question-circle') + button = self.make_button("Help for this report", + url=report.help_url, + is_external=True, + icon_left='question-circle') button = HTML.tag('div', class_='level-item', c=[button]) rendered = HTML.tag('div', class_='level-item', c=[rendered]) rendered = HTML.tag('div', class_='level-left', c=[rendered, button]) @@ -311,7 +314,7 @@ class ReportOutputView(ExportMasterView): labels={'key': "Name"}, ) return HTML.literal( - g.render_buefy_table_element(data_prop='paramsData')) + g.render_table_element(data_prop='paramsData')) def get_params_context(self, report): params_data = [] @@ -323,7 +326,7 @@ class ReportOutputView(ExportMasterView): return params_data def template_kwargs_view(self, **kwargs): - kwargs = super(ReportOutputView, self).template_kwargs_view(**kwargs) + kwargs = super().template_kwargs_view(**kwargs) output = kwargs['instance'] kwargs['params_data'] = self.get_params_context(output) @@ -339,7 +342,7 @@ class ReportOutputView(ExportMasterView): return kwargs def template_kwargs_delete(self, **kwargs): - kwargs = super(ReportOutputView, self).template_kwargs_delete(**kwargs) + kwargs = super().template_kwargs_delete(**kwargs) report = kwargs['instance'] kwargs['params_data'] = self.get_params_context(report) @@ -496,7 +499,9 @@ class ReportOutputView(ExportMasterView): resulting :class:`rattail:~rattail.db.model.reports.ReportOutput` object. """ - session = RattailSession() + app = self.get_rattail_app() + model = self.model + session = app.make_session() user = session.get(model.User, user_uuid) try: output = self.report_handler.generate_output(session, report, params, user, progress=progress) @@ -603,7 +608,7 @@ class ProblemReportView(MasterView): ] def __init__(self, request): - super(ProblemReportView, self).__init__(request) + super().__init__(request) app = self.get_rattail_app() self.problem_handler = app.get_problem_report_handler() @@ -660,7 +665,7 @@ class ProblemReportView(MasterView): return ProblemReportSchema() def configure_form(self, f): - super(ProblemReportView, self).configure_form(f) + super().configure_form(f) # email_* if self.editing: @@ -703,10 +708,10 @@ class ProblemReportView(MasterView): g = self.get_grid_factory()('days', [], columns=['weekday_name', 'enabled'], labels={'weekday_name': "Weekday"}) - return HTML.literal(g.render_buefy_table_element(data_prop='weekdaysData')) + return HTML.literal(g.render_table_element(data_prop='weekdaysData')) def template_kwargs_view(self, **kwargs): - kwargs = super(ProblemReportView, self).template_kwargs_view(**kwargs) + kwargs = super().template_kwargs_view(**kwargs) report_info = kwargs['instance'] data = [] diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py index 2be47415..19faabd8 100644 --- a/tailbone/views/roles.py +++ b/tailbone/views/roles.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -29,7 +29,7 @@ import os from sqlalchemy import orm from openpyxl.styles import Font, PatternFill -from rattail.db import model +from rattail.db.model import Role from rattail.db.auth import administrator_role, guest_role, authenticated_role from rattail.excel import ExcelWriter @@ -46,7 +46,7 @@ class RoleView(PrincipalMasterView): """ Master view for the Role model. """ - model_class = model.Role + model_class = Role has_versions = True touchable = True @@ -77,7 +77,7 @@ class RoleView(PrincipalMasterView): ] def configure_grid(self, g): - super(RoleView, self).configure_grid(g) + super().configure_grid(g) # name g.filters['name'].default_active = True @@ -158,6 +158,7 @@ class RoleView(PrincipalMasterView): return True def unique_name(self, node, value): + model = self.model query = self.Session.query(model.Role)\ .filter(model.Role.name == value) if self.editing: @@ -167,7 +168,7 @@ class RoleView(PrincipalMasterView): raise colander.Invalid(node, "Name must be unique") def configure_form(self, f): - super(RoleView, self).configure_form(f) + super().configure_form(f) role = f.model_instance app = self.get_rattail_app() auth = app.get_auth_handler() @@ -265,7 +266,7 @@ class RoleView(PrincipalMasterView): g.main_actions.append(self.make_action('edit', icon='edit')) return HTML.literal( - g.render_buefy_table_element(data_prop='usersData')) + g.render_table_element(data_prop='usersData')) def get_available_permissions(self): """ @@ -322,7 +323,7 @@ class RoleView(PrincipalMasterView): """ if data is None: data = form.validated - role = super(RoleView, self).objectify(form, data) + role = super().objectify(form, data) self.update_permissions(role, data['permissions']) return role @@ -345,6 +346,7 @@ class RoleView(PrincipalMasterView): auth.revoke_permission(role, pkey) def template_kwargs_view(self, **kwargs): + model = self.model role = kwargs['instance'] if role.users: users = sorted(role.users, key=lambda u: u.username) @@ -390,6 +392,7 @@ class RoleView(PrincipalMasterView): def find_principals_with_permission(self, session, permission): app = self.get_rattail_app() + model = self.model auth = app.get_auth_handler() # TODO: this should search Permission table instead, and work backward to Role? @@ -408,6 +411,7 @@ class RoleView(PrincipalMasterView): Excel spreadsheet, and returns that file. """ app = self.get_rattail_app() + model = self.model auth = app.get_auth_handler() roles = self.Session.query(model.Role)\ diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index 47cca0c5..46e4c02b 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -32,8 +32,8 @@ from collections import OrderedDict import json -from rattail.db import model -from rattail.settings import Setting +from rattail.db.model import Setting +from rattail.settings import Setting as AppSetting from rattail.util import import_module_path import colander @@ -81,7 +81,7 @@ class AppInfoView(MasterView): return data def configure_grid(self, g): - super(AppInfoView, self).configure_grid(g) + super().configure_grid(g) g.sorters['name'] = g.make_simple_sorter('name', foldcase=True) g.set_sort_defaults('name') @@ -94,12 +94,12 @@ class AppInfoView(MasterView): g.set_searchable('editable_project_location') def template_kwargs_index(self, **kwargs): - kwargs = super(AppInfoView, self).template_kwargs_index(**kwargs) + kwargs = super().template_kwargs_index(**kwargs) kwargs['configure_button_title'] = "Configure App" return kwargs def configure_get_context(self, **kwargs): - context = super(AppInfoView, self).configure_get_context(**kwargs) + context = super().configure_get_context(**kwargs) weblibs = OrderedDict([ ('vue', "Vue"), @@ -195,7 +195,7 @@ class SettingView(MasterView): """ Master view for the settings model. """ - model_class = model.Setting + model_class = Setting model_title = "Raw Setting" model_title_plural = "Raw Settings" bulk_deletable = True @@ -207,18 +207,19 @@ class SettingView(MasterView): ] def configure_grid(self, g): - super(SettingView, self).configure_grid(g) + super().configure_grid(g) g.filters['name'].default_active = True g.filters['name'].default_verb = 'contains' g.set_sort_defaults('name') g.set_link('name') def configure_form(self, f): - super(SettingView, self).configure_form(f) + super().configure_form(f) if self.creating: f.set_validator('name', self.unique_name) def unique_name(self, node, value): + model = self.model setting = self.Session.get(model.Setting, value) if setting: raise colander.Invalid(node, "Setting name must be unique") @@ -245,7 +246,7 @@ class SettingView(MasterView): self.rattail_config.beaker_invalidate_setting(setting.name) # otherwise delete like normal - super(SettingView, self).delete_instance(setting) + super().delete_instance(setting) # TODO: deprecate / remove this @@ -307,14 +308,14 @@ class AppSettingsView(View): 'settings': settings, 'config_options': config_options, } - context['buefy_data'] = self.get_buefy_data(form, groups, settings) + context['settings_data'] = self.get_settings_data(form, groups, settings) # TODO: this seems hacky, and probably only needed if theme changes? if current_group == '(All)': current_group = '' context['current_group'] = current_group return context - def get_buefy_data(self, form, groups, settings): + def get_settings_data(self, form, groups, settings): dform = form.make_deform_form() grouped = dict([(label, []) for label in groups]) @@ -407,7 +408,7 @@ class AppSettingsView(View): module = import_module_path(module) for name in dir(module): obj = getattr(module, name) - if isinstance(obj, type) and issubclass(obj, Setting) and obj is not Setting: + if isinstance(obj, type) and issubclass(obj, AppSetting) and obj is not AppSetting: if core_only and not obj.core: continue # NOTE: we set this here, and reference it elsewhere diff --git a/tailbone/views/shifts/lib.py b/tailbone/views/shifts/lib.py index 8fc58264..1827bee0 100644 --- a/tailbone/views/shifts/lib.py +++ b/tailbone/views/shifts/lib.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -28,9 +28,8 @@ import datetime import sqlalchemy as sa -from rattail import enum -from rattail.db import model, api -from rattail.time import localtime, make_utc, get_sunday +from rattail.db import api +from rattail.time import get_sunday from rattail.util import hours_as_decimal import colander @@ -83,6 +82,8 @@ class TimeSheetView(View): """ Determine date/store/dept context from user's session and/or defaults. """ + app = self.get_rattail_app() + model = self.model date = None date_key = 'timesheet.{}.date'.format(self.key) if date_key in self.request.session: @@ -93,7 +94,7 @@ class TimeSheetView(View): except ValueError: pass if not date: - date = localtime(self.rattail_config).date() + date = app.today() store = None department = None @@ -113,7 +114,7 @@ class TimeSheetView(View): store = api.get_store(Session(), store) employees = Session.query(model.Employee)\ - .filter(model.Employee.status == enum.EMPLOYEE_STATUS_CURRENT) + .filter(model.Employee.status == self.enum.EMPLOYEE_STATUS_CURRENT) if store: employees = employees.join(model.EmployeeStore)\ .filter(model.EmployeeStore.store == store) @@ -132,6 +133,8 @@ class TimeSheetView(View): """ Determine employee/date context from user's session and/or defaults """ + app = self.get_rattail_app() + model = self.model date = None date_key = 'timesheet.{}.employee.date'.format(self.key) if date_key in self.request.session: @@ -142,7 +145,7 @@ class TimeSheetView(View): except ValueError: pass if not date: - date = localtime(self.rattail_config).date() + date = app.today() employee = None employee_key = 'timesheet.{}.employee'.format(self.key) @@ -191,7 +194,7 @@ class TimeSheetView(View): stores = self.get_stores() store_values = [(s.uuid, "{} - {}".format(s.id, s.name)) for s in stores] store_values.insert(0, ('', "(all)")) - form.set_widget('store', forms.widgets.PlainSelectWidget(values=store_values)) + form.set_widget('store', dfwidget.SelectWidget(values=store_values)) if context['store']: form.set_default('store', context['store'].uuid) else: @@ -203,7 +206,7 @@ class TimeSheetView(View): departments = self.get_departments() department_values = [(d.uuid, d.name) for d in departments] department_values.insert(0, ('', "(all)")) - form.set_widget('department', forms.widgets.PlainSelectWidget(values=department_values)) + form.set_widget('department', dfwidget.SelectWidget(values=department_values)) if context['department']: form.set_default('department', context['department'].uuid) else: @@ -292,6 +295,7 @@ class TimeSheetView(View): self.request.session['timesheet.{}.{}'.format(mainkey, key)] = value def get_stores(self): + model = self.model return Session.query(model.Store).order_by(model.Store.id).all() def get_store_options(self, stores): @@ -299,6 +303,7 @@ class TimeSheetView(View): return tags.Options(options, prompt="(all)") def get_departments(self): + model = self.model return Session.query(model.Department).order_by(model.Department.name).all() def get_department_options(self, departments): @@ -402,6 +407,7 @@ class TimeSheetView(View): the given params. The cached shift data is attached to each employee. """ app = self.get_rattail_app() + model = self.model # TODO: a bit hacky, this? display hours as HH:MM by default, but # check config in order to display as HH.HH for certain users @@ -413,19 +419,19 @@ class TimeSheetView(View): hours_style = 'pretty' shift_type = 'scheduled' if cls is model.ScheduledShift else 'worked' - min_time = localtime(self.rattail_config, datetime.datetime.combine(weekdays[0], datetime.time(0))) - max_time = localtime(self.rattail_config, datetime.datetime.combine(weekdays[-1] + datetime.timedelta(days=1), datetime.time(0))) + min_time = app.localtime(datetime.datetime.combine(weekdays[0], datetime.time(0))) + max_time = app.localtime(datetime.datetime.combine(weekdays[-1] + datetime.timedelta(days=1), datetime.time(0))) shifts = Session.query(cls)\ .filter(cls.employee_uuid.in_([e.uuid for e in employees]))\ .filter(sa.or_( sa.and_( - cls.start_time >= make_utc(min_time), - cls.start_time < make_utc(max_time), + cls.start_time >= app.make_utc(min_time), + cls.start_time < app.make_utc(max_time), ), sa.and_( cls.start_time == None, - cls.end_time >= make_utc(min_time), - cls.end_time < make_utc(max_time), + cls.end_time >= app.make_utc(min_time), + cls.end_time < app.make_utc(max_time), )))\ .all() diff --git a/tailbone/views/tables.py b/tailbone/views/tables.py index 962dbf50..bfd52f2b 100644 --- a/tailbone/views/tables.py +++ b/tailbone/views/tables.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -80,7 +80,7 @@ class TableView(MasterView): ] def __init__(self, request): - super(TableView, self).__init__(request) + super().__init__(request) app = self.get_rattail_app() self.db_handler = app.get_db_handler() @@ -102,7 +102,7 @@ class TableView(MasterView): for row in result] def configure_grid(self, g): - super(TableView, self).configure_grid(g) + super().configure_grid(g) # table_name g.sorters['table_name'] = g.make_simple_sorter('table_name', foldcase=True) @@ -114,7 +114,7 @@ class TableView(MasterView): g.sorters['row_count'] = g.make_simple_sorter('row_count') def configure_form(self, f): - super(TableView, self).configure_form(f) + super().configure_form(f) # TODO: should render this instead, by inspecting table if not self.creating: @@ -169,7 +169,7 @@ class TableView(MasterView): return TableSchema() def get_xref_buttons(self, table): - buttons = super(TableView, self).get_xref_buttons(table) + buttons = super().get_xref_buttons(table) if table.get('model_name'): all_views = self.request.registry.settings['tailbone_model_views'] @@ -182,15 +182,15 @@ class TableView(MasterView): if self.request.has_perm('model_views.create'): url = self.request.route_url('model_views.create', _query={'model_name': table['model_name']}) - buttons.append(self.make_buefy_button("New View", - is_primary=True, - url=url, - icon_left='plus')) + buttons.append(self.make_button("New View", + is_primary=True, + url=url, + icon_left='plus')) return buttons def template_kwargs_create(self, **kwargs): - kwargs = super(TableView, self).template_kwargs_create(**kwargs) + kwargs = super().template_kwargs_create(**kwargs) app = self.get_rattail_app() model = self.model @@ -301,7 +301,7 @@ class TableView(MasterView): return data def configure_row_grid(self, g): - super(TableView, self).configure_row_grid(g) + super().configure_row_grid(g) g.sorters['sequence'] = g.make_simple_sorter('sequence') g.set_sort_defaults('sequence') @@ -419,7 +419,7 @@ class TablesView(TableView): def __init__(self, request): warnings.warn("TablesView is deprecated; please use TableView instead", DeprecationWarning, stacklevel=2) - super(TablesView, self).__init__(request) + super().__init__(request) class TableSchema(colander.Schema): diff --git a/tailbone/views/tempmon/core.py b/tailbone/views/tempmon/core.py index 62ace028..98fe9199 100644 --- a/tailbone/views/tempmon/core.py +++ b/tailbone/views/tempmon/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -98,4 +98,4 @@ class MasterView(views.MasterView): main_actions=actions, ) return HTML.literal( - g.render_buefy_table_element(data_prop='probesData')) + g.render_table_element(data_prop='probesData')) diff --git a/tailbone/views/trainwreck/base.py b/tailbone/views/trainwreck/base.py index 82c5c163..1e273c87 100644 --- a/tailbone/views/trainwreck/base.py +++ b/tailbone/views/trainwreck/base.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -202,7 +202,7 @@ class TransactionView(MasterView): return 'warning' def configure_form(self, f): - super(TransactionView, self).configure_form(f) + super().configure_form(f) # system f.set_enum('system', self.enum.TRAINWRECK_SYSTEM) @@ -240,10 +240,10 @@ class TransactionView(MasterView): request=self.request) return HTML.literal( - g.render_buefy_table_element(data_prop='custorderXrefMarkersData')) + g.render_table_element(data_prop='custorderXrefMarkersData')) def template_kwargs_view(self, **kwargs): - kwargs = super(TransactionView, self).template_kwargs_view(**kwargs) + kwargs = super().template_kwargs_view(**kwargs) form = kwargs['form'] if 'custorder_xref_markers' in form: @@ -266,7 +266,7 @@ class TransactionView(MasterView): return item.transaction def configure_row_grid(self, g): - super(TransactionView, self).configure_row_grid(g) + super().configure_row_grid(g) g.set_sort_defaults('sequence') g.set_type('unit_quantity', 'quantity') @@ -286,7 +286,7 @@ class TransactionView(MasterView): return "Trainwreck Line Item" def configure_row_form(self, f): - super(TransactionView, self).configure_row_form(f) + super().configure_row_form(f) # transaction f.set_renderer('transaction', self.render_transaction) @@ -325,7 +325,7 @@ class TransactionView(MasterView): request=self.request) return HTML.literal( - g.render_buefy_table_element(data_prop='discountsData')) + g.render_table_element(data_prop='discountsData')) def template_kwargs_view_row(self, **kwargs): form = kwargs['form'] @@ -401,7 +401,7 @@ class TransactionView(MasterView): ] def configure_get_context(self): - context = super(TransactionView, self).configure_get_context() + context = super().configure_get_context() app = self.get_rattail_app() trainwreck_handler = app.get_trainwreck_handler() @@ -415,7 +415,7 @@ class TransactionView(MasterView): return context def configure_gather_settings(self, data): - settings = super(TransactionView, self).configure_gather_settings(data) + settings = super().configure_gather_settings(data) app = self.get_rattail_app() trainwreck_handler = app.get_trainwreck_handler() @@ -432,7 +432,7 @@ class TransactionView(MasterView): return settings def configure_remove_settings(self): - super(TransactionView, self).configure_remove_settings() + super().configure_remove_settings() app = self.get_rattail_app() names = [ diff --git a/tailbone/views/users.py b/tailbone/views/users.py index 833c6cf5..1501795f 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -84,7 +84,7 @@ class UserView(PrincipalMasterView): ] def __init__(self, request): - super(UserView, self).__init__(request) + super().__init__(request) app = self.get_rattail_app() # always get a reference to the auth/merge handler @@ -92,7 +92,7 @@ class UserView(PrincipalMasterView): self.merge_handler = self.auth_handler def query(self, session): - query = super(UserView, self).query(session) + query = super().query(session) model = self.model # bring in the related Person(s) @@ -102,7 +102,7 @@ class UserView(PrincipalMasterView): return query def configure_grid(self, g): - super(UserView, self).configure_grid(g) + super().configure_grid(g) model = self.model del g.filters['salt'] @@ -177,7 +177,7 @@ class UserView(PrincipalMasterView): raise colander.Invalid(node, "Person not found (you must *select* a record)") def configure_form(self, f): - super(UserView, self).configure_form(f) + super().configure_form(f) model = self.model user = f.model_instance @@ -290,12 +290,12 @@ class UserView(PrincipalMasterView): self.make_action('delete', icon='trash', click_handler="$emit('api-token-delete', props.row)")]) - button = self.make_buefy_button("New", is_primary=True, - icon_left='plus', - **{'@click': "$emit('api-new-token')"}) + button = self.make_button("New", is_primary=True, + icon_left='plus', + **{'@click': "$emit('api-new-token')"}) table = HTML.literal( - g.render_buefy_table_element(data_prop='apiTokens')) + g.render_table_element(data_prop='apiTokens')) return HTML.tag('div', c=[button, table]) @@ -329,7 +329,7 @@ class UserView(PrincipalMasterView): 'tokens': self.get_api_tokens(user)} def template_kwargs_view(self, **kwargs): - kwargs = super(UserView, self).template_kwargs_view(**kwargs) + kwargs = super().template_kwargs_view(**kwargs) user = kwargs['instance'] kwargs['api_tokens_data'] = self.get_api_tokens(user) @@ -377,7 +377,7 @@ class UserView(PrincipalMasterView): # create/update user as per normal if data is None: data = form.validated - user = super(UserView, self).objectify(form, data) + user = super().objectify(form, data) # create/update person as needed names = {} @@ -487,7 +487,7 @@ class UserView(PrincipalMasterView): .filter(model.UserEvent.user == user) def configure_row_grid(self, g): - super(UserView, self).configure_row_grid(g) + super().configure_row_grid(g) g.width = 'half' g.filterable = False g.set_sort_defaults('occurred', 'desc') @@ -588,7 +588,7 @@ class UserView(PrincipalMasterView): 'themes.style.{}'.format(name)) if css: options.append({'value': css, 'label': name}) - context['buefy_css_options'] = options + context['theme_style_options'] = options return context @@ -699,12 +699,12 @@ class UserEventView(MasterView): ] def get_data(self, session=None): - query = super(UserEventView, self).get_data(session=session) + query = super().get_data(session=session) model = self.model return query.join(model.User) def configure_grid(self, g): - super(UserEventView, self).configure_grid(g) + super().configure_grid(g) model = self.model g.set_joiner('person', lambda q: q.outerjoin(model.Person)) g.set_sorter('user', model.User.username) From ba521abf4f5d123b5b82f17a730e0d8d886655b6 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 14 Apr 2024 20:30:52 -0500 Subject: [PATCH 1353/1681] Remove some references to "buefy" name within docstrings, comments --- tailbone/forms/core.py | 8 ++++---- tailbone/forms/widgets.py | 10 +++++----- tailbone/static/css/grids.rowstatus.css | 2 +- tailbone/templates/customers/view.mako | 4 +--- tailbone/templates/roles/view.mako | 4 +--- tailbone/views/settings.py | 3 +-- 6 files changed, 13 insertions(+), 18 deletions(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 9ef8cb2b..a5ab3355 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -968,10 +968,10 @@ class Form(object): def render_field_complete(self, fieldname, bfield_attrs={}): """ - Render the given field in a Buefy-compatible way. Note that - this is meant to render *editable* fields, i.e. showing a - widget, unless the field input is hidden. In other words it's - not for "readonly" fields. + Render the given field completely, i.e. with ``<b-field>`` + wrapper. Note that this is meant to render *editable* fields, + i.e. showing a widget, unless the field input is hidden. In + other words it's not for "readonly" fields. """ dform = self.make_deform_form() field = dform[fieldname] if fieldname in dform else None diff --git a/tailbone/forms/widgets.py b/tailbone/forms/widgets.py index 63813452..6b74798c 100644 --- a/tailbone/forms/widgets.py +++ b/tailbone/forms/widgets.py @@ -57,11 +57,11 @@ class NumberInputWidget(dfwidget.TextInputWidget): class NumericInputWidget(NumberInputWidget): """ - This widget only supports Buefy themes for now. It uses a - ``<numeric-input>`` component, which will leverage the ``numeric.js`` - functions to ensure user doesn't enter any non-numeric values. Note that - this still uses a normal "text" input on the HTML side, as opposed to a - "number" input, since the latter is a bit ugly IMHO. + This widget uses a ``<numeric-input>`` component, which will + leverage the ``numeric.js`` functions to ensure user doesn't enter + any non-numeric values. Note that this still uses a normal "text" + input on the HTML side, as opposed to a "number" input, since the + latter is a bit ugly IMHO. """ template = 'numericinput' allow_enter = True diff --git a/tailbone/static/css/grids.rowstatus.css b/tailbone/static/css/grids.rowstatus.css index 9335b827..bfd73404 100644 --- a/tailbone/static/css/grids.rowstatus.css +++ b/tailbone/static/css/grids.rowstatus.css @@ -2,7 +2,7 @@ /******************************************************************************** * grids.rowstatus.css * - * Add "row status" styles for Buefy grid tables. + * Add "row status" styles for grid tables. ********************************************************************************/ /************************************************** diff --git a/tailbone/templates/customers/view.mako b/tailbone/templates/customers/view.mako index 2fa7c417..8b07bdb3 100644 --- a/tailbone/templates/customers/view.mako +++ b/tailbone/templates/customers/view.mako @@ -28,9 +28,7 @@ % endif ThisPage.methods.detachPerson = function(url) { - ## TODO: this should require POST, but we will add that once - ## we can assume a Buefy theme is present, to avoid having to - ## implement the logic in old jquery... + ## TODO: this should require POST! but for now we just redirect.. if (confirm("Are you sure you want to detach this person from this customer account?")) { location.href = url } diff --git a/tailbone/templates/roles/view.mako b/tailbone/templates/roles/view.mako index 5dcd9408..0f4ce472 100644 --- a/tailbone/templates/roles/view.mako +++ b/tailbone/templates/roles/view.mako @@ -15,9 +15,7 @@ % endif ThisPage.methods.detachPerson = function(url) { - ## TODO: this should require POST, but we will add that once - ## we can assume a Buefy theme is present, to avoid having to - ## implement the logic in old jquery... + ## TODO: this should require POST! but for now we just redirect.. if (confirm("Are you sure you want to detach this person from this customer account?")) { location.href = url } diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index 46e4c02b..679f170c 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -341,8 +341,7 @@ class AppSettingsView(View): # specify error / message if applicable # TODO: not entirely clear to me why some field errors are - # represented differently? presumably it depends on - # whether Buefy is used by the theme. + # represented differently? if field.error: s['error'] = True if isinstance(field.error, colander.Invalid): From d4089fbc6eac3d42dff09e07635df7a86c988def Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 14 Apr 2024 20:56:11 -0500 Subject: [PATCH 1354/1681] Some more tweaks to remove "buefy" references mostly just docstring / comments but there were some code changes too --- tailbone/grids/core.py | 6 ++--- tailbone/subscribers.py | 5 +--- tailbone/templates/grids/complete.mako | 3 --- tailbone/views/batch/core.py | 2 +- tailbone/views/batch/importer.py | 4 +-- tailbone/views/customers.py | 24 ++++++++++-------- tailbone/views/messages.py | 2 +- tailbone/views/people.py | 2 +- tailbone/views/products.py | 4 +-- tailbone/views/upgrades.py | 35 +++++++++++++------------- 10 files changed, 42 insertions(+), 45 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index b2f90204..f905659e 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -1335,7 +1335,7 @@ class Grid(object): def render_complete(self, template='/grids/complete.mako', **kwargs): """ - Render the Buefy grid, complete with filters. Note that this also + Render the grid, complete with filters. Note that this also includes the context menu items and grid tools. """ if 'grid_columns' not in kwargs: @@ -1437,7 +1437,7 @@ class Grid(object): def get_filters_data(self): """ - Returns a dict of current filters data, for use with Buefy grid view. + Returns a dict of current filters data, for use with index view. """ data = {} for filtr in self.filters.values(): @@ -1703,7 +1703,7 @@ class Grid(object): def set_action_urls(self, row, rowobj, i): """ Pre-generate all action URLs for the given data row. Meant for use - with Buefy table, since we can't generate URLs from JS. + with client-side table, since we can't generate URLs from JS. """ for action in (self.main_actions + self.more_actions): url = action.get_url(rowobj, i) diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index d05b8bd5..dce8b3ba 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -178,9 +178,6 @@ def before_render(event): renderer_globals['background_color'] = request.rattail_config.get( 'tailbone', 'background_color') - # TODO: remove this hack once nothing references it - renderer_globals['buefy_0_8'] = False - # maybe set custom stylesheet css = None if request.user: diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako index 73c7e415..f9e665dc 100644 --- a/tailbone/templates/grids/complete.mako +++ b/tailbone/templates/grids/complete.mako @@ -171,9 +171,6 @@ :loading="loading" :row-class="getRowClass" - ## TODO: this should be more configurable, maybe auto-detect based - ## on buefy version?? probably cannot do that, but this feature - ## is only supported with buefy 0.8.13 and newer % if request.rattail_config.getbool('tailbone', 'sticky_headers'): sticky-header height="600px" diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 46bdbb17..4df3d911 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -202,7 +202,7 @@ class BatchMasterView(MasterView): action_url=action_url, component='upload-worksheet-form') form.set_type('worksheet_file', 'file') - # TODO: must set these to avoid some default Buefy code + # TODO: must set these to avoid some default code form.auto_disable = False form.auto_disable_save = False return form diff --git a/tailbone/views/batch/importer.py b/tailbone/views/batch/importer.py index 962093da..ea4e1c74 100644 --- a/tailbone/views/batch/importer.py +++ b/tailbone/views/batch/importer.py @@ -145,9 +145,7 @@ class ImporterBatchView(BatchMasterView): make_filter('object_key') make_filter('object_str') - # for some reason we have to do this differently for Buefy? - kwargs = {} - make_filter('status_code', label="Status", **kwargs) + make_filter('status_code', label="Status") g.filters['status_code'].set_choices(self.enum.IMPORTER_BATCH_ROW_STATUS) def make_sorter(field): diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index dcd0e943..2958a98a 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -37,14 +37,14 @@ from tailbone import grids from tailbone.db import Session from tailbone.views import MasterView -from rattail.db import model +from rattail.db.model import Customer, CustomerShopper, PendingCustomer class CustomerView(MasterView): """ Master view for the Customer class. """ - model_class = model.Customer + model_class = Customer is_contact = True has_versions = True results_downloadable = True @@ -251,6 +251,7 @@ class CustomerView(MasterView): if instance: return instance + model = self.model key = self.request.matchdict['uuid'] # search by Customer.id @@ -270,7 +271,7 @@ class CustomerView(MasterView): if instance: return instance.customer - raise HTTPNotFound + raise self.notfound() def configure_form(self, f): super().configure_form(f) @@ -436,6 +437,7 @@ class CustomerView(MasterView): return kwargs def unique_id(self, node, value): + model = self.model query = self.Session.query(model.Customer)\ .filter(model.Customer.id == value) if self.editing: @@ -545,6 +547,7 @@ class CustomerView(MasterView): def get_version_child_classes(self): classes = super().get_version_child_classes() + model = self.model classes.extend([ (model.CustomerGroupAssignment, 'customer_uuid'), (model.CustomerPhoneNumber, 'parent_uuid'), @@ -556,6 +559,7 @@ class CustomerView(MasterView): return classes def detach_person(self): + model = self.model customer = self.get_instance() person = self.Session.get(model.Person, self.request.matchdict['person_uuid']) if not person: @@ -651,9 +655,7 @@ class CustomerView(MasterView): config.add_tailbone_permission(permission_prefix, '{}.detach_person'.format(permission_prefix), "Detach a Person from a {}".format(model_title)) - # TODO: this should require POST, but we'll add that once - # we can assume a Buefy theme is present, to avoid having - # to implement the logic in old jquery... + # TODO: this should require POST! config.add_route('{}.detach_person'.format(route_prefix), '{}/detach-person/{{person_uuid}}'.format(instance_url_prefix), # request_method='POST', @@ -667,7 +669,7 @@ class CustomerShopperView(MasterView): """ Master view for the CustomerShopper class. """ - model_class = model.CustomerShopper + model_class = CustomerShopper route_prefix = 'customer_shoppers' url_prefix = '/customer-shoppers' @@ -748,7 +750,7 @@ class PendingCustomerView(MasterView): """ Master view for the Pending Customer class. """ - model_class = model.PendingCustomer + model_class = PendingCustomer route_prefix = 'pending_customers' url_prefix = '/customers/pending' @@ -877,7 +879,7 @@ class PendingCustomerView(MasterView): # TODO: this only works when creating, need to add edit support? # TODO: can this just go away? since we have unique_id() view method above def unique_id(node, value): - customers = Session.query(model.Customer).filter(model.Customer.id == value) + customers = Session.query(Customer).filter(Customer.id == value) if customers.count(): raise colander.Invalid(node, "Customer ID must be unique") @@ -886,6 +888,8 @@ def customer_info(request): """ View which returns simple dictionary of info for a particular customer. """ + app = request.rattail_config.get_app() + model = app.model uuid = request.params.get('uuid') customer = Session.get(model.Customer, uuid) if uuid else None if not customer: diff --git a/tailbone/views/messages.py b/tailbone/views/messages.py index bf460436..ae050784 100644 --- a/tailbone/views/messages.py +++ b/tailbone/views/messages.py @@ -516,7 +516,7 @@ class SentView(MessageView): class RecipientsWidgetBuefy(dfwidget.Widget): """ - Custom "message recipients" widget, for use with Buefy / Vue.js themes. + Custom "message recipients" widget, for use with Vue.js themes. """ template = 'message_recipients' diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 7b175e25..d8e36ec9 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -1404,7 +1404,7 @@ class PersonView(MasterView): """ View which locates and organizes all relevant "transaction" (version) history data for a given Person. Returns JSON, for - use with the Buefy table element on the full profile view. + use with the table element on the full profile view. """ person = self.get_instance() versions = self.profile_revisions_collect(person) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 1a928d67..788cc24d 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -1800,8 +1800,8 @@ class ProductView(MasterView): def search(self): """ Perform a product search across multiple fields, and return - the results as JSON suitable for row data for a Buefy - ``<b-table>`` component. + the results as JSON suitable for row data for a table + component. """ if 'term' not in self.request.GET: # TODO: deprecate / remove this? not sure if/where it is used diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index f7c83eec..a281062e 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -33,9 +33,7 @@ from collections import OrderedDict import sqlalchemy as sa -from rattail.core import Object -from rattail.db import model, Session as RattailSession -from rattail.time import make_utc +from rattail.db.model import Upgrade from rattail.threads import Thread from deform import widget as dfwidget @@ -53,7 +51,7 @@ class UpgradeView(MasterView): """ Master view for all user events """ - model_class = model.Upgrade + model_class = Upgrade downloadable = True cloneable = True configurable = True @@ -100,7 +98,7 @@ class UpgradeView(MasterView): ] def __init__(self, request): - super(UpgradeView, self).__init__(request) + super().__init__(request) if hasattr(self, 'get_handler'): warnings.warn("defining get_handler() is deprecated. please " @@ -120,7 +118,8 @@ class UpgradeView(MasterView): return self.upgrade_handler def configure_grid(self, g): - super(UpgradeView, self).configure_grid(g) + super().configure_grid(g) + model = self.model # system systems = self.upgrade_handler.get_all_systems() @@ -147,7 +146,8 @@ class UpgradeView(MasterView): return 'notice' def template_kwargs_view(self, **kwargs): - kwargs = super(UpgradeView, self).template_kwargs_view(**kwargs) + kwargs = super().template_kwargs_view(**kwargs) + model = self.model upgrade = kwargs['instance'] kwargs['system_title'] = self.rattail_config.app_title() @@ -177,7 +177,7 @@ class UpgradeView(MasterView): return kwargs def configure_form(self, f): - super(UpgradeView, self).configure_form(f) + super().configure_form(f) upgrade = f.model_instance # system @@ -275,9 +275,10 @@ class UpgradeView(MasterView): f.fields = ['system', 'description', 'notes', 'enabled'] def clone_instance(self, original): + app = self.get_rattail_app() cloned = self.model_class() cloned.system = original.system - cloned.created = make_utc() + cloned.created = app.make_utc() cloned.created_by = self.request.user cloned.description = original.description cloned.notes = original.notes @@ -335,7 +336,6 @@ class UpgradeView(MasterView): return HTML.tag('div', c="(not available for this upgrade)") def get_extra_diff_row_attrs(self, field, attrs): - # note, this is only needed/used with Buefy extra = {} if attrs.get('class') != 'diff': extra['v-show'] = "showingPackages == 'all'" @@ -449,13 +449,14 @@ class UpgradeView(MasterView): return packages def parse_requirement(self, line): + app = self.get_rattail_app() match = re.match(r'^.*@(.*)#egg=(.*)$', line) if match: - return Object(name=match.group(2), version=match.group(1)) + return app.make_object(name=match.group(2), version=match.group(1)) match = re.match(r'^(.*)==(.*)$', line) if match: - return Object(name=match.group(1), version=match.group(2)) + return app.make_object(name=match.group(1), version=match.group(2)) def download_path(self, upgrade, filename): return self.rattail_config.upgrade_filepath(upgrade.uuid, filename=filename) @@ -537,17 +538,17 @@ class UpgradeView(MasterView): def delete_instance(self, upgrade): self.handler.delete_files(upgrade) - super(UpgradeView, self).delete_instance(upgrade) + super().delete_instance(upgrade) def configure_get_context(self, **kwargs): - context = super(UpgradeView, self).configure_get_context(**kwargs) + context = super().configure_get_context(**kwargs) context['upgrade_systems'] = self.upgrade_handler.get_all_systems() return context def configure_gather_settings(self, data): - settings = super(UpgradeView, self).configure_gather_settings(data) + settings = super().configure_gather_settings(data) keys = [] for system in json.loads(data['upgrade_systems']): @@ -568,7 +569,7 @@ class UpgradeView(MasterView): return settings def configure_remove_settings(self): - super(UpgradeView, self).configure_remove_settings() + super().configure_remove_settings() app = self.get_rattail_app() model = self.model From 2f115c07170cfc995097f4d92026a354b9e75ecd Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 15 Apr 2024 10:56:49 -0500 Subject: [PATCH 1355/1681] Update changelog --- CHANGES.rst | 7 ++++++- tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 1edf8a2a..aa4e3d8f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,10 @@ CHANGELOG Unreleased ---------- + +0.9.91 (2024-04-15) +------------------- + * Avoid uncaught error when updating order batch row quantities. * Try to return JSON error when receiving API call fails. @@ -13,7 +17,8 @@ Unreleased * Show toast msg instead of silent error, when grid fetch fails. -* Rename template files to avoid "buefy" names. +* Remove most references to "buefy" name in class methods, template + filenames etc. 0.9.90 (2024-04-01) diff --git a/tailbone/_version.py b/tailbone/_version.py index cff6f04f..b78f76b7 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.90' +__version__ = '0.9.91' From 666c16b74eb8d091bf37668a42cdea2d2af19801 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 15 Apr 2024 10:58:16 -0500 Subject: [PATCH 1356/1681] Fix default dist filename for release task not sure why this fix was needed, did setuptools behavior change? --- tasks.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tasks.py b/tasks.py index 48b51b39..fba0b699 100644 --- a/tasks.py +++ b/tasks.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,6 @@ Tasks for Tailbone """ -from __future__ import unicode_literals, absolute_import - import os import shutil @@ -47,4 +45,4 @@ def release(c, tests=False): if os.path.exists('Tailbone.egg-info'): shutil.rmtree('Tailbone.egg-info') c.run('python -m build --sdist') - c.run('twine upload dist/Tailbone-{}.tar.gz'.format(__version__)) + c.run(f'twine upload dist/tailbone-{__version__}.tar.gz') From d0d568b3a55f8e3ec8e699c4b2239b4b99871f97 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 15 Apr 2024 12:44:46 -0500 Subject: [PATCH 1357/1681] Escape underscore char for "contains" query filter since underscore has special meaning for LIKE clause --- tailbone/grids/filters.py | 50 ++++++++++++++++++++++++++------------- 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py index f70670b6..3b198614 100644 --- a/tailbone/grids/filters.py +++ b/tailbone/grids/filters.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -484,9 +484,13 @@ class AlchemyStringFilter(AlchemyGridFilter): """ if value is None or value == '': return query - return query.filter(sa.and_( - *[self.column.ilike(self.encode_value('%{}%'.format(v))) - for v in value.split()])) + + criteria = [] + for val in value.split(): + val = val.replace('_', r'\_') + val = self.encode_value(f'%{val}%') + criteria.append(self.column.ilike(val)) + return query.filter(sa.and_(*criteria)) def filter_does_not_contain(self, query, value): """ @@ -495,14 +499,17 @@ class AlchemyStringFilter(AlchemyGridFilter): if value is None or value == '': return query + criteria = [] + for val in value.split(): + val = val.replace('_', r'\_') + val = self.encode_value(f'%{val}%') + criteria.append(~self.column.ilike(val)) + # When saying something is 'not like' something else, we must also # include things which are nothing at all, in our result set. return query.filter(sa.or_( self.column == None, - sa.and_( - *[~self.column.ilike(self.encode_value('%{}%'.format(v))) - for v in value.split()]), - )) + sa.and_(*criteria))) def filter_contains_any_of(self, query, value): """ @@ -531,9 +538,12 @@ class AlchemyStringFilter(AlchemyGridFilter): conditions = [] for value in values: - conditions.append(sa.and_( - *[self.column.ilike(self.encode_value('%{}%'.format(v))) - for v in value.split()])) + criteria = [] + for val in value.split(): + val = val.replace('_', r'\_') + val = self.encode_value(f'%{val}%') + criteria.append(self.column.ilike(val)) + conditions.append(sa.and_(*criteria)) return query.filter(sa.or_(*conditions)) @@ -588,8 +598,13 @@ class AlchemyByteStringFilter(AlchemyStringFilter): """ if value is None or value == '': return query - return query.filter(sa.and_( - *[self.column.ilike(b'%{}%'.format(v)) for v in value.split()])) + + criteria = [] + for val in value.split(): + val = val.replace('_', r'\_') + val = b'%{}%'.format(val) + criteria.append(self.column.ilike(val)) + return query.filters(sa.and_(*criteria)) def filter_does_not_contain(self, query, value): """ @@ -598,13 +613,16 @@ class AlchemyByteStringFilter(AlchemyStringFilter): if value is None or value == '': return query + for val in value.split(): + val = val.replace('_', '\_') + val = b'%{}%'.format(val) + criteria.append(~self.column.ilike(val)) + # When saying something is 'not like' something else, we must also # include things which are nothing at all, in our result set. return query.filter(sa.or_( self.column == None, - sa.and_( - *[~self.column.ilike(b'%{}%'.format(v)) for v in value.split()]), - )) + sa.and_(*criteria))) class AlchemyNumericFilter(AlchemyGridFilter): From 52c8f3e12c68fc981d4bdab2bb3c961bb0534961 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 15 Apr 2024 13:02:13 -0500 Subject: [PATCH 1358/1681] Rename custom `user_css` context and stop checking an older deprecated setting --- tailbone/subscribers.py | 4 +--- tailbone/templates/base.mako | 5 ++--- tailbone/views/messages.py | 4 ++-- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index dce8b3ba..33a9d749 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -183,9 +183,7 @@ def before_render(event): if request.user: css = request.rattail_config.get('tailbone.{}'.format(request.user.uuid), 'buefy_css') - if not css: - css = request.rattail_config.get('tailbone', 'theme.falafel.buefy_css') - renderer_globals['buefy_css'] = css + renderer_globals['user_css'] = css # add global search data for quick access renderer_globals['global_search_data'] = get_global_search_options(request) diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index 2a42af0b..e1020b28 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -162,9 +162,8 @@ </%def> <%def name="buefy_styles()"> - % if buefy_css: - ## custom Buefy CSS - ${h.stylesheet_link(buefy_css)} + % if user_css: + ${h.stylesheet_link(user_css)} % else: ## upstream Buefy CSS ${h.stylesheet_link(h.get_liburl(request, 'buefy.css'))} diff --git a/tailbone/views/messages.py b/tailbone/views/messages.py index ae050784..9199c025 100644 --- a/tailbone/views/messages.py +++ b/tailbone/views/messages.py @@ -241,7 +241,7 @@ class MessageView(MasterView): f.insert_after('recipients', 'set_recipients') f.remove('recipients') f.set_node('set_recipients', colander.SchemaNode(colander.Set())) - f.set_widget('set_recipients', RecipientsWidgetBuefy()) + f.set_widget('set_recipients', RecipientsWidget()) f.set_label('set_recipients', "To") if self.replying: @@ -514,7 +514,7 @@ class SentView(MessageView): default_active=True, default_verb='contains') -class RecipientsWidgetBuefy(dfwidget.Widget): +class RecipientsWidget(dfwidget.Widget): """ Custom "message recipients" widget, for use with Vue.js themes. """ From 85d62a8e3898fa906b9663e34bcd2deaae24f10e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 15 Apr 2024 13:21:37 -0500 Subject: [PATCH 1359/1681] Reminder to improve css hack for datepicker in modal --- tailbone/static/css/layout.css | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tailbone/static/css/layout.css b/tailbone/static/css/layout.css index 20dbf6b7..0761d001 100644 --- a/tailbone/static/css/layout.css +++ b/tailbone/static/css/layout.css @@ -136,6 +136,12 @@ header span.header-text { overflow: visible !important; } +/* TODO: a simpler option we might try sometime instead? */ +/* cf. https://github.com/buefy/buefy/issues/292#issuecomment-1073851313 */ + +/* .dropdown-content{ */ +/* position: fixed; */ +/* } */ /****************************** * feedback From 8b4b3de33683cd1ece94fc03d2646d903687dc31 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 16 Apr 2024 09:48:29 -0500 Subject: [PATCH 1360/1681] Add support for Pyramid 2.x; new security policy custom apps are still free to use pyramid 1.x new security policy is only used if config file says so --- setup.cfg | 4 +-- tailbone/app.py | 12 ++++++-- tailbone/auth.py | 77 +++++++++++++++++++++++++++++++++++++++++++--- tailbone/webapi.py | 12 ++++++-- 4 files changed, 91 insertions(+), 14 deletions(-) diff --git a/setup.cfg b/setup.cfg index 67541d96..2195aee9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -49,9 +49,6 @@ install_requires = # TODO: remove once their bug is fixed? idk what this is about yet... deform<2.0.15 - # TODO: remove this cap and address warnings that follow - pyramid<2 - asgiref colander ColanderAlchemy @@ -65,6 +62,7 @@ install_requires = paginate_sqlalchemy passlib Pillow + pyramid pyramid_beaker>=0.6 pyramid_deform pyramid_exclog diff --git a/tailbone/app.py b/tailbone/app.py index ae10c9bc..abf2fa09 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -133,8 +133,14 @@ def make_pyramid_config(settings, configure_csrf=True): config.registry['rattail_config'] = rattail_config # configure user authorization / authentication - config.set_authorization_policy(TailboneAuthorizationPolicy()) - config.set_authentication_policy(SessionAuthenticationPolicy()) + # TODO: security policy should become the default, for pyramid 2.x + if rattail_config.getbool('tailbone', 'pyramid.use_security_policy', + usedb=False, default=False): + from tailbone.auth import TailboneSecurityPolicy + config.set_security_policy(TailboneSecurityPolicy()) + else: + config.set_authorization_policy(TailboneAuthorizationPolicy()) + config.set_authentication_policy(SessionAuthenticationPolicy()) # maybe require CSRF token protection if configure_csrf: diff --git a/tailbone/auth.py b/tailbone/auth.py index 1f057404..66deeff0 100644 --- a/tailbone/auth.py +++ b/tailbone/auth.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -27,7 +27,6 @@ Authentication & Authorization import logging import re -from rattail import enum from rattail.util import prettify, NOTSET from zope.interface import implementer @@ -46,7 +45,8 @@ def login_user(request, user, timeout=NOTSET): Perform the steps necessary to login the given user. Note that this returns a ``headers`` dict which you should pass to the redirect. """ - user.record_event(enum.USER_EVENT_LOGIN) + app = request.rattail_config.get_app() + user.record_event(app.enum.USER_EVENT_LOGIN) headers = remember(request, user.uuid) if timeout is NOTSET: timeout = session_timeout_for_user(user) @@ -60,9 +60,10 @@ def logout_user(request): Perform the logout action for the given request. Note that this returns a ``headers`` dict which you should pass to the redirect. """ + app = request.rattail_config.get_app() user = request.user if user: - user.record_event(enum.USER_EVENT_LOGOUT) + user.record_event(app.enum.USER_EVENT_LOGOUT) request.session.delete() request.session.invalidate() headers = forget(request) @@ -117,7 +118,7 @@ class TailboneAuthenticationPolicy(SessionAuthenticationPolicy): return user.uuid # otherwise do normal session-based logic - return super(TailboneAuthenticationPolicy, self).unauthenticated_userid(request) + return super().unauthenticated_userid(request) @implementer(IAuthorizationPolicy) @@ -150,6 +151,72 @@ class TailboneAuthorizationPolicy(object): raise NotImplementedError +class TailboneSecurityPolicy: + + def __init__(self, api_mode=False): + from pyramid.authentication import SessionAuthenticationHelper + from pyramid.request import RequestLocalCache + + self.api_mode = api_mode + self.session_helper = SessionAuthenticationHelper() + self.identity_cache = RequestLocalCache(self.load_identity) + + def load_identity(self, request): + config = request.registry.settings.get('rattail_config') + app = config.get_app() + user = None + + if self.api_mode: + + # determine/load user from header token if present + credentials = request.headers.get('Authorization') + if credentials: + match = re.match(r'^Bearer (\S+)$', credentials) + if match: + token = match.group(1) + auth = app.get_auth_handler() + user = auth.authenticate_user_token(Session(), token) + + if not user: + + # fetch user uuid from current session + uuid = self.session_helper.authenticated_userid(request) + if not uuid: + return + + # fetch user object from db + model = app.model + user = Session.get(model.User, uuid) + if not user: + return + + # this user is responsible for data changes in current request + Session().set_continuum_user(user) + return user + + def identity(self, request): + return self.identity_cache.get_or_create(request) + + def authenticated_userid(self, request): + user = self.identity(request) + if user is not None: + return user.uuid + + def remember(self, request, userid, **kw): + return self.session_helper.remember(request, userid, **kw) + + def forget(self, request, **kw): + return self.session_helper.forget(request, **kw) + + def permits(self, request, context, permission): + config = request.registry.settings.get('rattail_config') + app = config.get_app() + auth = app.get_auth_handler() + + user = self.identity(request) + return auth.has_permission(Session(), user, permission) + + def add_permission_group(config, key, label=None, overwrite=True): """ Add a permission group to the app configuration. diff --git a/tailbone/webapi.py b/tailbone/webapi.py index 7a2c81b4..70600e79 100644 --- a/tailbone/webapi.py +++ b/tailbone/webapi.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -50,8 +50,14 @@ def make_pyramid_config(settings): pyramid_config = Configurator(settings=settings, root_factory=app.Root) # configure user authorization / authentication - pyramid_config.set_authentication_policy(TailboneAuthenticationPolicy()) - pyramid_config.set_authorization_policy(TailboneAuthorizationPolicy()) + # TODO: security policy should become the default, for pyramid 2.x + if rattail_config.getbool('tailbone', 'pyramid.use_security_policy', + usedb=False, default=False): + from tailbone.auth import TailboneSecurityPolicy + pyramid_config.set_security_policy(TailboneSecurityPolicy(api_mode=True)) + else: + pyramid_config.set_authentication_policy(TailboneAuthenticationPolicy()) + pyramid_config.set_authorization_policy(TailboneAuthorizationPolicy()) # always require CSRF token protection pyramid_config.set_default_csrf_options(require_csrf=True, From c35c0f8b6167c54b939b5fefcf4dc912a3f39062 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 16 Apr 2024 10:44:33 -0500 Subject: [PATCH 1361/1681] Update changelog --- CHANGES.rst | 9 +++++++++ tailbone/_version.py | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index aa4e3d8f..99ae8ed9 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,15 @@ CHANGELOG Unreleased ---------- +0.9.92 (2024-04-16) +------------------- + +* Escape underscore char for "contains" query filter. + +* Rename custom ``user_css`` context. + +* Add support for Pyramid 2.x; new security policy. + 0.9.91 (2024-04-15) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index b78f76b7..701a9305 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.91' +__version__ = '0.9.92' From 0d9c5a078be54558796c79e79c290a0969f33314 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 16 Apr 2024 18:21:59 -0500 Subject: [PATCH 1362/1681] Improve form support for view supplements this seems a bit hacky yet but works for now.. cf. field logic for Vendor -> Quickbooks Bank Accounts, which requires this --- tailbone/forms/core.py | 28 +++++++++++++++++++++++++++- tailbone/templates/master/form.mako | 17 +++++++++++++---- tailbone/views/master.py | 17 +++++++++++++++-- 3 files changed, 55 insertions(+), 7 deletions(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index a5ab3355..9624f6fb 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -338,7 +338,7 @@ class Form(object): assume_local_times=False, renderers=None, renderer_kwargs={}, hidden={}, widgets={}, defaults={}, validators={}, required={}, helptext={}, focus_spec=None, action_url=None, cancel_url=None, component='tailbone-form', - vuejs_component_kwargs=None, vuejs_field_converters={}, + vuejs_component_kwargs=None, vuejs_field_converters={}, json_data={}, included_templates={}, # TODO: ugh this is getting out hand! can_edit_help=False, edit_help_url=None, route_prefix=None, ): @@ -381,6 +381,8 @@ class Form(object): self.component = component self.vuejs_component_kwargs = vuejs_component_kwargs or {} self.vuejs_field_converters = vuejs_field_converters or {} + self.json_data = json_data or {} + self.included_templates = included_templates or {} self.can_edit_help = can_edit_help self.edit_help_url = edit_help_url self.route_prefix = route_prefix @@ -966,6 +968,30 @@ class Form(object): kwargs.setdefault(':configure-fields-help', 'configureFieldsHelp') return HTML.tag(self.component, **kwargs) + def set_json_data(self, key, value): + """ + Establish a data value for use in client-side JS. This value + will be JSON-encoded and made available to the + `<tailbone-form>` component within the client page. + """ + self.json_data[key] = value + + def include_template(self, template, context): + """ + Declare a JS template as required by the current form. This + template will then be included in the final page, so all + widgets behave correctly. + """ + self.included_templates[template] = context + + def render_included_templates(self): + templates = [] + for template, context in self.included_templates.items(): + context = dict(context) + context['form'] = self + templates.append(HTML.literal(render(template, context))) + return HTML.literal('\n').join(templates) + def render_field_complete(self, fieldname, bfield_attrs={}): """ Render the given field completely, i.e. with ``<b-field>`` diff --git a/tailbone/templates/master/form.mako b/tailbone/templates/master/form.mako index c142d8ef..1339bd91 100644 --- a/tailbone/templates/master/form.mako +++ b/tailbone/templates/master/form.mako @@ -3,8 +3,14 @@ <%def name="modify_this_page_vars()"> ${parent.modify_this_page_vars()} - % if master.deletable and instance_deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple': - <script type="text/javascript"> + <script type="text/javascript"> + + ## declare extra data needed by form + % for key, value in form.json_data.items(): + ${form.component_studly}Data.${key} = ${json.dumps(value)|n} + % endfor + + % if master.deletable and instance_deletable and master.has_perm('delete') and master.delete_confirm == 'simple': ThisPage.methods.deleteObject = function() { if (confirm("Are you sure you wish to delete this ${model_title}?")) { @@ -12,8 +18,11 @@ } } - </script> - % endif + % endif + </script> + + ${form.render_included_templates()} + </%def> diff --git a/tailbone/views/master.py b/tailbone/views/master.py index c6ce44e0..20dc0dcf 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2695,8 +2695,15 @@ class MasterView(View): context.update(data) context.update(self.template_kwargs(**context)) - if hasattr(self, 'template_kwargs_{}'.format(template)): - context.update(getattr(self, 'template_kwargs_{}'.format(template))(**context)) + + method_name = f'template_kwargs_{template}' + if hasattr(self, method_name): + context.update(getattr(self, method_name)(**context)) + for supp in self.iter_view_supplements(): + if hasattr(supp, 'template_kwargs'): + context.update(getattr(supp, 'template_kwargs')(**context)) + if hasattr(supp, method_name): + context.update(getattr(supp, method_name)(**context)) # First try the template path most specific to the view. mako_path = '{}/{}.mako'.format(self.get_template_prefix(), template) @@ -4441,6 +4448,9 @@ class MasterView(View): if not self.has_perm('view_global'): obj.local_only = True + for supp in self.iter_view_supplements(): + obj = supp.objectify(obj, form, data) + return obj def objectify_contact(self, contact, data): @@ -5892,6 +5902,9 @@ class ViewSupplement(object): renderers, default values etc. for them. """ + def objectify(self, obj, form, data): + return obj + def get_xref_buttons(self, obj): return [] From b37981e83f1e39499729da468a2e00a129fc60de Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 16 Apr 2024 20:09:39 -0500 Subject: [PATCH 1363/1681] Prevent multi-click for grid filters "Save Defaults" button --- tailbone/templates/grids/complete.mako | 6 ++++++ tailbone/templates/grids/filters.mako | 5 +++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako index f9e665dc..205012be 100644 --- a/tailbone/templates/grids/complete.mako +++ b/tailbone/templates/grids/complete.mako @@ -357,6 +357,8 @@ loading: false, ajaxDataUrl: ${json.dumps(grid.ajax_data_url)|n}, + savingDefaults: false, + data: ${grid.component_studly}CurrentData, rowStatusMap: ${json.dumps(grid_data['row_status_map'])|n}, @@ -589,6 +591,7 @@ this.firstItem = data.first_item this.lastItem = data.last_item this.loading = false + this.savingDefaults = false this.checkedRows = this.locateCheckedRows(data.checked_rows) if (success) { success() @@ -600,6 +603,7 @@ duration: 2000, // 4 seconds }) this.loading = false + this.savingDefaults = false if (failure) { failure() } @@ -609,6 +613,7 @@ this.data = [] this.total = 0 this.loading = false + this.savingDefaults = false if (failure) { failure() } @@ -805,6 +810,7 @@ }, saveDefaults() { + this.savingDefaults = true // apply current filters as normal, but add special directive this.applyFilters({'save-current-filters-as-defaults': true}) diff --git a/tailbone/templates/grids/filters.mako b/tailbone/templates/grids/filters.mako index 5e1fef9b..4c584883 100644 --- a/tailbone/templates/grids/filters.mako +++ b/tailbone/templates/grids/filters.mako @@ -60,8 +60,9 @@ <b-button @click="saveDefaults()" icon-pack="fas" icon-left="save" - class="control"> - Save Defaults + class="control" + :disabled="savingDefaults"> + {{ savingDefaults ? "Working, please wait..." : "Save Defaults" }} </b-button> % endif From 9065f42195f1d64bb02452727fb6acf44b280623 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 16 Apr 2024 20:10:10 -0500 Subject: [PATCH 1364/1681] Fix typo when getting app instance --- tailbone/forms/core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 9624f6fb..496d59ee 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -1156,7 +1156,7 @@ class Form(object): value = self.obtain_value(record, field_name) if value is None: return "" - app = self.get_rattail_app() + app = self.request.rattail_config.get_app() value = app.localtime(value) return raw_datetime(self.request.rattail_config, value) @@ -1186,7 +1186,7 @@ class Form(object): value = self.obtain_value(obj, field) if value is None: return "" - app = self.get_rattail_app() + app = self.request.rattail_config.get_app() return app.render_quantity(value) def render_percent(self, obj, field): From 5a7deadba221ad45b764d9eedba14a6e3b07cb69 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 16 Apr 2024 20:11:15 -0500 Subject: [PATCH 1365/1681] Update changelog --- CHANGES.rst | 11 +++++++++++ tailbone/_version.py | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 99ae8ed9..e028452c 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,17 @@ CHANGELOG Unreleased ---------- + +0.9.93 (2024-04-16) +------------------- + +* Improve form support for view supplements. + +* Prevent multi-click for grid filters "Save Defaults" button. + +* Fix typo when getting app instance. + + 0.9.92 (2024-04-16) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 701a9305..109cbcbd 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.92' +__version__ = '0.9.93' From e7b8b6e818015ecff1604e71c558d36d5095f34e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 16 Apr 2024 21:13:53 -0500 Subject: [PATCH 1366/1681] Fix master template bug when no form in context --- tailbone/templates/master/form.mako | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tailbone/templates/master/form.mako b/tailbone/templates/master/form.mako index 1339bd91..dfe56fa8 100644 --- a/tailbone/templates/master/form.mako +++ b/tailbone/templates/master/form.mako @@ -6,9 +6,11 @@ <script type="text/javascript"> ## declare extra data needed by form - % for key, value in form.json_data.items(): - ${form.component_studly}Data.${key} = ${json.dumps(value)|n} - % endfor + % if form is not Undefined: + % for key, value in form.json_data.items(): + ${form.component_studly}Data.${key} = ${json.dumps(value)|n} + % endfor + % endif % if master.deletable and instance_deletable and master.has_perm('delete') and master.delete_confirm == 'simple': @@ -21,7 +23,9 @@ % endif </script> - ${form.render_included_templates()} + % if form is not Undefined: + ${form.render_included_templates()} + % endif </%def> From a95cc2b9e80bc5f3eab39352dbe1674d521ad8d2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 16 Apr 2024 21:14:23 -0500 Subject: [PATCH 1367/1681] Update changelog --- CHANGES.rst | 5 +++++ tailbone/_version.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index e028452c..ba3f2b97 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,11 @@ CHANGELOG Unreleased ---------- +0.9.94 (2024-04-16) +------------------- + +* Fix master template bug when no form in context. + 0.9.93 (2024-04-16) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 109cbcbd..665e5baa 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.93' +__version__ = '0.9.94' From 7fa39d42e2caf156022dc55187338936e9145db8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 16 Apr 2024 23:26:46 -0500 Subject: [PATCH 1368/1681] Fix ASGI websockets when serving on sub-path under site root --- CHANGES.rst | 3 +++ tailbone/asgi.py | 16 +++++++++------- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index ba3f2b97..7321ee2c 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,9 @@ CHANGELOG Unreleased ---------- +* Fix ASGI websockets when serving on sub-path under site root. + + 0.9.94 (2024-04-16) ------------------- diff --git a/tailbone/asgi.py b/tailbone/asgi.py index f2146577..1afbe12a 100644 --- a/tailbone/asgi.py +++ b/tailbone/asgi.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,14 +24,10 @@ ASGI App Utilities """ -from __future__ import unicode_literals, absolute_import - import os +import configparser import logging -import six -from six.moves import configparser - from rattail.util import load_object from asgiref.wsgi import WsgiToAsgi @@ -49,6 +45,12 @@ class TailboneWsgiToAsgi(WsgiToAsgi): protocol = scope['type'] path = scope['path'] + # strip off the root path, if non-empty. needed for serving + # under /poser or anything other than true site root + root_path = scope['root_path'] + if root_path and path.startswith(root_path): + path = path[len(root_path):] + if protocol == 'websocket': websockets = self.wsgi_application.registry.get( 'tailbone_websockets', {}) @@ -85,7 +87,7 @@ def make_asgi_app(main_app=None): # parse the settings needed for pyramid app settings = dict(parser.items('app:main')) - if isinstance(main_app, six.string_types): + if isinstance(main_app, str): make_wsgi_app = load_object(main_app) elif callable(main_app): make_wsgi_app = main_app From e82f0f37d860c1c63e924f4f042f6b95c29cac3a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 16 Apr 2024 23:29:56 -0500 Subject: [PATCH 1369/1681] Fix raw query to avoid SQLAlchemy 2.x warnings --- CHANGES.rst | 2 ++ tailbone/views/datasync.py | 14 +++++++------- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 7321ee2c..0ae23410 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -7,6 +7,8 @@ Unreleased * Fix ASGI websockets when serving on sub-path under site root. +* Fix raw query to avoid SQLAlchemy 2.x warnings. + 0.9.94 (2024-04-16) ------------------- diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py index ac0fec52..b734325f 100644 --- a/tailbone/views/datasync.py +++ b/tailbone/views/datasync.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -30,7 +30,7 @@ import logging import sqlalchemy as sa -from rattail.db import model +from rattail.db.model import DataSyncChange from rattail.datasync.util import purge_datasync_settings from rattail.util import simple_error @@ -71,7 +71,7 @@ class DataSyncThreadView(MasterView): ] def __init__(self, request, context=None): - super(DataSyncThreadView, self).__init__(request, context=context) + super().__init__(request, context=context) app = self.get_rattail_app() self.datasync_handler = app.get_datasync_handler() @@ -106,7 +106,7 @@ class DataSyncThreadView(MasterView): from datasync_change group by source, consumer """ - result = self.Session.execute(sql) + result = self.Session.execute(sa.text(sql)) all_changes = {} for row in result: all_changes[(row.source, row.consumer)] = row.changes @@ -368,7 +368,7 @@ class DataSyncChangeView(MasterView): """ Master view for the DataSyncChange model. """ - model_class = model.DataSyncChange + model_class = DataSyncChange url_prefix = '/datasync/changes' permission_prefix = 'datasync_changes' creatable = False @@ -390,7 +390,7 @@ class DataSyncChangeView(MasterView): ] def configure_grid(self, g): - super(DataSyncChangeView, self).configure_grid(g) + super().configure_grid(g) # batch_sequence g.set_label('batch_sequence', "Batch Seq.") @@ -404,7 +404,7 @@ class DataSyncChangeView(MasterView): return kwargs def configure_form(self, f): - super(DataSyncChangeView, self).configure_form(f) + super().configure_form(f) f.set_readonly('obtained') From 1fa6e35663b6a144d64d3fe4799564475719d1b4 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 19 Apr 2024 17:45:58 -0500 Subject: [PATCH 1370/1681] Remove config "style" from appinfo page there is only one style now (finally) --- tailbone/templates/appinfo/index.mako | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/templates/appinfo/index.mako b/tailbone/templates/appinfo/index.mako index 62a911ee..ac67e582 100644 --- a/tailbone/templates/appinfo/index.mako +++ b/tailbone/templates/appinfo/index.mako @@ -51,7 +51,7 @@ </b-icon> </span> - <span>Configuration Files (style: ${request.rattail_config._style})</span> + <span>Configuration Files</span> </div> </template> From 5cb643a32ad1b7196eddfe2e254dfe1b6d37850e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 19 Apr 2024 19:47:41 -0500 Subject: [PATCH 1371/1681] Update changelog --- CHANGES.rst | 5 +++++ tailbone/_version.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 0ae23410..53bc179f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,10 +5,15 @@ CHANGELOG Unreleased ---------- +0.9.95 (2024-04-19) +------------------- + * Fix ASGI websockets when serving on sub-path under site root. * Fix raw query to avoid SQLAlchemy 2.x warnings. +* Remove config "style" from appinfo page. + 0.9.94 (2024-04-16) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 665e5baa..016440ba 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.94' +__version__ = '0.9.95' From 36b9e00dc9ba21bef9b0ab699d088635dfd720d8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 19 Apr 2024 20:15:44 -0500 Subject: [PATCH 1372/1681] Remove unused code for `webhelpers2_grid` --- setup.cfg | 6 ---- tailbone/grids/core.py | 64 ------------------------------------------ 2 files changed, 70 deletions(-) diff --git a/setup.cfg b/setup.cfg index 2195aee9..514b77ab 100644 --- a/setup.cfg +++ b/setup.cfg @@ -40,12 +40,6 @@ classifiers = [options] install_requires = - # TODO: apparently they jumped from 0.1 to 0.9 and that broke us... - # (0.1 was released on 2014-09-14 and then 0.9 came out on 2018-09-27) - # (i've cached 0.1 at pypi.rattailproject.org just in case it disappears) - # (still, probably a better idea is to refactor so we can use 0.9) - webhelpers2_grid==0.1 - # TODO: remove once their bug is fixed? idk what this is about yet... deform<2.0.15 diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index f905659e..41d75fc2 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -34,7 +34,6 @@ from sqlalchemy import orm from rattail.db.types import GPCType from rattail.util import prettify, pretty_boolean, pretty_quantity -import webhelpers2_grid from pyramid.renderers import render from webhelpers2.html import HTML, tags from paginate_sqlalchemy import SqlalchemyOrmPage @@ -1721,69 +1720,6 @@ class Grid(object): return False -class CustomWebhelpersGrid(webhelpers2_grid.Grid): - """ - Implement column sorting links etc. for webhelpers2_grid - """ - - def __init__(self, itemlist, columns, **kwargs): - self.renderers = kwargs.pop('renderers', {}) - self.linked_columns = kwargs.pop('linked_columns', []) - self.extra_record_class = kwargs.pop('extra_record_class', None) - super().__init__(itemlist, columns, **kwargs) - - def generate_header_link(self, column_number, column, label_text): - - # display column header as simple no-op link; client-side JS takes care - # of the rest for us - label_text = tags.link_to(label_text, '#', data_sortkey=column) - - # Is the current column the one we're ordering on? - if (column == self.order_column): - return self.default_header_ordered_column_format(column_number, - column, - label_text) - else: - return self.default_header_column_format(column_number, column, - label_text) - - def default_record_format(self, i, record, columns): - kwargs = { - 'class_': self.get_record_class(i, record, columns), - } - if hasattr(record, 'uuid'): - kwargs['data_uuid'] = record.uuid - return HTML.tag('tr', columns, **kwargs) - - def get_record_class(self, i, record, columns): - if i % 2 == 0: - cls = 'even r{}'.format(i) - else: - cls = 'odd r{}'.format(i) - if self.extra_record_class: - extra = self.extra_record_class(record, i) - if extra: - cls = '{} {}'.format(cls, extra) - return cls - - def get_column_value(self, column_number, i, record, column_name): - if self.renderers and column_name in self.renderers: - return self.renderers[column_name](record, column_name) - try: - return record[column_name] - except TypeError: - return getattr(record, column_name) - - def default_column_format(self, column_number, i, record, column_name): - value = self.get_column_value(column_number, i, record, column_name) - if self.linked_columns and column_name in self.linked_columns and ( - value is not None and value != ''): - url = self.url_generator(record, i) - value = tags.link_to(value, url) - class_name = 'c{} {}'.format(column_number, column_name) - return HTML.tag('td', value, class_=class_name) - - class GridAction(object): """ Represents an action available to a grid. This is used to construct the From 49da9776e72f68483e67f6af2769169f92284cca Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 19 Apr 2024 20:25:07 -0500 Subject: [PATCH 1373/1681] Remove unused test fixtures --- setup.cfg | 4 ++-- tests/fixtures.py | 28 ---------------------------- 2 files changed, 2 insertions(+), 30 deletions(-) delete mode 100644 tests/fixtures.py diff --git a/setup.cfg b/setup.cfg index 514b77ab..7fcce722 100644 --- a/setup.cfg +++ b/setup.cfg @@ -73,7 +73,7 @@ install_requires = zope.sqlalchemy tests_require = Tailbone[tests] -test_suite = nose.collector +test_suite = tests packages = find: include_package_data = True zip_safe = False @@ -87,7 +87,7 @@ exclude = [options.extras_require] docs = Sphinx; sphinx-rtd-theme -tests = coverage; fixture; mock; nose; pytest; pytest-cov +tests = coverage; mock; pytest; pytest-cov [options.entry_points] diff --git a/tests/fixtures.py b/tests/fixtures.py deleted file mode 100644 index a07825fd..00000000 --- a/tests/fixtures.py +++ /dev/null @@ -1,28 +0,0 @@ - -import fixture - -from rattail.db import model - - -class DepartmentData(fixture.DataSet): - - class grocery: - number = 1 - name = 'Grocery' - - class supplements: - number = 2 - name = 'Supplements' - - -def load_fixtures(engine): - - dbfixture = fixture.SQLAlchemyFixture( - env={ - 'DepartmentData': model.Department, - }, - engine=engine) - - data = dbfixture.data(DepartmentData) - - data.setup() From 8781e34c98dd5215f346af8efe1bcb98ac412640 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 19 Apr 2024 21:18:57 -0500 Subject: [PATCH 1374/1681] Rename setting for custom user css (remove "buefy") but have to keep support for older setting name for now --- tailbone/subscribers.py | 10 ++++++++-- tailbone/templates/users/preferences.mako | 4 ++-- tailbone/views/users.py | 13 +++++++++++-- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 33a9d749..1dc0592a 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -27,6 +27,7 @@ Event Subscribers import six import json import datetime +import warnings import rattail @@ -181,8 +182,13 @@ def before_render(event): # maybe set custom stylesheet css = None if request.user: - css = request.rattail_config.get('tailbone.{}'.format(request.user.uuid), - 'buefy_css') + css = rattail_config.get(f'tailbone.{request.user.uuid}', 'user_css') + if not css: + css = rattail_config.get(f'tailbone.{request.user.uuid}', 'buefy_css') + if css: + warnings.warn(f"setting 'tailbone.{request.user.uuid}.buefy_css' should be" + f"changed to 'tailbone.{request.user.uuid}.user_css'", + DeprecationWarning) renderer_globals['user_css'] = css # add global search data for quick access diff --git a/tailbone/templates/users/preferences.mako b/tailbone/templates/users/preferences.mako index f1432676..c2e17396 100644 --- a/tailbone/templates/users/preferences.mako +++ b/tailbone/templates/users/preferences.mako @@ -27,8 +27,8 @@ <div class="block" style="padding-left: 2rem;"> <b-field label="Theme Style"> - <b-select name="tailbone.${user.uuid}.buefy_css" - v-model="simpleSettings['tailbone.${user.uuid}.buefy_css']" + <b-select name="tailbone.${user.uuid}.user_css" + v-model="simpleSettings['tailbone.${user.uuid}.user_css']" @input="settingsNeedSaved = true"> <option v-for="option in themeStyleOptions" :key="option.value" diff --git a/tailbone/views/users.py b/tailbone/views/users.py index 1501795f..fb81060a 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -601,11 +601,18 @@ class UserView(PrincipalMasterView): The only difference here is that we are given a user account, so the settings involved should only pertain to that user. """ + # TODO: can stop pre-fetching this value only once we are + # confident all settings have been updated in the wild + user_css = self.rattail_config.get(f'tailbone.{user.uuid}', 'user_css') + if not user_css: + user_css = self.rattail_config.get(f'tailbone.{user.uuid}', 'buefy_css') + return [ # display - {'section': 'tailbone.{}'.format(user.uuid), - 'option': 'buefy_css'}, + {'section': f'tailbone.{user.uuid}', + 'option': 'user_css', + 'value': user_css}, ] def preferences_gather_settings(self, data, user): @@ -614,9 +621,11 @@ class UserView(PrincipalMasterView): data, simple_settings=simple_settings, input_file_templates=False) def preferences_remove_settings(self, user): + app = self.get_rattail_app() simple_settings = self.preferences_get_simple_settings(user) self.configure_remove_settings(simple_settings=simple_settings, input_file_templates=False) + app.delete_setting(self.Session(), f'tailbone.{user.uuid}.buefy_css') @classmethod def defaults(cls, config): From d6fa83cd87052befbd47a4170118b12a9099f39b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 19 Apr 2024 22:27:30 -0500 Subject: [PATCH 1375/1681] Fix permission checks for root user with pyramid 2.x --- tailbone/auth.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tailbone/auth.py b/tailbone/auth.py index 66deeff0..0a5bd903 100644 --- a/tailbone/auth.py +++ b/tailbone/auth.py @@ -209,6 +209,10 @@ class TailboneSecurityPolicy: return self.session_helper.forget(request, **kw) def permits(self, request, context, permission): + # nb. root user can do anything + if request.is_root: + return True + config = request.registry.settings.get('rattail_config') app = config.get_app() auth = app.get_auth_handler() From 9f984241c4792a365482218ecc863c0ade1d4c90 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 24 Apr 2024 17:31:53 -0500 Subject: [PATCH 1376/1681] Cleanup grid/filters logic a bit get rid of grids.js file, remove filter templates from complete.mako move all that instead to filter-components.mako for now, base template does import + setup for the latter, "just in case" a given view has any grids. each grid should (still) be isolated but no code should be duplicated now. whereas before the grid filter templates were in comlete.mako and hence could be declared more than once if multiple grids are on a page --- tailbone/static/js/tailbone.buefy.grid.js | 167 ---------- tailbone/templates/base.mako | 5 +- tailbone/templates/grids/complete.mako | 126 ------- .../templates/grids/filter-components.mako | 313 ++++++++++++++++++ tailbone/templates/master/index.mako | 21 +- 5 files changed, 329 insertions(+), 303 deletions(-) delete mode 100644 tailbone/static/js/tailbone.buefy.grid.js create mode 100644 tailbone/templates/grids/filter-components.mako diff --git a/tailbone/static/js/tailbone.buefy.grid.js b/tailbone/static/js/tailbone.buefy.grid.js deleted file mode 100644 index 6be28f41..00000000 --- a/tailbone/static/js/tailbone.buefy.grid.js +++ /dev/null @@ -1,167 +0,0 @@ - -const GridFilterNumericValue = { - template: '#grid-filter-numeric-value-template', - props: { - value: String, - wantsRange: Boolean, - }, - data() { - return { - startValue: null, - endValue: null, - } - }, - mounted() { - if (this.wantsRange) { - if (this.value.includes('|')) { - let values = this.value.split('|') - if (values.length == 2) { - this.startValue = values[0] - this.endValue = values[1] - } else { - this.startValue = this.value - } - } else { - this.startValue = this.value - } - } else { - this.startValue = this.value - } - }, - watch: { - // when changing from e.g. 'equal' to 'between' filter verbs, - // must proclaim new filter value, to reflect (lack of) range - wantsRange(val) { - if (val) { - this.$emit('input', this.startValue + '|' + this.endValue) - } else { - this.$emit('input', this.startValue) - } - }, - }, - methods: { - focus() { - this.$refs.startValue.focus() - }, - startValueChanged(value) { - if (this.wantsRange) { - value += '|' + this.endValue - } - this.$emit('input', value) - }, - endValueChanged(value) { - value = this.startValue + '|' + value - this.$emit('input', value) - }, - }, -} - -Vue.component('grid-filter-numeric-value', GridFilterNumericValue) - - -const GridFilterDateValue = { - template: '#grid-filter-date-value-template', - props: { - value: String, - dateRange: Boolean, - }, - data() { - return { - startDate: null, - endDate: null, - } - }, - mounted() { - if (this.dateRange) { - if (this.value.includes('|')) { - let values = this.value.split('|') - if (values.length == 2) { - this.startDate = values[0] - this.endDate = values[1] - } else { - this.startDate = this.value - } - } else { - this.startDate = this.value - } - } else { - this.startDate = this.value - } - }, - methods: { - focus() { - this.$refs.startDate.focus() - }, - startDateChanged(value) { - if (this.dateRange) { - value += '|' + this.endDate - } - this.$emit('input', value) - }, - endDateChanged(value) { - value = this.startDate + '|' + value - this.$emit('input', value) - }, - }, -} - -Vue.component('grid-filter-date-value', GridFilterDateValue) - - -const GridFilter = { - template: '#grid-filter-template', - props: { - filter: Object - }, - - methods: { - - changeVerb() { - // set focus to value input, "as quickly as we can" - this.$nextTick(function() { - this.focusValue() - }) - }, - - valuedVerb() { - /* this returns true if the filter's current verb should expose value input(s) */ - - // if filter has no "valueless" verbs, then all verbs should expose value inputs - if (!this.filter.valueless_verbs) { - return true - } - - // if filter *does* have valueless verbs, check if "current" verb is valueless - if (this.filter.valueless_verbs.includes(this.filter.verb)) { - return false - } - - // current verb is *not* valueless - return true - }, - - multiValuedVerb() { - /* this returns true if the filter's current verb should expose a multi-value input */ - - // if filter has no "multi-value" verbs then we safely assume false - if (!this.filter.multiple_value_verbs) { - return false - } - - // if filter *does* have multi-value verbs, see if "current" is one - if (this.filter.multiple_value_verbs.includes(this.filter.verb)) { - return true - } - - // current verb is not multi-value - return false - }, - - focusValue: function() { - this.$refs.valueInput.focus() - // this.$refs.valueInput.select() - } - } -} - -Vue.component('grid-filter', GridFilter) diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index e1020b28..d8e86547 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -3,6 +3,7 @@ <%namespace file="/autocomplete.mako" import="tailbone_autocomplete_template" /> <%namespace name="base_meta" file="/base_meta.mako" /> <%namespace file="/formposter.mako" import="declare_formposter_mixin" /> +<%namespace file="/grids/filter-components.mako" import="make_grid_filter_components" /> <%namespace name="page_help" file="/page_help.mako" /> <%namespace name="multi_file_upload" file="/multi_file_upload.mako" /> <!DOCTYPE html> @@ -90,7 +91,6 @@ ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.numericinput.js') + '?ver={}'.format(tailbone.__version__))} ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.oncebutton.js') + '?ver={}'.format(tailbone.__version__))} ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.timepicker.js') + '?ver={}'.format(tailbone.__version__))} - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.grid.js') + '?ver={}'.format(tailbone.__version__))} <script type="text/javascript"> @@ -896,6 +896,9 @@ </%def> <%def name="make_whole_page_component()"> + + ${make_grid_filter_components()} + ${self.declare_whole_page_vars()} ${self.modify_whole_page_vars()} ${self.finalize_whole_page_vars()} diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako index 205012be..1476fbae 100644 --- a/tailbone/templates/grids/complete.mako +++ b/tailbone/templates/grids/complete.mako @@ -1,131 +1,5 @@ ## -*- coding: utf-8; -*- -<script type="text/x-template" id="grid-filter-numeric-value-template"> - <div class="level"> - <div class="level-left"> - <div class="level-item"> - <b-input v-model="startValue" - ref="startValue" - @input="startValueChanged"> - </b-input> - </div> - <div v-show="wantsRange" - class="level-item"> - and - </div> - <div v-show="wantsRange" - class="level-item"> - <b-input v-model="endValue" - ref="endValue" - @input="endValueChanged"> - </b-input> - </div> - </div> - </div> -</script> - -<script type="text/x-template" id="grid-filter-date-value-template"> - <div class="level"> - <div class="level-left"> - <div class="level-item"> - <tailbone-datepicker v-model="startDate" - ref="startDate" - @input="startDateChanged"> - </tailbone-datepicker> - </div> - <div v-show="dateRange" - class="level-item"> - and - </div> - <div v-show="dateRange" - class="level-item"> - <tailbone-datepicker v-model="endDate" - ref="endDate" - @input="endDateChanged"> - </tailbone-datepicker> - </div> - </div> - </div> -</script> - -<script type="text/x-template" id="grid-filter-template"> - - <div class="level filter" v-show="filter.visible"> - <div class="level-left" - style="align-items: start;"> - - <div class="level-item filter-fieldname"> - - <b-field> - <b-checkbox-button v-model="filter.active" native-value="IGNORED"> - <b-icon pack="fas" icon="check" v-show="filter.active"></b-icon> - <span>{{ filter.label }}</span> - </b-checkbox-button> - </b-field> - - </div> - - <b-field grouped v-show="filter.active" - class="level-item" - style="align-items: start;"> - - <b-select v-model="filter.verb" - @input="focusValue()" - class="filter-verb"> - <option v-for="verb in filter.verbs" - :key="verb" - :value="verb"> - {{ filter.verb_labels[verb] }} - </option> - </b-select> - - ## only one of the following "value input" elements will be rendered - - <grid-filter-date-value v-if="filter.data_type == 'date'" - v-model="filter.value" - v-show="valuedVerb()" - :date-range="filter.verb == 'between'" - ref="valueInput"> - </grid-filter-date-value> - - <b-select v-if="filter.data_type == 'choice'" - v-model="filter.value" - v-show="valuedVerb()" - ref="valueInput"> - <option v-for="choice in filter.choices" - :key="choice" - :value="choice"> - {{ filter.choice_labels[choice] || choice }} - </option> - </b-select> - - <grid-filter-numeric-value v-if="filter.data_type == 'number'" - v-model="filter.value" - v-show="valuedVerb()" - :wants-range="filter.verb == 'between'" - ref="valueInput"> - </grid-filter-numeric-value> - - <b-input v-if="filter.data_type == 'string' && !multiValuedVerb()" - v-model="filter.value" - v-show="valuedVerb()" - ref="valueInput"> - </b-input> - - <b-input v-if="filter.data_type == 'string' && multiValuedVerb()" - type="textarea" - v-model="filter.value" - v-show="valuedVerb()" - ref="valueInput"> - </b-input> - - </b-field> - - </div><!-- level-left --> - </div><!-- level --> - -</script> - <script type="text/x-template" id="${grid.component}-template"> <div> diff --git a/tailbone/templates/grids/filter-components.mako b/tailbone/templates/grids/filter-components.mako new file mode 100644 index 00000000..815e6028 --- /dev/null +++ b/tailbone/templates/grids/filter-components.mako @@ -0,0 +1,313 @@ +## -*- coding: utf-8; -*- + +<%def name="make_grid_filter_components()"> + ${self.make_grid_filter_numeric_value_component()} + ${self.make_grid_filter_date_value_component()} + ${self.make_grid_filter_component()} +</%def> + +<%def name="make_grid_filter_numeric_value_component()"> + <script type="text/x-template" id="grid-filter-numeric-value-template"> + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <b-input v-model="startValue" + ref="startValue" + @input="startValueChanged"> + </b-input> + </div> + <div v-show="wantsRange" + class="level-item"> + and + </div> + <div v-show="wantsRange" + class="level-item"> + <b-input v-model="endValue" + ref="endValue" + @input="endValueChanged"> + </b-input> + </div> + </div> + </div> + </script> + <script> + + const GridFilterNumericValue = { + template: '#grid-filter-numeric-value-template', + props: { + value: String, + wantsRange: Boolean, + }, + data() { + return { + startValue: null, + endValue: null, + } + }, + mounted() { + if (this.wantsRange) { + if (this.value.includes('|')) { + let values = this.value.split('|') + if (values.length == 2) { + this.startValue = values[0] + this.endValue = values[1] + } else { + this.startValue = this.value + } + } else { + this.startValue = this.value + } + } else { + this.startValue = this.value + } + }, + watch: { + // when changing from e.g. 'equal' to 'between' filter verbs, + // must proclaim new filter value, to reflect (lack of) range + wantsRange(val) { + if (val) { + this.$emit('input', this.startValue + '|' + this.endValue) + } else { + this.$emit('input', this.startValue) + } + }, + }, + methods: { + focus() { + this.$refs.startValue.focus() + }, + startValueChanged(value) { + if (this.wantsRange) { + value += '|' + this.endValue + } + this.$emit('input', value) + }, + endValueChanged(value) { + value = this.startValue + '|' + value + this.$emit('input', value) + }, + }, + } + + Vue.component('grid-filter-numeric-value', GridFilterNumericValue) + + </script> +</%def> + +<%def name="make_grid_filter_date_value_component()"> + <script type="text/x-template" id="grid-filter-date-value-template"> + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <tailbone-datepicker v-model="startDate" + ref="startDate" + @input="startDateChanged"> + </tailbone-datepicker> + </div> + <div v-show="dateRange" + class="level-item"> + and + </div> + <div v-show="dateRange" + class="level-item"> + <tailbone-datepicker v-model="endDate" + ref="endDate" + @input="endDateChanged"> + </tailbone-datepicker> + </div> + </div> + </div> + </script> + <script> + + const GridFilterDateValue = { + template: '#grid-filter-date-value-template', + props: { + value: String, + dateRange: Boolean, + }, + data() { + return { + startDate: null, + endDate: null, + } + }, + mounted() { + if (this.dateRange) { + if (this.value.includes('|')) { + let values = this.value.split('|') + if (values.length == 2) { + this.startDate = values[0] + this.endDate = values[1] + } else { + this.startDate = this.value + } + } else { + this.startDate = this.value + } + } else { + this.startDate = this.value + } + }, + methods: { + focus() { + this.$refs.startDate.focus() + }, + startDateChanged(value) { + if (this.dateRange) { + value += '|' + this.endDate + } + this.$emit('input', value) + }, + endDateChanged(value) { + value = this.startDate + '|' + value + this.$emit('input', value) + }, + }, + } + + Vue.component('grid-filter-date-value', GridFilterDateValue) + + </script> +</%def> + +<%def name="make_grid_filter_component()"> + <script type="text/x-template" id="grid-filter-template"> + + <div class="level filter" v-show="filter.visible"> + <div class="level-left" + style="align-items: start;"> + + <div class="level-item filter-fieldname"> + + <b-field> + <b-checkbox-button v-model="filter.active" native-value="IGNORED"> + <b-icon pack="fas" icon="check" v-show="filter.active"></b-icon> + <span>{{ filter.label }}</span> + </b-checkbox-button> + </b-field> + + </div> + + <b-field grouped v-show="filter.active" + class="level-item" + style="align-items: start;"> + + <b-select v-model="filter.verb" + @input="focusValue()" + class="filter-verb"> + <option v-for="verb in filter.verbs" + :key="verb" + :value="verb"> + {{ filter.verb_labels[verb] }} + </option> + </b-select> + + ## only one of the following "value input" elements will be rendered + + <grid-filter-date-value v-if="filter.data_type == 'date'" + v-model="filter.value" + v-show="valuedVerb()" + :date-range="filter.verb == 'between'" + ref="valueInput"> + </grid-filter-date-value> + + <b-select v-if="filter.data_type == 'choice'" + v-model="filter.value" + v-show="valuedVerb()" + ref="valueInput"> + <option v-for="choice in filter.choices" + :key="choice" + :value="choice"> + {{ filter.choice_labels[choice] || choice }} + </option> + </b-select> + + <grid-filter-numeric-value v-if="filter.data_type == 'number'" + v-model="filter.value" + v-show="valuedVerb()" + :wants-range="filter.verb == 'between'" + ref="valueInput"> + </grid-filter-numeric-value> + + <b-input v-if="filter.data_type == 'string' && !multiValuedVerb()" + v-model="filter.value" + v-show="valuedVerb()" + ref="valueInput"> + </b-input> + + <b-input v-if="filter.data_type == 'string' && multiValuedVerb()" + type="textarea" + v-model="filter.value" + v-show="valuedVerb()" + ref="valueInput"> + </b-input> + + </b-field> + + </div><!-- level-left --> + </div><!-- level --> + + </script> + <script> + + const GridFilter = { + template: '#grid-filter-template', + props: { + filter: Object + }, + + methods: { + + changeVerb() { + // set focus to value input, "as quickly as we can" + this.$nextTick(function() { + this.focusValue() + }) + }, + + valuedVerb() { + /* this returns true if the filter's current verb should expose value input(s) */ + + // if filter has no "valueless" verbs, then all verbs should expose value inputs + if (!this.filter.valueless_verbs) { + return true + } + + // if filter *does* have valueless verbs, check if "current" verb is valueless + if (this.filter.valueless_verbs.includes(this.filter.verb)) { + return false + } + + // current verb is *not* valueless + return true + }, + + multiValuedVerb() { + /* this returns true if the filter's current verb should expose a multi-value input */ + + // if filter has no "multi-value" verbs then we safely assume false + if (!this.filter.multiple_value_verbs) { + return false + } + + // if filter *does* have multi-value verbs, see if "current" is one + if (this.filter.multiple_value_verbs.includes(this.filter.verb)) { + return true + } + + // current verb is not multi-value + return false + }, + + focusValue: function() { + this.$refs.valueInput.focus() + // this.$refs.valueInput.select() + } + } + } + + Vue.component('grid-filter', GridFilter) + + </script> +</%def> diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako index 051a9ab6..d9dabc7b 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -299,6 +299,11 @@ % endif </%def> +<%def name="make_grid_component()"> + ## TODO: stop using |n filter? + ${grid.render_complete(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())|n} +</%def> + <%def name="render_grid_component()"> <${grid.component} ref="grid" :csrftoken="csrftoken" % if master.deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple': @@ -309,11 +314,16 @@ </%def> <%def name="make_this_page_component()"> + + ## define grid + ${self.make_grid_component()} + ${parent.make_this_page_component()} - <script type="text/javascript"> - ${grid.component_studly}.data = function() { return ${grid.component_studly}Data } + ## finalize grid + <script> + ${grid.component_studly}.data = () => { return ${grid.component_studly}Data } Vue.component('${grid.component}', ${grid.component_studly}) </script> @@ -323,13 +333,6 @@ ${self.page_content()} </%def> -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} - - ## TODO: stop using |n filter - ${grid.render_complete(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())|n} -</%def> - <%def name="modify_this_page_vars()"> ${parent.modify_this_page_vars()} <script type="text/javascript"> From 0ca3b31b2eb50cee024c48cc128a33c1dac296cf Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 24 Apr 2024 18:20:16 -0500 Subject: [PATCH 1377/1681] Use normal button for grid filters since that's more portable (for oruga) than "checkbox button" --- tailbone/templates/base.mako | 3 ++- tailbone/templates/grids/filter-components.mako | 9 +++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index d8e86547..53fac116 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -151,7 +151,8 @@ ${h.stylesheet_link(request.static_url('tailbone:static/css/codehilite.css') + '?ver={}'.format(tailbone.__version__))} <style type="text/css"> - .filters .filter-fieldname { + .filters .filter-fieldname, + .filters .filter-fieldname .button { min-width: ${filter_fieldname_width}; justify-content: left; } diff --git a/tailbone/templates/grids/filter-components.mako b/tailbone/templates/grids/filter-components.mako index 815e6028..9bc02fed 100644 --- a/tailbone/templates/grids/filter-components.mako +++ b/tailbone/templates/grids/filter-components.mako @@ -181,10 +181,11 @@ <div class="level-item filter-fieldname"> <b-field> - <b-checkbox-button v-model="filter.active" native-value="IGNORED"> - <b-icon pack="fas" icon="check" v-show="filter.active"></b-icon> - <span>{{ filter.label }}</span> - </b-checkbox-button> + <b-button @click="filter.active = !filter.active" + icon-pack="fas" + :icon-left="filter.active ? 'check' : null"> + {{ filter.label }} + </b-button> </b-field> </div> From ddafa9ed972f80520085149a237b5540932beb12 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 24 Apr 2024 20:19:15 -0500 Subject: [PATCH 1378/1681] Tweak icon for Download Results button make it more portable for oruga --- tailbone/templates/master/index.mako | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako index d9dabc7b..0ae4c8ab 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -20,7 +20,7 @@ <li>${h.link_to("Download results as XLSX", url('{}.results_xlsx'.format(route_prefix)))}</li> % endif % if master.has_input_file_templates and master.has_perm('create'): - % for template in six.itervalues(input_file_templates): + % for template in input_file_templates.values(): <li>${h.link_to("Download {} Template".format(template['label']), template['effective_url'])}</li> % endfor % endif @@ -45,7 +45,7 @@ % if master.results_downloadable and master.has_perm('download_results'): <b-button type="is-primary" icon-pack="fas" - icon-left="fas fa-download" + icon-left="download" @click="showDownloadResultsDialog = true" :disabled="!total"> Download Results @@ -86,7 +86,7 @@ <div> <b-field horizontal label="Format"> <b-select v-model="downloadResultsFormat"> - % for key, label in six.iteritems(master.download_results_supported_formats()): + % for key, label in master.download_results_supported_formats().items(): <option value="${key}">${label}</option> % endfor </b-select> From 4f6ee1fb22256bd9d8d1f81ae0926372b4d758f1 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 24 Apr 2024 22:10:56 -0500 Subject: [PATCH 1379/1681] Use v-model to track selection etc. for download results fields --- tailbone/templates/master/index.mako | 53 ++++++++++++++++------------ 1 file changed, 30 insertions(+), 23 deletions(-) diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako index 0ae4c8ab..7cb9ffa2 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -84,7 +84,7 @@ <div style="display: flex; justify-content: space-between"> <div> - <b-field horizontal label="Format"> + <b-field label="Format"> <b-select v-model="downloadResultsFormat"> % for key, label in master.download_results_supported_formats().items(): <option value="${key}">${label}</option> @@ -130,9 +130,9 @@ <b-field label="Excluded Fields"> <b-select multiple native-size="8" expanded + v-model="downloadResultsExcludedFieldsSelected" ref="downloadResultsExcludedFields"> - <option v-for="field in downloadResultsFieldsAvailable" - v-if="!downloadResultsFieldsIncluded.includes(field)" + <option v-for="field in downloadResultsFieldsExcluded" :key="field" :value="field"> {{ field }} @@ -156,6 +156,7 @@ <b-field label="Included Fields"> <b-select multiple native-size="8" expanded + v-model="downloadResultsIncludedFieldsSelected" ref="downloadResultsIncludedFields"> <option v-for="field in downloadResultsFieldsIncluded" :key="field" @@ -417,6 +418,9 @@ ${grid.component_studly}Data.downloadResultsFieldsDefault = ${json.dumps(download_results_fields_default)|n} ${grid.component_studly}Data.downloadResultsFieldsIncluded = ${json.dumps(download_results_fields_default)|n} + ${grid.component_studly}Data.downloadResultsExcludedFieldsSelected = [] + ${grid.component_studly}Data.downloadResultsIncludedFieldsSelected = [] + ${grid.component_studly}.computed.downloadResultsFieldsExcluded = function() { let excluded = [] this.downloadResultsFieldsAvailable.forEach(field => { @@ -428,45 +432,48 @@ } ${grid.component_studly}.methods.downloadResultsExcludeFields = function() { - let selected = this.$refs.downloadResultsIncludedFields.selected + const selected = Array.from(this.downloadResultsIncludedFieldsSelected) if (!selected) { return } - selected = Array.from(selected) - selected.forEach(field => { - // de-select the entry within "included" field input - let index = this.$refs.downloadResultsIncludedFields.selected.indexOf(field) - if (index > -1) { - this.$refs.downloadResultsIncludedFields.selected.splice(index, 1) + selected.forEach(field => { + let index + + // remove field from selected + index = this.downloadResultsIncludedFieldsSelected.indexOf(field) + if (index >= 0) { + this.downloadResultsIncludedFieldsSelected.splice(index, 1) } - // remove field from official "included" list + // remove field from included + // nb. excluded list will reflect this change too index = this.downloadResultsFieldsIncluded.indexOf(field) - if (index > -1) { + if (index >= 0) { this.downloadResultsFieldsIncluded.splice(index, 1) } - }, this) + }) } ${grid.component_studly}.methods.downloadResultsIncludeFields = function() { - let selected = this.$refs.downloadResultsExcludedFields.selected + const selected = Array.from(this.downloadResultsExcludedFieldsSelected) if (!selected) { return } - selected = Array.from(selected) - selected.forEach(field => { - // de-select the entry within "excluded" field input - let index = this.$refs.downloadResultsExcludedFields.selected.indexOf(field) - if (index > -1) { - this.$refs.downloadResultsExcludedFields.selected.splice(index, 1) + selected.forEach(field => { + let index + + // remove field from selected + index = this.downloadResultsExcludedFieldsSelected.indexOf(field) + if (index >= 0) { + this.downloadResultsExcludedFieldsSelected.splice(index, 1) } - // add field to official "included" list + // add field to included + // nb. excluded list will reflect this change too this.downloadResultsFieldsIncluded.push(field) - - }, this) + }) } ${grid.component_studly}.methods.downloadResultsUseDefaultFields = function() { From d2aa91502a5c3f943c3398179fb57f51493ed07d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 25 Apr 2024 14:02:45 -0500 Subject: [PATCH 1380/1681] Allow deleting rows from executed batches requires a view to explicitly opt-in. and a separate permission is required for the user --- tailbone/views/batch/core.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 4df3d911..84ef451f 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -63,6 +63,7 @@ class BatchMasterView(MasterView): batch_handler_class = None has_rows = True rows_deletable = True + rows_deletable_if_executed = False rows_bulk_deletable = True rows_downloadable_csv = True rows_downloadable_xlsx = True @@ -700,11 +701,11 @@ class BatchMasterView(MasterView): view = lambda r, i: self.get_row_action_url('view', r) actions.append(self.make_action('view', icon='eye', url=view)) - # edit and delete are NOT allowed after execution, or if batch is "complete" - if not batch.executed and not batch.complete: + # edit and delete are NOT allowed if batch is "complete" + if not batch.complete: # edit action - if self.rows_editable and self.has_perm('edit_row'): + if self.rows_editable and not batch.executed and self.has_perm('edit_row'): actions.append(self.make_action('edit', icon='edit', url=self.row_edit_action_url)) @@ -1241,9 +1242,16 @@ class BatchMasterView(MasterView): return False batch = self.get_parent(row) - if batch.complete or batch.executed: + + if batch.complete: return False + if batch.executed: + if not self.rows_deletable_if_executed: + return False + if not self.has_perm('delete_row_if_executed'): + return False + return True def template_kwargs_view_row(self, **kwargs): @@ -1504,6 +1512,12 @@ class BatchMasterView(MasterView): config.add_tailbone_permission(permission_prefix, '{}.refresh'.format(permission_prefix), "Refresh data for {}".format(model_title)) + # delete row if executed + if cls.rows_deletable_if_executed: + config.add_tailbone_permission(permission_prefix, + f'{permission_prefix}.delete_row_if_executed', + "Delete rows after batch is executed") + # toggle complete config.add_route('{}.toggle_complete'.format(route_prefix), '{}/{{{}}}/toggle-complete'.format(url_prefix, model_key)) config.add_view(cls, attr='toggle_complete', route_name='{}.toggle_complete'.format(route_prefix), From 23e6eef60430b427e997f4e1303a9a2eedf7d1ea Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 25 Apr 2024 14:05:10 -0500 Subject: [PATCH 1381/1681] Update changelog --- CHANGES.rst | 20 ++++++++++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 53bc179f..7bdb466d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,26 @@ CHANGELOG Unreleased ---------- +0.9.96 (2024-04-25) +------------------- + +* Remove unused code for ``webhelpers2_grid``. + +* Rename setting for custom user css (remove "buefy"). + +* Fix permission checks for root user with pyramid 2.x. + +* Cleanup grid/filters logic a bit. + +* Use normal (not checkbox) button for grid filters. + +* Tweak icon for Download Results button. + +* Use v-model to track selection etc. for download results fields. + +* Allow deleting rows from executed batches. + + 0.9.95 (2024-04-19) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 016440ba..fb15d91c 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.95' +__version__ = '0.9.96' From bfe6b5bc251969d5580b6de8e2e0c296005a1a81 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 25 Apr 2024 15:41:06 -0500 Subject: [PATCH 1382/1681] Use explicit flex styles for grid-tools element and so, must ensure children of grid-tools are atomic elements --- tailbone/static/css/grids.css | 5 + tailbone/templates/grids/complete.mako | 2 +- tailbone/templates/master/index.mako | 262 +++++++++++++------------ 3 files changed, 138 insertions(+), 131 deletions(-) diff --git a/tailbone/static/css/grids.css b/tailbone/static/css/grids.css index da5814c4..42da832c 100644 --- a/tailbone/static/css/grids.css +++ b/tailbone/static/css/grids.css @@ -25,6 +25,11 @@ margin: 0; } +.grid-tools { + display: flex; + gap: 0.5rem; +} + .grid-wrapper .grid-header td.tools { margin: 0; padding: 0; diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako index 1476fbae..db46764e 100644 --- a/tailbone/templates/grids/complete.mako +++ b/tailbone/templates/grids/complete.mako @@ -28,7 +28,7 @@ <div class="grid-tools-wrapper"> % if tools: - <div class="grid-tools field buttons is-grouped is-pulled-right"> + <div class="grid-tools"> ## TODO: stop using |n filter ${tools|n} </div> diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako index 7cb9ffa2..2ad9a21b 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -43,150 +43,152 @@ ## download search results % if master.results_downloadable and master.has_perm('download_results'): - <b-button type="is-primary" - icon-pack="fas" - icon-left="download" - @click="showDownloadResultsDialog = true" - :disabled="!total"> - Download Results - </b-button> + <div> + <b-button type="is-primary" + icon-pack="fas" + icon-left="download" + @click="showDownloadResultsDialog = true" + :disabled="!total"> + Download Results + </b-button> - ${h.form(url('{}.download_results'.format(route_prefix)), ref='download_results_form')} - ${h.csrf_token(request)} - <input type="hidden" name="fmt" :value="downloadResultsFormat" /> - <input type="hidden" name="fields" :value="downloadResultsFieldsIncluded" /> - ${h.end_form()} + ${h.form(url('{}.download_results'.format(route_prefix)), ref='download_results_form')} + ${h.csrf_token(request)} + <input type="hidden" name="fmt" :value="downloadResultsFormat" /> + <input type="hidden" name="fields" :value="downloadResultsFieldsIncluded" /> + ${h.end_form()} - <b-modal :active.sync="showDownloadResultsDialog"> - <div class="card"> + <b-modal :active.sync="showDownloadResultsDialog"> + <div class="card"> - <div class="card-content"> - <p> - There are - <span class="is-size-4 has-text-weight-bold"> - {{ total.toLocaleString('en') }} ${model_title_plural} - </span> - matching your current filters. - </p> - <p> - You may download this set as a single data file if you like. - </p> - <br /> + <div class="card-content"> + <p> + There are + <span class="is-size-4 has-text-weight-bold"> + {{ total.toLocaleString('en') }} ${model_title_plural} + </span> + matching your current filters. + </p> + <p> + You may download this set as a single data file if you like. + </p> + <br /> - <b-notification type="is-warning" :closable="false" - v-if="downloadResultsFormat == 'xlsx' && total >= 1000"> - Excel downloads for large data sets can take a long time to - generate, and bog down the server in the meantime. You are - encouraged to choose CSV for a large data set, even though - the end result (file size) may be larger with CSV. - </b-notification> + <b-notification type="is-warning" :closable="false" + v-if="downloadResultsFormat == 'xlsx' && total >= 1000"> + Excel downloads for large data sets can take a long time to + generate, and bog down the server in the meantime. You are + encouraged to choose CSV for a large data set, even though + the end result (file size) may be larger with CSV. + </b-notification> - <div style="display: flex; justify-content: space-between"> + <div style="display: flex; justify-content: space-between"> - <div> - <b-field label="Format"> - <b-select v-model="downloadResultsFormat"> - % for key, label in master.download_results_supported_formats().items(): - <option value="${key}">${label}</option> - % endfor - </b-select> - </b-field> - </div> - - <div> - - <div v-show="downloadResultsFieldsMode != 'choose'" - class="has-text-right"> - <p v-if="downloadResultsFieldsMode == 'default'"> - Will use DEFAULT fields. - </p> - <p v-if="downloadResultsFieldsMode == 'all'"> - Will use ALL fields. - </p> - <br /> + <div> + <b-field label="Format"> + <b-select v-model="downloadResultsFormat"> + % for key, label in master.download_results_supported_formats().items(): + <option value="${key}">${label}</option> + % endfor + </b-select> + </b-field> </div> - <div class="buttons is-right"> - <b-button type="is-primary" - v-show="downloadResultsFieldsMode != 'default'" - @click="downloadResultsUseDefaultFields()"> - Use Default Fields - </b-button> - <b-button type="is-primary" - v-show="downloadResultsFieldsMode != 'all'" - @click="downloadResultsUseAllFields()"> - Use All Fields - </b-button> - <b-button type="is-primary" - v-show="downloadResultsFieldsMode != 'choose'" - @click="downloadResultsFieldsMode = 'choose'"> - Choose Fields - </b-button> - </div> + <div> - <div v-show="downloadResultsFieldsMode == 'choose'"> - <div style="display: flex;"> - <div> - <b-field label="Excluded Fields"> - <b-select multiple native-size="8" - expanded - v-model="downloadResultsExcludedFieldsSelected" - ref="downloadResultsExcludedFields"> - <option v-for="field in downloadResultsFieldsExcluded" - :key="field" - :value="field"> - {{ field }} - </option> - </b-select> - </b-field> - </div> - <div> - <br /><br /> - <b-button style="margin: 0.5rem;" - @click="downloadResultsExcludeFields()"> - < - </b-button> - <br /> - <b-button style="margin: 0.5rem;" - @click="downloadResultsIncludeFields()"> - > - </b-button> - </div> - <div> - <b-field label="Included Fields"> - <b-select multiple native-size="8" - expanded - v-model="downloadResultsIncludedFieldsSelected" - ref="downloadResultsIncludedFields"> - <option v-for="field in downloadResultsFieldsIncluded" - :key="field" - :value="field"> - {{ field }} - </option> - </b-select> - </b-field> + <div v-show="downloadResultsFieldsMode != 'choose'" + class="has-text-right"> + <p v-if="downloadResultsFieldsMode == 'default'"> + Will use DEFAULT fields. + </p> + <p v-if="downloadResultsFieldsMode == 'all'"> + Will use ALL fields. + </p> + <br /> + </div> + + <div class="buttons is-right"> + <b-button type="is-primary" + v-show="downloadResultsFieldsMode != 'default'" + @click="downloadResultsUseDefaultFields()"> + Use Default Fields + </b-button> + <b-button type="is-primary" + v-show="downloadResultsFieldsMode != 'all'" + @click="downloadResultsUseAllFields()"> + Use All Fields + </b-button> + <b-button type="is-primary" + v-show="downloadResultsFieldsMode != 'choose'" + @click="downloadResultsFieldsMode = 'choose'"> + Choose Fields + </b-button> + </div> + + <div v-show="downloadResultsFieldsMode == 'choose'"> + <div style="display: flex;"> + <div> + <b-field label="Excluded Fields"> + <b-select multiple native-size="8" + expanded + v-model="downloadResultsExcludedFieldsSelected" + ref="downloadResultsExcludedFields"> + <option v-for="field in downloadResultsFieldsExcluded" + :key="field" + :value="field"> + {{ field }} + </option> + </b-select> + </b-field> + </div> + <div> + <br /><br /> + <b-button style="margin: 0.5rem;" + @click="downloadResultsExcludeFields()"> + < + </b-button> + <br /> + <b-button style="margin: 0.5rem;" + @click="downloadResultsIncludeFields()"> + > + </b-button> + </div> + <div> + <b-field label="Included Fields"> + <b-select multiple native-size="8" + expanded + v-model="downloadResultsIncludedFieldsSelected" + ref="downloadResultsIncludedFields"> + <option v-for="field in downloadResultsFieldsIncluded" + :key="field" + :value="field"> + {{ field }} + </option> + </b-select> + </b-field> + </div> </div> </div> + </div> - </div> - </div> - </div> <!-- card-content --> + </div> <!-- card-content --> - <footer class="modal-card-foot"> - <b-button @click="showDownloadResultsDialog = false"> - Cancel - </b-button> - <once-button type="is-primary" - @click="downloadResultsSubmit()" - icon-pack="fas" - icon-left="fas fa-download" - :disabled="!downloadResultsFieldsIncluded.length" - text="Download Results"> - </once-button> - </footer> - </div> - </b-modal> + <footer class="modal-card-foot"> + <b-button @click="showDownloadResultsDialog = false"> + Cancel + </b-button> + <once-button type="is-primary" + @click="downloadResultsSubmit()" + icon-pack="fas" + icon-left="fas fa-download" + :disabled="!downloadResultsFieldsIncluded.length" + text="Download Results"> + </once-button> + </footer> + </div> + </b-modal> + </div> % endif ## download rows for search results From f43259fbc1a60e9e3b972a600d902816ce5c1b5a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 25 Apr 2024 16:04:30 -0500 Subject: [PATCH 1383/1681] Use proper flex styles for grid pagination footer --- tailbone/templates/grids/complete.mako | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako index db46764e..710ea3e6 100644 --- a/tailbone/templates/grids/complete.mako +++ b/tailbone/templates/grids/complete.mako @@ -192,10 +192,12 @@ % endif % if grid.pageable: - <b-field grouped - v-if="firstItem"> - <span class="control"> - showing {{ firstItem.toLocaleString('en') }} - {{ lastItem.toLocaleString('en') }} of {{ total.toLocaleString('en') }} results; + <div v-if="firstItem" + style="display: flex; gap: 0.5rem; align-items: center;"> + <span> + showing + {{ firstItem.toLocaleString('en') }} - {{ lastItem.toLocaleString('en') }} + of {{ total.toLocaleString('en') }} results; </span> <b-select v-model="perPage" size="is-small" @@ -204,10 +206,10 @@ <option value="${value}">${value}</option> % endfor </b-select> - <span class="control"> + <span> per page </span> - </b-field> + </div> % endif </div> From ab57fb3f0f47eddc4ffcaf844abe7fb5c641d2a3 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 25 Apr 2024 18:16:39 -0500 Subject: [PATCH 1384/1681] Tweak flex styles for grid filters --- tailbone/templates/grids/complete.mako | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako index 710ea3e6..940174dc 100644 --- a/tailbone/templates/grids/complete.mako +++ b/tailbone/templates/grids/complete.mako @@ -5,8 +5,7 @@ <div style="display: flex; justify-content: space-between; margin-bottom: 0.5em;"> - <div style="display: flex; flex-direction: column; justify-content: space-between;"> - <div></div> + <div style="display: flex; flex-direction: column; justify-content: end;"> <div class="filters"> % if grid.filterable: ## TODO: stop using |n filter @@ -41,7 +40,6 @@ <b-table :data="visibleData" - ## :columns="columns" :loading="loading" :row-class="getRowClass" From daf68cad0185368231e8f555aaff9ea5e1ac9e41 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 25 Apr 2024 18:52:34 -0500 Subject: [PATCH 1385/1681] Fix data type handling for datepicker and grid filter components here is what's up now: - <b-datepicker> expects v-model to be a Date - <tailbone-datepicker> also expects a Date - <grid-filter-date-value> uses String for its v-model latter is so the value can represent a date range, e.g. 'YYYY-MM-DD|YYYY-MM-DD' anyway there was previously confusion about data type among these components, and hopefully they are straight now per the above outline --- .../static/js/tailbone.buefy.datepicker.js | 22 ++++++- .../templates/grids/filter-components.mako | 58 +++++++++++++------ 2 files changed, 60 insertions(+), 20 deletions(-) diff --git a/tailbone/static/js/tailbone.buefy.datepicker.js b/tailbone/static/js/tailbone.buefy.datepicker.js index fe649380..c516b97f 100644 --- a/tailbone/static/js/tailbone.buefy.datepicker.js +++ b/tailbone/static/js/tailbone.buefy.datepicker.js @@ -11,7 +11,7 @@ const TailboneDatepicker = { 'icon="calendar-alt"', ':date-formatter="formatDate"', ':date-parser="parseDate"', - ':value="value ? parseDate(value) : null"', + ':value="buefyValue"', '@input="dateChanged"', ':disabled="disabled"', 'ref="trueDatePicker"', @@ -26,6 +26,24 @@ const TailboneDatepicker = { disabled: Boolean, }, + data() { + let buefyValue = this.value + if (buefyValue && !buefyValue.getDate) { + buefyValue = this.parseDate(this.value) + } + return { + buefyValue, + } + }, + + watch: { + value(to, from) { + if (this.buefyValue != to) { + this.buefyValue = to + } + }, + }, + methods: { formatDate(date) { @@ -49,7 +67,7 @@ const TailboneDatepicker = { }, dateChanged(date) { - this.$emit('input', this.formatDate(date)) + this.$emit('input', date) }, focus() { diff --git a/tailbone/templates/grids/filter-components.mako b/tailbone/templates/grids/filter-components.mako index 9bc02fed..869455cc 100644 --- a/tailbone/templates/grids/filter-components.mako +++ b/tailbone/templates/grids/filter-components.mako @@ -127,40 +127,62 @@ dateRange: Boolean, }, data() { - return { - startDate: null, - endDate: null, - } - }, - mounted() { - if (this.dateRange) { - if (this.value.includes('|')) { + let startDate = null + let endDate = null + if (this.value) { + + if (this.dateRange) { let values = this.value.split('|') if (values.length == 2) { - this.startDate = values[0] - this.endDate = values[1] - } else { - this.startDate = this.value + startDate = this.parseDate(values[0]) + endDate = this.parseDate(values[1]) + } else { // no end date specified? + startDate = this.parseDate(this.value) } - } else { - this.startDate = this.value + + } else { // not a range, so start date only + startDate = this.parseDate(this.value) } - } else { - this.startDate = this.value + } + + return { + startDate, + endDate, } }, methods: { focus() { this.$refs.startDate.focus() }, + formatDate(date) { + if (date === null) { + return null + } + // just need to convert to simple ISO date format here, seems + // like there should be a more obvious way to do that? + var year = date.getFullYear() + var month = date.getMonth() + 1 + var day = date.getDate() + month = month < 10 ? '0' + month : month + day = day < 10 ? '0' + day : day + return year + '-' + month + '-' + day + }, + parseDate(value) { + if (value) { + // note, this assumes classic YYYY-MM-DD (i.e. ISO?) format + const parts = value.split('-') + return new Date(parts[0], parseInt(parts[1]) - 1, parts[2]) + } + }, startDateChanged(value) { + value = this.formatDate(value) if (this.dateRange) { - value += '|' + this.endDate + value += '|' + this.formatDate(this.endDate) } this.$emit('input', value) }, endDateChanged(value) { - value = this.startDate + '|' + value + value = this.formatDate(this.startDate) + '|' + this.formatDate(value) this.$emit('input', value) }, }, From 25a27af29c4bc6d4a665accefde561e6bd76a63d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 25 Apr 2024 20:45:03 -0500 Subject: [PATCH 1386/1681] Use explicit flex styles instead of "level" for grid filters etc. just to be more precise, and consistent --- tailbone/static/css/filters.css | 4 --- .../templates/grids/filter-components.mako | 35 +++++++------------ tailbone/templates/grids/filters.mako | 27 +++++++------- 3 files changed, 25 insertions(+), 41 deletions(-) diff --git a/tailbone/static/css/filters.css b/tailbone/static/css/filters.css index 6deff7b0..72506a06 100644 --- a/tailbone/static/css/filters.css +++ b/tailbone/static/css/filters.css @@ -3,10 +3,6 @@ * Grid Filters ******************************/ -.filters .filter { - margin-bottom: 0.5rem; -} - .filters .filter-fieldname .field, .filters .filter-fieldname .field label { width: 100%; diff --git a/tailbone/templates/grids/filter-components.mako b/tailbone/templates/grids/filter-components.mako index 869455cc..7897b3cf 100644 --- a/tailbone/templates/grids/filter-components.mako +++ b/tailbone/templates/grids/filter-components.mako @@ -195,26 +195,20 @@ <%def name="make_grid_filter_component()"> <script type="text/x-template" id="grid-filter-template"> + <div class="filter" + v-show="filter.visible" + style="display: flex; gap: 0.5rem;"> - <div class="level filter" v-show="filter.visible"> - <div class="level-left" - style="align-items: start;"> - - <div class="level-item filter-fieldname"> - - <b-field> - <b-button @click="filter.active = !filter.active" - icon-pack="fas" - :icon-left="filter.active ? 'check' : null"> - {{ filter.label }} - </b-button> - </b-field> - + <div class="filter-fieldname"> + <b-button @click="filter.active = !filter.active" + icon-pack="fas" + :icon-left="filter.active ? 'check' : null"> + {{ filter.label }} + </b-button> </div> - <b-field grouped v-show="filter.active" - class="level-item" - style="align-items: start;"> + <div v-show="filter.active" + style="display: flex; gap: 0.5rem;"> <b-select v-model="filter.verb" @input="focusValue()" @@ -266,11 +260,8 @@ ref="valueInput"> </b-input> - </b-field> - - </div><!-- level-left --> - </div><!-- level --> - + </div> + </div> </script> <script> diff --git a/tailbone/templates/grids/filters.mako b/tailbone/templates/grids/filters.mako index 4c584883..eb245934 100644 --- a/tailbone/templates/grids/filters.mako +++ b/tailbone/templates/grids/filters.mako @@ -2,26 +2,26 @@ <form action="${form.action_url}" method="GET" @submit.prevent="applyFilters()"> - <grid-filter v-for="key in filtersSequence" - :key="key" - :filter="filters[key]" - ref="gridFilters"> - </grid-filter> + <div style="display: flex; flex-direction: column; gap: 0.5rem;"> + <grid-filter v-for="key in filtersSequence" + :key="key" + :filter="filters[key]" + ref="gridFilters"> + </grid-filter> + </div> - <b-field grouped> + <div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;"> <b-button type="is-primary" native-type="submit" icon-pack="fas" - icon-left="check" - class="control"> + icon-left="check"> Apply Filters </b-button> <b-button v-if="!addFilterShow" icon-pack="fas" icon-left="plus" - class="control" @click="addFilterButton"> Add Filter </b-button> @@ -44,15 +44,13 @@ <b-button @click="resetView()" icon-pack="fas" - icon-left="home" - class="control"> + icon-left="home"> Default View </b-button> <b-button @click="clearFilters()" icon-pack="fas" - icon-left="trash" - class="control"> + icon-left="trash"> No Filters </b-button> @@ -60,12 +58,11 @@ <b-button @click="saveDefaults()" icon-pack="fas" icon-left="save" - class="control" :disabled="savingDefaults"> {{ savingDefaults ? "Working, please wait..." : "Save Defaults" }} </b-button> % endif - </b-field> + </div> </form> From e030dc841dc41e9726b3ae318c8a213f15290f60 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 25 Apr 2024 21:31:26 -0500 Subject: [PATCH 1387/1681] Expand some modal fields, per oruga styles --- tailbone/templates/page_help.mako | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/page_help.mako b/tailbone/templates/page_help.mako index 4da6ac37..19e8e121 100644 --- a/tailbone/templates/page_help.mako +++ b/tailbone/templates/page_help.mako @@ -128,13 +128,15 @@ <b-field label="Help Link (URL)"> <b-input v-model="helpURL" - ref="helpURL"> + ref="helpURL" + expanded> </b-input> </b-field> <b-field label="Help Text (Markdown)"> <b-input v-model="markdownText" - type="textarea" rows="8"> + type="textarea" rows="8" + expanded> </b-input> </b-field> From 6bee65780ca73ceb2f44215685d97362f734858b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 25 Apr 2024 22:00:01 -0500 Subject: [PATCH 1388/1681] Improve logic for Add Filter grid button/autocomplete this should work for oruga as well as buefy --- tailbone/templates/grids/complete.mako | 8 ++++++++ tailbone/templates/grids/filters.mako | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako index 940174dc..cb040cc4 100644 --- a/tailbone/templates/grids/complete.mako +++ b/tailbone/templates/grids/complete.mako @@ -294,6 +294,7 @@ computed: { addFilterChoices() { + // nb. this returns all choices available for "Add Filter" operation // collect all filters, which are *not* already shown let choices = [] @@ -354,6 +355,13 @@ methods: { + formatAddFilterItem(filtr) { + if (!filtr.key) { + filtr = this.filters[filtr] + } + return filtr.label || filtr.key + }, + % if grid.click_handlers: cellClick(row, column, rowIndex, columnIndex) { % for key in grid.click_handlers: diff --git a/tailbone/templates/grids/filters.mako b/tailbone/templates/grids/filters.mako index eb245934..cb6ec9e2 100644 --- a/tailbone/templates/grids/filters.mako +++ b/tailbone/templates/grids/filters.mako @@ -32,7 +32,7 @@ v-model="addFilterTerm" placeholder="Add Filter" field="key" - :custom-formatter="filtr => filtr.label" + :custom-formatter="formatAddFilterItem" open-on-focus keep-first icon-pack="fas" From 2a22e8939c806f959714f0c6c15484ff2520581b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 25 Apr 2024 22:17:59 -0500 Subject: [PATCH 1389/1681] Add index title to Change Password page --- tailbone/views/auth.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index 7c4d26f0..f559a5c4 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,7 +24,7 @@ Auth Views """ -from rattail.db.auth import authenticate_user, set_user_password +from rattail.db.auth import set_user_password import colander from deform import widget as dfwidget @@ -188,7 +188,8 @@ class AuthenticationView(View): self.request.session.flash("Your password has been changed.") return self.redirect(self.request.get_referrer()) - return {'form': form} + return {'index_title': str(self.request.user), + 'form': form} def become_root(self): """ From 8b3a9c9dad7bf0c9638b721402b2beaedfe9117b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 25 Apr 2024 22:49:37 -0500 Subject: [PATCH 1390/1681] Use simple field labels when possible only use template if it must include icons etc. --- tailbone/forms/core.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 496d59ee..f4fa79e4 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -1093,15 +1093,23 @@ class Form(object): label_contents.append(HTML.literal(' ')) label_contents.append(icon) - # nb. must apply hack to get <template #label> as final result - label_template = HTML.tag('template', c=label_contents, - **{'#label': 1}) - label_template = label_template.replace( - HTML.literal('<template #label="1"'), - HTML.literal('<template #label')) + # only declare label template if it's complex + html = [html] + if len(label_contents) > 1: + + # nb. must apply hack to get <template #label> as final result + label_template = HTML.tag('template', c=label_contents, + **{'#label': 1}) + label_template = label_template.replace( + HTML.literal('<template #label="1"'), + HTML.literal('<template #label')) + html.insert(0, label_template) + + else: # simple label + attrs['label'] = label # and finally wrap it all in a <b-field> - return HTML.tag('b-field', c=[label_template, html], **attrs) + return HTML.tag('b-field', c=html, **attrs) elif field: # hidden field From ba3242205916e730b25c46e16f14247ccb6d9b2e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 25 Apr 2024 23:56:21 -0500 Subject: [PATCH 1391/1681] Fix bug when saving user preferences theme it was being saved even when it should have been empty value --- tailbone/views/users.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tailbone/views/users.py b/tailbone/views/users.py index fb81060a..e4182da9 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -612,14 +612,25 @@ class UserView(PrincipalMasterView): # display {'section': f'tailbone.{user.uuid}', 'option': 'user_css', - 'value': user_css}, + 'value': user_css, + 'save_if_empty': False}, ] def preferences_gather_settings(self, data, user): simple_settings = self.preferences_get_simple_settings(user) - return self.configure_gather_settings( + settings = self.configure_gather_settings( data, simple_settings=simple_settings, input_file_templates=False) + # TODO: ugh why does user_css come back as 'default' instead of None? + final_settings = [] + for setting in settings: + if setting['name'].endswith('.user_css'): + if setting['value'] == 'default': + continue + final_settings.append(setting) + + return final_settings + def preferences_remove_settings(self, user): app = self.get_rattail_app() simple_settings = self.preferences_get_simple_settings(user) From 890ec64f3cb8ad64bbc45c4373631318670b9759 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 26 Apr 2024 11:02:22 -0500 Subject: [PATCH 1392/1681] Misc. template cleanup per oruga effort --- tailbone/forms/core.py | 14 ++++- tailbone/grids/core.py | 1 + tailbone/static/css/layout.css | 5 ++ tailbone/templates/forms/deform.mako | 8 ++- tailbone/templates/generate_feature.mako | 13 ++-- .../templates/generated-projects/create.mako | 3 +- tailbone/templates/grids/b-table.mako | 4 +- tailbone/templates/grids/complete.mako | 14 ++++- tailbone/templates/luigi/configure.mako | 49 ++++++++------- tailbone/templates/master/view.mako | 5 +- tailbone/templates/master/view_version.mako | 63 ++++++++----------- tailbone/templates/settings/email/index.mako | 10 ++- tailbone/templates/upgrades/configure.mako | 12 ++-- tailbone/templates/users/view.mako | 1 + tailbone/templates/views/model/create.mako | 2 +- tailbone/util.py | 2 +- tailbone/views/common.py | 3 +- tailbone/views/master.py | 2 +- tailbone/views/roles.py | 1 + tailbone/views/users.py | 4 +- 20 files changed, 125 insertions(+), 91 deletions(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index f4fa79e4..beae42a4 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -1043,9 +1043,17 @@ class Form(object): if field_type: attrs['type'] = field_type if messages: - attrs[':message'] = '[{}]'.format(', '.join([ - "'{}'".format(msg.replace("'", r"\'")) - for msg in messages])) + if len(messages) == 1: + msg = messages[0] + if msg.startswith('`') and msg.endswith('`'): + attrs[':message'] = msg + else: + attrs['message'] = msg + else: + # nb. must pass an array as JSON string + attrs[':message'] = '[{}]'.format(', '.join([ + "'{}'".format(msg.replace("'", r"\'")) + for msg in messages])) # merge anything caller provided attrs.update(bfield_attrs) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 41d75fc2..b428aaa6 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -1374,6 +1374,7 @@ class Grid(object): """ context = dict(kwargs) context['grid'] = self + context['request'] = self.request context['data_prop'] = data_prop context['empty_labels'] = empty_labels if 'grid_columns' not in context: diff --git a/tailbone/static/css/layout.css b/tailbone/static/css/layout.css index 0761d001..ef5c5352 100644 --- a/tailbone/static/css/layout.css +++ b/tailbone/static/css/layout.css @@ -90,6 +90,11 @@ header span.header-text { * "object helper" panel ******************************/ +.object-helpers .panel { + margin: 1rem; + margin-bottom: 1.5rem; +} + .object-helpers .panel-heading { white-space: nowrap; } diff --git a/tailbone/templates/forms/deform.mako b/tailbone/templates/forms/deform.mako index db63a424..8a940347 100644 --- a/tailbone/templates/forms/deform.mako +++ b/tailbone/templates/forms/deform.mako @@ -55,12 +55,16 @@ % if form.auto_disable_save or form.auto_disable: <b-button type="is-primary" native-type="submit" - :disabled="${form.component_studly}Submitting"> + :disabled="${form.component_studly}Submitting" + icon-pack="fas" + icon-left="save"> {{ ${form.component_studly}ButtonText }} </b-button> % else: <b-button type="is-primary" - native-type="submit"> + native-type="submit" + icon-pack="fas" + icon-left="save"> ${getattr(form, 'submit_label', getattr(form, 'save_label', "Submit"))} </b-button> % endif diff --git a/tailbone/templates/generate_feature.mako b/tailbone/templates/generate_feature.mako index 18c9a7a2..6b0d781f 100644 --- a/tailbone/templates/generate_feature.mako +++ b/tailbone/templates/generate_feature.mako @@ -87,7 +87,7 @@ <div class="level-item"> <b-button type="is-primary" icon-pack="fas" - icon-left="fas fa-plus" + icon-left="plus" @click="addColumn()"> New Column </b-button> @@ -97,7 +97,7 @@ <div class="level-item"> <b-button type="is-danger" icon-pack="fas" - icon-left="fas fa-trash" + icon-left="trash" @click="new_table.columns = []" :disabled="!new_table.columns.length"> Delete All @@ -164,11 +164,13 @@ <section class="modal-card-body"> <b-field label="Name"> - <b-input v-model="editingColumnName"></b-input> + <b-input v-model="editingColumnName" + expanded /> </b-field> <b-field label="Data Type"> - <b-input v-model="editingColumnDataType"></b-input> + <b-input v-model="editingColumnDataType" + expanded /> </b-field> <b-field label="Nullable"> @@ -179,7 +181,8 @@ </b-field> <b-field label="Description"> - <b-input v-model="editingColumnDescription"></b-input> + <b-input v-model="editingColumnDescription" + expanded /> </b-field> </section> diff --git a/tailbone/templates/generated-projects/create.mako b/tailbone/templates/generated-projects/create.mako index 32d205a0..6c3af299 100644 --- a/tailbone/templates/generated-projects/create.mako +++ b/tailbone/templates/generated-projects/create.mako @@ -8,7 +8,8 @@ <%def name="page_content()"> % if project_type: <b-field grouped> - <b-field horizontal expanded label="Project Type"> + <b-field horizontal expanded label="Project Type" + class="is-expanded"> ${project_type} </b-field> <once-button type="is-primary" diff --git a/tailbone/templates/grids/b-table.mako b/tailbone/templates/grids/b-table.mako index fbd36cbb..1ef3ba7b 100644 --- a/tailbone/templates/grids/b-table.mako +++ b/tailbone/templates/grids/b-table.mako @@ -76,12 +76,12 @@ </b-table-column> % endif - <template slot="empty"> + <template #empty> <div class="content has-text-grey has-text-centered"> <p> <b-icon pack="fas" - icon="fas fa-sad-tear" + icon="sad-tear" size="is-large"> </b-icon> </p> diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako index cb040cc4..af1a4f4d 100644 --- a/tailbone/templates/grids/complete.mako +++ b/tailbone/templates/grids/complete.mako @@ -199,7 +199,7 @@ </span> <b-select v-model="perPage" size="is-small" - @input="loadAsyncData()"> + @input="perPageUpdated"> % for value in grid.get_pagesize_options(): <option value="${value}">${value}</option> % endfor @@ -459,9 +459,11 @@ if (params === undefined || params === null) { params = new URLSearchParams(this.getBasicParams()) - params.append('partial', true) - params = params.toString() + } else { + params = new URLSearchParams(params) } + params.append('partial', true) + params = params.toString() this.loading = true this.$http.get(`${'$'}{this.ajaxDataUrl}?${'$'}{params}`).then(({ data }) => { @@ -520,6 +522,12 @@ this.loadAsyncData() }, + perPageUpdated(value) { + this.loadAsyncData({ + pagesize: value, + }) + }, + onSort(field, order, event) { if (event.ctrlKey) { diff --git a/tailbone/templates/luigi/configure.mako b/tailbone/templates/luigi/configure.mako index c35e3216..548701a9 100644 --- a/tailbone/templates/luigi/configure.mako +++ b/tailbone/templates/luigi/configure.mako @@ -77,31 +77,31 @@ <b-field label="Key" :type="overnightTaskKey ? null : 'is-danger'"> <b-input v-model.trim="overnightTaskKey" - ref="overnightTaskKey"> - </b-input> + ref="overnightTaskKey" + expanded /> </b-field> <b-field label="Description" :type="overnightTaskDescription ? null : 'is-danger'"> <b-input v-model.trim="overnightTaskDescription" - ref="overnightTaskDescription"> - </b-input> + ref="overnightTaskDescription" + expanded /> </b-field> <b-field label="Module"> - <b-input v-model.trim="overnightTaskModule"> - </b-input> + <b-input v-model.trim="overnightTaskModule" + expanded /> </b-field> <b-field label="Class Name"> - <b-input v-model.trim="overnightTaskClass"> - </b-input> + <b-input v-model.trim="overnightTaskClass" + expanded /> </b-field> <b-field label="Script"> - <b-input v-model.trim="overnightTaskScript"> - </b-input> + <b-input v-model.trim="overnightTaskScript" + expanded /> </b-field> <b-field label="Notes"> <b-input v-model.trim="overnightTaskNotes" - type="textarea"> - </b-input> + type="textarea" + expanded /> </b-field> </section> @@ -194,19 +194,19 @@ <b-field label="Key" :type="backfillTaskKey ? null : 'is-danger'"> <b-input v-model.trim="backfillTaskKey" - ref="backfillTaskKey"> - </b-input> + ref="backfillTaskKey" + expanded /> </b-field> <b-field label="Description" :type="backfillTaskDescription ? null : 'is-danger'"> <b-input v-model.trim="backfillTaskDescription" - ref="backfillTaskDescription"> - </b-input> + ref="backfillTaskDescription" + expanded /> </b-field> <b-field label="Script" :type="backfillTaskScript ? null : 'is-danger'"> - <b-input v-model.trim="backfillTaskScript"> - </b-input> + <b-input v-model.trim="backfillTaskScript" + expanded /> </b-field> <b-field grouped> <b-field label="Orientation"> @@ -222,8 +222,8 @@ </b-field> <b-field label="Notes"> <b-input v-model.trim="backfillTaskNotes" - type="textarea"> - </b-input> + type="textarea" + expanded /> </b-field> </section> @@ -252,7 +252,8 @@ expanded> <b-input name="rattail.luigi.url" v-model="simpleSettings['rattail.luigi.url']" - @input="settingsNeedSaved = true"> + @input="settingsNeedSaved = true" + expanded> </b-input> </b-field> @@ -261,7 +262,8 @@ expanded> <b-input name="rattail.luigi.scheduler.supervisor_process_name" v-model="simpleSettings['rattail.luigi.scheduler.supervisor_process_name']" - @input="settingsNeedSaved = true"> + @input="settingsNeedSaved = true" + expanded> </b-input> </b-field> @@ -270,7 +272,8 @@ expanded> <b-input name="rattail.luigi.scheduler.restart_command" v-model="simpleSettings['rattail.luigi.scheduler.restart_command']" - @input="settingsNeedSaved = true"> + @input="settingsNeedSaved = true" + expanded> </b-input> </b-field> diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako index dcf1f8ee..5973da43 100644 --- a/tailbone/templates/master/view.mako +++ b/tailbone/templates/master/view.mako @@ -10,10 +10,9 @@ <%def name="render_instance_header_title_extras()"> % if master.touchable and master.has_perm('touch'): <b-button title=""Touch" this record to trigger sync" - icon-pack="fas" - icon-left="hand-pointer" @click="touchRecord()" :disabled="touchSubmitting"> + <span><i class="fa fa-hand-pointer"></i></span> </b-button> % endif % if expose_versions: @@ -34,7 +33,7 @@ % if xref_buttons or xref_links: <nav class="panel"> <p class="panel-heading">Cross-Reference</p> - <div class="panel-block buttons"> + <div class="panel-block"> <div style="display: flex; flex-direction: column; gap: 0.5rem;"> % for button in xref_buttons: ${button} diff --git a/tailbone/templates/master/view_version.mako b/tailbone/templates/master/view_version.mako index 6417dfb7..dfe03a64 100644 --- a/tailbone/templates/master/view_version.mako +++ b/tailbone/templates/master/view_version.mako @@ -19,48 +19,39 @@ </%def> <%def name="page_content()"> -## TODO: this was basically copied from Revel diff template..need to abstract -<div class="form-wrapper"> + <div class="form-wrapper" style="margin: 1rem; 0;"> + <div class="form"> - <div class="form"> + <b-field label="Changed" horizontal> + <span>${h.pretty_datetime(request.rattail_config, changed)}</span> + </b-field> + + <b-field label="Changed by" horizontal> + <span>${transaction.user or ''}</span> + </b-field> + + <b-field label="IP Address" horizontal> + <span>${transaction.remote_addr}</span> + </b-field> + + <b-field label="Comment" horizontal> + <span>${transaction.meta.get('comment') or ''}</span> + </b-field> + + <b-field label="TXN ID" horizontal> + <span>${transaction.id}</span> + </b-field> - <div class="field-wrapper"> - <label>Changed</label> - <div class="field">${h.pretty_datetime(request.rattail_config, changed)}</div> </div> - - <div class="field-wrapper"> - <label>Changed by</label> - <div class="field">${transaction.user or ''}</div> - </div> - - <div class="field-wrapper"> - <label>IP Address</label> - <div class="field">${transaction.remote_addr}</div> - </div> - - <div class="field-wrapper"> - <label>Comment</label> - <div class="field">${transaction.meta.get('comment') or ''}</div> - </div> - - <div class="field-wrapper"> - <label>TXN ID</label> - <div class="field">${transaction.id}</div> - </div> - </div> -</div><!-- form-wrapper --> - -<div class="versions-wrapper"> - % for diff in version_diffs: - <h4 class="is-size-4 block">${diff.title}</h4> - ${diff.render_html()} - % endfor -</div> - + <div class="versions-wrapper"> + % for diff in version_diffs: + <h4 class="is-size-4 block">${diff.title}</h4> + ${diff.render_html()} + % endfor + </div> </%def> diff --git a/tailbone/templates/settings/email/index.mako b/tailbone/templates/settings/email/index.mako index 11881285..dbc963b9 100644 --- a/tailbone/templates/settings/email/index.mako +++ b/tailbone/templates/settings/email/index.mako @@ -49,10 +49,16 @@ let url = '${url('{}.toggle_hidden'.format(route_prefix))}' let params = { key: row.key, - hidden: row.hidden == 'No'? true : false, + hidden: row.hidden == 'No' ? true : false, } this.submitForm(url, params, response => { - row.hidden = params.hidden ? 'Yes' : 'No' + // must update "original" data row, since our row arg + // may just be a proxy and not trigger view refresh + for (let email of this.data) { + if (email.key == row.key) { + email.hidden = params.hidden ? 'Yes' : 'No' + } + } }) } diff --git a/tailbone/templates/upgrades/configure.mako b/tailbone/templates/upgrades/configure.mako index 4172c2b1..fd2c60ad 100644 --- a/tailbone/templates/upgrades/configure.mako +++ b/tailbone/templates/upgrades/configure.mako @@ -66,21 +66,21 @@ :type="upgradeSystemKey ? null : 'is-danger'"> <b-input v-model.trim="upgradeSystemKey" ref="upgradeSystemKey" - :disabled="upgradeSystemKey == 'rattail'"> - </b-input> + :disabled="upgradeSystemKey == 'rattail'" + expanded /> </b-field> <b-field label="Label" :type="upgradeSystemLabel ? null : 'is-danger'"> <b-input v-model.trim="upgradeSystemLabel" ref="upgradeSystemLabel" - :disabled="upgradeSystemKey == 'rattail'"> - </b-input> + :disabled="upgradeSystemKey == 'rattail'" + expanded /> </b-field> <b-field label="Command" :type="upgradeSystemCommand ? null : 'is-danger'"> <b-input v-model.trim="upgradeSystemCommand" - ref="upgradeSystemCommand"> - </b-input> + ref="upgradeSystemCommand" + expanded /> </b-field> </section> diff --git a/tailbone/templates/users/view.mako b/tailbone/templates/users/view.mako index f65b6d1c..94931e52 100644 --- a/tailbone/templates/users/view.mako +++ b/tailbone/templates/users/view.mako @@ -40,6 +40,7 @@ <b-field label="Description" :type="{'is-danger': !apiNewTokenDescription}"> <b-input v-model.trim="apiNewTokenDescription" + expanded ref="apiNewTokenDescription"> </b-input> </b-field> diff --git a/tailbone/templates/views/model/create.mako b/tailbone/templates/views/model/create.mako index 6a542c52..c5e22cfb 100644 --- a/tailbone/templates/views/model/create.mako +++ b/tailbone/templates/views/model/create.mako @@ -263,7 +263,7 @@ def includeme(config): ${parent.modify_this_page_vars()} <script type="text/javascript"> - ThisPageData.activeStep = null + ThisPageData.activeStep = 'enter-details' ThisPageData.modelNames = ${json.dumps(model_names)|n} ThisPageData.modelName = null diff --git a/tailbone/util.py b/tailbone/util.py index db6ce4a3..f6678316 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -83,7 +83,7 @@ def get_form_data(request): # TODO: this seems to work for our use case at least, but perhaps # there is a better way? see also # https://docs.pylonsproject.org/projects/pyramid/en/latest/api/request.html#pyramid.request.Request.is_xhr - if request.is_xhr and not request.POST: + if (request.is_xhr or request.content_type == 'application/json') and not request.POST: return request.json_body return request.POST diff --git a/tailbone/views/common.py b/tailbone/views/common.py index 4632a285..35332b6b 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -187,7 +187,8 @@ class CommonView(View): data['client_ip'] = self.request.client_addr app.send_email('user_feedback', data=data) return {'ok': True} - return {'error': "Form did not validate!"} + dform = form.make_deform_form() + return {'error': str(dform.error)} def consume_batch_id(self): """ diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 20dc0dcf..87c592ee 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -3003,7 +3003,7 @@ class MasterView(View): button = HTML.tag('b-button', **btn_kw) button = str(button) button = button.replace('<b-button ', - '<b-button tag="a"') + '<b-button tag="a" ') button = HTML.literal(button) return button diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py index 19faabd8..ddf08dd4 100644 --- a/tailbone/views/roles.py +++ b/tailbone/views/roles.py @@ -249,6 +249,7 @@ class RoleView(PrincipalMasterView): factory = self.get_grid_factory() g = factory( key='{}.users'.format(route_prefix), + request=self.request, data=[], columns=[ 'full_name', diff --git a/tailbone/views/users.py b/tailbone/views/users.py index e4182da9..893bf7c4 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -47,6 +47,7 @@ class UserView(PrincipalMasterView): """ model_class = User has_rows = True + rows_title = "User Events" model_row_class = UserEvent has_versions = True touchable = True @@ -225,7 +226,7 @@ class UserView(PrincipalMasterView): # f.set_required('password') # api_tokens - if self.creating or self.editing: + if self.creating or self.editing or self.deleting: f.remove('api_tokens') elif self.has_perm('manage_api_tokens'): f.set_renderer('api_tokens', self.render_api_tokens) @@ -283,6 +284,7 @@ class UserView(PrincipalMasterView): factory = self.get_grid_factory() g = factory( + request=self.request, key='{}.api_tokens'.format(route_prefix), data=[], columns=['description', 'created'], From 098ed5b1cfc5f256b21fe91482a56755a734a2e3 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 26 Apr 2024 11:02:48 -0500 Subject: [PATCH 1393/1681] Improve keydown handling for grid Add Filter autocomplete should work the same, but this way also works with oruga --- tailbone/templates/grids/complete.mako | 18 +++++++++++++----- tailbone/templates/grids/filters.mako | 5 ++--- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako index af1a4f4d..0a5c3780 100644 --- a/tailbone/templates/grids/complete.mako +++ b/tailbone/templates/grids/complete.mako @@ -581,26 +581,34 @@ location.href = url }, - addFilterButton(event) { + addFilterInit() { this.addFilterShow = true + this.$nextTick(() => { + const input = this.$refs.addFilterAutocomplete.$el.querySelector('input') + input.addEventListener('keydown', this.addFilterKeydown) this.$refs.addFilterAutocomplete.focus() }) }, + addFilterHide() { + const input = this.$refs.addFilterAutocomplete.$el.querySelector('input') + input.removeEventListener('keydown', this.addFilterKeydown) + this.addFilterTerm = '' + this.addFilterShow = false + }, + addFilterKeydown(event) { // ESC will clear searchbox if (event.which == 27) { - this.addFilterTerm = '' - this.addFilterShow = false + this.addFilterHide() } }, addFilterSelect(filtr) { this.addFilter(filtr.key) - this.addFilterTerm = '' - this.addFilterShow = false + this.addFilterHide() }, addFilter(filter_key) { diff --git a/tailbone/templates/grids/filters.mako b/tailbone/templates/grids/filters.mako index cb6ec9e2..9a80b911 100644 --- a/tailbone/templates/grids/filters.mako +++ b/tailbone/templates/grids/filters.mako @@ -22,7 +22,7 @@ <b-button v-if="!addFilterShow" icon-pack="fas" icon-left="plus" - @click="addFilterButton"> + @click="addFilterInit()"> Add Filter </b-button> @@ -38,8 +38,7 @@ icon-pack="fas" clearable clear-on-select - @select="addFilterSelect" - @keydown.native="addFilterKeydown"> + @select="addFilterSelect"> </b-autocomplete> <b-button @click="resetView()" From 5aa8d1f9a340ea44b1461958ee279769e8ef64b7 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 26 Apr 2024 20:04:38 -0500 Subject: [PATCH 1394/1681] Use buefy table for "find principal by perm" results this should work for oruga as well --- .../templates/principal/find_by_perm.mako | 34 ++++++++++++----- tailbone/templates/roles/find_by_perm.mako | 21 ---------- tailbone/templates/users/find_by_perm.mako | 23 ----------- tailbone/views/principal.py | 38 ++++++++++++++++++- tailbone/views/roles.py | 11 ++++++ tailbone/views/users.py | 15 ++++++++ 6 files changed, 88 insertions(+), 54 deletions(-) delete mode 100644 tailbone/templates/roles/find_by_perm.mako delete mode 100644 tailbone/templates/users/find_by_perm.mako diff --git a/tailbone/templates/principal/find_by_perm.mako b/tailbone/templates/principal/find_by_perm.mako index 3bf47dc1..e2672985 100644 --- a/tailbone/templates/principal/find_by_perm.mako +++ b/tailbone/templates/principal/find_by_perm.mako @@ -24,13 +24,13 @@ ref="permissionGroupAutocomplete" v-model="permissionGroupTerm" :data="permissionGroupChoices" - field="groupkey" :custom-formatter="filtr => filtr.label" open-on-focus keep-first icon-pack="fas" clearable clear-on-select + expanded @select="permissionGroupSelect"> </b-autocomplete> <b-button v-if="selectedGroup" @@ -45,13 +45,13 @@ ref="permissionAutocomplete" v-model="permissionTerm" :data="permissionChoices" - field="permkey" :custom-formatter="filtr => filtr.label" open-on-focus keep-first icon-pack="fas" clearable clear-on-select + expanded @select="permissionSelect"> </b-autocomplete> <b-button v-if="selectedPermission" @@ -80,17 +80,26 @@ ${h.end_form()} % if principals is not None: - <div class="grid half"> - <br /> - <h2>Found ${len(principals)} ${model_title_plural} with permission: ${selected_permission}</h2> - ${self.principal_table()} - </div> + <br /> + <p class="block"> + Found ${len(principals)} ${model_title_plural} with permission: + <span class="has-text-weight-bold">${selected_permission}</span> + </p> + ${self.principal_table()} % endif </div> </script> </%def> +<%def name="principal_table()"> + <div + style="width: 50%;" + > + ${grid.render_table_element(data_prop='principalsData')|n} + </div> +</%def> + <%def name="modify_this_page_vars()"> ${parent.modify_this_page_vars()} <script type="text/javascript"> @@ -105,7 +114,7 @@ ${parent.make_this_page_component()} <script type="text/javascript"> - Vue.component('find-principals', { + const FindPrincipals = { template: '#find-principals-template', props: { permissionGroups: Object, @@ -120,6 +129,7 @@ selectedPermission: ${json.dumps(selected_permission)|n}, selectedPermissionLabel: ${json.dumps(selected_permission_label or '')|n}, formSubmitting: false, + principalsData: ${json.dumps(principals_data)|n}, } }, @@ -187,6 +197,10 @@ methods: { + navigateTo(url) { + location.href = url + }, + permissionGroupSelect(option) { this.selectedPermission = null this.selectedPermissionLabel = null @@ -224,7 +238,9 @@ }) }, } - }) + } + + Vue.component('find-principals', FindPrincipals) </script> </%def> diff --git a/tailbone/templates/roles/find_by_perm.mako b/tailbone/templates/roles/find_by_perm.mako deleted file mode 100644 index 8908d12e..00000000 --- a/tailbone/templates/roles/find_by_perm.mako +++ /dev/null @@ -1,21 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/principal/find_by_perm.mako" /> - -<%def name="principal_table()"> - <table> - <thead> - <tr> - <th>Name</th> - </tr> - </thead> - <tbody> - % for role in principals: - <tr> - <td>${h.link_to(role.name, url('roles.view', uuid=role.uuid))}</td> - </tr> - % endfor - </tbody> - </table> -</%def> - -${parent.body()} diff --git a/tailbone/templates/users/find_by_perm.mako b/tailbone/templates/users/find_by_perm.mako deleted file mode 100644 index 59fcf643..00000000 --- a/tailbone/templates/users/find_by_perm.mako +++ /dev/null @@ -1,23 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/principal/find_by_perm.mako" /> - -<%def name="principal_table()"> - <table> - <thead> - <tr> - <th>Username</th> - <th>Person</th> - </tr> - </thead> - <tbody> - % for user in principals: - <tr> - <td>${h.link_to(user.username, url('users.view', uuid=user.uuid))}</td> - <td>${user.person or ''}</td> - </tr> - % endfor - </tbody> - </table> -</%def> - -${parent.body()} diff --git a/tailbone/views/principal.py b/tailbone/views/principal.py index 6bb623d1..fb09306b 100644 --- a/tailbone/views/principal.py +++ b/tailbone/views/principal.py @@ -65,14 +65,21 @@ class PrincipalMasterView(MasterView): principals = None permission_group = self.request.GET.get('permission_group') permission = self.request.GET.get('permission') + grid = None if permission_group and permission: principals = self.find_principals_with_permission(self.Session(), permission) + grid = self.find_by_perm_make_results_grid(principals) else: # otherwise clear both values permission_group = None permission = None - context = {'permissions': sorted_perms, 'principals': principals} + context = { + 'permissions': sorted_perms, + 'principals': principals, + 'principals_data': self.find_by_perm_results_data(principals), + 'grid': grid, + } perms = self.get_perms_data(sorted_perms) context['perms_data'] = perms @@ -114,6 +121,35 @@ class PrincipalMasterView(MasterView): return data + def find_by_perm_make_results_grid(self, principals): + route_prefix = self.get_route_prefix() + factory = self.get_grid_factory() + g = factory(key=f'{route_prefix}.results', + request=self.request, + data=[], + columns=[], + main_actions=[ + self.make_action('view', icon='eye', + click_handler='navigateTo(props.row._url)'), + ]) + self.find_by_perm_configure_results_grid(g) + return g + + def find_by_perm_configure_results_grid(self, g): + pass + + def find_by_perm_results_data(self, principals): + data = [] + for principal in principals or []: + data.append(self.find_by_perm_normalize(principal)) + return data + + def find_by_perm_normalize(self, principal): + return { + 'uuid': principal.uuid, + '_url': self.get_action_url('view', principal), + } + @classmethod def defaults(cls, config): cls._principal_defaults(config) diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py index ddf08dd4..0316ea87 100644 --- a/tailbone/views/roles.py +++ b/tailbone/views/roles.py @@ -406,6 +406,17 @@ class RoleView(PrincipalMasterView): roles.append(role) return roles + def find_by_perm_configure_results_grid(self, g): + g.append('name') + g.set_link('name') + + def find_by_perm_normalize(self, role): + data = super().find_by_perm_normalize(role) + + data['name'] = role.name + + return data + def download_permissions_matrix(self): """ View which renders the complete role / permissions matrix data into an diff --git a/tailbone/views/users.py b/tailbone/views/users.py index 893bf7c4..9cc1b5b5 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -521,6 +521,21 @@ class UserView(PrincipalMasterView): users.append(user) return users + def find_by_perm_configure_results_grid(self, g): + g.append('username') + g.set_link('username') + + g.append('person') + g.set_link('person') + + def find_by_perm_normalize(self, user): + data = super().find_by_perm_normalize(user) + + data['username'] = user.username + data['person'] = str(user.person or '') + + return data + def preferences(self, user=None): """ View to modify preferences for a particular user. From 2eaeb1891df9ba61584d72c6e9bec2ec1bd569d0 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 24 Apr 2024 16:13:14 -0500 Subject: [PATCH 1395/1681] Add initial support for Vue 3 + Oruga, via "butterball" theme just a savepoint, still have lots to do and test before this really works --- tailbone/forms/core.py | 26 +- tailbone/subscribers.py | 33 +- tailbone/templates/appinfo/configure.mako | 38 +- tailbone/templates/appinfo/index.mako | 22 +- tailbone/templates/datasync/configure.mako | 105 +- tailbone/templates/datasync/status.mako | 64 +- tailbone/templates/forms/deform.mako | 2 + tailbone/templates/generate_feature.mako | 57 +- tailbone/templates/grids/b-table.mako | 18 +- tailbone/templates/grids/complete.mako | 46 +- .../templates/grids/filter-components.mako | 22 +- tailbone/templates/importing/configure.mako | 36 +- tailbone/templates/luigi/configure.mako | 80 +- tailbone/templates/luigi/index.mako | 48 +- tailbone/templates/master/merge.mako | 2 + tailbone/templates/master/view.mako | 34 +- tailbone/templates/page.mako | 1 + tailbone/templates/page_help.mako | 174 ++- .../templates/principal/find_by_perm.mako | 2 + tailbone/templates/settings/email/view.mako | 2 + .../templates/themes/butterball/base.mako | 1141 +++++++++++++++++ .../themes/butterball/buefy-components.mako | 679 ++++++++++ .../themes/butterball/buefy-plugin.mako | 32 + .../themes/butterball/field-components.mako | 382 ++++++ .../themes/butterball/http-plugin.mako | 100 ++ .../templates/themes/butterball/progress.mako | 244 ++++ tailbone/templates/upgrades/configure.mako | 32 +- tailbone/util.py | 63 +- tailbone/views/settings.py | 42 + 29 files changed, 3212 insertions(+), 315 deletions(-) create mode 100644 tailbone/templates/themes/butterball/base.mako create mode 100644 tailbone/templates/themes/butterball/buefy-components.mako create mode 100644 tailbone/templates/themes/butterball/buefy-plugin.mako create mode 100644 tailbone/templates/themes/butterball/field-components.mako create mode 100644 tailbone/templates/themes/butterball/http-plugin.mako create mode 100644 tailbone/templates/themes/butterball/progress.mako diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index beae42a4..857bfccf 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -1103,18 +1103,22 @@ class Form(object): # only declare label template if it's complex html = [html] - if len(label_contents) > 1: - - # nb. must apply hack to get <template #label> as final result - label_template = HTML.tag('template', c=label_contents, - **{'#label': 1}) - label_template = label_template.replace( - HTML.literal('<template #label="1"'), - HTML.literal('<template #label')) - html.insert(0, label_template) - - else: # simple label + # TODO: figure out why complex label does not work for oruga + if self.request.use_oruga: attrs['label'] = label + else: + if len(label_contents) > 1: + + # nb. must apply hack to get <template #label> as final result + label_template = HTML.tag('template', c=label_contents, + **{'#label': 1}) + label_template = label_template.replace( + HTML.literal('<template #label="1"'), + HTML.literal('<template #label')) + html.insert(0, label_template) + + else: # simple label + attrs['label'] = label # and finally wrap it all in a <b-field> return HTML.tag('b-field', c=html, **attrs) diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 1dc0592a..9b56335a 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -27,7 +27,9 @@ Event Subscribers import six import json import datetime +import logging import warnings +from collections import OrderedDict import rattail @@ -41,7 +43,11 @@ from tailbone import helpers from tailbone.db import Session from tailbone.config import csrf_header_name, should_expose_websockets from tailbone.menus import make_simple_menus -from tailbone.util import get_available_themes, get_global_search_options +from tailbone.util import (get_available_themes, get_global_search_options, + should_use_oruga) + + +log = logging.getLogger(__name__) def new_request(event): @@ -92,6 +98,11 @@ def new_request(event): request.set_property(user, reify=True) + def use_oruga(request): + return should_use_oruga(request) + + request.set_property(use_oruga, reify=True) + # assign client IP address to the session, for sake of versioning Session().continuum_remote_addr = request.client_addr @@ -119,6 +130,25 @@ def new_request(event): return False request.has_any_perm = has_any_perm + def register_component(tagname, classname): + """ + Register a Vue 3 component, so the base template knows to + declare it for use within the app (page). + """ + if not hasattr(request, '_tailbone_registered_components'): + request._tailbone_registered_components = OrderedDict() + + if tagname in request._tailbone_registered_components: + log.warning("component with tagname '%s' already registered " + "with class '%s' but we are replacing that with " + "class '%s'", + tagname, + request._tailbone_registered_components[tagname], + classname) + + request._tailbone_registered_components[tagname] = classname + request.register_component = register_component + def before_render(event): """ @@ -143,6 +173,7 @@ def before_render(event): renderer_globals['colander'] = colander renderer_globals['deform'] = deform renderer_globals['csrf_header_name'] = csrf_header_name(request.rattail_config) + renderer_globals['b'] = 'o' if request.use_oruga else 'b' # for buefy # theme - we only want do this for classic web app, *not* API # TODO: so, clearly we need a better way to distinguish the two diff --git a/tailbone/templates/appinfo/configure.mako b/tailbone/templates/appinfo/configure.mako index 8483a7a2..657e98cf 100644 --- a/tailbone/templates/appinfo/configure.mako +++ b/tailbone/templates/appinfo/configure.mako @@ -100,27 +100,27 @@ <h3 class="block is-size-3">Web Libraries</h3> <div class="block" style="padding-left: 2rem;"> - <b-table :data="weblibs"> + <${b}-table :data="weblibs"> - <b-table-column field="title" + <${b}-table-column field="title" label="Name" v-slot="props"> {{ props.row.title }} - </b-table-column> + </${b}-table-column> - <b-table-column field="configured_version" + <${b}-table-column field="configured_version" label="Version" v-slot="props"> {{ props.row.configured_version || props.row.default_version }} - </b-table-column> + </${b}-table-column> - <b-table-column field="configured_url" + <${b}-table-column field="configured_url" label="URL Override" v-slot="props"> {{ props.row.configured_url }} - </b-table-column> + </${b}-table-column> - <b-table-column field="live_url" + <${b}-table-column field="live_url" label="Effective (Live) URL" v-slot="props"> <span v-if="props.row.modified" @@ -130,19 +130,23 @@ <span v-if="!props.row.modified"> {{ props.row.live_url }} </span> - </b-table-column> + </${b}-table-column> - <b-table-column field="actions" + <${b}-table-column field="actions" label="Actions" v-slot="props"> <a href="#" @click.prevent="editWebLibraryInit(props.row)"> - <i class="fas fa-edit"></i> + % if request.use_oruga: + <o-icon icon="edit" /> + % else: + <i class="fas fa-edit"></i> + % endif Edit </a> - </b-table-column> + </${b}-table-column> - </b-table> + </${b}-table> % for weblib in weblibs: ${h.hidden('tailbone.libver.{}'.format(weblib['key']), **{':value': "simpleSettings['tailbone.libver.{}']".format(weblib['key'])})} @@ -175,14 +179,14 @@ </b-field> <b-field label="Override URL"> - <b-input v-model="editWebLibraryURL"> - </b-input> + <b-input v-model="editWebLibraryURL" + expanded /> </b-field> <b-field label="Effective URL (as of last page load)"> <b-input v-model="editWebLibraryRecord.live_url" - disabled> - </b-input> + disabled + expanded /> </b-field> </section> diff --git a/tailbone/templates/appinfo/index.mako b/tailbone/templates/appinfo/index.mako index ac67e582..73f53920 100644 --- a/tailbone/templates/appinfo/index.mako +++ b/tailbone/templates/appinfo/index.mako @@ -28,10 +28,11 @@ </div> - <b-collapse class="panel" open> + <${b}-collapse class="panel" open> <template #trigger="props"> <div class="panel-heading" + style="cursor: pointer;" role="button"> ## TODO: for some reason buefy will "reuse" the icon @@ -57,30 +58,31 @@ <div class="panel-block"> <div style="width: 100%;"> - <b-table :data="configFiles"> + <${b}-table :data="configFiles"> - <b-table-column field="priority" + <${b}-table-column field="priority" label="Priority" v-slot="props"> {{ props.row.priority }} - </b-table-column> + </${b}-table-column> - <b-table-column field="path" + <${b}-table-column field="path" label="File Path" v-slot="props"> {{ props.row.path }} - </b-table-column> + </${b}-table-column> - </b-table> + </${b}-table> </div> </div> - </b-collapse> + </${b}-collapse> - <b-collapse class="panel" + <${b}-collapse class="panel" :open="false"> <template #trigger="props"> <div class="panel-heading" + style="cursor: pointer;" role="button"> ## TODO: for some reason buefy will "reuse" the icon @@ -109,7 +111,7 @@ ${parent.render_grid_component()} </div> </div> - </b-collapse> + </${b}-collapse> </%def> <%def name="modify_this_page_vars()"> diff --git a/tailbone/templates/datasync/configure.mako b/tailbone/templates/datasync/configure.mako index 6dc13e14..8b0f5e51 100644 --- a/tailbone/templates/datasync/configure.mako +++ b/tailbone/templates/datasync/configure.mako @@ -48,7 +48,12 @@ ${h.hidden('profiles', **{':value': 'JSON.stringify(profilesData)'})} <b-notification type="is-warning" - :active.sync="showConfigFilesNote"> + % if request.use_oruga: + v-model:active="showConfigFilesNote" + % else: + :active.sync="showConfigFilesNote" + % endif + > ## TODO: should link to some ratman page here, yes? <p class="block"> This tool works by modifying settings in the DB. It @@ -101,52 +106,52 @@ </div> </div> - <b-table :data="filteredProfilesData" + <${b}-table :data="filteredProfilesData" :row-class="(row, i) => row.enabled ? null : 'has-background-warning'"> - <b-table-column field="key" + <${b}-table-column field="key" label="Watcher Key" v-slot="props"> {{ props.row.key }} - </b-table-column> - <b-table-column field="watcher_spec" + </${b}-table-column> + <${b}-table-column field="watcher_spec" label="Watcher Spec" v-slot="props"> {{ props.row.watcher_spec }} - </b-table-column> - <b-table-column field="watcher_dbkey" + </${b}-table-column> + <${b}-table-column field="watcher_dbkey" label="DB Key" v-slot="props"> {{ props.row.watcher_dbkey }} - </b-table-column> - <b-table-column field="watcher_delay" + </${b}-table-column> + <${b}-table-column field="watcher_delay" label="Loop Delay" v-slot="props"> {{ props.row.watcher_delay }} sec - </b-table-column> - <b-table-column field="watcher_retry_attempts" + </${b}-table-column> + <${b}-table-column field="watcher_retry_attempts" label="Attempts / Delay" v-slot="props"> {{ props.row.watcher_retry_attempts }} / {{ props.row.watcher_retry_delay }} sec - </b-table-column> - <b-table-column field="watcher_default_runas" + </${b}-table-column> + <${b}-table-column field="watcher_default_runas" label="Default Runas" v-slot="props"> {{ props.row.watcher_default_runas }} - </b-table-column> - <b-table-column label="Consumers" + </${b}-table-column> + <${b}-table-column label="Consumers" v-slot="props"> {{ consumerShortList(props.row) }} - </b-table-column> -## <b-table-column field="notes" label="Notes"> + </${b}-table-column> +## <${b}-table-column field="notes" label="Notes"> ## TODO ## ## {{ props.row.notes }} -## </b-table-column> - <b-table-column field="enabled" +## </${b}-table-column> + <${b}-table-column field="enabled" label="Enabled" v-slot="props"> {{ props.row.enabled ? "Yes" : "No" }} - </b-table-column> - <b-table-column label="Actions" + </${b}-table-column> + <${b}-table-column label="Actions" v-slot="props" v-if="useProfileSettings"> <a href="#" @@ -162,14 +167,14 @@ <i class="fas fa-trash"></i> Delete </a> - </b-table-column> - <template slot="empty"> + </${b}-table-column> + <template #empty> <section class="section"> <div class="content has-text-grey has-text-centered"> <p> <b-icon pack="fas" - icon="fas fa-sad-tear" + icon="sad-tear" size="is-large"> </b-icon> </p> @@ -177,7 +182,7 @@ </div> </section> </template> - </b-table> + </${b}-table> <b-modal :active.sync="editProfileShowDialog"> <div class="card"> @@ -199,12 +204,12 @@ </b-field> - <b-field grouped> + <b-field grouped expanded> <b-field label="Watcher Spec" :type="editingProfileWatcherSpec ? null : 'is-danger'" expanded> - <b-input v-model="editingProfileWatcherSpec"> + <b-input v-model="editingProfileWatcherSpec" expanded> </b-input> </b-field> @@ -293,19 +298,19 @@ </div> - <b-table :data="editingProfilePendingWatcherKwargs" + <${b}-table :data="editingProfilePendingWatcherKwargs" style="margin-left: 1rem;"> - <b-table-column field="key" + <${b}-table-column field="key" label="Key" v-slot="props"> {{ props.row.key }} - </b-table-column> - <b-table-column field="value" + </${b}-table-column> + <${b}-table-column field="value" label="Value" v-slot="props"> {{ props.row.value }} - </b-table-column> - <b-table-column label="Actions" + </${b}-table-column> + <${b}-table-column label="Actions" v-slot="props"> <a href="#" @click.prevent="editProfileWatcherKwarg(props.row)"> @@ -319,14 +324,14 @@ <i class="fas fa-trash"></i> Delete </a> - </b-table-column> - <template slot="empty"> + </${b}-table-column> + <template #empty> <section class="section"> <div class="content has-text-grey has-text-centered"> <p> <b-icon pack="fas" - icon="fas fa-sad-tear" + icon="sad-tear" size="is-large"> </b-icon> </p> @@ -334,7 +339,7 @@ </div> </section> </template> - </b-table> + </${b}-table> </div> @@ -350,19 +355,19 @@ </b-checkbox> </b-field> - <b-table :data="editingProfilePendingConsumers" + <${b}-table :data="editingProfilePendingConsumers" v-if="!editingProfileWatcherConsumesSelf" :row-class="(row, i) => row.enabled ? null : 'has-background-warning'"> - <b-table-column field="key" + <${b}-table-column field="key" label="Consumer" v-slot="props"> {{ props.row.key }} - </b-table-column> - <b-table-column style="white-space: nowrap;" + </${b}-table-column> + <${b}-table-column style="white-space: nowrap;" v-slot="props"> {{ props.row.consumer_delay }} / {{ props.row.consumer_retry_attempts }} / {{ props.row.consumer_retry_delay }} - </b-table-column> - <b-table-column label="Actions" + </${b}-table-column> + <${b}-table-column label="Actions" v-slot="props"> <a href="#" class="grid-action" @@ -377,14 +382,14 @@ <i class="fas fa-trash"></i> Delete </a> - </b-table-column> - <template slot="empty"> + </${b}-table-column> + <template #empty> <section class="section"> <div class="content has-text-grey has-text-centered"> <p> <b-icon pack="fas" - icon="fas fa-sad-tear" + icon="sad-tear" size="is-large"> </b-icon> </p> @@ -392,7 +397,7 @@ </div> </section> </template> - </b-table> + </${b}-table> </div> @@ -526,7 +531,8 @@ expanded> <b-input name="supervisor_process_name" v-model="supervisorProcessName" - @input="settingsNeedSaved = true"> + @input="settingsNeedSaved = true" + expanded> </b-input> </b-field> @@ -535,7 +541,8 @@ expanded> <b-input name="restart_command" v-model="restartCommand" - @input="settingsNeedSaved = true"> + @input="settingsNeedSaved = true" + expanded> </b-input> </b-field> diff --git a/tailbone/templates/datasync/status.mako b/tailbone/templates/datasync/status.mako index 6df35bbb..43d05f51 100644 --- a/tailbone/templates/datasync/status.mako +++ b/tailbone/templates/datasync/status.mako @@ -47,79 +47,79 @@ </div> </b-field> - <b-field label="Watcher Status"> - <b-table :data="watchers"> - <b-table-column field="key" + <h3 class="is-size-3">Watcher Status</h3> + + <${b}-table :data="watchers"> + <${b}-table-column field="key" label="Watcher" v-slot="props"> {{ props.row.key }} - </b-table-column> - <b-table-column field="spec" + </${b}-table-column> + <${b}-table-column field="spec" label="Spec" v-slot="props"> {{ props.row.spec }} - </b-table-column> - <b-table-column field="dbkey" + </${b}-table-column> + <${b}-table-column field="dbkey" label="DB Key" v-slot="props"> {{ props.row.dbkey }} - </b-table-column> - <b-table-column field="delay" + </${b}-table-column> + <${b}-table-column field="delay" label="Delay" v-slot="props"> {{ props.row.delay }} second(s) - </b-table-column> - <b-table-column field="lastrun" + </${b}-table-column> + <${b}-table-column field="lastrun" label="Last Watched" v-slot="props"> <span v-html="props.row.lastrun"></span> - </b-table-column> - <b-table-column field="status" + </${b}-table-column> + <${b}-table-column field="status" label="Status" v-slot="props"> <span :class="props.row.status == 'okay' ? 'has-background-success' : 'has-background-warning'"> {{ props.row.status }} </span> - </b-table-column> - </b-table> - </b-field> + </${b}-table-column> + </${b}-table> - <b-field label="Consumer Status"> - <b-table :data="consumers"> - <b-table-column field="key" + <h3 class="is-size-3">Consumer Status</h3> + + <${b}-table :data="consumers"> + <${b}-table-column field="key" label="Consumer" v-slot="props"> {{ props.row.key }} - </b-table-column> - <b-table-column field="spec" + </${b}-table-column> + <${b}-table-column field="spec" label="Spec" v-slot="props"> {{ props.row.spec }} - </b-table-column> - <b-table-column field="dbkey" + </${b}-table-column> + <${b}-table-column field="dbkey" label="DB Key" v-slot="props"> {{ props.row.dbkey }} - </b-table-column> - <b-table-column field="delay" + </${b}-table-column> + <${b}-table-column field="delay" label="Delay" v-slot="props"> {{ props.row.delay }} second(s) - </b-table-column> - <b-table-column field="changes" + </${b}-table-column> + <${b}-table-column field="changes" label="Pending Changes" v-slot="props"> {{ props.row.changes }} - </b-table-column> - <b-table-column field="status" + </${b}-table-column> + <${b}-table-column field="status" label="Status" v-slot="props"> <span :class="props.row.status == 'okay' ? 'has-background-success' : 'has-background-warning'"> {{ props.row.status }} </span> - </b-table-column> - </b-table> - </b-field> + </${b}-table-column> + </${b}-table> </%def> <%def name="modify_this_page_vars()"> diff --git a/tailbone/templates/forms/deform.mako b/tailbone/templates/forms/deform.mako index 8a940347..00cf2c50 100644 --- a/tailbone/templates/forms/deform.mako +++ b/tailbone/templates/forms/deform.mako @@ -1,5 +1,7 @@ ## -*- coding: utf-8; -*- +<% request.register_component(form.component, form.component_studly) %> + <script type="text/x-template" id="${form.component}-template"> <div> diff --git a/tailbone/templates/generate_feature.mako b/tailbone/templates/generate_feature.mako index 6b0d781f..a7064331 100644 --- a/tailbone/templates/generate_feature.mako +++ b/tailbone/templates/generate_feature.mako @@ -106,55 +106,68 @@ </div> </div> - <b-table + <${b}-table :data="new_table.columns"> - <b-table-column field="name" + <${b}-table-column field="name" label="Name" v-slot="props"> {{ props.row.name }} - </b-table-column> + </${b}-table-column> - <b-table-column field="data_type" + <${b}-table-column field="data_type" label="Data Type" v-slot="props"> {{ props.row.data_type }} - </b-table-column> + </${b}-table-column> - <b-table-column field="nullable" + <${b}-table-column field="nullable" label="Nullable" v-slot="props"> {{ props.row.nullable }} - </b-table-column> + </${b}-table-column> - <b-table-column field="description" + <${b}-table-column field="description" label="Description" v-slot="props"> {{ props.row.description }} - </b-table-column> + </${b}-table-column> - <b-table-column field="actions" + <${b}-table-column field="actions" label="Actions" v-slot="props"> <a href="#" class="grid-action" - @click.prevent="editColumnRow(props.row)"> - <i class="fas fa-edit"></i> + @click.prevent="editColumnRow(props)"> + % if request.use_oruga: + <o-icon icon="edit" /> + % else: + <i class="fas fa-edit"></i> + % endif Edit </a> <a href="#" class="grid-action has-text-danger" @click.prevent="deleteColumn(props.index)"> - <i class="fas fa-trash"></i> + % if request.use_oruga: + <o-icon icon="trash" /> + % else: + <i class="fas fa-trash"></i> + % endif Delete </a> - </b-table-column> + </${b}-table-column> - </b-table> + </${b}-table> - <b-modal has-modal-card - :active.sync="showingEditColumn"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="showingEditColumn" + % else: + :active.sync="showingEditColumn" + % endif + > <div class="modal-card"> <header class="modal-card-head"> @@ -197,7 +210,7 @@ </b-button> </footer> </div> - </b-modal> + </${b}-modal> </div> </b-field> @@ -318,6 +331,7 @@ ThisPageData.showingEditColumn = false ThisPageData.editingColumn = null + ThisPageData.editingColumnIndex = null ThisPageData.editingColumnName = null ThisPageData.editingColumnDataType = null ThisPageData.editingColumnNullable = null @@ -325,6 +339,7 @@ ThisPage.methods.addColumn = function(column) { this.editingColumn = null + this.editingColumnIndex = null this.editingColumnName = null this.editingColumnDataType = null this.editingColumnNullable = true @@ -332,8 +347,10 @@ this.showingEditColumn = true } - ThisPage.methods.editColumnRow = function(column) { + ThisPage.methods.editColumnRow = function(props) { + const column = props.row this.editingColumn = column + this.editingColumnIndex = props.index this.editingColumnName = column.name this.editingColumnDataType = column.data_type this.editingColumnNullable = column.nullable @@ -343,7 +360,7 @@ ThisPage.methods.saveColumn = function() { if (this.editingColumn) { - column = this.editingColumn + column = this.new_table.columns[this.editingColumnIndex] } else { column = {} this.new_table.columns.push(column) diff --git a/tailbone/templates/grids/b-table.mako b/tailbone/templates/grids/b-table.mako index 1ef3ba7b..632193b5 100644 --- a/tailbone/templates/grids/b-table.mako +++ b/tailbone/templates/grids/b-table.mako @@ -1,5 +1,5 @@ ## -*- coding: utf-8; -*- -<b-table +<${b}-table :data="${data_prop}" icon-pack="fas" striped @@ -21,7 +21,7 @@ > % for i, column in enumerate(grid_columns): - <b-table-column field="${column['field']}" + <${b}-table-column field="${column['field']}" % if not empty_labels: label="${column['label']}" % elif i > 0: @@ -50,11 +50,11 @@ % else: <span v-html="props.row.${column['field']}"></span> % endif - </b-table-column> + </${b}-table-column> % endfor % if grid.main_actions or grid.more_actions: - <b-table-column field="actions" + <${b}-table-column field="actions" label="Actions" v-slot="props"> % for action in grid.main_actions: @@ -68,12 +68,16 @@ @click.prevent="${action.click_handler}" % endif > - <i class="fas fa-${action.icon}"></i> + % if request.use_oruga: + <o-icon icon="${action.icon}" /> + % else: + <i class="fas fa-${action.icon}"></i> + % endif ${action.label} </a> % endfor - </b-table-column> + </${b}-table-column> % endif <template #empty> @@ -99,4 +103,4 @@ </template> % endif -</b-table> +</${b}-table> diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako index 0a5c3780..fe9392d3 100644 --- a/tailbone/templates/grids/complete.mako +++ b/tailbone/templates/grids/complete.mako @@ -1,5 +1,7 @@ ## -*- coding: utf-8; -*- +<% request.register_component(grid.component, grid.component_studly) %> + <script type="text/x-template" id="${grid.component}-template"> <div> @@ -38,7 +40,7 @@ </div> - <b-table + <${b}-table :data="visibleData" :loading="loading" :row-class="getRowClass" @@ -51,7 +53,11 @@ :checkable="checkable" % if grid.checkboxes: - :checked-rows.sync="checkedRows" + % if request.use_oruga: + v-model:checked-rows="checkedRows" + % else: + :checked-rows.sync="checkedRows" + % endif % if grid.clicking_row_checks_box: @click="rowClick" % endif @@ -111,7 +117,7 @@ :narrowed="true"> % for column in grid_columns: - <b-table-column field="${column['field']}" + <${b}-table-column field="${column['field']}" label="${column['label']}" v-slot="props" :sortable="${json.dumps(column['sortable'])}" @@ -132,11 +138,11 @@ % else: <span v-html="props.row.${column['field']}"></span> % endif - </b-table-column> + </${b}-table-column> % endfor % if grid.main_actions or grid.more_actions: - <b-table-column field="actions" + <${b}-table-column field="actions" label="Actions" v-slot="props"> ## TODO: we do not currently differentiate for "main vs. more" @@ -152,12 +158,17 @@ target="${action.target}" % endif > - ${action.render_icon()|n} - ${action.render_label()|n} + % if request.use_oruga: + <o-icon icon="${action.icon}" /> + <span>${action.render_label()|n}</span> + % else: + ${action.render_icon()|n} + ${action.render_label()|n} + % endif </a> % endfor - </b-table-column> + </${b}-table-column> % endif <template #empty> @@ -183,7 +194,11 @@ size="is-small" @click="copyDirectLink()" title="Copy link to clipboard"> - <span><i class="fa fa-share-alt"></i></span> + % if request.use_oruga: + <o-icon icon="share-alt" /> + % else: + <span><i class="fa fa-share-alt"></i></span> + % endif </b-button> % else: <div></div> @@ -213,7 +228,7 @@ </div> </template> - </b-table> + </${b}-table> ## dummy input field needed for sharing links on *insecure* sites % if request.scheme == 'http': @@ -523,6 +538,12 @@ }, perPageUpdated(value) { + + // nb. buefy passes value, oruga passes event + if (value.target) { + value = event.target.value + } + this.loadAsyncData({ pagesize: value, }) @@ -530,6 +551,11 @@ onSort(field, order, event) { + // nb. buefy passes field name, oruga passes object + if (field.field) { + field = field.field + } + if (event.ctrlKey) { // engage or enhance multi-column sorting diff --git a/tailbone/templates/grids/filter-components.mako b/tailbone/templates/grids/filter-components.mako index 7897b3cf..9ec1c049 100644 --- a/tailbone/templates/grids/filter-components.mako +++ b/tailbone/templates/grids/filter-components.mako @@ -7,6 +7,7 @@ </%def> <%def name="make_grid_filter_numeric_value_component()"> + <% request.register_component('grid-filter-numeric-value', 'GridFilterNumericValue') %> <script type="text/x-template" id="grid-filter-numeric-value-template"> <div class="level"> <div class="level-left"> @@ -95,13 +96,14 @@ </%def> <%def name="make_grid_filter_date_value_component()"> + <% request.register_component('grid-filter-date-value', 'GridFilterDateValue') %> <script type="text/x-template" id="grid-filter-date-value-template"> <div class="level"> <div class="level-left"> <div class="level-item"> <tailbone-datepicker v-model="startDate" ref="startDate" - @input="startDateChanged"> + @${'update:model-value' if request.use_oruga else 'input'}="startDateChanged"> </tailbone-datepicker> </div> <div v-show="dateRange" @@ -112,7 +114,7 @@ class="level-item"> <tailbone-datepicker v-model="endDate" ref="endDate" - @input="endDateChanged"> + @${'update:model-value' if request.use_oruga else 'input'}="endDateChanged"> </tailbone-datepicker> </div> </div> @@ -123,25 +125,26 @@ const GridFilterDateValue = { template: '#grid-filter-date-value-template', props: { - value: String, + ${'modelValue' if request.use_oruga else 'value'}: String, dateRange: Boolean, }, data() { let startDate = null let endDate = null - if (this.value) { + let value = this.${'modelValue' if request.use_oruga else 'value'} + if (value) { if (this.dateRange) { - let values = this.value.split('|') + let values = value.split('|') if (values.length == 2) { startDate = this.parseDate(values[0]) endDate = this.parseDate(values[1]) } else { // no end date specified? - startDate = this.parseDate(this.value) + startDate = this.parseDate(value) } } else { // not a range, so start date only - startDate = this.parseDate(this.value) + startDate = this.parseDate(value) } } @@ -179,11 +182,11 @@ if (this.dateRange) { value += '|' + this.formatDate(this.endDate) } - this.$emit('input', value) + this.$emit("${'update:modelValue' if request.use_oruga else 'input'}", value) }, endDateChanged(value) { value = this.formatDate(this.startDate) + '|' + this.formatDate(value) - this.$emit('input', value) + this.$emit("${'update:modelValue' if request.use_oruga else 'input'}", value) }, }, } @@ -194,6 +197,7 @@ </%def> <%def name="make_grid_filter_component()"> + <% request.register_component('grid-filter', 'GridFilter') %> <script type="text/x-template" id="grid-filter-template"> <div class="filter" v-show="filter.visible" diff --git a/tailbone/templates/importing/configure.mako b/tailbone/templates/importing/configure.mako index 90f7cabd..fd8bc35b 100644 --- a/tailbone/templates/importing/configure.mako +++ b/tailbone/templates/importing/configure.mako @@ -6,54 +6,58 @@ <h3 class="is-size-3">Designated Handlers</h3> - <b-table :data="handlersData" + <${b}-table :data="handlersData" narrowed icon-pack="fas" :default-sort="['host_title', 'asc']"> - <b-table-column field="host_title" + <${b}-table-column field="host_title" label="Data Source" v-slot="props" sortable> {{ props.row.host_title }} - </b-table-column> - <b-table-column field="local_title" + </${b}-table-column> + <${b}-table-column field="local_title" label="Data Target" v-slot="props" sortable> {{ props.row.local_title }} - </b-table-column> - <b-table-column field="direction" + </${b}-table-column> + <${b}-table-column field="direction" label="Direction" v-slot="props" sortable> {{ props.row.direction_display }} - </b-table-column> - <b-table-column field="handler_spec" + </${b}-table-column> + <${b}-table-column field="handler_spec" label="Handler Spec" v-slot="props" sortable> {{ props.row.handler_spec }} - </b-table-column> - <b-table-column field="cmd" + </${b}-table-column> + <${b}-table-column field="cmd" label="Command" v-slot="props" sortable> {{ props.row.command }} {{ props.row.subcommand }} - </b-table-column> - <b-table-column field="runas" + </${b}-table-column> + <${b}-table-column field="runas" label="Default Runas" v-slot="props" sortable> {{ props.row.default_runas }} - </b-table-column> - <b-table-column label="Actions" + </${b}-table-column> + <${b}-table-column label="Actions" v-slot="props"> <a href="#" class="grid-action" @click.prevent="editHandler(props.row)"> + % if request.use_oruga: + <o-icon icon="edit" /> + % else: <i class="fas fa-edit"></i> + % endif Edit </a> - </b-table-column> + </${b}-table-column> <template slot="empty"> <section class="section"> <div class="content has-text-grey has-text-centered"> @@ -68,7 +72,7 @@ </div> </section> </template> - </b-table> + </${b}-table> <b-modal :active.sync="editHandlerShowDialog"> <div class="card"> diff --git a/tailbone/templates/luigi/configure.mako b/tailbone/templates/luigi/configure.mako index 548701a9..49060ceb 100644 --- a/tailbone/templates/luigi/configure.mako +++ b/tailbone/templates/luigi/configure.mako @@ -22,48 +22,56 @@ </div> <div class="block" style="padding-left: 2rem; display: flex;"> - <b-table :data="overnightTasks"> - <!-- <b-table-column field="key" --> + <${b}-table :data="overnightTasks"> + <!-- <${b}-table-column field="key" --> <!-- label="Key" --> <!-- sortable> --> <!-- {{ props.row.key }} --> - <!-- </b-table-column> --> - <b-table-column field="key" + <!-- </${b}-table-column> --> + <${b}-table-column field="key" label="Key" v-slot="props"> {{ props.row.key }} - </b-table-column> - <b-table-column field="description" + </${b}-table-column> + <${b}-table-column field="description" label="Description" v-slot="props"> {{ props.row.description }} - </b-table-column> - <b-table-column field="class_name" + </${b}-table-column> + <${b}-table-column field="class_name" label="Class Name" v-slot="props"> {{ props.row.class_name }} - </b-table-column> - <b-table-column field="script" + </${b}-table-column> + <${b}-table-column field="script" label="Script" v-slot="props"> {{ props.row.script }} - </b-table-column> - <b-table-column label="Actions" + </${b}-table-column> + <${b}-table-column label="Actions" v-slot="props"> <a href="#" @click.prevent="overnightTaskEdit(props.row)"> - <i class="fas fa-edit"></i> + % if request.use_oruga: + <o-icon icon="edit" /> + % else: + <i class="fas fa-edit"></i> + % endif Edit </a> <a href="#" class="has-text-danger" @click.prevent="overnightTaskDelete(props.row)"> - <i class="fas fa-trash"></i> + % if request.use_oruga: + <o-icon icon="trash" /> + % else: + <i class="fas fa-trash"></i> + % endif Delete </a> - </b-table-column> - </b-table> + </${b}-table-column> + </${b}-table> <b-modal has-modal-card :active.sync="overnightTaskShowDialog"> @@ -139,48 +147,56 @@ </div> <div class="block" style="padding-left: 2rem; display: flex;"> - <b-table :data="backfillTasks"> - <b-table-column field="key" + <${b}-table :data="backfillTasks"> + <${b}-table-column field="key" label="Key" v-slot="props"> {{ props.row.key }} - </b-table-column> - <b-table-column field="description" + </${b}-table-column> + <${b}-table-column field="description" label="Description" v-slot="props"> {{ props.row.description }} - </b-table-column> - <b-table-column field="script" + </${b}-table-column> + <${b}-table-column field="script" label="Script" v-slot="props"> {{ props.row.script }} - </b-table-column> - <b-table-column field="forward" + </${b}-table-column> + <${b}-table-column field="forward" label="Orientation" v-slot="props"> {{ props.row.forward ? "Forward" : "Backward" }} - </b-table-column> - <b-table-column field="target_date" + </${b}-table-column> + <${b}-table-column field="target_date" label="Target Date" v-slot="props"> {{ props.row.target_date }} - </b-table-column> - <b-table-column label="Actions" + </${b}-table-column> + <${b}-table-column label="Actions" v-slot="props"> <a href="#" @click.prevent="backfillTaskEdit(props.row)"> - <i class="fas fa-edit"></i> + % if request.use_oruga: + <o-icon icon="edit" /> + % else: + <i class="fas fa-edit"></i> + % endif Edit </a> <a href="#" class="has-text-danger" @click.prevent="backfillTaskDelete(props.row)"> - <i class="fas fa-trash"></i> + % if request.use_oruga: + <o-icon icon="trash" /> + % else: + <i class="fas fa-trash"></i> + % endif Delete </a> - </b-table-column> - </b-table> + </${b}-table-column> + </${b}-table> <b-modal has-modal-card :active.sync="backfillTaskShowDialog"> diff --git a/tailbone/templates/luigi/index.mako b/tailbone/templates/luigi/index.mako index a64866df..bb8d1465 100644 --- a/tailbone/templates/luigi/index.mako +++ b/tailbone/templates/luigi/index.mako @@ -53,25 +53,25 @@ <h3 class="block is-size-3">Overnight Tasks</h3> - <b-table :data="overnightTasks" hoverable> - <b-table-column field="description" + <${b}-table :data="overnightTasks" hoverable> + <${b}-table-column field="description" label="Description" v-slot="props"> {{ props.row.description }} - </b-table-column> - <b-table-column field="script" + </${b}-table-column> + <${b}-table-column field="script" label="Command" v-slot="props"> {{ props.row.script || props.row.class_name }} - </b-table-column> - <b-table-column field="last_date" + </${b}-table-column> + <${b}-table-column field="last_date" label="Last Date" v-slot="props"> <span :class="overnightTextClass(props.row)"> {{ props.row.last_date || "never!" }} </span> - </b-table-column> - <b-table-column label="Actions" + </${b}-table-column> + <${b}-table-column label="Actions" v-slot="props"> <b-button type="is-primary" icon-pack="fas" @@ -128,11 +128,11 @@ </footer> </div> </b-modal> - </b-table-column> + </${b}-table-column> <template #empty> <p class="block">No tasks defined.</p> </template> - </b-table> + </${b}-table> % endif @@ -140,35 +140,35 @@ <h3 class="block is-size-3">Backfill Tasks</h3> - <b-table :data="backfillTasks" hoverable> - <b-table-column field="description" + <${b}-table :data="backfillTasks" hoverable> + <${b}-table-column field="description" label="Description" v-slot="props"> {{ props.row.description }} - </b-table-column> - <b-table-column field="script" + </${b}-table-column> + <${b}-table-column field="script" label="Script" v-slot="props"> {{ props.row.script }} - </b-table-column> - <b-table-column field="forward" + </${b}-table-column> + <${b}-table-column field="forward" label="Orientation" v-slot="props"> {{ props.row.forward ? "Forward" : "Backward" }} - </b-table-column> - <b-table-column field="last_date" + </${b}-table-column> + <${b}-table-column field="last_date" label="Last Date" v-slot="props"> <span :class="backfillTextClass(props.row)"> {{ props.row.last_date }} </span> - </b-table-column> - <b-table-column field="target_date" + </${b}-table-column> + <${b}-table-column field="target_date" label="Target Date" v-slot="props"> {{ props.row.target_date }} - </b-table-column> - <b-table-column label="Actions" + </${b}-table-column> + <${b}-table-column label="Actions" v-slot="props"> <b-button type="is-primary" icon-pack="fas" @@ -176,11 +176,11 @@ @click="backfillTaskLaunch(props.row)"> Launch </b-button> - </b-table-column> + </${b}-table-column> <template #empty> <p class="block">No tasks defined.</p> </template> - </b-table> + </${b}-table> <b-modal has-modal-card :active.sync="backfillTaskShowLaunchDialog"> diff --git a/tailbone/templates/master/merge.mako b/tailbone/templates/master/merge.mako index 6727dc5c..5d90043f 100644 --- a/tailbone/templates/master/merge.mako +++ b/tailbone/templates/master/merge.mako @@ -177,6 +177,8 @@ Vue.component('merge-buttons', MergeButtons) + <% request.register_component('merge-buttons', 'MergeButtons') %> + </script> </%def> diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako index 5973da43..ac0577e0 100644 --- a/tailbone/templates/master/view.mako +++ b/tailbone/templates/master/view.mako @@ -12,7 +12,11 @@ <b-button title=""Touch" this record to trigger sync" @click="touchRecord()" :disabled="touchSubmitting"> - <span><i class="fa fa-hand-pointer"></i></span> + % if request.use_oruga: + <o-icon icon="hand-pointer" /> + % else: + <span><i class="fa fa-hand-pointer"></i></span> + % endif </b-button> % endif % if expose_versions: @@ -112,7 +116,11 @@ <p class="block"> <a href="${master.get_action_url('versions', instance)}" target="_blank"> - <i class="fas fa-external-link-alt"></i> + % if request.use_oruga: + <o-icon icon="external-link-alt" /> + % else: + <i class="fas fa-external-link-alt"></i> + % endif View as separate page </a> </p> @@ -122,7 +130,13 @@ @view-revision="viewRevision"> </versions-grid> - <b-modal :active.sync="viewVersionShowDialog" :width="1200"> + <${b}-modal :width="1200" + % if request.use_oruga: + v-model:active="viewVersionShowDialog" + % else: + :active.sync="viewVersionShowDialog" + % endif + > <div class="card"> <div class="card-content"> <div style="display: flex; flex-direction: column; gap: 1.5rem;"> @@ -169,7 +183,11 @@ <div> <a :href="viewVersionData.url" target="_blank"> - <i class="fas fa-external-link-alt"></i> + % if request.use_oruga: + <o-icon icon="external-link-alt" /> + % else: + <i class="fas fa-external-link-alt"></i> + % endif View as separate page </a> </div> @@ -212,10 +230,14 @@ </div> </div> - <b-loading :active.sync="viewVersionLoading" :is-full-page="false"></b-loading> + % if request.use_oruga: + <o-loading v-model:active="viewVersionLoading" :is-full-page="false" /> + % else: + <b-loading :active.sync="viewVersionLoading" :is-full-page="false"></b-loading> + % endif </div> </div> - </b-modal> + </${b}-modal> </div> % endif </%def> diff --git a/tailbone/templates/page.mako b/tailbone/templates/page.mako index bf799440..460cc6d6 100644 --- a/tailbone/templates/page.mako +++ b/tailbone/templates/page.mako @@ -72,6 +72,7 @@ ThisPage.data = function() { return ThisPageData } Vue.component('this-page', ThisPage) + <% request.register_component('this-page', 'ThisPage') %> </script> </%def> diff --git a/tailbone/templates/page_help.mako b/tailbone/templates/page_help.mako index 19e8e121..ea86c6da 100644 --- a/tailbone/templates/page_help.mako +++ b/tailbone/templates/page_help.mako @@ -6,10 +6,8 @@ % if help_url or help_markdown: - <b-field> - <p class="control"> - <b-button icon-pack="fas" - icon-left="question-circle" + % if request.use_oruga: + <o-button icon-left="question-circle" % if help_markdown: @click="displayInit()" % elif help_url: @@ -18,57 +16,117 @@ % endif > Help - </b-button> - </p> - % if can_edit_help: - ## TODO: this dropdown is duplicated, below - <b-dropdown aria-role="list" position="is-bottom-left"> - <template #trigger="{ active }"> - <b-button> - <span><i class="fa fa-cog"></i></span> - </b-button> - </template> - <b-dropdown-item aria-role="listitem" - @click="configureInit()"> - Edit Page Help - </b-dropdown-item> - <b-dropdown-item aria-role="listitem" - @click="configureFieldsInit()"> - Edit Fields Help - </b-dropdown-item> - </b-dropdown> - % endif - </b-field> + </o-button> + + % if can_edit_help: + ## TODO: this dropdown is duplicated, below + <o-dropdown position="bottom-left" + ## TODO: why does click not work here?! + :triggers="['click', 'hover']"> + <template #trigger> + <o-button> + <o-icon icon="cog" /> + </o-button> + </template> + <o-dropdown-item label="Edit Page Help" + @click="configureInit()" /> + <o-dropdown-item label="Edit Fields Help" + @click="configureFieldsInit()" /> + </o-dropdown> + % endif + + % else: + ## buefy + <b-field> + <p class="control"> + <b-button icon-pack="fas" + icon-left="question-circle" + % if help_markdown: + @click="displayInit()" + % elif help_url: + tag="a" href="${help_url}" + target="_blank" + % endif + > + Help + </b-button> + </p> + % if can_edit_help: + ## TODO: this dropdown is duplicated, below + <b-dropdown aria-role="list" position="is-bottom-left"> + <template #trigger="{ active }"> + <b-button> + <span><i class="fa fa-cog"></i></span> + </b-button> + </template> + <b-dropdown-item aria-role="listitem" + @click="configureInit()"> + Edit Page Help + </b-dropdown-item> + <b-dropdown-item aria-role="listitem" + @click="configureFieldsInit()"> + Edit Fields Help + </b-dropdown-item> + </b-dropdown> + % endif + </b-field> + % endif: % elif can_edit_help: - <b-field> - <p class="control"> - ## TODO: this dropdown is duplicated, above - <b-dropdown aria-role="list" position="is-bottom-left"> - <template #trigger="{ active }"> - <b-button> - <span><i class="fa fa-question-circle"></i></span> - <span><i class="fa fa-cog"></i></span> - </b-button> + ## TODO: this dropdown is duplicated, above + % if request.use_oruga: + <o-dropdown position="bottom-left" + ## TODO: why does click not work here?! + :triggers="['click', 'hover']"> + <template #trigger> + <o-button> + <o-icon icon="question-circle" /> + <o-icon icon="cog" /> + </o-button> </template> - <b-dropdown-item aria-role="listitem" - @click="configureInit()"> - Edit Page Help - </b-dropdown-item> - <b-dropdown-item aria-role="listitem" - @click="configureFieldsInit()"> - Edit Fields Help - </b-dropdown-item> - </b-dropdown> - </p> - </b-field> - + <o-dropdown-item label="Edit Page Help" + @click="configureInit()" /> + <o-dropdown-item label="Edit Fields Help" + @click="configureFieldsInit()" /> + </o-dropdown> + % else: + <b-field> + <p class="control"> + <b-dropdown aria-role="list" position="is-bottom-left"> + <template #trigger> + <b-button> + % if request.use_oruga: + <o-icon icon="question-circle" /> + <o-icon icon="cog" /> + % else: + <span><i class="fa fa-question-circle"></i></span> + <span><i class="fa fa-cog"></i></span> + % endif + </b-button> + </template> + <b-dropdown-item aria-role="listitem" + @click="configureInit()"> + Edit Page Help + </b-dropdown-item> + <b-dropdown-item aria-role="listitem" + @click="configureFieldsInit()"> + Edit Fields Help + </b-dropdown-item> + </b-dropdown> + </p> + </b-field> + % endif % endif % if help_markdown: - <b-modal has-modal-card - :active.sync="displayShowDialog"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="displayShowDialog" + % else: + :active.sync="displayShowDialog" + % endif + > <div class="modal-card"> <header class="modal-card-head"> @@ -94,14 +152,23 @@ </b-button> </footer> </div> - </b-modal> + </${b}-modal> % endif % if can_edit_help: - <b-modal has-modal-card - :active.sync="configureShowDialog"> - <div class="modal-card"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="configureShowDialog" + % else: + :active.sync="configureShowDialog" + % endif + > + <div class="modal-card" + % if request.use_oruga: + style="margin: auto;" + % endif + > <header class="modal-card-head"> <p class="modal-card-title">Configure Help</p> @@ -155,7 +222,7 @@ </b-button> </footer> </div> - </b-modal> + </${b}-modal> % endif @@ -237,6 +304,7 @@ PageHelp.data = function() { return PageHelpData } Vue.component('page-help', PageHelp) + <% request.register_component('page-help', 'PageHelp') %> </script> </%def> diff --git a/tailbone/templates/principal/find_by_perm.mako b/tailbone/templates/principal/find_by_perm.mako index e2672985..1a0a4b7d 100644 --- a/tailbone/templates/principal/find_by_perm.mako +++ b/tailbone/templates/principal/find_by_perm.mako @@ -242,6 +242,8 @@ Vue.component('find-principals', FindPrincipals) + <% request.register_component('find-principals', 'FindPrincipals') %> + </script> </%def> diff --git a/tailbone/templates/settings/email/view.mako b/tailbone/templates/settings/email/view.mako index 2a29ce0e..c1bc5ed4 100644 --- a/tailbone/templates/settings/email/view.mako +++ b/tailbone/templates/settings/email/view.mako @@ -102,6 +102,8 @@ Vue.component('email-preview-tools', EmailPreviewTools) + <% request.register_component('email-preview-tools', 'EmailPreviewTools') %> + </script> </%def> diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako new file mode 100644 index 00000000..2988f29d --- /dev/null +++ b/tailbone/templates/themes/butterball/base.mako @@ -0,0 +1,1141 @@ +## -*- coding: utf-8; -*- +<%namespace name="base_meta" file="/base_meta.mako" /> +<%namespace name="page_help" file="/page_help.mako" /> +<%namespace file="/field-components.mako" import="make_field_components" /> +<%namespace file="/formposter.mako" import="declare_formposter_mixin" /> +<%namespace file="/grids/filter-components.mako" import="make_grid_filter_components" /> +<%namespace file="/buefy-components.mako" import="make_buefy_components" /> +<%namespace file="/buefy-plugin.mako" import="make_buefy_plugin" /> +<%namespace file="/http-plugin.mako" import="make_http_plugin" /> +## <%namespace file="/grids/nav.mako" import="grid_index_nav" /> +## <%namespace name="multi_file_upload" file="/multi_file_upload.mako" /> +<!DOCTYPE html> +<html lang="en"> + <head> + <meta http-equiv="content-type" content="text/html; charset=UTF-8" /> + <title>${base_meta.global_title()} » ${capture(self.title)|n}</title> + ${base_meta.favicon()} + ${self.header_core()} + ${self.head_tags()} + </head> + + <body> + <div id="app" style="height: 100%; display: flex; flex-direction: column; justify-content: space-between;"> + <whole-page></whole-page> + </div> + + ## TODO: this must come before the self.body() call..but why? + ${declare_formposter_mixin()} + + ## global components used by various (but not all) pages + ${make_field_components()} + ${make_grid_filter_components()} + + ## global components for buefy-based template compatibility + ${make_http_plugin()} + ${make_buefy_plugin()} + ${make_buefy_components()} + + ## special global components, used by WholePage + ${self.make_menu_search_component()} + ${page_help.render_template()} + ${page_help.declare_vars()} + % if request.has_perm('common.feedback'): + ${self.make_feedback_component()} + % endif + + ## WholePage component + ${self.make_whole_page_component()} + + ## content body from derived/child template + ${self.body()} + + ## Vue app + ${self.make_whole_page_app()} + </body> +</html> + +<%def name="title()"></%def> + +<%def name="content_title()"> + ${self.title()} +</%def> + +<%def name="header_core()"> + ${self.core_javascript()} + ${self.core_styles()} +</%def> + +<%def name="core_javascript()"> + <script type="importmap"> + { + ## TODO: eventually version / url should be configurable + "imports": { + "vue": "${h.get_liburl(request, 'bb_vue')}", + "@oruga-ui/oruga-next": "${h.get_liburl(request, 'bb_oruga')}", + "@oruga-ui/theme-bulma": "${h.get_liburl(request, 'bb_oruga_bulma')}", + "@fortawesome/fontawesome-svg-core": "${h.get_liburl(request, 'bb_fontawesome_svg_core')}", + "@fortawesome/free-solid-svg-icons": "${h.get_liburl(request, 'bb_free_solid_svg_icons')}", + "@fortawesome/vue-fontawesome": "${h.get_liburl(request, 'bb_vue_fontawesome')}" + } + } + </script> + <script> + // empty stub to avoid errors for older buefy templates + const Vue = { + component(tagname, classname) {}, + } + </script> +</%def> + +<%def name="core_styles()"> + + ## ## TODO: eventually, allow custom css per-user + ## % if user_css: + ## ${h.stylesheet_link(user_css)} + ## % else: + ## ${h.stylesheet_link(h.get_liburl(request, 'bulma.css'))} + ## % endif + + ## TODO: eventually version / url should be configurable + ${h.stylesheet_link(h.get_liburl(request, 'bb_oruga_bulma_css'))} + +</%def> + +<%def name="head_tags()"> + ${self.extra_javascript()} + ${self.extra_styles()} +</%def> + +<%def name="extra_javascript()"> +## ## some commonly-useful logic for detecting (non-)numeric input +## ${h.javascript_link(request.static_url('tailbone:static/js/numeric.js') + '?ver={}'.format(tailbone.__version__))} +## +## ## debounce, for better autocomplete performance +## ${h.javascript_link(request.static_url('tailbone:static/js/debounce.js') + '?ver={}'.format(tailbone.__version__))} + +## ## Tailbone / Buefy stuff +## ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.numericinput.js') + '?ver={}'.format(tailbone.__version__))} +## ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.timepicker.js') + '?ver={}'.format(tailbone.__version__))} + +## <script type="text/javascript"> +## +## ## NOTE: this code was copied from +## ## https://bulma.io/documentation/components/navbar/#navbar-menu +## +## document.addEventListener('DOMContentLoaded', () => { +## +## // Get all "navbar-burger" elements +## const $navbarBurgers = Array.prototype.slice.call(document.querySelectorAll('.navbar-burger'), 0) +## +## // Add a click event on each of them +## $navbarBurgers.forEach( el => { +## el.addEventListener('click', () => { +## +## // Get the target from the "data-target" attribute +## const target = el.dataset.target +## const $target = document.getElementById(target) +## +## // Toggle the "is-active" class on both the "navbar-burger" and the "navbar-menu" +## el.classList.toggle('is-active') +## $target.classList.toggle('is-active') +## +## }) +## }) +## }) +## +## </script> +</%def> + +<%def name="extra_styles()"> + +## ${h.stylesheet_link(request.static_url('tailbone:static/css/base.css') + '?ver={}'.format(tailbone.__version__))} +## ${h.stylesheet_link(request.static_url('tailbone:static/css/layout.css') + '?ver={}'.format(tailbone.__version__))} +## ${h.stylesheet_link(request.static_url('tailbone:static/css/grids.css') + '?ver={}'.format(tailbone.__version__))} +## ${h.stylesheet_link(request.static_url('tailbone:static/css/grids.rowstatus.css') + '?ver={}'.format(tailbone.__version__))} +## ${h.stylesheet_link(request.static_url('tailbone:static/css/filters.css') + '?ver={}'.format(tailbone.__version__))} +## ${h.stylesheet_link(request.static_url('tailbone:static/css/forms.css') + '?ver={}'.format(tailbone.__version__))} + + ${h.stylesheet_link(request.static_url('tailbone:static/css/diffs.css') + '?ver={}'.format(tailbone.__version__))} + + ## nb. this is used (only?) in /generate-feature page + ${h.stylesheet_link(request.static_url('tailbone:static/css/codehilite.css') + '?ver={}'.format(tailbone.__version__))} + + <style> + + /* ****************************** */ + /* page */ + /* ****************************** */ + + /* nb. helps force footer to bottom of screen */ + html, body { + height: 100%; + } + + ## maybe add testing watermark + % if not request.rattail_config.production(): + html, .navbar, .footer { + background-image: url(${request.static_url('tailbone:static/img/testing.png')}); + } + % endif + + ## maybe force global background color + % if background_color: + body, .navbar, .footer { + background-color: ${background_color}; + } + % endif + + ## TODO: is this a good idea? + h1.title { + font-size: 2rem; + font-weight: bold; + margin-bottom: 0 !important; + } + + #context-menu { + margin-bottom: 1em; + /* margin-left: 1em; */ + text-align: right; + /* white-space: nowrap; */ + } + + ## TODO: ugh why is this needed to center modal on screen? + .modal .modal-content .modal-card { + margin: auto; + } + + .object-helpers .panel { + margin: 1rem; + margin-bottom: 1.5rem; + } + + /* ****************************** */ + /* grids */ + /* ****************************** */ + + .filters .filter-fieldname .button { + min-width: ${filter_fieldname_width}; + justify-content: left; + } + .filters .filter-verb { + min-width: ${filter_verb_width}; + } + + .grid-tools { + display: flex; + gap: 0.5rem; + justify-content: end; + } + + a.grid-action { + white-space: nowrap; + } + + /* ****************************** */ + /* forms */ + /* ****************************** */ + + /* note that these should only apply to "normal" primary forms */ + + .form { + padding-left: 5em; + } + + /* .form-wrapper .form .field.is-horizontal .field-label .label, */ + .form-wrapper .form .field.is-horizontal .field-label { + text-align: left; + white-space: nowrap; + min-width: 18em; + } + + .form-wrapper .form .field.is-horizontal .field-body { + min-width: 30em; + } + + .form-wrapper .form .field.is-horizontal .field-body .autocomplete, + .form-wrapper .form .field.is-horizontal .field-body .autocomplete .dropdown-trigger, + .form-wrapper .form .field.is-horizontal .field-body .select, + .form-wrapper .form .field.is-horizontal .field-body .select select { + width: 100%; + } + + .form-wrapper .form .buttons { + padding-left: 10rem; + } + + </style> +</%def> + +<%def name="make_feedback_component()"> + <% request.register_component('feedback-form', 'FeedbackForm') %> + <script type="text/x-template" id="feedback-form-template"> + <div> + + <o-button variant="primary" + @click="showFeedback()" + icon-left="comment"> + Feedback + </o-button> + + <o-modal v-model:active="showDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title"> + User Feedback + </p> + </header> + + <section class="modal-card-body"> + <p class="block"> + Questions, suggestions, comments, complaints, etc. + <span class="red">regarding this website</span> are + welcome and may be submitted below. + </p> + + <b-field label="User Name"> + <b-input v-model="userName" + % if request.user: + disabled + % endif + expanded> + </b-input> + </b-field> + + <b-field label="Referring URL"> + <b-input + v-model="referrer" + disabled expanded> + </b-input> + </b-field> + + <o-field label="Message"> + <o-input type="textarea" + v-model="message" + ref="message" + expanded> + </o-input> + </o-field> + + % if request.rattail_config.getbool('tailbone', 'feedback_allows_reply'): + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <b-checkbox v-model="pleaseReply" + @input="pleaseReplyChanged"> + Please email me back{{ pleaseReply ? " at: " : "" }} + </b-checkbox> + </div> + <div class="level-item" v-show="pleaseReply"> + <b-input v-model="userEmail" + ref="userEmail"> + </b-input> + </div> + </div> + </div> + % endif + + </section> + + <footer class="modal-card-foot"> + <o-button @click="showDialog = false"> + Cancel + </o-button> + <o-button variant="primary" + @click="sendFeedback()" + :disabled="sending || !message?.trim()"> + {{ sending ? "Working, please wait..." : "Send Message" }} + </o-button> + </footer> + </div> + </o-modal> + </div> + </script> + <script> + + const FeedbackForm = { + template: '#feedback-form-template', + mixins: [SimpleRequestMixin], + + props: { + action: String, + }, + + data() { + return { + referrer: null, + % if request.user: + userUUID: ${json.dumps(request.user.uuid)|n}, + userName: ${json.dumps(six.text_type(request.user))|n}, + % else: + userUUID: null, + userName: null, + % endif + message: null, + pleaseReply: false, + userEmail: null, + showDialog: false, + sending: false, + } + }, + + methods: { + + pleaseReplyChanged(value) { + this.$nextTick(() => { + this.$refs.userEmail.focus() + }) + }, + + showFeedback() { + this.referrer = location.href + this.message = null + this.showDialog = true + this.$nextTick(function() { + this.$refs.message.focus() + }) + }, + + sendFeedback() { + this.sending = true + + const params = { + referrer: this.referrer, + user: this.userUUID, + user_name: this.userName, + please_reply_to: this.pleaseReply ? this.userEmail : '', + message: this.message?.trim(), + } + + this.simplePOST(this.action, params, response => { + + this.$buefy.toast.open({ + message: "Message sent! Thank you for your feedback.", + type: 'is-info', + duration: 4000, // 4 seconds + }) + + this.sending = false + this.showDialog = false + + }, response => { + this.sending = false + }) + }, + } + } + + </script> +</%def> + +<%def name="make_menu_search_component()"> + <% request.register_component('menu-search', 'MenuSearch') %> + <script type="text/x-template" id="menu-search-template"> + <div> + + <a v-show="!searchActive" + href="${url('home')}" + class="navbar-item"> + ${base_meta.header_logo()} + <div id="global-header-title"> + ${base_meta.global_title()} + </div> + </a> + + <div v-show="searchActive" + class="navbar-item"> + <o-autocomplete ref="searchAutocomplete" + v-model="searchTerm" + :data="searchFilteredData" + field="label" + open-on-focus + keep-first + icon-pack="fas" + clearable + @select="searchSelect"> + </o-autocomplete> + </div> + </div> + </script> + <script> + + const MenuSearch = { + template: '#menu-search-template', + + props: { + searchData: Array, + }, + + data() { + return { + searchActive: false, + searchTerm: null, + searchInput: null, + } + }, + + computed: { + + searchFilteredData() { + if (!this.searchTerm || !this.searchTerm.length) { + return this.searchData + } + + let terms = [] + for (let term of this.searchTerm.toLowerCase().split(' ')) { + term = term.trim() + if (term) { + terms.push(term) + } + } + if (!terms.length) { + return this.searchData + } + + // all terms must match + return this.searchData.filter((option) => { + let label = option.label.toLowerCase() + for (let term of terms) { + if (label.indexOf(term) < 0) { + return false + } + } + return true + }) + }, + }, + + mounted() { + this.searchInput = this.$refs.searchAutocomplete.$el.querySelector('input') + this.searchInput.addEventListener('keydown', this.searchKeydown) + }, + + beforeDestroy() { + this.searchInput.removeEventListener('keydown', this.searchKeydown) + }, + + methods: { + + searchInit() { + this.searchTerm = '' + this.searchActive = true + this.$nextTick(() => { + this.$refs.searchAutocomplete.focus() + }) + }, + + searchKeydown(event) { + // ESC will dismiss searchbox + if (event.which == 27) { + this.searchActive = false + } + }, + + searchSelect(option) { + location.href = option.url + }, + }, + } + + </script> +</%def> + +<%def name="render_whole_page_template()"> + <script type="text/x-template" id="whole-page-template"> + <div id="whole-page" style="height: 100%; display: flex; flex-direction: column; justify-content: space-between;"> + + <div class="header-wrapper"> + + <header> + + <!-- this main menu, with search --> + <nav class="navbar" role="navigation" aria-label="main navigation"> + + <div class="navbar-brand"> + <menu-search :search-data="globalSearchData" + ref="menuSearch" /> + <a role="button" class="navbar-burger" data-target="navbarMenu" aria-label="menu" aria-expanded="false"> + <span aria-hidden="true"></span> + <span aria-hidden="true"></span> + <span aria-hidden="true"></span> + <span aria-hidden="true"></span> + </a> + </div> + + <div class="navbar-menu" id="navbarMenu"> + <div class="navbar-start"> + + ## global search button + <div v-if="globalSearchData.length" + class="navbar-item"> + <o-button variant="primary" + size="small" + @click="globalSearchInit()"> + <o-icon icon="search" size="small" /> + </o-button> + </div> + + ## main menu + % for topitem in menus: + % if topitem['is_link']: + ${h.link_to(topitem['title'], topitem['url'], target=topitem['target'], class_='navbar-item')} + % else: + <div class="navbar-item has-dropdown is-hoverable"> + <a class="navbar-link">${topitem['title']}</a> + <div class="navbar-dropdown"> + % for item in topitem['items']: + % if item['is_menu']: + <% item_hash = id(item) %> + <% toggle = f'menu_{item_hash}_shown' %> + <div> + <a class="navbar-link" @click.prevent="toggleNestedMenu('${item_hash}')"> + ${item['title']} + </a> + </div> + % for subitem in item['items']: + % if subitem['is_sep']: + <hr class="navbar-divider" v-show="${toggle}"> + % else: + ${h.link_to("{}".format(subitem['title']), subitem['url'], class_='navbar-item nested', target=subitem['target'], **{'v-show': toggle})} + % endif + % endfor + % else: + % if item['is_sep']: + <hr class="navbar-divider"> + % else: + ${h.link_to(item['title'], item['url'], class_='navbar-item', target=item['target'])} + % endif + % endif + % endfor + </div> + </div> + % endif + % endfor + + </div><!-- navbar-start --> + ${self.render_navbar_end()} + </div> + </nav> + + <!-- nb. this has index title, help button etc. --> + <nav style="display: flex; justify-content: space-between; align-items: center; padding: 0.5rem;"> + + ## Current Context + <div style="display: flex; gap: 0.5rem; align-items: center;"> + % if master: + % if master.listing: + <h1 class="title"> + ${index_title} + </h1> + % if master.creatable and master.show_create_link and master.has_perm('create'): + <once-button type="is-primary" + tag="a" href="${url('{}.create'.format(route_prefix))}" + icon-left="plus" + style="margin-left: 1rem;" + text="Create New"> + </once-button> + % endif + % elif index_url: + <h1 class="title"> + ${h.link_to(index_title, index_url)} + </h1> + % if parent_url is not Undefined: + <h1 class="title"> + » + </h1> + <h1 class="title"> + ${h.link_to(parent_title, parent_url)} + </h1> + % elif instance_url is not Undefined: + <h1 class="title"> + » + </h1> + <h1 class="title"> + ${h.link_to(instance_title, instance_url)} + </h1> + % elif master.creatable and master.show_create_link and master.has_perm('create'): + % if not request.matched_route.name.endswith('.create'): + <once-button type="is-primary" + tag="a" href="${url('{}.create'.format(route_prefix))}" + icon-left="plus" + style="margin-left: 1rem;" + text="Create New"> + </once-button> + % endif + % endif +## % if master.viewing and grid_index: +## ${grid_index_nav()} +## % endif + % else: + <h1 class="title"> + ${index_title} + </h1> + % endif + % elif index_title: + % if index_url: + <h1 class="title"> + ${h.link_to(index_title, index_url)} + </h1> + % else: + <h1 class="title"> + ${index_title} + </h1> + % endif + % endif + + ## % if expose_db_picker is not Undefined and expose_db_picker: + ## <div class="level-item"> + ## <p>DB:</p> + ## </div> + ## <div class="level-item"> + ## ${h.form(url('change_db_engine'), ref='dbPickerForm')} + ## ${h.csrf_token(request)} + ## ${h.hidden('engine_type', value=master.engine_type_key)} + ## <b-select name="dbkey" + ## value="${db_picker_selected}" + ## @input="changeDB()"> + ## % for option in db_picker_options: + ## <option value="${option.value}"> + ## ${option.label} + ## </option> + ## % endfor + ## </b-select> + ## ${h.end_form()} + ## </div> + ## % endif + + </div> + + <div style="display: flex; gap: 0.5rem;"> + +## ## Quickie Lookup +## % if quickie is not Undefined and quickie and request.has_perm(quickie.perm): +## <div class="level-item"> +## ${h.form(quickie.url, method="get")} +## <div class="level"> +## <div class="level-right"> +## <div class="level-item"> +## <b-input name="entry" +## placeholder="${quickie.placeholder}" +## autocomplete="off"> +## </b-input> +## </div> +## <div class="level-item"> +## <button type="submit" class="button is-primary"> +## <span class="icon is-small"> +## <i class="fas fa-search"></i> +## </span> +## <span>Lookup</span> +## </button> +## </div> +## </div> +## </div> +## ${h.end_form()} +## </div> +## % endif + + % if master and master.configurable and master.has_perm('configure'): + % if not request.matched_route.name.endswith('.configure'): + <once-button type="is-primary" + tag="a" + href="${url('{}.configure'.format(route_prefix))}" + icon-left="cog" + text="${(configure_button_title or "Configure") if configure_button_title is not Undefined else "Configure"}"> + </once-button> + % endif + % endif + + ## Theme Picker + % if expose_theme_picker and request.has_perm('common.change_app_theme'): + ${h.form(url('change_theme'), method="post", ref='themePickerForm')} + ${h.csrf_token(request)} + <div style="display: flex; align-items: center; gap: 0.5rem;"> + <span>Theme:</span> + <b-select name="theme" + v-model="globalTheme" + @input="changeTheme()"> + % for option in theme_picker_options: + <option value="${option.value}"> + ${option.label} + </option> + % endfor + </b-select> + </div> + ${h.end_form()} + % endif + + % if help_url or help_markdown or can_edit_help: + <page-help + % if can_edit_help: + @configure-fields-help="configureFieldsHelp = true" + % endif + > + </page-help> + % endif + + ## Feedback Button / Dialog + % if request.has_perm('common.feedback'): + <feedback-form action="${url('feedback')}" /> + % endif + </div> + </nav> + </header> + + ## Page Title + % if capture(self.content_title): + <section class="has-background-primary" + ## TODO: id is only for css, do we need it? + id="content-title" + style="padding: 0.5rem; padding-left: 1rem;"> + <div style="display: flex; align-items: center; gap: 1rem;"> + + <h1 class="title has-text-white" v-html="contentTitleHTML" /> + + <div style="flex-grow: 1; display: flex; gap: 0.5rem;"> + ${self.render_instance_header_title_extras()} + </div> + + <div style="display: flex; gap: 0.5rem;"> + ${self.render_instance_header_buttons()} + </div> + + </div> + </section> + % endif + + </div> <!-- header-wrapper --> + + <div class="content-wrapper" + style="flex-grow: 1; padding: 0.5rem;"> + + ## Page Body + <section id="page-body"> + + % if request.session.peek_flash('error'): + % for error in request.session.pop_flash('error'): + <b-notification type="is-warning"> + ${error} + </b-notification> + % endfor + % endif + + % if request.session.peek_flash('warning'): + % for msg in request.session.pop_flash('warning'): + <b-notification type="is-warning"> + ${msg} + </b-notification> + % endfor + % endif + + % if request.session.peek_flash(): + % for msg in request.session.pop_flash(): + <b-notification type="is-info"> + ${msg} + </b-notification> + % endfor + % endif + + ## true page content + <div> + ${self.render_this_page_component()} + </div> + </section> + </div><!-- content-wrapper --> + + ## Footer + <footer class="footer"> + <div class="content"> + ${base_meta.footer()} + </div> + </footer> + </div> + </script> + +## ${multi_file_upload.render_template()} +</%def> + +<%def name="render_this_page_component()"> + <this-page @change-content-title="changeContentTitle" + % if can_edit_help: + :configure-fields-help="configureFieldsHelp" + % endif + > + </this-page> +</%def> + +<%def name="render_navbar_end()"> + <div class="navbar-end"> + ${self.render_user_menu()} + </div> +</%def> + +<%def name="render_user_menu()"> + % if request.user: + <div class="navbar-item has-dropdown is-hoverable"> + % if messaging_enabled: + <a class="navbar-link ${'has-background-danger has-text-white' if request.is_root else ''}">${request.user}${" ({})".format(inbox_count) if inbox_count else ''}</a> + % else: + <a class="navbar-link ${'has-background-danger has-text-white' if request.is_root else ''}">${request.user}</a> + % endif + <div class="navbar-dropdown"> + % if request.is_root: + ${h.link_to("Stop being root", url('stop_root'), class_='navbar-item has-background-danger has-text-white')} + % elif request.is_admin: + ${h.link_to("Become root", url('become_root'), class_='navbar-item has-background-danger has-text-white')} + % endif + % if messaging_enabled: + ${h.link_to("Messages{}".format(" ({})".format(inbox_count) if inbox_count else ''), url('messages.inbox'), class_='navbar-item')} + % endif + % if request.is_root or not request.user.prevent_password_change: + ${h.link_to("Change Password", url('change_password'), class_='navbar-item')} + % endif + ${h.link_to("Edit Preferences", url('my.preferences'), class_='navbar-item')} + ${h.link_to("Logout", url('logout'), class_='navbar-item')} + </div> + </div> + % else: + ${h.link_to("Login", url('login'), class_='navbar-item')} + % endif +</%def> + +<%def name="render_instance_header_title_extras()"></%def> + +<%def name="render_instance_header_buttons()"> + ${self.render_crud_header_buttons()} + ${self.render_prevnext_header_buttons()} +</%def> + +<%def name="render_crud_header_buttons()"> + % if master and master.viewing: + ## TODO: is there a better way to check if viewing parent? + % if parent_instance is Undefined: + % if master.editable and instance_editable and master.has_perm('edit'): + <once-button tag="a" href="${action_url('edit', instance)}" + icon-left="edit" + text="Edit This"> + </once-button> + % endif + % if master.cloneable and master.has_perm('clone'): + <once-button tag="a" href="${action_url('clone', instance)}" + icon-left="object-ungroup" + text="Clone This"> + </once-button> + % endif + % if master.deletable and instance_deletable and master.has_perm('delete'): + <once-button tag="a" href="${action_url('delete', instance)}" + type="is-danger" + icon-left="trash" + text="Delete This"> + </once-button> + % endif + % else: + ## viewing row + % if instance_deletable and master.has_perm('delete_row'): + <once-button tag="a" href="${action_url('delete', instance)}" + type="is-danger" + icon-left="trash" + text="Delete This"> + </once-button> + % endif + % endif + % elif master and master.editing: + % if master.viewable and master.has_perm('view'): + <once-button tag="a" href="${action_url('view', instance)}" + icon-left="eye" + text="View This"> + </once-button> + % endif + % if master.deletable and instance_deletable and master.has_perm('delete'): + <once-button tag="a" href="${action_url('delete', instance)}" + type="is-danger" + icon-left="trash" + text="Delete This"> + </once-button> + % endif + % elif master and master.deleting: + % if master.viewable and master.has_perm('view'): + <once-button tag="a" href="${action_url('view', instance)}" + icon-left="eye" + text="View This"> + </once-button> + % endif + % if master.editable and instance_editable and master.has_perm('edit'): + <once-button tag="a" href="${action_url('edit', instance)}" + icon-left="edit" + text="Edit This"> + </once-button> + % endif + % endif +</%def> + +<%def name="render_prevnext_header_buttons()"> + % if show_prev_next is not Undefined and show_prev_next: + % if prev_url: + <b-button tag="a" href="${prev_url}" + icon-pack="fas" + icon-left="arrow-left"> + Older + </b-button> + % else: + <b-button tag="a" href="#" + disabled + icon-pack="fas" + icon-left="arrow-left"> + Older + </b-button> + % endif + % if next_url: + <b-button tag="a" href="${next_url}" + icon-pack="fas" + icon-left="arrow-right"> + Newer + </b-button> + % else: + <b-button tag="a" href="#" + disabled + icon-pack="fas" + icon-left="arrow-right"> + Newer + </b-button> + % endif + % endif +</%def> + +<%def name="declare_whole_page_vars()"> +## ${multi_file_upload.declare_vars()} + + <script> + + const WholePage = { + template: '#whole-page-template', + mixins: [SimpleRequestMixin], + + mounted() { + window.addEventListener('keydown', this.globalKey) + for (let hook of this.mountedHooks) { + hook(this) + } + }, + beforeDestroy() { + window.removeEventListener('keydown', this.globalKey) + }, + + methods: { + + changeContentTitle(newTitle) { + this.contentTitleHTML = newTitle + }, + + % if expose_db_picker is not Undefined and expose_db_picker: + changeDB() { + this.$refs.dbPickerForm.submit() + }, + % endif + + % if expose_theme_picker and request.has_perm('common.change_app_theme'): + changeTheme() { + this.$refs.themePickerForm.submit() + }, + % endif + + globalKey(event) { + + // Ctrl+8 opens global search + if (event.target.tagName == 'BODY') { + if (event.ctrlKey && event.key == '8') { + this.globalSearchInit() + } + } + }, + + globalSearchInit() { + this.$refs.menuSearch.searchInit() + }, + + toggleNestedMenu(hash) { + const key = 'menu_' + hash + '_shown' + this[key] = !this[key] + }, + }, + } + + const WholePageData = { + contentTitleHTML: ${json.dumps(capture(self.content_title))|n}, + globalSearchData: ${json.dumps(global_search_data)|n}, + mountedHooks: [], + + % if expose_theme_picker and request.has_perm('common.change_app_theme'): + globalTheme: ${json.dumps(theme)|n}, + % endif + + % if can_edit_help: + configureFieldsHelp: false, + % endif + } + + ## declare nested menu visibility toggle flags + % for topitem in menus: + % if topitem['is_menu']: + % for item in topitem['items']: + % if item['is_menu']: + WholePageData.menu_${id(item)}_shown = false + % endif + % endfor + % endif + % endfor + + </script> +</%def> + +<%def name="modify_whole_page_vars()"></%def> + +## TODO: do we really need this? +## <%def name="finalize_whole_page_vars()"></%def> + +<%def name="make_whole_page_component()"> + ${self.render_whole_page_template()} + ${self.declare_whole_page_vars()} + ${self.modify_whole_page_vars()} +## ${self.finalize_whole_page_vars()} + + ${page_help.make_component()} +## ${multi_file_upload.make_component()} + + <script> + WholePage.data = () => { return WholePageData } + </script> + <% request.register_component('whole-page', 'WholePage') %> +</%def> + +<%def name="make_whole_page_app()"> + <script type="module"> + import {createApp} from 'vue' + import {Oruga} from '@oruga-ui/oruga-next' + import {bulmaConfig} from '@oruga-ui/theme-bulma' + import { library } from "@fortawesome/fontawesome-svg-core" + import { fas } from "@fortawesome/free-solid-svg-icons" + import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome" + library.add(fas) + + const app = createApp() + app.component('vue-fontawesome', FontAwesomeIcon) + + % if hasattr(request, '_tailbone_registered_components'): + % for tagname, classname in request._tailbone_registered_components.items(): + app.component('${tagname}', ${classname}) + % endfor + % endif + + app.use(Oruga, { + ...bulmaConfig, + iconComponent: 'vue-fontawesome', + iconPack: 'fas', + }) + + app.use(HttpPlugin) + app.use(BuefyPlugin) + + app.mount('#app') + </script> +</%def> diff --git a/tailbone/templates/themes/butterball/buefy-components.mako b/tailbone/templates/themes/butterball/buefy-components.mako new file mode 100644 index 00000000..531ae4a5 --- /dev/null +++ b/tailbone/templates/themes/butterball/buefy-components.mako @@ -0,0 +1,679 @@ + +<%def name="make_buefy_components()"> + ${self.make_b_autocomplete_component()} + ${self.make_b_button_component()} + ${self.make_b_checkbox_component()} + ${self.make_b_collapse_component()} + ${self.make_b_datepicker_component()} + ${self.make_b_dropdown_component()} + ${self.make_b_dropdown_item_component()} + ${self.make_b_field_component()} + ${self.make_b_icon_component()} + ${self.make_b_input_component()} + ${self.make_b_loading_component()} + ${self.make_b_modal_component()} + ${self.make_b_notification_component()} + ${self.make_b_select_component()} + ${self.make_b_steps_component()} + ${self.make_b_step_item_component()} + ${self.make_b_table_component()} + ${self.make_b_table_column_component()} + ${self.make_once_button_component()} +</%def> + +<%def name="make_b_autocomplete_component()"> + <script type="text/x-template" id="b-autocomplete-template"> + <o-autocomplete v-model="buefyValue" + :data="data" + :field="field" + :open-on-focus="openOnFocus" + :keep-first="keepFirst" + :clearable="clearable" + :clear-on-select="clearOnSelect" + :formatter="customFormatter" + :placeholder="placeholder" + @update:model-value="buefyValueUpdated" + ref="autocomplete"> + </o-autocomplete> + </script> + <script> + const BAutocomplete = { + template: '#b-autocomplete-template', + props: { + modelValue: String, + data: Array, + field: String, + openOnFocus: Boolean, + keepFirst: Boolean, + clearable: Boolean, + clearOnSelect: Boolean, + customFormatter: null, + placeholder: String, + }, + data() { + return { + buefyValue: this.modelValue, + } + }, + watch: { + modelValue(to, from) { + if (this.buefyValue != to) { + this.buefyValue = to + } + }, + }, + methods: { + focus() { + const input = this.$refs.autocomplete.$el.querySelector('input') + input.focus() + }, + buefyValueUpdated(value) { + this.$emit('update:modelValue', value) + }, + }, + } + </script> + <% request.register_component('b-autocomplete', 'BAutocomplete') %> +</%def> + +<%def name="make_b_button_component()"> + <script type="text/x-template" id="b-button-template"> + <o-button :variant="variant" + :size="orugaSize" + :native-type="nativeType" + :tag="tag" + :href="href" + :icon-left="iconLeft"> + <slot /> + </o-button> + </script> + <script> + const BButton = { + template: '#b-button-template', + props: { + type: String, + nativeType: String, + tag: String, + href: String, + size: String, + iconPack: String, // ignored + iconLeft: String, + }, + computed: { + orugaSize() { + if (this.size) { + return this.size.replace(/^is-/, '') + } + }, + variant() { + if (this.type) { + return this.type.replace(/^is-/, '') + } + }, + }, + } + </script> + <% request.register_component('b-button', 'BButton') %> +</%def> + +<%def name="make_b_checkbox_component()"> + <script type="text/x-template" id="b-checkbox-template"> + <o-checkbox v-model="orugaValue" + @update:model-value="orugaValueUpdated" + :name="name" + :native-value="nativeValue"> + <slot /> + </o-checkbox> + </script> + <script> + const BCheckbox = { + template: '#b-checkbox-template', + props: { + modelValue: null, + name: String, + nativeValue: null, + }, + data() { + return { + orugaValue: this.modelValue, + } + }, + watch: { + modelValue(to, from) { + this.orugaValue = to + }, + }, + methods: { + orugaValueUpdated(value) { + this.$emit('update:modelValue', value) + }, + }, + } + </script> + <% request.register_component('b-checkbox', 'BCheckbox') %> +</%def> + +<%def name="make_b_collapse_component()"> + <script type="text/x-template" id="b-collapse-template"> + <o-collapse :open="open"> + <slot name="trigger" /> + <slot /> + </o-collapse> + </script> + <script> + const BCollapse = { + template: '#b-collapse-template', + props: { + open: Boolean, + }, + } + </script> + <% request.register_component('b-collapse', 'BCollapse') %> +</%def> + +<%def name="make_b_datepicker_component()"> + <script type="text/x-template" id="b-datepicker-template"> + <o-datepicker :name="name" + v-model="buefyValue" + @update:model-value="buefyValueUpdated" + :value="value" + :placeholder="placeholder" + :date-formatter="dateFormatter" + :date-parser="dateParser" + :disabled="disabled" + :editable="editable" + :icon="icon" + :close-on-click="false"> + </o-datepicker> + </script> + <script> + const BDatepicker = { + template: '#b-datepicker-template', + props: { + dateFormatter: null, + dateParser: null, + disabled: Boolean, + editable: Boolean, + icon: String, + // iconPack: String, // ignored + modelValue: Date, + name: String, + placeholder: String, + value: null, + }, + data() { + return { + buefyValue: this.modelValue, + } + }, + watch: { + modelValue(to, from) { + if (this.buefyValue != to) { + this.buefyValue = to + } + }, + }, + methods: { + buefyValueUpdated(value) { + if (this.modelValue != value) { + this.$emit('update:modelValue', value) + } + }, + }, + } + </script> + <% request.register_component('b-datepicker', 'BDatepicker') %> +</%def> + +<%def name="make_b_dropdown_component()"> + <script type="text/x-template" id="b-dropdown-template"> + <o-dropdown :position="buefyPosition" + :triggers="triggers"> + <slot name="trigger" /> + <slot /> + </o-dropdown> + </script> + <script> + const BDropdown = { + template: '#b-dropdown-template', + props: { + position: String, + triggers: Array, + }, + computed: { + buefyPosition() { + if (this.position) { + return this.position.replace(/^is-/, '') + } + }, + }, + } + </script> + <% request.register_component('b-dropdown', 'BDropdown') %> +</%def> + +<%def name="make_b_dropdown_item_component()"> + <script type="text/x-template" id="b-dropdown-item-template"> + <o-dropdown-item :label="label"> + <slot /> + </o-dropdown-item> + </script> + <script> + const BDropdownItem = { + template: '#b-dropdown-item-template', + props: { + label: String, + }, + } + </script> + <% request.register_component('b-dropdown-item', 'BDropdownItem') %> +</%def> + +<%def name="make_b_field_component()"> + <script type="text/x-template" id="b-field-template"> + <o-field :grouped="grouped" + :label="label" + :horizontal="horizontal" + :expanded="expanded" + :variant="variant"> + <slot /> + </o-field> + </script> + <script> + const BField = { + template: '#b-field-template', + props: { + expanded: Boolean, + grouped: Boolean, + horizontal: Boolean, + label: String, + type: String, + }, + computed: { + variant() { + if (this.type) { + return this.type.replace(/^is-/, '') + } + }, + }, + } + </script> + <% request.register_component('b-field', 'BField') %> +</%def> + +<%def name="make_b_icon_component()"> + <script type="text/x-template" id="b-icon-template"> + <o-icon :icon="icon" + :size="orugaSize" /> + </script> + <script> + const BIcon = { + template: '#b-icon-template', + props: { + icon: String, + size: String, + }, + computed: { + orugaSize() { + if (this.size) { + return this.size.replace(/^is-/, '') + } + }, + }, + } + </script> + <% request.register_component('b-icon', 'BIcon') %> +</%def> + +<%def name="make_b_input_component()"> + <script type="text/x-template" id="b-input-template"> + <o-input :type="type" + :disabled="disabled" + v-model="buefyValue" + @update:modelValue="val => $emit('update:modelValue', val)" + autocomplete="off" + ref="input"> + <slot /> + </o-input> + </script> + <script> + const BInput = { + template: '#b-input-template', + props: { + modelValue: null, + type: String, + disabled: Boolean, + }, + data() { + return { + buefyValue: this.modelValue + } + }, + watch: { + modelValue(to, from) { + if (this.buefyValue != to) { + this.buefyValue = to + } + }, + }, + methods: { + focus() { + if (this.type == 'textarea') { + // TODO: this does not work right + this.$refs.input.$el.querySelector('textarea').focus() + } + }, + }, + } + </script> + <% request.register_component('b-input', 'BInput') %> +</%def> + +<%def name="make_b_loading_component()"> + <script type="text/x-template" id="b-loading-template"> + <o-loading> + <slot /> + </o-loading> + </script> + <script> + const BLoading = { + template: '#b-loading-template', + } + </script> + <% request.register_component('b-loading', 'BLoading') %> +</%def> + +<%def name="make_b_modal_component()"> + <script type="text/x-template" id="b-modal-template"> + <o-modal v-model:active="trueActive" + @update:active="activeChanged"> + <slot /> + </o-modal> + </script> + <script> + const BModal = { + template: '#b-modal-template', + props: { + active: Boolean, + hasModalCard: Boolean, // nb. this is ignored + }, + data() { + return { + trueActive: this.active, + } + }, + watch: { + active(to, from) { + this.trueActive = to + }, + trueActive(to, from) { + if (this.active != to) { + this.tellParent(to) + } + }, + }, + methods: { + + tellParent(active) { + // TODO: this does not work properly + this.$emit('update:active', active) + }, + + activeChanged(active) { + this.tellParent(active) + }, + }, + } + </script> + <% request.register_component('b-modal', 'BModal') %> +</%def> + +<%def name="make_b_notification_component()"> + <script type="text/x-template" id="b-notification-template"> + <o-notification :variant="variant" + :closable="closable"> + <slot /> + </o-notification> + </script> + <script> + const BNotification = { + template: '#b-notification-template', + props: { + type: String, + closable: { + type: Boolean, + default: true, + }, + }, + computed: { + variant() { + if (this.type) { + return this.type.replace(/^is-/, '') + } + }, + }, + } + </script> + <% request.register_component('b-notification', 'BNotification') %> +</%def> + +<%def name="make_b_select_component()"> + <script type="text/x-template" id="b-select-template"> + <o-select :name="name" + v-model="orugaValue" + @update:model-value="orugaValueUpdated" + :expanded="expanded" + :multiple="multiple" + :size="orugaSize" + :native-size="nativeSize"> + <slot /> + </o-select> + </script> + <script> + const BSelect = { + template: '#b-select-template', + props: { + expanded: Boolean, + modelValue: null, + multiple: Boolean, + name: String, + nativeSize: null, + size: null, + }, + data() { + return { + orugaValue: this.modelValue, + } + }, + watch: { + modelValue(to, from) { + this.orugaValue = to + }, + }, + computed: { + orugaSize() { + if (this.size) { + return this.size.replace(/^is-/, '') + } + }, + }, + methods: { + orugaValueUpdated(value) { + this.$emit('update:modelValue', value) + this.$emit('input', value) + }, + }, + } + </script> + <% request.register_component('b-select', 'BSelect') %> +</%def> + +<%def name="make_b_steps_component()"> + <script type="text/x-template" id="b-steps-template"> + <o-steps v-model="orugaValue" + @update:model-value="orugaValueUpdated" + :animated="animated" + :rounded="rounded" + :has-navigation="hasNavigation" + :vertical="vertical"> + <slot /> + </o-steps> + </script> + <script> + const BSteps = { + template: '#b-steps-template', + props: { + modelValue: null, + animated: Boolean, + rounded: Boolean, + hasNavigation: Boolean, + vertical: Boolean, + }, + data() { + return { + orugaValue: this.modelValue, + } + }, + watch: { + modelValue(to, from) { + this.orugaValue = to + }, + }, + methods: { + orugaValueUpdated(value) { + this.$emit('update:modelValue', value) + this.$emit('input', value) + }, + }, + } + </script> + <% request.register_component('b-steps', 'BSteps') %> +</%def> + +<%def name="make_b_step_item_component()"> + <script type="text/x-template" id="b-step-item-template"> + <o-step-item :step="step" + :value="value" + :label="label" + :clickable="clickable"> + <slot /> + </o-step-item> + </script> + <script> + const BStepItem = { + template: '#b-step-item-template', + props: { + step: null, + value: null, + label: String, + clickable: Boolean, + }, + } + </script> + <% request.register_component('b-step-item', 'BStepItem') %> +</%def> + +<%def name="make_b_table_component()"> + <script type="text/x-template" id="b-table-template"> + <o-table :data="data"> + <slot /> + </o-table> + </script> + <script> + const BTable = { + template: '#b-table-template', + props: { + data: Array, + }, + } + </script> + <% request.register_component('b-table', 'BTable') %> +</%def> + +<%def name="make_b_table_column_component()"> + <script type="text/x-template" id="b-table-column-template"> + <o-table-column :field="field" + :label="label" + v-slot="props"> + ## TODO: this does not seem to really work for us... + <slot :props="props" /> + </o-table-column> + </script> + <script> + const BTableColumn = { + template: '#b-table-column-template', + props: { + field: String, + label: String, + }, + } + </script> + <% request.register_component('b-table-column', 'BTableColumn') %> +</%def> + +<%def name="make_once_button_component()"> + <script type="text/x-template" id="once-button-template"> + <b-button :type="type" + :native-type="nativeType" + :tag="tag" + :href="href" + :title="title" + :disabled="buttonDisabled" + @click="clicked" + icon-pack="fas" + :icon-left="iconLeft"> + {{ buttonText }} + </b-button> + </script> + <script> + const OnceButton = { + template: '#once-button-template', + props: { + type: String, + nativeType: String, + tag: String, + href: String, + text: String, + title: String, + iconLeft: String, + working: String, + workingText: String, + disabled: Boolean, + }, + data() { + return { + currentText: null, + currentDisabled: null, + } + }, + computed: { + buttonText: function() { + return this.currentText || this.text + }, + buttonDisabled: function() { + if (this.currentDisabled !== null) { + return this.currentDisabled + } + return this.disabled + }, + }, + methods: { + + clicked(event) { + this.currentDisabled = true + if (this.workingText) { + this.currentText = this.workingText + } else if (this.working) { + this.currentText = this.working + ", please wait..." + } else { + this.currentText = "Working, please wait..." + } + // this.$nextTick(function() { + // this.$emit('click', event) + // }) + } + }, + } + </script> + <% request.register_component('once-button', 'OnceButton') %> +</%def> diff --git a/tailbone/templates/themes/butterball/buefy-plugin.mako b/tailbone/templates/themes/butterball/buefy-plugin.mako new file mode 100644 index 00000000..4cbedfea --- /dev/null +++ b/tailbone/templates/themes/butterball/buefy-plugin.mako @@ -0,0 +1,32 @@ + +<%def name="make_buefy_plugin()"> + <script> + + const BuefyPlugin = { + install(app, options) { + app.config.globalProperties.$buefy = { + + toast: { + open(options) { + + let variant = null + if (options.type) { + variant = options.type.replace(/^is-/, '') + } + + const opts = { + duration: options.duration, + message: options.message, + position: 'top', + variant, + } + + const oruga = app.config.globalProperties.$oruga + oruga.notification.open(opts) + }, + }, + } + }, + } + </script> +</%def> diff --git a/tailbone/templates/themes/butterball/field-components.mako b/tailbone/templates/themes/butterball/field-components.mako new file mode 100644 index 00000000..1925e794 --- /dev/null +++ b/tailbone/templates/themes/butterball/field-components.mako @@ -0,0 +1,382 @@ +## -*- coding: utf-8; -*- + +<%def name="make_field_components()"> + ${self.make_tailbone_autocomplete_component()} + ${self.make_tailbone_datepicker_component()} +</%def> + +<%def name="make_tailbone_autocomplete_component()"> + <% request.register_component('tailbone-autocomplete', 'TailboneAutocomplete') %> + <script type="text/x-template" id="tailbone-autocomplete-template"> + <div> + + <o-button v-if="modelValue" + style="width: 100%; justify-content: left;" + @click="clearSelection(true)" + expanded> + {{ internalLabel }} (click to change #1) + </o-button> + + <o-autocomplete ref="autocompletex" + v-show="!modelValue" + v-model="orugaValue" + :placeholder="placeholder" + :data="filteredData" + :field="field" + :formatter="customFormatter" + @input="inputChanged" + @select="optionSelected" + keep-first + open-on-focus + expanded + :clearable="clearable" + :clear-on-select="clearOnSelect"> + <template #default="{ option }"> + {{ option.label }} + </template> + </o-autocomplete> + + <input type="hidden" :name="name" :value="modelValue" /> + </div> + </script> + <script> + + const TailboneAutocomplete = { + template: '#tailbone-autocomplete-template', + + props: { + + // this is the "input" field name essentially. primarily + // is useful for "traditional" tailbone forms; it normally + // is not used otherwise. it is passed as-is to the oruga + // autocomplete component `name` prop + name: String, + + // static data set; used when serviceUrl is not provided + data: Array, + + // the url from which search results are to be obtained. the + // url should expect a GET request with a query string with a + // single `term` parameter, and return results as a JSON array + // containing objects with `value` and `label` properties. + serviceUrl: String, + + // callers do not specify this directly but rather by way of + // the `v-model` directive. this component will emit `input` + // events when the value changes + modelValue: String, + + // callers may set an initial label if needed. this is useful + // in cases where the autocomplete needs to "already have a + // value" on page load. for instance when a user fills out + // the autocomplete field, but leaves other required fields + // blank and submits the form; page will re-load showing + // errors but the autocomplete field should remain "set" - + // normally it is only given a "value" (e.g. uuid) but this + // allows for the "label" to display correctly as well + initialLabel: String, + + // while the `initialLabel` above is useful for setting the + // *initial* label (of course), it cannot be used to + // arbitrarily update the label during the component's life. + // if you do need to *update* the label after initial page + // load, then you should set `assignedLabel` instead. one + // place this happens is in /custorders/create page, where + // product autocomplete shows some results, and user clicks + // one, but then handler logic can forcibly "swap" the + // selection, causing *different* product data to come back + // from the server, and autocomplete label should be updated + // to match. this feels a bit awkward still but does work.. + assignedLabel: String, + + // simple placeholder text for the input box + placeholder: String, + + // these are passed as-is to <o-autocomplete> + clearable: Boolean, + clearOnSelect: Boolean, + customFormatter: null, + expanded: Boolean, + field: String, + }, + + data() { + + const internalLabel = this.assignedLabel || this.initialLabel + + // we want to track the "currently selected option" - which + // should normally be `null` to begin with, unless we were + // given a value, in which case we use `initialLabel` to + // complete the option + let selected = null + if (this.modelValue) { + selected = { + value: this.modelValue, + label: internalLabel, + } + } + + return { + + // this contains the search results; its contents may + // change over time as new searches happen. the + // "currently selected option" should be one of these, + // unless it is null + fetchedData: [], + + // this tracks our "currently selected option" - per above + selected, + + // since we are wrapping a component which also makes + // use of the "value" paradigm, we must separate the + // concerns. so we use our own `modelValue` prop to + // interact with the caller, but then we use this + // `orugaValue` data point to communicate with the + // oruga autocomplete component. note that + // `this.modelValue` will always be either a uuid or + // null, whereas `this.orugaValue` may be raw text as + // entered by the user. + // orugaValue: this.modelValue, + orugaValue: null, + + // this stores the "internal" label for the button + internalLabel, + } + }, + + computed: { + + filteredData() { + + // do not filter if data comes from backend + if (this.serviceUrl) { + return this.fetchedData + } + + if (!this.orugaValue || !this.orugaValue.length) { + return this.data + } + + const terms = [] + for (let term of this.orugaValue.toLowerCase().split(' ')) { + term = term.trim() + if (term) { + terms.push(term) + } + } + if (!terms.length) { + return this.data + } + + // all terms must match + return this.data.filter((option) => { + const label = option.label.toLowerCase() + for (const term of terms) { + if (label.indexOf(term) < 0) { + return false + } + } + return true + }) + }, + }, + + watch: { + + assignedLabel(to, from) { + // update button label when caller changes it + this.internalLabel = to + }, + }, + + methods: { + + inputChanged(entry) { + if (this.serviceUrl) { + this.getAsyncData(entry) + } + }, + + // fetch new search results from the server. this is + // invoked via the `@input` event from oruga autocomplete + // component. + getAsyncData(entry) { + + // since the `@input` event from oruga component does + // not "self-regulate" in any way (?), we skip the + // search unless we have at least 3 characters of + // input from user + if (entry.length < 3) { + this.fetchedData = [] + return + } + + // and perform the search + this.$http.get(this.serviceUrl + '?term=' + encodeURIComponent(entry)) + .then(({ data }) => { + this.fetchedData = data + }) + .catch((error) => { + this.fetchedData = [] + throw error + }) + }, + + // this method is invoked via the `@select` event of the + // oruga autocomplete component. the `option` received + // will be one of: + // - object with (at least) `value` and `label` keys + // - simple string (e.g. when data set is static) + // - null + optionSelected(option) { + + this.selected = option + this.internalLabel = option?.label || option + + // reset the internal value for oruga autocomplete + // component. note that this value will normally hold + // either the raw text entered by the user, or a uuid. + // we will not be needing either of those b/c they are + // not visible to user once selection is made, and if + // the selection is cleared we want user to start over + // anyway + this.orugaValue = null + + // here is where we alert callers to the new value + if (option) { + this.$emit('newLabel', option.label) + } + const value = option?.[this.field || 'value'] || option + this.$emit('update:modelValue', value) + // this.$emit('select', option) + // this.$emit('input', value) + }, + +## // set selection to the given option, which should a simple +## // object with (at least) `value` and `label` properties +## setSelection(option) { +## this.$refs.autocomplete.setSelected(option) +## }, + + // clear the field of any value, i.e. set the "currently + // selected option" to null. this is invoked when you click + // the button, which is visible while the field has a value. + // but callers can invoke it directly as well. + clearSelection(focus) { + + this.$emit('update:modelValue', null) + this.$emit('input', null) + this.$emit('newLabel', null) + this.internalLabel = null + this.selected = null + this.orugaValue = null + +## // clear selection for the oruga autocomplete component +## this.$refs.autocomplete.setSelected(null) + + // maybe set focus to our (autocomplete) component + if (focus) { + this.$nextTick(function() { + this.focus() + }) + } + }, + + // set focus to this component, which will just set focus + // to the oruga autocomplete component + focus() { + // TODO: why is this ref null?! + if (this.$refs.autocompletex) { + this.$refs.autocompletex.focus() + } + }, + + // returns the "raw" user input from the underlying oruga + // autocomplete component + getUserInput() { + return this.orugaValue + }, + }, + } + + </script> +</%def> + +<%def name="make_tailbone_datepicker_component()"> + <% request.register_component('tailbone-datepicker', 'TailboneDatepicker') %> + <script type="text/x-template" id="tailbone-datepicker-template"> + <o-datepicker placeholder="Click to select ..." + icon="calendar-alt" + :date-formatter="formatDate" + :date-parser="parseDate" + v-model="orugaValue" + @update:model-value="orugaValueUpdated" + :disabled="disabled" + ref="trueDatePicker"> + </o-datepicker> + </script> + <script> + + const TailboneDatepicker = { + template: '#tailbone-datepicker-template', + + props: { + modelValue: Date, + disabled: Boolean, + }, + + data() { + return { + orugaValue: this.parseDate(this.modelValue), + } + }, + + watch: { + modelValue(to, from) { + if (this.orugaValue != to) { + this.orugaValue = to + } + }, + }, + + methods: { + + formatDate(date) { + if (date === null) { + return null + } + // just need to convert to simple ISO date format here, seems + // like there should be a more obvious way to do that? + var year = date.getFullYear() + var month = date.getMonth() + 1 + var day = date.getDate() + month = month < 10 ? '0' + month : month + day = day < 10 ? '0' + day : day + return year + '-' + month + '-' + day + }, + + parseDate(value) { + if (typeof(value) == 'object') { + // nb. we are assuming it is a Date here + return value + } + if (value) { + // note, this assumes classic YYYY-MM-DD (i.e. ISO?) format + const parts = value.split('-') + return new Date(parts[0], parseInt(parts[1]) - 1, parts[2]) + } + return null + }, + + orugaValueUpdated(date) { + this.$emit('update:modelValue', date) + }, + + focus() { + this.$refs.trueDatePicker.focus() + }, + }, + } + + </script> +</%def> diff --git a/tailbone/templates/themes/butterball/http-plugin.mako b/tailbone/templates/themes/butterball/http-plugin.mako new file mode 100644 index 00000000..06afc2bb --- /dev/null +++ b/tailbone/templates/themes/butterball/http-plugin.mako @@ -0,0 +1,100 @@ + +<%def name="make_http_plugin()"> + <script> + + const HttpPlugin = { + + install(app, options) { + app.config.globalProperties.$http = { + + get(url, options) { + if (options === undefined) { + options = {} + } + + if (options.params) { + // convert params to query string + const data = new URLSearchParams() + for (let [key, value] of Object.entries(options.params)) { + // nb. all values get converted to string here, so + // fallback to empty string to avoid null value + // from being interpreted as "null" string + if (value === null) { + value = '' + } + data.append(key, value) + } + // TODO: this should be smarter in case query string already exists + url += '?' + data.toString() + // params is not a valid arg for options to fetch() + delete options.params + } + + return new Promise((resolve, reject) => { + fetch(url, options).then(response => { + // original response does not contain 'data' + // attribute, so must use a "mock" response + // which does contain everything + response.json().then(json => { + resolve({ + data: json, + headers: response.headers, + ok: response.ok, + redirected: response.redirected, + status: response.status, + statusText: response.statusText, + type: response.type, + url: response.url, + }) + }, json => { + reject(response) + }) + }, response => { + reject(response) + }) + }) + }, + + post(url, params, options) { + + if (params) { + + // attach params as json + options.body = JSON.stringify(params) + + // and declare content-type + options.headers = new Headers(options.headers) + options.headers.append('Content-Type', 'application/json') + } + + options.method = 'POST' + + return new Promise((resolve, reject) => { + fetch(url, options).then(response => { + // original response does not contain 'data' + // attribute, so must use a "mock" response + // which does contain everything + response.json().then(json => { + resolve({ + data: json, + headers: response.headers, + ok: response.ok, + redirected: response.redirected, + status: response.status, + statusText: response.statusText, + type: response.type, + url: response.url, + }) + }, json => { + reject(response) + }) + }, response => { + reject(response) + }) + }) + }, + } + }, + } + </script> +</%def> diff --git a/tailbone/templates/themes/butterball/progress.mako b/tailbone/templates/themes/butterball/progress.mako new file mode 100644 index 00000000..1c389fb8 --- /dev/null +++ b/tailbone/templates/themes/butterball/progress.mako @@ -0,0 +1,244 @@ +## -*- coding: utf-8; -*- +<%namespace name="base_meta" file="/base_meta.mako" /> +<%namespace file="/base.mako" import="core_javascript" /> +<%namespace file="/base.mako" import="core_styles" /> +<%namespace file="/http-plugin.mako" import="make_http_plugin" /> +<!DOCTYPE html> +<html lang="en"> + <head> + <meta http-equiv="content-type" content="text/html; charset=UTF-8" /> + ${base_meta.favicon()} + <title>${initial_msg or "Working"}...</title> + ${core_javascript()} + ${core_styles()} + ${self.extra_styles()} + </head> + + <body> + <div id="app" style="height: 100%; display: flex; flex-direction: column; justify-content: space-between;"> + <whole-page></whole-page> + </div> + + ${make_http_plugin()} + ${self.make_whole_page_component()} + ${self.modify_whole_page_vars()} + ${self.make_whole_page_app()} + </body> +</html> + +<%def name="extra_styles()"></%def> + +<%def name="make_whole_page_component()"> + <script type="text/x-template" id="whole-page-template"> + <section class="hero is-fullheight"> + <div class="hero-body"> + <div class="container"> + + <div style="display: flex; flex-direction: column; justify-content: center;"> + <div style="margin: auto; display: flex; gap: 1rem; align-items: end;"> + + <div style="display: flex; flex-direction: column; gap: 1rem;"> + + <div style="display: flex; gap: 3rem;"> + <span>{{ progressMessage }} ... {{ totalDisplay }}</span> + <span>{{ percentageDisplay }}</span> + </div> + + <div style="display: flex; gap: 1rem; align-items: center;"> + + <div> + <progress class="progress is-large" + style="width: 400px;" + :max="progressMax" + :value="progressValue" /> + </div> + + % if can_cancel: + <o-button v-show="canCancel" + @click="cancelProgress()" + :disabled="cancelingProgress" + icon-left="ban"> + {{ cancelingProgress ? "Canceling, please wait..." : "Cancel" }} + </o-button> + % endif + + </div> + </div> + + </div> + </div> + + ${self.after_progress()} + + </div> + </div> + </section> + </script> + <script> + + const WholePage = { + template: '#whole-page-template', + + computed: { + + percentageDisplay() { + if (!this.progressMax) { + return + } + + const percent = this.progressValue / this.progressMax + return percent.toLocaleString(undefined, { + style: 'percent', + minimumFractionDigits: 0}) + }, + + totalDisplay() { + + % if can_cancel: + if (!this.stillInProgress && !this.cancelingProgress) { + return "done!" + } + % else: + if (!this.stillInProgress) { + return "done!" + } + % endif + + if (this.progressMaxDisplay) { + return `(${'$'}{this.progressMaxDisplay} total)` + } + }, + }, + + mounted() { + + // fetch first progress data, one second from now + setTimeout(() => { + this.updateProgress() + }, 1000) + + // custom logic if applicable + this.mountedCustom() + }, + + methods: { + + mountedCustom() {}, + + updateProgress() { + + this.$http.get(this.progressURL).then(response => { + + if (response.data.error) { + // errors stop the show, we redirect to "cancel" page + location.href = '${cancel_url}' + + } else { + + if (response.data.complete || response.data.maximum) { + this.progressMessage = response.data.message + this.progressMaxDisplay = response.data.maximum_display + + if (response.data.complete) { + this.progressValue = this.progressMax + this.stillInProgress = false + % if can_cancel: + this.canCancel = false + % endif + + location.href = response.data.success_url + + } else { + this.progressValue = response.data.value + this.progressMax = response.data.maximum + } + } + + // custom logic if applicable + this.updateProgressCustom(response) + + if (this.stillInProgress) { + + // fetch progress data again, in one second from now + setTimeout(() => { + this.updateProgress() + }, 1000) + } + } + }) + }, + + updateProgressCustom(response) {}, + + % if can_cancel: + + cancelProgress() { + + if (confirm("Do you really wish to cancel this operation?")) { + + this.cancelingProgress = true + this.stillInProgress = false + + let params = {cancel_msg: ${json.dumps(cancel_msg)|n}} + this.$http.get(this.cancelURL, {params: params}).then(response => { + location.href = ${json.dumps(cancel_url)|n} + }) + } + + }, + + % endif + } + } + + const WholePageData = { + + progressURL: '${url('progress', key=progress.key, _query={'sessiontype': progress.session.type})}', + progressMessage: "${(initial_msg or "Working").replace('"', '\\"')} (please wait)", + progressMax: null, + progressMaxDisplay: null, + progressValue: null, + stillInProgress: true, + + % if can_cancel: + canCancel: true, + cancelURL: '${url('progress.cancel', key=progress.key, _query={'sessiontype': progress.session.type})}', + cancelingProgress: false, + % endif + } + + </script> +</%def> + +<%def name="after_progress()"></%def> + +<%def name="modify_whole_page_vars()"></%def> + +<%def name="make_whole_page_app()"> + <script type="module"> + import {createApp} from 'vue' + import {Oruga} from '@oruga-ui/oruga-next' + import {bulmaConfig} from '@oruga-ui/theme-bulma' + import { library } from "@fortawesome/fontawesome-svg-core" + import { fas } from "@fortawesome/free-solid-svg-icons" + import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome" + library.add(fas) + + const app = createApp() + + app.component('vue-fontawesome', FontAwesomeIcon) + + WholePage.data = () => { return WholePageData } + app.component('whole-page', WholePage) + + app.use(Oruga, { + ...bulmaConfig, + iconComponent: 'vue-fontawesome', + iconPack: 'fas', + }) + + app.use(HttpPlugin) + + app.mount('#app') + </script> +</%def> diff --git a/tailbone/templates/upgrades/configure.mako b/tailbone/templates/upgrades/configure.mako index fd2c60ad..f7af685c 100644 --- a/tailbone/templates/upgrades/configure.mako +++ b/tailbone/templates/upgrades/configure.mako @@ -7,31 +7,35 @@ <h3 class="is-size-3">Upgradable Systems</h3> <div class="block" style="padding-left: 2rem; display: flex;"> - <b-table :data="upgradeSystems" + <${b}-table :data="upgradeSystems" sortable> - <b-table-column field="key" + <${b}-table-column field="key" label="Key" v-slot="props" sortable> {{ props.row.key }} - </b-table-column> - <b-table-column field="label" + </${b}-table-column> + <${b}-table-column field="label" label="Label" v-slot="props" sortable> {{ props.row.label }} - </b-table-column> - <b-table-column field="command" + </${b}-table-column> + <${b}-table-column field="command" label="Command" v-slot="props" sortable> {{ props.row.command }} - </b-table-column> - <b-table-column label="Actions" + </${b}-table-column> + <${b}-table-column label="Actions" v-slot="props"> <a href="#" @click.prevent="upgradeSystemEdit(props.row)"> - <i class="fas fa-edit"></i> + % if request.use_oruga: + <o-icon icon="edit" /> + % else: + <i class="fas fa-edit"></i> + % endif Edit </a> @@ -39,11 +43,15 @@ v-if="props.row.key != 'rattail'" class="has-text-danger" @click.prevent="updateSystemDelete(props.row)"> - <i class="fas fa-trash"></i> + % if request.use_oruga: + <o-icon icon="trash" /> + % else: + <i class="fas fa-trash"></i> + % endif Delete </a> - </b-table-column> - </b-table> + </${b}-table-column> + </${b}-table> <div style="margin-left: 1rem;"> <b-button type="is-primary" diff --git a/tailbone/util.py b/tailbone/util.py index f6678316..087bdfcb 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -31,7 +31,6 @@ import warnings import humanize import markdown -from rattail.time import timezone, make_utc from rattail.files import resource_path import colander @@ -161,6 +160,25 @@ def get_libver(request, key, fallback=True, default_only=False): elif key == 'fontawesome': return '5.3.1' + elif key == 'bb_vue': + # TODO: iiuc vue 3.4 does not work with oruga yet + return '3.3.11' + + elif key == 'bb_oruga': + return '0.8.8' + + elif key in ('bb_oruga_bulma', 'bb_oruga_bulma_css'): + return '0.3.0' + + elif key == 'bb_fontawesome_svg_core': + return '6.5.2' + + elif key == 'bb_free_solid_svg_icons': + return '6.5.2' + + elif key == 'bb_vue_fontawesome': + return '3.0.6' + def get_liburl(request, key, fallback=True): """ @@ -192,6 +210,27 @@ def get_liburl(request, key, fallback=True): elif key == 'fontawesome': return 'https://use.fontawesome.com/releases/v{}/js/all.js'.format(version) + elif key == 'bb_vue': + return f'https://unpkg.com/vue@{version}/dist/vue.esm-browser.js' + + elif key == 'bb_oruga': + return f'https://unpkg.com/@oruga-ui/oruga-next@{version}/dist/esm/index.mjs' + + elif key == 'bb_oruga_bulma': + return f'https://unpkg.com/@oruga-ui/theme-bulma@{version}/dist/bulma.mjs' + + elif key == 'bb_oruga_bulma_css': + return f'https://unpkg.com/@oruga-ui/theme-bulma@{version}/dist/bulma.css' + + elif key == 'bb_fontawesome_svg_core': + return f'https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-svg-core@{version}/+esm' + + elif key == 'bb_free_solid_svg_icons': + return f'https://cdn.jsdelivr.net/npm/@fortawesome/free-solid-svg-icons@{version}/+esm' + + elif key == 'bb_vue_fontawesome': + return f'https://cdn.jsdelivr.net/npm/@fortawesome/vue-fontawesome@{version}/+esm' + def pretty_datetime(config, value): """ @@ -214,10 +253,10 @@ def pretty_datetime(config, value): value = app.make_utc(value, tzinfo=True) # Calculate time diff using UTC. - time_ago = datetime.datetime.utcnow() - make_utc(value) + time_ago = datetime.datetime.utcnow() - app.make_utc(value) # Convert value to local timezone. - local = timezone(config) + local = app.get_timezone() value = local.normalize(value.astimezone(local)) return HTML.tag('span', @@ -246,10 +285,10 @@ def raw_datetime(config, value, verbose=False, as_date=False): value = app.make_utc(value, tzinfo=True) # Calculate time diff using UTC. - time_ago = datetime.datetime.utcnow() - make_utc(value) + time_ago = datetime.datetime.utcnow() - app.make_utc(value) # Convert value to local timezone. - local = timezone(config) + local = app.get_timezone() value = local.normalize(value.astimezone(local)) kwargs = {} @@ -378,6 +417,18 @@ def get_effective_theme(rattail_config, theme=None, session=None): return theme +def should_use_oruga(request): + """ + Returns a flag indicating whether or not the current theme + supports (and therefore should use) Oruga + Vue 3 as opposed to + the default of Buefy + Vue 2. + """ + theme = request.registry.settings['tailbone.theme'] + if theme == 'butterball': + return True + return False + + def validate_email_address(address): """ Perform basic validation on the given email address. This leverages the diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index 679f170c..cce5e53d 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -107,6 +107,13 @@ class AppInfoView(MasterView): ('buefy', "Buefy"), ('buefy.css', "Buefy CSS"), ('fontawesome', "FontAwesome"), + ('bb_vue', "(BB) vue"), + ('bb_oruga', "(BB) @oruga-ui/oruga-next"), + ('bb_oruga_bulma', "(BB) @oruga-ui/theme-bulma (JS)"), + ('bb_oruga_bulma_css', "(BB) @oruga-ui/theme-bulma (CSS)"), + ('bb_fontawesome_svg_core', "(BB) @fortawesome/fontawesome-svg-core"), + ('bb_free_solid_svg_icons', "(BB) @fortawesome/free-solid-svg-icons"), + ('bb_vue_fontawesome', "(BB) @fortawesome/vue-fontawesome"), ]) for key in weblibs: @@ -181,6 +188,41 @@ class AppInfoView(MasterView): {'section': 'tailbone', 'option': 'liburl.fontawesome'}, + {'section': 'tailbone', + 'option': 'libver.bb_vue'}, + {'section': 'tailbone', + 'option': 'liburl.bb_vue'}, + + {'section': 'tailbone', + 'option': 'libver.bb_oruga'}, + {'section': 'tailbone', + 'option': 'liburl.bb_oruga'}, + + {'section': 'tailbone', + 'option': 'libver.bb_oruga_bulma'}, + {'section': 'tailbone', + 'option': 'liburl.bb_oruga_bulma'}, + + {'section': 'tailbone', + 'option': 'libver.bb_oruga_bulma_css'}, + {'section': 'tailbone', + 'option': 'liburl.bb_oruga_bulma_css'}, + + {'section': 'tailbone', + 'option': 'libver.bb_fontawesome_svg_core'}, + {'section': 'tailbone', + 'option': 'liburl.bb_fontawesome_svg_core'}, + + {'section': 'tailbone', + 'option': 'libver.bb_free_solid_svg_icons'}, + {'section': 'tailbone', + 'option': 'liburl.bb_free_solid_svg_icons'}, + + {'section': 'tailbone', + 'option': 'libver.bb_vue_fontawesome'}, + {'section': 'tailbone', + 'option': 'liburl.bb_vue_fontawesome'}, + # nb. these are no longer used (deprecated), but we keep # them defined here so the tool auto-deletes them {'section': 'tailbone', From e7a44d9979eaecd16c7530f9f11c526a8927099c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 27 Apr 2024 21:54:55 -0500 Subject: [PATCH 1396/1681] Let caller use string data for <tailbone-datepicker> don't require a Date object, since callers thus far have not expected that --- tailbone/templates/themes/butterball/field-components.mako | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tailbone/templates/themes/butterball/field-components.mako b/tailbone/templates/themes/butterball/field-components.mako index 1925e794..b8c3d1dc 100644 --- a/tailbone/templates/themes/butterball/field-components.mako +++ b/tailbone/templates/themes/butterball/field-components.mako @@ -321,7 +321,7 @@ template: '#tailbone-datepicker-template', props: { - modelValue: Date, + modelValue: [Date, String], disabled: Boolean, }, @@ -333,9 +333,7 @@ watch: { modelValue(to, from) { - if (this.orugaValue != to) { - this.orugaValue = to - } + this.orugaValue = this.parseDate(to) }, }, From fb81a8302c7e804d473d37f58abe6aeecbfb0ff7 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 28 Apr 2024 00:20:43 -0500 Subject: [PATCH 1397/1681] Use oruga 0.8.7 by default instead of latest 0.8.8 until the new bug is fixed, https://github.com/oruga-ui/oruga/issues/913 --- tailbone/util.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tailbone/util.py b/tailbone/util.py index 087bdfcb..d1624670 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -165,7 +165,10 @@ def get_libver(request, key, fallback=True, default_only=False): return '3.3.11' elif key == 'bb_oruga': - return '0.8.8' + # TODO: as of writing, 0.8.8 is the latest release, but it has + # a bug which makes <o-field horizontal> basically not work + # cf. https://github.com/oruga-ui/oruga/issues/913 + return '0.8.7' elif key in ('bb_oruga_bulma', 'bb_oruga_bulma_css'): return '0.3.0' From 362d545f3405cbd08f0111a5484762eba114ba7c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 28 Apr 2024 00:25:03 -0500 Subject: [PATCH 1398/1681] Fix modal state for appinfo/configure page --- tailbone/templates/appinfo/configure.mako | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tailbone/templates/appinfo/configure.mako b/tailbone/templates/appinfo/configure.mako index 657e98cf..280b5cb9 100644 --- a/tailbone/templates/appinfo/configure.mako +++ b/tailbone/templates/appinfo/configure.mako @@ -153,8 +153,13 @@ ${h.hidden('tailbone.liburl.{}'.format(weblib['key']), **{':value': "simpleSettings['tailbone.liburl.{}']".format(weblib['key'])})} % endfor - <b-modal has-modal-card - :active.sync="editWebLibraryShowDialog"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="editWebLibraryShowDialog" + % else: + :active.sync="editWebLibraryShowDialog" + % endif + > <div class="modal-card"> <header class="modal-card-head"> @@ -203,7 +208,7 @@ </b-button> </footer> </div> - </b-modal> + </${b}-modal> </div> </%def> From 358816d9e77721ac130ca20b67dd452cbbe728e9 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 28 Apr 2024 00:51:07 -0500 Subject: [PATCH 1399/1681] Add oruga overhead for "classic" app only, not API --- tailbone/app.py | 4 ++++ tailbone/subscribers.py | 12 ++++++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/tailbone/app.py b/tailbone/app.py index abf2fa09..63610f85 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -123,6 +123,9 @@ def make_pyramid_config(settings, configure_csrf=True): config.set_root_factory(Root) else: + # declare this web app of the "classic" variety + settings.setdefault('tailbone.classic', 'true') + # we want the new themes feature! establish_theme(settings) @@ -130,6 +133,7 @@ def make_pyramid_config(settings, configure_csrf=True): config = Configurator(settings=settings, root_factory=Root) # add rattail config directly to registry + # TODO: why on earth do we do this again? config.registry['rattail_config'] = rattail_config # configure user authorization / authentication diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 9b56335a..8d10eb0b 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -98,10 +98,14 @@ def new_request(event): request.set_property(user, reify=True) - def use_oruga(request): - return should_use_oruga(request) + # nb. only add oruga check for "classic" web app + classic = rattail_config.parse_bool(request.registry.settings.get('tailbone.classic')) + if classic: - request.set_property(use_oruga, reify=True) + def use_oruga(request): + return should_use_oruga(request) + + request.set_property(use_oruga, reify=True) # assign client IP address to the session, for sake of versioning Session().continuum_remote_addr = request.client_addr @@ -173,11 +177,11 @@ def before_render(event): renderer_globals['colander'] = colander renderer_globals['deform'] = deform renderer_globals['csrf_header_name'] = csrf_header_name(request.rattail_config) - renderer_globals['b'] = 'o' if request.use_oruga else 'b' # for buefy # theme - we only want do this for classic web app, *not* API # TODO: so, clearly we need a better way to distinguish the two if 'tailbone.theme' in request.registry.settings: + renderer_globals['b'] = 'o' if request.use_oruga else 'b' # for buefy renderer_globals['theme'] = request.registry.settings['tailbone.theme'] # note, this is just a global flag; user still needs permission to see picker expose_picker = request.rattail_config.getbool('tailbone', 'themes.expose_picker', From 33251e880e7ea0d44ce47749b47b778166bb55ea Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 28 Apr 2024 01:58:19 -0500 Subject: [PATCH 1400/1681] Fix oruga styles for batch view also use typical panels, for row status breakdown etc. --- tailbone/templates/batch/view.mako | 19 ++++++++++--------- .../templates/themes/butterball/base.mako | 2 +- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako index a87b31a6..3c77cd70 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -68,18 +68,19 @@ </%def> <%def name="render_status_breakdown()"> - <div class="object-helper"> - <h3>Row Status Breakdown</h3> - <div class="object-helper-content"> + <nav class="panel"> + <p class="panel-heading">Row Status</p> + <div class="panel-block"> ${status_breakdown_grid} </div> - </div> + </nav> </%def> <%def name="render_execute_helper()"> - <div class="object-helper"> - <h3>Batch Execution</h3> - <div class="object-helper-content"> + <nav class="panel"> + <p class="panel-heading">Execution</p> + <div class="panel-block"> + <div style="display: flex; flex-direction: column; gap: 0.5rem;"> % if batch.executed: <p> Batch was executed @@ -89,7 +90,6 @@ % elif master.handler.executable(batch): % if master.has_perm('execute'): <p>Batch has not yet been executed.</p> - <br /> <b-button type="is-primary" % if not execute_enabled: disabled @@ -144,8 +144,9 @@ % else: <p>TODO: batch cannot be executed..?</p> % endif + </div> </div> - </div> + </nav> </%def> <%def name="render_form_template()"> diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako index 2988f29d..ce2eff74 100644 --- a/tailbone/templates/themes/butterball/base.mako +++ b/tailbone/templates/themes/butterball/base.mako @@ -152,10 +152,10 @@ ## ${h.stylesheet_link(request.static_url('tailbone:static/css/base.css') + '?ver={}'.format(tailbone.__version__))} ## ${h.stylesheet_link(request.static_url('tailbone:static/css/layout.css') + '?ver={}'.format(tailbone.__version__))} ## ${h.stylesheet_link(request.static_url('tailbone:static/css/grids.css') + '?ver={}'.format(tailbone.__version__))} -## ${h.stylesheet_link(request.static_url('tailbone:static/css/grids.rowstatus.css') + '?ver={}'.format(tailbone.__version__))} ## ${h.stylesheet_link(request.static_url('tailbone:static/css/filters.css') + '?ver={}'.format(tailbone.__version__))} ## ${h.stylesheet_link(request.static_url('tailbone:static/css/forms.css') + '?ver={}'.format(tailbone.__version__))} + ${h.stylesheet_link(request.static_url('tailbone:static/css/grids.rowstatus.css') + '?ver={}'.format(tailbone.__version__))} ${h.stylesheet_link(request.static_url('tailbone:static/css/diffs.css') + '?ver={}'.format(tailbone.__version__))} ## nb. this is used (only?) in /generate-feature page From f2f023e7b3273a84c81c9f5dba50f966c291fe0f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 28 Apr 2024 02:39:40 -0500 Subject: [PATCH 1401/1681] Fix v-model handling for grid-filter-numeric-value --- .../templates/grids/filter-components.mako | 60 ++++++++++++------- 1 file changed, 38 insertions(+), 22 deletions(-) diff --git a/tailbone/templates/grids/filter-components.mako b/tailbone/templates/grids/filter-components.mako index 9ec1c049..ff5ba8b7 100644 --- a/tailbone/templates/grids/filter-components.mako +++ b/tailbone/templates/grids/filter-components.mako @@ -36,30 +36,15 @@ const GridFilterNumericValue = { template: '#grid-filter-numeric-value-template', props: { - value: String, + ${'modelValue' if request.use_oruga else 'value'}: String, wantsRange: Boolean, }, data() { + const value = this.${'modelValue' if request.use_oruga else 'value'} + const {startValue, endValue} = this.parseValue(value) return { - startValue: null, - endValue: null, - } - }, - mounted() { - if (this.wantsRange) { - if (this.value.includes('|')) { - let values = this.value.split('|') - if (values.length == 2) { - this.startValue = values[0] - this.endValue = values[1] - } else { - this.startValue = this.value - } - } else { - this.startValue = this.value - } - } else { - this.startValue = this.value + startValue, + endValue, } }, watch: { @@ -72,6 +57,12 @@ this.$emit('input', this.startValue) } }, + + ${'modelValue' if request.use_oruga else 'value'}(to, from) { + const parsed = this.parseValue(to) + this.startValue = parsed.startValue + this.endValue = parsed.endValue + }, }, methods: { focus() { @@ -81,11 +72,36 @@ if (this.wantsRange) { value += '|' + this.endValue } - this.$emit('input', value) + this.$emit("${'update:modelValue' if request.use_oruga else 'input'}", value) }, endValueChanged(value) { value = this.startValue + '|' + value - this.$emit('input', value) + this.$emit("${'update:modelValue' if request.use_oruga else 'input'}", value) + }, + + parseValue(value) { + let startValue = null + let endValue = null + if (this.wantsRange) { + if (value.includes('|')) { + let values = value.split('|') + if (values.length == 2) { + startValue = values[0] + endValue = values[1] + } else { + startValue = value + } + } else { + startValue = value + } + } else { + startValue = value + } + + return { + startValue, + endValue, + } }, }, } From 855fa7e1e28389a8f67adc14a54c4c3b4c60dcd7 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 28 Apr 2024 02:41:45 -0500 Subject: [PATCH 1402/1681] Fix centering for "Show Totals" grid tool --- tailbone/templates/master/index.mako | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako index 2ad9a21b..bf7e6455 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -30,14 +30,16 @@ ## grid totals % if master.supports_grid_totals: - <b-button v-if="gridTotalsDisplay == null" - :disabled="gridTotalsFetching" - @click="gridTotalsFetch()"> - {{ gridTotalsFetching ? "Working, please wait..." : "Show Totals" }} - </b-button> - <div v-if="gridTotalsDisplay != null" - class="control"> - Totals: {{ gridTotalsDisplay }} + <div style="display: flex; align-items: center;"> + <b-button v-if="gridTotalsDisplay == null" + :disabled="gridTotalsFetching" + @click="gridTotalsFetch()"> + {{ gridTotalsFetching ? "Working, please wait..." : "Show Totals" }} + </b-button> + <div v-if="gridTotalsDisplay != null" + class="control"> + Totals: {{ gridTotalsDisplay }} + </div> </div> % endif From 1d5a0630ef5d8a8a05d4808a37252aad76d34e92 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 28 Apr 2024 16:14:40 -0500 Subject: [PATCH 1403/1681] Change default URL for some vue3+oruga libs apparently the first ones were not ideal / optimized, but these are --- tailbone/util.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/util.py b/tailbone/util.py index d1624670..64308f27 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -214,10 +214,10 @@ def get_liburl(request, key, fallback=True): return 'https://use.fontawesome.com/releases/v{}/js/all.js'.format(version) elif key == 'bb_vue': - return f'https://unpkg.com/vue@{version}/dist/vue.esm-browser.js' + return f'https://unpkg.com/vue@{version}/dist/vue.esm-browser.prod.js' elif key == 'bb_oruga': - return f'https://unpkg.com/@oruga-ui/oruga-next@{version}/dist/esm/index.mjs' + return f'https://unpkg.com/@oruga-ui/oruga-next@{version}/dist/oruga.mjs' elif key == 'bb_oruga_bulma': return f'https://unpkg.com/@oruga-ui/theme-bulma@{version}/dist/bulma.mjs' From adaa39f572389d56eea982b6b77b23b2d1521737 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 28 Apr 2024 17:33:06 -0500 Subject: [PATCH 1404/1681] Update changelog --- CHANGES.rst | 13 +++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 7bdb466d..309f6e13 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,19 @@ CHANGELOG Unreleased ---------- +0.10.0 (2024-04-28) +------------------- + +This version bump is to reflect adding support for Vue 3 + Oruga via +the 'butterball' theme. There is likely more work to be done for that +yet, but it mostly works at this point. + +* Misc. template and view logic tweaks (applicable to all themes) for + better patterns, consistency etc. + +* Add initial support for Vue 3 + Oruga, via "butterball" theme. + + 0.9.96 (2024-04-25) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index fb15d91c..20a1fc79 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.96' +__version__ = '0.10.0' From 34878f929357759f3f03e99050b5abc3d52215f4 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 28 Apr 2024 18:37:00 -0500 Subject: [PATCH 1405/1681] Sort list of available themes and add `computed` attr for WholePage; needed by some customizations --- .../templates/themes/butterball/base.mako | 1 + tailbone/util.py | 25 +++++++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako index ce2eff74..9364accf 100644 --- a/tailbone/templates/themes/butterball/base.mako +++ b/tailbone/templates/themes/butterball/base.mako @@ -1010,6 +1010,7 @@ const WholePage = { template: '#whole-page-template', mixins: [SimpleRequestMixin], + computed: {}, mounted() { window.addEventListener('keydown', this.globalKey) diff --git a/tailbone/util.py b/tailbone/util.py index 64308f27..bb18f22d 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -377,22 +377,37 @@ def get_theme_template_path(rattail_config, theme=None, session=None): def get_available_themes(rattail_config, include=None): + """ + Returns a list of theme names which are available. If config does + not specify, some defaults will be assumed. + """ + # get available list from config, if it has one available = rattail_config.getlist('tailbone', 'themes.keys') if not available: available = rattail_config.getlist('tailbone', 'themes', ignore_ambiguous=True) if available: - warnings.warn(f"URGENT: instead of 'tailbone.themes', " - f"you should set 'tailbone.themes.keys'", + warnings.warn("URGENT: instead of 'tailbone.themes', " + "you should set 'tailbone.themes.keys'", DeprecationWarning, stacklevel=2) else: available = [] - if 'default' not in available: - available.insert(0, 'default') + + # include any themes specified by caller if include is not None: for theme in include: if theme not in available: available.append(theme) + + # sort the list by name + available.sort() + + # make default theme the first option + i = available.index('default') + if i >= 0: + available.pop(i) + available.insert(0, 'default') + return available @@ -427,7 +442,7 @@ def should_use_oruga(request): the default of Buefy + Vue 2. """ theme = request.registry.settings['tailbone.theme'] - if theme == 'butterball': + if 'butterball' in theme: return True return False From b3784dcc4a09a73330906841a3a35fb5cd2b7931 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 28 Apr 2024 18:49:11 -0500 Subject: [PATCH 1406/1681] Update various icon names for oruga compatibility --- tailbone/templates/base.mako | 2 +- tailbone/templates/batch/index.mako | 2 +- tailbone/templates/batch/view.mako | 6 +++--- tailbone/templates/custorders/create.mako | 14 +++++++------- tailbone/templates/grids/complete.mako | 2 +- tailbone/templates/importing/configure.mako | 4 ++-- tailbone/templates/master/index.mako | 4 ++-- tailbone/templates/ordering/view.mako | 4 ++-- tailbone/templates/products/lookup.mako | 4 ++-- tailbone/templates/units-of-measure/index.mako | 2 +- tailbone/templates/workorders/view.mako | 14 +++++++------- 11 files changed, 29 insertions(+), 29 deletions(-) diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index 53fac116..44c7dd0f 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -511,7 +511,7 @@ <b-button type="is-primary" @click="showFeedback()" icon-pack="fas" - icon-left="fas fa-comment"> + icon-left="comment"> Feedback </b-button> </div> diff --git a/tailbone/templates/batch/index.mako b/tailbone/templates/batch/index.mako index 3ea76641..209fbb0c 100644 --- a/tailbone/templates/batch/index.mako +++ b/tailbone/templates/batch/index.mako @@ -9,7 +9,7 @@ <b-button type="is-primary" :disabled="refreshResultsButtonDisabled" icon-pack="fas" - icon-left="fas fa-redo" + icon-left="redo" @click="refreshResults()"> {{ refreshResultsButtonText }} </b-button> diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako index 3c77cd70..9b214662 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -50,12 +50,12 @@ <b-button tag="a" href="${master.get_action_url('download_worksheet', batch)}" icon-pack="fas" - icon-left="fas fa-download"> + icon-left="download"> Download Worksheet </b-button> <b-button type="is-primary" icon-pack="fas" - icon-left="fas fa-upload" + icon-left="upload" @click="$emit('show-upload')"> Upload Worksheet </b-button> @@ -185,7 +185,7 @@ <b-button type="is-primary" @click="submitUpload()" icon-pack="fas" - icon-left="fas fa-upload" + icon-left="upload" :disabled="uploadButtonDisabled"> {{ uploadButtonText }} </b-button> diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index 399c1a6b..6b07571e 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -27,18 +27,18 @@ @click="submitOrder()" :disabled="submittingOrder" icon-pack="fas" - icon-left="fas fa-upload"> + icon-left="upload"> {{ submittingOrder ? "Working, please wait..." : "Submit this Order" }} </b-button> <b-button @click="startOverEntirely()" icon-pack="fas" - icon-left="fas fa-redo"> + icon-left="redo"> Start Over Entirely </b-button> <b-button @click="cancelOrder()" type="is-danger" icon-pack="fas" - icon-left="fas fa-trash"> + icon-left="trash"> Cancel this Order </b-button> </div> @@ -493,14 +493,14 @@ <div class="buttons"> <b-button type="is-primary" icon-pack="fas" - icon-left="fas fa-plus" + icon-left="plus" @click="showAddItemDialog()"> Add Item </b-button> % if allow_past_item_reorder: <b-button v-if="contactUUID" icon-pack="fas" - icon-left="fas fa-plus" + icon-left="plus" @click="showAddPastItem()"> Add Past Item </b-button> @@ -1013,12 +1013,12 @@ {{ props.row.vendor_name }} </b-table-column> - <template slot="empty"> + <template #empty> <div class="content has-text-grey has-text-centered"> <p> <b-icon pack="fas" - icon="fas fa-sad-tear" + icon="sad-tear" size="is-large"> </b-icon> </p> diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako index fe9392d3..a54cc127 100644 --- a/tailbone/templates/grids/complete.mako +++ b/tailbone/templates/grids/complete.mako @@ -177,7 +177,7 @@ <p> <b-icon pack="fas" - icon="fas fa-sad-tear" + icon="sad-tear" size="is-large"> </b-icon> </p> diff --git a/tailbone/templates/importing/configure.mako b/tailbone/templates/importing/configure.mako index fd8bc35b..0396745a 100644 --- a/tailbone/templates/importing/configure.mako +++ b/tailbone/templates/importing/configure.mako @@ -58,13 +58,13 @@ Edit </a> </${b}-table-column> - <template slot="empty"> + <template #empty> <section class="section"> <div class="content has-text-grey has-text-centered"> <p> <b-icon pack="fas" - icon="fas fa-sad-tear" + icon="sad-tear" size="is-large"> </b-icon> </p> diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako index bf7e6455..a619d84c 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -183,7 +183,7 @@ <once-button type="is-primary" @click="downloadResultsSubmit()" icon-pack="fas" - icon-left="fas fa-download" + icon-left="download" :disabled="!downloadResultsFieldsIncluded.length" text="Download Results"> </once-button> @@ -197,7 +197,7 @@ % if master.has_rows and master.results_rows_downloadable and master.has_perm('download_results_rows'): <b-button type="is-primary" icon-pack="fas" - icon-left="fas fa-download" + icon-left="download" @click="downloadResultsRows()" :disabled="downloadResultsRowsButtonDisabled"> {{ downloadResultsRowsButtonText }} diff --git a/tailbone/templates/ordering/view.mako b/tailbone/templates/ordering/view.mako index f0e6380a..aed6fd75 100644 --- a/tailbone/templates/ordering/view.mako +++ b/tailbone/templates/ordering/view.mako @@ -28,7 +28,7 @@ <div> <b-button type="is-primary" icon-pack="fas" - icon-left="fas fa-play" + icon-left="play" @click="startScanning()"> Start Scanning </b-button> @@ -111,7 +111,7 @@ <div class="buttons"> <b-button type="is-primary" icon-pack="fas" - icon-left="fas fa-save" + icon-left="save" @click="saveCurrentRow()"> Save </b-button> diff --git a/tailbone/templates/products/lookup.mako b/tailbone/templates/products/lookup.mako index 4e8c3a8b..52b06e88 100644 --- a/tailbone/templates/products/lookup.mako +++ b/tailbone/templates/products/lookup.mako @@ -157,12 +157,12 @@ </a> </b-table-column> - <template slot="empty"> + <template #empty> <div class="content has-text-grey has-text-centered"> <p> <b-icon pack="fas" - icon="fas fa-sad-tear" + icon="sad-tear" size="is-large"> </b-icon> </p> diff --git a/tailbone/templates/units-of-measure/index.mako b/tailbone/templates/units-of-measure/index.mako index fb3a3219..597cabfd 100644 --- a/tailbone/templates/units-of-measure/index.mako +++ b/tailbone/templates/units-of-measure/index.mako @@ -7,7 +7,7 @@ % if master.has_perm('collect_wild_uoms'): <b-button type="is-primary" icon-pack="fas" - icon-left="fas fa-shopping-basket" + icon-left="shopping-basket" @click="showingCollectWildDialog = true"> Collect from the Wild </b-button> diff --git a/tailbone/templates/workorders/view.mako b/tailbone/templates/workorders/view.mako index e631c141..8740b4c9 100644 --- a/tailbone/templates/workorders/view.mako +++ b/tailbone/templates/workorders/view.mako @@ -24,7 +24,7 @@ ${h.csrf_token(request)} <b-button type="is-primary" icon-pack="fas" - icon-left="fas fa-arrow-right" + icon-left="arrow-right" @click="receive()" :disabled="receiveButtonDisabled"> {{ receiveButtonText }} @@ -41,7 +41,7 @@ ${h.csrf_token(request)} <b-button type="is-primary" icon-pack="fas" - icon-left="fas fa-arrow-right" + icon-left="arrow-right" @click="awaitEstimate()" :disabled="awaitEstimateButtonDisabled"> {{ awaitEstimateButtonText }} @@ -58,7 +58,7 @@ ${h.csrf_token(request)} <b-button type="is-primary" icon-pack="fas" - icon-left="fas fa-arrow-right" + icon-left="arrow-right" @click="awaitParts()" :disabled="awaitPartsButtonDisabled"> {{ awaitPartsButtonText }} @@ -75,7 +75,7 @@ ${h.csrf_token(request)} <b-button type="is-primary" icon-pack="fas" - icon-left="fas fa-arrow-right" + icon-left="arrow-right" @click="workOnIt()" :disabled="workOnItButtonDisabled"> {{ workOnItButtonText }} @@ -92,7 +92,7 @@ ${h.csrf_token(request)} <b-button type="is-primary" icon-pack="fas" - icon-left="fas fa-arrow-right" + icon-left="arrow-right" @click="release()" :disabled="releaseButtonDisabled"> {{ releaseButtonText }} @@ -109,7 +109,7 @@ ${h.csrf_token(request)} <b-button type="is-primary" icon-pack="fas" - icon-left="fas fa-arrow-right" + icon-left="arrow-right" @click="deliver()" :disabled="deliverButtonDisabled"> {{ deliverButtonText }} @@ -132,7 +132,7 @@ ${h.csrf_token(request)} <b-button type="is-warning" icon-pack="fas" - icon-left="fas fa-ban" + icon-left="ban" @click="confirmCancel()" :disabled="cancelButtonDisabled"> {{ cancelButtonText }} From 72f48fa9630b6181a87a8c1adfe0f2543a84cc38 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 28 Apr 2024 19:30:35 -0500 Subject: [PATCH 1407/1681] Fix vertical alignment in main menu bar, for butterball --- tailbone/templates/themes/butterball/base.mako | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako index 9364accf..70442164 100644 --- a/tailbone/templates/themes/butterball/base.mako +++ b/tailbone/templates/themes/butterball/base.mako @@ -432,11 +432,12 @@ <%def name="make_menu_search_component()"> <% request.register_component('menu-search', 'MenuSearch') %> <script type="text/x-template" id="menu-search-template"> - <div> + <div style="display: flex;"> <a v-show="!searchActive" href="${url('home')}" - class="navbar-item"> + class="navbar-item" + style="display: flex; gap: 0.5rem;"> ${base_meta.header_logo()} <div id="global-header-title"> ${base_meta.global_title()} @@ -550,7 +551,8 @@ <header> <!-- this main menu, with search --> - <nav class="navbar" role="navigation" aria-label="main navigation"> + <nav class="navbar" role="navigation" aria-label="main navigation" + style="display: flex; align-items: center;"> <div class="navbar-brand"> <menu-search :search-data="globalSearchData" @@ -563,7 +565,9 @@ </a> </div> - <div class="navbar-menu" id="navbarMenu"> + <div class="navbar-menu" id="navbarMenu" + style="display: flex; align-items: center;" + > <div class="navbar-start"> ## global search button From 9ee6521d6a5318e566a00340a368acedc4807095 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 28 Apr 2024 20:12:06 -0500 Subject: [PATCH 1408/1681] Fix upgrade execution logic/UI per oruga --- tailbone/templates/upgrades/view.mako | 19 +++++++++++++++---- tailbone/views/master.py | 5 ++++- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/tailbone/templates/upgrades/view.mako b/tailbone/templates/upgrades/view.mako index fe20c1e1..6ae110e0 100644 --- a/tailbone/templates/upgrades/view.mako +++ b/tailbone/templates/upgrades/view.mako @@ -19,9 +19,15 @@ ${parent.render_this_page()} % if expose_websockets and master.has_perm('execute'): - <b-modal :active.sync="upgradeExecuting" - full-screen - :can-cancel="false"> + <${b}-modal full-screen + % if request.use_oruga: + v-model:active="upgradeExecuting" + :cancelable="false" + % else: + :active.sync="upgradeExecuting" + :can-cancel="false" + % endif + > <div class="card"> <div class="card-content"> @@ -32,6 +38,10 @@ Upgrading ${system_title} (please wait) ... {{ executeUpgradeComplete ? "DONE!" : "" }} </p> + % if request.use_oruga: + <progress class="progress is-large" + style="width: 400px;" /> + % else: <b-progress size="is-large" style="width: 400px;" ## :value="80" @@ -39,6 +49,7 @@ ## format="percent" > </b-progress> + % endif </div> <div class="level-right"> <div class="level-item"> @@ -65,7 +76,7 @@ </div> </div> - </b-modal> + </${b}-modal> % endif % if master.has_perm('execute'): diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 87c592ee..cc6e25ea 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2060,7 +2060,10 @@ class MasterView(View): # caller must explicitly request websocket behavior; otherwise # we will assume traditional behavior for progress - ws = self.request.is_xhr and self.request.json_body.get('ws') + ws = False + if ((self.request.is_xhr or self.request.content_type == 'application/json') + and self.request.json_body.get('ws')): + ws = True # make our progress tracker progress = self.make_execute_progress(obj, ws=ws) From 6ce65badebe74e47d11414408199db5505898d4d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 28 Apr 2024 20:12:49 -0500 Subject: [PATCH 1409/1681] Show "View This" button when cloning a record --- tailbone/templates/themes/butterball/base.mako | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako index 70442164..439cf81a 100644 --- a/tailbone/templates/themes/butterball/base.mako +++ b/tailbone/templates/themes/butterball/base.mako @@ -911,7 +911,7 @@ </%def> <%def name="render_crud_header_buttons()"> - % if master and master.viewing: + % if master and master.viewing and not master.cloning: ## TODO: is there a better way to check if viewing parent? % if parent_instance is Undefined: % if master.editable and instance_editable and master.has_perm('edit'): @@ -920,7 +920,7 @@ text="Edit This"> </once-button> % endif - % if master.cloneable and master.has_perm('clone'): + % if not master.cloning and master.cloneable and master.has_perm('clone'): <once-button tag="a" href="${action_url('clone', instance)}" icon-left="object-ungroup" text="Clone This"> @@ -970,6 +970,13 @@ text="Edit This"> </once-button> % endif + % elif master and master.cloning: + % if master.viewable and master.has_perm('view'): + <once-button tag="a" href="${action_url('view', instance)}" + icon-left="eye" + text="View This"> + </once-button> + % endif % endif </%def> From e9ddd6dc36ecdd68e2f280d3b3f800cb1b5654d8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 28 Apr 2024 20:21:20 -0500 Subject: [PATCH 1410/1681] Stop including 'falafel' as available theme --- tailbone/subscribers.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 8d10eb0b..5f477281 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -190,8 +190,7 @@ def before_render(event): if expose_picker: # TODO: should remove 'falafel' option altogether - available = get_available_themes(request.rattail_config, - include=['falafel']) + available = get_available_themes(request.rattail_config) options = [tags.Option(theme, value=theme) for theme in available] renderer_globals['theme_picker_options'] = options From 68384a00dc707ea8b18f95a9f1ef109c15ae3530 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 28 Apr 2024 20:25:55 -0500 Subject: [PATCH 1411/1681] Update changelog --- CHANGES.rst | 16 ++++++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 309f6e13..5a02c648 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,22 @@ CHANGELOG Unreleased ---------- +0.10.1 (2024-04-28) +------------------- + +* Sort list of available themes. + +* Update various icon names for oruga compatibility. + +* Fix vertical alignment in main menu bar, for butterball. + +* Fix upgrade execution logic/UI per oruga. + +* Show "View This" button when cloning a record. + +* Stop including 'falafel' as available theme. + + 0.10.0 (2024-04-28) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 20a1fc79..3ce74007 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.10.0' +__version__ = '0.10.1' From 15fedf59768fb3b6a6ef86ce35e536a857ac61db Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 6 May 2024 21:52:53 -0500 Subject: [PATCH 1412/1681] Fix employees grid when viewing department (per oruga) --- tailbone/views/departments.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/departments.py b/tailbone/views/departments.py index c6998105..6ee1439f 100644 --- a/tailbone/views/departments.py +++ b/tailbone/views/departments.py @@ -129,6 +129,7 @@ class DepartmentView(MasterView): factory = self.get_grid_factory() g = factory( key='{}.employees'.format(route_prefix), + request=self.request, data=[], columns=[ 'first_name', From e4c42596741b3bc638067b99ff7eff53826ae662 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 6 May 2024 21:53:11 -0500 Subject: [PATCH 1413/1681] Remove version restriction for pyramid_beaker dependency latest version is 0.9, so this wasn't all that relevant --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 7fcce722..48cc994a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -57,7 +57,7 @@ install_requires = passlib Pillow pyramid - pyramid_beaker>=0.6 + pyramid_beaker pyramid_deform pyramid_exclog pyramid_mako From 3d319cbd094a58cf139d8e8afc427580063b06d6 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 6 May 2024 22:13:43 -0500 Subject: [PATCH 1414/1681] Fix login "enter" key behavior, per oruga --- tailbone/templates/login.mako | 12 ++++++++++-- .../themes/butterball/buefy-components.mako | 7 +++++-- tailbone/views/auth.py | 2 +- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/tailbone/templates/login.mako b/tailbone/templates/login.mako index 6e6e347f..d18323b5 100644 --- a/tailbone/templates/login.mako +++ b/tailbone/templates/login.mako @@ -60,11 +60,19 @@ <%def name="modify_this_page_vars()"> <script type="text/javascript"> - TailboneForm.mounted = function() { + ${form.component_studly}Data.usernameInput = null + + ${form.component_studly}.mounted = function() { this.$refs.username.focus() + this.usernameInput = this.$refs.username.$el.querySelector('input') + this.usernameInput.addEventListener('keydown', this.usernameKeydown) } - TailboneForm.methods.usernameKeydown = function(event) { + ${form.component_studly}.beforeDestroy = function() { + this.usernameInput.removeEventListener('keydown', this.usernameKeydown) + } + + ${form.component_studly}.methods.usernameKeydown = function(event) { if (event.which == 13) { event.preventDefault() this.$refs.password.focus() diff --git a/tailbone/templates/themes/butterball/buefy-components.mako b/tailbone/templates/themes/butterball/buefy-components.mako index 531ae4a5..9bce4c26 100644 --- a/tailbone/templates/themes/butterball/buefy-components.mako +++ b/tailbone/templates/themes/butterball/buefy-components.mako @@ -331,7 +331,7 @@ :disabled="disabled" v-model="buefyValue" @update:modelValue="val => $emit('update:modelValue', val)" - autocomplete="off" + :autocomplete="autocomplete" ref="input"> <slot /> </o-input> @@ -342,6 +342,7 @@ props: { modelValue: null, type: String, + autocomplete: String, disabled: Boolean, }, data() { @@ -359,8 +360,10 @@ methods: { focus() { if (this.type == 'textarea') { - // TODO: this does not work right + // TODO: this does not always work right? this.$refs.input.$el.querySelector('textarea').focus() + } else { + this.$refs.input.$el.querySelector('input').focus() } }, }, diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index f559a5c4..0f0d1687 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -125,7 +125,7 @@ class AuthenticationView(View): dform = form.make_deform_form() dform['username'].widget.attributes = { 'ref': 'username', - '@keydown.native': 'usernameKeydown', + 'autocomplete': 'off', } dform['password'].widget.attributes = {'ref': 'password'} From f0d694cfe5acd75478c3937c5882ede7d605487f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 6 May 2024 22:56:47 -0500 Subject: [PATCH 1415/1681] Rename some attrs etc. for buefy components used with oruga --- .../themes/butterball/buefy-components.mako | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/tailbone/templates/themes/butterball/buefy-components.mako b/tailbone/templates/themes/butterball/buefy-components.mako index 9bce4c26..a7b42267 100644 --- a/tailbone/templates/themes/butterball/buefy-components.mako +++ b/tailbone/templates/themes/butterball/buefy-components.mako @@ -23,7 +23,7 @@ <%def name="make_b_autocomplete_component()"> <script type="text/x-template" id="b-autocomplete-template"> - <o-autocomplete v-model="buefyValue" + <o-autocomplete v-model="orugaValue" :data="data" :field="field" :open-on-focus="openOnFocus" @@ -32,7 +32,7 @@ :clear-on-select="clearOnSelect" :formatter="customFormatter" :placeholder="placeholder" - @update:model-value="buefyValueUpdated" + @update:model-value="orugaValueUpdated" ref="autocomplete"> </o-autocomplete> </script> @@ -52,13 +52,13 @@ }, data() { return { - buefyValue: this.modelValue, + orugaValue: this.modelValue, } }, watch: { modelValue(to, from) { - if (this.buefyValue != to) { - this.buefyValue = to + if (this.orugaValue != to) { + this.orugaValue = to } }, }, @@ -67,7 +67,7 @@ const input = this.$refs.autocomplete.$el.querySelector('input') input.focus() }, - buefyValueUpdated(value) { + orugaValueUpdated(value) { this.$emit('update:modelValue', value) }, }, @@ -174,8 +174,8 @@ <%def name="make_b_datepicker_component()"> <script type="text/x-template" id="b-datepicker-template"> <o-datepicker :name="name" - v-model="buefyValue" - @update:model-value="buefyValueUpdated" + v-model="orugaValue" + @update:model-value="orugaValueUpdated" :value="value" :placeholder="placeholder" :date-formatter="dateFormatter" @@ -203,18 +203,18 @@ }, data() { return { - buefyValue: this.modelValue, + orugaValue: this.modelValue, } }, watch: { modelValue(to, from) { - if (this.buefyValue != to) { - this.buefyValue = to + if (this.orugaValue != to) { + this.orugaValue = to } }, }, methods: { - buefyValueUpdated(value) { + orugaValueUpdated(value) { if (this.modelValue != value) { this.$emit('update:modelValue', value) } @@ -329,7 +329,7 @@ <script type="text/x-template" id="b-input-template"> <o-input :type="type" :disabled="disabled" - v-model="buefyValue" + v-model="orugaValue" @update:modelValue="val => $emit('update:modelValue', val)" :autocomplete="autocomplete" ref="input"> @@ -347,13 +347,13 @@ }, data() { return { - buefyValue: this.modelValue + orugaValue: this.modelValue } }, watch: { modelValue(to, from) { - if (this.buefyValue != to) { - this.buefyValue = to + if (this.orugaValue != to) { + this.orugaValue = to } }, }, From 703d583f6fcb1bd3452a7f775502e3ffc9cd5b9c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 7 May 2024 11:53:44 -0500 Subject: [PATCH 1416/1681] Fix "tools" helper for receiving batch view, per oruga --- tailbone/templates/batch/view.mako | 4 +- tailbone/templates/receiving/view.mako | 175 ++++++++++++------------- 2 files changed, 90 insertions(+), 89 deletions(-) diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako index 9b214662..5e3328d9 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -71,7 +71,9 @@ <nav class="panel"> <p class="panel-heading">Row Status</p> <div class="panel-block"> - ${status_breakdown_grid} + <div style="width: 100%;"> + ${status_breakdown_grid} + </div> </div> </nav> </%def> diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako index d639ff24..80c45103 100644 --- a/tailbone/templates/receiving/view.mako +++ b/tailbone/templates/receiving/view.mako @@ -38,103 +38,102 @@ <%def name="render_tools_helper()"> % if allow_confirm_all_costs or (master.has_perm('auto_receive') and master.can_auto_receive(batch)): + <nav class="panel"> + <p class="panel-heading">Tools</p> + <div class="panel-block"> + <div style="display: flex; flex-direction: column; gap: 0.5rem; width: 100%;"> - <div class="object-helper"> - <h3>Tools</h3> - <div class="object-helper-content" - style="display: flex; flex-direction: column; gap: 1rem;"> + % if allow_confirm_all_costs: + <b-button type="is-primary" + icon-pack="fas" + icon-left="check" + @click="confirmAllCostsShowDialog = true"> + Confirm All Costs + </b-button> + <b-modal has-modal-card + :active.sync="confirmAllCostsShowDialog"> + <div class="modal-card"> - % if allow_confirm_all_costs: - <b-button type="is-primary" - icon-pack="fas" - icon-left="check" - @click="confirmAllCostsShowDialog = true"> - Confirm All Costs - </b-button> - <b-modal has-modal-card - :active.sync="confirmAllCostsShowDialog"> - <div class="modal-card"> + <header class="modal-card-head"> + <p class="modal-card-title">Confirm All Costs</p> + </header> - <header class="modal-card-head"> - <p class="modal-card-title">Confirm All Costs</p> - </header> + <section class="modal-card-body"> + <p class="block"> + You can automatically mark all catalog and invoice + cost amounts as "confirmed" if you wish. + </p> + <p class="block"> + Would you like to do this? + </p> + </section> - <section class="modal-card-body"> - <p class="block"> - You can automatically mark all catalog and invoice - cost amounts as "confirmed" if you wish. - </p> - <p class="block"> - Would you like to do this? - </p> - </section> + <footer class="modal-card-foot"> + <b-button @click="confirmAllCostsShowDialog = false"> + Cancel + </b-button> + ${h.form(url(f'{route_prefix}.confirm_all_costs', uuid=batch.uuid), **{'@submit': 'confirmAllCostsSubmitting = true'})} + ${h.csrf_token(request)} + <b-button type="is-primary" + native-type="submit" + :disabled="confirmAllCostsSubmitting" + icon-pack="fas" + icon-left="check"> + {{ confirmAllCostsSubmitting ? "Working, please wait..." : "Confirm All" }} + </b-button> + ${h.end_form()} + </footer> + </div> + </b-modal> + % endif - <footer class="modal-card-foot"> - <b-button @click="confirmAllCostsShowDialog = false"> - Cancel - </b-button> - ${h.form(url(f'{route_prefix}.confirm_all_costs', uuid=batch.uuid), **{'@submit': 'confirmAllCostsSubmitting = true'})} - ${h.csrf_token(request)} - <b-button type="is-primary" - native-type="submit" - :disabled="confirmAllCostsSubmitting" - icon-pack="fas" - icon-left="check"> - {{ confirmAllCostsSubmitting ? "Working, please wait..." : "Confirm All" }} - </b-button> - ${h.end_form()} - </footer> - </div> - </b-modal> - % endif + % if master.has_perm('auto_receive') and master.can_auto_receive(batch): + <b-button type="is-primary" + @click="autoReceiveShowDialog = true" + icon-pack="fas" + icon-left="check"> + Auto-Receive All Items + </b-button> + <b-modal has-modal-card + :active.sync="autoReceiveShowDialog"> + <div class="modal-card"> - % if master.has_perm('auto_receive') and master.can_auto_receive(batch): - <b-button type="is-primary" - @click="autoReceiveShowDialog = true" - icon-pack="fas" - icon-left="check"> - Auto-Receive All Items - </b-button> - <b-modal has-modal-card - :active.sync="autoReceiveShowDialog"> - <div class="modal-card"> + <header class="modal-card-head"> + <p class="modal-card-title">Auto-Receive All Items</p> + </header> - <header class="modal-card-head"> - <p class="modal-card-title">Auto-Receive All Items</p> - </header> - - <section class="modal-card-body"> - <p class="block"> - You can automatically set the "received" quantity to - match the "shipped" quantity for all items, based on - the invoice. - </p> - <p class="block"> - Would you like to do so? - </p> - </section> - - <footer class="modal-card-foot"> - <b-button @click="autoReceiveShowDialog = false"> - Cancel - </b-button> - ${h.form(url('{}.auto_receive'.format(route_prefix), uuid=batch.uuid), **{'@submit': 'autoReceiveSubmitting = true'})} - ${h.csrf_token(request)} - <b-button type="is-primary" - native-type="submit" - :disabled="autoReceiveSubmitting" - icon-pack="fas" - icon-left="check"> - {{ autoReceiveSubmitting ? "Working, please wait..." : "Auto-Receive All Items" }} - </b-button> - ${h.end_form()} - </footer> - </div> - </b-modal> - % endif + <section class="modal-card-body"> + <p class="block"> + You can automatically set the "received" quantity to + match the "shipped" quantity for all items, based on + the invoice. + </p> + <p class="block"> + Would you like to do so? + </p> + </section> + <footer class="modal-card-foot"> + <b-button @click="autoReceiveShowDialog = false"> + Cancel + </b-button> + ${h.form(url('{}.auto_receive'.format(route_prefix), uuid=batch.uuid), **{'@submit': 'autoReceiveSubmitting = true'})} + ${h.csrf_token(request)} + <b-button type="is-primary" + native-type="submit" + :disabled="autoReceiveSubmitting" + icon-pack="fas" + icon-left="check"> + {{ autoReceiveSubmitting ? "Working, please wait..." : "Auto-Receive All Items" }} + </b-button> + ${h.end_form()} + </footer> + </div> + </b-modal> + % endif + </div> </div> - </div> + </nav> % endif </%def> From 9cd648f78f34169518090a21c645ba3a3a1151c6 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 7 May 2024 11:54:16 -0500 Subject: [PATCH 1417/1681] Fix button text for autocomplete whoops i think that was a debug thing i forgot to remove --- tailbone/templates/themes/butterball/field-components.mako | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/templates/themes/butterball/field-components.mako b/tailbone/templates/themes/butterball/field-components.mako index b8c3d1dc..fbd83421 100644 --- a/tailbone/templates/themes/butterball/field-components.mako +++ b/tailbone/templates/themes/butterball/field-components.mako @@ -14,7 +14,7 @@ style="width: 100%; justify-content: left;" @click="clearSelection(true)" expanded> - {{ internalLabel }} (click to change #1) + {{ internalLabel }} (click to change) </o-button> <o-autocomplete ref="autocompletex" From d607ab298148057d8435db368188fb507a18f6c2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 7 May 2024 12:43:07 -0500 Subject: [PATCH 1418/1681] Fix display for "view receiving row" page, per oruga this page still needs help; "Account for Product" is broken for oruga --- tailbone/forms/core.py | 24 ++++++++++++------- tailbone/templates/receiving/view_row.mako | 22 +++++++++++++---- .../templates/themes/butterball/base.mako | 2 +- tailbone/views/purchasing/batch.py | 1 + 4 files changed, 34 insertions(+), 15 deletions(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 857bfccf..7601fa26 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -1139,20 +1139,26 @@ class Form(object): if field_name not in self.fields: return '' - # TODO: fair bit of duplication here, should merge with deform.mako label = kwargs.get('label') if not label: label = self.get_label(field_name) - label = HTML.tag('label', label, for_=field_name) - field = self.render_field_value(field_name) or '' - field_div = HTML.tag('div', class_='field', c=[field]) - contents = [label, field_div] - if self.has_helptext(field_name): - contents.append(HTML.tag('span', class_='instructions', - c=[self.render_helptext(field_name)])) + value = self.render_field_value(field_name) or '' - return HTML.tag('div', class_='field-wrapper {}'.format(field_name), c=contents) + if not self.request.use_oruga: + + label = HTML.tag('label', label, for_=field_name) + field_div = HTML.tag('div', class_='field', c=[value]) + contents = [label, field_div] + + if self.has_helptext(field_name): + contents.append(HTML.tag('span', class_='instructions', + c=[self.render_helptext(field_name)])) + + return HTML.tag('div', class_='field-wrapper {}'.format(field_name), c=contents) + + # oruga uses <o-field> + return HTML.tag('o-field', label=label, c=[value], **{':horizontal': 'true'}) def render_field_value(self, field_name): record = self.model_instance diff --git a/tailbone/templates/receiving/view_row.mako b/tailbone/templates/receiving/view_row.mako index 2341cd3e..efc1883a 100644 --- a/tailbone/templates/receiving/view_row.mako +++ b/tailbone/templates/receiving/view_row.mako @@ -60,8 +60,12 @@ <nav class="panel"> <p class="panel-heading">Product</p> <div class="panel-block"> - <div style="display: flex;"> - <div> + <div style="display: flex; gap: 1rem;"> + <div style="flex-grow: 1;" + % if request.use_oruga: + class="form-wrapper" + % endif + > ${form.render_field_readonly('item_entry')} % if row.product: ${form.render_field_readonly(product_key_field)} @@ -80,7 +84,7 @@ ${form.render_field_readonly('catalog_unit_cost')} </div> % if image_url: - <div class="is-pulled-right"> + <div> ${h.image(image_url, "Product Image", width=150, height=150)} </div> % endif @@ -429,7 +433,11 @@ <nav class="panel" > <p class="panel-heading">Purchase Order</p> <div class="panel-block"> - <div> + <div + % if request.use_oruga: + class="form-wrapper" + % endif + > ${form.render_field_readonly('po_line_number')} ${form.render_field_readonly('po_unit_cost')} ${form.render_field_readonly('po_case_size')} @@ -443,7 +451,11 @@ <nav class="panel" > <p class="panel-heading">Invoice</p> <div class="panel-block"> - <div> + <div + % if request.use_oruga: + class="form-wrapper" + % endif + > ${form.render_field_readonly('invoice_number')} ${form.render_field_readonly('invoice_line_number')} ${form.render_field_readonly('invoice_unit_cost')} diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako index 439cf81a..4b6c03f8 100644 --- a/tailbone/templates/themes/butterball/base.mako +++ b/tailbone/templates/themes/butterball/base.mako @@ -243,7 +243,7 @@ } /* .form-wrapper .form .field.is-horizontal .field-label .label, */ - .form-wrapper .form .field.is-horizontal .field-label { + .form-wrapper .field.is-horizontal .field-label { text-align: left; white-space: nowrap; min-width: 18em; diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index cd369f0a..1d11130c 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -794,6 +794,7 @@ class PurchasingBatchView(BatchMasterView): g = factory( key='{}.row_credits'.format(route_prefix), + request=self.request, data=[], columns=[ 'credit_type', From 28fb3f44a7bc7c5948fe6fb22c5c35eb1a0e094a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 7 May 2024 18:20:26 -0500 Subject: [PATCH 1419/1681] More data type fixes for <tailbone-datepicker> traditionally the caller has always dealt with string values only, so the component should never emit events with date values, etc. --- .../static/js/tailbone.buefy.datepicker.js | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/tailbone/static/js/tailbone.buefy.datepicker.js b/tailbone/static/js/tailbone.buefy.datepicker.js index c516b97f..0b861fd6 100644 --- a/tailbone/static/js/tailbone.buefy.datepicker.js +++ b/tailbone/static/js/tailbone.buefy.datepicker.js @@ -27,20 +27,14 @@ const TailboneDatepicker = { }, data() { - let buefyValue = this.value - if (buefyValue && !buefyValue.getDate) { - buefyValue = this.parseDate(this.value) - } return { - buefyValue, + buefyValue: this.parseDate(this.value), } }, watch: { value(to, from) { - if (this.buefyValue != to) { - this.buefyValue = to - } + this.buefyValue = this.parseDate(to) }, }, @@ -61,13 +55,16 @@ const TailboneDatepicker = { }, parseDate(date) { - // note, this assumes classic YYYY-MM-DD (i.e. ISO?) format - var parts = date.split('-') - return new Date(parts[0], parseInt(parts[1]) - 1, parts[2]) + if (typeof(date) == 'string') { + // note, this assumes classic YYYY-MM-DD (i.e. ISO?) format + var parts = date.split('-') + return new Date(parts[0], parseInt(parts[1]) - 1, parts[2]) + } + return date }, dateChanged(date) { - this.$emit('input', date) + this.$emit('input', this.formatDate(date)) }, focus() { From b40423fc2db66e8fb42c73821b917546f2847bbd Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 7 May 2024 20:44:26 -0500 Subject: [PATCH 1420/1681] Fix "view receiving row" page, per oruga all the buttons and tools *should* work correctly for Vue 2 and 3 now --- tailbone/templates/receiving/view_row.mako | 147 ++++++++++-------- .../templates/themes/butterball/base.mako | 24 +++ .../themes/butterball/field-components.mako | 83 ++++++++++ 3 files changed, 190 insertions(+), 64 deletions(-) diff --git a/tailbone/templates/receiving/view_row.mako b/tailbone/templates/receiving/view_row.mako index efc1883a..5077539c 100644 --- a/tailbone/templates/receiving/view_row.mako +++ b/tailbone/templates/receiving/view_row.mako @@ -161,8 +161,13 @@ </div> - <b-modal has-modal-card - :active.sync="accountForProductShowDialog"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="accountForProductShowDialog" + % else: + :active.sync="accountForProductShowDialog" + % endif + > <div class="modal-card"> <header class="modal-card-head"> @@ -212,18 +217,26 @@ </b-field> - <div class="level"> - <div class="level-left"> + <div style="display: flex; gap: 0.5rem; align-items: center;"> - <div class="level-item"> - <numeric-input v-model="accountForProductQuantity" - ref="accountForProductQuantityInput"> - </numeric-input> - </div> + <numeric-input v-model="accountForProductQuantity" + ref="accountForProductQuantityInput"> + </numeric-input> - <div class="level-item"> - % if allow_cases: - <b-field> + % if allow_cases: + % if request.use_oruga: + <div> + <o-button label="Units" + :variant="accountForProductUOM == 'units' ? 'primary' : null" + @click="accountForProductUOMClicked('units')" /> + <o-button label="Cases" + :variant="accountForProductUOM == 'cases' ? 'primary' : null" + @click="accountForProductUOMClicked('cases')" /> + </div> + % else: + <b-field + ## TODO: a bit hacky, but otherwise buefy styles throw us off here + style="margin-bottom: 0;"> <b-radio-button v-model="accountForProductUOM" @click.native="accountForProductUOMClicked('units')" native-value="units"> @@ -235,24 +248,17 @@ Cases </b-radio-button> </b-field> - % else: - <b-field> - <input type="hidden" v-model="accountForProductUOM" /> - Units - </b-field> % endif - </div> + <span v-if="accountForProductUOM == 'cases' && accountForProductQuantity"> + = {{ accountForProductTotalUnits }} + </span> - % if allow_cases: - <div class="level-item" - v-if="accountForProductUOM == 'cases' && accountForProductQuantity"> - = {{ accountForProductTotalUnits }} - </div> - % endif + % else: + <input type="hidden" v-model="accountForProductUOM" /> + <span>Units</span> + % endif - </div> </div> - </section> <footer class="modal-card-foot"> @@ -268,10 +274,15 @@ </b-button> </footer> </div> - </b-modal> + </${b}-modal> - <b-modal has-modal-card - :active.sync="declareCreditShowDialog"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="declareCreditShowDialog" + % else: + :active.sync="declareCreditShowDialog" + % endif + > <div class="modal-card"> <header class="modal-card-head"> @@ -319,47 +330,51 @@ </b-field> - <div class="level"> - <div class="level-left"> + <div style="display: flex; gap: 0.5rem; align-items: center;"> - <div class="level-item"> - <numeric-input v-model="declareCreditQuantity" - ref="declareCreditQuantityInput"> - </numeric-input> - </div> - - <div class="level-item"> - % if allow_cases: - <b-field> - <b-radio-button v-model="declareCreditUOM" - @click.native="declareCreditUOMClicked('units')" - native-value="units"> - Units - </b-radio-button> - <b-radio-button v-model="declareCreditUOM" - @click.native="declareCreditUOMClicked('cases')" - native-value="cases"> - Cases - </b-radio-button> - </b-field> - % else: - <b-field> - <input type="hidden" v-model="declareCreditUOM" /> - Units - </b-field> - % endif - </div> + <numeric-input v-model="declareCreditQuantity" + ref="declareCreditQuantityInput"> + </numeric-input> % if allow_cases: - <div class="level-item" - v-if="declareCreditUOM == 'cases' && declareCreditQuantity"> + + % if request.use_oruga: + <div> + <o-button label="Units" + :variant="declareCreditUOM == 'units' ? 'primary' : null" + @click="declareCreditUOM = 'units'" /> + <o-button label="Cases" + :variant="declareCreditUOM == 'cases' ? 'primary' : null" + @click="declareCreditUOM = 'cases'" /> + </div> + % else: + <b-field + ## TODO: a bit hacky, but otherwise buefy styles throw us off here + style="margin-bottom: 0;"> + <b-radio-button v-model="declareCreditUOM" + @click.native="declareCreditUOMClicked('units')" + native-value="units"> + Units + </b-radio-button> + <b-radio-button v-model="declareCreditUOM" + @click.native="declareCreditUOMClicked('cases')" + native-value="cases"> + Cases + </b-radio-button> + </b-field> + % endif + <span v-if="declareCreditUOM == 'cases' && declareCreditQuantity"> = {{ declareCreditTotalUnits }} - </div> + </span> + + % else: + <b-field> + <input type="hidden" v-model="declareCreditUOM" /> + Units + </b-field> % endif - </div> </div> - </section> <footer class="modal-card-foot"> @@ -375,7 +390,7 @@ </b-button> </footer> </div> - </b-modal> + </${b}-modal> <nav class="panel" > <p class="panel-heading">Credits</p> @@ -527,6 +542,10 @@ ThisPage.methods.accountForProductUOMClicked = function(uom) { + % if request.use_oruga: + this.accountForProductUOM = uom + % endif + // TODO: this does not seem to work as expected..even though // the code appears to be correct this.$nextTick(() => { diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako index 4b6c03f8..70a32342 100644 --- a/tailbone/templates/themes/butterball/base.mako +++ b/tailbone/templates/themes/butterball/base.mako @@ -264,6 +264,30 @@ padding-left: 10rem; } + /****************************** + * fix datepicker within modals + * TODO: someday this may not be necessary? cf. + * https://github.com/buefy/buefy/issues/292#issuecomment-347365637 + ******************************/ + + /* TODO: this does change some things, but does not actually work 100% */ + /* right for oruga 0.8.7 or 0.8.9 */ + + .modal .animation-content .modal-card { + overflow: visible !important; + } + + .modal-card-body { + overflow: visible !important; + } + + /* TODO: a simpler option we might try sometime instead? */ + /* cf. https://github.com/buefy/buefy/issues/292#issuecomment-1073851313 */ + + /* .dropdown-content{ */ + /* position: fixed; */ + /* } */ + </style> </%def> diff --git a/tailbone/templates/themes/butterball/field-components.mako b/tailbone/templates/themes/butterball/field-components.mako index fbd83421..8f9f884a 100644 --- a/tailbone/templates/themes/butterball/field-components.mako +++ b/tailbone/templates/themes/butterball/field-components.mako @@ -1,10 +1,93 @@ ## -*- coding: utf-8; -*- <%def name="make_field_components()"> + ${self.make_numeric_input_component()} ${self.make_tailbone_autocomplete_component()} ${self.make_tailbone_datepicker_component()} </%def> +<%def name="make_numeric_input_component()"> + <% request.register_component('numeric-input', 'NumericInput') %> + ${h.javascript_link(request.static_url('tailbone:static/js/numeric.js') + f'?ver={tailbone.__version__}')} + <script type="text/x-template" id="numeric-input-template"> + <o-input v-model="orugaValue" + @update:model-value="orugaValueUpdated" + ref="input" + :disabled="disabled" + :icon="icon" + :name="name" + :placeholder="placeholder" + :size="size" + /> + </script> + <script> + + const NumericInput = { + template: '#numeric-input-template', + + props: { + modelValue: [Number, String], + allowEnter: Boolean, + disabled: Boolean, + icon: String, + iconPack: String, // ignored + name: String, + placeholder: String, + size: String, + }, + + data() { + return { + orugaValue: this.modelValue, + inputElement: null, + } + }, + + watch: { + modelValue(to, from) { + this.orugaValue = to + }, + }, + + mounted() { + this.inputElement = this.$refs.input.$el.querySelector('input') + this.inputElement.addEventListener('keydown', this.keyDown) + }, + + beforeDestroy() { + this.inputElement.removeEventListener('keydown', this.keyDown) + }, + + methods: { + + focus() { + this.$refs.input.focus() + }, + + keyDown(event) { + // by default we only allow numeric keys, and general navigation + // keys, but we might also allow Enter key + if (!key_modifies(event) && !key_allowed(event)) { + if (!this.allowEnter || event.which != 13) { + event.preventDefault() + } + } + }, + + orugaValueUpdated(value) { + this.$emit('update:modelValue', value) + this.$emit('input', value) + }, + + select() { + this.$el.children[0].select() + }, + }, + } + + </script> +</%def> + <%def name="make_tailbone_autocomplete_component()"> <% request.register_component('tailbone-autocomplete', 'TailboneAutocomplete') %> <script type="text/x-template" id="tailbone-autocomplete-template"> From 9b65e18261d98609e8f5190c6830c09ca2313a23 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 8 May 2024 11:16:16 -0500 Subject: [PATCH 1421/1681] Tweak styles for grid action links, per butterball --- tailbone/templates/themes/butterball/base.mako | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako index 70a32342..c30e0156 100644 --- a/tailbone/templates/themes/butterball/base.mako +++ b/tailbone/templates/themes/butterball/base.mako @@ -229,6 +229,9 @@ } a.grid-action { + align-items: center; + display: flex; + gap: 0.1rem; white-space: nowrap; } From b65b514270d802840c9c8e97f3ccbf804f38a790 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 8 May 2024 11:17:58 -0500 Subject: [PATCH 1422/1681] Update changelog --- CHANGES.rst | 22 ++++++++++++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 5a02c648..5d1d2366 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,28 @@ CHANGELOG Unreleased ---------- +0.10.2 (2024-05-08) +------------------- + +* Fix employees grid when viewing department (per oruga). + +* Remove version restriction for pyramid_beaker dependency. + +* Fix login "enter" key behavior, per oruga. + +* Rename some attrs etc. for buefy components used with oruga. + +* Fix "tools" helper for receiving batch view, per oruga. + +* Fix button text for autocomplete. + +* More data type fixes for ``<tailbone-datepicker>``. + +* Fix "view receiving row" page, per oruga. + +* Tweak styles for grid action links, per butterball. + + 0.10.1 (2024-04-28) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 3ce74007..cc88ae5b 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.10.1' +__version__ = '0.10.2' From c43deb1307ae842861ee26cbe59b10cd667b971e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 8 May 2024 20:50:54 -0500 Subject: [PATCH 1423/1681] Fix bug with grid date filters --- tailbone/templates/grids/filter-components.mako | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tailbone/templates/grids/filter-components.mako b/tailbone/templates/grids/filter-components.mako index ff5ba8b7..e4915065 100644 --- a/tailbone/templates/grids/filter-components.mako +++ b/tailbone/templates/grids/filter-components.mako @@ -177,6 +177,9 @@ if (date === null) { return null } + if (typeof(date) == 'string') { + return date + } // just need to convert to simple ISO date format here, seems // like there should be a more obvious way to do that? var year = date.getFullYear() From 6bb6c16bc7f617ba686912ed1f4d9eecac4206ce Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 10 May 2024 15:00:21 -0500 Subject: [PATCH 1424/1681] Update changelogo --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 5d1d2366..a370a2a9 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,12 @@ CHANGELOG Unreleased ---------- +0.10.3 (2024-05-10) +------------------- + +* Fix bug with grid date filters. + + 0.10.2 (2024-05-08) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index cc88ae5b..1bfa81a7 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.10.2' +__version__ = '0.10.3' From 66304a418ea3b1e218c6957cb38f7e4dfda3aad3 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 10 May 2024 15:50:21 -0500 Subject: [PATCH 1425/1681] Fix styles for grid actions, per butterball --- tailbone/templates/themes/butterball/base.mako | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako index c30e0156..b62a8de2 100644 --- a/tailbone/templates/themes/butterball/base.mako +++ b/tailbone/templates/themes/butterball/base.mako @@ -230,7 +230,7 @@ a.grid-action { align-items: center; - display: flex; + display: inline-flex; gap: 0.1rem; white-space: nowrap; } From ec61444b3d8000ee887cdaab02b452bfec929692 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 12 May 2024 17:41:09 -0500 Subject: [PATCH 1426/1681] Update changelog --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index a370a2a9..4a85251e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,12 @@ CHANGELOG Unreleased ---------- +0.10.4 (2024-05-12) +------------------- + +* Fix styles for grid actions, per butterball. + + 0.10.3 (2024-05-10) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 1bfa81a7..a29d3dbe 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.10.3' +__version__ = '0.10.4' From fb9bc019392fae3caa5415fa99c30d00ce184cb3 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 14 May 2024 12:17:50 -0500 Subject: [PATCH 1427/1681] Add `<tailbone-timepicker>` component for oruga --- tailbone/forms/widgets.py | 10 +++ .../themes/butterball/field-components.mako | 66 +++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/tailbone/forms/widgets.py b/tailbone/forms/widgets.py index 6b74798c..c0bb0b4d 100644 --- a/tailbone/forms/widgets.py +++ b/tailbone/forms/widgets.py @@ -27,6 +27,7 @@ Form Widgets import json import datetime import decimal +import re import colander from deform import widget as dfwidget @@ -249,6 +250,8 @@ class FalafelDateTimeWidget(dfwidget.DateTimeInputWidget): """ template = 'datetime_falafel' + new_pattern = re.compile(r'^\d\d?:\d\d:\d\d [AP]M$') + def serialize(self, field, cstruct, **kw): """ """ readonly = kw.get('readonly', self.readonly) @@ -260,6 +263,13 @@ class FalafelDateTimeWidget(dfwidget.DateTimeInputWidget): """ """ if pstruct == '': return colander.null + + # nb. we now allow '4:20:00 PM' on the widget side, but the + # true node needs it to be '16:20:00' instead + if self.new_pattern.match(pstruct['time']): + time = datetime.datetime.strptime(pstruct['time'], '%I:%M:%S %p') + pstruct['time'] = time.strftime('%H:%M:%S') + return pstruct diff --git a/tailbone/templates/themes/butterball/field-components.mako b/tailbone/templates/themes/butterball/field-components.mako index 8f9f884a..8c1d1d70 100644 --- a/tailbone/templates/themes/butterball/field-components.mako +++ b/tailbone/templates/themes/butterball/field-components.mako @@ -4,6 +4,7 @@ ${self.make_numeric_input_component()} ${self.make_tailbone_autocomplete_component()} ${self.make_tailbone_datepicker_component()} + ${self.make_tailbone_timepicker_component()} </%def> <%def name="make_numeric_input_component()"> @@ -461,3 +462,68 @@ </script> </%def> + +<%def name="make_tailbone_timepicker_component()"> + <% request.register_component('tailbone-timepicker', 'TailboneTimepicker') %> + <script type="text/x-template" id="tailbone-timepicker-template"> + <o-timepicker :name="name" + v-model="orugaValue" + @update:model-value="orugaValueUpdated" + placeholder="Click to select ..." + icon="clock" + hour-format="12" + :time-formatter="formatTime" /> + </script> + <script> + + const TailboneTimepicker = { + template: '#tailbone-timepicker-template', + + props: { + modelValue: [Date, String], + name: String, + }, + + data() { + return { + orugaValue: this.parseTime(this.modelValue), + } + }, + + watch: { + modelValue(to, from) { + this.orugaValue = this.parseTime(to) + }, + }, + + methods: { + + formatTime(value) { + if (!value) { + return null + } + + return value.toLocaleTimeString('en-US') + }, + + parseTime(value) { + + if (value.getHours) { + return value + } + + let found = value.match(/^(\d\d):(\d\d):\d\d$/) + if (found) { + return new Date(null, null, null, + parseInt(found[1]), parseInt(found[2])) + } + }, + + orugaValueUpdated(value) { + this.$emit('update:modelValue', value) + }, + }, + } + + </script> +</%def> From f8ab8d462c07510b49646449443c4ad79aa33a8c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 29 May 2024 09:40:50 -0500 Subject: [PATCH 1428/1681] Update changelog --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 4a85251e..92d088b0 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,12 @@ CHANGELOG Unreleased ---------- +0.10.5 (2024-05-29) +------------------- + +* Add ``<tailbone-timepicker>`` component for oruga. + + 0.10.4 (2024-05-12) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index a29d3dbe..07f1c1c4 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.10.4' +__version__ = '0.10.5' From 4ccdf99a43e9659bc3c3192cb05d7d9c3741fa34 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 29 May 2024 15:47:04 -0500 Subject: [PATCH 1429/1681] Add way to flag organic products within lookup dialog --- tailbone/templates/base.mako | 4 +++- tailbone/templates/base_meta.mako | 2 ++ tailbone/templates/products/lookup.mako | 6 ++++-- tailbone/views/products.py | 20 ++++++++++++++++---- 4 files changed, 25 insertions(+), 7 deletions(-) diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index 44c7dd0f..1554d15d 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -171,7 +171,9 @@ % endif </%def> -<%def name="extra_styles()"></%def> +<%def name="extra_styles()"> + ${base_meta.extra_styles()} +</%def> <%def name="head_tags()"></%def> diff --git a/tailbone/templates/base_meta.mako b/tailbone/templates/base_meta.mako index 568782b7..07b13e61 100644 --- a/tailbone/templates/base_meta.mako +++ b/tailbone/templates/base_meta.mako @@ -4,6 +4,8 @@ <%def name="global_title()">${"[STAGE] " if not request.rattail_config.production() else ''}${self.app_title()}</%def> +<%def name="extra_styles()"></%def> + <%def name="favicon()"> <link rel="icon" type="image/x-icon" href="${request.rattail_config.get('tailbone', 'favicon_url', default=request.static_url('tailbone:static/img/rattail.ico'))}" /> </%def> diff --git a/tailbone/templates/products/lookup.mako b/tailbone/templates/products/lookup.mako index 52b06e88..bf8e7ef5 100644 --- a/tailbone/templates/products/lookup.mako +++ b/tailbone/templates/products/lookup.mako @@ -109,8 +109,10 @@ <b-table-column label="Description" field="description" v-slot="props"> - {{ props.row.description }} - {{ props.row.size }} + <span :class="{organic: props.row.organic}"> + {{ props.row.description }} + {{ props.row.size }} + </span> </b-table-column> <b-table-column label="Unit Price" diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 788cc24d..7219b6b3 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -33,7 +33,8 @@ from sqlalchemy import orm import sqlalchemy_continuum as continuum from rattail import enum, pod, sil -from rattail.db import model, api, auth, Session as RattailSession +from rattail.db import api, auth, Session as RattailSession +from rattail.db.model import Product, PendingProduct, CustomerOrderItem from rattail.gpc import GPC from rattail.threads import Thread from rattail.exceptions import LabelPrintingError @@ -74,7 +75,7 @@ class ProductView(MasterView): """ Master view for the Product class. """ - model_class = model.Product + model_class = Product has_versions = True results_downloadable_xlsx = True supports_autocomplete = True @@ -173,6 +174,7 @@ class ProductView(MasterView): def query(self, session): query = super().query(session) + model = self.model if not self.has_perm('view_deleted'): query = query.filter(model.Product.deleted == False) @@ -685,6 +687,7 @@ class ProductView(MasterView): return data def get_instance(self): + model = self.model key = self.request.matchdict['uuid'] product = self.Session.get(model.Product, key) if product: @@ -696,6 +699,7 @@ class ProductView(MasterView): def configure_form(self, f): super().configure_form(f) + model = self.model product = f.model_instance # unit_size @@ -1396,6 +1400,7 @@ class ProductView(MasterView): product's regular price history. """ app = self.get_rattail_app() + model = self.model Transaction = continuum.transaction_class(model.Product) ProductVersion = continuum.version_class(model.Product) ProductPriceVersion = continuum.version_class(model.ProductPrice) @@ -1466,6 +1471,7 @@ class ProductView(MasterView): product's current price history. """ app = self.get_rattail_app() + model = self.model Transaction = continuum.transaction_class(model.Product) ProductVersion = continuum.version_class(model.Product) ProductPriceVersion = continuum.version_class(model.ProductPrice) @@ -1609,6 +1615,7 @@ class ProductView(MasterView): product's SRP history. """ app = self.get_rattail_app() + model = self.model Transaction = continuum.transaction_class(model.Product) ProductVersion = continuum.version_class(model.Product) ProductPriceVersion = continuum.version_class(model.ProductPrice) @@ -1679,6 +1686,7 @@ class ProductView(MasterView): product's cost history. """ app = self.get_rattail_app() + model = self.model Transaction = continuum.transaction_class(model.Product) ProductVersion = continuum.version_class(model.Product) ProductCostVersion = continuum.version_class(model.ProductCost) @@ -1746,6 +1754,7 @@ class ProductView(MasterView): 'form': form}) def get_version_child_classes(self): + model = self.model return [ (model.ProductCode, 'product_uuid'), (model.ProductCost, 'product_uuid'), @@ -1893,6 +1902,7 @@ class ProductView(MasterView): 'case_price', 'case_price_display', 'uom_choices', + 'organic', ]) # TODO: deprecate / remove this? not sure if/where it is used @@ -1904,6 +1914,7 @@ class ProductView(MasterView): Eventually this should be more generic, or at least offer more fields for search. For now it operates only on the ``Product.upc`` field. """ + model = self.model data = None upc = self.request.GET.get('upc', '').strip() upc = re.sub(r'\D', '', upc) @@ -2091,6 +2102,7 @@ class ProductView(MasterView): """ Threat target for making a batch from current products query. """ + model = self.model session = RattailSession() user = session.get(model.User, user_uuid) assert user @@ -2231,7 +2243,7 @@ class PendingProductView(MasterView): """ Master view for the Pending Product class. """ - model_class = model.PendingProduct + model_class = PendingProduct route_prefix = 'pending_products' url_prefix = '/products/pending' bulk_deletable = True @@ -2278,7 +2290,7 @@ class PendingProductView(MasterView): ] has_rows = True - model_row_class = model.CustomerOrderItem + model_row_class = CustomerOrderItem rows_title = "Customer Orders" # TODO: add support for this someday rows_viewable = False From 9a841ba5e2df5d8862f040912b9e6b6d086f064a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 29 May 2024 16:33:30 -0500 Subject: [PATCH 1430/1681] Expose db picker for butterball theme --- .../templates/themes/butterball/base.mako | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako index b62a8de2..f57c3257 100644 --- a/tailbone/templates/themes/butterball/base.mako +++ b/tailbone/templates/themes/butterball/base.mako @@ -715,26 +715,22 @@ % endif % endif - ## % if expose_db_picker is not Undefined and expose_db_picker: - ## <div class="level-item"> - ## <p>DB:</p> - ## </div> - ## <div class="level-item"> - ## ${h.form(url('change_db_engine'), ref='dbPickerForm')} - ## ${h.csrf_token(request)} - ## ${h.hidden('engine_type', value=master.engine_type_key)} - ## <b-select name="dbkey" - ## value="${db_picker_selected}" - ## @input="changeDB()"> - ## % for option in db_picker_options: - ## <option value="${option.value}"> - ## ${option.label} - ## </option> - ## % endfor - ## </b-select> - ## ${h.end_form()} - ## </div> - ## % endif + % if expose_db_picker is not Undefined and expose_db_picker: + <span>DB:</span> + ${h.form(url('change_db_engine'), ref='dbPickerForm')} + ${h.csrf_token(request)} + ${h.hidden('engine_type', value=master.engine_type_key)} + <b-select name="dbkey" + v-model="dbSelected" + @input="changeDB()"> + % for option in db_picker_options: + <option value="${option.value}"> + ${option.label} + </option> + % endfor + </b-select> + ${h.end_form()} + % endif </div> @@ -1104,6 +1100,10 @@ globalSearchData: ${json.dumps(global_search_data)|n}, mountedHooks: [], + % if expose_db_picker is not Undefined and expose_db_picker: + dbSelected: ${json.dumps(db_picker_selected)|n}, + % endif + % if expose_theme_picker and request.has_perm('common.change_app_theme'): globalTheme: ${json.dumps(theme)|n}, % endif From b98d651144d2918cc948f18a68b2a704de0f8906 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 29 May 2024 16:55:42 -0500 Subject: [PATCH 1431/1681] Expose quickie lookup for butterball theme --- .../templates/themes/butterball/base.mako | 39 +++++++------------ 1 file changed, 14 insertions(+), 25 deletions(-) diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako index f57c3257..8a951831 100644 --- a/tailbone/templates/themes/butterball/base.mako +++ b/tailbone/templates/themes/butterball/base.mako @@ -736,31 +736,20 @@ <div style="display: flex; gap: 0.5rem;"> -## ## Quickie Lookup -## % if quickie is not Undefined and quickie and request.has_perm(quickie.perm): -## <div class="level-item"> -## ${h.form(quickie.url, method="get")} -## <div class="level"> -## <div class="level-right"> -## <div class="level-item"> -## <b-input name="entry" -## placeholder="${quickie.placeholder}" -## autocomplete="off"> -## </b-input> -## </div> -## <div class="level-item"> -## <button type="submit" class="button is-primary"> -## <span class="icon is-small"> -## <i class="fas fa-search"></i> -## </span> -## <span>Lookup</span> -## </button> -## </div> -## </div> -## </div> -## ${h.end_form()} -## </div> -## % endif + ## Quickie Lookup + % if quickie is not Undefined and quickie and request.has_perm(quickie.perm): + ${h.form(quickie.url, method='get', style='display: flex; gap: 0.5rem; margin-right: 1rem;')} + <b-input name="entry" + placeholder="${quickie.placeholder}" + autocomplete="off"> + </b-input> + <o-button variant="primary" + native-type="submit" + icon-left="search"> + Lookup + </o-button> + ${h.end_form()} + % endif % if master and master.configurable and master.has_perm('configure'): % if not request.matched_route.name.endswith('.configure'): From 54b75dbe1a7b9bfaf02f6e2d082e738289c2363f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 29 May 2024 20:47:10 -0500 Subject: [PATCH 1432/1681] Fix basic problems with people profile view, per butterball plenty more tweaks needed yet i'm sure, but page looks reasonable now at least --- tailbone/templates/people/view_profile.mako | 428 ++++++++++++------ .../themes/butterball/buefy-components.mako | 9 +- 2 files changed, 288 insertions(+), 149 deletions(-) diff --git a/tailbone/templates/people/view_profile.mako b/tailbone/templates/people/view_profile.mako index 65c96fd6..0ca42cef 100644 --- a/tailbone/templates/people/view_profile.mako +++ b/tailbone/templates/people/view_profile.mako @@ -115,8 +115,13 @@ Edit Name </b-button> </div> - <b-modal has-modal-card - :active.sync="editNameShowDialog"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="editNameShowDialog" + % else: + :active.sync="editNameShowDialog" + % endif + > <div class="modal-card"> <header class="modal-card-head"> @@ -125,49 +130,52 @@ <section class="modal-card-body"> - <b-field grouped> - - <b-field label="First Name" expanded> - <b-input v-model.trim="editNameFirst" - :maxlength="maxLengths.person_first_name || null"> - </b-input> - </b-field> - - % if use_preferred_first_name: + % if use_preferred_first_name: + <b-field grouped> + <b-field label="First Name"> + <b-input v-model.trim="editNameFirst" + :maxlength="maxLengths.person_first_name || null" /> + </b-field> <b-field label="Preferred First Name" expanded> <b-input v-model.trim="editNameFirstPreferred" :maxlength="maxLengths.person_preferred_first_name || null"> </b-input> </b-field> - % endif - - </b-field> + </b-field> + % else: + <b-field label="First Name"> + <b-input v-model.trim="editNameFirst" + :maxlength="maxLengths.person_first_name || null" + expanded /> + </b-field> + % endif <b-field label="Middle Name"> <b-input v-model.trim="editNameMiddle" - :maxlength="maxLengths.person_middle_name || null"> - </b-input> + :maxlength="maxLengths.person_middle_name || null" + expanded /> </b-field> <b-field label="Last Name"> <b-input v-model.trim="editNameLast" - :maxlength="maxLengths.person_last_name || null"> - </b-input> + :maxlength="maxLengths.person_last_name || null" + expanded /> </b-field> </section> <footer class="modal-card-foot"> - <once-button type="is-primary" - @click="editNameSave()" - :disabled="editNameSaveDisabled" - icon-left="save" - text="Save"> - </once-button> + <b-button type="is-primary" + @click="editNameSave()" + :disabled="editNameSaveDisabled" + icon-pack="fas" + icon-left="save"> + {{ editNameSaving ? "Working, please wait..." : "Save" }} + </b-button> <b-button @click="editNameShowDialog = false"> Cancel </b-button> </footer> </div> - </b-modal> + </${b}-modal> % endif </div> </div> @@ -219,8 +227,13 @@ icon-left="edit"> Edit Address </b-button> - <b-modal has-modal-card - :active.sync="editAddressShowDialog"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="editAddressShowDialog" + % else: + :active.sync="editAddressShowDialog" + % endif + > <div class="modal-card"> <header class="modal-card-head"> @@ -231,20 +244,20 @@ <b-field label="Street 1" expanded> <b-input v-model.trim="editAddressStreet1" - :maxlength="maxLengths.address_street || null"> - </b-input> + :maxlength="maxLengths.address_street || null" + expanded /> </b-field> <b-field label="Street 2" expanded> <b-input v-model.trim="editAddressStreet2" - :maxlength="maxLengths.address_street2 || null"> - </b-input> + :maxlength="maxLengths.address_street2 || null" + expanded /> </b-field> <b-field label="Zipcode"> <b-input v-model.trim="editAddressZipcode" - :maxlength="maxLengths.address_zipcode || null"> - </b-input> + :maxlength="maxLengths.address_zipcode || null" + expanded /> </b-field> <b-field grouped> @@ -280,7 +293,7 @@ </b-button> </footer> </div> - </b-modal> + </${b}-modal> % endif </div> </div> @@ -312,8 +325,13 @@ Add Phone </b-button> </div> - <b-modal has-modal-card - :active.sync="editPhoneShowDialog"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="editPhoneShowDialog" + % else: + :active.sync="editPhoneShowDialog" + % endif + > <div class="modal-card"> <header class="modal-card-head"> @@ -362,50 +380,64 @@ </b-button> </footer> </div> - </b-modal> + </${b}-modal> % endif - <b-table :data="person.phones"> + <${b}-table :data="person.phones"> - <b-table-column field="preference" + <${b}-table-column field="preference" label="Preferred" v-slot="props"> {{ props.row.preferred ? "Yes" : "" }} - </b-table-column> + </${b}-table-column> - <b-table-column field="type" + <${b}-table-column field="type" label="Type" v-slot="props"> {{ props.row.type }} - </b-table-column> + </${b}-table-column> - <b-table-column field="number" + <${b}-table-column field="number" label="Number" v-slot="props"> {{ props.row.number }} - </b-table-column> + </${b}-table-column> % if request.has_perm('people_profile.edit_person'): - <b-table-column label="Actions" + <${b}-table-column label="Actions" v-slot="props"> - <a href="#" @click.prevent="editPhoneInit(props.row)"> - <i class="fas fa-edit"></i> + <a class="grid-action" + href="#" @click.prevent="editPhoneInit(props.row)"> + % if request.use_oruga: + <o-icon icon="edit" /> + % else: + <i class="fas fa-edit"></i> + % endif Edit </a> - <a href="#" @click.prevent="deletePhoneInit(props.row)" - class="has-text-danger"> - <i class="fas fa-trash"></i> + <a class="grid-action has-text-danger" + href="#" @click.prevent="deletePhoneInit(props.row)"> + % if request.use_oruga: + <o-icon icon="trash" /> + % else: + <i class="fas fa-trash"></i> + % endif Delete </a> - <a href="#" @click.prevent="preferPhoneInit(props.row)" + <a class="grid-action" + href="#" @click.prevent="preferPhoneInit(props.row)" v-if="!props.row.preferred"> - <i class="fas fa-star"></i> + % if request.use_oruga: + <o-icon icon="star" /> + % else: + <i class="fas fa-star"></i> + % endif Set Preferred </a> - </b-table-column> + </${b}-table-column> % endif - </b-table> + </${b}-table> </div> </div> @@ -429,8 +461,13 @@ Add Email </b-button> </div> - <b-modal has-modal-card - :active.sync="editEmailShowDialog"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="editEmailShowDialog" + % else: + :active.sync="editEmailShowDialog" + % endif + > <div class="modal-card"> <header class="modal-card-head"> @@ -488,56 +525,70 @@ </b-button> </footer> </div> - </b-modal> + </${b}-modal> % endif - <b-table :data="person.emails"> + <${b}-table :data="person.emails"> - <b-table-column field="preference" + <${b}-table-column field="preference" label="Preferred" v-slot="props"> {{ props.row.preferred ? "Yes" : "" }} - </b-table-column> + </${b}-table-column> - <b-table-column field="type" + <${b}-table-column field="type" label="Type" v-slot="props"> {{ props.row.type }} - </b-table-column> + </${b}-table-column> - <b-table-column field="address" + <${b}-table-column field="address" label="Address" v-slot="props"> {{ props.row.address }} - </b-table-column> + </${b}-table-column> - <b-table-column field="invalid" + <${b}-table-column field="invalid" label="Invalid?" v-slot="props"> <span v-if="props.row.invalid" class="has-text-danger has-text-weight-bold">Invalid</span> - </b-table-column> + </${b}-table-column> % if request.has_perm('people_profile.edit_person'): - <b-table-column label="Actions" + <${b}-table-column label="Actions" v-slot="props"> - <a href="#" @click.prevent="editEmailInit(props.row)"> - <i class="fas fa-edit"></i> + <a class="grid-action" + href="#" @click.prevent="editEmailInit(props.row)"> + % if request.use_oruga: + <o-icon icon="edit" /> + % else: + <i class="fas fa-edit"></i> + % endif Edit </a> - <a href="#" @click.prevent="deleteEmailInit(props.row)" - class="has-text-danger"> - <i class="fas fa-trash"></i> + <a class="grid-action has-text-danger" + href="#" @click.prevent="deleteEmailInit(props.row)"> + % if request.use_oruga: + <o-icon icon="trash" /> + % else: + <i class="fas fa-trash"></i> + % endif Delete </a> - <a href="#" @click.prevent="preferEmailInit(props.row)" + <a class="grid-action" + href="#" @click.prevent="preferEmailInit(props.row)" v-if="!props.row.preferred"> - <i class="fas fa-star"></i> + % if request.use_oruga: + <o-icon icon="star" /> + % else: + <i class="fas fa-star"></i> + % endif Set Preferred </a> - </b-table-column> + </${b}-table-column> % endif - </b-table> + </${b}-table> </div> </div> @@ -566,16 +617,22 @@ </b-button> % endif </div> - <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading> + % if request.use_oruga: + <o-loading v-model:active="refreshingTab" :full-page="false"></o-loading> + % else: + <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading> + % endif </div> </script> </%def> <%def name="render_personal_tab()"> - <b-tab-item label="Personal" - value="personal" - icon-pack="fas" - :icon="tabchecks.personal ? 'check' : null"> + <${b}-tab-item label="Personal" + value="personal" + % if not request.use_oruga: + icon-pack="fas" + % endif + :icon="tabchecks.personal ? 'check' : null"> <personal-tab ref="tab_personal" :person="person" @profile-changed="profileChanged" @@ -583,7 +640,7 @@ :email-type-options="emailTypeOptions" :max-lengths="maxLengths"> </personal-tab> - </b-tab-item> + </${b}-tab-item> </%def> <%def name="render_member_tab_template()"> @@ -692,13 +749,17 @@ </div> % endif - <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading> + % if request.use_oruga: + <o-loading v-model:active="refreshingTab" :full-page="false"></o-loading> + % else: + <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading> + % endif </div> </script> </%def> <%def name="render_member_tab()"> - <b-tab-item label="Member" + <${b}-tab-item label="Member" value="member" icon-pack="fas" :icon="tabchecks.member ? 'check' : null"> @@ -707,7 +768,7 @@ @profile-changed="profileChanged" :phone-type-options="phoneTypeOptions"> </member-tab> - </b-tab-item> + </${b}-tab-item> </%def> <%def name="render_customer_tab_template()"> @@ -814,13 +875,17 @@ <div v-if="!customers.length"> <p>{{ person.display_name }} does not have a customer account.</p> </div> - <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading> + % if request.use_oruga: + <o-loading v-model:active="refreshingTab" :full-page="false"></o-loading> + % else: + <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading> + % endif </div> </script> </%def> <%def name="render_customer_tab()"> - <b-tab-item label="Customer" + <${b}-tab-item label="Customer" value="customer" icon-pack="fas" :icon="tabchecks.customer ? 'check' : null"> @@ -828,7 +893,7 @@ :person="person" @profile-changed="profileChanged"> </customer-tab> - </b-tab-item> + </${b}-tab-item> </%def> <%def name="render_shopper_tab_template()"> @@ -890,13 +955,17 @@ <div v-if="!shoppers.length"> <p>{{ person.display_name }} is not a shopper.</p> </div> - <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading> + % if request.use_oruga: + <o-loading v-model:active="refreshingTab" :full-page="false"></o-loading> + % else: + <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading> + % endif </div> </script> </%def> <%def name="render_shopper_tab()"> - <b-tab-item label="Shopper" + <${b}-tab-item label="Shopper" value="shopper" icon-pack="fas" :icon="tabchecks.shopper ? 'check' : null"> @@ -904,7 +973,7 @@ :person="person" @profile-changed="profileChanged"> </shopper-tab> - </b-tab-item> + </${b}-tab-item> </%def> <%def name="render_employee_tab_template()"> @@ -930,8 +999,13 @@ @click="editEmployeeIdInit()"> Edit ID </b-button> - <b-modal has-modal-card - :active.sync="editEmployeeIdShowDialog"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="editEmployeeIdShowDialog" + % else: + :active.sync="editEmployeeIdShowDialog" + % endif + > <div class="modal-card"> <header class="modal-card-head"> @@ -957,7 +1031,7 @@ </b-button> </footer> </div> - </b-modal> + </${b}-modal> </div> % endif </div> @@ -980,32 +1054,32 @@ <p><strong>Employee History</strong></p> <br /> - <b-table :data="employeeHistory"> + <${b}-table :data="employeeHistory"> - <b-table-column field="start_date" + <${b}-table-column field="start_date" label="Start Date" v-slot="props"> {{ props.row.start_date }} - </b-table-column> + </${b}-table-column> - <b-table-column field="end_date" + <${b}-table-column field="end_date" label="End Date" v-slot="props"> {{ props.row.end_date }} - </b-table-column> + </${b}-table-column> % if request.has_perm('people_profile.edit_employee_history'): - <b-table-column field="actions" + <${b}-table-column field="actions" label="Actions" v-slot="props"> <a href="#" @click.prevent="editEmployeeHistoryInit(props.row)"> <i class="fas fa-edit"></i> Edit </a> - </b-table-column> + </${b}-table-column> % endif - </b-table> + </${b}-table> </div> @@ -1032,8 +1106,13 @@ ${person} is no longer an Employee </b-button> - <b-modal has-modal-card - :active.sync="startEmployeeShowDialog"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="startEmployeeShowDialog" + % else: + :active.sync="startEmployeeShowDialog" + % endif + > <div class="modal-card"> <header class="modal-card-head"> @@ -1060,10 +1139,15 @@ </once-button> </footer> </div> - </b-modal> + </${b}-modal> - <b-modal has-modal-card - :active.sync="stopEmployeeShowDialog"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="stopEmployeeShowDialog" + % else: + :active.sync="stopEmployeeShowDialog" + % endif + > <div class="modal-card"> <header class="modal-card-head"> @@ -1092,12 +1176,17 @@ </once-button> </footer> </div> - </b-modal> + </${b}-modal> % endif % if request.has_perm('people_profile.edit_employee_history'): - <b-modal has-modal-card - :active.sync="editEmployeeHistoryShowDialog"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="editEmployeeHistoryShowDialog" + % else: + :active.sync="editEmployeeHistoryShowDialog" + % endif + > <div class="modal-card"> <header class="modal-card-head"> @@ -1126,7 +1215,7 @@ </once-button> </footer> </div> - </b-modal> + </${b}-modal> % endif % if request.has_perm('employees.view'): @@ -1140,13 +1229,17 @@ </div> </div> - <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading> + % if request.use_oruga: + <o-loading v-model:active="refreshingTab" :full-page="false"></o-loading> + % else: + <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading> + % endif </div> </script> </%def> <%def name="render_employee_tab()"> - <b-tab-item label="Employee" + <${b}-tab-item label="Employee" value="employee" icon-pack="fas" :icon="tabchecks.employee ? 'check' : null"> @@ -1154,7 +1247,7 @@ :person="person" @profile-changed="profileChanged"> </employee-tab> - </b-tab-item> + </${b}-tab-item> </%def> <%def name="render_notes_tab_template()"> @@ -1171,40 +1264,40 @@ </b-button> % endif - <b-table :data="notes"> + <${b}-table :data="notes"> - <b-table-column field="note_type" + <${b}-table-column field="note_type" label="Type" v-slot="props"> {{ props.row.note_type_display }} - </b-table-column> + </${b}-table-column> - <b-table-column field="subject" + <${b}-table-column field="subject" label="Subject" v-slot="props"> {{ props.row.subject }} - </b-table-column> + </${b}-table-column> - <b-table-column field="text" + <${b}-table-column field="text" label="Text" v-slot="props"> {{ props.row.text }} - </b-table-column> + </${b}-table-column> - <b-table-column field="created" + <${b}-table-column field="created" label="Created" v-slot="props"> <span v-html="props.row.created_display"></span> - </b-table-column> + </${b}-table-column> - <b-table-column field="created_by" + <${b}-table-column field="created_by" label="Created By" v-slot="props"> {{ props.row.created_by_display }} - </b-table-column> + </${b}-table-column> % if request.has_any_perm('people_profile.edit_note', 'people_profile.delete_note'): - <b-table-column label="Actions" + <${b}-table-column label="Actions" v-slot="props"> % if request.has_perm('people_profile.edit_note'): <a href="#" @click.prevent="editNoteInit(props.row)"> @@ -1219,14 +1312,19 @@ Delete </a> % endif - </b-table-column> + </${b}-table-column> % endif - </b-table> + </${b}-table> % if request.has_any_perm('people_profile.add_note', 'people_profile.edit_note', 'people_profile.delete_note'): - <b-modal :active.sync="editNoteShowDialog" - has-modal-card> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="editNoteShowDialog" + % else: + :active.sync="editNoteShowDialog" + % endif + > <div class="modal-card"> @@ -1285,16 +1383,20 @@ </footer> </div> - </b-modal> + </${b}-modal> % endif - <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading> + % if request.use_oruga: + <o-loading v-model:active="refreshingTab" :full-page="false"></o-loading> + % else: + <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading> + % endif </div> </script> </%def> <%def name="render_notes_tab()"> - <b-tab-item label="Notes" + <${b}-tab-item label="Notes" value="notes" icon-pack="fas" :icon="tabchecks.notes ? 'check' : null"> @@ -1302,7 +1404,7 @@ :person="person" @profile-changed="profileChanged"> </notes-tab> - </b-tab-item> + </${b}-tab-item> </%def> <%def name="render_user_tab_template()"> @@ -1355,13 +1457,17 @@ <div v-if="!users.length"> <p>{{ person.display_name }} does not have a user account.</p> </div> - <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading> + % if request.use_oruga: + <o-loading v-model:active="refreshingTab" :full-page="false"></o-loading> + % else: + <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading> + % endif </div> </script> </%def> <%def name="render_user_tab()"> - <b-tab-item label="User" + <${b}-tab-item label="User" value="user" icon-pack="fas" :icon="tabchecks.user ? 'check' : null"> @@ -1369,7 +1475,7 @@ :person="person" @profile-changed="profileChanged"> </user-tab> - </b-tab-item> + </${b}-tab-item> </%def> <%def name="render_profile_tabs()"> @@ -1392,14 +1498,20 @@ ${self.render_profile_info_extra_buttons()} - <b-tabs v-model="activeTab" - % if request.has_perm('people_profile.view_versions'): - v-show="!viewingHistory" - % endif - type="is-boxed" - @input="activeTabChanged"> + <${b}-tabs v-model="activeTab" + % if request.has_perm('people_profile.view_versions'): + v-show="!viewingHistory" + % endif + % if request.use_oruga: + type="boxed" + @change="activeTabChanged" + % else: + type="is-boxed" + @input="activeTabChanged" + % endif + > ${self.render_profile_tabs()} - </b-tabs> + </${b}-tabs> % if request.has_perm('people_profile.view_versions'): @@ -1408,7 +1520,13 @@ vshow='viewingHistory', loading='gettingRevisions')|n} - <b-modal :active.sync="showingRevisionDialog"> + <${b}-modal + % if request.use_oruga: + v-model:active="showingRevisionDialog" + % else: + :active.sync="showingRevisionDialog" + % endif + > <div class="card"> <div class="card-content"> @@ -1487,7 +1605,7 @@ </div> </div> - </b-modal> + </${b}-modal> % endif </div> @@ -1522,6 +1640,7 @@ % endif editNameMiddle: null, editNameLast: null, + editNameSaving: false, editAddressShowDialog: false, editAddressStreet1: null, @@ -1562,6 +1681,9 @@ % if request.has_perm('people_profile.edit_person'): editNameSaveDisabled: function() { + if (this.editNameSaving) { + return true + } if (!this.editNameFirst || !this.editNameLast) { return true } @@ -1622,6 +1744,7 @@ }, editNameSave() { + this.editNameSaving = true let url = '${url('people.profile_edit_name', uuid=person.uuid)}' let params = { first_name: this.editNameFirst, @@ -1636,6 +1759,9 @@ this.$emit('profile-changed', response.data) this.editNameShowDialog = false this.refreshTab() + this.editNameSaving = false + }, response => { + this.editNameSaving = false }) }, @@ -1827,6 +1953,7 @@ PersonalTab.data = function() { return PersonalTabData } Vue.component('personal-tab', PersonalTab) + <% request.register_component('personal-tab', 'PersonalTab') %> </script> </%def> @@ -1872,6 +1999,7 @@ MemberTab.data = function() { return MemberTabData } Vue.component('member-tab', MemberTab) + <% request.register_component('member-tab', 'MemberTab') %> </script> </%def> @@ -1908,6 +2036,7 @@ CustomerTab.data = function() { return CustomerTabData } Vue.component('customer-tab', CustomerTab) + <% request.register_component('customer-tab', 'CustomerTab') %> </script> </%def> @@ -1944,6 +2073,7 @@ ShopperTab.data = function() { return ShopperTabData } Vue.component('shopper-tab', ShopperTab) + <% request.register_component('shopper-tab', 'ShopperTab') %> </script> </%def> @@ -2100,6 +2230,7 @@ EmployeeTab.data = function() { return EmployeeTabData } Vue.component('employee-tab', EmployeeTab) + <% request.register_component('employee-tab', 'EmployeeTab') %> </script> </%def> @@ -2220,6 +2351,7 @@ NotesTab.data = function() { return NotesTabData } Vue.component('notes-tab', NotesTab) + <% request.register_component('notes-tab', 'NotesTab') %> </script> </%def> @@ -2256,6 +2388,7 @@ UserTab.data = function() { return UserTabData } Vue.component('user-tab', UserTab) + <% request.register_component('user-tab', 'UserTab') %> </script> </%def> @@ -2264,7 +2397,7 @@ <script type="text/javascript"> let ProfileInfoData = { - activeTab: location.hash ? location.hash.substring(1) : undefined, + activeTab: location.hash ? location.hash.substring(1) : 'personal', tabchecks: ${json.dumps(tabchecks)|n}, today: '${rattail_app.today()}', profileLastChanged: Date.now(), @@ -2360,6 +2493,7 @@ ProfileInfo.data = function() { return ProfileInfoData } Vue.component('profile-info', ProfileInfo) + <% request.register_component('profile-info', 'ProfileInfo') %> </script> </%def> diff --git a/tailbone/templates/themes/butterball/buefy-components.mako b/tailbone/templates/themes/butterball/buefy-components.mako index a7b42267..b11e34ea 100644 --- a/tailbone/templates/themes/butterball/buefy-components.mako +++ b/tailbone/templates/themes/butterball/buefy-components.mako @@ -332,7 +332,8 @@ v-model="orugaValue" @update:modelValue="val => $emit('update:modelValue', val)" :autocomplete="autocomplete" - ref="input"> + ref="input" + :expanded="expanded"> <slot /> </o-input> </script> @@ -344,6 +345,7 @@ type: String, autocomplete: String, disabled: Boolean, + expanded: Boolean, }, data() { return { @@ -374,13 +376,16 @@ <%def name="make_b_loading_component()"> <script type="text/x-template" id="b-loading-template"> - <o-loading> + <o-loading :full-page="isFullPage"> <slot /> </o-loading> </script> <script> const BLoading = { template: '#b-loading-template', + props: { + isFullPage: Boolean, + }, } </script> <% request.register_component('b-loading', 'BLoading') %> From 0d8928bdf57c058b0f09756c577a43276df7dabf Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 29 May 2024 22:15:39 -0500 Subject: [PATCH 1433/1681] Update changelog --- CHANGES.rst | 12 ++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 92d088b0..3f2b16aa 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,18 @@ CHANGELOG Unreleased ---------- +0.10.6 (2024-05-29) +------------------- + +* Add way to flag organic products within lookup dialog. + +* Expose db picker for butterball theme. + +* Expose quickie lookup for butterball theme. + +* Fix basic problems with people profile view, per butterball. + + 0.10.5 (2024-05-29) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 07f1c1c4..711da994 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.10.5' +__version__ = '0.10.6' From 49cd05027299424696c79ab32f008e201f428246 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 31 May 2024 10:57:28 -0500 Subject: [PATCH 1434/1681] Add setting to allow decimal quantities for receiving --- tailbone/templates/receiving/configure.mako | 9 +++++++++ tailbone/views/purchasing/receiving.py | 3 +++ 2 files changed, 12 insertions(+) diff --git a/tailbone/templates/receiving/configure.mako b/tailbone/templates/receiving/configure.mako index 92003fee..f613e13e 100644 --- a/tailbone/templates/receiving/configure.mako +++ b/tailbone/templates/receiving/configure.mako @@ -115,6 +115,15 @@ </b-checkbox> </b-field> + <b-field message="NB. Allow Decimal Quantities setting also affects Ordering behavior."> + <b-checkbox name="rattail.batch.purchase.allow_decimal_quantities" + v-model="simpleSettings['rattail.batch.purchase.allow_decimal_quantities']" + native-value="true" + @input="settingsNeedSaved = true"> + Allow Decimal Quantities + </b-checkbox> + </b-field> + <b-field> <b-checkbox name="rattail.batch.purchase.allow_expired_credits" v-model="simpleSettings['rattail.batch.purchase.allow_expired_credits']" diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 739fe0bd..be15c1a8 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -1998,6 +1998,9 @@ class ReceivingBatchView(PurchasingBatchView): {'section': 'rattail.batch', 'option': 'purchase.allow_cases', 'type': bool}, + {'section': 'rattail.batch', + 'option': 'purchase.allow_decimal_quantities', + 'type': bool}, {'section': 'rattail.batch', 'option': 'purchase.allow_expired_credits', 'type': bool}, From 9b88f01378f1f219f583d3b064a77cd1e7b3903e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 31 May 2024 12:04:11 -0500 Subject: [PATCH 1435/1681] Log error if registry has no rattail config not clear if this is even possible, but if so i want to know about it trying to figure out the occasional error email we get, latest being from collectd/curl pinging the /login page, but request.has_perm() call fails with missing attr?! seems like either the rattail config is empty, or else the subscriber events aren't firing (in the correct order) ? --- tailbone/app.py | 3 +-- tailbone/subscribers.py | 4 ++++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/tailbone/app.py b/tailbone/app.py index 63610f85..0519f35b 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -132,8 +132,7 @@ def make_pyramid_config(settings, configure_csrf=True): settings.setdefault('pyramid_deform.template_search_path', 'tailbone:templates/deform') config = Configurator(settings=settings, root_factory=Root) - # add rattail config directly to registry - # TODO: why on earth do we do this again? + # add rattail config directly to registry, for access throughout the app config.registry['rattail_config'] = rattail_config # configure user authorization / authentication diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 5f477281..42d3cab7 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -80,11 +80,14 @@ def new_request(event): * A shortcut method for permission checking, as ``has_perm()``. """ + log.debug("new request: %s", event) request = event.request rattail_config = request.registry.settings.get('rattail_config') # TODO: why would this ever be null? if rattail_config: request.rattail_config = rattail_config + else: + log.error("registry has no rattail_config ?!") def user(request): user = None @@ -158,6 +161,7 @@ def before_render(event): """ Adds goodies to the global template renderer context. """ + log.debug("before_render: %s", event) request = event.get('request') or threadlocal.get_current_request() rattail_config = request.rattail_config From 3ac131cb5171356e76de5c133ca7512a97f401d4 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 31 May 2024 14:51:01 -0500 Subject: [PATCH 1436/1681] Add column filters for import/export main grid --- tailbone/views/importing.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/tailbone/views/importing.py b/tailbone/views/importing.py index acfddbf8..e9167132 100644 --- a/tailbone/views/importing.py +++ b/tailbone/views/importing.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -25,13 +25,13 @@ View for running arbitrary import/export jobs """ import getpass -import socket -import sys +import json import logging +import socket import subprocess +import sys import time -import json import sqlalchemy as sa from rattail.exceptions import ConfigurationError @@ -152,10 +152,15 @@ class ImportingView(MasterView): return data def configure_grid(self, g): - super(ImportingView, self).configure_grid(g) + super().configure_grid(g) g.set_link('host_title') + g.set_searchable('host_title') + g.set_link('local_title') + g.set_searchable('local_title') + + g.set_searchable('handler_spec') def get_instance(self): """ @@ -177,7 +182,7 @@ class ImportingView(MasterView): return ImportHandlerSchema() def make_form_kwargs(self, **kwargs): - kwargs = super(ImportingView, self).make_form_kwargs(**kwargs) + kwargs = super().make_form_kwargs(**kwargs) # nb. this is set as sort of a hack, to prevent SA model # inspection logic @@ -186,7 +191,7 @@ class ImportingView(MasterView): return kwargs def configure_form(self, f): - super(ImportingView, self).configure_form(f) + super().configure_form(f) f.set_renderer('models', self.render_models) @@ -198,7 +203,7 @@ class ImportingView(MasterView): return HTML.tag('ul', c=items) def template_kwargs_view(self, **kwargs): - kwargs = super(ImportingView, self).template_kwargs_view(**kwargs) + kwargs = super().template_kwargs_view(**kwargs) handler_info = kwargs['instance'] kwargs['handler'] = handler_info['_handler'] return kwargs From ba519334d17b5d39a3ccbc09c41384f2bea70807 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 31 May 2024 18:01:56 -0500 Subject: [PATCH 1437/1681] Fix overflow when instance header title is too long --- tailbone/templates/themes/butterball/base.mako | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako index 8a951831..ba0f64ba 100644 --- a/tailbone/templates/themes/butterball/base.mako +++ b/tailbone/templates/themes/butterball/base.mako @@ -186,6 +186,13 @@ } % endif + #content-title h1 { + max-width: 50%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + ## TODO: is this a good idea? h1.title { font-size: 2rem; From b87b1a3801bdeaf76bba7ba6f0ec64cd11ae6de4 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 31 May 2024 21:20:45 -0500 Subject: [PATCH 1438/1681] Escape all unsafe html for grid data --- tailbone/grids/core.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index b428aaa6..91c3d1f5 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -1651,7 +1651,11 @@ class Grid(object): value = self.obtain_value(rowobj, name) if value is None: value = "" - row[name] = str(value) + + # this value will ultimately be inserted into table + # cell a la <td v-html="..."> so we must escape it + # here to be safe + row[name] = HTML.literal.escape(value) # maybe add UUID for convenience if 'uuid' not in self.columns: From d05458c7fb48e040b046f74900b0ef24e02a5068 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 1 Jun 2024 13:40:50 -0500 Subject: [PATCH 1439/1681] Add speedbumps for delete, set preferred email/phone in profile view --- tailbone/templates/people/view_profile.mako | 264 +++++++++++++++--- .../themes/butterball/buefy-components.mako | 5 +- 2 files changed, 231 insertions(+), 38 deletions(-) diff --git a/tailbone/templates/people/view_profile.mako b/tailbone/templates/people/view_profile.mako index 0ca42cef..bf94b7fa 100644 --- a/tailbone/templates/people/view_profile.mako +++ b/tailbone/templates/people/view_profile.mako @@ -341,23 +341,21 @@ </header> <section class="modal-card-body"> - <b-field grouped> - <b-field label="Type" expanded> - <b-select v-model="editPhoneType" expanded> - <option v-for="option in phoneTypeOptions" - :key="option.value" - :value="option.value"> - {{ option.label }} - </option> - </b-select> - </b-field> + <b-field label="Type"> + <b-select v-model="editPhoneType"> + <option v-for="option in phoneTypeOptions" + :key="option.value" + :value="option.value"> + {{ option.label }} + </option> + </b-select> + </b-field> - <b-field label="Number" expanded> - <b-input v-model.trim="editPhoneNumber" - ref="editPhoneInput"> - </b-input> - </b-field> + <b-field label="Number"> + <b-input v-model.trim="editPhoneNumber" + ref="editPhoneInput" + expanded /> </b-field> <b-field label="Preferred?"> @@ -439,6 +437,72 @@ </${b}-table> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="deletePhoneShowDialog" + % else: + :active.sync="deletePhoneShowDialog" + % endif + > + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Delete Phone</p> + </header> + + <section class="modal-card-body"> + <p class="block">Really delete this phone number?</p> + <p class="block has-text-weight-bold">{{ deletePhoneNumber }}</p> + </section> + + <footer class="modal-card-foot"> + <b-button type="is-danger" + @click="deletePhoneSave()" + :disabled="deletePhoneSaving" + icon-pack="fas" + icon-left="trash"> + {{ deletePhoneSaving ? "Working, please wait..." : "Delete" }} + </b-button> + <b-button @click="deletePhoneShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </${b}-modal> + + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="preferPhoneShowDialog" + % else: + :active.sync="preferPhoneShowDialog" + % endif + > + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Set Preferred Phone</p> + </header> + + <section class="modal-card-body"> + <p class="block">Really make this the preferred phone number?</p> + <p class="block has-text-weight-bold">{{ preferPhoneNumber }}</p> + </section> + + <footer class="modal-card-foot"> + <b-button type="is-primary" + @click="preferPhoneSave()" + :disabled="preferPhoneSaving" + icon-pack="fas" + icon-left="save"> + {{ preferPhoneSaving ? "Working, please wait..." : "Set Preferred" }} + </b-button> + <b-button @click="preferPhoneShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </${b}-modal> + </div> </div> </div> @@ -477,24 +541,21 @@ </header> <section class="modal-card-body"> - <b-field grouped> - <b-field label="Type" expanded> - <b-select v-model="editEmailType" expanded> - <option v-for="option in emailTypeOptions" - :key="option.value" - :value="option.value"> - {{ option.label }} - </option> - </b-select> - </b-field> - - <b-field label="Address" expanded> - <b-input v-model.trim="editEmailAddress" - ref="editEmailInput"> - </b-input> - </b-field> + <b-field label="Type"> + <b-select v-model="editEmailType"> + <option v-for="option in emailTypeOptions" + :key="option.value" + :value="option.value"> + {{ option.label }} + </option> + </b-select> + </b-field> + <b-field label="Address"> + <b-input v-model.trim="editEmailAddress" + ref="editEmailInput" + expanded /> </b-field> <b-field v-if="!editEmailUUID" @@ -590,6 +651,72 @@ </${b}-table> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="deleteEmailShowDialog" + % else: + :active.sync="deleteEmailShowDialog" + % endif + > + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Delete Email</p> + </header> + + <section class="modal-card-body"> + <p class="block">Really delete this email address?</p> + <p class="block has-text-weight-bold">{{ deleteEmailAddress }}</p> + </section> + + <footer class="modal-card-foot"> + <b-button type="is-danger" + @click="deleteEmailSave()" + :disabled="deleteEmailSaving" + icon-pack="fas" + icon-left="trash"> + {{ deleteEmailSaving ? "Working, please wait..." : "Delete" }} + </b-button> + <b-button @click="deleteEmailShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </${b}-modal> + + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="preferEmailShowDialog" + % else: + :active.sync="preferEmailShowDialog" + % endif + > + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Set Preferred Email</p> + </header> + + <section class="modal-card-body"> + <p class="block">Really make this the preferred email address?</p> + <p class="block has-text-weight-bold">{{ preferEmailAddress }}</p> + </section> + + <footer class="modal-card-foot"> + <b-button type="is-primary" + @click="preferEmailSave()" + :disabled="preferEmailSaving" + icon-pack="fas" + icon-left="save"> + {{ preferEmailSaving ? "Working, please wait..." : "Set Preferred" }} + </b-button> + <b-button @click="preferEmailShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </${b}-modal> + </div> </div> </div> @@ -1657,6 +1784,16 @@ editPhonePreferred: false, editPhoneSaving: false, + deletePhoneShowDialog: false, + deletePhoneUUID: null, + deletePhoneNumber: null, + deletePhoneSaving: false, + + preferPhoneShowDialog: false, + preferPhoneUUID: null, + preferPhoneNumber: null, + preferPhoneSaving: false, + editEmailShowDialog: false, editEmailUUID: null, editEmailType: null, @@ -1664,6 +1801,17 @@ editEmailPreferred: null, editEmailInvalid: false, editEmailSaving: false, + + deleteEmailShowDialog: false, + deleteEmailUUID: null, + deleteEmailAddress: null, + deleteEmailSaving: false, + + preferEmailShowDialog: false, + preferEmailUUID: null, + preferEmailAddress: null, + preferEmailSaving: false, + % endif } @@ -1843,26 +1991,47 @@ }, deletePhoneInit(phone) { + this.deletePhoneUUID = phone.uuid + this.deletePhoneNumber = phone.number + this.deletePhoneShowDialog = true + }, + + deletePhoneSave() { + this.deletePhoneSaving = true let url = '${url('people.profile_delete_phone', uuid=person.uuid)}' let params = { - phone_uuid: phone.uuid, + phone_uuid: this.deletePhoneUUID, } - this.simplePOST(url, params, response => { this.$emit('profile-changed', response.data) this.refreshTab() + this.deletePhoneShowDialog = false + this.deletePhoneSaving = false + }, response => { + this.deletePhoneSaving = false }) }, preferPhoneInit(phone) { + this.preferPhoneUUID = phone.uuid + this.preferPhoneNumber = phone.number + this.preferPhoneShowDialog = true + }, + + preferPhoneSave() { + this.preferPhoneSaving = true let url = '${url('people.profile_set_preferred_phone', uuid=person.uuid)}' let params = { - phone_uuid: phone.uuid, + phone_uuid: this.preferPhoneUUID, } this.simplePOST(url, params, response => { this.$emit('profile-changed', response.data) this.refreshTab() + this.preferPhoneShowDialog = false + this.preferPhoneSaving = false + }, response => { + this.preferPhoneSaving = false }) }, @@ -1917,26 +2086,47 @@ }, deleteEmailInit(email) { + this.deleteEmailUUID = email.uuid + this.deleteEmailAddress = email.address + this.deleteEmailShowDialog = true + }, + + deleteEmailSave() { + this.deleteEmailSaving = true let url = '${url('people.profile_delete_email', uuid=person.uuid)}' let params = { - email_uuid: email.uuid, + email_uuid: this.deleteEmailUUID, } - this.simplePOST(url, params, response => { this.$emit('profile-changed', response.data) this.refreshTab() + this.deleteEmailShowDialog = false + this.deleteEmailSaving = false + }, response => { + this.deleteEmailSaving = false }) }, preferEmailInit(email) { + this.preferEmailUUID = email.uuid + this.preferEmailAddress = email.address + this.preferEmailShowDialog = true + }, + + preferEmailSave() { + this.preferEmailSaving = true let url = '${url('people.profile_set_preferred_email', uuid=person.uuid)}' let params = { - email_uuid: email.uuid, + email_uuid: this.preferEmailUUID, } this.simplePOST(url, params, response => { this.$emit('profile-changed', response.data) this.refreshTab() + this.preferEmailShowDialog = false + this.preferEmailSaving = false + }, response => { + this.preferEmailSaving = false }) }, diff --git a/tailbone/templates/themes/butterball/buefy-components.mako b/tailbone/templates/themes/butterball/buefy-components.mako index b11e34ea..7553729b 100644 --- a/tailbone/templates/themes/butterball/buefy-components.mako +++ b/tailbone/templates/themes/butterball/buefy-components.mako @@ -365,7 +365,10 @@ // TODO: this does not always work right? this.$refs.input.$el.querySelector('textarea').focus() } else { - this.$refs.input.$el.querySelector('input').focus() + // TODO: pretty sure we can rely on the <o-input> focus() + // here, but not sure why we weren't already doing that? + //this.$refs.input.$el.querySelector('input').focus() + this.$refs.input.focus() } }, }, From 6b1c313efd30c7b83ad2fb2384e61a1fa8719879 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 1 Jun 2024 14:23:35 -0500 Subject: [PATCH 1440/1681] Fix file upload widget for oruga --- tailbone/forms/core.py | 4 ++-- tailbone/forms/widgets.py | 17 +++++++++++++++++ tailbone/templates/deform/file_upload.pt | 24 ++++++++++++++++++++++-- 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 7601fa26..6918a9cc 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -52,7 +52,7 @@ from tailbone.util import raw_datetime, get_form_data, render_markdown from tailbone.forms import types from tailbone.forms.widgets import (ReadonlyWidget, PlainDateWidget, JQueryDateWidget, JQueryTimeWidget, - MultiFileUploadWidget) + FileUploadWidget, MultiFileUploadWidget) from tailbone.exceptions import TailboneJSONFieldError @@ -646,7 +646,7 @@ class Form(object): self.set_widget(key, dfwidget.TextAreaWidget(cols=80, rows=8)) elif type_ == 'file': tmpstore = SessionFileUploadTempStore(self.request) - kw = {'widget': dfwidget.FileUploadWidget(tmpstore), + kw = {'widget': FileUploadWidget(tmpstore, request=self.request), 'title': self.get_label(key)} if 'required' in kwargs and not kwargs['required']: kw['missing'] = colander.null diff --git a/tailbone/forms/widgets.py b/tailbone/forms/widgets.py index c0bb0b4d..2923b7ec 100644 --- a/tailbone/forms/widgets.py +++ b/tailbone/forms/widgets.py @@ -337,6 +337,23 @@ class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget): return field.renderer(template, **tmpl_values) +class FileUploadWidget(dfwidget.FileUploadWidget): + """ + Widget to handle file upload. Must override to add ``use_oruga`` + to field template context. + """ + + def __init__(self, *args, **kwargs): + self.request = kwargs.pop('request') + super().__init__(*args, **kwargs) + + def get_template_values(self, field, cstruct, kw): + values = super().get_template_values(field, cstruct, kw) + if self.request: + values['use_oruga'] = self.request.use_oruga + return values + + class MultiFileUploadWidget(dfwidget.FileUploadWidget): """ Widget to handle multiple (arbitrary number) of file uploads. diff --git a/tailbone/templates/deform/file_upload.pt b/tailbone/templates/deform/file_upload.pt index e165fdfa..af78eaf9 100644 --- a/tailbone/templates/deform/file_upload.pt +++ b/tailbone/templates/deform/file_upload.pt @@ -2,11 +2,14 @@ <tal:block tal:define="oid oid|field.oid; css_class css_class|field.widget.css_class; style style|field.widget.style; - field_name field_name|field.name;"> + field_name field_name|field.name; + use_oruga use_oruga;"> <div tal:define="vmodel vmodel|'field_model_' + field_name;"> ${field.start_mapping()} - <b-field class="file"> + + <b-field class="file" + tal:condition="not use_oruga"> <b-upload name="upload" v-model="${vmodel}"> <a class="button is-primary"> @@ -18,6 +21,23 @@ {{ ${vmodel}.name }} </span> </b-field> + + <o-field class="file" + tal:condition="use_oruga"> + <o-upload name="upload" + v-slot="{ onclick }" + v-model="${vmodel}"> + <o-button variant="primary" + @click="onclick"> + <o-icon icon="upload" /> + <span>Click to upload</span> + </o-button> + </o-upload> + <span class="file-name" v-if="${vmodel}"> + {{ ${vmodel}.name }} + </span> + </o-field> + ${field.end_mapping()} </div> From 43db60bbee167d43032251d856ef77259f1afa23 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 1 Jun 2024 14:26:17 -0500 Subject: [PATCH 1441/1681] Update changelog --- CHANGES.rst | 18 ++++++++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 3f2b16aa..08b01d5e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,24 @@ CHANGELOG Unreleased ---------- +0.10.7 (2024-06-01) +------------------- + +* Add setting to allow decimal quantities for receiving. + +* Log error if registry has no rattail config. + +* Add column filters for import/export main grid. + +* Fix overflow when instance header title is too long (butterball). + +* Escape all unsafe html for grid data. + +* Add speedbumps for delete, set preferred email/phone in profile view. + +* Fix file upload widget for oruga. + + 0.10.6 (2024-05-29) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 711da994..21ae87f2 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.10.6' +__version__ = '0.10.7' From 77eeb63b62fb806fdb0b2c5017bc2b81a52228d8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 1 Jun 2024 17:46:35 -0500 Subject: [PATCH 1442/1681] Add styling for checked grid rows, per oruga/butterball --- tailbone/templates/grids/complete.mako | 9 ++++--- .../templates/themes/butterball/base.mako | 25 +++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako index a54cc127..e200cdc3 100644 --- a/tailbone/templates/grids/complete.mako +++ b/tailbone/templates/grids/complete.mako @@ -44,6 +44,9 @@ :data="visibleData" :loading="loading" :row-class="getRowClass" + % if request.use_oruga: + tr-checked-class="is-checked" + % endif % if request.rattail_config.getbool('tailbone', 'sticky_headers'): sticky-header @@ -58,9 +61,9 @@ % else: :checked-rows.sync="checkedRows" % endif - % if grid.clicking_row_checks_box: - @click="rowClick" - % endif + % if grid.clicking_row_checks_box: + @click="rowClick" + % endif % endif % if grid.check_handler: diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako index ba0f64ba..7a27c0ed 100644 --- a/tailbone/templates/themes/butterball/base.mako +++ b/tailbone/templates/themes/butterball/base.mako @@ -242,6 +242,31 @@ white-space: nowrap; } + /************************************************** + * grid rows which are "checked" (selected) + **************************************************/ + + /* TODO: this references some color values, whereas it would be preferable + * to refer to some sort of "state" instead, color of which was + * configurable. b/c these are just the default Buefy theme colors. */ + + tr.is-checked { + background-color: #7957d5; + color: white; + } + + tr.is-checked:hover { + color: #363636; + } + + tr.is-checked a { + color: white; + } + + tr.is-checked:hover a { + color: #7957d5; + } + /* ****************************** */ /* forms */ /* ****************************** */ From 1bf28eb2862876f9baacd01774c33754c33cfb12 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 1 Jun 2024 19:07:07 -0500 Subject: [PATCH 1443/1681] Fix product view template for oruga/butterball --- tailbone/forms/core.py | 4 +++ tailbone/templates/products/view.mako | 35 +++++++++++++++------------ tailbone/views/products.py | 9 ++++--- 3 files changed, 29 insertions(+), 19 deletions(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 6918a9cc..d6303bb1 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -1157,6 +1157,10 @@ class Form(object): return HTML.tag('div', class_='field-wrapper {}'.format(field_name), c=contents) + # nb. for some reason we must wrap once more for oruga, + # otherwise it splits up the field?! + value = HTML.tag('span', c=[value]) + # oruga uses <o-field> return HTML.tag('o-field', label=label, c=[value], **{':horizontal': 'true'}) diff --git a/tailbone/templates/products/view.mako b/tailbone/templates/products/view.mako index c4da08ba..bd4afc7f 100644 --- a/tailbone/templates/products/view.mako +++ b/tailbone/templates/products/view.mako @@ -4,6 +4,9 @@ <%def name="extra_styles()"> ${parent.extra_styles()} <style type="text/css"> + nav.item-panel { + min-width: 600px; + } #main-product-panel { margin-right: 2em; margin-top: 1em; @@ -22,18 +25,18 @@ </%def> <%def name="left_column()"> - <nav class="panel" id="pricing-panel"> + <nav class="panel item-panel" id="pricing-panel"> <p class="panel-heading">Pricing</p> <div class="panel-block"> - <div> + <div style="width: 100%;"> ${self.render_price_fields(form)} </div> </div> </nav> - <nav class="panel"> + <nav class="panel item-panel"> <p class="panel-heading">Flags</p> <div class="panel-block"> - <div> + <div style="width: 100%;"> ${self.render_flag_fields(form)} </div> </div> @@ -54,10 +57,10 @@ <%def name="extra_main_fields(form)"></%def> <%def name="organization_panel()"> - <nav class="panel"> + <nav class="panel item-panel"> <p class="panel-heading">Organization</p> <div class="panel-block"> - <div> + <div style="width: 100%;"> ${self.render_organization_fields(form)} </div> </div> @@ -93,10 +96,10 @@ </%def> <%def name="movement_panel()"> - <nav class="panel"> + <nav class="panel item-panel"> <p class="panel-heading">Movement</p> <div class="panel-block"> - <div> + <div style="width: 100%;"> ${self.render_movement_fields(form)} </div> </div> @@ -112,7 +115,7 @@ </%def> <%def name="lookup_codes_panel()"> - <nav class="panel"> + <nav class="panel item-panel"> <p class="panel-heading">Additional Lookup Codes</p> <div class="panel-block"> ${self.lookup_codes_grid()} @@ -125,7 +128,7 @@ </%def> <%def name="sources_panel()"> - <nav class="panel"> + <nav class="panel item-panel"> <p class="panel-heading"> Vendor Sources % if request.rattail_config.versioning_enabled() and master.has_perm('versions'): @@ -141,7 +144,7 @@ </%def> <%def name="notes_panel()"> - <nav class="panel"> + <nav class="panel item-panel"> <p class="panel-heading">Notes</p> <div class="panel-block"> <div class="field">${form.render_field_readonly('notes')}</div> @@ -150,7 +153,7 @@ </%def> <%def name="ingredients_panel()"> - <nav class="panel"> + <nav class="panel item-panel"> <p class="panel-heading">Ingredients</p> <div class="panel-block"> ${form.render_field_readonly('ingredients')} @@ -245,13 +248,13 @@ </%def> <%def name="page_content()"> - <div style="display: flex; flex-direction: column;"> + <div style="display: flex; flex-direction: column;"> - <nav class="panel" id="main-product-panel"> + <nav class="panel item-panel" id="main-product-panel"> <p class="panel-heading">Product</p> <div class="panel-block"> - <div style="display: flex; justify-content: space-between; width: 100%;"> - <div> + <div style="display: flex; gap: 2rem; width: 100%;"> + <div style="flex-grow: 1;"> ${self.render_main_fields(form)} </div> <div> diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 7219b6b3..28186ac3 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -445,9 +445,12 @@ class ProductView(MasterView): if not text: return history - text = HTML.tag('span', c=[text]) - br = HTML.tag('br') - return HTML.tag('div', c=[text, br, history]) + text = HTML.tag('p', c=[text]) + history = HTML.tag('p', c=[history]) + div = HTML.tag('div', c=[text, history]) + # nb. for some reason we must wrap once more for oruga, + # otherwise it splits up the field?! + return HTML.tag('div', c=[div]) def show_price_effective_dates(self): if not self.rattail_config.versioning_enabled(): From 9258237b85d418137a0013cd27d999b041334130 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 1 Jun 2024 21:37:38 -0500 Subject: [PATCH 1444/1681] Allow per-user custom styles for butterball --- tailbone/templates/themes/butterball/base.mako | 16 +++++----------- tailbone/views/users.py | 7 +++++-- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako index 7a27c0ed..420f23d9 100644 --- a/tailbone/templates/themes/butterball/base.mako +++ b/tailbone/templates/themes/butterball/base.mako @@ -89,17 +89,11 @@ </%def> <%def name="core_styles()"> - - ## ## TODO: eventually, allow custom css per-user - ## % if user_css: - ## ${h.stylesheet_link(user_css)} - ## % else: - ## ${h.stylesheet_link(h.get_liburl(request, 'bulma.css'))} - ## % endif - - ## TODO: eventually version / url should be configurable - ${h.stylesheet_link(h.get_liburl(request, 'bb_oruga_bulma_css'))} - + % if user_css: + ${h.stylesheet_link(user_css)} + % else: + ${h.stylesheet_link(h.get_liburl(request, 'bb_oruga_bulma_css'))} + % endif </%def> <%def name="head_tags()"> diff --git a/tailbone/views/users.py b/tailbone/views/users.py index 9cc1b5b5..0f844bfb 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -601,8 +601,11 @@ class UserView(PrincipalMasterView): styles = self.rattail_config.getlist('tailbone', 'themes.styles', default=[]) for name in styles: - css = self.rattail_config.get('tailbone', - 'themes.style.{}'.format(name)) + css = None + if self.request.use_oruga: + css = self.rattail_config.get(f'tailbone.themes.bb_style.{name}') + if not css: + css = self.rattail_config.get(f'tailbone.themes.style.{name}') if css: options.append({'value': css, 'label': name}) context['theme_style_options'] = options From 40edde26942fb5f5a0e0759af0af37e37c68ac3d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 1 Jun 2024 23:06:19 -0500 Subject: [PATCH 1445/1681] Use oruga 0.8.9 by default --- tailbone/util.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tailbone/util.py b/tailbone/util.py index bb18f22d..7d838541 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -165,10 +165,7 @@ def get_libver(request, key, fallback=True, default_only=False): return '3.3.11' elif key == 'bb_oruga': - # TODO: as of writing, 0.8.8 is the latest release, but it has - # a bug which makes <o-field horizontal> basically not work - # cf. https://github.com/oruga-ui/oruga/issues/913 - return '0.8.7' + return '0.8.9' elif key in ('bb_oruga_bulma', 'bb_oruga_bulma_css'): return '0.3.0' From 254df6d6f25192cf7e9e4dd3619995751774c6c4 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 2 Jun 2024 14:55:18 -0500 Subject: [PATCH 1446/1681] Update changelog --- CHANGES.rst | 12 ++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 08b01d5e..762ca455 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,18 @@ CHANGELOG Unreleased ---------- +0.10.8 (2024-06-02) +------------------- + +* Add styling for checked grid rows, per oruga/butterball. + +* Fix product view template for oruga/butterball. + +* Allow per-user custom styles for butterball. + +* Use oruga 0.8.9 by default. + + 0.10.7 (2024-06-01) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 21ae87f2..e6aa0601 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.10.7' +__version__ = '0.10.8' From fa25857680eb8d19fb7be260ed7eea881ed12446 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 2 Jun 2024 15:54:42 -0500 Subject: [PATCH 1447/1681] Let master view control context menu items for page that really does not belong in the template if we can help it. some templates still define context menu items but can hopefully phase those out over time --- .../templates/datasync/changes/index.mako | 7 ---- tailbone/templates/datasync/status.mako | 7 ---- tailbone/templates/master/index.mako | 14 ------- tailbone/templates/master/view.mako | 6 --- tailbone/templates/page.mako | 8 +++- .../trainwreck/transactions/index.mako | 12 ------ tailbone/templates/users/view.mako | 7 ---- tailbone/views/datasync.py | 23 +++++++++++ tailbone/views/master.py | 39 +++++++++++++++++++ tailbone/views/trainwreck/base.py | 12 ++++++ tailbone/views/users.py | 11 ++++++ 11 files changed, 92 insertions(+), 54 deletions(-) delete mode 100644 tailbone/templates/trainwreck/transactions/index.mako diff --git a/tailbone/templates/datasync/changes/index.mako b/tailbone/templates/datasync/changes/index.mako index b5aeb79a..6d171619 100644 --- a/tailbone/templates/datasync/changes/index.mako +++ b/tailbone/templates/datasync/changes/index.mako @@ -1,13 +1,6 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/index.mako" /> -<%def name="context_menu_items()"> - ${parent.context_menu_items()} - % if request.has_perm('datasync.status'): - <li>${h.link_to("View DataSync Status", url('datasync.status'))}</li> - % endif -</%def> - <%def name="grid_tools()"> ${parent.grid_tools()} diff --git a/tailbone/templates/datasync/status.mako b/tailbone/templates/datasync/status.mako index 43d05f51..c782dec6 100644 --- a/tailbone/templates/datasync/status.mako +++ b/tailbone/templates/datasync/status.mako @@ -5,13 +5,6 @@ <%def name="content_title()"></%def> -<%def name="context_menu_items()"> - ${parent.context_menu_items()} - % if request.has_perm('datasync_changes.list'): - <li>${h.link_to("View DataSync Changes", url('datasyncchanges'))}</li> - % endif -</%def> - <%def name="page_content()"> % if expose_websockets and not supervisor_error: <b-notification type="is-warning" diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako index a619d84c..33592559 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -12,20 +12,6 @@ <%def name="content_title()"></%def> -<%def name="context_menu_items()"> - % if master.results_downloadable_csv and request.has_perm('{}.results_csv'.format(permission_prefix)): - <li>${h.link_to("Download results as CSV", url('{}.results_csv'.format(route_prefix)))}</li> - % endif - % if master.results_downloadable_xlsx and request.has_perm('{}.results_xlsx'.format(permission_prefix)): - <li>${h.link_to("Download results as XLSX", url('{}.results_xlsx'.format(route_prefix)))}</li> - % endif - % if master.has_input_file_templates and master.has_perm('create'): - % for template in input_file_templates.values(): - <li>${h.link_to("Download {} Template".format(template['label']), template['effective_url'])}</li> - % endfor - % endif -</%def> - <%def name="grid_tools()"> ## grid totals diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako index ac0577e0..fe44caa9 100644 --- a/tailbone/templates/master/view.mako +++ b/tailbone/templates/master/view.mako @@ -51,12 +51,6 @@ % endif </%def> -<%def name="context_menu_items()"> - ## TODO: either make this configurable, or just lose it. - ## nobody seems to ever find it useful in practice. - ## <li>${h.link_to("Permalink for this {}".format(model_title), action_url('view', instance))}</li> -</%def> - <%def name="render_row_grid_tools()"> ${rows_grid_tools} % if master.rows_downloadable_xlsx and master.has_perm('row_results_xlsx'): diff --git a/tailbone/templates/page.mako b/tailbone/templates/page.mako index 460cc6d6..17d87c9a 100644 --- a/tailbone/templates/page.mako +++ b/tailbone/templates/page.mako @@ -1,7 +1,13 @@ ## -*- coding: utf-8; -*- <%inherit file="/base.mako" /> -<%def name="context_menu_items()"></%def> +<%def name="context_menu_items()"> + % if context_menu_list_items is not Undefined: + % for item in context_menu_list_items: + <li>${item}</li> + % endfor + % endif +</%def> <%def name="page_content()"></%def> diff --git a/tailbone/templates/trainwreck/transactions/index.mako b/tailbone/templates/trainwreck/transactions/index.mako deleted file mode 100644 index 31d956fc..00000000 --- a/tailbone/templates/trainwreck/transactions/index.mako +++ /dev/null @@ -1,12 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/master/index.mako" /> - -<%def name="context_menu_items()"> - ${parent.context_menu_items()} - % if master.has_perm('rollover'): - <li>${h.link_to("Yearly Rollover", url('{}.rollover'.format(route_prefix)))}</li> - % endif -</%def> - - -${parent.body()} diff --git a/tailbone/templates/users/view.mako b/tailbone/templates/users/view.mako index 94931e52..ed2b5f16 100644 --- a/tailbone/templates/users/view.mako +++ b/tailbone/templates/users/view.mako @@ -14,13 +14,6 @@ % endif </%def> -<%def name="context_menu_items()"> - ${parent.context_menu_items()} - % if master.has_perm('preferences'): - <li>${h.link_to("Edit User Preferences", action_url('preferences', instance))}</li> - % endif -</%def> - <%def name="render_this_page()"> ${parent.render_this_page()} diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py index b734325f..7616d288 100644 --- a/tailbone/views/datasync.py +++ b/tailbone/views/datasync.py @@ -34,6 +34,8 @@ from rattail.db.model import DataSyncChange from rattail.datasync.util import purge_datasync_settings from rattail.util import simple_error +from webhelpers2.html import tags + from tailbone.views import MasterView from tailbone.util import raw_datetime from tailbone.config import should_expose_websockets @@ -75,6 +77,16 @@ class DataSyncThreadView(MasterView): app = self.get_rattail_app() self.datasync_handler = app.get_datasync_handler() + def get_context_menu_items(self, thread=None): + items = super().get_context_menu_items(thread) + + # nb. just one view here, no need to check if listing etc. + if self.request.has_perm('datasync_changes.list'): + url = self.request.route_url('datasyncchanges') + items.append(tags.link_to("View DataSync Changes", url)) + + return items + def status(self): """ View to list/filter/sort the model data. @@ -389,6 +401,17 @@ class DataSyncChangeView(MasterView): 'consumer', ] + def get_context_menu_items(self, change=None): + items = super().get_context_menu_items(change) + + if self.listing: + + if self.request.has_perm('datasync.status'): + url = self.request.route_url('datasync.status') + items.append(tags.link_to("View DataSync Status", url)) + + return items + def configure_grid(self, g): super().configure_grid(g) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index cc6e25ea..48bc32fe 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2807,6 +2807,13 @@ class MasterView(View): kwargs['db_picker_options'] = [tags.Option(k, value=k) for k in engines] kwargs['db_picker_selected'] = selected + # context menu + obj = kwargs.get('instance') + items = self.get_context_menu_items(obj) + for supp in self.iter_view_supplements(): + items.extend(supp.get_context_menu_items(obj) or []) + kwargs['context_menu_list_items'] = items + # add info for downloadable input file templates, if any if self.has_input_file_templates: templates = self.normalize_input_file_templates() @@ -2914,6 +2921,35 @@ class MasterView(View): kwargs['xref_links'] = self.get_xref_links(obj) return kwargs + def get_context_menu_items(self, obj=None): + items = [] + route_prefix = self.get_route_prefix() + + if self.listing: + + if self.results_downloadable_csv and self.has_perm('results_csv'): + url = self.request.route_url(f'{route_prefix}.results_csv') + items.append(tags.link_to("Download results as CSV", url)) + + if self.results_downloadable_xlsx and self.has_perm('results_xlsx'): + url = self.request.route_url(f'{route_prefix}.results_xlsx') + items.append(tags.link_to("Download results as XLSX", url)) + + if self.has_input_file_templates and self.has_perm('create'): + templates = self.normalize_input_file_templates() + for template in templates: + items.append(tags.link_to(f"Download {template['label']} Template", + template['effective_url'])) + + # if self.viewing: + + # # # TODO: either make this configurable, or just lose it. + # # # nobody seems to ever find it useful in practice. + # # url = self.get_action_url('view', instance) + # # items.append(tags.link_to(f"Permalink for this {model_title}", url)) + + return items + def get_xref_buttons(self, obj): buttons = [] for supp in self.iter_view_supplements(): @@ -5914,6 +5950,9 @@ class ViewSupplement(object): def get_xref_links(self, obj): return [] + def get_context_menu_items(self, obj): + return [] + def get_version_child_classes(self): """ Return a list of additional "version child classes" which are diff --git a/tailbone/views/trainwreck/base.py b/tailbone/views/trainwreck/base.py index 1e273c87..59a42301 100644 --- a/tailbone/views/trainwreck/base.py +++ b/tailbone/views/trainwreck/base.py @@ -164,6 +164,18 @@ class TransactionView(MasterView): return TrainwreckSession() + def get_context_menu_items(self, txn=None): + items = super().get_context_menu_items(txn) + route_prefix = self.get_route_prefix() + + if self.listing: + + if self.has_perm('rollover'): + url = self.request.route_url(f'{route_prefix}.rollover') + items.append(tags.link_to("Yearly Rollover", url)) + + return items + def configure_grid(self, g): super().configure_grid(g) app = self.get_rattail_app() diff --git a/tailbone/views/users.py b/tailbone/views/users.py index 0f844bfb..dd3f7f7b 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -92,6 +92,17 @@ class UserView(PrincipalMasterView): self.auth_handler = app.get_auth_handler() self.merge_handler = self.auth_handler + def get_context_menu_items(self, user=None): + items = super().get_context_menu_items(user) + + if self.viewing: + + if self.has_perm('preferences'): + url = self.get_action_url('preferences', user) + items.append(tags.link_to("Edit User Preferences", url)) + + return items + def query(self, session): query = super().query(session) model = self.model From 3dc8deef670085d829c67a8d86610a42a4c8b18b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 2 Jun 2024 15:58:10 -0500 Subject: [PATCH 1448/1681] Fix panel style for PO vs. Invoice breakdown in receiving batch --- tailbone/templates/receiving/view.mako | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako index 80c45103..5f103d7f 100644 --- a/tailbone/templates/receiving/view.mako +++ b/tailbone/templates/receiving/view.mako @@ -27,12 +27,14 @@ <%def name="render_po_vs_invoice_helper()"> % if master.handler.has_purchase_order(batch) and master.handler.has_invoice_file(batch): - <div class="object-helper"> - <h3>PO vs. Invoice</h3> - <div class="object-helper-content"> - ${po_vs_invoice_breakdown_grid} + <nav class="panel"> + <p class="panel-heading">PO vs. Invoice</p> + <div class="panel-block"> + <div style="width: 100%;"> + ${po_vs_invoice_breakdown_grid} + </div> </div> - </div> + </nav> % endif </%def> From 58f95882619b3ba2912666c1d402f4d3d325c32a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 2 Jun 2024 19:56:42 -0500 Subject: [PATCH 1449/1681] Fix the "new custorder" page for butterball reasonably confident this one is complete, and didn't break buefy theme.. --- tailbone/templates/custorders/create.mako | 764 +++++++++++------- tailbone/templates/products/lookup.mako | 97 ++- .../themes/butterball/buefy-components.mako | 36 + .../themes/butterball/field-components.mako | 9 +- 4 files changed, 559 insertions(+), 347 deletions(-) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index 6b07571e..9a3a2d57 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -56,12 +56,19 @@ ${self.order_form_buttons()} - <b-collapse class="panel" :class="customerPanelType" - :open.sync="customerPanelOpen"> + <${b}-collapse class="panel" + :class="customerPanelType" + % if request.use_oruga: + v-model:open="customerPanelOpen" + % else: + :open.sync="customerPanelOpen" + % endif + > <template #trigger="props"> <div class="panel-heading" - role="button"> + role="button" + style="cursor: pointer;"> ## TODO: for some reason buefy will "reuse" the icon ## element in such a way that its display does not @@ -89,11 +96,33 @@ <div style="display: flex; flex-direction: row;"> <div style="flex-grow: 1; margin-right: 1rem;"> - <b-notification :type="customerStatusType" - position="is-bottom-right" - :closable="false"> - {{ customerStatusText }} - </b-notification> + % if request.use_oruga: + ## TODO: for some reason o-notification variant is not + ## being updated properly, so for now the workaround is + ## to maintain a separate component for each variant + ## i tried to reproduce the problem in a simple page + ## but was unable; this is really a hack but it works.. + <o-notification v-if="customerStatusType == null" + :closable="false"> + {{ customerStatusText }} + </o-notification> + <o-notification v-if="customerStatusType == 'is-warning'" + variant="warning" + :closable="false"> + {{ customerStatusText }} + </o-notification> + <o-notification v-if="customerStatusType == 'is-danger'" + variant="danger" + :closable="false"> + {{ customerStatusText }} + </o-notification> + % else: + <b-notification :type="customerStatusType" + position="is-bottom-right" + :closable="false"> + {{ customerStatusText }} + </b-notification> + % endif </div> <!-- <div class="buttons"> --> <!-- <b-button @click="startOverCustomer()" --> @@ -117,23 +146,28 @@ <div :style="{'flex-grow': contactNotes.length ? 0 : 1}"> - <b-field label="Customer" grouped> - <b-field style="margin-left: 1rem;" - :expanded="!contactUUID"> + <b-field label="Customer"> + <div style="display: flex; gap: 1rem; width: 100%;"> <tailbone-autocomplete ref="contactAutocomplete" v-model="contactUUID" + :style="{'flex-grow': contactUUID ? '0' : '1'}" + expanded placeholder="Enter name or phone number" - :initial-label="contactDisplay" % if new_order_requires_customer: serviceUrl="${url('{}.customer_autocomplete'.format(route_prefix))}" % else: serviceUrl="${url('{}.person_autocomplete'.format(route_prefix))}" % endif - @input="contactChanged"> + % if request.use_oruga: + :assigned-label="contactDisplay" + @update:model-value="contactChanged" + % else: + :initial-label="contactDisplay" + @input="contactChanged" + % endif + > </tailbone-autocomplete> - </b-field> - <div v-if="contactUUID"> - <b-button v-if="contactProfileURL" + <b-button v-if="contactUUID && contactProfileURL" type="is-primary" tag="a" target="_blank" :href="contactProfileURL" @@ -141,8 +175,8 @@ icon-left="external-link-alt"> View Profile </b-button> - - <b-button @click="refreshContact" + <b-button v-if="contactUUID" + @click="refreshContact" icon-pack="fas" icon-left="redo" :disabled="refreshingContact"> @@ -186,8 +220,13 @@ Edit </b-button> - <b-modal has-modal-card - :active.sync="editPhoneNumberShowDialog"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="editPhoneNumberShowDialog" + % else: + :active.sync="editPhoneNumberShowDialog" + % endif + > <div class="modal-card"> <header class="modal-card-head"> @@ -241,7 +280,7 @@ </b-button> </footer> </div> - </b-modal> + </${b}-modal> </div> % endif @@ -279,8 +318,13 @@ icon-left="edit"> Edit </b-button> - <b-modal has-modal-card - :active.sync="editEmailAddressShowDialog"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active.sync="editEmailAddressShowDialog" + % else: + :active.sync="editEmailAddressShowDialog" + % endif + > <div class="modal-card"> <header class="modal-card-head"> @@ -334,7 +378,7 @@ </b-button> </footer> </div> - </b-modal> + </${b}-modal> </div> % endif </div> @@ -409,8 +453,13 @@ </b-notification> </div> - <b-modal has-modal-card - :active.sync="editNewCustomerShowDialog"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="editNewCustomerShowDialog" + % else: + :active.sync="editNewCustomerShowDialog" + % endif + > <div class="modal-card"> <header class="modal-card-head"> @@ -452,20 +501,21 @@ </b-button> </footer> </div> - </b-modal> + </${b}-modal> </div> </div> </div> <!-- panel-block --> - </b-collapse> + </${b}-collapse> - <b-collapse class="panel" + <${b}-collapse class="panel" open> <template #trigger="props"> <div class="panel-heading" - role="button"> + role="button" + style="cursor: pointer;"> ## TODO: for some reason buefy will "reuse" the icon ## element in such a way that its display does not @@ -507,15 +557,28 @@ % endif </div> - <b-modal :active.sync="showingItemDialog"> + <${b}-modal + % if request.use_oruga: + v-model:active="showingItemDialog" + % else: + :active.sync="showingItemDialog" + % endif + > <div class="card"> <div class="card-content"> - <b-tabs type="is-boxed is-toggle" - v-model="itemDialogTabIndex" - :animated="false"> + <${b}-tabs :animated="false" + % if request.use_oruga: + v-model="itemDialogTab" + type="toggle" + % else: + v-model="itemDialogTabIndex" + type="is-boxed is-toggle" + % endif + > - <b-tab-item label="Product"> + <${b}-tab-item label="Product" + value="product"> <div class="field"> <b-radio v-model="productIsKnown" @@ -525,84 +588,82 @@ </div> <div v-show="productIsKnown" - style="padding-left: 5rem;"> - - <b-field grouped> - <p class="label control"> - Product - </p> - <tailbone-product-lookup ref="productLookup" - :product="selectedProduct" - @selected="productLookupSelected" - autocomplete-url="${url(f'{route_prefix}.product_autocomplete')}"> - </tailbone-product-lookup> - </b-field> - - <div v-if="productUUID"> - - <div class="is-pulled-right has-text-centered"> - <img :src="productImageURL" - style="max-height: 150px; max-width: 150px; "/> - </div> - - <b-field grouped> - <b-field :label="productKeyLabel"> - <span>{{ productKey }}</span> - </b-field> - - <b-field label="Unit Size"> - <span>{{ productSize || '' }}</span> - </b-field> - - <b-field label="Case Size"> - <span>{{ productCaseQuantity }}</span> - </b-field> - - <b-field label="Reg. Price" - v-if="productSalePriceDisplay"> - <span>{{ productUnitRegularPriceDisplay }}</span> - </b-field> - - <b-field label="Unit Price" - v-if="!productSalePriceDisplay"> - <span - % if product_price_may_be_questionable: - :class="productPriceNeedsConfirmation ? 'has-background-warning' : ''" - % endif - > - {{ productUnitPriceDisplay }} - </span> - </b-field> - <!-- <b-field label="Last Changed"> --> - <!-- <span>2021-01-01</span> --> - <!-- </b-field> --> - - <b-field label="Sale Price" - v-if="productSalePriceDisplay"> - <span class="has-background-warning"> - {{ productSalePriceDisplay }} - </span> - </b-field> - - <b-field label="Sale Ends" - v-if="productSaleEndsDisplay"> - <span class="has-background-warning"> - {{ productSaleEndsDisplay }} - </span> - </b-field> + style="padding-left: 3rem; display: flex; gap: 1rem;"> + <div style="flex-grow: 1;"> + <b-field label="Product"> + <tailbone-product-lookup ref="productLookup" + :product="selectedProduct" + @selected="productLookupSelected" + autocomplete-url="${url(f'{route_prefix}.product_autocomplete')}"> + </tailbone-product-lookup> </b-field> - % if product_price_may_be_questionable: - <b-checkbox v-model="productPriceNeedsConfirmation" - type="is-warning" - size="is-small"> - This price is questionable and should be confirmed - by someone before order proceeds. - </b-checkbox> - % endif + <div v-if="productUUID"> + + <b-field grouped> + <b-field :label="productKeyLabel"> + <span>{{ productKey }}</span> + </b-field> + + <b-field label="Unit Size"> + <span>{{ productSize || '' }}</span> + </b-field> + + <b-field label="Case Size"> + <span>{{ productCaseQuantity }}</span> + </b-field> + + <b-field label="Reg. Price" + v-if="productSalePriceDisplay"> + <span>{{ productUnitRegularPriceDisplay }}</span> + </b-field> + + <b-field label="Unit Price" + v-if="!productSalePriceDisplay"> + <span + % if product_price_may_be_questionable: + :class="productPriceNeedsConfirmation ? 'has-background-warning' : ''" + % endif + > + {{ productUnitPriceDisplay }} + </span> + </b-field> + <!-- <b-field label="Last Changed"> --> + <!-- <span>2021-01-01</span> --> + <!-- </b-field> --> + + <b-field label="Sale Price" + v-if="productSalePriceDisplay"> + <span class="has-background-warning"> + {{ productSalePriceDisplay }} + </span> + </b-field> + + <b-field label="Sale Ends" + v-if="productSaleEndsDisplay"> + <span class="has-background-warning"> + {{ productSaleEndsDisplay }} + </span> + </b-field> + + </b-field> + + % if product_price_may_be_questionable: + <b-checkbox v-model="productPriceNeedsConfirmation" + type="is-warning" + size="is-small"> + This price is questionable and should be confirmed + by someone before order proceeds. + </b-checkbox> + % endif + </div> </div> + <img v-if="productUUID" + :src="productImageURL" + style="max-height: 150px; max-width: 150px; "/> + </div> <br /> @@ -744,132 +805,148 @@ <b-field label="Notes"> <b-input v-model="pendingProduct.notes" - type="textarea"> - </b-input> + type="textarea" + expanded /> </b-field> </div> - </b-tab-item> - <b-tab-item label="Quantity"> + </${b}-tab-item> + <${b}-tab-item label="Quantity" + value="quantity"> - <div class="is-pulled-right has-text-centered"> - <img :src="productImageURL" - style="max-height: 150px; max-width: 150px; "/> - </div> + <div style="display: flex; gap: 1rem; white-space: nowrap;"> - <b-field grouped> - <b-field label="Product" horizontal> - <span :class="productIsKnown ? null : 'has-text-success'"> - {{ productIsKnown ? productDisplay : (pendingProduct.brand_name || '') + ' ' + (pendingProduct.description || '') + ' ' + (pendingProduct.size || '') }} - </span> - </b-field> - </b-field> - - <b-field grouped> - - <b-field label="Unit Size"> - <span :class="productIsKnown ? null : 'has-text-success'"> - {{ productIsKnown ? productSize : pendingProduct.size }} - </span> - </b-field> - - <b-field label="Reg. Price" - v-if="productSalePriceDisplay"> - <span> - {{ productUnitRegularPriceDisplay }} - </span> - </b-field> - - <b-field label="Unit Price" - v-if="!productSalePriceDisplay"> - <span :class="productIsKnown ? null : 'has-text-success'" - % if product_price_may_be_questionable: - :class="productPriceNeedsConfirmation ? 'has-background-warning' : ''" - % endif - > - {{ productIsKnown ? productUnitPriceDisplay : (pendingProduct.regular_price_amount ? '$' + pendingProduct.regular_price_amount : '') }} - </span> - </b-field> - - <b-field label="Sale Price" - v-if="productSalePriceDisplay"> - <span class="has-background-warning" - :class="productIsKnown ? null : 'has-text-success'"> - {{ productSalePriceDisplay }} - </span> - </b-field> - - <b-field label="Sale Ends" - v-if="productSaleEndsDisplay"> - <span class="has-background-warning" - :class="productIsKnown ? null : 'has-text-success'"> - {{ productSaleEndsDisplay }} - </span> - </b-field> - - <b-field label="Case Size"> - <span :class="productIsKnown ? null : 'has-text-success'"> - {{ productIsKnown ? productCaseQuantity : pendingProduct.case_size }} - </span> - </b-field> - - <b-field label="Case Price"> - <span - % if product_price_may_be_questionable: - :class="{'has-text-success': !productIsKnown, 'has-background-warning': productPriceNeedsConfirmation || productSalePriceDisplay}" - % else: - :class="{'has-text-success': !productIsKnown, 'has-background-warning': !!productSalePriceDisplay}" - % endif - > - {{ getCasePriceDisplay() }} - </span> - </b-field> - - </b-field> - - <b-field grouped> - - <b-field label="Quantity" horizontal> - <numeric-input v-model="productQuantity" - style="width: 5rem;"> - </numeric-input> - </b-field> - - <b-select v-model="productUOM"> - <option v-for="choice in productUnitChoices" - :key="choice.key" - :value="choice.key" - v-html="choice.value"> - </option> - </b-select> - - </b-field> - - <b-field grouped> - % if allow_item_discounts: - <b-field label="Discount" horizontal> - <div class="level"> - <div class="level-item"> - <numeric-input v-model="productDiscountPercent" - style="width: 5rem;" - :disabled="!allowItemDiscount"> - </numeric-input> - </div> - <div class="level-item"> - <span> %</span> - </div> - </div> + <div style="flex-grow: 1;"> + <b-field grouped> + <b-field label="Product" horizontal> + <span :class="productIsKnown ? null : 'has-text-success'" + ## nb. hack to force refresh for vue3 + :key="refreshProductDescription"> + {{ productIsKnown ? productDisplay : (pendingProduct.brand_name || '') + ' ' + (pendingProduct.description || '') + ' ' + (pendingProduct.size || '') }} + </span> </b-field> - % endif - <b-field label="Total Price" horizontal expanded> - <span :class="productSalePriceDisplay ? 'has-background-warning': null"> - {{ getItemTotalPriceDisplay() }} - </span> - </b-field> - </b-field> + </b-field> - </b-tab-item> - </b-tabs> + <b-field grouped> + + <b-field label="Unit Size"> + <span :class="productIsKnown ? null : 'has-text-success'"> + {{ productIsKnown ? productSize : pendingProduct.size }} + </span> + </b-field> + + <b-field label="Reg. Price" + v-if="productSalePriceDisplay"> + <span> + {{ productUnitRegularPriceDisplay }} + </span> + </b-field> + + <b-field label="Unit Price" + v-if="!productSalePriceDisplay"> + <span :class="productIsKnown ? null : 'has-text-success'" + % if product_price_may_be_questionable: + :class="productPriceNeedsConfirmation ? 'has-background-warning' : ''" + % endif + > + {{ productIsKnown ? productUnitPriceDisplay : (pendingProduct.regular_price_amount ? '$' + pendingProduct.regular_price_amount : '') }} + </span> + </b-field> + + <b-field label="Sale Price" + v-if="productSalePriceDisplay"> + <span class="has-background-warning" + :class="productIsKnown ? null : 'has-text-success'"> + {{ productSalePriceDisplay }} + </span> + </b-field> + + <b-field label="Sale Ends" + v-if="productSaleEndsDisplay"> + <span class="has-background-warning" + :class="productIsKnown ? null : 'has-text-success'"> + {{ productSaleEndsDisplay }} + </span> + </b-field> + + <b-field label="Case Size"> + <span :class="productIsKnown ? null : 'has-text-success'"> + {{ productIsKnown ? productCaseQuantity : pendingProduct.case_size }} + </span> + </b-field> + + <b-field label="Case Price"> + <span + % if product_price_may_be_questionable: + :class="{'has-text-success': !productIsKnown, 'has-background-warning': productPriceNeedsConfirmation || productSalePriceDisplay}" + % else: + :class="{'has-text-success': !productIsKnown, 'has-background-warning': !!productSalePriceDisplay}" + % endif + > + {{ getCasePriceDisplay() }} + </span> + </b-field> + + </b-field> + + <b-field grouped> + + <b-field label="Quantity" horizontal> + <numeric-input v-model="productQuantity" + @input="refreshTotalPrice += 1" + style="width: 5rem;"> + </numeric-input> + </b-field> + + <b-select v-model="productUOM" + @input="refreshTotalPrice += 1"> + <option v-for="choice in productUnitChoices" + :key="choice.key" + :value="choice.key" + v-html="choice.value"> + </option> + </b-select> + + </b-field> + + <div style="display: flex; gap: 1rem;"> + % if allow_item_discounts: + <b-field label="Discount" horizontal> + <div class="level"> + <div class="level-item"> + <numeric-input v-model="productDiscountPercent" + @input="refreshTotalPrice += 1" + style="width: 5rem;" + :disabled="!allowItemDiscount"> + </numeric-input> + </div> + <div class="level-item"> + <span> %</span> + </div> + </div> + </b-field> + % endif + <b-field label="Total Price" horizontal expanded + :key="refreshTotalPrice"> + <span :class="productSalePriceDisplay ? 'has-background-warning': null"> + {{ getItemTotalPriceDisplay() }} + </span> + </b-field> + </div> + + <!-- <b-field grouped> --> + <!-- </b-field> --> + </div> + + <!-- <div class="is-pulled-right has-text-centered"> --> + <img :src="productImageURL" + style="max-height: 150px; max-width: 150px; "/> + <!-- </div> --> + + </div> + + </${b}-tab-item> + </${b}-tabs> <div class="buttons"> <b-button @click="showingItemDialog = false"> @@ -886,11 +963,16 @@ </div> </div> - </b-modal> + </${b}-modal> % if unknown_product_confirm_price: - <b-modal has-modal-card - :active.sync="confirmPriceShowDialog"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="confirmPriceShowDialog" + % else: + :active.sync="confirmPriceShowDialog" + % endif + > <div class="modal-card"> <header class="modal-card-head"> @@ -931,87 +1013,97 @@ </b-button> </footer> </div> - </b-modal> + </${b}-modal> % endif % if allow_past_item_reorder: - <b-modal :active.sync="pastItemsShowDialog"> + <${b}-modal + % if request.use_oruga: + v-model:active="pastItemsShowDialog" + % else: + :active.sync="pastItemsShowDialog" + % endif + > <div class="card"> <div class="card-content"> - <b-table :data="pastItems" - icon-pack="fas" - :loading="pastItemsLoading" - :selected.sync="pastItemsSelected" - sortable - paginated - per-page="5" - :debounce-search="1000"> + <${b}-table :data="pastItems" + icon-pack="fas" + :loading="pastItemsLoading" + % if request.use_oruga: + v-model:selected="pastItemsSelected" + % else: + :selected.sync="pastItemsSelected" + % endif + sortable + paginated + per-page="5" + :debounce-search="1000"> - <b-table-column :label="productKeyLabel" + <${b}-table-column :label="productKeyLabel" field="key" v-slot="props" sortable> {{ props.row.key }} - </b-table-column> + </${b}-table-column> - <b-table-column label="Brand" + <${b}-table-column label="Brand" field="brand_name" v-slot="props" sortable searchable> {{ props.row.brand_name }} - </b-table-column> + </${b}-table-column> - <b-table-column label="Description" + <${b}-table-column label="Description" field="description" v-slot="props" sortable searchable> {{ props.row.description }} {{ props.row.size }} - </b-table-column> + </${b}-table-column> - <b-table-column label="Unit Price" + <${b}-table-column label="Unit Price" field="unit_price" v-slot="props" sortable> {{ props.row.unit_price_display }} - </b-table-column> + </${b}-table-column> - <b-table-column label="Sale Price" + <${b}-table-column label="Sale Price" field="sale_price" v-slot="props" sortable> <span class="has-background-warning"> {{ props.row.sale_price_display }} </span> - </b-table-column> + </${b}-table-column> - <b-table-column label="Sale Ends" + <${b}-table-column label="Sale Ends" field="sale_ends" v-slot="props" sortable> <span class="has-background-warning"> {{ props.row.sale_ends_display }} </span> - </b-table-column> + </${b}-table-column> - <b-table-column label="Department" + <${b}-table-column label="Department" field="department_name" v-slot="props" sortable searchable> {{ props.row.department_name }} - </b-table-column> + </${b}-table-column> - <b-table-column label="Vendor" + <${b}-table-column label="Vendor" field="vendor_name" v-slot="props" sortable searchable> {{ props.row.vendor_name }} - </b-table-column> + </${b}-table-column> <template #empty> <div class="content has-text-grey has-text-centered"> @@ -1025,7 +1117,7 @@ <p>Nothing here.</p> </div> </template> - </b-table> + </${b}-table> <div class="buttons"> <b-button @click="pastItemsShowDialog = false"> @@ -1042,44 +1134,44 @@ </div> </div> - </b-modal> + </${b}-modal> % endif - <b-table v-if="items.length" + <${b}-table v-if="items.length" :data="items" :row-class="(row, i) => row.product_uuid ? null : 'has-text-success'"> - <b-table-column :label="productKeyLabel" + <${b}-table-column :label="productKeyLabel" v-slot="props"> {{ props.row.product_key }} - </b-table-column> + </${b}-table-column> - <b-table-column label="Brand" + <${b}-table-column label="Brand" v-slot="props"> {{ props.row.product_brand }} - </b-table-column> + </${b}-table-column> - <b-table-column label="Description" + <${b}-table-column label="Description" v-slot="props"> {{ props.row.product_description }} - </b-table-column> + </${b}-table-column> - <b-table-column label="Size" + <${b}-table-column label="Size" v-slot="props"> {{ props.row.product_size }} - </b-table-column> + </${b}-table-column> - <b-table-column label="Department" + <${b}-table-column label="Department" v-slot="props"> {{ props.row.department_display }} - </b-table-column> + </${b}-table-column> - <b-table-column label="Quantity" + <${b}-table-column label="Quantity" v-slot="props"> <span v-html="props.row.order_quantity_display"></span> - </b-table-column> + </${b}-table-column> - <b-table-column label="Unit Price" + <${b}-table-column label="Unit Price" v-slot="props"> <span % if product_price_may_be_questionable: @@ -1090,16 +1182,16 @@ > {{ props.row.unit_price_display }} </span> - </b-table-column> + </${b}-table-column> % if allow_item_discounts: - <b-table-column label="Discount" + <${b}-table-column label="Discount" v-slot="props"> {{ props.row.discount_percent }}{{ props.row.discount_percent ? " %" : "" }} - </b-table-column> + </${b}-table-column> % endif - <b-table-column label="Total" + <${b}-table-column label="Total" v-slot="props"> <span % if product_price_may_be_questionable: @@ -1110,35 +1202,57 @@ > {{ props.row.total_price_display }} </span> - </b-table-column> + </${b}-table-column> - <b-table-column label="Vendor" + <${b}-table-column label="Vendor" v-slot="props"> {{ props.row.vendor_display }} - </b-table-column> + </${b}-table-column> - <b-table-column field="actions" + <${b}-table-column field="actions" label="Actions" v-slot="props"> - <a href="#" class="grid-action" + <a href="#" + % if not request.use_oruga: + class="grid-action" + % endif @click.prevent="showEditItemDialog(props.row)"> - <i class="fas fa-edit"></i> - Edit + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="edit" /> + <span>Edit</span> + </span> + % else: + <i class="fas fa-edit"></i> + Edit + % endif </a> - <a href="#" class="grid-action has-text-danger" + <a href="#" + % if request.use_oruga: + class="has-text-danger" + % else: + class="grid-action has-text-danger" + % endif @click.prevent="deleteItem(props.index)"> - <i class="fas fa-trash"></i> - Delete + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="trash" /> + <span>Delete</span> + </span> + % else: + <i class="fas fa-trash"></i> + Delete + % endif </a> - </b-table-column> + </${b}-table-column> - </b-table> + </${b}-table> </div> </div> - </b-collapse> + </${b}-collapse> ${self.order_form_buttons()} @@ -1222,7 +1336,11 @@ editingItem: null, showingItemDialog: false, itemDialogSaving: false, - itemDialogTabIndex: 0, + % if request.use_oruga: + itemDialogTab: 'product', + % else: + itemDialogTabIndex: 0, + % endif % if allow_past_item_reorder: pastItemsShowDialog: false, pastItemsLoading: false, @@ -1271,6 +1389,10 @@ confirmPriceShowDialog: false, % endif + // nb. hack to force refresh for vue3 + refreshProductDescription: 1, + refreshTotalPrice: 1, + submittingOrder: false, } }, @@ -1632,22 +1754,21 @@ uuid: this.contactUUID, } } - let that = this - this.submitBatchData(params, function(response) { + this.submitBatchData(params, response => { % if new_order_requires_customer: - that.contactUUID = response.data.customer_uuid + this.contactUUID = response.data.customer_uuid % else: - that.contactUUID = response.data.person_uuid + this.contactUUID = response.data.person_uuid % endif - that.contactDisplay = response.data.contact_display - that.orderPhoneNumber = response.data.phone_number - that.orderEmailAddress = response.data.email_address - that.addOtherPhoneNumber = response.data.add_phone_number - that.addOtherEmailAddress = response.data.add_email_address - that.contactProfileURL = response.data.contact_profile_url - that.contactPhones = response.data.contact_phones - that.contactEmails = response.data.contact_emails - that.contactNotes = response.data.contact_notes + this.contactDisplay = response.data.contact_display + this.orderPhoneNumber = response.data.phone_number + this.orderEmailAddress = response.data.email_address + this.addOtherPhoneNumber = response.data.add_phone_number + this.addOtherEmailAddress = response.data.add_email_address + this.contactProfileURL = response.data.contact_profile_url + this.contactPhones = response.data.contact_phones + this.contactEmails = response.data.contact_emails + this.contactNotes = response.data.contact_notes if (callback) { callback() } @@ -1937,7 +2058,11 @@ this.productDiscountPercent = ${json.dumps(default_item_discount)|n} % endif - this.itemDialogTabIndex = 0 + % if request.use_oruga: + this.itemDialogTab = 'product' + % else: + this.itemDialogTabIndex = 0 + % endif this.showingItemDialog = true this.$nextTick(() => { this.$refs.productLookup.focus() @@ -1993,7 +2118,15 @@ this.productPriceNeedsConfirmation = false % endif - this.itemDialogTabIndex = 1 + // nb. hack to force refresh for vue3 + this.refreshProductDescription += 1 + this.refreshTotalPrice += 1 + + % if request.use_oruga: + this.itemDialogTab = 'quantity' + % else: + this.itemDialogTabIndex = 1 + % endif this.showingItemDialog = true }, @@ -2050,7 +2183,15 @@ this.productDiscountPercent = row.discount_percent % endif - this.itemDialogTabIndex = 1 + // nb. hack to force refresh for vue3 + this.refreshProductDescription += 1 + this.refreshTotalPrice += 1 + + % if request.use_oruga: + this.itemDialogTab = 'quantity' + % else: + this.itemDialogTabIndex = 1 + % endif this.showingItemDialog = true }, @@ -2160,7 +2301,15 @@ this.productPriceNeedsConfirmation = false % endif - this.itemDialogTabIndex = 1 + % if request.use_oruga: + this.itemDialogTab = 'quantity' + % else: + this.itemDialogTabIndex = 1 + % endif + + // nb. hack to force refresh for vue3 + this.refreshProductDescription += 1 + this.refreshTotalPrice += 1 }, response => { this.clearProduct() @@ -2250,6 +2399,7 @@ } Vue.component('customer-order-creator', CustomerOrderCreator) + <% request.register_component('customer-order-creator', 'CustomerOrderCreator') %> </script> </%def> diff --git a/tailbone/templates/products/lookup.mako b/tailbone/templates/products/lookup.mako index bf8e7ef5..48206de1 100644 --- a/tailbone/templates/products/lookup.mako +++ b/tailbone/templates/products/lookup.mako @@ -3,21 +3,26 @@ <%def name="tailbone_product_lookup_template()"> <script type="text/x-template" id="tailbone-product-lookup-template"> <div style="width: 100%;"> + <div style="display: flex; gap: 0.5rem;"> - <b-field grouped> - - <b-field :expanded="!product"> - <b-autocomplete ref="productAutocomplete" - v-if="!product" - v-model="autocompleteValue" - placeholder="Enter UPC or brand, description etc." - :data="autocompleteOptions" - field="value" - :custom-formatter="option => option.label" - @typing="getAutocompleteOptions" - @select="autocompleteSelected" - style="width: 100%;"> - </b-autocomplete> + <b-field :style="{'flex-grow': product ? '0' : '1'}"> + <${b}-autocomplete v-if="!product" + ref="productAutocomplete" + v-model="autocompleteValue" + expanded + placeholder="Enter UPC or brand, description etc." + :data="autocompleteOptions" + % if request.use_oruga: + @input="getAutocompleteOptions" + :formatter="option => option.label" + % else: + @typing="getAutocompleteOptions" + :custom-formatter="option => option.label" + field="value" + % endif + @select="autocompleteSelected" + style="width: 100%;"> + </${b}-autocomplete> <b-button v-if="product" @click="clearSelection(true)"> {{ product.full_description }} @@ -42,7 +47,7 @@ View Product </b-button> - </b-field> + </div> <b-modal :active.sync="lookupShowDialog"> <div class="card"> @@ -88,68 +93,72 @@ </b-field> - <b-table :data="searchResults" - narrowed - icon-pack="fas" - :loading="searchResultsLoading" - :selected.sync="searchResultSelected"> + <${b}-table :data="searchResults" + narrowed + % if request.use_oruga: + v-model:selected="searchResultSelected" + % else: + :selected.sync="searchResultSelected" + icon-pack="fas" + % endif + :loading="searchResultsLoading"> - <b-table-column label="${request.rattail_config.product_key_title()}" + <${b}-table-column label="${request.rattail_config.product_key_title()}" field="product_key" v-slot="props"> {{ props.row.product_key }} - </b-table-column> + </${b}-table-column> - <b-table-column label="Brand" + <${b}-table-column label="Brand" field="brand_name" v-slot="props"> {{ props.row.brand_name }} - </b-table-column> + </${b}-table-column> - <b-table-column label="Description" + <${b}-table-column label="Description" field="description" v-slot="props"> <span :class="{organic: props.row.organic}"> {{ props.row.description }} {{ props.row.size }} </span> - </b-table-column> + </${b}-table-column> - <b-table-column label="Unit Price" + <${b}-table-column label="Unit Price" field="unit_price" v-slot="props"> {{ props.row.unit_price_display }} - </b-table-column> + </${b}-table-column> - <b-table-column label="Sale Price" + <${b}-table-column label="Sale Price" field="sale_price" v-slot="props"> <span class="has-background-warning"> {{ props.row.sale_price_display }} </span> - </b-table-column> + </${b}-table-column> - <b-table-column label="Sale Ends" + <${b}-table-column label="Sale Ends" field="sale_ends" v-slot="props"> <span class="has-background-warning"> {{ props.row.sale_ends_display }} </span> - </b-table-column> + </${b}-table-column> - <b-table-column label="Department" + <${b}-table-column label="Department" field="department_name" v-slot="props"> {{ props.row.department_name }} - </b-table-column> + </${b}-table-column> - <b-table-column label="Vendor" + <${b}-table-column label="Vendor" field="vendor_name" v-slot="props"> {{ props.row.vendor_name }} - </b-table-column> + </${b}-table-column> - <b-table-column label="Actions" + <${b}-table-column label="Actions" v-slot="props"> <a :href="props.row.url" target="_blank" @@ -157,7 +166,7 @@ <i class="fas fa-external-link-alt"></i> View </a> - </b-table-column> + </${b}-table-column> <template #empty> <div class="content has-text-grey has-text-centered"> @@ -171,7 +180,7 @@ <p>Nothing here.</p> </div> </template> - </b-table> + </${b}-table> <br /> <div class="level"> @@ -263,7 +272,12 @@ } }, + ## TODO: add debounce for oruga? + % if request.use_oruga: + getAutocompleteOptions(entry) { + % else: getAutocompleteOptions: debounce(function (entry) { + % endif // since the `@typing` event from buefy component does not // "self-regulate" in any way, we a) use `debounce` above, @@ -282,7 +296,11 @@ this.autocompleteOptions = [] throw error }) + % if request.use_oruga: + }, + % else: }), + % endif autocompleteSelected(option) { this.$emit('selected', { @@ -359,6 +377,7 @@ } Vue.component('tailbone-product-lookup', TailboneProductLookup) + <% request.register_component('tailbone-product-lookup', 'TailboneProductLookup') %> </script> </%def> diff --git a/tailbone/templates/themes/butterball/buefy-components.mako b/tailbone/templates/themes/butterball/buefy-components.mako index 7553729b..d641cbe7 100644 --- a/tailbone/templates/themes/butterball/buefy-components.mako +++ b/tailbone/templates/themes/butterball/buefy-components.mako @@ -13,6 +13,7 @@ ${self.make_b_loading_component()} ${self.make_b_modal_component()} ${self.make_b_notification_component()} + ${self.make_b_radio_component()} ${self.make_b_select_component()} ${self.make_b_steps_component()} ${self.make_b_step_item_component()} @@ -468,6 +469,41 @@ <% request.register_component('b-notification', 'BNotification') %> </%def> +<%def name="make_b_radio_component()"> + <script type="text/x-template" id="b-radio-template"> + <o-radio v-model="orugaValue" + @update:model-value="orugaValueUpdated" + :native-value="nativeValue"> + <slot /> + </o-radio> + </script> + <script> + const BRadio = { + template: '#b-radio-template', + props: { + modelValue: null, + nativeValue: null, + }, + data() { + return { + orugaValue: this.modelValue, + } + }, + watch: { + modelValue(to, from) { + this.orugaValue = to + }, + }, + methods: { + orugaValueUpdated(value) { + this.$emit('update:modelValue', value) + }, + }, + } + </script> + <% request.register_component('b-radio', 'BRadio') %> +</%def> + <%def name="make_b_select_component()"> <script type="text/x-template" id="b-select-template"> <o-select :name="name" diff --git a/tailbone/templates/themes/butterball/field-components.mako b/tailbone/templates/themes/butterball/field-components.mako index 8c1d1d70..2b9ca342 100644 --- a/tailbone/templates/themes/butterball/field-components.mako +++ b/tailbone/templates/themes/butterball/field-components.mako @@ -112,7 +112,7 @@ @select="optionSelected" keep-first open-on-focus - expanded + :expanded="expanded" :clearable="clearable" :clear-on-select="clearOnSelect"> <template #default="{ option }"> @@ -325,6 +325,7 @@ // the selection is cleared we want user to start over // anyway this.orugaValue = null + this.fetchedData = [] // here is where we alert callers to the new value if (option) { @@ -366,6 +367,12 @@ } }, + // nb. this used to be relevant but now is here only for sake + // of backward-compatibility (for callers) + getDisplayText() { + return this.internalLabel + }, + // set focus to this component, which will just set focus // to the oruga autocomplete component focus() { From 29c9ea1a2b693927d1a1fdf41ac0add09f9bc7a1 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 3 Jun 2024 10:28:42 -0500 Subject: [PATCH 1450/1681] Update changelog --- CHANGES.rst | 10 ++++++++++ tailbone/_version.py | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 762ca455..ca512864 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,16 @@ CHANGELOG Unreleased ---------- +0.10.9 (2024-06-03) +------------------- + +* Let master view control context menu items for page. + +* Fix panel style for PO vs. Invoice breakdown in receiving batch. + +* Fix the "new custorder" page for butterball. + + 0.10.8 (2024-06-02) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index e6aa0601..bc09b216 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.10.8' +__version__ = '0.10.9' From 30238528fe8d08d04a3c8966fa1223bde82a58ff Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 3 Jun 2024 10:48:59 -0500 Subject: [PATCH 1451/1681] Fix focus for `<b-select>` shim component --- tailbone/templates/themes/butterball/buefy-components.mako | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tailbone/templates/themes/butterball/buefy-components.mako b/tailbone/templates/themes/butterball/buefy-components.mako index d641cbe7..f44f30ad 100644 --- a/tailbone/templates/themes/butterball/buefy-components.mako +++ b/tailbone/templates/themes/butterball/buefy-components.mako @@ -507,6 +507,7 @@ <%def name="make_b_select_component()"> <script type="text/x-template" id="b-select-template"> <o-select :name="name" + ref="select" v-model="orugaValue" @update:model-value="orugaValueUpdated" :expanded="expanded" @@ -545,6 +546,9 @@ }, }, methods: { + focus() { + this.$refs.select.focus() + }, orugaValueUpdated(value) { this.$emit('update:modelValue', value) this.$emit('input', value) From b27987f1d1e12ad7555d48c763e42ca0413dde5e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 3 Jun 2024 11:15:22 -0500 Subject: [PATCH 1452/1681] More butterball fixes for "view profile" template --- tailbone/templates/people/view_profile.mako | 129 +++++++++++++++----- 1 file changed, 96 insertions(+), 33 deletions(-) diff --git a/tailbone/templates/people/view_profile.mako b/tailbone/templates/people/view_profile.mako index bf94b7fa..c5c86806 100644 --- a/tailbone/templates/people/view_profile.mako +++ b/tailbone/templates/people/view_profile.mako @@ -404,33 +404,53 @@ % if request.has_perm('people_profile.edit_person'): <${b}-table-column label="Actions" v-slot="props"> - <a class="grid-action" - href="#" @click.prevent="editPhoneInit(props.row)"> + <a href="#" @click.prevent="editPhoneInit(props.row)" + % if not request.use_oruga: + class="grid-action" + % endif + > % if request.use_oruga: - <o-icon icon="edit" /> + <span class="icon-text"> + <o-icon icon="edit" /> + <span>Edit</span> + </span> % else: <i class="fas fa-edit"></i> + Edit % endif - Edit </a> - <a class="grid-action has-text-danger" - href="#" @click.prevent="deletePhoneInit(props.row)"> + <a href="#" @click.prevent="deletePhoneInit(props.row)" + % if request.use_oruga: + class="has-text-danger" + % else: + class="grid-action has-text-danger" + % endif + > % if request.use_oruga: - <o-icon icon="trash" /> + <span class="icon-text"> + <o-icon icon="trash" /> + <span>Delete</span> + </span> % else: <i class="fas fa-trash"></i> + Delete % endif - Delete </a> - <a class="grid-action" + <a v-if="!props.row.preferred" href="#" @click.prevent="preferPhoneInit(props.row)" - v-if="!props.row.preferred"> + % if not request.use_oruga: + class="grid-action" + % endif + > % if request.use_oruga: - <o-icon icon="star" /> + <span class="icon-text"> + <o-icon icon="star" /> + <span>Set Preferred</span> + </span> % else: <i class="fas fa-star"></i> + Set Preferred % endif - Set Preferred </a> </${b}-table-column> % endif @@ -618,33 +638,52 @@ % if request.has_perm('people_profile.edit_person'): <${b}-table-column label="Actions" v-slot="props"> - <a class="grid-action" - href="#" @click.prevent="editEmailInit(props.row)"> + <a href="#" @click.prevent="editEmailInit(props.row)" + % if not request.use_oruga: + class="grid-action" + % endif + > % if request.use_oruga: - <o-icon icon="edit" /> + <span class="icon-text"> + <o-icon icon="edit" /> + <span>Edit</span> + </span> % else: <i class="fas fa-edit"></i> + Edit % endif - Edit </a> - <a class="grid-action has-text-danger" - href="#" @click.prevent="deleteEmailInit(props.row)"> + <a href="#" @click.prevent="deleteEmailInit(props.row)" + % if request.use_oruga: + class="has-text-danger" + % else: + class="grid-action has-text-danger" + % endif + > % if request.use_oruga: - <o-icon icon="trash" /> + <span class="icon-text"> + <o-icon icon="trash" /> + <span>Delete</span> + </span> % else: <i class="fas fa-trash"></i> + Delete % endif - Delete </a> - <a class="grid-action" - href="#" @click.prevent="preferEmailInit(props.row)" - v-if="!props.row.preferred"> + <a v-if="!props.row.preferred" + % if not request.use_oruga: + class="grid-action" + % endif + href="#" @click.prevent="preferEmailInit(props.row)"> % if request.use_oruga: - <o-icon icon="star" /> + <span class="icon-text"> + <o-icon icon="star" /> + <span>Set Preferred</span> + </span> % else: <i class="fas fa-star"></i> + Set Preferred % endif - Set Preferred </a> </${b}-table-column> % endif @@ -1200,8 +1239,15 @@ label="Actions" v-slot="props"> <a href="#" @click.prevent="editEmployeeHistoryInit(props.row)"> - <i class="fas fa-edit"></i> - Edit + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="edit" /> + <span>Edit</span> + </span> + % else: + <i class="fas fa-edit"></i> + Edit + % endif </a> </${b}-table-column> % endif @@ -1428,15 +1474,30 @@ v-slot="props"> % if request.has_perm('people_profile.edit_note'): <a href="#" @click.prevent="editNoteInit(props.row)"> - <i class="fas fa-edit"></i> - Edit + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="edit" /> + <span>Edit</span> + </span> + % else: + <i class="fas fa-edit"></i> + Edit + % endif </a> % endif % if request.has_perm('people_profile.delete_note'): <a href="#" @click.prevent="deleteNoteInit(props.row)" class="has-text-danger"> - <i class="fas fa-trash"></i> - Delete + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="trash" /> + <span>Delete</span> + </span> + % else: + <i class="fas fa-trash"></i> + Delete + % endif + </a> % endif </${b}-table-column> @@ -1477,14 +1538,16 @@ <b-field label="Subject"> <b-input v-model.trim="editNoteSubject" - :disabled="editNoteDelete"> + :disabled="editNoteDelete" + expanded> </b-input> </b-field> <b-field label="Text"> <b-input v-model.trim="editNoteText" type="textarea" - :disabled="editNoteDelete"> + :disabled="editNoteDelete" + expanded> </b-input> </b-field> From ab523719a6b8e42265993383c0dbb8b7b44bf9d8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 3 Jun 2024 11:16:33 -0500 Subject: [PATCH 1453/1681] Update changelog --- CHANGES.rst | 8 ++++++++ tailbone/_version.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index ca512864..012e6ff3 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,14 @@ CHANGELOG Unreleased ---------- +0.10.10 (2024-06-03) +-------------------- + +* Fix focus for ``<b-select>`` shim component. + +* More butterball fixes for "view profile" template. + + 0.10.9 (2024-06-03) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index bc09b216..37b06700 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.10.9' +__version__ = '0.10.10' From 9243edf7afea44922acc7f9a9c9ee66249e30be5 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 3 Jun 2024 16:51:29 -0500 Subject: [PATCH 1454/1681] Fix vue3 refresh for name, address cards in profile view --- tailbone/templates/people/view_profile.mako | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/people/view_profile.mako b/tailbone/templates/people/view_profile.mako index c5c86806..467371aa 100644 --- a/tailbone/templates/people/view_profile.mako +++ b/tailbone/templates/people/view_profile.mako @@ -78,7 +78,9 @@ </%def> <%def name="render_personal_name_card()"> - <div class="card personal"> + <div class="card personal" + ## nb. hack to force refresh for vue3 + :key="refreshPersonalCard"> <header class="card-header"> <p class="card-header-title">Name</p> </header> @@ -184,7 +186,9 @@ </%def> <%def name="render_personal_address_card()"> - <div class="card personal"> + <div class="card personal" + ## nb. hack to force refresh for vue3 + :key="refreshAddressCard"> <header class="card-header"> <p class="card-header-title">Address</p> </header> @@ -1822,6 +1826,10 @@ let PersonalTabData = { refreshTabURL: '${url('people.profile_tab_personal', uuid=person.uuid)}', + // nb. hack to force refresh for vue3 + refreshPersonalCard: 1, + refreshAddressCard: 1, + % if request.has_perm('people_profile.edit_person'): editNameShowDialog: false, editNameFirst: null, @@ -1971,6 +1979,8 @@ this.editNameShowDialog = false this.refreshTab() this.editNameSaving = false + // nb. hack to force refresh for vue3 + this.refreshPersonalCard += 1 }, response => { this.editNameSaving = false }) @@ -2002,6 +2012,8 @@ this.$emit('profile-changed', response.data) this.editAddressShowDialog = false this.refreshTab() + // nb. hack to force refresh for vue3 + this.refreshAddressCard += 1 }) }, From 0303014acb700bf22336be26380aabf724572e8e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 3 Jun 2024 17:37:11 -0500 Subject: [PATCH 1455/1681] Fix vue3 refresh for employee tab of profile view and misc. related cleanup --- tailbone/templates/people/view_profile.mako | 257 ++++++++++++------ .../themes/butterball/field-components.mako | 3 + 2 files changed, 175 insertions(+), 85 deletions(-) diff --git a/tailbone/templates/people/view_profile.mako b/tailbone/templates/people/view_profile.mako index 467371aa..3520d924 100644 --- a/tailbone/templates/people/view_profile.mako +++ b/tailbone/templates/people/view_profile.mako @@ -1155,70 +1155,72 @@ <div v-if="employee.uuid"> - <b-field horizontal label="Employee ID"> - <div class="level"> - <div class="level-left"> - <div class="level-item"> - <span>{{ employee.id }}</span> + <div :key="refreshEmployeeCard"> + <b-field horizontal label="Employee ID"> + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <span>{{ employee.id }}</span> + </div> + % if request.has_perm('employees.edit'): + <div class="level-item"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="edit" + @click="editEmployeeIdInit()"> + Edit ID + </b-button> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="editEmployeeIdShowDialog" + % else: + :active.sync="editEmployeeIdShowDialog" + % endif + > + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Employee ID</p> + </header> + + <section class="modal-card-body"> + <b-field label="Employee ID"> + <b-input v-model="editEmployeeIdValue"></b-input> + </b-field> + </section> + + <footer class="modal-card-foot"> + <b-button @click="editEmployeeIdShowDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + icon-pack="fas" + icon-left="save" + :disabled="editEmployeeIdSaving" + @click="editEmployeeIdSave()"> + {{ editEmployeeIdSaving ? "Working, please wait..." : "Save" }} + </b-button> + </footer> + </div> + </${b}-modal> + </div> + % endif </div> - % if request.has_perm('employees.edit'): - <div class="level-item"> - <b-button type="is-primary" - icon-pack="fas" - icon-left="edit" - @click="editEmployeeIdInit()"> - Edit ID - </b-button> - <${b}-modal has-modal-card - % if request.use_oruga: - v-model:active="editEmployeeIdShowDialog" - % else: - :active.sync="editEmployeeIdShowDialog" - % endif - > - <div class="modal-card"> - - <header class="modal-card-head"> - <p class="modal-card-title">Employee ID</p> - </header> - - <section class="modal-card-body"> - <b-field label="Employee ID"> - <b-input v-model="editEmployeeIdValue"></b-input> - </b-field> - </section> - - <footer class="modal-card-foot"> - <b-button @click="editEmployeeIdShowDialog = false"> - Cancel - </b-button> - <b-button type="is-primary" - icon-pack="fas" - icon-left="save" - :disabled="editEmployeeIdSaving" - @click="editEmployeeIdSave()"> - {{ editEmployeeIdSaving ? "Working, please wait..." : "Save" }} - </b-button> - </footer> - </div> - </${b}-modal> - </div> - % endif </div> - </div> - </b-field> + </b-field> - <b-field horizontal label="Employee Status"> - <span>{{ employee.status_display }}</span> - </b-field> + <b-field horizontal label="Employee Status"> + <span>{{ employee.status_display }}</span> + </b-field> - <b-field horizontal label="Start Date"> - <span>{{ employee.start_date }}</span> - </b-field> + <b-field horizontal label="Start Date"> + <span>{{ employee.start_date }}</span> + </b-field> - <b-field horizontal label="End Date"> - <span>{{ employee.end_date }}</span> - </b-field> + <b-field horizontal label="End Date"> + <span>{{ employee.end_date }}</span> + </b-field> + </div> <br /> <p><strong>Employee History</strong></p> @@ -1301,7 +1303,8 @@ <b-input v-model="startEmployeeID"></b-input> </b-field> <b-field label="Start Date"> - <tailbone-datepicker v-model="startEmployeeStartDate"></tailbone-datepicker> + <tailbone-datepicker v-model="startEmployeeStartDate" + ref="startEmployeeStartDate" /> </b-field> </section> @@ -1309,11 +1312,13 @@ <b-button @click="startEmployeeShowDialog = false"> Cancel </b-button> - <once-button type="is-primary" - @click="startEmployeeSave()" - :disabled="!startEmployeeStartDate" - text="Save"> - </once-button> + <b-button type="is-primary" + @click="startEmployeeSave()" + :disabled="startEmployeeSaveDisabled" + icon-pack="fas" + icon-left="save"> + {{ startEmployeeSaving ? "Working, please wait..." : "Save" }} + </b-button> </footer> </div> </${b}-modal> @@ -1346,11 +1351,13 @@ <b-button @click="stopEmployeeShowDialog = false"> Cancel </b-button> - <once-button type="is-primary" - @click="stopEmployeeSave()" - :disabled="!stopEmployeeEndDate" - text="Save"> - </once-button> + <b-button type="is-primary" + @click="stopEmployeeSave()" + :disabled="stopEmployeeSaveDisabled" + icon-pack="fas" + icon-left="save"> + {{ stopEmployeeSaving ? "Working, please wait..." : "Save" }} + </b-button> </footer> </div> </${b}-modal> @@ -1385,11 +1392,13 @@ <b-button @click="editEmployeeHistoryShowDialog = false"> Cancel </b-button> - <once-button type="is-primary" - @click="editEmployeeHistorySave()" - :disabled="!editEmployeeHistoryStartDate || (editEmployeeHistoryEndDateRequired && !editEmployeeHistoryEndDate)" - text="Save"> - </once-button> + <b-button type="is-primary" + @click="editEmployeeHistorySave()" + :disabled="editEmployeeHistorySaveDisabled" + icon-pack="fas" + icon-left="save"> + {{ editEmployeeHistorySaving ? "Working, please wait..." : "Save" }} + </b-button> </footer> </div> </${b}-modal> @@ -2351,6 +2360,9 @@ employee: {}, employeeHistory: [], + // nb. hack to force refresh for vue3 + refreshEmployeeCard: 1, + % if request.has_perm('employees.edit'): editEmployeeIdShowDialog: false, editEmployeeIdValue: null, @@ -2361,10 +2373,12 @@ startEmployeeShowDialog: false, startEmployeeID: null, startEmployeeStartDate: null, + startEmployeeSaving: false, stopEmployeeShowDialog: false, stopEmployeeEndDate: null, stopEmployeeRevokeAccess: false, + stopEmployeeSaving: false, % endif % if request.has_perm('people_profile.edit_employee_history'): @@ -2373,6 +2387,7 @@ editEmployeeHistoryStartDate: null, editEmployeeHistoryEndDate: null, editEmployeeHistoryEndDateRequired: false, + editEmployeeHistorySaving: false, % endif } @@ -2382,11 +2397,56 @@ props: { person: Object, }, - computed: {}, + computed: { + + % if request.has_perm('people_profile.toggle_employee'): + + startEmployeeSaveDisabled() { + if (this.startEmployeeSaving) { + return true + } + if (!this.startEmployeeStartDate) { + return true + } + return false + }, + + stopEmployeeSaveDisabled() { + if (this.stopEmployeeSaving) { + return true + } + if (!this.stopEmployeeEndDate) { + return true + } + return false + }, + + % endif + + % if request.has_perm('people_profile.edit_employee_history'): + + editEmployeeHistorySaveDisabled() { + if (this.editEmployeeHistorySaving) { + return true + } + if (!this.editEmployeeHistoryStartDate) { + return true + } + if (this.editEmployeeHistoryEndDateRequired && !this.editEmployeeHistoryEndDate) { + return true + } + return false + }, + + % endif + + }, methods: { refreshTabSuccess(response) { this.employee = response.data.employee + // nb. hack to force refresh for vue3 + this.refreshEmployeeCard += 1 this.employeeHistory = response.data.employee_history }, @@ -2401,7 +2461,7 @@ this.editEmployeeIdSaving = true let url = '${url('people.profile_update_employee_id', uuid=instance.uuid)}' let params = { - 'employee_id': this.editEmployeeIdValue, + 'employee_id': this.editEmployeeIdValue || null, } this.simplePOST(url, params, response => { this.$emit('profile-changed', response.data) @@ -2424,34 +2484,52 @@ }, startEmployeeSave() { - let url = '${url('people.profile_start_employee', uuid=person.uuid)}' - let params = { + this.startEmployeeSaving = true + const url = '${url('people.profile_start_employee', uuid=person.uuid)}' + const params = { id: this.startEmployeeID, - start_date: this.startEmployeeStartDate, + % if request.use_oruga: + start_date: this.$refs.startEmployeeStartDate.formatDate(this.startEmployeeStartDate), + % else: + start_date: this.startEmployeeStartDate, + % endif } this.simplePOST(url, params, response => { this.$emit('profile-changed', response.data) this.startEmployeeShowDialog = false this.refreshTab() + this.startEmployeeSaving = false + }, response => { + this.startEmployeeSaving = false }) }, stopEmployeeInit() { + this.stopEmployeeEndDate = null + this.stopEmployeeRevokeAccess = false this.stopEmployeeShowDialog = true }, stopEmployeeSave() { - let url = '${url('people.profile_end_employee', uuid=person.uuid)}' - let params = { - end_date: this.stopEmployeeEndDate, + this.stopEmployeeSaving = true + const url = '${url('people.profile_end_employee', uuid=person.uuid)}' + const params = { + % if request.use_oruga: + end_date: this.$refs.startEmployeeStartDate.formatDate(this.stopEmployeeEndDate), + % else: + end_date: this.stopEmployeeEndDate, + % endif revoke_access: this.stopEmployeeRevokeAccess, } this.simplePOST(url, params, response => { this.$emit('profile-changed', response.data) this.stopEmployeeShowDialog = false + this.stopEmployeeSaving = false this.refreshTab() + }, response => { + this.stopEmployeeSaving = false }) }, @@ -2468,17 +2546,26 @@ }, editEmployeeHistorySave() { + this.editEmployeeHistorySaving = true let url = '${url('people.profile_edit_employee_history', uuid=person.uuid)}' let params = { uuid: this.editEmployeeHistoryUUID, - start_date: this.editEmployeeHistoryStartDate, - end_date: this.editEmployeeHistoryEndDate, + % if request.use_oruga: + start_date: this.$refs.startEmployeeStartDate.formatDate(this.editEmployeeHistoryStartDate), + end_date: this.$refs.startEmployeeStartDate.formatDate(this.editEmployeeHistoryEndDate), + % else: + start_date: this.editEmployeeHistoryStartDate, + end_date: this.editEmployeeHistoryEndDate, + % endif } this.simplePOST(url, params, response => { this.$emit('profile-changed', response.data) this.editEmployeeHistoryShowDialog = false this.refreshTab() + this.editEmployeeHistorySaving = false + }, response => { + this.editEmployeeHistorySaving = false }) }, diff --git a/tailbone/templates/themes/butterball/field-components.mako b/tailbone/templates/themes/butterball/field-components.mako index 2b9ca342..d79c88f4 100644 --- a/tailbone/templates/themes/butterball/field-components.mako +++ b/tailbone/templates/themes/butterball/field-components.mako @@ -434,6 +434,9 @@ if (date === null) { return null } + if (typeof(date) == 'string') { + return date + } // just need to convert to simple ISO date format here, seems // like there should be a more obvious way to do that? var year = date.getFullYear() From 2791e1c385fd48d7a1602bca7b5bc8319d82033d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 3 Jun 2024 19:51:16 -0500 Subject: [PATCH 1456/1681] Fix grid bug for tempmon appliance view, per oruga --- tailbone/views/tempmon/core.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/tempmon/core.py b/tailbone/views/tempmon/core.py index 98fe9199..d551d6e6 100644 --- a/tailbone/views/tempmon/core.py +++ b/tailbone/views/tempmon/core.py @@ -78,6 +78,7 @@ class MasterView(views.MasterView): factory = self.get_grid_factory() g = factory( key='{}.probes'.format(route_prefix), + request=self.request, data=[], columns=[ 'description', From 2498da390948f626263c4438eb00d3392dcc60de Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 3 Jun 2024 20:04:01 -0500 Subject: [PATCH 1457/1681] Fix ordering worksheet generator, per butterball --- tailbone/templates/reports/ordering.mako | 38 ++++++++++++++++-------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/tailbone/templates/reports/ordering.mako b/tailbone/templates/reports/ordering.mako index 84e9b819..1e526792 100644 --- a/tailbone/templates/reports/ordering.mako +++ b/tailbone/templates/reports/ordering.mako @@ -18,35 +18,46 @@ <tailbone-autocomplete v-model="vendorUUID" service-url="${url('vendors.autocomplete')}" name="vendor" - @input="vendorChanged"> + expanded + % if request.use_oruga: + @update:model-value="vendorChanged" + % else: + @input="vendorChanged" + % endif + > </tailbone-autocomplete> </b-field> <b-field label="Departments"> - <b-table v-if="fetchedDepartments" - :data="departments" - narrowed - checkable - :checked-rows.sync="checkedDepartments" - :loading="fetchingDepartments"> + <${b}-table v-if="fetchedDepartments" + :data="departments" + narrowed + checkable + % if request.use_oruga: + v-model:checked-rows="checkedDepartments" + % else: + :checked-rows.sync="checkedDepartments" + % endif + :loading="fetchingDepartments"> - <b-table-column field="number" + <${b}-table-column field="number" label="Number" v-slot="props"> {{ props.row.number }} - </b-table-column> + </${b}-table-column> - <b-table-column field="name" + <${b}-table-column field="name" label="Name" v-slot="props"> {{ props.row.name }} - </b-table-column> + </${b}-table-column> - </b-table> + </${b}-table> </b-field> <b-field> - <b-checkbox name="preferred_only" :value="true" + <b-checkbox name="preferred_only" + v-model="preferredVendorOnly" native-value="1"> Only include products for which this vendor is preferred. </b-checkbox> @@ -77,6 +88,7 @@ ThisPageData.vendorUUID = null ThisPageData.departments = [] ThisPageData.checkedDepartments = [] + ThisPageData.preferredVendorOnly = true ThisPageData.fetchingDepartments = false ThisPageData.fetchedDepartments = false From 30a8b8e5e4ffab4f568855659c07212185ddc215 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 3 Jun 2024 20:07:42 -0500 Subject: [PATCH 1458/1681] Fix inventory worksheet generator, per butterball --- tailbone/templates/reports/inventory.mako | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/reports/inventory.mako b/tailbone/templates/reports/inventory.mako index 6c6e739f..f051959f 100644 --- a/tailbone/templates/reports/inventory.mako +++ b/tailbone/templates/reports/inventory.mako @@ -1,4 +1,4 @@ -## -*- coding: utf-8 -*- +## -*- coding: utf-8; -*- <%inherit file="/page.mako" /> <%def name="title()">Inventory Worksheet</%def> @@ -29,7 +29,8 @@ </b-field> <b-field> - <b-checkbox name="exclude-not-for-sale" :value="true" + <b-checkbox name="exclude-not-for-sale" + v-model="excludeNotForSale" native-value="1"> Exclude items marked "not for sale". </b-checkbox> @@ -52,6 +53,7 @@ <script type="text/javascript"> ThisPageData.departments = ${json.dumps([{'uuid': d.uuid, 'name': d.name} for d in departments])|n} + ThisPageData.excludeNotForSale = true </script> </%def> From e17ef2edd892cf161afbf2f432327b6e5ba61b9b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 3 Jun 2024 21:16:42 -0500 Subject: [PATCH 1459/1681] Update changelog --- CHANGES.rst | 12 ++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 012e6ff3..a3be0af8 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,18 @@ CHANGELOG Unreleased ---------- +0.10.11 (2024-06-03) +-------------------- + +* Fix vue3 refresh bugs for various views. + +* Fix grid bug for tempmon appliance view, per oruga. + +* Fix ordering worksheet generator, per butterball. + +* Fix inventory worksheet generator, per butterball. + + 0.10.10 (2024-06-03) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 37b06700..d24c3b8e 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.10.10' +__version__ = '0.10.11' From efe477d0db86ffc361c5318dfadcbc5387b235a0 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 3 Jun 2024 23:13:25 -0500 Subject: [PATCH 1460/1681] Require pyramid 2.x; remove 1.x-style auth policies --- setup.cfg | 2 +- tailbone/app.py | 11 ++------ tailbone/auth.py | 70 ++-------------------------------------------- tailbone/webapi.py | 11 ++------ 4 files changed, 8 insertions(+), 86 deletions(-) diff --git a/setup.cfg b/setup.cfg index 48cc994a..811afc17 100644 --- a/setup.cfg +++ b/setup.cfg @@ -56,7 +56,7 @@ install_requires = paginate_sqlalchemy passlib Pillow - pyramid + pyramid>=2 pyramid_beaker pyramid_deform pyramid_exclog diff --git a/tailbone/app.py b/tailbone/app.py index 0519f35b..5ca4c5c9 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -39,7 +39,7 @@ from pyramid.authentication import SessionAuthenticationPolicy from zope.sqlalchemy import register import tailbone.db -from tailbone.auth import TailboneAuthorizationPolicy +from tailbone.auth import TailboneSecurityPolicy from tailbone.config import csrf_token_name, csrf_header_name from tailbone.util import get_effective_theme, get_theme_template_path from tailbone.providers import get_all_providers @@ -136,14 +136,7 @@ def make_pyramid_config(settings, configure_csrf=True): config.registry['rattail_config'] = rattail_config # configure user authorization / authentication - # TODO: security policy should become the default, for pyramid 2.x - if rattail_config.getbool('tailbone', 'pyramid.use_security_policy', - usedb=False, default=False): - from tailbone.auth import TailboneSecurityPolicy - config.set_security_policy(TailboneSecurityPolicy()) - else: - config.set_authorization_policy(TailboneAuthorizationPolicy()) - config.set_authentication_policy(SessionAuthenticationPolicy()) + config.set_security_policy(TailboneSecurityPolicy()) # maybe require CSRF token protection if configure_csrf: diff --git a/tailbone/auth.py b/tailbone/auth.py index 0a5bd903..5a35caa6 100644 --- a/tailbone/auth.py +++ b/tailbone/auth.py @@ -30,9 +30,9 @@ import re from rattail.util import prettify, NOTSET from zope.interface import implementer -from pyramid.interfaces import IAuthorizationPolicy -from pyramid.security import remember, forget, Everyone, Authenticated -from pyramid.authentication import SessionAuthenticationPolicy +from pyramid.authentication import SessionAuthenticationHelper +from pyramid.request import RequestLocalCache +from pyramid.security import remember, forget from tailbone.db import Session @@ -90,73 +90,9 @@ def set_session_timeout(request, timeout): request.session['_timeout'] = timeout or None -class TailboneAuthenticationPolicy(SessionAuthenticationPolicy): - """ - Custom authentication policy for Tailbone. - - This is mostly Pyramid's built-in session-based policy, but adds - logic to accept Rattail User API Tokens in lieu of current user - being identified via the session. - - Note that the traditional Tailbone web app does *not* use this - policy, only the Tailbone web API uses it by default. - """ - - def unauthenticated_userid(self, request): - - # figure out userid from header token if present - credentials = request.headers.get('Authorization') - if credentials: - match = re.match(r'^Bearer (\S+)$', credentials) - if match: - token = match.group(1) - rattail_config = request.registry.settings.get('rattail_config') - app = rattail_config.get_app() - auth = app.get_auth_handler() - user = auth.authenticate_user_token(Session(), token) - if user: - return user.uuid - - # otherwise do normal session-based logic - return super().unauthenticated_userid(request) - - -@implementer(IAuthorizationPolicy) -class TailboneAuthorizationPolicy(object): - - def permits(self, context, principals, permission): - config = context.request.rattail_config - model = config.get_model() - app = config.get_app() - auth = app.get_auth_handler() - - for userid in principals: - if userid not in (Everyone, Authenticated): - if context.request.user and context.request.user.uuid == userid: - return context.request.has_perm(permission) - else: - # this is pretty rare, but can happen in dev after - # re-creating the database, which means new user uuids. - # TODO: the odds of this query returning a user in that - # case, are probably nil, and we should just skip this bit? - user = Session.get(model.User, userid) - if user: - if auth.has_permission(Session(), user, permission): - return True - if Everyone in principals: - return auth.has_permission(Session(), None, permission) - return False - - def principals_allowed_by_permission(self, context, permission): - raise NotImplementedError - - class TailboneSecurityPolicy: def __init__(self, api_mode=False): - from pyramid.authentication import SessionAuthenticationHelper - from pyramid.request import RequestLocalCache - self.api_mode = api_mode self.session_helper = SessionAuthenticationHelper() self.identity_cache = RequestLocalCache(self.load_identity) diff --git a/tailbone/webapi.py b/tailbone/webapi.py index 70600e79..1c2fa106 100644 --- a/tailbone/webapi.py +++ b/tailbone/webapi.py @@ -30,7 +30,7 @@ from cornice.renderer import CorniceRenderer from pyramid.config import Configurator from tailbone import app -from tailbone.auth import TailboneAuthenticationPolicy, TailboneAuthorizationPolicy +from tailbone.auth import TailboneSecurityPolicy from tailbone.providers import get_all_providers @@ -50,14 +50,7 @@ def make_pyramid_config(settings): pyramid_config = Configurator(settings=settings, root_factory=app.Root) # configure user authorization / authentication - # TODO: security policy should become the default, for pyramid 2.x - if rattail_config.getbool('tailbone', 'pyramid.use_security_policy', - usedb=False, default=False): - from tailbone.auth import TailboneSecurityPolicy - pyramid_config.set_security_policy(TailboneSecurityPolicy(api_mode=True)) - else: - pyramid_config.set_authentication_policy(TailboneAuthenticationPolicy()) - pyramid_config.set_authorization_policy(TailboneAuthorizationPolicy()) + pyramid_config.set_security_policy(TailboneSecurityPolicy(api_mode=True)) # always require CSRF token protection pyramid_config.set_default_csrf_options(require_csrf=True, From 6a7c06d26ece23cb4003599a5d641fcadc8a91c0 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 3 Jun 2024 23:16:00 -0500 Subject: [PATCH 1461/1681] Remove version cap for deform see also commit 95dd8d83dc7af0aadf4d630fe3dd3646312bb181 where i first added the version cap; it mentions an error that i am not sure how to reproduce. so we'll see if there really is still an error..or if it has since fixed itself --- setup.cfg | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/setup.cfg b/setup.cfg index 811afc17..5f46bc5c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -39,15 +39,12 @@ classifiers = [options] install_requires = - - # TODO: remove once their bug is fixed? idk what this is about yet... - deform<2.0.15 - asgiref colander ColanderAlchemy cornice cornice-swagger + deform humanize Mako markdown From 00e2af1561e57667a5ea0f99cda2c58da378bcfe Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 4 Jun 2024 01:05:05 -0500 Subject: [PATCH 1462/1681] Set explicit referrer when changing app theme to include url #hash value if there is one, so switching theme is more seamless from the view profile page --- tailbone/templates/base.mako | 2 ++ tailbone/templates/themes/butterball/base.mako | 2 ++ tailbone/views/common.py | 13 ++++++------- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index 1554d15d..f576473d 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -401,6 +401,7 @@ <div class="level-item"> ${h.form(url('change_theme'), method="post", ref='themePickerForm')} ${h.csrf_token(request)} + <input type="hidden" name="referrer" :value="referrer" /> <div style="display: flex; align-items: center; gap: 0.5rem;"> <span>Theme:</span> <b-select name="theme" @@ -856,6 +857,7 @@ % if expose_theme_picker and request.has_perm('common.change_app_theme'): globalTheme: ${json.dumps(theme)|n}, + referrer: location.href, % endif % if can_edit_help: diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako index 420f23d9..9c8af78a 100644 --- a/tailbone/templates/themes/butterball/base.mako +++ b/tailbone/templates/themes/butterball/base.mako @@ -792,6 +792,7 @@ % if expose_theme_picker and request.has_perm('common.change_app_theme'): ${h.form(url('change_theme'), method="post", ref='themePickerForm')} ${h.csrf_token(request)} + <input type="hidden" name="referrer" :value="referrer" /> <div style="display: flex; align-items: center; gap: 0.5rem;"> <span>Theme:</span> <b-select name="theme" @@ -1121,6 +1122,7 @@ % if expose_theme_picker and request.has_perm('common.change_app_theme'): globalTheme: ${json.dumps(theme)|n}, + referrer: location.href, % endif % if can_edit_help: diff --git a/tailbone/views/common.py b/tailbone/views/common.py index 35332b6b..266561fd 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,16 +24,14 @@ Various common views """ +import importlib import os from collections import OrderedDict from rattail.batch import consume_batch_id -from rattail.util import simple_error, import_module_path +from rattail.util import simple_error from rattail.files import resource_path -from pyramid import httpexceptions -from pyramid.response import Response - from tailbone import forms from tailbone.forms.common import Feedback from tailbone.db import Session @@ -110,7 +108,7 @@ class CommonView(View): return self.project_version pkg = self.rattail_config.app_package() - mod = import_module_path(pkg) + mod = importlib.import_module(pkg) return mod.__version__ def exception(self): @@ -155,7 +153,8 @@ class CommonView(View): self.request.session.flash(msg, 'error') else: self.request.session.flash("App theme has been changed to: {}".format(theme)) - return self.redirect(self.request.get_referrer()) + referrer = self.request.params.get('referrer') or self.request.get_referrer() + return self.redirect(referrer) def change_db_engine(self): """ From 10aac388f01e410977cf8f51d40ac1f868053bba Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 4 Jun 2024 17:15:29 -0500 Subject: [PATCH 1463/1681] Add `<b-tooltip>` component shim --- .../themes/butterball/buefy-components.mako | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tailbone/templates/themes/butterball/buefy-components.mako b/tailbone/templates/themes/butterball/buefy-components.mako index f44f30ad..51a0deb9 100644 --- a/tailbone/templates/themes/butterball/buefy-components.mako +++ b/tailbone/templates/themes/butterball/buefy-components.mako @@ -19,6 +19,7 @@ ${self.make_b_step_item_component()} ${self.make_b_table_component()} ${self.make_b_table_column_component()} + ${self.make_b_tooltip_component()} ${self.make_once_button_component()} </%def> @@ -662,6 +663,25 @@ <% request.register_component('b-table-column', 'BTableColumn') %> </%def> +<%def name="make_b_tooltip_component()"> + <script type="text/x-template" id="b-tooltip-template"> + <o-tooltip :label="label" + :multiline="multilined"> + <slot /> + </o-tooltip> + </script> + <script> + const BTooltip = { + template: '#b-tooltip-template', + props: { + label: String, + multilined: Boolean, + }, + } + </script> + <% request.register_component('b-tooltip', 'BTooltip') %> +</%def> + <%def name="make_once_button_component()"> <script type="text/x-template" id="once-button-template"> <b-button :type="type" From d02bf0e5c7c4e68d0977789b257ff2278cfdd5cd Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 4 Jun 2024 17:15:42 -0500 Subject: [PATCH 1464/1681] Include extra styles from `base_meta` template for butterball --- tailbone/templates/themes/butterball/base.mako | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako index 9c8af78a..3f0253ce 100644 --- a/tailbone/templates/themes/butterball/base.mako +++ b/tailbone/templates/themes/butterball/base.mako @@ -318,6 +318,7 @@ /* } */ </style> + ${base_meta.extra_styles()} </%def> <%def name="make_feedback_component()"> From da6ccf4425b778d80d682626ba4b5ffb5470f036 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 4 Jun 2024 17:16:57 -0500 Subject: [PATCH 1465/1681] Fix product lookup component, per butterball --- tailbone/templates/products/lookup.mako | 32 +++++++++++++++++++------ 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/tailbone/templates/products/lookup.mako b/tailbone/templates/products/lookup.mako index 48206de1..7997eb7d 100644 --- a/tailbone/templates/products/lookup.mako +++ b/tailbone/templates/products/lookup.mako @@ -56,9 +56,7 @@ <b-field grouped> <b-input v-model="searchTerm" - ref="searchTermInput" - @keydown.native="searchTermInputKeydown"> - </b-input> + ref="searchTermInput" /> <b-button class="control" type="is-primary" @@ -161,10 +159,19 @@ <${b}-table-column label="Actions" v-slot="props"> <a :href="props.row.url" - target="_blank" - class="grid-action"> - <i class="fas fa-external-link-alt"></i> - View + % if not request.use_oruga: + class="grid-action" + % endif + target="_blank"> + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="external-link-alt" /> + <span>View</span> + </span> + % else: + <i class="fas fa-external-link-alt"></i> + View + % endif </a> </${b}-table-column> @@ -236,6 +243,7 @@ lookupShowDialog: false, searchTerm: null, + searchTermInputElement: null, searchTermLastUsed: null, searchProductKey: true, @@ -250,6 +258,16 @@ searchResultSelected: null, } }, + + mounted() { + this.searchTermInputElement = this.$refs.searchTermInput.$el.querySelector('input') + this.searchTermInputElement.addEventListener('keydown', this.searchTermInputKeydown) + }, + + beforeDestroy() { + this.searchTermInputElement.removeEventListener('keydown', this.searchTermInputKeydown) + }, + methods: { focus() { From 22aceb4d67e184b5a878364489e1858b5eabc668 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 4 Jun 2024 17:28:07 -0500 Subject: [PATCH 1466/1681] Include butterball theme by default for new apps but it is not "the" default yet.. --- tailbone/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/config.py b/tailbone/config.py index 9326a3cb..ee906149 100644 --- a/tailbone/config.py +++ b/tailbone/config.py @@ -49,7 +49,7 @@ class ConfigExtension(BaseExtension): configure_session(config, Session) # provide default theme selection - config.setdefault('tailbone', 'themes.keys', 'default, falafel') + config.setdefault('tailbone', 'themes.keys', 'default, butterball') config.setdefault('tailbone', 'themes.expose_picker', 'true') From c1892734711401c5bd4cb6c0bc92b72ef1c6870c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 4 Jun 2024 21:12:44 -0500 Subject: [PATCH 1467/1681] Update changelog --- CHANGES.rst | 18 ++++++++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index a3be0af8..c6809592 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,24 @@ CHANGELOG Unreleased ---------- +0.10.12 (2024-06-04) +-------------------- + +* Require pyramid 2.x; remove 1.x-style auth policies. + +* Remove version cap for deform. + +* Set explicit referrer when changing app theme. + +* Add ``<b-tooltip>`` component shim. + +* Include extra styles from ``base_meta`` template for butterball. + +* Fix product lookup component, per butterball. + +* Include butterball theme by default for new apps. + + 0.10.11 (2024-06-03) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index d24c3b8e..2af82b6d 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.10.11' +__version__ = '0.10.12' From 1afc70e788a650ccb4e29d984b008bd307fa6211 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 4 Jun 2024 22:11:51 -0500 Subject: [PATCH 1468/1681] Remove old/unused scaffold for use with `pcreate` we now have a better Generate Project feature --- setup.cfg | 3 --- tailbone/scaffolds.py | 45 ------------------------------------------- 2 files changed, 48 deletions(-) delete mode 100644 tailbone/scaffolds.py diff --git a/setup.cfg b/setup.cfg index 5f46bc5c..6184c7c2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -98,6 +98,3 @@ rattail.cleaners = rattail.config.extensions = tailbone = tailbone.config:ConfigExtension - -pyramid.scaffold = - rattail = tailbone.scaffolds:RattailTemplate diff --git a/tailbone/scaffolds.py b/tailbone/scaffolds.py deleted file mode 100644 index 10bf9640..00000000 --- a/tailbone/scaffolds.py +++ /dev/null @@ -1,45 +0,0 @@ -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU General Public License as published by the Free Software -# Foundation, either version 3 of the License, or (at your option) any later -# version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -# details. -# -# You should have received a copy of the GNU General Public License along with -# Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ -""" -Pyramid scaffold templates -""" - -from __future__ import unicode_literals, absolute_import - -from rattail.files import resource_path -from rattail.util import prettify - -from pyramid.scaffolds import PyramidTemplate - - -class RattailTemplate(PyramidTemplate): - _template_dir = resource_path('rattail:data/project') - summary = "Starter project based on Rattail / Tailbone" - - def pre(self, command, output_dir, vars): - """ - Adds some more variables to the template context. - """ - vars['project_title'] = prettify(vars['project']) - vars['package_title'] = vars['package'].capitalize() - return super(RattailTemplate, self).pre(command, output_dir, vars) From d9911cf23d5864a772d83777c0605c7040382120 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 5 Jun 2024 23:04:45 -0500 Subject: [PATCH 1469/1681] Add 'fanstatic' support for sake of libcache assets for vue.js and oruga etc. we don't want to include files in tailbone since they are apt to change over time, and probably need to use different versions for different apps etc. much may need to change yet, this is a first attempt but so far it seems quite promising --- setup.cfg | 1 + tailbone/app.py | 2 ++ tailbone/util.py | 21 +++++++++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/setup.cfg b/setup.cfg index 6184c7c2..50c057f9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -57,6 +57,7 @@ install_requires = pyramid_beaker pyramid_deform pyramid_exclog + pyramid_fanstatic pyramid_mako pyramid_retry pyramid_tm diff --git a/tailbone/app.py b/tailbone/app.py index 5ca4c5c9..b0160bd3 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -129,6 +129,7 @@ def make_pyramid_config(settings, configure_csrf=True): # we want the new themes feature! establish_theme(settings) + settings.setdefault('fanstatic.versioning', 'true') settings.setdefault('pyramid_deform.template_search_path', 'tailbone:templates/deform') config = Configurator(settings=settings, root_factory=Root) @@ -147,6 +148,7 @@ def make_pyramid_config(settings, configure_csrf=True): # Bring in some Pyramid goodies. config.include('tailbone.beaker') config.include('pyramid_deform') + config.include('pyramid_fanstatic') config.include('pyramid_mako') config.include('pyramid_tm') diff --git a/tailbone/util.py b/tailbone/util.py index 7d838541..9a993176 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -25,6 +25,7 @@ Utilities """ import datetime +import importlib import logging import warnings @@ -195,6 +196,12 @@ def get_liburl(request, key, fallback=True): version = get_libver(request, key) + static = config.get('tailbone.static_libcache.module') + if static: + static = importlib.import_module(static) + needed = request.environ['fanstatic.needed'] + liburl = needed.library_url(static.libcache) + '/' + if key == 'buefy': return 'https://unpkg.com/buefy@{}/dist/buefy.min.js'.format(version) @@ -211,24 +218,38 @@ def get_liburl(request, key, fallback=True): return 'https://use.fontawesome.com/releases/v{}/js/all.js'.format(version) elif key == 'bb_vue': + if static and hasattr(static, 'bb_vue_js'): + return liburl + static.bb_vue_js.relpath return f'https://unpkg.com/vue@{version}/dist/vue.esm-browser.prod.js' elif key == 'bb_oruga': + if static and hasattr(static, 'bb_oruga_js'): + return liburl + static.bb_oruga_js.relpath return f'https://unpkg.com/@oruga-ui/oruga-next@{version}/dist/oruga.mjs' elif key == 'bb_oruga_bulma': + if static and hasattr(static, 'bb_oruga_bulma_js'): + return liburl + static.bb_oruga_bulma_js.relpath return f'https://unpkg.com/@oruga-ui/theme-bulma@{version}/dist/bulma.mjs' elif key == 'bb_oruga_bulma_css': + if static and hasattr(static, 'bb_oruga_bulma_css'): + return liburl + static.bb_oruga_bulma_css.relpath return f'https://unpkg.com/@oruga-ui/theme-bulma@{version}/dist/bulma.css' elif key == 'bb_fontawesome_svg_core': + if static and hasattr(static, 'bb_fontawesome_svg_core_js'): + return liburl + static.bb_fontawesome_svg_core_js.relpath return f'https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-svg-core@{version}/+esm' elif key == 'bb_free_solid_svg_icons': + if static and hasattr(static, 'bb_free_solid_svg_icons_js'): + return liburl + static.bb_free_solid_svg_icons_js.relpath return f'https://cdn.jsdelivr.net/npm/@fortawesome/free-solid-svg-icons@{version}/+esm' elif key == 'bb_vue_fontawesome': + if static and hasattr(static, 'bb_vue_fontawesome_js'): + return liburl + static.bb_vue_fontawesome_js.relpath return f'https://cdn.jsdelivr.net/npm/@fortawesome/vue-fontawesome@{version}/+esm' From ce290f5f8b400ba0dc2fa5ec51a145d74adf7a8b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 6 Jun 2024 15:30:48 -0500 Subject: [PATCH 1470/1681] Update changelog --- CHANGES.rst | 8 ++++++++ tailbone/_version.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index c6809592..cc04273e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,14 @@ CHANGELOG Unreleased ---------- +0.10.13 (2024-06-06) +-------------------- + +* Remove old/unused scaffold for use with ``pcreate``. + +* Add 'fanstatic' support for sake of libcache assets. + + 0.10.12 (2024-06-04) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 2af82b6d..1daf8e32 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.10.12' +__version__ = '0.10.13' From f6f2a53a0c7a542ead7bb5dc9a8414e6057ed774 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 6 Jun 2024 20:33:36 -0500 Subject: [PATCH 1471/1681] Use `pkg_resources` to determine package versions and always add `app_version` to global template context. this was for sake of "About This App v1.0.0" style links in custom page footers --- tailbone/subscribers.py | 5 +++++ tailbone/views/common.py | 5 ++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 42d3cab7..59ef64dc 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -28,6 +28,7 @@ import six import json import datetime import logging +import pkg_resources import warnings from collections import OrderedDict @@ -168,7 +169,11 @@ def before_render(event): renderer_globals = event renderer_globals['rattail_app'] = request.rattail_config.get_app() + renderer_globals['app_title'] = request.rattail_config.app_title() + pkg = rattail_config.app_package() + renderer_globals['app_version'] = pkg_resources.get_distribution(pkg).version + renderer_globals['h'] = helpers renderer_globals['url'] = request.route_url renderer_globals['rattail'] = rattail diff --git a/tailbone/views/common.py b/tailbone/views/common.py index 266561fd..58346f3b 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -24,8 +24,8 @@ Various common views """ -import importlib import os +import pkg_resources from collections import OrderedDict from rattail.batch import consume_batch_id @@ -108,8 +108,7 @@ class CommonView(View): return self.project_version pkg = self.rattail_config.app_package() - mod = importlib.import_module(pkg) - return mod.__version__ + return pkg_resources.get_distribution(pkg).version def exception(self): """ From 0491d8517c59379bf6e272ba1dc93245e6c82930 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 6 Jun 2024 23:04:47 -0500 Subject: [PATCH 1472/1681] Update changelog --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index cc04273e..178135e4 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,12 @@ CHANGELOG Unreleased ---------- +0.10.14 (2024-06-06) +-------------------- + +* Use ``pkg_resources`` to determine package versions. + + 0.10.13 (2024-06-06) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 1daf8e32..7a64f8d0 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.10.13' +__version__ = '0.10.14' From 94d7836321b34d9892f3deace05c7d369c07808f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 6 Jun 2024 23:05:40 -0500 Subject: [PATCH 1473/1681] Ignore dist folder --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 906dc226..03545d1a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .coverage .tox/ +dist/ docs/_build/ htmlcov/ Tailbone.egg-info/ From 610e1666c01b48a5aae4990d51aa1cafc04d60ce Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 7 Jun 2024 10:07:31 -0500 Subject: [PATCH 1474/1681] Revert "Use `pkg_resources` to determine package versions" This reverts commit f6f2a53a0c7a542ead7bb5dc9a8414e6057ed774. --- tailbone/subscribers.py | 5 ----- tailbone/views/common.py | 5 +++-- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 59ef64dc..42d3cab7 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -28,7 +28,6 @@ import six import json import datetime import logging -import pkg_resources import warnings from collections import OrderedDict @@ -169,11 +168,7 @@ def before_render(event): renderer_globals = event renderer_globals['rattail_app'] = request.rattail_config.get_app() - renderer_globals['app_title'] = request.rattail_config.app_title() - pkg = rattail_config.app_package() - renderer_globals['app_version'] = pkg_resources.get_distribution(pkg).version - renderer_globals['h'] = helpers renderer_globals['url'] = request.route_url renderer_globals['rattail'] = rattail diff --git a/tailbone/views/common.py b/tailbone/views/common.py index 58346f3b..266561fd 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -24,8 +24,8 @@ Various common views """ +import importlib import os -import pkg_resources from collections import OrderedDict from rattail.batch import consume_batch_id @@ -108,7 +108,8 @@ class CommonView(View): return self.project_version pkg = self.rattail_config.app_package() - return pkg_resources.get_distribution(pkg).version + mod = importlib.import_module(pkg) + return mod.__version__ def exception(self): """ From a849d8452b6f9ece02a3df231b2e520caa5fc9f8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 7 Jun 2024 10:25:14 -0500 Subject: [PATCH 1475/1681] Update changelog --- CHANGES.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index 178135e4..2295867e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,12 @@ CHANGELOG Unreleased ---------- +0.10.15 (unreleased) +-------------------- + +* Do *not* Use ``pkg_resources`` to determine package versions. + + 0.10.14 (2024-06-06) -------------------- From 7c3d5b46f38876c70d6114b23e678df5e810f6c6 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 7 Jun 2024 10:25:48 -0500 Subject: [PATCH 1476/1681] Update changelog --- CHANGES.rst | 2 +- tailbone/_version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 2295867e..a711be5f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,7 +5,7 @@ CHANGELOG Unreleased ---------- -0.10.15 (unreleased) +0.10.15 (2024-06-07) -------------------- * Do *not* Use ``pkg_resources`` to determine package versions. diff --git a/tailbone/_version.py b/tailbone/_version.py index 7a64f8d0..f6e50fc4 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.10.14' +__version__ = '0.10.15' From b8ace1eb98b76b93025f84311201a4497076dc06 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 9 Jun 2024 23:07:52 -0500 Subject: [PATCH 1477/1681] fix: avoid deprecated config methods for app/node title --- tailbone/api/common.py | 11 ++++++----- tailbone/subscribers.py | 5 +++-- tailbone/templates/base_meta.mako | 2 +- tailbone/views/auth.py | 3 ++- tailbone/views/common.py | 11 +++++++---- tailbone/views/settings.py | 3 ++- tailbone/views/upgrades.py | 3 ++- 7 files changed, 23 insertions(+), 15 deletions(-) diff --git a/tailbone/api/common.py b/tailbone/api/common.py index 30dfeab1..1dcaff08 100644 --- a/tailbone/api/common.py +++ b/tailbone/api/common.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -27,8 +27,6 @@ Tailbone Web API - "Common" Views from collections import OrderedDict import rattail -from rattail.db import model -from rattail.mail import send_email from cornice import Service from cornice.service import get_services @@ -66,7 +64,8 @@ class CommonView(APIView): } def get_project_title(self): - return self.rattail_config.app_title(default="Tailbone") + app = self.get_rattail_app() + return app.get_title() def get_project_version(self): import tailbone @@ -87,6 +86,8 @@ class CommonView(APIView): """ View to handle user feedback form submits. """ + app = self.get_rattail_app() + model = self.model # TODO: this logic was copied from tailbone.views.common and is largely # identical; perhaps should merge somehow? schema = Feedback().bind(session=Session()) @@ -106,7 +107,7 @@ class CommonView(APIView): data['client_ip'] = self.request.client_addr email_key = data['email_key'] or self.feedback_email_key - send_email(self.rattail_config, email_key, data=data) + app.send_email(email_key, data=data) return {'ok': True} return {'error': "Form did not validate!"} diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 42d3cab7..bc851629 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -165,10 +165,11 @@ def before_render(event): request = event.get('request') or threadlocal.get_current_request() rattail_config = request.rattail_config + app = rattail_config.get_app() renderer_globals = event - renderer_globals['rattail_app'] = request.rattail_config.get_app() - renderer_globals['app_title'] = request.rattail_config.app_title() + renderer_globals['rattail_app'] = app + renderer_globals['app_title'] = app.get_title() renderer_globals['h'] = helpers renderer_globals['url'] = request.route_url renderer_globals['rattail'] = rattail diff --git a/tailbone/templates/base_meta.mako b/tailbone/templates/base_meta.mako index 07b13e61..00cfdfe9 100644 --- a/tailbone/templates/base_meta.mako +++ b/tailbone/templates/base_meta.mako @@ -1,6 +1,6 @@ ## -*- coding: utf-8; -*- -<%def name="app_title()">${request.rattail_config.node_title(default="Rattail")}</%def> +<%def name="app_title()">${rattail_app.get_node_title()}</%def> <%def name="global_title()">${"[STAGE] " if not request.rattail_config.production() else ''}${self.app_title()}</%def> diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index 0f0d1687..7ecdc6cd 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -92,6 +92,7 @@ class AuthenticationView(View): """ The login view, responsible for displaying and handling the login form. """ + app = self.get_rattail_app() referrer = self.request.get_referrer(default=self.request.route_url('home')) # redirect if already logged in @@ -133,7 +134,7 @@ class AuthenticationView(View): 'form': form, 'referrer': referrer, 'image_url': image_url, - 'index_title': self.rattail_config.node_title(), + 'index_title': app.get_node_title(), 'help_url': global_help_url(self.rattail_config), } diff --git a/tailbone/views/common.py b/tailbone/views/common.py index 266561fd..25eb7dee 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -50,6 +50,7 @@ class CommonView(View): """ Home page view. """ + app = self.get_rattail_app() if not self.request.user: if self.rattail_config.getbool('tailbone', 'login_is_home', default=True): raise self.redirect(self.request.route_url('login')) @@ -60,7 +61,7 @@ class CommonView(View): context = { 'image_url': image_url, - 'index_title': self.rattail_config.node_title(), + 'index_title': app.get_node_title(), 'help_url': global_help_url(self.rattail_config), } @@ -99,7 +100,8 @@ class CommonView(View): return response def get_project_title(self): - return self.rattail_config.app_title() + app = self.get_rattail_app() + return app.get_title() def get_project_version(self): @@ -121,11 +123,12 @@ class CommonView(View): """ Generic view to show "about project" info page. """ + app = self.get_rattail_app() return { 'project_title': self.get_project_title(), 'project_version': self.get_project_version(), 'packages': self.get_packages(), - 'index_title': self.rattail_config.node_title(), + 'index_title': app.get_node_title(), } def get_packages(self): @@ -209,7 +212,7 @@ class CommonView(View): raise self.forbidden() app = self.get_rattail_app() - app_title = self.rattail_config.app_title() + app_title = app.get_title() poser_handler = app.get_poser_handler() poser_dir = poser_handler.get_default_poser_dir() poser_dir_exists = os.path.isdir(poser_dir) diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index cce5e53d..8d389530 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -67,8 +67,9 @@ class AppInfoView(MasterView): ] def get_index_title(self): + app = self.get_rattail_app() return "{} for {}".format(self.get_model_title_plural(), - self.rattail_config.app_title()) + app.get_title()) def get_data(self, session=None): pip = os.path.join(sys.prefix, 'bin', 'pip') diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index a281062e..3276b64d 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.py @@ -147,10 +147,11 @@ class UpgradeView(MasterView): def template_kwargs_view(self, **kwargs): kwargs = super().template_kwargs_view(**kwargs) + app = self.get_rattail_app() model = self.model upgrade = kwargs['instance'] - kwargs['system_title'] = self.rattail_config.app_title() + kwargs['system_title'] = app.get_title() if upgrade.system: system = self.upgrade_handler.get_system(upgrade.system) if system: From 2c2727bf6632febc6ec823182498e803c9fd5617 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 10 Jun 2024 09:07:10 -0500 Subject: [PATCH 1478/1681] feat: standardize how app, package versions are determined --- tailbone/api/common.py | 11 +++++------ tailbone/beaker.py | 8 ++++---- tailbone/subscribers.py | 1 + tailbone/views/common.py | 13 +++++-------- 4 files changed, 15 insertions(+), 18 deletions(-) diff --git a/tailbone/api/common.py b/tailbone/api/common.py index 1dcaff08..6cacfb06 100644 --- a/tailbone/api/common.py +++ b/tailbone/api/common.py @@ -26,13 +26,12 @@ Tailbone Web API - "Common" Views from collections import OrderedDict -import rattail +from rattail.util import get_pkg_version from cornice import Service from cornice.service import get_services from cornice_swagger import CorniceSwagger -import tailbone from tailbone import forms from tailbone.forms.common import Feedback from tailbone.api import APIView, api @@ -68,8 +67,8 @@ class CommonView(APIView): return app.get_title() def get_project_version(self): - import tailbone - return tailbone.__version__ + app = self.get_rattail_app() + return app.get_version() def get_packages(self): """ @@ -77,8 +76,8 @@ class CommonView(APIView): 'about' page. """ return OrderedDict([ - ('rattail', rattail.__version__), - ('Tailbone', tailbone.__version__), + ('rattail', get_pkg_version('rattail')), + ('Tailbone', get_pkg_version('Tailbone')), ]) @api diff --git a/tailbone/beaker.py b/tailbone/beaker.py index b5d592f1..25a450df 100644 --- a/tailbone/beaker.py +++ b/tailbone/beaker.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -27,11 +27,11 @@ Note that most of the code for this module was copied from the beaker and pyramid_beaker projects. """ -from __future__ import unicode_literals, absolute_import - import time from pkg_resources import parse_version +from rattail.util import get_pkg_version + import beaker from beaker.session import Session from beaker.util import coerce_session_params @@ -49,7 +49,7 @@ class TailboneSession(Session): "Loads the data from this session from persistent storage" # are we using older version of beaker? - old_beaker = parse_version(beaker.__version__) < parse_version('1.12') + old_beaker = parse_version(get_pkg_version('beaker')) < parse_version('1.12') self.namespace = self.namespace_class(self.id, data_dir=self.data_dir, diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index bc851629..3fcd1017 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -170,6 +170,7 @@ def before_render(event): renderer_globals = event renderer_globals['rattail_app'] = app renderer_globals['app_title'] = app.get_title() + renderer_globals['app_version'] = app.get_version() renderer_globals['h'] = helpers renderer_globals['url'] = request.route_url renderer_globals['rattail'] = rattail diff --git a/tailbone/views/common.py b/tailbone/views/common.py index 25eb7dee..3c4b659b 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -24,12 +24,11 @@ Various common views """ -import importlib import os from collections import OrderedDict from rattail.batch import consume_batch_id -from rattail.util import simple_error +from rattail.util import get_pkg_version, simple_error from rattail.files import resource_path from tailbone import forms @@ -109,9 +108,8 @@ class CommonView(View): if hasattr(self, 'project_version'): return self.project_version - pkg = self.rattail_config.app_package() - mod = importlib.import_module(pkg) - return mod.__version__ + app = self.get_rattail_app() + return app.get_version() def exception(self): """ @@ -136,10 +134,9 @@ class CommonView(View): Should return the full set of packages which should be displayed on the 'about' page. """ - import rattail, tailbone return OrderedDict([ - ('rattail', rattail.__version__), - ('Tailbone', tailbone.__version__), + ('rattail', get_pkg_version('rattail')), + ('Tailbone', get_pkg_version('Tailbone')), ]) def change_theme(self): From dd58c640fa2a626efb4fc6a729cedf368ba3668f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 10 Jun 2024 11:11:06 -0500 Subject: [PATCH 1479/1681] Update changelog --- CHANGES.rst | 7 +++++++ tailbone/_version.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index a711be5f..ad65b7bf 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,13 @@ CHANGELOG Unreleased ---------- +0.10.16 (2024-06-10) +-------------------- + +* fix: avoid deprecated config methods for app/node title +* feat: standardize how app, package versions are determined + + 0.10.15 (2024-06-07) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index f6e50fc4..e1187ee4 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.10.15' +__version__ = '0.10.16' From 1402d437b5900aee406577696c5b02ae0281d5ba Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 10 Jun 2024 16:23:38 -0500 Subject: [PATCH 1480/1681] feat: switch from setup.cfg to pyproject.toml + hatchling --- .gitignore | 2 + pyproject.toml | 101 +++++++++++++++++++++++++++++++++++++++++++ setup.cfg | 101 ------------------------------------------- setup.py | 29 ------------- tailbone/_version.py | 8 +++- tasks.py | 13 +++++- 6 files changed, 122 insertions(+), 132 deletions(-) create mode 100644 pyproject.toml delete mode 100644 setup.cfg delete mode 100644 setup.py diff --git a/.gitignore b/.gitignore index 03545d1a..b3006f90 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +*~ +*.pyc .coverage .tox/ dist/ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..7c894886 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,101 @@ + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + + +[project] +name = "Tailbone" +version = "0.10.16" +description = "Backoffice Web Application for Rattail" +readme = "README.rst" +authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] +license = {text = "GNU GPL v3+"} +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Web Environment", + "Framework :: Pyramid", + "Intended Audience :: Developers", + "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Office/Business", + "Topic :: Software Development :: Libraries :: Python Modules", +] + +dependencies = [ + "asgiref", + "colander", + "ColanderAlchemy", + "cornice", + "cornice-swagger", + "deform", + "humanize", + "Mako", + "markdown", + "openpyxl", + "paginate", + "paginate_sqlalchemy", + "passlib", + "Pillow", + "pyramid>=2", + "pyramid_beaker", + "pyramid_deform", + "pyramid_exclog", + "pyramid_fanstatic", + "pyramid_mako", + "pyramid_retry", + "pyramid_tm", + "rattail[db,bouncer]", + "six", + "sa-filters", + "simplejson", + "transaction", + "waitress", + "WebHelpers2", + "zope.sqlalchemy", +] + + +[project.optional-dependencies] +docs = ["Sphinx", "sphinx-rtd-theme"] +tests = ["coverage", "mock", "pytest", "pytest-cov"] + + +[project.entry-points."paste.app_factory"] +main = "tailbone.app:main" +webapi = "tailbone.webapi:main" + + +[project.entry-points."rattail.cleaners"] +beaker = "tailbone.cleanup:BeakerCleaner" + + +[project.entry-points."rattail.config.extensions"] +tailbone = "tailbone.config:ConfigExtension" + + +[project.urls] +Homepage = "https://rattailproject.org" +Repository = "https://kallithea.rattailproject.org/rattail-project/tailbone" +Issues = "https://redmine.rattailproject.org/projects/tailbone/issues" +Changelog = "https://kallithea.rattailproject.org/rattail-project/tailbone/files/master/CHANGES.rst" + + +[tool.commitizen] +version_provider = "pep621" +tag_format = "v$version" +update_changelog_on_bump = true + + +# [tool.hatch.build.targets.wheel] +# packages = ["corepos"] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 50c057f9..00000000 --- a/setup.cfg +++ /dev/null @@ -1,101 +0,0 @@ -# -*- coding: utf-8; -*- - -[nosetests] -nocapture = 1 -cover-package = tailbone -cover-erase = 1 -cover-html = 1 -cover-html-dir = htmlcov - -[metadata] -name = Tailbone -version = attr: tailbone.__version__ -author = Lance Edgar -author_email = lance@edbob.org -url = http://rattailproject.org/ -license = GNU GPL v3 -description = Backoffice Web Application for Rattail -long_description = file: README.rst -classifiers = - Development Status :: 4 - Beta - Environment :: Web Environment - Framework :: Pyramid - Intended Audience :: Developers - License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+) - Natural Language :: English - Operating System :: OS Independent - Programming Language :: Python - Programming Language :: Python :: 3 - Programming Language :: Python :: 3.6 - Programming Language :: Python :: 3.7 - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 - Programming Language :: Python :: 3.10 - Programming Language :: Python :: 3.11 - Topic :: Internet :: WWW/HTTP - Topic :: Office/Business - Topic :: Software Development :: Libraries :: Python Modules - - -[options] -install_requires = - asgiref - colander - ColanderAlchemy - cornice - cornice-swagger - deform - humanize - Mako - markdown - openpyxl - paginate - paginate_sqlalchemy - passlib - Pillow - pyramid>=2 - pyramid_beaker - pyramid_deform - pyramid_exclog - pyramid_fanstatic - pyramid_mako - pyramid_retry - pyramid_tm - rattail[db,bouncer] - six - sa-filters - simplejson - transaction - waitress - WebHelpers2 - zope.sqlalchemy - -tests_require = Tailbone[tests] -test_suite = tests -packages = find: -include_package_data = True -zip_safe = False - - -[options.packages.find] -exclude = - tests.* - tests - - -[options.extras_require] -docs = Sphinx; sphinx-rtd-theme -tests = coverage; mock; pytest; pytest-cov - - -[options.entry_points] - -paste.app_factory = - main = tailbone.app:main - webapi = tailbone.webapi:main - -rattail.cleaners = - beaker = tailbone.cleanup:BeakerCleaner - -rattail.config.extensions = - tailbone = tailbone.config:ConfigExtension diff --git a/setup.py b/setup.py deleted file mode 100644 index 5645ddff..00000000 --- a/setup.py +++ /dev/null @@ -1,29 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU General Public License as published by the Free Software -# Foundation, either version 3 of the License, or (at your option) any later -# version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -# details. -# -# You should have received a copy of the GNU General Public License along with -# Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ -""" -Setup script for Tailbone -""" - -from setuptools import setup - -setup() diff --git a/tailbone/_version.py b/tailbone/_version.py index e1187ee4..7095f6c8 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,9 @@ # -*- coding: utf-8; -*- -__version__ = '0.10.16' +try: + from importlib.metadata import version +except ImportError: + from importlib_metadata import version + + +__version__ = version('Tailbone') diff --git a/tasks.py b/tasks.py index fba0b699..e9f47ccd 100644 --- a/tasks.py +++ b/tasks.py @@ -25,13 +25,24 @@ Tasks for Tailbone """ import os +import re import shutil from invoke import task here = os.path.abspath(os.path.dirname(__file__)) -exec(open(os.path.join(here, 'tailbone', '_version.py')).read()) +__version__ = None +pattern = re.compile(r'^version = "(\d+\.\d+\.\d+)"$') +with open(os.path.join(here, 'pyproject.toml'), 'rt') as f: + for line in f: + line = line.rstrip('\n') + match = pattern.match(line) + if match: + __version__ = match.group(1) + break +if not __version__: + raise RuntimeError("could not parse version!") @task From f9cb6cb59bdd525540bc46fc85ff1450bc52d11f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 10 Jun 2024 16:40:55 -0500 Subject: [PATCH 1481/1681] =?UTF-8?q?bump:=20version=200.10.16=20=E2=86=92?= =?UTF-8?q?=200.11.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 218 +++++++++++++++++++++++++++++ CHANGES.rst => docs/OLDCHANGES.rst | 199 +------------------------- docs/changelog.rst | 8 ++ docs/index.rst | 8 ++ pyproject.toml | 4 +- 5 files changed, 243 insertions(+), 194 deletions(-) create mode 100644 CHANGELOG.md rename CHANGES.rst => docs/OLDCHANGES.rst (97%) create mode 100644 docs/changelog.rst diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..c51f3fda --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,218 @@ + +# Changelog +All notable changes to Tailbone will be documented in this file. + +The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) +and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). + +## v0.11.0 (2024-06-10) + +### Feat + +- switch from setup.cfg to pyproject.toml + hatchling + +## v0.10.16 (2024-06-10) + +### Feat + +- standardize how app, package versions are determined + +### Fix + +- avoid deprecated config methods for app/node title + +## v0.10.15 (2024-06-07) + +### Fix + +- do *not* Use `pkg_resources` to determine package versions + +## v0.10.14 (2024-06-06) + +### Fix + +- use `pkg_resources` to determine package versions + +## v0.10.13 (2024-06-06) + +### Feat + +- remove old/unused scaffold for use with `pcreate` + +- add 'fanstatic' support for sake of libcache assets + +## v0.10.12 (2024-06-04) + +### Feat + +- require pyramid 2.x; remove 1.x-style auth policies + +- remove version cap for deform + +- set explicit referrer when changing app theme + +- add `<b-tooltip>` component shim + +- include extra styles from `base_meta` template for butterball + +- include butterball theme by default for new apps + +### Fix + +- fix product lookup component, per butterball + +## v0.10.11 (2024-06-03) + +### Feat + +- fix vue3 refresh bugs for various views + +- fix grid bug for tempmon appliance view, per oruga + +- fix ordering worksheet generator, per butterball + +- fix inventory worksheet generator, per butterball + +## v0.10.10 (2024-06-03) + +### Feat + +- more butterball fixes for "view profile" template + +### Fix + +- fix focus for `<b-select>` shim component + +## v0.10.9 (2024-06-03) + +### Feat + +- let master view control context menu items for page + +- fix the "new custorder" page for butterball + +### Fix + +- fix panel style for PO vs. Invoice breakdown in receiving batch + +## v0.10.8 (2024-06-02) + +### Feat + +- add styling for checked grid rows, per oruga/butterball + +- fix product view template for oruga/butterball + +- allow per-user custom styles for butterball + +- use oruga 0.8.9 by default + +## v0.10.7 (2024-06-01) + +### Feat + +- add setting to allow decimal quantities for receiving + +- log error if registry has no rattail config + +- add column filters for import/export main grid + +- escape all unsafe html for grid data + +- add speedbumps for delete, set preferred email/phone in profile view + +- fix file upload widget for oruga + +### Fix + +- fix overflow when instance header title is too long (butterball) + +## v0.10.6 (2024-05-29) + +### Feat + +- add way to flag organic products within lookup dialog + +- expose db picker for butterball theme + +- expose quickie lookup for butterball theme + +- fix basic problems with people profile view, per butterball + +## v0.10.5 (2024-05-29) + +### Feat + +- add `<tailbone-timepicker>` component for oruga + +## v0.10.4 (2024-05-12) + +### Fix + +- fix styles for grid actions, per butterball + +## v0.10.3 (2024-05-10) + +### Fix + +- fix bug with grid date filters + +## v0.10.2 (2024-05-08) + +### Feat + +- remove version restriction for pyramid_beaker dependency + +- rename some attrs etc. for buefy components used with oruga + +- fix "tools" helper for receiving batch view, per oruga + +- more data type fixes for ``<tailbone-datepicker>`` + +- fix "view receiving row" page, per oruga + +- tweak styles for grid action links, per butterball + +### Fix + +- fix employees grid when viewing department (per oruga) + +- fix login "enter" key behavior, per oruga + +- fix button text for autocomplete + +## v0.10.1 (2024-04-28) + +### Feat + +- sort list of available themes + +- update various icon names for oruga compatibility + +- show "View This" button when cloning a record + +- stop including 'falafel' as available theme + +### Fix + +- fix vertical alignment in main menu bar, for butterball + +- fix upgrade execution logic/UI per oruga + +## v0.10.0 (2024-04-28) + +This version bump is to reflect adding support for Vue 3 + Oruga via +the 'butterball' theme. There is likely more work to be done for that +yet, but it mostly works at this point. + +### Feat + +- misc. template and view logic tweaks (applicable to all themes) for + better patterns, consistency etc. + +- add initial support for Vue 3 + Oruga, via "butterball" theme + + +## Older Releases + +Please see `docs/OLDCHANGES.rst` for older release notes. diff --git a/CHANGES.rst b/docs/OLDCHANGES.rst similarity index 97% rename from CHANGES.rst rename to docs/OLDCHANGES.rst index ad65b7bf..0a802f40 100644 --- a/CHANGES.rst +++ b/docs/OLDCHANGES.rst @@ -2,193 +2,8 @@ CHANGELOG ========= -Unreleased ----------- - -0.10.16 (2024-06-10) --------------------- - -* fix: avoid deprecated config methods for app/node title -* feat: standardize how app, package versions are determined - - -0.10.15 (2024-06-07) --------------------- - -* Do *not* Use ``pkg_resources`` to determine package versions. - - -0.10.14 (2024-06-06) --------------------- - -* Use ``pkg_resources`` to determine package versions. - - -0.10.13 (2024-06-06) --------------------- - -* Remove old/unused scaffold for use with ``pcreate``. - -* Add 'fanstatic' support for sake of libcache assets. - - -0.10.12 (2024-06-04) --------------------- - -* Require pyramid 2.x; remove 1.x-style auth policies. - -* Remove version cap for deform. - -* Set explicit referrer when changing app theme. - -* Add ``<b-tooltip>`` component shim. - -* Include extra styles from ``base_meta`` template for butterball. - -* Fix product lookup component, per butterball. - -* Include butterball theme by default for new apps. - - -0.10.11 (2024-06-03) --------------------- - -* Fix vue3 refresh bugs for various views. - -* Fix grid bug for tempmon appliance view, per oruga. - -* Fix ordering worksheet generator, per butterball. - -* Fix inventory worksheet generator, per butterball. - - -0.10.10 (2024-06-03) --------------------- - -* Fix focus for ``<b-select>`` shim component. - -* More butterball fixes for "view profile" template. - - -0.10.9 (2024-06-03) -------------------- - -* Let master view control context menu items for page. - -* Fix panel style for PO vs. Invoice breakdown in receiving batch. - -* Fix the "new custorder" page for butterball. - - -0.10.8 (2024-06-02) -------------------- - -* Add styling for checked grid rows, per oruga/butterball. - -* Fix product view template for oruga/butterball. - -* Allow per-user custom styles for butterball. - -* Use oruga 0.8.9 by default. - - -0.10.7 (2024-06-01) -------------------- - -* Add setting to allow decimal quantities for receiving. - -* Log error if registry has no rattail config. - -* Add column filters for import/export main grid. - -* Fix overflow when instance header title is too long (butterball). - -* Escape all unsafe html for grid data. - -* Add speedbumps for delete, set preferred email/phone in profile view. - -* Fix file upload widget for oruga. - - -0.10.6 (2024-05-29) -------------------- - -* Add way to flag organic products within lookup dialog. - -* Expose db picker for butterball theme. - -* Expose quickie lookup for butterball theme. - -* Fix basic problems with people profile view, per butterball. - - -0.10.5 (2024-05-29) -------------------- - -* Add ``<tailbone-timepicker>`` component for oruga. - - -0.10.4 (2024-05-12) -------------------- - -* Fix styles for grid actions, per butterball. - - -0.10.3 (2024-05-10) -------------------- - -* Fix bug with grid date filters. - - -0.10.2 (2024-05-08) -------------------- - -* Fix employees grid when viewing department (per oruga). - -* Remove version restriction for pyramid_beaker dependency. - -* Fix login "enter" key behavior, per oruga. - -* Rename some attrs etc. for buefy components used with oruga. - -* Fix "tools" helper for receiving batch view, per oruga. - -* Fix button text for autocomplete. - -* More data type fixes for ``<tailbone-datepicker>``. - -* Fix "view receiving row" page, per oruga. - -* Tweak styles for grid action links, per butterball. - - -0.10.1 (2024-04-28) -------------------- - -* Sort list of available themes. - -* Update various icon names for oruga compatibility. - -* Fix vertical alignment in main menu bar, for butterball. - -* Fix upgrade execution logic/UI per oruga. - -* Show "View This" button when cloning a record. - -* Stop including 'falafel' as available theme. - - -0.10.0 (2024-04-28) -------------------- - -This version bump is to reflect adding support for Vue 3 + Oruga via -the 'butterball' theme. There is likely more work to be done for that -yet, but it mostly works at this point. - -* Misc. template and view logic tweaks (applicable to all themes) for - better patterns, consistency etc. - -* Add initial support for Vue 3 + Oruga, via "butterball" theme. +NB. this file contains "old" release notes only. for newer releases +see the `CHANGELOG.md` file in the source root folder. 0.9.96 (2024-04-25) @@ -5177,7 +4992,7 @@ and related technologies. 0.6.47 (2017-11-08) ------------------- -* Fix manifest to include *.pt deform templates +* Fix manifest to include ``*.pt`` deform templates 0.6.46 (2017-11-08) @@ -5510,13 +5325,13 @@ and related technologies. 0.6.13 (2017-07-26) ------------------- +------------------- * Allow master view to decide whether each grid checkbox is checked 0.6.12 (2017-07-26) ------------------- +------------------- * Add basic support for product inventory and status @@ -5524,7 +5339,7 @@ and related technologies. 0.6.11 (2017-07-18) ------------------- +------------------- * Tweak some basic styles for forms/grids @@ -5532,7 +5347,7 @@ and related technologies. 0.6.10 (2017-07-18) ------------------- +------------------- * Fix grid bug if "current page" becomes invalid diff --git a/docs/changelog.rst b/docs/changelog.rst new file mode 100644 index 00000000..bbf94f4b --- /dev/null +++ b/docs/changelog.rst @@ -0,0 +1,8 @@ + +Changelog Archive +================= + +.. toctree:: + :maxdepth: 1 + + OLDCHANGES diff --git a/docs/index.rst b/docs/index.rst index 351e910d..db05d0c1 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -60,6 +60,14 @@ Package API: api/views/purchasing.ordering +Changelog: + +.. toctree:: + :maxdepth: 1 + + changelog + + Documentation To-Do =================== diff --git a/pyproject.toml b/pyproject.toml index 7c894886..13a232ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.10.16" +version = "0.11.0" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] @@ -88,7 +88,7 @@ tailbone = "tailbone.config:ConfigExtension" Homepage = "https://rattailproject.org" Repository = "https://kallithea.rattailproject.org/rattail-project/tailbone" Issues = "https://redmine.rattailproject.org/projects/tailbone/issues" -Changelog = "https://kallithea.rattailproject.org/rattail-project/tailbone/files/master/CHANGES.rst" +Changelog = "https://kallithea.rattailproject.org/rattail-project/tailbone/files/master/CHANGELOG.md" [tool.commitizen] From fb0c538a2bd0d58f85ea37c4d8524b4fcf8515a0 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 10 Jun 2024 17:42:29 -0500 Subject: [PATCH 1482/1681] test: skip running tests for py36 we should soon require python 3.8 anyway --- tox.ini | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index ea833b39..6e45883c 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,9 @@ [tox] -envlist = py36, py37, py38, py39, py310, py311 +# TODO: i had to remove py36 since something (hatchling?) broke it +# somehow, and i was not able to quickly fix. as of writing only +# one app is known to run py36 and hopefully that is not for long. +envlist = py37, py38, py39, py310, py311 # TODO: can remove this when we drop py36 support # nb. need this for testing older python versions From 6e741f6156a50442426f6a59f2321d11eedcbdf3 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 14 Jun 2024 17:57:01 -0500 Subject: [PATCH 1483/1681] fix: revert back to setup.py + setup.cfg apparently with python 3.6 things "mostly" work but then they break if any specified dependencies have a dot in the name. which in this project, is the case for `zope.sqlalchemy` so until we drop python 3.6 support, we cannot use pyproject.toml here --- pyproject.toml | 101 ------------------------------------------------- setup.cfg | 97 +++++++++++++++++++++++++++++++++++++++++++++++ setup.py | 3 ++ 3 files changed, 100 insertions(+), 101 deletions(-) delete mode 100644 pyproject.toml create mode 100644 setup.cfg create mode 100644 setup.py diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 13a232ae..00000000 --- a/pyproject.toml +++ /dev/null @@ -1,101 +0,0 @@ - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - - -[project] -name = "Tailbone" -version = "0.11.0" -description = "Backoffice Web Application for Rattail" -readme = "README.rst" -authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] -license = {text = "GNU GPL v3+"} -classifiers = [ - "Development Status :: 4 - Beta", - "Environment :: Web Environment", - "Framework :: Pyramid", - "Intended Audience :: Developers", - "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", - "Natural Language :: English", - "Operating System :: OS Independent", - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Topic :: Internet :: WWW/HTTP", - "Topic :: Office/Business", - "Topic :: Software Development :: Libraries :: Python Modules", -] - -dependencies = [ - "asgiref", - "colander", - "ColanderAlchemy", - "cornice", - "cornice-swagger", - "deform", - "humanize", - "Mako", - "markdown", - "openpyxl", - "paginate", - "paginate_sqlalchemy", - "passlib", - "Pillow", - "pyramid>=2", - "pyramid_beaker", - "pyramid_deform", - "pyramid_exclog", - "pyramid_fanstatic", - "pyramid_mako", - "pyramid_retry", - "pyramid_tm", - "rattail[db,bouncer]", - "six", - "sa-filters", - "simplejson", - "transaction", - "waitress", - "WebHelpers2", - "zope.sqlalchemy", -] - - -[project.optional-dependencies] -docs = ["Sphinx", "sphinx-rtd-theme"] -tests = ["coverage", "mock", "pytest", "pytest-cov"] - - -[project.entry-points."paste.app_factory"] -main = "tailbone.app:main" -webapi = "tailbone.webapi:main" - - -[project.entry-points."rattail.cleaners"] -beaker = "tailbone.cleanup:BeakerCleaner" - - -[project.entry-points."rattail.config.extensions"] -tailbone = "tailbone.config:ConfigExtension" - - -[project.urls] -Homepage = "https://rattailproject.org" -Repository = "https://kallithea.rattailproject.org/rattail-project/tailbone" -Issues = "https://redmine.rattailproject.org/projects/tailbone/issues" -Changelog = "https://kallithea.rattailproject.org/rattail-project/tailbone/files/master/CHANGELOG.md" - - -[tool.commitizen] -version_provider = "pep621" -tag_format = "v$version" -update_changelog_on_bump = true - - -# [tool.hatch.build.targets.wheel] -# packages = ["corepos"] diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..83ce9814 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,97 @@ + +[metadata] +name = Tailbone +version = 0.11.0 +author = Lance Edgar +author_email = lance@edbob.org +url = http://rattailproject.org/ +license = GNU GPL v3 +description = Backoffice Web Application for Rattail +long_description = file: README.rst +classifiers = + Development Status :: 4 - Beta + Environment :: Web Environment + Framework :: Pyramid + Intended Audience :: Developers + License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+) + Natural Language :: English + Operating System :: OS Independent + Programming Language :: Python + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.6 + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 + Programming Language :: Python :: 3.11 + Topic :: Internet :: WWW/HTTP + Topic :: Office/Business + Topic :: Software Development :: Libraries :: Python Modules + + +[options] +packages = find: +include_package_data = True +install_requires = + asgiref + colander + ColanderAlchemy + cornice + cornice-swagger + deform + humanize + Mako + markdown + openpyxl + paginate + paginate_sqlalchemy + passlib + Pillow + pyramid>=2 + pyramid_beaker + pyramid_deform + pyramid_exclog + pyramid_fanstatic + pyramid_mako + pyramid_retry + pyramid_tm + rattail[db,bouncer] + six + sa-filters + simplejson + transaction + waitress + WebHelpers2 + zope.sqlalchemy + + +[options.packages.find] +exclude = + tests.* + tests + + +[options.extras_require] +docs = Sphinx; sphinx-rtd-theme +tests = coverage; mock; pytest; pytest-cov + + +[options.entry_points] + +paste.app_factory = + main = tailbone.app:main + webapi = tailbone.webapi:main + +rattail.cleaners = + beaker = tailbone.cleanup:BeakerCleaner + +rattail.config.extensions = + tailbone = tailbone.config:ConfigExtension + + +[nosetests] +nocapture = 1 +cover-package = tailbone +cover-erase = 1 +cover-html = 1 +cover-html-dir = htmlcov diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..b908cbe5 --- /dev/null +++ b/setup.py @@ -0,0 +1,3 @@ +import setuptools + +setuptools.setup() From ab4dbbedf05ffaf927d191fed670391f74a98eb1 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 14 Jun 2024 18:01:40 -0500 Subject: [PATCH 1484/1681] =?UTF-8?q?bump:=20version=200.11.0=20=E2=86=92?= =?UTF-8?q?=200.11.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ setup.cfg | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c51f3fda..40dfa16e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.11.1 (2024-06-14) + +### Fix + +- revert back to setup.py + setup.cfg + ## v0.11.0 (2024-06-10) ### Feat diff --git a/setup.cfg b/setup.cfg index 83ce9814..2ea746e9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,7 +1,7 @@ [metadata] name = Tailbone -version = 0.11.0 +version = 0.11.1 author = Lance Edgar author_email = lance@edbob.org url = http://rattailproject.org/ From da4450b574cef8ec1b8cf77ac0c52085f395d5aa Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 14 Jun 2024 18:02:39 -0500 Subject: [PATCH 1485/1681] build: avoid version parse when uploading release --- tasks.py | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/tasks.py b/tasks.py index e9f47ccd..b57315a0 100644 --- a/tasks.py +++ b/tasks.py @@ -25,26 +25,11 @@ Tasks for Tailbone """ import os -import re import shutil from invoke import task -here = os.path.abspath(os.path.dirname(__file__)) -__version__ = None -pattern = re.compile(r'^version = "(\d+\.\d+\.\d+)"$') -with open(os.path.join(here, 'pyproject.toml'), 'rt') as f: - for line in f: - line = line.rstrip('\n') - match = pattern.match(line) - if match: - __version__ = match.group(1) - break -if not __version__: - raise RuntimeError("could not parse version!") - - @task def release(c, tests=False): """ @@ -53,7 +38,9 @@ def release(c, tests=False): if tests: c.run('tox') + if os.path.exists('dist'): + shutil.rmtree('dist') if os.path.exists('Tailbone.egg-info'): shutil.rmtree('Tailbone.egg-info') c.run('python -m build --sdist') - c.run(f'twine upload dist/tailbone-{__version__}.tar.gz') + c.run('twine upload dist/*') From 0212e52b6611b4906107217113f1fbe0e30d252d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 14 Jun 2024 19:59:52 -0500 Subject: [PATCH 1486/1681] fix: hide certain custorder settings if not applicable --- tailbone/templates/custorders/configure.mako | 51 ++++++++++++-------- 1 file changed, 30 insertions(+), 21 deletions(-) diff --git a/tailbone/templates/custorders/configure.mako b/tailbone/templates/custorders/configure.mako index d2f6610d..16d26d21 100644 --- a/tailbone/templates/custorders/configure.mako +++ b/tailbone/templates/custorders/configure.mako @@ -24,29 +24,38 @@ </b-checkbox> </b-field> - <b-field message="Only applies if user is allowed to choose contact info."> - <b-checkbox name="rattail.custorders.new_orders.allow_contact_info_create" - v-model="simpleSettings['rattail.custorders.new_orders.allow_contact_info_create']" - native-value="true" - @input="settingsNeedSaved = true"> - Allow user to enter new contact info - </b-checkbox> - </b-field> + <div v-show="simpleSettings['rattail.custorders.new_orders.allow_contact_info_choice']" + style="padding-left: 2rem;"> - <p class="block"> - If you allow users to enter new contact info, the default action - when the order is submitted, is to send email with details of - the new contact info. Settings for these are at: - </p> + <b-field message="Only applies if user is allowed to choose contact info."> + <b-checkbox name="rattail.custorders.new_orders.allow_contact_info_create" + v-model="simpleSettings['rattail.custorders.new_orders.allow_contact_info_create']" + native-value="true" + @input="settingsNeedSaved = true"> + Allow user to enter new contact info + </b-checkbox> + </b-field> - <ul class="list"> - <li class="list-item"> - ${h.link_to("New Phone Request", url('emailprofiles.view', key='new_phone_requested'))} - </li> - <li class="list-item"> - ${h.link_to("New Email Request", url('emailprofiles.view', key='new_email_requested'))} - </li> - </ul> + <div v-show="simpleSettings['rattail.custorders.new_orders.allow_contact_info_create']" + style="padding-left: 2rem;"> + + <p class="block"> + If you allow users to enter new contact info, the default action + when the order is submitted, is to send email with details of + the new contact info. Settings for these are at: + </p> + + <ul class="list"> + <li class="list-item"> + ${h.link_to("New Phone Request", url('emailprofiles.view', key='new_phone_requested'))} + </li> + <li class="list-item"> + ${h.link_to("New Email Request", url('emailprofiles.view', key='new_email_requested'))} + </li> + </ul> + + </div> + </div> </div> <h3 class="block is-size-3">Product Handling</h3> From 88e7d86087c590d3ae3bc957dc1685ca7b815414 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 18 Jun 2024 15:04:05 -0500 Subject: [PATCH 1487/1681] fix: use different logic for buefy/oruga for product lookup keydown i could have swore the new logic worked with buefy..but today it didn't --- tailbone/templates/products/lookup.mako | 28 +++++++++++++++++-------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/tailbone/templates/products/lookup.mako b/tailbone/templates/products/lookup.mako index 7997eb7d..bb9590b2 100644 --- a/tailbone/templates/products/lookup.mako +++ b/tailbone/templates/products/lookup.mako @@ -56,7 +56,11 @@ <b-field grouped> <b-input v-model="searchTerm" - ref="searchTermInput" /> + ref="searchTermInput" + % if not request.use_oruga: + @keydown.native="searchTermInputKeydown" + % endif + /> <b-button class="control" type="is-primary" @@ -243,8 +247,10 @@ lookupShowDialog: false, searchTerm: null, - searchTermInputElement: null, searchTermLastUsed: null, + % if request.use_oruga: + searchTermInputElement: null, + % endif searchProductKey: true, searchVendorItemCode: true, @@ -259,14 +265,18 @@ } }, - mounted() { - this.searchTermInputElement = this.$refs.searchTermInput.$el.querySelector('input') - this.searchTermInputElement.addEventListener('keydown', this.searchTermInputKeydown) - }, + % if request.use_oruga: - beforeDestroy() { - this.searchTermInputElement.removeEventListener('keydown', this.searchTermInputKeydown) - }, + mounted() { + this.searchTermInputElement = this.$refs.searchTermInput.$el.querySelector('input') + this.searchTermInputElement.addEventListener('keydown', this.searchTermInputKeydown) + }, + + beforeDestroy() { + this.searchTermInputElement.removeEventListener('keydown', this.searchTermInputKeydown) + }, + + % endif methods: { From 231ca0363acf680a3538ad10b289d8ad9148666d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 18 Jun 2024 16:06:55 -0500 Subject: [PATCH 1488/1681] fix: product records should be touchable --- tailbone/views/products.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 28186ac3..5265edbc 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -81,6 +81,7 @@ class ProductView(MasterView): supports_autocomplete = True bulk_deletable = True mergeable = True + touchable = True configurable = True labels = { From a0cd8835e038f4952824da17f37172d5fe9fe334 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 18 Jun 2024 16:07:07 -0500 Subject: [PATCH 1489/1681] fix: show flash error message if resolve pending product fails --- tailbone/views/products.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 5265edbc..c395ff24 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -2563,7 +2563,14 @@ class PendingProductView(MasterView): app = self.get_rattail_app() products_handler = app.get_products_handler() kwargs = self.get_resolve_product_kwargs() - products_handler.resolve_product(pending, product, self.request.user, **kwargs) + + try: + products_handler.resolve_product(pending, product, self.request.user, **kwargs) + except Exception as error: + log.warning("failed to resolve product", exc_info=True) + self.request.session.flash(f"Resolve failed: {simple_error(error)}", 'error') + return redirect + return redirect def get_resolve_product_kwargs(self, **kwargs): From 525a28f3fe7b0e1b6e21576f06bd0cc341252ff7 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 18 Jun 2024 18:05:05 -0500 Subject: [PATCH 1490/1681] =?UTF-8?q?bump:=20version=200.11.1=20=E2=86=92?= =?UTF-8?q?=200.11.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 12 ++++++++++++ setup.cfg | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40dfa16e..ed866741 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.11.2 (2024-06-18) + +### Fix + +- hide certain custorder settings if not applicable + +- use different logic for buefy/oruga for product lookup keydown + +- product records should be touchable + +- show flash error message if resolve pending product fails + ## v0.11.1 (2024-06-14) ### Fix diff --git a/setup.cfg b/setup.cfg index 2ea746e9..aa14088a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,7 +1,7 @@ [metadata] name = Tailbone -version = 0.11.1 +version = 0.11.2 author = Lance Edgar author_email = lance@edbob.org url = http://rattailproject.org/ From 067ca5bd4354f8dd47f5a3e9206627e3c6f6ae32 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 27 Jun 2024 23:11:13 -0500 Subject: [PATCH 1491/1681] fix: add link to "resolved by" user for pending products --- tailbone/views/products.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index c395ff24..bf2d7f14 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -2457,9 +2457,10 @@ class PendingProductView(MasterView): # resolved* if self.creating: f.remove('resolved', 'resolved_by') + elif pending.resolved: + f.set_renderer('resolved_by', self.render_user) else: - if not pending.resolved: - f.remove('resolved', 'resolved_by') + f.remove('resolved', 'resolved_by') def render_status_code(self, pending, field): status = pending.status_code From 3b7cc19faa758e83cb6f358e4bcb93fc3f15c06e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 28 Jun 2024 15:36:08 -0500 Subject: [PATCH 1492/1681] fix: handle error when merging 2 records fails should give the user some idea of the problem instead of just sending error email to admins --- tailbone/views/master.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 48bc32fe..1e917902 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2292,9 +2292,13 @@ class MasterView(View): except Exception as error: self.request.session.flash("Requested merge cannot proceed (maybe swap kept/removed and try again?): {}".format(error), 'error') else: - self.merge_objects(object_to_remove, object_to_keep) - self.request.session.flash("{} has been merged into {}".format(msg, object_to_keep)) - return self.redirect(self.get_action_url('view', object_to_keep)) + try: + self.merge_objects(object_to_remove, object_to_keep) + self.request.session.flash("{} has been merged into {}".format(msg, object_to_keep)) + return self.redirect(self.get_action_url('view', object_to_keep)) + except Exception as error: + error = simple_error(error) + self.request.session.flash(f"merge failed: {error}", 'error') if not object_to_remove or not object_to_keep or object_to_remove is object_to_keep: return self.redirect(self.get_index_url()) From d17bd35909444f30807b486a3b0eded5bb4915b4 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 28 Jun 2024 15:39:59 -0500 Subject: [PATCH 1493/1681] =?UTF-8?q?bump:=20version=200.11.2=20=E2=86=92?= =?UTF-8?q?=200.11.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 ++++++++ setup.cfg | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed866741..f18a87ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.11.3 (2024-06-28) + +### Fix + +- add link to "resolved by" user for pending products + +- handle error when merging 2 records fails + ## v0.11.2 (2024-06-18) ### Fix diff --git a/setup.cfg b/setup.cfg index aa14088a..2dd65a74 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,7 +1,7 @@ [metadata] name = Tailbone -version = 0.11.2 +version = 0.11.3 author = Lance Edgar author_email = lance@edbob.org url = http://rattailproject.org/ From ec5ed490d91438b679315ee88cfeb37c2368ac10 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 28 Jun 2024 17:34:54 -0500 Subject: [PATCH 1494/1681] fix: start/stop being root should submit POST instead of GET obviously it's access-restricted anyway but this just seems more correct but more importantly this makes the referrer explicit, since for some unknown reason i am suddenly seeing that be blank for certain installs where that wasn't the case before (?) - and the result was that every time you start/stop being root you would be redirected to home page instead of remaining on current page --- .../templates/themes/butterball/base.mako | 30 +++++++++++++++++-- tailbone/views/auth.py | 3 ++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako index 3f0253ce..339d23bd 100644 --- a/tailbone/templates/themes/butterball/base.mako +++ b/tailbone/templates/themes/butterball/base.mako @@ -924,9 +924,23 @@ % endif <div class="navbar-dropdown"> % if request.is_root: - ${h.link_to("Stop being root", url('stop_root'), class_='navbar-item has-background-danger has-text-white')} + ${h.form(url('stop_root'), ref='stopBeingRootForm')} + ${h.csrf_token(request)} + <input type="hidden" name="referrer" value="${request.current_route_url()}" /> + <a @click="stopBeingRoot()" + class="navbar-item has-background-danger has-text-white"> + Stop being root + </a> + ${h.end_form()} % elif request.is_admin: - ${h.link_to("Become root", url('become_root'), class_='navbar-item has-background-danger has-text-white')} + ${h.form(url('become_root'), ref='startBeingRootForm')} + ${h.csrf_token(request)} + <input type="hidden" name="referrer" value="${request.current_route_url()}" /> + <a @click="startBeingRoot()" + class="navbar-item has-background-danger has-text-white"> + Become root + </a> + ${h.end_form()} % endif % if messaging_enabled: ${h.link_to("Messages{}".format(" ({})".format(inbox_count) if inbox_count else ''), url('messages.inbox'), class_='navbar-item')} @@ -1109,6 +1123,18 @@ const key = 'menu_' + hash + '_shown' this[key] = !this[key] }, + + % if request.is_admin: + + startBeingRoot() { + this.$refs.startBeingRootForm.submit() + }, + + stopBeingRoot() { + this.$refs.stopBeingRootForm.submit() + }, + + % endif }, } diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index 7ecdc6cd..730d7b6a 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -238,6 +238,9 @@ class AuthenticationView(View): config.add_view(cls, attr='change_password', route_name='change_password', renderer='/change_password.mako') # become/stop root + # TODO: these should require POST but i won't bother until + # after butterball becomes default theme..or probably should + # just refactor the falafel theme accordingly..? config.add_route('become_root', '/root/yes') config.add_view(cls, attr='become_root', route_name='become_root') config.add_route('stop_root', '/root/no') From 9b6447c4cb1c5a51486436ee8ddb4ec675c6fb7d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 28 Jun 2024 17:58:27 -0500 Subject: [PATCH 1495/1681] fix: require vendor when making new ordering batch via api pretty sure this pattern needs to be expanded and probably improved, but wanted to fix this one scenario for now, per error email --- tailbone/api/batch/ordering.py | 2 ++ tailbone/api/master.py | 14 +++++++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/tailbone/api/batch/ordering.py b/tailbone/api/batch/ordering.py index 1b11194e..204be8ad 100644 --- a/tailbone/api/batch/ordering.py +++ b/tailbone/api/batch/ordering.py @@ -86,6 +86,8 @@ class OrderingBatchViews(APIBatchView): Sets the mode to "ordering" for the new batch. """ data = dict(data) + if not data.get('vendor_uuid'): + raise ValueError("You must specify the vendor") data['mode'] = self.enum.PURCHASE_BATCH_MODE_ORDERING batch = super().create_object(data) return batch diff --git a/tailbone/api/master.py b/tailbone/api/master.py index 70616484..2d17339e 100644 --- a/tailbone/api/master.py +++ b/tailbone/api/master.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -31,7 +31,7 @@ from rattail.db.util import get_fieldnames from cornice import resource, Service -from tailbone.api import APIView, api +from tailbone.api import APIView from tailbone.db import Session from tailbone.util import SortColumn @@ -355,9 +355,13 @@ class APIMasterView(APIView): data = self.request.json_body # add instance to session, and return data for it - obj = self.create_object(data) - self.Session.flush() - return self._get(obj) + try: + obj = self.create_object(data) + except Exception as error: + return self.json_response({'error': str(error)}) + else: + self.Session.flush() + return self._get(obj) def create_object(self, data): """ From 83e4d95741e098a065a41d81f0776232b8008583 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 30 Jun 2024 10:32:05 -0500 Subject: [PATCH 1496/1681] fix: don't escape each address for email attempts grid now that we are properly escaping the full cell value, no need --- tailbone/views/email.py | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/tailbone/views/email.py b/tailbone/views/email.py index 22954782..4014c05e 100644 --- a/tailbone/views/email.py +++ b/tailbone/views/email.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -28,14 +28,13 @@ import logging import re import warnings -from rattail import mail -from rattail.db import model -from rattail.config import parse_list +from wuttjamaican.util import parse_list + +from rattail.db.model import EmailAttempt from rattail.util import simple_error import colander from deform import widget as dfwidget -from webhelpers2.html import HTML from tailbone import grids from tailbone.db import Session @@ -85,7 +84,7 @@ class EmailSettingView(MasterView): ] def __init__(self, request): - super(EmailSettingView, self).__init__(request) + super().__init__(request) self.email_handler = self.get_handler() @property @@ -204,7 +203,7 @@ class EmailSettingView(MasterView): return True def configure_form(self, f): - super(EmailSettingView, self).configure_form(f) + super().configure_form(f) profile = f.model_instance['_email'] # key @@ -437,7 +436,7 @@ class EmailPreview(View): """ def __init__(self, request): - super(EmailPreview, self).__init__(request) + super().__init__(request) if hasattr(self, 'get_handler'): warnings.warn("defining a get_handler() method is deprecated; " @@ -520,7 +519,7 @@ class EmailAttemptView(MasterView): """ Master view for email attempts. """ - model_class = model.EmailAttempt + model_class = EmailAttempt route_prefix = 'email_attempts' url_prefix = '/email/attempts' creatable = False @@ -553,7 +552,7 @@ class EmailAttemptView(MasterView): ] def configure_grid(self, g): - super(EmailAttemptView, self).configure_grid(g) + super().configure_grid(g) # sent g.set_sort_defaults('sent', 'desc') @@ -583,13 +582,12 @@ class EmailAttemptView(MasterView): if len(recips) > 2: recips = recips[:2] recips.append('...') - recips = [HTML.escape(r) for r in recips] return ', '.join(recips) return value def configure_form(self, f): - super(EmailAttemptView, self).configure_form(f) + super().configure_form(f) # key f.set_renderer('key', self.render_email_key) From eff5341335a898c6770d6a28dd7dde77b2bdad20 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 30 Jun 2024 10:49:54 -0500 Subject: [PATCH 1497/1681] =?UTF-8?q?bump:=20version=200.11.3=20=E2=86=92?= =?UTF-8?q?=200.11.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 10 ++++++++++ setup.cfg | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f18a87ea..8d92a99e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.11.4 (2024-06-30) + +### Fix + +- start/stop being root should submit POST instead of GET + +- require vendor when making new ordering batch via api + +- don't escape each address for email attempts grid + ## v0.11.3 (2024-06-28) ### Fix diff --git a/setup.cfg b/setup.cfg index 2dd65a74..82cf3b25 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,7 +1,7 @@ [metadata] name = Tailbone -version = 0.11.3 +version = 0.11.4 author = Lance Edgar author_email = lance@edbob.org url = http://rattailproject.org/ From 1dc632174eed4058f07c75ede528e0b7ec0188a9 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 30 Jun 2024 11:44:33 -0500 Subject: [PATCH 1498/1681] fix: allow comma in numeric filter input just remove them and run with the remainder, on the SQL side --- tailbone/grids/filters.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py index 3b198614..7e52bb8d 100644 --- a/tailbone/grids/filters.py +++ b/tailbone/grids/filters.py @@ -26,6 +26,7 @@ Grid Filters import re import datetime +import decimal import logging from collections import OrderedDict @@ -647,12 +648,22 @@ class AlchemyNumericFilter(AlchemyGridFilter): # first just make sure it's somewhat numeric try: - float(value) - except ValueError: + self.parse_decimal(value) + except decimal.InvalidOperation: return True return bool(value and len(str(value)) > 8) + def parse_decimal(self, value): + if value: + value = value.replace(',', '') + return decimal.Decimal(value) + + def encode_value(self, value): + if value: + value = str(self.parse_decimal(value)) + return super().encode_value(value) + def filter_equal(self, query, value): if self.value_invalid(value): return query From 3f7de5872e50f4ffd6e8c510ed8738bd24e0b870 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 30 Jun 2024 12:40:03 -0500 Subject: [PATCH 1499/1681] fix: add custom url prefix if needed, for fanstatic --- tailbone/util.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tailbone/util.py b/tailbone/util.py index 9a993176..78c41313 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -201,6 +201,9 @@ def get_liburl(request, key, fallback=True): static = importlib.import_module(static) needed = request.environ['fanstatic.needed'] liburl = needed.library_url(static.libcache) + '/' + # nb. add custom url prefix if needed, e.g. /theo + if request.script_name: + liburl = request.script_name + liburl if key == 'buefy': return 'https://unpkg.com/buefy@{}/dist/buefy.min.js'.format(version) From d6939e52b48bd5d6b947deb67a241782c321b7f0 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 30 Jun 2024 18:25:01 -0500 Subject: [PATCH 1500/1681] fix: use vue 3.4.31 and oruga 0.8.12 by default i.e. for butterball theme cf. https://github.com/oruga-ui/oruga/issues/974#issuecomment-2198573369 --- tailbone/util.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tailbone/util.py b/tailbone/util.py index 78c41313..98a7f7d4 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -162,11 +162,10 @@ def get_libver(request, key, fallback=True, default_only=False): return '5.3.1' elif key == 'bb_vue': - # TODO: iiuc vue 3.4 does not work with oruga yet - return '3.3.11' + return '3.4.31' elif key == 'bb_oruga': - return '0.8.9' + return '0.8.12' elif key in ('bb_oruga_bulma', 'bb_oruga_bulma_css'): return '0.3.0' From cad50c9149143eff8c6329e77d3c20015d0f0331 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 30 Jun 2024 21:28:56 -0500 Subject: [PATCH 1501/1681] =?UTF-8?q?bump:=20version=200.11.4=20=E2=86=92?= =?UTF-8?q?=200.11.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 10 ++++++++++ setup.cfg | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d92a99e..510aa6a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.11.5 (2024-06-30) + +### Fix + +- allow comma in numeric filter input + +- add custom url prefix if needed, for fanstatic + +- use vue 3.4.31 and oruga 0.8.12 by default + ## v0.11.4 (2024-06-30) ### Fix diff --git a/setup.cfg b/setup.cfg index 82cf3b25..4ee92f01 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,7 +1,7 @@ [metadata] name = Tailbone -version = 0.11.4 +version = 0.11.5 author = Lance Edgar author_email = lance@edbob.org url = http://rattailproject.org/ From 6f8b825b0b24370af4a66cac02e4faa2eb43fee1 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 1 Jul 2024 15:23:56 -0500 Subject: [PATCH 1502/1681] fix: set explicit referrer when changing dbkey since for some reason HTTP_REFERER is not always set now?? --- tailbone/templates/themes/butterball/base.mako | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako index 339d23bd..e38696c5 100644 --- a/tailbone/templates/themes/butterball/base.mako +++ b/tailbone/templates/themes/butterball/base.mako @@ -747,6 +747,7 @@ ${h.form(url('change_db_engine'), ref='dbPickerForm')} ${h.csrf_token(request)} ${h.hidden('engine_type', value=master.engine_type_key)} + <input type="hidden" name="referrer" :value="referrer" /> <b-select name="dbkey" v-model="dbSelected" @input="changeDB()"> From 2feb07e1d3488a798028be3ab7cc63b3ef40de1c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 1 Jul 2024 17:01:01 -0500 Subject: [PATCH 1503/1681] fix: remove references, dependency for `six` package --- setup.cfg | 1 - tailbone/api/batch/labels.py | 10 ++--- tailbone/api/customers.py | 8 +--- tailbone/api/people.py | 8 +--- tailbone/api/upgrades.py | 8 +--- tailbone/api/vendors.py | 8 +--- tailbone/api/workorders.py | 20 ++++------ tailbone/exceptions.py | 7 +--- tailbone/handler.py | 9 ++--- tailbone/subscribers.py | 2 - tailbone/templates/base.mako | 2 +- tailbone/templates/configure.mako | 2 +- tailbone/templates/custorders/items/view.mako | 2 +- tailbone/templates/generate_feature.mako | 2 +- tailbone/templates/ordering/worksheet.mako | 4 +- tailbone/templates/poser/views/configure.mako | 2 +- tailbone/templates/products/batch.mako | 2 +- tailbone/templates/shifts/base.mako | 4 +- .../templates/themes/butterball/base.mako | 2 +- .../trainwreck/transactions/configure.mako | 2 +- .../trainwreck/transactions/rollover.mako | 2 +- tailbone/tweens.py | 7 +--- tailbone/views/batch/labels.py | 16 +++----- tailbone/views/batch/pricing.py | 24 +++++------ tailbone/views/batch/vendorinvoice.py | 16 +++----- tailbone/views/exports.py | 24 ++++------- tailbone/views/poser/reports.py | 14 +++---- tailbone/views/poser/views.py | 40 +++++++++---------- tailbone/views/progress.py | 8 +--- tailbone/views/tempmon/appliances.py | 16 ++++---- tailbone/views/vendors/core.py | 14 +++---- 31 files changed, 105 insertions(+), 181 deletions(-) diff --git a/setup.cfg b/setup.cfg index 4ee92f01..8afd9be4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -56,7 +56,6 @@ install_requires = pyramid_retry pyramid_tm rattail[db,bouncer] - six sa-filters simplejson transaction diff --git a/tailbone/api/batch/labels.py b/tailbone/api/batch/labels.py index 4787aeb9..4f154b21 100644 --- a/tailbone/api/batch/labels.py +++ b/tailbone/api/batch/labels.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Tailbone Web API - Label Batches """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from tailbone.api.batch import APIBatchView, APIBatchRowView @@ -56,10 +52,10 @@ class LabelBatchRowViews(APIBatchRowView): def normalize(self, row): batch = row.batch - data = super(LabelBatchRowViews, self).normalize(row) + data = super().normalize(row) data['item_id'] = row.item_id - data['upc'] = six.text_type(row.upc) + data['upc'] = str(row.upc) data['upc_pretty'] = row.upc.pretty() if row.upc else None data['brand_name'] = row.brand_name data['description'] = row.description diff --git a/tailbone/api/customers.py b/tailbone/api/customers.py index e9953572..85d28c24 100644 --- a/tailbone/api/customers.py +++ b/tailbone/api/customers.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Tailbone Web API - Customer Views """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from tailbone.api import APIMasterView @@ -46,7 +42,7 @@ class CustomerView(APIMasterView): def normalize(self, customer): return { 'uuid': customer.uuid, - '_str': six.text_type(customer), + '_str': str(customer), 'id': customer.id, 'number': customer.number, 'name': customer.name, diff --git a/tailbone/api/people.py b/tailbone/api/people.py index 7e06e969..f7c08dfa 100644 --- a/tailbone/api/people.py +++ b/tailbone/api/people.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Tailbone Web API - Person Views """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from tailbone.api import APIMasterView @@ -45,7 +41,7 @@ class PersonView(APIMasterView): def normalize(self, person): return { 'uuid': person.uuid, - '_str': six.text_type(person), + '_str': str(person), 'first_name': person.first_name, 'last_name': person.last_name, 'display_name': person.display_name, diff --git a/tailbone/api/upgrades.py b/tailbone/api/upgrades.py index 6ce5f778..467c8a0d 100644 --- a/tailbone/api/upgrades.py +++ b/tailbone/api/upgrades.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Tailbone Web API - Upgrade Views """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from tailbone.api import APIMasterView @@ -53,7 +49,7 @@ class UpgradeView(APIMasterView): data['status_code'] = None else: data['status_code'] = self.enum.UPGRADE_STATUS.get(upgrade.status_code, - six.text_type(upgrade.status_code)) + str(upgrade.status_code)) return data diff --git a/tailbone/api/vendors.py b/tailbone/api/vendors.py index 7fa61590..64311b1b 100644 --- a/tailbone/api/vendors.py +++ b/tailbone/api/vendors.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Tailbone Web API - Vendor Views """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from tailbone.api import APIMasterView @@ -44,7 +40,7 @@ class VendorView(APIMasterView): def normalize(self, vendor): return { 'uuid': vendor.uuid, - '_str': six.text_type(vendor), + '_str': str(vendor), 'id': vendor.id, 'name': vendor.name, } diff --git a/tailbone/api/workorders.py b/tailbone/api/workorders.py index eabe4cdb..19def6c4 100644 --- a/tailbone/api/workorders.py +++ b/tailbone/api/workorders.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,12 +24,8 @@ Tailbone Web API - Work Order Views """ -from __future__ import unicode_literals, absolute_import - import datetime -import six - from rattail.db.model import WorkOrder from cornice import Service @@ -44,19 +40,19 @@ class WorkOrderView(APIMasterView): object_url_prefix = '/workorder' def __init__(self, *args, **kwargs): - super(WorkOrderView, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) app = self.get_rattail_app() self.workorder_handler = app.get_workorder_handler() def normalize(self, workorder): - data = super(WorkOrderView, self).normalize(workorder) + data = super().normalize(workorder) data.update({ 'customer_name': workorder.customer.name, 'status_label': self.enum.WORKORDER_STATUS[workorder.status_code], - 'date_submitted': six.text_type(workorder.date_submitted or ''), - 'date_received': six.text_type(workorder.date_received or ''), - 'date_released': six.text_type(workorder.date_released or ''), - 'date_delivered': six.text_type(workorder.date_delivered or ''), + 'date_submitted': str(workorder.date_submitted or ''), + 'date_received': str(workorder.date_received or ''), + 'date_released': str(workorder.date_released or ''), + 'date_delivered': str(workorder.date_delivered or ''), }) return data @@ -87,7 +83,7 @@ class WorkOrderView(APIMasterView): if 'status_code' in data: data['status_code'] = int(data['status_code']) - return super(WorkOrderView, self).update_object(workorder, data) + return super().update_object(workorder, data) def status_codes(self): """ diff --git a/tailbone/exceptions.py b/tailbone/exceptions.py index beea1366..3468562a 100644 --- a/tailbone/exceptions.py +++ b/tailbone/exceptions.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Tailbone Exceptions """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.exceptions import RattailError @@ -37,7 +33,6 @@ class TailboneError(RattailError): """ -@six.python_2_unicode_compatible class TailboneJSONFieldError(TailboneError): """ Error raised when JSON serialization of a form field results in an error. diff --git a/tailbone/handler.py b/tailbone/handler.py index db95bc71..22f33cca 100644 --- a/tailbone/handler.py +++ b/tailbone/handler.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,9 +24,6 @@ Tailbone Handler """ -from __future__ import unicode_literals, absolute_import - -import six from mako.lookup import TemplateLookup from rattail.app import GenericHandler @@ -41,7 +38,7 @@ class TailboneHandler(GenericHandler): """ def __init__(self, *args, **kwargs): - super(TailboneHandler, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) # TODO: make templates dir configurable? templates = [resource_path('rattail:templates/web')] @@ -67,7 +64,7 @@ class TailboneHandler(GenericHandler): Returns an iterator over all registered Tailbone providers. """ providers = get_all_providers(self.config) - return six.itervalues(providers) + return providers.values() def write_model_view(self, data, path, **kwargs): """ diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 3fcd1017..bd59a033 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -24,7 +24,6 @@ Event Subscribers """ -import six import json import datetime import logging @@ -177,7 +176,6 @@ def before_render(event): renderer_globals['tailbone'] = tailbone renderer_globals['model'] = request.rattail_config.get_model() renderer_globals['enum'] = request.rattail_config.get_enum() - renderer_globals['six'] = six renderer_globals['json'] = json renderer_globals['datetime'] = datetime renderer_globals['colander'] = colander diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index f576473d..c4cbd648 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -890,7 +890,7 @@ % if request.user: FeedbackFormData.userUUID = ${json.dumps(request.user.uuid)|n} - FeedbackFormData.userName = ${json.dumps(six.text_type(request.user))|n} + FeedbackFormData.userName = ${json.dumps(str(request.user))|n} % endif </script> diff --git a/tailbone/templates/configure.mako b/tailbone/templates/configure.mako index 3aa60f31..f33779c8 100644 --- a/tailbone/templates/configure.mako +++ b/tailbone/templates/configure.mako @@ -236,7 +236,7 @@ % if input_file_template_settings is not Undefined: ThisPage.methods.validateInputFileTemplateSettings = function() { - % for tmpl in six.itervalues(input_file_templates): + % for tmpl in input_file_templates.values(): if (this.inputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted') { if (!this.inputFileTemplateSettings['${tmpl['setting_file']}']) { if (!this.inputFileTemplateUploads['${tmpl['key']}']) { diff --git a/tailbone/templates/custorders/items/view.mako b/tailbone/templates/custorders/items/view.mako index 41567d41..f7a6dd0a 100644 --- a/tailbone/templates/custorders/items/view.mako +++ b/tailbone/templates/custorders/items/view.mako @@ -347,7 +347,7 @@ } ThisPageData.orderItemStatuses = ${json.dumps(enum.CUSTORDER_ITEM_STATUS)|n} - ThisPageData.orderItemStatusOptions = ${json.dumps([dict(key=k, label=v) for k, v in six.iteritems(enum.CUSTORDER_ITEM_STATUS)])|n} + ThisPageData.orderItemStatusOptions = ${json.dumps([dict(key=k, label=v) for k, v in enum.CUSTORDER_ITEM_STATUS.items()])|n} ThisPageData.oldStatusCode = ${instance.status_code} diff --git a/tailbone/templates/generate_feature.mako b/tailbone/templates/generate_feature.mako index a7064331..18a26f58 100644 --- a/tailbone/templates/generate_feature.mako +++ b/tailbone/templates/generate_feature.mako @@ -296,7 +296,7 @@ % endfor } - % for key, form in six.iteritems(feature_forms): + % for key, form in feature_forms.items(): <% safekey = key.replace('-', '_') %> ThisPageData.${safekey} = { <% dform = feature_forms[key].make_deform_form() %> diff --git a/tailbone/templates/ordering/worksheet.mako b/tailbone/templates/ordering/worksheet.mako index e41fe15f..ca1abf6e 100644 --- a/tailbone/templates/ordering/worksheet.mako +++ b/tailbone/templates/ordering/worksheet.mako @@ -73,7 +73,7 @@ <div class="grid"> <table class="order-form"> <% column_count = 8 + len(header_columns) + (0 if ignore_cases else 1) + int(capture(self.extra_count)) %> - % for department in sorted(six.itervalues(departments), key=lambda d: d.name if d else ''): + % for department in sorted(departments.values(), key=lambda d: d.name if d else ''): <thead> <tr> <th class="department" colspan="${column_count}">Department @@ -84,7 +84,7 @@ % endif </th> </tr> - % for subdepartment in sorted(six.itervalues(department._order_subdepartments), key=lambda s: s.name if s else ''): + % for subdepartment in sorted(department._order_subdepartments.values(), key=lambda s: s.name if s else ''): <tr> <th class="subdepartment" colspan="${column_count}">Subdepartment % if subdepartment.number or subdepartment.name: diff --git a/tailbone/templates/poser/views/configure.mako b/tailbone/templates/poser/views/configure.mako index f4d75779..cdde15c5 100644 --- a/tailbone/templates/poser/views/configure.mako +++ b/tailbone/templates/poser/views/configure.mako @@ -9,7 +9,7 @@ % for topkey, topgroup in sorted(view_settings.items(), key=lambda itm: 'aaaa' if itm[0] == 'rattail' else itm[0]): <h3 class="block is-size-3">Views for: ${topkey}</h3> - % for group_key, group in six.iteritems(topgroup): + % for group_key, group in topgroup.items(): <h4 class="block is-size-4">${group_key.capitalize()}</h4> % for key, label in group: ${self.simple_flag(key, label)} diff --git a/tailbone/templates/products/batch.mako b/tailbone/templates/products/batch.mako index e0b93bd6..a4a4d503 100644 --- a/tailbone/templates/products/batch.mako +++ b/tailbone/templates/products/batch.mako @@ -30,7 +30,7 @@ ${render_deform_field(form, dform['description'])} ${render_deform_field(form, dform['notes'])} - % for key, pform in six.iteritems(params_forms): + % for key, pform in params_forms.items(): <div v-show="field_model_batch_type == '${key}'"> % for field in pform.make_deform_form(): ${render_deform_field(pform, field)} diff --git a/tailbone/templates/shifts/base.mako b/tailbone/templates/shifts/base.mako index 4bae5ebf..52b48832 100644 --- a/tailbone/templates/shifts/base.mako +++ b/tailbone/templates/shifts/base.mako @@ -57,7 +57,7 @@ <div class="field-wrapper employee"> <label>Employee</label> <div class="field"> - ${dform['employee'].serialize(text=six.text_type(employee), selected_callback='employee_selected')|n} + ${dform['employee'].serialize(text=str(employee), selected_callback='employee_selected')|n} </div> </div> % endif @@ -152,7 +152,7 @@ </tr> </thead> <tbody> - % for emp in sorted(employees, key=six.text_type): + % for emp in sorted(employees, key=str): <tr data-employee-uuid="${emp.uuid}"> <td class="employee"> ## TODO: add link to single employee schedule / timesheet here... diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako index e38696c5..b0e43a37 100644 --- a/tailbone/templates/themes/butterball/base.mako +++ b/tailbone/templates/themes/butterball/base.mako @@ -421,7 +421,7 @@ referrer: null, % if request.user: userUUID: ${json.dumps(request.user.uuid)|n}, - userName: ${json.dumps(six.text_type(request.user))|n}, + userName: ${json.dumps(str(request.user))|n}, % else: userUUID: null, userName: null, diff --git a/tailbone/templates/trainwreck/transactions/configure.mako b/tailbone/templates/trainwreck/transactions/configure.mako index fd6c53a7..99b43fde 100644 --- a/tailbone/templates/trainwreck/transactions/configure.mako +++ b/tailbone/templates/trainwreck/transactions/configure.mako @@ -33,7 +33,7 @@ The selected DBs will be hidden from the DB picker when viewing Trainwreck data. </p> - % for key, engine in six.iteritems(trainwreck_engines): + % for key, engine in trainwreck_engines.items(): <b-field> <b-checkbox name="hidedb_${key}" v-model="hiddenDatabases['${key}']" diff --git a/tailbone/templates/trainwreck/transactions/rollover.mako b/tailbone/templates/trainwreck/transactions/rollover.mako index 8e27d087..b36e7bc3 100644 --- a/tailbone/templates/trainwreck/transactions/rollover.mako +++ b/tailbone/templates/trainwreck/transactions/rollover.mako @@ -8,7 +8,7 @@ <%def name="page_content()"> <br /> - % if six.text_type(next_year) not in trainwreck_engines: + % if str(next_year) not in trainwreck_engines: <b-notification type="is-warning"> You do not have a database configured for next year (${next_year}). You should be sure to configure it before next year rolls around. diff --git a/tailbone/tweens.py b/tailbone/tweens.py index f944a66f..9c06c1be 100644 --- a/tailbone/tweens.py +++ b/tailbone/tweens.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,9 +24,6 @@ Tween Factories """ -from __future__ import unicode_literals, absolute_import - -import six from sqlalchemy.exc import OperationalError @@ -64,7 +61,7 @@ def sqlerror_tween_factory(handler, registry): mark_error_retryable(error) raise error else: - raise TransientError(six.text_type(error)) + raise TransientError(str(error)) # if connection was *not* invalid, raise original error raise diff --git a/tailbone/views/batch/labels.py b/tailbone/views/batch/labels.py index 79b14a76..7291b05e 100644 --- a/tailbone/views/batch/labels.py +++ b/tailbone/views/batch/labels.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Views for label batches """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from deform import widget as dfwidget @@ -123,7 +119,7 @@ class LabelBatchView(BatchMasterView): ] def configure_form(self, f): - super(LabelBatchView, self).configure_form(f) + super().configure_form(f) # handheld_batches if self.creating: @@ -142,7 +138,7 @@ class LabelBatchView(BatchMasterView): f.replace('label_profile', 'label_profile_uuid') # TODO: should restrict somehow? just allow override? profiles = self.Session.query(model.LabelProfile) - values = [(p.uuid, six.text_type(p)) + values = [(p.uuid, str(p)) for p in profiles] require_profile = False if not require_profile: @@ -159,7 +155,7 @@ class LabelBatchView(BatchMasterView): return HTML.tag('ul', c=items) def configure_row_grid(self, g): - super(LabelBatchView, self).configure_row_grid(g) + super().configure_row_grid(g) # short labels g.set_label('brand_name', "Brand") @@ -171,7 +167,7 @@ class LabelBatchView(BatchMasterView): return 'warning' def configure_row_form(self, f): - super(LabelBatchView, self).configure_row_form(f) + super().configure_row_form(f) # readonly fields f.set_readonly('sequence') @@ -219,7 +215,7 @@ class LabelBatchView(BatchMasterView): profiles = self.Session.query(model.LabelProfile)\ .filter(model.LabelProfile.visible == True)\ .order_by(model.LabelProfile.ordinal) - profile_values = [(p.uuid, six.text_type(p)) + profile_values = [(p.uuid, str(p)) for p in profiles] f.set_widget('label_profile_uuid', forms.widgets.JQuerySelectWidget(values=profile_values)) diff --git a/tailbone/views/batch/pricing.py b/tailbone/views/batch/pricing.py index 6ba28889..5b5d013b 100644 --- a/tailbone/views/batch/pricing.py +++ b/tailbone/views/batch/pricing.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Views for pricing batches """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from rattail.time import localtime @@ -155,7 +151,7 @@ class PricingBatchView(BatchMasterView): return self.batch_handler.allow_future() def configure_form(self, f): - super(PricingBatchView, self).configure_form(f) + super().configure_form(f) app = self.get_rattail_app() batch = f.model_instance @@ -192,7 +188,7 @@ class PricingBatchView(BatchMasterView): f.set_required('input_filename', False) def get_batch_kwargs(self, batch, **kwargs): - kwargs = super(PricingBatchView, self).get_batch_kwargs(batch, **kwargs) + kwargs = super().get_batch_kwargs(batch, **kwargs) kwargs['start_date'] = batch.start_date kwargs['min_diff_threshold'] = batch.min_diff_threshold kwargs['min_diff_percent'] = batch.min_diff_percent @@ -213,7 +209,7 @@ class PricingBatchView(BatchMasterView): return kwargs def configure_row_grid(self, g): - super(PricingBatchView, self).configure_row_grid(g) + super().configure_row_grid(g) g.set_joiner('vendor_id', lambda q: q.outerjoin(model.Vendor)) g.set_sorter('vendor_id', model.Vendor.id) @@ -241,13 +237,13 @@ class PricingBatchView(BatchMasterView): if row.subdepartment_number: if row.subdepartment_name: return HTML.tag('span', title=row.subdepartment_name, - c=six.text_type(row.subdepartment_number)) + c=str(row.subdepartment_number)) return row.subdepartment_number def render_true_margin(self, row, field): margin = row.true_margin if margin: - margin = six.text_type(margin) + margin = str(margin) else: margin = HTML.literal(' ') if row.old_true_margin is not None: @@ -295,7 +291,7 @@ class PricingBatchView(BatchMasterView): return HTML.tag('span', title=title, c=text) def configure_row_form(self, f): - super(PricingBatchView, self).configure_row_form(f) + super().configure_row_form(f) # readonly fields f.set_readonly('product') @@ -328,7 +324,7 @@ class PricingBatchView(BatchMasterView): return tags.link_to(text, url) def get_row_csv_fields(self): - fields = super(PricingBatchView, self).get_row_csv_fields() + fields = super().get_row_csv_fields() if 'vendor_uuid' in fields: i = fields.index('vendor_uuid') @@ -344,7 +340,7 @@ class PricingBatchView(BatchMasterView): # TODO: this is the same as xlsx row! should merge/share somehow? def get_row_csv_row(self, row, fields): - csvrow = super(PricingBatchView, self).get_row_csv_row(row, fields) + csvrow = super().get_row_csv_row(row, fields) vendor = row.vendor if 'vendor_id' in fields: @@ -358,7 +354,7 @@ class PricingBatchView(BatchMasterView): # TODO: this is the same as csv row! should merge/share somehow? def get_row_xlsx_row(self, row, fields): - xlrow = super(PricingBatchView, self).get_row_xlsx_row(row, fields) + xlrow = super().get_row_xlsx_row(row, fields) vendor = row.vendor if 'vendor_id' in fields: diff --git a/tailbone/views/batch/vendorinvoice.py b/tailbone/views/batch/vendorinvoice.py index 6b8bdef7..4815d1f4 100644 --- a/tailbone/views/batch/vendorinvoice.py +++ b/tailbone/views/batch/vendorinvoice.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Views for maintaining vendor invoices """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from rattail.vendors.invoices import iter_invoice_parsers, require_invoice_parser @@ -89,10 +85,10 @@ class VendorInvoiceView(FileBatchMasterView): ] def get_instance_title(self, batch): - return six.text_type(batch.vendor) + return str(batch.vendor) def configure_grid(self, g): - super(VendorInvoiceView, self).configure_grid(g) + super().configure_grid(g) # vendor g.set_joiner('vendor', lambda q: q.join(model.Vendor)) @@ -118,7 +114,7 @@ class VendorInvoiceView(FileBatchMasterView): g.set_link('executed', False) def configure_form(self, f): - super(VendorInvoiceView, self).configure_form(f) + super().configure_form(f) # vendor if self.creating: @@ -167,7 +163,7 @@ class VendorInvoiceView(FileBatchMasterView): # raise formalchemy.ValidationError(unicode(error)) def get_batch_kwargs(self, batch): - kwargs = super(VendorInvoiceView, self).get_batch_kwargs(batch) + kwargs = super().get_batch_kwargs(batch) kwargs['parser_key'] = batch.parser_key return kwargs @@ -183,7 +179,7 @@ class VendorInvoiceView(FileBatchMasterView): return True def configure_row_grid(self, g): - super(VendorInvoiceView, self).configure_row_grid(g) + super().configure_row_grid(g) g.set_label('upc', "UPC") g.set_label('brand_name', "Brand") g.set_label('shipped_cases', "Cases") diff --git a/tailbone/views/exports.py b/tailbone/views/exports.py index 82591099..44df359f 100644 --- a/tailbone/views/exports.py +++ b/tailbone/views/exports.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,13 +24,9 @@ Master class for generic export history views """ -from __future__ import unicode_literals, absolute_import - import os import shutil -import six - from pyramid.response import FileResponse from webhelpers2.html import tags @@ -83,7 +79,7 @@ class ExportMasterView(MasterView): return self.get_file_path(export) def configure_grid(self, g): - super(ExportMasterView, self).configure_grid(g) + super().configure_grid(g) model = self.model # id @@ -106,7 +102,7 @@ class ExportMasterView(MasterView): return export.id_str def configure_form(self, f): - super(ExportMasterView, self).configure_form(f) + super().configure_form(f) export = f.model_instance # NOTE: we try to handle the 'creating' scenario even though this class @@ -149,7 +145,7 @@ class ExportMasterView(MasterView): f.set_renderer('filename', self.render_downloadable_file) def objectify(self, form, data=None): - obj = super(ExportMasterView, self).objectify(form, data=data) + obj = super().objectify(form, data=data) if self.creating: obj.created_by = self.request.user return obj @@ -158,7 +154,7 @@ class ExportMasterView(MasterView): user = export.created_by if not user: return "" - text = six.text_type(user) + text = str(user) if self.request.has_perm('users.view'): url = self.request.route_url('users.view', uuid=user.uuid) return tags.link_to(text, url) @@ -175,12 +171,8 @@ class ExportMasterView(MasterView): export = self.get_instance() path = self.get_file_path(export) response = FileResponse(path, request=self.request) - if six.PY3: - response.headers['Content-Length'] = str(os.path.getsize(path)) - response.headers['Content-Disposition'] = 'attachment; filename="{}"'.format(export.filename) - else: - response.headers[b'Content-Length'] = six.binary_type(os.path.getsize(path)) - response.headers[b'Content-Disposition'] = b'attachment; filename="{}"'.format(export.filename) + response.headers['Content-Length'] = str(os.path.getsize(path)) + response.headers['Content-Disposition'] = 'attachment; filename="{}"'.format(export.filename) return response def delete_instance(self, export): @@ -195,4 +187,4 @@ class ExportMasterView(MasterView): shutil.rmtree(dirname) # continue w/ normal deletion - super(ExportMasterView, self).delete_instance(export) + super().delete_instance(export) diff --git a/tailbone/views/poser/reports.py b/tailbone/views/poser/reports.py index 43ba211d..462df51d 100644 --- a/tailbone/views/poser/reports.py +++ b/tailbone/views/poser/reports.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,12 +24,8 @@ Poser Report Views """ -from __future__ import unicode_literals, absolute_import - import os -import six - from rattail.util import simple_error import colander @@ -95,7 +91,7 @@ class PoserReportView(PoserMasterView): return self.poser_handler.get_all_reports(ignore_errors=False) def configure_grid(self, g): - super(PoserReportView, self).configure_grid(g) + super().configure_grid(g) g.sorters['report_key'] = g.make_simple_sorter('report_key', foldcase=True) g.sorters['report_name'] = g.make_simple_sorter('report_name', foldcase=True) @@ -157,7 +153,7 @@ class PoserReportView(PoserMasterView): return report def configure_form(self, f): - super(PoserReportView, self).configure_form(f) + super().configure_form(f) report = f.model_instance # report_key @@ -179,7 +175,7 @@ class PoserReportView(PoserMasterView): f.set_helptext('flavor', "Determines the type of sample code to generate.") flavors = self.poser_handler.get_supported_report_flavors() values = [(key, flavor['description']) - for key, flavor in six.iteritems(flavors)] + for key, flavor in flavors.items()] f.set_widget('flavor', dfwidget.SelectWidget(values=values)) f.set_validator('flavor', colander.OneOf(flavors)) if flavors: @@ -231,7 +227,7 @@ class PoserReportView(PoserMasterView): return report def configure_row_grid(self, g): - super(PoserReportView, self).configure_row_grid(g) + super().configure_row_grid(g) g.set_renderer('id', self.render_id_str) diff --git a/tailbone/views/poser/views.py b/tailbone/views/poser/views.py index 14c97a61..3d3543c7 100644 --- a/tailbone/views/poser/views.py +++ b/tailbone/views/poser/views.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Poser Views for Views... """ -from __future__ import unicode_literals, absolute_import - -import six - import colander from .master import PoserMasterView @@ -68,7 +64,7 @@ class PoserViewView(PoserMasterView): return self.make_form({}) def configure_form(self, f): - super(PoserViewView, self).configure_form(f) + super().configure_form(f) view = f.model_instance # key @@ -224,28 +220,28 @@ class PoserViewView(PoserMasterView): }, }} - for key, views in six.iteritems(everything['rattail']): - for vkey, view in six.iteritems(views): + for key, views in everything['rattail'].items(): + for vkey, view in views.items(): view['options'] = [vkey] providers = get_all_providers(self.rattail_config) - for provider in six.itervalues(providers): + for provider in providers.values(): # loop thru provider top-level groups - for topkey, groups in six.iteritems(provider.get_provided_views()): + for topkey, groups in provider.get_provided_views().items()): # get or create top group topgroup = everything.setdefault(topkey, {}) # loop thru provider view groups - for key, views in six.iteritems(groups): + for key, views in groups.items(): # add group to top group, if it's new if key not in topgroup: topgroup[key] = views # also must init the options for group - for vkey, view in six.iteritems(views): + for vkey, view in views.items(): view['options'] = [vkey] else: # otherwise must "update" existing group @@ -254,7 +250,7 @@ class PoserViewView(PoserMasterView): stdgroup = topgroup[key] # loop thru views within provider group - for vkey, view in six.iteritems(views): + for vkey, view in views.items(): # add view to group if it's new if vkey not in stdgroup: @@ -270,8 +266,8 @@ class PoserViewView(PoserMasterView): settings = [] view_settings = self.collect_available_view_settings() - for topgroup in six.itervalues(view_settings): - for view_section, section_settings in six.iteritems(topgroup): + for topgroup in view_settings.values(): + for view_section, section_settings in topgroup.items(): for key in section_settings: settings.append({'section': 'tailbone.includes', 'option': key}) @@ -282,25 +278,25 @@ class PoserViewView(PoserMasterView): input_file_templates=True): # first get normal context - context = super(PoserViewView, self).configure_get_context( + context = super().configure_get_context( simple_settings=simple_settings, input_file_templates=input_file_templates) # first add available options view_settings = self.collect_available_view_settings() view_options = {} - for topgroup in six.itervalues(view_settings): - for key, views in six.iteritems(topgroup): - for vkey, view in six.iteritems(views): + for topgroup in view_settings.values(): + for key, views in topgroup.items(): + for vkey, view in views.items(): view_options[vkey] = view['options'] context['view_options'] = view_options # then add all available settings as sorted (key, label) options - for topkey, topgroup in six.iteritems(view_settings): + for topkey, topgroup in view_settings.items(): for key in list(topgroup): settings = topgroup[key] settings = [(key, setting.get('label', key)) - for key, setting in six.iteritems(settings)] + for key, setting in settings.items()] settings.sort(key=lambda itm: itm[1]) topgroup[key] = settings context['view_settings'] = view_settings @@ -308,7 +304,7 @@ class PoserViewView(PoserMasterView): return context def configure_flash_settings_saved(self): - super(PoserViewView, self).configure_flash_settings_saved() + super().configure_flash_settings_saved() self.request.session.flash("Please restart the web app!", 'warning') diff --git a/tailbone/views/progress.py b/tailbone/views/progress.py index 169f324e..3f47ba3e 100644 --- a/tailbone/views/progress.py +++ b/tailbone/views/progress.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Progress Views """ -from __future__ import unicode_literals, absolute_import - -import six - from tailbone.progress import get_progress_session @@ -44,7 +40,7 @@ def progress(request): bits = session.get('extra_session_bits') if bits: - for key, value in six.iteritems(bits): + for key, value in bits.items(): request.session[key] = value elif session.get('error'): diff --git a/tailbone/views/tempmon/appliances.py b/tailbone/views/tempmon/appliances.py index c523ae78..4ce52009 100644 --- a/tailbone/views/tempmon/appliances.py +++ b/tailbone/views/tempmon/appliances.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,11 +24,9 @@ Views for tempmon appliances """ -from __future__ import unicode_literals, absolute_import - +import io import os -import six from PIL import Image from rattail_tempmon.db import model as tempmon @@ -68,7 +66,7 @@ class TempmonApplianceView(MasterView): ] def configure_grid(self, g): - super(TempmonApplianceView, self).configure_grid(g) + super().configure_grid(g) # name g.set_sort_defaults('name') @@ -94,7 +92,7 @@ class TempmonApplianceView(MasterView): return HTML.tag('div', class_='image-frame', c=[helper, image]) def configure_form(self, f): - super(TempmonApplianceView, self).configure_form(f) + super().configure_form(f) # name f.set_validator('name', self.unique_name) @@ -122,7 +120,7 @@ class TempmonApplianceView(MasterView): f.remove_field('probes') def template_kwargs_view(self, **kwargs): - kwargs = super(TempmonApplianceView, self).template_kwargs_view(**kwargs) + kwargs = super().template_kwargs_view(**kwargs) appliance = kwargs['instance'] kwargs['probes_data'] = self.normalize_probes(appliance.probes) @@ -176,13 +174,13 @@ class TempmonApplianceView(MasterView): im = Image.open(f) im.thumbnail((600, 600), Image.ANTIALIAS) - data = six.BytesIO() + data = io.BytesIO() im.save(data, 'JPEG') appliance.image_normal = data.getvalue() data.close() im.thumbnail((150, 150), Image.ANTIALIAS) - data = six.BytesIO() + data = io.BytesIO() im.save(data, 'JPEG') appliance.image_thumbnail = data.getvalue() data.close() diff --git a/tailbone/views/vendors/core.py b/tailbone/views/vendors/core.py index 8b9361b7..addf153c 100644 --- a/tailbone/views/vendors/core.py +++ b/tailbone/views/vendors/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Vendor Views """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from webhelpers2.html import tags @@ -158,7 +154,7 @@ class VendorView(MasterView): person = vendor.contact if not person: return "" - text = six.text_type(person) + text = str(person) url = self.request.route_url('people.view', uuid=person.uuid) return tags.link_to(text, url) @@ -198,7 +194,7 @@ class VendorView(MasterView): data, **kwargs) supported_vendor_settings = self.configure_get_supported_vendor_settings() - for setting in six.itervalues(supported_vendor_settings): + for setting in supported_vendor_settings.values(): name = 'rattail.vendor.{}'.format(setting['key']) settings.append({'name': name, 'value': data[name]}) @@ -211,7 +207,7 @@ class VendorView(MasterView): names = [] supported_vendor_settings = self.configure_get_supported_vendor_settings() - for setting in six.itervalues(supported_vendor_settings): + for setting in supported_vendor_settings.values(): names.append('rattail.vendor.{}'.format(setting['key'])) if names: @@ -236,7 +232,7 @@ class VendorView(MasterView): settings[key] = { 'key': key, 'value': vendor.uuid if vendor else None, - 'label': six.text_type(vendor) if vendor else None, + 'label': str(vendor) if vendor else None, } return settings From c887412825696fc7fe9cd3032d06b28dada4d1b8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 1 Jul 2024 19:06:04 -0500 Subject: [PATCH 1504/1681] fix: fix syntax bug --- tailbone/views/poser/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/poser/views.py b/tailbone/views/poser/views.py index 3d3543c7..27efd549 100644 --- a/tailbone/views/poser/views.py +++ b/tailbone/views/poser/views.py @@ -228,7 +228,7 @@ class PoserViewView(PoserMasterView): for provider in providers.values(): # loop thru provider top-level groups - for topkey, groups in provider.get_provided_views().items()): + for topkey, groups in provider.get_provided_views().items(): # get or create top group topgroup = everything.setdefault(topkey, {}) From db67630363ffc7f8d4d7844c91f18b67bbcd57f4 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 1 Jul 2024 23:20:09 -0500 Subject: [PATCH 1505/1681] =?UTF-8?q?bump:=20version=200.11.5=20=E2=86=92?= =?UTF-8?q?=200.11.6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 ++++++++ setup.cfg | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 510aa6a1..9410fe3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.11.6 (2024-07-01) + +### Fix + +- set explicit referrer when changing dbkey + +- remove references, dependency for `six` package + ## v0.11.5 (2024-06-30) ### Fix diff --git a/setup.cfg b/setup.cfg index 8afd9be4..17f6b151 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,7 +1,7 @@ [metadata] name = Tailbone -version = 0.11.5 +version = 0.11.6 author = Lance Edgar author_email = lance@edbob.org url = http://rattailproject.org/ From aab4dec27ebadd60da81efcdf906a049c7af2cea Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 2 Jul 2024 09:05:51 -0500 Subject: [PATCH 1506/1681] fix: add stacklevel to deprecation warnings --- tailbone/views/batch/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 84ef451f..f4f74a34 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -853,7 +853,7 @@ class BatchMasterView(MasterView): if isinstance(field.widget, forms.widgets.PlainSelectWidget): warnings.warn("PlainSelectWidget is deprecated; " "please use deform.widget.SelectWidget instead", - DeprecationWarning) + DeprecationWarning, stacklevel=2) field.widget = dfwidget.SelectWidget(values=field.widget.values) if not schema: From d72d6f8c7c143c4941169a1b0ad3ee6f2b025cbe Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 2 Jul 2024 11:14:03 -0500 Subject: [PATCH 1507/1681] fix: require zope.sqlalchemy >= 1.5 so we can do away with some old cruft, since latest zope.sqlalchemy is 3.1 from 2023-09-12 --- docs/api/db.rst | 6 ++ docs/index.rst | 1 + setup.cfg | 2 +- tailbone/db.py | 231 +++++++++++++++++++++++------------------------ tests/test_db.py | 7 ++ 5 files changed, 129 insertions(+), 118 deletions(-) create mode 100644 docs/api/db.rst create mode 100644 tests/test_db.py diff --git a/docs/api/db.rst b/docs/api/db.rst new file mode 100644 index 00000000..ace21b68 --- /dev/null +++ b/docs/api/db.rst @@ -0,0 +1,6 @@ + +``tailbone.db`` +=============== + +.. automodule:: tailbone.db + :members: diff --git a/docs/index.rst b/docs/index.rst index db05d0c1..3ca6d4e2 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -44,6 +44,7 @@ Package API: api/api/batch/core api/api/batch/ordering + api/db api/diffs api/forms api/forms.widgets diff --git a/setup.cfg b/setup.cfg index 17f6b151..c42ff675 100644 --- a/setup.cfg +++ b/setup.cfg @@ -61,7 +61,7 @@ install_requires = transaction waitress WebHelpers2 - zope.sqlalchemy + zope.sqlalchemy>=1.5 [options.packages.find] diff --git a/tailbone/db.py b/tailbone/db.py index 4a6821f9..8b37f399 100644 --- a/tailbone/db.py +++ b/tailbone/db.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -21,14 +21,13 @@ # ################################################################################ """ -Database Stuff +Database sessions etc. """ import sqlalchemy as sa from zope.sqlalchemy import datamanager import sqlalchemy_continuum as continuum from sqlalchemy.orm import sessionmaker, scoped_session -from pkg_resources import get_distribution, parse_version from rattail.db import SessionBase from rattail.db.continuum import versioning_manager @@ -43,23 +42,28 @@ TrainwreckSession = scoped_session(sessionmaker()) # empty dict for now, this must populated on app startup (if needed) ExtraTrainwreckSessions = {} -# some of the logic below may need to vary somewhat, based on which version of -# zope.sqlalchemy we have installed -zope_sqlalchemy_version = get_distribution('zope.sqlalchemy').version -zope_sqlalchemy_version_parsed = parse_version(zope_sqlalchemy_version) - class TailboneSessionDataManager(datamanager.SessionDataManager): - """Integrate a top level sqlalchemy session transaction into a zope transaction + """ + Integrate a top level sqlalchemy session transaction into a zope + transaction One phase variant. .. note:: - This class appears to be necessary in order for the Continuum - integration to work alongside the Zope transaction integration. + + This class appears to be necessary in order for the + SQLAlchemy-Continuum integration to work alongside the Zope + transaction integration. + + It subclasses + ``zope.sqlalchemy.datamanager.SessionDataManager`` but injects + some SQLAlchemy-Continuum logic within :meth:`tpc_vote()`, and + is sort of monkey-patched into the mix. """ def tpc_vote(self, trans): + """ """ # for a one phase data manager commit last in tpc_vote if self.tx is not None: # there may have been no work to do @@ -71,126 +75,120 @@ class TailboneSessionDataManager(datamanager.SessionDataManager): self._finish('committed') -def join_transaction(session, initial_state=datamanager.STATUS_ACTIVE, transaction_manager=datamanager.zope_transaction.manager, keep_session=False): - """Join a session to a transaction using the appropriate datamanager. +def join_transaction( + session, + initial_state=datamanager.STATUS_ACTIVE, + transaction_manager=datamanager.zope_transaction.manager, + keep_session=False, +): + """ + Join a session to a transaction using the appropriate datamanager. - It is safe to call this multiple times, if the session is already joined - then it just returns. + It is safe to call this multiple times, if the session is already + joined then it just returns. - `initial_state` is either STATUS_ACTIVE, STATUS_INVALIDATED or STATUS_READONLY + `initial_state` is either STATUS_ACTIVE, STATUS_INVALIDATED or + STATUS_READONLY - If using the default initial status of STATUS_ACTIVE, you must ensure that - mark_changed(session) is called when data is written to the database. + If using the default initial status of STATUS_ACTIVE, you must + ensure that mark_changed(session) is called when data is written + to the database. - The ZopeTransactionExtesion SessionExtension can be used to ensure that this is - called automatically after session write operations. + The ZopeTransactionExtesion SessionExtension can be used to ensure + that this is called automatically after session write operations. .. note:: - This function is copied from upstream, and tweaked so that our custom - :class:`TailboneSessionDataManager` will be used. + + This function appears to be necessary in order for the + SQLAlchemy-Continuum integration to work alongside the Zope + transaction integration. + + It overrides ``zope.sqlalchemy.datamanager.join_transaction()`` + to ensure the custom :class:`TailboneSessionDataManager` is + used, and is sort of monkey-patched into the mix. """ # the upstream internals of this function has changed a little over time. # unfortunately for us, that means we must include each variant here. - if zope_sqlalchemy_version_parsed >= parse_version('1.1'): # 1.1+ - if datamanager._SESSION_STATE.get(session, None) is None: - if session.twophase: - DataManager = datamanager.TwoPhaseSessionDataManager - else: - DataManager = TailboneSessionDataManager - DataManager(session, initial_state, transaction_manager, keep_session=keep_session) - - else: # pre-1.1 - if datamanager._SESSION_STATE.get(id(session), None) is None: - if session.twophase: - DataManager = datamanager.TwoPhaseSessionDataManager - else: - DataManager = TailboneSessionDataManager - DataManager(session, initial_state, transaction_manager, keep_session=keep_session) + if datamanager._SESSION_STATE.get(session, None) is None: + if session.twophase: + DataManager = datamanager.TwoPhaseSessionDataManager + else: + DataManager = TailboneSessionDataManager + DataManager(session, initial_state, transaction_manager, keep_session=keep_session) -if zope_sqlalchemy_version_parsed >= parse_version('1.2'): # 1.2+ - - class ZopeTransactionEvents(datamanager.ZopeTransactionEvents): - """ - Record that a flush has occurred on a session's - connection. This allows the DataManager to rollback rather - than commit on read only transactions. - - .. note:: - This class is copied from upstream, and tweaked so that our - custom :func:`join_transaction()` will be used. - """ - - def after_begin(self, session, transaction, connection): - join_transaction(session, self.initial_state, - self.transaction_manager, self.keep_session) - - def after_attach(self, session, instance): - join_transaction(session, self.initial_state, - self.transaction_manager, self.keep_session) - - def join_transaction(self, session): - join_transaction(session, self.initial_state, - self.transaction_manager, self.keep_session) - -else: # pre-1.2 - - class ZopeTransactionExtension(datamanager.ZopeTransactionExtension): - """ - Record that a flush has occurred on a session's - connection. This allows the DataManager to rollback rather - than commit on read only transactions. - - .. note:: - This class is copied from upstream, and tweaked so that our - custom :func:`join_transaction()` will be used. - """ - - def after_begin(self, session, transaction, connection): - join_transaction(session, self.initial_state, - self.transaction_manager, self.keep_session) - - def after_attach(self, session, instance): - join_transaction(session, self.initial_state, - self.transaction_manager, self.keep_session) - - -def register(session, initial_state=datamanager.STATUS_ACTIVE, - transaction_manager=datamanager.zope_transaction.manager, keep_session=False): - """Register ZopeTransaction listener events on the - given Session or Session factory/class. - - This function requires at least SQLAlchemy 0.7 and makes use - of the newer sqlalchemy.event package in order to register event listeners - on the given Session. - - The session argument here may be a Session class or subclass, a - sessionmaker or scoped_session instance, or a specific Session instance. - Event listening will be specific to the scope of the type of argument - passed, including specificity to its subclass as well as its identity. +class ZopeTransactionEvents(datamanager.ZopeTransactionEvents): + """ + Record that a flush has occurred on a session's connection. This + allows the DataManager to rollback rather than commit on read only + transactions. .. note:: - This function is copied from upstream, and tweaked so that our custom - :class:`ZopeTransactionExtension` will be used. + + This class appears to be necessary in order for the + SQLAlchemy-Continuum integration to work alongside the Zope + transaction integration. + + It subclasses + ``zope.sqlalchemy.datamanager.ZopeTransactionEvents`` but + overrides various methods to ensure the custom + :func:`join_transaction()` is called, and is sort of + monkey-patched into the mix. + """ + + def after_begin(self, session, transaction, connection): + """ """ + join_transaction(session, self.initial_state, + self.transaction_manager, self.keep_session) + + def after_attach(self, session, instance): + """ """ + join_transaction(session, self.initial_state, + self.transaction_manager, self.keep_session) + + def join_transaction(self, session): + """ """ + join_transaction(session, self.initial_state, + self.transaction_manager, self.keep_session) + + +def register( + session, + initial_state=datamanager.STATUS_ACTIVE, + transaction_manager=datamanager.zope_transaction.manager, + keep_session=False, +): + """ + Register ZopeTransaction listener events on the given Session or + Session factory/class. + + This function requires at least SQLAlchemy 0.7 and makes use of + the newer sqlalchemy.event package in order to register event + listeners on the given Session. + + The session argument here may be a Session class or subclass, a + sessionmaker or scoped_session instance, or a specific Session + instance. Event listening will be specific to the scope of the + type of argument passed, including specificity to its subclass as + well as its identity. + + .. note:: + + This function appears to be necessary in order for the + SQLAlchemy-Continuum integration to work alongside the Zope + transaction integration. + + It overrides ``zope.sqlalchemy.datamanager.regsiter()`` to + ensure the custom :class:`ZopeTransactionEvents` is used. """ from sqlalchemy import event - if zope_sqlalchemy_version_parsed >= parse_version('1.2'): # 1.2+ - - ext = ZopeTransactionEvents( - initial_state=initial_state, - transaction_manager=transaction_manager, - keep_session=keep_session, - ) - - else: # pre-1.2 - - ext = ZopeTransactionExtension( - initial_state=initial_state, - transaction_manager=transaction_manager, - keep_session=keep_session, - ) + ext = ZopeTransactionEvents( + initial_state=initial_state, + transaction_manager=transaction_manager, + keep_session=keep_session, + ) event.listen(session, "after_begin", ext.after_begin) event.listen(session, "after_attach", ext.after_attach) @@ -199,9 +197,8 @@ def register(session, initial_state=datamanager.STATUS_ACTIVE, event.listen(session, "after_bulk_delete", ext.after_bulk_delete) event.listen(session, "before_commit", ext.before_commit) - if zope_sqlalchemy_version_parsed >= parse_version('1.5'): # 1.5+ - if datamanager.SA_GE_14: - event.listen(session, "do_orm_execute", ext.do_orm_execute) + if datamanager.SA_GE_14: + event.listen(session, "do_orm_execute", ext.do_orm_execute) register(Session) diff --git a/tests/test_db.py b/tests/test_db.py new file mode 100644 index 00000000..88cb9d41 --- /dev/null +++ b/tests/test_db.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8; -*- + +# TODO: add real tests at some point but this at least gives us basic +# coverage when running this "test" module alone + +from tailbone import db + From 1f38894f02e6279e2180d87185bef6d3651a0065 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 2 Jul 2024 14:14:15 -0500 Subject: [PATCH 1508/1681] fix: include edit profile email/phone dialogs only if user has perms otherwise we get JS errors when page loads --- tailbone/templates/people/view_profile.mako | 238 ++++++++++---------- 1 file changed, 122 insertions(+), 116 deletions(-) diff --git a/tailbone/templates/people/view_profile.mako b/tailbone/templates/people/view_profile.mako index 3520d924..22b4b8c6 100644 --- a/tailbone/templates/people/view_profile.mako +++ b/tailbone/templates/people/view_profile.mako @@ -461,72 +461,75 @@ </${b}-table> - <${b}-modal has-modal-card - % if request.use_oruga: - v-model:active="deletePhoneShowDialog" - % else: - :active.sync="deletePhoneShowDialog" - % endif - > - <div class="modal-card"> + % if request.has_perm('people_profile.edit_person'): - <header class="modal-card-head"> - <p class="modal-card-title">Delete Phone</p> - </header> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="deletePhoneShowDialog" + % else: + :active.sync="deletePhoneShowDialog" + % endif + > + <div class="modal-card"> - <section class="modal-card-body"> - <p class="block">Really delete this phone number?</p> - <p class="block has-text-weight-bold">{{ deletePhoneNumber }}</p> - </section> + <header class="modal-card-head"> + <p class="modal-card-title">Delete Phone</p> + </header> - <footer class="modal-card-foot"> - <b-button type="is-danger" - @click="deletePhoneSave()" - :disabled="deletePhoneSaving" - icon-pack="fas" - icon-left="trash"> - {{ deletePhoneSaving ? "Working, please wait..." : "Delete" }} - </b-button> - <b-button @click="deletePhoneShowDialog = false"> - Cancel - </b-button> - </footer> - </div> - </${b}-modal> + <section class="modal-card-body"> + <p class="block">Really delete this phone number?</p> + <p class="block has-text-weight-bold">{{ deletePhoneNumber }}</p> + </section> - <${b}-modal has-modal-card - % if request.use_oruga: - v-model:active="preferPhoneShowDialog" - % else: - :active.sync="preferPhoneShowDialog" - % endif - > - <div class="modal-card"> + <footer class="modal-card-foot"> + <b-button type="is-danger" + @click="deletePhoneSave()" + :disabled="deletePhoneSaving" + icon-pack="fas" + icon-left="trash"> + {{ deletePhoneSaving ? "Working, please wait..." : "Delete" }} + </b-button> + <b-button @click="deletePhoneShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </${b}-modal> - <header class="modal-card-head"> - <p class="modal-card-title">Set Preferred Phone</p> - </header> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="preferPhoneShowDialog" + % else: + :active.sync="preferPhoneShowDialog" + % endif + > + <div class="modal-card"> - <section class="modal-card-body"> - <p class="block">Really make this the preferred phone number?</p> - <p class="block has-text-weight-bold">{{ preferPhoneNumber }}</p> - </section> + <header class="modal-card-head"> + <p class="modal-card-title">Set Preferred Phone</p> + </header> - <footer class="modal-card-foot"> - <b-button type="is-primary" - @click="preferPhoneSave()" - :disabled="preferPhoneSaving" - icon-pack="fas" - icon-left="save"> - {{ preferPhoneSaving ? "Working, please wait..." : "Set Preferred" }} - </b-button> - <b-button @click="preferPhoneShowDialog = false"> - Cancel - </b-button> - </footer> - </div> - </${b}-modal> + <section class="modal-card-body"> + <p class="block">Really make this the preferred phone number?</p> + <p class="block has-text-weight-bold">{{ preferPhoneNumber }}</p> + </section> + <footer class="modal-card-foot"> + <b-button type="is-primary" + @click="preferPhoneSave()" + :disabled="preferPhoneSaving" + icon-pack="fas" + icon-left="save"> + {{ preferPhoneSaving ? "Working, please wait..." : "Set Preferred" }} + </b-button> + <b-button @click="preferPhoneShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </${b}-modal> + + % endif </div> </div> </div> @@ -694,72 +697,75 @@ </${b}-table> - <${b}-modal has-modal-card - % if request.use_oruga: - v-model:active="deleteEmailShowDialog" - % else: - :active.sync="deleteEmailShowDialog" - % endif - > - <div class="modal-card"> + % if request.has_perm('people_profile.edit_person'): - <header class="modal-card-head"> - <p class="modal-card-title">Delete Email</p> - </header> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="deleteEmailShowDialog" + % else: + :active.sync="deleteEmailShowDialog" + % endif + > + <div class="modal-card"> - <section class="modal-card-body"> - <p class="block">Really delete this email address?</p> - <p class="block has-text-weight-bold">{{ deleteEmailAddress }}</p> - </section> + <header class="modal-card-head"> + <p class="modal-card-title">Delete Email</p> + </header> - <footer class="modal-card-foot"> - <b-button type="is-danger" - @click="deleteEmailSave()" - :disabled="deleteEmailSaving" - icon-pack="fas" - icon-left="trash"> - {{ deleteEmailSaving ? "Working, please wait..." : "Delete" }} - </b-button> - <b-button @click="deleteEmailShowDialog = false"> - Cancel - </b-button> - </footer> - </div> - </${b}-modal> + <section class="modal-card-body"> + <p class="block">Really delete this email address?</p> + <p class="block has-text-weight-bold">{{ deleteEmailAddress }}</p> + </section> - <${b}-modal has-modal-card - % if request.use_oruga: - v-model:active="preferEmailShowDialog" - % else: - :active.sync="preferEmailShowDialog" - % endif - > - <div class="modal-card"> + <footer class="modal-card-foot"> + <b-button type="is-danger" + @click="deleteEmailSave()" + :disabled="deleteEmailSaving" + icon-pack="fas" + icon-left="trash"> + {{ deleteEmailSaving ? "Working, please wait..." : "Delete" }} + </b-button> + <b-button @click="deleteEmailShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </${b}-modal> - <header class="modal-card-head"> - <p class="modal-card-title">Set Preferred Email</p> - </header> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="preferEmailShowDialog" + % else: + :active.sync="preferEmailShowDialog" + % endif + > + <div class="modal-card"> - <section class="modal-card-body"> - <p class="block">Really make this the preferred email address?</p> - <p class="block has-text-weight-bold">{{ preferEmailAddress }}</p> - </section> + <header class="modal-card-head"> + <p class="modal-card-title">Set Preferred Email</p> + </header> - <footer class="modal-card-foot"> - <b-button type="is-primary" - @click="preferEmailSave()" - :disabled="preferEmailSaving" - icon-pack="fas" - icon-left="save"> - {{ preferEmailSaving ? "Working, please wait..." : "Set Preferred" }} - </b-button> - <b-button @click="preferEmailShowDialog = false"> - Cancel - </b-button> - </footer> - </div> - </${b}-modal> + <section class="modal-card-body"> + <p class="block">Really make this the preferred email address?</p> + <p class="block has-text-weight-bold">{{ preferEmailAddress }}</p> + </section> + <footer class="modal-card-foot"> + <b-button type="is-primary" + @click="preferEmailSave()" + :disabled="preferEmailSaving" + icon-pack="fas" + icon-left="save"> + {{ preferEmailSaving ? "Working, please wait..." : "Set Preferred" }} + </b-button> + <b-button @click="preferEmailShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </${b}-modal> + + % endif </div> </div> </div> From 9146cdc835f63518c7ffc2bb98a9fdbd310ce00f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 2 Jul 2024 14:20:48 -0500 Subject: [PATCH 1509/1681] fix: allow view supplements to add to profile member context --- tailbone/views/people.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index d8e36ec9..d3a82dc0 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -742,10 +742,15 @@ class PersonView(MasterView): membership = app.get_membership_handler() data = OrderedDict() - members = membership.get_members_for_account_holder(person) for member in members: - data[member.uuid] = self.get_context_member(member) + context = self.get_context_member(member) + + for supp in self.iter_view_supplements(): + if hasattr(supp, 'get_context_for_member'): + context = supp.get_context_for_member(member, context) + + data[member.uuid] = context return list(data.values()) From e23193b73083c33c0b79e74be572d406e3e2a16e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 2 Jul 2024 16:45:10 -0500 Subject: [PATCH 1510/1681] fix: cast enum as list to satisfy deform widget seems to only be an issue for deform 2.0.15+ --- tailbone/views/batch/product.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/batch/product.py b/tailbone/views/batch/product.py index af8374ac..590c3ff0 100644 --- a/tailbone/views/batch/product.py +++ b/tailbone/views/batch/product.py @@ -46,7 +46,7 @@ class ExecutionOptions(colander.Schema): action = colander.SchemaNode( colander.String(), validator=colander.OneOf(ACTION_OPTIONS), - widget=dfwidget.SelectWidget(values=ACTION_OPTIONS.items())) + widget=dfwidget.SelectWidget(values=list(ACTION_OPTIONS.items()))) class ProductBatchView(BatchMasterView): From 5e11a2ecf6ab9e258d799ffdb43a8a37a4b27613 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 2 Jul 2024 22:47:03 -0500 Subject: [PATCH 1511/1681] fix: expand POD image URL setting input --- tailbone/templates/products/configure.mako | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/products/configure.mako b/tailbone/templates/products/configure.mako index 10f3c0e5..6121af67 100644 --- a/tailbone/templates/products/configure.mako +++ b/tailbone/templates/products/configure.mako @@ -41,8 +41,8 @@ <b-input name="rattail.pod.pictures.gtin.root_url" v-model="simpleSettings['rattail.pod.pictures.gtin.root_url']" :disabled="!simpleSettings['tailbone.products.show_pod_image']" - @input="settingsNeedSaved = true"> - </b-input> + @input="settingsNeedSaved = true" + expanded /> </b-field> </div> From 76897c24dee986b60f63cc0d33f4b18de47c1eb1 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 4 Jul 2024 08:20:31 -0500 Subject: [PATCH 1512/1681] =?UTF-8?q?bump:=20version=200.11.6=20=E2=86=92?= =?UTF-8?q?=200.11.7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 16 ++++++++++++++++ setup.cfg | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9410fe3f..672bd2b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.11.7 (2024-07-04) + +### Fix + +- add stacklevel to deprecation warnings + +- require zope.sqlalchemy >= 1.5 + +- include edit profile email/phone dialogs only if user has perms + +- allow view supplements to add to profile member context + +- cast enum as list to satisfy deform widget + +- expand POD image URL setting input + ## v0.11.6 (2024-07-01) ### Fix diff --git a/setup.cfg b/setup.cfg index c42ff675..e00b92f2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,7 +1,7 @@ [metadata] name = Tailbone -version = 0.11.6 +version = 0.11.7 author = Lance Edgar author_email = lance@edbob.org url = http://rattailproject.org/ From 793a15883e9b16d2f5a8bcedecdafaff63460ddb Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 4 Jul 2024 15:59:05 -0500 Subject: [PATCH 1513/1681] fix: fix grid action icons for datasync/configure, per oruga --- tailbone/templates/datasync/configure.mako | 66 ++++++++++++++++++---- 1 file changed, 54 insertions(+), 12 deletions(-) diff --git a/tailbone/templates/datasync/configure.mako b/tailbone/templates/datasync/configure.mako index 8b0f5e51..a512745c 100644 --- a/tailbone/templates/datasync/configure.mako +++ b/tailbone/templates/datasync/configure.mako @@ -157,15 +157,29 @@ <a href="#" class="grid-action" @click.prevent="editProfile(props.row)"> - <i class="fas fa-edit"></i> - Edit + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="edit" /> + <span>Edit</span> + </span> + % else: + <i class="fas fa-edit"></i> + Edit + % endif </a> <a href="#" class="grid-action has-text-danger" @click.prevent="deleteProfile(props.row)"> - <i class="fas fa-trash"></i> - Delete + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="trash" /> + <span>Delete</span> + </span> + % else: + <i class="fas fa-trash"></i> + Delete + % endif </a> </${b}-table-column> <template #empty> @@ -314,15 +328,29 @@ v-slot="props"> <a href="#" @click.prevent="editProfileWatcherKwarg(props.row)"> - <i class="fas fa-edit"></i> - Edit + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="edit" /> + <span>Edit</span> + </span> + % else: + <i class="fas fa-edit"></i> + Edit + % endif </a> <a href="#" class="has-text-danger" @click.prevent="deleteProfileWatcherKwarg(props.row)"> - <i class="fas fa-trash"></i> - Delete + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="trash" /> + <span>Delete</span> + </span> + % else: + <i class="fas fa-trash"></i> + Delete + % endif </a> </${b}-table-column> <template #empty> @@ -372,15 +400,29 @@ <a href="#" class="grid-action" @click.prevent="editProfileConsumer(props.row)"> - <i class="fas fa-edit"></i> - Edit + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="edit" /> + <span>Edit</span> + </span> + % else: + <i class="fas fa-edit"></i> + Edit + % endif </a> <a href="#" class="grid-action has-text-danger" @click.prevent="deleteProfileConsumer(props.row)"> - <i class="fas fa-trash"></i> - Delete + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="trash" /> + <span>Delete</span> + </span> + % else: + <i class="fas fa-trash"></i> + Delete + % endif </a> </${b}-table-column> <template #empty> From 89d7009a1855e2062a2a1e8cfd107e801a3356b3 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 4 Jul 2024 18:21:06 -0500 Subject: [PATCH 1514/1681] fix: allow view supplements to add extra links for profile employee tab --- tailbone/templates/people/view_profile.mako | 250 ++++++++++---------- tailbone/views/people.py | 6 + 2 files changed, 136 insertions(+), 120 deletions(-) diff --git a/tailbone/templates/people/view_profile.mako b/tailbone/templates/people/view_profile.mako index 22b4b8c6..4767a924 100644 --- a/tailbone/templates/people/view_profile.mako +++ b/tailbone/templates/people/view_profile.mako @@ -1274,141 +1274,151 @@ </div> - <div> - <div class="buttons"> + <div style="display: flex; gap: 0.75rem;"> - % if request.has_perm('people_profile.toggle_employee'): + % if request.has_perm('people_profile.toggle_employee'): - <b-button v-if="!employee.current" - type="is-primary" - @click="startEmployeeInit()"> - ${person} is now an Employee - </b-button> + <b-button v-if="!employee.current" + type="is-primary" + @click="startEmployeeInit()"> + ${person} is now an Employee + </b-button> - <b-button v-if="employee.current" - type="is-primary" - @click="stopEmployeeInit()"> - ${person} is no longer an Employee - </b-button> + <b-button v-if="employee.current" + type="is-primary" + @click="stopEmployeeInit()"> + ${person} is no longer an Employee + </b-button> - <${b}-modal has-modal-card - % if request.use_oruga: - v-model:active="startEmployeeShowDialog" - % else: - :active.sync="startEmployeeShowDialog" - % endif - > - <div class="modal-card"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="startEmployeeShowDialog" + % else: + :active.sync="startEmployeeShowDialog" + % endif + > + <div class="modal-card"> - <header class="modal-card-head"> - <p class="modal-card-title">Employee Start</p> - </header> + <header class="modal-card-head"> + <p class="modal-card-title">Employee Start</p> + </header> - <section class="modal-card-body"> - <b-field label="Employee Number"> - <b-input v-model="startEmployeeID"></b-input> - </b-field> - <b-field label="Start Date"> - <tailbone-datepicker v-model="startEmployeeStartDate" - ref="startEmployeeStartDate" /> - </b-field> - </section> + <section class="modal-card-body"> + <b-field label="Employee Number"> + <b-input v-model="startEmployeeID"></b-input> + </b-field> + <b-field label="Start Date"> + <tailbone-datepicker v-model="startEmployeeStartDate" + ref="startEmployeeStartDate" /> + </b-field> + </section> - <footer class="modal-card-foot"> - <b-button @click="startEmployeeShowDialog = false"> - Cancel - </b-button> - <b-button type="is-primary" - @click="startEmployeeSave()" - :disabled="startEmployeeSaveDisabled" - icon-pack="fas" - icon-left="save"> - {{ startEmployeeSaving ? "Working, please wait..." : "Save" }} - </b-button> - </footer> - </div> - </${b}-modal> + <footer class="modal-card-foot"> + <b-button @click="startEmployeeShowDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + @click="startEmployeeSave()" + :disabled="startEmployeeSaveDisabled" + icon-pack="fas" + icon-left="save"> + {{ startEmployeeSaving ? "Working, please wait..." : "Save" }} + </b-button> + </footer> + </div> + </${b}-modal> - <${b}-modal has-modal-card - % if request.use_oruga: - v-model:active="stopEmployeeShowDialog" - % else: - :active.sync="stopEmployeeShowDialog" - % endif - > - <div class="modal-card"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="stopEmployeeShowDialog" + % else: + :active.sync="stopEmployeeShowDialog" + % endif + > + <div class="modal-card"> - <header class="modal-card-head"> - <p class="modal-card-title">Employee End</p> - </header> + <header class="modal-card-head"> + <p class="modal-card-title">Employee End</p> + </header> - <section class="modal-card-body"> - <b-field label="End Date" - :type="stopEmployeeEndDate ? null : 'is-danger'"> - <tailbone-datepicker v-model="stopEmployeeEndDate"></tailbone-datepicker> - </b-field> - <b-field label="Revoke Internal App Access"> - <b-checkbox v-model="stopEmployeeRevokeAccess"> - </b-checkbox> - </b-field> - </section> + <section class="modal-card-body"> + <b-field label="End Date" + :type="stopEmployeeEndDate ? null : 'is-danger'"> + <tailbone-datepicker v-model="stopEmployeeEndDate"></tailbone-datepicker> + </b-field> + <b-field label="Revoke Internal App Access"> + <b-checkbox v-model="stopEmployeeRevokeAccess"> + </b-checkbox> + </b-field> + </section> - <footer class="modal-card-foot"> - <b-button @click="stopEmployeeShowDialog = false"> - Cancel - </b-button> - <b-button type="is-primary" - @click="stopEmployeeSave()" - :disabled="stopEmployeeSaveDisabled" - icon-pack="fas" - icon-left="save"> - {{ stopEmployeeSaving ? "Working, please wait..." : "Save" }} - </b-button> - </footer> - </div> - </${b}-modal> - % endif + <footer class="modal-card-foot"> + <b-button @click="stopEmployeeShowDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + @click="stopEmployeeSave()" + :disabled="stopEmployeeSaveDisabled" + icon-pack="fas" + icon-left="save"> + {{ stopEmployeeSaving ? "Working, please wait..." : "Save" }} + </b-button> + </footer> + </div> + </${b}-modal> + % endif - % if request.has_perm('people_profile.edit_employee_history'): - <${b}-modal has-modal-card - % if request.use_oruga: - v-model:active="editEmployeeHistoryShowDialog" - % else: - :active.sync="editEmployeeHistoryShowDialog" - % endif - > - <div class="modal-card"> + % if request.has_perm('people_profile.edit_employee_history'): + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="editEmployeeHistoryShowDialog" + % else: + :active.sync="editEmployeeHistoryShowDialog" + % endif + > + <div class="modal-card"> - <header class="modal-card-head"> - <p class="modal-card-title">Edit Employee History</p> - </header> + <header class="modal-card-head"> + <p class="modal-card-title">Edit Employee History</p> + </header> - <section class="modal-card-body"> - <b-field label="Start Date"> - <tailbone-datepicker v-model="editEmployeeHistoryStartDate"></tailbone-datepicker> - </b-field> - <b-field label="End Date"> - <tailbone-datepicker v-model="editEmployeeHistoryEndDate" - :disabled="!editEmployeeHistoryEndDateRequired"> - </tailbone-datepicker> - </b-field> - </section> + <section class="modal-card-body"> + <b-field label="Start Date"> + <tailbone-datepicker v-model="editEmployeeHistoryStartDate"></tailbone-datepicker> + </b-field> + <b-field label="End Date"> + <tailbone-datepicker v-model="editEmployeeHistoryEndDate" + :disabled="!editEmployeeHistoryEndDateRequired"> + </tailbone-datepicker> + </b-field> + </section> - <footer class="modal-card-foot"> - <b-button @click="editEmployeeHistoryShowDialog = false"> - Cancel - </b-button> - <b-button type="is-primary" - @click="editEmployeeHistorySave()" - :disabled="editEmployeeHistorySaveDisabled" - icon-pack="fas" - icon-left="save"> - {{ editEmployeeHistorySaving ? "Working, please wait..." : "Save" }} - </b-button> - </footer> - </div> - </${b}-modal> - % endif + <footer class="modal-card-foot"> + <b-button @click="editEmployeeHistoryShowDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + @click="editEmployeeHistorySave()" + :disabled="editEmployeeHistorySaveDisabled" + icon-pack="fas" + icon-left="save"> + {{ editEmployeeHistorySaving ? "Working, please wait..." : "Save" }} + </b-button> + </footer> + </div> + </${b}-modal> + % endif + + <div style="display: flex; flex-direction: column; align-items: right; gap: 0.75rem;"> + + <b-button v-for="link in employee.external_links" + :key="link.url" + type="is-primary" + tag="a" :href="link.url" target="_blank" + icon-pack="fas" + icon-left="external-link-alt"> + {{ link.label }} + </b-button> % if request.has_perm('employees.view'): <b-button v-if="employee.view_url" diff --git a/tailbone/views/people.py b/tailbone/views/people.py index d3a82dc0..b9fe5c4b 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -803,6 +803,12 @@ class PersonView(MasterView): app = self.get_rattail_app() handler = app.get_employment_handler() context = handler.get_context_employee(employee) + context.setdefault('external_links', []) + + for supp in self.iter_view_supplements(): + if hasattr(supp, 'get_context_for_employee'): + context = supp.get_context_for_employee(employee, context) + context['view_url'] = self.request.route_url('employees.view', uuid=employee.uuid) return context From ddec77c37f6e6cb8b9b54666c63b0a79808ebeee Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 4 Jul 2024 20:33:34 -0500 Subject: [PATCH 1515/1681] fix: leverage import handler method to determine command/subcommand just moved previous logic to rattail/handler --- tailbone/views/importing.py | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/tailbone/views/importing.py b/tailbone/views/importing.py index e9167132..48b32cc2 100644 --- a/tailbone/views/importing.py +++ b/tailbone/views/importing.py @@ -34,7 +34,6 @@ import time import sqlalchemy as sa -from rattail.exceptions import ConfigurationError from rattail.threads import Thread import colander @@ -458,22 +457,7 @@ And here is the output: return HTML.tag('div', class_='tailbone-markdown', c=[notes]) def get_cmd_for_handler(self, handler, ignore_errors=False): - handler_key = handler.get_key() - - cmd = self.rattail_config.getlist('rattail.importing', - '{}.cmd'.format(handler_key)) - if not cmd or len(cmd) != 2: - cmd = self.rattail_config.getlist('rattail.importing', - '{}.default_cmd'.format(handler_key)) - - if not cmd or len(cmd) != 2: - msg = ("Missing or invalid config; please set '{}.default_cmd' in the " - "[rattail.importing] section of your config file".format(handler_key)) - if ignore_errors: - return - raise ConfigurationError(msg) - - return cmd + return handler.get_cmd(ignore_errors=ignore_errors) def get_runas_for_handler(self, handler): handler_key = handler.get_key() From 58be7e9d5b7f62d8e4c8440e8a3d77a16f63e7b9 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 4 Jul 2024 21:32:46 -0500 Subject: [PATCH 1516/1681] fix: add tool to make user account from profile view --- tailbone/templates/people/view_profile.mako | 144 +++++++++++++++++--- tailbone/views/people.py | 40 +++++- 2 files changed, 167 insertions(+), 17 deletions(-) diff --git a/tailbone/templates/people/view_profile.mako b/tailbone/templates/people/view_profile.mako index 4767a924..0b700ca5 100644 --- a/tailbone/templates/people/view_profile.mako +++ b/tailbone/templates/people/view_profile.mako @@ -1635,28 +1635,30 @@ <br /> <div id="users-accordion"> - <b-collapse class="panel" - v-for="user in users" - :key="user.uuid"> + <${b}-collapse v-for="user in users" + :key="user.uuid" + class="panel"> <div slot="trigger" + slot-scope="props" class="panel-heading" role="button"> - <strong>{{ user.username }}</strong> + <b-icon pack="fas" + icon="caret-right"> + </b-icon> + <strong>{{ user.username }}</strong> </div> <div class="panel-block"> <div style="display: flex; justify-content: space-between; width: 100%;"> - <div> - <div class="field-wrapper id"> - <div class="field-row"> - <label>Username</label> - <div class="field"> - {{ user.username }} - </div> - </div> - </div> + <div style="flex-grow: 1;"> + <b-field horizontal label="Username"> + {{ user.username }} + </b-field> + <b-field horizontal label="Active"> + {{ user.active ? "Yes" : "No" }} + </b-field> </div> <div> @@ -1669,13 +1671,66 @@ </div> </div> - </b-collapse> + </${b}-collapse> </div> </div> - <div v-if="!users.length"> + <div v-if="!users.length" + style="display: flex; justify-content: space-between;"> + <p>{{ person.display_name }} does not have a user account.</p> + + % if request.has_perm('users.create'): + <b-button type="primary" + icon-pack="fas" + icon-left="plus" + @click="createUserInit()"> + Create User + </b-button> + + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="createUserShowDialog" + % else: + :active.sync="createUserShowDialog" + % endif + > + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Create User</p> + </header> + + <section class="modal-card-body"> + <b-field label="Person"> + <span>{{ person.display_name }}</span> + </b-field> + <b-field label="Username"> + <b-input v-model="createUserUsername" + ref="username" /> + </b-field> + <b-field label="Active"> + <b-checkbox v-model="createUserActive" /> + </b-field> + </section> + + <footer class="modal-card-foot"> + <b-button @click="createUserShowDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + @click="createUserSave()" + :disabled="createUserSaveDisabled" + icon-pack="fas" + icon-left="save"> + {{ createUserSaving ? "Working, please wait..." : "Save" }} + </b-button> + </footer> + </div> + </${b}-modal> + % endif </div> + % if request.use_oruga: <o-loading v-model:active="refreshingTab" :full-page="false"></o-loading> % else: @@ -2730,6 +2785,13 @@ let UserTabData = { refreshTabURL: '${url('people.profile_tab_user', uuid=person.uuid)}', users: [], + + % if request.has_perm('users.create'): + createUserShowDialog: false, + createUserUsername: null, + createUserActive: false, + createUserSaving: false, + % endif } let UserTab = { @@ -2738,12 +2800,62 @@ props: { person: Object, }, - computed: {}, + + computed: { + + % if request.has_perm('users.create'): + + createUserSaveDisabled() { + if (this.createUserSaving) { + return true + } + if (!this.createUserUsername) { + return true + } + return false + }, + + % endif + }, + methods: { refreshTabSuccess(response) { this.users = response.data.users + this.createUserSuggestedUsername = response.data.suggested_username }, + + % if request.has_perm('users.create'): + + createUserInit() { + this.createUserUsername = this.createUserSuggestedUsername + this.createUserActive = true + this.createUserShowDialog = true + this.$nextTick(() => { + this.$refs.username.focus() + }) + }, + + createUserSave() { + this.createUserSaving = true + + let url = '${master.get_action_url('profile_make_user', instance)}' + let params = { + username: this.createUserUsername, + active: this.createUserActive, + } + + this.simplePOST(url, params, response => { + this.$emit('profile-changed', response.data) + this.createUserSaving = false + this.createUserShowDialog = false + this.refreshTab() + }, response => { + this.createUserSaving = false + }) + }, + + % endif }, } diff --git a/tailbone/views/people.py b/tailbone/views/people.py index b9fe5c4b..08e32c3c 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -1280,11 +1280,40 @@ class PersonView(MasterView): """ Fetch user tab data for profile view. """ + app = self.get_rattail_app() + auth = app.get_auth_handler() person = self.get_instance() - return { + context = { 'users': self.get_context_users(person), } + if not context['users']: + context['suggested_username'] = auth.generate_unique_username(self.Session(), + person=person) + + return context + + def profile_make_user(self): + """ + Create a new user account, presumably from the profile view. + """ + app = self.get_rattail_app() + model = self.model + auth = app.get_auth_handler() + + person = self.get_instance() + if person.users: + return {'error': f"This person already has {len(person.users)} user accounts."} + + data = self.request.json_body + user = auth.make_user(session=self.Session(), + person=person, + username=data['username'], + active=data['active']) + + self.Session.flush() + return self.profile_changed_response(person) + def profile_revisions_grid(self, person): route_prefix = self.get_route_prefix() factory = self.get_grid_factory() @@ -1787,6 +1816,15 @@ class PersonView(MasterView): route_name=f'{route_prefix}.profile_tab_user', renderer='json') + # profile - make user + config.add_route(f'{route_prefix}.profile_make_user', + f'{instance_url_prefix}/make-user', + request_method='POST') + config.add_view(cls, attr='profile_make_user', + route_name=f'{route_prefix}.profile_make_user', + permission='users.create', + renderer='json') + # profile - revisions data config.add_tailbone_permission('people_profile', 'people_profile.view_versions', From 431a4d7433d639d6b3850a8f571d1eab0b9230ce Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 4 Jul 2024 23:59:06 -0500 Subject: [PATCH 1517/1681] =?UTF-8?q?bump:=20version=200.11.7=20=E2=86=92?= =?UTF-8?q?=200.11.8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 12 ++++++++++++ setup.cfg | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 672bd2b6..15fe3a46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.11.8 (2024-07-04) + +### Fix + +- fix grid action icons for datasync/configure, per oruga + +- allow view supplements to add extra links for profile employee tab + +- leverage import handler method to determine command/subcommand + +- add tool to make user account from profile view + ## v0.11.7 (2024-07-04) ### Fix diff --git a/setup.cfg b/setup.cfg index e00b92f2..6e81a547 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,7 +1,7 @@ [metadata] name = Tailbone -version = 0.11.7 +version = 0.11.8 author = Lance Edgar author_email = lance@edbob.org url = http://rattailproject.org/ From 2988ff3ee937a5fadbcda853b5bad97eacde7028 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 5 Jul 2024 12:50:45 -0500 Subject: [PATCH 1518/1681] fix: do not show flash message when changing app theme it is just distracting esp. when testing different themes --- tailbone/views/common.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tailbone/views/common.py b/tailbone/views/common.py index 3c4b659b..7e9ddb09 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -151,8 +151,6 @@ class CommonView(View): except Exception as error: msg = "Failed to set theme: {}: {}".format(error.__class__.__name__, error) self.request.session.flash(msg, 'error') - else: - self.request.session.flash("App theme has been changed to: {}".format(theme)) referrer = self.request.params.get('referrer') or self.request.get_referrer() return self.redirect(referrer) From 735327e46b22663eeafdea588e80d5566a8dc8c4 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 5 Jul 2024 12:53:14 -0500 Subject: [PATCH 1519/1681] fix: improve collapse panels for butterball theme --- tailbone/templates/custorders/create.mako | 10 +- tailbone/templates/people/view_profile.mako | 117 ++++++++++++++------ 2 files changed, 87 insertions(+), 40 deletions(-) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index 9a3a2d57..63505422 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -78,15 +78,16 @@ <b-icon v-if="props.open" pack="fas" - icon="angle-down"> + icon="caret-down"> </b-icon> <span v-if="!props.open"> <b-icon pack="fas" - icon="angle-right"> + icon="caret-right"> </b-icon> </span> + <strong v-html="customerPanelHeader"></strong> </div> </template> @@ -525,15 +526,16 @@ <b-icon v-if="props.open" pack="fas" - icon="angle-down"> + icon="caret-down"> </b-icon> <span v-if="!props.open"> <b-icon pack="fas" - icon="angle-right"> + icon="caret-right"> </b-icon> </span> + <strong v-html="itemsPanelHeader"></strong> </div> </template> diff --git a/tailbone/templates/people/view_profile.mako b/tailbone/templates/people/view_profile.mako index 0b700ca5..1eac6a2f 100644 --- a/tailbone/templates/people/view_profile.mako +++ b/tailbone/templates/people/view_profile.mako @@ -836,20 +836,35 @@ </div> <br /> - <b-collapse v-for="member in members" - :key="member.uuid" - class="panel" - :open="members.length == 1"> + <${b}-collapse v-for="member in members" + :key="member.uuid" + class="panel" + :open="members.length == 1"> - <div slot="trigger" - slot-scope="props" - class="panel-heading" - role="button"> - <b-icon pack="fas" - icon="caret-right"> - </b-icon> - <strong>{{ member._key }} - {{ member.display }}</strong> - </div> + <template #trigger="props"> + <div class="panel-heading" + role="button" + style="cursor: pointer;"> + + ## TODO: for some reason buefy will "reuse" the icon + ## element in such a way that its display does not + ## refresh. so to work around that, we use different + ## structure for the two icons, so buefy is forced to + ## re-draw + + <b-icon v-if="props.open" + pack="fas" + icon="caret-down" /> + + <span v-if="!props.open"> + <b-icon pack="fas" + icon="caret-right" /> + </span> + + + <strong>{{ member._key }} - {{ member.display }}</strong> + </div> + </template> <div class="panel-block"> <div style="display: flex; justify-content: space-between; width: 100%;"> @@ -917,7 +932,7 @@ </div> </div> </div> - </b-collapse> + </${b}-collapse> </div> <div v-if="!members.length"> @@ -957,20 +972,35 @@ </div> <br /> - <b-collapse v-for="customer in customers" - :key="customer.uuid" - class="panel" - :open="customers.length == 1"> + <${b}-collapse v-for="customer in customers" + :key="customer.uuid" + class="panel" + :open="customers.length == 1"> - <div slot="trigger" - slot-scope="props" - class="panel-heading" - role="button"> - <b-icon pack="fas" - icon="caret-right"> - </b-icon> - <strong>{{ customer._key }} - {{ customer.name }}</strong> - </div> + <template #trigger="props"> + <div class="panel-heading" + role="button" + style="cursor: pointer;"> + + ## TODO: for some reason buefy will "reuse" the icon + ## element in such a way that its display does not + ## refresh. so to work around that, we use different + ## structure for the two icons, so buefy is forced to + ## re-draw + + <b-icon v-if="props.open" + pack="fas" + icon="caret-down" /> + + <span v-if="!props.open"> + <b-icon pack="fas" + icon="caret-right" /> + </span> + + + <strong>{{ customer._key }} - {{ customer.name }}</strong> + </div> + </template> <div class="panel-block"> <div style="display: flex; justify-content: space-between; width: 100%;"> @@ -1045,7 +1075,7 @@ </div> </div> </div> - </b-collapse> + </${b}-collapse> </div> <div v-if="!customers.length"> @@ -1639,15 +1669,30 @@ :key="user.uuid" class="panel"> - <div slot="trigger" - slot-scope="props" - class="panel-heading" - role="button"> - <b-icon pack="fas" - icon="caret-right"> - </b-icon> + <template #trigger="props"> + <div class="panel-heading" + role="button" + style="cursor: pointer;"> + + ## TODO: for some reason buefy will "reuse" the icon + ## element in such a way that its display does not + ## refresh. so to work around that, we use different + ## structure for the two icons, so buefy is forced to + ## re-draw + + <b-icon v-if="props.open" + pack="fas" + icon="caret-down" /> + + <span v-if="!props.open"> + <b-icon pack="fas" + icon="caret-right" /> + </span> + + <strong>{{ user.username }}</strong> - </div> + </div> + </template> <div class="panel-block"> <div style="display: flex; justify-content: space-between; width: 100%;"> From 19e65f5bb9f2191b90b6e81d26d105f8d26ca3db Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 5 Jul 2024 13:07:08 -0500 Subject: [PATCH 1520/1681] fix: expand input for butterball theme --- tailbone/templates/people/configure.mako | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tailbone/templates/people/configure.mako b/tailbone/templates/people/configure.mako index 9e6ce5fb..d821d898 100644 --- a/tailbone/templates/people/configure.mako +++ b/tailbone/templates/people/configure.mako @@ -4,7 +4,7 @@ <%def name="form_content()"> <h3 class="block is-size-3">General</h3> - <div class="block" style="padding-left: 2rem;"> + <div class="block" style="padding-left: 2rem; width: 50%;"> <b-field message="If set, grid links are to Personal tab of Profile view."> <b-checkbox name="rattail.people.straight_to_profile" @@ -28,8 +28,8 @@ message="Leave blank for default handler."> <b-input name="rattail.people.handler" v-model="simpleSettings['rattail.people.handler']" - @input="settingsNeedSaved = true"> - </b-input> + @input="settingsNeedSaved = true" + expanded /> </b-field> </div> From b7d26b6b8ccf896643fc8da2b07678e3dc8e2bf7 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 5 Jul 2024 14:30:52 -0500 Subject: [PATCH 1521/1681] fix: add xref button to customer profile, for trainwreck txn view --- tailbone/views/trainwreck/base.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tailbone/views/trainwreck/base.py b/tailbone/views/trainwreck/base.py index 59a42301..9a6086d7 100644 --- a/tailbone/views/trainwreck/base.py +++ b/tailbone/views/trainwreck/base.py @@ -270,6 +270,23 @@ class TransactionView(MasterView): return kwargs + def get_xref_buttons(self, txn): + app = self.get_rattail_app() + clientele = app.get_clientele_handler() + buttons = super().get_xref_buttons(txn) + + if txn.customer_id: + customer = clientele.locate_customer_for_key(Session(), txn.customer_id) + if customer: + person = app.get_person(customer) + if person: + url = self.request.route_url('people.view_profile', uuid=person.uuid) + buttons.append(self.make_xref_button(text=str(person), + url=url, + internal=True)) + + return buttons + def get_row_data(self, transaction): return self.Session.query(self.model_row_class)\ .filter(self.model_row_class.transaction == transaction) From 16bf13787dff8b0528d20ff360273643139dba0f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 5 Jul 2024 14:45:35 -0500 Subject: [PATCH 1522/1681] fix: add optional Transactions tab for profile view showing Trainwreck data by default --- tailbone/templates/people/configure.mako | 14 +++ tailbone/templates/people/view_profile.mako | 93 ++++++++++++++++++ tailbone/views/people.py | 103 ++++++++++++++++++++ 3 files changed, 210 insertions(+) diff --git a/tailbone/templates/people/configure.mako b/tailbone/templates/people/configure.mako index d821d898..7d7a5618 100644 --- a/tailbone/templates/people/configure.mako +++ b/tailbone/templates/people/configure.mako @@ -33,6 +33,20 @@ </b-field> </div> + + <h3 class="block is-size-3">Profile View</h3> + <div class="block" style="padding-left: 2rem; width: 50%;"> + + <b-field> + <b-checkbox name="tailbone.people.profile.expose_transactions" + v-model="simpleSettings['tailbone.people.profile.expose_transactions']" + native-value="true" + @input="settingsNeedSaved = true"> + Show tab for Customer POS Transactions + </b-checkbox> + </b-field> + + </div> </%def> diff --git a/tailbone/templates/people/view_profile.mako b/tailbone/templates/people/view_profile.mako index 1eac6a2f..9d9ab37d 100644 --- a/tailbone/templates/people/view_profile.mako +++ b/tailbone/templates/people/view_profile.mako @@ -1656,6 +1656,34 @@ </${b}-tab-item> </%def> +% if expose_transactions: + + <%def name="render_transactions_tab_template()"> + <script type="text/x-template" id="transactions-tab-template"> + <div> + <transactions-grid + ref="transactionsGrid" + /> + </div> + </script> + </%def> + + <%def name="render_transactions_tab()"> + <${b}-tab-item label="Transactions" + value="transactions" + % if not request.use_oruga: + icon-pack="fas" + % endif + icon="bars"> + <transactions-tab ref="tab_transactions" + :person="person" + @profile-changed="profileChanged" /> + </${b}-tab-item> + </%def> + +% endif + + <%def name="render_user_tab_template()"> <script type="text/x-template" id="user-tab-template"> <div> @@ -1806,6 +1834,9 @@ % endif ${self.render_employee_tab()} ${self.render_notes_tab()} + % if expose_transactions: + ${self.render_transactions_tab()} + % endif ${self.render_user_tab()} </%def> @@ -1941,6 +1972,12 @@ % endif ${self.render_employee_tab_template()} ${self.render_notes_tab_template()} + + % if expose_transactions: + ${transactions_grid.render_complete(allow_save_defaults=False)|n} + ${self.render_transactions_tab_template()} + % endif + ${self.render_user_tab_template()} ${self.render_profile_info_template()} </%def> @@ -2824,6 +2861,49 @@ </script> </%def> +% if expose_transactions: + + <%def name="declare_transactions_tab_vars()"> + <script type="text/javascript"> + + let TransactionsTabData = {} + + let TransactionsTab = { + template: '#transactions-tab-template', + mixins: [TabMixin, SimpleRequestMixin], + props: { + person: Object, + }, + computed: {}, + methods: { + + // nb. we override this completely, just tell the grid to refresh + refreshTab() { + this.refreshingTab = true + this.$refs.transactionsGrid.loadAsyncData(null, () => { + this.refreshed = Date.now() + this.refreshingTab = false + }) + } + }, + } + + </script> + </%def> + + <%def name="make_transactions_tab_component()"> + ${self.declare_transactions_tab_vars()} + <script type="text/javascript"> + + TransactionsTab.data = function() { return TransactionsTabData } + Vue.component('transactions-tab', TransactionsTab) + <% request.register_component('transactions-tab', 'TransactionsTab') %> + + </script> + </%def> + +% endif + <%def name="declare_user_tab_vars()"> <script type="text/javascript"> @@ -3086,6 +3166,19 @@ % endif ${self.make_employee_tab_component()} ${self.make_notes_tab_component()} + + % if expose_transactions: + <script type="text/javascript"> + + TransactionsGrid.data = function() { return TransactionsGridData } + Vue.component('transactions-grid', TransactionsGrid) + ## TODO: why is this line not needed? + ## <% request.register_component('transactions-grid', 'TransactionsGrid') %> + + </script> + ${self.make_transactions_tab_component()} + % endif + ${self.make_user_tab_component()} ${self.make_profile_info_component()} </%def> diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 08e32c3c..2cabf1ec 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -40,6 +40,7 @@ import colander from webhelpers2.html import HTML, tags from tailbone import forms, grids +from tailbone.db import TrainwreckSession from tailbone.views import MasterView from tailbone.util import raw_datetime @@ -487,13 +488,101 @@ class PersonView(MasterView): 'expose_customer_shoppers': self.customers_should_expose_shoppers(), 'max_one_member': app.get_membership_handler().max_one_per_person(), 'use_preferred_first_name': self.people_handler.should_use_preferred_first_name(), + 'expose_transactions': self.should_expose_profile_transactions(), } + if context['expose_transactions']: + context['transactions_grid'] = self.profile_transactions_grid(person, empty=True) + if self.request.has_perm('people_profile.view_versions'): context['revisions_grid'] = self.profile_revisions_grid(person) return self.render_to_response('view_profile', context) + def should_expose_profile_transactions(self): + return self.rattail_config.get_bool('tailbone.people.profile.expose_transactions', + default=False) + + def profile_transactions_grid(self, person, empty=False): + app = self.get_rattail_app() + trainwreck = app.get_trainwreck_handler() + model = trainwreck.get_model() + route_prefix = self.get_route_prefix() + if empty: + # TODO: surely there is a better way to have empty data..? but so + # much logic depends on a query, can't just pass empty list here + data = TrainwreckSession.query(model.Transaction)\ + .filter(model.Transaction.uuid == 'bogus') + else: + data = self.profile_transactions_query(person) + factory = self.get_grid_factory() + g = factory( + f'{route_prefix}.profile.transactions.{person.uuid}', + data, + request=self.request, + model_class=model.Transaction, + ajax_data_url=self.get_action_url('view_profile_transactions', person), + columns=[ + 'start_time', + 'end_time', + 'system', + 'terminal_id', + 'receipt_number', + 'cashier_name', + 'customer_id', + 'customer_name', + 'total', + ], + labels={ + 'terminal_id': "Terminal", + 'customer_id': "Customer " + app.get_customer_key_label(), + }, + filterable=True, + sortable=True, + pageable=True, + default_sortkey='end_time', + default_sortdir='desc', + component='transactions-grid', + ) + if self.request.has_perm('trainwreck.transactions.view'): + url = lambda row, i: self.request.route_url('trainwreck.transactions.view', + uuid=row.uuid) + g.main_actions.append(grids.GridAction('view', icon='eye', url=url)) + g.load_settings() + + g.set_enum('system', self.enum.TRAINWRECK_SYSTEM) + g.set_type('total', 'currency') + + return g + + def profile_transactions_query(self, person): + """ + Method which must return the base query for the profile's POS + Transactions grid data. + """ + app = self.get_rattail_app() + customer = app.get_customer(person) + + key_field = app.get_customer_key_field() + customer_key = getattr(customer, key_field) + if customer_key is not None: + customer_key = str(customer_key) + + trainwreck = app.get_trainwreck_handler() + model = trainwreck.get_model() + query = TrainwreckSession.query(model.Transaction)\ + .filter(model.Transaction.customer_id == customer_key) + return query + + def profile_transactions_data(self): + """ + AJAX view to return new sorted, filtered data for transactions + grid within profile view. + """ + person = self.get_instance() + grid = self.profile_transactions_grid(person) + return grid.get_table_data() + def get_context_tabchecks(self, person): app = self.get_rattail_app() membership = app.get_membership_handler() @@ -1605,6 +1694,11 @@ class PersonView(MasterView): {'section': 'rattail', 'option': 'people.handler'}, + + # Profile View + {'section': 'tailbone', + 'option': 'people.profile.expose_transactions', + 'type': bool}, ] @classmethod @@ -1873,6 +1967,15 @@ class PersonView(MasterView): permission='people_profile.delete_note', renderer='json') + # profile - transactions data + config.add_route(f'{route_prefix}.view_profile_transactions', + f'{instance_url_prefix}/profile/transactions', + request_method='GET') + config.add_view(cls, attr='profile_transactions_data', + route_name=f'{route_prefix}.view_profile_transactions', + permission=f'{permission_prefix}.view_profile', + renderer='json') + # make user for person config.add_route('{}.make_user'.format(route_prefix), '{}/make-user'.format(url_prefix), request_method='POST') From 2917463bb6460dc477566457709f614bba5f3de5 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 5 Jul 2024 14:49:59 -0500 Subject: [PATCH 1523/1681] =?UTF-8?q?bump:=20version=200.11.8=20=E2=86=92?= =?UTF-8?q?=200.11.9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 14 ++++++++++++++ setup.cfg | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15fe3a46..c493f7c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.11.9 (2024-07-05) + +### Fix + +- do not show flash message when changing app theme + +- improve collapse panels for butterball theme + +- expand input for butterball theme + +- add xref button to customer profile, for trainwreck txn view + +- add optional Transactions tab for profile view + ## v0.11.8 (2024-07-04) ### Fix diff --git a/setup.cfg b/setup.cfg index 6e81a547..c87b903a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,7 +1,7 @@ [metadata] name = Tailbone -version = 0.11.8 +version = 0.11.9 author = Lance Edgar author_email = lance@edbob.org url = http://rattailproject.org/ From 2f2ebd0f079dc47a237b3aab6b3e9c7705f6d438 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 5 Jul 2024 14:57:19 -0500 Subject: [PATCH 1524/1681] fix: make the Members tab optional, for profile view and hidden by default --- tailbone/templates/people/configure.mako | 8 +++++++ tailbone/templates/people/view_profile.mako | 22 +++++++++++++++++--- tailbone/views/people.py | 23 ++++++++++++++------- 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/tailbone/templates/people/configure.mako b/tailbone/templates/people/configure.mako index 7d7a5618..257432dc 100644 --- a/tailbone/templates/people/configure.mako +++ b/tailbone/templates/people/configure.mako @@ -37,6 +37,14 @@ <h3 class="block is-size-3">Profile View</h3> <div class="block" style="padding-left: 2rem; width: 50%;"> + <b-field> + <b-checkbox name="tailbone.people.profile.expose_members" + v-model="simpleSettings['tailbone.people.profile.expose_members']" + native-value="true" + @input="settingsNeedSaved = true"> + Show tab for Member Accounts + </b-checkbox> + </b-field> <b-field> <b-checkbox name="tailbone.people.profile.expose_transactions" v-model="simpleSettings['tailbone.people.profile.expose_transactions']" diff --git a/tailbone/templates/people/view_profile.mako b/tailbone/templates/people/view_profile.mako index 9d9ab37d..8044f7c6 100644 --- a/tailbone/templates/people/view_profile.mako +++ b/tailbone/templates/people/view_profile.mako @@ -819,6 +819,7 @@ </${b}-tab-item> </%def> +% if expose_members: <%def name="render_member_tab_template()"> <script type="text/x-template" id="member-tab-template"> <div> @@ -961,6 +962,7 @@ </member-tab> </${b}-tab-item> </%def> +% endif <%def name="render_customer_tab_template()"> <script type="text/x-template" id="customer-tab-template"> @@ -1827,7 +1829,11 @@ <%def name="render_profile_tabs()"> ${self.render_personal_tab()} - ${self.render_member_tab()} + + % if expose_members: + ${self.render_member_tab()} + % endif + ${self.render_customer_tab()} % if expose_customer_shoppers: ${self.render_shopper_tab()} @@ -1965,7 +1971,11 @@ <%def name="render_this_page_template()"> ${parent.render_this_page_template()} ${self.render_personal_tab_template()} - ${self.render_member_tab_template()} + + % if expose_members: + ${self.render_member_tab_template()} + % endif + ${self.render_customer_tab_template()} % if expose_customer_shoppers: ${self.render_shopper_tab_template()} @@ -2385,6 +2395,7 @@ </script> </%def> +% if expose_members: <%def name="declare_member_tab_vars()"> <script type="text/javascript"> @@ -2430,6 +2441,7 @@ </script> </%def> +% endif <%def name="declare_customer_tab_vars()"> <script type="text/javascript"> @@ -3159,7 +3171,11 @@ <%def name="make_this_page_component()"> ${parent.make_this_page_component()} ${self.make_personal_tab_component()} - ${self.make_member_tab_component()} + + % if expose_members: + ${self.make_member_tab_component()} + % endif + ${self.make_customer_tab_component()} % if expose_customer_shoppers: ${self.make_shopper_tab_component()} diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 2cabf1ec..9b28b94d 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -488,6 +488,7 @@ class PersonView(MasterView): 'expose_customer_shoppers': self.customers_should_expose_shoppers(), 'max_one_member': app.get_membership_handler().max_one_per_person(), 'use_preferred_first_name': self.people_handler.should_use_preferred_first_name(), + 'expose_members': self.should_expose_profile_members(), 'expose_transactions': self.should_expose_profile_transactions(), } @@ -499,6 +500,10 @@ class PersonView(MasterView): return self.render_to_response('view_profile', context) + def should_expose_profile_members(self): + return self.rattail_config.get_bool('tailbone.people.profile.expose_members', + default=False) + def should_expose_profile_transactions(self): return self.rattail_config.get_bool('tailbone.people.profile.expose_transactions', default=False) @@ -585,7 +590,6 @@ class PersonView(MasterView): def get_context_tabchecks(self, person): app = self.get_rattail_app() - membership = app.get_membership_handler() clientele = app.get_clientele_handler() tabchecks = {} @@ -596,12 +600,14 @@ class PersonView(MasterView): tabchecks['personal'] = True # member - if membership.max_one_per_person(): - member = app.get_member(person) - tabchecks['member'] = bool(member and member.active) - else: - members = membership.get_members_for_account_holder(person) - tabchecks['member'] = any([m.active for m in members]) + if self.should_expose_profile_members(): + membership = app.get_membership_handler() + if membership.max_one_per_person(): + member = app.get_member(person) + tabchecks['member'] = bool(member and member.active) + else: + members = membership.get_members_for_account_holder(person) + tabchecks['member'] = any([m.active for m in members]) # customer customers = clientele.get_customers_for_account_holder(person) @@ -1696,6 +1702,9 @@ class PersonView(MasterView): # Profile View + {'section': 'tailbone', + 'option': 'people.profile.expose_members', + 'type': bool}, {'section': 'tailbone', 'option': 'people.profile.expose_transactions', 'type': bool}, From 12f8b7bdf7bde13b69e09fe156323d4a7560b97d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 5 Jul 2024 14:58:02 -0500 Subject: [PATCH 1525/1681] =?UTF-8?q?bump:=20version=200.11.9=20=E2=86=92?= =?UTF-8?q?=200.11.10?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ setup.cfg | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c493f7c5..54b0e1de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.11.10 (2024-07-05) + +### Fix + +- make the Members tab optional, for profile view + ## v0.11.9 (2024-07-05) ### Fix diff --git a/setup.cfg b/setup.cfg index c87b903a..1787343a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,7 +1,7 @@ [metadata] name = Tailbone -version = 0.11.9 +version = 0.11.10 author = Lance Edgar author_email = lance@edbob.org url = http://rattailproject.org/ From a86a33445e25c3255eaa5633fea573c33a53d93e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 9 Jul 2024 16:45:36 -0500 Subject: [PATCH 1526/1681] feat: drop python 3.6 support, use pyproject.toml (again) --- pyproject.toml | 102 +++++++++++++++++++++++++++++++++++++++++++++++++ setup.cfg | 96 ---------------------------------------------- setup.py | 3 -- tox.ini | 14 +------ 4 files changed, 103 insertions(+), 112 deletions(-) create mode 100644 pyproject.toml delete mode 100644 setup.cfg delete mode 100644 setup.py diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..bc4bb451 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,102 @@ + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + + +[project] +name = "Tailbone" +version = "0.11.10" +description = "Backoffice Web Application for Rattail" +readme = "README.rst" +authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] +license = {text = "GNU GPL v3+"} +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Web Environment", + "Framework :: Pyramid", + "Intended Audience :: Developers", + "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Office/Business", + "Topic :: Software Development :: Libraries :: Python Modules", +] +requires-python = ">= 3.8" +dependencies = [ + "asgiref", + "colander", + "ColanderAlchemy", + "cornice", + "cornice-swagger", + "deform", + "humanize", + "Mako", + "markdown", + "openpyxl", + "paginate", + "paginate_sqlalchemy", + "passlib", + "Pillow", + "pyramid>=2", + "pyramid_beaker", + "pyramid_deform", + "pyramid_exclog", + "pyramid_fanstatic", + "pyramid_mako", + "pyramid_retry", + "pyramid_tm", + "rattail[db,bouncer]", + "sa-filters", + "simplejson", + "transaction", + "waitress", + "WebHelpers2", + "zope.sqlalchemy>=1.5", +] + + +[project.optional-dependencies] +docs = ["Sphinx", "sphinx-rtd-theme"] +tests = ["coverage", "mock", "pytest", "pytest-cov"] + + +[project.entry-points."paste.app_factory"] +main = "tailbone.app:main" +webapi = "tailbone.webapi:main" + + +[project.entry-points."rattail.cleaners"] +beaker = "tailbone.cleanup:BeakerCleaner" + + +[project.entry-points."rattail.config.extensions"] +tailbone = "tailbone.config:ConfigExtension" + + +[project.urls] +Homepage = "https://rattailproject.org" +Repository = "https://kallithea.rattailproject.org/rattail-project/tailbone" +Issues = "https://redmine.rattailproject.org/projects/tailbone/issues" +Changelog = "https://kallithea.rattailproject.org/rattail-project/tailbone/files/master/CHANGELOG.md" + + +[tool.commitizen] +version_provider = "pep621" +tag_format = "v$version" +update_changelog_on_bump = true + + +[tool.nosetests] +nocapture = 1 +cover-package = "tailbone" +cover-erase = 1 +cover-html = 1 +cover-html-dir = "htmlcov" diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 1787343a..00000000 --- a/setup.cfg +++ /dev/null @@ -1,96 +0,0 @@ - -[metadata] -name = Tailbone -version = 0.11.10 -author = Lance Edgar -author_email = lance@edbob.org -url = http://rattailproject.org/ -license = GNU GPL v3 -description = Backoffice Web Application for Rattail -long_description = file: README.rst -classifiers = - Development Status :: 4 - Beta - Environment :: Web Environment - Framework :: Pyramid - Intended Audience :: Developers - License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+) - Natural Language :: English - Operating System :: OS Independent - Programming Language :: Python - Programming Language :: Python :: 3 - Programming Language :: Python :: 3.6 - Programming Language :: Python :: 3.7 - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 - Programming Language :: Python :: 3.10 - Programming Language :: Python :: 3.11 - Topic :: Internet :: WWW/HTTP - Topic :: Office/Business - Topic :: Software Development :: Libraries :: Python Modules - - -[options] -packages = find: -include_package_data = True -install_requires = - asgiref - colander - ColanderAlchemy - cornice - cornice-swagger - deform - humanize - Mako - markdown - openpyxl - paginate - paginate_sqlalchemy - passlib - Pillow - pyramid>=2 - pyramid_beaker - pyramid_deform - pyramid_exclog - pyramid_fanstatic - pyramid_mako - pyramid_retry - pyramid_tm - rattail[db,bouncer] - sa-filters - simplejson - transaction - waitress - WebHelpers2 - zope.sqlalchemy>=1.5 - - -[options.packages.find] -exclude = - tests.* - tests - - -[options.extras_require] -docs = Sphinx; sphinx-rtd-theme -tests = coverage; mock; pytest; pytest-cov - - -[options.entry_points] - -paste.app_factory = - main = tailbone.app:main - webapi = tailbone.webapi:main - -rattail.cleaners = - beaker = tailbone.cleanup:BeakerCleaner - -rattail.config.extensions = - tailbone = tailbone.config:ConfigExtension - - -[nosetests] -nocapture = 1 -cover-package = tailbone -cover-erase = 1 -cover-html = 1 -cover-html-dir = htmlcov diff --git a/setup.py b/setup.py deleted file mode 100644 index b908cbe5..00000000 --- a/setup.py +++ /dev/null @@ -1,3 +0,0 @@ -import setuptools - -setuptools.setup() diff --git a/tox.ini b/tox.ini index 6e45883c..3896befb 100644 --- a/tox.ini +++ b/tox.ini @@ -1,24 +1,12 @@ [tox] -# TODO: i had to remove py36 since something (hatchling?) broke it -# somehow, and i was not able to quickly fix. as of writing only -# one app is known to run py36 and hopefully that is not for long. -envlist = py37, py38, py39, py310, py311 - -# TODO: can remove this when we drop py36 support -# nb. need this for testing older python versions -# https://tox.wiki/en/latest/faq.html#testing-end-of-life-python-versions -requires = virtualenv<20.22.0 +envlist = py38, py39, py310, py311 [testenv] deps = rattail-tempmon extras = tests commands = pytest {posargs} -[testenv:py37] -# nb. Chameleon 4.3 requires Python 3.9+ -deps = Chameleon<4.3 - [testenv:coverage] basepython = python3 extras = tests From 4eb58663798de21e34391c2298c12b53a7f2b4c5 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 9 Jul 2024 16:45:45 -0500 Subject: [PATCH 1527/1681] =?UTF-8?q?bump:=20version=200.11.10=20=E2=86=92?= =?UTF-8?q?=200.12.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54b0e1de..e3832f0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.12.0 (2024-07-09) + +### Feat + +- drop python 3.6 support, use pyproject.toml (again) + ## v0.11.10 (2024-07-05) ### Fix diff --git a/pyproject.toml b/pyproject.toml index bc4bb451..7b4cd713 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.11.10" +version = "0.12.0" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From ae8212069c731a10cc342965711c562d6f1db603 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 11 Jul 2024 13:16:02 -0500 Subject: [PATCH 1528/1681] fix: refactor `config.get_model()` => `app.model` per rattail changes --- pyproject.toml | 2 +- tailbone/forms/core.py | 3 ++- tailbone/forms/widgets.py | 18 ++++++++++++------ tailbone/grids/core.py | 12 +++++++----- tailbone/subscribers.py | 8 +++++--- tailbone/util.py | 2 +- tailbone/views/asgi/__init__.py | 7 ++++--- tailbone/views/core.py | 18 +++++++++--------- tailbone/views/custorders/batch.py | 18 +++++++++--------- tasks.py | 6 ++++-- 10 files changed, 54 insertions(+), 40 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7b4cd713..3b2b3b6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ dependencies = [ "pyramid_mako", "pyramid_retry", "pyramid_tm", - "rattail[db,bouncer]", + "rattail[db,bouncer]>=0.16.0", "sa-filters", "simplejson", "transaction", diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index d6303bb1..11d489a7 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -875,7 +875,8 @@ class Form(object): for field in self]) def get_field_markdowns(self): - model = self.request.rattail_config.get_model() + app = self.request.rattail_config.get_app() + model = app.model if not hasattr(self, 'field_markdowns'): infos = Session.query(model.TailboneFieldInfo)\ diff --git a/tailbone/forms/widgets.py b/tailbone/forms/widgets.py index 2923b7ec..8c16726d 100644 --- a/tailbone/forms/widgets.py +++ b/tailbone/forms/widgets.py @@ -477,7 +477,8 @@ class CustomerAutocompleteWidget(JQueryAutocompleteWidget): def __init__(self, request, *args, **kwargs): super().__init__(*args, **kwargs) self.request = request - model = self.request.rattail_config.get_model() + app = self.request.rattail_config.get_app() + model = app.model # must figure out URL providing autocomplete service if 'service_url' not in kwargs: @@ -498,7 +499,8 @@ class CustomerAutocompleteWidget(JQueryAutocompleteWidget): """ """ # fetch customer to provide button label, if we have a value if cstruct: - model = self.request.rattail_config.get_model() + app = self.request.rattail_config.get_app() + model = app.model customer = Session.get(model.Customer, cstruct) if customer: self.field_display = str(customer) @@ -552,7 +554,8 @@ class DepartmentWidget(dfwidget.SelectWidget): def __init__(self, request, **kwargs): if 'values' not in kwargs: - model = request.rattail_config.get_model() + app = request.rattail_config.get_app() + model = app.model departments = Session.query(model.Department)\ .order_by(model.Department.number) values = [(dept.uuid, str(dept)) @@ -594,7 +597,8 @@ class VendorAutocompleteWidget(JQueryAutocompleteWidget): def __init__(self, request, *args, **kwargs): super().__init__(*args, **kwargs) self.request = request - model = self.request.rattail_config.get_model() + app = self.request.rattail_config.get_app() + model = app.model # must figure out URL providing autocomplete service if 'service_url' not in kwargs: @@ -615,7 +619,8 @@ class VendorAutocompleteWidget(JQueryAutocompleteWidget): """ """ # fetch vendor to provide button label, if we have a value if cstruct: - model = self.request.rattail_config.get_model() + app = self.request.rattail_config.get_app() + model = app.model vendor = Session.get(model.Vendor, cstruct) if vendor: self.field_display = str(vendor) @@ -643,7 +648,8 @@ class VendorDropdownWidget(dfwidget.SelectWidget): vendors = vendors() else: # default vendor list - model = self.request.rattail_config.get_model() + app = self.request.rattail_config.get_app() + model = app.model vendors = Session.query(model.Vendor)\ .order_by(model.Vendor.name)\ .all() diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 91c3d1f5..b4610a18 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -32,7 +32,7 @@ import sqlalchemy as sa from sqlalchemy import orm from rattail.db.types import GPCType -from rattail.util import prettify, pretty_boolean, pretty_quantity +from rattail.util import prettify, pretty_boolean from pyramid.renderers import render from webhelpers2.html import HTML, tags @@ -60,7 +60,7 @@ class FieldList(list): self.insert(i + 1, newfield) -class Grid(object): +class Grid: """ Core grid class. In sore need of documentation. @@ -532,7 +532,8 @@ class Grid(object): def render_quantity(self, obj, column_name): value = self.obtain_value(obj, column_name) - return pretty_quantity(value) + app = self.request.rattail_config.get_app() + return app.render_quantity(value) def render_duration(self, obj, column_name): seconds = self.obtain_value(obj, column_name) @@ -1152,10 +1153,12 @@ class Grid(object): """ Persist the given settings in some way, as defined by ``func``. """ + app = self.request.rattail_config.get_app() + model = app.model + def persist(key, value=lambda k: settings[k]): if to == 'defaults': skey = 'tailbone.{}.grid.{}.{}'.format(self.request.user.uuid, self.key, key) - app = self.request.rattail_config.get_app() app.save_setting(Session(), skey, value(key)) else: # to == session skey = 'grid.{}.{}'.format(self.key, key) @@ -1172,7 +1175,6 @@ class Grid(object): # first clear existing settings for *sorting* only # nb. this is because number of sort settings will vary if to == 'defaults': - model = self.request.rattail_config.get_model() prefix = f'tailbone.{self.request.user.uuid}.grid.{self.key}' query = Session.query(model.Setting)\ .filter(sa.or_( diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index bd59a033..b02346a3 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -92,7 +92,8 @@ def new_request(event): user = None uuid = request.authenticated_userid if uuid: - model = request.rattail_config.get_model() + app = request.rattail_config.get_app() + model = app.model user = Session.get(model.User, uuid) if user: Session().set_continuum_user(user) @@ -174,7 +175,7 @@ def before_render(event): renderer_globals['url'] = request.route_url renderer_globals['rattail'] = rattail renderer_globals['tailbone'] = tailbone - renderer_globals['model'] = request.rattail_config.get_model() + renderer_globals['model'] = app.model renderer_globals['enum'] = request.rattail_config.get_enum() renderer_globals['json'] = json renderer_globals['datetime'] = datetime @@ -258,8 +259,9 @@ def add_inbox_count(event): request = event.get('request') or threadlocal.get_current_request() if request.user: renderer_globals = event + app = request.rattail_config.get_app() + model = app.model enum = request.rattail_config.get_enum() - model = request.rattail_config.get_model() renderer_globals['inbox_count'] = Session.query(model.Message)\ .outerjoin(model.MessageRecipient)\ .filter(model.MessageRecipient.recipient == Session.merge(request.user))\ diff --git a/tailbone/util.py b/tailbone/util.py index 98a7f7d4..c1a0e1d5 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -506,7 +506,7 @@ def include_configured_views(pyramid_config): """ rattail_config = pyramid_config.registry.settings.get('rattail_config') app = rattail_config.get_app() - model = rattail_config.get_model() + model = app.model session = app.make_session() # fetch all include-related settings at once diff --git a/tailbone/views/asgi/__init__.py b/tailbone/views/asgi/__init__.py index bebe16f3..33888654 100644 --- a/tailbone/views/asgi/__init__.py +++ b/tailbone/views/asgi/__init__.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -41,12 +41,13 @@ class MockRequest(dict): pass -class WebsocketView(object): +class WebsocketView: def __init__(self, pyramid_config): self.pyramid_config = pyramid_config self.registry = self.pyramid_config.registry - self.model = self.rattail_config.get_model() + app = self.get_rattail_app() + self.model = app.model @property def rattail_config(self): diff --git a/tailbone/views/core.py b/tailbone/views/core.py index 97b59c10..b0658d80 100644 --- a/tailbone/views/core.py +++ b/tailbone/views/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -26,10 +26,6 @@ Base View Class import os -from rattail.db import model -from rattail.core import Object -from rattail.util import progress_loop - from pyramid import httpexceptions from pyramid.renderers import render_to_response from pyramid.response import FileResponse @@ -40,7 +36,7 @@ from tailbone.progress import SessionProgress from tailbone.config import protected_usernames -class View(object): +class View: """ Base class for all class-based views. """ @@ -62,8 +58,9 @@ class View(object): config = self.rattail_config if config: + app = config.get_app() + self.model = app.model self.enum = config.get_enum() - self.model = config.get_model() @property def rattail_config(self): @@ -94,6 +91,7 @@ class View(object): Returns the :class:`rattail:rattail.db.model.User` instance corresponding to the "late login" form data (if any), or ``None``. """ + model = self.model if self.request.method == 'POST': uuid = self.request.POST.get('late-login-user') if uuid: @@ -120,7 +118,8 @@ class View(object): return httpexceptions.HTTPFound(location=url, **kwargs) def progress_loop(self, func, items, factory, *args, **kwargs): - return progress_loop(func, items, factory, *args, **kwargs) + app = self.get_rattail_app() + return app.progress_loop(func, items, factory, *args, **kwargs) def make_progress(self, key, **kwargs): """ @@ -165,7 +164,8 @@ class View(object): return self.expose_quickie_search def get_quickie_context(self): - return Object( + app = self.get_rattail_app() + return app.make_object( url=self.get_quickie_url(), perm=self.get_quickie_perm(), placeholder=self.get_quickie_placeholder()) diff --git a/tailbone/views/custorders/batch.py b/tailbone/views/custorders/batch.py index 38d2eda7..fa0df901 100644 --- a/tailbone/views/custorders/batch.py +++ b/tailbone/views/custorders/batch.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,7 +24,7 @@ Base class for customer order batch views """ -from rattail.db import model +from rattail.db.model import CustomerOrderBatch, CustomerOrderBatchRow import colander from webhelpers2.html import tags @@ -38,8 +38,8 @@ class CustomerOrderBatchView(BatchMasterView): Master view base class, for customer order batches. The views for the various mode/workflow batches will derive from this. """ - model_class = model.CustomerOrderBatch - model_row_class = model.CustomerOrderBatchRow + model_class = CustomerOrderBatch + model_row_class = CustomerOrderBatchRow default_handler_spec = 'rattail.batch.custorder:CustomerOrderBatchHandler' grid_columns = [ @@ -122,7 +122,7 @@ class CustomerOrderBatchView(BatchMasterView): ] def configure_grid(self, g): - super(CustomerOrderBatchView, self).configure_grid(g) + super().configure_grid(g) g.set_type('total_price', 'currency') @@ -131,9 +131,9 @@ class CustomerOrderBatchView(BatchMasterView): g.set_link('created_by') def configure_form(self, f): - super(CustomerOrderBatchView, self).configure_form(f) + super().configure_form(f) order = f.model_instance - model = self.rattail_config.get_model() + model = self.model # readonly fields f.set_readonly('rows') @@ -201,7 +201,7 @@ class CustomerOrderBatchView(BatchMasterView): return 'notice' def configure_row_grid(self, g): - super(CustomerOrderBatchView, self).configure_row_grid(g) + super().configure_row_grid(g) g.set_type('case_quantity', 'quantity') g.set_type('cases_ordered', 'quantity') @@ -215,7 +215,7 @@ class CustomerOrderBatchView(BatchMasterView): g.set_link('product_description') def configure_row_form(self, f): - super(CustomerOrderBatchView, self).configure_row_form(f) + super().configure_row_form(f) f.set_renderer('product', self.render_product) f.set_renderer('pending_product', self.render_pending_product) diff --git a/tasks.py b/tasks.py index b57315a0..4ca01bab 100644 --- a/tasks.py +++ b/tasks.py @@ -31,16 +31,18 @@ from invoke import task @task -def release(c, tests=False): +def release(c, skip_tests=False): """ Release a new version of 'Tailbone'. """ - if tests: + if not skip_tests: c.run('tox') if os.path.exists('dist'): shutil.rmtree('dist') if os.path.exists('Tailbone.egg-info'): shutil.rmtree('Tailbone.egg-info') + c.run('python -m build --sdist') + c.run('twine upload dist/*') From 09ce2d5a40af7204621a921cdb8c448d45f0c5ec Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 11 Jul 2024 13:16:36 -0500 Subject: [PATCH 1529/1681] =?UTF-8?q?bump:=20version=200.12.0=20=E2=86=92?= =?UTF-8?q?=200.12.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3832f0f..dfeabd92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.12.1 (2024-07-11) + +### Fix + +- refactor `config.get_model()` => `app.model` + ## v0.12.0 (2024-07-09) ### Feat diff --git a/pyproject.toml b/pyproject.toml index 3b2b3b6d..847b5e28 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.12.0" +version = "0.12.1" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From e531f98079c7d5a04ef8a003686748e5b1a3cf82 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 11 Jul 2024 13:54:37 -0500 Subject: [PATCH 1530/1681] fix: cast enum as list to satisfy deform widget seems to only be an issue for deform 2.0.15+ --- tailbone/views/batch/handheld.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/batch/handheld.py b/tailbone/views/batch/handheld.py index eb22f367..486d8774 100644 --- a/tailbone/views/batch/handheld.py +++ b/tailbone/views/batch/handheld.py @@ -46,7 +46,7 @@ class ExecutionOptions(colander.Schema): action = colander.SchemaNode( colander.String(), validator=colander.OneOf(ACTION_OPTIONS), - widget=dfwidget.SelectWidget(values=ACTION_OPTIONS.items())) + widget=dfwidget.SelectWidget(values=list(ACTION_OPTIONS.items()))) class HandheldBatchView(FileBatchMasterView): From ce156d6278b1b243a714499315c665ca49760fbe Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 12 Jul 2024 09:35:34 -0500 Subject: [PATCH 1531/1681] feat: begin integrating WuttaWeb as upstream dependency the bare minimum, just to get the relationship established. mostly it's calling upstream subscriber / event hooks where applicable. this also overhauls the docs config to use furo theme etc. --- docs/api/subscribers.rst | 3 +- docs/conf.py | 276 ++++----------------------------------- pyproject.toml | 3 +- tailbone/app.py | 7 +- tailbone/config.py | 3 + tailbone/subscribers.py | 138 +++++++++++--------- 6 files changed, 112 insertions(+), 318 deletions(-) diff --git a/docs/api/subscribers.rst b/docs/api/subscribers.rst index 8b25c994..d28a1b15 100644 --- a/docs/api/subscribers.rst +++ b/docs/api/subscribers.rst @@ -3,5 +3,4 @@ ======================== .. automodule:: tailbone.subscribers - -.. autofunction:: new_request + :members: diff --git a/docs/conf.py b/docs/conf.py index 505396ed..52e384f5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,38 +1,21 @@ -# -*- coding: utf-8; -*- +# Configuration file for the Sphinx documentation builder. # -# Tailbone documentation build configuration file, created by -# sphinx-quickstart on Sat Feb 15 23:15:27 2014. -# -# This file is exec()d with the current directory set to its -# containing dir. -# -# Note that not all possible configuration values are present in this -# autogenerated file. -# -# All configuration values have a default; values that are commented out -# serve to show the default. +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html -import sys -import os +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information -import sphinx_rtd_theme +from importlib.metadata import version as get_version -exec(open(os.path.join(os.pardir, 'tailbone', '_version.py')).read()) +project = 'Tailbone' +copyright = '2010 - 2024, Lance Edgar' +author = 'Lance Edgar' +release = get_version('Tailbone') +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -#sys.path.insert(0, os.path.abspath('.')) - -# -- General configuration ------------------------------------------------ - -# If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.todo', @@ -40,241 +23,30 @@ extensions = [ 'sphinx.ext.viewcode', ] +templates_path = ['_templates'] +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + intersphinx_mapping = { 'rattail': ('https://rattailproject.org/docs/rattail/', None), 'webhelpers2': ('https://webhelpers2.readthedocs.io/en/latest/', None), + 'wuttaweb': ('https://rattailproject.org/docs/wuttaweb/', None), + 'wuttjamaican': ('https://rattailproject.org/docs/wuttjamaican/', None), } -# Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] - -# The suffix of source filenames. -source_suffix = '.rst' - -# The encoding of source files. -#source_encoding = 'utf-8-sig' - -# The master toctree document. -master_doc = 'index' - -# General information about the project. -project = u'Tailbone' -copyright = u'2010 - 2020, Lance Edgar' - -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -# -# The short X.Y version. -# version = '0.3' -version = '.'.join(__version__.split('.')[:2]) -# The full version, including alpha/beta/rc tags. -release = __version__ - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -#language = None - -# There are two options for replacing |today|: either, you set today to some -# non-false value, then it is used: -#today = '' -# Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -exclude_patterns = ['_build'] - -# The reST default role (used for this markup: `text`) to use for all -# documents. -#default_role = None - -# If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True - -# If true, the current module name will be prepended to all description -# unit titles (such as .. function::). -#add_module_names = True - -# If true, sectionauthor and moduleauthor directives will be shown in the -# output. They are ignored by default. -#show_authors = False - -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' - -# A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] - -# If true, keep warnings as "system message" paragraphs in the built documents. -#keep_warnings = False - -# Allow todo entries to show up. +# allow todo entries to show up todo_include_todos = True -# -- Options for HTML output ---------------------------------------------- +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -# html_theme = 'classic' -html_theme = 'sphinx_rtd_theme' - -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. -#html_theme_options = {} - -# Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] -html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] - -# The name for this set of Sphinx documents. If None, it defaults to -# "<project> v<release> documentation". -#html_title = None - -# A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None +html_theme = 'furo' +html_static_path = ['_static'] # The name of an image file (relative to this directory) to place at the top # of the sidebar. #html_logo = None -html_logo = 'images/rattail_avatar.png' - -# The name of an image file (within the static path) to use as favicon of the -# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 -# pixels large. -#html_favicon = None - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] - -# Add any extra paths that contain custom files (such as robots.txt or -# .htaccess) here, relative to this directory. These files are copied -# directly to the root of the documentation. -#html_extra_path = [] - -# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, -# using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' - -# If true, SmartyPants will be used to convert quotes and dashes to -# typographically correct entities. -#html_use_smartypants = True - -# Custom sidebar templates, maps document names to template names. -#html_sidebars = {} - -# Additional templates that should be rendered to pages, maps page names to -# template names. -#html_additional_pages = {} - -# If false, no module index is generated. -#html_domain_indices = True - -# If false, no index is generated. -#html_use_index = True - -# If true, the index is split into individual pages for each letter. -#html_split_index = False - -# If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True - -# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True - -# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True - -# If true, an OpenSearch description file will be output, and all pages will -# contain a <link> tag referring to it. The value of this option must be the -# base URL from which the finished HTML is served. -#html_use_opensearch = '' - -# This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None +#html_logo = 'images/rattail_avatar.png' # Output file base name for HTML help builder. -htmlhelp_basename = 'Tailbonedoc' - - -# -- Options for LaTeX output --------------------------------------------- - -latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', - -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', - -# Additional stuff for the LaTeX preamble. -#'preamble': '', -} - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, -# author, documentclass [howto, manual, or own class]). -latex_documents = [ - ('index', 'Tailbone.tex', u'Tailbone Documentation', - u'Lance Edgar', 'manual'), -] - -# The name of an image file (relative to this directory) to place at the top of -# the title page. -#latex_logo = None - -# For "manual" documents, if this is true, then toplevel headings are parts, -# not chapters. -#latex_use_parts = False - -# If true, show page references after internal links. -#latex_show_pagerefs = False - -# If true, show URL addresses after external links. -#latex_show_urls = False - -# Documents to append as an appendix to all manuals. -#latex_appendices = [] - -# If false, no module index is generated. -#latex_domain_indices = True - - -# -- Options for manual page output --------------------------------------- - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [ - ('index', 'tailbone', u'Tailbone Documentation', - [u'Lance Edgar'], 1) -] - -# If true, show URL addresses after external links. -#man_show_urls = False - - -# -- Options for Texinfo output ------------------------------------------- - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - ('index', 'Tailbone', u'Tailbone Documentation', - u'Lance Edgar', 'Tailbone', 'One line description of project.', - 'Miscellaneous'), -] - -# Documents to append as an appendix to all manuals. -#texinfo_appendices = [] - -# If false, no module index is generated. -#texinfo_domain_indices = True - -# How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' - -# If true, do not generate a @detailmenu in the "Top" node's menu. -#texinfo_no_detailmenu = False +#htmlhelp_basename = 'Tailbonedoc' diff --git a/pyproject.toml b/pyproject.toml index 847b5e28..defb1ffe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,12 +59,13 @@ dependencies = [ "transaction", "waitress", "WebHelpers2", + "WuttaWeb", "zope.sqlalchemy>=1.5", ] [project.optional-dependencies] -docs = ["Sphinx", "sphinx-rtd-theme"] +docs = ["Sphinx", "furo"] tests = ["coverage", "mock", "pytest", "pytest-cov"] diff --git a/tailbone/app.py b/tailbone/app.py index b0160bd3..b7220703 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -30,7 +30,9 @@ import warnings import sqlalchemy as sa from sqlalchemy.orm import sessionmaker, scoped_session -from rattail.config import make_config, parse_list +from wuttjamaican.util import parse_list + +from rattail.config import make_config from rattail.exceptions import ConfigurationError from rattail.db.types import GPCType @@ -61,6 +63,9 @@ def make_rattail_config(settings): rattail_config = make_config(path) settings['rattail_config'] = rattail_config + # nb. this is for compaibility with wuttaweb + settings['wutta_config'] = rattail_config + # configure database sessions if hasattr(rattail_config, 'rattail_engine'): tailbone.db.Session.configure(bind=rattail_config.rattail_engine) diff --git a/tailbone/config.py b/tailbone/config.py index ee906149..ce1691ae 100644 --- a/tailbone/config.py +++ b/tailbone/config.py @@ -52,6 +52,9 @@ class ConfigExtension(BaseExtension): config.setdefault('tailbone', 'themes.keys', 'default, butterball') config.setdefault('tailbone', 'themes.expose_picker', 'true') + # override oruga detection + config.setdefault('wuttaweb.oruga_detector.spec', 'tailbone.util:should_use_oruga') + def csrf_token_name(config): return config.get('tailbone', 'csrf_token_name', default='_csrf') diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index b02346a3..0bf218cb 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -24,7 +24,6 @@ Event Subscribers """ -import json import datetime import logging import warnings @@ -37,13 +36,14 @@ import deform from pyramid import threadlocal from webhelpers2.html import tags +from wuttaweb import subscribers as base + import tailbone from tailbone import helpers from tailbone.db import Session from tailbone.config import csrf_header_name, should_expose_websockets from tailbone.menus import make_simple_menus -from tailbone.util import (get_available_themes, get_global_search_options, - should_use_oruga) +from tailbone.util import get_available_themes, get_global_search_options log = logging.getLogger(__name__) @@ -51,42 +51,59 @@ log = logging.getLogger(__name__) def new_request(event): """ - Identify the current user, and cache their current permissions. Also adds - the ``rattail_config`` attribute to the request. + Event hook called when processing a new request. - A global Rattail ``config`` should already be present within the Pyramid - application registry's settings, which would normally be accessed via:: - - request.registry.settings['rattail_config'] + This first invokes the upstream hook: + :func:`wuttaweb:wuttaweb.subscribers.new_request()` - This function merely "promotes" that config object so that it is more - directly accessible, a la:: + It then adds more things to the request object; among them: - request.rattail_config + .. attribute:: request.rattail_config - .. note:: - This of course assumes that a Rattail ``config`` object *has* in fact - already been placed in the application registry settings. If this is - not the case, this function will do nothing. + Reference to the app :term:`config object`. Note that this + will be the same as ``request.wutta_config``. - Also, attach some goodies to the request object: + .. attribute:: request.user - * The currently logged-in user instance (if any), as ``user``. + Reference to the current authenticated user, or ``None``. - * ``is_admin`` flag indicating whether user has the Administrator role. + .. attribute:: request.is_admin - * ``is_root`` flag indicating whether user is currently elevated to root. + Flag indicating whether current user is a member of the + Administrator role. - * A shortcut method for permission checking, as ``has_perm()``. + .. attribute:: request.is_root + + Flag indicating whether user is currently elevated to root + privileges. This is only possible if ``request.is_admin = + True``. + + .. method:: request.has_perm(name) + + Function to check if current user has the given permission. + + .. method:: request.has_any_perm(*names) + + Function to check if current user has any of the given + permissions. + + .. method:: request.register_component(tagname, classname) + + Function to register a Vue component for use with the app. + + This can be called from wherever a component is defined, and + then in the base template all registered components will be + properly loaded. """ - log.debug("new request: %s", event) + # log.debug("new request: %s", event) request = event.request - rattail_config = request.registry.settings.get('rattail_config') - # TODO: why would this ever be null? - if rattail_config: - request.rattail_config = rattail_config - else: - log.error("registry has no rattail_config ?!") + + # invoke upstream logic + base.new_request(event) + + # compatibility + rattail_config = request.wutta_config + request.rattail_config = rattail_config def user(request): user = None @@ -101,15 +118,6 @@ def new_request(event): request.set_property(user, reify=True) - # nb. only add oruga check for "classic" web app - classic = rattail_config.parse_bool(request.registry.settings.get('tailbone.classic')) - if classic: - - def use_oruga(request): - return should_use_oruga(request) - - request.set_property(use_oruga, reify=True) - # assign client IP address to the session, for sake of versioning Session().continuum_remote_addr = request.client_addr @@ -161,27 +169,34 @@ def before_render(event): """ Adds goodies to the global template renderer context. """ - log.debug("before_render: %s", event) + # log.debug("before_render: %s", event) + + # invoke upstream logic + base.before_render(event) request = event.get('request') or threadlocal.get_current_request() - rattail_config = request.rattail_config - app = rattail_config.get_app() + config = request.wutta_config + app = config.get_app() renderer_globals = event - renderer_globals['rattail_app'] = app - renderer_globals['app_title'] = app.get_title() - renderer_globals['app_version'] = app.get_version() + + # wuttaweb overrides renderer_globals['h'] = helpers - renderer_globals['url'] = request.route_url - renderer_globals['rattail'] = rattail - renderer_globals['tailbone'] = tailbone - renderer_globals['model'] = app.model - renderer_globals['enum'] = request.rattail_config.get_enum() - renderer_globals['json'] = json + + # misc. renderer_globals['datetime'] = datetime renderer_globals['colander'] = colander renderer_globals['deform'] = deform - renderer_globals['csrf_header_name'] = csrf_header_name(request.rattail_config) + renderer_globals['csrf_header_name'] = csrf_header_name(config) + + # TODO: deprecate / remove these + renderer_globals['rattail_app'] = app + renderer_globals['app_title'] = app.get_title() + renderer_globals['app_version'] = app.get_version() + renderer_globals['rattail'] = rattail + renderer_globals['tailbone'] = tailbone + renderer_globals['model'] = app.model + renderer_globals['enum'] = app.enum # theme - we only want do this for classic web app, *not* API # TODO: so, clearly we need a better way to distinguish the two @@ -189,13 +204,13 @@ def before_render(event): renderer_globals['b'] = 'o' if request.use_oruga else 'b' # for buefy renderer_globals['theme'] = request.registry.settings['tailbone.theme'] # note, this is just a global flag; user still needs permission to see picker - expose_picker = request.rattail_config.getbool('tailbone', 'themes.expose_picker', - default=False) + expose_picker = config.get_bool('tailbone.themes.expose_picker', + default=False) renderer_globals['expose_theme_picker'] = expose_picker if expose_picker: # TODO: should remove 'falafel' option altogether - available = get_available_themes(request.rattail_config) + available = get_available_themes(config) options = [tags.Option(theme, value=theme) for theme in available] renderer_globals['theme_picker_options'] = options @@ -204,26 +219,25 @@ def before_render(event): # (we don't want this to happen for the API either!) # TODO: just..awful *shrug* # note that we assume "simple" menus nowadays - if request.rattail_config.getbool('tailbone', 'menus.simple', default=True): + if config.get_bool('tailbone.menus.simple', default=True): renderer_globals['menus'] = make_simple_menus(request) # TODO: ugh, same deal here - renderer_globals['messaging_enabled'] = request.rattail_config.getbool( - 'tailbone', 'messaging.enabled', default=False) + renderer_globals['messaging_enabled'] = config.get_bool('tailbone.messaging.enabled', + default=False) # background color may be set per-request, by some apps if hasattr(request, 'background_color') and request.background_color: renderer_globals['background_color'] = request.background_color else: # otherwise we use the one from config - renderer_globals['background_color'] = request.rattail_config.get( - 'tailbone', 'background_color') + renderer_globals['background_color'] = config.get('tailbone.background_color') # maybe set custom stylesheet css = None if request.user: - css = rattail_config.get(f'tailbone.{request.user.uuid}', 'user_css') + css = config.get(f'tailbone.{request.user.uuid}', 'user_css') if not css: - css = rattail_config.get(f'tailbone.{request.user.uuid}', 'buefy_css') + css = config.get(f'tailbone.{request.user.uuid}', 'buefy_css') if css: warnings.warn(f"setting 'tailbone.{request.user.uuid}.buefy_css' should be" f"changed to 'tailbone.{request.user.uuid}.user_css'", @@ -234,7 +248,7 @@ def before_render(event): renderer_globals['global_search_data'] = get_global_search_options(request) # here we globally declare widths for grid filter pseudo-columns - widths = request.rattail_config.get('tailbone', 'grids.filters.column_widths') + widths = config.get('tailbone.grids.filters.column_widths') if widths: widths = widths.split(';') if len(widths) < 2: @@ -245,7 +259,7 @@ def before_render(event): renderer_globals['filter_verb_width'] = widths[1] # declare global support for websockets, or lack thereof - renderer_globals['expose_websockets'] = should_expose_websockets(rattail_config) + renderer_globals['expose_websockets'] = should_expose_websockets(config) def add_inbox_count(event): From ca660f408712344683121aea37f7d937f26c6fbc Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 12 Jul 2024 09:38:12 -0500 Subject: [PATCH 1532/1681] =?UTF-8?q?bump:=20version=200.12.1=20=E2=86=92?= =?UTF-8?q?=200.13.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 10 ++++++++++ pyproject.toml | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfeabd92..92e849f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.13.0 (2024-07-12) + +### Feat + +- begin integrating WuttaWeb as upstream dependency + +### Fix + +- cast enum as list to satisfy deform widget + ## v0.12.1 (2024-07-11) ### Fix diff --git a/pyproject.toml b/pyproject.toml index defb1ffe..396ba8dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.12.1" +version = "0.13.0" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From ee781ec48984a4d159fddf805dd88e513a4aad6e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 13 Jul 2024 15:14:04 -0500 Subject: [PATCH 1533/1681] fix: fix settings persistence bug(s) for datasync/configure page also hide the Changes context menu link, within the Configure page --- tailbone/templates/datasync/configure.mako | 61 ++++++++++++++++------ tailbone/views/datasync.py | 10 ++-- 2 files changed, 50 insertions(+), 21 deletions(-) diff --git a/tailbone/templates/datasync/configure.mako b/tailbone/templates/datasync/configure.mako index a512745c..04eda0fb 100644 --- a/tailbone/templates/datasync/configure.mako +++ b/tailbone/templates/datasync/configure.mako @@ -1,6 +1,15 @@ ## -*- coding: utf-8; -*- <%inherit file="/configure.mako" /> +<%def name="extra_styles()"> + ${parent.extra_styles()} + <style> + .invisible-watcher { + display: none; + } + </style> +</%def> + <%def name="buttons_row()"> <div class="level"> <div class="level-left"> @@ -106,8 +115,8 @@ </div> </div> - <${b}-table :data="filteredProfilesData" - :row-class="(row, i) => row.enabled ? null : 'has-background-warning'"> + <${b}-table :data="profilesData" + :row-class="getWatcherRowClass"> <${b}-table-column field="key" label="Watcher Key" v-slot="props"> @@ -625,19 +634,6 @@ ThisPageData.supervisorProcessName = ${json.dumps(supervisor_process_name)|n} ThisPageData.restartCommand = ${json.dumps(restart_command)|n} - ThisPage.computed.filteredProfilesData = function() { - if (this.showDisabledProfiles) { - return this.profilesData - } - let data = [] - for (let row of this.profilesData) { - if (row.enabled) { - data.push(row) - } - } - return data - } - ThisPage.computed.updateConsumerDisabled = function() { if (!this.editingConsumerKey) { return true @@ -665,6 +661,15 @@ this.showDisabledProfiles = !this.showDisabledProfiles } + ThisPage.methods.getWatcherRowClass = function(row, i) { + if (!row.enabled) { + if (!this.showDisabledProfiles) { + return 'invisible-watcher' + } + return 'has-background-warning' + } + } + ThisPage.methods.consumerShortList = function(row) { let keys = [] if (row.watcher_consumes_self) { @@ -795,9 +800,10 @@ } ThisPage.methods.updateProfile = function() { - let row = this.editingProfile + const row = this.editingProfile - if (!row.key) { + const newRow = !row.key + if (newRow) { row.consumers_data = [] this.profilesData.push(row) } @@ -874,10 +880,31 @@ row.consumers_data.splice(i, 1) } + if (newRow) { + + // nb. must explicitly update the original data row; + // otherwise (with vue3) it will remain stale and + // submitting the form will keep same settings! + // TODO: this probably means i am doing something + // sloppy, but at least this hack fixes for now. + const profile = this.findProfile(row) + for (const key of Object.keys(row)) { + profile[key] = row[key] + } + } + this.settingsNeedSaved = true this.editProfileShowDialog = false } + ThisPage.methods.findProfile = function(row) { + for (const profile of this.profilesData) { + if (profile.key == row.key) { + return profile + } + } + } + ThisPage.methods.deleteProfile = function(row) { if (confirm("Are you sure you want to delete the '" + row.key + "' profile?")) { let i = this.profilesData.indexOf(row) diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py index 7616d288..134d6018 100644 --- a/tailbone/views/datasync.py +++ b/tailbone/views/datasync.py @@ -79,11 +79,13 @@ class DataSyncThreadView(MasterView): def get_context_menu_items(self, thread=None): items = super().get_context_menu_items(thread) + route_prefix = self.get_route_prefix() - # nb. just one view here, no need to check if listing etc. - if self.request.has_perm('datasync_changes.list'): - url = self.request.route_url('datasyncchanges') - items.append(tags.link_to("View DataSync Changes", url)) + # nb. do not show this for /configure page + if self.request.matched_route.name != f'{route_prefix}.configure': + if self.request.has_perm('datasync_changes.list'): + url = self.request.route_url('datasyncchanges') + items.append(tags.link_to("View DataSync Changes", url)) return items From eede274529db7eaa93a4ab6f4d920d63d2297cde Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 13 Jul 2024 15:15:51 -0500 Subject: [PATCH 1534/1681] =?UTF-8?q?bump:=20version=200.13.0=20=E2=86=92?= =?UTF-8?q?=200.13.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92e849f8..c766025a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.13.1 (2024-07-13) + +### Fix + +- fix settings persistence bug(s) for datasync/configure page + ## v0.13.0 (2024-07-12) ### Feat diff --git a/pyproject.toml b/pyproject.toml index 396ba8dd..c15e8073 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.13.0" +version = "0.13.1" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From d2d0206b4503e8d6b525df5e737cf24421591493 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 13 Jul 2024 15:16:45 -0500 Subject: [PATCH 1535/1681] build: run `pytest` but avoid `tox` when preparing release buildbot can let us know if something goes wrong with an atypical python version etc. --- tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tasks.py b/tasks.py index 4ca01bab..6983dbea 100644 --- a/tasks.py +++ b/tasks.py @@ -36,7 +36,7 @@ def release(c, skip_tests=False): Release a new version of 'Tailbone'. """ if not skip_tests: - c.run('tox') + c.run('pytest') if os.path.exists('dist'): shutil.rmtree('dist') From 27214cc62f781aa271f1645ad8c5dac6f3924d4d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 13 Jul 2024 15:28:28 -0500 Subject: [PATCH 1536/1681] fix: fix logic bug for datasync/config settings save dang it --- tailbone/templates/datasync/configure.mako | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/templates/datasync/configure.mako b/tailbone/templates/datasync/configure.mako index 04eda0fb..0889b144 100644 --- a/tailbone/templates/datasync/configure.mako +++ b/tailbone/templates/datasync/configure.mako @@ -880,7 +880,7 @@ row.consumers_data.splice(i, 1) } - if (newRow) { + if (!newRow) { // nb. must explicitly update the original data row; // otherwise (with vue3) it will remain stale and From 0b4629ea29edb56abfcfb21e9bc99673143baca8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 13 Jul 2024 15:28:59 -0500 Subject: [PATCH 1537/1681] =?UTF-8?q?bump:=20version=200.13.1=20=E2=86=92?= =?UTF-8?q?=200.13.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c766025a..40604948 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.13.2 (2024-07-13) + +### Fix + +- fix logic bug for datasync/config settings save + ## v0.13.1 (2024-07-13) ### Fix diff --git a/pyproject.toml b/pyproject.toml index c15e8073..12f6a538 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.13.1" +version = "0.13.2" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From fd1ec01128438fffc78996cf6b4f367f48de7f41 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 14 Jul 2024 10:52:32 -0500 Subject: [PATCH 1538/1681] feat: move core menu logic to wuttaweb tailbone still defines the default menus, and allows for making dynamic menus from config (which wuttaweb does not). also remove some even older logic for "v1" menu functions --- tailbone/handler.py | 13 ++- tailbone/menus.py | 232 +++++++--------------------------------- tailbone/subscribers.py | 10 +- tailbone/views/menus.py | 11 +- 4 files changed, 54 insertions(+), 212 deletions(-) diff --git a/tailbone/handler.py b/tailbone/handler.py index 22f33cca..00f41bc9 100644 --- a/tailbone/handler.py +++ b/tailbone/handler.py @@ -24,6 +24,8 @@ Tailbone Handler """ +import warnings + from mako.lookup import TemplateLookup from rattail.app import GenericHandler @@ -46,11 +48,14 @@ class TailboneHandler(GenericHandler): def get_menu_handler(self, **kwargs): """ - Get the configured "menu" handler. - - :returns: The :class:`~tailbone.menus.MenuHandler` instance - for the app. + DEPRECATED; use + :meth:`wuttaweb.handler.WebHandler.get_menu_handler()` + instead. """ + warnings.warn("TailboneHandler.get_menu_handler() is deprecated; " + "please use WebHandler.get_menu_handler() instead", + DeprecationWarning, stacklevel=2) + if not hasattr(self, 'menu_handler'): spec = self.config.get('tailbone.menus', 'handler', default='tailbone.menus:MenuHandler') diff --git a/tailbone/menus.py b/tailbone/menus.py index 50dd3f4a..0752c22d 100644 --- a/tailbone/menus.py +++ b/tailbone/menus.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,37 +24,48 @@ App Menus """ -import re import logging import warnings -from rattail.app import GenericHandler from rattail.util import prettify, simple_error from webhelpers2.html import tags, HTML +from wuttaweb.menus import MenuHandler as WuttaMenuHandler + from tailbone.db import Session log = logging.getLogger(__name__) -class MenuHandler(GenericHandler): +class TailboneMenuHandler(WuttaMenuHandler): """ Base class and default implementation for menu handler. """ - def make_raw_menus(self, request, **kwargs): - """ - Generate a full set of "raw" menus for the app. + ############################## + # internal methods + ############################## - The "raw" menus are basically just a set of dicts to represent - the final menus. + def _is_allowed(self, request, item): + """ + TODO: must override this until wuttaweb has proper user auth checks + """ + perm = item.get('perm') + if perm: + return request.has_perm(perm) + return True + + def _make_raw_menus(self, request, **kwargs): + """ + We are overriding this to allow for making dynamic menus from + config/settings. Which may or may not be a good idea.. """ # first try to make menus from config, but this is highly # susceptible to failure, so try to warn user of problems try: - menus = self.make_menus_from_config(request) + menus = self._make_menus_from_config(request) if menus: return menus except Exception as error: @@ -71,9 +82,9 @@ class MenuHandler(GenericHandler): request.session.flash(msg, 'warning') # okay, no config, so menus will be built from code - return self.make_menus(request) + return self.make_menus(request, **kwargs) - def make_menus_from_config(self, request, **kwargs): + def _make_menus_from_config(self, request, **kwargs): """ Try to build a complete menu set from config/settings. @@ -101,16 +112,15 @@ class MenuHandler(GenericHandler): query=query, key='name', normalizer=lambda s: s.value) for key in main_keys: - menus.append(self.make_single_menu_from_settings(request, key, - settings)) + menus.append(self._make_single_menu_from_settings(request, key, settings)) else: # read from config file only for key in main_keys: - menus.append(self.make_single_menu_from_config(request, key)) + menus.append(self._make_single_menu_from_config(request, key)) return menus - def make_single_menu_from_config(self, request, key, **kwargs): + def _make_single_menu_from_config(self, request, key, **kwargs): """ Makes a single top-level menu dict from config file. Note that this will read from config file(s) *only* and avoids @@ -178,7 +188,7 @@ class MenuHandler(GenericHandler): return menu - def make_single_menu_from_settings(self, request, key, settings, **kwargs): + def _make_single_menu_from_settings(self, request, key, settings, **kwargs): """ Makes a single top-level menu dict from DB settings. """ @@ -237,6 +247,10 @@ class MenuHandler(GenericHandler): return menu + ############################## + # menu defaults + ############################## + def make_menus(self, request, **kwargs): """ Make the full set of menus for the app. @@ -723,182 +737,10 @@ class MenuHandler(GenericHandler): } -def make_simple_menus(request): - """ - Build the main menu list for the app. - """ - app = request.rattail_config.get_app() - tailbone_handler = app.get_tailbone_handler() - menu_handler = tailbone_handler.get_menu_handler() +class MenuHandler(TailboneMenuHandler): - raw_menus = menu_handler.make_raw_menus(request) - - # now we have "simple" (raw) menus definition, but must refine - # that somewhat to produce our final menus - mark_allowed(request, raw_menus) - final_menus = [] - for topitem in raw_menus: - - if topitem['allowed']: - - if topitem.get('type') == 'link': - final_menus.append(make_menu_entry(request, topitem)) - - else: # assuming 'menu' type - - menu_items = [] - for item in topitem['items']: - if not item['allowed']: - continue - - # nested submenu - if item.get('type') == 'menu': - submenu_items = [] - for subitem in item['items']: - if subitem['allowed']: - submenu_items.append(make_menu_entry(request, subitem)) - menu_items.append({ - 'type': 'submenu', - 'title': item['title'], - 'items': submenu_items, - 'is_menu': True, - 'is_sep': False, - }) - - elif item.get('type') == 'sep': - # we only want to add a sep, *if* we already have some - # menu items (i.e. there is something to separate) - # *and* the last menu item is not a sep (avoid doubles) - if menu_items and not menu_items[-1]['is_sep']: - menu_items.append(make_menu_entry(request, item)) - - else: # standard menu item - menu_items.append(make_menu_entry(request, item)) - - # remove final separator if present - if menu_items and menu_items[-1]['is_sep']: - menu_items.pop() - - # only add if we wound up with something - assert menu_items - if menu_items: - group = { - 'type': 'menu', - 'key': topitem.get('key'), - 'title': topitem['title'], - 'items': menu_items, - 'is_menu': True, - 'is_link': False, - } - - # topitem w/ no key likely means it did not come - # from config but rather explicit definition in - # code. so we are free to "invent" a (safe) key - # for it, since that is only for editing config - if not group['key']: - group['key'] = make_menu_key(request.rattail_config, - topitem['title']) - - final_menus.append(group) - - return final_menus - - -def make_menu_key(config, value): - """ - Generate a normalized menu key for the given value. - """ - return re.sub(r'\W', '', value.lower()) - - -def make_menu_entry(request, item): - """ - Convert a simple menu entry dict, into a proper menu-related object, for - use in constructing final menu. - """ - # separator - if item.get('type') == 'sep': - return { - 'type': 'sep', - 'is_menu': False, - 'is_sep': True, - } - - # standard menu item - entry = { - 'type': 'item', - 'title': item['title'], - 'perm': item.get('perm'), - 'target': item.get('target'), - 'is_link': True, - 'is_menu': False, - 'is_sep': False, - } - if item.get('route'): - entry['route'] = item['route'] - try: - entry['url'] = request.route_url(entry['route']) - except KeyError: # happens if no such route - log.warning("invalid route name for menu entry: %s", entry) - entry['url'] = entry['route'] - entry['key'] = entry['route'] - else: - if item.get('url'): - entry['url'] = item['url'] - entry['key'] = make_menu_key(request.rattail_config, entry['title']) - return entry - - -def is_allowed(request, item): - """ - Logic to determine if a given menu item is "allowed" for current user. - """ - perm = item.get('perm') - if perm: - return request.has_perm(perm) - return True - - -def mark_allowed(request, menus): - """ - Traverse the menu set, and mark each item as "allowed" (or not) based on - current user permissions. - """ - for topitem in menus: - - if topitem.get('type', 'menu') == 'menu': - topitem['allowed'] = False - - for item in topitem['items']: - - if item.get('type') == 'menu': - for subitem in item['items']: - subitem['allowed'] = is_allowed(request, subitem) - - item['allowed'] = False - for subitem in item['items']: - if subitem['allowed'] and subitem.get('type') != 'sep': - item['allowed'] = True - break - - else: - item['allowed'] = is_allowed(request, item) - - for item in topitem['items']: - if item['allowed'] and item.get('type') != 'sep': - topitem['allowed'] = True - break - - -def make_admin_menu(request, **kwargs): - """ - Generate a typical Admin menu - """ - warnings.warn("make_admin_menu() function is deprecated; please use " - "MenuHandler.make_admin_menu() instead", - DeprecationWarning, stacklevel=2) - - app = request.rattail_config.get_app() - tailbone_handler = app.get_tailbone_handler() - menu_handler = tailbone_handler.get_menu_handler() - return menu_handler.make_admin_menu(request, **kwargs) + def __init__(self, *args, **kwargs): + warnings.warn("tailbone.menus.MenuHandler is deprecated; " + "please use tailbone.menus.TailboneMenuHandler instead", + DeprecationWarning, stacklevel=2) + super().__init__(*args, **kwargs) diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 0bf218cb..12e1e32a 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -42,7 +42,6 @@ import tailbone from tailbone import helpers from tailbone.db import Session from tailbone.config import csrf_header_name, should_expose_websockets -from tailbone.menus import make_simple_menus from tailbone.util import get_available_themes, get_global_search_options @@ -180,7 +179,7 @@ def before_render(event): renderer_globals = event - # wuttaweb overrides + # overrides renderer_globals['h'] = helpers # misc. @@ -215,13 +214,6 @@ def before_render(event): options = [tags.Option(theme, value=theme) for theme in available] renderer_globals['theme_picker_options'] = options - # heck while we're assuming the classic web app here... - # (we don't want this to happen for the API either!) - # TODO: just..awful *shrug* - # note that we assume "simple" menus nowadays - if config.get_bool('tailbone.menus.simple', default=True): - renderer_globals['menus'] = make_simple_menus(request) - # TODO: ugh, same deal here renderer_globals['messaging_enabled'] = config.get_bool('tailbone.messaging.enabled', default=False) diff --git a/tailbone/views/menus.py b/tailbone/views/menus.py index f60ad274..b606e4e7 100644 --- a/tailbone/views/menus.py +++ b/tailbone/views/menus.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -30,7 +30,6 @@ import sqlalchemy as sa from tailbone.views import View from tailbone.db import Session -from tailbone.menus import make_menu_key class MenuConfigView(View): @@ -79,12 +78,16 @@ class MenuConfigView(View): return context def configure_gather_settings(self, data): + app = self.get_rattail_app() + web = app.get_web_handler() + menus = web.get_menu_handler() + settings = [{'name': 'tailbone.menu.from_settings', 'value': 'true'}] main_keys = [] for topitem in json.loads(data['menus']): - key = make_menu_key(self.rattail_config, topitem['title']) + key = menus._make_menu_key(self.rattail_config, topitem['title']) main_keys.append(key) settings.extend([ @@ -99,7 +102,7 @@ class MenuConfigView(View): if item.get('route'): item_key = item['route'] else: - item_key = make_menu_key(self.rattail_config, item['title']) + item_key = menus._make_menu_key(self.rattail_config, item['title']) item_keys.append(item_key) settings.extend([ From d70bac74f099e7f53bc9fe98d3d63e995aeed909 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 14 Jul 2024 11:11:44 -0500 Subject: [PATCH 1539/1681] =?UTF-8?q?bump:=20version=200.13.2=20=E2=86=92?= =?UTF-8?q?=200.14.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40604948..4c5304d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.14.0 (2024-07-14) + +### Feat + +- move core menu logic to wuttaweb + ## v0.13.2 (2024-07-13) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 12f6a538..de65655a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.13.2" +version = "0.14.0" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] @@ -59,7 +59,7 @@ dependencies = [ "transaction", "waitress", "WebHelpers2", - "WuttaWeb", + "WuttaWeb>=0.2.0", "zope.sqlalchemy>=1.5", ] From 25e62fe6ef06ae2c9366e6f0c9c4445771e5bb16 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 14 Jul 2024 11:47:15 -0500 Subject: [PATCH 1540/1681] fix: fix bug when making "integration" menus per recent refactor --- tailbone/menus.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tailbone/menus.py b/tailbone/menus.py index 0752c22d..9048ae43 100644 --- a/tailbone/menus.py +++ b/tailbone/menus.py @@ -281,8 +281,9 @@ class TailboneMenuHandler(WuttaMenuHandler): """ Make a set of menus for all registered system integrations. """ + tb = self.app.get_tailbone_handler() menus = [] - for provider in self.tb.iter_providers(): + for provider in tb.iter_providers(): menu = provider.make_integration_menu(request) if menu: menus.append(menu) From 5e1c0a5187ab5ab33a63f38cbb0c9da4a7a1f786 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 14 Jul 2024 12:41:08 -0500 Subject: [PATCH 1541/1681] fix: fix model reference in menu handler --- tailbone/menus.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/menus.py b/tailbone/menus.py index 9048ae43..84c12343 100644 --- a/tailbone/menus.py +++ b/tailbone/menus.py @@ -96,7 +96,7 @@ class TailboneMenuHandler(WuttaMenuHandler): if not main_keys: return - model = self.model + model = self.app.model menus = [] # menu definition can come either from config file or db From ece29d7b6cfeb193e0fe7ee66a238f6dedba1144 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 14 Jul 2024 23:29:17 -0500 Subject: [PATCH 1542/1681] fix: update usage of auth handler, per rattail changes --- pyproject.toml | 2 +- tailbone/api/core.py | 4 ++-- tailbone/auth.py | 14 +++++++++----- tailbone/subscribers.py | 9 +++++++-- 4 files changed, 19 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index de65655a..22fa5676 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ dependencies = [ "pyramid_mako", "pyramid_retry", "pyramid_tm", - "rattail[db,bouncer]>=0.16.0", + "rattail[db,bouncer]>=0.17.0", "sa-filters", "simplejson", "transaction", diff --git a/tailbone/api/core.py b/tailbone/api/core.py index b278d4af..0d8eec32 100644 --- a/tailbone/api/core.py +++ b/tailbone/api/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -102,7 +102,7 @@ class APIView(View): auth = app.get_auth_handler() # basic / default info - is_admin = user.is_admin() + is_admin = auth.user_is_admin(user) employee = app.get_employee(user) info = { 'uuid': user.uuid, diff --git a/tailbone/auth.py b/tailbone/auth.py index 5a35caa6..826c5d40 100644 --- a/tailbone/auth.py +++ b/tailbone/auth.py @@ -45,11 +45,12 @@ def login_user(request, user, timeout=NOTSET): Perform the steps necessary to login the given user. Note that this returns a ``headers`` dict which you should pass to the redirect. """ - app = request.rattail_config.get_app() + config = request.rattail_config + app = config.get_app() user.record_event(app.enum.USER_EVENT_LOGIN) headers = remember(request, user.uuid) if timeout is NOTSET: - timeout = session_timeout_for_user(user) + timeout = session_timeout_for_user(config, user) log.debug("setting session timeout for '{}' to {}".format(user.username, timeout)) set_session_timeout(request, timeout) return headers @@ -70,15 +71,18 @@ def logout_user(request): return headers -def session_timeout_for_user(user): +def session_timeout_for_user(config, user): """ Returns the "max" session timeout for the user, according to roles """ - from rattail.db.auth import authenticated_role + app = config.get_app() + auth = app.get_auth_handler() - roles = user.roles + [authenticated_role(Session())] + authenticated = auth.get_role_authenticated(Session()) + roles = user.roles + [authenticated] timeouts = [role.session_timeout for role in roles if role.session_timeout is not None] + if timeouts and 0 not in timeouts: return max(timeouts) diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 12e1e32a..181c84bc 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -98,10 +98,15 @@ def new_request(event): request = event.request # invoke upstream logic + # nb. this sets request.wutta_config base.new_request(event) + config = request.wutta_config + app = config.get_app() + auth = app.get_auth_handler() + # compatibility - rattail_config = request.wutta_config + rattail_config = config request.rattail_config = rattail_config def user(request): @@ -120,7 +125,7 @@ def new_request(event): # assign client IP address to the session, for sake of versioning Session().continuum_remote_addr = request.client_addr - request.is_admin = bool(request.user) and request.user.is_admin() + request.is_admin = auth.user_is_admin(request.user) request.is_root = request.is_admin and request.session.get('is_root', False) # TODO: why would this ever be null? From 57fdacdb834dabab7bd61d1d492bc2c2d41d42dd Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 14 Jul 2024 23:29:35 -0500 Subject: [PATCH 1543/1681] =?UTF-8?q?bump:=20version=200.14.0=20=E2=86=92?= =?UTF-8?q?=200.14.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 ++++++++ pyproject.toml | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c5304d6..df38a20f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.14.1 (2024-07-14) + +### Fix + +- update usage of auth handler, per rattail changes +- fix model reference in menu handler +- fix bug when making "integration" menus + ## v0.14.0 (2024-07-14) ### Feat diff --git a/pyproject.toml b/pyproject.toml index 22fa5676..d7fa1c95 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.14.0" +version = "0.14.1" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From be6eb5f8153e373772278f2786aa72b0c15f8daf Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 15 Jul 2024 21:51:45 -0500 Subject: [PATCH 1544/1681] fix: add null menu handler, for use with API apps --- tailbone/menus.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tailbone/menus.py b/tailbone/menus.py index 84c12343..abd0b58b 100644 --- a/tailbone/menus.py +++ b/tailbone/menus.py @@ -745,3 +745,18 @@ class MenuHandler(TailboneMenuHandler): "please use tailbone.menus.TailboneMenuHandler instead", DeprecationWarning, stacklevel=2) super().__init__(*args, **kwargs) + + +class NullMenuHandler(WuttaMenuHandler): + """ + Null menu handler which uses an empty menu set. + + .. note: + + This class shouldn't even exist, but for the moment, it is + useful to configure non-traditional (e.g. API) web apps to use + this, in order to avoid most of the overhead. + """ + + def make_menus(self, request, **kwargs): + return [] From af0f84762c5dfaecc0c29cf7431d84aa7231f666 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 15 Jul 2024 21:52:05 -0500 Subject: [PATCH 1545/1681] =?UTF-8?q?bump:=20version=200.14.1=20=E2=86=92?= =?UTF-8?q?=200.14.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df38a20f..c27cc130 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.14.2 (2024-07-15) + +### Fix + +- add null menu handler, for use with API apps + ## v0.14.1 (2024-07-14) ### Fix diff --git a/pyproject.toml b/pyproject.toml index d7fa1c95..c19bb3e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.14.1" +version = "0.14.2" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From 3aafe578f03893e0f03fd8e6ff5d57408a0daa38 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 16 Jul 2024 18:59:35 -0500 Subject: [PATCH 1546/1681] fix: allow auto-collapse of header when viewing trainwreck txn --- tailbone/templates/form.mako | 60 +++++++++++++++++-- .../trainwreck/transactions/configure.mako | 13 ++++ tailbone/views/trainwreck/base.py | 12 ++++ 3 files changed, 81 insertions(+), 4 deletions(-) diff --git a/tailbone/templates/form.mako b/tailbone/templates/form.mako index 0352b04c..9ce7039a 100644 --- a/tailbone/templates/form.mako +++ b/tailbone/templates/form.mako @@ -16,10 +16,53 @@ </%def> <%def name="page_content()"> - <div class="form-wrapper"> - <br /> - ${self.render_form()} - </div> + % if main_form_collapsible: + <${b}-collapse class="panel" + % if request.use_oruga: + v-model:open="mainFormPanelOpen" + % else: + :open.sync="mainFormPanelOpen" + % endif + > + <template #trigger="props"> + <div class="panel-heading" + role="button" + style="cursor: pointer;"> + + ## TODO: for some reason buefy will "reuse" the icon + ## element in such a way that its display does not + ## refresh. so to work around that, we use different + ## structure for the two icons, so buefy is forced to + ## re-draw + + <b-icon v-if="props.open" + pack="fas" + icon="caret-down"> + </b-icon> + + <span v-if="!props.open"> + <b-icon pack="fas" + icon="caret-right"> + </b-icon> + </span> + + + <strong>Transaction Header</strong> + </div> + </template> + <div class="panel-block"> + <div class="form-wrapper"> + <br /> + ${self.render_form()} + </div> + </div> + </${b}-collapse> + % else: + <div class="form-wrapper"> + <br /> + ${self.render_form()} + </div> + % endif </%def> <%def name="render_this_page()"> @@ -54,6 +97,15 @@ ${parent.render_this_page_template()} </%def> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + % if main_form_collapsible: + <script> + ThisPageData.mainFormPanelOpen = ${'false' if main_form_autocollapse else 'true'} + </script> + % endif +</%def> + <%def name="finalize_this_page_vars()"> ${parent.finalize_this_page_vars()} % if form is not Undefined: diff --git a/tailbone/templates/trainwreck/transactions/configure.mako b/tailbone/templates/trainwreck/transactions/configure.mako index 99b43fde..4569759b 100644 --- a/tailbone/templates/trainwreck/transactions/configure.mako +++ b/tailbone/templates/trainwreck/transactions/configure.mako @@ -3,6 +3,19 @@ <%def name="form_content()"> + <h3 class="block is-size-3">Display</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field> + <b-checkbox name="tailbone.trainwreck.view_txn.autocollapse_header" + v-model="simpleSettings['tailbone.trainwreck.view_txn.autocollapse_header']" + native-value="true" + @input="settingsNeedSaved = true"> + Auto-collapse header when viewing transaction + </b-checkbox> + </b-field> + </div> + <h3 class="block is-size-3">Rotation</h3> <div class="block" style="padding-left: 2rem;"> diff --git a/tailbone/views/trainwreck/base.py b/tailbone/views/trainwreck/base.py index 9a6086d7..f529eb66 100644 --- a/tailbone/views/trainwreck/base.py +++ b/tailbone/views/trainwreck/base.py @@ -256,6 +256,7 @@ class TransactionView(MasterView): def template_kwargs_view(self, **kwargs): kwargs = super().template_kwargs_view(**kwargs) + config = self.rattail_config form = kwargs['form'] if 'custorder_xref_markers' in form: @@ -268,6 +269,12 @@ class TransactionView(MasterView): }) kwargs['custorder_xref_markers_data'] = markers + # collapse header + kwargs['main_form_collapsible'] = True + kwargs['main_form_autocollapse'] = config.get_bool( + 'tailbone.trainwreck.view_txn.autocollapse_header', + default=False) + return kwargs def get_xref_buttons(self, txn): @@ -419,6 +426,11 @@ class TransactionView(MasterView): def configure_get_simple_settings(self): return [ + # display + {'section': 'tailbone', + 'option': 'trainwreck.view_txn.autocollapse_header', + 'type': bool}, + # rotation {'section': 'trainwreck', 'option': 'use_rotation', From e88b8fc9bc25ff8b7632756f193b00ead8246ae4 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 16 Jul 2024 21:21:43 -0500 Subject: [PATCH 1547/1681] fix: fix auto-collapse title for viewing trainwreck txn --- tailbone/templates/form.mako | 2 +- tailbone/views/trainwreck/base.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/tailbone/templates/form.mako b/tailbone/templates/form.mako index 9ce7039a..c9c8ea88 100644 --- a/tailbone/templates/form.mako +++ b/tailbone/templates/form.mako @@ -47,7 +47,7 @@ </span> - <strong>Transaction Header</strong> + <strong>${main_form_title}</strong> </div> </template> <div class="panel-block"> diff --git a/tailbone/views/trainwreck/base.py b/tailbone/views/trainwreck/base.py index f529eb66..9c150c6a 100644 --- a/tailbone/views/trainwreck/base.py +++ b/tailbone/views/trainwreck/base.py @@ -270,6 +270,7 @@ class TransactionView(MasterView): kwargs['custorder_xref_markers_data'] = markers # collapse header + kwargs['main_form_title'] = "Transaction Header" kwargs['main_form_collapsible'] = True kwargs['main_form_autocollapse'] = config.get_bool( 'tailbone.trainwreck.view_txn.autocollapse_header', From 9c466796dae12c11e50cc6be04c5a467e478d255 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 17 Jul 2024 18:24:21 -0500 Subject: [PATCH 1548/1681] =?UTF-8?q?bump:=20version=200.14.2=20=E2=86=92?= =?UTF-8?q?=200.14.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c27cc130..70d9b6ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.14.3 (2024-07-17) + +### Fix + +- fix auto-collapse title for viewing trainwreck txn +- allow auto-collapse of header when viewing trainwreck txn + ## v0.14.2 (2024-07-15) ### Fix diff --git a/pyproject.toml b/pyproject.toml index c19bb3e2..e785fb0c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.14.2" +version = "0.14.3" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From f4f79f170a5fefec8cbced39fab3f0eb6dff2873 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 17 Jul 2024 19:45:47 -0500 Subject: [PATCH 1549/1681] fix: fix modals for luigi tasks page, per oruga --- tailbone/templates/luigi/index.mako | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/tailbone/templates/luigi/index.mako b/tailbone/templates/luigi/index.mako index bb8d1465..b5134c25 100644 --- a/tailbone/templates/luigi/index.mako +++ b/tailbone/templates/luigi/index.mako @@ -79,8 +79,13 @@ @click="overnightTaskLaunchInit(props.row)"> Launch </b-button> - <b-modal has-modal-card - :active.sync="overnightTaskShowLaunchDialog"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="overnightTaskShowLaunchDialog" + % else: + :active.sync="overnightTaskShowLaunchDialog" + % endif + > <div class="modal-card"> <header class="modal-card-head"> @@ -127,7 +132,7 @@ </b-button> </footer> </div> - </b-modal> + </${b}-modal> </${b}-table-column> <template #empty> <p class="block">No tasks defined.</p> @@ -182,8 +187,13 @@ </template> </${b}-table> - <b-modal has-modal-card - :active.sync="backfillTaskShowLaunchDialog"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="backfillTaskShowLaunchDialog" + % else: + :active.sync="backfillTaskShowLaunchDialog" + % endif + > <div class="modal-card"> <header class="modal-card-head"> @@ -238,7 +248,7 @@ </b-button> </footer> </div> - </b-modal> + </${b}-modal> % endif From 1bba6d994744585244f91c008fd93ec4ca2a9bc9 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 18 Jul 2024 17:58:59 -0500 Subject: [PATCH 1550/1681] fix: fix more settings persistence bug(s) for datasync/configure esp. for the profile consumers info --- tailbone/templates/datasync/configure.mako | 27 +++++++++++----------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/tailbone/templates/datasync/configure.mako b/tailbone/templates/datasync/configure.mako index 0889b144..7922d189 100644 --- a/tailbone/templates/datasync/configure.mako +++ b/tailbone/templates/datasync/configure.mako @@ -734,16 +734,9 @@ this.editingProfilePendingConsumers = [] for (let consumer of row.consumers_data) { - let pending = { + const pending = { + ...consumer, original_key: consumer.key, - key: consumer.key, - consumer_spec: consumer.consumer_spec, - consumer_dbkey: consumer.consumer_dbkey, - consumer_delay: consumer.consumer_delay, - consumer_retry_attempts: consumer.consumer_retry_attempts, - consumer_retry_delay: consumer.consumer_retry_delay, - consumer_runas: consumer.consumer_runas, - enabled: consumer.enabled, } this.editingProfilePendingConsumers.push(pending) } @@ -791,8 +784,8 @@ this.editingProfilePendingWatcherKwargs.splice(i, 1) } - ThisPage.methods.findOriginalConsumer = function(key) { - for (let consumer of this.editingProfile.consumers_data) { + ThisPage.methods.findConsumer = function(profileConsumers, key) { + for (const consumer of profileConsumers) { if (consumer.key == key) { return consumer } @@ -803,9 +796,12 @@ const row = this.editingProfile const newRow = !row.key + let originalProfile = null if (newRow) { row.consumers_data = [] this.profilesData.push(row) + } else { + originalProfile = this.findProfile(row) } row.key = this.editingProfileKey @@ -853,7 +849,8 @@ for (let pending of this.editingProfilePendingConsumers) { persistentConsumers.push(pending.key) if (pending.original_key) { - let consumer = this.findOriginalConsumer(pending.original_key) + const consumer = this.findConsumer(originalProfile.consumers_data, + pending.original_key) consumer.key = pending.key consumer.consumer_spec = pending.consumer_spec consumer.consumer_dbkey = pending.consumer_dbkey @@ -941,8 +938,10 @@ } ThisPage.methods.updateConsumer = function() { - let pending = this.editingConsumer - let isNew = !pending.key + const pending = this.findConsumer( + this.editingProfilePendingConsumers, + this.editingConsumer.key) + const isNew = !pending.key pending.key = this.editingConsumerKey pending.consumer_spec = this.editingConsumerSpec From a9495b6a7059deb256059615eb2aabd3e2308790 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 18 Jul 2024 17:59:55 -0500 Subject: [PATCH 1551/1681] =?UTF-8?q?bump:=20version=200.14.3=20=E2=86=92?= =?UTF-8?q?=200.14.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70d9b6ec..44157ba6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.14.4 (2024-07-18) + +### Fix + +- fix more settings persistence bug(s) for datasync/configure +- fix modals for luigi tasks page, per oruga + ## v0.14.3 (2024-07-17) ### Fix diff --git a/pyproject.toml b/pyproject.toml index e785fb0c..5cc0470b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.14.3" +version = "0.14.4" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From 08a89c490a5ffa07599ec3bee928d07170ca4d78 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 21 Jul 2024 20:20:43 -0500 Subject: [PATCH 1552/1681] fix: avoid duplicate `partial` param when grid reloads data --- tailbone/templates/grids/complete.mako | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako index e200cdc3..a0f927d3 100644 --- a/tailbone/templates/grids/complete.mako +++ b/tailbone/templates/grids/complete.mako @@ -480,7 +480,9 @@ } else { params = new URLSearchParams(params) } - params.append('partial', true) + if (!params.has('partial')) { + params.append('partial', true) + } params = params.toString() this.loading = true From 458c95696a1faab6ea1f567ca38c6b00046f98f4 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 3 Aug 2024 14:13:16 -0500 Subject: [PATCH 1553/1681] fix: use auth handler instead of deprecated auth functions --- tailbone/views/users.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/tailbone/views/users.py b/tailbone/views/users.py index dd3f7f7b..b641e578 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -28,8 +28,6 @@ import sqlalchemy as sa from sqlalchemy import orm from rattail.db.model import User, UserEvent -from rattail.db.auth import (administrator_role, guest_role, - authenticated_role, set_user_password) import colander from deform import widget as dfwidget @@ -360,17 +358,19 @@ class UserView(PrincipalMasterView): return tokens def get_possible_roles(self): - model = self.model + app = self.get_rattail_app() + auth = app.get_auth_handler() + model = app.model # some roles should never have users "belong" to them excluded = [ - guest_role(self.Session()).uuid, - authenticated_role(self.Session()).uuid, + auth.get_role_anonymous(self.Session()).uuid, + auth.get_role_authenticated(self.Session()).uuid, ] # only allow "root" user to change true admin role membership if not self.request.is_root: - excluded.append(administrator_role(self.Session()).uuid) + excluded.append(auth.get_role_administrator(self.Session()).uuid) # basic list, minus exclusions so far roles = self.Session.query(model.Role)\ @@ -385,7 +385,9 @@ class UserView(PrincipalMasterView): return roles.order_by(model.Role.name) def objectify(self, form, data=None): - model = self.model + app = self.get_rattail_app() + auth = app.get_auth_handler() + model = app.model # create/update user as per normal if data is None: @@ -420,7 +422,7 @@ class UserView(PrincipalMasterView): # maybe set user password if 'set_password' in form and data['set_password']: - set_user_password(user, data['set_password']) + auth.set_user_password(user, data['set_password']) # update roles for user self.update_roles(user, data) @@ -433,10 +435,12 @@ class UserView(PrincipalMasterView): if 'roles' not in data: return - model = self.model + app = self.get_rattail_app() + auth = app.get_auth_handler() + model = app.model old_roles = set([r.uuid for r in user.roles]) new_roles = data['roles'] - admin = administrator_role(self.Session()) + admin = auth.get_role_administrator(self.Session()) # add any new roles for the user, taking care not to add the admin role # unless acting as root From 5ec899cf084b67806ae6e21578c6c04071fa5f22 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 3 Aug 2024 17:43:46 -0500 Subject: [PATCH 1554/1681] =?UTF-8?q?bump:=20version=200.14.4=20=E2=86=92?= =?UTF-8?q?=200.14.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44157ba6..412e6e4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.14.5 (2024-08-03) + +### Fix + +- use auth handler instead of deprecated auth functions +- avoid duplicate `partial` param when grid reloads data + ## v0.14.4 (2024-07-18) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 5cc0470b..0783f2bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.14.4" +version = "0.14.5" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From 3b92bb3a9e365a761b48335d42cca4d6f86e01b8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 4 Aug 2024 14:56:12 -0500 Subject: [PATCH 1555/1681] fix: use wuttaweb logic for `util.get_form_data()` --- docs/api/util.rst | 6 ++++ docs/index.rst | 1 + tailbone/forms/core.py | 10 +++++-- tailbone/util.py | 18 ++++++------ tailbone/views/purchasing/receiving.py | 8 +++--- tests/test_util.py | 39 ++++++++++++++++++++++++++ 6 files changed, 65 insertions(+), 17 deletions(-) create mode 100644 docs/api/util.rst create mode 100644 tests/test_util.py diff --git a/docs/api/util.rst b/docs/api/util.rst new file mode 100644 index 00000000..35e66ed3 --- /dev/null +++ b/docs/api/util.rst @@ -0,0 +1,6 @@ + +``tailbone.util`` +================= + +.. automodule:: tailbone.util + :members: diff --git a/docs/index.rst b/docs/index.rst index 3ca6d4e2..d964086f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -52,6 +52,7 @@ Package API: api/grids.core api/progress api/subscribers + api/util api/views/batch api/views/batch.vendorcatalog api/views/core diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 11d489a7..60c2f61b 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -35,7 +35,7 @@ from sqlalchemy import orm from sqlalchemy.ext.associationproxy import AssociationProxy, ASSOCIATION_PROXY from wuttjamaican.util import UNSPECIFIED -from rattail.util import prettify, pretty_boolean +from rattail.util import pretty_boolean from rattail.db.util import get_fieldnames import colander @@ -47,8 +47,10 @@ from pyramid_deform import SessionFileUploadTempStore from pyramid.renderers import render from webhelpers2.html import tags, HTML +from wuttaweb.util import get_form_data + from tailbone.db import Session -from tailbone.util import raw_datetime, get_form_data, render_markdown +from tailbone.util import raw_datetime, render_markdown from tailbone.forms import types from tailbone.forms.widgets import (ReadonlyWidget, PlainDateWidget, JQueryDateWidget, JQueryTimeWidget, @@ -570,7 +572,9 @@ class Form(object): self.schema[key].title = label def get_label(self, key): - return self.labels.get(key, prettify(key)) + config = self.request.rattail_config + app = config.get_app() + return self.labels.get(key, app.make_title(key)) def set_readonly(self, key, readonly=True): if readonly: diff --git a/tailbone/util.py b/tailbone/util.py index c1a0e1d5..9a0314a0 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -39,6 +39,8 @@ from pyramid.renderers import get_renderer from pyramid.interfaces import IRoutesMapper from webhelpers2.html import HTML, tags +from wuttaweb.util import get_form_data as wutta_get_form_data + log = logging.getLogger(__name__) @@ -75,17 +77,13 @@ def csrf_token(request, name='_csrf'): def get_form_data(request): """ - Returns the effective form data for the given request. Mostly - this is a convenience, to return either POST or JSON depending on - the type of request. + DEPECATED - use :func:`wuttaweb:wuttaweb.util.get_form_data()` + instead. """ - # nb. we prefer JSON only if no POST is present - # TODO: this seems to work for our use case at least, but perhaps - # there is a better way? see also - # https://docs.pylonsproject.org/projects/pyramid/en/latest/api/request.html#pyramid.request.Request.is_xhr - if (request.is_xhr or request.content_type == 'application/json') and not request.POST: - return request.json_body - return request.POST + warnings.warn("tailbone.util.get_form_data() is deprecated; " + "please use wuttaweb.util.get_form_data() instead", + DeprecationWarning, stacklevel=2) + return wutta_get_form_data(request) def get_global_search_options(request): diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index be15c1a8..55936184 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -25,22 +25,22 @@ Views for 'receiving' (purchasing) batches """ import os -import re import decimal import logging from collections import OrderedDict -import humanize +# import humanize from rattail import pod -from rattail.util import prettify, simple_error +from rattail.util import simple_error import colander from deform import widget as dfwidget from webhelpers2.html import tags, HTML +from wuttaweb.util import get_form_data + from tailbone import forms, grids -from tailbone.util import get_form_data from tailbone.views.purchasing import PurchasingBatchView diff --git a/tests/test_util.py b/tests/test_util.py new file mode 100644 index 00000000..46684f0c --- /dev/null +++ b/tests/test_util.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8; -*- + +from unittest import TestCase + +from pyramid import testing + +from rattail.config import RattailConfig + +from tailbone import util + + +class TestGetFormData(TestCase): + + def setUp(self): + self.config = RattailConfig() + + def make_request(self, **kwargs): + kwargs.setdefault('wutta_config', self.config) + kwargs.setdefault('rattail_config', self.config) + kwargs.setdefault('is_xhr', None) + kwargs.setdefault('content_type', None) + kwargs.setdefault('POST', {'foo1': 'bar'}) + kwargs.setdefault('json_body', {'foo2': 'baz'}) + return testing.DummyRequest(**kwargs) + + def test_default(self): + request = self.make_request() + data = util.get_form_data(request) + self.assertEqual(data, {'foo1': 'bar'}) + + def test_is_xhr(self): + request = self.make_request(POST=None, is_xhr=True) + data = util.get_form_data(request) + self.assertEqual(data, {'foo2': 'baz'}) + + def test_content_type(self): + request = self.make_request(POST=None, content_type='application/json') + data = util.get_form_data(request) + self.assertEqual(data, {'foo2': 'baz'}) From 9d2684046ff4e5bf4b0c0da979e2cb604a915638 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 5 Aug 2024 15:00:11 -0500 Subject: [PATCH 1556/1681] feat: move more subscriber logic to wuttaweb --- tailbone/subscribers.py | 66 ++++++++++------------------------------- 1 file changed, 15 insertions(+), 51 deletions(-) diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 181c84bc..c783287b 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -52,30 +52,17 @@ def new_request(event): """ Event hook called when processing a new request. - This first invokes the upstream hook: - :func:`wuttaweb:wuttaweb.subscribers.new_request()` + This first invokes the upstream hooks: + + * :func:`wuttaweb:wuttaweb.subscribers.new_request()` + * :func:`wuttaweb:wuttaweb.subscribers.new_request_set_user()` It then adds more things to the request object; among them: .. attribute:: request.rattail_config Reference to the app :term:`config object`. Note that this - will be the same as ``request.wutta_config``. - - .. attribute:: request.user - - Reference to the current authenticated user, or ``None``. - - .. attribute:: request.is_admin - - Flag indicating whether current user is a member of the - Administrator role. - - .. attribute:: request.is_root - - Flag indicating whether user is currently elevated to root - privileges. This is only possible if ``request.is_admin = - True``. + will be the same as :attr:`wuttaweb:request.wutta_config`. .. method:: request.has_perm(name) @@ -94,10 +81,9 @@ def new_request(event): then in the base template all registered components will be properly loaded. """ - # log.debug("new request: %s", event) request = event.request - # invoke upstream logic + # invoke main upstream logic # nb. this sets request.wutta_config base.new_request(event) @@ -109,25 +95,20 @@ def new_request(event): rattail_config = config request.rattail_config = rattail_config - def user(request): - user = None - uuid = request.authenticated_userid - if uuid: - app = request.rattail_config.get_app() - model = app.model - user = Session.get(model.User, uuid) - if user: - Session().set_continuum_user(user) - return user + def user_getter(request, db_session=None): + user = base.default_user_getter(request, db_session=db_session) + if user: + # nb. we also assign continuum user to session + session = db_session or Session() + session.set_continuum_user(user) + return user - request.set_property(user, reify=True) + # invoke upstream hook to set user + base.new_request_set_user(event, user_getter=user_getter, db_session=Session()) # assign client IP address to the session, for sake of versioning Session().continuum_remote_addr = request.client_addr - request.is_admin = auth.user_is_admin(request.user) - request.is_root = request.is_admin and request.session.get('is_root', False) - # TODO: why would this ever be null? if rattail_config: @@ -286,27 +267,10 @@ def context_found(event): The following is attached to the request: - * ``get_referrer()`` function - * ``get_session_timeout()`` function """ request = event.request - def get_referrer(default=None, **kwargs): - if request.params.get('referrer'): - return request.params['referrer'] - if request.session.get('referrer'): - return request.session.pop('referrer') - referrer = request.referrer - if (not referrer or referrer == request.current_route_url() - or not referrer.startswith(request.host_url)): - if default: - referrer = default - else: - referrer = request.route_url('home') - return referrer - request.get_referrer = get_referrer - def get_session_timeout(): """ Returns the timeout in effect for the current session From 2903b376b5038a495feaef5a70c3a31a75466476 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 5 Aug 2024 15:35:06 -0500 Subject: [PATCH 1557/1681] =?UTF-8?q?bump:=20version=200.14.5=20=E2=86=92?= =?UTF-8?q?=200.15.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 10 ++++++++++ pyproject.toml | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 412e6e4a..6f1e1ac3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.15.0 (2024-08-05) + +### Feat + +- move more subscriber logic to wuttaweb + +### Fix + +- use wuttaweb logic for `util.get_form_data()` + ## v0.14.5 (2024-08-03) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 0783f2bc..1d05052d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.14.5" +version = "0.15.0" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From 91ea9021d7aaceb17a7cd56cd48ece52a71abb31 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 5 Aug 2024 21:50:22 -0500 Subject: [PATCH 1558/1681] fix: move magic `b` template context var to wuttaweb --- tailbone/subscribers.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index c783287b..02c4e518 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -186,7 +186,6 @@ def before_render(event): # theme - we only want do this for classic web app, *not* API # TODO: so, clearly we need a better way to distinguish the two if 'tailbone.theme' in request.registry.settings: - renderer_globals['b'] = 'o' if request.use_oruga else 'b' # for buefy renderer_globals['theme'] = request.registry.settings['tailbone.theme'] # note, this is just a global flag; user still needs permission to see picker expose_picker = config.get_bool('tailbone.themes.expose_picker', From bd1993f44029d4c0546a5d5224ef06680ce74ca6 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 5 Aug 2024 22:57:02 -0500 Subject: [PATCH 1559/1681] =?UTF-8?q?bump:=20version=200.15.0=20=E2=86=92?= =?UTF-8?q?=200.15.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f1e1ac3..6a02e734 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.15.1 (2024-08-05) + +### Fix + +- move magic `b` template context var to wuttaweb + ## v0.15.0 (2024-08-05) ### Feat diff --git a/pyproject.toml b/pyproject.toml index 1d05052d..9e68e401 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.15.0" +version = "0.15.1" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From 518c108c883a3bcceb431c10394d3176922f4658 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 6 Aug 2024 10:36:20 -0500 Subject: [PATCH 1560/1681] fix: use auth handler, avoid legacy calls for role/perm checks --- tailbone/views/principal.py | 2 +- tailbone/views/roles.py | 57 +++++++++++++++++++++++-------------- tailbone/views/users.py | 2 +- 3 files changed, 37 insertions(+), 24 deletions(-) diff --git a/tailbone/views/principal.py b/tailbone/views/principal.py index fb09306b..b053453d 100644 --- a/tailbone/views/principal.py +++ b/tailbone/views/principal.py @@ -194,7 +194,7 @@ class PermissionsRenderer(Object): rendered = False for key in sorted(perms, key=lambda p: perms[p]['label'].lower()): checked = auth.has_permission(Session(), principal, key, - include_guest=self.include_guest, + include_anonymous=self.include_guest, include_authenticated=self.include_authenticated) if checked: label = perms[key]['label'] diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py index 0316ea87..09633c6e 100644 --- a/tailbone/views/roles.py +++ b/tailbone/views/roles.py @@ -30,7 +30,6 @@ from sqlalchemy import orm from openpyxl.styles import Font, PatternFill from rattail.db.model import Role -from rattail.db.auth import administrator_role, guest_role, authenticated_role from rattail.excel import ExcelWriter import colander @@ -107,8 +106,11 @@ class RoleView(PrincipalMasterView): if role.node_type and role.node_type != self.rattail_config.node_type(): return False + app = self.get_rattail_app() + auth = app.get_auth_handler() + # only "root" can edit Administrator - if role is administrator_role(self.Session()): + if role is auth.get_role_administrator(self.Session()): return self.request.is_root # only "admin" can edit "admin-ish" roles @@ -116,11 +118,11 @@ class RoleView(PrincipalMasterView): return self.request.is_admin # can edit Authenticated only if user has permission - if role is authenticated_role(self.Session()): + if role is auth.get_role_authenticated(self.Session()): return self.has_perm('edit_authenticated') # can edit Guest only if user has permission - if role is guest_role(self.Session()): + if role is auth.get_role_anonymous(self.Session()): return self.has_perm('edit_guest') # current user can edit their own roles, only if they have permission @@ -139,11 +141,14 @@ class RoleView(PrincipalMasterView): if role.node_type and role.node_type != self.rattail_config.node_type(): return False - if role is administrator_role(self.Session()): + app = self.get_rattail_app() + auth = app.get_auth_handler() + + if role is auth.get_role_administrator(self.Session()): return False - if role is authenticated_role(self.Session()): + if role is auth.get_role_authenticated(self.Session()): return False - if role is guest_role(self.Session()): + if role is auth.get_role_anonymous(self.Session()): return False # only "admin" can delete "admin-ish" roles @@ -186,17 +191,17 @@ class RoleView(PrincipalMasterView): # session_timeout f.set_renderer('session_timeout', self.render_session_timeout) - if self.editing and role is guest_role(self.Session()): + if self.editing and role is auth.get_role_anonymous(self.Session()): f.set_readonly('session_timeout') # sync_me, node_type if not self.creating: include = True - if role is administrator_role(self.Session()): + if role is auth.get_role_administrator(self.Session()): include = False - elif role is authenticated_role(self.Session()): + elif role is auth.get_role_authenticated(self.Session()): include = False - elif role is guest_role(self.Session()): + elif role is auth.get_role_anonymous(self.Session()): include = False if not include: f.remove('sync_me', 'sync_users', 'node_type') @@ -227,7 +232,7 @@ class RoleView(PrincipalMasterView): for groupkey in self.tailbone_permissions: for key in self.tailbone_permissions[groupkey]['perms']: if auth.has_permission(self.Session(), role, key, - include_guest=False, + include_anonymous=False, include_authenticated=False): granted.append(key) f.set_default('permissions', granted) @@ -235,12 +240,14 @@ class RoleView(PrincipalMasterView): f.remove_field('permissions') def render_users(self, role, field): + app = self.get_rattail_app() + auth = app.get_auth_handler() - if role is guest_role(self.Session()): + if role is auth.get_role_anonymous(self.Session()): return ("The guest role is implied for all anonymous users, " "i.e. when not logged in.") - if role is authenticated_role(self.Session()): + if role is auth.get_role_authenticated(self.Session()): return ("The authenticated role is implied for all users, " "but only when logged in.") @@ -308,7 +315,9 @@ class RoleView(PrincipalMasterView): return available def render_session_timeout(self, role, field): - if role is guest_role(self.Session()): + app = self.get_rattail_app() + auth = app.get_auth_handler() + if role is auth.get_role_anonymous(self.Session()): return "(not applicable)" if role.session_timeout is None: return "" @@ -347,6 +356,8 @@ class RoleView(PrincipalMasterView): auth.revoke_permission(role, pkey) def template_kwargs_view(self, **kwargs): + app = self.get_rattail_app() + auth = app.get_auth_handler() model = self.model role = kwargs['instance'] if role.users: @@ -362,8 +373,8 @@ class RoleView(PrincipalMasterView): else: kwargs['users'] = None - kwargs['guest_role'] = guest_role(self.Session()) - kwargs['authenticated_role'] = authenticated_role(self.Session()) + kwargs['guest_role'] = auth.get_role_anonymous(self.Session()) + kwargs['authenticated_role'] = auth.get_role_authenticated(self.Session()) role = kwargs['instance'] if role not in (kwargs['guest_role'], kwargs['authenticated_role']): @@ -384,9 +395,11 @@ class RoleView(PrincipalMasterView): return kwargs def before_delete(self, role): - admin = administrator_role(self.Session()) - guest = guest_role(self.Session()) - authenticated = authenticated_role(self.Session()) + app = self.get_rattail_app() + auth = app.get_auth_handler() + admin = auth.get_role_administrator(self.Session()) + guest = auth.get_role_anonymous(self.Session()) + authenticated = auth.get_role_authenticated(self.Session()) if role in (admin, guest, authenticated): self.request.session.flash("You may not delete the {} role.".format(role.name), 'error') return self.redirect(self.request.get_referrer(default=self.request.route_url('roles'))) @@ -402,7 +415,7 @@ class RoleView(PrincipalMasterView): .options(orm.joinedload(model.Role._permissions)) roles = [] for role in all_roles: - if auth.has_permission(session, role, permission, include_guest=False): + if auth.has_permission(session, role, permission, include_anonymous=False): roles.append(role) return roles @@ -475,7 +488,7 @@ class RoleView(PrincipalMasterView): # and show an 'X' for any role which has this perm for col, role in enumerate(roles, 2): if auth.has_permission(self.Session(), role, key, - include_guest=False): + include_anonymous=False): sheet.cell(row=writing_row, column=col, value="X") writing_row += 1 diff --git a/tailbone/views/users.py b/tailbone/views/users.py index b641e578..1012575a 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -279,7 +279,7 @@ class UserView(PrincipalMasterView): permissions = self.request.registry.settings.get('tailbone_permissions', {}) f.set_renderer('permissions', PermissionsRenderer(request=self.request, permissions=permissions, - include_guest=True, + include_anonymous=True, include_authenticated=True)) else: f.remove('permissions') From 80dc4eb7a9a619ba1fa39372045f63f7894aeff1 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 6 Aug 2024 23:19:14 -0500 Subject: [PATCH 1561/1681] =?UTF-8?q?bump:=20version=200.15.1=20=E2=86=92?= =?UTF-8?q?=200.15.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a02e734..733d990b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.15.2 (2024-08-06) + +### Fix + +- use auth handler, avoid legacy calls for role/perm checks + ## v0.15.1 (2024-08-05) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 9e68e401..54f4df73 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.15.1" +version = "0.15.2" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From ffd694e7b72ae11faf09a086d7f36681f12094e7 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 8 Aug 2024 19:39:01 -0500 Subject: [PATCH 1562/1681] fix: fix timepicker `parseTime()` when value is null --- tailbone/templates/themes/butterball/field-components.mako | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tailbone/templates/themes/butterball/field-components.mako b/tailbone/templates/themes/butterball/field-components.mako index d79c88f4..917083c4 100644 --- a/tailbone/templates/themes/butterball/field-components.mako +++ b/tailbone/templates/themes/butterball/field-components.mako @@ -517,6 +517,9 @@ }, parseTime(value) { + if (!value) { + return value + } if (value.getHours) { return value From 0b8315fc7876ca0cc43547bb0df21e80559a33cb Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 8 Aug 2024 19:39:36 -0500 Subject: [PATCH 1563/1681] =?UTF-8?q?bump:=20version=200.15.2=20=E2=86=92?= =?UTF-8?q?=200.15.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 733d990b..7cce885b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.15.3 (2024-08-08) + +### Fix + +- fix timepicker `parseTime()` when value is null + ## v0.15.2 (2024-08-06) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 54f4df73..800e8ab0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.15.2" +version = "0.15.3" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From 7e683dfc4af7a5e9830a4fb6d70e153b917b0519 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 9 Aug 2024 10:11:38 -0500 Subject: [PATCH 1564/1681] fix: avoid bug when checking current theme this check is happening not only for classic views but API as well, which doesn't really have a theme.. probably need a proper fix in wuttaweb but this should be okay for now --- tailbone/util.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/util.py b/tailbone/util.py index 9a0314a0..eb6fb8a8 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -459,8 +459,8 @@ def should_use_oruga(request): supports (and therefore should use) Oruga + Vue 3 as opposed to the default of Buefy + Vue 2. """ - theme = request.registry.settings['tailbone.theme'] - if 'butterball' in theme: + theme = request.registry.settings.get('tailbone.theme') + if theme and 'butterball' in theme: return True return False From b5f0ecb165fd9d480577b561ca0cff49ba0dea96 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 9 Aug 2024 10:13:00 -0500 Subject: [PATCH 1565/1681] =?UTF-8?q?bump:=20version=200.15.3=20=E2=86=92?= =?UTF-8?q?=200.15.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cce885b..05648c25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.15.4 (2024-08-09) + +### Fix + +- avoid bug when checking current theme + ## v0.15.3 (2024-08-08) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 800e8ab0..4478aef5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.15.3" +version = "0.15.4" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From f2fce2e30526db7c85c69b0dfc6162c4d2f7e6b0 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 9 Aug 2024 19:22:26 -0500 Subject: [PATCH 1566/1681] fix: assign convenience attrs for all views (config, app, enum, model) --- tailbone/views/core.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tailbone/views/core.py b/tailbone/views/core.py index b0658d80..88b2519f 100644 --- a/tailbone/views/core.py +++ b/tailbone/views/core.py @@ -58,9 +58,10 @@ class View: config = self.rattail_config if config: - app = config.get_app() - self.model = app.model - self.enum = config.get_enum() + self.config = config + self.app = self.config.get_app() + self.model = self.app.model + self.enum = self.app.enum @property def rattail_config(self): From d57efba3811bc286fe49290f66a69df04b814633 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 9 Aug 2024 19:48:51 -0500 Subject: [PATCH 1567/1681] =?UTF-8?q?bump:=20version=200.15.4=20=E2=86=92?= =?UTF-8?q?=200.15.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05648c25..de92a834 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.15.5 (2024-08-09) + +### Fix + +- assign convenience attrs for all views (config, app, enum, model) + ## v0.15.4 (2024-08-09) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 4478aef5..c4335903 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.15.4" +version = "0.15.5" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From 2c46fde74288da664561277f9637a571a494dcaf Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 10 Aug 2024 08:43:54 -0500 Subject: [PATCH 1568/1681] fix: simplify verbiage for batch execution panel --- tailbone/templates/batch/view.mako | 2 -- 1 file changed, 2 deletions(-) diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako index 5e3328d9..63cb9056 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -85,13 +85,11 @@ <div style="display: flex; flex-direction: column; gap: 0.5rem;"> % if batch.executed: <p> - Batch was executed ${h.pretty_datetime(request.rattail_config, batch.executed)} by ${batch.executed_by} </p> % elif master.handler.executable(batch): % if master.has_perm('execute'): - <p>Batch has not yet been executed.</p> <b-button type="is-primary" % if not execute_enabled: disabled From 1f752530d2d757028aa52ac6518cb3d7b0462aed Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 10 Aug 2024 13:49:41 -0500 Subject: [PATCH 1569/1681] fix: avoid `before_render` subscriber hook for web API the purpose of that function is to setup extra template context, but API views always render as 'json' with no template --- tailbone/webapi.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tailbone/webapi.py b/tailbone/webapi.py index 1c2fa106..7c0e9b41 100644 --- a/tailbone/webapi.py +++ b/tailbone/webapi.py @@ -91,15 +91,21 @@ def make_pyramid_config(settings): return pyramid_config -def main(global_config, **settings): +def main(global_config, views='tailbone.api', **settings): """ This function returns a Pyramid WSGI application. """ rattail_config = make_rattail_config(settings) pyramid_config = make_pyramid_config(settings) - # bring in some Tailbone - pyramid_config.include('tailbone.subscribers') - pyramid_config.include('tailbone.api') + # event hooks + pyramid_config.add_subscriber('tailbone.subscribers.new_request', + 'pyramid.events.NewRequest') + # TODO: is this really needed? + pyramid_config.add_subscriber('tailbone.subscribers.context_found', + 'pyramid.events.ContextFound') + + # views + pyramid_config.include(views) return pyramid_config.make_wsgi_app() From b53479f8e46db1e622a773c8367f719d289185f8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 13 Aug 2024 11:21:38 -0500 Subject: [PATCH 1570/1681] =?UTF-8?q?bump:=20version=200.15.5=20=E2=86=92?= =?UTF-8?q?=200.15.6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 +++++++ pyproject.toml | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de92a834..3836ff08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.15.6 (2024-08-13) + +### Fix + +- avoid `before_render` subscriber hook for web API +- simplify verbiage for batch execution panel + ## v0.15.5 (2024-08-09) ### Fix diff --git a/pyproject.toml b/pyproject.toml index c4335903..e515a0d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.15.5" +version = "0.15.6" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] @@ -53,7 +53,7 @@ dependencies = [ "pyramid_mako", "pyramid_retry", "pyramid_tm", - "rattail[db,bouncer]>=0.17.0", + "rattail[db,bouncer]>=0.17.11", "sa-filters", "simplejson", "transaction", From a6ce5eb21d7ba61f187ac1093abc08b4d9ccdb01 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 15 Aug 2024 14:34:20 -0500 Subject: [PATCH 1571/1681] feat: refactor forms/grids/views/templates per wuttaweb compat this starts to get things more aligned between wuttaweb and tailbone. the use case in mind so far is for a wuttaweb view to be included in a tailbone app. form and grid classes now have some new methods to match wuttaweb, so templates call the shared method names where possible. templates can no longer assume they have tailbone-native master view, form, grid etc. so must inspect context more closely in some cases. --- tailbone/app.py | 13 +- tailbone/auth.py | 29 +--- tailbone/config.py | 5 +- tailbone/forms/core.py | 112 +++++++++++-- tailbone/grids/core.py | 88 +++++++++- tailbone/subscribers.py | 71 +++----- tailbone/templates/base.mako | 8 +- tailbone/templates/form.mako | 8 +- tailbone/templates/forms/deform.mako | 41 +++-- tailbone/templates/forms/vue_template.mako | 3 + tailbone/templates/grids/complete.mako | 94 +++++------ tailbone/templates/grids/vue_template.mako | 3 + tailbone/templates/master/create.mako | 2 +- tailbone/templates/master/delete.mako | 10 +- tailbone/templates/master/form.mako | 6 +- tailbone/templates/master/index.mako | 128 +++++++-------- tailbone/templates/master/view.mako | 10 +- tailbone/templates/people/index.mako | 4 +- tailbone/templates/people/view.mako | 4 +- .../templates/principal/find_by_perm.mako | 4 +- .../templates/themes/butterball/base.mako | 28 ++-- tailbone/views/master.py | 4 +- tailbone/views/principal.py | 2 +- tailbone/views/roles.py | 4 +- tailbone/views/users.py | 2 +- tests/__init__.py | 3 - tests/forms/__init__.py | 0 tests/forms/test_core.py | 153 ++++++++++++++++++ tests/grids/__init__.py | 0 tests/grids/test_core.py | 139 ++++++++++++++++ tests/test_app.py | 43 +++-- tests/test_auth.py | 3 + tests/test_config.py | 12 ++ tests/test_subscribers.py | 58 +++++++ tests/util.py | 75 +++++++++ tests/views/test_master.py | 26 +++ tests/views/test_principal.py | 29 ++++ tests/views/test_roles.py | 80 +++++++++ tests/views/test_users.py | 33 ++++ 39 files changed, 1037 insertions(+), 300 deletions(-) create mode 100644 tailbone/templates/forms/vue_template.mako create mode 100644 tailbone/templates/grids/vue_template.mako create mode 100644 tests/forms/__init__.py create mode 100644 tests/forms/test_core.py create mode 100644 tests/grids/__init__.py create mode 100644 tests/grids/test_core.py create mode 100644 tests/test_auth.py create mode 100644 tests/test_config.py create mode 100644 tests/test_subscribers.py create mode 100644 tests/util.py create mode 100644 tests/views/test_master.py create mode 100644 tests/views/test_principal.py create mode 100644 tests/views/test_roles.py create mode 100644 tests/views/test_users.py diff --git a/tailbone/app.py b/tailbone/app.py index b7220703..5e8e49d9 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -189,9 +189,16 @@ def make_pyramid_config(settings, configure_csrf=True): for spec in includes: config.include(spec) - # Add some permissions magic. - config.add_directive('add_tailbone_permission_group', 'tailbone.auth.add_permission_group') - config.add_directive('add_tailbone_permission', 'tailbone.auth.add_permission') + # add some permissions magic + config.add_directive('add_wutta_permission_group', + 'wuttaweb.auth.add_permission_group') + config.add_directive('add_wutta_permission', + 'wuttaweb.auth.add_permission') + # TODO: deprecate / remove these + config.add_directive('add_tailbone_permission_group', + 'wuttaweb.auth.add_permission_group') + config.add_directive('add_tailbone_permission', + 'wuttaweb.auth.add_permission') # and some similar magic for certain master views config.add_directive('add_tailbone_index_page', 'tailbone.app.add_index_page') diff --git a/tailbone/auth.py b/tailbone/auth.py index 826c5d40..fbe6bf2f 100644 --- a/tailbone/auth.py +++ b/tailbone/auth.py @@ -27,7 +27,7 @@ Authentication & Authorization import logging import re -from rattail.util import prettify, NOTSET +from rattail.util import NOTSET from zope.interface import implementer from pyramid.authentication import SessionAuthenticationHelper @@ -159,30 +159,3 @@ class TailboneSecurityPolicy: user = self.identity(request) return auth.has_permission(Session(), user, permission) - - -def add_permission_group(config, key, label=None, overwrite=True): - """ - Add a permission group to the app configuration. - """ - def action(): - perms = config.get_settings().get('tailbone_permissions', {}) - if key not in perms or overwrite: - group = perms.setdefault(key, {'key': key}) - group['label'] = label or prettify(key) - config.add_settings({'tailbone_permissions': perms}) - config.action(None, action) - - -def add_permission(config, groupkey, key, label=None): - """ - Add a permission to the app configuration. - """ - def action(): - perms = config.get_settings().get('tailbone_permissions', {}) - group = perms.setdefault(groupkey, {'key': groupkey}) - group.setdefault('label', prettify(groupkey)) - perm = group.setdefault('perms', {}).setdefault(key, {'key': key}) - perm['label'] = label or prettify(key) - config.add_settings({'tailbone_permissions': perms}) - config.action(None, action) diff --git a/tailbone/config.py b/tailbone/config.py index ce1691ae..8392ba0a 100644 --- a/tailbone/config.py +++ b/tailbone/config.py @@ -26,13 +26,14 @@ Rattail config extension for Tailbone import warnings -from rattail.config import ConfigExtension as BaseExtension +from wuttjamaican.conf import WuttaConfigExtension + from rattail.db.config import configure_session from tailbone.db import Session -class ConfigExtension(BaseExtension): +class ConfigExtension(WuttaConfigExtension): """ Rattail config extension for Tailbone. Does the following: diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 60c2f61b..eeae4537 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -47,7 +47,7 @@ from pyramid_deform import SessionFileUploadTempStore from pyramid.renderers import render from webhelpers2.html import tags, HTML -from wuttaweb.util import get_form_data +from wuttaweb.util import get_form_data, make_json_safe from tailbone.db import Session from tailbone.util import raw_datetime, render_markdown @@ -328,7 +328,7 @@ class Form(object): """ Base class for all forms. """ - save_label = "Save" + save_label = "Submit" update_label = "Save" show_cancel = True auto_disable = True @@ -339,10 +339,12 @@ class Form(object): model_instance=None, model_class=None, appstruct=UNSPECIFIED, nodes={}, enums={}, labels={}, assume_local_times=False, renderers=None, renderer_kwargs={}, hidden={}, widgets={}, defaults={}, validators={}, required={}, helptext={}, focus_spec=None, - action_url=None, cancel_url=None, component='tailbone-form', + action_url=None, cancel_url=None, + vue_tagname=None, vuejs_component_kwargs=None, vuejs_field_converters={}, json_data={}, included_templates={}, # TODO: ugh this is getting out hand! can_edit_help=False, edit_help_url=None, route_prefix=None, + **kwargs ): self.fields = None if fields is not None: @@ -380,7 +382,17 @@ class Form(object): self.focus_spec = focus_spec self.action_url = action_url self.cancel_url = cancel_url - self.component = component + + # vue_tagname + self.vue_tagname = vue_tagname + if not self.vue_tagname and kwargs.get('component'): + warnings.warn("component kwarg is deprecated for Form(); " + "please use vue_tagname param instead", + DeprecationWarning, stacklevel=2) + self.vue_tagname = kwargs['component'] + if not self.vue_tagname: + self.vue_tagname = 'tailbone-form' + self.vuejs_component_kwargs = vuejs_component_kwargs or {} self.vuejs_field_converters = vuejs_field_converters or {} self.json_data = json_data or {} @@ -393,10 +405,54 @@ class Form(object): return iter(self.fields) @property - def component_studly(self): - words = self.component.split('-') + def vue_component(self): + """ + String name for the Vue component, e.g. ``'TailboneGrid'``. + + This is a generated value based on :attr:`vue_tagname`. + """ + words = self.vue_tagname.split('-') return ''.join([word.capitalize() for word in words]) + @property + def component(self): + """ + DEPRECATED - use :attr:`vue_tagname` instead. + """ + warnings.warn("Form.component is deprecated; " + "please use vue_tagname instead", + DeprecationWarning, stacklevel=2) + return self.vue_tagname + + @property + def component_studly(self): + """ + DEPRECATED - use :attr:`vue_component` instead. + """ + warnings.warn("Form.component_studly is deprecated; " + "please use vue_component instead", + DeprecationWarning, stacklevel=2) + return self.vue_component + + def get_button_label_submit(self): + """ """ + if hasattr(self, '_button_label_submit'): + return self._button_label_submit + + label = getattr(self, 'submit_label', None) + if label: + return label + + return self.save_label + + def set_button_label_submit(self, value): + """ """ + self._button_label_submit = value + + # wutta compat + button_label_submit = property(get_button_label_submit, + set_button_label_submit) + def __contains__(self, item): return item in self.fields @@ -805,6 +861,10 @@ class Form(object): DeprecationWarning, stacklevel=2) return self.render_deform(**kwargs) + def get_deform(self): + """ """ + return self.make_deform_form() + def make_deform_form(self): if not hasattr(self, 'deform_form'): @@ -843,6 +903,10 @@ class Form(object): return self.deform_form + def render_vue_template(self, template='/forms/deform.mako', **context): + """ """ + return self.render_deform(template=template, **context) + def render_deform(self, dform=None, template=None, **kwargs): if not template: template = '/forms/deform.mako' @@ -865,8 +929,8 @@ class Form(object): context.setdefault('form_kwargs', {}) # TODO: deprecate / remove the latter option here if self.auto_disable_save or self.auto_disable: - context['form_kwargs'].setdefault('ref', self.component_studly) - context['form_kwargs']['@submit'] = 'submit{}'.format(self.component_studly) + context['form_kwargs'].setdefault('ref', self.vue_component) + context['form_kwargs']['@submit'] = 'submit{}'.format(self.vue_component) if self.focus_spec: context['form_kwargs']['data-focus'] = self.focus_spec context['request'] = self.request @@ -878,12 +942,13 @@ class Form(object): return dict([(field, self.get_label(field)) for field in self]) - def get_field_markdowns(self): + def get_field_markdowns(self, session=None): app = self.request.rattail_config.get_app() model = app.model + session = session or Session() if not hasattr(self, 'field_markdowns'): - infos = Session.query(model.TailboneFieldInfo)\ + infos = session.query(model.TailboneFieldInfo)\ .filter(model.TailboneFieldInfo.route_prefix == self.route_prefix)\ .all() self.field_markdowns = dict([(info.field_name, info.markdown_text) @@ -891,6 +956,18 @@ class Form(object): return self.field_markdowns + def get_vue_field_value(self, key): + """ """ + if key not in self.fields: + return + + dform = self.get_deform() + if key not in dform: + return + + field = dform[key] + return make_json_safe(field.cstruct) + def get_vuejs_model_value(self, field): """ This method must return "raw" JS which will be assigned as the initial @@ -957,6 +1034,10 @@ class Form(object): def set_vuejs_component_kwargs(self, **kwargs): self.vuejs_component_kwargs.update(kwargs) + def render_vue_tag(self, **kwargs): + """ """ + return self.render_vuejs_component() + def render_vuejs_component(self): """ Render the Vue.js component HTML for the form. @@ -971,7 +1052,7 @@ class Form(object): kwargs = dict(self.vuejs_component_kwargs) if self.can_edit_help: kwargs.setdefault(':configure-fields-help', 'configureFieldsHelp') - return HTML.tag(self.component, **kwargs) + return HTML.tag(self.vue_tagname, **kwargs) def set_json_data(self, key, value): """ @@ -997,7 +1078,12 @@ class Form(object): templates.append(HTML.literal(render(template, context))) return HTML.literal('\n').join(templates) - def render_field_complete(self, fieldname, bfield_attrs={}): + def render_vue_field(self, fieldname, **kwargs): + """ """ + return self.render_field_complete(fieldname, **kwargs) + + def render_field_complete(self, fieldname, bfield_attrs={}, + session=None): """ Render the given field completely, i.e. with ``<b-field>`` wrapper. Note that this is meant to render *editable* fields, @@ -1015,7 +1101,7 @@ class Form(object): if self.field_visible(fieldname): label = self.get_label(fieldname) - markdowns = self.get_field_markdowns() + markdowns = self.get_field_markdowns(session=session) # these attrs will be for the <b-field> (*not* the widget) attrs = { diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index b4610a18..3f1769cf 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -198,7 +198,8 @@ class Grid: checkable=None, row_uuid_getter=None, clicking_row_checks_box=False, click_handlers=None, main_actions=[], more_actions=[], delete_speedbump=False, - ajax_data_url=None, component='tailbone-grid', + ajax_data_url=None, + vue_tagname=None, expose_direct_link=False, **kwargs): @@ -268,19 +269,63 @@ class Grid: if ajax_data_url: self.ajax_data_url = ajax_data_url elif self.request: - self.ajax_data_url = self.request.current_route_url(_query=None) + self.ajax_data_url = self.request.path_url else: self.ajax_data_url = '' - self.component = component + # vue_tagname + self.vue_tagname = vue_tagname + if not self.vue_tagname and kwargs.get('component'): + warnings.warn("component kwarg is deprecated for Grid(); " + "please use vue_tagname param instead", + DeprecationWarning, stacklevel=2) + self.vue_tagname = kwargs['component'] + if not self.vue_tagname: + self.vue_tagname = 'tailbone-grid' + self.expose_direct_link = expose_direct_link self._whgrid_kwargs = kwargs @property - def component_studly(self): - words = self.component.split('-') + def vue_component(self): + """ + String name for the Vue component, e.g. ``'TailboneGrid'``. + + This is a generated value based on :attr:`vue_tagname`. + """ + words = self.vue_tagname.split('-') return ''.join([word.capitalize() for word in words]) + @property + def component(self): + """ + DEPRECATED - use :attr:`vue_tagname` instead. + """ + warnings.warn("Grid.component is deprecated; " + "please use vue_tagname instead", + DeprecationWarning, stacklevel=2) + return self.vue_tagname + + @property + def component_studly(self): + """ + DEPRECATED - use :attr:`vue_component` instead. + """ + warnings.warn("Grid.component_studly is deprecated; " + "please use vue_component instead", + DeprecationWarning, stacklevel=2) + return self.vue_component + + @property + def actions(self): + """ """ + actions = [] + if self.main_actions: + actions.extend(self.main_actions) + if self.more_actions: + actions.extend(self.more_actions) + return actions + def make_columns(self): """ Return a default list of columns, based on :attr:`model_class`. @@ -1334,6 +1379,21 @@ class Grid: data = self.pager return data + def render_vue_tag(self, master=None, **kwargs): + """ """ + kwargs.setdefault('ref', 'grid') + kwargs.setdefault(':csrftoken', 'csrftoken') + + if (master and master.deletable and master.has_perm('delete') + and master.delete_confirm == 'simple'): + kwargs.setdefault('@deleteActionClicked', 'deleteObject') + + return HTML.tag(self.vue_tagname, **kwargs) + + def render_vue_template(self, template='/grids/complete.mako', **context): + """ """ + return self.render_complete(template=template, **context) + def render_complete(self, template='/grids/complete.mako', **kwargs): """ Render the grid, complete with filters. Note that this also @@ -1359,7 +1419,8 @@ class Grid: context['request'] = self.request context.setdefault('allow_save_defaults', True) context.setdefault('view_click_handler', self.get_view_click_handler()) - return render(template, context) + html = render(template, context) + return HTML.literal(html) def render_buefy(self, **kwargs): warnings.warn("Grid.render_buefy() is deprecated; " @@ -1575,6 +1636,10 @@ class Grid: return True return False + def get_vue_columns(self): + """ """ + return self.get_table_columns() + def get_table_columns(self): """ Return a list of dicts representing all grid columns. Meant @@ -1600,11 +1665,19 @@ class Grid: if hasattr(rowobj, 'uuid'): return rowobj.uuid + def get_vue_data(self): + """ """ + table_data = self.get_table_data() + return table_data['data'] + def get_table_data(self): """ Returns a list of data rows for the grid, for use with client-side JS table. """ + if hasattr(self, '_table_data'): + return self._table_data + # filter / sort / paginate to get "visible" data raw_data = self.make_visible_data() data = [] @@ -1704,7 +1777,8 @@ class Grid: else: results['total_items'] = count - return results + self._table_data = results + return self._table_data def set_action_urls(self, row, rowobj, i): """ diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 02c4e518..268d4818 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -48,7 +48,7 @@ from tailbone.util import get_available_themes, get_global_search_options log = logging.getLogger(__name__) -def new_request(event): +def new_request(event, session=None): """ Event hook called when processing a new request. @@ -64,15 +64,6 @@ def new_request(event): Reference to the app :term:`config object`. Note that this will be the same as :attr:`wuttaweb:request.wutta_config`. - .. method:: request.has_perm(name) - - Function to check if current user has the given permission. - - .. method:: request.has_any_perm(*names) - - Function to check if current user has any of the given - permissions. - .. method:: request.register_component(tagname, classname) Function to register a Vue component for use with the app. @@ -90,6 +81,7 @@ def new_request(event): config = request.wutta_config app = config.get_app() auth = app.get_auth_handler() + session = session or Session() # compatibility rattail_config = config @@ -104,50 +96,31 @@ def new_request(event): return user # invoke upstream hook to set user - base.new_request_set_user(event, user_getter=user_getter, db_session=Session()) + base.new_request_set_user(event, user_getter=user_getter, db_session=session) # assign client IP address to the session, for sake of versioning - Session().continuum_remote_addr = request.client_addr + if hasattr(request, 'client_addr'): + session.continuum_remote_addr = request.client_addr - # TODO: why would this ever be null? - if rattail_config: + # request.register_component() + def register_component(tagname, classname): + """ + Register a Vue 3 component, so the base template knows to + declare it for use within the app (page). + """ + if not hasattr(request, '_tailbone_registered_components'): + request._tailbone_registered_components = OrderedDict() - app = rattail_config.get_app() - auth = app.get_auth_handler() - request.tailbone_cached_permissions = auth.get_permissions( - Session(), request.user) + if tagname in request._tailbone_registered_components: + log.warning("component with tagname '%s' already registered " + "with class '%s' but we are replacing that with " + "class '%s'", + tagname, + request._tailbone_registered_components[tagname], + classname) - def has_perm(name): - if name in request.tailbone_cached_permissions: - return True - return request.is_root - request.has_perm = has_perm - - def has_any_perm(*names): - for name in names: - if has_perm(name): - return True - return False - request.has_any_perm = has_any_perm - - def register_component(tagname, classname): - """ - Register a Vue 3 component, so the base template knows to - declare it for use within the app (page). - """ - if not hasattr(request, '_tailbone_registered_components'): - request._tailbone_registered_components = OrderedDict() - - if tagname in request._tailbone_registered_components: - log.warning("component with tagname '%s' already registered " - "with class '%s' but we are replacing that with " - "class '%s'", - tagname, - request._tailbone_registered_components[tagname], - classname) - - request._tailbone_registered_components[tagname] = classname - request.register_component = register_component + request._tailbone_registered_components[tagname] = classname + request.register_component = register_component def before_render(event): diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index c4cbd648..6811397b 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -153,12 +153,16 @@ <style type="text/css"> .filters .filter-fieldname, .filters .filter-fieldname .button { + % if filter_fieldname_width is not Undefined: min-width: ${filter_fieldname_width}; + % endif justify-content: left; } + % if filter_fieldname_width is not Undefined: .filters .filter-verb { min-width: ${filter_verb_width}; } + % endif </style> </%def> @@ -856,7 +860,7 @@ feedbackMessage: "", % if expose_theme_picker and request.has_perm('common.change_app_theme'): - globalTheme: ${json.dumps(theme)|n}, + globalTheme: ${json.dumps(theme or None)|n}, referrer: location.href, % endif @@ -866,7 +870,7 @@ globalSearchActive: false, globalSearchTerm: '', - globalSearchData: ${json.dumps(global_search_data)|n}, + globalSearchData: ${json.dumps(global_search_data or [])|n}, mountedHooks: [], } diff --git a/tailbone/templates/form.mako b/tailbone/templates/form.mako index c9c8ea88..fec721fd 100644 --- a/tailbone/templates/form.mako +++ b/tailbone/templates/form.mako @@ -6,12 +6,12 @@ <%def name="render_form_buttons()"></%def> <%def name="render_form_template()"> - ${form.render_deform(buttons=capture(self.render_form_buttons))|n} + ${form.render_vue_template(buttons=capture(self.render_form_buttons))|n} </%def> <%def name="render_form()"> <div class="form"> - ${form.render_vuejs_component()} + ${form.render_vue_tag()} </div> </%def> @@ -111,9 +111,9 @@ % if form is not Undefined: <script type="text/javascript"> - ${form.component_studly}.data = function() { return ${form.component_studly}Data } + ${form.vue_component}.data = function() { return ${form.vue_component}Data } - Vue.component('${form.component}', ${form.component_studly}) + Vue.component('${form.vue_tagname}', ${form.vue_component}) </script> % endif diff --git a/tailbone/templates/forms/deform.mako b/tailbone/templates/forms/deform.mako index 00cf2c50..26c8b4ee 100644 --- a/tailbone/templates/forms/deform.mako +++ b/tailbone/templates/forms/deform.mako @@ -1,19 +1,19 @@ ## -*- coding: utf-8; -*- -<% request.register_component(form.component, form.component_studly) %> +<% request.register_component(form.vue_tagname, form.vue_component) %> -<script type="text/x-template" id="${form.component}-template"> +<script type="text/x-template" id="${form.vue_tagname}-template"> <div> % if not form.readonly: - ${h.form(form.action_url, id=dform.formid, method='post', enctype='multipart/form-data', **form_kwargs)} + ${h.form(form.action_url, id=dform.formid, method='post', enctype='multipart/form-data', **(form_kwargs or {}))} ${h.csrf_token(request)} % endif <section> % if form_body is not Undefined and form_body: ${form_body|n} - % elif form.grouping: + % elif getattr(form, 'grouping', None): % for group in form.grouping: <nav class="panel"> <p class="panel-heading">${group}</p> @@ -27,8 +27,8 @@ </nav> % endfor % else: - % for field in form.fields: - ${form.render_field_complete(field)} + % for fieldname in form.fields: + ${form.render_vue_field(fieldname, session=session)} % endfor % endif </section> @@ -54,20 +54,20 @@ <input type="reset" value="Reset" class="button" /> % endif ## TODO: deprecate / remove the latter option here - % if form.auto_disable_save or form.auto_disable: + % if getattr(form, 'auto_disable_submit', False) or form.auto_disable_save or form.auto_disable: <b-button type="is-primary" native-type="submit" - :disabled="${form.component_studly}Submitting" + :disabled="${form.vue_component}Submitting" icon-pack="fas" icon-left="save"> - {{ ${form.component_studly}ButtonText }} + {{ ${form.vue_component}Submitting ? "Working, please wait..." : "${form.button_label_submit}" }} </b-button> % else: <b-button type="is-primary" native-type="submit" icon-pack="fas" icon-left="save"> - ${getattr(form, 'submit_label', getattr(form, 'save_label', "Submit"))} + ${form.button_label_submit} </b-button> % endif </div> @@ -122,8 +122,8 @@ <script type="text/javascript"> - let ${form.component_studly} = { - template: '#${form.component}-template', + let ${form.vue_component} = { + template: '#${form.vue_tagname}-template', mixins: [FormPosterMixin], components: {}, props: { @@ -136,10 +136,9 @@ methods: { ## TODO: deprecate / remove the latter option here - % if form.auto_disable_save or form.auto_disable: - submit${form.component_studly}() { - this.${form.component_studly}Submitting = true - this.${form.component_studly}ButtonText = "Working, please wait..." + % if getattr(form, 'auto_disable_submit', False) or form.auto_disable_save or form.auto_disable: + submit${form.vue_component}() { + this.${form.vue_component}Submitting = true }, % endif @@ -178,7 +177,7 @@ } } - let ${form.component_studly}Data = { + let ${form.vue_component}Data = { ## TODO: should find a better way to handle CSRF token csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}, @@ -198,16 +197,14 @@ % if not form.readonly: % for field in form.fields: % if field in dform: - <% field = dform[field] %> - field_model_${field.name}: ${form.get_vuejs_model_value(field)|n}, + field_model_${field}: ${json.dumps(form.get_vue_field_value(field))|n}, % endif % endfor % endif ## TODO: deprecate / remove the latter option here - % if form.auto_disable_save or form.auto_disable: - ${form.component_studly}Submitting: false, - ${form.component_studly}ButtonText: ${json.dumps(getattr(form, 'submit_label', getattr(form, 'save_label', "Submit")))|n}, + % if getattr(form, 'auto_disable_submit', False) or form.auto_disable_save or form.auto_disable: + ${form.vue_component}Submitting: false, % endif } diff --git a/tailbone/templates/forms/vue_template.mako b/tailbone/templates/forms/vue_template.mako new file mode 100644 index 00000000..ac096f67 --- /dev/null +++ b/tailbone/templates/forms/vue_template.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8; -*- +<%inherit file="/forms/deform.mako" /> +${parent.body()} diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako index a0f927d3..fc48916b 100644 --- a/tailbone/templates/grids/complete.mako +++ b/tailbone/templates/grids/complete.mako @@ -1,15 +1,15 @@ ## -*- coding: utf-8; -*- -<% request.register_component(grid.component, grid.component_studly) %> +<% request.register_component(grid.vue_tagname, grid.vue_component) %> -<script type="text/x-template" id="${grid.component}-template"> +<script type="text/x-template" id="${grid.vue_tagname}-template"> <div> <div style="display: flex; justify-content: space-between; margin-bottom: 0.5em;"> <div style="display: flex; flex-direction: column; justify-content: end;"> <div class="filters"> - % if grid.filterable: + % if getattr(grid, 'filterable', False): ## TODO: stop using |n filter ${grid.render_filters(allow_save_defaults=allow_save_defaults)|n} % endif @@ -55,7 +55,7 @@ :checkable="checkable" - % if grid.checkboxes: + % if getattr(grid, 'checkboxes', False): % if request.use_oruga: v-model:checked-rows="checkedRows" % else: @@ -66,20 +66,22 @@ % endif % endif - % if grid.check_handler: + % if getattr(grid, 'check_handler', None): @check="${grid.check_handler}" % endif - % if grid.check_all_handler: + % if getattr(grid, 'check_all_handler', None): @check-all="${grid.check_all_handler}" % endif + % if hasattr(grid, 'checkable'): % if isinstance(grid.checkable, str): :is-row-checkable="${grid.row_checkable}" % elif grid.checkable: :is-row-checkable="row => row._checkable" % endif + % endif - % if grid.sortable: + % if getattr(grid, 'sortable', False): backend-sorting @sort="onSort" @sorting-priority-removed="sortingPriorityRemoved" @@ -101,7 +103,7 @@ sort-multiple-key="ctrlKey" % endif - % if grid.click_handlers: + % if getattr(grid, 'click_handlers', None): @cellclick="cellClick" % endif @@ -119,17 +121,17 @@ :hoverable="true" :narrowed="true"> - % for column in grid_columns: + % for column in grid.get_vue_columns(): <${b}-table-column field="${column['field']}" label="${column['label']}" v-slot="props" - :sortable="${json.dumps(column['sortable'])}" - % if grid.is_searchable(column['field']): + :sortable="${json.dumps(column.get('sortable', False))}" + % if hasattr(grid, 'is_searchable') and grid.is_searchable(column['field']): searchable % endif cell-class="c_${column['field']}" - :visible="${json.dumps(column['visible'])}"> - % if column['field'] in grid.raw_renderers: + :visible="${json.dumps(column.get('visible', True))}"> + % if hasattr(grid, 'raw_renderers') and column['field'] in grid.raw_renderers: ${grid.raw_renderers[column['field']]()} % elif grid.is_linked(column['field']): <a :href="props.row._action_url_view" @@ -144,20 +146,20 @@ </${b}-table-column> % endfor - % if grid.main_actions or grid.more_actions: + % if grid.actions: <${b}-table-column field="actions" label="Actions" v-slot="props"> ## TODO: we do not currently differentiate for "main vs. more" ## here, but ideally we would tuck "more" away in a drawer etc. - % for action in grid.main_actions + grid.more_actions: + % for action in grid.actions: <a v-if="props.row._action_url_${action.key}" :href="props.row._action_url_${action.key}" class="grid-action${' has-text-danger' if action.key == 'delete' else ''} ${action.link_class or ''}" - % if action.click_handler: + % if getattr(action, 'click_handler', None): @click.prevent="${action.click_handler}" % endif - % if action.target: + % if getattr(action, 'target', None): target="${action.target}" % endif > @@ -192,7 +194,7 @@ <template #footer> <div style="display: flex; justify-content: space-between;"> - % if grid.expose_direct_link: + % if getattr(grid, 'expose_direct_link', False): <b-button type="is-primary" size="is-small" @click="copyDirectLink()" @@ -207,7 +209,7 @@ <div></div> % endif - % if grid.pageable: + % if getattr(grid, 'pageable', False): <div v-if="firstItem" style="display: flex; gap: 0.5rem; align-items: center;"> <span> @@ -234,7 +236,7 @@ </${b}-table> ## dummy input field needed for sharing links on *insecure* sites - % if request.scheme == 'http': + % if getattr(request, 'scheme', None) == 'http': <b-input v-model="shareLink" ref="shareLink" v-show="shareLink"></b-input> % endif @@ -243,30 +245,30 @@ <script type="text/javascript"> - let ${grid.component_studly}CurrentData = ${json.dumps(grid_data['data'])|n} + let ${grid.vue_component}CurrentData = ${json.dumps(grid.get_vue_data())|n} - let ${grid.component_studly}Data = { + let ${grid.vue_component}Data = { loading: false, - ajaxDataUrl: ${json.dumps(grid.ajax_data_url)|n}, + ajaxDataUrl: ${json.dumps(getattr(grid, 'ajax_data_url', request.path_url))|n}, savingDefaults: false, - data: ${grid.component_studly}CurrentData, - rowStatusMap: ${json.dumps(grid_data['row_status_map'])|n}, + data: ${grid.vue_component}CurrentData, + rowStatusMap: ${json.dumps(grid_data['row_status_map'] if grid_data is not Undefined else {})|n}, - checkable: ${json.dumps(grid.checkboxes)|n}, - % if grid.checkboxes: + checkable: ${json.dumps(getattr(grid, 'checkboxes', False))|n}, + % if getattr(grid, 'checkboxes', False): checkedRows: ${grid_data['checked_rows_code']|n}, % endif - paginated: ${json.dumps(grid.pageable)|n}, - total: ${len(grid_data['data']) if static_data else grid_data['total_items']}, - perPage: ${json.dumps(grid.pagesize if grid.pageable else None)|n}, - currentPage: ${json.dumps(grid.page if grid.pageable else None)|n}, - firstItem: ${json.dumps(grid_data['first_item'] if grid.pageable else None)|n}, - lastItem: ${json.dumps(grid_data['last_item'] if grid.pageable else None)|n}, + paginated: ${json.dumps(getattr(grid, 'pageable', False))|n}, + total: ${len(grid_data['data']) if static_data else (grid_data['total_items'] if grid_data is not Undefined else 0)}, + perPage: ${json.dumps(grid.pagesize if getattr(grid, 'pageable', False) else None)|n}, + currentPage: ${json.dumps(grid.page if getattr(grid, 'pageable', False) else None)|n}, + firstItem: ${json.dumps(grid_data['first_item'] if getattr(grid, 'pageable', False) else None)|n}, + lastItem: ${json.dumps(grid_data['last_item'] if getattr(grid, 'pageable', False) else None)|n}, - % if grid.sortable: + % if getattr(grid, 'sortable', False): ## TODO: there is a bug (?) which prevents the arrow from ## displaying for simple default single-column sort. so to @@ -289,19 +291,19 @@ % endif ## filterable: ${json.dumps(grid.filterable)|n}, - filters: ${json.dumps(filters_data if grid.filterable else None)|n}, - filtersSequence: ${json.dumps(filters_sequence if grid.filterable else None)|n}, + filters: ${json.dumps(filters_data if getattr(grid, 'filterable', False) else None)|n}, + filtersSequence: ${json.dumps(filters_sequence if getattr(grid, 'filterable', False) else None)|n}, addFilterTerm: '', addFilterShow: false, ## dummy input value needed for sharing links on *insecure* sites - % if request.scheme == 'http': + % if getattr(request, 'scheme', None) == 'http': shareLink: null, % endif } - let ${grid.component_studly} = { - template: '#${grid.component}-template', + let ${grid.vue_component} = { + template: '#${grid.vue_tagname}-template', mixins: [FormPosterMixin], @@ -358,7 +360,7 @@ directLink() { let params = new URLSearchParams(this.getAllParams()) - return `${request.current_route_url(_query=None)}?${'$'}{params}` + return `${request.path_url}?${'$'}{params}` }, }, @@ -380,7 +382,7 @@ return filtr.label || filtr.key }, - % if grid.click_handlers: + % if getattr(grid, 'click_handlers', None): cellClick(row, column, rowIndex, columnIndex) { % for key in grid.click_handlers: if (column._props.field == '${key}') { @@ -437,13 +439,13 @@ getBasicParams() { let params = {} - % if grid.sortable: + % if getattr(grid, 'sortable', False): for (let i = 1; i <= this.backendSorters.length; i++) { params['sort'+i+'key'] = this.backendSorters[i-1].field params['sort'+i+'dir'] = this.backendSorters[i-1].order } % endif - % if grid.pageable: + % if getattr(grid, 'pageable', False): params.pagesize = this.perPage params.page = this.currentPage % endif @@ -488,8 +490,8 @@ this.loading = true this.$http.get(`${'$'}{this.ajaxDataUrl}?${'$'}{params}`).then(({ data }) => { if (!data.error) { - ${grid.component_studly}CurrentData = data.data - this.data = ${grid.component_studly}CurrentData + ${grid.vue_component}CurrentData = data.data + this.data = ${grid.vue_component}CurrentData this.rowStatusMap = data.row_status_map this.total = data.total_items this.firstItem = data.first_item @@ -776,7 +778,7 @@ } else { this.checkedRows.push(row) } - % if grid.check_handler: + % if getattr(grid, 'check_handler', None): this.${grid.check_handler}(this.checkedRows, row) % endif }, diff --git a/tailbone/templates/grids/vue_template.mako b/tailbone/templates/grids/vue_template.mako new file mode 100644 index 00000000..625f046b --- /dev/null +++ b/tailbone/templates/grids/vue_template.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8; -*- +<%inherit file="/grids/complete.mako" /> +${parent.body()} diff --git a/tailbone/templates/master/create.mako b/tailbone/templates/master/create.mako index 27cd404c..d7dcbbd8 100644 --- a/tailbone/templates/master/create.mako +++ b/tailbone/templates/master/create.mako @@ -1,6 +1,6 @@ ## -*- coding: utf-8; -*- <%inherit file="/form.mako" /> -<%def name="title()">New ${model_title_plural if master.creates_multiple else model_title}</%def> +<%def name="title()">New ${model_title_plural if getattr(master, 'creates_multiple', False) else model_title}</%def> ${parent.body()} diff --git a/tailbone/templates/master/delete.mako b/tailbone/templates/master/delete.mako index 30bb50ab..c6187d55 100644 --- a/tailbone/templates/master/delete.mako +++ b/tailbone/templates/master/delete.mako @@ -27,7 +27,7 @@ <b-button type="is-primary is-danger" native-type="submit" :disabled="formSubmitting"> - {{ formButtonText }} + {{ formSubmitting ? "Working, please wait..." : "${form.button_label_submit}" }} </b-button> </div> ${h.end_form()} @@ -35,14 +35,12 @@ <%def name="modify_this_page_vars()"> ${parent.modify_this_page_vars()} - <script type="text/javascript"> + <script> - TailboneFormData.formSubmitting = false - TailboneFormData.formButtonText = "Yes, please DELETE this data forever!" + ${form.vue_component}Data.formSubmitting = false - TailboneForm.methods.submitForm = function() { + ${form.vue_component}.methods.submitForm = function() { this.formSubmitting = true - this.formButtonText = "Working, please wait..." } </script> diff --git a/tailbone/templates/master/form.mako b/tailbone/templates/master/form.mako index dfe56fa8..dc9743ea 100644 --- a/tailbone/templates/master/form.mako +++ b/tailbone/templates/master/form.mako @@ -6,13 +6,13 @@ <script type="text/javascript"> ## declare extra data needed by form - % if form is not Undefined: + % if form is not Undefined and getattr(form, 'json_data', None): % for key, value in form.json_data.items(): ${form.component_studly}Data.${key} = ${json.dumps(value)|n} % endfor % endif - % if master.deletable and instance_deletable and master.has_perm('delete') and master.delete_confirm == 'simple': + % if master.deletable and instance_deletable and master.has_perm('delete') and getattr(master, 'delete_confirm', 'full') == 'simple': ThisPage.methods.deleteObject = function() { if (confirm("Are you sure you wish to delete this ${model_title}?")) { @@ -23,7 +23,7 @@ % endif </script> - % if form is not Undefined: + % if form is not Undefined and hasattr(form, 'render_included_templates'): ${form.render_included_templates()} % endif diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako index 33592559..81c11213 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -15,7 +15,7 @@ <%def name="grid_tools()"> ## grid totals - % if master.supports_grid_totals: + % if getattr(master, 'supports_grid_totals', False): <div style="display: flex; align-items: center;"> <b-button v-if="gridTotalsDisplay == null" :disabled="gridTotalsFetching" @@ -30,7 +30,7 @@ % endif ## download search results - % if master.results_downloadable and master.has_perm('download_results'): + % if getattr(master, 'results_downloadable', False) and master.has_perm('download_results'): <div> <b-button type="is-primary" icon-pack="fas" @@ -180,7 +180,7 @@ % endif ## download rows for search results - % if master.has_rows and master.results_rows_downloadable and master.has_perm('download_results_rows'): + % if getattr(master, 'has_rows', False) and master.results_rows_downloadable and master.has_perm('download_results_rows'): <b-button type="is-primary" icon-pack="fas" icon-left="download" @@ -194,7 +194,7 @@ % endif ## merge 2 objects - % if master.mergeable and request.has_perm('{}.merge'.format(permission_prefix)): + % if getattr(master, 'mergeable', False) and request.has_perm('{}.merge'.format(permission_prefix)): ${h.form(url('{}.merge'.format(route_prefix)), class_='control', **{'@submit': 'submitMergeForm'})} ${h.csrf_token(request)} @@ -212,7 +212,7 @@ % endif ## enable / disable selected objects - % if master.supports_set_enabled_toggle and master.has_perm('enable_disable_set'): + % if getattr(master, 'supports_set_enabled_toggle', False) and master.has_perm('enable_disable_set'): ${h.form(url('{}.enable_set'.format(route_prefix)), class_='control', ref='enable_selected_form')} ${h.csrf_token(request)} @@ -234,7 +234,7 @@ % endif ## delete selected objects - % if master.set_deletable and master.has_perm('delete_set'): + % if getattr(master, 'set_deletable', False) and master.has_perm('delete_set'): ${h.form(url('{}.delete_set'.format(route_prefix)), ref='delete_selected_form', class_='control')} ${h.csrf_token(request)} ${h.hidden('uuids', v_model='selected_uuids')} @@ -249,7 +249,7 @@ % endif ## delete search results - % if master.bulk_deletable and request.has_perm('{}.bulk_delete'.format(permission_prefix)): + % if getattr(master, 'bulk_deletable', False) and request.has_perm('{}.bulk_delete'.format(permission_prefix)): ${h.form(url('{}.bulk_delete'.format(route_prefix)), ref='delete_results_form', class_='control')} ${h.csrf_token(request)} <b-button type="is-danger" @@ -283,7 +283,7 @@ ${self.render_grid_component()} - % if master.deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple': + % if master.deletable and master.has_perm('delete') and getattr(master, 'delete_confirm', 'full') == 'simple': ${h.form('#', ref='deleteObjectForm')} ${h.csrf_token(request)} ${h.end_form()} @@ -291,17 +291,11 @@ </%def> <%def name="make_grid_component()"> - ## TODO: stop using |n filter? - ${grid.render_complete(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())|n} + ${grid.render_vue_template(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())} </%def> <%def name="render_grid_component()"> - <${grid.component} ref="grid" :csrftoken="csrftoken" - % if master.deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple': - @deleteActionClicked="deleteObject" - % endif - > - </${grid.component}> + ${grid.render_vue_tag()} </%def> <%def name="make_this_page_component()"> @@ -313,10 +307,8 @@ ## finalize grid <script> - - ${grid.component_studly}.data = () => { return ${grid.component_studly}Data } - Vue.component('${grid.component}', ${grid.component_studly}) - + ${grid.vue_component}.data = function() { return ${grid.vue_component}Data } + Vue.component('${grid.vue_tagname}', ${grid.vue_component}) </script> </%def> @@ -328,11 +320,11 @@ ${parent.modify_this_page_vars()} <script type="text/javascript"> - % if master.supports_grid_totals: - ${grid.component_studly}Data.gridTotalsDisplay = null - ${grid.component_studly}Data.gridTotalsFetching = false + % if getattr(master, 'supports_grid_totals', False): + ${grid.vue_component}Data.gridTotalsDisplay = null + ${grid.vue_component}Data.gridTotalsFetching = false - ${grid.component_studly}.methods.gridTotalsFetch = function() { + ${grid.vue_component}.methods.gridTotalsFetch = function() { this.gridTotalsFetching = true let url = '${url(f'{route_prefix}.fetch_grid_totals')}' @@ -344,7 +336,7 @@ }) } - ${grid.component_studly}.methods.appliedFiltersHook = function() { + ${grid.vue_component}.methods.appliedFiltersHook = function() { this.gridTotalsDisplay = null this.gridTotalsFetching = false } @@ -388,7 +380,7 @@ % endif ## delete single object - % if master.deletable and master.has_perm('delete') and master.delete_confirm == 'simple': + % if master.deletable and master.has_perm('delete') and getattr(master, 'delete_confirm', 'full') == 'simple': ThisPage.methods.deleteObject = function(url) { if (confirm("Are you sure you wish to delete this ${model_title}?")) { let form = this.$refs.deleteObjectForm @@ -399,19 +391,19 @@ % endif ## download results - % if master.results_downloadable and master.has_perm('download_results'): + % if getattr(master, 'results_downloadable', False) and master.has_perm('download_results'): - ${grid.component_studly}Data.downloadResultsFormat = '${master.download_results_default_format()}' - ${grid.component_studly}Data.showDownloadResultsDialog = false - ${grid.component_studly}Data.downloadResultsFieldsMode = 'default' - ${grid.component_studly}Data.downloadResultsFieldsAvailable = ${json.dumps(download_results_fields_available)|n} - ${grid.component_studly}Data.downloadResultsFieldsDefault = ${json.dumps(download_results_fields_default)|n} - ${grid.component_studly}Data.downloadResultsFieldsIncluded = ${json.dumps(download_results_fields_default)|n} + ${grid.vue_component}Data.downloadResultsFormat = '${master.download_results_default_format()}' + ${grid.vue_component}Data.showDownloadResultsDialog = false + ${grid.vue_component}Data.downloadResultsFieldsMode = 'default' + ${grid.vue_component}Data.downloadResultsFieldsAvailable = ${json.dumps(download_results_fields_available)|n} + ${grid.vue_component}Data.downloadResultsFieldsDefault = ${json.dumps(download_results_fields_default)|n} + ${grid.vue_component}Data.downloadResultsFieldsIncluded = ${json.dumps(download_results_fields_default)|n} - ${grid.component_studly}Data.downloadResultsExcludedFieldsSelected = [] - ${grid.component_studly}Data.downloadResultsIncludedFieldsSelected = [] + ${grid.vue_component}Data.downloadResultsExcludedFieldsSelected = [] + ${grid.vue_component}Data.downloadResultsIncludedFieldsSelected = [] - ${grid.component_studly}.computed.downloadResultsFieldsExcluded = function() { + ${grid.vue_component}.computed.downloadResultsFieldsExcluded = function() { let excluded = [] this.downloadResultsFieldsAvailable.forEach(field => { if (!this.downloadResultsFieldsIncluded.includes(field)) { @@ -421,7 +413,7 @@ return excluded } - ${grid.component_studly}.methods.downloadResultsExcludeFields = function() { + ${grid.vue_component}.methods.downloadResultsExcludeFields = function() { const selected = Array.from(this.downloadResultsIncludedFieldsSelected) if (!selected) { return @@ -445,7 +437,7 @@ }) } - ${grid.component_studly}.methods.downloadResultsIncludeFields = function() { + ${grid.vue_component}.methods.downloadResultsIncludeFields = function() { const selected = Array.from(this.downloadResultsExcludedFieldsSelected) if (!selected) { return @@ -466,28 +458,28 @@ }) } - ${grid.component_studly}.methods.downloadResultsUseDefaultFields = function() { + ${grid.vue_component}.methods.downloadResultsUseDefaultFields = function() { this.downloadResultsFieldsIncluded = Array.from(this.downloadResultsFieldsDefault) this.downloadResultsFieldsMode = 'default' } - ${grid.component_studly}.methods.downloadResultsUseAllFields = function() { + ${grid.vue_component}.methods.downloadResultsUseAllFields = function() { this.downloadResultsFieldsIncluded = Array.from(this.downloadResultsFieldsAvailable) this.downloadResultsFieldsMode = 'all' } - ${grid.component_studly}.methods.downloadResultsSubmit = function() { + ${grid.vue_component}.methods.downloadResultsSubmit = function() { this.$refs.download_results_form.submit() } % endif ## download rows for results - % if master.has_rows and master.results_rows_downloadable and master.has_perm('download_results_rows'): + % if getattr(master, 'has_rows', False) and master.results_rows_downloadable and master.has_perm('download_results_rows'): - ${grid.component_studly}Data.downloadResultsRowsButtonDisabled = false - ${grid.component_studly}Data.downloadResultsRowsButtonText = "Download Rows for Results" + ${grid.vue_component}Data.downloadResultsRowsButtonDisabled = false + ${grid.vue_component}Data.downloadResultsRowsButtonText = "Download Rows for Results" - ${grid.component_studly}.methods.downloadResultsRows = function() { + ${grid.vue_component}.methods.downloadResultsRows = function() { if (confirm("This will generate an Excel file which contains " + "not the results themselves, but the *rows* for " + "each.\n\nAre you sure you want this?")) { @@ -499,12 +491,12 @@ % endif ## enable / disable selected objects - % if master.supports_set_enabled_toggle and master.has_perm('enable_disable_set'): + % if getattr(master, 'supports_set_enabled_toggle', False) and master.has_perm('enable_disable_set'): - ${grid.component_studly}Data.enableSelectedSubmitting = false - ${grid.component_studly}Data.enableSelectedText = "Enable Selected" + ${grid.vue_component}Data.enableSelectedSubmitting = false + ${grid.vue_component}Data.enableSelectedText = "Enable Selected" - ${grid.component_studly}.computed.enableSelectedDisabled = function() { + ${grid.vue_component}.computed.enableSelectedDisabled = function() { if (this.enableSelectedSubmitting) { return true } @@ -514,7 +506,7 @@ return false } - ${grid.component_studly}.methods.enableSelectedSubmit = function() { + ${grid.vue_component}.methods.enableSelectedSubmit = function() { let uuids = this.checkedRowUUIDs() if (!uuids.length) { alert("You must first select one or more objects to disable.") @@ -529,10 +521,10 @@ this.$refs.enable_selected_form.submit() } - ${grid.component_studly}Data.disableSelectedSubmitting = false - ${grid.component_studly}Data.disableSelectedText = "Disable Selected" + ${grid.vue_component}Data.disableSelectedSubmitting = false + ${grid.vue_component}Data.disableSelectedText = "Disable Selected" - ${grid.component_studly}.computed.disableSelectedDisabled = function() { + ${grid.vue_component}.computed.disableSelectedDisabled = function() { if (this.disableSelectedSubmitting) { return true } @@ -542,7 +534,7 @@ return false } - ${grid.component_studly}.methods.disableSelectedSubmit = function() { + ${grid.vue_component}.methods.disableSelectedSubmit = function() { let uuids = this.checkedRowUUIDs() if (!uuids.length) { alert("You must first select one or more objects to disable.") @@ -560,12 +552,12 @@ % endif ## delete selected objects - % if master.set_deletable and master.has_perm('delete_set'): + % if getattr(master, 'set_deletable', False) and master.has_perm('delete_set'): - ${grid.component_studly}Data.deleteSelectedSubmitting = false - ${grid.component_studly}Data.deleteSelectedText = "Delete Selected" + ${grid.vue_component}Data.deleteSelectedSubmitting = false + ${grid.vue_component}Data.deleteSelectedText = "Delete Selected" - ${grid.component_studly}.computed.deleteSelectedDisabled = function() { + ${grid.vue_component}.computed.deleteSelectedDisabled = function() { if (this.deleteSelectedSubmitting) { return true } @@ -575,7 +567,7 @@ return false } - ${grid.component_studly}.methods.deleteSelectedSubmit = function() { + ${grid.vue_component}.methods.deleteSelectedSubmit = function() { let uuids = this.checkedRowUUIDs() if (!uuids.length) { alert("You must first select one or more objects to disable.") @@ -591,12 +583,12 @@ } % endif - % if master.bulk_deletable and master.has_perm('bulk_delete'): + % if getattr(master, 'bulk_deletable', False) and master.has_perm('bulk_delete'): - ${grid.component_studly}Data.deleteResultsSubmitting = false - ${grid.component_studly}Data.deleteResultsText = "Delete Results" + ${grid.vue_component}Data.deleteResultsSubmitting = false + ${grid.vue_component}Data.deleteResultsText = "Delete Results" - ${grid.component_studly}.computed.deleteResultsDisabled = function() { + ${grid.vue_component}.computed.deleteResultsDisabled = function() { if (this.deleteResultsSubmitting) { return true } @@ -606,7 +598,7 @@ return false } - ${grid.component_studly}.methods.deleteResultsSubmit = function() { + ${grid.vue_component}.methods.deleteResultsSubmit = function() { // TODO: show "plural model title" here? if (!confirm("You are about to delete " + this.total.toLocaleString('en') + " objects.\n\nAre you sure?")) { return @@ -619,12 +611,12 @@ % endif - % if master.mergeable and master.has_perm('merge'): + % if getattr(master, 'mergeable', False) and master.has_perm('merge'): - ${grid.component_studly}Data.mergeFormButtonText = "Merge 2 ${model_title_plural}" - ${grid.component_studly}Data.mergeFormSubmitting = false + ${grid.vue_component}Data.mergeFormButtonText = "Merge 2 ${model_title_plural}" + ${grid.vue_component}Data.mergeFormSubmitting = false - ${grid.component_studly}.methods.submitMergeForm = function() { + ${grid.vue_component}.methods.submitMergeForm = function() { this.mergeFormSubmitting = true this.mergeFormButtonText = "Working, please wait..." } diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako index fe44caa9..a61020f3 100644 --- a/tailbone/templates/master/view.mako +++ b/tailbone/templates/master/view.mako @@ -8,7 +8,7 @@ </%def> <%def name="render_instance_header_title_extras()"> - % if master.touchable and master.has_perm('touch'): + % if getattr(master, 'touchable', False) and master.has_perm('touch'): <b-button title=""Touch" this record to trigger sync" @click="touchRecord()" :disabled="touchSubmitting"> @@ -93,7 +93,7 @@ ${parent.render_this_page()} ## render row grid - % if master.has_rows: + % if getattr(master, 'has_rows', False): <br /> % if rows_title: <h4 class="block is-size-4">${rows_title}</h4> @@ -241,7 +241,7 @@ </%def> <%def name="render_this_page_template()"> - % if master.has_rows: + % if getattr(master, 'has_rows', False): ## TODO: stop using |n filter ${rows_grid.render_complete(allow_save_defaults=False, tools=capture(self.render_row_grid_tools))|n} % endif @@ -318,7 +318,7 @@ ${parent.modify_whole_page_vars()} <script type="text/javascript"> - % if master.touchable and master.has_perm('touch'): + % if getattr(master, 'touchable', False) and master.has_perm('touch'): WholePageData.touchSubmitting = false @@ -340,7 +340,7 @@ ${parent.finalize_this_page_vars()} <script type="text/javascript"> - % if master.has_rows: + % if getattr(master, 'has_rows', False): TailboneGrid.data = function() { return TailboneGridData } Vue.component('tailbone-grid', TailboneGrid) % endif diff --git a/tailbone/templates/people/index.mako b/tailbone/templates/people/index.mako index c819050a..9339dfd5 100644 --- a/tailbone/templates/people/index.mako +++ b/tailbone/templates/people/index.mako @@ -3,7 +3,7 @@ <%def name="grid_tools()"> - % if master.mergeable and master.has_perm('request_merge'): + % if getattr(master, 'mergeable', False) and master.has_perm('request_merge'): <b-button @click="showMergeRequest()" icon-pack="fas" icon-left="object-ungroup" @@ -65,7 +65,7 @@ ${parent.modify_this_page_vars()} <script type="text/javascript"> - % if master.mergeable and master.has_perm('request_merge'): + % if getattr(master, 'mergeable', False) and master.has_perm('request_merge'): ${grid.component_studly}Data.mergeRequestShowDialog = false ${grid.component_studly}Data.mergeRequestRows = [] diff --git a/tailbone/templates/people/view.mako b/tailbone/templates/people/view.mako index 184f2b91..d28d7558 100644 --- a/tailbone/templates/people/view.mako +++ b/tailbone/templates/people/view.mako @@ -9,7 +9,7 @@ <%def name="render_form()"> <div class="form"> - <tailbone-form v-on:make-user="makeUser"></tailbone-form> + <${form.vue_tagname} v-on:make-user="makeUser"></${form.vue_tagname}> </div> </%def> @@ -17,7 +17,7 @@ ${parent.modify_this_page_vars()} <script type="text/javascript"> - TailboneForm.methods.clickMakeUser = function(event) { + ${form.vue_component}.methods.clickMakeUser = function(event) { this.$emit('make-user') } diff --git a/tailbone/templates/principal/find_by_perm.mako b/tailbone/templates/principal/find_by_perm.mako index 1a0a4b7d..2ea289c8 100644 --- a/tailbone/templates/principal/find_by_perm.mako +++ b/tailbone/templates/principal/find_by_perm.mako @@ -15,7 +15,7 @@ <script type="text/x-template" id="find-principals-template"> <div> - ${h.form(request.current_route_url(), method='GET', **{'@submit': 'formSubmitting = true'})} + ${h.form(request.url, method='GET', **{'@submit': 'formSubmitting = true'})} <div style="margin-left: 10rem; max-width: 50%;"> ${h.hidden('permission_group', **{':value': 'selectedGroup'})} @@ -63,7 +63,7 @@ <b-field horizontal> <div class="buttons" style="margin-top: 1rem;"> <once-button tag="a" - href="${request.current_route_url(_query=None)}" + href="${request.path_url}" text="Reset Form"> </once-button> <b-button type="is-primary" diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako index b0e43a37..f06b45f9 100644 --- a/tailbone/templates/themes/butterball/base.mako +++ b/tailbone/templates/themes/butterball/base.mako @@ -686,7 +686,7 @@ <h1 class="title"> ${index_title} </h1> - % if master.creatable and master.show_create_link and master.has_perm('create'): + % if master.creatable and getattr(master, 'show_create_link', True) and master.has_perm('create'): <once-button type="is-primary" tag="a" href="${url('{}.create'.format(route_prefix))}" icon-left="plus" @@ -712,7 +712,7 @@ <h1 class="title"> ${h.link_to(instance_title, instance_url)} </h1> - % elif master.creatable and master.show_create_link and master.has_perm('create'): + % elif master.creatable and getattr(master, 'show_create_link', True) and master.has_perm('create'): % if not request.matched_route.name.endswith('.create'): <once-button type="is-primary" tag="a" href="${url('{}.create'.format(route_prefix))}" @@ -966,23 +966,23 @@ </%def> <%def name="render_crud_header_buttons()"> - % if master and master.viewing and not master.cloning: +% if master and master.viewing and not getattr(master, 'cloning', False): ## TODO: is there a better way to check if viewing parent? % if parent_instance is Undefined: % if master.editable and instance_editable and master.has_perm('edit'): - <once-button tag="a" href="${action_url('edit', instance)}" + <once-button tag="a" href="${master.get_action_url('edit', instance)}" icon-left="edit" text="Edit This"> </once-button> % endif - % if not master.cloning and master.cloneable and master.has_perm('clone'): - <once-button tag="a" href="${action_url('clone', instance)}" + % if not getattr(master, 'cloning', False) and getattr(master, 'cloneable', False) and master.has_perm('clone'): + <once-button tag="a" href="${master.get_action_url('clone', instance)}" icon-left="object-ungroup" text="Clone This"> </once-button> % endif % if master.deletable and instance_deletable and master.has_perm('delete'): - <once-button tag="a" href="${action_url('delete', instance)}" + <once-button tag="a" href="${master.get_action_url('delete', instance)}" type="is-danger" icon-left="trash" text="Delete This"> @@ -991,7 +991,7 @@ % else: ## viewing row % if instance_deletable and master.has_perm('delete_row'): - <once-button tag="a" href="${action_url('delete', instance)}" + <once-button tag="a" href="${master.get_action_url('delete', instance)}" type="is-danger" icon-left="trash" text="Delete This"> @@ -1000,13 +1000,13 @@ % endif % elif master and master.editing: % if master.viewable and master.has_perm('view'): - <once-button tag="a" href="${action_url('view', instance)}" + <once-button tag="a" href="${master.get_action_url('view', instance)}" icon-left="eye" text="View This"> </once-button> % endif % if master.deletable and instance_deletable and master.has_perm('delete'): - <once-button tag="a" href="${action_url('delete', instance)}" + <once-button tag="a" href="${master.get_action_url('delete', instance)}" type="is-danger" icon-left="trash" text="Delete This"> @@ -1014,20 +1014,20 @@ % endif % elif master and master.deleting: % if master.viewable and master.has_perm('view'): - <once-button tag="a" href="${action_url('view', instance)}" + <once-button tag="a" href="${master.get_action_url('view', instance)}" icon-left="eye" text="View This"> </once-button> % endif % if master.editable and instance_editable and master.has_perm('edit'): - <once-button tag="a" href="${action_url('edit', instance)}" + <once-button tag="a" href="${master.get_action_url('edit', instance)}" icon-left="edit" text="Edit This"> </once-button> % endif - % elif master and master.cloning: + % elif master and getattr(master, 'cloning', False): % if master.viewable and master.has_perm('view'): - <once-button tag="a" href="${action_url('view', instance)}" + <once-button tag="a" href="${master.get_action_url('view', instance)}" icon-left="eye" text="View This"> </once-button> diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 1e917902..f2d78b80 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -1366,7 +1366,7 @@ class MasterView(View): txnid=txn.id) kwargs = { - 'component': 'versions-grid', + 'vue_tagname': 'versions-grid', 'ajax_data_url': self.get_action_url('revisions_data', obj), 'sortable': True, 'default_sortkey': 'changed', @@ -4421,7 +4421,7 @@ class MasterView(View): 'request': self.request, 'readonly': self.viewing, 'model_class': getattr(self, 'model_class', None), - 'action_url': self.request.current_route_url(_query=None), + 'action_url': self.request.path_url, 'assume_local_times': self.has_local_times, 'route_prefix': route_prefix, 'can_edit_help': self.can_edit_help(), diff --git a/tailbone/views/principal.py b/tailbone/views/principal.py index b053453d..bb799efc 100644 --- a/tailbone/views/principal.py +++ b/tailbone/views/principal.py @@ -54,7 +54,7 @@ class PrincipalMasterView(MasterView): View for finding all users who have been granted a given permission """ permissions = copy.deepcopy( - self.request.registry.settings.get('tailbone_permissions', {})) + self.request.registry.settings.get('wutta_permissions', {})) # sort groups, and permissions for each group, for UI's sake sorted_perms = sorted(permissions.items(), key=self.perm_sortkey) diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py index 09633c6e..b34b3673 100644 --- a/tailbone/views/roles.py +++ b/tailbone/views/roles.py @@ -287,8 +287,8 @@ class RoleView(PrincipalMasterView): if the current user is an admin; otherwise it will be the "subset" of permissions which the current user has been granted. """ - # fetch full set of permissions registered in the app - permissions = self.request.registry.settings.get('tailbone_permissions', {}) + # get all known permissions from settings cache + permissions = self.request.registry.settings.get('wutta_permissions', {}) # admin user gets to manage all permissions if self.request.is_admin: diff --git a/tailbone/views/users.py b/tailbone/views/users.py index 1012575a..f8bcb1b8 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -276,7 +276,7 @@ class UserView(PrincipalMasterView): # fs.confirm_password.attrs(autocomplete='new-password') if self.viewing: - permissions = self.request.registry.settings.get('tailbone_permissions', {}) + permissions = self.request.registry.settings.get('wutta_permissions', {}) f.set_renderer('permissions', PermissionsRenderer(request=self.request, permissions=permissions, include_anonymous=True, diff --git a/tests/__init__.py b/tests/__init__.py index 7dec63f0..40d8071f 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -12,9 +12,6 @@ class TestCase(unittest.TestCase): def setUp(self): self.config = testing.setUp() - # TODO: this probably shouldn't (need to) be here - self.config.add_directive('add_tailbone_permission_group', 'tailbone.auth.add_permission_group') - self.config.add_directive('add_tailbone_permission', 'tailbone.auth.add_permission') def tearDown(self): testing.tearDown() diff --git a/tests/forms/__init__.py b/tests/forms/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/forms/test_core.py b/tests/forms/test_core.py new file mode 100644 index 00000000..894d2302 --- /dev/null +++ b/tests/forms/test_core.py @@ -0,0 +1,153 @@ +# -*- coding: utf-8; -*- + +from unittest.mock import patch + +import deform +from pyramid import testing + +from tailbone.forms import core as mod +from tests.util import WebTestCase + + +class TestForm(WebTestCase): + + def setUp(self): + self.setup_web() + self.config.setdefault('rattail.web.menus.handler_spec', 'tests.util:NullMenuHandler') + + def make_form(self, **kwargs): + kwargs.setdefault('request', self.request) + return mod.Form(**kwargs) + + def test_basic(self): + form = self.make_form() + self.assertIsInstance(form, mod.Form) + + def test_vue_tagname(self): + + # default + form = self.make_form() + self.assertEqual(form.vue_tagname, 'tailbone-form') + + # can override with param + form = self.make_form(vue_tagname='something-else') + self.assertEqual(form.vue_tagname, 'something-else') + + # can still pass old param + form = self.make_form(component='legacy-name') + self.assertEqual(form.vue_tagname, 'legacy-name') + + def test_vue_component(self): + + # default + form = self.make_form() + self.assertEqual(form.vue_component, 'TailboneForm') + + # can override with param + form = self.make_form(vue_tagname='something-else') + self.assertEqual(form.vue_component, 'SomethingElse') + + # can still pass old param + form = self.make_form(component='legacy-name') + self.assertEqual(form.vue_component, 'LegacyName') + + def test_component(self): + + # default + form = self.make_form() + self.assertEqual(form.component, 'tailbone-form') + + # can override with param + form = self.make_form(vue_tagname='something-else') + self.assertEqual(form.component, 'something-else') + + # can still pass old param + form = self.make_form(component='legacy-name') + self.assertEqual(form.component, 'legacy-name') + + def test_component_studly(self): + + # default + form = self.make_form() + self.assertEqual(form.component_studly, 'TailboneForm') + + # can override with param + form = self.make_form(vue_tagname='something-else') + self.assertEqual(form.component_studly, 'SomethingElse') + + # can still pass old param + form = self.make_form(component='legacy-name') + self.assertEqual(form.component_studly, 'LegacyName') + + def test_button_label_submit(self): + form = self.make_form() + + # default + self.assertEqual(form.button_label_submit, "Submit") + + # can set submit_label + with patch.object(form, 'submit_label', new="Submit Label", create=True): + self.assertEqual(form.button_label_submit, "Submit Label") + + # can set save_label + with patch.object(form, 'save_label', new="Save Label"): + self.assertEqual(form.button_label_submit, "Save Label") + + # can set button_label_submit + form.button_label_submit = "New Label" + self.assertEqual(form.button_label_submit, "New Label") + + def test_get_deform(self): + model = self.app.model + + # sanity check + form = self.make_form(model_class=model.Setting) + dform = form.get_deform() + self.assertIsInstance(dform, deform.Form) + + def test_render_vue_tag(self): + model = self.app.model + + # sanity check + form = self.make_form(model_class=model.Setting) + html = form.render_vue_tag() + self.assertIn('<tailbone-form', html) + + def test_render_vue_template(self): + self.pyramid_config.include('tailbone.views.common') + model = self.app.model + + # sanity check + form = self.make_form(model_class=model.Setting) + html = form.render_vue_template(session=self.session) + self.assertIn('<form ', html) + + def test_get_vue_field_value(self): + model = self.app.model + form = self.make_form(model_class=model.Setting) + + # TODO: yikes what a hack (?) + dform = form.get_deform() + dform.set_appstruct({'name': 'foo', 'value': 'bar'}) + + # null for missing field + value = form.get_vue_field_value('doesnotexist') + self.assertIsNone(value) + + # normal value is returned + value = form.get_vue_field_value('name') + self.assertEqual(value, 'foo') + + # but not if we remove field from deform + # TODO: what is the use case here again? + dform.children.remove(dform['name']) + value = form.get_vue_field_value('name') + self.assertIsNone(value) + + def test_render_vue_field(self): + model = self.app.model + + # sanity check + form = self.make_form(model_class=model.Setting) + html = form.render_vue_field('name', session=self.session) + self.assertIn('<b-field ', html) diff --git a/tests/grids/__init__.py b/tests/grids/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/grids/test_core.py b/tests/grids/test_core.py new file mode 100644 index 00000000..e6f9d675 --- /dev/null +++ b/tests/grids/test_core.py @@ -0,0 +1,139 @@ +# -*- coding: utf-8; -*- + +from unittest.mock import MagicMock + +from tailbone.grids import core as mod +from tests.util import WebTestCase + + +class TestGrid(WebTestCase): + + def setUp(self): + self.setup_web() + self.config.setdefault('rattail.web.menus.handler_spec', 'tests.util:NullMenuHandler') + + def make_grid(self, key, data=[], **kwargs): + kwargs.setdefault('request', self.request) + return mod.Grid(key, data=data, **kwargs) + + def test_basic(self): + grid = self.make_grid('foo') + self.assertIsInstance(grid, mod.Grid) + + def test_vue_tagname(self): + + # default + grid = self.make_grid('foo') + self.assertEqual(grid.vue_tagname, 'tailbone-grid') + + # can override with param + grid = self.make_grid('foo', vue_tagname='something-else') + self.assertEqual(grid.vue_tagname, 'something-else') + + # can still pass old param + grid = self.make_grid('foo', component='legacy-name') + self.assertEqual(grid.vue_tagname, 'legacy-name') + + def test_vue_component(self): + + # default + grid = self.make_grid('foo') + self.assertEqual(grid.vue_component, 'TailboneGrid') + + # can override with param + grid = self.make_grid('foo', vue_tagname='something-else') + self.assertEqual(grid.vue_component, 'SomethingElse') + + # can still pass old param + grid = self.make_grid('foo', component='legacy-name') + self.assertEqual(grid.vue_component, 'LegacyName') + + def test_component(self): + + # default + grid = self.make_grid('foo') + self.assertEqual(grid.component, 'tailbone-grid') + + # can override with param + grid = self.make_grid('foo', vue_tagname='something-else') + self.assertEqual(grid.component, 'something-else') + + # can still pass old param + grid = self.make_grid('foo', component='legacy-name') + self.assertEqual(grid.component, 'legacy-name') + + def test_component_studly(self): + + # default + grid = self.make_grid('foo') + self.assertEqual(grid.component_studly, 'TailboneGrid') + + # can override with param + grid = self.make_grid('foo', vue_tagname='something-else') + self.assertEqual(grid.component_studly, 'SomethingElse') + + # can still pass old param + grid = self.make_grid('foo', component='legacy-name') + self.assertEqual(grid.component_studly, 'LegacyName') + + def test_actions(self): + + # default + grid = self.make_grid('foo') + self.assertEqual(grid.actions, []) + + # main actions + grid = self.make_grid('foo', main_actions=['foo']) + self.assertEqual(grid.actions, ['foo']) + + # more actions + grid = self.make_grid('foo', main_actions=['foo'], more_actions=['bar']) + self.assertEqual(grid.actions, ['foo', 'bar']) + + def test_render_vue_tag(self): + model = self.app.model + + # standard + grid = self.make_grid('settings', model_class=model.Setting) + html = grid.render_vue_tag() + self.assertIn('<tailbone-grid', html) + self.assertNotIn('@deleteActionClicked', html) + + # with delete hook + master = MagicMock(deletable=True, delete_confirm='simple') + master.has_perm.return_value = True + grid = self.make_grid('settings', model_class=model.Setting) + html = grid.render_vue_tag(master=master) + self.assertIn('<tailbone-grid', html) + self.assertIn('@deleteActionClicked', html) + + def test_render_vue_template(self): + # self.pyramid_config.include('tailbone.views.common') + model = self.app.model + + # sanity check + grid = self.make_grid('settings', model_class=model.Setting) + html = grid.render_vue_template(session=self.session) + self.assertIn('<b-table', html) + + def test_get_vue_columns(self): + model = self.app.model + + # sanity check + grid = self.make_grid('settings', model_class=model.Setting) + columns = grid.get_vue_columns() + self.assertEqual(len(columns), 2) + self.assertEqual(columns[0]['field'], 'name') + self.assertEqual(columns[1]['field'], 'value') + + def test_get_vue_data(self): + model = self.app.model + + # sanity check + grid = self.make_grid('settings', model_class=model.Setting) + data = grid.get_vue_data() + self.assertEqual(data, []) + + # calling again returns same data + data2 = grid.get_vue_data() + self.assertIs(data2, data) diff --git a/tests/test_app.py b/tests/test_app.py index 2523c424..e16461ba 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -3,14 +3,14 @@ import os from unittest import TestCase -from sqlalchemy import create_engine +from pyramid.config import Configurator + +from wuttjamaican.testing import FileConfigTestCase -from rattail.config import RattailConfig from rattail.exceptions import ConfigurationError -from rattail.db import Session as RattailSession - -from tailbone import app -from tailbone.db import Session as TailboneSession +from rattail.config import RattailConfig +from tailbone import app as mod +from tests.util import DataTestCase class TestRattailConfig(TestCase): @@ -18,15 +18,34 @@ class TestRattailConfig(TestCase): config_path = os.path.abspath( os.path.join(os.path.dirname(__file__), 'data', 'tailbone.conf')) - def tearDown(self): - # may or may not be necessary depending on test - TailboneSession.remove() - def test_settings_arg_must_include_config_path_by_default(self): # error raised if path not provided - self.assertRaises(ConfigurationError, app.make_rattail_config, {}) + self.assertRaises(ConfigurationError, mod.make_rattail_config, {}) # get a config object if path provided - result = app.make_rattail_config({'rattail.config': self.config_path}) + result = mod.make_rattail_config({'rattail.config': self.config_path}) # nb. cannot test isinstance(RattailConfig) b/c now uses wrapper! self.assertIsNotNone(result) self.assertTrue(hasattr(result, 'get')) + + +class TestMakePyramidConfig(DataTestCase): + + def make_config(self): + myconf = self.write_file('web.conf', """ +[rattail.db] +default.url = sqlite:// +""") + + self.settings = { + 'rattail.config': myconf, + 'mako.directories': 'tailbone:templates', + } + return mod.make_rattail_config(self.settings) + + def test_basic(self): + model = self.app.model + model.Base.metadata.create_all(bind=self.config.appdb_engine) + + # sanity check + pyramid_config = mod.make_pyramid_config(self.settings) + self.assertIsInstance(pyramid_config, Configurator) diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 00000000..4519e152 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8; -*- + +from tailbone import auth as mod diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 00000000..0cd1938c --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8; -*- + +from tailbone import config as mod +from tests.util import DataTestCase + + +class TestConfigExtension(DataTestCase): + + def test_basic(self): + # sanity / coverage check + ext = mod.ConfigExtension() + ext.configure(self.config) diff --git a/tests/test_subscribers.py b/tests/test_subscribers.py new file mode 100644 index 00000000..81bc2869 --- /dev/null +++ b/tests/test_subscribers.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8; -*- + +from unittest.mock import MagicMock + +from pyramid import testing + +from tailbone import subscribers as mod +from tests.util import DataTestCase + + +class TestNewRequest(DataTestCase): + + def setUp(self): + self.setup_db() + self.request = self.make_request() + self.pyramid_config = testing.setUp(request=self.request, settings={ + 'wutta_config': self.config, + }) + + def tearDown(self): + self.teardown_db() + testing.tearDown() + + def make_request(self, **kwargs): + return testing.DummyRequest(**kwargs) + + def make_event(self): + return MagicMock(request=self.request) + + def test_continuum_remote_addr(self): + event = self.make_event() + + # nothing happens + mod.new_request(event, session=self.session) + self.assertFalse(hasattr(self.session, 'continuum_remote_addr')) + + # unless request has client_addr + self.request.client_addr = '127.0.0.1' + mod.new_request(event, session=self.session) + self.assertEqual(self.session.continuum_remote_addr, '127.0.0.1') + + def test_register_component(self): + event = self.make_event() + + # function added + self.assertFalse(hasattr(self.request, 'register_component')) + mod.new_request(event, session=self.session) + self.assertTrue(callable(self.request.register_component)) + + # call function + self.request.register_component('tailbone-datepicker', 'TailboneDatepicker') + self.assertEqual(self.request._tailbone_registered_components, + {'tailbone-datepicker': 'TailboneDatepicker'}) + + # duplicate registration ignored + self.request.register_component('tailbone-datepicker', 'TailboneDatepicker') + self.assertEqual(self.request._tailbone_registered_components, + {'tailbone-datepicker': 'TailboneDatepicker'}) diff --git a/tests/util.py b/tests/util.py new file mode 100644 index 00000000..98d89ce0 --- /dev/null +++ b/tests/util.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8; -*- + +from unittest.mock import MagicMock + +from pyramid import testing + +from tailbone import subscribers +from wuttaweb.menus import MenuHandler +# from wuttaweb.subscribers import new_request_set_user +from rattail.testing import DataTestCase + + +class WebTestCase(DataTestCase): + """ + Base class for test suites requiring a full (typical) web app. + """ + + def setUp(self): + self.setup_web() + + def setup_web(self): + self.setup_db() + self.request = self.make_request() + self.pyramid_config = testing.setUp(request=self.request, settings={ + 'wutta_config': self.config, + 'rattail_config': self.config, + 'mako.directories': ['tailbone:templates'], + # 'pyramid_deform.template_search_path': 'wuttaweb:templates/deform', + }) + + # init web + # self.pyramid_config.include('pyramid_deform') + self.pyramid_config.include('pyramid_mako') + self.pyramid_config.add_directive('add_wutta_permission_group', + 'wuttaweb.auth.add_permission_group') + self.pyramid_config.add_directive('add_wutta_permission', + 'wuttaweb.auth.add_permission') + self.pyramid_config.add_directive('add_tailbone_permission_group', + 'wuttaweb.auth.add_permission_group') + self.pyramid_config.add_directive('add_tailbone_permission', + 'wuttaweb.auth.add_permission') + self.pyramid_config.add_directive('add_tailbone_index_page', + 'tailbone.app.add_index_page') + self.pyramid_config.add_directive('add_tailbone_model_view', + 'tailbone.app.add_model_view') + self.pyramid_config.add_subscriber('tailbone.subscribers.before_render', + 'pyramid.events.BeforeRender') + self.pyramid_config.include('tailbone.static') + + # setup new request w/ anonymous user + event = MagicMock(request=self.request) + subscribers.new_request(event, session=self.session) + # def user_getter(request, **kwargs): pass + # new_request_set_user(event, db_session=self.session, + # user_getter=user_getter) + + def tearDown(self): + self.teardown_web() + + def teardown_web(self): + testing.tearDown() + self.teardown_db() + + def make_request(self, **kwargs): + kwargs.setdefault('rattail_config', self.config) + # kwargs.setdefault('wutta_config', self.config) + return testing.DummyRequest(**kwargs) + + +class NullMenuHandler(MenuHandler): + """ + Dummy menu handler for testing. + """ + def make_menus(self, request, **kwargs): + return [] diff --git a/tests/views/test_master.py b/tests/views/test_master.py new file mode 100644 index 00000000..19321496 --- /dev/null +++ b/tests/views/test_master.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8; -*- + +from unittest.mock import patch + +from tailbone.views import master as mod +from tests.util import WebTestCase + + +class TestMasterView(WebTestCase): + + def make_view(self): + return mod.MasterView(self.request) + + def test_make_form_kwargs(self): + self.pyramid_config.add_route('settings.view', '/settings/{name}') + model = self.app.model + setting = model.Setting(name='foo', value='bar') + self.session.add(setting) + self.session.commit() + with patch.multiple(mod.MasterView, create=True, + model_class=model.Setting): + view = self.make_view() + + # sanity / coverage check + kw = view.make_form_kwargs(model_instance=setting) + self.assertIsNotNone(kw['action_url']) diff --git a/tests/views/test_principal.py b/tests/views/test_principal.py new file mode 100644 index 00000000..2b31531c --- /dev/null +++ b/tests/views/test_principal.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8; -*- + +from unittest.mock import patch, MagicMock + +from tailbone.views import principal as mod +from tests.util import WebTestCase + + +class TestPrincipalMasterView(WebTestCase): + + def make_view(self): + return mod.PrincipalMasterView(self.request) + + def test_find_by_perm(self): + model = self.app.model + self.config.setdefault('rattail.web.menus.handler_spec', 'tests.util:NullMenuHandler') + self.pyramid_config.include('tailbone.views.common') + self.pyramid_config.include('tailbone.views.auth') + self.pyramid_config.add_route('roles', '/roles/') + with patch.multiple(mod.PrincipalMasterView, create=True, + model_class=model.Role, + get_help_url=MagicMock(return_value=None), + get_help_markdown=MagicMock(return_value=None), + can_edit_help=MagicMock(return_value=False)): + + # sanity / coverage check + view = self.make_view() + response = view.find_by_perm() + self.assertEqual(response.status_code, 200) diff --git a/tests/views/test_roles.py b/tests/views/test_roles.py new file mode 100644 index 00000000..0cdc724e --- /dev/null +++ b/tests/views/test_roles.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8; -*- + +from unittest.mock import patch + +from tailbone.views import roles as mod +from tests.util import WebTestCase + + +class TestRoleView(WebTestCase): + + def make_view(self): + return mod.RoleView(self.request) + + def test_includeme(self): + self.pyramid_config.include('tailbone.views.roles') + + def get_permissions(self): + return { + 'widgets': { + 'label': "Widgets", + 'perms': { + 'widgets.list': { + 'label': "List widgets", + }, + 'widgets.polish': { + 'label': "Polish the widgets", + }, + 'widgets.view': { + 'label': "View widget", + }, + }, + }, + } + + def test_get_available_permissions(self): + model = self.app.model + auth = self.app.get_auth_handler() + blokes = model.Role(name="Blokes") + auth.grant_permission(blokes, 'widgets.list') + self.session.add(blokes) + barney = model.User(username='barney') + barney.roles.append(blokes) + self.session.add(barney) + self.session.commit() + view = self.make_view() + all_perms = self.get_permissions() + self.request.registry.settings['wutta_permissions'] = all_perms + + def has_perm(perm): + if perm == 'widgets.list': + return True + return False + + with patch.object(self.request, 'has_perm', new=has_perm, create=True): + + # sanity check; current request has 1 perm + self.assertTrue(self.request.has_perm('widgets.list')) + self.assertFalse(self.request.has_perm('widgets.polish')) + self.assertFalse(self.request.has_perm('widgets.view')) + + # when editing, user sees only the 1 perm + with patch.object(view, 'editing', new=True): + perms = view.get_available_permissions() + self.assertEqual(list(perms), ['widgets']) + self.assertEqual(list(perms['widgets']['perms']), ['widgets.list']) + + # but when viewing, same user sees all perms + with patch.object(view, 'viewing', new=True): + perms = view.get_available_permissions() + self.assertEqual(list(perms), ['widgets']) + self.assertEqual(list(perms['widgets']['perms']), + ['widgets.list', 'widgets.polish', 'widgets.view']) + + # also, when admin user is editing, sees all perms + self.request.is_admin = True + with patch.object(view, 'editing', new=True): + perms = view.get_available_permissions() + self.assertEqual(list(perms), ['widgets']) + self.assertEqual(list(perms['widgets']['perms']), + ['widgets.list', 'widgets.polish', 'widgets.view']) diff --git a/tests/views/test_users.py b/tests/views/test_users.py new file mode 100644 index 00000000..4b94caf2 --- /dev/null +++ b/tests/views/test_users.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8; -*- + +from unittest.mock import patch, MagicMock + +from tailbone.views import users as mod +from tailbone.views.principal import PermissionsRenderer +from tests.util import WebTestCase + + +class TestUserView(WebTestCase): + + def make_view(self): + return mod.UserView(self.request) + + def test_includeme(self): + self.pyramid_config.include('tailbone.views.users') + + def test_configure_form(self): + self.pyramid_config.include('tailbone.views.users') + model = self.app.model + barney = model.User(username='barney') + self.session.add(barney) + self.session.commit() + view = self.make_view() + + # must use mock configure when making form + def configure(form): pass + form = view.make_form(instance=barney, configure=configure) + + with patch.object(view, 'viewing', new=True): + self.assertNotIn('permissions', form.renderers) + view.configure_form(form) + self.assertIsInstance(form.renderers['permissions'], PermissionsRenderer) From dd176a5e9e43752ef87e16440e74f45ce303f1f3 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 15 Aug 2024 16:05:53 -0500 Subject: [PATCH 1572/1681] feat: add first wutta-based master, for PersonView still opt-in-only at this point, the traditional tailbone-native master is used by default. new wutta master is not feature complete yet. but at least things seem to render and form posts work okay.. when enabled, this uses a "completely" wutta-based stack for the view, grid and forms. but the underlying DB is of course rattail, and the templates are still traditional/tailbone. --- tailbone/views/people.py | 6 +- tailbone/views/wutta/__init__.py | 0 tailbone/views/wutta/people.py | 102 +++++++++++++++++++++++++++++++ tests/util.py | 2 + tests/views/test_people.py | 17 ++++++ tests/views/wutta/__init__.py | 0 tests/views/wutta/test_people.py | 47 ++++++++++++++ 7 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 tailbone/views/wutta/__init__.py create mode 100644 tailbone/views/wutta/people.py create mode 100644 tests/views/test_people.py create mode 100644 tests/views/wutta/__init__.py create mode 100644 tests/views/wutta/test_people.py diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 9b28b94d..94c85821 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -2187,4 +2187,8 @@ def defaults(config, **kwargs): def includeme(config): - defaults(config) + wutta_config = config.registry.settings['wutta_config'] + if wutta_config.get_bool('tailbone.use_wutta_views', default=False, usedb=False): + config.include('tailbone.views.wutta.people') + else: + defaults(config) diff --git a/tailbone/views/wutta/__init__.py b/tailbone/views/wutta/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tailbone/views/wutta/people.py b/tailbone/views/wutta/people.py new file mode 100644 index 00000000..44cc26d9 --- /dev/null +++ b/tailbone/views/wutta/people.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Person Views +""" + +from rattail.db.model import Person + +from wuttaweb.views import people as wutta +from tailbone.views import people as tailbone +from tailbone.db import Session + + +class PersonView(wutta.PersonView): + """ + This is the first attempt at blending newer Wutta views with + legacy Tailbone config. + + So, this is a Wutta-based view but it should be included by a + Tailbone app configurator. + """ + model_class = Person + Session = Session + + # labels = { + # 'display_name': "Full Name", + # } + + grid_columns = [ + 'display_name', + 'first_name', + 'last_name', + 'phone', + 'email', + 'merge_requested', + ] + + form_fields = [ + 'first_name', + 'middle_name', + 'last_name', + 'display_name', + 'default_phone', + 'default_email', + # 'address', + # 'employee', + 'customers', + # 'members', + 'users', + ] + + def get_query(self, session=None): + """ """ + model = self.app.model + session = session or self.Session() + return session.query(model.Person)\ + .order_by(model.Person.display_name) + + def configure_form(self, f): + """ """ + super().configure_form(f) + + # default_phone + f.set_required('default_phone', False) + + # default_email + f.set_required('default_email', False) + + # customers + if self.creating or self.editing: + f.remove('customers') + + +def defaults(config, **kwargs): + base = globals() + + kwargs.setdefault('PersonView', base['PersonView']) + tailbone.defaults(config, **kwargs) + + +def includeme(config): + defaults(config) diff --git a/tests/util.py b/tests/util.py index 98d89ce0..3aa04f5e 100644 --- a/tests/util.py +++ b/tests/util.py @@ -43,6 +43,8 @@ class WebTestCase(DataTestCase): 'tailbone.app.add_index_page') self.pyramid_config.add_directive('add_tailbone_model_view', 'tailbone.app.add_model_view') + self.pyramid_config.add_directive('add_tailbone_config_page', + 'tailbone.app.add_config_page') self.pyramid_config.add_subscriber('tailbone.subscribers.before_render', 'pyramid.events.BeforeRender') self.pyramid_config.include('tailbone.static') diff --git a/tests/views/test_people.py b/tests/views/test_people.py new file mode 100644 index 00000000..f85577e7 --- /dev/null +++ b/tests/views/test_people.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8; -*- + +from tailbone.views import users as mod +from tests.util import WebTestCase + + +class TestPersonView(WebTestCase): + + def make_view(self): + return mod.PersonView(self.request) + + def test_includeme(self): + self.pyramid_config.include('tailbone.views.people') + + def test_includeme_wutta(self): + self.config.setdefault('tailbone.use_wutta_views', 'true') + self.pyramid_config.include('tailbone.views.people') diff --git a/tests/views/wutta/__init__.py b/tests/views/wutta/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/views/wutta/test_people.py b/tests/views/wutta/test_people.py new file mode 100644 index 00000000..7795d641 --- /dev/null +++ b/tests/views/wutta/test_people.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8; -*- + +from unittest.mock import patch + +from sqlalchemy import orm + +from tailbone.views.wutta import people as mod +from tests.util import WebTestCase + + +class TestPersonView(WebTestCase): + + def make_view(self): + return mod.PersonView(self.request) + + def test_includeme(self): + self.pyramid_config.include('tailbone.views.wutta.people') + + def test_get_query(self): + view = self.make_view() + + # sanity / coverage check + query = view.get_query(session=self.session) + self.assertIsInstance(query, orm.Query) + + def test_configure_form(self): + model = self.app.model + barney = model.User(username='barney') + self.session.add(barney) + self.session.commit() + view = self.make_view() + + # customers field remains when viewing + with patch.object(view, 'viewing', new=True): + form = view.make_form(model_instance=barney, + fields=view.get_form_fields()) + self.assertIn('customers', form.fields) + view.configure_form(form) + self.assertIn('customers', form) + + # customers field removed when editing + with patch.object(view, 'editing', new=True): + form = view.make_form(model_instance=barney, + fields=view.get_form_fields()) + self.assertIn('customers', form.fields) + view.configure_form(form) + self.assertNotIn('customers', form) From bab09e3fe73af86f3ecb9501ac35f26a270ff35a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 15 Aug 2024 16:22:35 -0500 Subject: [PATCH 1573/1681] =?UTF-8?q?bump:=20version=200.15.6=20=E2=86=92?= =?UTF-8?q?=200.16.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 +++++++ pyproject.toml | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3836ff08..401c1b25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.16.0 (2024-08-15) + +### Feat + +- add first wutta-based master, for PersonView +- refactor forms/grids/views/templates per wuttaweb compat + ## v0.15.6 (2024-08-13) ### Fix diff --git a/pyproject.toml b/pyproject.toml index e515a0d0..dc0887d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.15.6" +version = "0.16.0" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] @@ -59,7 +59,7 @@ dependencies = [ "transaction", "waitress", "WebHelpers2", - "WuttaWeb>=0.2.0", + "WuttaWeb>=0.7.0", "zope.sqlalchemy>=1.5", ] From 1cacfab2a63a5d9b6216a3d1173e9efbc7919848 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 15 Aug 2024 18:44:14 -0500 Subject: [PATCH 1574/1681] fix: tweak template for `people/view_profile` per wutta compat wutta has the view defined but it returns minimal context --- tailbone/templates/people/view_profile.mako | 26 +++++++++++++++------ 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/tailbone/templates/people/view_profile.mako b/tailbone/templates/people/view_profile.mako index 8044f7c6..cdb6c5cc 100644 --- a/tailbone/templates/people/view_profile.mako +++ b/tailbone/templates/people/view_profile.mako @@ -15,7 +15,7 @@ </%def> <%def name="content_title()"> - ${dynamic_content_title} + ${dynamic_content_title or str(instance)} </%def> <%def name="render_instance_header_title_extras()"> @@ -1008,7 +1008,7 @@ <div style="display: flex; justify-content: space-between; width: 100%;"> <div style="flex-grow: 1;"> - <b-field horizontal label="${customer_key_label}"> + <b-field horizontal label="${customer_key_label or 'TODO: Customer Key'}"> {{ customer._key }} </b-field> @@ -1996,7 +1996,9 @@ <script type="text/javascript"> let PersonalTabData = { + % if hasattr(master, 'profile_tab_personal'): refreshTabURL: '${url('people.profile_tab_personal', uuid=person.uuid)}', + % endif // nb. hack to force refresh for vue3 refreshPersonalCard: 1, @@ -2447,7 +2449,9 @@ <script type="text/javascript"> let CustomerTabData = { + % if hasattr(master, 'profile_tab_customer'): refreshTabURL: '${url('people.profile_tab_customer', uuid=person.uuid)}', + % endif customers: [], } @@ -2521,7 +2525,9 @@ <script type="text/javascript"> let EmployeeTabData = { + % if hasattr(master, 'profile_tab_employee'): refreshTabURL: '${url('people.profile_tab_employee', uuid=person.uuid)}', + % endif employee: {}, employeeHistory: [], @@ -2756,7 +2762,9 @@ <script type="text/javascript"> let NotesTabData = { + % if hasattr(master, 'profile_tab_notes'): refreshTabURL: '${url('people.profile_tab_notes', uuid=person.uuid)}', + % endif notes: [], noteTypeOptions: [], @@ -2920,7 +2928,9 @@ <script type="text/javascript"> let UserTabData = { + % if hasattr(master, 'profile_tab_user'): refreshTabURL: '${url('people.profile_tab_user', uuid=person.uuid)}', + % endif users: [], % if request.has_perm('users.create'): @@ -2976,7 +2986,9 @@ createUserSave() { this.createUserSaving = true + % if hasattr(master, 'profile_make_user'): let url = '${master.get_action_url('profile_make_user', instance)}' + % endif let params = { username: this.createUserUsername, active: this.createUserActive, @@ -3015,13 +3027,13 @@ let ProfileInfoData = { activeTab: location.hash ? location.hash.substring(1) : 'personal', - tabchecks: ${json.dumps(tabchecks)|n}, + tabchecks: ${json.dumps(tabchecks or {})|n}, today: '${rattail_app.today()}', profileLastChanged: Date.now(), - person: ${json.dumps(person_data)|n}, - phoneTypeOptions: ${json.dumps(phone_type_options)|n}, - emailTypeOptions: ${json.dumps(email_type_options)|n}, - maxLengths: ${json.dumps(max_lengths)|n}, + person: ${json.dumps(person_data or {})|n}, + phoneTypeOptions: ${json.dumps(phone_type_options or [])|n}, + emailTypeOptions: ${json.dumps(email_type_options or [])|n}, + maxLengths: ${json.dumps(max_lengths or {})|n}, % if request.has_perm('people_profile.view_versions'): loadingRevisions: false, From 53040dc6befed83aaf691000c951b2678669a499 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 15 Aug 2024 20:29:36 -0500 Subject: [PATCH 1575/1681] fix: update references to `get_class_hierarchy()` per upstream changes --- tailbone/views/master.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index f2d78b80..0d322da3 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -39,8 +39,9 @@ from sqlalchemy import orm import sqlalchemy_continuum as continuum from sqlalchemy_utils.functions import get_primary_keys, get_columns +from wuttjamaican.util import get_class_hierarchy from rattail.db.continuum import model_transaction_query -from rattail.util import simple_error, get_class_hierarchy +from rattail.util import simple_error from rattail.threads import Thread from rattail.csvutil import UnicodeDictWriter from rattail.excel import ExcelWriter From 7f0c571a446a71520e70d666e11f6b9be5aeecb8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 15 Aug 2024 21:12:34 -0500 Subject: [PATCH 1576/1681] fix: improve wutta People view a bit try to behave more like traditional tailbone, for the few things supported so far. taking a conservative approach here for now since probably other things are more pressing. --- tailbone/views/wutta/people.py | 85 ++++++++++++++++++++++++-------- tests/views/wutta/test_people.py | 52 ++++++++++++++++--- 2 files changed, 110 insertions(+), 27 deletions(-) diff --git a/tailbone/views/wutta/people.py b/tailbone/views/wutta/people.py index 44cc26d9..c92e34ae 100644 --- a/tailbone/views/wutta/people.py +++ b/tailbone/views/wutta/people.py @@ -24,11 +24,14 @@ Person Views """ -from rattail.db.model import Person +import colander +import sqlalchemy as sa +from webhelpers2.html import HTML from wuttaweb.views import people as wutta from tailbone.views import people as tailbone from tailbone.db import Session +from rattail.db.model import Person class PersonView(wutta.PersonView): @@ -42,9 +45,9 @@ class PersonView(wutta.PersonView): model_class = Person Session = Session - # labels = { - # 'display_name': "Full Name", - # } + labels = { + 'display_name': "Full Name", + } grid_columns = [ 'display_name', @@ -60,15 +63,16 @@ class PersonView(wutta.PersonView): 'middle_name', 'last_name', 'display_name', - 'default_phone', - 'default_email', + 'phone', + 'email', + # TODO # 'address', - # 'employee', - 'customers', - # 'members', - 'users', ] + ############################## + # CRUD methods + ############################## + def get_query(self, session=None): """ """ model = self.app.model @@ -76,25 +80,64 @@ class PersonView(wutta.PersonView): return session.query(model.Person)\ .order_by(model.Person.display_name) + def configure_grid(self, g): + """ """ + super().configure_grid(g) + + # display_name + g.set_link('display_name') + + # first_name + g.set_link('first_name') + + # last_name + g.set_link('last_name') + + # merge_requested + g.set_label('merge_requested', "MR") + g.set_renderer('merge_requested', self.render_merge_requested) + def configure_form(self, f): """ """ super().configure_form(f) - # default_phone - f.set_required('default_phone', False) - - # default_email - f.set_required('default_email', False) - - # customers + # email if self.creating or self.editing: - f.remove('customers') + f.remove('email') + else: + # nb. avoid colanderalchemy + f.set_node('email', colander.String()) + + # phone + if self.creating or self.editing: + f.remove('phone') + else: + # nb. avoid colanderalchemy + f.set_node('phone', colander.String()) + + ############################## + # support methods + ############################## + + def render_merge_requested(self, person, key, value, session=None): + """ """ + model = self.app.model + session = session or self.Session() + merge_request = session.query(model.MergePeopleRequest)\ + .filter(sa.or_( + model.MergePeopleRequest.removing_uuid == person.uuid, + model.MergePeopleRequest.keeping_uuid == person.uuid))\ + .filter(model.MergePeopleRequest.merged == None)\ + .first() + if merge_request: + return HTML.tag('span', + class_='has-text-danger has-text-weight-bold', + title="A merge has been requested for this person.", + c="MR") def defaults(config, **kwargs): - base = globals() - - kwargs.setdefault('PersonView', base['PersonView']) + kwargs.setdefault('PersonView', PersonView) tailbone.defaults(config, **kwargs) diff --git a/tests/views/wutta/test_people.py b/tests/views/wutta/test_people.py index 7795d641..f178a64f 100644 --- a/tests/views/wutta/test_people.py +++ b/tests/views/wutta/test_people.py @@ -23,6 +23,19 @@ class TestPersonView(WebTestCase): query = view.get_query(session=self.session) self.assertIsInstance(query, orm.Query) + def test_configure_grid(self): + model = self.app.model + barney = model.User(username='barney') + self.session.add(barney) + self.session.commit() + view = self.make_view() + + # sanity / coverage check + grid = view.make_grid(model_class=model.Person) + self.assertNotIn('first_name', grid.linked_columns) + view.configure_grid(grid) + self.assertIn('first_name', grid.linked_columns) + def test_configure_form(self): model = self.app.model barney = model.User(username='barney') @@ -30,18 +43,45 @@ class TestPersonView(WebTestCase): self.session.commit() view = self.make_view() - # customers field remains when viewing + # email field remains when viewing with patch.object(view, 'viewing', new=True): form = view.make_form(model_instance=barney, fields=view.get_form_fields()) - self.assertIn('customers', form.fields) + self.assertIn('email', form.fields) view.configure_form(form) - self.assertIn('customers', form) + self.assertIn('email', form) - # customers field removed when editing + # email field removed when editing with patch.object(view, 'editing', new=True): form = view.make_form(model_instance=barney, fields=view.get_form_fields()) - self.assertIn('customers', form.fields) + self.assertIn('email', form.fields) view.configure_form(form) - self.assertNotIn('customers', form) + self.assertNotIn('email', form) + + def test_render_merge_requested(self): + model = self.app.model + barney = model.Person(display_name="Barney Rubble") + self.session.add(barney) + user = model.User(username='user') + self.session.add(user) + self.session.commit() + view = self.make_view() + + # null by default + html = view.render_merge_requested(barney, 'merge_requested', None, + session=self.session) + self.assertIsNone(html) + + # unless a merge request exists + barney2 = model.Person(display_name="Barney Rubble") + self.session.add(barney2) + self.session.commit() + mr = model.MergePeopleRequest(removing_uuid=barney2.uuid, + keeping_uuid=barney.uuid, + requested_by=user) + self.session.add(mr) + self.session.commit() + html = view.render_merge_requested(barney, 'merge_requested', None, + session=self.session) + self.assertIn('<span ', html) From bbc2c584ec030b69e7c8f711d7d3e1a31a18bceb Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 15 Aug 2024 21:16:53 -0500 Subject: [PATCH 1577/1681] =?UTF-8?q?bump:=20version=200.16.0=20=E2=86=92?= =?UTF-8?q?=200.16.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 ++++++++ pyproject.toml | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 401c1b25..f532ae03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.16.1 (2024-08-15) + +### Fix + +- improve wutta People view a bit +- update references to `get_class_hierarchy()` +- tweak template for `people/view_profile` per wutta compat + ## v0.16.0 (2024-08-15) ### Feat diff --git a/pyproject.toml b/pyproject.toml index dc0887d4..69c35a68 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.16.0" +version = "0.16.1" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] @@ -53,7 +53,7 @@ dependencies = [ "pyramid_mako", "pyramid_retry", "pyramid_tm", - "rattail[db,bouncer]>=0.17.11", + "rattail[db,bouncer]>=0.18.1", "sa-filters", "simplejson", "transaction", From da0f6bd5e10a6f623d47d017ee862ea7e455faa2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 15 Aug 2024 23:12:02 -0500 Subject: [PATCH 1578/1681] feat: use wuttaweb for `get_liburl()` logic thankfully this is already handled and we can remove from tailbone. although this adds some new cruft as well, to handle auto-migrating any existing liburl config for apps. eventually once all apps have migrated to new settings we can remove the prefix from our calls here but also in wuttaweb signature --- tailbone/helpers.py | 4 +- tailbone/templates/appinfo/configure.mako | 8 +- tailbone/templates/base.mako | 10 +- .../templates/themes/butterball/base.mako | 14 +- tailbone/util.py | 162 +++--------------- tailbone/views/settings.py | 131 +++++++------- tests/views/test_settings.py | 10 ++ 7 files changed, 110 insertions(+), 229 deletions(-) create mode 100644 tests/views/test_settings.py diff --git a/tailbone/helpers.py b/tailbone/helpers.py index d4065cc5..23988423 100644 --- a/tailbone/helpers.py +++ b/tailbone/helpers.py @@ -36,11 +36,11 @@ from rattail.db.util import maxlen from webhelpers2.html import * from webhelpers2.html.tags import * +from wuttaweb.util import get_liburl from tailbone.util import (csrf_token, get_csrf_token, pretty_datetime, raw_datetime, render_markdown, - route_exists, - get_liburl) + route_exists) def pretty_date(date): diff --git a/tailbone/templates/appinfo/configure.mako b/tailbone/templates/appinfo/configure.mako index 280b5cb9..aab180c4 100644 --- a/tailbone/templates/appinfo/configure.mako +++ b/tailbone/templates/appinfo/configure.mako @@ -149,8 +149,8 @@ </${b}-table> % for weblib in weblibs: - ${h.hidden('tailbone.libver.{}'.format(weblib['key']), **{':value': "simpleSettings['tailbone.libver.{}']".format(weblib['key'])})} - ${h.hidden('tailbone.liburl.{}'.format(weblib['key']), **{':value': "simpleSettings['tailbone.liburl.{}']".format(weblib['key'])})} + ${h.hidden('wuttaweb.libver.{}'.format(weblib['key']), **{':value': "simpleSettings['wuttaweb.libver.{}']".format(weblib['key'])})} + ${h.hidden('wuttaweb.liburl.{}'.format(weblib['key']), **{':value': "simpleSettings['wuttaweb.liburl.{}']".format(weblib['key'])})} % endfor <${b}-modal has-modal-card @@ -236,8 +236,8 @@ this.editWebLibraryRecord.configured_url = this.editWebLibraryURL this.editWebLibraryRecord.modified = true - this.simpleSettings[`tailbone.libver.${'$'}{this.editWebLibraryRecord.key}`] = this.editWebLibraryVersion - this.simpleSettings[`tailbone.liburl.${'$'}{this.editWebLibraryRecord.key}`] = this.editWebLibraryURL + this.simpleSettings[`wuttaweb.libver.${'$'}{this.editWebLibraryRecord.key}`] = this.editWebLibraryVersion + this.simpleSettings[`wuttaweb.liburl.${'$'}{this.editWebLibraryRecord.key}`] = this.editWebLibraryURL this.settingsNeedSaved = true this.editWebLibraryShowDialog = false diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index 6811397b..27e900e4 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -122,16 +122,16 @@ </%def> <%def name="vuejs()"> - ${h.javascript_link(h.get_liburl(request, 'vue'))} - ${h.javascript_link(h.get_liburl(request, 'vue_resource'))} + ${h.javascript_link(h.get_liburl(request, 'vue', prefix='tailbone'))} + ${h.javascript_link(h.get_liburl(request, 'vue_resource', prefix='tailbone'))} </%def> <%def name="buefy()"> - ${h.javascript_link(h.get_liburl(request, 'buefy'))} + ${h.javascript_link(h.get_liburl(request, 'buefy', prefix='tailbone'))} </%def> <%def name="fontawesome()"> - <script defer src="${h.get_liburl(request, 'fontawesome')}"></script> + <script defer src="${h.get_liburl(request, 'fontawesome', prefix='tailbone')}"></script> </%def> <%def name="extra_javascript()"></%def> @@ -171,7 +171,7 @@ ${h.stylesheet_link(user_css)} % else: ## upstream Buefy CSS - ${h.stylesheet_link(h.get_liburl(request, 'buefy.css'))} + ${h.stylesheet_link(h.get_liburl(request, 'buefy.css', prefix='tailbone'))} % endif </%def> diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako index f06b45f9..306b3430 100644 --- a/tailbone/templates/themes/butterball/base.mako +++ b/tailbone/templates/themes/butterball/base.mako @@ -71,12 +71,12 @@ { ## TODO: eventually version / url should be configurable "imports": { - "vue": "${h.get_liburl(request, 'bb_vue')}", - "@oruga-ui/oruga-next": "${h.get_liburl(request, 'bb_oruga')}", - "@oruga-ui/theme-bulma": "${h.get_liburl(request, 'bb_oruga_bulma')}", - "@fortawesome/fontawesome-svg-core": "${h.get_liburl(request, 'bb_fontawesome_svg_core')}", - "@fortawesome/free-solid-svg-icons": "${h.get_liburl(request, 'bb_free_solid_svg_icons')}", - "@fortawesome/vue-fontawesome": "${h.get_liburl(request, 'bb_vue_fontawesome')}" + "vue": "${h.get_liburl(request, 'bb_vue', prefix='tailbone')}", + "@oruga-ui/oruga-next": "${h.get_liburl(request, 'bb_oruga', prefix='tailbone')}", + "@oruga-ui/theme-bulma": "${h.get_liburl(request, 'bb_oruga_bulma', prefix='tailbone')}", + "@fortawesome/fontawesome-svg-core": "${h.get_liburl(request, 'bb_fontawesome_svg_core', prefix='tailbone')}", + "@fortawesome/free-solid-svg-icons": "${h.get_liburl(request, 'bb_free_solid_svg_icons', prefix='tailbone')}", + "@fortawesome/vue-fontawesome": "${h.get_liburl(request, 'bb_vue_fontawesome', prefix='tailbone')}" } } </script> @@ -92,7 +92,7 @@ % if user_css: ${h.stylesheet_link(user_css)} % else: - ${h.stylesheet_link(h.get_liburl(request, 'bb_oruga_bulma_css'))} + ${h.stylesheet_link(h.get_liburl(request, 'bb_oruga_bulma_css', prefix='tailbone'))} % endif </%def> diff --git a/tailbone/util.py b/tailbone/util.py index eb6fb8a8..594fd69b 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -39,7 +39,9 @@ from pyramid.renderers import get_renderer from pyramid.interfaces import IRoutesMapper from webhelpers2.html import HTML, tags -from wuttaweb.util import get_form_data as wutta_get_form_data +from wuttaweb.util import (get_form_data as wutta_get_form_data, + get_libver as wutta_get_libver, + get_liburl as wutta_get_liburl) log = logging.getLogger(__name__) @@ -103,154 +105,32 @@ def get_global_search_options(request): return options -def get_libver(request, key, fallback=True, default_only=False): +def get_libver(request, key, fallback=True, default_only=False): # pragma: no cover """ - Return the appropriate URL for the library identified by ``key``. + DEPRECATED - use :func:`wuttaweb:wuttaweb.util.get_libver()` + instead. """ - config = request.rattail_config + warnings.warn("tailbone.util.get_libver() is deprecated; " + "please use wuttaweb.util.get_libver() instead", + DeprecationWarning, stacklevel=2) - if not default_only: - version = config.get('tailbone', 'libver.{}'.format(key)) - if version: - return version - - if not fallback and not default_only: - - if key == 'buefy': - version = config.get('tailbone', 'buefy_version') - if version: - return version - - elif key == 'buefy.css': - version = get_libver(request, 'buefy', fallback=False) - if version: - return version - - elif key == 'vue': - version = config.get('tailbone', 'vue_version') - if version: - return version - - return - - if key == 'buefy': - if not default_only: - version = config.get('tailbone', 'buefy_version') - if version: - return version - return 'latest' - - elif key == 'buefy.css': - version = get_libver(request, 'buefy', default_only=default_only) - if version: - return version - return 'latest' - - elif key == 'vue': - if not default_only: - version = config.get('tailbone', 'vue_version') - if version: - return version - return '2.6.14' - - elif key == 'vue_resource': - return 'latest' - - elif key == 'fontawesome': - return '5.3.1' - - elif key == 'bb_vue': - return '3.4.31' - - elif key == 'bb_oruga': - return '0.8.12' - - elif key in ('bb_oruga_bulma', 'bb_oruga_bulma_css'): - return '0.3.0' - - elif key == 'bb_fontawesome_svg_core': - return '6.5.2' - - elif key == 'bb_free_solid_svg_icons': - return '6.5.2' - - elif key == 'bb_vue_fontawesome': - return '3.0.6' + return wutta_get_libver(request, key, prefix='tailbone', + configured_only=not fallback, + default_only=default_only) -def get_liburl(request, key, fallback=True): +def get_liburl(request, key, fallback=True): # pragma: no cover """ - Return the appropriate URL for the library identified by ``key``. + DEPRECATED - use :func:`wuttaweb:wuttaweb.util.get_liburl()` + instead. """ - config = request.rattail_config + warnings.warn("tailbone.util.get_liburl() is deprecated; " + "please use wuttaweb.util.get_liburl() instead", + DeprecationWarning, stacklevel=2) - url = config.get('tailbone', 'liburl.{}'.format(key)) - if url: - return url - - if not fallback: - return - - version = get_libver(request, key) - - static = config.get('tailbone.static_libcache.module') - if static: - static = importlib.import_module(static) - needed = request.environ['fanstatic.needed'] - liburl = needed.library_url(static.libcache) + '/' - # nb. add custom url prefix if needed, e.g. /theo - if request.script_name: - liburl = request.script_name + liburl - - if key == 'buefy': - return 'https://unpkg.com/buefy@{}/dist/buefy.min.js'.format(version) - - elif key == 'buefy.css': - return 'https://unpkg.com/buefy@{}/dist/buefy.min.css'.format(version) - - elif key == 'vue': - return 'https://unpkg.com/vue@{}/dist/vue.min.js'.format(version) - - elif key == 'vue_resource': - return 'https://cdn.jsdelivr.net/npm/vue-resource@{}'.format(version) - - elif key == 'fontawesome': - return 'https://use.fontawesome.com/releases/v{}/js/all.js'.format(version) - - elif key == 'bb_vue': - if static and hasattr(static, 'bb_vue_js'): - return liburl + static.bb_vue_js.relpath - return f'https://unpkg.com/vue@{version}/dist/vue.esm-browser.prod.js' - - elif key == 'bb_oruga': - if static and hasattr(static, 'bb_oruga_js'): - return liburl + static.bb_oruga_js.relpath - return f'https://unpkg.com/@oruga-ui/oruga-next@{version}/dist/oruga.mjs' - - elif key == 'bb_oruga_bulma': - if static and hasattr(static, 'bb_oruga_bulma_js'): - return liburl + static.bb_oruga_bulma_js.relpath - return f'https://unpkg.com/@oruga-ui/theme-bulma@{version}/dist/bulma.mjs' - - elif key == 'bb_oruga_bulma_css': - if static and hasattr(static, 'bb_oruga_bulma_css'): - return liburl + static.bb_oruga_bulma_css.relpath - return f'https://unpkg.com/@oruga-ui/theme-bulma@{version}/dist/bulma.css' - - elif key == 'bb_fontawesome_svg_core': - if static and hasattr(static, 'bb_fontawesome_svg_core_js'): - return liburl + static.bb_fontawesome_svg_core_js.relpath - return f'https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-svg-core@{version}/+esm' - - elif key == 'bb_free_solid_svg_icons': - if static and hasattr(static, 'bb_free_solid_svg_icons_js'): - return liburl + static.bb_free_solid_svg_icons_js.relpath - return f'https://cdn.jsdelivr.net/npm/@fortawesome/free-solid-svg-icons@{version}/+esm' - - elif key == 'bb_vue_fontawesome': - if static and hasattr(static, 'bb_vue_fontawesome_js'): - return liburl + static.bb_vue_fontawesome_js.relpath - return f'https://cdn.jsdelivr.net/npm/@fortawesome/vue-fontawesome@{version}/+esm' + return wutta_get_liburl(request, key, prefix='tailbone', + configured_only=not fallback, + default_only=False) def pretty_datetime(config, value): diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index 8d389530..9d7f6e02 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -24,24 +24,23 @@ Settings Views """ +import json import os import re import subprocess import sys from collections import OrderedDict -import json +import colander from rattail.db.model import Setting from rattail.settings import Setting as AppSetting from rattail.util import import_module_path -import colander - from tailbone import forms from tailbone.db import Session from tailbone.views import MasterView, View -from tailbone.util import get_libver, get_liburl +from wuttaweb.util import get_libver, get_liburl class AppInfoView(MasterView): @@ -99,10 +98,9 @@ class AppInfoView(MasterView): kwargs['configure_button_title'] = "Configure App" return kwargs - def configure_get_context(self, **kwargs): - context = super().configure_get_context(**kwargs) - - weblibs = OrderedDict([ + def get_weblibs(self): + """ """ + return OrderedDict([ ('vue', "Vue"), ('vue_resource', "vue-resource"), ('buefy', "Buefy"), @@ -117,6 +115,12 @@ class AppInfoView(MasterView): ('bb_vue_fontawesome', "(BB) @fortawesome/vue-fontawesome"), ]) + def configure_get_context(self, **kwargs): + """ """ + context = super().configure_get_context(**kwargs) + simple_settings = context['simple_settings'] + weblibs = self.get_weblibs() + for key in weblibs: title = weblibs[key] weblibs[key] = { @@ -125,19 +129,33 @@ class AppInfoView(MasterView): # nb. these values are exactly as configured, and are # used for editing the settings - 'configured_version': get_libver(self.request, key, fallback=False), - 'configured_url': get_liburl(self.request, key, fallback=False), + 'configured_version': get_libver(self.request, key, + prefix='tailbone', + configured_only=True), + 'configured_url': get_liburl(self.request, key, + prefix='tailbone', + configured_only=True), # these are for informational purposes only - 'default_version': get_libver(self.request, key, default_only=True), - 'live_url': get_liburl(self.request, key), + 'default_version': get_libver(self.request, key, + prefix='tailbone', + default_only=True), + 'live_url': get_liburl(self.request, key, + prefix='tailbone'), } + # TODO: this is only needed to migrate legacy settings to + # use the newer wutaweb setting names + url = simple_settings[f'wuttaweb.liburl.{key}'] + if not url and weblibs[key]['configured_url']: + simple_settings[f'wuttaweb.liburl.{key}'] = weblibs[key]['configured_url'] + context['weblibs'] = list(weblibs.values()) return context def configure_get_simple_settings(self): - return [ + """ """ + simple_settings = [ # basics {'section': 'rattail', @@ -167,63 +185,6 @@ class AppInfoView(MasterView): # 'type': int }, - # web libs - {'section': 'tailbone', - 'option': 'libver.vue'}, - {'section': 'tailbone', - 'option': 'liburl.vue'}, - {'section': 'tailbone', - 'option': 'libver.vue_resource'}, - {'section': 'tailbone', - 'option': 'liburl.vue_resource'}, - {'section': 'tailbone', - 'option': 'libver.buefy'}, - {'section': 'tailbone', - 'option': 'liburl.buefy'}, - {'section': 'tailbone', - 'option': 'libver.buefy.css'}, - {'section': 'tailbone', - 'option': 'liburl.buefy.css'}, - {'section': 'tailbone', - 'option': 'libver.fontawesome'}, - {'section': 'tailbone', - 'option': 'liburl.fontawesome'}, - - {'section': 'tailbone', - 'option': 'libver.bb_vue'}, - {'section': 'tailbone', - 'option': 'liburl.bb_vue'}, - - {'section': 'tailbone', - 'option': 'libver.bb_oruga'}, - {'section': 'tailbone', - 'option': 'liburl.bb_oruga'}, - - {'section': 'tailbone', - 'option': 'libver.bb_oruga_bulma'}, - {'section': 'tailbone', - 'option': 'liburl.bb_oruga_bulma'}, - - {'section': 'tailbone', - 'option': 'libver.bb_oruga_bulma_css'}, - {'section': 'tailbone', - 'option': 'liburl.bb_oruga_bulma_css'}, - - {'section': 'tailbone', - 'option': 'libver.bb_fontawesome_svg_core'}, - {'section': 'tailbone', - 'option': 'liburl.bb_fontawesome_svg_core'}, - - {'section': 'tailbone', - 'option': 'libver.bb_free_solid_svg_icons'}, - {'section': 'tailbone', - 'option': 'liburl.bb_free_solid_svg_icons'}, - - {'section': 'tailbone', - 'option': 'libver.bb_vue_fontawesome'}, - {'section': 'tailbone', - 'option': 'liburl.bb_vue_fontawesome'}, - # nb. these are no longer used (deprecated), but we keep # them defined here so the tool auto-deletes them {'section': 'tailbone', @@ -233,6 +194,36 @@ class AppInfoView(MasterView): ] + def getval(key): + return self.config.get(f'tailbone.{key}') + + weblibs = self.get_weblibs() + for key, title in weblibs.items(): + + simple_settings.append({ + 'section': 'wuttaweb', + 'option': f"libver.{key}", + 'default': getval(f"libver.{key}"), + }) + simple_settings.append({ + 'section': 'wuttaweb', + 'option': f"liburl.{key}", + 'default': getval(f"liburl.{key}"), + }) + + # nb. these are no longer used (deprecated), but we keep + # them defined here so the tool auto-deletes them + simple_settings.append({ + 'section': 'tailbone', + 'option': f"libver.{key}", + }) + simple_settings.append({ + 'section': 'tailbone', + 'option': f"liburl.{key}", + }) + + return simple_settings + class SettingView(MasterView): """ diff --git a/tests/views/test_settings.py b/tests/views/test_settings.py new file mode 100644 index 00000000..b8523729 --- /dev/null +++ b/tests/views/test_settings.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8; -*- + +from tailbone.views import settings as mod +from tests.util import WebTestCase + + +class TestSettingView(WebTestCase): + + def test_includeme(self): + self.pyramid_config.include('tailbone.views.settings') From bbd98e7b2f0ec57c3b4ffcd0b30786e8f0449504 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 15 Aug 2024 23:15:25 -0500 Subject: [PATCH 1579/1681] =?UTF-8?q?bump:=20version=200.16.1=20=E2=86=92?= =?UTF-8?q?=200.17.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f532ae03..5724e685 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.17.0 (2024-08-15) + +### Feat + +- use wuttaweb for `get_liburl()` logic + ## v0.16.1 (2024-08-15) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 69c35a68..31c7ef8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.16.1" +version = "0.17.0" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] @@ -59,7 +59,7 @@ dependencies = [ "transaction", "waitress", "WebHelpers2", - "WuttaWeb>=0.7.0", + "WuttaWeb>=0.8.1", "zope.sqlalchemy>=1.5", ] From 09612b1921af0a7b3bb7141381c3bb861b4d64ab Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 15 Aug 2024 23:46:58 -0500 Subject: [PATCH 1580/1681] fix: fix some more wutta compat for base template missed those earlier --- tailbone/templates/base.mako | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index 27e900e4..3a12859e 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -280,7 +280,7 @@ <span class="header-text"> ${index_title} </span> - % if master.creatable and master.show_create_link and master.has_perm('create'): + % if master.creatable and getattr(master, 'show_create_link', True) and master.has_perm('create'): <once-button type="is-primary" tag="a" href="${url('{}.create'.format(route_prefix))}" icon-left="plus" @@ -306,7 +306,7 @@ <span class="header-text"> ${h.link_to(instance_title, instance_url)} </span> - % elif master.creatable and master.show_create_link and master.has_perm('create'): + % elif master.creatable and getattr(master, 'show_create_link', True) and master.has_perm('create'): % if not request.matched_route.name.endswith('.create'): <once-button type="is-primary" tag="a" href="${url('{}.create'.format(route_prefix))}" From 1b78bd617c09f229a40161c14d07d883159b3668 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 16 Aug 2024 11:56:12 -0500 Subject: [PATCH 1581/1681] feat: inherit most logic from wuttaweb, for GridAction --- tailbone/grids/core.py | 65 ++++++++++---------------- tailbone/templates/grids/b-table.mako | 11 ++--- tailbone/templates/grids/complete.mako | 8 +--- tailbone/views/master.py | 8 +++- tailbone/views/people.py | 2 +- tailbone/views/purchasing/receiving.py | 4 +- tailbone/views/roles.py | 2 +- tests/grids/test_core.py | 17 +++++++ tests/views/test_master.py | 9 ++++ 9 files changed, 65 insertions(+), 61 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 3f1769cf..b9254c18 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -38,6 +38,7 @@ from pyramid.renderers import render from webhelpers2.html import HTML, tags from paginate_sqlalchemy import SqlalchemyOrmPage +from wuttaweb.grids import GridAction as WuttaGridAction from . import filters as gridfilters from tailbone.db import Session from tailbone.util import raw_datetime @@ -1801,18 +1802,20 @@ class Grid: return False -class GridAction(object): +class GridAction(WuttaGridAction): """ - Represents an action available to a grid. This is used to construct the - 'actions' column when rendering the grid. + Represents a "row action" hyperlink within a grid context. - :param key: Key for the action (e.g. ``'edit'``), unique within - the grid. + This is a subclass of + :class:`wuttaweb:wuttaweb.grids.base.GridAction`. - :param label: Label to be displayed for the action. If not set, - will be a capitalized version of ``key``. + .. warning:: - :param icon: Icon name for the action. + This class remains for now, to retain compatibility with + existing code. But at some point the WuttaWeb class will + supersede this one entirely. + + :param target: HTML "target" attribute for the ``<a>`` tag. :param click_handler: Optional JS click handler for the action. This value will be rendered as-is within the final grid @@ -1824,41 +1827,23 @@ class GridAction(object): * ``$emit('do-something', props.row)`` """ - def __init__(self, key, label=None, url='#', icon=None, target=None, - link_class=None, click_handler=None): - self.key = key - self.label = label or prettify(key) - self.icon = icon - self.url = url + def __init__( + self, + request, + key, + target=None, + click_handler=None, + **kwargs, + ): + # TODO: previously url default was '#' - but i don't think we + # need that anymore? guess we'll see.. + #kwargs.setdefault('url', '#') + + super().__init__(request, key, **kwargs) + self.target = target - self.link_class = link_class self.click_handler = click_handler - def get_url(self, row, i): - """ - Returns an action URL for the given row. - """ - if callable(self.url): - return self.url(row, i) - return self.url - - def render_icon(self): - """ - Render the HTML snippet for the action link icon. - """ - return HTML.tag('i', class_='fas fa-{}'.format(self.icon)) - - def render_label(self): - """ - Render the label "text" within the actions column of a grid - row. Most actions have a static label that never varies, but - you can override this to add e.g. HTML content. Note that the - return value will be treated / rendered as HTML whether or not - it contains any, so perhaps be careful that it is trusted - content. - """ - return self.label - class URLMaker(object): """ diff --git a/tailbone/templates/grids/b-table.mako b/tailbone/templates/grids/b-table.mako index 632193b5..da9f2aae 100644 --- a/tailbone/templates/grids/b-table.mako +++ b/tailbone/templates/grids/b-table.mako @@ -53,11 +53,11 @@ </${b}-table-column> % endfor - % if grid.main_actions or grid.more_actions: + % if grid.actions: <${b}-table-column field="actions" label="Actions" v-slot="props"> - % for action in grid.main_actions: + % for action in grid.actions: <a :href="props.row._action_url_${action.key}" % if action.link_class: class="${action.link_class}" @@ -68,12 +68,7 @@ @click.prevent="${action.click_handler}" % endif > - % if request.use_oruga: - <o-icon icon="${action.icon}" /> - % else: - <i class="fas fa-${action.icon}"></i> - % endif - ${action.label} + ${action.render_icon_and_label()} </a> % endfor diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako index fc48916b..93bb6c26 100644 --- a/tailbone/templates/grids/complete.mako +++ b/tailbone/templates/grids/complete.mako @@ -163,13 +163,7 @@ target="${action.target}" % endif > - % if request.use_oruga: - <o-icon icon="${action.icon}" /> - <span>${action.render_label()|n}</span> - % else: - ${action.render_icon()|n} - ${action.render_label()|n} - % endif + ${action.render_icon_and_label()} </a> % endfor diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 0d322da3..097cb229 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -3220,14 +3220,18 @@ class MasterView(View): def make_action(self, key, url=None, factory=None, **kwargs): """ - Make a new :class:`GridAction` instance for the current grid. + Make and return a new :class:`~tailbone.grids.core.GridAction` + instance. + + This can be called to make actions for any grid, not just the + one from :meth:`index()`. """ if url is None: route = '{}.{}'.format(self.get_route_prefix(), key) url = lambda r, i: self.request.route_url(route, **self.get_action_route_kwargs(r)) if not factory: factory = grids.GridAction - return factory(key, url=url, **kwargs) + return factory(self.request, key, url=url, **kwargs) def get_action_route_kwargs(self, obj): """ diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 94c85821..163a9a52 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -552,7 +552,7 @@ class PersonView(MasterView): if self.request.has_perm('trainwreck.transactions.view'): url = lambda row, i: self.request.route_url('trainwreck.transactions.view', uuid=row.uuid) - g.main_actions.append(grids.GridAction('view', icon='eye', url=url)) + g.main_actions.append(self.make_action('view', icon='eye', url=url)) g.load_settings() g.set_enum('system', self.enum.TRAINWRECK_SYSTEM) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 55936184..0a305f0a 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -40,7 +40,7 @@ from webhelpers2.html import tags, HTML from wuttaweb.util import get_form_data -from tailbone import forms, grids +from tailbone import forms from tailbone.views.purchasing import PurchasingBatchView @@ -1031,7 +1031,7 @@ class ReceivingBatchView(PurchasingBatchView): if batch.is_truck_dump_parent(): permission_prefix = self.get_permission_prefix() if self.request.has_perm('{}.edit_row'.format(permission_prefix)): - transform = grids.GridAction('transform', + transform = self.make_action('transform', icon='shuffle', label="Transform to Unit", url=self.transform_unit_url) diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py index b34b3673..fb834479 100644 --- a/tailbone/views/roles.py +++ b/tailbone/views/roles.py @@ -363,7 +363,7 @@ class RoleView(PrincipalMasterView): if role.users: users = sorted(role.users, key=lambda u: u.username) actions = [ - grids.GridAction('view', icon='zoomin', + self.make_action('view', icon='zoomin', url=lambda r, i: self.request.route_url('users.view', uuid=r.uuid)) ] kwargs['users'] = grids.Grid(None, users, ['username', 'active'], diff --git a/tests/grids/test_core.py b/tests/grids/test_core.py index e6f9d675..0a8d5d66 100644 --- a/tests/grids/test_core.py +++ b/tests/grids/test_core.py @@ -137,3 +137,20 @@ class TestGrid(WebTestCase): # calling again returns same data data2 = grid.get_vue_data() self.assertIs(data2, data) + + +class TestGridAction(WebTestCase): + + def test_constructor(self): + + # null by default + action = mod.GridAction(self.request, 'view') + self.assertIsNone(action.target) + self.assertIsNone(action.click_handler) + + # but can set them + action = mod.GridAction(self.request, 'view', + target='_blank', + click_handler='doSomething(props.row)') + self.assertEqual(action.target, '_blank') + self.assertEqual(action.click_handler, 'doSomething(props.row)') diff --git a/tests/views/test_master.py b/tests/views/test_master.py index 19321496..572875a0 100644 --- a/tests/views/test_master.py +++ b/tests/views/test_master.py @@ -3,6 +3,7 @@ from unittest.mock import patch from tailbone.views import master as mod +from wuttaweb.grids import GridAction from tests.util import WebTestCase @@ -24,3 +25,11 @@ class TestMasterView(WebTestCase): # sanity / coverage check kw = view.make_form_kwargs(model_instance=setting) self.assertIsNotNone(kw['action_url']) + + def test_make_action(self): + model = self.app.model + with patch.multiple(mod.MasterView, create=True, + model_class=model.Setting): + view = self.make_view() + action = view.make_action('view') + self.assertIsInstance(action, GridAction) From f7641218cb44c6ad18d6672361d1f1243c05e397 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 16 Aug 2024 11:56:54 -0500 Subject: [PATCH 1582/1681] fix: avoid route error in user view, when using wutta people view kind of a temporary edge case here, can eventually change it back --- tailbone/views/users.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tailbone/views/users.py b/tailbone/views/users.py index f8bcb1b8..9eae74d8 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -208,9 +208,13 @@ class UserView(PrincipalMasterView): person_display = str(person) elif self.editing: person_display = str(user.person or '') - people_url = self.request.route_url('people.autocomplete') - f.set_widget('person_uuid', forms.widgets.JQueryAutocompleteWidget( - field_display=person_display, service_url=people_url)) + try: + people_url = self.request.route_url('people.autocomplete') + except KeyError: + pass # TODO: wutta compat + else: + f.set_widget('person_uuid', forms.widgets.JQueryAutocompleteWidget( + field_display=person_display, service_url=people_url)) f.set_validator('person_uuid', self.valid_person) f.set_label('person_uuid', "Person") From 2a0b6da2f9169c22c099ca2c367a3ab2d89fa6e2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 16 Aug 2024 14:34:50 -0500 Subject: [PATCH 1583/1681] feat: inherit from wutta base class for Grid --- tailbone/grids/core.py | 241 ++++++++++--------------- tailbone/views/batch/core.py | 8 +- tailbone/views/batch/pos.py | 1 + tailbone/views/customers.py | 19 +- tailbone/views/custorders/items.py | 1 + tailbone/views/custorders/orders.py | 71 ++++---- tailbone/views/departments.py | 8 +- tailbone/views/email.py | 2 +- tailbone/views/employees.py | 3 +- tailbone/views/master.py | 48 +++-- tailbone/views/members.py | 3 +- tailbone/views/people.py | 19 +- tailbone/views/poser/reports.py | 2 +- tailbone/views/principal.py | 6 +- tailbone/views/products.py | 28 +-- tailbone/views/purchasing/batch.py | 4 +- tailbone/views/purchasing/receiving.py | 18 +- tailbone/views/reports.py | 12 +- tailbone/views/roles.py | 15 +- tailbone/views/tempmon/core.py | 6 +- tailbone/views/trainwreck/base.py | 12 +- tailbone/views/users.py | 15 +- tests/grids/test_core.py | 49 ++++- 23 files changed, 317 insertions(+), 274 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index b9254c18..a5617215 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -38,7 +38,7 @@ from pyramid.renderers import render from webhelpers2.html import HTML, tags from paginate_sqlalchemy import SqlalchemyOrmPage -from wuttaweb.grids import GridAction as WuttaGridAction +from wuttaweb.grids import Grid as WuttaGrid, GridAction as WuttaGridAction from . import filters as gridfilters from tailbone.db import Session from tailbone.util import raw_datetime @@ -61,7 +61,7 @@ class FieldList(list): self.insert(i + 1, newfield) -class Grid: +class Grid(WuttaGrid): """ Core grid class. In sore need of documentation. @@ -186,32 +186,59 @@ class Grid: grid.row_uuid_getter = fake_uuid """ - def __init__(self, key, data, columns=None, width='auto', request=None, - model_class=None, model_title=None, model_title_plural=None, - enums={}, labels={}, assume_local_times=False, renderers={}, invisible=[], - raw_renderers={}, - extra_row_class=None, linked_columns=[], url='#', - joiners={}, filterable=False, filters={}, use_byte_string_filters=False, - searchable={}, - sortable=False, sorters={}, default_sortkey=None, default_sortdir='asc', - pageable=False, default_pagesize=None, default_page=1, - checkboxes=False, checked=None, check_handler=None, check_all_handler=None, - checkable=None, row_uuid_getter=None, - clicking_row_checks_box=False, click_handlers=None, - main_actions=[], more_actions=[], delete_speedbump=False, - ajax_data_url=None, - vue_tagname=None, - expose_direct_link=False, - **kwargs): + def __init__( + self, + request, + key=None, + data=None, + width='auto', + model_title=None, + model_title_plural=None, + enums={}, + assume_local_times=False, + invisible=[], + raw_renderers={}, + extra_row_class=None, + url='#', + joiners={}, + filterable=False, + filters={}, + use_byte_string_filters=False, + searchable={}, + sortable=False, + sorters={}, + default_sortkey=None, + default_sortdir='asc', + pageable=False, + default_pagesize=None, + default_page=1, + checkboxes=False, + checked=None, + check_handler=None, + check_all_handler=None, + checkable=None, + row_uuid_getter=None, + clicking_row_checks_box=False, + click_handlers=None, + main_actions=[], + more_actions=[], + delete_speedbump=False, + ajax_data_url=None, + expose_direct_link=False, + **kwargs, + ): + if kwargs.get('component'): + warnings.warn("component param is deprecated for Grid(); " + "please use vue_tagname param instead", + DeprecationWarning, stacklevel=2) + kwargs.setdefault('vue_tagname', kwargs.pop('component')) - self.key = key - self.data = data - self.columns = FieldList(columns) if columns is not None else None - self.width = width - self.request = request - self.model_class = model_class - if self.model_class and self.columns is None: - self.columns = self.make_columns() + # TODO: pretty sure this should go away? + kwargs.setdefault('vue_tagname', 'tailbone-grid') + + kwargs['key'] = key + kwargs['data'] = data + super().__init__(request, **kwargs) self.model_title = model_title if not self.model_title and self.model_class and hasattr(self.model_class, 'get_model_title'): @@ -224,15 +251,13 @@ class Grid: if not self.model_title_plural: self.model_title_plural = '{}s'.format(self.model_title) + self.width = width self.enums = enums or {} - - self.labels = labels or {} self.assume_local_times = assume_local_times - self.renderers = self.make_default_renderers(renderers or {}) + self.renderers = self.make_default_renderers(self.renderers) self.raw_renderers = raw_renderers or {} self.invisible = invisible or [] self.extra_row_class = extra_row_class - self.linked_columns = linked_columns or [] self.url = url self.joiners = joiners or {} @@ -263,8 +288,6 @@ class Grid: self.click_handlers = click_handlers or {} - self.main_actions = main_actions or [] - self.more_actions = more_actions or [] self.delete_speedbump = delete_speedbump if ajax_data_url: @@ -274,29 +297,22 @@ class Grid: else: self.ajax_data_url = '' - # vue_tagname - self.vue_tagname = vue_tagname - if not self.vue_tagname and kwargs.get('component'): - warnings.warn("component kwarg is deprecated for Grid(); " - "please use vue_tagname param instead", + self.main_actions = main_actions or [] + if self.main_actions: + warnings.warn("main_actions param is deprecated for Grdi(); " + "please use actions param instead", DeprecationWarning, stacklevel=2) - self.vue_tagname = kwargs['component'] - if not self.vue_tagname: - self.vue_tagname = 'tailbone-grid' + self.actions.extend(self.main_actions) + self.more_actions = more_actions or [] + if self.more_actions: + warnings.warn("more_actions param is deprecated for Grdi(); " + "please use actions param instead", + DeprecationWarning, stacklevel=2) + self.actions.extend(self.more_actions) self.expose_direct_link = expose_direct_link self._whgrid_kwargs = kwargs - @property - def vue_component(self): - """ - String name for the Vue component, e.g. ``'TailboneGrid'``. - - This is a generated value based on :attr:`vue_tagname`. - """ - words = self.vue_tagname.split('-') - return ''.join([word.capitalize() for word in words]) - @property def component(self): """ @@ -317,34 +333,6 @@ class Grid: DeprecationWarning, stacklevel=2) return self.vue_component - @property - def actions(self): - """ """ - actions = [] - if self.main_actions: - actions.extend(self.main_actions) - if self.more_actions: - actions.extend(self.more_actions) - return actions - - def make_columns(self): - """ - Return a default list of columns, based on :attr:`model_class`. - """ - if not self.model_class: - raise ValueError("Must define model_class to use make_columns()") - - mapper = orm.class_mapper(self.model_class) - return [prop.key for prop in mapper.iterate_properties] - - def remove(self, *keys): - """ - This *removes* some column(s) from the grid, altogether. - """ - for key in keys: - if key in self.columns: - self.columns.remove(key) - def hide_column(self, key): """ This *removes* a column from the grid, altogether. @@ -377,9 +365,6 @@ class Grid: if key in self.invisible: self.invisible.remove(key) - def append(self, field): - self.columns.append(field) - def insert_before(self, field, newfield): self.columns.insert_before(field, newfield) @@ -430,24 +415,22 @@ class Grid: self.filters.pop(key, None) def set_label(self, key, label, column_only=False): - self.labels[key] = label + """ + Set/override the label for a column. + + This overrides + :meth:`~wuttaweb:wuttaweb.grids.base.Grid.set_label()` to add + the following params: + + :param column_only: Boolean indicating whether the label + should be applied *only* to the column header (if + ``True``), vs. applying also to the filter (if ``False``). + """ + super().set_label(key, label) + if not column_only and key in self.filters: self.filters[key].label = label - def get_label(self, key): - """ - Returns the label text for given field key. - """ - return self.labels.get(key, prettify(key)) - - def set_link(self, key, link=True): - if link: - if key not in self.linked_columns: - self.linked_columns.append(key) - else: # unlink - if self.linked_columns and key in self.linked_columns: - self.linked_columns.remove(key) - def set_click_handler(self, key, handler): if handler: self.click_handlers[key] = handler @@ -457,9 +440,6 @@ class Grid: def has_click_handler(self, key): return key in self.click_handlers - def set_renderer(self, key, renderer): - self.renderers[key] = renderer - def set_raw_renderer(self, key, renderer): """ Set or remove the "raw" renderer for the given field. @@ -1450,22 +1430,13 @@ class Grid: return render(template, context) def get_view_click_handler(self): - + """ """ # locate the 'view' action # TODO: this should be easier, and/or moved elsewhere? view = None - for action in self.main_actions: + for action in self.actions: if action.key == 'view': - view = action - break - if not view: - for action in self.more_actions: - if action.key == 'view': - view = action - break - - if view: - return view.click_handler + return action.click_handler def set_filters_sequence(self, filters, only=False): """ @@ -1561,26 +1532,21 @@ class Grid: kwargs['form'] = form return render(template, kwargs) - def render_actions(self, row, i): - """ - Returns the rendered contents of the 'actions' column for a given row. - """ - main_actions = [self.render_action(a, row, i) - for a in self.main_actions] - main_actions = [a for a in main_actions if a] - more_actions = [self.render_action(a, row, i) - for a in self.more_actions] - more_actions = [a for a in more_actions if a] - if more_actions: - icon = HTML.tag('span', class_='ui-icon ui-icon-carat-1-e') - link = tags.link_to("More" + icon, '#', class_='more') - main_actions.append(HTML.literal(' ') + link + HTML.tag('div', class_='more', c=more_actions)) - return HTML.literal('').join(main_actions) + def render_actions(self, row, i): # pragma: no cover + """ """ + warnings.warn("grid.render_actions() is deprecated!", + DeprecationWarning, stacklevel=2) + + actions = [self.render_action(a, row, i) + for a in self.actions] + actions = [a for a in actions if a] + return HTML.literal('').join(actions) + + def render_action(self, action, row, i): # pragma: no cover + """ """ + warnings.warn("grid.render_action() is deprecated!", + DeprecationWarning, stacklevel=2) - def render_action(self, action, row, i): - """ - Renders an action menu item (link) for the given row. - """ url = action.get_url(row, i) if url: kwargs = {'class_': action.key, 'target': action.target} @@ -1786,21 +1752,10 @@ class Grid: Pre-generate all action URLs for the given data row. Meant for use with client-side table, since we can't generate URLs from JS. """ - for action in (self.main_actions + self.more_actions): + for action in self.actions: url = action.get_url(rowobj, i) row['_action_url_{}'.format(action.key)] = url - def is_linked(self, name): - """ - Should return ``True`` if the given column name is configured to be - "linked" (i.e. table cell should contain a link to "view object"), - otherwise ``False``. - """ - if self.linked_columns: - if name in self.linked_columns: - return True - return False - class GridAction(WuttaGridAction): """ diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index f4f74a34..5dd7b548 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -186,7 +186,9 @@ class BatchMasterView(MasterView): breakdown = self.make_status_breakdown(batch) factory = self.get_grid_factory() - g = factory('batch_row_status_breakdown', [], + g = factory(self.request, + key='batch_row_status_breakdown', + data=[], columns=['title', 'count']) g.set_click_handler('title', "autoFilterStatus(props.row)") kwargs['status_breakdown_data'] = breakdown @@ -693,7 +695,7 @@ class BatchMasterView(MasterView): batch = self.get_instance() # TODO: most of this logic is copied from MasterView, should refactor/merge somehow... - if 'main_actions' not in kwargs: + if 'actions' not in kwargs: actions = [] # view action @@ -714,7 +716,7 @@ class BatchMasterView(MasterView): actions.append(self.make_action('delete', icon='trash', url=self.row_delete_action_url)) kwargs.setdefault('delete_speedbump', self.rows_deletable_speedbump) - kwargs['main_actions'] = actions + kwargs['actions'] = actions return super().make_row_grid_kwargs(**kwargs) diff --git a/tailbone/views/batch/pos.py b/tailbone/views/batch/pos.py index 11031353..b6fef6c8 100644 --- a/tailbone/views/batch/pos.py +++ b/tailbone/views/batch/pos.py @@ -195,6 +195,7 @@ class POSBatchView(BatchMasterView): factory = self.get_grid_factory() g = factory( + self.request, key=f'{route_prefix}.taxes', data=[], columns=[ diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index 2958a98a..7e49ccef 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -208,8 +208,7 @@ class CustomerView(MasterView): url = lambda r, i: self.request.route_url( f'{route_prefix}.view', **self.get_action_route_kwargs(r)) # nb. insert to slot 1, just after normal View action - g.main_actions.insert(1, self.make_action( - 'view_raw', url=url, icon='eye')) + g.actions.insert(1, self.make_action('view_raw', url=url, icon='eye')) g.set_link('name') g.set_link('person') @@ -471,7 +470,8 @@ class CustomerView(MasterView): factory = self.get_grid_factory() g = factory( - key='{}.people'.format(route_prefix), + self.request, + key=f'{route_prefix}.people', data=[], columns=[ 'shopper_number', @@ -500,7 +500,8 @@ class CustomerView(MasterView): factory = self.get_grid_factory() g = factory( - key='{}.people'.format(route_prefix), + self.request, + key=f'{route_prefix}.people', data=[], columns=[ 'full_name', @@ -512,13 +513,13 @@ class CustomerView(MasterView): ) if self.request.has_perm('people.view'): - g.main_actions.append(self.make_action('view', icon='eye')) + g.actions.append(self.make_action('view', icon='eye')) if self.request.has_perm('people.edit'): - g.main_actions.append(self.make_action('edit', icon='edit')) + g.actions.append(self.make_action('edit', icon='edit')) if self.people_detachable and self.has_perm('detach_person'): - g.main_actions.append(self.make_action('detach', icon='minus-circle', - link_class='has-text-warning', - click_handler="$emit('detach-person', props.row._action_url_detach)")) + g.actions.append(self.make_action('detach', icon='minus-circle', + link_class='has-text-warning', + click_handler="$emit('detach-person', props.row._action_url_detach)")) return HTML.literal( g.render_table_element(data_prop='peopleData')) diff --git a/tailbone/views/custorders/items.py b/tailbone/views/custorders/items.py index d8e39f55..e7edf3aa 100644 --- a/tailbone/views/custorders/items.py +++ b/tailbone/views/custorders/items.py @@ -385,6 +385,7 @@ class CustomerOrderItemView(MasterView): factory = self.get_grid_factory() g = factory( + self.request, key=f'{route_prefix}.events', data=[], columns=[ diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index f76d4d93..b1a9831a 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -29,13 +29,12 @@ import logging from sqlalchemy import orm -from rattail.db import model -from rattail.util import pretty_quantity, simple_error +from rattail.db.model import CustomerOrder, CustomerOrderItem +from rattail.util import simple_error from rattail.batch import get_batch_handler from webhelpers2.html import tags, HTML -from tailbone.db import Session from tailbone.views import MasterView @@ -46,7 +45,7 @@ class CustomerOrderView(MasterView): """ Master view for customer orders """ - model_class = model.CustomerOrder + model_class = CustomerOrder route_prefix = 'custorders' editable = False configurable = True @@ -80,7 +79,7 @@ class CustomerOrderView(MasterView): ] has_rows = True - model_row_class = model.CustomerOrderItem + model_row_class = CustomerOrderItem rows_viewable = False row_labels = { @@ -116,15 +115,17 @@ class CustomerOrderView(MasterView): ] def __init__(self, request): - super(CustomerOrderView, self).__init__(request) + super().__init__(request) self.batch_handler = self.get_batch_handler() def query(self, session): + model = self.app.model return session.query(model.CustomerOrder)\ .options(orm.joinedload(model.CustomerOrder.customer)) def configure_grid(self, g): super().configure_grid(g) + model = self.app.model # id g.set_link('id') @@ -163,7 +164,7 @@ class CustomerOrderView(MasterView): return f"#{order.id} for {order.customer or order.person}" def configure_form(self, f): - super(CustomerOrderView, self).configure_form(f) + super().configure_form(f) order = f.model_instance f.set_readonly('id') @@ -233,6 +234,7 @@ class CustomerOrderView(MasterView): class_='has-background-warning') def get_row_data(self, order): + model = self.app.model return self.Session.query(model.CustomerOrderItem)\ .filter(model.CustomerOrderItem.order == order) @@ -240,11 +242,13 @@ class CustomerOrderView(MasterView): return item.order def make_row_grid_kwargs(self, **kwargs): - kwargs = super(CustomerOrderView, self).make_row_grid_kwargs(**kwargs) + kwargs = super().make_row_grid_kwargs(**kwargs) - assert not kwargs['main_actions'] - kwargs['main_actions'].append( - self.make_action('view', icon='eye', url=self.row_view_action_url)) + actions = kwargs.get('actions', []) + if not actions: + actions.append(self.make_action('view', icon='eye', + url=self.row_view_action_url)) + kwargs['actions'] = actions return kwargs @@ -253,7 +257,7 @@ class CustomerOrderView(MasterView): return self.request.route_url('custorders.items.view', uuid=item.uuid) def configure_row_grid(self, g): - super(CustomerOrderView, self).configure_row_grid(g) + super().configure_row_grid(g) app = self.get_rattail_app() handler = app.get_batch_handler( 'custorder', @@ -423,6 +427,7 @@ class CustomerOrderView(MasterView): if not user: raise RuntimeError("this feature requires a user to be logged in") + model = self.app.model try: # there should be at most *one* new batch per user batch = self.Session.query(model.CustomerOrderBatch)\ @@ -488,6 +493,7 @@ class CustomerOrderView(MasterView): if not uuid: return {'error': "Must specify a customer UUID"} + model = self.app.model customer = self.Session.get(model.Customer, uuid) if not customer: return {'error': "Customer not found"} @@ -508,6 +514,7 @@ class CustomerOrderView(MasterView): return info def assign_contact(self, batch, data): + model = self.app.model kwargs = {} # this will either be a Person or Customer UUID @@ -662,6 +669,7 @@ class CustomerOrderView(MasterView): if not uuid: return {'error': "Must specify a product UUID"} + model = self.app.model product = self.Session.get(model.Product, uuid) if not product: return {'error': "Product not found"} @@ -725,8 +733,7 @@ class CustomerOrderView(MasterView): return app.render_currency(obj.unit_price) def normalize_row(self, row): - app = self.get_rattail_app() - products_handler = app.get_products_handler() + products_handler = self.app.get_products_handler() data = { 'uuid': row.uuid, @@ -742,20 +749,20 @@ class CustomerOrderView(MasterView): 'product_size': row.product_size, 'product_weighed': row.product_weighed, - 'case_quantity': pretty_quantity(row.case_quantity), - 'cases_ordered': pretty_quantity(row.cases_ordered), - 'units_ordered': pretty_quantity(row.units_ordered), - 'order_quantity': pretty_quantity(row.order_quantity), + 'case_quantity': self.app.render_quantity(row.case_quantity), + 'cases_ordered': self.app.render_quantity(row.cases_ordered), + 'units_ordered': self.app.render_quantity(row.units_ordered), + 'order_quantity': self.app.render_quantity(row.order_quantity), 'order_uom': row.order_uom, 'order_uom_choices': self.uom_choices_for_row(row), - 'discount_percent': pretty_quantity(row.discount_percent), + 'discount_percent': self.app.render_quantity(row.discount_percent), 'department_display': row.department_name, 'unit_price': float(row.unit_price) if row.unit_price is not None else None, 'unit_price_display': self.get_unit_price_display(row), 'total_price': float(row.total_price) if row.total_price is not None else None, - 'total_price_display': app.render_currency(row.total_price), + 'total_price_display': self.app.render_currency(row.total_price), 'status_code': row.status_code, 'status_text': row.status_text, @@ -763,15 +770,15 @@ class CustomerOrderView(MasterView): if row.unit_regular_price: data['unit_regular_price'] = float(row.unit_regular_price) - data['unit_regular_price_display'] = app.render_currency(row.unit_regular_price) + data['unit_regular_price_display'] = self.app.render_currency(row.unit_regular_price) if row.unit_sale_price: data['unit_sale_price'] = float(row.unit_sale_price) - data['unit_sale_price_display'] = app.render_currency(row.unit_sale_price) + data['unit_sale_price_display'] = self.app.render_currency(row.unit_sale_price) if row.sale_ends: - sale_ends = app.localtime(row.sale_ends, from_utc=True).date() + sale_ends = self.app.localtime(row.sale_ends, from_utc=True).date() data['sale_ends'] = str(sale_ends) - data['sale_ends_display'] = app.render_date(sale_ends) + data['sale_ends_display'] = self.app.render_date(sale_ends) if row.unit_sale_price and row.unit_price == row.unit_sale_price: data['pricing_reflects_sale'] = True @@ -808,12 +815,12 @@ class CustomerOrderView(MasterView): case_price = self.batch_handler.get_case_price_for_row(row) data['case_price'] = float(case_price) if case_price is not None else None - data['case_price_display'] = app.render_currency(case_price) + data['case_price_display'] = self.app.render_currency(case_price) if self.batch_handler.product_price_may_be_questionable(): data['price_needs_confirmation'] = row.price_needs_confirmation - key = app.get_product_key_field() + key = self.app.get_product_key_field() if key == 'upc': data['product_key'] = data['product_upc_pretty'] elif key == 'item_id': @@ -837,7 +844,7 @@ class CustomerOrderView(MasterView): case_qty = unit_qty = '??' else: case_qty = data['case_quantity'] - unit_qty = pretty_quantity(row.order_quantity * row.case_quantity) + unit_qty = self.app.render_quantity(row.order_quantity * row.case_quantity) data.update({ 'order_quantity_display': "{} {} (× {} {} = {} {})".format( data['order_quantity'], @@ -850,14 +857,14 @@ class CustomerOrderView(MasterView): else: data.update({ 'order_quantity_display': "{} {}".format( - pretty_quantity(row.order_quantity), + self.app.render_quantity(row.order_quantity), self.enum.UNIT_OF_MEASURE[unit_uom]), }) return data def add_item(self, batch, data): - app = self.get_rattail_app() + model = self.app.model order_quantity = decimal.Decimal(data.get('order_quantity') or '0') order_uom = data.get('order_uom') @@ -888,7 +895,7 @@ class CustomerOrderView(MasterView): pending_info = dict(data['pending_product']) if 'upc' in pending_info: - pending_info['upc'] = app.make_gpc(pending_info['upc']) + pending_info['upc'] = self.app.make_gpc(pending_info['upc']) for field in ('unit_cost', 'regular_price_amount', 'case_size'): if field in pending_info: @@ -917,6 +924,7 @@ class CustomerOrderView(MasterView): if not uuid: return {'error': "Must specify a row UUID"} + model = self.app.model row = self.Session.get(model.CustomerOrderBatchRow, uuid) if not row: return {'error': "Row not found"} @@ -975,6 +983,7 @@ class CustomerOrderView(MasterView): if not uuid: return {'error': "Must specify a row UUID"} + model = self.app.model row = self.Session.get(model.CustomerOrderBatchRow, uuid) if not row: return {'error': "Row not found"} diff --git a/tailbone/views/departments.py b/tailbone/views/departments.py index 6ee1439f..47de8dca 100644 --- a/tailbone/views/departments.py +++ b/tailbone/views/departments.py @@ -128,8 +128,8 @@ class DepartmentView(MasterView): factory = self.get_grid_factory() g = factory( - key='{}.employees'.format(route_prefix), - request=self.request, + self.request, + key=f'{route_prefix}.employees', data=[], columns=[ 'first_name', @@ -140,9 +140,9 @@ class DepartmentView(MasterView): ) if self.request.has_perm('employees.view'): - g.main_actions.append(self.make_action('view', icon='eye')) + g.actions.append(self.make_action('view', icon='eye')) if self.request.has_perm('employees.edit'): - g.main_actions.append(self.make_action('edit', icon='edit')) + g.actions.append(self.make_action('edit', icon='edit')) return HTML.literal( g.render_table_element(data_prop='employeesData')) diff --git a/tailbone/views/email.py b/tailbone/views/email.py index 4014c05e..a99e8553 100644 --- a/tailbone/views/email.py +++ b/tailbone/views/email.py @@ -141,7 +141,7 @@ class EmailSettingView(MasterView): # toggle hidden if self.has_perm('configure'): - g.main_actions.append( + g.actions.append( self.make_action('toggle_hidden', url='#', icon='ban', click_handler='toggleHidden(props.row)', factory=ToggleHidden)) diff --git a/tailbone/views/employees.py b/tailbone/views/employees.py index f4f99058..debd8fcb 100644 --- a/tailbone/views/employees.py +++ b/tailbone/views/employees.py @@ -167,8 +167,7 @@ class EmployeeView(MasterView): url = lambda r, i: self.request.route_url( f'{route_prefix}.view', **self.get_action_route_kwargs(r)) # nb. insert to slot 1, just after normal View action - g.main_actions.insert(1, self.make_action( - 'view_raw', url=url, icon='eye')) + g.actions.insert(1, self.make_action('view_raw', url=url, icon='eye')) def default_view_url(self): if (self.request.has_perm('people.view_profile') diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 097cb229..8f65fc88 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -392,9 +392,8 @@ class MasterView(View): if columns is None: columns = self.get_grid_columns() - kwargs.setdefault('request', self.request) kwargs = self.make_grid_kwargs(**kwargs) - grid = factory(key, data, columns, **kwargs) + grid = factory(self.request, key=key, data=data, columns=columns, **kwargs) self.configure_grid(grid) grid.load_settings() return grid @@ -454,10 +453,26 @@ class MasterView(View): if self.sortable or self.pageable or self.filterable: defaults['expose_direct_link'] = True - if 'main_actions' not in kwargs and 'more_actions' not in kwargs: - main, more = self.get_grid_actions() - defaults['main_actions'] = main - defaults['more_actions'] = more + if 'actions' not in kwargs: + + if 'main_actions' in kwargs: + warnings.warn("main_actions param is deprecated for make_grid_kwargs(); " + "please use actions param instead", + DeprecationWarning, stacklevel=2) + main = kwargs.pop('main_actions') + else: + main = self.get_main_actions() + + if 'more_actions' in kwargs: + warnings.warn("more_actions param is deprecated for make_grid_kwargs(); " + "please use actions param instead", + DeprecationWarning, stacklevel=2) + more = kwargs.pop('more_actions') + else: + more = self.get_more_actions() + + defaults['actions'] = main + more + defaults.update(kwargs) return defaults @@ -548,9 +563,8 @@ class MasterView(View): if columns is None: columns = self.get_row_grid_columns() - kwargs.setdefault('request', self.request) kwargs = self.make_row_grid_kwargs(**kwargs) - grid = factory(key, data, columns, **kwargs) + grid = factory(self.request, key=key, data=data, columns=columns, **kwargs) self.configure_row_grid(grid) grid.load_settings() return grid @@ -577,7 +591,7 @@ class MasterView(View): if self.rows_default_pagesize: defaults['default_pagesize'] = self.rows_default_pagesize - if self.has_rows and 'main_actions' not in defaults: + if self.has_rows and 'actions' not in defaults: actions = [] # view action @@ -595,7 +609,7 @@ class MasterView(View): actions.append(self.make_action('delete', icon='trash', url=self.row_delete_action_url)) defaults['delete_speedbump'] = self.rows_deletable_speedbump - defaults['main_actions'] = actions + defaults['actions'] = actions defaults.update(kwargs) return defaults @@ -630,9 +644,8 @@ class MasterView(View): if columns is None: columns = self.get_version_grid_columns() - kwargs.setdefault('request', self.request) kwargs = self.make_version_grid_kwargs(**kwargs) - grid = factory(key, data, columns, **kwargs) + grid = factory(self.request, key=key, data=data, columns=columns, **kwargs) self.configure_version_grid(grid) grid.load_settings() return grid @@ -661,9 +674,9 @@ class MasterView(View): 'pageable': True, 'url': lambda txn: self.request.route_url(route, uuid=instance.uuid, txnid=txn.id), } - if 'main_actions' not in kwargs: + if 'actions' not in kwargs: url = lambda txn, i: self.request.route_url(route, uuid=instance.uuid, txnid=txn.id) - defaults['main_actions'] = [ + defaults['actions'] = [ self.make_action('view', icon='eye', url=url), ] defaults.update(kwargs) @@ -1372,7 +1385,7 @@ class MasterView(View): 'sortable': True, 'default_sortkey': 'changed', 'default_sortdir': 'desc', - 'main_actions': [ + 'actions': [ self.make_action('view', icon='eye', url='#', click_handler='viewRevision(props.row)'), self.make_action('view_separate', url=row_url, target='_blank', @@ -3111,6 +3124,11 @@ class MasterView(View): return key def get_grid_actions(self): + """ """ + warnings.warn("get_grid_actions() method is deprecated; " + "please use get_main_actions() or get_more_actions() instead", + DeprecationWarning, stacklevel=2) + main, more = self.get_main_actions(), self.get_more_actions() if len(more) == 1: main, more = main + more, [] diff --git a/tailbone/views/members.py b/tailbone/views/members.py index de844eb7..46ed7e4b 100644 --- a/tailbone/views/members.py +++ b/tailbone/views/members.py @@ -229,8 +229,7 @@ class MemberView(MasterView): url = lambda r, i: self.request.route_url( f'{route_prefix}.view', **self.get_action_route_kwargs(r)) # nb. insert to slot 1, just after normal View action - g.main_actions.insert(1, self.make_action( - 'view_raw', url=url, icon='eye')) + g.actions.insert(1, self.make_action('view_raw', url=url, icon='eye')) # equity_total # TODO: should make this configurable diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 163a9a52..020babc5 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -175,8 +175,7 @@ class PersonView(MasterView): url = lambda r, i: self.request.route_url( f'{route_prefix}.view', **self.get_action_route_kwargs(r)) # nb. insert to slot 1, just after normal View action - g.main_actions.insert(1, self.make_action( - 'view_raw', url=url, icon='eye')) + g.actions.insert(1, self.make_action('view_raw', url=url, icon='eye')) g.set_link('display_name') g.set_link('first_name') @@ -522,9 +521,9 @@ class PersonView(MasterView): data = self.profile_transactions_query(person) factory = self.get_grid_factory() g = factory( - f'{route_prefix}.profile.transactions.{person.uuid}', - data, - request=self.request, + self.request, + key=f'{route_prefix}.profile.transactions.{person.uuid}', + data=data, model_class=model.Transaction, ajax_data_url=self.get_action_url('view_profile_transactions', person), columns=[ @@ -552,7 +551,7 @@ class PersonView(MasterView): if self.request.has_perm('trainwreck.transactions.view'): url = lambda row, i: self.request.route_url('trainwreck.transactions.view', uuid=row.uuid) - g.main_actions.append(self.make_action('view', icon='eye', url=url)) + g.actions.append(self.make_action('view', icon='eye', url=url)) g.load_settings() g.set_enum('system', self.enum.TRAINWRECK_SYSTEM) @@ -1413,9 +1412,9 @@ class PersonView(MasterView): route_prefix = self.get_route_prefix() factory = self.get_grid_factory() g = factory( - '{}.profile.revisions'.format(route_prefix), - [], # start with empty data! - request=self.request, + self.request, + key=f'{route_prefix}.profile.revisions', + data=[], # start with empty data! columns=[ 'changed', 'changed_by', @@ -1430,7 +1429,7 @@ class PersonView(MasterView): 'changed_by', 'comment', ], - main_actions=[ + actions=[ self.make_action('view', icon='eye', url='#', click_handler='viewRevision(props.row)'), ], diff --git a/tailbone/views/poser/reports.py b/tailbone/views/poser/reports.py index 462df51d..ded80b18 100644 --- a/tailbone/views/poser/reports.py +++ b/tailbone/views/poser/reports.py @@ -110,7 +110,7 @@ class PoserReportView(PoserMasterView): g.set_searchable('description') if self.request.has_perm('report_output.create'): - g.more_actions.append(self.make_action( + g.actions.append(self.make_action( 'generate', icon='arrow-circle-right', url=self.get_generate_url)) diff --git a/tailbone/views/principal.py b/tailbone/views/principal.py index bb799efc..3986f8b0 100644 --- a/tailbone/views/principal.py +++ b/tailbone/views/principal.py @@ -124,11 +124,11 @@ class PrincipalMasterView(MasterView): def find_by_perm_make_results_grid(self, principals): route_prefix = self.get_route_prefix() factory = self.get_grid_factory() - g = factory(key=f'{route_prefix}.results', - request=self.request, + g = factory(self.request, + key=f'{route_prefix}.results', data=[], columns=[], - main_actions=[ + actions=[ self.make_action('view', icon='eye', click_handler='navigateTo(props.row._url)'), ]) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index bf2d7f14..c546a0f4 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -384,7 +384,7 @@ class ProductView(MasterView): g.set_filter('report_code_name', model.ReportCode.name) if self.expose_label_printing and self.has_perm('print_labels'): - g.more_actions.append(self.make_action( + g.actions.append(self.make_action( 'print_label', icon='print', url='#', click_handler='quickLabelPrint(props.row)')) @@ -1197,8 +1197,9 @@ class ProductView(MasterView): # regular price data = [] # defer fetching until user asks for it - grid = grids.Grid('products.regular_price_history', data, - request=self.request, + grid = grids.Grid(self.request, + key='products.regular_price_history', + data=data, columns=[ 'price', 'since', @@ -1211,8 +1212,9 @@ class ProductView(MasterView): # current price data = [] # defer fetching until user asks for it - grid = grids.Grid('products.current_price_history', data, - request=self.request, + grid = grids.Grid(self.request, + key='products.current_price_history', + data=data, columns=[ 'price', 'price_type', @@ -1229,8 +1231,9 @@ class ProductView(MasterView): # suggested price data = [] # defer fetching until user asks for it - grid = grids.Grid('products.suggested_price_history', data, - request=self.request, + grid = grids.Grid(self.request, + key='products.suggested_price_history', + data=data, columns=[ 'price', 'since', @@ -1243,8 +1246,9 @@ class ProductView(MasterView): # cost history data = [] # defer fetching until user asks for it - grid = grids.Grid('products.cost_history', data, - request=self.request, + grid = grids.Grid(self.request, + key='products.cost_history', + data=data, columns=[ 'cost', 'vendor', @@ -1335,7 +1339,8 @@ class ProductView(MasterView): factory = self.get_grid_factory() g = factory( - key='{}.vendor_sources'.format(route_prefix), + self.request, + key=f'{route_prefix}.vendor_sources', data=[], columns=columns, labels={ @@ -1376,7 +1381,8 @@ class ProductView(MasterView): factory = self.get_grid_factory() g = factory( - key='{}.lookup_codes'.format(route_prefix), + self.request, + key=f'{route_prefix}.lookup_codes', data=[], columns=[ 'sequence', diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index 1d11130c..590b9af5 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -793,8 +793,8 @@ class PurchasingBatchView(BatchMasterView): factory = self.get_grid_factory() g = factory( - key='{}.row_credits'.format(route_prefix), - request=self.request, + self.request, + key=f'{route_prefix}.row_credits', data=[], columns=[ 'credit_type', diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 0a305f0a..de19a2b9 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -774,8 +774,10 @@ class ReceivingBatchView(PurchasingBatchView): breakdown = self.make_po_vs_invoice_breakdown(batch) factory = self.get_grid_factory() - g = factory('batch_po_vs_invoice_breakdown', [], - columns=['title', 'count']) + g = factory(self.request, + key='batch_po_vs_invoice_breakdown', + data=[], + columns=['title', 'count']) g.set_click_handler('title', "autoFilterPoVsInvoice(props.row)") kwargs['po_vs_invoice_breakdown_data'] = breakdown kwargs['po_vs_invoice_breakdown_grid'] = HTML.literal( @@ -1035,10 +1037,12 @@ class ReceivingBatchView(PurchasingBatchView): icon='shuffle', label="Transform to Unit", url=self.transform_unit_url) - g.more_actions.append(transform) - if g.main_actions and g.main_actions[-1].key == 'delete': - delete = g.main_actions.pop() - g.more_actions.append(delete) + if g.actions and g.actions[-1].key == 'delete': + delete = g.actions.pop() + g.actions.append(transform) + g.actions.append(delete) + else: + g.actions.append(transform) # truck_dump_status if not batch.is_truck_dump_parent(): @@ -1111,7 +1115,7 @@ class ReceivingBatchView(PurchasingBatchView): and self.row_editable(row)): # add the Un-Declare action - g.main_actions.append(self.make_action( + g.actions.append(self.make_action( 'remove', label="Un-Declare", url='#', icon='trash', link_class='has-text-danger', diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index aedda61c..099224be 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -308,7 +308,8 @@ class ReportOutputView(ExportMasterView): route_prefix = self.get_route_prefix() factory = self.get_grid_factory() g = factory( - key='{}.params'.format(route_prefix), + self.request, + key=f'{route_prefix}.params', data=params, columns=['key', 'value'], labels={'key': "Name"}, @@ -705,9 +706,12 @@ class ProblemReportView(MasterView): return ', '.join(recips) def render_days(self, report_info, field): - g = self.get_grid_factory()('days', [], - columns=['weekday_name', 'enabled'], - labels={'weekday_name': "Weekday"}) + factory = self.get_grid_factory() + g = factory(self.request, + key='days', + data=[], + columns=['weekday_name', 'enabled'], + labels={'weekday_name': "Weekday"}) return HTML.literal(g.render_table_element(data_prop='weekdaysData')) def template_kwargs_view(self, **kwargs): diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py index fb834479..e8a6d8a2 100644 --- a/tailbone/views/roles.py +++ b/tailbone/views/roles.py @@ -255,8 +255,8 @@ class RoleView(PrincipalMasterView): permission_prefix = self.get_permission_prefix() factory = self.get_grid_factory() g = factory( - key='{}.users'.format(route_prefix), - request=self.request, + self.request, + key=f'{route_prefix}.users', data=[], columns=[ 'full_name', @@ -269,9 +269,9 @@ class RoleView(PrincipalMasterView): ) if self.request.has_perm('users.view'): - g.main_actions.append(self.make_action('view', icon='eye')) + g.actions.append(self.make_action('view', icon='eye')) if self.request.has_perm('users.edit'): - g.main_actions.append(self.make_action('edit', icon='edit')) + g.actions.append(self.make_action('edit', icon='edit')) return HTML.literal( g.render_table_element(data_prop='usersData')) @@ -366,10 +366,11 @@ class RoleView(PrincipalMasterView): self.make_action('view', icon='zoomin', url=lambda r, i: self.request.route_url('users.view', uuid=r.uuid)) ] - kwargs['users'] = grids.Grid(None, users, ['username', 'active'], - request=self.request, + kwargs['users'] = grids.Grid(self.request, + data=users, + columns=['username', 'active'], model_class=model.User, - main_actions=actions) + actions=actions) else: kwargs['users'] = None diff --git a/tailbone/views/tempmon/core.py b/tailbone/views/tempmon/core.py index d551d6e6..7540abbe 100644 --- a/tailbone/views/tempmon/core.py +++ b/tailbone/views/tempmon/core.py @@ -77,8 +77,8 @@ class MasterView(views.MasterView): factory = self.get_grid_factory() g = factory( - key='{}.probes'.format(route_prefix), - request=self.request, + self.request, + key=f'{route_prefix}.probes', data=[], columns=[ 'description', @@ -96,7 +96,7 @@ class MasterView(views.MasterView): 'critical_temp_max': "Crit. Max", }, linked_columns=['description'], - main_actions=actions, + actions=actions, ) return HTML.literal( g.render_table_element(data_prop='probesData')) diff --git a/tailbone/views/trainwreck/base.py b/tailbone/views/trainwreck/base.py index 9c150c6a..d5f077aa 100644 --- a/tailbone/views/trainwreck/base.py +++ b/tailbone/views/trainwreck/base.py @@ -246,10 +246,10 @@ class TransactionView(MasterView): factory = self.get_grid_factory() g = factory( - key='{}.custorder_xref_markers'.format(route_prefix), + self.request, + key=f'{route_prefix}.custorder_xref_markers', data=[], - columns=['custorder_xref', 'custorder_item_xref'], - request=self.request) + columns=['custorder_xref', 'custorder_item_xref']) return HTML.literal( g.render_table_element(data_prop='custorderXrefMarkersData')) @@ -355,11 +355,11 @@ class TransactionView(MasterView): factory = self.get_grid_factory() g = factory( - key='{}.discounts'.format(route_prefix), + self.request, + key=f'{route_prefix}.discounts', data=[], columns=['discount_type', 'description', 'amount'], - labels={'discount_type': "Type"}, - request=self.request) + labels={'discount_type': "Type"}) return HTML.literal( g.render_table_element(data_prop='discountsData')) diff --git a/tailbone/views/users.py b/tailbone/views/users.py index 9eae74d8..9b533efe 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -44,9 +44,6 @@ class UserView(PrincipalMasterView): Master view for the User model. """ model_class = User - has_rows = True - rows_title = "User Events" - model_row_class = UserEvent has_versions = True touchable = True mergeable = True @@ -77,6 +74,11 @@ class UserView(PrincipalMasterView): 'permissions', ] + has_rows = True + model_row_class = UserEvent + rows_title = "User Events" + rows_viewable = False + row_grid_columns = [ 'type_code', 'occurred', @@ -297,11 +299,11 @@ class UserView(PrincipalMasterView): factory = self.get_grid_factory() g = factory( - request=self.request, - key='{}.api_tokens'.format(route_prefix), + self.request, + key=f'{route_prefix}.api_tokens', data=[], columns=['description', 'created'], - main_actions=[ + actions=[ self.make_action('delete', icon='trash', click_handler="$emit('api-token-delete', props.row)")]) @@ -514,7 +516,6 @@ class UserView(PrincipalMasterView): g.set_sort_defaults('occurred', 'desc') g.set_enum('type_code', self.enum.USER_EVENT) g.set_label('type_code', "Event Type") - g.main_actions = [] def get_version_child_classes(self): model = self.model diff --git a/tests/grids/test_core.py b/tests/grids/test_core.py index 0a8d5d66..0d0fe112 100644 --- a/tests/grids/test_core.py +++ b/tests/grids/test_core.py @@ -12,9 +12,8 @@ class TestGrid(WebTestCase): self.setup_web() self.config.setdefault('rattail.web.menus.handler_spec', 'tests.util:NullMenuHandler') - def make_grid(self, key, data=[], **kwargs): - kwargs.setdefault('request', self.request) - return mod.Grid(key, data=data, **kwargs) + def make_grid(self, key=None, data=[], **kwargs): + return mod.Grid(self.request, key=key, data=data, **kwargs) def test_basic(self): grid = self.make_grid('foo') @@ -90,6 +89,50 @@ class TestGrid(WebTestCase): grid = self.make_grid('foo', main_actions=['foo'], more_actions=['bar']) self.assertEqual(grid.actions, ['foo', 'bar']) + def test_set_label(self): + model = self.app.model + grid = self.make_grid(model_class=model.Setting) + self.assertEqual(grid.labels, {}) + + # basic + grid.set_label('name', "NAME COL") + self.assertEqual(grid.labels['name'], "NAME COL") + + # can replace label + grid.set_label('name', "Different") + self.assertEqual(grid.labels['name'], "Different") + self.assertEqual(grid.get_label('name'), "Different") + + # can update only column, not filter + self.assertEqual(grid.labels, {'name': "Different"}) + self.assertIn('name', grid.filters) + self.assertEqual(grid.filters['name'].label, "Different") + grid.set_label('name', "COLUMN ONLY", column_only=True) + self.assertEqual(grid.get_label('name'), "COLUMN ONLY") + self.assertEqual(grid.filters['name'].label, "Different") + + def test_get_view_click_handler(self): + model = self.app.model + grid = self.make_grid(model_class=model.Setting) + + grid.actions.append( + mod.GridAction(self.request, 'view', + click_handler='clickHandler(props.row)')) + + handler = grid.get_view_click_handler() + self.assertEqual(handler, 'clickHandler(props.row)') + + def test_set_action_urls(self): + model = self.app.model + grid = self.make_grid(model_class=model.Setting) + + grid.actions.append( + mod.GridAction(self.request, 'view', url='/blarg')) + + setting = {'name': 'foo', 'value': 'bar'} + grid.set_action_urls(setting, setting, 0) + self.assertEqual(setting['_action_url_view'], '/blarg') + def test_render_vue_tag(self): model = self.app.model From 9da2a148c65ebde63b39903028bdf77577d53780 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 16 Aug 2024 18:45:04 -0500 Subject: [PATCH 1584/1681] feat: move "basic" grid pagination logic to wuttaweb so far only "simple" pagination is supported by wuttaweb, so basically the main feature flag, page size, current page. in this scenario *all* data is written to client-side JSON and Buefy handles the actual pagination. backend pagination coming soon for wuttaweb but for now tailbone still handles all that. --- tailbone/grids/core.py | 130 +++++++++++++++++-------- tailbone/templates/grids/complete.mako | 18 ++-- tailbone/views/master.py | 4 +- tailbone/views/wutta/people.py | 4 + tests/grids/test_core.py | 86 ++++++++++++++++ 5 files changed, 195 insertions(+), 47 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index a5617215..0b23fb78 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -31,6 +31,7 @@ import logging import sqlalchemy as sa from sqlalchemy import orm +from wuttjamaican.util import UNSPECIFIED from rattail.db.types import GPCType from rattail.util import prettify, pretty_boolean @@ -209,9 +210,6 @@ class Grid(WuttaGrid): sorters={}, default_sortkey=None, default_sortdir='asc', - pageable=False, - default_pagesize=None, - default_page=1, checkboxes=False, checked=None, check_handler=None, @@ -233,7 +231,26 @@ class Grid(WuttaGrid): DeprecationWarning, stacklevel=2) kwargs.setdefault('vue_tagname', kwargs.pop('component')) - # TODO: pretty sure this should go away? + if kwargs.get('pageable'): + warnings.warn("component param is deprecated for Grid(); " + "please use vue_tagname param instead", + DeprecationWarning, stacklevel=2) + kwargs.setdefault('paginated', kwargs.pop('pageable')) + + if kwargs.get('default_pagesize'): + warnings.warn("default_pagesize param is deprecated for Grid(); " + "please use pagesize param instead", + DeprecationWarning, stacklevel=2) + kwargs.setdefault('pagesize', kwargs.pop('default_pagesize')) + + if kwargs.get('default_page'): + warnings.warn("default_page param is deprecated for Grid(); " + "please use page param instead", + DeprecationWarning, stacklevel=2) + kwargs.setdefault('page', kwargs.pop('default_page')) + + # TODO: this should not be needed once all templates correctly + # reference grid.vue_component etc. kwargs.setdefault('vue_tagname', 'tailbone-grid') kwargs['key'] = key @@ -272,10 +289,6 @@ class Grid(WuttaGrid): self.default_sortkey = default_sortkey self.default_sortdir = default_sortdir - self.pageable = pageable - self.default_pagesize = default_pagesize - self.default_page = default_page - self.checkboxes = checkboxes self.checked = checked if self.checked is None: @@ -333,6 +346,16 @@ class Grid(WuttaGrid): DeprecationWarning, stacklevel=2) return self.vue_component + def get_pageable(self): + """ """ + return self.paginated + + def set_pageable(self, value): + """ """ + self.paginated = value + + pageable = property(get_pageable, set_pageable) + def hide_column(self, key): """ This *removes* a column from the grid, altogether. @@ -756,18 +779,61 @@ class Grid(WuttaGrid): keyfunc = lambda v: v[key] return lambda q, d: sorted(q, key=keyfunc, reverse=d == 'desc') - def get_default_pagesize(self): + def get_pagesize_options(self, default=None): + """ """ + # let upstream check config + options = super().get_pagesize_options(default=UNSPECIFIED) + if options is not UNSPECIFIED: + return options + + # fallback to legacy config + options = self.config.get_list('tailbone.grid.pagesize_options') + if options: + warnings.warn("tailbone.grid.pagesize_options setting is deprecated; " + "please set wuttaweb.grids.default_pagesize_options instead", + DeprecationWarning) + options = [int(size) for size in options + if size.isdigit()] + if options: + return options + + if default: + return default + + # use upstream default + return super().get_pagesize_options() + + def get_pagesize(self, default=None): + """ """ + # let upstream check config + pagesize = super().get_pagesize(default=UNSPECIFIED) + if pagesize is not UNSPECIFIED: + return pagesize + + # fallback to legacy config + pagesize = self.config.get_int('tailbone.grid.default_pagesize') + if pagesize: + warnings.warn("tailbone.grid.default_pagesize setting is deprecated; " + "please use wuttaweb.grids.default_pagesize instead", + DeprecationWarning) + return pagesize + + if default: + return default + + # use upstream default + return super().get_pagesize() + + def get_default_pagesize(self): # pragma: no cover + """ """ + warnings.warn("Grid.get_default_pagesize() method is deprecated; " + "please use Grid.get_pagesize() of Grid.page instead", + DeprecationWarning, stacklevel=2) + if self.default_pagesize: return self.default_pagesize - pagesize = self.request.rattail_config.getint('tailbone', - 'grid.default_pagesize', - default=0) - if pagesize: - return pagesize - - options = self.get_pagesize_options() - return options[0] + return self.get_pagesize() def load_settings(self, store=True): """ @@ -789,9 +855,9 @@ class Grid(WuttaGrid): settings['sorters.1.dir'] = self.default_sortdir else: settings['sorters.length'] = 0 - if self.pageable: - settings['pagesize'] = self.get_default_pagesize() - settings['page'] = self.default_page + if self.paginated: + settings['pagesize'] = self.pagesize + settings['page'] = self.page if self.filterable: for filtr in self.iter_filters(): settings['filter.{}.active'.format(filtr.key)] = filtr.default_active @@ -867,7 +933,7 @@ class Grid(WuttaGrid): 'field': settings[f'sorters.{i}.key'], 'order': settings[f'sorters.{i}.dir'], }) - if self.pageable: + if self.paginated: self.pagesize = settings['pagesize'] self.page = settings['page'] @@ -971,7 +1037,7 @@ class Grid(WuttaGrid): merge(f'sorters.{i}.key') merge(f'sorters.{i}.dir') - if self.pageable: + if self.paginated: merge('pagesize', int) merge('page', int) @@ -1154,7 +1220,7 @@ class Grid(WuttaGrid): :param settings: Dictionary of initial settings, which is to be updated. """ - if not self.pageable: + if not self.paginated: return pagesize = self.request.GET.get('pagesize') @@ -1231,7 +1297,7 @@ class Grid(WuttaGrid): persist(f'sorters.{i}.key') persist(f'sorters.{i}.dir') - if self.pageable: + if self.paginated: persist('pagesize') persist('page') @@ -1355,7 +1421,7 @@ class Grid(WuttaGrid): data = self.filter_data(data) if self.sortable: data = self.sort_data(data) - if self.pageable: + if self.paginated: self.pager = self.paginate_data(data) data = self.pager return data @@ -1580,18 +1646,6 @@ class Grid(WuttaGrid): return tags.checkbox('checkbox-{}-{}'.format(self.key, self.get_row_key(item)), checked=self.checked(item)) - def get_pagesize_options(self): - - # use values from config, if defined - options = self.request.rattail_config.getlist('tailbone', 'grid.pagesize_options') - if options: - options = [int(size) for size in options - if size.isdigit()] - if options: - return options - - return [5, 10, 20, 50, 100, 200] - def has_static_data(self): """ Should return ``True`` if the grid data can be considered "static" @@ -1734,7 +1788,7 @@ class Grid(WuttaGrid): results['checked_rows_code'] = '[{}]'.format( ', '.join(['{}[{}]'.format(var, i) for i in checked])) - if self.pageable and self.pager is not None: + if self.paginated and self.pager is not None: results['total_items'] = self.pager.item_count results['per_page'] = self.pager.items_per_page results['page'] = self.pager.page diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako index 93bb6c26..53043803 100644 --- a/tailbone/templates/grids/complete.mako +++ b/tailbone/templates/grids/complete.mako @@ -107,12 +107,14 @@ @cellclick="cellClick" % endif + % if grid.paginated: :paginated="paginated" :per-page="perPage" :current-page="currentPage" backend-pagination :total="total" @page-change="onPageChange" + % endif ## TODO: should let grid (or master view) decide how to set these? icon-pack="fas" @@ -203,7 +205,7 @@ <div></div> % endif - % if getattr(grid, 'pageable', False): + % if grid.paginated: <div v-if="firstItem" style="display: flex; gap: 0.5rem; align-items: center;"> <span> @@ -255,12 +257,14 @@ checkedRows: ${grid_data['checked_rows_code']|n}, % endif - paginated: ${json.dumps(getattr(grid, 'pageable', False))|n}, + % if grid.paginated: + paginated: ${json.dumps(grid.paginated)|n}, total: ${len(grid_data['data']) if static_data else (grid_data['total_items'] if grid_data is not Undefined else 0)}, - perPage: ${json.dumps(grid.pagesize if getattr(grid, 'pageable', False) else None)|n}, - currentPage: ${json.dumps(grid.page if getattr(grid, 'pageable', False) else None)|n}, - firstItem: ${json.dumps(grid_data['first_item'] if getattr(grid, 'pageable', False) else None)|n}, - lastItem: ${json.dumps(grid_data['last_item'] if getattr(grid, 'pageable', False) else None)|n}, + perPage: ${json.dumps(grid.pagesize if grid.paginated else None)|n}, + currentPage: ${json.dumps(grid.page if grid.paginated else None)|n}, + firstItem: ${json.dumps(grid_data['first_item'] if grid.paginated else None)|n}, + lastItem: ${json.dumps(grid_data['last_item'] if grid.paginated else None)|n}, + % endif % if getattr(grid, 'sortable', False): @@ -439,7 +443,7 @@ params['sort'+i+'dir'] = this.backendSorters[i-1].order } % endif - % if getattr(grid, 'pageable', False): + % if grid.paginated: params.pagesize = this.perPage params.page = this.currentPage % endif diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 8f65fc88..58b93568 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -439,7 +439,7 @@ class MasterView(View): 'filterable': self.filterable, 'use_byte_string_filters': self.use_byte_string_filters, 'sortable': self.sortable, - 'pageable': self.pageable, + 'paginated': self.pageable, 'extra_row_class': self.grid_extra_class, 'url': lambda obj: self.get_action_url('view', obj), 'checkboxes': checkboxes, @@ -589,7 +589,7 @@ class MasterView(View): } if self.rows_default_pagesize: - defaults['default_pagesize'] = self.rows_default_pagesize + defaults['pagesize'] = self.rows_default_pagesize if self.has_rows and 'actions' not in defaults: actions = [] diff --git a/tailbone/views/wutta/people.py b/tailbone/views/wutta/people.py index c92e34ae..3158b478 100644 --- a/tailbone/views/wutta/people.py +++ b/tailbone/views/wutta/people.py @@ -45,6 +45,10 @@ class PersonView(wutta.PersonView): model_class = Person Session = Session + # TODO: /grids/complete.mako is too aggressive for the + # limited support we have in wuttaweb thus far + paginated = False + labels = { 'display_name': "Full Name", } diff --git a/tests/grids/test_core.py b/tests/grids/test_core.py index 0d0fe112..7cba917a 100644 --- a/tests/grids/test_core.py +++ b/tests/grids/test_core.py @@ -19,6 +19,32 @@ class TestGrid(WebTestCase): grid = self.make_grid('foo') self.assertIsInstance(grid, mod.Grid) + def test_deprecated_params(self): + + # component + grid = self.make_grid() + self.assertEqual(grid.vue_tagname, 'tailbone-grid') + grid = self.make_grid(component='blarg') + self.assertEqual(grid.vue_tagname, 'blarg') + + # pageable + grid = self.make_grid() + self.assertFalse(grid.paginated) + grid = self.make_grid(pageable=True) + self.assertTrue(grid.paginated) + + # default_pagesize + grid = self.make_grid() + self.assertEqual(grid.pagesize, 20) + grid = self.make_grid(default_pagesize=15) + self.assertEqual(grid.pagesize, 15) + + # default_page + grid = self.make_grid() + self.assertEqual(grid.page, 1) + grid = self.make_grid(default_page=42) + self.assertEqual(grid.page, 42) + def test_vue_tagname(self): # default @@ -133,6 +159,66 @@ class TestGrid(WebTestCase): grid.set_action_urls(setting, setting, 0) self.assertEqual(setting['_action_url_view'], '/blarg') + def test_pageable(self): + grid = self.make_grid() + self.assertFalse(grid.paginated) + grid.pageable = True + self.assertTrue(grid.paginated) + grid.paginated = False + self.assertFalse(grid.pageable) + + def test_get_pagesize_options(self): + grid = self.make_grid() + + # default + options = grid.get_pagesize_options() + self.assertEqual(options, [5, 10, 20, 50, 100, 200]) + + # override default + options = grid.get_pagesize_options(default=[42]) + self.assertEqual(options, [42]) + + # from legacy config + self.config.setdefault('tailbone.grid.pagesize_options', '1 2 3') + grid = self.make_grid() + options = grid.get_pagesize_options() + self.assertEqual(options, [1, 2, 3]) + + # from new config + self.config.setdefault('wuttaweb.grids.default_pagesize_options', '4, 5, 6') + grid = self.make_grid() + options = grid.get_pagesize_options() + self.assertEqual(options, [4, 5, 6]) + + def test_get_pagesize(self): + grid = self.make_grid() + + # default + size = grid.get_pagesize() + self.assertEqual(size, 20) + + # override default + size = grid.get_pagesize(default=42) + self.assertEqual(size, 42) + + # override default options + self.config.setdefault('wuttaweb.grids.default_pagesize_options', '10 15 30') + grid = self.make_grid() + size = grid.get_pagesize() + self.assertEqual(size, 10) + + # from legacy config + self.config.setdefault('tailbone.grid.default_pagesize', '12') + grid = self.make_grid() + size = grid.get_pagesize() + self.assertEqual(size, 12) + + # from new config + self.config.setdefault('wuttaweb.grids.default_pagesize', '15') + grid = self.make_grid() + size = grid.get_pagesize() + self.assertEqual(size, 15) + def test_render_vue_tag(self): model = self.app.model From f4c8176d8325f052e4aa46666b6ae9d5a5779e75 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 16 Aug 2024 22:54:22 -0500 Subject: [PATCH 1585/1681] =?UTF-8?q?bump:=20version=200.17.0=20=E2=86=92?= =?UTF-8?q?=200.18.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 13 +++++++++++++ pyproject.toml | 4 ++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5724e685..0671e03b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.18.0 (2024-08-16) + +### Feat + +- move "basic" grid pagination logic to wuttaweb +- inherit from wutta base class for Grid +- inherit most logic from wuttaweb, for GridAction + +### Fix + +- avoid route error in user view, when using wutta people view +- fix some more wutta compat for base template + ## v0.17.0 (2024-08-15) ### Feat diff --git a/pyproject.toml b/pyproject.toml index 31c7ef8d..bd4882c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.17.0" +version = "0.18.0" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] @@ -59,7 +59,7 @@ dependencies = [ "transaction", "waitress", "WebHelpers2", - "WuttaWeb>=0.8.1", + "WuttaWeb>=0.9.0", "zope.sqlalchemy>=1.5", ] From 5e82fe3946d4a65c67527b704198ac5a8d73c6e1 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 18 Aug 2024 10:20:09 -0500 Subject: [PATCH 1586/1681] fix: fix broken permission directives in web api startup --- tailbone/webapi.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tailbone/webapi.py b/tailbone/webapi.py index 7c0e9b41..d0edb412 100644 --- a/tailbone/webapi.py +++ b/tailbone/webapi.py @@ -85,8 +85,15 @@ def make_pyramid_config(settings): provider.configure_db_sessions(rattail_config, pyramid_config) # add some permissions magic - pyramid_config.add_directive('add_tailbone_permission_group', 'tailbone.auth.add_permission_group') - pyramid_config.add_directive('add_tailbone_permission', 'tailbone.auth.add_permission') + pyramid_config.add_directive('add_wutta_permission_group', + 'wuttaweb.auth.add_permission_group') + pyramid_config.add_directive('add_wutta_permission', + 'wuttaweb.auth.add_permission') + # TODO: deprecate / remove these + pyramid_config.add_directive('add_tailbone_permission_group', + 'wuttaweb.auth.add_permission_group') + pyramid_config.add_directive('add_tailbone_permission', + 'wuttaweb.auth.add_permission') return pyramid_config From c95e42bf828b93f22247660e15df67f2c431a5c4 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 17 Aug 2024 11:05:15 -0500 Subject: [PATCH 1587/1681] fix: fix misc. errors in grid template per wuttaweb --- tailbone/templates/grids/complete.mako | 91 +++++++++++++++++++------- tailbone/views/master.py | 5 +- tailbone/views/wutta/people.py | 10 --- 3 files changed, 70 insertions(+), 36 deletions(-) diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako index 53043803..d3981a16 100644 --- a/tailbone/templates/grids/complete.mako +++ b/tailbone/templates/grids/complete.mako @@ -107,13 +107,17 @@ @cellclick="cellClick" % endif + ## paging % if grid.paginated: - :paginated="paginated" - :per-page="perPage" - :current-page="currentPage" - backend-pagination - :total="total" - @page-change="onPageChange" + paginated + pagination-size="is-small" + :per-page="perPage" + :current-page="currentPage" + @page-change="onPageChange" + % if grid.paginate_on_backend: + backend-pagination + :total="pagerStats.item_count" + % endif % endif ## TODO: should let grid (or master view) decide how to set these? @@ -206,12 +210,13 @@ % endif % if grid.paginated: - <div v-if="firstItem" + <div v-if="pagerStats.first_item" style="display: flex; gap: 0.5rem; align-items: center;"> <span> showing - {{ firstItem.toLocaleString('en') }} - {{ lastItem.toLocaleString('en') }} - of {{ total.toLocaleString('en') }} results; + {{ renderNumber(pagerStats.first_item) }} + - {{ renderNumber(pagerStats.last_item) }} + of {{ renderNumber(pagerStats.item_count) }} results; </span> <b-select v-model="perPage" size="is-small" @@ -257,13 +262,14 @@ checkedRows: ${grid_data['checked_rows_code']|n}, % endif + ## paging % if grid.paginated: - paginated: ${json.dumps(grid.paginated)|n}, - total: ${len(grid_data['data']) if static_data else (grid_data['total_items'] if grid_data is not Undefined else 0)}, - perPage: ${json.dumps(grid.pagesize if grid.paginated else None)|n}, - currentPage: ${json.dumps(grid.page if grid.paginated else None)|n}, - firstItem: ${json.dumps(grid_data['first_item'] if grid.paginated else None)|n}, - lastItem: ${json.dumps(grid_data['last_item'] if grid.paginated else None)|n}, + pageSizeOptions: ${json.dumps(grid.pagesize_options)|n}, + perPage: ${json.dumps(grid.pagesize)|n}, + currentPage: ${json.dumps(grid.page)|n}, + % if grid.paginate_on_backend: + pagerStats: ${json.dumps(grid.get_vue_pager_stats())|n}, + % endif % endif % if getattr(grid, 'sortable', False): @@ -311,6 +317,32 @@ computed: { + ## TODO: this should be temporary? but anyway 'total' is + ## still referenced in other places, e.g. "delete results" + % if grid.paginated: + total() { return this.pagerStats.item_count }, + % endif + + % if not grid.paginate_on_backend: + + pagerStats() { + const data = this.visibleData + let last = this.currentPage * this.perPage + let first = last - this.perPage + 1 + if (last > data.length) { + last = data.length + } + return { + 'item_count': data.length, + 'items_per_page': this.perPage, + 'page': this.currentPage, + 'first_item': first, + 'last_item': last, + } + }, + + % endif + addFilterChoices() { // nb. this returns all choices available for "Add Filter" operation @@ -373,6 +405,12 @@ methods: { + renderNumber(value) { + if (value != undefined) { + return value.toLocaleString('en') + } + }, + formatAddFilterItem(filtr) { if (!filtr.key) { filtr = this.filters[filtr] @@ -486,23 +524,23 @@ params = params.toString() this.loading = true - this.$http.get(`${'$'}{this.ajaxDataUrl}?${'$'}{params}`).then(({ data }) => { - if (!data.error) { - ${grid.vue_component}CurrentData = data.data + this.$http.get(`${'$'}{this.ajaxDataUrl}?${'$'}{params}`).then(response => { + if (!response.data.error) { + ${grid.vue_component}CurrentData = response.data.data.data this.data = ${grid.vue_component}CurrentData - this.rowStatusMap = data.row_status_map - this.total = data.total_items - this.firstItem = data.first_item - this.lastItem = data.last_item + % if grid.paginated and grid.paginate_on_backend: + this.pagerStats = response.data.pager_stats + % endif + this.rowStatusMap = response.data.data.row_status_map this.loading = false this.savingDefaults = false - this.checkedRows = this.locateCheckedRows(data.checked_rows) + this.checkedRows = this.locateCheckedRows(response.data.data.checked_rows) if (success) { success() } } else { this.$buefy.toast.open({ - message: data.error, + message: response.data.error, type: 'is-danger', duration: 2000, // 4 seconds }) @@ -514,8 +552,11 @@ } }) .catch((error) => { + ${grid.vue_component}CurrentData = [] this.data = [] - this.total = 0 + % if grid.paginated and grid.paginate_on_backend: + this.pagerStats = {} + % endif this.loading = false this.savingDefaults = false if (failure) { diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 58b93568..1fa0ae40 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -346,7 +346,10 @@ class MasterView(View): # return grid data only, if partial page was requested if self.request.params.get('partial'): - return self.json_response(grid.get_table_data()) + context = {'data': grid.get_table_data()} + if grid.paginated and grid.paginate_on_backend: + context['pager_stats'] = grid.get_vue_pager_stats() + return self.json_response(context) context = { 'grid': grid, diff --git a/tailbone/views/wutta/people.py b/tailbone/views/wutta/people.py index 3158b478..c10020ea 100644 --- a/tailbone/views/wutta/people.py +++ b/tailbone/views/wutta/people.py @@ -45,10 +45,6 @@ class PersonView(wutta.PersonView): model_class = Person Session = Session - # TODO: /grids/complete.mako is too aggressive for the - # limited support we have in wuttaweb thus far - paginated = False - labels = { 'display_name': "Full Name", } @@ -91,12 +87,6 @@ class PersonView(wutta.PersonView): # display_name g.set_link('display_name') - # first_name - g.set_link('first_name') - - # last_name - g.set_link('last_name') - # merge_requested g.set_label('merge_requested', "MR") g.set_renderer('merge_requested', self.render_merge_requested) From ec36df4a341a1e8c7ba5821fa270fdb1125b1848 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 18 Aug 2024 14:05:52 -0500 Subject: [PATCH 1588/1681] feat: move single-column grid sorting logic to wuttaweb --- tailbone/forms/core.py | 26 +-- tailbone/grids/core.py | 272 ++++++++++++++----------- tailbone/templates/grids/complete.mako | 63 +++--- tailbone/views/master.py | 28 +-- tailbone/views/wutta/people.py | 8 +- tests/grids/test_core.py | 245 +++++++++++++++++++++- tests/views/test_master.py | 33 ++- 7 files changed, 475 insertions(+), 200 deletions(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index eeae4537..704d3b54 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -47,7 +47,7 @@ from pyramid_deform import SessionFileUploadTempStore from pyramid.renderers import render from webhelpers2.html import tags, HTML -from wuttaweb.util import get_form_data, make_json_safe +from wuttaweb.util import FieldList, get_form_data, make_json_safe from tailbone.db import Session from tailbone.util import raw_datetime, render_markdown @@ -1418,30 +1418,6 @@ class Form(object): return False -class FieldList(list): - """ - Convenience wrapper for a form's field list. - """ - - def insert_before(self, field, newfield): - if field in self: - i = self.index(field) - self.insert(i, newfield) - else: - log.warning("field '%s' not found, will append new field: %s", - field, newfield) - self.append(newfield) - - def insert_after(self, field, newfield): - if field in self: - i = self.index(field) - self.insert(i + 1, newfield) - else: - log.warning("field '%s' not found, will append new field: %s", - field, newfield) - self.append(newfield) - - @colander.deferred def upload_widget(node, kw): request = kw['request'] diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 0b23fb78..cc1888fb 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -39,7 +39,8 @@ from pyramid.renderers import render from webhelpers2.html import HTML, tags from paginate_sqlalchemy import SqlalchemyOrmPage -from wuttaweb.grids import Grid as WuttaGrid, GridAction as WuttaGridAction +from wuttaweb.grids import Grid as WuttaGrid, GridAction as WuttaGridAction, SortInfo +from wuttaweb.util import FieldList from . import filters as gridfilters from tailbone.db import Session from tailbone.util import raw_datetime @@ -48,23 +49,17 @@ from tailbone.util import raw_datetime log = logging.getLogger(__name__) -class FieldList(list): - """ - Convenience wrapper for a field list. - """ - - def insert_before(self, field, newfield): - i = self.index(field) - self.insert(i, newfield) - - def insert_after(self, field, newfield): - i = self.index(field) - self.insert(i + 1, newfield) - - class Grid(WuttaGrid): """ - Core grid class. In sore need of documentation. + Base class for all grids. + + This is now a subclass of + :class:`wuttaweb:wuttaweb.grids.base.Grid`, and exists to add + customizations which have traditionally been part of Tailbone. + + Some of these customizations are still undocumented. Some will + eventually be moved to the upstream/parent class, and possibly + some will be removed outright. What docs we have, are shown here. .. _Buefy docs: https://buefy.org/documentation/table/ @@ -206,10 +201,6 @@ class Grid(WuttaGrid): filters={}, use_byte_string_filters=False, searchable={}, - sortable=False, - sorters={}, - default_sortkey=None, - default_sortdir='asc', checkboxes=False, checked=None, check_handler=None, @@ -231,6 +222,20 @@ class Grid(WuttaGrid): DeprecationWarning, stacklevel=2) kwargs.setdefault('vue_tagname', kwargs.pop('component')) + if kwargs.get('default_sortkey'): + warnings.warn("default_sortkey param is deprecated for Grid(); " + "please use sort_defaults param instead", + DeprecationWarning, stacklevel=2) + if kwargs.get('default_sortdir'): + warnings.warn("default_sortdir param is deprecated for Grid(); " + "please use sort_defaults param instead", + DeprecationWarning, stacklevel=2) + if kwargs.get('default_sortkey') or kwargs.get('default_sortdir'): + sortkey = kwargs.pop('default_sortkey', None) + sortdir = kwargs.pop('default_sortdir', 'asc') + if sortkey: + kwargs.setdefault('sort_defaults', [(sortkey, sortdir)]) + if kwargs.get('pageable'): warnings.warn("component param is deprecated for Grid(); " "please use vue_tagname param instead", @@ -284,11 +289,6 @@ class Grid(WuttaGrid): self.searchable = searchable or {} - self.sortable = sortable - self.sorters = self.make_sorters(sorters) - self.default_sortkey = default_sortkey - self.default_sortdir = default_sortdir - self.checkboxes = checkboxes self.checked = checked if self.checked is None: @@ -328,9 +328,7 @@ class Grid(WuttaGrid): @property def component(self): - """ - DEPRECATED - use :attr:`vue_tagname` instead. - """ + """ """ warnings.warn("Grid.component is deprecated; " "please use vue_tagname instead", DeprecationWarning, stacklevel=2) @@ -338,20 +336,66 @@ class Grid(WuttaGrid): @property def component_studly(self): - """ - DEPRECATED - use :attr:`vue_component` instead. - """ + """ """ warnings.warn("Grid.component_studly is deprecated; " "please use vue_component instead", DeprecationWarning, stacklevel=2) return self.vue_component + def get_default_sortkey(self): + """ """ + warnings.warn("Grid.default_sortkey is deprecated; " + "please use Grid.sort_defaults instead", + DeprecationWarning, stacklevel=2) + if self.sort_defaults: + return self.sort_defaults[0].sortkey + + def set_default_sortkey(self, value): + """ """ + warnings.warn("Grid.default_sortkey is deprecated; " + "please use Grid.sort_defaults instead", + DeprecationWarning, stacklevel=2) + if self.sort_defaults: + info = self.sort_defaults[0] + self.sort_defaults[0] = SortInfo(value, info.sortdir) + else: + self.sort_defaults = [SortInfo(value, 'asc')] + + default_sortkey = property(get_default_sortkey, set_default_sortkey) + + def get_default_sortdir(self): + """ """ + warnings.warn("Grid.default_sortdir is deprecated; " + "please use Grid.sort_defaults instead", + DeprecationWarning, stacklevel=2) + if self.sort_defaults: + return self.sort_defaults[0].sortdir + + def set_default_sortdir(self, value): + """ """ + warnings.warn("Grid.default_sortdir is deprecated; " + "please use Grid.sort_defaults instead", + DeprecationWarning, stacklevel=2) + if self.sort_defaults: + info = self.sort_defaults[0] + self.sort_defaults[0] = SortInfo(info.sortkey, value) + else: + raise ValueError("cannot set default_sortdir without default_sortkey") + + default_sortdir = property(get_default_sortdir, set_default_sortdir) + def get_pageable(self): """ """ + warnings.warn("Grid.pageable is deprecated; " + "please use Grid.paginated instead", + DeprecationWarning, stacklevel=2) return self.paginated def set_pageable(self, value): """ """ + warnings.warn("Grid.pageable is deprecated; " + "please use Grid.paginated instead", + DeprecationWarning, stacklevel=2) self.paginated = value pageable = property(get_pageable, set_pageable) @@ -405,18 +449,30 @@ class Grid(WuttaGrid): self.joiners[key] = joiner def set_sorter(self, key, *args, **kwargs): - if len(args) == 1 and args[0] is None: - self.remove_sorter(key) + """ """ + + if len(args) == 1: + if kwargs: + warnings.warn("kwargs are ignored for Grid.set_sorter(); " + "please refactor your code accordingly", + DeprecationWarning, stacklevel=2) + if args[0] is None: + warnings.warn("specifying None is deprecated for Grid.set_sorter(); " + "please use Grid.remove_sorter() instead", + DeprecationWarning, stacklevel=2) + self.remove_sorter(key) + else: + super().set_sorter(key, args[0]) + + elif len(args) == 0: + super().set_sorter(key) + else: + warnings.warn("multiple args are deprecated for Grid.set_sorter(); " + "please refactor your code accordingly", + DeprecationWarning, stacklevel=2) self.sorters[key] = self.make_sorter(*args, **kwargs) - def remove_sorter(self, key): - self.sorters.pop(key, None) - - def set_sort_defaults(self, sortkey, sortdir='asc'): - self.default_sortkey = sortkey - self.default_sortdir = sortdir - def set_filter(self, key, *args, **kwargs): if len(args) == 1 and args[0] is None: self.remove_filter(key) @@ -731,53 +787,12 @@ class Grid(WuttaGrid): if filtr.active: yield filtr - def make_sorters(self, sorters=None): - """ - Returns an initial set of sorters which will be available to the grid. - The grid itself may or may not provide some default sorters, and the - ``sorters`` kwarg may contain additions and/or overrides. - """ - sorters, updates = {}, sorters - if self.model_class: - mapper = orm.class_mapper(self.model_class) - for prop in mapper.iterate_properties: - if isinstance(prop, orm.ColumnProperty) and not prop.key.endswith('uuid'): - sorters[prop.key] = self.make_sorter(prop) - if updates: - sorters.update(updates) - return sorters - - def make_sorter(self, model_property): - """ - Returns a function suitable for a sort map callable, with typical logic - built in for sorting applied to ``field``. - """ - class_ = getattr(model_property, 'class_', self.model_class) - column = getattr(class_, model_property.key) - - def sorter(query, direction): - # TODO: this seems hacky..normally we expect a true query - # of course, but in some cases it may be a list instead. - # if so then we can't actually sort - if isinstance(query, list): - return query - return query.order_by(getattr(column, direction)()) - - sorter._class = class_ - sorter._column = column - - return sorter - def make_simple_sorter(self, key, foldcase=False): - """ - Returns a function suitable for a sort map callable, with typical logic - built in for sorting a data set comprised of dicts, on the given key. - """ - if foldcase: - keyfunc = lambda v: v[key].lower() - else: - keyfunc = lambda v: v[key] - return lambda q, d: sorted(q, key=keyfunc, reverse=d == 'desc') + """ """ + warnings.warn("Grid.make_simple_sorter() is deprecated; " + "please use Grid.make_sorter() instead", + DeprecationWarning, stacklevel=2) + return self.make_sorter(key, foldcase=foldcase) def get_pagesize_options(self, default=None): """ """ @@ -849,10 +864,17 @@ class Grid(WuttaGrid): # initial default settings settings = {} if self.sortable: - if self.default_sortkey: + if self.sort_defaults: + sort_defaults = self.sort_defaults + if len(sort_defaults) > 1: + log.warning("multiple sort defaults are not yet supported; " + "list will be pruned to first element for '%s' grid: %s", + self.key, sort_defaults) + sort_defaults = [sort_defaults[0]] + sortinfo = sort_defaults[0] settings['sorters.length'] = 1 - settings['sorters.1.key'] = self.default_sortkey - settings['sorters.1.dir'] = self.default_sortdir + settings['sorters.1.key'] = sortinfo.sortkey + settings['sorters.1.dir'] = sortinfo.sortdir else: settings['sorters.length'] = 0 if self.paginated: @@ -927,11 +949,12 @@ class Grid(WuttaGrid): filtr.verb = settings['filter.{}.verb'.format(filtr.key)] filtr.value = settings['filter.{}.value'.format(filtr.key)] if self.sortable: + # and self.sort_on_backend: self.active_sorters = [] for i in range(1, settings['sorters.length'] + 1): self.active_sorters.append({ - 'field': settings[f'sorters.{i}.key'], - 'order': settings[f'sorters.{i}.dir'], + 'key': settings[f'sorters.{i}.key'], + 'dir': settings[f'sorters.{i}.dir'], }) if self.paginated: self.pagesize = settings['pagesize'] @@ -1321,21 +1344,24 @@ class Grid(WuttaGrid): return data - def sort_data(self, data): - """ - Sort the given query according to current settings, and return the result. - """ - # bail if no sort settings - if not self.active_sorters: + def sort_data(self, data, sorters=None): + """ """ + if sorters is None: + sorters = self.active_sorters + if not sorters: return data - # TODO: is there a better way to check for SA sorting? - if self.model_class: + # sqlalchemy queries require special handling, in case of + # multi-column sorting + if isinstance(data, orm.Query): # collect actual column sorters for order_by clause - sorters = [] - for sorter in self.active_sorters: - sortkey = sorter['field'] + query_sorters = [] + for sorter in sorters: + sortkey = sorter['key'] + sortdir = sorter['dir'] + + # cannot sort unless we have a sorter callable sortfunc = self.sorters.get(sortkey) if not sortfunc: log.warning("unknown sorter: %s", sorter) @@ -1347,34 +1373,36 @@ class Grid(WuttaGrid): self.joined.add(sortkey) # add column/dir to collection - sortdir = sorter['order'] - sorters.append(getattr(sortfunc._column, sortdir)()) + query_sorters.append(getattr(sortfunc._column, sortdir)()) # apply sorting to query - if sorters: - data = data.order_by(*sorters) + if query_sorters: + data = data.order_by(*query_sorters) return data - else: - # not a SQLAlchemy grid, custom sorter + # manual sorting; only one column allowed + if len(sorters) != 1: + raise NotImplementedError("mulit-column manual sorting not yet supported") - assert len(self.active_sorters) < 2 + # our one and only active sorter + sorter = sorters[0] + sortkey = sorter['key'] + sortdir = sorter['dir'] - sortkey = self.active_sorters[0]['field'] - sortdir = self.active_sorters[0]['order'] or 'asc' + # cannot sort unless we have a sorter callable + sortfunc = self.sorters.get(sortkey) + if not sortfunc: + return data - # Cannot sort unless we have a sort function. - sortfunc = self.sorters.get(sortkey) - if not sortfunc: - return data + # apply joins needed for this sorter + # TODO: is this actually relevant for manual sort? + if sortkey in self.joiners and sortkey not in self.joined: + data = self.joiners[sortkey](data) + self.joined.add(sortkey) - # apply joins needed for this sorter - if sortkey in self.joiners and sortkey not in self.joined: - data = self.joiners[sortkey](data) - self.joined.add(sortkey) - - return sortfunc(data, sortdir) + # invoke the sorter + return sortfunc(data, sortdir) def paginate_data(self, data): """ @@ -1671,7 +1699,7 @@ class Grid(WuttaGrid): columns.append({ 'field': name, 'label': self.get_label(name), - 'sortable': self.sortable and name in self.sorters, + 'sortable': self.is_sortable(name), 'visible': name not in self.invisible, }) return columns diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako index d3981a16..5a005c2e 100644 --- a/tailbone/templates/grids/complete.mako +++ b/tailbone/templates/grids/complete.mako @@ -81,7 +81,11 @@ % endif % endif - % if getattr(grid, 'sortable', False): + ## sorting + % if grid.sortable: + ## nb. buefy only supports *one* default sorter + :default-sort="sorters.length ? [sorters[0].key, sorters[0].dir] : null" + backend-sorting @sort="onSort" @sorting-priority-removed="sortingPriorityRemoved" @@ -93,8 +97,6 @@ ## https://github.com/buefy/buefy/issues/2584 :sort-multiple="allowMultiSort" - ## nb. specify default sort only if single-column - :default-sort="backendSorters.length == 1 ? [backendSorters[0].field, backendSorters[0].order] : null" ## nb. otherwise there may be default multi-column sort :sort-multiple-data="sortingPriority" @@ -272,7 +274,9 @@ % endif % endif - % if getattr(grid, 'sortable', False): + ## sorting + % if grid.sortable: + sorters: ${json.dumps(grid.active_sorters)|n}, ## TODO: there is a bug (?) which prevents the arrow from ## displaying for simple default single-column sort. so to @@ -281,10 +285,7 @@ ## https://github.com/buefy/buefy/issues/2584 allowMultiSort: false, - ## nb. this contains all truly active sorters - backendSorters: ${json.dumps(grid.active_sorters)|n}, - - ## nb. whereas this will only contain multi-column sorters, + ## nb. this will only contain multi-column sorters, ## but will be *empty* for single-column sorting % if len(grid.active_sorters) > 1: sortingPriority: ${json.dumps(grid.active_sorters)|n}, @@ -474,17 +475,18 @@ }, getBasicParams() { - let params = {} - % if getattr(grid, 'sortable', False): - for (let i = 1; i <= this.backendSorters.length; i++) { - params['sort'+i+'key'] = this.backendSorters[i-1].field - params['sort'+i+'dir'] = this.backendSorters[i-1].order + const params = { + % if grid.paginated and grid.paginate_on_backend: + pagesize: this.perPage, + page: this.currentPage, + % endif + } + % if grid.sortable and grid.sort_on_backend: + for (let i = 1; i <= this.sorters.length; i++) { + params['sort'+i+'key'] = this.sorters[i-1].key + params['sort'+i+'dir'] = this.sorters[i-1].dir } % endif - % if grid.paginated: - params.pagesize = this.perPage - params.page = this.currentPage - % endif return params }, @@ -526,15 +528,15 @@ this.loading = true this.$http.get(`${'$'}{this.ajaxDataUrl}?${'$'}{params}`).then(response => { if (!response.data.error) { - ${grid.vue_component}CurrentData = response.data.data.data + ${grid.vue_component}CurrentData = response.data.data this.data = ${grid.vue_component}CurrentData % if grid.paginated and grid.paginate_on_backend: this.pagerStats = response.data.pager_stats % endif - this.rowStatusMap = response.data.data.row_status_map + this.rowStatusMap = response.data.row_status_map || {} this.loading = false this.savingDefaults = false - this.checkedRows = this.locateCheckedRows(response.data.data.checked_rows) + this.checkedRows = this.locateCheckedRows(response.data.checked_rows || []) if (success) { success() } @@ -597,26 +599,26 @@ onSort(field, order, event) { - // nb. buefy passes field name, oruga passes object - if (field.field) { + ## nb. buefy passes field name; oruga passes field object + % if request.use_oruga: field = field.field - } + % endif if (event.ctrlKey) { // engage or enhance multi-column sorting - let sorter = this.backendSorters.filter(i => i.field === field)[0] + const sorter = this.sorters.filter(s => s.key === field)[0] if (sorter) { - sorter.order = sorter.order === 'desc' ? 'asc' : 'desc' + sorter.dir = sorter.dir === 'desc' ? 'asc' : 'desc' } else { - this.backendSorters.push({field, order}) + this.sorters.push({key: field, dir: order}) } - this.sortingPriority = this.backendSorters + this.sortingPriority = this.sorters } else { // sort by single column only - this.backendSorters = [{field, order}] + this.sorters = [{key: field, dir: order}] this.sortingPriority = [] } @@ -629,12 +631,11 @@ sortingPriorityRemoved(field) { // prune field from active sorters - this.backendSorters = this.backendSorters.filter( - (sorter) => sorter.field !== field) + this.sorters = this.sorters.filter(s => s.key !== field) // nb. must keep active sorter list "as-is" even if // there is only one sorter; buefy seems to expect it - this.sortingPriority = this.backendSorters + this.sortingPriority = this.sorters this.loadAsyncData() }, diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 1fa0ae40..53f46020 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -345,8 +345,8 @@ class MasterView(View): self.first_visible_grid_index = grid.pager.first_item # return grid data only, if partial page was requested - if self.request.params.get('partial'): - context = {'data': grid.get_table_data()} + if self.request.GET.get('partial'): + context = grid.get_table_data() if grid.paginated and grid.paginate_on_backend: context['pager_stats'] = grid.get_vue_pager_stats() return self.json_response(context) @@ -2565,11 +2565,12 @@ class MasterView(View): so if you like you can return a different help URL depending on which type of CRUD view is in effect, etc. """ + # nb. self.Session may differ, so use tailbone.db.Session + session = Session() model = self.model route_prefix = self.get_route_prefix() - # nb. self.Session may differ, so use tailbone.db.Session - info = Session.query(model.TailbonePageHelp)\ + info = session.query(model.TailbonePageHelp)\ .filter(model.TailbonePageHelp.route_prefix == route_prefix)\ .first() if info and info.help_url: @@ -2587,11 +2588,12 @@ class MasterView(View): """ Return the markdown help text for current page, if defined. """ + # nb. self.Session may differ, so use tailbone.db.Session + session = Session() model = self.model route_prefix = self.get_route_prefix() - # nb. self.Session may differ, so use tailbone.db.Session - info = Session.query(model.TailbonePageHelp)\ + info = session.query(model.TailbonePageHelp)\ .filter(model.TailbonePageHelp.route_prefix == route_prefix)\ .first() if info and info.markdown_text: @@ -2608,6 +2610,8 @@ class MasterView(View): if not self.can_edit_help(): raise self.forbidden() + # nb. self.Session may differ, so use tailbone.db.Session + session = Session() model = self.model route_prefix = self.get_route_prefix() schema = colander.Schema() @@ -2625,13 +2629,12 @@ class MasterView(View): if not form.validate(): return {'error': "Form did not validate"} - # nb. self.Session may differ, so use tailbone.db.Session - info = Session.query(model.TailbonePageHelp)\ + info = session.query(model.TailbonePageHelp)\ .filter(model.TailbonePageHelp.route_prefix == route_prefix)\ .first() if not info: info = model.TailbonePageHelp(route_prefix=route_prefix) - Session.add(info) + session.add(info) info.help_url = form.validated['help_url'] info.markdown_text = form.validated['markdown_text'] @@ -2641,6 +2644,8 @@ class MasterView(View): if not self.can_edit_help(): raise self.forbidden() + # nb. self.Session may differ, so use tailbone.db.Session + session = Session() model = self.model route_prefix = self.get_route_prefix() schema = colander.Schema() @@ -2657,15 +2662,14 @@ class MasterView(View): if not form.validate(): return {'error': "Form did not validate"} - # nb. self.Session may differ, so use tailbone.db.Session - info = Session.query(model.TailboneFieldInfo)\ + info = session.query(model.TailboneFieldInfo)\ .filter(model.TailboneFieldInfo.route_prefix == route_prefix)\ .filter(model.TailboneFieldInfo.field_name == form.validated['field_name'])\ .first() if not info: info = model.TailboneFieldInfo(route_prefix=route_prefix, field_name=form.validated['field_name']) - Session.add(info) + session.add(info) info.markdown_text = form.validated['markdown_text'] return {'ok': True} diff --git a/tailbone/views/wutta/people.py b/tailbone/views/wutta/people.py index c10020ea..968eaf3d 100644 --- a/tailbone/views/wutta/people.py +++ b/tailbone/views/wutta/people.py @@ -44,6 +44,7 @@ class PersonView(wutta.PersonView): """ model_class = Person Session = Session + sort_defaults = 'display_name' labels = { 'display_name': "Full Name", @@ -73,13 +74,6 @@ class PersonView(wutta.PersonView): # CRUD methods ############################## - def get_query(self, session=None): - """ """ - model = self.app.model - session = session or self.Session() - return session.query(model.Person)\ - .order_by(model.Person.display_name) - def configure_grid(self, g): """ """ super().configure_grid(g) diff --git a/tests/grids/test_core.py b/tests/grids/test_core.py index 7cba917a..9f9b816f 100644 --- a/tests/grids/test_core.py +++ b/tests/grids/test_core.py @@ -1,6 +1,8 @@ # -*- coding: utf-8; -*- -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch + +from sqlalchemy import orm from tailbone.grids import core as mod from tests.util import WebTestCase @@ -27,6 +29,16 @@ class TestGrid(WebTestCase): grid = self.make_grid(component='blarg') self.assertEqual(grid.vue_tagname, 'blarg') + # default_sortkey, default_sortdir + grid = self.make_grid() + self.assertEqual(grid.sort_defaults, []) + grid = self.make_grid(default_sortkey='name') + self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'asc')]) + grid = self.make_grid(default_sortdir='desc') + self.assertEqual(grid.sort_defaults, []) + grid = self.make_grid(default_sortkey='name', default_sortdir='desc') + self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'desc')]) + # pageable grid = self.make_grid() self.assertFalse(grid.paginated) @@ -159,6 +171,27 @@ class TestGrid(WebTestCase): grid.set_action_urls(setting, setting, 0) self.assertEqual(setting['_action_url_view'], '/blarg') + def test_default_sortkey(self): + grid = self.make_grid() + self.assertEqual(grid.sort_defaults, []) + self.assertIsNone(grid.default_sortkey) + grid.default_sortkey = 'name' + self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'asc')]) + self.assertEqual(grid.default_sortkey, 'name') + grid.default_sortkey = 'value' + self.assertEqual(grid.sort_defaults, [mod.SortInfo('value', 'asc')]) + self.assertEqual(grid.default_sortkey, 'value') + + def test_default_sortdir(self): + grid = self.make_grid() + self.assertEqual(grid.sort_defaults, []) + self.assertIsNone(grid.default_sortdir) + self.assertRaises(ValueError, setattr, grid, 'default_sortdir', 'asc') + grid.sort_defaults = [mod.SortInfo('name', 'asc')] + grid.default_sortdir = 'desc' + self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'desc')]) + self.assertEqual(grid.default_sortdir, 'desc') + def test_pageable(self): grid = self.make_grid() self.assertFalse(grid.paginated) @@ -219,6 +252,212 @@ class TestGrid(WebTestCase): size = grid.get_pagesize() self.assertEqual(size, 15) + def test_set_sorter(self): + model = self.app.model + grid = self.make_grid(model_class=model.Setting, + sortable=True, sort_on_backend=True) + + # passing None will remove sorter + self.assertIn('name', grid.sorters) + grid.set_sorter('name', None) + self.assertNotIn('name', grid.sorters) + + # can recreate sorter with just column name + grid.set_sorter('name') + self.assertIn('name', grid.sorters) + grid.remove_sorter('name') + self.assertNotIn('name', grid.sorters) + grid.set_sorter('name', 'name') + self.assertIn('name', grid.sorters) + + # can recreate sorter with model property + grid.remove_sorter('name') + self.assertNotIn('name', grid.sorters) + grid.set_sorter('name', model.Setting.name) + self.assertIn('name', grid.sorters) + + # extra kwargs are ignored + grid.remove_sorter('name') + self.assertNotIn('name', grid.sorters) + grid.set_sorter('name', model.Setting.name, foo='bar') + self.assertIn('name', grid.sorters) + + # passing multiple args will invoke make_filter() directly + grid.remove_sorter('name') + self.assertNotIn('name', grid.sorters) + with patch.object(grid, 'make_sorter') as make_sorter: + make_sorter.return_value = 42 + grid.set_sorter('name', 'foo', 'bar') + make_sorter.assert_called_once_with('foo', 'bar') + self.assertEqual(grid.sorters['name'], 42) + + def test_make_simple_sorter(self): + model = self.app.model + grid = self.make_grid(model_class=model.Setting, + sortable=True, sort_on_backend=True) + + # delegates to grid.make_sorter() + with patch.object(grid, 'make_sorter') as make_sorter: + make_sorter.return_value = 42 + sorter = grid.make_simple_sorter('name', foldcase=True) + make_sorter.assert_called_once_with('name', foldcase=True) + self.assertEqual(sorter, 42) + + def test_load_settings(self): + model = self.app.model + + # nb. first use a paging grid + grid = self.make_grid(key='foo', paginated=True, paginate_on_backend=True, + pagesize=20, page=1) + + # settings are loaded, applied, saved + self.assertEqual(grid.page, 1) + self.assertNotIn('grid.foo.page', self.request.session) + self.request.GET = {'pagesize': '10', 'page': '2'} + grid.load_settings() + self.assertEqual(grid.page, 2) + self.assertEqual(self.request.session['grid.foo.page'], 2) + + # can skip the saving step + self.request.GET = {'pagesize': '10', 'page': '3'} + grid.load_settings(store=False) + self.assertEqual(grid.page, 3) + self.assertEqual(self.request.session['grid.foo.page'], 2) + + # no error for non-paginated grid + grid = self.make_grid(key='foo', paginated=False) + grid.load_settings() + self.assertFalse(grid.paginated) + + # nb. next use a sorting grid + grid = self.make_grid(key='settings', model_class=model.Setting, + sortable=True, sort_on_backend=True) + + # settings are loaded, applied, saved + self.assertEqual(grid.sort_defaults, []) + self.assertFalse(hasattr(grid, 'active_sorters')) + self.request.GET = {'sort1key': 'name', 'sort1dir': 'desc'} + grid.load_settings() + self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'desc'}]) + self.assertEqual(self.request.session['grid.settings.sorters.length'], 1) + self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name') + self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'desc') + + # can skip the saving step + self.request.GET = {'sort1key': 'name', 'sort1dir': 'asc'} + grid.load_settings(store=False) + self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'asc'}]) + self.assertEqual(self.request.session['grid.settings.sorters.length'], 1) + self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name') + self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'desc') + + # no error for non-sortable grid + grid = self.make_grid(key='foo', sortable=False) + grid.load_settings() + self.assertFalse(grid.sortable) + + # with sort defaults + grid = self.make_grid(model_class=model.Setting, sortable=True, + sort_on_backend=True, sort_defaults='name') + self.assertFalse(hasattr(grid, 'active_sorters')) + grid.load_settings() + self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'asc'}]) + + # with multi-column sort defaults + grid = self.make_grid(model_class=model.Setting, sortable=True, + sort_on_backend=True) + grid.sort_defaults = [ + mod.SortInfo('name', 'asc'), + mod.SortInfo('value', 'desc'), + ] + self.assertFalse(hasattr(grid, 'active_sorters')) + grid.load_settings() + self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'asc'}]) + + # load settings from session when nothing is in request + self.request.GET = {} + self.request.session.invalidate() + self.assertNotIn('grid.settings.sorters.length', self.request.session) + self.request.session['grid.settings.sorters.length'] = 1 + self.request.session['grid.settings.sorters.1.key'] = 'name' + self.request.session['grid.settings.sorters.1.dir'] = 'desc' + grid = self.make_grid(key='settings', model_class=model.Setting, + sortable=True, sort_on_backend=True, + paginated=True, paginate_on_backend=True) + self.assertFalse(hasattr(grid, 'active_sorters')) + grid.load_settings() + self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'desc'}]) + + def test_sort_data(self): + model = self.app.model + sample_data = [ + {'name': 'foo1', 'value': 'ONE'}, + {'name': 'foo2', 'value': 'two'}, + {'name': 'foo3', 'value': 'three'}, + {'name': 'foo4', 'value': 'four'}, + {'name': 'foo5', 'value': 'five'}, + {'name': 'foo6', 'value': 'six'}, + {'name': 'foo7', 'value': 'seven'}, + {'name': 'foo8', 'value': 'eight'}, + {'name': 'foo9', 'value': 'nine'}, + ] + for setting in sample_data: + self.app.save_setting(self.session, setting['name'], setting['value']) + self.session.commit() + sample_query = self.session.query(model.Setting) + + grid = self.make_grid(model_class=model.Setting, + sortable=True, sort_on_backend=True, + sort_defaults=('name', 'desc')) + grid.load_settings() + + # can sort a simple list of data + sorted_data = grid.sort_data(sample_data) + self.assertIsInstance(sorted_data, list) + self.assertEqual(len(sorted_data), 9) + self.assertEqual(sorted_data[0]['name'], 'foo9') + self.assertEqual(sorted_data[-1]['name'], 'foo1') + + # can also sort a data query + sorted_query = grid.sort_data(sample_query) + self.assertIsInstance(sorted_query, orm.Query) + sorted_data = sorted_query.all() + self.assertEqual(len(sorted_data), 9) + self.assertEqual(sorted_data[0]['name'], 'foo9') + self.assertEqual(sorted_data[-1]['name'], 'foo1') + + # cannot sort data if sorter missing in overrides + sorted_data = grid.sort_data(sample_data, sorters=[]) + # nb. sorted data is in same order as original sample (not sorted) + self.assertEqual(sorted_data[0]['name'], 'foo1') + self.assertEqual(sorted_data[-1]['name'], 'foo9') + + # error if mult-column sort attempted + self.assertRaises(NotImplementedError, grid.sort_data, sample_data, sorters=[ + {'key': 'name', 'dir': 'desc'}, + {'key': 'value', 'dir': 'asc'}, + ]) + + # cannot sort data if sortfunc is missing for column + grid.remove_sorter('name') + sorted_data = grid.sort_data(sample_data) + # nb. sorted data is in same order as original sample (not sorted) + self.assertEqual(sorted_data[0]['name'], 'foo1') + self.assertEqual(sorted_data[-1]['name'], 'foo9') + + # cannot sort data if sortfunc is missing for column + grid.remove_sorter('name') + # nb. attempting multi-column sort, but only one sorter exists + self.assertEqual(list(grid.sorters), ['value']) + grid.active_sorters = [{'key': 'name', 'dir': 'asc'}, + {'key': 'value', 'dir': 'asc'}] + with patch.object(sample_query, 'order_by') as order_by: + order_by.return_value = 42 + sorted_query = grid.sort_data(sample_query) + order_by.assert_called_once() + self.assertEqual(len(order_by.call_args.args), 1) + self.assertEqual(sorted_query, 42) + def test_render_vue_tag(self): model = self.app.model @@ -249,11 +488,13 @@ class TestGrid(WebTestCase): model = self.app.model # sanity check - grid = self.make_grid('settings', model_class=model.Setting) + grid = self.make_grid('settings', model_class=model.Setting, sortable=True) columns = grid.get_vue_columns() self.assertEqual(len(columns), 2) self.assertEqual(columns[0]['field'], 'name') + self.assertTrue(columns[0]['sortable']) self.assertEqual(columns[1]['field'], 'value') + self.assertTrue(columns[1]['sortable']) def test_get_vue_data(self): model = self.app.model diff --git a/tests/views/test_master.py b/tests/views/test_master.py index 572875a0..0e459e7d 100644 --- a/tests/views/test_master.py +++ b/tests/views/test_master.py @@ -1,6 +1,6 @@ # -*- coding: utf-8; -*- -from unittest.mock import patch +from unittest.mock import patch, MagicMock from tailbone.views import master as mod from wuttaweb.grids import GridAction @@ -33,3 +33,34 @@ class TestMasterView(WebTestCase): view = self.make_view() action = view.make_action('view') self.assertIsInstance(action, GridAction) + + def test_index(self): + self.pyramid_config.include('tailbone.views.common') + self.pyramid_config.include('tailbone.views.auth') + model = self.app.model + + # mimic view for /settings + with patch.object(mod, 'Session', return_value=self.session): + with patch.multiple(mod.MasterView, create=True, + model_class=model.Setting, + Session=MagicMock(return_value=self.session), + get_index_url=MagicMock(return_value='/settings/'), + get_help_url=MagicMock(return_value=None)): + + # basic + view = self.make_view() + response = view.index() + self.assertEqual(response.status_code, 200) + + # then again with data, to include view action url + data = [{'name': 'foo', 'value': 'bar'}] + with patch.object(view, 'get_data', return_value=data): + response = view.index() + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content_type, 'text/html') + + # then once more as 'partial' - aka. data only + self.request.GET = {'partial': '1'} + response = view.index() + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content_type, 'application/json') From 290f8fd51eddca9e2f3778a23f44bfe356e94ad7 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 18 Aug 2024 19:22:04 -0500 Subject: [PATCH 1589/1681] feat: move multi-column grid sorting logic to wuttaweb tailbone grid template still duplicates much for Vue, and will until we can port the filters and anything else remaining.. --- tailbone/grids/core.py | 251 +++++++------------------ tailbone/templates/base.mako | 18 +- tailbone/templates/grids/complete.mako | 181 ++++++++++-------- tailbone/views/master.py | 3 +- tests/grids/test_core.py | 91 ++++++--- 5 files changed, 252 insertions(+), 292 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index cc1888fb..9c445fec 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -850,28 +850,23 @@ class Grid(WuttaGrid): return self.get_pagesize() - def load_settings(self, store=True): - """ - Load current/effective settings for the grid, from the request query - string and/or session storage. If ``store`` is true, then once - settings have been fully read, they are stored in current session for - next time. Finally, various instance attributes of the grid and its - filters are updated in-place to reflect the settings; this is so code - needn't access the settings dict directly, but the more Pythonic - instance attributes. - """ + def load_settings(self, **kwargs): + """ """ + if 'store' in kwargs: + warnings.warn("the 'store' param is deprecated for load_settings(); " + "please use the 'persist' param instead", + DeprecationWarning, stacklevel=2) + kwargs.setdefault('persist', kwargs.pop('store')) + + persist = kwargs.get('persist', True) # initial default settings settings = {} if self.sortable: if self.sort_defaults: - sort_defaults = self.sort_defaults - if len(sort_defaults) > 1: - log.warning("multiple sort defaults are not yet supported; " - "list will be pruned to first element for '%s' grid: %s", - self.key, sort_defaults) - sort_defaults = [sort_defaults[0]] - sortinfo = sort_defaults[0] + # nb. as of writing neither Buefy nor Oruga support a + # multi-column *default* sort; so just use first sorter + sortinfo = self.sort_defaults[0] settings['sorters.length'] = 1 settings['sorters.1.key'] = sortinfo.sortkey settings['sorters.1.dir'] = sortinfo.sortdir @@ -900,16 +895,16 @@ class Grid(WuttaGrid): elif self.filterable and self.request_has_settings('filter'): self.update_filter_settings(settings, 'request') if self.request_has_settings('sort'): - self.update_sort_settings(settings, 'request') + self.update_sort_settings(settings, src='request') else: - self.update_sort_settings(settings, 'session') + self.update_sort_settings(settings, src='session') self.update_page_settings(settings) # If request has no filter settings but does have sort settings, grab # those, then grab filter settings from session, then grab pager # settings from request or session. elif self.request_has_settings('sort'): - self.update_sort_settings(settings, 'request') + self.update_sort_settings(settings, src='request') self.update_filter_settings(settings, 'session') self.update_page_settings(settings) @@ -921,26 +916,26 @@ class Grid(WuttaGrid): elif self.request_has_settings('page'): self.update_page_settings(settings) self.update_filter_settings(settings, 'session') - self.update_sort_settings(settings, 'session') + self.update_sort_settings(settings, src='session') # If request has no settings, grab all from session. elif self.session_has_settings(): self.update_filter_settings(settings, 'session') - self.update_sort_settings(settings, 'session') + self.update_sort_settings(settings, src='session') self.update_page_settings(settings) # If no settings were found in request or session, don't store result. else: - store = False + persist = False # Maybe store settings for next time. - if store: - self.persist_settings(settings, 'session') + if persist: + self.persist_settings(settings, dest='session') # If request contained instruction to save current settings as defaults # for the current user, then do that. if self.request.GET.get('save-current-filters-as-defaults') == 'true': - self.persist_settings(settings, 'defaults') + self.persist_settings(settings, dest='defaults') # update ourself to reflect settings if self.filterable: @@ -1107,44 +1102,6 @@ class Grid(WuttaGrid): return any([key.startswith(f'{prefix}.filter') for key in self.request.session]) - def get_setting(self, source, settings, key, normalize=lambda v: v, default=None): - """ - Get the effective value for a particular setting, preferring ``source`` - but falling back to existing ``settings`` and finally the ``default``. - """ - if source not in ('request', 'session'): - raise ValueError("Invalid source identifier: {}".format(source)) - - # If source is query string, try that first. - if source == 'request': - value = self.request.GET.get(key) - if value is not None: - try: - value = normalize(value) - except ValueError: - pass - else: - return value - - # Or, if source is session, try that first. - else: - value = self.request.session.get('grid.{}.{}'.format(self.key, key)) - if value is not None: - return normalize(value) - - # If source had nothing, try default/existing settings. - value = settings.get(key) - if value is not None: - try: - value = normalize(value) - except ValueError: - pass - else: - return value - - # Okay then, default it is. - return default - def update_filter_settings(self, settings, source): """ Updates a settings dictionary according to filter settings data found @@ -1165,71 +1122,18 @@ class Grid(WuttaGrid): # consider filter active if query string contains a value for it settings['{}.active'.format(prefix)] = filtr.key in self.request.GET settings['{}.verb'.format(prefix)] = self.get_setting( - source, settings, '{}.verb'.format(filtr.key), default='') + settings, f'{filtr.key}.verb', src='request', default='') settings['{}.value'.format(prefix)] = self.get_setting( - source, settings, filtr.key, default='') + settings, filtr.key, src='request', default='') else: # source = session settings['{}.active'.format(prefix)] = self.get_setting( - source, settings, '{}.active'.format(prefix), + settings, f'{prefix}.active', src='session', normalize=lambda v: str(v).lower() == 'true', default=False) settings['{}.verb'.format(prefix)] = self.get_setting( - source, settings, '{}.verb'.format(prefix), default='') + settings, f'{prefix}.verb', src='session', default='') settings['{}.value'.format(prefix)] = self.get_setting( - source, settings, '{}.value'.format(prefix), default='') - - def update_sort_settings(self, settings, source): - """ - Updates a settings dictionary according to sort settings data found in - either the GET query string, or session storage. - - :param settings: Dictionary of initial settings, which is to be updated. - - :param source: String identifying the source to consult for settings - data. Must be one of: ``('request', 'session')``. - """ - if not self.sortable: - return - - if source == 'request': - - # TODO: remove this eventually, but some links in the wild - # may still include these params, so leave it for now - if 'sortkey' in self.request.GET: - settings['sorters.length'] = 1 - settings['sorters.1.key'] = self.get_setting(source, settings, 'sortkey') - settings['sorters.1.dir'] = self.get_setting(source, settings, 'sortdir') - - else: # the future - i = 1 - while True: - skey = f'sort{i}key' - if skey in self.request.GET: - settings[f'sorters.{i}.key'] = self.get_setting(source, settings, skey) - settings[f'sorters.{i}.dir'] = self.get_setting(source, settings, f'sort{i}dir') - else: - break - i += 1 - settings['sorters.length'] = i - 1 - - else: # session - - # TODO: definitely will remove this, but leave it for now - # so it doesn't monkey with current user sessions when - # next upgrade happens. so, remove after all are upgraded - sortkey = self.get_setting(source, settings, 'sortkey') - if sortkey: - settings['sorters.length'] = 1 - settings['sorters.1.key'] = sortkey - settings['sorters.1.dir'] = self.get_setting(source, settings, 'sortdir') - - else: # the future - settings['sorters.length'] = self.get_setting(source, settings, - 'sorters.length', int) - for i in range(1, settings['sorters.length'] + 1): - for key in ('key', 'dir'): - skey = f'sorters.{i}.{key}' - settings[skey] = self.get_setting(source, settings, skey) + settings, f'{prefix}.value', src='session', default='') def update_page_settings(self, settings): """ @@ -1264,18 +1168,19 @@ class Grid(WuttaGrid): if page is not None: settings['page'] = int(page) - def persist_settings(self, settings, to='session'): - """ - Persist the given settings in some way, as defined by ``func``. - """ + def persist_settings(self, settings, dest='session'): + """ """ + if dest not in ('defaults', 'session'): + raise ValueError(f"invalid dest identifier: {dest}") + app = self.request.rattail_config.get_app() model = app.model - def persist(key, value=lambda k: settings[k]): - if to == 'defaults': + def persist(key, value=lambda k: settings.get(k)): + if dest == 'defaults': skey = 'tailbone.{}.grid.{}.{}'.format(self.request.user.uuid, self.key, key) app.save_setting(Session(), skey, value(key)) - else: # to == session + else: # dest == session skey = 'grid.{}.{}'.format(self.key, key) self.request.session[skey] = value(key) @@ -1287,9 +1192,11 @@ class Grid(WuttaGrid): if self.sortable: - # first clear existing settings for *sorting* only - # nb. this is because number of sort settings will vary - if to == 'defaults': + # first must clear all sort settings from dest. this is + # because number of sort settings will vary, so we delete + # all and then write all + + if dest == 'defaults': prefix = f'tailbone.{self.request.user.uuid}.grid.{self.key}' query = Session.query(model.Setting)\ .filter(sa.or_( @@ -1303,7 +1210,9 @@ class Grid(WuttaGrid): for setting in query.all(): Session.delete(setting) Session.flush() + else: # session + # remove sort settings from user session prefix = f'grid.{self.key}' for key in list(self.request.session): if key.startswith(f'{prefix}.sorters.'): @@ -1315,10 +1224,12 @@ class Grid(WuttaGrid): self.request.session.pop(f'{prefix}.sortkey', None) self.request.session.pop(f'{prefix}.sortdir', None) - persist('sorters.length') - for i in range(1, settings['sorters.length'] + 1): - persist(f'sorters.{i}.key') - persist(f'sorters.{i}.dir') + # now save sort settings to dest + if 'sorters.length' in settings: + persist('sorters.length') + for i in range(1, settings['sorters.length'] + 1): + persist(f'sorters.{i}.key') + persist(f'sorters.{i}.dir') if self.paginated: persist('pagesize') @@ -1351,58 +1262,32 @@ class Grid(WuttaGrid): if not sorters: return data - # sqlalchemy queries require special handling, in case of - # multi-column sorting - if isinstance(data, orm.Query): + # nb. when data is a query, we want to apply sorters in the + # requested order, so the final query has order_by() in the + # correct "as-is" sequence. however when data is a list we + # must do the opposite, applying in the reverse order, so the + # final list has the most "important" sort(s) applied last. + if not isinstance(data, orm.Query): + sorters = reversed(sorters) - # collect actual column sorters for order_by clause - query_sorters = [] - for sorter in sorters: - sortkey = sorter['key'] - sortdir = sorter['dir'] + for sorter in sorters: + sortkey = sorter['key'] + sortdir = sorter['dir'] - # cannot sort unless we have a sorter callable - sortfunc = self.sorters.get(sortkey) - if not sortfunc: - log.warning("unknown sorter: %s", sorter) - continue + # cannot sort unless we have a sorter callable + sortfunc = self.sorters.get(sortkey) + if not sortfunc: + return data - # join appropriate model if needed - if sortkey in self.joiners and sortkey not in self.joined: - data = self.joiners[sortkey](data) - self.joined.add(sortkey) + # join appropriate model if needed + if sortkey in self.joiners and sortkey not in self.joined: + data = self.joiners[sortkey](data) + self.joined.add(sortkey) - # add column/dir to collection - query_sorters.append(getattr(sortfunc._column, sortdir)()) + # invoke the sorter + data = sortfunc(data, sortdir) - # apply sorting to query - if query_sorters: - data = data.order_by(*query_sorters) - - return data - - # manual sorting; only one column allowed - if len(sorters) != 1: - raise NotImplementedError("mulit-column manual sorting not yet supported") - - # our one and only active sorter - sorter = sorters[0] - sortkey = sorter['key'] - sortdir = sorter['dir'] - - # cannot sort unless we have a sorter callable - sortfunc = self.sorters.get(sortkey) - if not sortfunc: - return data - - # apply joins needed for this sorter - # TODO: is this actually relevant for manual sort? - if sortkey in self.joiners and sortkey not in self.joined: - data = self.joiners[sortkey](data) - self.joined.add(sortkey) - - # invoke the sorter - return sortfunc(data, sortdir) + return data def paginate_data(self, data): """ diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index 3a12859e..8e3b7785 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -658,19 +658,19 @@ ## TODO: is there a better way to check if viewing parent? % if parent_instance is Undefined: % if master.editable and instance_editable and master.has_perm('edit'): - <once-button tag="a" href="${action_url('edit', instance)}" + <once-button tag="a" href="${master.get_action_url('edit', instance)}" icon-left="edit" text="Edit This"> </once-button> % endif - % if master.cloneable and master.has_perm('clone'): - <once-button tag="a" href="${action_url('clone', instance)}" + % if getattr(master, 'cloneable', False) and master.has_perm('clone'): + <once-button tag="a" href="${master.get_action_url('clone', instance)}" icon-left="object-ungroup" text="Clone This"> </once-button> % endif % if master.deletable and instance_deletable and master.has_perm('delete'): - <once-button tag="a" href="${action_url('delete', instance)}" + <once-button tag="a" href="${master.get_action_url('delete', instance)}" type="is-danger" icon-left="trash" text="Delete This"> @@ -679,7 +679,7 @@ % else: ## viewing row % if instance_deletable and master.has_perm('delete_row'): - <once-button tag="a" href="${action_url('delete', instance)}" + <once-button tag="a" href="${master.get_action_url('delete', instance)}" type="is-danger" icon-left="trash" text="Delete This"> @@ -688,13 +688,13 @@ % endif % elif master and master.editing: % if master.viewable and master.has_perm('view'): - <once-button tag="a" href="${action_url('view', instance)}" + <once-button tag="a" href="${master.get_action_url('view', instance)}" icon-left="eye" text="View This"> </once-button> % endif % if master.deletable and instance_deletable and master.has_perm('delete'): - <once-button tag="a" href="${action_url('delete', instance)}" + <once-button tag="a" href="${master.get_action_url('delete', instance)}" type="is-danger" icon-left="trash" text="Delete This"> @@ -702,13 +702,13 @@ % endif % elif master and master.deleting: % if master.viewable and master.has_perm('view'): - <once-button tag="a" href="${action_url('view', instance)}" + <once-button tag="a" href="${master.get_action_url('view', instance)}" icon-left="eye" text="View This"> </once-button> % endif % if master.editable and instance_editable and master.has_perm('edit'): - <once-button tag="a" href="${action_url('edit', instance)}" + <once-button tag="a" href="${master.get_action_url('edit', instance)}" icon-left="edit" text="Edit This"> </once-button> diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako index 5a005c2e..8dc2d6dc 100644 --- a/tailbone/templates/grids/complete.mako +++ b/tailbone/templates/grids/complete.mako @@ -83,26 +83,29 @@ ## sorting % if grid.sortable: - ## nb. buefy only supports *one* default sorter - :default-sort="sorters.length ? [sorters[0].key, sorters[0].dir] : null" - - backend-sorting - @sort="onSort" - @sorting-priority-removed="sortingPriorityRemoved" - - ## TODO: there is a bug (?) which prevents the arrow from - ## displaying for simple default single-column sort. so to - ## work around that, we *disable* multi-sort until the - ## component is mounted. seems to work for now..see also - ## https://github.com/buefy/buefy/issues/2584 - :sort-multiple="allowMultiSort" - - - ## nb. otherwise there may be default multi-column sort - :sort-multiple-data="sortingPriority" - - ## user must ctrl-click column header to do multi-sort - sort-multiple-key="ctrlKey" + ## nb. buefy/oruga only support *one* default sorter + :default-sort="sorters.length ? [sorters[0].field, sorters[0].order] : null" + % if grid.sort_on_backend: + backend-sorting + @sort="onSort" + % endif + % if grid.sort_multiple: + % if grid.sort_on_backend: + ## TODO: there is a bug (?) which prevents the arrow + ## from displaying for simple default single-column sort, + ## when multi-column sort is allowed for the table. for + ## now we work around that by waiting until mount to + ## enable the multi-column support. see also + ## https://github.com/buefy/buefy/issues/2584 + :sort-multiple="allowMultiSort" + :sort-multiple-data="sortingPriority" + @sorting-priority-removed="sortingPriorityRemoved" + % else: + sort-multiple + % endif + ## nb. user must ctrl-click column header for multi-sort + sort-multiple-key="ctrlKey" + % endif % endif % if getattr(grid, 'click_handlers', None): @@ -276,23 +279,24 @@ ## sorting % if grid.sortable: - sorters: ${json.dumps(grid.active_sorters)|n}, - - ## TODO: there is a bug (?) which prevents the arrow from - ## displaying for simple default single-column sort. so to - ## work around that, we *disable* multi-sort until the - ## component is mounted. seems to work for now..see also - ## https://github.com/buefy/buefy/issues/2584 - allowMultiSort: false, - - ## nb. this will only contain multi-column sorters, - ## but will be *empty* for single-column sorting - % if len(grid.active_sorters) > 1: - sortingPriority: ${json.dumps(grid.active_sorters)|n}, - % else: - sortingPriority: [], + sorters: ${json.dumps(grid.get_vue_active_sorters())|n}, + % if grid.sort_multiple: + % if grid.sort_on_backend: + ## TODO: there is a bug (?) which prevents the arrow + ## from displaying for simple default single-column sort, + ## when multi-column sort is allowed for the table. for + ## now we work around that by waiting until mount to + ## enable the multi-column support. see also + ## https://github.com/buefy/buefy/issues/2584 + allowMultiSort: false, + ## nb. this should be empty when current sort is single-column + % if len(grid.active_sorters) > 1: + sortingPriority: ${json.dumps(grid.get_vue_active_sorters())|n}, + % else: + sortingPriority: [], + % endif + % endif % endif - % endif ## filterable: ${json.dumps(grid.filterable)|n}, @@ -395,14 +399,19 @@ }, }, - mounted() { - ## TODO: there is a bug (?) which prevents the arrow from - ## displaying for simple default single-column sort. so to - ## work around that, we *disable* multi-sort until the - ## component is mounted. seems to work for now..see also - ## https://github.com/buefy/buefy/issues/2584 - this.allowMultiSort = true - }, + % if grid.sortable and grid.sort_multiple and grid.sort_on_backend: + + ## TODO: there is a bug (?) which prevents the arrow + ## from displaying for simple default single-column sort, + ## when multi-column sort is allowed for the table. for + ## now we work around that by waiting until mount to + ## enable the multi-column support. see also + ## https://github.com/buefy/buefy/issues/2584 + mounted() { + this.allowMultiSort = true + }, + + % endif methods: { @@ -483,8 +492,8 @@ } % if grid.sortable and grid.sort_on_backend: for (let i = 1; i <= this.sorters.length; i++) { - params['sort'+i+'key'] = this.sorters[i-1].key - params['sort'+i+'dir'] = this.sorters[i-1].dir + params['sort'+i+'key'] = this.sorters[i-1].field + params['sort'+i+'dir'] = this.sorters[i-1].order } % endif return params @@ -597,48 +606,66 @@ }) }, - onSort(field, order, event) { + % if grid.sortable and grid.sort_on_backend: - ## nb. buefy passes field name; oruga passes field object - % if request.use_oruga: - field = field.field - % endif + onSort(field, order, event) { - if (event.ctrlKey) { + ## nb. buefy passes field name; oruga passes field object + % if request.use_oruga: + field = field.field + % endif - // engage or enhance multi-column sorting - const sorter = this.sorters.filter(s => s.key === field)[0] - if (sorter) { - sorter.dir = sorter.dir === 'desc' ? 'asc' : 'desc' - } else { - this.sorters.push({key: field, dir: order}) - } - this.sortingPriority = this.sorters + % if grid.sort_multiple: - } else { + // did user ctrl-click the column header? + if (event.ctrlKey) { + + // toggle direction for existing, or add new sorter + const sorter = this.sorters.filter(s => s.field === field)[0] + if (sorter) { + sorter.order = sorter.order === 'desc' ? 'asc' : 'desc' + } else { + this.sorters.push({field, order}) + } + + // apply multi-column sorting + this.sortingPriority = this.sorters + + } else { + + % endif // sort by single column only - this.sorters = [{key: field, dir: order}] - this.sortingPriority = [] - } + this.sorters = [{field, order}] - // always reset to first page when changing sort options - // TODO: i mean..right? would we ever not want that? - this.currentPage = 1 - this.loadAsyncData() - }, + % if grid.sort_multiple: + // multi-column sort not engaged + this.sortingPriority = [] + } + % endif - sortingPriorityRemoved(field) { + // nb. always reset to first page when sorting changes + this.currentPage = 1 + this.loadAsyncData() + }, - // prune field from active sorters - this.sorters = this.sorters.filter(s => s.key !== field) + % if grid.sort_multiple: - // nb. must keep active sorter list "as-is" even if - // there is only one sorter; buefy seems to expect it - this.sortingPriority = this.sorters + sortingPriorityRemoved(field) { - this.loadAsyncData() - }, + // prune from active sorters + this.sorters = this.sorters.filter(s => s.field !== field) + + // nb. even though we might have just one sorter + // now, we are still technically in multi-sort mode + this.sortingPriority = this.sorters + + this.loadAsyncData() + }, + + % endif + + % endif resetView() { this.loading = true diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 53f46020..dde72106 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -341,7 +341,7 @@ class MasterView(View): return self.redirect(self.request.current_route_url(**kw)) # Stash some grid stats, for possible use when generating URLs. - if grid.pageable and hasattr(grid, 'pager'): + if grid.paginated and hasattr(grid, 'pager'): self.first_visible_grid_index = grid.pager.first_item # return grid data only, if partial page was requested @@ -442,6 +442,7 @@ class MasterView(View): 'filterable': self.filterable, 'use_byte_string_filters': self.use_byte_string_filters, 'sortable': self.sortable, + 'sort_multiple': not self.request.use_oruga, 'paginated': self.pageable, 'extra_row_class': self.grid_extra_class, 'url': lambda obj: self.get_action_url('view', obj), diff --git a/tests/grids/test_core.py b/tests/grids/test_core.py index 9f9b816f..c621627a 100644 --- a/tests/grids/test_core.py +++ b/tests/grids/test_core.py @@ -388,14 +388,63 @@ class TestGrid(WebTestCase): grid.load_settings() self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'desc'}]) + def test_persist_settings(self): + model = self.app.model + + # nb. start out with paginated-only grid + grid = self.make_grid(key='foo', paginated=True, paginate_on_backend=True) + + # invalid dest + self.assertRaises(ValueError, grid.persist_settings, {}, dest='doesnotexist') + + # nb. no error if empty settings, but it saves null values + grid.persist_settings({}, dest='session') + self.assertIsNone(self.request.session['grid.foo.page']) + + # provided values are saved + grid.persist_settings({'pagesize': 15, 'page': 3}, dest='session') + self.assertEqual(self.request.session['grid.foo.page'], 3) + + # nb. now switch to sortable-only grid + grid = self.make_grid(key='settings', model_class=model.Setting, + sortable=True, sort_on_backend=True) + + # no error if empty settings; does not save values + grid.persist_settings({}, dest='session') + self.assertNotIn('grid.settings.sorters.length', self.request.session) + + # provided values are saved + grid.persist_settings({'sorters.length': 2, + 'sorters.1.key': 'name', + 'sorters.1.dir': 'desc', + 'sorters.2.key': 'value', + 'sorters.2.dir': 'asc'}, + dest='session') + self.assertEqual(self.request.session['grid.settings.sorters.length'], 2) + self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name') + self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'desc') + self.assertEqual(self.request.session['grid.settings.sorters.2.key'], 'value') + self.assertEqual(self.request.session['grid.settings.sorters.2.dir'], 'asc') + + # old values removed when new are saved + grid.persist_settings({'sorters.length': 1, + 'sorters.1.key': 'name', + 'sorters.1.dir': 'desc'}, + dest='session') + self.assertEqual(self.request.session['grid.settings.sorters.length'], 1) + self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name') + self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'desc') + self.assertNotIn('grid.settings.sorters.2.key', self.request.session) + self.assertNotIn('grid.settings.sorters.2.dir', self.request.session) + def test_sort_data(self): model = self.app.model sample_data = [ {'name': 'foo1', 'value': 'ONE'}, {'name': 'foo2', 'value': 'two'}, - {'name': 'foo3', 'value': 'three'}, - {'name': 'foo4', 'value': 'four'}, - {'name': 'foo5', 'value': 'five'}, + {'name': 'foo3', 'value': 'ggg'}, + {'name': 'foo4', 'value': 'ggg'}, + {'name': 'foo5', 'value': 'ggg'}, {'name': 'foo6', 'value': 'six'}, {'name': 'foo7', 'value': 'seven'}, {'name': 'foo8', 'value': 'eight'}, @@ -432,32 +481,30 @@ class TestGrid(WebTestCase): self.assertEqual(sorted_data[0]['name'], 'foo1') self.assertEqual(sorted_data[-1]['name'], 'foo9') - # error if mult-column sort attempted - self.assertRaises(NotImplementedError, grid.sort_data, sample_data, sorters=[ - {'key': 'name', 'dir': 'desc'}, - {'key': 'value', 'dir': 'asc'}, - ]) + # multi-column sorting for list data + sorted_data = grid.sort_data(sample_data, sorters=[{'key': 'value', 'dir': 'asc'}, + {'key': 'name', 'dir': 'asc'}]) + self.assertEqual(dict(sorted_data[0]), {'name': 'foo8', 'value': 'eight'}) + self.assertEqual(dict(sorted_data[1]), {'name': 'foo3', 'value': 'ggg'}) + self.assertEqual(dict(sorted_data[3]), {'name': 'foo5', 'value': 'ggg'}) + self.assertEqual(dict(sorted_data[-1]), {'name': 'foo2', 'value': 'two'}) + + # multi-column sorting for query + sorted_query = grid.sort_data(sample_query, sorters=[{'key': 'value', 'dir': 'asc'}, + {'key': 'name', 'dir': 'asc'}]) + self.assertEqual(dict(sorted_data[0]), {'name': 'foo8', 'value': 'eight'}) + self.assertEqual(dict(sorted_data[1]), {'name': 'foo3', 'value': 'ggg'}) + self.assertEqual(dict(sorted_data[3]), {'name': 'foo5', 'value': 'ggg'}) + self.assertEqual(dict(sorted_data[-1]), {'name': 'foo2', 'value': 'two'}) # cannot sort data if sortfunc is missing for column grid.remove_sorter('name') - sorted_data = grid.sort_data(sample_data) + sorted_data = grid.sort_data(sample_data, sorters=[{'key': 'value', 'dir': 'asc'}, + {'key': 'name', 'dir': 'asc'}]) # nb. sorted data is in same order as original sample (not sorted) self.assertEqual(sorted_data[0]['name'], 'foo1') self.assertEqual(sorted_data[-1]['name'], 'foo9') - # cannot sort data if sortfunc is missing for column - grid.remove_sorter('name') - # nb. attempting multi-column sort, but only one sorter exists - self.assertEqual(list(grid.sorters), ['value']) - grid.active_sorters = [{'key': 'name', 'dir': 'asc'}, - {'key': 'value', 'dir': 'asc'}] - with patch.object(sample_query, 'order_by') as order_by: - order_by.return_value = 42 - sorted_query = grid.sort_data(sample_query) - order_by.assert_called_once() - self.assertEqual(len(order_by.call_args.args), 1) - self.assertEqual(sorted_query, 42) - def test_render_vue_tag(self): model = self.app.model From b7955a587179e4c7819a9d0a67a60be280e9c386 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 18 Aug 2024 19:58:50 -0500 Subject: [PATCH 1590/1681] =?UTF-8?q?bump:=20version=200.18.0=20=E2=86=92?= =?UTF-8?q?=200.19.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 12 ++++++++++++ pyproject.toml | 4 ++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0671e03b..72798b30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.19.0 (2024-08-18) + +### Feat + +- move multi-column grid sorting logic to wuttaweb +- move single-column grid sorting logic to wuttaweb + +### Fix + +- fix misc. errors in grid template per wuttaweb +- fix broken permission directives in web api startup + ## v0.18.0 (2024-08-16) ### Feat diff --git a/pyproject.toml b/pyproject.toml index bd4882c6..1840de77 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.18.0" +version = "0.19.0" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] @@ -59,7 +59,7 @@ dependencies = [ "transaction", "waitress", "WebHelpers2", - "WuttaWeb>=0.9.0", + "WuttaWeb>=0.10.0", "zope.sqlalchemy>=1.5", ] From 0fb3c0f3d2dde74157b77d0313756151c1373317 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 19 Aug 2024 09:23:31 -0500 Subject: [PATCH 1591/1681] fix: fix broken user auth for web API app --- tailbone/api/auth.py | 12 +++++------ tailbone/app.py | 4 ---- tailbone/auth.py | 50 ++++++++++---------------------------------- 3 files changed, 16 insertions(+), 50 deletions(-) diff --git a/tailbone/api/auth.py b/tailbone/api/auth.py index 1b347b21..a710e30d 100644 --- a/tailbone/api/auth.py +++ b/tailbone/api/auth.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,6 @@ Tailbone Web API - Auth Views """ -from rattail.db.auth import set_user_password - from cornice import Service from tailbone.api import APIView, api @@ -42,11 +40,10 @@ class AuthenticationView(APIView): This will establish a server-side web session for the user if none exists. Note that this also resets the user's session timer. """ - data = {'ok': True} + data = {'ok': True, 'permissions': []} if self.request.user: data['user'] = self.get_user_info(self.request.user) - - data['permissions'] = list(self.request.tailbone_cached_permissions) + data['permissions'] = list(self.request.user_permissions) # background color may be set per-request, by some apps if hasattr(self.request, 'background_color') and self.request.background_color: @@ -176,7 +173,8 @@ class AuthenticationView(APIView): return {'error': "The current/old password you provided is incorrect"} # okay then, set new password - set_user_password(self.request.user, data['new_password']) + auth = self.app.get_auth_handler() + auth.set_user_password(self.request.user, data['new_password']) return { 'ok': True, 'user': self.get_user_info(self.request.user), diff --git a/tailbone/app.py b/tailbone/app.py index 5e8e49d9..626c9206 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -25,19 +25,15 @@ Application Entry Point """ import os -import warnings -import sqlalchemy as sa from sqlalchemy.orm import sessionmaker, scoped_session from wuttjamaican.util import parse_list from rattail.config import make_config from rattail.exceptions import ConfigurationError -from rattail.db.types import GPCType from pyramid.config import Configurator -from pyramid.authentication import SessionAuthenticationPolicy from zope.sqlalchemy import register import tailbone.db diff --git a/tailbone/auth.py b/tailbone/auth.py index fbe6bf2f..95bf90ba 100644 --- a/tailbone/auth.py +++ b/tailbone/auth.py @@ -27,20 +27,18 @@ Authentication & Authorization import logging import re -from rattail.util import NOTSET +from wuttjamaican.util import UNSPECIFIED -from zope.interface import implementer -from pyramid.authentication import SessionAuthenticationHelper -from pyramid.request import RequestLocalCache from pyramid.security import remember, forget +from wuttaweb.auth import WuttaSecurityPolicy from tailbone.db import Session log = logging.getLogger(__name__) -def login_user(request, user, timeout=NOTSET): +def login_user(request, user, timeout=UNSPECIFIED): """ Perform the steps necessary to login the given user. Note that this returns a ``headers`` dict which you should pass to the redirect. @@ -49,7 +47,7 @@ def login_user(request, user, timeout=NOTSET): app = config.get_app() user.record_event(app.enum.USER_EVENT_LOGIN) headers = remember(request, user.uuid) - if timeout is NOTSET: + if timeout is UNSPECIFIED: timeout = session_timeout_for_user(config, user) log.debug("setting session timeout for '{}' to {}".format(user.username, timeout)) set_session_timeout(request, timeout) @@ -94,12 +92,12 @@ def set_session_timeout(request, timeout): request.session['_timeout'] = timeout or None -class TailboneSecurityPolicy: +class TailboneSecurityPolicy(WuttaSecurityPolicy): - def __init__(self, api_mode=False): + def __init__(self, db_session=None, api_mode=False, **kwargs): + kwargs['db_session'] = db_session or Session() + super().__init__(**kwargs) self.api_mode = api_mode - self.session_helper = SessionAuthenticationHelper() - self.identity_cache = RequestLocalCache(self.load_identity) def load_identity(self, request): config = request.registry.settings.get('rattail_config') @@ -115,7 +113,7 @@ class TailboneSecurityPolicy: if match: token = match.group(1) auth = app.get_auth_handler() - user = auth.authenticate_user_token(Session(), token) + user = auth.authenticate_user_token(self.db_session, token) if not user: @@ -126,36 +124,10 @@ class TailboneSecurityPolicy: # fetch user object from db model = app.model - user = Session.get(model.User, uuid) + user = self.db_session.get(model.User, uuid) if not user: return # this user is responsible for data changes in current request - Session().set_continuum_user(user) + self.db_session.set_continuum_user(user) return user - - def identity(self, request): - return self.identity_cache.get_or_create(request) - - def authenticated_userid(self, request): - user = self.identity(request) - if user is not None: - return user.uuid - - def remember(self, request, userid, **kw): - return self.session_helper.remember(request, userid, **kw) - - def forget(self, request, **kw): - return self.session_helper.forget(request, **kw) - - def permits(self, request, context, permission): - # nb. root user can do anything - if request.is_root: - return True - - config = request.registry.settings.get('rattail_config') - app = config.get_app() - auth = app.get_auth_handler() - - user = self.identity(request) - return auth.has_permission(Session(), user, permission) From b642c98d4091729ef8f957abb213d70c2c2e8fb8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 19 Aug 2024 09:23:55 -0500 Subject: [PATCH 1592/1681] =?UTF-8?q?bump:=20version=200.19.0=20=E2=86=92?= =?UTF-8?q?=200.19.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72798b30..ce64ec60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.19.1 (2024-08-19) + +### Fix + +- fix broken user auth for web API app + ## v0.19.0 (2024-08-18) ### Feat diff --git a/pyproject.toml b/pyproject.toml index 1840de77..fa33a2df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.19.0" +version = "0.19.1" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From 1d56a4c0d09d857f3d9276ac010743eceb8e2eac Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 19 Aug 2024 09:53:10 -0500 Subject: [PATCH 1593/1681] fix: replace all occurrences of `component_studly` => `vue_component` --- tailbone/grids/core.py | 2 +- tailbone/templates/batch/index.mako | 6 +++--- .../batch/inventory/desktop_form.mako | 4 ++-- tailbone/templates/batch/pos/view.mako | 2 +- .../templates/batch/vendorcatalog/create.mako | 12 +++++------ tailbone/templates/batch/view.mako | 18 ++++++++--------- tailbone/templates/customers/view.mako | 4 ++-- tailbone/templates/custorders/items/view.mako | 6 +++--- tailbone/templates/departments/view.mako | 2 +- tailbone/templates/importing/runjob.mako | 14 ++++++------- tailbone/templates/login.mako | 8 ++++---- tailbone/templates/master/form.mako | 2 +- tailbone/templates/people/index.mako | 16 +++++++-------- tailbone/templates/poser/reports/view.mako | 6 +++--- tailbone/templates/products/batch.mako | 20 +++++++++---------- tailbone/templates/products/index.mako | 8 ++++---- .../templates/purchases/credits/index.mako | 12 +++++------ tailbone/templates/receiving/view.mako | 8 ++++---- .../templates/reports/generated/delete.mako | 2 +- .../templates/reports/generated/view.mako | 2 +- tailbone/templates/reports/problems/view.mako | 2 +- tailbone/templates/roles/view.mako | 2 +- tailbone/templates/settings/email/index.mako | 8 ++++---- .../templates/tempmon/appliances/view.mako | 2 +- tailbone/templates/tempmon/clients/view.mako | 2 +- .../trainwreck/transactions/view.mako | 2 +- .../trainwreck/transactions/view_row.mako | 2 +- tailbone/templates/users/view.mako | 2 +- 28 files changed, 88 insertions(+), 88 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 9c445fec..d00a85ae 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -1697,7 +1697,7 @@ class Grid(WuttaGrid): results['checked_rows'] = checked # TODO: this seems a bit hacky, but is required for now to # initialize things on the client side... - var = '{}CurrentData'.format(self.component_studly) + var = '{}CurrentData'.format(self.vue_component) results['checked_rows_code'] = '[{}]'.format( ', '.join(['{}[{}]'.format(var, i) for i in checked])) diff --git a/tailbone/templates/batch/index.mako b/tailbone/templates/batch/index.mako index 209fbb0c..a7808590 100644 --- a/tailbone/templates/batch/index.mako +++ b/tailbone/templates/batch/index.mako @@ -83,7 +83,7 @@ % if master.results_executable and master.has_perm('execute_multiple'): <script type="text/javascript"> - ${execute_form.component_studly}.methods.submit = function() { + ${execute_form.vue_component}.methods.submit = function() { this.$refs.actualExecuteForm.submit() } @@ -123,9 +123,9 @@ % if master.results_executable and master.has_perm('execute_multiple'): <script type="text/javascript"> - ${execute_form.component_studly}.data = function() { return ${execute_form.component_studly}Data } + ${execute_form.vue_component}.data = function() { return ${execute_form.vue_component}Data } - Vue.component('${execute_form.component}', ${execute_form.component_studly}) + Vue.component('${execute_form.component}', ${execute_form.vue_component}) </script> % endif diff --git a/tailbone/templates/batch/inventory/desktop_form.mako b/tailbone/templates/batch/inventory/desktop_form.mako index 7e4795a8..8ca32ce0 100644 --- a/tailbone/templates/batch/inventory/desktop_form.mako +++ b/tailbone/templates/batch/inventory/desktop_form.mako @@ -147,7 +147,7 @@ <script type="text/javascript"> - let ${form.component_studly} = { + let ${form.vue_component} = { template: '#${form.component}-template', mixins: [SimpleRequestMixin], @@ -278,7 +278,7 @@ }, } - let ${form.component_studly}Data = { + let ${form.vue_component}Data = { submitting: false, productUPC: null, diff --git a/tailbone/templates/batch/pos/view.mako b/tailbone/templates/batch/pos/view.mako index 0da755aa..bdb8709d 100644 --- a/tailbone/templates/batch/pos/view.mako +++ b/tailbone/templates/batch/pos/view.mako @@ -5,7 +5,7 @@ ${parent.modify_this_page_vars()} <script type="text/javascript"> - ${form.component_studly}Data.taxesData = ${json.dumps(taxes_data)|n} + ${form.vue_component}Data.taxesData = ${json.dumps(taxes_data)|n} </script> </%def> diff --git a/tailbone/templates/batch/vendorcatalog/create.mako b/tailbone/templates/batch/vendorcatalog/create.mako index d25c8f16..63865bd5 100644 --- a/tailbone/templates/batch/vendorcatalog/create.mako +++ b/tailbone/templates/batch/vendorcatalog/create.mako @@ -5,12 +5,12 @@ ${parent.modify_this_page_vars()} <script type="text/javascript"> - ${form.component_studly}Data.parsers = ${json.dumps(parsers_data)|n} + ${form.vue_component}Data.parsers = ${json.dumps(parsers_data)|n} - ${form.component_studly}Data.vendorName = null - ${form.component_studly}Data.vendorNameReplacement = null + ${form.vue_component}Data.vendorName = null + ${form.vue_component}Data.vendorNameReplacement = null - ${form.component_studly}.watch.field_model_parser_key = function(val) { + ${form.vue_component}.watch.field_model_parser_key = function(val) { let parser = this.parsers[val] if (parser.vendor_uuid) { if (this.field_model_vendor_uuid != parser.vendor_uuid) { @@ -24,11 +24,11 @@ } } - ${form.component_studly}.methods.vendorLabelChanging = function(label) { + ${form.vue_component}.methods.vendorLabelChanging = function(label) { this.vendorNameReplacement = label } - ${form.component_studly}.methods.vendorChanged = function(uuid) { + ${form.vue_component}.methods.vendorChanged = function(uuid) { if (uuid) { this.vendorName = this.vendorNameReplacement this.vendorNameReplacement = null diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako index 63cb9056..bef18cd4 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -285,7 +285,7 @@ } % if not batch.executed and master.has_perm('edit'): - ${form.component_studly}Data.togglingBatchComplete = false + ${form.vue_component}Data.togglingBatchComplete = false % endif % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'): @@ -306,7 +306,7 @@ form.submit() } - ${upload_worksheet_form.component_studly}.methods.submit = function() { + ${upload_worksheet_form.vue_component}.methods.submit = function() { this.$refs.actualUploadForm.submit() } @@ -321,7 +321,7 @@ this.$refs.executeBatchForm.submit() } - ${execute_form.component_studly}.methods.submit = function() { + ${execute_form.vue_component}.methods.submit = function() { this.$refs.actualExecuteForm.submit() } @@ -329,9 +329,9 @@ % if master.rows_bulk_deletable and not batch.executed and master.has_perm('delete_rows'): - ${rows_grid.component_studly}Data.deleteResultsShowDialog = false + ${rows_grid.vue_component}Data.deleteResultsShowDialog = false - ${rows_grid.component_studly}.methods.deleteResultsInit = function() { + ${rows_grid.vue_component}.methods.deleteResultsInit = function() { this.deleteResultsShowDialog = true } @@ -346,8 +346,8 @@ <script type="text/javascript"> ## UploadForm - ${upload_worksheet_form.component_studly}.data = function() { return ${upload_worksheet_form.component_studly}Data } - Vue.component('${upload_worksheet_form.component}', ${upload_worksheet_form.component_studly}) + ${upload_worksheet_form.vue_component}.data = function() { return ${upload_worksheet_form.vue_component}Data } + Vue.component('${upload_worksheet_form.component}', ${upload_worksheet_form.vue_component}) </script> % endif @@ -356,8 +356,8 @@ <script type="text/javascript"> ## ExecuteForm - ${execute_form.component_studly}.data = function() { return ${execute_form.component_studly}Data } - Vue.component('${execute_form.component}', ${execute_form.component_studly}) + ${execute_form.vue_component}.data = function() { return ${execute_form.vue_component}Data } + Vue.component('${execute_form.component}', ${execute_form.vue_component}) </script> % endif diff --git a/tailbone/templates/customers/view.mako b/tailbone/templates/customers/view.mako index 8b07bdb3..bbca9580 100644 --- a/tailbone/templates/customers/view.mako +++ b/tailbone/templates/customers/view.mako @@ -21,10 +21,10 @@ <script type="text/javascript"> % if expose_shoppers: - ${form.component_studly}Data.shoppers = ${json.dumps(shoppers_data)|n} + ${form.vue_component}Data.shoppers = ${json.dumps(shoppers_data)|n} % endif % if expose_people: - ${form.component_studly}Data.peopleData = ${json.dumps(people_data)|n} + ${form.vue_component}Data.peopleData = ${json.dumps(people_data)|n} % endif ThisPage.methods.detachPerson = function(url) { diff --git a/tailbone/templates/custorders/items/view.mako b/tailbone/templates/custorders/items/view.mako index f7a6dd0a..8eaee69a 100644 --- a/tailbone/templates/custorders/items/view.mako +++ b/tailbone/templates/custorders/items/view.mako @@ -295,7 +295,7 @@ ${parent.modify_this_page_vars()} <script type="text/javascript"> - ${form.component_studly}Data.eventsData = ${json.dumps(events_data)|n} + ${form.vue_component}Data.eventsData = ${json.dumps(events_data)|n} % if master.has_perm('confirm_price'): @@ -392,9 +392,9 @@ this.$refs.changeStatusForm.submit() } - ${form.component_studly}Data.changeFlaggedSubmitting = false + ${form.vue_component}Data.changeFlaggedSubmitting = false - ${form.component_studly}.methods.changeFlaggedSubmit = function() { + ${form.vue_component}.methods.changeFlaggedSubmit = function() { this.changeFlaggedSubmitting = true } diff --git a/tailbone/templates/departments/view.mako b/tailbone/templates/departments/view.mako index 442f045f..f892f333 100644 --- a/tailbone/templates/departments/view.mako +++ b/tailbone/templates/departments/view.mako @@ -5,7 +5,7 @@ ${parent.modify_this_page_vars()} <script type="text/javascript"> - ${form.component_studly}Data.employeesData = ${json.dumps(employees_data)|n} + ${form.vue_component}Data.employeesData = ${json.dumps(employees_data)|n} </script> </%def> diff --git a/tailbone/templates/importing/runjob.mako b/tailbone/templates/importing/runjob.mako index 2bc2a4e9..23526ed2 100644 --- a/tailbone/templates/importing/runjob.mako +++ b/tailbone/templates/importing/runjob.mako @@ -67,21 +67,21 @@ ${parent.modify_this_page_vars()} <script type="text/javascript"> - ${form.component_studly}Data.submittingRun = false - ${form.component_studly}Data.submittingExplain = false - ${form.component_studly}Data.runJob = false + ${form.vue_component}Data.submittingRun = false + ${form.vue_component}Data.submittingExplain = false + ${form.vue_component}Data.runJob = false - ${form.component_studly}.methods.submitRun = function() { + ${form.vue_component}.methods.submitRun = function() { this.submittingRun = true this.runJob = true this.$nextTick(() => { - this.$refs.${form.component_studly}.submit() + this.$refs.${form.vue_component}.submit() }) } - ${form.component_studly}.methods.submitExplain = function() { + ${form.vue_component}.methods.submitExplain = function() { this.submittingExplain = true - this.$refs.${form.component_studly}.submit() + this.$refs.${form.vue_component}.submit() } </script> diff --git a/tailbone/templates/login.mako b/tailbone/templates/login.mako index d18323b5..f898660f 100644 --- a/tailbone/templates/login.mako +++ b/tailbone/templates/login.mako @@ -60,19 +60,19 @@ <%def name="modify_this_page_vars()"> <script type="text/javascript"> - ${form.component_studly}Data.usernameInput = null + ${form.vue_component}Data.usernameInput = null - ${form.component_studly}.mounted = function() { + ${form.vue_component}.mounted = function() { this.$refs.username.focus() this.usernameInput = this.$refs.username.$el.querySelector('input') this.usernameInput.addEventListener('keydown', this.usernameKeydown) } - ${form.component_studly}.beforeDestroy = function() { + ${form.vue_component}.beforeDestroy = function() { this.usernameInput.removeEventListener('keydown', this.usernameKeydown) } - ${form.component_studly}.methods.usernameKeydown = function(event) { + ${form.vue_component}.methods.usernameKeydown = function(event) { if (event.which == 13) { event.preventDefault() this.$refs.password.focus() diff --git a/tailbone/templates/master/form.mako b/tailbone/templates/master/form.mako index dc9743ea..fac18ee2 100644 --- a/tailbone/templates/master/form.mako +++ b/tailbone/templates/master/form.mako @@ -8,7 +8,7 @@ ## declare extra data needed by form % if form is not Undefined and getattr(form, 'json_data', None): % for key, value in form.json_data.items(): - ${form.component_studly}Data.${key} = ${json.dumps(value)|n} + ${form.vue_component}Data.${key} = ${json.dumps(value)|n} % endfor % endif diff --git a/tailbone/templates/people/index.mako b/tailbone/templates/people/index.mako index 9339dfd5..6ce14633 100644 --- a/tailbone/templates/people/index.mako +++ b/tailbone/templates/people/index.mako @@ -67,31 +67,31 @@ % if getattr(master, 'mergeable', False) and master.has_perm('request_merge'): - ${grid.component_studly}Data.mergeRequestShowDialog = false - ${grid.component_studly}Data.mergeRequestRows = [] - ${grid.component_studly}Data.mergeRequestSubmitText = "Submit Merge Request" - ${grid.component_studly}Data.mergeRequestSubmitting = false + ${grid.vue_component}Data.mergeRequestShowDialog = false + ${grid.vue_component}Data.mergeRequestRows = [] + ${grid.vue_component}Data.mergeRequestSubmitText = "Submit Merge Request" + ${grid.vue_component}Data.mergeRequestSubmitting = false - ${grid.component_studly}.computed.mergeRequestRemovingUUID = function() { + ${grid.vue_component}.computed.mergeRequestRemovingUUID = function() { if (this.mergeRequestRows.length) { return this.mergeRequestRows[0].uuid } return null } - ${grid.component_studly}.computed.mergeRequestKeepingUUID = function() { + ${grid.vue_component}.computed.mergeRequestKeepingUUID = function() { if (this.mergeRequestRows.length) { return this.mergeRequestRows[1].uuid } return null } - ${grid.component_studly}.methods.showMergeRequest = function() { + ${grid.vue_component}.methods.showMergeRequest = function() { this.mergeRequestRows = this.checkedRows this.mergeRequestShowDialog = true } - ${grid.component_studly}.methods.submitMergeRequest = function() { + ${grid.vue_component}.methods.submitMergeRequest = function() { this.mergeRequestSubmitting = true this.mergeRequestSubmitText = "Working, please wait..." } diff --git a/tailbone/templates/poser/reports/view.mako b/tailbone/templates/poser/reports/view.mako index aac0c7ae..274a8806 100644 --- a/tailbone/templates/poser/reports/view.mako +++ b/tailbone/templates/poser/reports/view.mako @@ -67,11 +67,11 @@ % if master.has_perm('replace'): <script type="text/javascript"> - ${form.component_studly}Data.showUploadForm = false + ${form.vue_component}Data.showUploadForm = false - ${form.component_studly}Data.uploadFile = null + ${form.vue_component}Data.uploadFile = null - ${form.component_studly}Data.uploadSubmitting = false + ${form.vue_component}Data.uploadSubmitting = false </script> % endif diff --git a/tailbone/templates/products/batch.mako b/tailbone/templates/products/batch.mako index a4a4d503..66e38028 100644 --- a/tailbone/templates/products/batch.mako +++ b/tailbone/templates/products/batch.mako @@ -22,7 +22,7 @@ </%def> <%def name="render_form_innards()"> - ${h.form(request.current_route_url(), **{'@submit': 'submit{}'.format(form.component_studly)})} + ${h.form(request.current_route_url(), **{'@submit': 'submit{}'.format(form.vue_component)})} ${h.csrf_token(request)} <section> @@ -43,8 +43,8 @@ <div class="buttons"> <b-button type="is-primary" native-type="submit" - :disabled="${form.component_studly}Submitting"> - {{ ${form.component_studly}ButtonText }} + :disabled="${form.vue_component}Submitting"> + {{ ${form.vue_component}ButtonText }} </b-button> <b-button tag="a" href="${url('products')}"> Cancel @@ -66,21 +66,21 @@ ## TODO: ugh, an awful lot of duplicated code here (from /forms/deform.mako) - let ${form.component_studly} = { + let ${form.vue_component} = { template: '#${form.component}-template', methods: { ## TODO: deprecate / remove the latter option here % if form.auto_disable_save or form.auto_disable: - submit${form.component_studly}() { - this.${form.component_studly}Submitting = true - this.${form.component_studly}ButtonText = "Working, please wait..." + submit${form.vue_component}() { + this.${form.vue_component}Submitting = true + this.${form.vue_component}ButtonText = "Working, please wait..." } % endif } } - let ${form.component_studly}Data = { + let ${form.vue_component}Data = { ## TODO: ugh, this seems pretty hacky. need to declare some data models ## for various field components to bind to... @@ -95,8 +95,8 @@ ## TODO: deprecate / remove the latter option here % if form.auto_disable_save or form.auto_disable: - ${form.component_studly}Submitting: false, - ${form.component_studly}ButtonText: ${json.dumps(getattr(form, 'submit_label', getattr(form, 'save_label', "Submit")))|n}, + ${form.vue_component}Submitting: false, + ${form.vue_component}ButtonText: ${json.dumps(getattr(form, 'submit_label', getattr(form, 'save_label', "Submit")))|n}, % endif ## TODO: more hackiness, this is for the sake of batch params diff --git a/tailbone/templates/products/index.mako b/tailbone/templates/products/index.mako index 0d4bc410..b4731dee 100644 --- a/tailbone/templates/products/index.mako +++ b/tailbone/templates/products/index.mako @@ -41,11 +41,11 @@ % if label_profiles and master.has_perm('print_labels'): <script type="text/javascript"> - ${grid.component_studly}Data.quickLabelProfile = ${json.dumps(label_profiles[0].uuid)|n} - ${grid.component_studly}Data.quickLabelQuantity = 1 - ${grid.component_studly}Data.quickLabelSpeedbumpThreshold = ${json.dumps(quick_label_speedbump_threshold)|n} + ${grid.vue_component}Data.quickLabelProfile = ${json.dumps(label_profiles[0].uuid)|n} + ${grid.vue_component}Data.quickLabelQuantity = 1 + ${grid.vue_component}Data.quickLabelSpeedbumpThreshold = ${json.dumps(quick_label_speedbump_threshold)|n} - ${grid.component_studly}.methods.quickLabelPrint = function(row) { + ${grid.vue_component}.methods.quickLabelPrint = function(row) { let quantity = parseInt(this.quickLabelQuantity) if (isNaN(quantity)) { diff --git a/tailbone/templates/purchases/credits/index.mako b/tailbone/templates/purchases/credits/index.mako index 4248d4ad..0cfbc031 100644 --- a/tailbone/templates/purchases/credits/index.mako +++ b/tailbone/templates/purchases/credits/index.mako @@ -63,17 +63,17 @@ ${parent.modify_this_page_vars()} <script type="text/javascript"> - ${grid.component_studly}Data.changeStatusShowDialog = false - ${grid.component_studly}Data.changeStatusOptions = ${json.dumps(status_options)|n} - ${grid.component_studly}Data.changeStatusValue = null - ${grid.component_studly}Data.changeStatusSubmitting = false + ${grid.vue_component}Data.changeStatusShowDialog = false + ${grid.vue_component}Data.changeStatusOptions = ${json.dumps(status_options)|n} + ${grid.vue_component}Data.changeStatusValue = null + ${grid.vue_component}Data.changeStatusSubmitting = false - ${grid.component_studly}.methods.changeStatusInit = function() { + ${grid.vue_component}.methods.changeStatusInit = function() { this.changeStatusValue = null this.changeStatusShowDialog = true } - ${grid.component_studly}.methods.changeStatusSubmit = function() { + ${grid.vue_component}.methods.changeStatusSubmit = function() { this.changeStatusSubmitting = true this.$refs.changeStatusForm.submit() } diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako index 5f103d7f..45a8d66b 100644 --- a/tailbone/templates/receiving/view.mako +++ b/tailbone/templates/receiving/view.mako @@ -318,13 +318,13 @@ % if allow_edit_catalog_unit_cost: - ${rows_grid.component_studly}.methods.catalogUnitCostClicked = function(row) { + ${rows_grid.vue_component}.methods.catalogUnitCostClicked = function(row) { // start edit for clicked cell this.$refs['catalogUnitCost_' + row.uuid].startEdit() } - ${rows_grid.component_studly}.methods.catalogCostConfirmed = function(amount, index) { + ${rows_grid.vue_component}.methods.catalogCostConfirmed = function(amount, index) { // update display to indicate cost was confirmed this.addRowClass(index, 'catalog_cost_confirmed') @@ -353,13 +353,13 @@ % if allow_edit_invoice_unit_cost: - ${rows_grid.component_studly}.methods.invoiceUnitCostClicked = function(row) { + ${rows_grid.vue_component}.methods.invoiceUnitCostClicked = function(row) { // start edit for clicked cell this.$refs['invoiceUnitCost_' + row.uuid].startEdit() } - ${rows_grid.component_studly}.methods.invoiceCostConfirmed = function(amount, index) { + ${rows_grid.vue_component}.methods.invoiceCostConfirmed = function(amount, index) { // update display to indicate cost was confirmed this.addRowClass(index, 'invoice_cost_confirmed') diff --git a/tailbone/templates/reports/generated/delete.mako b/tailbone/templates/reports/generated/delete.mako index 0c994ad0..bce54662 100644 --- a/tailbone/templates/reports/generated/delete.mako +++ b/tailbone/templates/reports/generated/delete.mako @@ -6,7 +6,7 @@ <script type="text/javascript"> % if params_data is not Undefined: - ${form.component_studly}Data.paramsData = ${json.dumps(params_data)|n} + ${form.vue_component}Data.paramsData = ${json.dumps(params_data)|n} % endif </script> diff --git a/tailbone/templates/reports/generated/view.mako b/tailbone/templates/reports/generated/view.mako index 6260efba..e5bcc9e4 100644 --- a/tailbone/templates/reports/generated/view.mako +++ b/tailbone/templates/reports/generated/view.mako @@ -28,7 +28,7 @@ <script type="text/javascript"> % if params_data is not Undefined: - ${form.component_studly}Data.paramsData = ${json.dumps(params_data)|n} + ${form.vue_component}Data.paramsData = ${json.dumps(params_data)|n} % endif </script> diff --git a/tailbone/templates/reports/problems/view.mako b/tailbone/templates/reports/problems/view.mako index 026c73dc..1d5cb14f 100644 --- a/tailbone/templates/reports/problems/view.mako +++ b/tailbone/templates/reports/problems/view.mako @@ -67,7 +67,7 @@ <script type="text/javascript"> % if weekdays_data is not Undefined: - ${form.component_studly}Data.weekdaysData = ${json.dumps(weekdays_data)|n} + ${form.vue_component}Data.weekdaysData = ${json.dumps(weekdays_data)|n} % endif ThisPageData.runReportShowDialog = false diff --git a/tailbone/templates/roles/view.mako b/tailbone/templates/roles/view.mako index 0f4ce472..0dc2956f 100644 --- a/tailbone/templates/roles/view.mako +++ b/tailbone/templates/roles/view.mako @@ -11,7 +11,7 @@ <script type="text/javascript"> % if users_data is not Undefined: - ${form.component_studly}Data.usersData = ${json.dumps(users_data)|n} + ${form.vue_component}Data.usersData = ${json.dumps(users_data)|n} % endif ThisPage.methods.detachPerson = function(url) { diff --git a/tailbone/templates/settings/email/index.mako b/tailbone/templates/settings/email/index.mako index dbc963b9..050a5833 100644 --- a/tailbone/templates/settings/email/index.mako +++ b/tailbone/templates/settings/email/index.mako @@ -26,9 +26,9 @@ this.$refs.grid.showEmails = this.showEmails } - ${grid.component_studly}Data.showEmails = 'available' + ${grid.vue_component}Data.showEmails = 'available' - ${grid.component_studly}.computed.visibleData = function() { + ${grid.vue_component}.computed.visibleData = function() { if (this.showEmails == 'available') { return this.data.filter(email => email.hidden == 'No') @@ -41,11 +41,11 @@ return this.data } - ${grid.component_studly}.methods.renderLabelToggleHidden = function(row) { + ${grid.vue_component}.methods.renderLabelToggleHidden = function(row) { return row.hidden == 'Yes' ? "Un-hide" : "Hide" } - ${grid.component_studly}.methods.toggleHidden = function(row) { + ${grid.vue_component}.methods.toggleHidden = function(row) { let url = '${url('{}.toggle_hidden'.format(route_prefix))}' let params = { key: row.key, diff --git a/tailbone/templates/tempmon/appliances/view.mako b/tailbone/templates/tempmon/appliances/view.mako index 07a524b8..7dd9314a 100644 --- a/tailbone/templates/tempmon/appliances/view.mako +++ b/tailbone/templates/tempmon/appliances/view.mako @@ -12,7 +12,7 @@ ${parent.modify_this_page_vars()} <script type="text/javascript"> - ${form.component_studly}Data.probesData = ${json.dumps(probes_data)|n} + ${form.vue_component}Data.probesData = ${json.dumps(probes_data)|n} </script> </%def> diff --git a/tailbone/templates/tempmon/clients/view.mako b/tailbone/templates/tempmon/clients/view.mako index cff22fed..b1db423b 100644 --- a/tailbone/templates/tempmon/clients/view.mako +++ b/tailbone/templates/tempmon/clients/view.mako @@ -26,7 +26,7 @@ ${parent.modify_this_page_vars()} <script type="text/javascript"> - ${form.component_studly}Data.probesData = ${json.dumps(probes_data)|n} + ${form.vue_component}Data.probesData = ${json.dumps(probes_data)|n} </script> </%def> diff --git a/tailbone/templates/trainwreck/transactions/view.mako b/tailbone/templates/trainwreck/transactions/view.mako index 2be51c7d..02950941 100644 --- a/tailbone/templates/trainwreck/transactions/view.mako +++ b/tailbone/templates/trainwreck/transactions/view.mako @@ -6,7 +6,7 @@ <script type="text/javascript"> % if custorder_xref_markers_data is not Undefined: - ${form.component_studly}Data.custorderXrefMarkersData = ${json.dumps(custorder_xref_markers_data)|n} + ${form.vue_component}Data.custorderXrefMarkersData = ${json.dumps(custorder_xref_markers_data)|n} % endif </script> diff --git a/tailbone/templates/trainwreck/transactions/view_row.mako b/tailbone/templates/trainwreck/transactions/view_row.mako index 9abcb8ba..9c76f7bd 100644 --- a/tailbone/templates/trainwreck/transactions/view_row.mako +++ b/tailbone/templates/trainwreck/transactions/view_row.mako @@ -6,7 +6,7 @@ <script type="text/javascript"> % if discounts_data is not Undefined: - ${form.component_studly}Data.discountsData = ${json.dumps(discounts_data)|n} + ${form.vue_component}Data.discountsData = ${json.dumps(discounts_data)|n} % endif </script> diff --git a/tailbone/templates/users/view.mako b/tailbone/templates/users/view.mako index ed2b5f16..06087927 100644 --- a/tailbone/templates/users/view.mako +++ b/tailbone/templates/users/view.mako @@ -81,7 +81,7 @@ % if master.has_perm('manage_api_tokens'): <script type="text/javascript"> - ${form.component_studly}.props.apiTokens = null + ${form.vue_component}.props.apiTokens = null ThisPageData.apiTokens = ${json.dumps(api_tokens_data)|n} From 0eeeb4bd35981ee1ff4213f0b3cbd1b5dd774baf Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 19 Aug 2024 11:09:49 -0500 Subject: [PATCH 1594/1681] fix: prefer attr over key lookup when getting model values applies to both forms and grids. the base model class can still handle `obj[key]` but now it is limited to the column fields only, no association proxies. so, better to just try `getattr(obj, key)` first and only fall back to the other if it fails. unless the obj is clearly a dict in which case try `obj[key]` only --- tailbone/forms/core.py | 13 ++++++++----- tailbone/grids/core.py | 10 ++++++---- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 704d3b54..2f1c9370 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -1359,12 +1359,15 @@ class Form(object): def obtain_value(self, record, field_name): if record: - try: + + if isinstance(record, dict): return record[field_name] - except KeyError: - return None - except TypeError: - return getattr(record, field_name, None) + + try: + return getattr(record, field_name) + except AttributeError: + pass + return record[field_name] # TODO: is this always safe to do? elif self.defaults and field_name in self.defaults: diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index d00a85ae..3caf909c 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -586,12 +586,14 @@ class Grid(WuttaGrid): if isinstance(obj, sa.engine.Row): return obj._mapping[column_name] - try: + if isinstance(obj, dict): return obj[column_name] - except KeyError: + + try: + return getattr(obj, column_name) + except AttributeError: pass - except TypeError: - return getattr(obj, column_name, None) + return obj[column_name] def render_currency(self, obj, column_name): value = self.obtain_value(obj, column_name) From f5661fe349a456de2ef68e56b9afed02ad765fa7 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 19 Aug 2024 11:56:46 -0500 Subject: [PATCH 1595/1681] fix: sort on frontend for appinfo package listing grid --- tailbone/views/settings.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index 9d7f6e02..bda62ccc 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -83,14 +83,15 @@ class AppInfoView(MasterView): def configure_grid(self, g): super().configure_grid(g) - g.sorters['name'] = g.make_simple_sorter('name', foldcase=True) + # sort on frontend + g.sort_on_backend = False + g.sort_multiple = False g.set_sort_defaults('name') + + # name g.set_searchable('name') - g.sorters['version'] = g.make_simple_sorter('version', foldcase=True) - - g.sorters['editable_project_location'] = g.make_simple_sorter( - 'editable_project_location', foldcase=True) + # editable_project_location g.set_searchable('editable_project_location') def template_kwargs_index(self, **kwargs): From 41945c5e3777958d9940e94add680a0fd2e8d476 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 19 Aug 2024 12:01:42 -0500 Subject: [PATCH 1596/1681] =?UTF-8?q?bump:=20version=200.19.1=20=E2=86=92?= =?UTF-8?q?=200.19.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 ++++++++ pyproject.toml | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce64ec60..1fe71f3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.19.2 (2024-08-19) + +### Fix + +- sort on frontend for appinfo package listing grid +- prefer attr over key lookup when getting model values +- replace all occurrences of `component_studly` => `vue_component` + ## v0.19.1 (2024-08-19) ### Fix diff --git a/pyproject.toml b/pyproject.toml index fa33a2df..8f840642 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.19.1" +version = "0.19.2" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] @@ -59,7 +59,7 @@ dependencies = [ "transaction", "waitress", "WebHelpers2", - "WuttaWeb>=0.10.0", + "WuttaWeb>=0.10.1", "zope.sqlalchemy>=1.5", ] From 15ab0c959244c4de7a515e647fb60b8dd22d64b0 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 19 Aug 2024 13:48:18 -0500 Subject: [PATCH 1597/1681] fix: add pager stats to all grid vue data (fixes view history) also various other tweaks to modernize --- tailbone/grids/core.py | 6 +++++- tailbone/templates/grids/complete.mako | 2 +- tailbone/templates/master/view.mako | 30 +++++++++----------------- tailbone/views/master.py | 11 +++++----- 4 files changed, 21 insertions(+), 28 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 3caf909c..6ec55987 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -237,7 +237,7 @@ class Grid(WuttaGrid): kwargs.setdefault('sort_defaults', [(sortkey, sortdir)]) if kwargs.get('pageable'): - warnings.warn("component param is deprecated for Grid(); " + warnings.warn("pageable param is deprecated for Grid(); " "please use vue_tagname param instead", DeprecationWarning, stacklevel=2) kwargs.setdefault('paginated', kwargs.pop('pageable')) @@ -1703,6 +1703,10 @@ class Grid(WuttaGrid): results['checked_rows_code'] = '[{}]'.format( ', '.join(['{}[{}]'.format(var, i) for i in checked])) + if self.paginated and self.paginate_on_backend: + results['pager_stats'] = self.get_vue_pager_stats() + + # TODO: is this actually needed now that we have pager_stats? if self.paginated and self.pager is not None: results['total_items'] = self.pager.item_count results['per_page'] = self.pager.items_per_page diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako index 8dc2d6dc..c136273b 100644 --- a/tailbone/templates/grids/complete.mako +++ b/tailbone/templates/grids/complete.mako @@ -115,7 +115,7 @@ ## paging % if grid.paginated: paginated - pagination-size="is-small" + pagination-size="${'small' if request.use_oruga else 'is-small'}" :per-page="perPage" :current-page="currentPage" @page-change="onPageChange" diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako index a61020f3..37f57237 100644 --- a/tailbone/templates/master/view.mako +++ b/tailbone/templates/master/view.mako @@ -120,9 +120,7 @@ </p> </div> - <versions-grid ref="versionsGrid" - @view-revision="viewRevision"> - </versions-grid> + ${versions_grid.render_vue_tag(ref='versionsGrid', **{'@view-revision': 'viewRevision'})} <${b}-modal :width="1200" % if request.use_oruga: @@ -237,17 +235,16 @@ </%def> <%def name="render_row_grid_component()"> - <tailbone-grid ref="rowGrid" id="rowGrid"></tailbone-grid> + ${rows_grid.render_vue_tag(id='rowGrid', ref='rowGrid')} </%def> <%def name="render_this_page_template()"> % if getattr(master, 'has_rows', False): - ## TODO: stop using |n filter - ${rows_grid.render_complete(allow_save_defaults=False, tools=capture(self.render_row_grid_tools))|n} + ${rows_grid.render_vue_template(allow_save_defaults=False, tools=capture(self.render_row_grid_tools))} % endif ${parent.render_this_page_template()} % if expose_versions: - ${versions_grid.render_complete()|n} + ${versions_grid.render_vue_template()} % endif </%def> @@ -338,19 +335,12 @@ <%def name="finalize_this_page_vars()"> ${parent.finalize_this_page_vars()} - <script type="text/javascript"> - - % if getattr(master, 'has_rows', False): - TailboneGrid.data = function() { return TailboneGridData } - Vue.component('tailbone-grid', TailboneGrid) - % endif - - % if expose_versions: - VersionsGrid.data = function() { return VersionsGridData } - Vue.component('versions-grid', VersionsGrid) - % endif - - </script> + % if getattr(master, 'has_rows', False): + ${rows_grid.render_vue_finalize()} + % endif + % if expose_versions: + ${versions_grid.render_vue_finalize()} + % endif </%def> diff --git a/tailbone/views/master.py b/tailbone/views/master.py index dde72106..ac74a070 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -347,8 +347,6 @@ class MasterView(View): # return grid data only, if partial page was requested if self.request.GET.get('partial'): context = grid.get_table_data() - if grid.paginated and grid.paginate_on_backend: - context['pager_stats'] = grid.get_vue_pager_stats() return self.json_response(context) context = { @@ -587,7 +585,8 @@ class MasterView(View): 'filterable': self.rows_filterable, 'use_byte_string_filters': self.use_byte_string_filters, 'sortable': self.rows_sortable, - 'pageable': self.rows_pageable, + 'sort_multiple': not self.request.use_oruga, + 'paginated': self.rows_pageable, 'extra_row_class': self.row_grid_extra_class, 'url': lambda obj: self.get_row_action_url('view', obj), } @@ -675,7 +674,7 @@ class MasterView(View): defaults = { 'model_class': continuum.transaction_class(self.get_model_class()), 'width': 'full', - 'pageable': True, + 'paginated': True, 'url': lambda txn: self.request.route_url(route, uuid=instance.uuid, txnid=txn.id), } if 'actions' not in kwargs: @@ -1387,8 +1386,8 @@ class MasterView(View): 'vue_tagname': 'versions-grid', 'ajax_data_url': self.get_action_url('revisions_data', obj), 'sortable': True, - 'default_sortkey': 'changed', - 'default_sortdir': 'desc', + 'sort_multiple': not self.request.use_oruga, + 'sort_defaults': ('changed', 'desc'), 'actions': [ self.make_action('view', icon='eye', url='#', click_handler='viewRevision(props.row)'), From b762a0782a1b677817166609ee8b94bca872a7e2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 19 Aug 2024 13:57:36 -0500 Subject: [PATCH 1598/1681] =?UTF-8?q?bump:=20version=200.19.2=20=E2=86=92?= =?UTF-8?q?=200.19.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fe71f3f..c8017445 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.19.3 (2024-08-19) + +### Fix + +- add pager stats to all grid vue data (fixes view history) + ## v0.19.2 (2024-08-19) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 8f840642..3e07abaa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.19.2" +version = "0.19.3" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] @@ -59,7 +59,7 @@ dependencies = [ "transaction", "waitress", "WebHelpers2", - "WuttaWeb>=0.10.1", + "WuttaWeb>=0.10.2", "zope.sqlalchemy>=1.5", ] From d29b8403435237effd5ca2d122a9fb00ff6896b2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 19 Aug 2024 14:38:41 -0500 Subject: [PATCH 1599/1681] fix: avoid deprecated reference to app db engine --- tailbone/app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/app.py b/tailbone/app.py index 626c9206..ad9663cf 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -63,8 +63,8 @@ def make_rattail_config(settings): settings['wutta_config'] = rattail_config # configure database sessions - if hasattr(rattail_config, 'rattail_engine'): - tailbone.db.Session.configure(bind=rattail_config.rattail_engine) + if hasattr(rattail_config, 'appdb_engine'): + tailbone.db.Session.configure(bind=rattail_config.appdb_engine) if hasattr(rattail_config, 'trainwreck_engine'): tailbone.db.TrainwreckSession.configure(bind=rattail_config.trainwreck_engine) if hasattr(rattail_config, 'tempmon_engine'): From 1ec1eba49681867aac1e24e11d3b89ed8bba060e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 19 Aug 2024 21:30:58 -0500 Subject: [PATCH 1600/1681] feat: refactor templates to simplify base/page/form structure to mimic what has been done in wuttaweb --- tailbone/templates/appinfo/configure.mako | 9 +- tailbone/templates/appinfo/index.mako | 11 +- tailbone/templates/appsettings.mako | 20 +- tailbone/templates/base.mako | 164 +++++---- tailbone/templates/batch/index.mako | 36 +- .../batch/inventory/desktop_form.mako | 11 +- tailbone/templates/batch/pos/view.mako | 10 +- .../batch/vendorcatalog/configure.mako | 11 +- .../templates/batch/vendorcatalog/create.mako | 9 +- tailbone/templates/batch/view.mako | 58 ++-- tailbone/templates/configure-menus.mako | 9 +- tailbone/templates/configure.mako | 9 +- tailbone/templates/customers/configure.mako | 9 +- .../templates/customers/pending/view.mako | 8 +- tailbone/templates/customers/view.mako | 8 +- tailbone/templates/custorders/create.mako | 18 +- tailbone/templates/custorders/items/view.mako | 8 +- .../templates/datasync/changes/index.mako | 9 +- tailbone/templates/datasync/configure.mako | 9 +- tailbone/templates/datasync/status.mako | 8 +- tailbone/templates/departments/view.mako | 10 +- tailbone/templates/form.mako | 20 +- tailbone/templates/generate_feature.mako | 9 +- tailbone/templates/importing/configure.mako | 9 +- tailbone/templates/importing/runjob.mako | 8 +- tailbone/templates/login.mako | 8 +- tailbone/templates/luigi/configure.mako | 9 +- tailbone/templates/luigi/index.mako | 9 +- tailbone/templates/master/clone.mako | 9 +- tailbone/templates/master/delete.mako | 7 +- tailbone/templates/master/form.mako | 9 +- tailbone/templates/master/index.mako | 44 +-- tailbone/templates/master/merge.mako | 23 +- tailbone/templates/master/versions.mako | 31 +- tailbone/templates/master/view.mako | 54 ++- tailbone/templates/members/configure.mako | 9 +- tailbone/templates/messages/create.mako | 13 +- tailbone/templates/messages/index.mako | 17 +- tailbone/templates/messages/view.mako | 15 +- tailbone/templates/ordering/view.mako | 21 +- tailbone/templates/ordering/worksheet.mako | 25 +- tailbone/templates/page.mako | 96 +++--- tailbone/templates/people/index.mako | 8 +- .../templates/people/merge-requests/view.mako | 8 +- tailbone/templates/people/view.mako | 30 +- tailbone/templates/people/view_profile.mako | 317 +++++++++--------- tailbone/templates/poser/reports/view.mako | 20 +- tailbone/templates/poser/setup.mako | 11 +- .../templates/principal/find_by_perm.mako | 53 ++- tailbone/templates/products/batch.mako | 9 +- tailbone/templates/products/configure.mako | 9 +- tailbone/templates/products/index.mako | 9 +- tailbone/templates/products/pending/view.mako | 23 +- tailbone/templates/products/view.mako | 9 +- .../templates/purchases/credits/index.mako | 9 +- tailbone/templates/receiving/view.mako | 26 +- tailbone/templates/receiving/view_row.mako | 9 +- .../templates/reports/generated/choose.mako | 13 +- .../templates/reports/generated/delete.mako | 11 +- .../templates/reports/generated/view.mako | 11 +- tailbone/templates/reports/inventory.mako | 11 +- tailbone/templates/reports/ordering.mako | 9 +- tailbone/templates/reports/problems/view.mako | 9 +- tailbone/templates/roles/create.mako | 12 +- tailbone/templates/roles/edit.mako | 12 +- tailbone/templates/roles/view.mako | 8 +- .../templates/settings/email/configure.mako | 9 +- tailbone/templates/settings/email/index.mako | 8 +- tailbone/templates/settings/email/view.mako | 21 +- tailbone/templates/tables/create.mako | 9 +- .../templates/tempmon/appliances/view.mako | 11 +- tailbone/templates/tempmon/clients/view.mako | 11 +- tailbone/templates/tempmon/dashboard.mako | 9 +- tailbone/templates/tempmon/probes/graph.mako | 9 +- .../templates/themes/butterball/base.mako | 100 ++++-- .../trainwreck/transactions/configure.mako | 11 +- .../trainwreck/transactions/rollover.mako | 11 +- .../trainwreck/transactions/view.mako | 10 +- .../trainwreck/transactions/view_row.mako | 11 +- .../templates/units-of-measure/index.mako | 19 +- tailbone/templates/upgrades/configure.mako | 9 +- tailbone/templates/upgrades/view.mako | 21 +- tailbone/templates/users/preferences.mako | 11 +- tailbone/templates/users/view.mako | 9 +- tailbone/templates/vendors/configure.mako | 11 +- tailbone/templates/views/model/create.mako | 9 +- tailbone/templates/workorders/view.mako | 9 +- 87 files changed, 818 insertions(+), 1045 deletions(-) diff --git a/tailbone/templates/appinfo/configure.mako b/tailbone/templates/appinfo/configure.mako index aab180c4..4794f00b 100644 --- a/tailbone/templates/appinfo/configure.mako +++ b/tailbone/templates/appinfo/configure.mako @@ -213,9 +213,9 @@ </div> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.weblibs = ${json.dumps(weblibs)|n} @@ -245,6 +245,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/appinfo/index.mako b/tailbone/templates/appinfo/index.mako index 73f53920..68244300 100644 --- a/tailbone/templates/appinfo/index.mako +++ b/tailbone/templates/appinfo/index.mako @@ -114,14 +114,9 @@ </${b}-collapse> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.configFiles = ${json.dumps([dict(path=p, priority=i) for i, p in enumerate(request.rattail_config.prioritized_files, 1)])|n} - </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/appsettings.mako b/tailbone/templates/appsettings.mako index 4f935956..ba667e0e 100644 --- a/tailbone/templates/appsettings.mako +++ b/tailbone/templates/appsettings.mako @@ -15,8 +15,8 @@ <app-settings :groups="groups" :showing-group="showingGroup"></app-settings> </%def> -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} <script type="text/x-template" id="app-settings-template"> <div class="form"> @@ -150,19 +150,18 @@ </script> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.groups = ${json.dumps(settings_data)|n} ThisPageData.showingGroup = ${json.dumps(current_group or '')|n} - </script> </%def> -<%def name="make_this_page_component()"> - ${parent.make_this_page_component()} - <script type="text/javascript"> +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + <script> Vue.component('app-settings', { template: '#app-settings-template', @@ -193,6 +192,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index 8e3b7785..a0e58e22 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -34,17 +34,21 @@ </head> <body> - ${declare_formposter_mixin()} - - ${self.body()} - - <div id="whole-page-app"> + <div id="app" style="height: 100%;"> <whole-page></whole-page> </div> - ${self.render_whole_page_template()} - ${self.make_whole_page_component()} - ${self.make_whole_page_app()} + ## TODO: this must come before the self.body() call..but why? + ${declare_formposter_mixin()} + + ## content body from derived/child template + ${self.body()} + + ## Vue app + ${self.render_vue_templates()} + ${self.modify_vue_vars()} + ${self.make_vue_components()} + ${self.make_vue_app()} </body> </html> @@ -181,7 +185,7 @@ <%def name="head_tags()"></%def> -<%def name="render_whole_page_template()"> +<%def name="render_vue_template_whole_page()"> <script type="text/x-template" id="whole-page-template"> <div> <header> @@ -749,11 +753,8 @@ % endif </%def> -<%def name="declare_whole_page_vars()"> - ${page_help.declare_vars()} - ${multi_file_upload.declare_vars()} - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.feedback.js') + '?ver={}'.format(tailbone.__version__))} - <script type="text/javascript"> +<%def name="render_vue_script_whole_page()"> + <script> let WholePage = { template: '#whole-page-template', @@ -889,57 +890,6 @@ </script> </%def> -<%def name="modify_whole_page_vars()"> - <script type="text/javascript"> - - % if request.user: - FeedbackFormData.userUUID = ${json.dumps(request.user.uuid)|n} - FeedbackFormData.userName = ${json.dumps(str(request.user))|n} - % endif - - </script> -</%def> - -<%def name="finalize_whole_page_vars()"> - ## NOTE: if you override this, must use <script> tags -</%def> - -<%def name="make_whole_page_component()"> - - ${make_grid_filter_components()} - - ${self.declare_whole_page_vars()} - ${self.modify_whole_page_vars()} - ${self.finalize_whole_page_vars()} - - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.autocomplete.js') + '?ver={}'.format(tailbone.__version__))} - - ${page_help.make_component()} - ${multi_file_upload.make_component()} - - <script type="text/javascript"> - - FeedbackForm.data = function() { return FeedbackFormData } - - Vue.component('feedback-form', FeedbackForm) - - WholePage.data = function() { return WholePageData } - - Vue.component('whole-page', WholePage) - - </script> -</%def> - -<%def name="make_whole_page_app()"> - <script type="text/javascript"> - - new Vue({ - el: '#whole-page-app' - }) - - </script> -</%def> - <%def name="wtfield(form, name, **kwargs)"> <div class="field-wrapper${' error' if form[name].errors else ''}"> <label for="${name}">${form[name].label}</label> @@ -961,3 +911,87 @@ </div> </div> </%def> + +############################## +## vue components + app +############################## + +<%def name="render_vue_templates()"> + ${page_help.declare_vars()} + ${multi_file_upload.declare_vars()} + ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.feedback.js') + '?ver={}'.format(tailbone.__version__))} + ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.autocomplete.js') + '?ver={}'.format(tailbone.__version__))} + + ## DEPRECATED; called for back-compat + ${self.render_whole_page_template()} +</%def> + +## DEPRECATED; remains for back-compat +<%def name="render_whole_page_template()"> + ${self.render_vue_template_whole_page()} + ${self.declare_whole_page_vars()} +</%def> + +## DEPRECATED; remains for back-compat +<%def name="declare_whole_page_vars()"> + ${self.render_vue_script_whole_page()} +</%def> + +<%def name="modify_vue_vars()"> + ## DEPRECATED; called for back-compat + ${self.modify_whole_page_vars()} +</%def> + +## DEPRECATED; remains for back-compat +<%def name="modify_whole_page_vars()"> + <script> + + % if request.user: + FeedbackFormData.userUUID = ${json.dumps(request.user.uuid)|n} + FeedbackFormData.userName = ${json.dumps(str(request.user))|n} + % endif + + </script> +</%def> + +<%def name="make_vue_components()"> + ${make_grid_filter_components()} + ${page_help.make_component()} + ${multi_file_upload.make_component()} + <script> + FeedbackForm.data = function() { return FeedbackFormData } + Vue.component('feedback-form', FeedbackForm) + </script> + + ## DEPRECATED; called for back-compat + ${self.finalize_whole_page_vars()} + ${self.make_whole_page_component()} +</%def> + +## DEPRECATED; remains for back-compat +<%def name="make_whole_page_component()"> + <script> + WholePage.data = function() { return WholePageData } + Vue.component('whole-page', WholePage) + </script> +</%def> + +<%def name="make_vue_app()"> + ## DEPRECATED; called for back-compat + ${self.make_whole_page_app()} +</%def> + +## DEPRECATED; remains for back-compat +<%def name="make_whole_page_app()"> + <script> + new Vue({ + el: '#app' + }) + </script> +</%def> + +############################## +## DEPRECATED +############################## + +<%def name="finalize_whole_page_vars()"></%def> diff --git a/tailbone/templates/batch/index.mako b/tailbone/templates/batch/index.mako index a7808590..a1b11b89 100644 --- a/tailbone/templates/batch/index.mako +++ b/tailbone/templates/batch/index.mako @@ -64,10 +64,17 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} + % if master.results_executable and master.has_perm('execute_multiple'): + ${execute_form.render_deform(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)|n} + % endif +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} % if master.results_refreshable and master.has_perm('refresh'): - <script type="text/javascript"> + <script> TailboneGridData.refreshResultsButtonText = "Refresh Results" TailboneGridData.refreshResultsButtonDisabled = false @@ -81,7 +88,7 @@ </script> % endif % if master.results_executable and master.has_perm('execute_multiple'): - <script type="text/javascript"> + <script> ${execute_form.vue_component}.methods.submit = function() { this.$refs.actualExecuteForm.submit() @@ -118,25 +125,12 @@ % endif </%def> -<%def name="make_this_page_component()"> - ${parent.make_this_page_component()} +<%def name="make_vue_components()"> + ${parent.make_vue_components()} % if master.results_executable and master.has_perm('execute_multiple'): - <script type="text/javascript"> - + <script> ${execute_form.vue_component}.data = function() { return ${execute_form.vue_component}Data } - - Vue.component('${execute_form.component}', ${execute_form.vue_component}) - + Vue.component('${execute_form.vue_tagname}', ${execute_form.vue_component}) </script> % endif </%def> - -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} - % if master.results_executable and master.has_perm('execute_multiple'): - ${execute_form.render_deform(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)|n} - % endif -</%def> - - -${parent.body()} diff --git a/tailbone/templates/batch/inventory/desktop_form.mako b/tailbone/templates/batch/inventory/desktop_form.mako index 8ca32ce0..cddaa2c5 100644 --- a/tailbone/templates/batch/inventory/desktop_form.mako +++ b/tailbone/templates/batch/inventory/desktop_form.mako @@ -297,14 +297,9 @@ </script> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.toggleCompleteSubmitting = false - </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/batch/pos/view.mako b/tailbone/templates/batch/pos/view.mako index bdb8709d..5ecabd4d 100644 --- a/tailbone/templates/batch/pos/view.mako +++ b/tailbone/templates/batch/pos/view.mako @@ -1,13 +1,9 @@ ## -*- coding: utf-8; -*- <%inherit file="/batch/view.mako" /> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ${form.vue_component}Data.taxesData = ${json.dumps(taxes_data)|n} - </script> </%def> - -${parent.body()} diff --git a/tailbone/templates/batch/vendorcatalog/configure.mako b/tailbone/templates/batch/vendorcatalog/configure.mako index 0d57053e..4f91cb02 100644 --- a/tailbone/templates/batch/vendorcatalog/configure.mako +++ b/tailbone/templates/batch/vendorcatalog/configure.mako @@ -39,14 +39,9 @@ </div> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.catalogParsers = ${json.dumps(catalog_parsers_data)|n} - </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/batch/vendorcatalog/create.mako b/tailbone/templates/batch/vendorcatalog/create.mako index 63865bd5..d9d62bd1 100644 --- a/tailbone/templates/batch/vendorcatalog/create.mako +++ b/tailbone/templates/batch/vendorcatalog/create.mako @@ -1,9 +1,9 @@ ## -*- coding: utf-8; -*- <%inherit file="/batch/create.mako" /> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ${form.vue_component}Data.parsers = ${json.dumps(parsers_data)|n} @@ -37,6 +37,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako index bef18cd4..cdfa9ba7 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -149,12 +149,6 @@ </nav> </%def> -<%def name="render_form_template()"> - ## TODO: should use self.render_form_buttons() - ## ${form.render_deform(form_id='batch-form', buttons=capture(self.render_form_buttons))|n} - ${form.render_deform(form_id='batch-form', buttons=capture(buttons))|n} -</%def> - <%def name="render_this_page()"> ${parent.render_this_page()} @@ -197,16 +191,6 @@ </%def> -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} - % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'): - ${upload_worksheet_form.render_deform(buttons=False, form_kwargs={'ref': 'actualUploadForm'})|n} - % endif - % if master.handler.executable(batch) and master.has_perm('execute'): - ${execute_form.render_deform(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)|n} - % endif -</%def> - <%def name="render_form()"> <div class="form"> <${form.component} @show-upload="showUploadDialog = true"> @@ -267,9 +251,27 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} + % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'): + ${upload_worksheet_form.render_deform(buttons=False, form_kwargs={'ref': 'actualUploadForm'})|n} + % endif + % if master.handler.executable(batch) and master.has_perm('execute'): + ${execute_form.render_deform(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)|n} + % endif +</%def> + +## DEPRECATED; remains for back-compat +## nb. this is called by parent template, /form.mako +<%def name="render_form_template()"> + ## TODO: should use self.render_form_buttons() + ## ${form.render_deform(form_id='batch-form', buttons=capture(self.render_form_buttons))|n} + ${form.render_deform(form_id='batch-form', buttons=capture(buttons))|n} +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.statusBreakdownData = ${json.dumps(status_breakdown_data)|n} @@ -340,28 +342,18 @@ </script> </%def> -<%def name="make_this_page_component()"> - ${parent.make_this_page_component()} +<%def name="make_vue_components()"> + ${parent.make_vue_components()} % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'): - <script type="text/javascript"> - - ## UploadForm + <script> ${upload_worksheet_form.vue_component}.data = function() { return ${upload_worksheet_form.vue_component}Data } Vue.component('${upload_worksheet_form.component}', ${upload_worksheet_form.vue_component}) - </script> % endif - % if execute_enabled and master.has_perm('execute'): - <script type="text/javascript"> - - ## ExecuteForm + <script> ${execute_form.vue_component}.data = function() { return ${execute_form.vue_component}Data } Vue.component('${execute_form.component}', ${execute_form.vue_component}) - </script> % endif </%def> - - -${parent.body()} diff --git a/tailbone/templates/configure-menus.mako b/tailbone/templates/configure-menus.mako index c0200912..c7f46d21 100644 --- a/tailbone/templates/configure-menus.mako +++ b/tailbone/templates/configure-menus.mako @@ -208,9 +208,9 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.menuSequence = ${json.dumps([m['key'] for m in menus])|n} @@ -443,6 +443,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/configure.mako b/tailbone/templates/configure.mako index f33779c8..272aadce 100644 --- a/tailbone/templates/configure.mako +++ b/tailbone/templates/configure.mako @@ -205,9 +205,9 @@ ${h.end_form()} </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> % if simple_settings is not Undefined: ThisPageData.simpleSettings = ${json.dumps(simple_settings)|n} @@ -293,6 +293,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/customers/configure.mako b/tailbone/templates/customers/configure.mako index e68f4543..1a6dca8b 100644 --- a/tailbone/templates/customers/configure.mako +++ b/tailbone/templates/customers/configure.mako @@ -88,9 +88,9 @@ </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPage.methods.getLabelForKey = function(key) { switch (key) { @@ -111,6 +111,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/customers/pending/view.mako b/tailbone/templates/customers/pending/view.mako index e9e54c99..1cea9d1f 100644 --- a/tailbone/templates/customers/pending/view.mako +++ b/tailbone/templates/customers/pending/view.mako @@ -106,9 +106,9 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.resolvePersonShowDialog = false ThisPageData.resolvePersonUUID = null @@ -139,5 +139,3 @@ </script> </%def> - -${parent.body()} diff --git a/tailbone/templates/customers/view.mako b/tailbone/templates/customers/view.mako index bbca9580..490e4757 100644 --- a/tailbone/templates/customers/view.mako +++ b/tailbone/templates/customers/view.mako @@ -16,9 +16,9 @@ </div> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> % if expose_shoppers: ${form.vue_component}Data.shoppers = ${json.dumps(shoppers_data)|n} @@ -36,5 +36,3 @@ </script> </%def> - -${parent.body()} diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index 63505422..382a121f 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -47,10 +47,9 @@ </div> </%def> -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} ${product_lookup.tailbone_product_lookup_template()} - <script type="text/x-template" id="customer-order-creator-template"> <div> @@ -1265,12 +1264,7 @@ </div> </script> -</%def> - -<%def name="make_this_page_component()"> - ${parent.make_this_page_component()} - ${product_lookup.tailbone_product_lookup_component()} - <script type="text/javascript"> + <script> const CustomerOrderCreator = { template: '#customer-order-creator-template', @@ -2406,5 +2400,7 @@ </script> </%def> - -${parent.body()} +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + ${product_lookup.tailbone_product_lookup_component()} +</%def> diff --git a/tailbone/templates/custorders/items/view.mako b/tailbone/templates/custorders/items/view.mako index 8eaee69a..4cc92bbf 100644 --- a/tailbone/templates/custorders/items/view.mako +++ b/tailbone/templates/custorders/items/view.mako @@ -291,9 +291,9 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ${form.vue_component}Data.eventsData = ${json.dumps(events_data)|n} @@ -448,5 +448,3 @@ </script> </%def> - -${parent.body()} diff --git a/tailbone/templates/datasync/changes/index.mako b/tailbone/templates/datasync/changes/index.mako index 6d171619..86f5c121 100644 --- a/tailbone/templates/datasync/changes/index.mako +++ b/tailbone/templates/datasync/changes/index.mako @@ -26,9 +26,9 @@ </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> % if request.has_perm('datasync.restart'): TailboneGridData.restartDatasyncFormSubmitting = false @@ -50,6 +50,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/datasync/configure.mako b/tailbone/templates/datasync/configure.mako index 7922d189..3651d0c4 100644 --- a/tailbone/templates/datasync/configure.mako +++ b/tailbone/templates/datasync/configure.mako @@ -599,9 +599,9 @@ </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.showConfigFilesNote = false ThisPageData.profilesData = ${json.dumps(profiles_data)|n} @@ -982,6 +982,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/datasync/status.mako b/tailbone/templates/datasync/status.mako index c782dec6..e14686f8 100644 --- a/tailbone/templates/datasync/status.mako +++ b/tailbone/templates/datasync/status.mako @@ -115,8 +115,9 @@ </${b}-table> </%def> -<%def name="modify_this_page_vars()"> - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.processInfo = ${json.dumps(process_info)|n} @@ -171,6 +172,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/departments/view.mako b/tailbone/templates/departments/view.mako index f892f333..c5c39cbb 100644 --- a/tailbone/templates/departments/view.mako +++ b/tailbone/templates/departments/view.mako @@ -1,13 +1,9 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ${form.vue_component}Data.employeesData = ${json.dumps(employees_data)|n} - </script> </%def> - -${parent.body()} diff --git a/tailbone/templates/form.mako b/tailbone/templates/form.mako index fec721fd..3bb04257 100644 --- a/tailbone/templates/form.mako +++ b/tailbone/templates/form.mako @@ -90,15 +90,15 @@ <%def name="before_object_helpers()"></%def> -<%def name="render_this_page_template()"> +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} % if form is not Undefined: ${self.render_form_template()} % endif - ${parent.render_this_page_template()} </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} % if main_form_collapsible: <script> ThisPageData.mainFormPanelOpen = ${'false' if main_form_autocollapse else 'true'} @@ -106,18 +106,12 @@ % endif </%def> -<%def name="finalize_this_page_vars()"> - ${parent.finalize_this_page_vars()} +<%def name="make_vue_components()"> + ${parent.make_vue_components()} % if form is not Undefined: - <script type="text/javascript"> - + <script> ${form.vue_component}.data = function() { return ${form.vue_component}Data } - Vue.component('${form.vue_tagname}', ${form.vue_component}) - </script> % endif </%def> - - -${parent.body()} diff --git a/tailbone/templates/generate_feature.mako b/tailbone/templates/generate_feature.mako index 18a26f58..0f2a9f7b 100644 --- a/tailbone/templates/generate_feature.mako +++ b/tailbone/templates/generate_feature.mako @@ -276,9 +276,9 @@ </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.featureType = ${json.dumps(feature_type)|n} ThisPageData.resultGenerated = ${json.dumps(bool(result))|n} @@ -385,6 +385,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/importing/configure.mako b/tailbone/templates/importing/configure.mako index 0396745a..2445341d 100644 --- a/tailbone/templates/importing/configure.mako +++ b/tailbone/templates/importing/configure.mako @@ -144,9 +144,9 @@ </b-modal> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.handlersData = ${json.dumps(handlers_data)|n} @@ -203,6 +203,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/importing/runjob.mako b/tailbone/templates/importing/runjob.mako index 23526ed2..a9625bc3 100644 --- a/tailbone/templates/importing/runjob.mako +++ b/tailbone/templates/importing/runjob.mako @@ -63,9 +63,9 @@ </div> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ${form.vue_component}Data.submittingRun = false ${form.vue_component}Data.submittingExplain = false @@ -86,5 +86,3 @@ </script> </%def> - -${parent.body()} diff --git a/tailbone/templates/login.mako b/tailbone/templates/login.mako index f898660f..3eb46403 100644 --- a/tailbone/templates/login.mako +++ b/tailbone/templates/login.mako @@ -57,8 +57,9 @@ </div> </%def> -<%def name="modify_this_page_vars()"> - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ${form.vue_component}Data.usernameInput = null @@ -81,6 +82,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/luigi/configure.mako b/tailbone/templates/luigi/configure.mako index 49060ceb..de364828 100644 --- a/tailbone/templates/luigi/configure.mako +++ b/tailbone/templates/luigi/configure.mako @@ -297,9 +297,9 @@ </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.overnightTasks = ${json.dumps(overnight_tasks)|n} ThisPageData.overnightTaskShowDialog = false @@ -425,6 +425,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/luigi/index.mako b/tailbone/templates/luigi/index.mako index b5134c25..0dd72d01 100644 --- a/tailbone/templates/luigi/index.mako +++ b/tailbone/templates/luigi/index.mako @@ -255,9 +255,9 @@ </div> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> % if master.has_perm('restart_scheduler'): @@ -374,6 +374,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/master/clone.mako b/tailbone/templates/master/clone.mako index 59d6aea2..4c7e4662 100644 --- a/tailbone/templates/master/clone.mako +++ b/tailbone/templates/master/clone.mako @@ -34,9 +34,9 @@ ${h.end_form()} </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> TailboneFormData.formSubmitting = false TailboneFormData.submitButtonText = "Yes, please clone away" @@ -48,6 +48,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/master/delete.mako b/tailbone/templates/master/delete.mako index c6187d55..d2f517d9 100644 --- a/tailbone/templates/master/delete.mako +++ b/tailbone/templates/master/delete.mako @@ -33,8 +33,8 @@ ${h.end_form()} </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} <script> ${form.vue_component}Data.formSubmitting = false @@ -45,6 +45,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/master/form.mako b/tailbone/templates/master/form.mako index fac18ee2..17063c21 100644 --- a/tailbone/templates/master/form.mako +++ b/tailbone/templates/master/form.mako @@ -1,9 +1,9 @@ ## -*- coding: utf-8; -*- <%inherit file="/form.mako" /> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ## declare extra data needed by form % if form is not Undefined and getattr(form, 'json_data', None): @@ -28,6 +28,3 @@ % endif </%def> - - -${parent.body()} diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako index 81c11213..a2d26c60 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -265,6 +265,11 @@ </%def> +## DEPRECATED; remains for back-compat +<%def name="render_this_page()"> + ${self.page_content()} +</%def> + <%def name="page_content()"> % if download_results_path: @@ -290,34 +295,28 @@ % endif </%def> -<%def name="make_grid_component()"> - ${grid.render_vue_template(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())} -</%def> - <%def name="render_grid_component()"> ${grid.render_vue_tag()} </%def> -<%def name="make_this_page_component()"> +############################## +## vue components +############################## - ## define grid +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} + + ## DEPRECATED; called for back-compat ${self.make_grid_component()} - - ${parent.make_this_page_component()} - - ## finalize grid - <script> - ${grid.vue_component}.data = function() { return ${grid.vue_component}Data } - Vue.component('${grid.vue_tagname}', ${grid.vue_component}) - </script> </%def> -<%def name="render_this_page()"> - ${self.page_content()} +## DEPRECATED; remains for back-compat +<%def name="make_grid_component()"> + ${grid.render_vue_template(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())} </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} <script type="text/javascript"> % if getattr(master, 'supports_grid_totals', False): @@ -624,5 +623,10 @@ </script> </%def> - -${parent.body()} +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + <script> + ${grid.vue_component}.data = function() { return ${grid.vue_component}Data } + Vue.component('${grid.vue_tagname}', ${grid.vue_component}) + </script> +</%def> diff --git a/tailbone/templates/master/merge.mako b/tailbone/templates/master/merge.mako index 5d90043f..487d258d 100644 --- a/tailbone/templates/master/merge.mako +++ b/tailbone/templates/master/merge.mako @@ -109,8 +109,8 @@ <merge-buttons></merge-buttons> </%def> -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} <script type="text/x-template" id="merge-buttons-template"> <div class="level" style="margin-top: 2em;"> @@ -147,11 +147,7 @@ </div> </div> </script> -</%def> - -<%def name="make_this_page_component()"> - ${parent.make_this_page_component()} - <script type="text/javascript"> + <script> const MergeButtons = { template: '#merge-buttons-template', @@ -175,12 +171,13 @@ } } - Vue.component('merge-buttons', MergeButtons) - - <% request.register_component('merge-buttons', 'MergeButtons') %> - </script> </%def> - -${parent.body()} +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + <script> + Vue.component('merge-buttons', MergeButtons) + <% request.register_component('merge-buttons', 'MergeButtons') %> + </script> +</%def> diff --git a/tailbone/templates/master/versions.mako b/tailbone/templates/master/versions.mako index 307674b8..a6bb14f0 100644 --- a/tailbone/templates/master/versions.mako +++ b/tailbone/templates/master/versions.mako @@ -16,27 +16,16 @@ ${self.page_content()} </%def> -<%def name="make_this_page_component()"> - ${parent.make_this_page_component()} - <script type="text/javascript"> - - TailboneGrid.data = function() { return TailboneGridData } - - Vue.component('tailbone-grid', TailboneGrid) - - </script> -</%def> - -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} - - ## TODO: stop using |n filter - ${grid.render_complete()|n} -</%def> - <%def name="page_content()"> - <tailbone-grid :csrftoken="csrftoken"> - </tailbone-grid> + ${grid.render_vue_tag(**{':csrftoken': 'csrftoken'})} </%def> -${parent.body()} +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} + ${grid.render_vue_template()} +</%def> + +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + ${grid.render_vue_finalize()} +</%def> diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako index 37f57237..0a1f9c62 100644 --- a/tailbone/templates/master/view.mako +++ b/tailbone/templates/master/view.mako @@ -238,21 +238,34 @@ ${rows_grid.render_vue_tag(id='rowGrid', ref='rowGrid')} </%def> -<%def name="render_this_page_template()"> +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} % if getattr(master, 'has_rows', False): ${rows_grid.render_vue_template(allow_save_defaults=False, tools=capture(self.render_row_grid_tools))} % endif - ${parent.render_this_page_template()} % if expose_versions: ${versions_grid.render_vue_template()} % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - % if expose_versions: - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + % if getattr(master, 'touchable', False) and master.has_perm('touch'): + + WholePageData.touchSubmitting = false + + WholePage.methods.touchRecord = function() { + this.touchSubmitting = true + location.href = '${master.get_action_url('touch', instance)}' + } + + % endif + + % if expose_versions: + + WholePageData.viewingHistory = false ThisPage.props.viewingHistory = Boolean ThisPageData.gettingRevisions = false @@ -307,34 +320,12 @@ this.viewVersionShowAllFields = !this.viewVersionShowAllFields } - </script> - % endif -</%def> - -<%def name="modify_whole_page_vars()"> - ${parent.modify_whole_page_vars()} - <script type="text/javascript"> - - % if getattr(master, 'touchable', False) and master.has_perm('touch'): - - WholePageData.touchSubmitting = false - - WholePage.methods.touchRecord = function() { - this.touchSubmitting = true - location.href = '${master.get_action_url('touch', instance)}' - } - % endif - - % if expose_versions: - WholePageData.viewingHistory = false - % endif - </script> </%def> -<%def name="finalize_this_page_vars()"> - ${parent.finalize_this_page_vars()} +<%def name="make_vue_components()"> + ${parent.make_vue_components()} % if getattr(master, 'has_rows', False): ${rows_grid.render_vue_finalize()} % endif @@ -342,6 +333,3 @@ ${versions_grid.render_vue_finalize()} % endif </%def> - - -${parent.body()} diff --git a/tailbone/templates/members/configure.mako b/tailbone/templates/members/configure.mako index 465bf611..f1f0e39f 100644 --- a/tailbone/templates/members/configure.mako +++ b/tailbone/templates/members/configure.mako @@ -52,9 +52,9 @@ </div> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPage.methods.getLabelForKey = function(key) { switch (key) { @@ -75,6 +75,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/messages/create.mako b/tailbone/templates/messages/create.mako index 4a15573b..39236f75 100644 --- a/tailbone/templates/messages/create.mako +++ b/tailbone/templates/messages/create.mako @@ -32,14 +32,14 @@ % endif </%def> -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} ${message_recipients_template()} </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> TailboneFormData.possibleRecipients = new Map(${json.dumps(available_recipients)|n}) TailboneFormData.recipientDisplayMap = ${json.dumps(recipient_display_map)|n} @@ -59,6 +59,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/messages/index.mako b/tailbone/templates/messages/index.mako index 3fc82fd3..eaa4b6c9 100644 --- a/tailbone/templates/messages/index.mako +++ b/tailbone/templates/messages/index.mako @@ -22,15 +22,15 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} % if request.matched_route.name in ('messages.inbox', 'messages.archive'): - <script type="text/javascript"> + <script> - TailboneGridData.moveMessagesSubmitting = false - TailboneGridData.moveMessagesText = null + ${grid.vue_component}Data.moveMessagesSubmitting = false + ${grid.vue_component}Data.moveMessagesText = null - TailboneGrid.computed.moveMessagesTextCurrent = function() { + ${grid.vue_component}.computed.moveMessagesTextCurrent = function() { if (this.moveMessagesText) { return this.moveMessagesText } @@ -38,7 +38,7 @@ return "Move " + count.toString() + " selected to ${'Archive' if request.matched_route.name == 'messages.inbox' else 'Inbox'}" } - TailboneGrid.methods.moveMessagesSubmit = function() { + ${grid.vue_component}.methods.moveMessagesSubmit = function() { this.moveMessagesSubmitting = true this.moveMessagesText = "Working, please wait..." } @@ -46,6 +46,3 @@ </script> % endif </%def> - - -${parent.body()} diff --git a/tailbone/templates/messages/view.mako b/tailbone/templates/messages/view.mako index 2e2baa60..36418698 100644 --- a/tailbone/templates/messages/view.mako +++ b/tailbone/templates/messages/view.mako @@ -82,22 +82,19 @@ </div> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> - TailboneFormData.showingAllRecipients = false + ${form.vue_component}Data.showingAllRecipients = false - TailboneForm.methods.showMoreRecipients = function() { + ${form.vue_component}.methods.showMoreRecipients = function() { this.showingAllRecipients = true } - TailboneForm.methods.hideMoreRecipients = function() { + ${form.vue_component}.methods.hideMoreRecipients = function() { this.showingAllRecipients = false } </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/ordering/view.mako b/tailbone/templates/ordering/view.mako index aed6fd75..584559c1 100644 --- a/tailbone/templates/ordering/view.mako +++ b/tailbone/templates/ordering/view.mako @@ -21,8 +21,8 @@ % endif </%def> -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} % if not batch.executed and not batch.complete and master.has_perm('edit_row'): <script type="text/x-template" id="ordering-scanner-template"> <div> @@ -185,10 +185,10 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} % if not batch.executed and not batch.complete and master.has_perm('edit_row'): - <script type="text/javascript"> + <script> let OrderingScanner = { template: '#ordering-scanner-template', @@ -408,16 +408,11 @@ % endif </%def> -<%def name="make_this_page_component()"> - ${parent.make_this_page_component()} +<%def name="make_vue_components()"> + ${parent.make_vue_components()} % if not batch.executed and not batch.complete and master.has_perm('edit_row'): - <script type="text/javascript"> - + <script> Vue.component('ordering-scanner', OrderingScanner) - </script> % endif </%def> - - -${parent.body()} diff --git a/tailbone/templates/ordering/worksheet.mako b/tailbone/templates/ordering/worksheet.mako index ca1abf6e..cb98c48f 100644 --- a/tailbone/templates/ordering/worksheet.mako +++ b/tailbone/templates/ordering/worksheet.mako @@ -199,9 +199,8 @@ <ordering-worksheet></ordering-worksheet> </%def> -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} - +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} <script type="text/x-template" id="ordering-worksheet-template"> <div> <div class="form-wrapper"> @@ -239,11 +238,7 @@ ${self.order_form_grid()} </div> </script> -</%def> - -<%def name="make_this_page_component()"> - ${parent.make_this_page_component()} - <script type="text/javascript"> + <script> const OrderingWorksheet = { template: '#ordering-worksheet-template', @@ -298,14 +293,12 @@ }, } - Vue.component('ordering-worksheet', OrderingWorksheet) - </script> </%def> - -############################## -## page body -############################## - -${parent.body()} +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + <script> + Vue.component('ordering-worksheet', OrderingWorksheet) + </script> +</%def> diff --git a/tailbone/templates/page.mako b/tailbone/templates/page.mako index 17d87c9a..54b47278 100644 --- a/tailbone/templates/page.mako +++ b/tailbone/templates/page.mako @@ -1,42 +1,26 @@ ## -*- coding: utf-8; -*- <%inherit file="/base.mako" /> -<%def name="context_menu_items()"> - % if context_menu_list_items is not Undefined: - % for item in context_menu_list_items: - <li>${item}</li> - % endfor - % endif +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} + ${self.render_vue_template_this_page()} </%def> -<%def name="page_content()"></%def> - -<%def name="render_this_page()"> - <div style="display: flex;"> - - <div class="this-page-content" style="flex-grow: 1;"> - ${self.page_content()} - </div> - - <ul id="context-menu"> - ${self.context_menu_items()} - </ul> - - </div> +<%def name="render_vue_template_this_page()"> + ## DEPRECATED; called for back-compat + ${self.render_this_page_template()} </%def> <%def name="render_this_page_template()"> <script type="text/x-template" id="this-page-template"> <div> + ## DEPRECATED; called for back-compat ${self.render_this_page()} </div> </script> -</%def> + <script> -<%def name="declare_this_page_vars()"> - <script type="text/javascript"> - - let ThisPage = { + const ThisPage = { template: '#this-page-template', mixins: [SimpleRequestMixin], props: { @@ -52,7 +36,7 @@ }, } - let ThisPageData = { + const ThisPageData = { ## TODO: should find a better way to handle CSRF token csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}, } @@ -60,29 +44,63 @@ </script> </%def> -<%def name="modify_this_page_vars()"> - ## NOTE: if you override this, must use <script> tags +## DEPRECATED; remains for back-compat +<%def name="render_this_page()"> + <div style="display: flex;"> + + <div class="this-page-content" style="flex-grow: 1;"> + ${self.page_content()} + </div> + + ## DEPRECATED; remains for back-compat + <ul id="context-menu"> + ${self.context_menu_items()} + </ul> + </div> </%def> -<%def name="finalize_this_page_vars()"> - ## NOTE: if you override this, must use <script> tags +## nb. this is the canonical block for page content! +<%def name="page_content()"></%def> + +## DEPRECATED; remains for back-compat +<%def name="context_menu_items()"> + % if context_menu_list_items is not Undefined: + % for item in context_menu_list_items: + <li>${item}</li> + % endfor + % endif +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + + ## DEPRECATED; called for back-compat + ${self.declare_this_page_vars()} + ${self.modify_this_page_vars()} +</%def> + +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + + ## DEPRECATED; called for back-compat + ${self.make_this_page_component()} </%def> <%def name="make_this_page_component()"> - ${self.declare_this_page_vars()} - ${self.modify_this_page_vars()} ${self.finalize_this_page_vars()} - - <script type="text/javascript"> - + <script> ThisPage.data = function() { return ThisPageData } - Vue.component('this-page', ThisPage) <% request.register_component('this-page', 'ThisPage') %> - </script> </%def> +############################## +## DEPRECATED +############################## -${self.render_this_page_template()} -${self.make_this_page_component()} +<%def name="declare_this_page_vars()"></%def> + +<%def name="modify_this_page_vars()"></%def> + +<%def name="finalize_this_page_vars()"></%def> diff --git a/tailbone/templates/people/index.mako b/tailbone/templates/people/index.mako index 6ce14633..cd6fddf1 100644 --- a/tailbone/templates/people/index.mako +++ b/tailbone/templates/people/index.mako @@ -61,9 +61,9 @@ ${parent.grid_tools()} </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> % if getattr(master, 'mergeable', False) and master.has_perm('request_merge'): @@ -100,5 +100,3 @@ </script> </%def> - -${parent.body()} diff --git a/tailbone/templates/people/merge-requests/view.mako b/tailbone/templates/people/merge-requests/view.mako index 9e8905cf..e2db1476 100644 --- a/tailbone/templates/people/merge-requests/view.mako +++ b/tailbone/templates/people/merge-requests/view.mako @@ -18,10 +18,10 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} % if not instance.merged and request.has_perm('people.merge'): - <script type="text/javascript"> + <script> ThisPageData.mergeFormButtonText = "Perform Merge" ThisPageData.mergeFormSubmitting = false @@ -34,5 +34,3 @@ </script> % endif </%def> - -${parent.body()} diff --git a/tailbone/templates/people/view.mako b/tailbone/templates/people/view.mako index d28d7558..15c669fa 100644 --- a/tailbone/templates/people/view.mako +++ b/tailbone/templates/people/view.mako @@ -2,6 +2,16 @@ <%inherit file="/master/view.mako" /> <%namespace file="/util.mako" import="view_profiles_helper" /> +<%def name="page_content()"> + ${parent.page_content()} + % if not instance.users and request.has_perm('users.create'): + ${h.form(url('people.make_user'), ref='makeUserForm')} + ${h.csrf_token(request)} + ${h.hidden('person_uuid', value=instance.uuid)} + ${h.end_form()} + % endif +</%def> + <%def name="object_helpers()"> ${parent.object_helpers()} ${view_profiles_helper([instance])} @@ -13,9 +23,9 @@ </div> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ${form.vue_component}.methods.clickMakeUser = function(event) { this.$emit('make-user') @@ -29,17 +39,3 @@ </script> </%def> - -<%def name="page_content()"> - ${parent.page_content()} - % if not instance.users and request.has_perm('users.create'): - ${h.form(url('people.make_user'), ref='makeUserForm')} - ${h.csrf_token(request)} - ${h.hidden('person_uuid', value=instance.uuid)} - ${h.end_form()} - % endif -</%def> - - -${parent.body()} - diff --git a/tailbone/templates/people/view_profile.mako b/tailbone/templates/people/view_profile.mako index cdb6c5cc..6ca5a84c 100644 --- a/tailbone/templates/people/view_profile.mako +++ b/tailbone/templates/people/view_profile.mako @@ -1966,30 +1966,97 @@ </div> </script> -</%def> + <script> -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} - ${self.render_personal_tab_template()} + let ProfileInfoData = { + activeTab: location.hash ? location.hash.substring(1) : 'personal', + tabchecks: ${json.dumps(tabchecks or {})|n}, + today: '${rattail_app.today()}', + profileLastChanged: Date.now(), + person: ${json.dumps(person_data or {})|n}, + phoneTypeOptions: ${json.dumps(phone_type_options or [])|n}, + emailTypeOptions: ${json.dumps(email_type_options or [])|n}, + maxLengths: ${json.dumps(max_lengths or {})|n}, - % if expose_members: - ${self.render_member_tab_template()} - % endif + % if request.has_perm('people_profile.view_versions'): + loadingRevisions: false, + showingRevisionDialog: false, + revision: {}, + revisionShowAllFields: false, + % endif + } - ${self.render_customer_tab_template()} - % if expose_customer_shoppers: - ${self.render_shopper_tab_template()} - % endif - ${self.render_employee_tab_template()} - ${self.render_notes_tab_template()} + let ProfileInfo = { + template: '#profile-info-template', + props: { + % if request.has_perm('people_profile.view_versions'): + viewingHistory: Boolean, + gettingRevisions: Boolean, + revisions: Array, + revisionVersionMap: null, + % endif + }, + computed: {}, + mounted() { - % if expose_transactions: - ${transactions_grid.render_complete(allow_save_defaults=False)|n} - ${self.render_transactions_tab_template()} - % endif + // auto-refresh whichever tab is shown first + ## TODO: how to not assume 'personal' is the default tab? + let tab = this.$refs['tab_' + (this.activeTab || 'personal')] + if (tab && tab.refreshTab) { + tab.refreshTab() + } + }, + methods: { - ${self.render_user_tab_template()} - ${self.render_profile_info_template()} + profileChanged(data) { + this.$emit('change-content-title', data.person.dynamic_content_title) + this.person = data.person + this.tabchecks = data.tabchecks + this.profileLastChanged = Date.now() + }, + + activeTabChanged(value) { + location.hash = value + this.refreshTabIfNeeded(value) + this.activeTabChangedExtra(value) + }, + + refreshTabIfNeeded(key) { + // TODO: this is *always* refreshing, should be more selective (?) + let tab = this.$refs['tab_' + key] + if (tab && tab.refreshIfNeeded) { + tab.refreshIfNeeded(this.profileLastChanged) + } + }, + + activeTabChangedExtra(value) {}, + + % if request.has_perm('people_profile.view_versions'): + + viewRevision(row) { + this.revision = this.revisionVersionMap[row.txnid] + this.showingRevisionDialog = true + }, + + viewPrevRevision() { + let txnid = this.revision.prev_txnid + this.revision = this.revisionVersionMap[txnid] + }, + + viewNextRevision() { + let txnid = this.revision.next_txnid + this.revision = this.revisionVersionMap[txnid] + }, + + toggleVersionFields() { + this.revisionShowAllFields = !this.revisionShowAllFields + }, + + % endif + }, + } + + </script> </%def> <%def name="declare_personal_tab_vars()"> @@ -3022,114 +3089,46 @@ </script> </%def> -<%def name="declare_profile_info_vars()"> - <script type="text/javascript"> - - let ProfileInfoData = { - activeTab: location.hash ? location.hash.substring(1) : 'personal', - tabchecks: ${json.dumps(tabchecks or {})|n}, - today: '${rattail_app.today()}', - profileLastChanged: Date.now(), - person: ${json.dumps(person_data or {})|n}, - phoneTypeOptions: ${json.dumps(phone_type_options or [])|n}, - emailTypeOptions: ${json.dumps(email_type_options or [])|n}, - maxLengths: ${json.dumps(max_lengths or {})|n}, - - % if request.has_perm('people_profile.view_versions'): - loadingRevisions: false, - showingRevisionDialog: false, - revision: {}, - revisionShowAllFields: false, - % endif - } - - let ProfileInfo = { - template: '#profile-info-template', - props: { - % if request.has_perm('people_profile.view_versions'): - viewingHistory: Boolean, - gettingRevisions: Boolean, - revisions: Array, - revisionVersionMap: null, - % endif - }, - computed: {}, - mounted() { - - // auto-refresh whichever tab is shown first - ## TODO: how to not assume 'personal' is the default tab? - let tab = this.$refs['tab_' + (this.activeTab || 'personal')] - if (tab && tab.refreshTab) { - tab.refreshTab() - } - }, - methods: { - - profileChanged(data) { - this.$emit('change-content-title', data.person.dynamic_content_title) - this.person = data.person - this.tabchecks = data.tabchecks - this.profileLastChanged = Date.now() - }, - - activeTabChanged(value) { - location.hash = value - this.refreshTabIfNeeded(value) - this.activeTabChangedExtra(value) - }, - - refreshTabIfNeeded(key) { - // TODO: this is *always* refreshing, should be more selective (?) - let tab = this.$refs['tab_' + key] - if (tab && tab.refreshIfNeeded) { - tab.refreshIfNeeded(this.profileLastChanged) - } - }, - - activeTabChangedExtra(value) {}, - - % if request.has_perm('people_profile.view_versions'): - - viewRevision(row) { - this.revision = this.revisionVersionMap[row.txnid] - this.showingRevisionDialog = true - }, - - viewPrevRevision() { - let txnid = this.revision.prev_txnid - this.revision = this.revisionVersionMap[txnid] - }, - - viewNextRevision() { - let txnid = this.revision.next_txnid - this.revision = this.revisionVersionMap[txnid] - }, - - toggleVersionFields() { - this.revisionShowAllFields = !this.revisionShowAllFields - }, - - % endif - }, - } - - </script> -</%def> - <%def name="make_profile_info_component()"> - ${self.declare_profile_info_vars()} - <script type="text/javascript"> + ## DEPRECATED; called for back-compat + ${self.declare_profile_info_vars()} + + <script> ProfileInfo.data = function() { return ProfileInfoData } Vue.component('profile-info', ProfileInfo) <% request.register_component('profile-info', 'ProfileInfo') %> - </script> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} + + ${self.render_personal_tab_template()} + + % if expose_members: + ${self.render_member_tab_template()} + % endif + + ${self.render_customer_tab_template()} + % if expose_customer_shoppers: + ${self.render_shopper_tab_template()} + % endif + ${self.render_employee_tab_template()} + ${self.render_notes_tab_template()} + + % if expose_transactions: + ${transactions_grid.render_complete(allow_save_defaults=False)|n} + ${self.render_transactions_tab_template()} + % endif + + ${self.render_user_tab_template()} + ${self.render_profile_info_template()} +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> % if request.has_perm('people_profile.view_versions'): ThisPage.props.viewingHistory = Boolean @@ -3177,45 +3176,8 @@ }, } - </script> -</%def> -<%def name="make_this_page_component()"> - ${parent.make_this_page_component()} - ${self.make_personal_tab_component()} - - % if expose_members: - ${self.make_member_tab_component()} - % endif - - ${self.make_customer_tab_component()} - % if expose_customer_shoppers: - ${self.make_shopper_tab_component()} - % endif - ${self.make_employee_tab_component()} - ${self.make_notes_tab_component()} - - % if expose_transactions: - <script type="text/javascript"> - - TransactionsGrid.data = function() { return TransactionsGridData } - Vue.component('transactions-grid', TransactionsGrid) - ## TODO: why is this line not needed? - ## <% request.register_component('transactions-grid', 'TransactionsGrid') %> - - </script> - ${self.make_transactions_tab_component()} - % endif - - ${self.make_user_tab_component()} - ${self.make_profile_info_component()} -</%def> - -<%def name="modify_whole_page_vars()"> - ${parent.modify_whole_page_vars()} - - % if request.has_perm('people_profile.view_versions'): - <script type="text/javascript"> + % if request.has_perm('people_profile.view_versions'): WholePageData.viewingHistory = false WholePageData.gettingRevisions = false @@ -3251,9 +3213,44 @@ }) } - </script> - % endif + % endif + </script> </%def> +<%def name="make_vue_components()"> + ${parent.make_vue_components()} -${parent.body()} + ${self.make_personal_tab_component()} + + % if expose_members: + ${self.make_member_tab_component()} + % endif + + ${self.make_customer_tab_component()} + % if expose_customer_shoppers: + ${self.make_shopper_tab_component()} + % endif + ${self.make_employee_tab_component()} + ${self.make_notes_tab_component()} + + % if expose_transactions: + <script type="text/javascript"> + + TransactionsGrid.data = function() { return TransactionsGridData } + Vue.component('transactions-grid', TransactionsGrid) + ## TODO: why is this line not needed? + ## <% request.register_component('transactions-grid', 'TransactionsGrid') %> + + </script> + ${self.make_transactions_tab_component()} + % endif + + ${self.make_user_tab_component()} + ${self.make_profile_info_component()} +</%def> + +############################## +## DEPRECATED +############################## + +<%def name="declare_profile_info_vars()"></%def> diff --git a/tailbone/templates/poser/reports/view.mako b/tailbone/templates/poser/reports/view.mako index 274a8806..cb8b51aa 100644 --- a/tailbone/templates/poser/reports/view.mako +++ b/tailbone/templates/poser/reports/view.mako @@ -62,19 +62,13 @@ <br /> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} % if master.has_perm('replace'): - <script type="text/javascript"> - - ${form.vue_component}Data.showUploadForm = false - - ${form.vue_component}Data.uploadFile = null - - ${form.vue_component}Data.uploadSubmitting = false - - </script> + <script> + ${form.vue_component}Data.showUploadForm = false + ${form.vue_component}Data.uploadFile = null + ${form.vue_component}Data.uploadSubmitting = false + </script> % endif </%def> - -${parent.body()} diff --git a/tailbone/templates/poser/setup.mako b/tailbone/templates/poser/setup.mako index 8d01bb33..239e7db2 100644 --- a/tailbone/templates/poser/setup.mako +++ b/tailbone/templates/poser/setup.mako @@ -118,14 +118,9 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.setupSubmitting = false - </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/principal/find_by_perm.mako b/tailbone/templates/principal/find_by_perm.mako index 2ea289c8..ddc44e3d 100644 --- a/tailbone/templates/principal/find_by_perm.mako +++ b/tailbone/templates/principal/find_by_perm.mako @@ -10,8 +10,16 @@ </find-principals> </%def> -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} +<%def name="principal_table()"> + <div + style="width: 50%;" + > + ${grid.render_table_element(data_prop='principalsData')|n} + </div> +</%def> + +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} <script type="text/x-template" id="find-principals-template"> <div> @@ -90,28 +98,6 @@ </div> </script> -</%def> - -<%def name="principal_table()"> - <div - style="width: 50%;" - > - ${grid.render_table_element(data_prop='principalsData')|n} - </div> -</%def> - -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - - ThisPageData.permissionGroups = ${json.dumps(perms_data)|n} - ThisPageData.sortedGroups = ${json.dumps(sorted_groups_data)|n} - - </script> -</%def> - -<%def name="make_this_page_component()"> - ${parent.make_this_page_component()} <script type="text/javascript"> const FindPrincipals = { @@ -240,12 +226,21 @@ } } - Vue.component('find-principals', FindPrincipals) - - <% request.register_component('find-principals', 'FindPrincipals') %> - </script> </%def> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + ThisPageData.permissionGroups = ${json.dumps(perms_data)|n} + ThisPageData.sortedGroups = ${json.dumps(sorted_groups_data)|n} + </script> +</%def> -${parent.body()} +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + <script> + Vue.component('find-principals', FindPrincipals) + <% request.register_component('find-principals', 'FindPrincipals') %> + </script> +</%def> diff --git a/tailbone/templates/products/batch.mako b/tailbone/templates/products/batch.mako index 66e38028..9f969468 100644 --- a/tailbone/templates/products/batch.mako +++ b/tailbone/templates/products/batch.mako @@ -60,9 +60,9 @@ </script> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ## TODO: ugh, an awful lot of duplicated code here (from /forms/deform.mako) @@ -114,6 +114,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/products/configure.mako b/tailbone/templates/products/configure.mako index 6121af67..a43a85d4 100644 --- a/tailbone/templates/products/configure.mako +++ b/tailbone/templates/products/configure.mako @@ -95,9 +95,9 @@ </div> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPage.methods.getTitleForKey = function(key) { switch (key) { @@ -118,6 +118,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/products/index.mako b/tailbone/templates/products/index.mako index b4731dee..5ffa9512 100644 --- a/tailbone/templates/products/index.mako +++ b/tailbone/templates/products/index.mako @@ -36,10 +36,10 @@ </${grid.component}> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} % if label_profiles and master.has_perm('print_labels'): - <script type="text/javascript"> + <script> ${grid.vue_component}Data.quickLabelProfile = ${json.dumps(label_profiles[0].uuid)|n} ${grid.vue_component}Data.quickLabelQuantity = 1 @@ -83,6 +83,3 @@ </script> % endif </%def> - - -${parent.body()} diff --git a/tailbone/templates/products/pending/view.mako b/tailbone/templates/products/pending/view.mako index 765c8838..72c9c76d 100644 --- a/tailbone/templates/products/pending/view.mako +++ b/tailbone/templates/products/pending/view.mako @@ -2,11 +2,6 @@ <%inherit file="/master/view.mako" /> <%namespace name="product_lookup" file="/products/lookup.mako" /> -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} - ${product_lookup.tailbone_product_lookup_template()} -</%def> - <%def name="page_content()"> ${parent.page_content()} @@ -67,9 +62,14 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} + ${product_lookup.tailbone_product_lookup_template()} +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> % if master.has_perm('ignore_product') and instance.status_code in (enum.PENDING_PRODUCT_STATUS_PENDING, enum.PENDING_PRODUCT_STATUS_READY): @@ -124,10 +124,7 @@ </script> </%def> -<%def name="make_this_page_component()"> - ${parent.make_this_page_component()} +<%def name="make_vue_components()"> + ${parent.make_vue_components()} ${product_lookup.tailbone_product_lookup_component()} </%def> - - -${parent.body()} diff --git a/tailbone/templates/products/view.mako b/tailbone/templates/products/view.mako index bd4afc7f..66ca3128 100644 --- a/tailbone/templates/products/view.mako +++ b/tailbone/templates/products/view.mako @@ -282,9 +282,9 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.vendorSourcesData = ${json.dumps(vendor_sources['data'])|n} ThisPageData.lookupCodesData = ${json.dumps(lookup_codes['data'])|n} @@ -411,6 +411,3 @@ % endif </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/purchases/credits/index.mako b/tailbone/templates/purchases/credits/index.mako index 0cfbc031..94028bdb 100644 --- a/tailbone/templates/purchases/credits/index.mako +++ b/tailbone/templates/purchases/credits/index.mako @@ -59,9 +59,9 @@ </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ${grid.vue_component}Data.changeStatusShowDialog = false ${grid.vue_component}Data.changeStatusOptions = ${json.dumps(status_options)|n} @@ -80,6 +80,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako index 45a8d66b..710dec4a 100644 --- a/tailbone/templates/receiving/view.mako +++ b/tailbone/templates/receiving/view.mako @@ -139,9 +139,15 @@ % endif </%def> -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} +<%def name="object_helpers()"> + ${self.render_status_breakdown()} + ${self.render_po_vs_invoice_helper()} + ${self.render_execute_helper()} + ${self.render_tools_helper()} +</%def> +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} % if allow_edit_catalog_unit_cost or allow_edit_invoice_unit_cost: <script type="text/x-template" id="receiving-cost-editor-template"> <div> @@ -162,16 +168,9 @@ % endif </%def> -<%def name="object_helpers()"> - ${self.render_status_breakdown()} - ${self.render_po_vs_invoice_helper()} - ${self.render_execute_helper()} - ${self.render_tools_helper()} -</%def> - -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> % if allow_confirm_all_costs: @@ -389,6 +388,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/receiving/view_row.mako b/tailbone/templates/receiving/view_row.mako index 5077539c..086754c6 100644 --- a/tailbone/templates/receiving/view_row.mako +++ b/tailbone/templates/receiving/view_row.mako @@ -484,9 +484,9 @@ </div> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ## ThisPage.methods.editUnitCost = function() { ## alert("TODO: not yet implemented") @@ -720,6 +720,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/reports/generated/choose.mako b/tailbone/templates/reports/generated/choose.mako index a952fb6a..0921530c 100644 --- a/tailbone/templates/reports/generated/choose.mako +++ b/tailbone/templates/reports/generated/choose.mako @@ -53,13 +53,13 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> - TailboneFormData.reportDescriptions = ${json.dumps(report_descriptions)|n} + ${form.vue_component}Data.reportDescriptions = ${json.dumps(report_descriptions)|n} - TailboneForm.methods.reportTypeChanged = function(reportType) { + ${form.vue_component}.methods.reportTypeChanged = function(reportType) { this.$emit('report-change', this.reportDescriptions[reportType]) } @@ -71,6 +71,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/reports/generated/delete.mako b/tailbone/templates/reports/generated/delete.mako index bce54662..f60a9819 100644 --- a/tailbone/templates/reports/generated/delete.mako +++ b/tailbone/templates/reports/generated/delete.mako @@ -1,16 +1,11 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/delete.mako" /> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> % if params_data is not Undefined: ${form.vue_component}Data.paramsData = ${json.dumps(params_data)|n} % endif - </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/reports/generated/view.mako b/tailbone/templates/reports/generated/view.mako index e5bcc9e4..cce6f346 100644 --- a/tailbone/templates/reports/generated/view.mako +++ b/tailbone/templates/reports/generated/view.mako @@ -23,16 +23,11 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> % if params_data is not Undefined: ${form.vue_component}Data.paramsData = ${json.dumps(params_data)|n} % endif - </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/reports/inventory.mako b/tailbone/templates/reports/inventory.mako index f051959f..cc5adc10 100644 --- a/tailbone/templates/reports/inventory.mako +++ b/tailbone/templates/reports/inventory.mako @@ -48,15 +48,10 @@ ${h.end_form()} </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.departments = ${json.dumps([{'uuid': d.uuid, 'name': d.name} for d in departments])|n} ThisPageData.excludeNotForSale = true - </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/reports/ordering.mako b/tailbone/templates/reports/ordering.mako index 1e526792..61ccdb16 100644 --- a/tailbone/templates/reports/ordering.mako +++ b/tailbone/templates/reports/ordering.mako @@ -81,9 +81,9 @@ <%def name="extra_fields()"></%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.vendorUUID = null ThisPageData.departments = [] @@ -127,6 +127,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/reports/problems/view.mako b/tailbone/templates/reports/problems/view.mako index 1d5cb14f..00ac1503 100644 --- a/tailbone/templates/reports/problems/view.mako +++ b/tailbone/templates/reports/problems/view.mako @@ -62,9 +62,9 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> % if weekdays_data is not Undefined: ${form.vue_component}Data.weekdaysData = ${json.dumps(weekdays_data)|n} @@ -75,6 +75,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/roles/create.mako b/tailbone/templates/roles/create.mako index 625b2675..89dd56c3 100644 --- a/tailbone/templates/roles/create.mako +++ b/tailbone/templates/roles/create.mako @@ -6,15 +6,11 @@ ${h.stylesheet_link(request.static_url('tailbone:static/css/perms.css'))} </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> // TODO: this variable name should be more dynamic (?) since this is // connected to (and only here b/c of) the permissions field - TailboneFormData.showingPermissionGroup = '' - + ${form.vue_component}Data.showingPermissionGroup = '' </script> </%def> - -${parent.body()} diff --git a/tailbone/templates/roles/edit.mako b/tailbone/templates/roles/edit.mako index 67f63013..e77cca33 100644 --- a/tailbone/templates/roles/edit.mako +++ b/tailbone/templates/roles/edit.mako @@ -6,15 +6,11 @@ ${h.stylesheet_link(request.static_url('tailbone:static/css/perms.css'))} </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> // TODO: this variable name should be more dynamic (?) since this is // connected to (and only here b/c of) the permissions field - TailboneFormData.showingPermissionGroup = '' - + ${form.vue_component}Data.showingPermissionGroup = '' </script> </%def> - -${parent.body()} diff --git a/tailbone/templates/roles/view.mako b/tailbone/templates/roles/view.mako index 0dc2956f..f5588695 100644 --- a/tailbone/templates/roles/view.mako +++ b/tailbone/templates/roles/view.mako @@ -6,9 +6,9 @@ ${h.stylesheet_link(request.static_url('tailbone:static/css/perms.css'))} </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> % if users_data is not Undefined: ${form.vue_component}Data.usersData = ${json.dumps(users_data)|n} @@ -23,5 +23,3 @@ </script> </%def> - -${parent.body()} diff --git a/tailbone/templates/settings/email/configure.mako b/tailbone/templates/settings/email/configure.mako index ef487809..f9c815c2 100644 --- a/tailbone/templates/settings/email/configure.mako +++ b/tailbone/templates/settings/email/configure.mako @@ -86,9 +86,9 @@ </div> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.testRecipient = ${json.dumps(user_email_address)|n} ThisPageData.sendingTest = false @@ -137,6 +137,3 @@ % endif </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/settings/email/index.mako b/tailbone/templates/settings/email/index.mako index 050a5833..ab8d6fa4 100644 --- a/tailbone/templates/settings/email/index.mako +++ b/tailbone/templates/settings/email/index.mako @@ -15,10 +15,10 @@ ${parent.render_grid_component()} </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} % if master.has_perm('configure'): - <script type="text/javascript"> + <script> ThisPageData.showEmails = 'available' @@ -65,5 +65,3 @@ </script> % endif </%def> - -${parent.body()} diff --git a/tailbone/templates/settings/email/view.mako b/tailbone/templates/settings/email/view.mako index c1bc5ed4..73ad7066 100644 --- a/tailbone/templates/settings/email/view.mako +++ b/tailbone/templates/settings/email/view.mako @@ -6,8 +6,8 @@ <email-preview-tools></email-preview-tools> </%def> -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} <script type="text/x-template" id="email-preview-tools-template"> ${h.form(url('email.preview'), **{'@submit': 'submitPreviewForm'})} @@ -72,10 +72,6 @@ ${h.end_form()} </script> -</%def> - -<%def name="make_this_page_component()"> - ${parent.make_this_page_component()} <script type="text/javascript"> const EmailPreviewTools = { @@ -100,12 +96,13 @@ } } - Vue.component('email-preview-tools', EmailPreviewTools) - - <% request.register_component('email-preview-tools', 'EmailPreviewTools') %> - </script> </%def> - -${parent.body()} +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + <script> + Vue.component('email-preview-tools', EmailPreviewTools) + <% request.register_component('email-preview-tools', 'EmailPreviewTools') %> + </script> +</%def> diff --git a/tailbone/templates/tables/create.mako b/tailbone/templates/tables/create.mako index 4fc2eb96..34844c5c 100644 --- a/tailbone/templates/tables/create.mako +++ b/tailbone/templates/tables/create.mako @@ -695,9 +695,9 @@ </b-steps> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> // nb. for warning user they may lose changes if leaving page ThisPageData.dirty = false @@ -983,6 +983,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/tempmon/appliances/view.mako b/tailbone/templates/tempmon/appliances/view.mako index 7dd9314a..a55af922 100644 --- a/tailbone/templates/tempmon/appliances/view.mako +++ b/tailbone/templates/tempmon/appliances/view.mako @@ -8,14 +8,9 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ${form.vue_component}Data.probesData = ${json.dumps(probes_data)|n} - </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/tempmon/clients/view.mako b/tailbone/templates/tempmon/clients/view.mako index b1db423b..434da4c8 100644 --- a/tailbone/templates/tempmon/clients/view.mako +++ b/tailbone/templates/tempmon/clients/view.mako @@ -22,14 +22,9 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ${form.vue_component}Data.probesData = ${json.dumps(probes_data)|n} - </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/tempmon/dashboard.mako b/tailbone/templates/tempmon/dashboard.mako index 396b0e68..befaf8b4 100644 --- a/tailbone/templates/tempmon/dashboard.mako +++ b/tailbone/templates/tempmon/dashboard.mako @@ -59,9 +59,9 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.appliances = ${json.dumps(appliances_data)|n} ThisPageData.applianceUUID = ${json.dumps(appliance.uuid if appliance else None)|n} @@ -118,6 +118,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/tempmon/probes/graph.mako b/tailbone/templates/tempmon/probes/graph.mako index 412f25dd..94a440e0 100644 --- a/tailbone/templates/tempmon/probes/graph.mako +++ b/tailbone/templates/tempmon/probes/graph.mako @@ -66,9 +66,9 @@ <canvas ref="tempchart" width="400" height="150"></canvas> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.currentTimeRange = ${json.dumps(current_time_range)|n} ThisPageData.chart = null @@ -128,6 +128,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako index 306b3430..14616474 100644 --- a/tailbone/templates/themes/butterball/base.mako +++ b/tailbone/templates/themes/butterball/base.mako @@ -20,38 +20,21 @@ </head> <body> - <div id="app" style="height: 100%; display: flex; flex-direction: column; justify-content: space-between;"> + <div id="app" style="height: 100%;"> <whole-page></whole-page> </div> ## TODO: this must come before the self.body() call..but why? ${declare_formposter_mixin()} - ## global components used by various (but not all) pages - ${make_field_components()} - ${make_grid_filter_components()} - - ## global components for buefy-based template compatibility - ${make_http_plugin()} - ${make_buefy_plugin()} - ${make_buefy_components()} - - ## special global components, used by WholePage - ${self.make_menu_search_component()} - ${page_help.render_template()} - ${page_help.declare_vars()} - % if request.has_perm('common.feedback'): - ${self.make_feedback_component()} - % endif - - ## WholePage component - ${self.make_whole_page_component()} - ## content body from derived/child template ${self.body()} ## Vue app - ${self.make_whole_page_app()} + ${self.render_vue_templates()} + ${self.modify_vue_vars()} + ${self.make_vue_components()} + ${self.make_vue_app()} </body> </html> @@ -596,7 +579,7 @@ </script> </%def> -<%def name="render_whole_page_template()"> +<%def name="render_vue_template_whole_page()"> <script type="text/x-template" id="whole-page-template"> <div id="whole-page" style="height: 100%; display: flex; flex-direction: column; justify-content: space-between;"> @@ -896,8 +879,6 @@ </footer> </div> </script> - -## ${multi_file_upload.render_template()} </%def> <%def name="render_this_page_component()"> @@ -1068,9 +1049,7 @@ % endif </%def> -<%def name="declare_whole_page_vars()"> -## ${multi_file_upload.declare_vars()} - +<%def name="render_vue_script_whole_page()"> <script> const WholePage = { @@ -1172,26 +1151,71 @@ </script> </%def> -<%def name="modify_whole_page_vars()"></%def> +############################## +## vue components + app +############################## -## TODO: do we really need this? -## <%def name="finalize_whole_page_vars()"></%def> +<%def name="render_vue_templates()"> +## ${multi_file_upload.render_template()} +## ${multi_file_upload.declare_vars()} -<%def name="make_whole_page_component()"> + ## global components used by various (but not all) pages + ${make_field_components()} + ${make_grid_filter_components()} + + ## global components for buefy-based template compatibility + ${make_http_plugin()} + ${make_buefy_plugin()} + ${make_buefy_components()} + + ## special global components, used by WholePage + ${self.make_menu_search_component()} + ${page_help.render_template()} + ${page_help.declare_vars()} + % if request.has_perm('common.feedback'): + ${self.make_feedback_component()} + % endif + + ## DEPRECATED; called for back-compat ${self.render_whole_page_template()} + + ## DEPRECATED; called for back-compat ${self.declare_whole_page_vars()} +</%def> + +## DEPRECATED; remains for back-compat +<%def name="render_whole_page_template()"> + ${self.render_vue_template_whole_page()} + ${self.render_vue_script_whole_page()} +</%def> + +<%def name="modify_vue_vars()"> + ## DEPRECATED; called for back-compat ${self.modify_whole_page_vars()} -## ${self.finalize_whole_page_vars()} +</%def> +<%def name="make_vue_components()"> ${page_help.make_component()} -## ${multi_file_upload.make_component()} + ## ${multi_file_upload.make_component()} + ## DEPRECATED; called for back-compat (?) + ${self.make_whole_page_component()} +</%def> + +## DEPRECATED; remains for back-compat +<%def name="make_whole_page_component()"> <script> WholePage.data = () => { return WholePageData } </script> <% request.register_component('whole-page', 'WholePage') %> </%def> +<%def name="make_vue_app()"> + ## DEPRECATED; called for back-compat + ${self.make_whole_page_app()} +</%def> + +## DEPRECATED; remains for back-compat <%def name="make_whole_page_app()"> <script type="module"> import {createApp} from 'vue' @@ -1223,3 +1247,11 @@ app.mount('#app') </script> </%def> + +############################## +## DEPRECATED +############################## + +<%def name="declare_whole_page_vars()"></%def> + +<%def name="modify_whole_page_vars()"></%def> diff --git a/tailbone/templates/trainwreck/transactions/configure.mako b/tailbone/templates/trainwreck/transactions/configure.mako index 4569759b..10c57e18 100644 --- a/tailbone/templates/trainwreck/transactions/configure.mako +++ b/tailbone/templates/trainwreck/transactions/configure.mako @@ -62,14 +62,9 @@ </div> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.hiddenDatabases = ${json.dumps(hidden_databases)|n} - </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/trainwreck/transactions/rollover.mako b/tailbone/templates/trainwreck/transactions/rollover.mako index b36e7bc3..f26515b5 100644 --- a/tailbone/templates/trainwreck/transactions/rollover.mako +++ b/tailbone/templates/trainwreck/transactions/rollover.mako @@ -48,14 +48,9 @@ </b-table> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.engines = ${json.dumps(engines_data)|n} - </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/trainwreck/transactions/view.mako b/tailbone/templates/trainwreck/transactions/view.mako index 02950941..630950cf 100644 --- a/tailbone/templates/trainwreck/transactions/view.mako +++ b/tailbone/templates/trainwreck/transactions/view.mako @@ -1,15 +1,11 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> % if custorder_xref_markers_data is not Undefined: ${form.vue_component}Data.custorderXrefMarkersData = ${json.dumps(custorder_xref_markers_data)|n} % endif - </script> </%def> - -${parent.body()} diff --git a/tailbone/templates/trainwreck/transactions/view_row.mako b/tailbone/templates/trainwreck/transactions/view_row.mako index 9c76f7bd..2507492e 100644 --- a/tailbone/templates/trainwreck/transactions/view_row.mako +++ b/tailbone/templates/trainwreck/transactions/view_row.mako @@ -1,16 +1,11 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view_row.mako" /> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> % if discounts_data is not Undefined: ${form.vue_component}Data.discountsData = ${json.dumps(discounts_data)|n} % endif - </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/units-of-measure/index.mako b/tailbone/templates/units-of-measure/index.mako index 597cabfd..4815fc79 100644 --- a/tailbone/templates/units-of-measure/index.mako +++ b/tailbone/templates/units-of-measure/index.mako @@ -51,20 +51,17 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} % if master.has_perm('collect_wild_uoms'): - <script type="text/javascript"> + <script> - TailboneGridData.showingCollectWildDialog = false + ${grid.vue_component}Data.showingCollectWildDialog = false - TailboneGrid.methods.collectFromWild = function() { - this.$refs['collect-wild-uoms-form'].submit() - } + ${grid.vue_component}.methods.collectFromWild = function() { + this.$refs['collect-wild-uoms-form'].submit() + } - </script> + </script> % endif </%def> - - -${parent.body()} diff --git a/tailbone/templates/upgrades/configure.mako b/tailbone/templates/upgrades/configure.mako index f7af685c..9439f830 100644 --- a/tailbone/templates/upgrades/configure.mako +++ b/tailbone/templates/upgrades/configure.mako @@ -111,9 +111,9 @@ </div> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.upgradeSystems = ${json.dumps(upgrade_systems)|n} ThisPageData.upgradeSystemShowDialog = false @@ -161,6 +161,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/upgrades/view.mako b/tailbone/templates/upgrades/view.mako index 6ae110e0..c3fca81d 100644 --- a/tailbone/templates/upgrades/view.mako +++ b/tailbone/templates/upgrades/view.mako @@ -137,11 +137,11 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> - TailboneFormData.showingPackages = 'diffs' + ${form.vue_component}Data.showingPackages = 'diffs' % if master.has_perm('execute'): @@ -153,7 +153,7 @@ // execute upgrade ////////////////////////////// - TailboneForm.props.upgradeExecuting = { + ${form.vue_component}.props.upgradeExecuting = { type: Boolean, default: false, } @@ -253,9 +253,9 @@ // execute upgrade ////////////////////////////// - TailboneFormData.formSubmitting = false + ${form.vue_component}Data.formSubmitting = false - TailboneForm.methods.submitForm = function() { + ${form.vue_component}.methods.submitForm = function() { this.formSubmitting = true } @@ -265,12 +265,12 @@ // declare failure ////////////////////////////// - TailboneForm.props.declareFailureSubmitting = { + ${form.vue_component}.props.declareFailureSubmitting = { type: Boolean, default: false, } - TailboneForm.methods.declareFailureClick = function() { + ${form.vue_component}.methods.declareFailureClick = function() { this.$emit('declare-failure-click') } @@ -287,6 +287,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/users/preferences.mako b/tailbone/templates/users/preferences.mako index c2e17396..ecfdd1c7 100644 --- a/tailbone/templates/users/preferences.mako +++ b/tailbone/templates/users/preferences.mako @@ -42,14 +42,9 @@ </div> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.themeStyleOptions = ${json.dumps(theme_style_options)|n} - </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/users/view.mako b/tailbone/templates/users/view.mako index 06087927..d1afd218 100644 --- a/tailbone/templates/users/view.mako +++ b/tailbone/templates/users/view.mako @@ -76,10 +76,10 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} % if master.has_perm('manage_api_tokens'): - <script type="text/javascript"> + <script> ${form.vue_component}.props.apiTokens = null @@ -134,6 +134,3 @@ </script> % endif </%def> - - -${parent.body()} diff --git a/tailbone/templates/vendors/configure.mako b/tailbone/templates/vendors/configure.mako index 79dad455..6b135346 100644 --- a/tailbone/templates/vendors/configure.mako +++ b/tailbone/templates/vendors/configure.mako @@ -44,14 +44,9 @@ </div> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.supportedVendorSettings = ${json.dumps(supported_vendor_settings)|n} - </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/views/model/create.mako b/tailbone/templates/views/model/create.mako index c5e22cfb..e902fd48 100644 --- a/tailbone/templates/views/model/create.mako +++ b/tailbone/templates/views/model/create.mako @@ -259,9 +259,9 @@ def includeme(config): </b-steps> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.activeStep = 'enter-details' @@ -334,6 +334,3 @@ def includeme(config): </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/workorders/view.mako b/tailbone/templates/workorders/view.mako index 8740b4c9..432e011d 100644 --- a/tailbone/templates/workorders/view.mako +++ b/tailbone/templates/workorders/view.mako @@ -145,9 +145,9 @@ </nav> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.receiveButtonDisabled = false ThisPageData.receiveButtonText = "I've received the order from customer" @@ -216,6 +216,3 @@ </script> </%def> - - -${parent.body()} From 59bd58aca768f9e18a1e3db7447a576c48d29191 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 20 Aug 2024 13:46:40 -0500 Subject: [PATCH 1601/1681] feat: add new 'waterpark' theme, based on wuttaweb w/ vue2 + buefy hoping to eventually replace the 'default' view with this one, if all goes well. definitely needs more testing and is not exposed as an option yet, unless configured --- tailbone/app.py | 3 +- tailbone/forms/core.py | 15 +- tailbone/grids/core.py | 14 +- tailbone/static/__init__.py | 5 +- tailbone/templates/appinfo/index.mako | 4 +- tailbone/templates/base.mako | 2 + tailbone/templates/batch/index.mako | 9 +- tailbone/templates/batch/view.mako | 20 +- tailbone/templates/form.mako | 5 +- tailbone/templates/themes/waterpark/base.mako | 486 ++++++++++++++++++ .../templates/themes/waterpark/configure.mako | 2 + tailbone/templates/themes/waterpark/form.mako | 2 + .../themes/waterpark/master/configure.mako | 2 + .../themes/waterpark/master/create.mako | 2 + .../themes/waterpark/master/delete.mako | 46 ++ .../themes/waterpark/master/edit.mako | 2 + .../themes/waterpark/master/form.mako | 2 + .../themes/waterpark/master/index.mako | 294 +++++++++++ .../themes/waterpark/master/view.mako | 2 + tailbone/templates/themes/waterpark/page.mako | 48 ++ tailbone/views/master.py | 12 +- tailbone/views/people.py | 2 +- tests/util.py | 2 +- 23 files changed, 937 insertions(+), 44 deletions(-) create mode 100644 tailbone/templates/themes/waterpark/base.mako create mode 100644 tailbone/templates/themes/waterpark/configure.mako create mode 100644 tailbone/templates/themes/waterpark/form.mako create mode 100644 tailbone/templates/themes/waterpark/master/configure.mako create mode 100644 tailbone/templates/themes/waterpark/master/create.mako create mode 100644 tailbone/templates/themes/waterpark/master/delete.mako create mode 100644 tailbone/templates/themes/waterpark/master/edit.mako create mode 100644 tailbone/templates/themes/waterpark/master/form.mako create mode 100644 tailbone/templates/themes/waterpark/master/index.mako create mode 100644 tailbone/templates/themes/waterpark/master/view.mako create mode 100644 tailbone/templates/themes/waterpark/page.mako diff --git a/tailbone/app.py b/tailbone/app.py index ad9663cf..b7262866 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -321,7 +321,8 @@ def main(global_config, **settings): """ This function returns a Pyramid WSGI application. """ - settings.setdefault('mako.directories', ['tailbone:templates']) + settings.setdefault('mako.directories', ['tailbone:templates', + 'wuttaweb:templates']) rattail_config = make_rattail_config(settings) pyramid_config = make_pyramid_config(settings) pyramid_config.include('tailbone') diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 2f1c9370..059b212a 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -905,7 +905,8 @@ class Form(object): def render_vue_template(self, template='/forms/deform.mako', **context): """ """ - return self.render_deform(template=template, **context) + output = self.render_deform(template=template, **context) + return HTML.literal(output) def render_deform(self, dform=None, template=None, **kwargs): if not template: @@ -1220,6 +1221,18 @@ class Form(object): # TODO: again, why does serialize() not return literal? return HTML.literal(field.serialize()) + # TODO: this was copied from wuttaweb; can remove when we align + # Form class structure + def render_vue_finalize(self): + """ """ + set_data = f"{self.vue_component}.data = function() {{ return {self.vue_component}Data }}" + make_component = f"Vue.component('{self.vue_tagname}', {self.vue_component})" + return HTML.tag('script', c=['\n', + HTML.literal(set_data), + '\n', + HTML.literal(make_component), + '\n']) + def render_field_readonly(self, field_name, **kwargs): """ Render the given field completely, but in read-only fashion. diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 6ec55987..eada1041 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -216,39 +216,39 @@ class Grid(WuttaGrid): expose_direct_link=False, **kwargs, ): - if kwargs.get('component'): + if 'component' in kwargs: warnings.warn("component param is deprecated for Grid(); " "please use vue_tagname param instead", DeprecationWarning, stacklevel=2) kwargs.setdefault('vue_tagname', kwargs.pop('component')) - if kwargs.get('default_sortkey'): + if 'default_sortkey' in kwargs: warnings.warn("default_sortkey param is deprecated for Grid(); " "please use sort_defaults param instead", DeprecationWarning, stacklevel=2) - if kwargs.get('default_sortdir'): + if 'default_sortdir' in kwargs: warnings.warn("default_sortdir param is deprecated for Grid(); " "please use sort_defaults param instead", DeprecationWarning, stacklevel=2) - if kwargs.get('default_sortkey') or kwargs.get('default_sortdir'): + if 'default_sortkey' in kwargs or 'default_sortdir' in kwargs: sortkey = kwargs.pop('default_sortkey', None) sortdir = kwargs.pop('default_sortdir', 'asc') if sortkey: kwargs.setdefault('sort_defaults', [(sortkey, sortdir)]) - if kwargs.get('pageable'): + if 'pageable' in kwargs: warnings.warn("pageable param is deprecated for Grid(); " "please use vue_tagname param instead", DeprecationWarning, stacklevel=2) kwargs.setdefault('paginated', kwargs.pop('pageable')) - if kwargs.get('default_pagesize'): + if 'default_pagesize' in kwargs: warnings.warn("default_pagesize param is deprecated for Grid(); " "please use pagesize param instead", DeprecationWarning, stacklevel=2) kwargs.setdefault('pagesize', kwargs.pop('default_pagesize')) - if kwargs.get('default_page'): + if 'default_page' in kwargs: warnings.warn("default_page param is deprecated for Grid(); " "please use page param instead", DeprecationWarning, stacklevel=2) diff --git a/tailbone/static/__init__.py b/tailbone/static/__init__.py index 2ad5161a..57700b80 100644 --- a/tailbone/static/__init__.py +++ b/tailbone/static/__init__.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,9 +24,8 @@ Static Assets """ -from __future__ import unicode_literals, absolute_import - def includeme(config): + config.include('wuttaweb.static') config.add_static_view('tailbone', 'tailbone:static') config.add_static_view('deform', 'deform:static') diff --git a/tailbone/templates/appinfo/index.mako b/tailbone/templates/appinfo/index.mako index 68244300..75032c1f 100644 --- a/tailbone/templates/appinfo/index.mako +++ b/tailbone/templates/appinfo/index.mako @@ -1,7 +1,7 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/index.mako" /> -<%def name="render_grid_component()"> +<%def name="page_content()"> <div class="buttons"> @@ -108,7 +108,7 @@ <div class="panel-block"> <div style="width: 100%;"> - ${parent.render_grid_component()} + ${grid.render_vue_tag()} </div> </div> </${b}-collapse> diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index a0e58e22..eb950011 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -1,4 +1,5 @@ ## -*- coding: utf-8; -*- +<%namespace file="/wutta-components.mako" import="make_wutta_components" /> <%namespace file="/grids/nav.mako" import="grid_index_nav" /> <%namespace file="/autocomplete.mako" import="tailbone_autocomplete_template" /> <%namespace name="base_meta" file="/base_meta.mako" /> @@ -955,6 +956,7 @@ </%def> <%def name="make_vue_components()"> + ${make_wutta_components()} ${make_grid_filter_components()} ${page_help.make_component()} ${multi_file_upload.make_component()} diff --git a/tailbone/templates/batch/index.mako b/tailbone/templates/batch/index.mako index a1b11b89..bea10a97 100644 --- a/tailbone/templates/batch/index.mako +++ b/tailbone/templates/batch/index.mako @@ -43,7 +43,7 @@ <br /> <div class="form-wrapper"> <div class="form"> - <${execute_form.component} ref="executeResultsForm"></${execute_form.component}> + ${execute_form.render_vue_tag(ref='executeResultsForm')} </div> </div> </section> @@ -67,7 +67,7 @@ <%def name="render_vue_templates()"> ${parent.render_vue_templates()} % if master.results_executable and master.has_perm('execute_multiple'): - ${execute_form.render_deform(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)|n} + ${execute_form.render_vue_template(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)} % endif </%def> @@ -128,9 +128,6 @@ <%def name="make_vue_components()"> ${parent.make_vue_components()} % if master.results_executable and master.has_perm('execute_multiple'): - <script> - ${execute_form.vue_component}.data = function() { return ${execute_form.vue_component}Data } - Vue.component('${execute_form.vue_tagname}', ${execute_form.vue_component}) - </script> + ${execute_form.render_vue_finalize()} % endif </%def> diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako index cdfa9ba7..7c81ab0e 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -119,8 +119,7 @@ <div class="markdown"> ${execution_described|n} </div> - <${execute_form.component} ref="executeBatchForm"> - </${execute_form.component}> + ${execute_form.render_vue_tag(ref='executeBatchForm')} </section> <footer class="modal-card-foot"> @@ -168,8 +167,7 @@ Please be certain to use the right one! </p> <br /> - <${upload_worksheet_form.component} ref="uploadForm"> - </${upload_worksheet_form.component}> + ${upload_worksheet_form.render_vue_tag(ref='uploadForm')} </section> <footer class="modal-card-foot"> @@ -254,10 +252,10 @@ <%def name="render_vue_templates()"> ${parent.render_vue_templates()} % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'): - ${upload_worksheet_form.render_deform(buttons=False, form_kwargs={'ref': 'actualUploadForm'})|n} + ${upload_worksheet_form.render_vue_template(buttons=False, form_kwargs={'ref': 'actualUploadForm'})} % endif % if master.handler.executable(batch) and master.has_perm('execute'): - ${execute_form.render_deform(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)|n} + ${execute_form.render_vue_template(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)} % endif </%def> @@ -345,15 +343,9 @@ <%def name="make_vue_components()"> ${parent.make_vue_components()} % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'): - <script> - ${upload_worksheet_form.vue_component}.data = function() { return ${upload_worksheet_form.vue_component}Data } - Vue.component('${upload_worksheet_form.component}', ${upload_worksheet_form.vue_component}) - </script> + ${upload_worksheet_form.render_vue_finalize()} % endif % if execute_enabled and master.has_perm('execute'): - <script> - ${execute_form.vue_component}.data = function() { return ${execute_form.vue_component}Data } - Vue.component('${execute_form.component}', ${execute_form.vue_component}) - </script> + ${execute_form.render_vue_finalize()} % endif </%def> diff --git a/tailbone/templates/form.mako b/tailbone/templates/form.mako index 3bb04257..e3a4d5dc 100644 --- a/tailbone/templates/form.mako +++ b/tailbone/templates/form.mako @@ -109,9 +109,6 @@ <%def name="make_vue_components()"> ${parent.make_vue_components()} % if form is not Undefined: - <script> - ${form.vue_component}.data = function() { return ${form.vue_component}Data } - Vue.component('${form.vue_tagname}', ${form.vue_component}) - </script> + ${form.render_vue_finalize()} % endif </%def> diff --git a/tailbone/templates/themes/waterpark/base.mako b/tailbone/templates/themes/waterpark/base.mako new file mode 100644 index 00000000..15184f6e --- /dev/null +++ b/tailbone/templates/themes/waterpark/base.mako @@ -0,0 +1,486 @@ +## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/base.mako" /> +<%namespace name="base_meta" file="/base_meta.mako" /> +<%namespace file="/formposter.mako" import="declare_formposter_mixin" /> +<%namespace file="/grids/filter-components.mako" import="make_grid_filter_components" /> +<%namespace name="page_help" file="/page_help.mako" /> + +<%def name="base_styles()"> + ${parent.base_styles()} + <style> + + .filters .filter-fieldname .field, + .filters .filter-fieldname .field label { + width: 100%; + } + + .filters .filter-fieldname, + .filters .filter-fieldname .field label, + .filters .filter-fieldname .button { + justify-content: left; + } + + .filters .filter-verb .select, + .filters .filter-verb .select select { + width: 100%; + } + + % if filter_fieldname_width is not Undefined: + + .filters .filter-fieldname, + .filters .filter-fieldname .button { + min-width: ${filter_fieldname_width}; + } + + .filters .filter-verb { + min-width: ${filter_verb_width}; + } + + % endif + + </style> +</%def> + +<%def name="before_content()"> + ## TODO: this must come before the self.body() call..but why? + ${declare_formposter_mixin()} +</%def> + +<%def name="render_navbar_brand()"> + <div class="navbar-brand"> + <a class="navbar-item" href="${url('home')}" + v-show="!menuSearchActive"> + ${base_meta.header_logo()} + <div id="global-header-title"> + ${base_meta.global_title()} + </div> + </a> + <div v-show="menuSearchActive" + class="navbar-item"> + <b-autocomplete ref="menuSearchAutocomplete" + v-model="menuSearchTerm" + :data="menuSearchFilteredData" + field="label" + open-on-focus + keep-first + icon-pack="fas" + clearable + @keydown.native="menuSearchKeydown" + @select="menuSearchSelect"> + </b-autocomplete> + </div> + <a role="button" class="navbar-burger" data-target="navbar-menu" aria-label="menu" aria-expanded="false"> + <span aria-hidden="true"></span> + <span aria-hidden="true"></span> + <span aria-hidden="true"></span> + </a> + </div> +</%def> + +<%def name="render_navbar_start()"> + <div class="navbar-start"> + + <div v-if="menuSearchData.length" + class="navbar-item"> + <b-button type="is-primary" + size="is-small" + @click="menuSearchInit()"> + <span><i class="fa fa-search"></i></span> + </b-button> + </div> + + % for topitem in menus: + % if topitem['is_link']: + ${h.link_to(topitem['title'], topitem['url'], target=topitem['target'], class_='navbar-item')} + % else: + <div class="navbar-item has-dropdown is-hoverable"> + <a class="navbar-link">${topitem['title']}</a> + <div class="navbar-dropdown"> + % for item in topitem['items']: + % if item['is_menu']: + <% item_hash = id(item) %> + <% toggle = 'menu_{}_shown'.format(item_hash) %> + <div> + <a class="navbar-link" @click.prevent="toggleNestedMenu('${item_hash}')"> + ${item['title']} + </a> + </div> + % for subitem in item['items']: + % if subitem['is_sep']: + <hr class="navbar-divider" v-show="${toggle}"> + % else: + ${h.link_to("{}".format(subitem['title']), subitem['url'], class_='navbar-item nested', target=subitem['target'], **{'v-show': toggle})} + % endif + % endfor + % else: + % if item['is_sep']: + <hr class="navbar-divider"> + % else: + ${h.link_to(item['title'], item['url'], class_='navbar-item', target=item['target'])} + % endif + % endif + % endfor + </div> + </div> + % endif + % endfor + + </div> +</%def> + +<%def name="render_theme_picker()"> + % if expose_theme_picker and request.has_perm('common.change_app_theme'): + <div class="level-item"> + ${h.form(url('change_theme'), method="post", ref='themePickerForm')} + ${h.csrf_token(request)} + <input type="hidden" name="referrer" :value="referrer" /> + <div style="display: flex; align-items: center; gap: 0.5rem;"> + <span>Theme:</span> + <b-select name="theme" + v-model="globalTheme" + @input="changeTheme()"> + % for option in theme_picker_options: + <option value="${option.value}"> + ${option.label} + </option> + % endfor + </b-select> + </div> + ${h.end_form()} + </div> + % endif +</%def> + +<%def name="render_feedback_button()"> + + <div class="level-item"> + <page-help + % if can_edit_help: + @configure-fields-help="configureFieldsHelp = true" + % endif + /> + </div> + + % if request.has_perm('common.feedback'): + <feedback-form + action="${url('feedback')}" + :message="feedbackMessage"> + </feedback-form> + % endif +</%def> + +<%def name="render_this_page_component()"> + <this-page @change-content-title="changeContentTitle" + % if can_edit_help: + :configure-fields-help="configureFieldsHelp" + % endif + /> +</%def> + +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} + + ${page_help.render_template()} + ${page_help.declare_vars()} + + % if request.has_perm('common.feedback'): + <script type="text/x-template" id="feedback-template"> + <div> + + <div class="level-item"> + <b-button type="is-primary" + @click="showFeedback()" + icon-pack="fas" + icon-left="comment"> + Feedback + </b-button> + </div> + + <b-modal has-modal-card + :active.sync="showDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">User Feedback</p> + </header> + + <section class="modal-card-body"> + <p class="block"> + Questions, suggestions, comments, complaints, etc. + <span class="red">regarding this website</span> are + welcome and may be submitted below. + </p> + + <b-field label="User Name"> + <b-input v-model="userName" + % if request.user: + disabled + % endif + > + </b-input> + </b-field> + + <b-field label="Referring URL"> + <b-input + v-model="referrer" + disabled="true"> + </b-input> + </b-field> + + <b-field label="Message"> + <b-input type="textarea" + v-model="message" + ref="textarea"> + </b-input> + </b-field> + + % if config.get_bool('tailbone.feedback_allows_reply'): + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <b-checkbox v-model="pleaseReply" + @input="pleaseReplyChanged"> + Please email me back{{ pleaseReply ? " at: " : "" }} + </b-checkbox> + </div> + <div class="level-item" v-show="pleaseReply"> + <b-input v-model="userEmail" + ref="userEmail"> + </b-input> + </div> + </div> + </div> + % endif + + </section> + + <footer class="modal-card-foot"> + <b-button @click="showDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + icon-pack="fas" + icon-left="paper-plane" + @click="sendFeedback()" + :disabled="sendingFeedback || !message.trim()"> + {{ sendingFeedback ? "Working, please wait..." : "Send Message" }} + </b-button> + </footer> + </div> + </b-modal> + + </div> + </script> + <script> + + const FeedbackForm = { + template: '#feedback-template', + mixins: [SimpleRequestMixin], + props: [ + 'action', + 'message', + ], + methods: { + + showFeedback() { + this.referrer = location.href + this.showDialog = true + this.$nextTick(function() { + this.$refs.textarea.focus() + }) + }, + + % if config.get_bool('tailbone.feedback_allows_reply'): + pleaseReplyChanged(value) { + this.$nextTick(() => { + this.$refs.userEmail.focus() + }) + }, + % endif + + sendFeedback() { + this.sendingFeedback = true + + const params = { + referrer: this.referrer, + user: this.userUUID, + user_name: this.userName, + % if config.get_bool('tailbone.feedback_allows_reply'): + please_reply_to: this.pleaseReply ? this.userEmail : null, + % endif + message: this.message.trim(), + } + + this.simplePOST(this.action, params, response => { + + this.$buefy.toast.open({ + message: "Message sent! Thank you for your feedback.", + type: 'is-info', + duration: 4000, // 4 seconds + }) + + this.showDialog = false + // clear out message, in case they need to send another + this.message = "" + this.sendingFeedback = false + + }, response => { // failure + this.sendingFeedback = false + }) + }, + } + } + + const FeedbackFormData = { + referrer: null, + userUUID: null, + userName: null, + userEmail: null, + % if config.get_bool('tailbone.feedback_allows_reply'): + pleaseReply: false, + % endif + showDialog: false, + sendingFeedback: false, + } + + </script> + % endif +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + ############################## + ## menu search + ############################## + + WholePageData.menuSearchActive = false + WholePageData.menuSearchTerm = '' + WholePageData.menuSearchData = ${json.dumps(global_search_data or [])|n} + + WholePage.computed.menuSearchFilteredData = function() { + if (!this.menuSearchTerm.length) { + return this.menuSearchData + } + + const terms = [] + for (let term of this.menuSearchTerm.toLowerCase().split(' ')) { + term = term.trim() + if (term) { + terms.push(term) + } + } + if (!terms.length) { + return this.menuSearchData + } + + // all terms must match + return this.menuSearchData.filter((option) => { + const label = option.label.toLowerCase() + for (const term of terms) { + if (label.indexOf(term) < 0) { + return false + } + } + return true + }) + } + + WholePage.methods.globalKey = function(event) { + + // Ctrl+8 opens menu search + if (event.target.tagName == 'BODY') { + if (event.ctrlKey && event.key == '8') { + this.menuSearchInit() + } + } + } + + WholePage.mounted = function() { + window.addEventListener('keydown', this.globalKey) + for (let hook of this.mountedHooks) { + hook(this) + } + } + + WholePage.beforeDestroy = function() { + window.removeEventListener('keydown', this.globalKey) + } + + WholePage.methods.menuSearchInit = function() { + this.menuSearchTerm = '' + this.menuSearchActive = true + this.$nextTick(() => { + this.$refs.menuSearchAutocomplete.focus() + }) + } + + WholePage.methods.menuSearchKeydown = function(event) { + + // ESC will dismiss searchbox + if (event.which == 27) { + this.menuSearchActive = false + } + } + + WholePage.methods.menuSearchSelect = function(option) { + location.href = option.url + } + + ############################## + ## theme picker + ############################## + + % if expose_theme_picker and request.has_perm('common.change_app_theme'): + + WholePageData.globalTheme = ${json.dumps(theme or None)|n} + ## WholePageData.referrer = location.href + + WholePage.methods.changeTheme = function() { + this.$refs.themePickerForm.submit() + } + + % endif + + ############################## + ## feedback + ############################## + + % if request.has_perm('common.feedback'): + + WholePageData.feedbackMessage = "" + + % if request.user: + FeedbackFormData.userUUID = ${json.dumps(request.user.uuid)|n} + FeedbackFormData.userName = ${json.dumps(str(request.user))|n} + % endif + + % endif + + ############################## + ## edit fields help + ############################## + + % if can_edit_help: + WholePageData.configureFieldsHelp = false + % endif + + </script> +</%def> + +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.datepicker.js') + f'?ver={tailbone.__version__}')} + ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.numericinput.js') + f'?ver={tailbone.__version__}')} + ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.oncebutton.js') + f'?ver={tailbone.__version__}')} + ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.timepicker.js') + f'?ver={tailbone.__version__}')} + ${make_grid_filter_components()} + ${page_help.make_component()} + % if request.has_perm('common.feedback'): + <script> + FeedbackForm.data = function() { return FeedbackFormData } + Vue.component('feedback-form', FeedbackForm) + </script> + % endif +</%def> diff --git a/tailbone/templates/themes/waterpark/configure.mako b/tailbone/templates/themes/waterpark/configure.mako new file mode 100644 index 00000000..9ac9a5cd --- /dev/null +++ b/tailbone/templates/themes/waterpark/configure.mako @@ -0,0 +1,2 @@ +## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/configure.mako" /> diff --git a/tailbone/templates/themes/waterpark/form.mako b/tailbone/templates/themes/waterpark/form.mako new file mode 100644 index 00000000..cf1ddb8a --- /dev/null +++ b/tailbone/templates/themes/waterpark/form.mako @@ -0,0 +1,2 @@ +## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/form.mako" /> diff --git a/tailbone/templates/themes/waterpark/master/configure.mako b/tailbone/templates/themes/waterpark/master/configure.mako new file mode 100644 index 00000000..51da5b0a --- /dev/null +++ b/tailbone/templates/themes/waterpark/master/configure.mako @@ -0,0 +1,2 @@ +## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/master/configure.mako" /> diff --git a/tailbone/templates/themes/waterpark/master/create.mako b/tailbone/templates/themes/waterpark/master/create.mako new file mode 100644 index 00000000..23399b9e --- /dev/null +++ b/tailbone/templates/themes/waterpark/master/create.mako @@ -0,0 +1,2 @@ +## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/master/create.mako" /> diff --git a/tailbone/templates/themes/waterpark/master/delete.mako b/tailbone/templates/themes/waterpark/master/delete.mako new file mode 100644 index 00000000..a15dfaf8 --- /dev/null +++ b/tailbone/templates/themes/waterpark/master/delete.mako @@ -0,0 +1,46 @@ +## -*- coding: utf-8; -*- +<%inherit file="tailbone:templates/form.mako" /> + +<%def name="title()">Delete ${model_title}: ${instance_title}</%def> + +<%def name="render_form()"> + <br /> + <b-notification type="is-danger" :closable="false"> + You are about to delete the following ${model_title} and all associated data: + </b-notification> + ${parent.render_form()} +</%def> + +<%def name="render_form_buttons()"> + <br /> + <b-notification type="is-danger" :closable="false"> + Are you sure about this? + </b-notification> + <br /> + + ${h.form(request.current_route_url(), **{'@submit': 'submitForm'})} + ${h.csrf_token(request)} + <div class="buttons"> + <wutta-button once tag="a" href="${form.cancel_url}" + label="Whoops, nevermind..." /> + <b-button type="is-primary is-danger" + native-type="submit" + :disabled="formSubmitting"> + {{ formSubmitting ? "Working, please wait..." : "${form.button_label_submit}" }} + </b-button> + </div> + ${h.end_form()} +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + ${form.vue_component}Data.formSubmitting = false + + ${form.vue_component}.methods.submitForm = function() { + this.formSubmitting = true + } + + </script> +</%def> diff --git a/tailbone/templates/themes/waterpark/master/edit.mako b/tailbone/templates/themes/waterpark/master/edit.mako new file mode 100644 index 00000000..18a2fa2f --- /dev/null +++ b/tailbone/templates/themes/waterpark/master/edit.mako @@ -0,0 +1,2 @@ +## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/master/edit.mako" /> diff --git a/tailbone/templates/themes/waterpark/master/form.mako b/tailbone/templates/themes/waterpark/master/form.mako new file mode 100644 index 00000000..db56843b --- /dev/null +++ b/tailbone/templates/themes/waterpark/master/form.mako @@ -0,0 +1,2 @@ +## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/master/form.mako" /> diff --git a/tailbone/templates/themes/waterpark/master/index.mako b/tailbone/templates/themes/waterpark/master/index.mako new file mode 100644 index 00000000..e3b5b42d --- /dev/null +++ b/tailbone/templates/themes/waterpark/master/index.mako @@ -0,0 +1,294 @@ +## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/master/index.mako" /> + +<%def name="grid_tools()"> + + ## grid totals + % if getattr(master, 'supports_grid_totals', False): + <div style="display: flex; align-items: center;"> + <b-button v-if="gridTotalsDisplay == null" + :disabled="gridTotalsFetching" + @click="gridTotalsFetch()"> + {{ gridTotalsFetching ? "Working, please wait..." : "Show Totals" }} + </b-button> + <div v-if="gridTotalsDisplay != null" + class="control"> + Totals: {{ gridTotalsDisplay }} + </div> + </div> + % endif + + ## download search results + % if getattr(master, 'results_downloadable', False) and master.has_perm('download_results'): + <div> + <b-button type="is-primary" + icon-pack="fas" + icon-left="download" + @click="showDownloadResultsDialog = true" + :disabled="!total"> + Download Results + </b-button> + + ${h.form(url('{}.download_results'.format(route_prefix)), ref='download_results_form')} + ${h.csrf_token(request)} + <input type="hidden" name="fmt" :value="downloadResultsFormat" /> + <input type="hidden" name="fields" :value="downloadResultsFieldsIncluded" /> + ${h.end_form()} + + <b-modal :active.sync="showDownloadResultsDialog"> + <div class="card"> + + <div class="card-content"> + <p> + There are + <span class="is-size-4 has-text-weight-bold"> + {{ total.toLocaleString('en') }} ${model_title_plural} + </span> + matching your current filters. + </p> + <p> + You may download this set as a single data file if you like. + </p> + <br /> + + <b-notification type="is-warning" :closable="false" + v-if="downloadResultsFormat == 'xlsx' && total >= 1000"> + Excel downloads for large data sets can take a long time to + generate, and bog down the server in the meantime. You are + encouraged to choose CSV for a large data set, even though + the end result (file size) may be larger with CSV. + </b-notification> + + <div style="display: flex; justify-content: space-between"> + + <div> + <b-field label="Format"> + <b-select v-model="downloadResultsFormat"> + % for key, label in master.download_results_supported_formats().items(): + <option value="${key}">${label}</option> + % endfor + </b-select> + </b-field> + </div> + + <div> + + <div v-show="downloadResultsFieldsMode != 'choose'" + class="has-text-right"> + <p v-if="downloadResultsFieldsMode == 'default'"> + Will use DEFAULT fields. + </p> + <p v-if="downloadResultsFieldsMode == 'all'"> + Will use ALL fields. + </p> + <br /> + </div> + + <div class="buttons is-right"> + <b-button type="is-primary" + v-show="downloadResultsFieldsMode != 'default'" + @click="downloadResultsUseDefaultFields()"> + Use Default Fields + </b-button> + <b-button type="is-primary" + v-show="downloadResultsFieldsMode != 'all'" + @click="downloadResultsUseAllFields()"> + Use All Fields + </b-button> + <b-button type="is-primary" + v-show="downloadResultsFieldsMode != 'choose'" + @click="downloadResultsFieldsMode = 'choose'"> + Choose Fields + </b-button> + </div> + + <div v-show="downloadResultsFieldsMode == 'choose'"> + <div style="display: flex;"> + <div> + <b-field label="Excluded Fields"> + <b-select multiple native-size="8" + expanded + v-model="downloadResultsExcludedFieldsSelected" + ref="downloadResultsExcludedFields"> + <option v-for="field in downloadResultsFieldsExcluded" + :key="field" + :value="field"> + {{ field }} + </option> + </b-select> + </b-field> + </div> + <div> + <br /><br /> + <b-button style="margin: 0.5rem;" + @click="downloadResultsExcludeFields()"> + < + </b-button> + <br /> + <b-button style="margin: 0.5rem;" + @click="downloadResultsIncludeFields()"> + > + </b-button> + </div> + <div> + <b-field label="Included Fields"> + <b-select multiple native-size="8" + expanded + v-model="downloadResultsIncludedFieldsSelected" + ref="downloadResultsIncludedFields"> + <option v-for="field in downloadResultsFieldsIncluded" + :key="field" + :value="field"> + {{ field }} + </option> + </b-select> + </b-field> + </div> + </div> + </div> + + </div> + </div> + </div> <!-- card-content --> + + <footer class="modal-card-foot"> + <b-button @click="showDownloadResultsDialog = false"> + Cancel + </b-button> + <once-button type="is-primary" + @click="downloadResultsSubmit()" + icon-pack="fas" + icon-left="download" + :disabled="!downloadResultsFieldsIncluded.length" + text="Download Results"> + </once-button> + </footer> + </div> + </b-modal> + </div> + % endif + + ## download rows for search results + % if getattr(master, 'has_rows', False) and master.results_rows_downloadable and master.has_perm('download_results_rows'): + <b-button type="is-primary" + icon-pack="fas" + icon-left="download" + @click="downloadResultsRows()" + :disabled="downloadResultsRowsButtonDisabled"> + {{ downloadResultsRowsButtonText }} + </b-button> + ${h.form(url('{}.download_results_rows'.format(route_prefix)), ref='downloadResultsRowsForm')} + ${h.csrf_token(request)} + ${h.end_form()} + % endif + + ## merge 2 objects + % if getattr(master, 'mergeable', False) and request.has_perm('{}.merge'.format(permission_prefix)): + + ${h.form(url('{}.merge'.format(route_prefix)), class_='control', **{'@submit': 'submitMergeForm'})} + ${h.csrf_token(request)} + <input type="hidden" + name="uuids" + :value="checkedRowUUIDs()" /> + <b-button type="is-primary" + native-type="submit" + icon-pack="fas" + icon-left="object-ungroup" + :disabled="mergeFormSubmitting || checkedRows.length != 2"> + {{ mergeFormButtonText }} + </b-button> + ${h.end_form()} + % endif + + ## enable / disable selected objects + % if getattr(master, 'supports_set_enabled_toggle', False) and master.has_perm('enable_disable_set'): + + ${h.form(url('{}.enable_set'.format(route_prefix)), class_='control', ref='enable_selected_form')} + ${h.csrf_token(request)} + ${h.hidden('uuids', v_model='selected_uuids')} + <b-button :disabled="enableSelectedDisabled" + @click="enableSelectedSubmit()"> + {{ enableSelectedText }} + </b-button> + ${h.end_form()} + + ${h.form(url('{}.disable_set'.format(route_prefix)), ref='disable_selected_form', class_='control')} + ${h.csrf_token(request)} + ${h.hidden('uuids', v_model='selected_uuids')} + <b-button :disabled="disableSelectedDisabled" + @click="disableSelectedSubmit()"> + {{ disableSelectedText }} + </b-button> + ${h.end_form()} + % endif + + ## delete selected objects + % if getattr(master, 'set_deletable', False) and master.has_perm('delete_set'): + ${h.form(url('{}.delete_set'.format(route_prefix)), ref='delete_selected_form', class_='control')} + ${h.csrf_token(request)} + ${h.hidden('uuids', v_model='selected_uuids')} + <b-button type="is-danger" + :disabled="deleteSelectedDisabled" + @click="deleteSelectedSubmit()" + icon-pack="fas" + icon-left="trash"> + {{ deleteSelectedText }} + </b-button> + ${h.end_form()} + % endif + + ## delete search results + % if getattr(master, 'bulk_deletable', False) and request.has_perm('{}.bulk_delete'.format(permission_prefix)): + ${h.form(url('{}.bulk_delete'.format(route_prefix)), ref='delete_results_form', class_='control')} + ${h.csrf_token(request)} + <b-button type="is-danger" + :disabled="deleteResultsDisabled" + :title="total ? null : 'There are no results to delete'" + @click="deleteResultsSubmit()" + icon-pack="fas" + icon-left="trash"> + {{ deleteResultsText }} + </b-button> + ${h.end_form()} + % endif + +</%def> + +<%def name="render_vue_template_grid()"> + ${grid.render_vue_template(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())} +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + % if getattr(master, 'bulk_deletable', False) and master.has_perm('bulk_delete'): + + ${grid.vue_component}Data.deleteResultsSubmitting = false + ${grid.vue_component}Data.deleteResultsText = "Delete Results" + + ${grid.vue_component}.computed.deleteResultsDisabled = function() { + if (this.deleteResultsSubmitting) { + return true + } + if (!this.total) { + return true + } + return false + } + + ${grid.vue_component}.methods.deleteResultsSubmit = function() { + // TODO: show "plural model title" here? + if (!confirm("You are about to delete " + this.total.toLocaleString('en') + " objects.\n\nAre you sure?")) { + return + } + + this.deleteResultsSubmitting = true + this.deleteResultsText = "Working, please wait..." + this.$refs.delete_results_form.submit() + } + + % endif + + </script> +</%def> diff --git a/tailbone/templates/themes/waterpark/master/view.mako b/tailbone/templates/themes/waterpark/master/view.mako new file mode 100644 index 00000000..99194469 --- /dev/null +++ b/tailbone/templates/themes/waterpark/master/view.mako @@ -0,0 +1,2 @@ +## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/master/view.mako" /> diff --git a/tailbone/templates/themes/waterpark/page.mako b/tailbone/templates/themes/waterpark/page.mako new file mode 100644 index 00000000..7e6851a7 --- /dev/null +++ b/tailbone/templates/themes/waterpark/page.mako @@ -0,0 +1,48 @@ +## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/page.mako" /> + +<%def name="render_vue_template_this_page()"> + <script type="text/x-template" id="this-page-template"> + <div style="height: 100%;"> + ## DEPRECATED; called for back-compat + ${self.render_this_page()} + </div> + </script> +</%def> + +## DEPRECATED; remains for back-compat +<%def name="render_this_page()"> + <div style="display: flex;"> + + <div class="this-page-content" style="flex-grow: 1;"> + ${self.page_content()} + </div> + + ## DEPRECATED; remains for back-compat + <ul id="context-menu"> + ${self.context_menu_items()} + </ul> + </div> +</%def> + +## DEPRECATED; remains for back-compat +<%def name="context_menu_items()"> + % if context_menu_list_items is not Undefined: + % for item in context_menu_list_items: + <li>${item}</li> + % endfor + % endif +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + ThisPageData.csrftoken = ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n} + + % if can_edit_help: + ThisPage.props.configureFieldsHelp = Boolean + % endif + + </script> +</%def> diff --git a/tailbone/views/master.py b/tailbone/views/master.py index ac74a070..a8365482 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -137,6 +137,7 @@ class MasterView(View): deleting = False executing = False cloning = False + configuring = False has_pk_fields = False has_image = False has_thumbnail = False @@ -350,6 +351,7 @@ class MasterView(View): return self.json_response(context) context = { + 'index_url': None, # nb. avoid title link since this *is* the index 'grid': grid, } @@ -380,7 +382,7 @@ class MasterView(View): grid contents etc. """ - def make_grid(self, factory=None, key=None, data=None, columns=None, **kwargs): + def make_grid(self, factory=None, key=None, data=None, columns=None, session=None, **kwargs): """ Creates a new grid instance """ @@ -389,7 +391,7 @@ class MasterView(View): if key is None: key = self.get_grid_key() if data is None: - data = self.get_data(session=kwargs.get('session')) + data = self.get_data(session=session) if columns is None: columns = self.get_grid_columns() @@ -407,7 +409,7 @@ class MasterView(View): """ if session is None: session = self.Session() - kwargs.setdefault('pageable', False) + kwargs.setdefault('paginated', False) grid = self.make_grid(session=session, **kwargs) return grid.make_visible_data() @@ -1701,7 +1703,7 @@ class MasterView(View): """ if session is None: session = self.Session() - kwargs.setdefault('pageable', False) + kwargs.setdefault('paginated', False) kwargs.setdefault('sortable', sort) grid = self.make_row_grid(session=session, **kwargs) return grid.make_visible_data() @@ -1879,6 +1881,7 @@ class MasterView(View): return self.redirect(self.get_action_url('view', instance)) form = self.make_form(instance) + form.save_label = "DELETE Forever" # TODO: Add better validation, ideally CSRF etc. if self.request.method == 'POST': @@ -5119,6 +5122,7 @@ class MasterView(View): """ Generic view for configuring some aspect of the software. """ + self.configuring = True app = self.get_rattail_app() if self.request.method == 'POST': if self.request.POST.get('remove_settings'): diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 020babc5..b6a4c0b9 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -543,7 +543,7 @@ class PersonView(MasterView): }, filterable=True, sortable=True, - pageable=True, + paginated=True, default_sortkey='end_time', default_sortdir='desc', component='transactions-grid', diff --git a/tests/util.py b/tests/util.py index 3aa04f5e..4277a7c3 100644 --- a/tests/util.py +++ b/tests/util.py @@ -24,7 +24,7 @@ class WebTestCase(DataTestCase): self.pyramid_config = testing.setUp(request=self.request, settings={ 'wutta_config': self.config, 'rattail_config': self.config, - 'mako.directories': ['tailbone:templates'], + 'mako.directories': ['tailbone:templates', 'wuttaweb:templates'], # 'pyramid_deform.template_search_path': 'wuttaweb:templates/deform', }) From 83586ef90fd3c8acae6eda85bd7d44a5992464f5 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 20 Aug 2024 15:06:09 -0500 Subject: [PATCH 1602/1681] =?UTF-8?q?bump:=20version=200.19.3=20=E2=86=92?= =?UTF-8?q?=200.20.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 11 +++++++++++ pyproject.toml | 4 ++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8017445..5840f59f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.20.0 (2024-08-20) + +### Feat + +- add new 'waterpark' theme, based on wuttaweb w/ vue2 + buefy +- refactor templates to simplify base/page/form structure + +### Fix + +- avoid deprecated reference to app db engine + ## v0.19.3 (2024-08-19) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 3e07abaa..150544ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.19.3" +version = "0.20.0" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] @@ -59,7 +59,7 @@ dependencies = [ "transaction", "waitress", "WebHelpers2", - "WuttaWeb>=0.10.2", + "WuttaWeb>=0.11.0", "zope.sqlalchemy>=1.5", ] From 21f90f3f32f76d509b75348388445cc1a6dccd85 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 20 Aug 2024 16:02:35 -0500 Subject: [PATCH 1603/1681] fix: fix default filter verbs logic for workorder status --- tailbone/views/workorders.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/tailbone/views/workorders.py b/tailbone/views/workorders.py index a53037bc..d8094e4b 100644 --- a/tailbone/views/workorders.py +++ b/tailbone/views/workorders.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -83,12 +83,12 @@ class WorkOrderView(MasterView): ] def __init__(self, request): - super(WorkOrderView, self).__init__(request) + super().__init__(request) app = self.get_rattail_app() self.workorder_handler = app.get_workorder_handler() def configure_grid(self, g): - super(WorkOrderView, self).configure_grid(g) + super().configure_grid(g) model = self.model # customer @@ -113,7 +113,7 @@ class WorkOrderView(MasterView): return 'warning' def configure_form(self, f): - super(WorkOrderView, self).configure_form(f) + super().configure_form(f) model = self.model SelectWidget = forms.widgets.JQuerySelectWidget @@ -208,7 +208,7 @@ class WorkOrderView(MasterView): return event.workorder def configure_row_grid(self, g): - super(WorkOrderView, self).configure_row_grid(g) + super().configure_row_grid(g) g.set_enum('type_code', self.enum.WORKORDER_EVENT) g.set_sort_defaults('occurred') @@ -353,7 +353,7 @@ class WorkOrderView(MasterView): class StatusFilter(grids.filters.AlchemyIntegerFilter): def __init__(self, *args, **kwargs): - super(StatusFilter, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) from drild import enum @@ -369,14 +369,14 @@ class StatusFilter(grids.filters.AlchemyIntegerFilter): @property def verb_labels(self): - labels = dict(super(StatusFilter, self).verb_labels) + labels = dict(super().verb_labels) labels['is_active'] = "Is Active" labels['not_active'] = "Is Not Active" return labels @property def valueless_verbs(self): - verbs = list(super(StatusFilter, self).valueless_verbs) + verbs = list(super().valueless_verbs) verbs.extend([ 'is_active', 'not_active', @@ -385,7 +385,11 @@ class StatusFilter(grids.filters.AlchemyIntegerFilter): @property def default_verbs(self): - verbs = list(super(StatusFilter, self).default_verbs) + verbs = super().default_verbs + if callable(verbs): + verbs = verbs() + + verbs = list(verbs or []) verbs.insert(0, 'is_active') verbs.insert(1, 'not_active') return verbs From 526c84dfa62cc88d2cd4ec28861e6caef70205e4 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 20 Aug 2024 16:05:52 -0500 Subject: [PATCH 1604/1681] =?UTF-8?q?bump:=20version=200.20.0=20=E2=86=92?= =?UTF-8?q?=200.20.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5840f59f..4e2b348a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.20.1 (2024-08-20) + +### Fix + +- fix default filter verbs logic for workorder status + ## v0.20.0 (2024-08-20) ### Feat diff --git a/pyproject.toml b/pyproject.toml index 150544ba..90ecd953 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.20.0" +version = "0.20.1" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From c8dc60cb68c72530b04df13fdc012a3ba382ba01 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 20 Aug 2024 16:37:58 -0500 Subject: [PATCH 1605/1681] fix: fix spacing for navbar logo/title in waterpark theme --- tailbone/templates/themes/waterpark/base.mako | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tailbone/templates/themes/waterpark/base.mako b/tailbone/templates/themes/waterpark/base.mako index 15184f6e..878090dc 100644 --- a/tailbone/templates/themes/waterpark/base.mako +++ b/tailbone/templates/themes/waterpark/base.mako @@ -50,9 +50,11 @@ <div class="navbar-brand"> <a class="navbar-item" href="${url('home')}" v-show="!menuSearchActive"> - ${base_meta.header_logo()} - <div id="global-header-title"> - ${base_meta.global_title()} + <div style="display: flex; align-items: center;"> + ${base_meta.header_logo()} + <div id="navbar-brand-title"> + ${base_meta.global_title()} + </div> </div> </a> <div v-show="menuSearchActive" From 07871188aa323331a4464c80021b4f25057dd54d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 20 Aug 2024 17:03:57 -0500 Subject: [PATCH 1606/1681] fix: fix master/index template rendering for waterpark theme --- tailbone/templates/themes/waterpark/master/index.mako | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tailbone/templates/themes/waterpark/master/index.mako b/tailbone/templates/themes/waterpark/master/index.mako index e3b5b42d..e6702599 100644 --- a/tailbone/templates/themes/waterpark/master/index.mako +++ b/tailbone/templates/themes/waterpark/master/index.mako @@ -254,6 +254,11 @@ </%def> +## DEPRECATED; remains for back-compat +<%def name="render_this_page()"> + ${self.page_content()} +</%def> + <%def name="render_vue_template_grid()"> ${grid.render_vue_template(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())} </%def> From 1def26a35bc36b399ff6783198a4687af206482e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 20 Aug 2024 19:09:56 -0500 Subject: [PATCH 1607/1681] feat: add "has output file templates" config option for master view this is a bit hacky, a quick copy/paste job from the equivalent feature for input file templates. i assume this will get cleaned up when moved to wuttaweb.. --- tailbone/templates/configure.mako | 107 +++++++++- .../templates/themes/waterpark/configure.mako | 76 +++++++ tailbone/views/master.py | 202 +++++++++++++++++- 3 files changed, 381 insertions(+), 4 deletions(-) diff --git a/tailbone/templates/configure.mako b/tailbone/templates/configure.mako index 272aadce..6d9c2261 100644 --- a/tailbone/templates/configure.mako +++ b/tailbone/templates/configure.mako @@ -143,6 +143,68 @@ </div> </%def> +<%def name="output_file_template_field(key)"> + <% tmpl = output_file_templates[key] %> + <b-field grouped> + + <b-field label="${tmpl['label']}"> + <b-select name="${tmpl['setting_mode']}" + v-model="outputFileTemplateSettings['${tmpl['setting_mode']}']" + @input="settingsNeedSaved = true"> + <option value="default">use default</option> + <option value="hosted">use uploaded file</option> + </b-select> + </b-field> + + <b-field label="File" + v-show="outputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted'" + :message="outputFileTemplateSettings['${tmpl['setting_file']}'] ? 'This file lives on disk at: ${output_file_option_dirs[tmpl['key']]}' : null"> + <b-select name="${tmpl['setting_file']}" + v-model="outputFileTemplateSettings['${tmpl['setting_file']}']" + @input="settingsNeedSaved = true"> + <option :value="null">-new-</option> + <option v-for="option in outputFileTemplateFileOptions['${tmpl['key']}']" + :key="option" + :value="option"> + {{ option }} + </option> + </b-select> + </b-field> + + <b-field label="Upload" + v-show="outputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted' && !outputFileTemplateSettings['${tmpl['setting_file']}']"> + + <b-field class="file is-primary" + :class="{'has-name': !!outputFileTemplateSettings['${tmpl['setting_file']}']}"> + <b-upload name="${tmpl['setting_file']}.upload" + v-model="outputFileTemplateUploads['${tmpl['key']}']" + class="file-label" + @input="settingsNeedSaved = true"> + <span class="file-cta"> + <b-icon class="file-icon" pack="fas" icon="upload"></b-icon> + <span class="file-label">Click to upload</span> + </span> + </b-upload> + <span v-if="outputFileTemplateUploads['${tmpl['key']}']" + class="file-name"> + {{ outputFileTemplateUploads['${tmpl['key']}'].name }} + </span> + </b-field> + + </b-field> + + </b-field> +</%def> + +<%def name="output_file_templates_section()"> + <h3 class="block is-size-3">Output File Templates</h3> + <div class="block" style="padding-left: 2rem;"> + % for key in output_file_templates: + ${self.output_file_template_field(key)} + % endfor + </div> +</%def> + <%def name="form_content()"></%def> <%def name="page_content()"> @@ -229,6 +291,7 @@ ThisPageData.settingsNeedSaved = false ThisPageData.undoChanges = false ThisPageData.savingSettings = false + ThisPageData.validators = [] ThisPage.methods.purgeSettingsInit = function() { this.purgeSettingsShowDialog = true @@ -260,7 +323,19 @@ } ThisPage.methods.saveSettings = function() { - let msg = this.validateSettings() + let msg + + // nb. this is the future + for (let validator of this.validators) { + msg = validator.call(this) + if (msg) { + alert(msg) + return + } + } + + // nb. legacy method + msg = this.validateSettings() if (msg) { alert(msg) return @@ -291,5 +366,35 @@ window.addEventListener('beforeunload', this.beforeWindowUnload) } + ############################## + ## output file templates + ############################## + + % if output_file_template_settings is not Undefined: + + ThisPageData.outputFileTemplateSettings = ${json.dumps(output_file_template_settings)|n} + ThisPageData.outputFileTemplateFileOptions = ${json.dumps(output_file_options)|n} + ThisPageData.outputFileTemplateUploads = { + % for key in output_file_templates: + '${key}': null, + % endfor + } + + ThisPage.methods.validateOutputFileTemplateSettings = function() { + % for tmpl in output_file_templates.values(): + if (this.outputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted') { + if (!this.outputFileTemplateSettings['${tmpl['setting_file']}']) { + if (!this.outputFileTemplateUploads['${tmpl['key']}']) { + return "You must provide a file to upload for the ${tmpl['label']} template." + } + } + } + % endfor + } + + ThisPageData.validators.push(ThisPage.methods.validateOutputFileTemplateSettings) + + % endif + </script> </%def> diff --git a/tailbone/templates/themes/waterpark/configure.mako b/tailbone/templates/themes/waterpark/configure.mako index 9ac9a5cd..7a3e5261 100644 --- a/tailbone/templates/themes/waterpark/configure.mako +++ b/tailbone/templates/themes/waterpark/configure.mako @@ -1,2 +1,78 @@ ## -*- coding: utf-8; -*- <%inherit file="wuttaweb:templates/configure.mako" /> +<%namespace name="tailbone_base" file="tailbone:templates/configure.mako" /> + +<%def name="input_file_templates_section()"> + ${tailbone_base.input_file_templates_section()} +</%def> + +<%def name="output_file_templates_section()"> + ${tailbone_base.output_file_templates_section()} +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + ############################## + ## input file templates + ############################## + + % if input_file_template_settings is not Undefined: + + ThisPageData.inputFileTemplateSettings = ${json.dumps(input_file_template_settings)|n} + ThisPageData.inputFileTemplateFileOptions = ${json.dumps(input_file_options)|n} + ThisPageData.inputFileTemplateUploads = { + % for key in input_file_templates: + '${key}': null, + % endfor + } + + ThisPage.methods.validateInputFileTemplateSettings = function() { + % for tmpl in input_file_templates.values(): + if (this.inputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted') { + if (!this.inputFileTemplateSettings['${tmpl['setting_file']}']) { + if (!this.inputFileTemplateUploads['${tmpl['key']}']) { + return "You must provide a file to upload for the ${tmpl['label']} template." + } + } + } + % endfor + } + + ThisPageData.validators.push(ThisPage.methods.validateInputFileTemplateSettings) + + % endif + + ############################## + ## output file templates + ############################## + + % if output_file_template_settings is not Undefined: + + ThisPageData.outputFileTemplateSettings = ${json.dumps(output_file_template_settings)|n} + ThisPageData.outputFileTemplateFileOptions = ${json.dumps(output_file_options)|n} + ThisPageData.outputFileTemplateUploads = { + % for key in output_file_templates: + '${key}': null, + % endfor + } + + ThisPage.methods.validateOutputFileTemplateSettings = function() { + % for tmpl in output_file_templates.values(): + if (this.outputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted') { + if (!this.outputFileTemplateSettings['${tmpl['setting_file']}']) { + if (!this.outputFileTemplateUploads['${tmpl['key']}']) { + return "You must provide a file to upload for the ${tmpl['label']} template." + } + } + } + % endfor + } + + ThisPageData.validators.push(ThisPage.methods.validateOutputFileTemplateSettings) + + % endif + + </script> +</%def> diff --git a/tailbone/views/master.py b/tailbone/views/master.py index a8365482..e4d6c3f6 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -117,6 +117,7 @@ class MasterView(View): supports_prev_next = False supports_import_batch_from_file = False has_input_file_templates = False + has_output_file_templates = False configurable = False # set to True to add "View *global* Objects" permission, and @@ -1820,6 +1821,26 @@ class MasterView(View): path = os.path.join(basedir, filespec) return self.file_response(path) + def download_output_file_template(self): + """ + View for downloading an output file template. + """ + key = self.request.GET['key'] + filespec = self.request.GET['file'] + + matches = [tmpl for tmpl in self.get_output_file_templates() + if tmpl['key'] == key] + if not matches: + raise self.notfound() + + template = matches[0] + templatesdir = os.path.join(self.rattail_config.datadir(), + 'templates', 'output_files', + self.get_route_prefix()) + basedir = os.path.join(templatesdir, template['key']) + path = os.path.join(basedir, filespec) + return self.file_response(path) + def edit(self): """ View for editing an existing model record. @@ -2848,6 +2869,12 @@ class MasterView(View): kwargs['input_file_templates'] = OrderedDict([(tmpl['key'], tmpl) for tmpl in templates]) + # add info for downloadable output file templates, if any + if self.has_output_file_templates: + templates = self.normalize_output_file_templates() + kwargs['output_file_templates'] = OrderedDict([(tmpl['key'], tmpl) + for tmpl in templates]) + return kwargs def get_input_file_templates(self): @@ -2922,6 +2949,81 @@ class MasterView(View): return templates + def get_output_file_templates(self): + return [] + + def normalize_output_file_templates(self, templates=None, + include_file_options=False): + if templates is None: + templates = self.get_output_file_templates() + + route_prefix = self.get_route_prefix() + + if include_file_options: + templatesdir = os.path.join(self.rattail_config.datadir(), + 'templates', 'output_files', + route_prefix) + + for template in templates: + + if 'config_section' not in template: + if hasattr(self, 'output_file_template_config_section'): + template['config_section'] = self.output_file_template_config_section + else: + template['config_section'] = route_prefix + section = template['config_section'] + + if 'config_prefix' not in template: + template['config_prefix'] = '{}.{}'.format( + self.output_file_template_config_prefix, + template['key']) + prefix = template['config_prefix'] + + for key in ('mode', 'file', 'url'): + + if 'option_{}'.format(key) not in template: + template['option_{}'.format(key)] = '{}.{}'.format(prefix, key) + + if 'setting_{}'.format(key) not in template: + template['setting_{}'.format(key)] = '{}.{}'.format( + section, + template['option_{}'.format(key)]) + + if key not in template: + value = self.rattail_config.get( + section, + template['option_{}'.format(key)]) + if value is not None: + template[key] = value + + template.setdefault('mode', 'default') + template.setdefault('file', None) + template.setdefault('url', template['default_url']) + + if include_file_options: + options = [] + basedir = os.path.join(templatesdir, template['key']) + if os.path.exists(basedir): + for name in sorted(os.listdir(basedir)): + if len(name) == 4 and name.isdigit(): + files = os.listdir(os.path.join(basedir, name)) + if len(files) == 1: + options.append(os.path.join(name, files[0])) + template['file_options'] = options + template['file_options_dir'] = basedir + + if template['mode'] == 'external': + template['effective_url'] = template['url'] + elif template['mode'] == 'hosted': + template['effective_url'] = self.request.route_url( + '{}.download_output_file_template'.format(route_prefix), + _query={'key': template['key'], + 'file': template['file']}) + else: + template['effective_url'] = template['default_url'] + + return templates + def template_kwargs_index(self, **kwargs): """ Method stub, so subclass can always invoke super() for it. @@ -2969,6 +3071,12 @@ class MasterView(View): items.append(tags.link_to(f"Download {template['label']} Template", template['effective_url'])) + if self.has_output_file_templates and self.has_perm('configure'): + templates = self.normalize_output_file_templates() + for template in templates: + items.append(tags.link_to(f"Download {template['label']} Template", + template['effective_url'])) + # if self.viewing: # # # TODO: either make this configurable, or just lose it. @@ -5204,6 +5312,39 @@ class MasterView(View): data[template['setting_file']] = os.path.join(numdir, info['filename']) + if self.has_output_file_templates: + templatesdir = os.path.join(self.rattail_config.datadir(), + 'templates', 'output_files', + self.get_route_prefix()) + + def get_next_filedir(basedir): + nextid = 1 + while True: + path = os.path.join(basedir, '{:04d}'.format(nextid)) + if not os.path.exists(path): + # this should fail if there happens to be a race + # condition and someone else got to this id first + os.mkdir(path) + return path + nextid += 1 + + for template in self.normalize_output_file_templates(): + key = '{}.upload'.format(template['setting_file']) + if key in uploads: + assert self.request.POST[template['setting_mode']] == 'hosted' + assert not self.request.POST[template['setting_file']] + info = uploads[key] + basedir = os.path.join(templatesdir, template['key']) + if not os.path.exists(basedir): + os.makedirs(basedir) + filedir = get_next_filedir(basedir) + filepath = os.path.join(filedir, info['filename']) + shutil.copyfile(info['filepath'], filepath) + shutil.rmtree(info['filedir']) + numdir = os.path.basename(filedir) + data[template['setting_file']] = os.path.join(numdir, + info['filename']) + def configure_get_simple_settings(self): """ If you have some "simple" settings, each of which basically @@ -5248,7 +5389,8 @@ class MasterView(View): simple['option']) def configure_get_context(self, simple_settings=None, - input_file_templates=True): + input_file_templates=True, + output_file_templates=True): """ Returns the full context dict, for rendering the configure page template. @@ -5305,10 +5447,27 @@ class MasterView(View): context['input_file_options'] = file_options context['input_file_option_dirs'] = file_option_dirs + # add settings for output file templates, if any + if output_file_templates and self.has_output_file_templates: + settings = {} + file_options = {} + file_option_dirs = {} + for template in self.normalize_output_file_templates( + include_file_options=True): + settings[template['setting_mode']] = template['mode'] + settings[template['setting_file']] = template['file'] + settings[template['setting_url']] = template['url'] + file_options[template['key']] = template['file_options'] + file_option_dirs[template['key']] = template['file_options_dir'] + context['output_file_template_settings'] = settings + context['output_file_options'] = file_options + context['output_file_option_dirs'] = file_option_dirs + return context def configure_gather_settings(self, data, simple_settings=None, - input_file_templates=True): + input_file_templates=True, + output_file_templates=True): settings = [] # maybe collect "simple" settings @@ -5354,10 +5513,30 @@ class MasterView(View): settings.append({'name': template['setting_url'], 'value': data.get(template['setting_url'])}) + # maybe also collect output file template settings + if output_file_templates and self.has_output_file_templates: + for template in self.normalize_output_file_templates(): + + # mode + settings.append({'name': template['setting_mode'], + 'value': data.get(template['setting_mode'])}) + + # file + value = data.get(template['setting_file']) + if value: + # nb. avoid saving if empty, so can remain "null" + settings.append({'name': template['setting_file'], + 'value': value}) + + # url + settings.append({'name': template['setting_url'], + 'value': data.get(template['setting_url'])}) + return settings def configure_remove_settings(self, simple_settings=None, - input_file_templates=True): + input_file_templates=True, + output_file_templates=True): app = self.get_rattail_app() model = self.model names = [] @@ -5376,6 +5555,14 @@ class MasterView(View): template['setting_url'], ]) + if output_file_templates and self.has_output_file_templates: + for template in self.normalize_output_file_templates(): + names.extend([ + template['setting_mode'], + template['setting_file'], + template['setting_url'], + ]) + if names: # nb. using thread-local session here; we do not use # self.Session b/c it may not point to Rattail @@ -5638,6 +5825,15 @@ class MasterView(View): route_name='{}.download_input_file_template'.format(route_prefix), permission='{}.create'.format(permission_prefix)) + # download output file template + if cls.has_output_file_templates and cls.configurable: + config.add_route(f'{route_prefix}.download_output_file_template', + f'{url_prefix}/download-output-file-template') + config.add_view(cls, attr='download_output_file_template', + route_name=f'{route_prefix}.download_output_file_template', + # TODO: this is different from input file, should change? + permission=f'{permission_prefix}.configure') + # view if cls.viewable: cls._defaults_view(config) From b6a8e508bf2629d528b1bba3e1b12d6da83b1abf Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 20 Aug 2024 22:16:01 -0500 Subject: [PATCH 1608/1681] fix: prefer wuttaweb config for "home redirect to login" feature --- tailbone/views/common.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/tailbone/views/common.py b/tailbone/views/common.py index 7e9ddb09..26ef2626 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -25,6 +25,7 @@ Various common views """ import os +import warnings from collections import OrderedDict from rattail.batch import consume_batch_id @@ -50,9 +51,21 @@ class CommonView(View): Home page view. """ app = self.get_rattail_app() + + # maybe auto-redirect anons to login if not self.request.user: - if self.rattail_config.getbool('tailbone', 'login_is_home', default=True): - raise self.redirect(self.request.route_url('login')) + redirect = self.config.get_bool('wuttaweb.home_redirect_to_login') + if redirect is None: + redirect = self.config.get_bool('tailbone.login_is_home') + if redirect is not None: + warnings.warn("tailbone.login_is_home setting is deprecated; " + "please set wuttaweb.home_redirect_to_login instead", + DeprecationWarning) + else: + # TODO: this is opposite of upstream default, should change + redirect = True + if redirect: + return self.redirect(self.request.route_url('login')) image_url = self.rattail_config.get( 'tailbone', 'main_image_url', From 2ffc067097a7c979c4935eee1da4d697e7774845 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 20 Aug 2024 22:27:11 -0500 Subject: [PATCH 1609/1681] fix: inherit from wuttaweb for appinfo/index template although for now, still must override for some link buttons --- tailbone/templates/appinfo/index.mako | 95 +------------------------- tailbone/templates/grids/complete.mako | 14 ++++ tailbone/views/settings.py | 10 +++ 3 files changed, 26 insertions(+), 93 deletions(-) diff --git a/tailbone/templates/appinfo/index.mako b/tailbone/templates/appinfo/index.mako index 75032c1f..faaea935 100644 --- a/tailbone/templates/appinfo/index.mako +++ b/tailbone/templates/appinfo/index.mako @@ -1,8 +1,7 @@ ## -*- coding: utf-8; -*- -<%inherit file="/master/index.mako" /> +<%inherit file="wuttaweb:templates/appinfo/index.mako" /> <%def name="page_content()"> - <div class="buttons"> <once-button type="is-primary" @@ -28,95 +27,5 @@ </div> - <${b}-collapse class="panel" open> - - <template #trigger="props"> - <div class="panel-heading" - style="cursor: pointer;" - role="button"> - - ## TODO: for some reason buefy will "reuse" the icon - ## element in such a way that its display does not - ## refresh. so to work around that, we use different - ## structure for the two icons, so buefy is forced to - ## re-draw - - <b-icon v-if="props.open" - pack="fas" - icon="angle-down"> - </b-icon> - - <span v-if="!props.open"> - <b-icon pack="fas" - icon="angle-right"> - </b-icon> - </span> - - <span>Configuration Files</span> - </div> - </template> - - <div class="panel-block"> - <div style="width: 100%;"> - <${b}-table :data="configFiles"> - - <${b}-table-column field="priority" - label="Priority" - v-slot="props"> - {{ props.row.priority }} - </${b}-table-column> - - <${b}-table-column field="path" - label="File Path" - v-slot="props"> - {{ props.row.path }} - </${b}-table-column> - - </${b}-table> - </div> - </div> - </${b}-collapse> - - <${b}-collapse class="panel" - :open="false"> - - <template #trigger="props"> - <div class="panel-heading" - style="cursor: pointer;" - role="button"> - - ## TODO: for some reason buefy will "reuse" the icon - ## element in such a way that its display does not - ## refresh. so to work around that, we use different - ## structure for the two icons, so buefy is forced to - ## re-draw - - <b-icon v-if="props.open" - pack="fas" - icon="angle-down"> - </b-icon> - - <span v-if="!props.open"> - <b-icon pack="fas" - icon="angle-right"> - </b-icon> - </span> - - <strong>Installed Packages</strong> - </div> - </template> - - <div class="panel-block"> - <div style="width: 100%;"> - ${grid.render_vue_tag()} - </div> - </div> - </${b}-collapse> -</%def> - -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> - ThisPageData.configFiles = ${json.dumps([dict(path=p, priority=i) for i, p in enumerate(request.rattail_config.prioritized_files, 1)])|n} - </script> + ${parent.page_content()} </%def> diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako index c136273b..5d406512 100644 --- a/tailbone/templates/grids/complete.mako +++ b/tailbone/templates/grids/complete.mako @@ -257,6 +257,9 @@ loading: false, ajaxDataUrl: ${json.dumps(getattr(grid, 'ajax_data_url', request.path_url))|n}, + ## nb. this tracks whether grid.fetchFirstData() happened + fetchedFirstData: false, + savingDefaults: false, data: ${grid.vue_component}CurrentData, @@ -519,6 +522,17 @@ ...this.getFilterParams()} }, + ## nb. this is meant to call for a grid which is hidden at + ## first, when it is first being shown to the user. and if + ## it was initialized with empty data set. + async fetchFirstData() { + if (this.fetchedFirstData) { + return + } + await this.loadAsyncData() + this.fetchedFirstData = true + }, + ## TODO: i noticed buefy docs show using `async` keyword here, ## so now i am too. knowing nothing at all of if/how this is ## supposed to improve anything. we shall see i guess diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index bda62ccc..4d99cb2a 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -71,10 +71,20 @@ class AppInfoView(MasterView): app.get_title()) def get_data(self, session=None): + """ """ + + # nb. init with empty data, only load it upon user request + if not self.request.GET.get('partial'): + return [] + + # TODO: pretty sure this is not cross-platform. probably some + # sort of pip methods belong on the app handler? or it should + # have a pip handler for all that? pip = os.path.join(sys.prefix, 'bin', 'pip') output = subprocess.check_output([pip, 'list', '--format=json']) data = json.loads(output.decode('utf_8').strip()) + # must avoid null values for sort to work right for pkg in data: pkg.setdefault('editable_project_location', '') From f7554602420eceb62d98fbde600c86aba0a944a3 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 20 Aug 2024 23:23:23 -0500 Subject: [PATCH 1610/1681] feat: inherit from wuttaweb for AppInfoView, appinfo/configure template --- tailbone/menus.py | 2 +- tailbone/templates/appinfo/configure.mako | 247 +----------------- .../themes/butterball/buefy-components.mako | 9 + tailbone/views/settings.py | 202 +++----------- 4 files changed, 48 insertions(+), 412 deletions(-) diff --git a/tailbone/menus.py b/tailbone/menus.py index abd0b58b..3ddee095 100644 --- a/tailbone/menus.py +++ b/tailbone/menus.py @@ -703,7 +703,7 @@ class TailboneMenuHandler(WuttaMenuHandler): }, {'type': 'sep'}, { - 'title': "App Details", + 'title': "App Info", 'route': 'appinfo', 'perm': 'appinfo.list', }, diff --git a/tailbone/templates/appinfo/configure.mako b/tailbone/templates/appinfo/configure.mako index 4794f00b..9d866cea 100644 --- a/tailbone/templates/appinfo/configure.mako +++ b/tailbone/templates/appinfo/configure.mako @@ -1,247 +1,2 @@ ## -*- coding: utf-8; -*- -<%inherit file="/configure.mako" /> - -<%def name="form_content()"> - - <h3 class="block is-size-3">Basics</h3> - <div class="block" style="padding-left: 2rem;"> - - <b-field grouped> - - <b-field label="App Title"> - <b-input name="rattail.app_title" - v-model="simpleSettings['rattail.app_title']" - @input="settingsNeedSaved = true"> - </b-input> - </b-field> - - <b-field label="Node Type"> - ## TODO: should be a dropdown, app handler defines choices - <b-input name="rattail.node_type" - v-model="simpleSettings['rattail.node_type']" - @input="settingsNeedSaved = true"> - </b-input> - </b-field> - - <b-field label="Node Title"> - <b-input name="rattail.node_title" - v-model="simpleSettings['rattail.node_title']" - @input="settingsNeedSaved = true"> - </b-input> - </b-field> - - </b-field> - - <b-field> - <b-checkbox name="rattail.production" - v-model="simpleSettings['rattail.production']" - native-value="true" - @input="settingsNeedSaved = true"> - Production Mode - </b-checkbox> - </b-field> - - <div class="level-left"> - <div class="level-item"> - <b-field> - <b-checkbox name="rattail.running_from_source" - v-model="simpleSettings['rattail.running_from_source']" - native-value="true" - @input="settingsNeedSaved = true"> - Running from Source - </b-checkbox> - </b-field> - </div> - <div class="level-item"> - <b-field label="Top-Level Package" horizontal - v-if="simpleSettings['rattail.running_from_source']"> - <b-input name="rattail.running_from_source.rootpkg" - v-model="simpleSettings['rattail.running_from_source.rootpkg']" - @input="settingsNeedSaved = true"> - </b-input> - </b-field> - </div> - </div> - - </div> - - <h3 class="block is-size-3">Display</h3> - <div class="block" style="padding-left: 2rem;"> - - <b-field grouped> - - <b-field label="Background Color"> - <b-input name="tailbone.background_color" - v-model="simpleSettings['tailbone.background_color']" - @input="settingsNeedSaved = true"> - </b-input> - </b-field> - - </b-field> - - </div> - - <h3 class="block is-size-3">Grids</h3> - <div class="block" style="padding-left: 2rem;"> - - <b-field grouped> - - <b-field label="Default Page Size"> - <b-input name="tailbone.grid.default_pagesize" - v-model="simpleSettings['tailbone.grid.default_pagesize']" - @input="settingsNeedSaved = true"> - </b-input> - </b-field> - - </b-field> - - </div> - - <h3 class="block is-size-3">Web Libraries</h3> - <div class="block" style="padding-left: 2rem;"> - - <${b}-table :data="weblibs"> - - <${b}-table-column field="title" - label="Name" - v-slot="props"> - {{ props.row.title }} - </${b}-table-column> - - <${b}-table-column field="configured_version" - label="Version" - v-slot="props"> - {{ props.row.configured_version || props.row.default_version }} - </${b}-table-column> - - <${b}-table-column field="configured_url" - label="URL Override" - v-slot="props"> - {{ props.row.configured_url }} - </${b}-table-column> - - <${b}-table-column field="live_url" - label="Effective (Live) URL" - v-slot="props"> - <span v-if="props.row.modified" - class="has-text-warning"> - save settings and refresh page to see new URL - </span> - <span v-if="!props.row.modified"> - {{ props.row.live_url }} - </span> - </${b}-table-column> - - <${b}-table-column field="actions" - label="Actions" - v-slot="props"> - <a href="#" - @click.prevent="editWebLibraryInit(props.row)"> - % if request.use_oruga: - <o-icon icon="edit" /> - % else: - <i class="fas fa-edit"></i> - % endif - Edit - </a> - </${b}-table-column> - - </${b}-table> - - % for weblib in weblibs: - ${h.hidden('wuttaweb.libver.{}'.format(weblib['key']), **{':value': "simpleSettings['wuttaweb.libver.{}']".format(weblib['key'])})} - ${h.hidden('wuttaweb.liburl.{}'.format(weblib['key']), **{':value': "simpleSettings['wuttaweb.liburl.{}']".format(weblib['key'])})} - % endfor - - <${b}-modal has-modal-card - % if request.use_oruga: - v-model:active="editWebLibraryShowDialog" - % else: - :active.sync="editWebLibraryShowDialog" - % endif - > - <div class="modal-card"> - - <header class="modal-card-head"> - <p class="modal-card-title">Web Library: {{ editWebLibraryRecord.title }}</p> - </header> - - <section class="modal-card-body"> - - <b-field grouped> - - <b-field label="Default Version"> - <b-input v-model="editWebLibraryRecord.default_version" - disabled> - </b-input> - </b-field> - - <b-field label="Override Version"> - <b-input v-model="editWebLibraryVersion"> - </b-input> - </b-field> - - </b-field> - - <b-field label="Override URL"> - <b-input v-model="editWebLibraryURL" - expanded /> - </b-field> - - <b-field label="Effective URL (as of last page load)"> - <b-input v-model="editWebLibraryRecord.live_url" - disabled - expanded /> - </b-field> - - </section> - - <footer class="modal-card-foot"> - <b-button type="is-primary" - @click="editWebLibrarySave()" - icon-pack="fas" - icon-left="save"> - Save - </b-button> - <b-button @click="editWebLibraryShowDialog = false"> - Cancel - </b-button> - </footer> - </div> - </${b}-modal> - - </div> -</%def> - -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> - - ThisPageData.weblibs = ${json.dumps(weblibs)|n} - - ThisPageData.editWebLibraryShowDialog = false - ThisPageData.editWebLibraryRecord = {} - ThisPageData.editWebLibraryVersion = null - ThisPageData.editWebLibraryURL = null - - ThisPage.methods.editWebLibraryInit = function(row) { - this.editWebLibraryRecord = row - this.editWebLibraryVersion = row.configured_version - this.editWebLibraryURL = row.configured_url - this.editWebLibraryShowDialog = true - } - - ThisPage.methods.editWebLibrarySave = function() { - this.editWebLibraryRecord.configured_version = this.editWebLibraryVersion - this.editWebLibraryRecord.configured_url = this.editWebLibraryURL - this.editWebLibraryRecord.modified = true - - this.simpleSettings[`wuttaweb.libver.${'$'}{this.editWebLibraryRecord.key}`] = this.editWebLibraryVersion - this.simpleSettings[`wuttaweb.liburl.${'$'}{this.editWebLibraryRecord.key}`] = this.editWebLibraryURL - - this.settingsNeedSaved = true - this.editWebLibraryShowDialog = false - } - - </script> -</%def> +<%inherit file="wuttaweb:templates/appinfo/configure.mako" /> diff --git a/tailbone/templates/themes/butterball/buefy-components.mako b/tailbone/templates/themes/butterball/buefy-components.mako index 51a0deb9..3a2cd798 100644 --- a/tailbone/templates/themes/butterball/buefy-components.mako +++ b/tailbone/templates/themes/butterball/buefy-components.mako @@ -666,6 +666,7 @@ <%def name="make_b_tooltip_component()"> <script type="text/x-template" id="b-tooltip-template"> <o-tooltip :label="label" + :position="orugaPosition" :multiline="multilined"> <slot /> </o-tooltip> @@ -676,6 +677,14 @@ props: { label: String, multilined: Boolean, + position: String, + }, + computed: { + orugaPosition() { + if (this.position) { + return this.position.replace(/^is-/, '') + } + }, }, } </script> diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index 4d99cb2a..099a77e1 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -25,11 +25,7 @@ Settings Views """ import json -import os import re -import subprocess -import sys -from collections import OrderedDict import colander @@ -37,201 +33,77 @@ from rattail.db.model import Setting from rattail.settings import Setting as AppSetting from rattail.util import import_module_path -from tailbone import forms +from tailbone import forms, grids from tailbone.db import Session from tailbone.views import MasterView, View from wuttaweb.util import get_libver, get_liburl +from wuttaweb.views.settings import AppInfoView as WuttaAppInfoView -class AppInfoView(MasterView): - """ - Master view for the overall app, to show/edit config etc. - """ - route_prefix = 'appinfo' - model_key = 'UNUSED' - model_title = "UNUSED" - model_title_plural = "App Details" - creatable = False - viewable = False - editable = False - deletable = False - filterable = False - pageable = False - configurable = True +class AppInfoView(WuttaAppInfoView): + """ """ + Session = Session + weblib_config_prefix = 'tailbone' - grid_columns = [ - 'name', - 'version', - 'editable_project_location', - ] - - def get_index_title(self): - app = self.get_rattail_app() - return "{} for {}".format(self.get_model_title_plural(), - app.get_title()) - - def get_data(self, session=None): + # TODO: for now we override to get tailbone searchable grid + def make_grid(self, **kwargs): """ """ - - # nb. init with empty data, only load it upon user request - if not self.request.GET.get('partial'): - return [] - - # TODO: pretty sure this is not cross-platform. probably some - # sort of pip methods belong on the app handler? or it should - # have a pip handler for all that? - pip = os.path.join(sys.prefix, 'bin', 'pip') - output = subprocess.check_output([pip, 'list', '--format=json']) - data = json.loads(output.decode('utf_8').strip()) - - # must avoid null values for sort to work right - for pkg in data: - pkg.setdefault('editable_project_location', '') - - return data + return grids.Grid(self.request, **kwargs) def configure_grid(self, g): + """ """ super().configure_grid(g) - # sort on frontend - g.sort_on_backend = False - g.sort_multiple = False - g.set_sort_defaults('name') - # name g.set_searchable('name') # editable_project_location g.set_searchable('editable_project_location') - def template_kwargs_index(self, **kwargs): - kwargs = super().template_kwargs_index(**kwargs) - kwargs['configure_button_title'] = "Configure App" - return kwargs - - def get_weblibs(self): - """ """ - return OrderedDict([ - ('vue', "Vue"), - ('vue_resource', "vue-resource"), - ('buefy', "Buefy"), - ('buefy.css', "Buefy CSS"), - ('fontawesome', "FontAwesome"), - ('bb_vue', "(BB) vue"), - ('bb_oruga', "(BB) @oruga-ui/oruga-next"), - ('bb_oruga_bulma', "(BB) @oruga-ui/theme-bulma (JS)"), - ('bb_oruga_bulma_css', "(BB) @oruga-ui/theme-bulma (CSS)"), - ('bb_fontawesome_svg_core', "(BB) @fortawesome/fontawesome-svg-core"), - ('bb_free_solid_svg_icons', "(BB) @fortawesome/free-solid-svg-icons"), - ('bb_vue_fontawesome', "(BB) @fortawesome/vue-fontawesome"), - ]) - def configure_get_context(self, **kwargs): """ """ context = super().configure_get_context(**kwargs) simple_settings = context['simple_settings'] - weblibs = self.get_weblibs() + weblibs = context['weblibs'] - for key in weblibs: - title = weblibs[key] - weblibs[key] = { - 'key': key, - 'title': title, - - # nb. these values are exactly as configured, and are - # used for editing the settings - 'configured_version': get_libver(self.request, key, - prefix='tailbone', - configured_only=True), - 'configured_url': get_liburl(self.request, key, - prefix='tailbone', - configured_only=True), - - # these are for informational purposes only - 'default_version': get_libver(self.request, key, - prefix='tailbone', - default_only=True), - 'live_url': get_liburl(self.request, key, - prefix='tailbone'), - } + for weblib in weblibs: + key = weblib['key'] # TODO: this is only needed to migrate legacy settings to - # use the newer wutaweb setting names + # use the newer wuttaweb setting names url = simple_settings[f'wuttaweb.liburl.{key}'] - if not url and weblibs[key]['configured_url']: - simple_settings[f'wuttaweb.liburl.{key}'] = weblibs[key]['configured_url'] + if not url and weblib['configured_url']: + simple_settings[f'wuttaweb.liburl.{key}'] = weblib['configured_url'] - context['weblibs'] = list(weblibs.values()) return context def configure_get_simple_settings(self): """ """ - simple_settings = [ + simple_settings = super().configure_get_simple_settings() - # basics - {'section': 'rattail', - 'option': 'app_title'}, - {'section': 'rattail', - 'option': 'node_type'}, - {'section': 'rattail', - 'option': 'node_title'}, - {'section': 'rattail', - 'option': 'production', - 'type': bool}, - {'section': 'rattail', - 'option': 'running_from_source', - 'type': bool}, - {'section': 'rattail', - 'option': 'running_from_source.rootpkg'}, + # TODO: the update home page redirect setting is off by + # default for wuttaweb, but on for tailbone + for setting in simple_settings: + if setting['name'] == 'wuttaweb.home_redirect_to_login': + value = self.config.get_bool('wuttaweb.home_redirect_to_login') + if value is None: + value = self.config.get_bool('tailbone.login_is_home', default=True) + setting['default'] = value + break - # display - {'section': 'tailbone', - 'option': 'background_color'}, + # nb. these are no longer used (deprecated), but we keep + # them defined here so the tool auto-deletes them - # grids - {'section': 'tailbone', - 'option': 'grid.default_pagesize', - # TODO: seems like should enforce this, but validation is - # not setup yet - # 'type': int - }, + simple_settings.extend([ + {'name': 'tailbone.buefy_version'}, + {'name': 'tailbone.vue_version'}, + ]) - # nb. these are no longer used (deprecated), but we keep - # them defined here so the tool auto-deletes them - {'section': 'tailbone', - 'option': 'buefy_version'}, - {'section': 'tailbone', - 'option': 'vue_version'}, - - ] - - def getval(key): - return self.config.get(f'tailbone.{key}') - - weblibs = self.get_weblibs() - for key, title in weblibs.items(): - - simple_settings.append({ - 'section': 'wuttaweb', - 'option': f"libver.{key}", - 'default': getval(f"libver.{key}"), - }) - simple_settings.append({ - 'section': 'wuttaweb', - 'option': f"liburl.{key}", - 'default': getval(f"liburl.{key}"), - }) - - # nb. these are no longer used (deprecated), but we keep - # them defined here so the tool auto-deletes them - simple_settings.append({ - 'section': 'tailbone', - 'option': f"libver.{key}", - }) - simple_settings.append({ - 'section': 'tailbone', - 'option': f"liburl.{key}", - }) + for key in self.get_weblibs(): + simple_settings.extend([ + {'name': f'tailbone.libver.{key}'}, + {'name': f'tailbone.liburl.{key}'}, + ]) return simple_settings From 71abbe06da0d08c4a285fbca2b583c570f3def4c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 21 Aug 2024 00:07:03 -0500 Subject: [PATCH 1611/1681] feat: inherit from wuttaweb templates for home, login pages --- tailbone/templates/base_meta.mako | 13 +----- tailbone/templates/home.mako | 30 +----------- tailbone/templates/login.mako | 77 ++----------------------------- tailbone/views/common.py | 12 +++-- 4 files changed, 18 insertions(+), 114 deletions(-) diff --git a/tailbone/templates/base_meta.mako b/tailbone/templates/base_meta.mako index 00cfdfe9..b6376448 100644 --- a/tailbone/templates/base_meta.mako +++ b/tailbone/templates/base_meta.mako @@ -1,10 +1,7 @@ ## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/base_meta.mako" /> -<%def name="app_title()">${rattail_app.get_node_title()}</%def> - -<%def name="global_title()">${"[STAGE] " if not request.rattail_config.production() else ''}${self.app_title()}</%def> - -<%def name="extra_styles()"></%def> +<%def name="app_title()">${app.get_node_title()}</%def> <%def name="favicon()"> <link rel="icon" type="image/x-icon" href="${request.rattail_config.get('tailbone', 'favicon_url', default=request.static_url('tailbone:static/img/rattail.ico'))}" /> @@ -13,9 +10,3 @@ <%def name="header_logo()"> ${h.image(request.rattail_config.get('tailbone', 'header_image_url', default=request.static_url('tailbone:static/img/rattail.ico')), "Header Logo", style="height: 49px;")} </%def> - -<%def name="footer()"> - <p class="has-text-centered"> - powered by ${h.link_to("Rattail", url('about'))} - </p> -</%def> diff --git a/tailbone/templates/home.mako b/tailbone/templates/home.mako index e4f7d072..54e44d57 100644 --- a/tailbone/templates/home.mako +++ b/tailbone/templates/home.mako @@ -1,33 +1,7 @@ ## -*- coding: utf-8; -*- -<%inherit file="/page.mako" /> -<%namespace name="base_meta" file="/base_meta.mako" /> - -<%def name="title()">Home</%def> - -<%def name="extra_styles()"> - ${parent.extra_styles()} - <style type="text/css"> - .logo { - text-align: center; - } - .logo img { - margin: 3em auto; - max-height: 350px; - max-width: 800px; - } - </style> -</%def> +<%inherit file="wuttaweb:templates/home.mako" /> +## DEPRECATED; remains for back-compat <%def name="render_this_page()"> ${self.page_content()} </%def> - -<%def name="page_content()"> - <div class="logo"> - ${h.image(image_url, "{} logo".format(capture(base_meta.app_title)))} - <h1>Welcome to ${base_meta.app_title()}</h1> - </div> -</%def> - - -${parent.body()} diff --git a/tailbone/templates/login.mako b/tailbone/templates/login.mako index 3eb46403..d2ea7828 100644 --- a/tailbone/templates/login.mako +++ b/tailbone/templates/login.mako @@ -1,84 +1,17 @@ ## -*- coding: utf-8; -*- -<%inherit file="/form.mako" /> -<%namespace name="base_meta" file="/base_meta.mako" /> - -<%def name="title()">Login</%def> +<%inherit file="wuttaweb:templates/auth/login.mako" /> +## TODO: this will not be needed with wuttaform <%def name="extra_styles()"> ${parent.extra_styles()} - <style type="text/css"> - .logo img { - display: block; - margin: 3rem auto; - max-height: 350px; - max-width: 800px; - } - - /* must force a particular label with, in order to make sure */ - /* the username and password inputs are the same size */ - .field.is-horizontal .field-label .label { - text-align: left; - width: 6rem; - } - - .buttons { + <style> + .card-content .buttons { justify-content: right; } </style> </%def> -<%def name="logo()"> - ${h.image(image_url, "{} logo".format(capture(base_meta.app_title)))} -</%def> - -<%def name="login_form()"> - <div class="form"> - ${form.render_deform(form_kwargs={'data-ajax': 'false'})|n} - </div> -</%def> - +## DEPRECATED; remains for back-compat <%def name="render_this_page()"> ${self.page_content()} </%def> - -<%def name="page_content()"> - <div class="logo"> - ${self.logo()} - </div> - - <div class="columns is-centered"> - <div class="column is-narrow"> - <div class="card"> - <div class="card-content"> - <tailbone-form></tailbone-form> - </div> - </div> - </div> - </div> -</%def> - -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> - - ${form.vue_component}Data.usernameInput = null - - ${form.vue_component}.mounted = function() { - this.$refs.username.focus() - this.usernameInput = this.$refs.username.$el.querySelector('input') - this.usernameInput.addEventListener('keydown', this.usernameKeydown) - } - - ${form.vue_component}.beforeDestroy = function() { - this.usernameInput.removeEventListener('keydown', this.usernameKeydown) - } - - ${form.vue_component}.methods.usernameKeydown = function(event) { - if (event.which == 13) { - event.preventDefault() - this.$refs.password.focus() - } - } - - </script> -</%def> diff --git a/tailbone/views/common.py b/tailbone/views/common.py index 26ef2626..f4d98c05 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -67,9 +67,15 @@ class CommonView(View): if redirect: return self.redirect(self.request.route_url('login')) - image_url = self.rattail_config.get( - 'tailbone', 'main_image_url', - default=self.request.static_url('tailbone:static/img/home_logo.png')) + image_url = self.config.get('wuttaweb.logo_url') + if not image_url: + image_url = self.config.get('tailbone.main_image_url') + if image_url: + warnings.warn("tailbone.main_image_url setting is deprecated; " + "please set wuttaweb.logo_url instead", + DeprecationWarning) + else: + image_url = self.request.static_url('tailbone:static/img/home_logo.png') context = { 'image_url': image_url, From 1d00fe994a069e366d67558d4f5f3709e103e991 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 21 Aug 2024 09:44:32 -0500 Subject: [PATCH 1612/1681] fix: use wuttaweb to get/render csrf token --- tailbone/helpers.py | 12 ++++----- tailbone/templates/formposter.mako | 2 +- tailbone/templates/forms/deform.mako | 2 +- tailbone/templates/ordering/view.mako | 2 +- tailbone/templates/ordering/worksheet.mako | 2 +- tailbone/templates/page.mako | 2 +- tailbone/templates/themes/waterpark/page.mako | 2 +- tailbone/util.py | 27 +++++++++---------- 8 files changed, 24 insertions(+), 27 deletions(-) diff --git a/tailbone/helpers.py b/tailbone/helpers.py index 23988423..50b38c30 100644 --- a/tailbone/helpers.py +++ b/tailbone/helpers.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,6 +24,9 @@ Template Context Helpers """ +# start off with all from wuttaweb +from wuttaweb.helpers import * + import os import datetime from decimal import Decimal @@ -33,12 +36,7 @@ from rattail.time import localtime, make_utc from rattail.util import pretty_quantity, pretty_hours, hours_as_decimal from rattail.db.util import maxlen -from webhelpers2.html import * -from webhelpers2.html.tags import * - -from wuttaweb.util import get_liburl -from tailbone.util import (csrf_token, get_csrf_token, - pretty_datetime, raw_datetime, +from tailbone.util import (pretty_datetime, raw_datetime, render_markdown, route_exists) diff --git a/tailbone/templates/formposter.mako b/tailbone/templates/formposter.mako index ab9c720d..d566a467 100644 --- a/tailbone/templates/formposter.mako +++ b/tailbone/templates/formposter.mako @@ -39,7 +39,7 @@ simplePOST(action, params, success, failure) { - let csrftoken = ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n} + let csrftoken = ${json.dumps(h.get_csrf_token(request))|n} let headers = { '${csrf_header_name}': csrftoken, diff --git a/tailbone/templates/forms/deform.mako b/tailbone/templates/forms/deform.mako index 26c8b4ee..ea35ab17 100644 --- a/tailbone/templates/forms/deform.mako +++ b/tailbone/templates/forms/deform.mako @@ -180,7 +180,7 @@ let ${form.vue_component}Data = { ## TODO: should find a better way to handle CSRF token - csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}, + csrftoken: ${json.dumps(h.get_csrf_token(request))|n}, % if can_edit_help: fieldLabels: ${json.dumps(field_labels)|n}, diff --git a/tailbone/templates/ordering/view.mako b/tailbone/templates/ordering/view.mako index 584559c1..34a6085f 100644 --- a/tailbone/templates/ordering/view.mako +++ b/tailbone/templates/ordering/view.mako @@ -204,7 +204,7 @@ saving: false, ## TODO: should find a better way to handle CSRF token - csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}, + csrftoken: ${json.dumps(h.get_csrf_token(request))|n}, } }, computed: { diff --git a/tailbone/templates/ordering/worksheet.mako b/tailbone/templates/ordering/worksheet.mako index cb98c48f..eb2077e7 100644 --- a/tailbone/templates/ordering/worksheet.mako +++ b/tailbone/templates/ordering/worksheet.mako @@ -250,7 +250,7 @@ submitting: false, ## TODO: should find a better way to handle CSRF token - csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}, + csrftoken: ${json.dumps(h.get_csrf_token(request))|n}, } }, methods: { diff --git a/tailbone/templates/page.mako b/tailbone/templates/page.mako index 54b47278..43b0a266 100644 --- a/tailbone/templates/page.mako +++ b/tailbone/templates/page.mako @@ -38,7 +38,7 @@ const ThisPageData = { ## TODO: should find a better way to handle CSRF token - csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}, + csrftoken: ${json.dumps(h.get_csrf_token(request))|n}, } </script> diff --git a/tailbone/templates/themes/waterpark/page.mako b/tailbone/templates/themes/waterpark/page.mako index 7e6851a7..66ce47dc 100644 --- a/tailbone/templates/themes/waterpark/page.mako +++ b/tailbone/templates/themes/waterpark/page.mako @@ -38,7 +38,7 @@ ${parent.modify_vue_vars()} <script> - ThisPageData.csrftoken = ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n} + ThisPageData.csrftoken = ${json.dumps(h.get_csrf_token(request))|n} % if can_edit_help: ThisPage.props.configureFieldsHelp = Boolean diff --git a/tailbone/util.py b/tailbone/util.py index 594fd69b..71aa35e3 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -41,7 +41,9 @@ from webhelpers2.html import HTML, tags from wuttaweb.util import (get_form_data as wutta_get_form_data, get_libver as wutta_get_libver, - get_liburl as wutta_get_liburl) + get_liburl as wutta_get_liburl, + get_csrf_token as wutta_get_csrf_token, + render_csrf_token) log = logging.getLogger(__name__) @@ -59,22 +61,19 @@ class SortColumn(object): def get_csrf_token(request): - """ - Convenience function to retrieve the effective CSRF token for the given - request. - """ - token = request.session.get_csrf_token() - if token is None: - token = request.session.new_csrf_token() - return token + """ """ + warnings.warn("tailbone.util.get_csrf_token() is deprecated; " + "please use wuttaweb.util.get_csrf_token() instead", + DeprecationWarning, stacklevel=2) + return wutta_get_csrf_token(request) def csrf_token(request, name='_csrf'): - """ - Convenience function. Returns CSRF hidden tag inside hidden DIV. - """ - token = get_csrf_token(request) - return HTML.tag("div", tags.hidden(name, value=token), style="display:none;") + """ """ + warnings.warn("tailbone.util.csrf_token() is deprecated; " + "please use wuttaweb.util.render_csrf_token() instead", + DeprecationWarning, stacklevel=2) + return render_csrf_token(request, name=name) def get_form_data(request): From ffa724ef374ec59e90b51a2b14a83ee703bea5a0 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 21 Aug 2024 15:50:55 -0500 Subject: [PATCH 1613/1681] fix: move "searchable columns" grid feature to wuttaweb --- tailbone/grids/core.py | 19 +++++++------------ tailbone/templates/grids/complete.mako | 6 ++---- tests/grids/test_core.py | 6 ++++++ 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index eada1041..92452b31 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -200,7 +200,6 @@ class Grid(WuttaGrid): filterable=False, filters={}, use_byte_string_filters=False, - searchable={}, checkboxes=False, checked=None, check_handler=None, @@ -254,6 +253,12 @@ class Grid(WuttaGrid): DeprecationWarning, stacklevel=2) kwargs.setdefault('page', kwargs.pop('default_page')) + if 'searchable' in kwargs: + warnings.warn("searchable param is deprecated for Grid(); " + "please use searchable_columns param instead", + DeprecationWarning, stacklevel=2) + kwargs.setdefault('searchable_columns', kwargs.pop('searchable')) + # TODO: this should not be needed once all templates correctly # reference grid.vue_component etc. kwargs.setdefault('vue_tagname', 'tailbone-grid') @@ -287,8 +292,6 @@ class Grid(WuttaGrid): self.use_byte_string_filters = use_byte_string_filters self.filters = self.make_filters(filters) - self.searchable = searchable or {} - self.checkboxes = checkboxes self.checked = checked if self.checked is None: @@ -481,15 +484,6 @@ class Grid(WuttaGrid): kwargs['label'] = self.labels[key] self.filters[key] = self.make_filter(key, *args, **kwargs) - def set_searchable(self, key, searchable=True): - if searchable: - self.searchable[key] = True - else: - self.searchable.pop(key, None) - - def is_searchable(self, key): - return self.searchable.get(key, False) - def remove_filter(self, key): self.filters.pop(key, None) @@ -1587,6 +1581,7 @@ class Grid(WuttaGrid): 'field': name, 'label': self.get_label(name), 'sortable': self.is_sortable(name), + 'searchable': self.is_searchable(name), 'visible': name not in self.invisible, }) return columns diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako index 5d406512..54ad0527 100644 --- a/tailbone/templates/grids/complete.mako +++ b/tailbone/templates/grids/complete.mako @@ -136,10 +136,8 @@ <${b}-table-column field="${column['field']}" label="${column['label']}" v-slot="props" - :sortable="${json.dumps(column.get('sortable', False))}" - % if hasattr(grid, 'is_searchable') and grid.is_searchable(column['field']): - searchable - % endif + :sortable="${json.dumps(column.get('sortable', False))|n}" + :searchable="${json.dumps(column.get('searchable', False))|n}" cell-class="c_${column['field']}" :visible="${json.dumps(column.get('visible', True))}"> % if hasattr(grid, 'raw_renderers') and column['field'] in grid.raw_renderers: diff --git a/tests/grids/test_core.py b/tests/grids/test_core.py index c621627a..5169e599 100644 --- a/tests/grids/test_core.py +++ b/tests/grids/test_core.py @@ -57,6 +57,12 @@ class TestGrid(WebTestCase): grid = self.make_grid(default_page=42) self.assertEqual(grid.page, 42) + # searchable + grid = self.make_grid() + self.assertEqual(grid.searchable_columns, set()) + grid = self.make_grid(searchable={'foo': True}) + self.assertEqual(grid.searchable_columns, {'foo'}) + def test_vue_tagname(self): # default From e52a83751e8b95c72917277214ff504a0ede13b6 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 21 Aug 2024 20:16:03 -0500 Subject: [PATCH 1614/1681] feat: move "most" filtering logic for grid class to wuttaweb we still define all filters, and the "most important" grid methods for filtering --- tailbone/grids/core.py | 295 +++++++++-------------------------------- 1 file changed, 62 insertions(+), 233 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 92452b31..969be50a 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -196,9 +196,6 @@ class Grid(WuttaGrid): raw_renderers={}, extra_row_class=None, url='#', - joiners={}, - filterable=False, - filters={}, use_byte_string_filters=False, checkboxes=False, checked=None, @@ -263,6 +260,8 @@ class Grid(WuttaGrid): # reference grid.vue_component etc. kwargs.setdefault('vue_tagname', 'tailbone-grid') + self.use_byte_string_filters = use_byte_string_filters + kwargs['key'] = key kwargs['data'] = data super().__init__(request, **kwargs) @@ -286,11 +285,6 @@ class Grid(WuttaGrid): self.invisible = invisible or [] self.extra_row_class = extra_row_class self.url = url - self.joiners = joiners or {} - - self.filterable = filterable - self.use_byte_string_filters = use_byte_string_filters - self.filters = self.make_filters(filters) self.checkboxes = checkboxes self.checked = checked @@ -446,10 +440,14 @@ class Grid(WuttaGrid): self.remove(oldfield) def set_joiner(self, key, joiner): + """ """ if joiner is None: - self.joiners.pop(key, None) + warnings.warn("specifying None is deprecated for Grid.set_joiner(); " + "please use Grid.remove_joiner() instead", + DeprecationWarning, stacklevel=2) + self.remove_joiner(key) else: - self.joiners[key] = joiner + super().set_joiner(key, joiner) def set_sorter(self, key, *args, **kwargs): """ """ @@ -477,33 +475,27 @@ class Grid(WuttaGrid): self.sorters[key] = self.make_sorter(*args, **kwargs) def set_filter(self, key, *args, **kwargs): - if len(args) == 1 and args[0] is None: - self.remove_filter(key) + """ """ + + if len(args) == 1: + if args[0] is None: + warnings.warn("specifying None is deprecated for Grid.set_filter(); " + "please use Grid.remove_filter() instead", + DeprecationWarning, stacklevel=2) + self.remove_filter(key) + else: + super().set_filter(key, args[0], **kwargs) + + elif len(args) == 0: + super().set_filter(key, **kwargs) + else: - if 'label' not in kwargs and key in self.labels: - kwargs['label'] = self.labels[key] + warnings.warn("multiple args are deprecated for Grid.set_filter(); " + "please refactor your code accordingly", + DeprecationWarning, stacklevel=2) + kwargs.setdefault('label', self.get_label(key)) self.filters[key] = self.make_filter(key, *args, **kwargs) - def remove_filter(self, key): - self.filters.pop(key, None) - - def set_label(self, key, label, column_only=False): - """ - Set/override the label for a column. - - This overrides - :meth:`~wuttaweb:wuttaweb.grids.base.Grid.set_label()` to add - the following params: - - :param column_only: Boolean indicating whether the label - should be applied *only* to the column header (if - ``True``), vs. applying also to the filter (if ``False``). - """ - super().set_label(key, label) - - if not column_only and key in self.filters: - self.filters[key].label = label - def set_click_handler(self, key, handler): if handler: self.click_handlers[key] = handler @@ -702,6 +694,14 @@ class Grid(WuttaGrid): def actions_column_format(self, column_number, row_number, item): return HTML.td(self.render_actions(item, row_number), class_='actions') + # TODO: upstream should handle this.. + def make_backend_filters(self, filters=None): + """ """ + final = self.get_default_filters() + if filters: + final.update(filters) + return final + def get_default_filters(self): """ Returns the default set of filters provided by the grid. @@ -726,16 +726,6 @@ class Grid(WuttaGrid): filters[prop.key] = self.make_filter(prop.key, column) return filters - def make_filters(self, filters=None): - """ - Returns an initial set of filters which will be available to the grid. - The grid itself may or may not provide some default filters, and the - ``filters`` kwarg may contain additions and/or overrides. - """ - if filters: - return filters - return self.get_default_filters() - def make_filter(self, key, column, **kwargs): """ Make a filter suitable for use with the given column. @@ -888,8 +878,8 @@ class Grid(WuttaGrid): # If request has filter settings, grab those, then grab sort/pager # settings from request or session. - elif self.filterable and self.request_has_settings('filter'): - self.update_filter_settings(settings, 'request') + elif self.request_has_settings('filter'): + self.update_filter_settings(settings, src='request') if self.request_has_settings('sort'): self.update_sort_settings(settings, src='request') else: @@ -901,7 +891,7 @@ class Grid(WuttaGrid): # settings from request or session. elif self.request_has_settings('sort'): self.update_sort_settings(settings, src='request') - self.update_filter_settings(settings, 'session') + self.update_filter_settings(settings, src='session') self.update_page_settings(settings) # NOTE: These next two are functionally equivalent, but are kept @@ -911,12 +901,12 @@ class Grid(WuttaGrid): # grab those, then grab filter/sort settings from session. elif self.request_has_settings('page'): self.update_page_settings(settings) - self.update_filter_settings(settings, 'session') + self.update_filter_settings(settings, src='session') self.update_sort_settings(settings, src='session') # If request has no settings, grab all from session. elif self.session_has_settings(): - self.update_filter_settings(settings, 'session') + self.update_filter_settings(settings, src='session') self.update_sort_settings(settings, src='session') self.update_page_settings(settings) @@ -1056,18 +1046,11 @@ class Grid(WuttaGrid): merge('page', int) def request_has_settings(self, type_): - """ - Determine if the current request (GET query string) contains any - filter/sort settings for the grid. - """ - if type_ == 'filter': - for filtr in self.iter_filters(): - if filtr.key in self.request.GET: - return True - if 'filter' in self.request.GET: # user may be applying empty filters - return True + """ """ + if super().request_has_settings(type_): + return True - elif type_ == 'sort': + if type_ == 'sort': # TODO: remove this eventually, but some links in the wild # may still include these params, so leave it for now @@ -1075,14 +1058,6 @@ class Grid(WuttaGrid): if key in self.request.GET: return True - if 'sort1key' in self.request.GET: - return True - - elif type_ == 'page': - for key in ['pagesize', 'page']: - if key in self.request.GET: - return True - return False def session_has_settings(self): @@ -1098,72 +1073,6 @@ class Grid(WuttaGrid): return any([key.startswith(f'{prefix}.filter') for key in self.request.session]) - def update_filter_settings(self, settings, source): - """ - Updates a settings dictionary according to filter settings data found - in either the GET query string, or session storage. - - :param settings: Dictionary of initial settings, which is to be updated. - - :param source: String identifying the source to consult for settings - data. Must be one of: ``('request', 'session')``. - """ - if not self.filterable: - return - - for filtr in self.iter_filters(): - prefix = 'filter.{}'.format(filtr.key) - - if source == 'request': - # consider filter active if query string contains a value for it - settings['{}.active'.format(prefix)] = filtr.key in self.request.GET - settings['{}.verb'.format(prefix)] = self.get_setting( - settings, f'{filtr.key}.verb', src='request', default='') - settings['{}.value'.format(prefix)] = self.get_setting( - settings, filtr.key, src='request', default='') - - else: # source = session - settings['{}.active'.format(prefix)] = self.get_setting( - settings, f'{prefix}.active', src='session', - normalize=lambda v: str(v).lower() == 'true', default=False) - settings['{}.verb'.format(prefix)] = self.get_setting( - settings, f'{prefix}.verb', src='session', default='') - settings['{}.value'.format(prefix)] = self.get_setting( - settings, f'{prefix}.value', src='session', default='') - - def update_page_settings(self, settings): - """ - Updates a settings dictionary according to pager settings data found in - either the GET query string, or session storage. - - Note that due to how the actual pager functions, the effective settings - will often come from *both* the request and session. This is so that - e.g. the page size will remain constant (coming from the session) while - the user jumps between pages (which only provides the single setting). - - :param settings: Dictionary of initial settings, which is to be updated. - """ - if not self.paginated: - return - - pagesize = self.request.GET.get('pagesize') - if pagesize is not None: - if pagesize.isdigit(): - settings['pagesize'] = int(pagesize) - else: - pagesize = self.request.session.get('grid.{}.pagesize'.format(self.key)) - if pagesize is not None: - settings['pagesize'] = pagesize - - page = self.request.GET.get('page') - if page is not None: - if page.isdigit(): - settings['page'] = int(page) - else: - page = self.request.session.get('grid.{}.page'.format(self.key)) - if page is not None: - settings['page'] = int(page) - def persist_settings(self, settings, dest='session'): """ """ if dest not in ('defaults', 'session'): @@ -1251,89 +1160,12 @@ class Grid(WuttaGrid): return data - def sort_data(self, data, sorters=None): - """ """ - if sorters is None: - sorters = self.active_sorters - if not sorters: - return data - - # nb. when data is a query, we want to apply sorters in the - # requested order, so the final query has order_by() in the - # correct "as-is" sequence. however when data is a list we - # must do the opposite, applying in the reverse order, so the - # final list has the most "important" sort(s) applied last. - if not isinstance(data, orm.Query): - sorters = reversed(sorters) - - for sorter in sorters: - sortkey = sorter['key'] - sortdir = sorter['dir'] - - # cannot sort unless we have a sorter callable - sortfunc = self.sorters.get(sortkey) - if not sortfunc: - return data - - # join appropriate model if needed - if sortkey in self.joiners and sortkey not in self.joined: - data = self.joiners[sortkey](data) - self.joined.add(sortkey) - - # invoke the sorter - data = sortfunc(data, sortdir) - - return data - - def paginate_data(self, data): - """ - Paginate the given data set according to current settings, and return - the result. - """ - # we of course assume our current page is correct, at first - pager = self.make_pager(data) - - # if pager has detected that our current page is outside the valid - # range, we must re-orient ourself around the "new" (valid) page - if pager.page != self.page: - self.page = pager.page - self.request.session['grid.{}.page'.format(self.key)] = self.page - pager = self.make_pager(data) - - return pager - - def make_pager(self, data): - - # TODO: this seems hacky..normally we expect `data` to be a - # query of course, but in some cases it may be a list instead. - # if so then we can't use ORM pager - if isinstance(data, list): - import paginate - return paginate.Page(data, - items_per_page=self.pagesize, - page=self.page) - - return SqlalchemyOrmPage(data, - items_per_page=self.pagesize, - page=self.page, - url_maker=URLMaker(self.request)) - def make_visible_data(self): - """ - Apply various settings to the raw data set, to produce a final data - set. This will page / sort / filter as necessary, according to the - grid's defaults and the current request etc. - """ - self.joined = set() - data = self.data - if self.filterable: - data = self.filter_data(data) - if self.sortable: - data = self.sort_data(data) - if self.paginated: - self.pager = self.paginate_data(data) - data = self.pager - return data + """ """ + warnings.warn("grid.make_visible_data() method is deprecated; " + "please use grid.get_visible_data() instead", + DeprecationWarning, stacklevel=2) + return self.get_visible_data() def render_vue_tag(self, master=None, **kwargs): """ """ @@ -1356,7 +1188,7 @@ class Grid(WuttaGrid): includes the context menu items and grid tools. """ if 'grid_columns' not in kwargs: - kwargs['grid_columns'] = self.get_table_columns() + kwargs['grid_columns'] = self.get_vue_columns() if 'grid_data' not in kwargs: kwargs['grid_data'] = self.get_table_data() @@ -1379,6 +1211,7 @@ class Grid(WuttaGrid): return HTML.literal(html) def render_buefy(self, **kwargs): + """ """ warnings.warn("Grid.render_buefy() is deprecated; " "please use Grid.render_complete() instead", DeprecationWarning, stacklevel=2) @@ -1568,23 +1401,19 @@ class Grid(WuttaGrid): def get_vue_columns(self): """ """ - return self.get_table_columns() + columns = super().get_vue_columns() + + for column in columns: + column['visible'] = column['field'] not in self.invisible + + return columns def get_table_columns(self): - """ - Return a list of dicts representing all grid columns. Meant - for use with the client-side JS table. - """ - columns = [] - for name in self.columns: - columns.append({ - 'field': name, - 'label': self.get_label(name), - 'sortable': self.is_sortable(name), - 'searchable': self.is_searchable(name), - 'visible': name not in self.invisible, - }) - return columns + """ """ + warnings.warn("grid.get_table_columns() method is deprecated; " + "please use grid.get_vue_columns() instead", + DeprecationWarning, stacklevel=2) + return self.get_vue_columns() def get_uuid_for_row(self, rowobj): @@ -1610,7 +1439,7 @@ class Grid(WuttaGrid): return self._table_data # filter / sort / paginate to get "visible" data - raw_data = self.make_visible_data() + raw_data = self.get_visible_data() data = [] status_map = {} checked = [] From b8131c83933f87eef5a05a08e919791233040b58 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 22 Aug 2024 13:49:57 -0500 Subject: [PATCH 1615/1681] fix: change grid reset-view param name to match wuttaweb --- tailbone/grids/core.py | 2 +- tailbone/templates/grids/complete.mako | 2 +- tailbone/views/master.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 969be50a..e58315d3 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -873,7 +873,7 @@ class Grid(WuttaGrid): # If request contains instruction to reset to default filters, then we # can skip the rest of the request/session checks. - if self.request.GET.get('reset-to-default-filters') == 'true': + if self.request.GET.get('reset-view'): pass # If request has filter settings, grab those, then grab sort/pager diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako index 54ad0527..49758275 100644 --- a/tailbone/templates/grids/complete.mako +++ b/tailbone/templates/grids/complete.mako @@ -683,7 +683,7 @@ this.loading = true // use current url proper, plus reset param - let url = '?reset-to-default-filters=true' + let url = '?reset-view=true' // add current hash, to preserve that in redirect if (location.hash) { diff --git a/tailbone/views/master.py b/tailbone/views/master.py index e4d6c3f6..c53fd8b4 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -335,7 +335,7 @@ class MasterView(View): # If user just refreshed the page with a reset instruction, issue a # redirect in order to clear out the query string. - if self.request.GET.get('reset-to-default-filters') == 'true': + if self.request.GET.get('reset-view'): kw = {'_query': None} hash_ = self.request.GET.get('hash') if hash_: @@ -1184,7 +1184,7 @@ class MasterView(View): # If user just refreshed the page with a reset instruction, issue a # redirect in order to clear out the query string. - if self.request.GET.get('reset-to-default-filters') == 'true': + if self.request.GET.get('reset-view'): kw = {'_query': None} hash_ = self.request.GET.get('hash') if hash_: From 8d5427e92f9fe272ad1ceb4a6a1b5b0c3cd4ef27 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 22 Aug 2024 14:53:59 -0500 Subject: [PATCH 1616/1681] =?UTF-8?q?bump:=20version=200.20.1=20=E2=86=92?= =?UTF-8?q?=200.21.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 19 +++++++++++++++++++ pyproject.toml | 6 +++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e2b348a..c54d5642 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.21.0 (2024-08-22) + +### Feat + +- move "most" filtering logic for grid class to wuttaweb +- inherit from wuttaweb templates for home, login pages +- inherit from wuttaweb for AppInfoView, appinfo/configure template +- add "has output file templates" config option for master view + +### Fix + +- change grid reset-view param name to match wuttaweb +- move "searchable columns" grid feature to wuttaweb +- use wuttaweb to get/render csrf token +- inherit from wuttaweb for appinfo/index template +- prefer wuttaweb config for "home redirect to login" feature +- fix master/index template rendering for waterpark theme +- fix spacing for navbar logo/title in waterpark theme + ## v0.20.1 (2024-08-20) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 90ecd953..613d3272 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.20.1" +version = "0.21.0" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] @@ -53,13 +53,13 @@ dependencies = [ "pyramid_mako", "pyramid_retry", "pyramid_tm", - "rattail[db,bouncer]>=0.18.1", + "rattail[db,bouncer]>=0.18.4", "sa-filters", "simplejson", "transaction", "waitress", "WebHelpers2", - "WuttaWeb>=0.11.0", + "WuttaWeb>=0.12.0", "zope.sqlalchemy>=1.5", ] From f292850d05c7f83334cd2f4156264112e01a4377 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 22 Aug 2024 14:57:39 -0500 Subject: [PATCH 1617/1681] test: fix some tests --- tests/grids/test_core.py | 2 +- tests/views/wutta/test_people.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/grids/test_core.py b/tests/grids/test_core.py index 5169e599..4d143c85 100644 --- a/tests/grids/test_core.py +++ b/tests/grids/test_core.py @@ -135,7 +135,7 @@ class TestGrid(WebTestCase): def test_set_label(self): model = self.app.model - grid = self.make_grid(model_class=model.Setting) + grid = self.make_grid(model_class=model.Setting, filterable=True) self.assertEqual(grid.labels, {}) # basic diff --git a/tests/views/wutta/test_people.py b/tests/views/wutta/test_people.py index f178a64f..31aeb501 100644 --- a/tests/views/wutta/test_people.py +++ b/tests/views/wutta/test_people.py @@ -38,7 +38,7 @@ class TestPersonView(WebTestCase): def test_configure_form(self): model = self.app.model - barney = model.User(username='barney') + barney = model.Person(display_name="Barney Rubble") self.session.add(barney) self.session.commit() view = self.make_view() From 7b40c527c860e95be4dd74e09b2344b672110d98 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 22 Aug 2024 15:14:11 -0500 Subject: [PATCH 1618/1681] fix: misc. bugfixes per recent changes --- tailbone/grids/core.py | 23 +++++++++-------------- tailbone/views/email.py | 11 +++++------ 2 files changed, 14 insertions(+), 20 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index e58315d3..754868bc 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -260,6 +260,9 @@ class Grid(WuttaGrid): # reference grid.vue_component etc. kwargs.setdefault('vue_tagname', 'tailbone-grid') + # nb. these must be set before super init, as they are + # referenced when constructing filters + self.assume_local_times = assume_local_times self.use_byte_string_filters = use_byte_string_filters kwargs['key'] = key @@ -279,7 +282,6 @@ class Grid(WuttaGrid): self.width = width self.enums = enums or {} - self.assume_local_times = assume_local_times self.renderers = self.make_default_renderers(self.renderers) self.raw_renderers = raw_renderers or {} self.invisible = invisible or [] @@ -476,25 +478,18 @@ class Grid(WuttaGrid): def set_filter(self, key, *args, **kwargs): """ """ - if len(args) == 1: if args[0] is None: warnings.warn("specifying None is deprecated for Grid.set_filter(); " "please use Grid.remove_filter() instead", DeprecationWarning, stacklevel=2) self.remove_filter(key) - else: - super().set_filter(key, args[0], **kwargs) + return - elif len(args) == 0: - super().set_filter(key, **kwargs) - - else: - warnings.warn("multiple args are deprecated for Grid.set_filter(); " - "please refactor your code accordingly", - DeprecationWarning, stacklevel=2) - kwargs.setdefault('label', self.get_label(key)) - self.filters[key] = self.make_filter(key, *args, **kwargs) + # TODO: our make_filter() signature differs from upstream, + # so must call it explicitly instead of delegating to super + kwargs.setdefault('label', self.get_label(key)) + self.filters[key] = self.make_filter(key, *args, **kwargs) def set_click_handler(self, key, handler): if handler: @@ -1230,7 +1225,7 @@ class Grid(WuttaGrid): context['data_prop'] = data_prop context['empty_labels'] = empty_labels if 'grid_columns' not in context: - context['grid_columns'] = self.get_table_columns() + context['grid_columns'] = self.get_vue_columns() context.setdefault('paginated', False) if context['paginated']: context.setdefault('per_page', 20) diff --git a/tailbone/views/email.py b/tailbone/views/email.py index a99e8553..98bd4295 100644 --- a/tailbone/views/email.py +++ b/tailbone/views/email.py @@ -116,11 +116,12 @@ class EmailSettingView(MasterView): return data def configure_grid(self, g): - g.sorters['key'] = g.make_simple_sorter('key', foldcase=True) - g.sorters['prefix'] = g.make_simple_sorter('prefix', foldcase=True) - g.sorters['subject'] = g.make_simple_sorter('subject', foldcase=True) - g.sorters['enabled'] = g.make_simple_sorter('enabled') + super().configure_grid(g) + + g.sort_on_backend = False + g.sort_multiple = False g.set_sort_defaults('key') + g.set_type('enabled', 'boolean') g.set_link('key') g.set_link('subject') @@ -130,11 +131,9 @@ class EmailSettingView(MasterView): # to g.set_renderer('to', self.render_to_short) - g.sorters['to'] = g.make_simple_sorter('to', foldcase=True) # hidden if self.has_perm('configure'): - g.sorters['hidden'] = g.make_simple_sorter('hidden') g.set_type('hidden', 'boolean') else: g.remove('hidden') From 7d6f75bb05bbbe2345e0f220f9c7a536c8f119e3 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 22 Aug 2024 15:33:28 -0500 Subject: [PATCH 1619/1681] =?UTF-8?q?bump:=20version=200.21.0=20=E2=86=92?= =?UTF-8?q?=200.21.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c54d5642..3bcbc6ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.21.1 (2024-08-22) + +### Fix + +- misc. bugfixes per recent changes + ## v0.21.0 (2024-08-22) ### Feat diff --git a/pyproject.toml b/pyproject.toml index 613d3272..2db880ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.21.0" +version = "0.21.1" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From c176d978701648904c1cd00725cf9057fafbe26e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 22 Aug 2024 15:54:15 -0500 Subject: [PATCH 1620/1681] fix: avoid deprecated `component` form kwarg --- tailbone/views/batch/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 5dd7b548..8ee3a37d 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -861,7 +861,7 @@ class BatchMasterView(MasterView): if not schema: schema = colander.Schema() - kwargs['component'] = 'execute-form' + kwargs['vue_tagname'] = 'execute-form' form = forms.Form(schema=schema, request=self.request, defaults=defaults, **kwargs) self.configure_execute_form(form) return form From 4c3e3aeb6a70ae45eb16a90cc53c1af336e6d083 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 22 Aug 2024 17:09:58 -0500 Subject: [PATCH 1621/1681] fix: various fixes for waterpark theme --- tailbone/templates/base.mako | 2 +- tailbone/templates/themes/waterpark/base.mako | 83 +++++++++++++++++++ tailbone/templates/themes/waterpark/form.mako | 8 ++ 3 files changed, 92 insertions(+), 1 deletion(-) diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index eb950011..c01b3b37 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -668,7 +668,7 @@ text="Edit This"> </once-button> % endif - % if getattr(master, 'cloneable', False) and master.has_perm('clone'): + % if getattr(master, 'cloneable', False) and not master.cloning and master.has_perm('clone'): <once-button tag="a" href="${master.get_action_url('clone', instance)}" icon-left="object-ungroup" text="Clone This"> diff --git a/tailbone/templates/themes/waterpark/base.mako b/tailbone/templates/themes/waterpark/base.mako index 878090dc..520e18ce 100644 --- a/tailbone/templates/themes/waterpark/base.mako +++ b/tailbone/templates/themes/waterpark/base.mako @@ -7,6 +7,7 @@ <%def name="base_styles()"> ${parent.base_styles()} + ${h.stylesheet_link(request.static_url('tailbone:static/css/diffs.css') + '?ver={}'.format(tailbone.__version__))} <style> .filters .filter-fieldname .field, @@ -171,6 +172,88 @@ % endif </%def> +<%def name="render_crud_header_buttons()"> + % if master: + % if master.viewing: + % if instance_editable and master.has_perm('edit'): + <wutta-button once + tag="a" href="${master.get_action_url('edit', instance)}" + icon-left="edit" + label="Edit This" /> + % endif + % if getattr(master, 'cloneable', False) and not master.cloning and master.has_perm('clone'): + <wutta-button once + tag="a" href="${master.get_action_url('clone', instance)}" + icon-left="object-ungroup" + label="Clone This" /> + % endif + % if instance_deletable and master.has_perm('delete'): + <wutta-button once type="is-danger" + tag="a" href="${master.get_action_url('delete', instance)}" + icon-left="trash" + label="Delete This" /> + % endif + % elif master.editing: + % if master.has_perm('view'): + <wutta-button once + tag="a" href="${master.get_action_url('view', instance)}" + icon-left="eye" + label="View This" /> + % endif + % if instance_deletable and master.has_perm('delete'): + <wutta-button once type="is-danger" + tag="a" href="${master.get_action_url('delete', instance)}" + icon-left="trash" + label="Delete This" /> + % endif + % elif master.deleting: + % if master.has_perm('view'): + <wutta-button once + tag="a" href="${master.get_action_url('view', instance)}" + icon-left="eye" + label="View This" /> + % endif + % if instance_editable and master.has_perm('edit'): + <wutta-button once + tag="a" href="${master.get_action_url('edit', instance)}" + icon-left="edit" + label="Edit This" /> + % endif + % endif + % endif +</%def> + +<%def name="render_prevnext_header_buttons()"> + % if show_prev_next is not Undefined and show_prev_next: + % if prev_url: + <wutta-button once + tag="a" href="${prev_url}" + icon-left="arrow-left" + label="Older" /> + % else: + <b-button tag="a" href="#" + disabled + icon-pack="fas" + icon-left="arrow-left"> + Older + </b-button> + % endif + % if next_url: + <wutta-button once + tag="a" href="${next_url}" + icon-left="arrow-right" + label="Newer" /> + % else: + <b-button tag="a" href="#" + disabled + icon-pack="fas" + icon-left="arrow-right"> + Newer + </b-button> + % endif + % endif +</%def> + <%def name="render_this_page_component()"> <this-page @change-content-title="changeContentTitle" % if can_edit_help: diff --git a/tailbone/templates/themes/waterpark/form.mako b/tailbone/templates/themes/waterpark/form.mako index cf1ddb8a..f88d6821 100644 --- a/tailbone/templates/themes/waterpark/form.mako +++ b/tailbone/templates/themes/waterpark/form.mako @@ -1,2 +1,10 @@ ## -*- coding: utf-8; -*- <%inherit file="wuttaweb:templates/form.mako" /> + +<%def name="render_vue_template_form()"> + % if form is not Undefined: + ${form.render_vue_template(buttons=capture(self.render_form_buttons))} + % endif +</%def> + +<%def name="render_form_buttons()"></%def> From 29531c83c4b785e2ef7b5c4006bd4c86c7b5f045 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 22 Aug 2024 19:21:48 -0500 Subject: [PATCH 1622/1681] fix: some fixes for wutta people view --- tailbone/grids/core.py | 35 +++++++++++++++++++++++++--------- tailbone/views/master.py | 6 ++++-- tailbone/views/wutta/people.py | 12 +++++++++++- 3 files changed, 41 insertions(+), 12 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 754868bc..afd6e11b 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -24,9 +24,10 @@ Core Grid Classes """ -from urllib.parse import urlencode -import warnings +import inspect import logging +import warnings +from urllib.parse import urlencode import sqlalchemy as sa from sqlalchemy import orm @@ -858,9 +859,13 @@ class Grid(WuttaGrid): settings['page'] = self.page if self.filterable: for filtr in self.iter_filters(): - settings['filter.{}.active'.format(filtr.key)] = filtr.default_active - settings['filter.{}.verb'.format(filtr.key)] = filtr.default_verb - settings['filter.{}.value'.format(filtr.key)] = filtr.default_value + defaults = self.filter_defaults.get(filtr.key, {}) + settings[f'filter.{filtr.key}.active'] = defaults.get('active', + filtr.default_active) + settings[f'filter.{filtr.key}.verb'] = defaults.get('verb', + filtr.default_verb) + settings[f'filter.{filtr.key}.value'] = defaults.get('value', + filtr.default_value) # If user has default settings on file, apply those first. if self.user_has_defaults(): @@ -1239,7 +1244,7 @@ class Grid(WuttaGrid): view = None for action in self.actions: if action.key == 'view': - return action.click_handler + return getattr(action, 'click_handler', None) def set_filters_sequence(self, filters, only=False): """ @@ -1475,10 +1480,22 @@ class Grid(WuttaGrid): # leverage configured rendering logic where applicable; # otherwise use "raw" data value as string + value = self.obtain_value(rowobj, name) if self.renderers and name in self.renderers: - value = self.renderers[name](rowobj, name) - else: - value = self.obtain_value(rowobj, name) + renderer = self.renderers[name] + + # TODO: legacy renderer callables require 2 args, + # but wuttaweb callables require 3 args + sig = inspect.signature(renderer) + required = [param for param in sig.parameters.values() + if param.default == param.empty] + + if len(required) == 2: + # TODO: legacy renderer + value = renderer(rowobj, name) + else: # the future + value = renderer(rowobj, name, value) + if value is None: value = "" diff --git a/tailbone/views/master.py b/tailbone/views/master.py index c53fd8b4..1028ff27 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -612,7 +612,9 @@ class MasterView(View): # delete action if self.rows_deletable and self.has_perm('delete_row'): - actions.append(self.make_action('delete', icon='trash', url=self.row_delete_action_url)) + actions.append(self.make_action('delete', icon='trash', + url=self.row_delete_action_url, + link_class='has-text-danger')) defaults['delete_speedbump'] = self.rows_deletable_speedbump defaults['actions'] = actions @@ -3322,7 +3324,7 @@ class MasterView(View): url=self.default_clone_url) def make_grid_action_delete(self): - kwargs = {} + kwargs = {'link_class': 'has-text-danger'} if self.delete_confirm == 'simple': kwargs['click_handler'] = 'deleteObject' return self.make_action('delete', icon='trash', url=self.default_delete_url, **kwargs) diff --git a/tailbone/views/wutta/people.py b/tailbone/views/wutta/people.py index 968eaf3d..bd96bd4d 100644 --- a/tailbone/views/wutta/people.py +++ b/tailbone/views/wutta/people.py @@ -32,6 +32,7 @@ from wuttaweb.views import people as wutta from tailbone.views import people as tailbone from tailbone.db import Session from rattail.db.model import Person +from tailbone.grids import Grid class PersonView(wutta.PersonView): @@ -44,7 +45,6 @@ class PersonView(wutta.PersonView): """ model_class = Person Session = Session - sort_defaults = 'display_name' labels = { 'display_name': "Full Name", @@ -59,6 +59,11 @@ class PersonView(wutta.PersonView): 'merge_requested', ] + filter_defaults = { + 'display_name': {'active': True, 'verb': 'contains'}, + } + sort_defaults = 'display_name' + form_fields = [ 'first_name', 'middle_name', @@ -74,6 +79,11 @@ class PersonView(wutta.PersonView): # CRUD methods ############################## + # TODO: must use older grid for now, to render filters correctly + def make_grid(self, **kwargs): + """ """ + return Grid(self.request, **kwargs) + def configure_grid(self, g): """ """ super().configure_grid(g) From cea3e4b927eab7114dd0548d6216df8c33dd37a4 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 22 Aug 2024 19:40:21 -0500 Subject: [PATCH 1623/1681] fix: add basic wutta view for users just proving concepts still at this point..nothing reliable --- tailbone/templates/base.mako | 6 +++- tailbone/views/users.py | 6 +++- tailbone/views/wutta/users.py | 57 +++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 tailbone/views/wutta/users.py diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index c01b3b37..86b1ba1d 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -642,7 +642,11 @@ % if request.is_root or not request.user.prevent_password_change: ${h.link_to("Change Password", url('change_password'), class_='navbar-item')} % endif - ${h.link_to("Edit Preferences", url('my.preferences'), class_='navbar-item')} + % try: + ## nb. does not exist yet for wuttaweb + ${h.link_to("Edit Preferences", url('my.preferences'), class_='navbar-item')} + % except: + % endtry ${h.link_to("Logout", url('logout'), class_='navbar-item')} </div> </div> diff --git a/tailbone/views/users.py b/tailbone/views/users.py index 9b533efe..dfed0a11 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -801,4 +801,8 @@ def defaults(config, **kwargs): def includeme(config): - defaults(config) + wutta_config = config.registry.settings['wutta_config'] + if wutta_config.get_bool('tailbone.use_wutta_views', default=False, usedb=False): + config.include('tailbone.views.wutta.users') + else: + defaults(config) diff --git a/tailbone/views/wutta/users.py b/tailbone/views/wutta/users.py new file mode 100644 index 00000000..3c3f8d52 --- /dev/null +++ b/tailbone/views/wutta/users.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +User Views +""" + +from wuttaweb.views import users as wutta +from tailbone.views import users as tailbone +from tailbone.db import Session +from rattail.db.model import User +from tailbone.grids import Grid + + +class UserView(wutta.UserView): + """ + This is the first attempt at blending newer Wutta views with + legacy Tailbone config. + + So, this is a Wutta-based view but it should be included by a + Tailbone app configurator. + """ + model_class = User + Session = Session + + # TODO: must use older grid for now, to render filters correctly + def make_grid(self, **kwargs): + """ """ + return Grid(self.request, **kwargs) + + +def defaults(config, **kwargs): + kwargs.setdefault('UserView', UserView) + tailbone.defaults(config, **kwargs) + + +def includeme(config): + defaults(config) From 37f760959d277c2fe158c500c65684fb5af49102 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 22 Aug 2024 19:58:27 -0500 Subject: [PATCH 1624/1681] fix: merge filters into main grid template to better match wuttaweb --- tailbone/grids/core.py | 22 --------- tailbone/templates/grids/complete.mako | 66 ++++++++++++++++++++++++- tailbone/templates/grids/filters.mako | 67 -------------------------- 3 files changed, 64 insertions(+), 91 deletions(-) delete mode 100644 tailbone/templates/grids/filters.mako diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index afd6e11b..12e45aec 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -1318,28 +1318,6 @@ class Grid(WuttaGrid): return data - def render_filters(self, template='/grids/filters.mako', **kwargs): - """ - Render the filters to a Unicode string, using the specified template. - Additional kwargs are passed along as context to the template. - """ - # Provide default data to filters form, so renderer can do some of the - # work for us. - data = {} - for filtr in self.iter_active_filters(): - data['{}.active'.format(filtr.key)] = filtr.active - data['{}.verb'.format(filtr.key)] = filtr.verb - data[filtr.key] = filtr.value - - form = gridfilters.GridFiltersForm(self.filters, - request=self.request, - defaults=data) - - kwargs['request'] = self.request - kwargs['grid'] = self - kwargs['form'] = form - return render(template, kwargs) - def render_actions(self, row, i): # pragma: no cover """ """ warnings.warn("grid.render_actions() is deprecated!", diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako index 49758275..f5d1da95 100644 --- a/tailbone/templates/grids/complete.mako +++ b/tailbone/templates/grids/complete.mako @@ -10,8 +10,70 @@ <div style="display: flex; flex-direction: column; justify-content: end;"> <div class="filters"> % if getattr(grid, 'filterable', False): - ## TODO: stop using |n filter - ${grid.render_filters(allow_save_defaults=allow_save_defaults)|n} + <form method="GET" @submit.prevent="applyFilters()"> + + <div style="display: flex; flex-direction: column; gap: 0.5rem;"> + <grid-filter v-for="key in filtersSequence" + :key="key" + :filter="filters[key]" + ref="gridFilters"> + </grid-filter> + </div> + + <div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;"> + + <b-button type="is-primary" + native-type="submit" + icon-pack="fas" + icon-left="check"> + Apply Filters + </b-button> + + <b-button v-if="!addFilterShow" + icon-pack="fas" + icon-left="plus" + @click="addFilterInit()"> + Add Filter + </b-button> + + <b-autocomplete v-if="addFilterShow" + ref="addFilterAutocomplete" + :data="addFilterChoices" + v-model="addFilterTerm" + placeholder="Add Filter" + field="key" + :custom-formatter="formatAddFilterItem" + open-on-focus + keep-first + icon-pack="fas" + clearable + clear-on-select + @select="addFilterSelect"> + </b-autocomplete> + + <b-button @click="resetView()" + icon-pack="fas" + icon-left="home"> + Default View + </b-button> + + <b-button @click="clearFilters()" + icon-pack="fas" + icon-left="trash"> + No Filters + </b-button> + + % if allow_save_defaults and request.user: + <b-button @click="saveDefaults()" + icon-pack="fas" + icon-left="save" + :disabled="savingDefaults"> + {{ savingDefaults ? "Working, please wait..." : "Save Defaults" }} + </b-button> + % endif + + </div> + </form> % endif </div> </div> diff --git a/tailbone/templates/grids/filters.mako b/tailbone/templates/grids/filters.mako deleted file mode 100644 index 9a80b911..00000000 --- a/tailbone/templates/grids/filters.mako +++ /dev/null @@ -1,67 +0,0 @@ -## -*- coding: utf-8; -*- - -<form action="${form.action_url}" method="GET" @submit.prevent="applyFilters()"> - - <div style="display: flex; flex-direction: column; gap: 0.5rem;"> - <grid-filter v-for="key in filtersSequence" - :key="key" - :filter="filters[key]" - ref="gridFilters"> - </grid-filter> - </div> - - <div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;"> - - <b-button type="is-primary" - native-type="submit" - icon-pack="fas" - icon-left="check"> - Apply Filters - </b-button> - - <b-button v-if="!addFilterShow" - icon-pack="fas" - icon-left="plus" - @click="addFilterInit()"> - Add Filter - </b-button> - - <b-autocomplete v-if="addFilterShow" - ref="addFilterAutocomplete" - :data="addFilterChoices" - v-model="addFilterTerm" - placeholder="Add Filter" - field="key" - :custom-formatter="formatAddFilterItem" - open-on-focus - keep-first - icon-pack="fas" - clearable - clear-on-select - @select="addFilterSelect"> - </b-autocomplete> - - <b-button @click="resetView()" - icon-pack="fas" - icon-left="home"> - Default View - </b-button> - - <b-button @click="clearFilters()" - icon-pack="fas" - icon-left="trash"> - No Filters - </b-button> - - % if allow_save_defaults and request.user: - <b-button @click="saveDefaults()" - icon-pack="fas" - icon-left="save" - :disabled="savingDefaults"> - {{ savingDefaults ? "Working, please wait..." : "Save Defaults" }} - </b-button> - % endif - - </div> - -</form> From c1a2c9cc70b36044fb7a82bedf3d5cd59f5cd487 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 23 Aug 2024 14:14:03 -0500 Subject: [PATCH 1625/1681] fix: tweak how grid data translates to Vue template context per wuttaweb changes --- tailbone/grids/core.py | 6 ++++++ tailbone/templates/grids/complete.mako | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 12e45aec..ecf462fd 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -1403,6 +1403,10 @@ class Grid(WuttaGrid): if hasattr(rowobj, 'uuid'): return rowobj.uuid + def get_vue_context(self): + """ """ + return self.get_table_data() + def get_vue_data(self): """ """ table_data = self.get_table_data() @@ -1506,6 +1510,8 @@ class Grid(WuttaGrid): results = { 'data': data, + 'row_classes': status_map, + # TODO: deprecate / remove this 'row_status_map': status_map, } diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako index f5d1da95..60f9a3b8 100644 --- a/tailbone/templates/grids/complete.mako +++ b/tailbone/templates/grids/complete.mako @@ -311,7 +311,8 @@ <script type="text/javascript"> - let ${grid.vue_component}CurrentData = ${json.dumps(grid.get_vue_data())|n} + const ${grid.vue_component}Context = ${json.dumps(grid.get_vue_context())|n} + let ${grid.vue_component}CurrentData = ${grid.vue_component}Context.data let ${grid.vue_component}Data = { loading: false, From b7991b5dc61ff40e268f69be269adacb931519a0 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 23 Aug 2024 16:18:17 -0500 Subject: [PATCH 1626/1681] fix: fix input/output file upload feature for configure pages, per oruga --- tailbone/templates/configure.mako | 170 ++++++++++++++++++------------ 1 file changed, 101 insertions(+), 69 deletions(-) diff --git a/tailbone/templates/configure.mako b/tailbone/templates/configure.mako index 6d9c2261..463d48b1 100644 --- a/tailbone/templates/configure.mako +++ b/tailbone/templates/configure.mako @@ -92,7 +92,7 @@ <b-select name="${tmpl['setting_file']}" v-model="inputFileTemplateSettings['${tmpl['setting_file']}']" @input="settingsNeedSaved = true"> - <option :value="null">-new-</option> + <option value="">-new-</option> <option v-for="option in inputFileTemplateFileOptions['${tmpl['key']}']" :key="option" :value="option"> @@ -104,22 +104,40 @@ <b-field label="Upload" v-show="inputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted' && !inputFileTemplateSettings['${tmpl['setting_file']}']"> - <b-field class="file is-primary" - :class="{'has-name': !!inputFileTemplateSettings['${tmpl['setting_file']}']}"> - <b-upload name="${tmpl['setting_file']}.upload" - v-model="inputFileTemplateUploads['${tmpl['key']}']" - class="file-label" - @input="settingsNeedSaved = true"> - <span class="file-cta"> - <b-icon class="file-icon" pack="fas" icon="upload"></b-icon> - <span class="file-label">Click to upload</span> - </span> - </b-upload> - <span v-if="inputFileTemplateUploads['${tmpl['key']}']" - class="file-name"> - {{ inputFileTemplateUploads['${tmpl['key']}'].name }} - </span> - </b-field> + % if request.use_oruga: + <o-field class="file"> + <o-upload name="${tmpl['setting_file']}.upload" + v-model="inputFileTemplateUploads['${tmpl['key']}']" + v-slot="{ onclick }" + @input="settingsNeedSaved = true"> + <o-button variant="primary" + @click="onclick"> + <o-icon icon="upload" /> + <span>Click to upload</span> + </o-button> + <span class="file-name" v-if="inputFileTemplateUploads['${tmpl['key']}']"> + {{ inputFileTemplateUploads['${tmpl['key']}'].name }} + </span> + </o-upload> + </o-field> + % else: + <b-field class="file is-primary" + :class="{'has-name': !!inputFileTemplateSettings['${tmpl['setting_file']}']}"> + <b-upload name="${tmpl['setting_file']}.upload" + v-model="inputFileTemplateUploads['${tmpl['key']}']" + class="file-label" + @input="settingsNeedSaved = true"> + <span class="file-cta"> + <b-icon class="file-icon" pack="fas" icon="upload"></b-icon> + <span class="file-label">Click to upload</span> + </span> + </b-upload> + <span v-if="inputFileTemplateUploads['${tmpl['key']}']" + class="file-name"> + {{ inputFileTemplateUploads['${tmpl['key']}'].name }} + </span> + </b-field> + % endif </b-field> @@ -162,7 +180,7 @@ <b-select name="${tmpl['setting_file']}" v-model="outputFileTemplateSettings['${tmpl['setting_file']}']" @input="settingsNeedSaved = true"> - <option :value="null">-new-</option> + <option value="">-new-</option> <option v-for="option in outputFileTemplateFileOptions['${tmpl['key']}']" :key="option" :value="option"> @@ -174,23 +192,40 @@ <b-field label="Upload" v-show="outputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted' && !outputFileTemplateSettings['${tmpl['setting_file']}']"> - <b-field class="file is-primary" - :class="{'has-name': !!outputFileTemplateSettings['${tmpl['setting_file']}']}"> - <b-upload name="${tmpl['setting_file']}.upload" - v-model="outputFileTemplateUploads['${tmpl['key']}']" - class="file-label" - @input="settingsNeedSaved = true"> - <span class="file-cta"> - <b-icon class="file-icon" pack="fas" icon="upload"></b-icon> - <span class="file-label">Click to upload</span> - </span> - </b-upload> - <span v-if="outputFileTemplateUploads['${tmpl['key']}']" - class="file-name"> - {{ outputFileTemplateUploads['${tmpl['key']}'].name }} - </span> - </b-field> - + % if request.use_oruga: + <o-field class="file"> + <o-upload name="${tmpl['setting_file']}.upload" + v-model="outputFileTemplateUploads['${tmpl['key']}']" + v-slot="{ onclick }" + @input="settingsNeedSaved = true"> + <o-button variant="primary" + @click="onclick"> + <o-icon icon="upload" /> + <span>Click to upload</span> + </o-button> + <span class="file-name" v-if="outputFileTemplateUploads['${tmpl['key']}']"> + {{ outputFileTemplateUploads['${tmpl['key']}'].name }} + </span> + </o-upload> + </o-field> + % else: + <b-field class="file is-primary" + :class="{'has-name': !!outputFileTemplateSettings['${tmpl['setting_file']}']}"> + <b-upload name="${tmpl['setting_file']}.upload" + v-model="outputFileTemplateUploads['${tmpl['key']}']" + class="file-label" + @input="settingsNeedSaved = true"> + <span class="file-cta"> + <b-icon class="file-icon" pack="fas" icon="upload"></b-icon> + <span class="file-label">Click to upload</span> + </span> + </b-upload> + <span v-if="outputFileTemplateUploads['${tmpl['key']}']" + class="file-name"> + {{ outputFileTemplateUploads['${tmpl['key']}'].name }} + </span> + </b-field> + % endif </b-field> </b-field> @@ -275,16 +310,6 @@ ThisPageData.simpleSettings = ${json.dumps(simple_settings)|n} % endif - % if input_file_template_settings is not Undefined: - ThisPageData.inputFileTemplateSettings = ${json.dumps(input_file_template_settings)|n} - ThisPageData.inputFileTemplateFileOptions = ${json.dumps(input_file_options)|n} - ThisPageData.inputFileTemplateUploads = { - % for key in input_file_templates: - '${key}': null, - % endfor - } - % endif - ThisPageData.purgeSettingsShowDialog = false ThisPageData.purgingSettings = false @@ -297,30 +322,7 @@ this.purgeSettingsShowDialog = true } - % if input_file_template_settings is not Undefined: - ThisPage.methods.validateInputFileTemplateSettings = function() { - % for tmpl in input_file_templates.values(): - if (this.inputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted') { - if (!this.inputFileTemplateSettings['${tmpl['setting_file']}']) { - if (!this.inputFileTemplateUploads['${tmpl['key']}']) { - return "You must provide a file to upload for the ${tmpl['label']} template." - } - } - } - % endfor - } - % endif - - ThisPage.methods.validateSettings = function() { - let msg - - % if input_file_template_settings is not Undefined: - msg = this.validateInputFileTemplateSettings() - if (msg) { - return msg - } - % endif - } + ThisPage.methods.validateSettings = function() {} ThisPage.methods.saveSettings = function() { let msg @@ -366,6 +368,36 @@ window.addEventListener('beforeunload', this.beforeWindowUnload) } + ############################## + ## input file templates + ############################## + + % if input_file_template_settings is not Undefined: + + ThisPageData.inputFileTemplateSettings = ${json.dumps(input_file_template_settings)|n} + ThisPageData.inputFileTemplateFileOptions = ${json.dumps(input_file_options)|n} + ThisPageData.inputFileTemplateUploads = { + % for key in input_file_templates: + '${key}': null, + % endfor + } + + ThisPage.methods.validateInputFileTemplateSettings = function() { + % for tmpl in input_file_templates.values(): + if (this.inputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted') { + if (!this.inputFileTemplateSettings['${tmpl['setting_file']}']) { + if (!this.inputFileTemplateUploads['${tmpl['key']}']) { + return "You must provide a file to upload for the ${tmpl['label']} template." + } + } + } + % endfor + } + + ThisPageData.validators.push(ThisPage.methods.validateInputFileTemplateSettings) + + % endif + ############################## ## output file templates ############################## From d1f4c0f150f51b1fde0bdbdffa5a11d489f4ec9a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 26 Aug 2024 14:54:45 -0500 Subject: [PATCH 1627/1681] fix: refactor waterpark base template to use wutta feedback component although for now we still provide the template and add reply-to --- tailbone/templates/themes/waterpark/base.mako | 277 +++++++----------- 1 file changed, 105 insertions(+), 172 deletions(-) diff --git a/tailbone/templates/themes/waterpark/base.mako b/tailbone/templates/themes/waterpark/base.mako index 520e18ce..774479ba 100644 --- a/tailbone/templates/themes/waterpark/base.mako +++ b/tailbone/templates/themes/waterpark/base.mako @@ -164,12 +164,7 @@ /> </div> - % if request.has_perm('common.feedback'): - <feedback-form - action="${url('feedback')}" - :message="feedbackMessage"> - </feedback-form> - % endif + ${parent.render_feedback_button()} </%def> <%def name="render_crud_header_buttons()"> @@ -262,174 +257,133 @@ /> </%def> -<%def name="render_vue_templates()"> - ${parent.render_vue_templates()} +<%def name="render_vue_template_feedback()"> + <script type="text/x-template" id="feedback-template"> + <div> - ${page_help.render_template()} - ${page_help.declare_vars()} + <div class="level-item"> + <b-button type="is-primary" + @click="showFeedback()" + icon-pack="fas" + icon-left="comment"> + Feedback + </b-button> + </div> - % if request.has_perm('common.feedback'): - <script type="text/x-template" id="feedback-template"> - <div> + <b-modal has-modal-card + :active.sync="showDialog"> + <div class="modal-card"> - <div class="level-item"> - <b-button type="is-primary" - @click="showFeedback()" - icon-pack="fas" - icon-left="comment"> - Feedback - </b-button> - </div> + <header class="modal-card-head"> + <p class="modal-card-title">User Feedback</p> + </header> - <b-modal has-modal-card - :active.sync="showDialog"> - <div class="modal-card"> + <section class="modal-card-body"> + <p class="block"> + Questions, suggestions, comments, complaints, etc. + <span class="red">regarding this website</span> are + welcome and may be submitted below. + </p> - <header class="modal-card-head"> - <p class="modal-card-title">User Feedback</p> - </header> + <b-field label="User Name"> + <b-input v-model="userName" + % if request.user: + disabled + % endif + > + </b-input> + </b-field> - <section class="modal-card-body"> - <p class="block"> - Questions, suggestions, comments, complaints, etc. - <span class="red">regarding this website</span> are - welcome and may be submitted below. - </p> + <b-field label="Referring URL"> + <b-input + v-model="referrer" + disabled="true"> + </b-input> + </b-field> - <b-field label="User Name"> - <b-input v-model="userName" - % if request.user: - disabled - % endif - > - </b-input> - </b-field> + <b-field label="Message"> + <b-input type="textarea" + v-model="message" + ref="textarea"> + </b-input> + </b-field> - <b-field label="Referring URL"> - <b-input - v-model="referrer" - disabled="true"> - </b-input> - </b-field> - - <b-field label="Message"> - <b-input type="textarea" - v-model="message" - ref="textarea"> - </b-input> - </b-field> - - % if config.get_bool('tailbone.feedback_allows_reply'): - <div class="level"> - <div class="level-left"> - <div class="level-item"> - <b-checkbox v-model="pleaseReply" - @input="pleaseReplyChanged"> - Please email me back{{ pleaseReply ? " at: " : "" }} - </b-checkbox> - </div> - <div class="level-item" v-show="pleaseReply"> - <b-input v-model="userEmail" - ref="userEmail"> - </b-input> - </div> - </div> + % if config.get_bool('tailbone.feedback_allows_reply'): + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <b-checkbox v-model="pleaseReply" + @input="pleaseReplyChanged"> + Please email me back{{ pleaseReply ? " at: " : "" }} + </b-checkbox> </div> - % endif + <div class="level-item" v-show="pleaseReply"> + <b-input v-model="userEmail" + ref="userEmail"> + </b-input> + </div> + </div> + </div> + % endif - </section> - - <footer class="modal-card-foot"> - <b-button @click="showDialog = false"> - Cancel - </b-button> - <b-button type="is-primary" - icon-pack="fas" - icon-left="paper-plane" - @click="sendFeedback()" - :disabled="sendingFeedback || !message.trim()"> - {{ sendingFeedback ? "Working, please wait..." : "Send Message" }} - </b-button> - </footer> - </div> - </b-modal> + </section> + <footer class="modal-card-foot"> + <b-button @click="showDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + icon-pack="fas" + icon-left="paper-plane" + @click="sendFeedback()" + :disabled="sendingFeedback || !message || !message.trim()"> + {{ sendingFeedback ? "Working, please wait..." : "Send Message" }} + </b-button> + </footer> </div> - </script> - <script> + </b-modal> - const FeedbackForm = { - template: '#feedback-template', - mixins: [SimpleRequestMixin], - props: [ - 'action', - 'message', - ], - methods: { + </div> + </script> +</%def> - showFeedback() { - this.referrer = location.href - this.showDialog = true - this.$nextTick(function() { - this.$refs.textarea.focus() - }) - }, +<%def name="render_vue_script_feedback()"> + ${parent.render_vue_script_feedback()} + <script> - % if config.get_bool('tailbone.feedback_allows_reply'): - pleaseReplyChanged(value) { - this.$nextTick(() => { - this.$refs.userEmail.focus() - }) - }, - % endif + WuttaFeedbackForm.template = '#feedback-template' + WuttaFeedbackForm.props.message = String - sendFeedback() { - this.sendingFeedback = true + % if config.get_bool('tailbone.feedback_allows_reply'): - const params = { - referrer: this.referrer, - user: this.userUUID, - user_name: this.userName, - % if config.get_bool('tailbone.feedback_allows_reply'): - please_reply_to: this.pleaseReply ? this.userEmail : null, - % endif - message: this.message.trim(), - } + WuttaFeedbackFormData.pleaseReply = false + WuttaFeedbackFormData.userEmail = null - this.simplePOST(this.action, params, response => { + WuttaFeedbackForm.methods.pleaseReplyChanged = function(value) { + this.$nextTick(() => { + this.$refs.userEmail.focus() + }) + } - this.$buefy.toast.open({ - message: "Message sent! Thank you for your feedback.", - type: 'is-info', - duration: 4000, // 4 seconds - }) - - this.showDialog = false - // clear out message, in case they need to send another - this.message = "" - this.sendingFeedback = false - - }, response => { // failure - this.sendingFeedback = false - }) - }, + WuttaFeedbackForm.methods.getExtraParams = function() { + return { + please_reply_to: this.pleaseReply ? this.userEmail : null, } } - const FeedbackFormData = { - referrer: null, - userUUID: null, - userName: null, - userEmail: null, - % if config.get_bool('tailbone.feedback_allows_reply'): - pleaseReply: false, - % endif - showDialog: false, - sendingFeedback: false, - } + % endif - </script> - % endif + // TODO: deprecate / remove these + const FeedbackForm = WuttaFeedbackForm + const FeedbackFormData = WuttaFeedbackFormData + + </script> +</%def> + +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} + ${page_help.render_template()} + ${page_help.declare_vars()} </%def> <%def name="modify_vue_vars()"> @@ -528,21 +482,6 @@ % endif - ############################## - ## feedback - ############################## - - % if request.has_perm('common.feedback'): - - WholePageData.feedbackMessage = "" - - % if request.user: - FeedbackFormData.userUUID = ${json.dumps(request.user.uuid)|n} - FeedbackFormData.userName = ${json.dumps(str(request.user))|n} - % endif - - % endif - ############################## ## edit fields help ############################## @@ -562,10 +501,4 @@ ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.timepicker.js') + f'?ver={tailbone.__version__}')} ${make_grid_filter_components()} ${page_help.make_component()} - % if request.has_perm('common.feedback'): - <script> - FeedbackForm.data = function() { return FeedbackFormData } - Vue.component('feedback-form', FeedbackForm) - </script> - % endif </%def> From 3a9bf69aa7f63fc838259eef477324beee7c66a8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 26 Aug 2024 14:56:15 -0500 Subject: [PATCH 1628/1681] =?UTF-8?q?bump:=20version=200.21.1=20=E2=86=92?= =?UTF-8?q?=200.21.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 13 +++++++++++++ pyproject.toml | 6 +++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bcbc6ec..4616cf5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.21.2 (2024-08-26) + +### Fix + +- refactor waterpark base template to use wutta feedback component +- fix input/output file upload feature for configure pages, per oruga +- tweak how grid data translates to Vue template context +- merge filters into main grid template +- add basic wutta view for users +- some fixes for wutta people view +- various fixes for waterpark theme +- avoid deprecated `component` form kwarg + ## v0.21.1 (2024-08-22) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 2db880ad..831133c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.21.1" +version = "0.21.2" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] @@ -53,13 +53,13 @@ dependencies = [ "pyramid_mako", "pyramid_retry", "pyramid_tm", - "rattail[db,bouncer]>=0.18.4", + "rattail[db,bouncer]>=0.18.5", "sa-filters", "simplejson", "transaction", "waitress", "WebHelpers2", - "WuttaWeb>=0.12.0", + "WuttaWeb>=0.13.1", "zope.sqlalchemy>=1.5", ] From d67eb2f1cc15719478a26b8b76246947b528885e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 26 Aug 2024 15:24:40 -0500 Subject: [PATCH 1629/1681] fix: show non-standard config values for app info configure email this page is currently showing some basic email sender/recips etc. but the config keys traditionally used by rattail are different than wuttjamaican..so for now we must "translate" --- tailbone/views/settings.py | 49 ++++++++++++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index 099a77e1..0180aa4b 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -81,15 +81,56 @@ class AppInfoView(WuttaAppInfoView): """ """ simple_settings = super().configure_get_simple_settings() - # TODO: the update home page redirect setting is off by - # default for wuttaweb, but on for tailbone for setting in simple_settings: + + # TODO: the update home page redirect setting is off by + # default for wuttaweb, but on for tailbone if setting['name'] == 'wuttaweb.home_redirect_to_login': value = self.config.get_bool('wuttaweb.home_redirect_to_login') if value is None: value = self.config.get_bool('tailbone.login_is_home', default=True) - setting['default'] = value - break + setting['value'] = value + + # TODO: sending email is off by default for wuttjamaican, + # but on for rattail + elif setting['name'] == 'rattail.mail.send_emails': + value = self.config.get_bool('rattail.mail.send_emails', default=True) + setting['value'] = value + + # TODO: email defaults have different config keys in rattail + elif setting['name'] == 'rattail.email.default.sender': + value = self.config.get('rattail.email.default.sender') + if value is None: + value = self.config.get('rattail.mail.default.from') + setting['value'] = value + + # TODO: email defaults have different config keys in rattail + elif setting['name'] == 'rattail.email.default.subject': + value = self.config.get('rattail.email.default.subject') + if value is None: + value = self.config.get('rattail.mail.default.subject') + setting['value'] = value + + # TODO: email defaults have different config keys in rattail + elif setting['name'] == 'rattail.email.default.to': + value = self.config.get('rattail.email.default.to') + if value is None: + value = self.config.get('rattail.mail.default.to') + setting['value'] = value + + # TODO: email defaults have different config keys in rattail + elif setting['name'] == 'rattail.email.default.cc': + value = self.config.get('rattail.email.default.cc') + if value is None: + value = self.config.get('rattail.mail.default.cc') + setting['value'] = value + + # TODO: email defaults have different config keys in rattail + elif setting['name'] == 'rattail.email.default.bcc': + value = self.config.get('rattail.email.default.bcc') + if value is None: + value = self.config.get('rattail.mail.default.bcc') + setting['value'] = value # nb. these are no longer used (deprecated), but we keep # them defined here so the tool auto-deletes them From dffd951369de5ca36a877f9b8b36e344245266b0 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 26 Aug 2024 15:25:56 -0500 Subject: [PATCH 1630/1681] =?UTF-8?q?bump:=20version=200.21.2=20=E2=86=92?= =?UTF-8?q?=200.21.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4616cf5f..52a17a2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.21.3 (2024-08-26) + +### Fix + +- show non-standard config values for app info configure email + ## v0.21.2 (2024-08-26) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 831133c1..2c18bd02 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.21.2" +version = "0.21.3" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From 7a9d5772db794d69632ce3a8621396d08e6ec679 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 26 Aug 2024 16:11:32 -0500 Subject: [PATCH 1631/1681] fix: handle differing email profile keys for appinfo/configure hopefully this all can improve some day soon.. --- tailbone/templates/configure.mako | 5 +- tailbone/views/settings.py | 96 +++++++++++++++++++++---------- 2 files changed, 69 insertions(+), 32 deletions(-) diff --git a/tailbone/templates/configure.mako b/tailbone/templates/configure.mako index 463d48b1..e6b128fc 100644 --- a/tailbone/templates/configure.mako +++ b/tailbone/templates/configure.mako @@ -280,15 +280,14 @@ <b-button @click="purgeSettingsShowDialog = false"> Cancel </b-button> - ${h.form(request.current_route_url())} + ${h.form(request.current_route_url(), **{'@submit': 'purgingSettings = true'})} ${h.csrf_token(request)} ${h.hidden('remove_settings', 'true')} <b-button type="is-danger" native-type="submit" :disabled="purgingSettings" icon-pack="fas" - icon-left="trash" - @click="purgingSettings = true"> + icon-left="trash"> {{ purgingSettings ? "Working, please wait..." : "Remove All Settings" }} </b-button> ${h.end_form()} diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index 0180aa4b..10a0c2eb 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -77,13 +77,41 @@ class AppInfoView(WuttaAppInfoView): return context + # nb. these email settings require special handling below + configure_profile_key_mismatches = [ + 'default.subject', + 'default.to', + 'default.cc', + 'default.bcc', + 'feedback.subject', + 'feedback.to', + ] + def configure_get_simple_settings(self): """ """ simple_settings = super().configure_get_simple_settings() + # TODO: + # there are several email config keys which differ between + # wuttjamaican and rattail. basically all of the "profile" keys + # have a different prefix. + + # after wuttaweb has declared its settings, we examine each and + # overwrite the value if one is defined with rattail config key. + # (nb. this happens even if wuttjamaican key has a value!) + + # note that we *do* declare the profile mismatch keys for + # rattail, as part of simple settings. this ensures the + # parent logic will always remove them when saving. however + # we must also include them in gather_settings() to ensure + # they are saved to match wuttjamaican values. + + # there are also a couple of flags where rattail's default is the + # opposite of wuttjamaican. so we overwrite those too as needed. + for setting in simple_settings: - # TODO: the update home page redirect setting is off by + # nb. the update home page redirect setting is off by # default for wuttaweb, but on for tailbone if setting['name'] == 'wuttaweb.home_redirect_to_login': value = self.config.get_bool('wuttaweb.home_redirect_to_login') @@ -91,55 +119,43 @@ class AppInfoView(WuttaAppInfoView): value = self.config.get_bool('tailbone.login_is_home', default=True) setting['value'] = value - # TODO: sending email is off by default for wuttjamaican, + # nb. sending email is off by default for wuttjamaican, # but on for rattail elif setting['name'] == 'rattail.mail.send_emails': value = self.config.get_bool('rattail.mail.send_emails', default=True) setting['value'] = value - # TODO: email defaults have different config keys in rattail + # nb. this one is even more special, key is entirely different elif setting['name'] == 'rattail.email.default.sender': value = self.config.get('rattail.email.default.sender') if value is None: value = self.config.get('rattail.mail.default.from') setting['value'] = value - # TODO: email defaults have different config keys in rattail - elif setting['name'] == 'rattail.email.default.subject': - value = self.config.get('rattail.email.default.subject') - if value is None: - value = self.config.get('rattail.mail.default.subject') - setting['value'] = value + else: - # TODO: email defaults have different config keys in rattail - elif setting['name'] == 'rattail.email.default.to': - value = self.config.get('rattail.email.default.to') - if value is None: - value = self.config.get('rattail.mail.default.to') - setting['value'] = value - - # TODO: email defaults have different config keys in rattail - elif setting['name'] == 'rattail.email.default.cc': - value = self.config.get('rattail.email.default.cc') - if value is None: - value = self.config.get('rattail.mail.default.cc') - setting['value'] = value - - # TODO: email defaults have different config keys in rattail - elif setting['name'] == 'rattail.email.default.bcc': - value = self.config.get('rattail.email.default.bcc') - if value is None: - value = self.config.get('rattail.mail.default.bcc') - setting['value'] = value + # nb. fetch alternate value for profile key mismatch + for key in self.configure_profile_key_mismatches: + if setting['name'] == f'rattail.email.{key}': + value = self.config.get(f'rattail.email.{key}') + if value is None: + value = self.config.get(f'rattail.mail.{key}') + setting['value'] = value + break # nb. these are no longer used (deprecated), but we keep # them defined here so the tool auto-deletes them simple_settings.extend([ + {'name': 'tailbone.login_is_home'}, {'name': 'tailbone.buefy_version'}, {'name': 'tailbone.vue_version'}, ]) + simple_settings.append({'name': 'rattail.mail.default.from'}) + for key in self.configure_profile_key_mismatches: + simple_settings.append({'name': f'rattail.mail.{key}'}) + for key in self.get_weblibs(): simple_settings.extend([ {'name': f'tailbone.libver.{key}'}, @@ -148,6 +164,28 @@ class AppInfoView(WuttaAppInfoView): return simple_settings + def configure_gather_settings(self, data, simple_settings=None): + """ """ + settings = super().configure_gather_settings(data, simple_settings=simple_settings) + + # nb. must add legacy rattail profile settings to match new ones + for setting in list(settings): + + if setting['name'] == 'rattail.email.default.sender': + value = setting['value'] + settings.append({'name': 'rattail.mail.default.from', + 'value': value}) + + else: + for key in self.configure_profile_key_mismatches: + if setting['name'] == f'rattail.email.{key}': + value = setting['value'] + settings.append({'name': f'rattail.mail.{key}', + 'value': value}) + break + + return settings + class SettingView(MasterView): """ From ca05e688905398758470d5dd2db0ba288b8216a5 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 26 Aug 2024 16:12:14 -0500 Subject: [PATCH 1632/1681] =?UTF-8?q?bump:=20version=200.21.3=20=E2=86=92?= =?UTF-8?q?=200.21.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52a17a2f..e18c786c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.21.4 (2024-08-26) + +### Fix + +- handle differing email profile keys for appinfo/configure + ## v0.21.3 (2024-08-26) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 2c18bd02..4845708b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.21.3" +version = "0.21.4" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From 2e20fc5b7527275eaf7408dad56e3516ef6433e3 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 27 Aug 2024 13:50:30 -0500 Subject: [PATCH 1633/1681] fix: set empty string for "-new-" file configure option otherwise the "-new-" option is not properly auto-selected --- tailbone/views/master.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 1028ff27..6e05c35d 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -5441,7 +5441,7 @@ class MasterView(View): for template in self.normalize_input_file_templates( include_file_options=True): settings[template['setting_mode']] = template['mode'] - settings[template['setting_file']] = template['file'] + settings[template['setting_file']] = template['file'] or '' settings[template['setting_url']] = template['url'] file_options[template['key']] = template['file_options'] file_option_dirs[template['key']] = template['file_options_dir'] @@ -5457,7 +5457,7 @@ class MasterView(View): for template in self.normalize_output_file_templates( include_file_options=True): settings[template['setting_mode']] = template['mode'] - settings[template['setting_file']] = template['file'] + settings[template['setting_file']] = template['file'] or '' settings[template['setting_url']] = template['url'] file_options[template['key']] = template['file_options'] file_option_dirs[template['key']] = template['file_options_dir'] From b30f066c41f3b758882e0d8fc68e4a61b501e186 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 28 Aug 2024 00:30:15 -0500 Subject: [PATCH 1634/1681] =?UTF-8?q?bump:=20version=200.21.4=20=E2=86=92?= =?UTF-8?q?=200.21.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e18c786c..d3c8a92f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.21.5 (2024-08-28) + +### Fix + +- set empty string for "-new-" file configure option + ## v0.21.4 (2024-08-26) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 4845708b..4743fd3b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.21.4" +version = "0.21.5" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] @@ -59,7 +59,7 @@ dependencies = [ "transaction", "waitress", "WebHelpers2", - "WuttaWeb>=0.13.1", + "WuttaWeb>=0.14.0", "zope.sqlalchemy>=1.5", ] From b81914fbf52357e3097a8f88d913c19ef30c0388 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 28 Aug 2024 00:35:15 -0500 Subject: [PATCH 1635/1681] test: fix broken test --- tests/test_app.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/tests/test_app.py b/tests/test_app.py index e16461ba..f49f6b13 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -5,12 +5,9 @@ from unittest import TestCase from pyramid.config import Configurator -from wuttjamaican.testing import FileConfigTestCase - from rattail.exceptions import ConfigurationError -from rattail.config import RattailConfig +from rattail.testing import DataTestCase from tailbone import app as mod -from tests.util import DataTestCase class TestRattailConfig(TestCase): @@ -30,7 +27,7 @@ class TestRattailConfig(TestCase): class TestMakePyramidConfig(DataTestCase): - def make_config(self): + def make_config(self, **kwargs): myconf = self.write_file('web.conf', """ [rattail.db] default.url = sqlite:// From 0b6cfaa9c57bbbf0ef3ad51cab4e5d5bc56d6843 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 28 Aug 2024 09:53:14 -0500 Subject: [PATCH 1636/1681] fix: avoid error when grid value cannot be obtained --- tailbone/grids/core.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index ecf462fd..c6257d4b 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -575,7 +575,11 @@ class Grid(WuttaGrid): return getattr(obj, column_name) except AttributeError: pass - return obj[column_name] + + try: + return obj[column_name] + except TypeError: + pass def render_currency(self, obj, column_name): value = self.obtain_value(obj, column_name) From 71d63f6b93fee7ff8ff2ff19eebe844dce9476df Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 28 Aug 2024 09:53:37 -0500 Subject: [PATCH 1637/1681] =?UTF-8?q?bump:=20version=200.21.5=20=E2=86=92?= =?UTF-8?q?=200.21.6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3c8a92f..59fcfcc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.21.6 (2024-08-28) + +### Fix + +- avoid error when grid value cannot be obtained + ## v0.21.5 (2024-08-28) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 4743fd3b..16018dbb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.21.5" +version = "0.21.6" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From bc399182ba5eb957ae7c521f3b71701ff4bf39d1 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 28 Aug 2024 14:20:17 -0500 Subject: [PATCH 1638/1681] fix: avoid error when form value cannot be obtained --- tailbone/forms/core.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 059b212a..b5020975 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -1380,7 +1380,11 @@ class Form(object): return getattr(record, field_name) except AttributeError: pass - return record[field_name] + + try: + return record[field_name] + except TypeError: + pass # TODO: is this always safe to do? elif self.defaults and field_name in self.defaults: From 20dcdd8b86dfdbab1224676e3135ee8171b57f00 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 28 Aug 2024 14:20:51 -0500 Subject: [PATCH 1639/1681] =?UTF-8?q?bump:=20version=200.21.6=20=E2=86=92?= =?UTF-8?q?=200.21.7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59fcfcc9..aee19700 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.21.7 (2024-08-28) + +### Fix + +- avoid error when form value cannot be obtained + ## v0.21.6 (2024-08-28) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 16018dbb..45a2adc9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.21.6" +version = "0.21.7" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From 812d8d2349e7517e2ef5702dcf904cd0b5c5c8af Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 28 Aug 2024 14:37:18 -0500 Subject: [PATCH 1640/1681] fix: ignore session kwarg for `MasterView.make_row_grid()` --- tailbone/views/master.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 6e05c35d..baf63caa 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -551,7 +551,8 @@ class MasterView(View): def get_quickie_result_url(self, obj): return self.get_action_url('view', obj) - def make_row_grid(self, factory=None, key=None, data=None, columns=None, **kwargs): + def make_row_grid(self, factory=None, key=None, data=None, columns=None, + session=None, **kwargs): """ Make and return a new (configured) rows grid instance. """ From 9be2f6347571d5989fabad88a9fc90ebf63812f9 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 28 Aug 2024 14:37:40 -0500 Subject: [PATCH 1641/1681] =?UTF-8?q?bump:=20version=200.21.7=20=E2=86=92?= =?UTF-8?q?=200.21.8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aee19700..a31b80ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.21.8 (2024-08-28) + +### Fix + +- ignore session kwarg for `MasterView.make_row_grid()` + ## v0.21.7 (2024-08-28) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 45a2adc9..350803dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.21.7" +version = "0.21.8" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From 2219cf81988c583320014492a6e114c40e025e2b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 28 Aug 2024 17:38:05 -0500 Subject: [PATCH 1642/1681] fix: render custom attrs in form component tag --- tailbone/forms/core.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index b5020975..601dcfb1 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -1037,9 +1037,9 @@ class Form(object): def render_vue_tag(self, **kwargs): """ """ - return self.render_vuejs_component() + return self.render_vuejs_component(**kwargs) - def render_vuejs_component(self): + def render_vuejs_component(self, **kwargs): """ Render the Vue.js component HTML for the form. @@ -1050,10 +1050,11 @@ class Form(object): <tailbone-form :configure-fields-help="configureFieldsHelp"> </tailbone-form> """ - kwargs = dict(self.vuejs_component_kwargs) + kw = dict(self.vuejs_component_kwargs) + kw.update(kwargs) if self.can_edit_help: - kwargs.setdefault(':configure-fields-help', 'configureFieldsHelp') - return HTML.tag(self.vue_tagname, **kwargs) + kw.setdefault(':configure-fields-help', 'configureFieldsHelp') + return HTML.tag(self.vue_tagname, **kw) def set_json_data(self, key, value): """ From 55f45ae8a081123af3c8fc931a7745f0d7ea0b2b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 28 Aug 2024 17:38:33 -0500 Subject: [PATCH 1643/1681] =?UTF-8?q?bump:=20version=200.21.8=20=E2=86=92?= =?UTF-8?q?=200.21.9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a31b80ac..da628cf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.21.9 (2024-08-28) + +### Fix + +- render custom attrs in form component tag + ## v0.21.8 (2024-08-28) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 350803dc..2720d003 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.21.8" +version = "0.21.9" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From 8df52bf2a2d8902cc1565a5e46370273db580be2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 29 Aug 2024 17:01:28 -0500 Subject: [PATCH 1644/1681] fix: expose datasync consumer batch size via configure page --- tailbone/templates/datasync/configure.mako | 29 ++++++---- tailbone/views/datasync.py | 65 +++++++++++++--------- 2 files changed, 55 insertions(+), 39 deletions(-) diff --git a/tailbone/templates/datasync/configure.mako b/tailbone/templates/datasync/configure.mako index 3651d0c4..2e444fb5 100644 --- a/tailbone/templates/datasync/configure.mako +++ b/tailbone/templates/datasync/configure.mako @@ -83,8 +83,8 @@ </b-notification> <b-field> - <b-checkbox name="use_profile_settings" - v-model="useProfileSettings" + <b-checkbox name="rattail.datasync.use_profile_settings" + v-model="simpleSettings['rattail.datasync.use_profile_settings']" native-value="true" @input="settingsNeedSaved = true"> Use these Settings to configure watchers and consumers @@ -99,7 +99,7 @@ </div> <div class="level-right"> <div class="level-item" - v-show="useProfileSettings"> + v-show="simpleSettings['rattail.datasync.use_profile_settings']"> <b-button type="is-primary" @click="newProfile()" icon-pack="fas" @@ -162,7 +162,7 @@ </${b}-table-column> <${b}-table-column label="Actions" v-slot="props" - v-if="useProfileSettings"> + v-if="simpleSettings['rattail.datasync.use_profile_settings']"> <a href="#" class="grid-action" @click.prevent="editProfile(props.row)"> @@ -580,18 +580,27 @@ <b-field label="Supervisor Process Name" message="This should be the complete name, including group - e.g. poser:poser_datasync" expanded> - <b-input name="supervisor_process_name" - v-model="supervisorProcessName" + <b-input name="rattail.datasync.supervisor_process_name" + v-model="simpleSettings['rattail.datasync.supervisor_process_name']" @input="settingsNeedSaved = true" expanded> </b-input> </b-field> + <b-field label="Consumer Batch Size" + message="Max number of changes to be consumed at once." + expanded> + <numeric-input name="rattail.datasync.batch_size_limit" + v-model="simpleSettings['rattail.datasync.batch_size_limit']" + @input="settingsNeedSaved = true" /> + </b-field> + + <h3 class="is-size-3">Legacy</h3> <b-field label="Restart Command" message="This will run as '${system_user}' system user - please configure sudoers as needed. Typical command is like: sudo supervisorctl restart poser:poser_datasync" expanded> - <b-input name="restart_command" - v-model="restartCommand" + <b-input name="tailbone.datasync.restart" + v-model="simpleSettings['tailbone.datasync.restart']" @input="settingsNeedSaved = true" expanded> </b-input> @@ -606,7 +615,6 @@ ThisPageData.showConfigFilesNote = false ThisPageData.profilesData = ${json.dumps(profiles_data)|n} ThisPageData.showDisabledProfiles = false - ThisPageData.useProfileSettings = ${json.dumps(use_profile_settings)|n} ThisPageData.editProfileShowDialog = false ThisPageData.editingProfile = null @@ -631,9 +639,6 @@ ThisPageData.editingConsumerRunas = null ThisPageData.editingConsumerEnabled = true - ThisPageData.supervisorProcessName = ${json.dumps(supervisor_process_name)|n} - ThisPageData.restartCommand = ${json.dumps(restart_command)|n} - ThisPage.computed.updateConsumerDisabled = function() { if (!this.editingConsumerKey) { return true diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py index 134d6018..2b955b5f 100644 --- a/tailbone/views/datasync.py +++ b/tailbone/views/datasync.py @@ -202,10 +202,36 @@ class DataSyncThreadView(MasterView): return self.redirect(self.request.get_referrer( default=self.request.route_url('datasyncchanges'))) - def configure_get_context(self): + def configure_get_simple_settings(self): + """ """ + return [ + + # basic + {'section': 'rattail.datasync', + 'option': 'use_profile_settings', + 'type': bool}, + + # misc. + {'section': 'rattail.datasync', + 'option': 'supervisor_process_name'}, + {'section': 'rattail.datasync', + 'option': 'batch_size_limit', + 'type': int}, + + # legacy + {'section': 'tailbone', + 'option': 'datasync.restart'}, + + ] + + def configure_get_context(self, **kwargs): + """ """ + context = super().configure_get_context(**kwargs) + profiles = self.datasync_handler.get_configured_profiles( include_disabled=True, ignore_problems=True) + context['profiles'] = profiles profiles_data = [] for profile in sorted(profiles.values(), key=lambda p: p.key): @@ -243,25 +269,15 @@ class DataSyncThreadView(MasterView): data['consumers_data'] = consumers profiles_data.append(data) - return { - 'profiles': profiles, - 'profiles_data': profiles_data, - 'use_profile_settings': self.datasync_handler.should_use_profile_settings(), - 'supervisor_process_name': self.rattail_config.get( - 'rattail.datasync', 'supervisor_process_name'), - 'restart_command': self.rattail_config.get( - 'tailbone', 'datasync.restart'), - } + context['profiles_data'] = profiles_data + return context - def configure_gather_settings(self, data): - settings = [] - watch = [] + def configure_gather_settings(self, data, **kwargs): + """ """ + settings = super().configure_gather_settings(data, **kwargs) - use_profile_settings = data.get('use_profile_settings') == 'true' - settings.append({'name': 'rattail.datasync.use_profile_settings', - 'value': 'true' if use_profile_settings else 'false'}) - - if use_profile_settings: + if data.get('rattail.datasync.use_profile_settings') == 'true': + watch = [] for profile in json.loads(data['profiles']): pkey = profile['key'] @@ -323,17 +339,12 @@ class DataSyncThreadView(MasterView): settings.append({'name': 'rattail.datasync.watch', 'value': ', '.join(watch)}) - if data['supervisor_process_name']: - settings.append({'name': 'rattail.datasync.supervisor_process_name', - 'value': data['supervisor_process_name']}) - - if data['restart_command']: - settings.append({'name': 'tailbone.datasync.restart', - 'value': data['restart_command']}) - return settings - def configure_remove_settings(self): + def configure_remove_settings(self, **kwargs): + """ """ + super().configure_remove_settings(**kwargs) + purge_datasync_settings(self.rattail_config, self.Session()) @classmethod From b9b8bbd2eae1543cb74898f95e72cee5e7de6f46 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 29 Aug 2024 17:18:32 -0500 Subject: [PATCH 1645/1681] fix: wrap notes text for batch view --- tailbone/views/batch/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 8ee3a37d..a75fda1c 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -383,7 +383,7 @@ class BatchMasterView(MasterView): f.set_label('executed_by', "Executed by") # notes - f.set_type('notes', 'text') + f.set_type('notes', 'text_wrapped') # if self.creating and self.request.user: # batch = fs.model From 5e742eab1795fe4c53573070af264c8d8a4cf3c0 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 9 Sep 2024 08:32:28 -0500 Subject: [PATCH 1646/1681] fix: use better icon for submit button on login page --- tailbone/forms/core.py | 2 ++ tailbone/templates/forms/deform.mako | 2 +- tailbone/views/auth.py | 6 +++--- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 601dcfb1..4024557b 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -401,6 +401,8 @@ class Form(object): self.edit_help_url = edit_help_url self.route_prefix = route_prefix + self.button_icon_submit = kwargs.get('button_icon_submit', 'save') + def __iter__(self): return iter(self.fields) diff --git a/tailbone/templates/forms/deform.mako b/tailbone/templates/forms/deform.mako index ea35ab17..2100b460 100644 --- a/tailbone/templates/forms/deform.mako +++ b/tailbone/templates/forms/deform.mako @@ -59,7 +59,7 @@ native-type="submit" :disabled="${form.vue_component}Submitting" icon-pack="fas" - icon-left="save"> + icon-left="${form.button_icon_submit}"> {{ ${form.vue_component}Submitting ? "Working, please wait..." : "${form.button_label_submit}" }} </b-button> % else: diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index 730d7b6a..a54a19a9 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -24,8 +24,6 @@ Auth Views """ -from rattail.db.auth import set_user_password - import colander from deform import widget as dfwidget from pyramid.httpexceptions import HTTPForbidden @@ -104,6 +102,7 @@ class AuthenticationView(View): form.save_label = "Login" form.show_reset = True form.show_cancel = False + form.button_icon_submit = 'user' if form.validate(): user = self.authenticate_user(form.validated['username'], form.validated['password']) @@ -185,7 +184,8 @@ class AuthenticationView(View): schema = ChangePassword().bind(user=self.request.user, request=self.request) form = forms.Form(schema=schema, request=self.request) if form.validate(): - set_user_password(self.request.user, form.validated['new_password']) + auth = self.app.get_auth_handler() + auth.set_user_password(self.request.user, form.validated['new_password']) self.request.session.flash("Your password has been changed.") return self.redirect(self.request.get_referrer()) From a4d81a6e3cf431bae5fb91337ccf1c345e75c137 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 13 Sep 2024 18:16:07 -0500 Subject: [PATCH 1647/1681] docs: use markdown for readme file --- README.rst => README.md | 8 +++----- pyproject.toml | 2 +- 2 files changed, 4 insertions(+), 6 deletions(-) rename README.rst => README.md (56%) diff --git a/README.rst b/README.md similarity index 56% rename from README.rst rename to README.md index 0cffc62d..74c007f6 100644 --- a/README.rst +++ b/README.md @@ -1,10 +1,8 @@ -Tailbone -======== +# Tailbone Tailbone is an extensible web application based on Rattail. It provides a "back-office network environment" (BONE) for use in managing retail data. -Please see Rattail's `home page`_ for more information. - -.. _home page: http://rattailproject.org/ +Please see Rattail's [home page](http://rattailproject.org/) for more +information. diff --git a/pyproject.toml b/pyproject.toml index 2720d003..8c6525c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "hatchling.build" name = "Tailbone" version = "0.21.9" description = "Backoffice Web Application for Rattail" -readme = "README.rst" +readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] license = {text = "GNU GPL v3+"} classifiers = [ From 0b646d2d187fafe743cb7816ab0a86d171b76646 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 14 Sep 2024 12:49:37 -0500 Subject: [PATCH 1648/1681] fix: update project repo links, kallithea -> forgejo --- pyproject.toml | 6 ++-- tailbone/views/upgrades.py | 69 +++++++++++--------------------------- 2 files changed, 23 insertions(+), 52 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8c6525c6..a1c96dd4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,9 +84,9 @@ tailbone = "tailbone.config:ConfigExtension" [project.urls] Homepage = "https://rattailproject.org" -Repository = "https://kallithea.rattailproject.org/rattail-project/tailbone" -Issues = "https://redmine.rattailproject.org/projects/tailbone/issues" -Changelog = "https://kallithea.rattailproject.org/rattail-project/tailbone/files/master/CHANGELOG.md" +Repository = "https://forgejo.wuttaproject.org/rattail/tailbone" +Issues = "https://forgejo.wuttaproject.org/rattail/tailbone/issues" +Changelog = "https://forgejo.wuttaproject.org/rattail/tailbone/src/branch/master/CHANGELOG.md" [tool.commitizen] diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index 3276b64d..ffa88032 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.py @@ -348,56 +348,27 @@ class UpgradeView(MasterView): commit_hash_pattern = re.compile(r'^.{40}$') def get_changelog_projects(self): - projects = { - 'rattail': { - 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/rattail/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail/files/v{new_version}/CHANGES.rst', - }, - 'Tailbone': { - 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone/files/v{new_version}/CHANGES.rst', - }, - 'pyCOREPOS': { - 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/pycorepos/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-project/pycorepos/files/v{new_version}/CHANGES.rst', - }, - 'rattail_corepos': { - 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-corepos/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-corepos/files/v{new_version}/CHANGES.rst', - }, - 'tailbone_corepos': { - 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-corepos/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-corepos/files/v{new_version}/CHANGES.rst', - }, - 'onager': { - 'commit_url': 'https://kallithea.rattailproject.org/rattail-restricted/onager/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-restricted/onager/files/v{new_version}/CHANGES.rst', - }, - 'rattail-onager': { - 'commit_url': 'https://kallithea.rattailproject.org/rattail-restricted/rattail-onager/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-restricted/rattail-onager/files/v{new_version}/CHANGELOG.md', - }, - 'rattail_tempmon': { - 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-tempmon/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-tempmon/files/v{new_version}/CHANGES.rst', - }, - 'tailbone-onager': { - 'commit_url': 'https://kallithea.rattailproject.org/rattail-restricted/tailbone-onager/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-restricted/tailbone-onager/files/v{new_version}/CHANGELOG.md', - }, - 'rattail_woocommerce': { - 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-woocommerce/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-woocommerce/files/v{new_version}/CHANGES.rst', - }, - 'tailbone_woocommerce': { - 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-woocommerce/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-woocommerce/files/v{new_version}/CHANGES.rst', - }, - 'tailbone_theo': { - 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/theo/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-project/theo/files/v{new_version}/CHANGES.rst', - }, + project_map = { + 'onager': 'onager', + 'pyCOREPOS': 'pycorepos', + 'rattail': 'rattail', + 'rattail_corepos': 'rattail-corepos', + 'rattail-onager': 'rattail-onager', + 'rattail_tempmon': 'rattail-tempmon', + 'rattail_woocommerce': 'rattail-woocommerce', + 'Tailbone': 'tailbone', + 'tailbone_corepos': 'tailbone-corepos', + 'tailbone-onager': 'tailbone-onager', + 'tailbone_theo': 'theo', + 'tailbone_woocommerce': 'tailbone-woocommerce', } + + projects = {} + for name, repo in project_map.items(): + projects[name] = { + 'commit_url': f'https://forgejo.wuttaproject.org/rattail/{repo}/compare/{{old_version}}...{{new_version}}', + 'release_url': f'https://forgejo.wuttaproject.org/rattail/{repo}/src/tag/v{{new_version}}/CHANGELOG.md', + } return projects def get_changelog_url(self, project, old_version, new_version): From 0b4efae392ff35ca4a0d0ac1ea59859b25e084f2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 15 Sep 2024 10:56:01 -0500 Subject: [PATCH 1649/1681] =?UTF-8?q?bump:=20version=200.21.9=20=E2=86=92?= =?UTF-8?q?=200.21.10?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 9 +++++++++ pyproject.toml | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da628cf3..73c8b72b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.21.10 (2024-09-15) + +### Fix + +- update project repo links, kallithea -> forgejo +- use better icon for submit button on login page +- wrap notes text for batch view +- expose datasync consumer batch size via configure page + ## v0.21.9 (2024-08-28) ### Fix diff --git a/pyproject.toml b/pyproject.toml index a1c96dd4..3368842b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.21.9" +version = "0.21.10" description = "Backoffice Web Application for Rattail" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From 2308d2e2408ea5429ce196ed6c193241a21742a8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 16 Sep 2024 12:55:58 -0500 Subject: [PATCH 1650/1681] fix: become/stop root should redirect to previous url for default theme; butterball already did that --- tailbone/templates/base.mako | 18 ++++++++++++++++-- tailbone/templates/themes/butterball/base.mako | 16 ++-------------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index 86b1ba1d..8228f823 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -632,9 +632,23 @@ % endif <div class="navbar-dropdown"> % if request.is_root: - ${h.link_to("Stop being root", url('stop_root'), class_='navbar-item root-user')} + ${h.form(url('stop_root'), ref='stopBeingRootForm')} + ${h.csrf_token(request)} + <input type="hidden" name="referrer" value="${request.current_route_url()}" /> + <a @click="$refs.stopBeingRootForm.submit()" + class="navbar-item root-user"> + Stop being root + </a> + ${h.end_form()} % elif request.is_admin: - ${h.link_to("Become root", url('become_root'), class_='navbar-item root-user')} + ${h.form(url('become_root'), ref='startBeingRootForm')} + ${h.csrf_token(request)} + <input type="hidden" name="referrer" value="${request.current_route_url()}" /> + <a @click="$refs.startBeingRootForm.submit()" + class="navbar-item root-user"> + Become root + </a> + ${h.end_form()} % endif % if messaging_enabled: ${h.link_to("Messages{}".format(" ({})".format(inbox_count) if inbox_count else ''), url('messages.inbox'), class_='navbar-item')} diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako index 14616474..b69eacfb 100644 --- a/tailbone/templates/themes/butterball/base.mako +++ b/tailbone/templates/themes/butterball/base.mako @@ -909,7 +909,7 @@ ${h.form(url('stop_root'), ref='stopBeingRootForm')} ${h.csrf_token(request)} <input type="hidden" name="referrer" value="${request.current_route_url()}" /> - <a @click="stopBeingRoot()" + <a @click="$refs.stopBeingRootForm.submit()" class="navbar-item has-background-danger has-text-white"> Stop being root </a> @@ -918,7 +918,7 @@ ${h.form(url('become_root'), ref='startBeingRootForm')} ${h.csrf_token(request)} <input type="hidden" name="referrer" value="${request.current_route_url()}" /> - <a @click="startBeingRoot()" + <a @click="$refs.startBeingRootForm.submit()" class="navbar-item has-background-danger has-text-white"> Become root </a> @@ -1103,18 +1103,6 @@ const key = 'menu_' + hash + '_shown' this[key] = !this[key] }, - - % if request.is_admin: - - startBeingRoot() { - this.$refs.startBeingRootForm.submit() - }, - - stopBeingRoot() { - this.$refs.stopBeingRootForm.submit() - }, - - % endif }, } From d520f64fee9c2c083e867816e2c90e56028c41f8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 3 Oct 2024 08:56:52 -0500 Subject: [PATCH 1651/1681] fix: custom method for adding grid action since for now, we are using custom grid action class --- tailbone/grids/core.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index c6257d4b..73de42c6 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -1544,6 +1544,11 @@ class Grid(WuttaGrid): self._table_data = results return self._table_data + # TODO: remove this when we use upstream GridAction + def add_action(self, key, **kwargs): + """ """ + self.actions.append(GridAction(self.request, key, **kwargs)) + def set_action_urls(self, row, rowobj, i): """ Pre-generate all action URLs for the given data row. Meant for use From c6365f263166c53934fd81083c01d2bceccb01ab Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 3 Oct 2024 09:05:46 -0500 Subject: [PATCH 1652/1681] =?UTF-8?q?bump:=20version=200.21.10=20=E2=86=92?= =?UTF-8?q?=200.21.11?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73c8b72b..3c31ae92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.21.11 (2024-10-03) + +### Fix + +- custom method for adding grid action +- become/stop root should redirect to previous url + ## v0.21.10 (2024-09-15) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 3368842b..5b63a71f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.21.10" +version = "0.21.11" description = "Backoffice Web Application for Rattail" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From 072db39233dd8c0c22e429202f446cd67f578863 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 22 Oct 2024 14:26:10 -0500 Subject: [PATCH 1653/1681] feat: add support for new ordering batch from parsed file --- tailbone/api/batch/receiving.py | 30 +- tailbone/templates/ordering/configure.mako | 74 +++++ tailbone/templates/receiving/configure.mako | 8 +- tailbone/views/batch/core.py | 5 +- tailbone/views/purchasing/batch.py | 290 +++++++++++++++++++- tailbone/views/purchasing/ordering.py | 101 ++++++- tailbone/views/purchasing/receiving.py | 219 +++------------ 7 files changed, 498 insertions(+), 229 deletions(-) create mode 100644 tailbone/templates/ordering/configure.mako diff --git a/tailbone/api/batch/receiving.py b/tailbone/api/batch/receiving.py index daa4290f..b23bff55 100644 --- a/tailbone/api/batch/receiving.py +++ b/tailbone/api/batch/receiving.py @@ -29,8 +29,7 @@ import logging import humanize import sqlalchemy as sa -from rattail.db import model -from rattail.util import pretty_quantity +from rattail.db.model import PurchaseBatch, PurchaseBatchRow from cornice import Service from deform import widget as dfwidget @@ -45,7 +44,7 @@ log = logging.getLogger(__name__) class ReceivingBatchViews(APIBatchView): - model_class = model.PurchaseBatch + model_class = PurchaseBatch default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler' route_prefix = 'receivingbatchviews' permission_prefix = 'receiving' @@ -55,7 +54,8 @@ class ReceivingBatchViews(APIBatchView): supports_execute = True def base_query(self): - query = super(ReceivingBatchViews, self).base_query() + model = self.app.model + query = super().base_query() query = query.filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING) return query @@ -85,7 +85,7 @@ class ReceivingBatchViews(APIBatchView): # assume "receive from PO" if given a PO key if data.get('purchase_key'): - data['receiving_workflow'] = 'from_po' + data['workflow'] = 'from_po' return super().create_object(data) @@ -120,6 +120,7 @@ class ReceivingBatchViews(APIBatchView): return self._get(obj=batch) def eligible_purchases(self): + model = self.app.model uuid = self.request.params.get('vendor_uuid') vendor = self.Session.get(model.Vendor, uuid) if uuid else None if not vendor: @@ -176,7 +177,7 @@ class ReceivingBatchViews(APIBatchView): class ReceivingBatchRowViews(APIBatchRowView): - model_class = model.PurchaseBatchRow + model_class = PurchaseBatchRow default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler' route_prefix = 'receiving.rows' permission_prefix = 'receiving' @@ -185,7 +186,8 @@ class ReceivingBatchRowViews(APIBatchRowView): supports_quick_entry = True def make_filter_spec(self): - filters = super(ReceivingBatchRowViews, self).make_filter_spec() + model = self.app.model + filters = super().make_filter_spec() if filters: # must translate certain convenience filters @@ -296,11 +298,11 @@ class ReceivingBatchRowViews(APIBatchRowView): return filters def normalize(self, row): - data = super(ReceivingBatchRowViews, self).normalize(row) + data = super().normalize(row) + model = self.app.model batch = row.batch - app = self.get_rattail_app() - prodder = app.get_products_handler() + prodder = self.app.get_products_handler() data['product_uuid'] = row.product_uuid data['item_id'] = row.item_id @@ -375,7 +377,7 @@ class ReceivingBatchRowViews(APIBatchRowView): if accounted_for: # some product accounted for; button should receive "remainder" only if remainder: - remainder = pretty_quantity(remainder) + remainder = self.app.render_quantity(remainder) data['quick_receive_quantity'] = remainder data['quick_receive_text'] = "Receive Remainder ({} {})".format( remainder, data['unit_uom']) @@ -386,7 +388,7 @@ class ReceivingBatchRowViews(APIBatchRowView): else: # nothing yet accounted for, button should receive "all" if not remainder: log.warning("quick receive remainder is empty for row %s", row.uuid) - remainder = pretty_quantity(remainder) + remainder = self.app.render_quantity(remainder) data['quick_receive_quantity'] = remainder data['quick_receive_text'] = "Receive ALL ({} {})".format( remainder, data['unit_uom']) @@ -414,7 +416,7 @@ class ReceivingBatchRowViews(APIBatchRowView): data['received_alert'] = None if self.batch_handler.get_units_confirmed(row): msg = "You have already received some of this product; last update was {}.".format( - humanize.naturaltime(app.make_utc() - row.modified)) + humanize.naturaltime(self.app.make_utc() - row.modified)) data['received_alert'] = msg return data @@ -423,6 +425,8 @@ class ReceivingBatchRowViews(APIBatchRowView): """ View which handles "receiving" against a particular batch row. """ + model = self.app.model + # first do basic input validation schema = ReceiveRow().bind(session=self.Session()) form = forms.Form(schema=schema, request=self.request) diff --git a/tailbone/templates/ordering/configure.mako b/tailbone/templates/ordering/configure.mako new file mode 100644 index 00000000..dc505c42 --- /dev/null +++ b/tailbone/templates/ordering/configure.mako @@ -0,0 +1,74 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="form_content()"> + + <h3 class="block is-size-3">Workflows</h3> + <div class="block" style="padding-left: 2rem;"> + + <p class="block"> + Users can only choose from the workflows enabled below. + </p> + + <b-field> + <b-checkbox name="rattail.batch.purchase.allow_ordering_from_scratch" + v-model="simpleSettings['rattail.batch.purchase.allow_ordering_from_scratch']" + native-value="true" + @input="settingsNeedSaved = true"> + From Scratch + </b-checkbox> + </b-field> + + <b-field> + <b-checkbox name="rattail.batch.purchase.allow_ordering_from_file" + v-model="simpleSettings['rattail.batch.purchase.allow_ordering_from_file']" + native-value="true" + @input="settingsNeedSaved = true"> + From Order File + </b-checkbox> + </b-field> + + </div> + + <h3 class="block is-size-3">Vendors</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field message="If not set, user must choose a "supported" vendor."> + <b-checkbox name="rattail.batch.purchase.allow_ordering_any_vendor" + v-model="simpleSettings['rattail.batch.purchase.allow_ordering_any_vendor']" + native-value="true" + @input="settingsNeedSaved = true"> + Allow ordering for <span class="has-text-weight-bold">any</span> vendor + </b-checkbox> + </b-field> + + </div> + + <h3 class="block is-size-3">Order Parsers</h3> + <div class="block" style="padding-left: 2rem;"> + + <p class="block"> + Only the selected file parsers will be exposed to users. + </p> + + % for Parser in order_parsers: + <b-field message="${Parser.key}"> + <b-checkbox name="order_parser_${Parser.key}" + v-model="orderParsers['${Parser.key}']" + native-value="true" + @input="settingsNeedSaved = true"> + ${Parser.title} + </b-checkbox> + </b-field> + % endfor + + </div> + +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + ThisPageData.orderParsers = ${json.dumps(order_parsers_data)|n} + </script> +</%def> diff --git a/tailbone/templates/receiving/configure.mako b/tailbone/templates/receiving/configure.mako index f613e13e..a36dde43 100644 --- a/tailbone/templates/receiving/configure.mako +++ b/tailbone/templates/receiving/configure.mako @@ -69,12 +69,12 @@ <h3 class="block is-size-3">Vendors</h3> <div class="block" style="padding-left: 2rem;"> - <b-field message="If set, user must choose a "supported" vendor; otherwise they may choose "any" vendor."> - <b-checkbox name="rattail.batch.purchase.supported_vendors_only" - v-model="simpleSettings['rattail.batch.purchase.supported_vendors_only']" + <b-field message="If not set, user must choose a "supported" vendor."> + <b-checkbox name="rattail.batch.purchase.allow_receiving_any_vendor" + v-model="simpleSettings['rattail.batch.purchase.allow_receiving_any_vendor']" native-value="true" @input="settingsNeedSaved = true"> - Only allow batch for "supported" vendors + Allow receiving for <span class="has-text-weight-bold">any</span> vendor </b-checkbox> </b-field> diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index a75fda1c..c162b579 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -46,10 +46,11 @@ import colander from deform import widget as dfwidget from webhelpers2.html import HTML, tags +from wuttaweb.util import render_csrf_token + from tailbone import forms, grids from tailbone.db import Session from tailbone.views import MasterView -from tailbone.util import csrf_token log = logging.getLogger(__name__) @@ -441,7 +442,7 @@ class BatchMasterView(MasterView): form = [ begin_form, - csrf_token(self.request), + render_csrf_token(self.request), tags.hidden('complete', value=value), submit, tags.end_form(), diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index 590b9af5..5e00704e 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -24,6 +24,8 @@ Base class for purchasing batch views """ +import warnings + from rattail.db.model import PurchaseBatch, PurchaseBatchRow import colander @@ -67,6 +69,8 @@ class PurchasingBatchView(BatchMasterView): 'store', 'buyer', 'vendor', + 'description', + 'workflow', 'department', 'purchase', 'vendor_email', @@ -158,6 +162,174 @@ class PurchasingBatchView(BatchMasterView): def batch_mode(self): raise NotImplementedError("Please define `batch_mode` for your purchasing batch view") + def get_supported_workflows(self): + """ + Return the supported "create batch" workflows. + """ + enum = self.app.enum + if self.batch_mode == enum.PURCHASE_BATCH_MODE_ORDERING: + return self.batch_handler.supported_ordering_workflows() + elif self.batch_mode == enum.PURCHASE_BATCH_MODE_RECEIVING: + return self.batch_handler.supported_receiving_workflows() + elif self.batch_mode == enum.PURCHASE_BATCH_MODE_COSTING: + return self.batch_handler.supported_costing_workflows() + raise ValueError("unknown batch mode") + + def allow_any_vendor(self): + """ + Return boolean indicating whether creating a batch for "any" + vendor is allowed, vs. only supported vendors. + """ + enum = self.app.enum + + if self.batch_mode == enum.PURCHASE_BATCH_MODE_ORDERING: + return self.batch_handler.allow_ordering_any_vendor() + + elif self.batch_mode == enum.PURCHASE_BATCH_MODE_RECEIVING: + value = self.config.get_bool('rattail.batch.purchase.allow_receiving_any_vendor') + if value is not None: + return value + value = self.config.get_bool('rattail.batch.purchase.supported_vendors_only') + if value is not None: + warnings.warn("setting rattail.batch.purchase.supported_vendors_only is deprecated; " + "please use rattail.batch.purchase.allow_receiving_any_vendor instead", + DeprecationWarning) + # nb. must negate this setting + return not value + return False + + raise ValueError("unknown batch mode") + + def get_supported_vendors(self): + """ + Return the supported vendors for creating a batch. + """ + return [] + + def create(self, form=None, **kwargs): + """ + Custom view for creating a new batch. We split the process + into two steps, 1) choose workflow and 2) create batch. This + is because the specific form details for creating a batch will + depend on which "type" of batch creation is to be done, and + it's much easier to keep conditional logic for that in the + server instead of client-side etc. + """ + model = self.app.model + enum = self.app.enum + route_prefix = self.get_route_prefix() + + workflows = self.get_supported_workflows() + valid_workflows = [workflow['workflow_key'] + for workflow in workflows] + + # if user has already identified their desired workflow, then + # we can just farm out to the default logic. we will of + # course configure our form differently, based on workflow, + # but this create() method at least will not need + # customization for that. + if self.request.matched_route.name.endswith('create_workflow'): + + redirect = self.redirect(self.request.route_url(f'{route_prefix}.create')) + + # however we do have one more thing to check - the workflow + # requested must of course be valid! + workflow_key = self.request.matchdict['workflow_key'] + if workflow_key not in valid_workflows: + self.request.session.flash(f"Not a supported workflow: {workflow_key}", 'error') + raise redirect + + # also, we require vendor to be correctly identified. if + # someone e.g. navigates to a URL by accident etc. we want + # to gracefully handle and redirect + uuid = self.request.matchdict['vendor_uuid'] + vendor = self.Session.get(model.Vendor, uuid) + if not vendor: + self.request.session.flash("Invalid vendor selection. " + "Please choose an existing vendor.", + 'warning') + raise redirect + + # okay now do the normal thing, per workflow + return super().create(**kwargs) + + # on the other hand, if caller provided a form, that means we are in + # the middle of some other custom workflow, e.g. "add child to truck + # dump parent" or some such. in which case we also defer to the normal + # logic, so as to not interfere with that. + if form: + return super().create(form=form, **kwargs) + + # okay, at this point we need the user to select a vendor and workflow + self.creating = True + context = {} + + # form to accept user choice of vendor/workflow + schema = colander.Schema() + schema.add(colander.SchemaNode(colander.String(), name='vendor')) + schema.add(colander.SchemaNode(colander.String(), name='workflow', + validator=colander.OneOf(valid_workflows))) + factory = self.get_form_factory() + form = factory(schema=schema, request=self.request) + + # configure vendor field + vendor_handler = self.app.get_vendor_handler() + if self.allow_any_vendor(): + # user may choose *any* available vendor + use_dropdown = vendor_handler.choice_uses_dropdown() + if use_dropdown: + vendors = self.Session.query(model.Vendor)\ + .order_by(model.Vendor.id)\ + .all() + vendor_values = [(vendor.uuid, f"({vendor.id}) {vendor.name}") + for vendor in vendors] + form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values)) + if len(vendors) == 1: + form.set_default('vendor', vendors[0].uuid) + else: + vendor_display = "" + if self.request.method == 'POST': + if self.request.POST.get('vendor'): + vendor = self.Session.get(model.Vendor, self.request.POST['vendor']) + if vendor: + vendor_display = str(vendor) + vendors_url = self.request.route_url('vendors.autocomplete') + form.set_widget('vendor', forms.widgets.JQueryAutocompleteWidget( + field_display=vendor_display, service_url=vendors_url)) + else: # only "supported" vendors allowed + vendors = self.get_supported_vendors() + vendor_values = [(vendor.uuid, vendor_handler.render_vendor(vendor)) + for vendor in vendors] + form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values)) + form.set_validator('vendor', self.valid_vendor_uuid) + + # configure workflow field + values = [(workflow['workflow_key'], workflow['display']) + for workflow in workflows] + form.set_widget('workflow', + dfwidget.SelectWidget(values=values)) + if len(workflows) == 1: + form.set_default('workflow', workflows[0]['workflow_key']) + + form.submit_label = "Continue" + form.cancel_url = self.get_index_url() + + # if form validates, that means user has chosen a creation + # type, so we just redirect to the appropriate "new batch of + # type X" page + if form.validate(): + workflow_key = form.validated['workflow'] + vendor_uuid = form.validated['vendor'] + url = self.request.route_url(f'{route_prefix}.create_workflow', + workflow_key=workflow_key, + vendor_uuid=vendor_uuid) + raise self.redirect(url) + + context['form'] = form + if hasattr(form, 'make_deform_form'): + context['dform'] = form.make_deform_form() + return self.render_to_response('create', context) + def query(self, session): model = self.model return session.query(model.PurchaseBatch)\ @@ -226,20 +398,40 @@ class PurchasingBatchView(BatchMasterView): def configure_form(self, f): super().configure_form(f) - model = self.model + model = self.app.model + enum = self.app.enum + route_prefix = self.get_route_prefix() + + today = self.app.today() batch = f.model_instance - app = self.get_rattail_app() - today = app.localtime().date() + workflow = self.request.matchdict.get('workflow_key') + vendor_handler = self.app.get_vendor_handler() # mode - f.set_enum('mode', self.enum.PURCHASE_BATCH_MODE) + f.set_enum('mode', enum.PURCHASE_BATCH_MODE) + + # workflow + if self.creating: + if workflow: + f.set_widget('workflow', dfwidget.HiddenWidget()) + f.set_default('workflow', workflow) + f.set_hidden('workflow') + # nb. show readonly '_workflow' + f.insert_after('workflow', '_workflow') + f.set_readonly('_workflow') + f.set_renderer('_workflow', self.render_workflow) + else: + f.set_readonly('workflow') + f.set_renderer('workflow', self.render_workflow) + else: + f.remove('workflow') # store - single_store = self.rattail_config.single_store() + single_store = self.config.single_store() if self.creating: f.replace('store', 'store_uuid') if single_store: - store = self.rattail_config.get_store(self.Session()) + store = self.config.get_store(self.Session()) f.set_widget('store_uuid', dfwidget.HiddenWidget()) f.set_default('store_uuid', store.uuid) f.set_hidden('store_uuid') @@ -263,7 +455,6 @@ class PurchasingBatchView(BatchMasterView): if self.creating: f.replace('vendor', 'vendor_uuid') f.set_label('vendor_uuid', "Vendor") - vendor_handler = app.get_vendor_handler() use_dropdown = vendor_handler.choice_uses_dropdown() if use_dropdown: vendors = self.Session.query(model.Vendor)\ @@ -313,7 +504,7 @@ class PurchasingBatchView(BatchMasterView): if buyer: buyer_display = str(buyer) elif self.creating: - buyer = app.get_employee(self.request.user) + buyer = self.app.get_employee(self.request.user) if buyer: buyer_display = str(buyer) f.set_default('buyer_uuid', buyer.uuid) @@ -324,6 +515,30 @@ class PurchasingBatchView(BatchMasterView): field_display=buyer_display, service_url=buyers_url)) f.set_label('buyer_uuid', "Buyer") + # order_file + if self.creating: + f.set_type('order_file', 'file', required=False) + else: + f.set_readonly('order_file') + f.set_renderer('order_file', self.render_downloadable_file) + + # order_parser_key + if self.creating: + kwargs = {} + if 'vendor_uuid' in self.request.matchdict: + vendor = self.Session.get(model.Vendor, + self.request.matchdict['vendor_uuid']) + if vendor: + kwargs['vendor'] = vendor + parsers = vendor_handler.get_supported_order_parsers(**kwargs) + parser_values = [(p.key, p.title) for p in parsers] + if len(parsers) == 1: + f.set_default('order_parser_key', parsers[0].key) + f.set_widget('order_parser_key', dfwidget.SelectWidget(values=parser_values)) + f.set_label('order_parser_key', "Order Parser") + else: + f.remove_field('order_parser_key') + # invoice_file if self.creating: f.set_type('invoice_file', 'file', required=False) @@ -341,7 +556,7 @@ class PurchasingBatchView(BatchMasterView): if vendor: kwargs['vendor'] = vendor - parsers = self.handler.get_supported_invoice_parsers(**kwargs) + parsers = self.batch_handler.get_supported_invoice_parsers(**kwargs) parser_values = [(p.key, p.display) for p in parsers] if len(parsers) == 1: f.set_default('invoice_parser_key', parsers[0].key) @@ -400,6 +615,35 @@ class PurchasingBatchView(BatchMasterView): 'vendor_contact', 'status_code') + # tweak some things if we are in "step 2" of creating new batch + if self.creating and workflow: + + # display vendor but do not allow changing + vendor = self.Session.get(model.Vendor, self.request.matchdict['vendor_uuid']) + if not vendor: + raise ValueError(f"vendor not found: {self.request.matchdict['vendor_uuid']}") + f.set_readonly('vendor_uuid') + f.set_default('vendor_uuid', str(vendor)) + + # cancel should take us back to choosing a workflow + f.cancel_url = self.request.route_url(f'{route_prefix}.create') + + def render_workflow(self, batch, field): + key = self.request.matchdict['workflow_key'] + info = self.get_workflow_info(key) + if info: + return info['display'] + + def get_workflow_info(self, key): + enum = self.app.enum + if self.batch_mode == enum.PURCHASE_BATCH_MODE_ORDERING: + return self.batch_handler.ordering_workflow_info(key) + elif self.batch_mode == enum.PURCHASE_BATCH_MODE_RECEIVING: + return self.batch_handler.receiving_workflow_info(key) + elif self.batch_mode == enum.PURCHASE_BATCH_MODE_COSTING: + return self.batch_handler.costing_workflow_info(key) + raise ValueError("unknown batch mode") + def render_store(self, batch, field): store = batch.store if not store: @@ -515,10 +759,12 @@ class PurchasingBatchView(BatchMasterView): def get_batch_kwargs(self, batch, **kwargs): kwargs = super().get_batch_kwargs(batch, **kwargs) - model = self.model + model = self.app.model kwargs['mode'] = self.batch_mode + kwargs['workflow'] = self.request.POST['workflow'] kwargs['truck_dump'] = batch.truck_dump + kwargs['order_parser_key'] = batch.order_parser_key kwargs['invoice_parser_key'] = batch.invoice_parser_key if batch.store: @@ -536,6 +782,11 @@ class PurchasingBatchView(BatchMasterView): elif batch.vendor_uuid: kwargs['vendor_uuid'] = batch.vendor_uuid + # must pull vendor from URL if it was not in form data + if 'vendor_uuid' not in kwargs and 'vendor' not in kwargs: + if 'vendor_uuid' in self.request.matchdict: + kwargs['vendor_uuid'] = self.request.matchdict['vendor_uuid'] + if batch.department: kwargs['department'] = batch.department elif batch.department_uuid: @@ -919,6 +1170,25 @@ class PurchasingBatchView(BatchMasterView): # # otherwise just view batch again # return self.get_action_url('view', batch) + @classmethod + def defaults(cls, config): + cls._purchase_batch_defaults(config) + cls._batch_defaults(config) + cls._defaults(config) + + @classmethod + def _purchase_batch_defaults(cls, config): + route_prefix = cls.get_route_prefix() + url_prefix = cls.get_url_prefix() + permission_prefix = cls.get_permission_prefix() + + # new batch using workflow X + config.add_route(f'{route_prefix}.create_workflow', + f'{url_prefix}/new/{{workflow_key}}/{{vendor_uuid}}') + config.add_view(cls, attr='create', + route_name=f'{route_prefix}.create_workflow', + permission=f'{permission_prefix}.create') + class NewProduct(colander.Schema): diff --git a/tailbone/views/purchasing/ordering.py b/tailbone/views/purchasing/ordering.py index 2e24eebb..c7cc7bfc 100644 --- a/tailbone/views/purchasing/ordering.py +++ b/tailbone/views/purchasing/ordering.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -28,14 +28,10 @@ import os import json import openpyxl -from sqlalchemy import orm -from rattail.db import model, api from rattail.core import Object -from rattail.time import localtime - -from webhelpers2.html import tags +from tailbone.db import Session from tailbone.views.purchasing import PurchasingBatchView @@ -51,6 +47,8 @@ class OrderingBatchView(PurchasingBatchView): rows_editable = True has_worksheet = True default_help_url = 'https://rattailproject.org/docs/rattail-manual/features/purchasing/ordering/index.html' + downloadable = True + configurable = True labels = { 'po_total_calculated': "PO Total", @@ -59,9 +57,14 @@ class OrderingBatchView(PurchasingBatchView): form_fields = [ 'id', 'store', - 'buyer', 'vendor', + 'description', + 'workflow', + 'order_file', + 'order_parser_key', + 'buyer', 'department', + 'params', 'purchase', 'vendor_email', 'vendor_fax', @@ -132,15 +135,26 @@ class OrderingBatchView(PurchasingBatchView): return self.enum.PURCHASE_BATCH_MODE_ORDERING def configure_form(self, f): - super(OrderingBatchView, self).configure_form(f) + super().configure_form(f) batch = f.model_instance + workflow = self.request.matchdict.get('workflow_key') # purchase if self.creating or not batch.executed or not batch.purchase: f.remove_field('purchase') + # now that all fields are setup, some final tweaks based on workflow + if self.creating and workflow: + + if workflow == 'from_scratch': + f.remove('order_file', + 'order_parser_key') + + elif workflow == 'from_file': + f.set_required('order_file') + def get_batch_kwargs(self, batch, **kwargs): - kwargs = super(OrderingBatchView, self).get_batch_kwargs(batch, **kwargs) + kwargs = super().get_batch_kwargs(batch, **kwargs) kwargs['ship_method'] = batch.ship_method kwargs['notes_to_vendor'] = batch.notes_to_vendor return kwargs @@ -155,7 +169,7 @@ class OrderingBatchView(PurchasingBatchView): * ``cases_ordered`` * ``units_ordered`` """ - super(OrderingBatchView, self).configure_row_form(f) + super().configure_row_form(f) # when editing, only certain fields should allow changes if self.editing: @@ -308,7 +322,7 @@ class OrderingBatchView(PurchasingBatchView): title = self.get_instance_title(batch) order_date = batch.date_ordered if not order_date: - order_date = localtime(self.rattail_config).date() + order_date = self.app.today() return self.render_to_response('worksheet', { 'batch': batch, @@ -369,6 +383,7 @@ class OrderingBatchView(PurchasingBatchView): of being updated. If a matching row is not found, it will not be created. """ + model = self.app.model batch = self.get_instance() try: @@ -478,13 +493,75 @@ class OrderingBatchView(PurchasingBatchView): return self.file_response(path) def get_execute_success_url(self, batch, result, **kwargs): + model = self.app.model if isinstance(result, model.Purchase): return self.request.route_url('purchases.view', uuid=result.uuid) - return super(OrderingBatchView, self).get_execute_success_url(batch, result, **kwargs) + return super().get_execute_success_url(batch, result, **kwargs) + + def configure_get_simple_settings(self): + return [ + + # workflows + {'section': 'rattail.batch', + 'option': 'purchase.allow_ordering_from_scratch', + 'type': bool, + 'default': True}, + {'section': 'rattail.batch', + 'option': 'purchase.allow_ordering_from_file', + 'type': bool, + 'default': True}, + + # vendors + {'section': 'rattail.batch', + 'option': 'purchase.allow_ordering_any_vendor', + 'type': bool, + 'default': True, + }, + ] + + def configure_get_context(self): + context = super().configure_get_context() + vendor_handler = self.app.get_vendor_handler() + + Parsers = vendor_handler.get_all_order_parsers() + Supported = vendor_handler.get_supported_order_parsers() + context['order_parsers'] = Parsers + context['order_parsers_data'] = dict([(Parser.key, Parser in Supported) + for Parser in Parsers]) + + return context + + def configure_gather_settings(self, data): + settings = super().configure_gather_settings(data) + vendor_handler = self.app.get_vendor_handler() + + supported = [] + for Parser in vendor_handler.get_all_order_parsers(): + name = f'order_parser_{Parser.key}' + if data.get(name) == 'true': + supported.append(Parser.key) + settings.append({'name': 'rattail.vendors.supported_order_parsers', + 'value': ', '.join(supported)}) + + return settings + + def configure_remove_settings(self): + super().configure_remove_settings() + + names = [ + 'rattail.vendors.supported_order_parsers', + ] + + # nb. using thread-local session here; we do not use + # self.Session b/c it may not point to Rattail + session = Session() + for name in names: + self.app.delete_setting(session, name) @classmethod def defaults(cls, config): cls._ordering_defaults(config) + cls._purchase_batch_defaults(config) cls._batch_defaults(config) cls._defaults(config) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index de19a2b9..01858c98 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -108,7 +108,7 @@ class ReceivingBatchView(PurchasingBatchView): 'store', 'vendor', 'description', - 'receiving_workflow', + 'workflow', 'truck_dump', 'truck_dump_children_first', 'truck_dump_children', @@ -235,135 +235,18 @@ class ReceivingBatchView(PurchasingBatchView): if not self.handler.allow_truck_dump_receiving(): g.remove('truck_dump') - def create(self, form=None, **kwargs): - """ - Custom view for creating a new receiving batch. We split the process - into two steps, 1) choose and 2) create. This is because the specific - form details for creating a batch will depend on which "type" of batch - creation is to be done, and it's much easier to keep conditional logic - for that in the server instead of client-side etc. - - See also - :meth:`tailbone.views.purchasing.costing:CostingBatchView.create()` - which uses similar logic. - """ - model = self.model - route_prefix = self.get_route_prefix() - workflows = self.handler.supported_receiving_workflows() - valid_workflows = [workflow['workflow_key'] - for workflow in workflows] - - # if user has already identified their desired workflow, then we can - # just farm out to the default logic. we will of course configure our - # form differently, based on workflow, but this create() method at - # least will not need customization for that. - if self.request.matched_route.name.endswith('create_workflow'): - - redirect = self.redirect(self.request.route_url('{}.create'.format(route_prefix))) - - # however we do have one more thing to check - the workflow - # requested must of course be valid! - workflow_key = self.request.matchdict['workflow_key'] - if workflow_key not in valid_workflows: - self.request.session.flash( - "Not a supported workflow: {}".format(workflow_key), - 'error') - raise redirect - - # also, we require vendor to be correctly identified. if - # someone e.g. navigates to a URL by accident etc. we want - # to gracefully handle and redirect - uuid = self.request.matchdict['vendor_uuid'] - vendor = self.Session.get(model.Vendor, uuid) - if not vendor: - self.request.session.flash("Invalid vendor selection. " - "Please choose an existing vendor.", - 'warning') - raise redirect - - # okay now do the normal thing, per workflow - return super().create(**kwargs) - - # on the other hand, if caller provided a form, that means we are in - # the middle of some other custom workflow, e.g. "add child to truck - # dump parent" or some such. in which case we also defer to the normal - # logic, so as to not interfere with that. - if form: - return super().create(form=form, **kwargs) - - # okay, at this point we need the user to select a vendor and workflow - self.creating = True - context = {} - - # form to accept user choice of vendor/workflow - schema = NewReceivingBatch().bind(valid_workflows=valid_workflows) - form = forms.Form(schema=schema, request=self.request) - - # configure vendor field - app = self.get_rattail_app() - vendor_handler = app.get_vendor_handler() - if self.rattail_config.getbool('rattail.batch', 'purchase.supported_vendors_only'): - # only show vendors for which we have dedicated invoice parsers - vendors = {} - for parser in self.batch_handler.get_supported_invoice_parsers(): - if parser.vendor_key: - vendor = vendor_handler.get_vendor(self.Session(), - parser.vendor_key) - if vendor: - vendors[vendor.uuid] = vendor - vendors = sorted(vendors.values(), key=lambda v: v.name) - vendor_values = [(vendor.uuid, vendor_handler.render_vendor(vendor)) - for vendor in vendors] - form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values)) - else: - # user may choose *any* available vendor - use_dropdown = vendor_handler.choice_uses_dropdown() - if use_dropdown: - vendors = self.Session.query(model.Vendor)\ - .order_by(model.Vendor.id)\ - .all() - vendor_values = [(vendor.uuid, "({}) {}".format(vendor.id, vendor.name)) - for vendor in vendors] - form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values)) - if len(vendors) == 1: - form.set_default('vendor', vendors[0].uuid) - else: - vendor_display = "" - if self.request.method == 'POST': - if self.request.POST.get('vendor'): - vendor = self.Session.get(model.Vendor, self.request.POST['vendor']) - if vendor: - vendor_display = str(vendor) - vendors_url = self.request.route_url('vendors.autocomplete') - form.set_widget('vendor', forms.widgets.JQueryAutocompleteWidget( - field_display=vendor_display, service_url=vendors_url)) - form.set_validator('vendor', self.valid_vendor_uuid) - - # configure workflow field - values = [(workflow['workflow_key'], workflow['display']) - for workflow in workflows] - form.set_widget('workflow', - dfwidget.SelectWidget(values=values)) - if len(workflows) == 1: - form.set_default('workflow', workflows[0]['workflow_key']) - - form.submit_label = "Continue" - form.cancel_url = self.get_index_url() - - # if form validates, that means user has chosen a creation type, so we - # just redirect to the appropriate "new batch of type X" page - if form.validate(): - workflow_key = form.validated['workflow'] - vendor_uuid = form.validated['vendor'] - url = self.request.route_url('{}.create_workflow'.format(route_prefix), - workflow_key=workflow_key, - vendor_uuid=vendor_uuid) - raise self.redirect(url) - - context['form'] = form - if hasattr(form, 'make_deform_form'): - context['dform'] = form.make_deform_form() - return self.render_to_response('create', context) + def get_supported_vendors(self): + """ """ + vendor_handler = self.app.get_vendor_handler() + vendors = {} + for parser in self.batch_handler.get_supported_invoice_parsers(): + if parser.vendor_key: + vendor = vendor_handler.get_vendor(self.Session(), + parser.vendor_key) + if vendor: + vendors[vendor.uuid] = vendor + vendors = sorted(vendors.values(), key=lambda v: v.name) + return vendors def row_deletable(self, row): @@ -404,13 +287,7 @@ class ReceivingBatchView(PurchasingBatchView): # cancel should take us back to choosing a workflow f.cancel_url = self.request.route_url('{}.create'.format(route_prefix)) - # receiving_workflow - if self.creating and workflow: - f.set_readonly('receiving_workflow') - f.set_renderer('receiving_workflow', self.render_receiving_workflow) - else: - f.remove('receiving_workflow') - + # TODO: remove this # batch_type if self.creating: f.set_widget('batch_type', dfwidget.HiddenWidget()) @@ -525,7 +402,7 @@ class ReceivingBatchView(PurchasingBatchView): # multiple invoice files (if applicable) if (not self.creating - and batch.get_param('receiving_workflow') == 'from_multi_invoice'): + and batch.get_param('workflow') == 'from_multi_invoice'): if 'invoice_files' not in f: f.insert_before('invoice_file', 'invoice_files') @@ -624,12 +501,6 @@ class ReceivingBatchView(PurchasingBatchView): items.append(HTML.tag('li', c=[link])) return HTML.tag('ul', c=items) - def render_receiving_workflow(self, batch, field): - key = self.request.matchdict['workflow_key'] - info = self.handler.receiving_workflow_info(key) - if info: - return info['display'] - def get_visible_params(self, batch): params = super().get_visible_params(batch) @@ -654,42 +525,40 @@ class ReceivingBatchView(PurchasingBatchView): def get_batch_kwargs(self, batch, **kwargs): kwargs = super().get_batch_kwargs(batch, **kwargs) - batch_type = self.request.POST['batch_type'] # must pull vendor from URL if it was not in form data if 'vendor_uuid' not in kwargs and 'vendor' not in kwargs: if 'vendor_uuid' in self.request.matchdict: kwargs['vendor_uuid'] = self.request.matchdict['vendor_uuid'] - # TODO: ugh should just have workflow and no batch_type - kwargs['receiving_workflow'] = batch_type - if batch_type == 'from_scratch': + workflow = kwargs['workflow'] + if workflow == 'from_scratch': kwargs.pop('truck_dump_batch', None) kwargs.pop('truck_dump_batch_uuid', None) - elif batch_type == 'from_invoice': + elif workflow == 'from_invoice': pass - elif batch_type == 'from_multi_invoice': + elif workflow == 'from_multi_invoice': pass - elif batch_type == 'from_po': + elif workflow == 'from_po': # TODO: how to best handle this field? this doesn't seem flexible kwargs['purchase_key'] = batch.purchase_uuid - elif batch_type == 'from_po_with_invoice': + elif workflow == 'from_po_with_invoice': # TODO: how to best handle this field? this doesn't seem flexible kwargs['purchase_key'] = batch.purchase_uuid - elif batch_type == 'truck_dump_children_first': + elif workflow == 'truck_dump_children_first': kwargs['truck_dump'] = True kwargs['truck_dump_children_first'] = True kwargs['order_quantities_known'] = True # TODO: this makes sense in some cases, but all? # (should just omit that field when not relevant) kwargs['date_ordered'] = None - elif batch_type == 'truck_dump_children_last': + elif workflow == 'truck_dump_children_last': kwargs['truck_dump'] = True kwargs['truck_dump_ready'] = True # TODO: this makes sense in some cases, but all? # (should just omit that field when not relevant) kwargs['date_ordered'] = None - elif batch_type.startswith('truck_dump_child'): + elif workflow.startswith('truck_dump_child'): truck_dump = self.get_instance() kwargs['store'] = truck_dump.store kwargs['vendor'] = truck_dump.vendor @@ -1986,6 +1855,12 @@ class ReceivingBatchView(PurchasingBatchView): 'type': bool}, # vendors + {'section': 'rattail.batch', + 'option': 'purchase.allow_receiving_any_vendor', + 'type': bool}, + # TODO: deprecated; can remove this once all live config + # is updated. but for now it remains so this setting is + # auto-deleted {'section': 'rattail.batch', 'option': 'purchase.supported_vendors_only', 'type': bool}, @@ -2036,6 +1911,7 @@ class ReceivingBatchView(PurchasingBatchView): @classmethod def defaults(cls, config): cls._receiving_defaults(config) + cls._purchase_batch_defaults(config) cls._batch_defaults(config) cls._defaults(config) @@ -2043,17 +1919,11 @@ class ReceivingBatchView(PurchasingBatchView): def _receiving_defaults(cls, config): rattail_config = config.registry.settings.get('rattail_config') route_prefix = cls.get_route_prefix() - url_prefix = cls.get_url_prefix() instance_url_prefix = cls.get_instance_url_prefix() model_key = cls.get_model_key() model_title = cls.get_model_title() permission_prefix = cls.get_permission_prefix() - # new receiving batch using workflow X - config.add_route('{}.create_workflow'.format(route_prefix), '{}/new/{{workflow_key}}/{{vendor_uuid}}'.format(url_prefix)) - config.add_view(cls, attr='create', route_name='{}.create_workflow'.format(route_prefix), - permission='{}.create'.format(permission_prefix)) - # row-level receiving config.add_route('{}.receive_row'.format(route_prefix), '{}/rows/{{row_uuid}}/receive'.format(instance_url_prefix)) config.add_view(cls, attr='receive_row', route_name='{}.receive_row'.format(route_prefix), @@ -2106,33 +1976,6 @@ class ReceivingBatchView(PurchasingBatchView): permission='{}.auto_receive'.format(permission_prefix)) -@colander.deferred -def valid_workflow(node, kw): - """ - Deferred validator for ``workflow`` field, for new batches. - """ - valid_workflows = kw['valid_workflows'] - - def validate(node, value): - # we just need to provide possible values, and let stock validator - # handle the rest - oneof = colander.OneOf(valid_workflows) - return oneof(node, value) - - return validate - - -class NewReceivingBatch(colander.Schema): - """ - Schema for choosing which "type" of new receiving batch should be created. - """ - vendor = colander.SchemaNode(colander.String(), - label="Vendor") - - workflow = colander.SchemaNode(colander.String(), - validator=valid_workflow) - - class ReceiveRowForm(colander.MappingSchema): mode = colander.SchemaNode(colander.String(), From 535317e4f769b2f39121060f70ed7a1c4a013aed Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 22 Oct 2024 15:04:40 -0500 Subject: [PATCH 1654/1681] fix: avoid deprecated method to suggest username --- tailbone/views/people.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index b6a4c0b9..d288b551 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -1382,8 +1382,8 @@ class PersonView(MasterView): } if not context['users']: - context['suggested_username'] = auth.generate_unique_username(self.Session(), - person=person) + context['suggested_username'] = auth.make_unique_username(self.Session(), + person=person) return context From 28f90ad6b5777dfe1c91db2d90c5ccccc678ad5e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 22 Oct 2024 17:09:29 -0500 Subject: [PATCH 1655/1681] =?UTF-8?q?bump:=20version=200.21.11=20=E2=86=92?= =?UTF-8?q?=200.22.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 10 ++++++++++ pyproject.toml | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c31ae92..8ed82c5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.22.0 (2024-10-22) + +### Feat + +- add support for new ordering batch from parsed file + +### Fix + +- avoid deprecated method to suggest username + ## v0.21.11 (2024-10-03) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 5b63a71f..b928ec9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.21.11" +version = "0.22.0" description = "Backoffice Web Application for Rattail" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From 9a6f8970aeb6117d9240b4bd4f024bca4ee136cf Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 23 Oct 2024 09:46:14 -0500 Subject: [PATCH 1656/1681] fix: avoid deprecated grid method --- tailbone/views/master.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index baf63caa..2e7ac147 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -412,7 +412,7 @@ class MasterView(View): session = self.Session() kwargs.setdefault('paginated', False) grid = self.make_grid(session=session, **kwargs) - return grid.make_visible_data() + return grid.get_visible_data() def get_grid_columns(self): """ @@ -1710,7 +1710,7 @@ class MasterView(View): kwargs.setdefault('paginated', False) kwargs.setdefault('sortable', sort) grid = self.make_row_grid(session=session, **kwargs) - return grid.make_visible_data() + return grid.get_visible_data() @classmethod def get_row_url_prefix(cls): From 54220601edfde3435420d5e04b8e4883ae4b4d53 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 1 Nov 2024 17:47:46 -0500 Subject: [PATCH 1657/1681] fix: fix submit button for running problem report esp. on Chrome(-based) browsers --- tailbone/templates/reports/problems/view.mako | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tailbone/templates/reports/problems/view.mako b/tailbone/templates/reports/problems/view.mako index 00ac1503..5cdf2be5 100644 --- a/tailbone/templates/reports/problems/view.mako +++ b/tailbone/templates/reports/problems/view.mako @@ -45,11 +45,10 @@ <b-button @click="runReportShowDialog = false"> Cancel </b-button> - ${h.form(master.get_action_url('execute', instance))} + ${h.form(master.get_action_url('execute', instance), **{'@submit': 'runReportSubmitting = true'})} ${h.csrf_token(request)} <b-button type="is-primary" native-type="submit" - @click="runReportSubmitting = true" :disabled="runReportSubmitting" icon-pack="fas" icon-left="arrow-circle-right"> From 29743e70b7cba3a1b53917c24d0d5a1aaf70972e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 2 Nov 2024 16:56:28 -0500 Subject: [PATCH 1658/1681] =?UTF-8?q?bump:=20version=200.22.0=20=E2=86=92?= =?UTF-8?q?=200.22.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ed82c5d..4dde0159 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.22.1 (2024-11-02) + +### Fix + +- fix submit button for running problem report +- avoid deprecated grid method + ## v0.22.0 (2024-10-22) ### Feat diff --git a/pyproject.toml b/pyproject.toml index b928ec9b..a4a64038 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.22.0" +version = "0.22.1" description = "Backoffice Web Application for Rattail" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From 3f27f626df9f5d2ccb6ae6d52bba0abaa09ecca9 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 10 Nov 2024 19:16:45 -0600 Subject: [PATCH 1659/1681] fix: avoid deprecated import --- tailbone/api/master.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tailbone/api/master.py b/tailbone/api/master.py index 2d17339e..551d6428 100644 --- a/tailbone/api/master.py +++ b/tailbone/api/master.py @@ -26,7 +26,6 @@ Tailbone Web API - Master View import json -from rattail.config import parse_bool from rattail.db.util import get_fieldnames from cornice import resource, Service @@ -185,7 +184,7 @@ class APIMasterView(APIView): if sortcol: spec = { 'field': sortcol.field_name, - 'direction': 'asc' if parse_bool(self.request.params['ascending']) else 'desc', + 'direction': 'asc' if self.config.parse_bool(self.request.params['ascending']) else 'desc', } if sortcol.model_name: spec['model'] = sortcol.model_name From 772b6610cbd99199cd4aae9bf4bbc3c5b748d829 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 12 Nov 2024 18:26:36 -0600 Subject: [PATCH 1660/1681] fix: always define `app` attr for ViewSupplement --- tailbone/views/master.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 2e7ac147..21a5e58f 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -903,7 +903,7 @@ class MasterView(View): def valid_employee_uuid(self, node, value): if value: - model = self.model + model = self.app.model employee = self.Session.get(model.Employee, value) if not employee: node.raise_invalid("Employee not found") @@ -939,7 +939,7 @@ class MasterView(View): def valid_vendor_uuid(self, node, value): if value: - model = self.model + model = self.app.model vendor = self.Session.get(model.Vendor, value) if not vendor: node.raise_invalid("Vendor not found") @@ -1382,7 +1382,7 @@ class MasterView(View): return classes def make_revisions_grid(self, obj, empty_data=False): - model = self.model + model = self.app.model route_prefix = self.get_route_prefix() row_url = lambda txn, i: self.request.route_url(f'{route_prefix}.version', uuid=obj.uuid, @@ -2153,7 +2153,7 @@ class MasterView(View): Thread target for executing an object. """ app = self.get_rattail_app() - model = self.model + model = self.app.model session = app.make_session() obj = self.get_instance_for_key(key, session) user = session.get(model.User, user_uuid) @@ -2594,7 +2594,7 @@ class MasterView(View): """ # nb. self.Session may differ, so use tailbone.db.Session session = Session() - model = self.model + model = self.app.model route_prefix = self.get_route_prefix() info = session.query(model.TailbonePageHelp)\ @@ -2617,7 +2617,7 @@ class MasterView(View): """ # nb. self.Session may differ, so use tailbone.db.Session session = Session() - model = self.model + model = self.app.model route_prefix = self.get_route_prefix() info = session.query(model.TailbonePageHelp)\ @@ -2639,7 +2639,7 @@ class MasterView(View): # nb. self.Session may differ, so use tailbone.db.Session session = Session() - model = self.model + model = self.app.model route_prefix = self.get_route_prefix() schema = colander.Schema() @@ -2673,7 +2673,7 @@ class MasterView(View): # nb. self.Session may differ, so use tailbone.db.Session session = Session() - model = self.model + model = self.app.model route_prefix = self.get_route_prefix() schema = colander.Schema() @@ -5541,7 +5541,7 @@ class MasterView(View): input_file_templates=True, output_file_templates=True): app = self.get_rattail_app() - model = self.model + model = self.app.model names = [] if simple_settings is None: @@ -6100,7 +6100,7 @@ class MasterView(View): renderer='json') -class ViewSupplement(object): +class ViewSupplement: """ Base class for view "supplements" - which are sort of like plugins which can "supplement" certain aspects of the view. @@ -6127,6 +6127,7 @@ class ViewSupplement(object): def __init__(self, master): self.master = master self.request = master.request + self.app = master.app self.model = master.model self.rattail_config = master.rattail_config self.Session = master.Session @@ -6160,7 +6161,7 @@ class ViewSupplement(object): This is accomplished by subjecting the current base query to a join, e.g. something like:: - model = self.model + model = self.app.model query = query.outerjoin(model.MyExtension) return query """ From 9e55717041f9955cb61a971a62340acb5473ab5f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 12 Nov 2024 18:28:41 -0600 Subject: [PATCH 1661/1681] fix: show continuum operation type when viewing version history --- tailbone/diffs.py | 6 +++++- tailbone/templates/master/view.mako | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/tailbone/diffs.py b/tailbone/diffs.py index 98253c57..8303d9e9 100644 --- a/tailbone/diffs.py +++ b/tailbone/diffs.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -27,6 +27,8 @@ Tools for displaying data diffs import sqlalchemy as sa import sqlalchemy_continuum as continuum +from rattail.enum import CONTINUUM_OPERATION + from pyramid.renderers import render from webhelpers2.html import HTML @@ -273,6 +275,8 @@ class VersionDiff(Diff): return { 'key': id(self.version), 'model_title': self.title, + 'operation': CONTINUUM_OPERATION.get(self.version.operation_type, + self.version.operation_type), 'diff_class': self.nature, 'fields': self.fields, 'values': values, diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako index 0a1f9c62..118c028c 100644 --- a/tailbone/templates/master/view.mako +++ b/tailbone/templates/master/view.mako @@ -196,6 +196,7 @@ <p class="block has-text-weight-bold"> {{ version.model_title }} + ({{ version.operation }}) </p> <table class="diff monospace is-size-7" From 20b3f87dbef3346de939d5eabaa18224cc146cce Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 12 Nov 2024 18:30:50 -0600 Subject: [PATCH 1662/1681] fix: add basic master view for Product Costs --- tailbone/menus.py | 10 +++++ tailbone/views/products.py | 77 +++++++++++++++++++++++++++++++++++++- 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/tailbone/menus.py b/tailbone/menus.py index 3ddee095..09d6f3f0 100644 --- a/tailbone/menus.py +++ b/tailbone/menus.py @@ -394,6 +394,11 @@ class TailboneMenuHandler(WuttaMenuHandler): 'route': 'products', 'perm': 'products.list', }, + { + 'title': "Product Costs", + 'route': 'product_costs', + 'perm': 'product_costs.list', + }, { 'title': "Departments", 'route': 'departments', @@ -451,6 +456,11 @@ class TailboneMenuHandler(WuttaMenuHandler): 'route': 'vendors', 'perm': 'vendors.list', }, + { + 'title': "Product Costs", + 'route': 'product_costs', + 'perm': 'product_costs.list', + }, {'type': 'sep'}, { 'title': "Ordering", diff --git a/tailbone/views/products.py b/tailbone/views/products.py index c546a0f4..ae6c550c 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -34,7 +34,7 @@ import sqlalchemy_continuum as continuum from rattail import enum, pod, sil from rattail.db import api, auth, Session as RattailSession -from rattail.db.model import Product, PendingProduct, CustomerOrderItem +from rattail.db.model import Product, PendingProduct, ProductCost, CustomerOrderItem from rattail.gpc import GPC from rattail.threads import Thread from rattail.exceptions import LabelPrintingError @@ -2668,6 +2668,78 @@ class PendingProductView(MasterView): permission=f'{permission_prefix}.ignore_product') +class ProductCostView(MasterView): + """ + Master view for Product Costs + """ + model_class = ProductCost + route_prefix = 'product_costs' + url_prefix = '/products/costs' + has_versions = True + + grid_columns = [ + '_product_key_', + 'vendor', + 'preference', + 'code', + 'case_size', + 'case_cost', + 'pack_size', + 'pack_cost', + 'unit_cost', + ] + + def query(self, session): + """ """ + query = super().query(session) + model = self.app.model + + # always join on Product + return query.join(model.Product) + + def configure_grid(self, g): + """ """ + super().configure_grid(g) + model = self.app.model + + # product key + field = self.get_product_key_field() + g.set_renderer(field, self.render_product_key) + g.set_sorter(field, getattr(model.Product, field)) + g.set_sort_defaults(field) + g.set_filter(field, getattr(model.Product, field)) + + # vendor + g.set_joiner('vendor', lambda q: q.join(model.Vendor)) + g.set_sorter('vendor', model.Vendor.name) + g.set_filter('vendor', model.Vendor.name, label="Vendor Name") + + def render_product_key(self, cost, field): + """ """ + handler = self.app.get_products_handler() + return handler.render_product_key(cost.product) + + def configure_form(self, f): + """ """ + super().configure_form(f) + + # product + f.set_renderer('product', self.render_product) + if 'product_uuid' in f and 'product' in f: + f.remove('product') + f.replace('product_uuid', 'product') + + # vendor + f.set_renderer('vendor', self.render_vendor) + if 'vendor_uuid' in f and 'vendor' in f: + f.remove('vendor') + f.replace('vendor_uuid', 'vendor') + + # futures + # TODO: should eventually show a subgrid here? + f.remove('futures') + + def defaults(config, **kwargs): base = globals() @@ -2677,6 +2749,9 @@ def defaults(config, **kwargs): PendingProductView = kwargs.get('PendingProductView', base['PendingProductView']) PendingProductView.defaults(config) + ProductCostView = kwargs.get('ProductCostView', base['ProductCostView']) + ProductCostView.defaults(config) + def includeme(config): defaults(config) From ac439c949b1760e46975292a7c19b81664b0b5f8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 12 Nov 2024 19:45:24 -0600 Subject: [PATCH 1663/1681] fix: use local/custom enum for continuum operations since we can't rely on that existing in rattail proper, due to it not always having sqlalchemy --- tailbone/diffs.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/tailbone/diffs.py b/tailbone/diffs.py index 8303d9e9..2e582b15 100644 --- a/tailbone/diffs.py +++ b/tailbone/diffs.py @@ -27,8 +27,6 @@ Tools for displaying data diffs import sqlalchemy as sa import sqlalchemy_continuum as continuum -from rattail.enum import CONTINUUM_OPERATION - from pyramid.renderers import render from webhelpers2.html import HTML @@ -272,11 +270,21 @@ class VersionDiff(Diff): for field in self.fields: values[field] = {'before': self.render_old_value(field), 'after': self.render_new_value(field)} + + operation = None + if self.version.operation_type == continuum.Operation.INSERT: + operation = 'INSERT' + elif self.version.operation_type == continuum.Operation.UPDATE: + operation = 'UPDATE' + elif self.version.operation_type == continuum.Operation.DELETE: + operation = 'DELETE' + else: + operation = self.version.operation_type + return { 'key': id(self.version), 'model_title': self.title, - 'operation': CONTINUUM_OPERATION.get(self.version.operation_type, - self.version.operation_type), + 'operation': operation, 'diff_class': self.nature, 'fields': self.fields, 'values': values, From bcaf0d08bcab4fe040504986eee3735b814b50d9 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 18 Nov 2024 14:08:10 -0600 Subject: [PATCH 1664/1681] =?UTF-8?q?bump:=20version=200.22.1=20=E2=86=92?= =?UTF-8?q?=200.22.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 10 ++++++++++ pyproject.toml | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dde0159..b7167b3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.22.2 (2024-11-18) + +### Fix + +- use local/custom enum for continuum operations +- add basic master view for Product Costs +- show continuum operation type when viewing version history +- always define `app` attr for ViewSupplement +- avoid deprecated import + ## v0.22.1 (2024-11-02) ### Fix diff --git a/pyproject.toml b/pyproject.toml index a4a64038..ef7d3584 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.22.1" +version = "0.22.2" description = "Backoffice Web Application for Rattail" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From 980031f5245f814b3313a4e0438cfae4218a72dc Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 18 Nov 2024 14:59:50 -0600 Subject: [PATCH 1665/1681] fix: avoid error for trainwreck query when not a customer when viewing a person's profile, who does not have a customer record, the trainwreck query can't really return anything since it normally should be matching on the customer ID --- tailbone/views/people.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index d288b551..405b1ca3 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -564,15 +564,19 @@ class PersonView(MasterView): Method which must return the base query for the profile's POS Transactions grid data. """ - app = self.get_rattail_app() - customer = app.get_customer(person) + customer = self.app.get_customer(person) - key_field = app.get_customer_key_field() - customer_key = getattr(customer, key_field) - if customer_key is not None: - customer_key = str(customer_key) + if customer: + key_field = self.app.get_customer_key_field() + customer_key = getattr(customer, key_field) + if customer_key is not None: + customer_key = str(customer_key) + else: + # nb. this should *not* match anything, so query returns + # no results.. + customer_key = person.uuid - trainwreck = app.get_trainwreck_handler() + trainwreck = self.app.get_trainwreck_handler() model = trainwreck.get_model() query = TrainwreckSession.query(model.Transaction)\ .filter(model.Transaction.customer_id == customer_key) From 993f066f2cb5da9bfabcf59a81627e5ff20dd7df Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 19 Nov 2024 15:45:37 -0600 Subject: [PATCH 1666/1681] =?UTF-8?q?bump:=20version=200.22.2=20=E2=86=92?= =?UTF-8?q?=200.22.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7167b3c..5ec4ef5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.22.3 (2024-11-19) + +### Fix + +- avoid error for trainwreck query when not a customer + ## v0.22.2 (2024-11-18) ### Fix diff --git a/pyproject.toml b/pyproject.toml index ef7d3584..2dca88db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.22.2" +version = "0.22.3" description = "Backoffice Web Application for Rattail" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From 7171c7fb06fa634a0688f525202a4b898868a8d7 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 19 Nov 2024 20:53:23 -0600 Subject: [PATCH 1667/1681] fix: use vmodel for confirm password widget input since previously this did not work at all for butterball (vue3 + oruga) - although it was never clear why per se.. Refs: #1 --- tailbone/templates/deform/checked_password.pt | 4 +- tailbone/views/auth.py | 40 ++++++++----------- 2 files changed, 19 insertions(+), 25 deletions(-) diff --git a/tailbone/templates/deform/checked_password.pt b/tailbone/templates/deform/checked_password.pt index f78c0b85..2121f01d 100644 --- a/tailbone/templates/deform/checked_password.pt +++ b/tailbone/templates/deform/checked_password.pt @@ -1,6 +1,7 @@ <div i18n:domain="deform" tal:omit-tag="" tal:define="oid oid|field.oid; name name|field.name; + vmodel vmodel|'field_model_' + name; css_class css_class|field.widget.css_class; style style|field.widget.style;"> @@ -8,7 +9,7 @@ ${field.start_mapping()} <b-input type="password" name="${name}" - value="${field.widget.redisplay and cstruct or ''}" + v-model="${vmodel}" tal:attributes="class string: form-control ${css_class or ''}; style style; attributes|field.widget.attributes|{};" @@ -18,7 +19,6 @@ </b-input> <b-input type="password" name="${name}-confirm" - value="${field.widget.redisplay and confirm or ''}" tal:attributes="class string: form-control ${css_class or ''}; style style; confirm_attributes|field.widget.confirm_attributes|{};" diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index a54a19a9..1338c107 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -44,28 +44,6 @@ class UserLogin(colander.MappingSchema): widget=dfwidget.PasswordWidget()) -@colander.deferred -def current_password_correct(node, kw): - request = kw['request'] - app = request.rattail_config.get_app() - auth = app.get_auth_handler() - user = kw['user'] - def validate(node, value): - if not auth.authenticate_user(Session(), user.username, value): - raise colander.Invalid(node, "The password is incorrect") - return validate - - -class ChangePassword(colander.MappingSchema): - - current_password = colander.SchemaNode(colander.String(), - widget=dfwidget.PasswordWidget(), - validator=current_password_correct) - - new_password = colander.SchemaNode(colander.String(), - widget=dfwidget.CheckedPasswordWidget()) - - class AuthenticationView(View): def forbidden(self): @@ -181,7 +159,23 @@ class AuthenticationView(View): self.request.user)) return self.redirect(self.request.get_referrer()) - schema = ChangePassword().bind(user=self.request.user, request=self.request) + def check_user_password(node, value): + auth = self.app.get_auth_handler() + user = self.request.user + if not auth.check_user_password(user, value): + node.raise_invalid("The password is incorrect") + + schema = colander.Schema() + + schema.add(colander.SchemaNode(colander.String(), + name='current_password', + widget=dfwidget.PasswordWidget(), + validator=check_user_password)) + + schema.add(colander.SchemaNode(colander.String(), + name='new_password', + widget=dfwidget.CheckedPasswordWidget())) + form = forms.Form(schema=schema, request=self.request) if form.validate(): auth = self.app.get_auth_handler() From aace6033c5ba63f0ae5b6c7e458702483b2e6c5f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 20 Nov 2024 20:16:06 -0600 Subject: [PATCH 1668/1681] fix: avoid error in product search for duplicated key --- tailbone/views/products.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index ae6c550c..8461ae03 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -1857,7 +1857,8 @@ class ProductView(MasterView): lookup_fields.append('alt_code') if lookup_fields: product = self.products_handler.locate_product_for_entry( - session, term, lookup_fields=lookup_fields) + session, term, lookup_fields=lookup_fields, + first_if_multiple=True) if product: final_results.append(self.search_normalize_result(product)) From f1c8ffedda2b88bd9b68faf3ec2161ede67ee972 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 22 Nov 2024 12:57:04 -0600 Subject: [PATCH 1669/1681] =?UTF-8?q?bump:=20version=200.22.3=20=E2=86=92?= =?UTF-8?q?=200.22.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ec4ef5c..b3b51f8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.22.4 (2024-11-22) + +### Fix + +- avoid error in product search for duplicated key +- use vmodel for confirm password widget input + ## v0.22.3 (2024-11-19) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 2dca88db..bde9bf89 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.22.3" +version = "0.22.4" description = "Backoffice Web Application for Rattail" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From 2c269b640b1f72ac2cf9fea6a051d496096e0a8c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@wuttaproject.org> Date: Sun, 1 Dec 2024 18:12:30 -0600 Subject: [PATCH 1670/1681] fix: let caller request safe HTML literal for rendered grid table mostly just for convenience --- tailbone/grids/core.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 73de42c6..134642dd 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -1223,6 +1223,7 @@ class Grid(WuttaGrid): def render_table_element(self, template='/grids/b-table.mako', data_prop='gridData', empty_labels=False, + literal=False, **kwargs): """ This is intended for ad-hoc "small" grids with static data. Renders @@ -1239,7 +1240,10 @@ class Grid(WuttaGrid): if context['paginated']: context.setdefault('per_page', 20) context['view_click_handler'] = self.get_view_click_handler() - return render(template, context) + result = render(template, context) + if literal: + result = HTML.literal(result) + return result def get_view_click_handler(self): """ """ From 23bdde245abae2721b02c06eec2e0e172c3e53c6 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@wuttaproject.org> Date: Tue, 10 Dec 2024 12:34:34 -0600 Subject: [PATCH 1671/1681] fix: require newer wuttaweb --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index bde9bf89..dc66e364 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,7 +59,7 @@ dependencies = [ "transaction", "waitress", "WebHelpers2", - "WuttaWeb>=0.14.0", + "WuttaWeb>=0.16.2", "zope.sqlalchemy>=1.5", ] From 7e559a01b3cdcfc3704b7ffa72cc2ec3df4c73f2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@wuttaproject.org> Date: Tue, 10 Dec 2024 12:52:49 -0600 Subject: [PATCH 1672/1681] fix: require newer rattail lib --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index dc66e364..8c0c2c15 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ dependencies = [ "pyramid_mako", "pyramid_retry", "pyramid_tm", - "rattail[db,bouncer]>=0.18.5", + "rattail[db,bouncer]>=0.21.1", "sa-filters", "simplejson", "transaction", From 358b3b75a534daa7c84decd64566aca5d1c29328 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@wuttaproject.org> Date: Tue, 10 Dec 2024 13:05:32 -0600 Subject: [PATCH 1673/1681] fix: whoops this is latest rattail --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8c0c2c15..759510ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ dependencies = [ "pyramid_mako", "pyramid_retry", "pyramid_tm", - "rattail[db,bouncer]>=0.21.1", + "rattail[db,bouncer]>=0.20.1", "sa-filters", "simplejson", "transaction", From 950db697a0306a87306facf07ca32ad1614341c9 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@wuttaproject.org> Date: Mon, 16 Dec 2024 12:46:45 -0600 Subject: [PATCH 1674/1681] =?UTF-8?q?bump:=20version=200.22.4=20=E2=86=92?= =?UTF-8?q?=200.22.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 9 +++++++++ pyproject.toml | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3b51f8d..cbacf2a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.22.5 (2024-12-16) + +### Fix + +- whoops this is latest rattail +- require newer rattail lib +- require newer wuttaweb +- let caller request safe HTML literal for rendered grid table + ## v0.22.4 (2024-11-22) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 759510ba..9c164772 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.22.4" +version = "0.22.5" description = "Backoffice Web Application for Rattail" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From c7ee9de9eb3b86c40e99987c10843bd4bee142f9 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@wuttaproject.org> Date: Sat, 28 Dec 2024 16:43:22 -0600 Subject: [PATCH 1675/1681] fix: register vue3 form component for products -> make batch --- tailbone/templates/products/batch.mako | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/products/batch.mako b/tailbone/templates/products/batch.mako index 9f969468..db029e5a 100644 --- a/tailbone/templates/products/batch.mako +++ b/tailbone/templates/products/batch.mako @@ -55,19 +55,20 @@ </%def> <%def name="render_form_template()"> - <script type="text/x-template" id="${form.component}-template"> + <script type="text/x-template" id="${form.vue_tagname}-template"> ${self.render_form_innards()} </script> </%def> <%def name="modify_vue_vars()"> ${parent.modify_vue_vars()} + <% request.register_component(form.vue_tagname, form.vue_component) %> <script> ## TODO: ugh, an awful lot of duplicated code here (from /forms/deform.mako) let ${form.vue_component} = { - template: '#${form.component}-template', + template: '#${form.vue_tagname}-template', methods: { ## TODO: deprecate / remove the latter option here From e0ebd43e7abaa3292dd252135bc2d880b6b312ca Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@wuttaproject.org> Date: Sat, 1 Feb 2025 15:18:12 -0600 Subject: [PATCH 1676/1681] =?UTF-8?q?bump:=20version=200.22.5=20=E2=86=92?= =?UTF-8?q?=200.22.6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cbacf2a5..0b1726a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.22.6 (2025-02-01) + +### Fix + +- register vue3 form component for products -> make batch + ## v0.22.5 (2024-12-16) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 9c164772..9e83df80 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.22.5" +version = "0.22.6" description = "Backoffice Web Application for Rattail" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] @@ -59,7 +59,7 @@ dependencies = [ "transaction", "waitress", "WebHelpers2", - "WuttaWeb>=0.16.2", + "WuttaWeb>=0.21.0", "zope.sqlalchemy>=1.5", ] From 4221fa50dd95771c84c20473381edcaff006043d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@wuttaproject.org> Date: Fri, 14 Feb 2025 11:37:21 -0600 Subject: [PATCH 1677/1681] fix: fix warning msg for deprecated Grid param --- tailbone/grids/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 134642dd..56b97b86 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -235,7 +235,7 @@ class Grid(WuttaGrid): if 'pageable' in kwargs: warnings.warn("pageable param is deprecated for Grid(); " - "please use vue_tagname param instead", + "please use paginated param instead", DeprecationWarning, stacklevel=2) kwargs.setdefault('paginated', kwargs.pop('pageable')) From 7348eec671542fa1317ad68a0816948ee96c76ac Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@wuttaproject.org> Date: Tue, 18 Feb 2025 11:16:23 -0600 Subject: [PATCH 1678/1681] fix: stop using old config for logo image url on login page --- tailbone/views/auth.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index 1338c107..eceab803 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -94,10 +94,6 @@ class AuthenticationView(View): else: self.request.session.flash("Invalid username or password", 'error') - image_url = self.rattail_config.get( - 'tailbone', 'main_image_url', - default=self.request.static_url('tailbone:static/img/home_logo.png')) - # nb. hacky..but necessary, to add the refs, for autofocus # (also add key handler, so ENTER acts like TAB) dform = form.make_deform_form() @@ -110,7 +106,6 @@ class AuthenticationView(View): return { 'form': form, 'referrer': referrer, - 'image_url': image_url, 'index_title': app.get_node_title(), 'help_url': global_help_url(self.rattail_config), } From a6508154cb93a376a7ec93efa930534c674364f8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@wuttaproject.org> Date: Tue, 18 Feb 2025 12:13:28 -0600 Subject: [PATCH 1679/1681] docs: update intersphinx doc links per server migration --- docs/conf.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 52e384f5..ade4c92a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -27,10 +27,10 @@ templates_path = ['_templates'] exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] intersphinx_mapping = { - 'rattail': ('https://rattailproject.org/docs/rattail/', None), + 'rattail': ('https://docs.wuttaproject.org/rattail/', None), 'webhelpers2': ('https://webhelpers2.readthedocs.io/en/latest/', None), - 'wuttaweb': ('https://rattailproject.org/docs/wuttaweb/', None), - 'wuttjamaican': ('https://rattailproject.org/docs/wuttjamaican/', None), + 'wuttaweb': ('https://docs.wuttaproject.org/wuttaweb/', None), + 'wuttjamaican': ('https://docs.wuttaproject.org/wuttjamaican/', None), } # allow todo entries to show up From e2582ffec5f84f97df9cc7d2fdcdf5201b2d135f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@wuttaproject.org> Date: Wed, 19 Feb 2025 10:33:39 -0600 Subject: [PATCH 1680/1681] =?UTF-8?q?bump:=20version=200.22.6=20=E2=86=92?= =?UTF-8?q?=200.22.7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b1726a4..c974b3a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.22.7 (2025-02-19) + +### Fix + +- stop using old config for logo image url on login page +- fix warning msg for deprecated Grid param + ## v0.22.6 (2025-02-01) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 9e83df80..a7214a8e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.22.6" +version = "0.22.7" description = "Backoffice Web Application for Rattail" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From e15045380171617b32f9dca6bcbda8b2c2472310 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@wuttaproject.org> Date: Wed, 5 Mar 2025 10:34:52 -0600 Subject: [PATCH 1681/1681] fix: add startup hack for tempmon DB model --- tailbone/app.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tailbone/app.py b/tailbone/app.py index b7262866..d2d0c5ef 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -62,6 +62,17 @@ def make_rattail_config(settings): # nb. this is for compaibility with wuttaweb settings['wutta_config'] = rattail_config + # must import all sqlalchemy models before things get rolling, + # otherwise can have errors about continuum TransactionMeta class + # not yet mapped, when relevant pages are first requested... + # cf. https://docs.pylonsproject.org/projects/pyramid_cookbook/en/latest/database/sqlalchemy.html#importing-all-sqlalchemy-models + # hat tip to https://stackoverflow.com/a/59241485 + if getattr(rattail_config, 'tempmon_engine', None): + from rattail_tempmon.db import model as tempmon_model, Session as TempmonSession + tempmon_session = TempmonSession() + tempmon_session.query(tempmon_model.Appliance).first() + tempmon_session.close() + # configure database sessions if hasattr(rattail_config, 'appdb_engine'): tailbone.db.Session.configure(bind=rattail_config.appdb_engine)