From 53fc1508f3c5925ce3a6526efcad0604a9a8a5d2 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 14 Oct 2021 17:49:12 -0400 Subject: [PATCH 0001/1230] 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 0002/1230] 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 0003/1230] 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 @@