From 3968e40a0b9fb416ad7eedf0f6e07844f9debd1b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 15 Sep 2023 19:19:20 -0500 Subject: [PATCH 001/483] 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: + + {{ gridTotalsFetching ? "Working, please wait..." : "Show Totals" }} + +
+ Totals: {{ gridTotalsDisplay }} +
+ % endif + ## download search results % if master.results_downloadable and master.has_perm('download_results'): + % 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 Date: Fri, 15 Sep 2023 19:30:27 -0500 Subject: [PATCH 002/483] 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 Date: Sat, 16 Sep 2023 13:06:26 -0500 Subject: [PATCH 003/483] 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 Date: Sat, 16 Sep 2023 13:06:54 -0500 Subject: [PATCH 004/483] 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 Date: Sat, 16 Sep 2023 20:01:32 -0500 Subject: [PATCH 005/483] 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"', '>', '' ].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 @@ +
+ + + ${field.start_mapping()} + + + + + + + + + + + + ${field.end_mapping()} + + +
From cc7b9ccb86739fbb0d50628883479a9a0c2da20d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 17 Sep 2023 17:23:59 -0500 Subject: [PATCH 006/483] 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 Date: Sun, 17 Sep 2023 18:03:30 -0500 Subject: [PATCH 007/483] 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 Date: Sun, 17 Sep 2023 18:30:38 -0500 Subject: [PATCH 008/483] 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 @@ + + + Auto-generate "missing" (DNR) credits for items not accounted for + + +

