From 7fabef60047edac2b85fc52354e44f1d72a876b6 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 10 Oct 2021 20:08:52 -0400 Subject: [PATCH 0001/1243] Stop rounding case/unit cost fields to 2 places for purchase batch --- tailbone/views/purchasing/batch.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index 95c12bb1..c00267a9 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -693,11 +693,10 @@ class PurchasingBatchView(BatchMasterView): f.set_type('units_mispick', 'quantity') # currency fields - f.set_type('po_unit_cost', 'currency') + # nb. we only show "total" fields as currency, but not case or + # unit cost fields, b/c currency is rounded to 2 places f.set_type('po_total', 'currency') f.set_type('po_total_calculated', 'currency') - f.set_type('invoice_unit_cost', 'currency') - f.set_type('catalog_unit_cost', 'currency') # upc f.set_type('upc', 'gpc') From ffb33d00c8eb04b7e2d0587c12bc273aaa7d1431 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 10 Oct 2021 20:21:41 -0400 Subject: [PATCH 0002/1243] Fix some phone/email bugs for new custorder page --- tailbone/templates/custorders/create.mako | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index 390d6f34..5aed3c7b 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -153,7 +153,13 @@
- {{ orderPhoneNumber }} + + {{ orderPhoneNumber }} + + + (no valid phone number on file) +
% if allow_contact_info_choice:
- (no valid email on file) + (no valid email address on file)
% if allow_contact_info_choice: @@ -1035,7 +1041,7 @@ that.orderPhoneNumber = response.data.phone_number that.orderEmailAddress = response.data.email_address that.addOtherPhoneNumber = response.data.add_phone_number - that.addOtherEmailAddres = response.data.add_email_address + that.addOtherEmailAddress = response.data.add_email_address that.contactProfileURL = response.data.contact_profile_url that.contactPhones = response.data.contact_phones that.contactEmails = response.data.contact_emails @@ -1051,7 +1057,7 @@ editPhoneNumberInit() { this.existingPhoneUUID = null - let normalOrderPhone = this.orderPhoneNumber.replace(/\D/g, '') + let normalOrderPhone = (this.orderPhoneNumber || '').replace(/\D/g, '') for (let phone of this.contactPhones) { let normal = phone.number.replace(/\D/g, '') if (normal == normalOrderPhone) { From 3e796e91642469b1f1735a61dddcd33c732eed23 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 10 Oct 2021 20:24:26 -0400 Subject: [PATCH 0003/1243] Fix bug when making context for mailing address sometimes those belong to a non-person, e.g. customer --- tailbone/views/people.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 3a54559d..8e8374c4 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -515,8 +515,7 @@ class PersonView(MasterView): return context def get_context_address(self, address): - person = address.person - return { + context = { 'uuid': address.uuid, 'street': address.street, 'street2': address.street2, @@ -524,9 +523,15 @@ class PersonView(MasterView): 'state': address.state, 'zipcode': address.zipcode, 'display': six.text_type(address), - 'invalid': self.handler.address_is_invalid(person, address), } + model = self.model + if isinstance(address, model.PersonMailingAddress): + person = address.person + context['invalid'] = self.handler.address_is_invalid(person, address) + + return context + def get_context_customers(self, person): data = [] for cp in person._customers: From 66bc775e148f2383d77b52f66ebd61cce18b16b8 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 10 Oct 2021 20:43:27 -0400 Subject: [PATCH 0004/1243] Improve display, handling for "add contact info to customer record" for new custorders page. in particular, show this flag in main screen --- tailbone/templates/custorders/create.mako | 72 ++++++++++++++--------- tailbone/views/custorders/orders.py | 2 + 2 files changed, 47 insertions(+), 27 deletions(-) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index 5aed3c7b..f353c8fc 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -153,13 +153,19 @@
- - {{ orderPhoneNumber }} - - +

+ {{ orderPhoneNumber }} +

+

+ will be added to customer record +

+
+

(no valid phone number on file) - +

% if allow_contact_info_choice:
- + - + add this phone number to customer record @@ -241,9 +247,15 @@
- - {{ orderEmailAddress }} - +
+

+ {{ orderEmailAddress }} +

+

+ will be added to customer record +

+
(no valid email address on file) @@ -293,9 +305,9 @@ - + - + add this email address to customer record @@ -671,22 +683,25 @@ orderPhoneNumber: ${json.dumps(batch.phone_number)|n}, contactPhones: ${json.dumps(contact_phones)|n}, + addOtherPhoneNumber: ${json.dumps(add_phone_number)|n}, orderEmailAddress: ${json.dumps(batch.email_address)|n}, contactEmails: ${json.dumps(contact_emails)|n}, + addOtherEmailAddress: ${json.dumps(add_email_address)|n}, % if allow_contact_info_choice: - existingPhoneUUID: null, - otherPhoneNumber: null, - addOtherPhoneNumber: ${json.dumps(add_phone_number)|n}, editPhoneNumberShowDialog: false, + editPhoneNumberOther: null, + editPhoneNumberAddOther: false, + existingPhoneUUID: null, editPhoneNumberSaving: false, - existingEmailUUID: null, - otherEmailAddress: null, - addOtherEmailAddress: ${json.dumps(add_email_address)|n}, editEmailAddressShowDialog: false, + editEmailAddressOther: null, + editEmailAddressAddOther: false, + existingEmailUUID: null, + editEmailAddressOther: null, editEmailAddressSaving: false, % endif @@ -833,7 +848,7 @@ if (this.editPhoneNumberSaving) { return true } - if (!this.existingPhoneUUID && !this.otherPhoneNumber) { + if (!this.existingPhoneUUID && !this.editPhoneNumberOther) { return true } return false @@ -850,7 +865,7 @@ if (this.editEmailAddressSaving) { return true } - if (!this.existingEmailUUID && !this.otherEmailAddress) { + if (!this.existingEmailUUID && !this.editEmailAddressOther) { return true } return false @@ -1065,7 +1080,8 @@ break } } - this.otherPhoneNumber = this.existingPhoneUUID ? null : this.orderPhoneNumber + this.editPhoneNumberOther = this.existingPhoneUUID ? null : this.orderPhoneNumber + this.editPhoneNumberAddOther = this.addOtherPhoneNumber this.editPhoneNumberShowDialog = true }, @@ -1089,13 +1105,14 @@ if (params.phone_number) { params.add_phone_number = false } else { - params.phone_number = this.otherPhoneNumber - params.add_phone_number = this.addOtherPhoneNumber + params.phone_number = this.editPhoneNumberOther + params.add_phone_number = this.editPhoneNumberAddOther } this.submitBatchData(params, response => { if (response.data.success) { this.orderPhoneNumber = response.data.phone_number + this.addOtherPhoneNumber = response.data.add_phone_number this.editPhoneNumberShowDialog = false } else { this.$buefy.toast.open({ @@ -1119,9 +1136,9 @@ break } } - this.otherEmailAddress = this.existingEmailUUID ? null : this.orderEmailAddress + this.editEmailAddressOther = this.existingEmailUUID ? null : this.orderEmailAddress + this.editEmailAddressAddOther = this.addOtherEmailAddress this.editEmailAddressShowDialog = true - }, editEmailAddressSave() { @@ -1144,13 +1161,14 @@ if (params.email_address) { params.add_email_address = false } else { - params.email_address = this.otherEmailAddress - params.add_email_address = this.addOtherEmailAddress + params.email_address = this.editEmailAddressOther + params.add_email_address = this.editEmailAddressAddOther } this.submitBatchData(params, response => { if (response.data.success) { this.orderEmailAddress = response.data.email_address + this.addOtherEmailAddress = response.data.add_email_address this.editEmailAddressShowDialog = false } else { this.$buefy.toast.open({ diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index a718486f..a7129d68 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -484,6 +484,7 @@ class CustomerOrderView(MasterView): return { 'success': True, 'phone_number': batch.phone_number, + 'add_phone_number': bool(batch.get_param('add_phone_number')), } def update_email_address(self, batch, data): @@ -499,6 +500,7 @@ class CustomerOrderView(MasterView): return { 'success': True, 'email_address': batch.email_address, + 'add_email_address': bool(batch.get_param('add_email_address')), } def update_pending_customer(self, batch, data): From 20492410ad63631db47acccc0729794c7b005090 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 11 Oct 2021 21:58:18 -0400 Subject: [PATCH 0005/1243] Update changelog --- CHANGES.rst | 12 ++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index ad1ae269..7aaee0a8 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,18 @@ CHANGELOG ========= +0.8.160 (2021-10-11) +-------------------- + +* Stop rounding case/unit cost fields to 2 places for purchase batch. + +* Fix some phone/email bugs for new custorder page. + +* Fix bug when making context for mailing address. + +* Improve display, handling for "add contact info to customer record". + + 0.8.159 (2021-10-10) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index bf07a45e..a1da473b 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.159' +__version__ = '0.8.160' From aeace0c7cf2eb1d31fb8f9163340b587b922b1af Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 12 Oct 2021 14:17:10 -0400 Subject: [PATCH 0006/1243] Add `debounce()` wrapper for buefy autocomplete per docs, although was not very clear "which" debounce i needed, this one at least works without errors.. hoping this fixes some page performance issues when tailbone autocomplete component is present --- tailbone/static/js/debounce.js | 36 +++++++++++++++++++ .../static/js/tailbone.buefy.autocomplete.js | 10 +++--- tailbone/templates/themes/falafel/base.mako | 3 ++ 3 files changed, 44 insertions(+), 5 deletions(-) create mode 100644 tailbone/static/js/debounce.js diff --git a/tailbone/static/js/debounce.js b/tailbone/static/js/debounce.js new file mode 100644 index 00000000..8fea0eda --- /dev/null +++ b/tailbone/static/js/debounce.js @@ -0,0 +1,36 @@ + +// this code was politely stolen from +// https://vanillajstoolkit.com/helpers/debounce/ + +// its purpose is to help with Buefy autocomplete performance +// https://buefy.org/documentation/autocomplete/ + +/** + * Debounce functions for better performance + * (c) 2021 Chris Ferdinandi, MIT License, https://gomakethings.com + * @param {Function} fn The function to debounce + */ +function debounce (fn) { + + // Setup a timer + let timeout; + + // Return a function to run debounced + return function () { + + // Setup the arguments + let context = this; + let args = arguments; + + // If there's a timer, cancel it + if (timeout) { + window.cancelAnimationFrame(timeout); + } + + // Setup the new requestAnimationFrame() + timeout = window.requestAnimationFrame(function () { + fn.apply(context, args); + }); + + }; +} diff --git a/tailbone/static/js/tailbone.buefy.autocomplete.js b/tailbone/static/js/tailbone.buefy.autocomplete.js index eb36fa74..9b28f6b3 100644 --- a/tailbone/static/js/tailbone.buefy.autocomplete.js +++ b/tailbone/static/js/tailbone.buefy.autocomplete.js @@ -98,7 +98,7 @@ const TailboneAutocomplete = { // TODO: buefy example uses `debounce()` here and perhaps we should too? // https://buefy.org/documentation/autocomplete - getAsyncData: function (entry) { + getAsyncData: debounce(function (entry) { if (entry.length < 3) { this.data = [] return @@ -112,10 +112,10 @@ const TailboneAutocomplete = { this.data = [] throw error }) - .finally(() => { - this.isFetching = false - }) - }, + .finally(() => { + this.isFetching = false + }) + }), }, } diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index 24b533d1..bf8f5ee7 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -77,6 +77,9 @@ ## some commonly-useful logic for detecting (non-)numeric input ${h.javascript_link(request.static_url('tailbone:static/js/numeric.js') + '?ver={}'.format(tailbone.__version__))} + ## debounce, for better autocomplete performance + ${h.javascript_link(request.static_url('tailbone:static/js/debounce.js') + '?ver={}'.format(tailbone.__version__))} + ## Tailbone / Buefy stuff ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.datepicker.js') + '?ver={}'.format(tailbone.__version__))} ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.numericinput.js') + '?ver={}'.format(tailbone.__version__))} From e3cad91be0d8520762de6f750837c32f729dc0d3 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 12 Oct 2021 18:22:04 -0400 Subject: [PATCH 0007/1243] Leverage the auth handler for main user login --- tailbone/views/auth.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index 51b27f14..d071ace7 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -135,7 +135,9 @@ class AuthenticationView(View): return context def authenticate_user(self, username, password): - return authenticate_user(Session(), username, password) + app = self.get_rattail_app() + auth = app.get_auth_handler() + return auth.authenticate_user(Session(), username, password) def logout(self, **kwargs): """ From 1463c09385c60451222af3378cbd0f9fc165b29a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 13 Oct 2021 12:19:49 -0400 Subject: [PATCH 0008/1243] Update changelog --- CHANGES.rst | 8 ++++++++ tailbone/_version.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 7aaee0a8..d2dcaee4 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.161 (2021-10-13) +-------------------- + +* Add ``debounce()`` wrapper for buefy autocomplete. + +* Leverage the auth handler for main user login. + + 0.8.160 (2021-10-11) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index a1da473b..e20174fb 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.160' +__version__ = '0.8.161' From 80589cde2f6de69a6b4a855af6f19f8e02512cdc Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 13 Oct 2021 17:29:41 -0400 Subject: [PATCH 0009/1243] Cleanup form display a bit, for App Settings --- tailbone/templates/appsettings.mako | 37 ++++++++++++++++------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/tailbone/templates/appsettings.mako b/tailbone/templates/appsettings.mako index 888a5b2a..79b2d952 100644 --- a/tailbone/templates/appsettings.mako +++ b/tailbone/templates/appsettings.mako @@ -88,7 +88,9 @@
+ ## TODO: not sure how the error handling looks now? + ## :class="'field-wrapper' + (setting.error ? ' with-error' : '')" + >
-
- -
+
- + + + + {{ setting.value || false }} + -
+ + + + + {{ setting.helptext }} +
- - {{ setting.helptext }} - - -
+
From 22aa55c24bf34757e340ceedea7446fd06540f01 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 14 Oct 2021 10:39:54 -0400 Subject: [PATCH 0010/1243] Invoke the auth handler to cache user permissions etc. various changes for sake of "synced" roles feature --- tailbone/auth.py | 11 +++++----- tailbone/subscribers.py | 14 +++++++++++-- tailbone/views/roles.py | 45 ++++++++++++++++++++++++++++++++++++----- tailbone/views/users.py | 16 +++++++++++++++ 4 files changed, 74 insertions(+), 12 deletions(-) diff --git a/tailbone/auth.py b/tailbone/auth.py index 338fac55..deda1ab7 100644 --- a/tailbone/auth.py +++ b/tailbone/auth.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -93,9 +93,6 @@ def set_session_timeout(request, timeout): class TailboneAuthorizationPolicy(object): def permits(self, context, principals, permission): - from rattail.db import model - from rattail.db.auth import has_permission - for userid in principals: if userid not in (Everyone, Authenticated): if context.request.user and context.request.user.uuid == userid: @@ -103,11 +100,15 @@ class TailboneAuthorizationPolicy(object): else: # this is pretty rare, but can happen in dev after # re-creating the database, which means new user uuids. + config = context.request.rattail_config + model = config.get_model() + app = config.get_app() + auth = app.get_auth_handler() # TODO: the odds of this query returning a user in that # case, are probably nil, and we should just skip this bit? user = Session.query(model.User).get(userid) if user: - if has_permission(Session(), user, permission): + if auth.has_permission(Session(), user, permission): return True if Everyone in principals: return has_permission(Session(), None, permission) diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index b0834496..5468df7f 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -32,7 +32,6 @@ import datetime import rattail from rattail.db import model -from rattail.db.auth import cache_permissions import colander import deform @@ -69,6 +68,7 @@ def new_request(event): """ request = event.request rattail_config = request.registry.settings.get('rattail_config') + # TODO: why would this ever be null? if rattail_config: request.rattail_config = rattail_config @@ -86,7 +86,17 @@ def new_request(event): request.is_admin = bool(request.user) and request.user.is_admin() request.is_root = request.is_admin and request.session.get('is_root', False) - request.tailbone_cached_permissions = cache_permissions(Session(), request.user) + if rattail_config: + app = rattail_config.get_app() + auth = app.get_auth_handler() + request.tailbone_cached_permissions = auth.cache_permissions( + Session(), request.user) + else: + # TODO: not sure why this would really work, or even be + # needed, if there was no rattail config? + from rattail.db.auth import cache_permissions + request.tailbone_cached_permissions = cache_permissions( + Session(), request.user) def before_render(event): diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py index 3cd62571..8dde78b8 100644 --- a/tailbone/views/roles.py +++ b/tailbone/views/roles.py @@ -52,10 +52,13 @@ class RoleView(PrincipalMasterView): """ model_class = model.Role has_versions = True + touchable = True grid_columns = [ 'name', 'session_timeout', + 'sync_me', + 'node_type', 'notes', ] @@ -63,6 +66,8 @@ class RoleView(PrincipalMasterView): 'name', 'session_timeout', 'notes', + 'sync_me', + 'node_type', 'users', 'permissions', ] @@ -93,6 +98,11 @@ class RoleView(PrincipalMasterView): We must prevent edit for certain built-in roles etc., depending on current user's permissions. """ + # role with node type specified, can only be edited from a + # node of the same type + if role.node_type and role.node_type != self.rattail_config.node_type(): + return False + # only "root" can edit Administrator if role is administrator_role(self.Session()): return self.request.is_root @@ -116,6 +126,11 @@ class RoleView(PrincipalMasterView): """ We must prevent deletion for all built-in roles. """ + # role with node type specified, can only be edited from a + # node of the same type + if role.node_type and role.node_type != self.rattail_config.node_type(): + return False + if role is administrator_role(self.Session()): return False if role is authenticated_role(self.Session()): @@ -147,6 +162,27 @@ class RoleView(PrincipalMasterView): # name f.set_validator('name', self.unique_name) + # session_timeout + f.set_renderer('session_timeout', self.render_session_timeout) + if self.editing and role is guest_role(self.Session()): + f.set_readonly('session_timeout') + + # sync_me, node_type + if not self.creating: + include = True + if role is administrator_role(self.Session()): + include = False + elif role is authenticated_role(self.Session()): + include = False + elif role is guest_role(self.Session()): + include = False + if not include: + f.remove('sync_me', 'node_type') + else: + if not self.has_perm('edit_node_sync'): + f.set_readonly('sync_me') + f.set_readonly('node_type') + # notes f.set_type('notes', 'text_wrapped') @@ -173,11 +209,6 @@ class RoleView(PrincipalMasterView): elif self.deleting: f.remove_field('permissions') - # session_timeout - f.set_renderer('session_timeout', self.render_session_timeout) - if self.editing and role is guest_role(self.Session()): - f.set_readonly('session_timeout') - def render_users(self, role, field): if role is guest_role(self.Session()): @@ -417,6 +448,7 @@ class RoleView(PrincipalMasterView): route_prefix = cls.get_route_prefix() url_prefix = cls.get_url_prefix() permission_prefix = cls.get_permission_prefix() + model_title = cls.get_model_title() # extra permissions for editing built-in roles etc. config.add_tailbone_permission(permission_prefix, '{}.edit_authenticated'.format(permission_prefix), @@ -425,6 +457,9 @@ class RoleView(PrincipalMasterView): "Edit the \"Guest\" Role") config.add_tailbone_permission(permission_prefix, '{}.edit_my'.format(permission_prefix), "Edit Role(s) to which current user belongs") + config.add_tailbone_permission(permission_prefix, + '{}.edit_node_sync'.format(permission_prefix), + "Edit the Node Type and Sync flags for a {}".format(model_title)) # download permissions matrix config.add_tailbone_permission(permission_prefix, '{}.download_permissions_matrix'.format(permission_prefix), diff --git a/tailbone/views/users.py b/tailbone/views/users.py index 6c8000ad..b30034b4 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -320,6 +320,14 @@ class UserView(PrincipalMasterView): if self.request.is_root or uuid != admin.uuid: user._roles.append(model.UserRole(role_uuid=uuid)) + # also record a change to the role, for datasync. + # this is done "just in case" the role is to be + # synced to all nodes + if self.Session().rattail_record_changes: + self.Session.add(model.Change(class_name='Role', + instance_uuid=uuid, + deleted=False)) + # remove any roles which were *not* specified, although must take care # not to remove admin role, unless acting as root for uuid in old_roles: @@ -328,6 +336,14 @@ class UserView(PrincipalMasterView): role = self.Session.query(model.Role).get(uuid) user.roles.remove(role) + # also record a change to the role, for datasync. + # this is done "just in case" the role is to be + # synced to all nodes + if self.Session().rattail_record_changes: + self.Session.add(model.Change(class_name='Role', + instance_uuid=uuid, + deleted=False)) + def render_person(self, user, field): person = user.person if not person: From d61fa7b6b916b6c55177cfa5d6bc4e7ae100f684 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 14 Oct 2021 12:12:10 -0400 Subject: [PATCH 0011/1243] Update changelog --- CHANGES.rst | 8 ++++++++ tailbone/_version.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index d2dcaee4..50f39f37 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.8.162 (2021-10-14) +-------------------- + +* Cleanup form display a bit, for App Settings. + +* Invoke the auth handler to cache user permissions etc. + + 0.8.161 (2021-10-13) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index e20174fb..8acdddff 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.161' +__version__ = '0.8.162' From dd6c9cc8cebddabf584bbae8395ef0d19a1a0428 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 14 Oct 2021 14:18:36 -0400 Subject: [PATCH 0012/1243] Misc. tweaks for users, roles --- tailbone/views/roles.py | 2 +- tailbone/views/users.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py index 8dde78b8..ded1f5cf 100644 --- a/tailbone/views/roles.py +++ b/tailbone/views/roles.py @@ -187,7 +187,7 @@ class RoleView(PrincipalMasterView): f.set_type('notes', 'text_wrapped') # users - if use_buefy and self.viewing or self.deleting: + if use_buefy and self.viewing: f.set_renderer('users', self.render_users) else: f.remove('users') diff --git a/tailbone/views/users.py b/tailbone/views/users.py index b30034b4..b0f6a5de 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -52,6 +52,7 @@ class UserView(PrincipalMasterView): has_rows = True model_row_class = model.UserEvent has_versions = True + touchable = True grid_columns = [ 'username', From 1b33c8a2b75429c67c38213a8701f3534f9a8e83 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 14 Oct 2021 14:22:07 -0400 Subject: [PATCH 0013/1243] Update changelog --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 50f39f37..540b069d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.163 (2021-10-14) +-------------------- + +* Misc. tweaks for users, roles. + + 0.8.162 (2021-10-14) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 8acdddff..29fe3709 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.162' +__version__ = '0.8.163' From 53fc1508f3c5925ce3a6526efcad0604a9a8a5d2 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 14 Oct 2021 17:49:12 -0400 Subject: [PATCH 0014/1243] Give custorder batch handler a couple ways to affect adding new items --- tailbone/views/custorders/orders.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index a7129d68..b7b583dc 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -530,10 +530,19 @@ class CustomerOrderView(MasterView): return {'error': "Must specify a product UPC"} product = self.handler.locate_product_for_entry( - self.Session(), upc, product_key='upc') + self.Session(), upc, product_key='upc', + # nb. let handler know "why" we're doing this, so that it + # can "modify" the result accordingly, i.e. return the + # appropriate item when a "different" scancode is entered + # by the user (e.g. PLU, and/or units vs. packs) + variation='new_custorder') if not product: return {'error': "Product not found"} + reason = self.handler.why_not_add_product(product, batch) + if reason: + return {'error': reason} + return self.info_for_product(batch, data, product) def get_product_info(self, batch, data): From 232a02b944ada5f9afafe428a07be938f93943df Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 14 Oct 2021 21:42:16 -0400 Subject: [PATCH 0015/1243] Refactor to leverage all existing methods of auth handler instead of importing and calling functions from core rattail --- tailbone/api/auth.py | 15 ++++++++++----- tailbone/auth.py | 11 ++++++----- tailbone/subscribers.py | 13 +++++++------ tailbone/views/auth.py | 7 +++++-- tailbone/views/principal.py | 10 ++++++---- tailbone/views/roles.py | 30 ++++++++++++++++++++++-------- tailbone/views/users.py | 11 ++++++++--- 7 files changed, 64 insertions(+), 33 deletions(-) diff --git a/tailbone/api/auth.py b/tailbone/api/auth.py index 16e48e82..80f8fac0 100644 --- a/tailbone/api/auth.py +++ b/tailbone/api/auth.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -26,7 +26,7 @@ Tailbone Web API - Auth Views from __future__ import unicode_literals, absolute_import -from rattail.db.auth import authenticate_user, set_user_password, cache_permissions +from rattail.db.auth import set_user_password from cornice import Service @@ -82,15 +82,20 @@ class AuthenticationView(APIView): if error: return {'error': error} + app = self.get_rattail_app() + auth = app.get_auth_handler() + login_user(self.request, user) return { 'ok': True, 'user': self.get_user_info(user), - 'permissions': list(cache_permissions(Session(), user)), + 'permissions': list(auth.cache_permissions(Session(), user)), } def authenticate_user(self, username, password): - return authenticate_user(Session(), username, password) + app = self.get_rattail_app() + auth = app.get_auth_handler() + return auth.authenticate_user(Session(), username, password) def why_cant_user_login(self, user): """ @@ -156,7 +161,7 @@ class AuthenticationView(APIView): data = self.request.json_body # first make sure "current" password is accurate - if not authenticate_user(Session(), self.request.user, data['current_password']): + if not self.authenticate_user(self.request.user, data['current_password']): return {'error': "The current/old password you provided is incorrect"} # okay then, set new password diff --git a/tailbone/auth.py b/tailbone/auth.py index deda1ab7..88fbab0b 100644 --- a/tailbone/auth.py +++ b/tailbone/auth.py @@ -93,6 +93,11 @@ def set_session_timeout(request, timeout): class TailboneAuthorizationPolicy(object): def permits(self, context, principals, permission): + config = context.request.rattail_config + model = config.get_model() + app = config.get_app() + auth = app.get_auth_handler() + for userid in principals: if userid not in (Everyone, Authenticated): if context.request.user and context.request.user.uuid == userid: @@ -100,10 +105,6 @@ class TailboneAuthorizationPolicy(object): else: # this is pretty rare, but can happen in dev after # re-creating the database, which means new user uuids. - config = context.request.rattail_config - model = config.get_model() - app = config.get_app() - auth = app.get_auth_handler() # TODO: the odds of this query returning a user in that # case, are probably nil, and we should just skip this bit? user = Session.query(model.User).get(userid) @@ -111,7 +112,7 @@ class TailboneAuthorizationPolicy(object): if auth.has_permission(Session(), user, permission): return True if Everyone in principals: - return has_permission(Session(), None, permission) + return auth.has_permission(Session(), None, permission) return False def principals_allowed_by_permission(self, context, permission): diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 5468df7f..6fbced82 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -91,12 +91,13 @@ def new_request(event): auth = app.get_auth_handler() request.tailbone_cached_permissions = auth.cache_permissions( Session(), request.user) - else: - # TODO: not sure why this would really work, or even be - # needed, if there was no rattail config? - from rattail.db.auth import cache_permissions - request.tailbone_cached_permissions = cache_permissions( - Session(), request.user) + # TODO: until we know otherwise, let's assume this is not needed + # else: + # # TODO: not sure why this would really work, or even be + # # needed, if there was no rattail config? + # from rattail.db.auth import cache_permissions + # request.tailbone_cached_permissions = cache_permissions( + # Session(), request.user) def before_render(event): diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index d071ace7..efe2794d 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -50,9 +50,12 @@ class UserLogin(colander.MappingSchema): @colander.deferred def current_password_correct(node, kw): + request = kw['request'] + app = request.rattail_config.get_app() + auth = app.get_auth_handler() user = kw['user'] def validate(node, value): - if not authenticate_user(Session(), user.username, value): + if not auth.authenticate_user(Session(), user.username, value): raise colander.Invalid(node, "The password is incorrect") return validate @@ -175,7 +178,7 @@ class AuthenticationView(View): return self.redirect(self.request.get_referrer()) use_buefy = self.get_use_buefy() - schema = ChangePassword().bind(user=self.request.user) + schema = ChangePassword().bind(user=self.request.user, request=self.request) form = forms.Form(schema=schema, request=self.request, use_buefy=use_buefy) if form.validate(newstyle=True): set_user_password(self.request.user, form.validated['new_password']) diff --git a/tailbone/views/principal.py b/tailbone/views/principal.py index b3d032ab..3fc5ce6b 100644 --- a/tailbone/views/principal.py +++ b/tailbone/views/principal.py @@ -28,7 +28,6 @@ from __future__ import unicode_literals, absolute_import import copy -from rattail.db.auth import has_permission from rattail.core import Object from rattail.util import OrderedDict @@ -144,6 +143,9 @@ class PermissionsRenderer(Object): return self.render() def render(self): + app = self.request.rattail_config.get_app() + auth = app.get_handler() + principal = self.principal html = '' for groupkey in sorted(self.permissions, key=lambda k: self.permissions[k]['label'].lower()): @@ -151,9 +153,9 @@ class PermissionsRenderer(Object): perms = self.permissions[groupkey]['perms'] rendered = False for key in sorted(perms, key=lambda p: perms[p]['label'].lower()): - checked = has_permission(Session(), principal, key, - include_guest=self.include_guest, - include_authenticated=self.include_authenticated) + checked = auth.has_permission(Session(), principal, key, + include_guest=self.include_guest, + include_authenticated=self.include_authenticated) if checked: label = perms[key]['label'] span = HTML.tag('span', c="[X]" if checked else "[ ]") diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py index ded1f5cf..2ce48f0d 100644 --- a/tailbone/views/roles.py +++ b/tailbone/views/roles.py @@ -33,8 +33,7 @@ from sqlalchemy import orm from openpyxl.styles import Font, PatternFill from rattail.db import model -from rattail.db.auth import (has_permission, grant_permission, revoke_permission, - administrator_role, guest_role, authenticated_role) +from rattail.db.auth import administrator_role, guest_role, authenticated_role from rattail.excel import ExcelWriter import colander @@ -158,6 +157,8 @@ class RoleView(PrincipalMasterView): super(RoleView, self).configure_form(f) role = f.model_instance use_buefy = self.get_use_buefy() + app = self.get_rattail_app() + auth = app.get_auth_handler() # name f.set_validator('name', self.unique_name) @@ -194,7 +195,8 @@ class RoleView(PrincipalMasterView): # permissions self.tailbone_permissions = self.get_available_permissions() - f.set_renderer('permissions', PermissionsRenderer(permissions=self.tailbone_permissions)) + f.set_renderer('permissions', PermissionsRenderer(request=self.request, + permissions=self.tailbone_permissions)) f.set_node('permissions', colander.Set()) f.set_widget('permissions', PermissionsWidget( permissions=self.tailbone_permissions, @@ -203,7 +205,9 @@ class RoleView(PrincipalMasterView): granted = [] for groupkey in self.tailbone_permissions: for key in self.tailbone_permissions[groupkey]['perms']: - if has_permission(self.Session(), role, key, include_guest=False, include_authenticated=False): + if auth.has_permission(self.Session(), role, key, + include_guest=False, + include_authenticated=False): granted.append(key) f.set_default('permissions', granted) elif self.deleting: @@ -309,13 +313,16 @@ class RoleView(PrincipalMasterView): permissions, but rather each "available" permission (depends on current user) will be examined individually, and updated as needed. """ + app = self.get_rattail_app() + auth = app.get_auth_handler() + available = self.tailbone_permissions for gkey, group in six.iteritems(available): for pkey, perm in six.iteritems(group['perms']): if pkey in permissions: - grant_permission(role, pkey) + auth.grant_permission(role, pkey) else: - revoke_permission(role, pkey) + auth.revoke_permission(role, pkey) def template_kwargs_view(self, **kwargs): role = kwargs['instance'] @@ -364,13 +371,16 @@ class RoleView(PrincipalMasterView): return self.redirect(self.request.get_referrer(default=self.request.route_url('roles'))) def find_principals_with_permission(self, session, permission): + app = self.get_rattail_app() + auth = app.get_auth_handler() + # TODO: this should search Permission table instead, and work backward to Role? all_roles = session.query(model.Role)\ .order_by(model.Role.name)\ .options(orm.joinedload(model.Role._permissions)) roles = [] for role in all_roles: - if has_permission(session, role, permission, include_guest=False): + if auth.has_permission(session, role, permission, include_guest=False): roles.append(role) return roles @@ -379,6 +389,9 @@ class RoleView(PrincipalMasterView): View which renders the complete role / permissions matrix data into an Excel spreadsheet, and returns that file. """ + app = self.get_rattail_app() + auth = app.get_auth_handler() + roles = self.Session.query(model.Role)\ .order_by(model.Role.name)\ .all() @@ -427,7 +440,8 @@ class RoleView(PrincipalMasterView): # and show an 'X' for any role which has this perm for col, role in enumerate(roles, 2): - if has_permission(self.Session(), role, key, include_guest=False): + if auth.has_permission(self.Session(), role, key, + include_guest=False): sheet.cell(row=writing_row, column=col, value="X") writing_row += 1 diff --git a/tailbone/views/users.py b/tailbone/views/users.py index b0f6a5de..7b8312bd 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -32,7 +32,8 @@ import six from sqlalchemy import orm from rattail.db import model -from rattail.db.auth import administrator_role, guest_role, authenticated_role, set_user_password, has_permission +from rattail.db.auth import (administrator_role, guest_role, + authenticated_role, set_user_password) import colander from deform import widget as dfwidget @@ -239,7 +240,8 @@ class UserView(PrincipalMasterView): if self.viewing: permissions = self.request.registry.settings.get('tailbone_permissions', {}) f.append('permissions') - f.set_renderer('permissions', PermissionsRenderer(permissions=permissions, + f.set_renderer('permissions', PermissionsRenderer(request=self.request, + permissions=permissions, include_guest=True, include_authenticated=True)) @@ -389,6 +391,9 @@ class UserView(PrincipalMasterView): ] def find_principals_with_permission(self, session, permission): + app = self.get_rattail_app() + auth = app.get_auth_handler() + # TODO: this should search Permission table instead, and work backward to User? all_users = session.query(model.User)\ .filter(model.User.active == True)\ @@ -398,7 +403,7 @@ class UserView(PrincipalMasterView): .joinedload(model.Role._permissions)) users = [] for user in all_users: - if has_permission(session, user, permission): + if auth.has_permission(session, user, permission): users.append(user) return users From 52fbe73893f3ed3a48709745092fd0c4e9cae91b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 16 Oct 2021 15:37:23 -0400 Subject: [PATCH 0016/1243] Overhaul the autocomplete component, for sake of new custorder turns out we had some issues with our understanding of how that all was supposed to work. this seems to be much cleaner and even semi-documented :) --- .../static/js/tailbone.buefy.autocomplete.js | 229 ++++++++++++------ tailbone/templates/autocomplete.mako | 5 +- tailbone/templates/custorders/create.mako | 18 +- 3 files changed, 166 insertions(+), 86 deletions(-) diff --git a/tailbone/static/js/tailbone.buefy.autocomplete.js b/tailbone/static/js/tailbone.buefy.autocomplete.js index 9b28f6b3..7969f35a 100644 --- a/tailbone/static/js/tailbone.buefy.autocomplete.js +++ b/tailbone/static/js/tailbone.buefy.autocomplete.js @@ -4,16 +4,54 @@ const TailboneAutocomplete = { template: '#tailbone-autocomplete-template', props: { + + // this is the "input" field name essentially. primarily is + // useful for "traditional" tailbone forms; it normally is not + // used otherwise. it is passed as-is to the buefy + // autocomplete component `name` prop name: String, + + // the url from which search results are to be obtained. the + // url should expect a GET request with a query string with a + // single `term` parameter, and return results as a JSON array + // containing objects with `value` and `label` properties. serviceUrl: String, + + // callers do not specify this directly but rather by way of + // the `v-model` directive. this component will emit `input` + // events when the value changes value: String, + + // callers may set an initial label if needed. this is useful + // in cases where the autocomplete needs to "already have a + // value" on page load. for instance when a user fills out + // the autocomplete field, but leaves other required fields + // blank and submits the form; page will re-load showing + // errors but the autocomplete field should remain "set" - + // normally it is only given a "value" (e.g. uuid) but this + // allows for the "label" to display correctly as well initialLabel: String, - assignedValue: String, + + // TODO: i am not sure this is needed? but current logic does + // handle it specially, so am leaving for now. if this prop + // is set by the caller, then the `assignedLabel` will *always* + // be shown for the button (when "selection" has been made) assignedLabel: String, + + // simple placeholder text for the input box placeholder: String, + + // TODO: pretty sure this can be ignored..? + // (should deprecate / remove if so) + assignedValue: String, }, data() { + + // we want to track the "currently selected option" - which + // should normally be `null` to begin with, unless we were + // given a value, in which case we use `initialLabel` to + // complete the option let selected = null if (this.value) { selected = { @@ -21,89 +59,55 @@ const TailboneAutocomplete = { label: this.initialLabel, } } - return { - data: [], - selected: selected, - isFetching: false, - } - }, - watch: { - value(to, from) { - if (from && !to) { - this.clearSelection(false) - } - }, + return { + + // this contains the search results; its contents may + // change over time as new searches happen. the + // "currently selected option" should be one of these, + // unless it is null + data: [], + + // this tracks our "currently selected option" - per above + selected: selected, + + // since we are wrapping a component which also makes use + // of the "value" paradigm, we must separate the concerns. + // so we use our own `value` prop to interact with the + // caller, but then we use this `buefyValue` data point to + // communicate with the buefy autocomplete component. + // note that `this.value` will always be either a uuid or + // null, whereas `this.buefyValue` may be raw text as + // entered by the user. + buefyValue: this.value, + + // // TODO: we are "setting" this at the appropriate time, + // // but not clear if that actually affects anything. + // // should we just remove it? + // isFetching: false, + } }, methods: { - clearSelection(focus) { - if (focus === undefined) { - focus = true - } - this.selected = null - this.value = null - if (focus) { - this.$nextTick(function() { - this.focus() - }) - } - - // TODO: should emit event for caller logic (can they cancel?) - // $('#' + oid + '-textbox').trigger('autocompletevaluecleared'); - }, - - focus() { - this.$refs.autocomplete.focus() - }, - - getDisplayText() { - if (this.assignedLabel) { - return this.assignedLabel - } - if (this.selected) { - return this.selected.display || this.selected.label - } - return "" - }, - - // TODO: should we allow custom callback? or is event enough? - // function (oid) { - // $('#' + oid + '-textbox').on('autocompletevaluecleared', function() { - // ${cleared_callback}(); - // }); - // } - - selectionMade(option) { - this.selected = option - - // TODO: should emit event for caller logic (can they cancel?) - // $('#' + oid + '-textbox').trigger('autocompletevalueselected', - // [ui.item.value, ui.item.label]); - }, - - // TODO: should we allow custom callback? or is event enough? - // function (oid) { - // $('#' + oid + '-textbox').on('autocompletevalueselected', function(event, uuid, label) { - // ${selected_callback}(uuid, label); - // }); - // } - - itemSelected(value) { - if (this.selected || !value) { - this.$emit('input', value) - } - }, - - // TODO: buefy example uses `debounce()` here and perhaps we should too? - // https://buefy.org/documentation/autocomplete + // fetch new search results from the server. this is invoked + // via the `@typing` event from buefy autocomplete component. + // the doc at https://buefy.org/documentation/autocomplete + // mentions `debounce` as being optional. at one point i + // thought it would fix a performance bug; not sure `debounce` + // helped but figured might as well leave it getAsyncData: debounce(function (entry) { + + // since the `@typing` event from buefy component does not + // "self-regulate" in any way, we a) use `debounce` above, + // but also b) skip the search unless we have at least 3 + // characters of input from user if (entry.length < 3) { this.data = [] return } - this.isFetching = true + + // and perform the search this.$http.get(this.serviceUrl + '?term=' + encodeURIComponent(entry)) .then(({ data }) => { this.data = data @@ -112,10 +116,81 @@ const TailboneAutocomplete = { this.data = [] throw error }) - .finally(() => { - this.isFetching = false - }) }), + + // this method is invoked via the `@select` event of the buefy + // autocomplete component. the `option` received will either + // be `null` or else a simple object with (at least) `value` + // and `label` properties + selectionMade(option) { + + // we want to keep track of the "currently selected + // option" so we can display its label etc. also this + // helps control the visibility of the autocomplete input + // field vs. the button which indicates the field has a + // value + this.selected = option + + // reset the internal value for buefy autocomplete + // component. note that this value will normally hold + // either the raw text entered by the user, or a uuid. we + // will not be needing either of those b/c they are not + // visible to user once selection is made, and if the + // selection is cleared we want user to start over anyway + this.buefyValue = null + + // here is where we alert callers to the new value + this.$emit('input', option ? option.value : null) + }, + + // clear the field of any value, i.e. set the "currently + // selected option" to null. this is invoked when you click + // the button, which is visible while the field has a value. + // but callers can invoke it directly as well. + clearSelection(focus) { + + // clear selection for the buefy autocomplete component + this.$refs.autocomplete.setSelected(null) + + // maybe set focus to our (autocomplete) component + if (focus) { + this.$nextTick(function() { + this.focus() + }) + } + }, + + // set focus to this component, which will just set focus to + // the buefy autocomplete component + focus() { + this.$refs.autocomplete.focus() + }, + + // this determines the "display text" for the button, which is + // shown when a selection has been made (or rather, when the + // field actually has a value) + getDisplayText() { + + // always use the "assigned" label if we have one + // TODO: where is this used? what is the use case? + if (this.assignedLabel) { + return this.assignedLabel + } + + // if we have a "currently selected option" then use its + // label. all search results / options have a `label` + // property as that is shown directly in the autocomplete + // dropdown. but if the option also has a `display` + // property then that is what we will show in the button. + // this way search results can show one thing in the + // search dropdown, and another in the button. + if (this.selected) { + return this.selected.display || this.selected.label + } + + // we have nothing to go on here.. + return "" + }, }, } diff --git a/tailbone/templates/autocomplete.mako b/tailbone/templates/autocomplete.mako index e7aad900..7961d07c 100644 --- a/tailbone/templates/autocomplete.mako +++ b/tailbone/templates/autocomplete.mako @@ -65,12 +65,11 @@