Mobile Interface

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 Date: Sun, 17 Sep 2023 21:21:10 -0500 Subject: [PATCH 009/483] 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 Date: Mon, 18 Sep 2023 18:28:11 -0500 Subject: [PATCH 010/483] 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 Date: Mon, 18 Sep 2023 18:37:41 -0500 Subject: [PATCH 011/483] 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 Date: Mon, 18 Sep 2023 18:40:51 -0500 Subject: [PATCH 012/483] 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 Date: Tue, 19 Sep 2023 14:40:58 -0500 Subject: [PATCH 013/483] 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 Date: Tue, 19 Sep 2023 14:41:15 -0500 Subject: [PATCH 014/483] 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 Date: Tue, 19 Sep 2023 14:45:48 -0500 Subject: [PATCH 015/483] 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 @@ Date: Tue, 19 Sep 2023 15:03:16 -0500 Subject: [PATCH 016/483] 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 Date: Tue, 19 Sep 2023 16:37:05 -0500 Subject: [PATCH 017/483] 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 Date: Wed, 20 Sep 2023 18:13:52 -0500 Subject: [PATCH 018/483] 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 Date: Thu, 21 Sep 2023 14:37:33 -0500 Subject: [PATCH 019/483] 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 Date: Thu, 21 Sep 2023 14:39:18 -0500 Subject: [PATCH 020/483] 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 Date: Sat, 23 Sep 2023 11:14:43 -0500 Subject: [PATCH 021/483] 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 . +# +################################################################################ +""" +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 Date: Sat, 23 Sep 2023 20:01:29 -0500 Subject: [PATCH 022/483] 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 Date: Sun, 24 Sep 2023 08:37:50 -0500 Subject: [PATCH 023/483] 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 name="render_instance_header_title_extras()"> + + % if master.touchable and master.has_perm('touch'): + + + % endif + + <%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)):
  • ${h.link_to("Version History", action_url('versions', instance))}
  • % endif - % if master.touchable and request.has_perm('{}.touch'.format(permission_prefix)): -
  • ${h.link_to("\"Touch\" this {}".format(model_title), master.get_action_url('touch', instance))}
  • - % endif <%def name="render_row_grid_tools()"> @@ -83,6 +92,22 @@ ${parent.render_this_page_template()} +<%def name="modify_whole_page_vars()"> + ${parent.modify_whole_page_vars()} + % if master.touchable and master.has_perm('touch'): + + % endif + + <%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 Date: Sun, 24 Sep 2023 14:47:54 -0500 Subject: [PATCH 024/483] 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 Date: Sun, 24 Sep 2023 19:30:59 -0500 Subject: [PATCH 025/483] 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 Date: Mon, 25 Sep 2023 18:06:16 -0500 Subject: [PATCH 026/483] 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 Date: Mon, 25 Sep 2023 19:22:02 -0500 Subject: [PATCH 027/483] 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 @@ +
    + + +
    From a11be5a1e10df98145491705e8aac3f30a6f41ce Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 25 Sep 2023 19:41:59 -0500 Subject: [PATCH 028/483] 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 Date: Tue, 26 Sep 2023 09:32:57 -0500 Subject: [PATCH 029/483] 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 Date: Tue, 26 Sep 2023 17:52:17 -0500 Subject: [PATCH 030/483] 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 Date: Wed, 27 Sep 2023 17:13:49 -0500 Subject: [PATCH 031/483] 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 . +# +################################################################################ +""" +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 Date: Thu, 28 Sep 2023 10:56:15 -0500 Subject: [PATCH 032/483] 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 Date: Sat, 30 Sep 2023 21:08:01 -0500 Subject: [PATCH 033/483] 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 Date: Sun, 1 Oct 2023 12:09:32 -0500 Subject: [PATCH 034/483] 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 Date: Sun, 1 Oct 2023 17:31:33 -0500 Subject: [PATCH 035/483] 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 Date: Sun, 1 Oct 2023 18:54:56 -0500 Subject: [PATCH 036/483] 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 Date: Mon, 2 Oct 2023 09:54:34 -0500 Subject: [PATCH 037/483] 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 Date: Wed, 4 Oct 2023 10:59:54 -0500 Subject: [PATCH 038/483] 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 Date: Wed, 4 Oct 2023 11:56:50 -0500 Subject: [PATCH 039/483] 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 Date: Wed, 4 Oct 2023 13:07:26 -0500 Subject: [PATCH 040/483] 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 Date: Wed, 4 Oct 2023 13:56:22 -0500 Subject: [PATCH 041/483] 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 Date: Thu, 5 Oct 2023 13:11:05 -0500 Subject: [PATCH 042/483] 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. `` 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()"> +
    +
    - ## ${self.render_form()} - - - -
    -
    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 Date: Thu, 5 Oct 2023 19:59:57 -0500 Subject: [PATCH 043/483] 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 Date: Fri, 6 Oct 2023 08:56:22 -0500 Subject: [PATCH 044/483] 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 Date: Fri, 6 Oct 2023 10:00:37 -0500 Subject: [PATCH 045/483] 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 Date: Fri, 6 Oct 2023 10:12:38 -0500 Subject: [PATCH 046/483] 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 Date: Fri, 6 Oct 2023 10:13:18 -0500 Subject: [PATCH 047/483] 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 Date: Fri, 6 Oct 2023 15:03:17 -0500 Subject: [PATCH 048/483] 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 Date: Fri, 6 Oct 2023 15:53:17 -0500 Subject: [PATCH 049/483] 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 name="context_menu_items()"> - ${parent.context_menu_items()} -
  • ${h.link_to("Back to Inventory Batch", url('batch.inventory.view', uuid=batch.uuid))}
  • +<%def name="object_helpers()"> + <%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 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 @@ +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + + + ${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 Date: Fri, 6 Oct 2023 20:34:14 -0500 Subject: [PATCH 050/483] 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 Date: Sat, 7 Oct 2023 16:26:33 -0500 Subject: [PATCH 051/483] 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()} + + + +${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 Date: Sat, 7 Oct 2023 18:57:03 -0500 Subject: [PATCH 052/483] 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 Date: Sat, 7 Oct 2023 20:13:41 -0500 Subject: [PATCH 053/483] 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 Date: Sun, 8 Oct 2023 14:29:01 -0500 Subject: [PATCH 054/483] 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 -*- -
    - - - - - - - - - - - - - - - -
    - % if grid.filterable: - ${grid.render_filters(allow_save_defaults=allow_save_defaults)|n} - % endif -
    - % if tools: -
    - ${tools|n} -
    - % endif -
    - - ${grid.render_grid()|n} - -
    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; -*- -
    - - ${grid.make_webhelpers_grid()} -
    - % if grid.pageable and grid.pager: -
    -

    - ${"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 -

    - -
    - % endif -
    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 Date: Sun, 8 Oct 2023 16:38:13 -0500 Subject: [PATCH 055/483] 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 Date: Mon, 9 Oct 2023 00:19:29 -0500 Subject: [PATCH 056/483] 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; -*- - +
    % 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 @@
    -% for version in versions: - -

    ${title_for_version(version)}

    - - % if version.previous and version.operation_type == continuum.Operation.DELETE: -
    - - - - - - - - - % for field in fields_for_version(version): - - - - - - % endfor - -
    field nameold valuenew value
    ${field}${render_old_value(version, field)} 
    - % elif version.previous: - - - - - - - - - - % for field in fields_for_version(version): - - - - - - % endfor - -
    field nameold valuenew value
    ${field}${render_old_value(version, field)}${render_new_value(version, field, 'dirty')}
    - % else: - - - - - - - - - - % for field in fields_for_version(version): - - - - - - % endfor - -
    field nameold valuenew value
    ${field} ${render_new_value(version, field, 'new')}
    - % endif - -% endfor + % for diff in version_diffs: +

    ${diff.title}

    + ${diff.render_html()} + % endfor
    + 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"> {{ field }} - {{ version.values[field].before }} - {{ version.values[field].after }} + + 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 Date: Mon, 9 Oct 2023 15:50:41 -0500 Subject: [PATCH 057/483] 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 Date: Tue, 10 Oct 2023 10:54:16 -0500 Subject: [PATCH 058/483] 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): -
    -
    -
    -
    -

    -
    +
    +
    + +

    +

    + +
    ${self.render_instance_header_title_extras()}
    -
    + +
    ${self.render_instance_header_buttons()}
    +
    % 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'): -
    - - -
    + + % endif % if master.cloneable and master.has_perm('clone'): -
    - - -
    + + % endif % if master.deletable and instance_deletable and master.has_perm('delete'): -
    - - -
    + + % endif % else: ## viewing row % if instance_deletable and master.has_perm('delete_row'): -
    - - -
    + + % endif % endif % elif master and master.editing: % if master.viewable and master.has_perm('view'): -
    - - -
    + + % endif % if master.deletable and instance_deletable and master.has_perm('delete'): -
    - - -
    + + % endif % elif master and master.deleting: % if master.viewable and master.has_perm('view'): -
    - - -
    + + % endif % if master.editable and instance_editable and master.has_perm('edit'): -
    - - -
    + + % endif % endif @@ -711,40 +700,32 @@ <%def name="render_prevnext_header_buttons()"> % if show_prev_next is not Undefined and show_prev_next: % if prev_url: -
    - - Older - -
    + + Older + % else: -
    - - Older - -
    + + Older + % endif % if next_url: -
    - - Newer - -
    + + Newer + % else: -
    - - Newer - -
    + + Newer + % endif % endif 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']): - + + % else: % 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 name="title()">${index_title} » ${instance_title} » Edit +<%def name="content_title()">Edit: ${instance_title} ${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 name="render_instance_header_title_extras()"> - % if master.touchable and master.has_perm('touch'): % endif + % if expose_versions: + + {{ viewingHistory ? "View Current" : "View History" }} + + % endif <%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. ##
  • ${h.link_to("Permalink for this {}".format(model_title), action_url('view', instance))}
  • - % if master.has_versions and request.rattail_config.versioning_enabled() and request.has_perm('{}.versions'.format(permission_prefix)): -
  • ${h.link_to("Version History", action_url('versions', instance))}
  • - % endif <%def name="render_row_grid_tools()"> @@ -69,14 +72,152 @@ % endif +<%def name="render_this_page_component()"> + ## TODO: should override this in a cleaner way! too much duplicate code w/ parent template + + + + <%def name="render_this_page()"> - ${parent.render_this_page()} - % if master.has_rows: -
    - % if rows_title: -

    ${rows_title}

    - % endif - ${self.render_row_grid_component()} +
    + + ## render main form + ${parent.render_this_page()} + + ## render row grid + % if master.has_rows: +
    + % if rows_title: +

    ${rows_title}

    + % endif + ${self.render_row_grid_component()} + % endif +
    + + % if expose_versions: +
    + +
    +

    Version History

    +

    + + + View as separate page + +

    +
    + + + + + +
    +
    +
    + +
    + +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    + +
    + +
    + + Older + + + Newer + +
    + + + + + {{ viewVersionShowAllFields ? "Show Diffs Only" : "Show All Fields" }} + +
    + +
    + +
    + +

    + {{ version.model_title }} +

    + + + + + + + + + + + + + + + + +
    field nameold valuenew value
    {{ field }}
    + +
    + +
    + +
    +
    +
    +
    % endif @@ -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 name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + % if expose_versions: + + % endif <%def name="modify_whole_page_vars()"> ${parent.modify_whole_page_vars()} - % if master.touchable and master.has_perm('touch'): - - % endif + % endif + + % if expose_versions: + WholePageData.viewingHistory = false + % endif + + <%def name="finalize_this_page_vars()"> ${parent.finalize_this_page_vars()} - % if master.has_rows: - % endif 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 @@
    ${transaction.meta.get('comment') or ''}
    +
    + +
    ${transaction.id}
    +
    +
    % for diff in version_diffs: -

    ${diff.title}

    +

    ${diff.title}

    ${diff.render_html()} % endfor
    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 Date: Tue, 10 Oct 2023 22:01:46 -0500 Subject: [PATCH 059/483] 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
    - - - + + Add Filter + + + + Date: Wed, 11 Oct 2023 15:56:16 -0500 Subject: [PATCH 060/483] 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 Date: Wed, 11 Oct 2023 16:13:20 -0500 Subject: [PATCH 061/483] 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 Date: Wed, 11 Oct 2023 18:35:35 -0500 Subject: [PATCH 062/483] 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 Date: Wed, 11 Oct 2023 19:55:43 -0500 Subject: [PATCH 063/483] 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 Date: Wed, 11 Oct 2023 23:11:23 -0500 Subject: [PATCH 064/483] 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 Date: Thu, 12 Oct 2023 10:33:44 -0500 Subject: [PATCH 065/483] 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 Date: Thu, 12 Oct 2023 10:37:12 -0500 Subject: [PATCH 066/483] 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 Date: Thu, 12 Oct 2023 11:57:18 -0500 Subject: [PATCH 067/483] 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 Date: Tue, 17 Oct 2023 15:26:22 -0500 Subject: [PATCH 068/483] 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 Date: Wed, 18 Oct 2023 17:35:14 -0500 Subject: [PATCH 069/483] 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 @@
    ${h.form(request.current_route_url(), method='GET', **{'@submit': 'formSubmitting = true'})} +
    - - - - - + ${h.hidden('permission_group', **{':value': 'selectedGroup'})} + + + + + {{ permissionGroups[selectedGroup].label }} + + - - - - - + ${h.hidden('permission', **{':value': 'selectedPermission'})} + + + + + {{ selectedPermissionLabel }} + + -
    - - - - {{ formSubmitting ? "Working, please wait..." : "Find ${model_title_plural}" }} - -
    + +
    + + + + {{ formSubmitting ? "Working, please wait..." : "Find ${model_title_plural}" }} + +
    +
    +
    ${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)): -
  • ${h.link_to("Find {} with Permission X".format(model_title_plural), url('{}.find_by_perm'.format(route_prefix)))}
  • + % if master.has_perm('find_by_perm'): +
  • ${h.link_to(f"Find {model_title_plural} by Permission", url(f'{route_prefix}.find_by_perm'))}
  • % endif 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 Date: Wed, 18 Oct 2023 18:18:55 -0500 Subject: [PATCH 070/483] 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 Date: Wed, 18 Oct 2023 21:24:37 -0500 Subject: [PATCH 071/483] 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 Date: Wed, 18 Oct 2023 21:25:13 -0500 Subject: [PATCH 072/483] 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 Date: Wed, 18 Oct 2023 21:25:32 -0500 Subject: [PATCH 073/483] 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 Date: Thu, 19 Oct 2023 13:02:17 -0500 Subject: [PATCH 074/483] 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 Date: Thu, 19 Oct 2023 14:03:25 -0500 Subject: [PATCH 075/483] 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 Date: Thu, 19 Oct 2023 14:57:06 -0500 Subject: [PATCH 076/483] 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 Date: Thu, 19 Oct 2023 19:12:28 -0500 Subject: [PATCH 077/483] 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 Date: Thu, 19 Oct 2023 20:48:52 -0500 Subject: [PATCH 078/483] 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 Date: Fri, 20 Oct 2023 14:29:45 -0500 Subject: [PATCH 079/483] 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 Date: Sat, 21 Oct 2023 16:10:36 -0500 Subject: [PATCH 080/483] 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 Date: Mon, 23 Oct 2023 13:06:38 -0500 Subject: [PATCH 081/483] 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 Date: Mon, 23 Oct 2023 15:48:48 -0500 Subject: [PATCH 082/483] 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 @@ - {{ productSize }} + {{ productSize || '' }} @@ -734,7 +734,7 @@ - {{ productIsKnown ? productDisplay : pendingProduct.brand_name + ' ' + pendingProduct.description + ' ' + pendingProduct.size }} + {{ productIsKnown ? productDisplay : (pendingProduct.brand_name || '') + ' ' + (pendingProduct.description || '') + ' ' + (pendingProduct.size || '') }} @@ -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 : '') }} 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 @@
    + % if unknown_product_confirm_price: + + + + % endif + @@ -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 Date: Wed, 25 Oct 2023 10:45:33 -0500 Subject: [PATCH 091/483] Use `` 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'):
    ${h.form(url('change_theme'), method="post", ref='themePickerForm')} - ${h.csrf_token(request)} - Theme: -
    -
    - ${h.select('theme', theme, theme_picker_options, **{'@change': 'changeTheme()'})} + ${h.csrf_token(request)} +
    + Theme: + + % for option in theme_picker_options: + + % endfor +
    -
    ${h.end_form()}
    % 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 Date: Wed, 25 Oct 2023 11:40:52 -0500 Subject: [PATCH 092/483] 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 Date: Wed, 25 Oct 2023 12:20:04 -0500 Subject: [PATCH 093/483] 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 Date: Wed, 25 Oct 2023 14:06:40 -0500 Subject: [PATCH 094/483] 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 Date: Wed, 25 Oct 2023 20:10:21 -0500 Subject: [PATCH 095/483] 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 @@

    Product

    - - - - - - - Full Lookup - - - - View Product - + +
    @@ -565,7 +542,6 @@
    - ##

    {{ productKey }}

    @@ -957,11 +933,6 @@ % endif - - - % if allow_past_item_reorder:
    @@ -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()"> @@ -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 Date: Wed, 25 Oct 2023 20:22:48 -0500 Subject: [PATCH 096/483] 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 ```` 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 Date: Thu, 26 Oct 2023 10:06:00 -0500 Subject: [PATCH 097/483] 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

    + :product="selectedProduct" + @selected="productLookupSelected" + autocomplete-url="${url(f'{route_prefix}.product_autocomplete')}"> 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 @@ - + - - {{ selectedProduct.full_description }} + {{ product.full_description }} Full Lookup - 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 name="object_helpers()"> ${parent.object_helpers()} @@ -43,12 +49,13 @@ ${instance.full_description} - - + + + ${h.hidden('product_uuid', **{':value': 'resolveProductUUID'})}