From 46c7ef42dea68f1ec9ff86b4408d06b196966818 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 8 Mar 2023 20:38:16 -0600 Subject: [PATCH 001/636] Remove version cap for cornice, now that we require python3 --- setup.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/setup.py b/setup.py index f200f89d..2cf45c4f 100644 --- a/setup.py +++ b/setup.py @@ -68,18 +68,16 @@ requires = [ # (still, probably a better idea is to refactor so we can use 0.9) 'webhelpers2_grid==0.1', # 0.1 - # TODO: remove version cap once we can drop support for python 2.x - 'cornice<5.0', # 3.4.2 4.0.1 - # TODO: remove once their bug is fixed? idk what this is about yet... 'deform<2.0.15', # 2.0.14 - # TODO: cornice<5 requires pyramid<2 (see above) + # TODO: remove this cap and address warnings that follow 'pyramid<2', # 1.3b2 1.10.8 'asgiref', # 3.2.3 'colander', # 1.7.0 'ColanderAlchemy', # 0.3.3 + 'cornice', # 3.4.2 'humanize', # 0.5.1 'Mako', # 0.6.2 'markdown', # 3.3.3 From 5aa982c95f0b7984147c748061a1d8304252152f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 8 Mar 2023 20:39:39 -0600 Subject: [PATCH 002/636] Update changelog --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 75453965..aa46a8b0 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.9.13 (2023-03-08) +------------------- + +* Remove version cap for cornice, now that we require python3. + + 0.9.12 (2023-03-02) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index b405ecf7..026493ca 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.12' +__version__ = '0.9.13' From 2ebe0401c3b7201d19c456ed4d28c8b2b5cba6de Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 9 Mar 2023 14:07:10 -0600 Subject: [PATCH 003/636] Fix JSON rendering for Cornice API views also make sure we use Cornice for all API views --- setup.py | 1 + tailbone/api/batch/core.py | 11 ++++---- tailbone/api/batch/receiving.py | 45 +++++++++++++++++---------------- tailbone/webapi.py | 11 ++++++-- 4 files changed, 38 insertions(+), 30 deletions(-) diff --git a/setup.py b/setup.py index 2cf45c4f..7cc8f867 100644 --- a/setup.py +++ b/setup.py @@ -95,6 +95,7 @@ requires = [ 'rattail[db,bouncer]', # 0.5.0 'six', # 1.10.0 'sa-filters', # 1.2.0 + 'simplejson', # 3.18.3 'transaction', # 1.2.0 'waitress', # 0.8.1 'WebHelpers2', # 2.0 diff --git a/tailbone/api/batch/core.py b/tailbone/api/batch/core.py index a1c06ee6..f239aaaf 100644 --- a/tailbone/api/batch/core.py +++ b/tailbone/api/batch/core.py @@ -348,13 +348,12 @@ class APIBatchRowView(APIBatchMixin, APIMasterView): route_prefix = cls.get_route_prefix() permission_prefix = cls.get_permission_prefix() collection_url_prefix = cls.get_collection_url_prefix() - object_url_prefix = cls.get_object_url_prefix() if cls.supports_quick_entry: # quick entry - config.add_route('{}.quick_entry'.format(route_prefix), '{}/quick-entry'.format(collection_url_prefix), - request_method=('OPTIONS', 'POST')) - config.add_view(cls, attr='quick_entry', route_name='{}.quick_entry'.format(route_prefix), - permission='{}.edit'.format(permission_prefix), - renderer='json') + quick_entry = Service(name='{}.quick_entry'.format(route_prefix), + path='{}/quick-entry'.format(collection_url_prefix)) + quick_entry.add_view('POST', 'quick_entry', klass=cls, + permission='{}.edit'.format(permission_prefix)) + config.add_cornice_service(quick_entry) diff --git a/tailbone/api/batch/receiving.py b/tailbone/api/batch/receiving.py index 339fc43f..53d5f98a 100644 --- a/tailbone/api/batch/receiving.py +++ b/tailbone/api/batch/receiving.py @@ -31,6 +31,7 @@ import humanize from rattail.db import model from rattail.util import pretty_quantity +from cornice import Service from deform import widget as dfwidget from tailbone import forms @@ -143,26 +144,26 @@ class ReceivingBatchViews(APIBatchView): collection_url_prefix = cls.get_collection_url_prefix() object_url_prefix = cls.get_object_url_prefix() - # auto-receive - config.add_route('{}.auto_receive'.format(route_prefix), - '{}/{{uuid}}/auto-receive'.format(object_url_prefix)) - config.add_view(cls, attr='auto_receive', - route_name='{}.auto_receive'.format(route_prefix), - permission='{}.auto_receive'.format(permission_prefix), - renderer='json') + # auto_receive + auto_receive = Service(name='{}.auto_receive'.format(route_prefix), + path='{}/{{uuid}}/auto-receive'.format(object_url_prefix)) + auto_receive.add_view('GET', 'auto_receive', klass=cls, + permission='{}.auto_receive'.format(permission_prefix)) + config.add_cornice_service(auto_receive) - # mark receiving complete - config.add_route('{}.mark_receiving_complete'.format(route_prefix), '{}/{{uuid}}/mark-receiving-complete'.format(object_url_prefix)) - config.add_view(cls, attr='mark_receiving_complete', route_name='{}.mark_receiving_complete'.format(route_prefix), - permission='{}.edit'.format(permission_prefix), - renderer='json') + # mark_receiving_complete + mark_receiving_complete = Service(name='{}.mark_receiving_complete'.format(route_prefix), + path='{}/{{uuid}}/mark-receiving-complete'.format(object_url_prefix)) + mark_receiving_complete.add_view('POST', 'mark_receiving_complete', klass=cls, + permission='{}.edit'.format(permission_prefix)) + config.add_cornice_service(mark_receiving_complete) # eligible purchases - config.add_route('{}.eligible_purchases'.format(route_prefix), '{}/eligible-purchases'.format(collection_url_prefix), - request_method='GET') - config.add_view(cls, attr='eligible_purchases', route_name='{}.eligible_purchases'.format(route_prefix), - permission='{}.create'.format(permission_prefix), - renderer='json') + eligible_purchases = Service(name='{}.eligible_purchases'.format(route_prefix), + path='{}/eligible-purchases'.format(collection_url_prefix)) + eligible_purchases.add_view('GET', 'eligible_purchases', klass=cls, + permission='{}.create'.format(permission_prefix)) + config.add_cornice_service(eligible_purchases) class ReceivingBatchRowViews(APIBatchRowView): @@ -437,11 +438,11 @@ class ReceivingBatchRowViews(APIBatchRowView): object_url_prefix = cls.get_object_url_prefix() # receive (row) - config.add_route('{}.receive'.format(route_prefix), '{}/{{uuid}}/receive'.format(object_url_prefix), - request_method=('OPTIONS', 'POST')) - config.add_view(cls, attr='receive', route_name='{}.receive'.format(route_prefix), - permission='{}.edit_row'.format(permission_prefix), - renderer='json') + receive = Service(name='{}.receive'.format(route_prefix), + path='{}/{{uuid}}/receive'.format(object_url_prefix)) + receive.add_view('POST', 'receive', klass=cls, + permission='{}.edit_row'.format(permission_prefix)) + config.add_cornice_service(receive) def defaults(config, **kwargs): diff --git a/tailbone/webapi.py b/tailbone/webapi.py index 10c3460b..b623bd70 100644 --- a/tailbone/webapi.py +++ b/tailbone/webapi.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2021 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,9 @@ Tailbone Web API """ -from __future__ import unicode_literals, absolute_import +import simplejson +from cornice.renderer import CorniceRenderer from pyramid.config import Configurator from pyramid.authentication import SessionAuthenticationPolicy @@ -61,6 +62,12 @@ def make_pyramid_config(settings): pyramid_config.include('pyramid_tm') pyramid_config.include('cornice') + # use simplejson to serialize cornice view context; cf. + # https://cornice.readthedocs.io/en/latest/upgrading.html#x-to-5-x + # https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/renderers.html + json_renderer = CorniceRenderer(serializer=simplejson.dumps) + pyramid_config.add_renderer('cornicejson', json_renderer) + # bring in the pyramid_retry logic, if available # TODO: pretty soon we can require this package, hopefully.. try: From 9ee46107d25681797ce5b44f6a80142c470de88d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 9 Mar 2023 14:10:31 -0600 Subject: [PATCH 004/636] Update changelog --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index aa46a8b0..0903b107 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.9.14 (2023-03-09) +------------------- + +* Fix JSON rendering for Cornice API views. + + 0.9.13 (2023-03-08) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 026493ca..74531a6d 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.13' +__version__ = '0.9.14' From e19adf89071bd72df22dc5e75755ae1fda502755 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 9 Mar 2023 15:26:34 -0600 Subject: [PATCH 005/636] Remove version workaround for sphinx no longer needed --- setup.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/setup.py b/setup.py index 7cc8f867..90383def 100644 --- a/setup.py +++ b/setup.py @@ -110,9 +110,7 @@ extras = { # # package # low high - # TODO: remove version workaround after next sphinx[-rtd-theme] release - # cf. https://github.com/readthedocs/sphinx_rtd_theme/issues/1343 - 'Sphinx!=5.2.0.post0', # 1.2 + 'Sphinx', # 1.2 'sphinx-rtd-theme', # 0.2.4 ], From 1ce67953dfe84ebc618006d2508660043f02b2b2 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 15 Mar 2023 09:33:20 -0500 Subject: [PATCH 006/636] Let providers do DB connection setup for web API --- tailbone/webapi.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tailbone/webapi.py b/tailbone/webapi.py index b623bd70..a437f0c3 100644 --- a/tailbone/webapi.py +++ b/tailbone/webapi.py @@ -32,6 +32,7 @@ from pyramid.authentication import SessionAuthenticationPolicy from tailbone import app from tailbone.auth import TailboneAuthorizationPolicy +from tailbone.providers import get_all_providers def make_rattail_config(settings): @@ -46,6 +47,7 @@ def make_pyramid_config(settings): """ Make a Pyramid config object from the given settings. """ + rattail_config = settings['rattail_config'] pyramid_config = Configurator(settings=settings, root_factory=app.Root) # configure user authorization / authentication @@ -77,6 +79,13 @@ def make_pyramid_config(settings): else: pyramid_config.include('pyramid_retry') + # fetch all tailbone providers + providers = get_all_providers(rattail_config) + for provider in providers.values(): + + # configure DB sessions associated with transaction manager + provider.configure_db_sessions(rattail_config, pyramid_config) + # add some permissions magic pyramid_config.add_directive('add_tailbone_permission_group', 'tailbone.auth.add_permission_group') pyramid_config.add_directive('add_tailbone_permission', 'tailbone.auth.add_permission') From 9125d7ef74e8b2390c9363d6cead15843a2723d7 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 15 Mar 2023 09:43:21 -0500 Subject: [PATCH 007/636] Update changelog --- CHANGES.rst | 8 ++++++++ tailbone/_version.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 0903b107..8aaaf62d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.9.15 (2023-03-15) +------------------- + +* Remove version workaround for sphinx. + +* Let providers do DB connection setup for web API. + + 0.9.14 (2023-03-09) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 74531a6d..f6936902 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.14' +__version__ = '0.9.15' From 714c0a6cfd3368820753335bf1f1c8d5a9c00ffe Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 23 Mar 2023 10:23:19 -0500 Subject: [PATCH 008/636] Avoid accidental auto-submit of new msg form, for subject field --- tailbone/templates/deform/textarea.pt | 3 ++- tailbone/templates/messages/create.mako | 13 +++++++++++++ tailbone/views/messages.py | 6 ++++-- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/tailbone/templates/deform/textarea.pt b/tailbone/templates/deform/textarea.pt index f705c652..bb9b6c84 100644 --- a/tailbone/templates/deform/textarea.pt +++ b/tailbone/templates/deform/textarea.pt @@ -9,7 +9,8 @@
+ v-model="${vmodel}" + tal:attributes="attributes|field.widget.attributes|{};">
diff --git a/tailbone/templates/messages/create.mako b/tailbone/templates/messages/create.mako index 10729590..4a15573b 100644 --- a/tailbone/templates/messages/create.mako +++ b/tailbone/templates/messages/create.mako @@ -44,6 +44,19 @@ TailboneFormData.possibleRecipients = new Map(${json.dumps(available_recipients)|n}) TailboneFormData.recipientDisplayMap = ${json.dumps(recipient_display_map)|n} + TailboneForm.methods.subjectKeydown = function(event) { + + // do not auto-submit form when user presses enter in subject field + if (event.which == 13) { + event.preventDefault() + + // set focus to msg body input if possible + if (this.$refs.messageBody && this.$refs.messageBody.focus) { + this.$refs.messageBody.focus() + } + } + } + diff --git a/tailbone/views/messages.py b/tailbone/views/messages.py index 6aaf342e..4c83da34 100644 --- a/tailbone/views/messages.py +++ b/tailbone/views/messages.py @@ -221,11 +221,13 @@ class MessageView(MasterView): if self.creating: f.set_widget('subject', dfwidget.TextInputWidget( placeholder="please enter a subject", - autocomplete='off')) + autocomplete='off', + attributes={'@keydown.native': 'subjectKeydown'})) f.set_required('subject') # body - f.set_widget('body', dfwidget.TextAreaWidget(cols=50, rows=15)) + f.set_widget('body', dfwidget.TextAreaWidget( + cols=50, rows=15, attributes={'ref': 'messageBody'})) if self.creating: f.remove('sender', 'sent') From 2f8411ba2f92dbff042f4c228ee6c133cdd86fa4 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 25 Mar 2023 01:01:52 -0500 Subject: [PATCH 009/636] Add `has_perm()` etc. to request during the NewRequest event still get the occasional server error when handling what should be a simple 404 request e.g. for /wp-login.php error indicates there is no `request.has_perm()` at the time, so hoping this moves it earlier in the life cycle so it *will* exist.. --- tailbone/subscribers.py | 51 ++++++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 60fba60d..b724a4c5 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -62,6 +62,16 @@ def new_request(event): This of course assumes that a Rattail ``config`` object *has* in fact already been placed in the application registry settings. If this is not the case, this function will do nothing. + + Also, attach some goodies to the request object: + + * The currently logged-in user instance (if any), as ``user``. + + * ``is_admin`` flag indicating whether user has the Administrator role. + + * ``is_root`` flag indicating whether user is currently elevated to root. + + * A shortcut method for permission checking, as ``has_perm()``. """ request = event.request rattail_config = request.registry.settings.get('rattail_config') @@ -87,12 +97,27 @@ def new_request(event): request.is_admin = bool(request.user) and request.user.is_admin() request.is_root = request.is_admin and request.session.get('is_root', False) + # TODO: why would this ever be null? if rattail_config: + app = rattail_config.get_app() auth = app.get_auth_handler() request.tailbone_cached_permissions = auth.get_permissions( Session(), request.user) + def has_perm(name): + if name in request.tailbone_cached_permissions: + return True + return request.is_root + request.has_perm = has_perm + + def has_any_perm(*names): + for name in names: + if has_perm(name): + return True + return False + request.has_any_perm = has_any_perm + def before_render(event): """ @@ -206,36 +231,16 @@ def add_inbox_count(event): def context_found(event): """ - Attach some goodies to the request object. + Attach some more goodies to the request object: The following is attached to the request: - * The currently logged-in user instance (if any), as ``user``. + * ``get_referrer()`` function - * ``is_admin`` flag indicating whether user has the Administrator role. - - * ``is_root`` flag indicating whether user is currently elevated to root. - - * A shortcut method for permission checking, as ``has_perm()``. - - * A shortcut method for fetching the referrer, as ``get_referrer()``. + * ``get_session_timeout()`` function """ - request = event.request - def has_perm(name): - if name in request.tailbone_cached_permissions: - return True - return request.is_root - request.has_perm = has_perm - - def has_any_perm(*names): - for name in names: - if has_perm(name): - return True - return False - request.has_any_perm = has_any_perm - def get_referrer(default=None, **kwargs): if request.params.get('referrer'): return request.params['referrer'] From 45b8d9fb8456dfccf3cefaf42876e6d64d15b00d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 25 Mar 2023 11:28:53 -0500 Subject: [PATCH 010/636] Fix table sorting for FK reference column in new table wizard also add LargeBinary data type option --- tailbone/templates/tables/create.mako | 1 + tailbone/views/tables.py | 9 +++------ 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/tailbone/templates/tables/create.mako b/tailbone/templates/tables/create.mako index 3ebad9d1..dfe6cc45 100644 --- a/tailbone/templates/tables/create.mako +++ b/tailbone/templates/tables/create.mako @@ -183,6 +183,7 @@ + diff --git a/tailbone/views/tables.py b/tailbone/views/tables.py index d11a2923..75a61086 100644 --- a/tailbone/views/tables.py +++ b/tailbone/views/tables.py @@ -24,13 +24,10 @@ Views with info about the underlying Rattail tables """ -from __future__ import unicode_literals, absolute_import - import os import sys import warnings -import six from sqlalchemy_utils import get_mapper from rattail.util import simple_error @@ -203,8 +200,8 @@ class TableView(MasterView): branch_name = None kwargs['branch_name'] = branch_name - kwargs['existing_tables'] = [{'name': table.name} - for table in model.Base.metadata.sorted_tables] + kwargs['existing_tables'] = [{'name': table} + for table in sorted(model.Base.metadata.tables)] kwargs['model_dir'] = (os.path.dirname(model.__file__) + os.sep) @@ -294,7 +291,7 @@ class TableView(MasterView): 'column': column, 'sequence': i, 'column_name': column.name, - 'data_type': six.text_type(repr(column.type)), + 'data_type': str(repr(column.type)), 'nullable': column.nullable, 'description': column.doc, }) From e96f8844e2e83312346635ba355d7494f452a4ae Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 25 Mar 2023 13:03:47 -0500 Subject: [PATCH 011/636] Overhaul the "find by perm" feature a bit use GET instead of POST on form submit, so can more easily share URL for a particular result also get rid of WTForms dependency! sheesh results table is still not pretty but..feeling lazy --- setup.py | 1 - .../templates/principal/find_by_perm.mako | 72 +++++++++---------- tailbone/views/principal.py | 44 +++++------- 3 files changed, 52 insertions(+), 65 deletions(-) diff --git a/setup.py b/setup.py index 90383def..b295f062 100644 --- a/setup.py +++ b/setup.py @@ -99,7 +99,6 @@ requires = [ 'transaction', # 1.2.0 'waitress', # 0.8.1 'WebHelpers2', # 2.0 - 'WTForms', # 2.1 'zope.sqlalchemy', # 0.7 2.0 ] diff --git a/tailbone/templates/principal/find_by_perm.mako b/tailbone/templates/principal/find_by_perm.mako index 24b43e36..097597fc 100644 --- a/tailbone/templates/principal/find_by_perm.mako +++ b/tailbone/templates/principal/find_by_perm.mako @@ -4,6 +4,7 @@ <%def name="title()">Find ${model_title_plural} by Permission <%def name="page_content()"> +
@@ -12,46 +13,45 @@ <%def name="render_this_page_template()"> ${parent.render_this_page_template()} @@ -100,7 +100,6 @@ % else: selectedPermission: null, % endif - formButtonText: "Find ${model_title_plural}", formSubmitting: false, } }, @@ -112,11 +111,6 @@ this.groupPermissions = this.permissionGroups[groupkey].permissions this.selectedPermission = this.groupPermissions[0].permkey }, - - submitForm() { - this.formSubmitting = true - this.formButtonText = "Working, please wait..." - } } }) diff --git a/tailbone/views/principal.py b/tailbone/views/principal.py index 04fe97ad..9effd2af 100644 --- a/tailbone/views/principal.py +++ b/tailbone/views/principal.py @@ -29,7 +29,6 @@ import copy from rattail.core import Object from rattail.util import OrderedDict -import wtforms from webhelpers2.html import HTML from tailbone.db import Session @@ -54,40 +53,32 @@ class PrincipalMasterView(MasterView): """ View for finding all users who have been granted a given permission """ - permissions = copy.deepcopy(self.request.registry.settings.get('tailbone_permissions', {})) + permissions = copy.deepcopy( + self.request.registry.settings.get('tailbone_permissions', {})) # sort groups, and permissions for each group, for UI's sake sorted_perms = sorted(permissions.items(), key=self.perm_sortkey) for key, group in sorted_perms: group['perms'] = sorted(group['perms'].items(), key=self.perm_sortkey) - # group options are stable, permission options may depend on submitted group - group_choices = [(gkey, group['label']) for gkey, group in sorted_perms] - permission_choices = [('_any_', "(any)")] - if self.request.method == 'POST': - if self.request.POST.get('permission_group') in permissions: - permission_choices.extend([ - (pkey, perm['label']) - for pkey, perm in permissions[self.request.POST['permission_group']]['perms'] - ]) - - class PermissionForm(wtforms.Form): - permission_group = wtforms.SelectField(choices=group_choices) - permission = wtforms.SelectField(choices=permission_choices) - + # if both field values are in query string, do lookup principals = None - form = PermissionForm(self.request.POST) - if self.request.method == 'POST' and form.validate(): - permission = form.permission.data - principals = self.find_principals_with_permission(self.Session(), permission) + permission_group = self.request.GET.get('permission_group') + permission = self.request.GET.get('permission') + if permission_group and permission: + principals = self.find_principals_with_permission(self.Session(), + permission) + else: # otherwise clear both values + permission_group = None + permission = None - context = {'form': form, 'permissions': sorted_perms, 'principals': principals} + context = {'permissions': sorted_perms, 'principals': principals} perms = self.get_buefy_perms_data(sorted_perms) context['buefy_perms'] = perms context['buefy_sorted_groups'] = list(perms) - context['selected_group'] = self.request.POST.get('permission_group', 'common') - context['selected_permission'] = self.request.POST.get('permission', None) + context['selected_group'] = permission_group or 'common' + context['selected_permission'] = permission return self.render_to_response('find_by_perm', context) @@ -123,8 +114,11 @@ class PrincipalMasterView(MasterView): model_title_plural = cls.get_model_title_plural() # find principal by permission - config.add_route('{}.find_by_perm'.format(route_prefix), '{}/find-by-perm'.format(url_prefix)) - config.add_view(cls, attr='find_by_perm', route_name='{}.find_by_perm'.format(route_prefix), + config.add_route('{}.find_by_perm'.format(route_prefix), + '{}/find-by-perm'.format(url_prefix), + request_method='GET') + config.add_view(cls, attr='find_by_perm', + route_name='{}.find_by_perm'.format(route_prefix), permission='{}.find_by_perm'.format(permission_prefix)) config.add_tailbone_permission(permission_prefix, '{}.find_by_perm'.format(permission_prefix), "Find all {} with permission X".format(model_title_plural)) From efb8f8f3157e84a2e87b98cc213a1a9909e80e8a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 27 Mar 2023 12:53:16 -0500 Subject: [PATCH 012/636] Update changelog --- CHANGES.rst | 12 ++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 8aaaf62d..0b047179 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,18 @@ CHANGELOG ========= +0.9.16 (2023-03-27) +------------------- + +* Avoid accidental auto-submit of new msg form, for subject field. + +* Add ``has_perm()`` etc. to request during the NewRequest event. + +* Fix table sorting for FK reference column in new table wizard. + +* Overhaul the "find by perm" feature a bit. + + 0.9.15 (2023-03-15) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index f6936902..30c5daef 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.15' +__version__ = '0.9.16' From 6ab3898f27907d49808e3f29b11198f748b4f2fc Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 31 Mar 2023 12:55:05 -0500 Subject: [PATCH 013/636] Allow bulk-delete for products grid --- tailbone/views/products.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index bace8421..cc474840 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -79,6 +79,7 @@ class ProductView(MasterView): has_versions = True results_downloadable_xlsx = True supports_autocomplete = True + bulk_deletable = True mergeable = True configurable = True From 18f8577005bba7dae921090ae18534a663612300 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 31 Mar 2023 14:02:09 -0500 Subject: [PATCH 014/636] Improve global menu search behavior for multiple terms --- tailbone/templates/base.mako | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index f4935113..df4451c6 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -757,8 +757,27 @@ if (!this.globalSearchTerm.length) { return this.globalSearchData } + + let terms = [] + for (let term of this.globalSearchTerm.toLowerCase().split(' ')) { + term = term.trim() + if (term) { + terms.push(term) + } + } + if (!terms.length) { + return this.globalSearchData + } + + // all terms must match return this.globalSearchData.filter((option) => { - return option.label.toLowerCase().indexOf(this.globalSearchTerm.toLowerCase()) >= 0 + let label = option.label.toLowerCase() + for (let term of terms) { + if (label.indexOf(term) < 0) { + return false + } + } + return true }) }, From eb31fa9ab788905cd2bc28bf8d9361eaa0689f9f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 17 Apr 2023 16:10:37 -0500 Subject: [PATCH 015/636] Update changelog --- CHANGES.rst | 8 ++++++++ tailbone/_version.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 0b047179..667f2c70 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.9.17 (2023-04-17) +------------------- + +* Allow bulk-delete for products grid. + +* Improve global menu search behavior for multiple terms. + + 0.9.16 (2023-03-27) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 30c5daef..d929f9e2 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.16' +__version__ = '0.9.17' From 4993b349ef197e19c9cc2b40bb83ba1639aba1ac Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 21 Apr 2023 12:04:36 -0500 Subject: [PATCH 016/636] Avoid error if tempmon probe has invalid status --- tailbone/views/tempmon/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/tempmon/core.py b/tailbone/views/tempmon/core.py index 3f4df128..62ace028 100644 --- a/tailbone/views/tempmon/core.py +++ b/tailbone/views/tempmon/core.py @@ -55,7 +55,7 @@ class MasterView(views.MasterView): 'good_temp_min': probe.good_temp_min, 'good_temp_max': probe.good_temp_max, 'critical_temp_max': probe.critical_temp_max, - 'status': self.enum.TEMPMON_PROBE_STATUS[probe.status], + 'status': self.enum.TEMPMON_PROBE_STATUS.get(probe.status, '??'), 'enabled': "Yes" if probe.enabled else "No", }) app = self.get_rattail_app() From 2863ff7a5cbc26cf9a113cdcc11cc4f94e29ad37 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 27 Apr 2023 09:22:48 -0500 Subject: [PATCH 017/636] Remove references to deprecated extra in `tox.ini` --- tox.ini | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tox.ini b/tox.ini index 70767e56..8681465d 100644 --- a/tox.ini +++ b/tox.ini @@ -6,14 +6,14 @@ envlist = py36, py37, py39 commands = pip install --upgrade pip pip install --upgrade setuptools wheel - pip install --upgrade --upgrade-strategy eager Tailbone[tests] rattail[auth,bouncer,db] rattail-tempmon + pip install --upgrade --upgrade-strategy eager Tailbone[tests] rattail[bouncer,db] rattail-tempmon pytest {posargs} [testenv:coverage] basepython = python3 commands = pip install --upgrade pip - pip install --upgrade --upgrade-strategy eager Tailbone[tests] rattail[auth,bouncer,db] rattail-tempmon + pip install --upgrade --upgrade-strategy eager Tailbone[tests] rattail[bouncer,db] rattail-tempmon pytest --cov=tailbone --cov-report=html [testenv:docs] @@ -21,5 +21,5 @@ basepython = python3 changedir = docs commands = pip install --upgrade pip - pip install --upgrade --upgrade-strategy eager Tailbone[docs] rattail[auth,bouncer,db] rattail-tempmon + pip install --upgrade --upgrade-strategy eager Tailbone[docs] rattail[bouncer,db] rattail-tempmon sphinx-build -b html -d {envtmpdir}/doctrees -W -T . {envtmpdir}/docs From f913ed8332055e9165bc7f6aa5b8ed841307bbfd Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 2 May 2023 19:13:28 -0500 Subject: [PATCH 018/636] Expose, honor the `prevent_password_change` flag for Users --- tailbone/api/auth.py | 7 ++++--- tailbone/api/users.py | 8 +++++--- tailbone/templates/base.mako | 4 +++- tailbone/views/auth.py | 8 ++++++-- tailbone/views/users.py | 8 ++++++-- 5 files changed, 24 insertions(+), 11 deletions(-) diff --git a/tailbone/api/auth.py b/tailbone/api/auth.py index 867c15a8..1b347b21 100644 --- a/tailbone/api/auth.py +++ b/tailbone/api/auth.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,6 @@ Tailbone Web API - Auth Views """ -from __future__ import unicode_literals, absolute_import - from rattail.db.auth import set_user_password from cornice import Service @@ -168,6 +166,9 @@ class AuthenticationView(APIView): if not self.request.user: raise self.forbidden() + if self.request.user.prevent_password_change and not self.request.is_root: + raise self.forbidden() + data = self.request.json_body # first make sure "current" password is accurate diff --git a/tailbone/api/users.py b/tailbone/api/users.py index 2b6476a2..a6bcad57 100644 --- a/tailbone/api/users.py +++ b/tailbone/api/users.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,6 @@ Tailbone Web API - User Views """ -from __future__ import unicode_literals, absolute_import - from rattail.db import model from tailbone.api import APIMasterView @@ -57,6 +55,10 @@ class UserView(APIMasterView): query = query.outerjoin(model.Person) return query + def update_object(self, user, data): + # TODO: should ensure prevent_password_change is respected + return super(UserView, self).update_object(user, data) + def defaults(config, **kwargs): base = globals() diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index df4451c6..91589990 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -607,7 +607,9 @@ % if messaging_enabled: ${h.link_to("Messages{}".format(" ({})".format(inbox_count) if inbox_count else ''), url('messages.inbox'), class_='navbar-item')} % endif - ${h.link_to("Change Password", url('change_password'), class_='navbar-item')} + % if request.is_root or not request.user.prevent_password_change: + ${h.link_to("Change Password", url('change_password'), class_='navbar-item')} + % endif ${h.link_to("Edit Preferences", url('my.preferences'), class_='navbar-item')} ${h.link_to("Logout", url('logout'), class_='navbar-item')} diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index 9bcb644f..fbae397b 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -175,8 +175,12 @@ class AuthenticationView(View): if not self.request.user: return self.redirect(self.request.route_url('home')) - if self.user_is_protected(self.request.user) and not self.request.is_root: - self.request.session.flash("Cannot change password for user: {}".format(self.request.user)) + if ((self.request.user.prevent_password_change + or self.user_is_protected(self.request.user)) + and not self.request.is_root): + + self.request.session.flash("Cannot change password for user: {}".format( + self.request.user)) return self.redirect(self.request.get_referrer()) schema = ChangePassword().bind(user=self.request.user, request=self.request) diff --git a/tailbone/views/users.py b/tailbone/views/users.py index 4f3a0070..ff614460 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -67,6 +67,7 @@ class UserView(PrincipalMasterView): 'active', 'active_sticky', 'set_password', + 'prevent_password_change', 'roles', 'permissions', ] @@ -210,7 +211,10 @@ class UserView(PrincipalMasterView): f.set_renderer('display_name_', self.render_person_name) # set_password - f.set_widget('set_password', dfwidget.CheckedPasswordWidget()) + if self.editing and user.prevent_password_change and not self.request.is_root: + f.remove('set_password') + else: + f.set_widget('set_password', dfwidget.CheckedPasswordWidget()) # if self.creating: # f.set_required('password') @@ -316,7 +320,7 @@ class UserView(PrincipalMasterView): user.person.local_only = True # maybe set user password - if data['set_password']: + if 'set_password' in form and data['set_password']: set_user_password(user, data['set_password']) # update roles for user From 026d98551cd640aada6d4be4e0c9eac8402e9e20 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 3 May 2023 10:55:15 -0500 Subject: [PATCH 019/636] Update changelog --- CHANGES.rst | 8 ++++++++ tailbone/_version.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 667f2c70..8cb0fd98 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.9.18 (2023-05-03) +------------------- + +* Avoid error if tempmon probe has invalid status. + +* Expose, honor the ``prevent_password_change`` flag for Users. + + 0.9.17 (2023-04-17) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index d929f9e2..a1a9c6bb 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.17' +__version__ = '0.9.18' From 2ed63b1c1a29e2d681beb27d0f62a84ca2b0ca8b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 5 May 2023 00:18:16 -0500 Subject: [PATCH 020/636] Massive overhaul of "generate project" feature previous incarnation was woefully lacking. new feature is much more extensible. still need to remove old POS integration specifics in some places. and a couple of unrelated things that snuck in.. - deprecate `rattail.util.OrderedDict` - deprecate `rattail.util.import_module_path()` - deprecate `rattail.util.import_reload()` --- tailbone/api/common.py | 3 +- tailbone/forms/core.py | 17 +- tailbone/grids/filters.py | 2 +- tailbone/helpers.py | 6 +- tailbone/menus.py | 19 +- tailbone/templates/forms/deform_buefy.mako | 19 +- tailbone/templates/generate_project.mako | 480 ----------------- .../templates/generated-projects/create.mako | 24 + tailbone/views/batch/handheld.py | 5 +- tailbone/views/batch/inventory.py | 3 +- tailbone/views/batch/product.py | 5 +- tailbone/views/common.py | 3 +- tailbone/views/master.py | 20 +- tailbone/views/people.py | 3 +- tailbone/views/principal.py | 2 +- tailbone/views/products.py | 4 +- tailbone/views/projects.py | 493 ++++++++++++------ tailbone/views/purchasing/receiving.py | 3 +- tailbone/views/reports.py | 3 +- tailbone/views/settings.py | 3 +- tailbone/views/tables.py | 5 +- tailbone/views/upgrades.py | 2 +- 22 files changed, 424 insertions(+), 700 deletions(-) delete mode 100644 tailbone/templates/generate_project.mako create mode 100644 tailbone/templates/generated-projects/create.mako diff --git a/tailbone/api/common.py b/tailbone/api/common.py index b82bafd0..6d8e9344 100644 --- a/tailbone/api/common.py +++ b/tailbone/api/common.py @@ -24,10 +24,11 @@ Tailbone Web API - "Common" Views """ +from collections import OrderedDict + import rattail from rattail.db import model from rattail.mail import send_email -from rattail.util import OrderedDict from cornice import Service diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 161bfa25..9f30512b 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -26,6 +26,7 @@ Forms Core import json import logging +from collections import OrderedDict import sqlalchemy as sa from sqlalchemy import orm @@ -346,6 +347,7 @@ class Form(object): self.schema = schema if self.fields is None and self.schema: self.set_fields([f.name for f in self.schema]) + self.grouping = None self.request = request self.readonly = readonly self.readonly_fields = set(readonly_fields or []) @@ -371,6 +373,7 @@ class Form(object): self.validators = validators or {} self.required = required or {} self.helptext = helptext or {} + self.dynamic_helptext = {} self.focus_spec = focus_spec self.action_url = action_url self.cancel_url = cancel_url @@ -404,6 +407,9 @@ class Form(object): return get_fieldnames(self.request.rattail_config, self.model_class, columns=True, proxies=True, relations=True) + def set_grouping(self, items): + self.grouping = OrderedDict(items) + def make_renderers(self): """ Return a default set of field renderers, based on :attr:`model_class`. @@ -728,11 +734,15 @@ class Form(object): """ self.defaults[key] = value - def set_helptext(self, key, value): + def set_helptext(self, key, value, dynamic=False): """ Set the help text for a given field. """ self.helptext[key] = value + if value and dynamic: + self.dynamic_helptext[key] = True + else: + self.dynamic_helptext.pop(key, None) def has_helptext(self, key): """ @@ -935,7 +945,10 @@ class Form(object): # TODO: older logic did this only if field was *not* # readonly, perhaps should add that back.. if self.has_helptext(fieldname): - attrs['message'] = self.render_helptext(fieldname) + msgkey = 'message' + if self.dynamic_helptext.get(fieldname): + msgkey = ':message' + attrs[msgkey] = self.render_helptext(fieldname) # show errors if present error_messages = self.get_error_messages(field) if field else None diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py index e4b522f5..26ef4f59 100644 --- a/tailbone/grids/filters.py +++ b/tailbone/grids/filters.py @@ -27,11 +27,11 @@ Grid Filters import re import datetime import logging +from collections import OrderedDict import sqlalchemy as sa from rattail.gpc import GPC -from rattail.util import OrderedDict from rattail.core import UNSPECIFIED from rattail.time import localtime, make_utc from rattail.util import prettify diff --git a/tailbone/helpers.py b/tailbone/helpers.py index aeb6aa01..d4065cc5 100644 --- a/tailbone/helpers.py +++ b/tailbone/helpers.py @@ -24,15 +24,13 @@ Template Context Helpers """ -from __future__ import unicode_literals, absolute_import - import os import datetime from decimal import Decimal +from collections import OrderedDict from rattail.time import localtime, make_utc -from rattail.util import (pretty_quantity, pretty_hours, hours_as_decimal, - OrderedDict) +from rattail.util import pretty_quantity, pretty_hours, hours_as_decimal from rattail.db.util import maxlen from webhelpers2.html import * diff --git a/tailbone/menus.py b/tailbone/menus.py index 98006c00..9a0ba066 100644 --- a/tailbone/menus.py +++ b/tailbone/menus.py @@ -667,11 +667,18 @@ class MenuHandler(GenericHandler): 'route': 'appinfo', 'perm': 'appinfo.list', }, - { - 'title': "Label Settings", - 'route': 'labelprofiles', - 'perm': 'labelprofiles.list', - }, + ]) + + if kwargs.get('include_label_settings', False): + items.extend([ + { + 'title': "Label Settings", + 'route': 'labelprofiles', + 'perm': 'labelprofiles.list', + }, + ]) + + items.extend([ { 'title': "Raw Settings", 'route': 'settings', @@ -807,7 +814,7 @@ def make_menu_entry(request, item): try: entry['url'] = request.route_url(entry['route']) except KeyError: # happens if no such route - log.debug("invalid route name for menu entry: %s", entry) + log.warning("invalid route name for menu entry: %s", entry) entry['url'] = entry['route'] entry['key'] = entry['route'] else: diff --git a/tailbone/templates/forms/deform_buefy.mako b/tailbone/templates/forms/deform_buefy.mako index 4ff9c0b5..39633117 100644 --- a/tailbone/templates/forms/deform_buefy.mako +++ b/tailbone/templates/forms/deform_buefy.mako @@ -11,10 +11,23 @@
% if form_body is not Undefined and form_body: ${form_body|n} + % elif form.grouping: + % for group in form.grouping: + + % endfor % else: - % for field in form.fields: - ${form.render_buefy_field(field)} - % endfor + % for field in form.fields: + ${form.render_buefy_field(field)} + % endfor % endif
diff --git a/tailbone/templates/generate_project.mako b/tailbone/templates/generate_project.mako deleted file mode 100644 index f2b67cb3..00000000 --- a/tailbone/templates/generate_project.mako +++ /dev/null @@ -1,480 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/page.mako" /> - -<%def name="title()">Generate Project - -<%def name="content_title()"> - -<%def name="page_content()"> - - - - - - ## - - - - -
- ${h.form(request.current_route_url(), ref='rattailForm')} - ${h.csrf_token(request)} - ${h.hidden('project_type', value='rattail')} -
-
-
-

Naming

-
-
-
- - - - - - - - - - - - - - - - - - - - - -
-
-
-
-
-
-

Database

-
-
-
- - - - - - - - - - - - - - - - -
-
-
-
-
-
-

Web App

-
-
-
- - - - - - - - - - - -
-
-
-
-
-
-

Integrations

-
-
-
- - - - - - - - - - - - - - - - - - - - - -
-
-
-
-
-
-

Deployment

-
-
-
- - - - - - -
-
-
- ${h.end_form()} -
- -
- ${h.form(request.current_route_url(), ref='rattail_integrationForm')} - ${h.csrf_token(request)} - ${h.hidden('project_type', value='rattail_integration')} -
-
-
-

Naming

-
-
-
- - - - - - - - - - - - - - ${h.hidden('slug', **{'v-model': 'rattail_integration.python_project_name'})} - - - - - -
-
-
-
-
-
-

Options

-
-
-
- - - - - - - - - - - -
-
-
- ${h.end_form()} -
- -
- ${h.form(request.current_route_url(), ref='tailbone_integrationForm')} - ${h.csrf_token(request)} - ${h.hidden('project_type', value='tailbone_integration')} -
-
-
-

Naming

-
-
-
- - - - - - - - - - - - - - ${h.hidden('slug', **{'v-model': 'tailbone_integration.python_project_name'})} - - - - - -
-
-
-
-
-
-

Options

-
-
-
- - - - - - -
-
-
- ${h.end_form()} -
- -
- ${h.form(request.current_route_url(), ref='byjoveForm')} - ${h.csrf_token(request)} - ${h.hidden('project_type', value='byjove')} - -
-
-
-

Naming

-
-
-
- - - - - - - - - -
-
-
- - ${h.end_form()} -
- -
- ${h.form(request.current_route_url(), ref='fabricForm')} - ${h.csrf_token(request)} - ${h.hidden('project_type', value='fabric')} - -
-
-
-

Naming

-
-
-
- - - - - - - - - - - - - - - - - - - - - -
-
-
- -
-
-
-

Theo

-
-
-
- - - - - - - ## - - - -
-
-
- - ${h.end_form()} -
- -
-
- - Generate Project - -
- - - -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - - - - -${parent.body()} diff --git a/tailbone/templates/generated-projects/create.mako b/tailbone/templates/generated-projects/create.mako new file mode 100644 index 00000000..32d205a0 --- /dev/null +++ b/tailbone/templates/generated-projects/create.mako @@ -0,0 +1,24 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/create.mako" /> + +<%def name="title()">${index_title} + +<%def name="content_title()"> + +<%def name="page_content()"> + % if project_type: + + + ${project_type} + + + + + % endif + ${parent.page_content()} + + + +${parent.body()} diff --git a/tailbone/views/batch/handheld.py b/tailbone/views/batch/handheld.py index d4f15ffd..03b9a441 100644 --- a/tailbone/views/batch/handheld.py +++ b/tailbone/views/batch/handheld.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,9 @@ Views for handheld batches """ -from __future__ import unicode_literals, absolute_import +from collections import OrderedDict from rattail.db import model -from rattail.util import OrderedDict import colander from webhelpers2.html import tags diff --git a/tailbone/views/batch/inventory.py b/tailbone/views/batch/inventory.py index e13dacca..b41a995e 100644 --- a/tailbone/views/batch/inventory.py +++ b/tailbone/views/batch/inventory.py @@ -27,12 +27,13 @@ Views for inventory batches import re import decimal import logging +from collections import OrderedDict from rattail import pod from rattail.db import model from rattail.db.util import make_full_description from rattail.gpc import GPC -from rattail.util import pretty_quantity, OrderedDict +from rattail.util import pretty_quantity import colander from deform import widget as dfwidget diff --git a/tailbone/views/batch/product.py b/tailbone/views/batch/product.py index 50b18953..dfe8d890 100644 --- a/tailbone/views/batch/product.py +++ b/tailbone/views/batch/product.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,9 @@ Views for generic product batches """ -from __future__ import unicode_literals, absolute_import +from collections import OrderedDict from rattail.db import model -from rattail.util import OrderedDict import colander from webhelpers2.html import HTML diff --git a/tailbone/views/common.py b/tailbone/views/common.py index 6de6bc2b..3882f357 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -25,9 +25,10 @@ Various common views """ import os +from collections import OrderedDict from rattail.batch import consume_batch_id -from rattail.util import OrderedDict, simple_error, import_module_path +from rattail.util import simple_error, import_module_path from rattail.files import resource_path from pyramid import httpexceptions diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 4f0411ac..ed0ed009 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -32,6 +32,7 @@ import getpass import shutil import tempfile import logging +from collections import OrderedDict import json import sqlalchemy as sa @@ -41,7 +42,7 @@ from sqlalchemy_utils.functions import get_primary_keys, get_columns from rattail.db import model, Session as RattailSession from rattail.db.continuum import model_transaction_query -from rattail.util import prettify, OrderedDict, simple_error +from rattail.util import prettify, simple_error, get_class_hierarchy from rattail.time import localtime from rattail.threads import Thread from rattail.csvutil import UnicodeDictWriter @@ -268,17 +269,7 @@ class MasterView(View): return labels def get_class_hierarchy(self): - hierarchy = [] - - def traverse(cls): - if cls is not object: - hierarchy.append(cls) - for parent in cls.__bases__: - traverse(parent) - - traverse(self.__class__) - hierarchy.reverse() - return hierarchy + return get_class_hierarchy(self.__class__) def set_row_labels(self, obj): labels = self.collect_row_labels() @@ -2215,8 +2206,9 @@ class MasterView(View): """ Returns the master view's index URL. """ - route = self.get_route_prefix() - return self.request.route_url(route, **kwargs) + if self.listable: + route = self.get_route_prefix() + return self.request.route_url(route, **kwargs) # TODO: this should not be class method, if possible # (pretty sure overriding as instance method works fine) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 9556f66d..3761941a 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -26,6 +26,7 @@ Person Views import datetime import logging +from collections import OrderedDict import sqlalchemy as sa from sqlalchemy import orm @@ -33,7 +34,7 @@ from sqlalchemy import orm from rattail.db import model, api from rattail.db.util import maxlen from rattail.time import localtime -from rattail.util import OrderedDict, simple_error +from rattail.util import simple_error import colander from pyramid.httpexceptions import HTTPFound, HTTPNotFound diff --git a/tailbone/views/principal.py b/tailbone/views/principal.py index 9effd2af..5d477677 100644 --- a/tailbone/views/principal.py +++ b/tailbone/views/principal.py @@ -25,9 +25,9 @@ """ import copy +from collections import OrderedDict from rattail.core import Object -from rattail.util import OrderedDict from webhelpers2.html import HTML diff --git a/tailbone/views/products.py b/tailbone/views/products.py index cc474840..ebec578e 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -26,7 +26,7 @@ Product Views import re import logging - +from collections import OrderedDict import humanize import sqlalchemy as sa from sqlalchemy import orm @@ -37,7 +37,7 @@ from rattail.db import model, api, auth, Session as RattailSession from rattail.gpc import GPC from rattail.threads import Thread from rattail.exceptions import LabelPrintingError -from rattail.util import load_object, pretty_quantity, OrderedDict, simple_error +from rattail.util import load_object, pretty_quantity, simple_error from rattail.time import localtime, make_utc import colander diff --git a/tailbone/views/projects.py b/tailbone/views/projects.py index 60b531c9..0cfcd349 100644 --- a/tailbone/views/projects.py +++ b/tailbone/views/projects.py @@ -24,207 +24,358 @@ Project views """ -import os -import zipfile -# from collections import OrderedDict +from collections import OrderedDict import colander +from deform import widget as dfwidget + +from rattail.projects import PythonProjectGenerator, PoserProjectGenerator from tailbone import forms -from tailbone.views import View +from tailbone.views import MasterView -class GenerateProject(colander.MappingSchema): - """ - Base schema for the "generate project" form - """ - name = colander.SchemaNode(colander.String()) - - slug = colander.SchemaNode(colander.String()) - - organization = colander.SchemaNode(colander.String()) - - python_project_name = colander.SchemaNode(colander.String()) - - python_name = colander.SchemaNode(colander.String()) - - has_db = colander.SchemaNode(colander.Boolean()) - - extends_db = colander.SchemaNode(colander.Boolean()) - - has_batch_schema = colander.SchemaNode(colander.Boolean()) - - has_web = colander.SchemaNode(colander.Boolean()) - - has_web_api = colander.SchemaNode(colander.Boolean()) - - has_datasync = colander.SchemaNode(colander.Boolean()) - - # has_filemon = colander.SchemaNode(colander.Boolean()) - - # has_tempmon = colander.SchemaNode(colander.Boolean()) - - # has_bouncer = colander.SchemaNode(colander.Boolean()) - - integrates_catapult = colander.SchemaNode(colander.Boolean()) - - integrates_corepos = colander.SchemaNode(colander.Boolean()) - - # integrates_instacart = colander.SchemaNode(colander.Boolean()) - - integrates_locsms = colander.SchemaNode(colander.Boolean()) - - # integrates_mailchimp = colander.SchemaNode(colander.Boolean()) - - uses_fabric = colander.SchemaNode(colander.Boolean()) - - -class GenerateRattailIntegrationProject(colander.MappingSchema): - """ - Schema to generate new rattail-integration project - """ - integration_name = colander.SchemaNode(colander.String()) - - integration_url = colander.SchemaNode(colander.String()) - - slug = colander.SchemaNode(colander.String()) - - python_project_name = colander.SchemaNode(colander.String()) - - python_name = colander.SchemaNode(colander.String()) - - extends_config = colander.SchemaNode(colander.Boolean()) - - extends_db = colander.SchemaNode(colander.Boolean()) - - -class GenerateTailboneIntegrationProject(colander.MappingSchema): - """ - Schema to generate new tailbone-integration project - """ - integration_name = colander.SchemaNode(colander.String()) - - integration_url = colander.SchemaNode(colander.String()) - - slug = colander.SchemaNode(colander.String()) - - python_project_name = colander.SchemaNode(colander.String()) - - python_name = colander.SchemaNode(colander.String()) - - has_static_files = colander.SchemaNode(colander.Boolean()) - - -class GenerateByjoveProject(colander.MappingSchema): - """ - Schema for generating a new 'byjove' project - """ - name = colander.SchemaNode(colander.String()) - - slug = colander.SchemaNode(colander.String()) - - -class GenerateFabricProject(colander.MappingSchema): - """ - Schema for generating a new 'fabric' project - """ - name = colander.SchemaNode(colander.String()) - - slug = colander.SchemaNode(colander.String()) - - organization = colander.SchemaNode(colander.String()) - - python_project_name = colander.SchemaNode(colander.String()) - - python_name = colander.SchemaNode(colander.String()) - - integrates_with = colander.SchemaNode(colander.String(), - missing=colander.null) - - -class GenerateProjectView(View): +class GeneratedProjectView(MasterView): """ View for generating new project source code """ + model_title = "Generated Project" + model_key = 'folder' + route_prefix = 'generated_projects' + url_prefix = '/generated-projects' + listable = False + viewable = False + editable = False + deletable = False def __init__(self, request): - super(GenerateProjectView, self).__init__(request) - self.project_handler = self.get_handler() - # TODO: deprecate / remove this - self.handler = self.project_handler + super(GeneratedProjectView, self).__init__(request) + self.project_handler = self.get_project_handler() - def get_handler(self): - from rattail.projects.handler import RattailProjectHandler - return RattailProjectHandler(self.rattail_config) + def get_project_handler(self): + app = self.get_rattail_app() + return app.get_project_handler() - def __call__(self): + def create(self): + supported = self.project_handler.get_supported_project_generators() + supported_keys = list(supported) - # choices = OrderedDict([ - # ('has_db', {'prompt': "Does project need its own Rattail DB?", - # 'type': 'bool'}), - # ]) + project_type = self.request.matchdict.get('project_type') + if project_type: + form = self.make_project_form(project_type) + if form.validate(newstyle=True): + zipped = self.generate_project(project_type, form) + return self.file_response(zipped) - project_type = 'rattail' - if self.request.method == 'POST': - project_type = self.request.POST.get('project_type', 'rattail') - if project_type not in self.project_handler.get_supported_project_types(): - raise ValueError("Unknown project type: {}".format(project_type)) + else: # no project_type - if project_type == 'byjove': - schema = GenerateByjoveProject - elif project_type == 'fabric': - schema = GenerateFabricProject - elif project_type == 'rattail_integration': - schema = GenerateRattailIntegrationProject - elif project_type == 'tailbone_integration': - schema = GenerateTailboneIntegrationProject - else: - schema = GenerateProject - form = forms.Form(schema=schema(), request=self.request) - if form.validate(newstyle=True): - zipped = self.generate_project(project_type, form) - return self.file_response(zipped) - # self.request.session.flash("New project was generated: {}".format(form.validated['name'])) - # return self.redirect(self.request.current_route_url()) + # make form to accept user choice of report type + schema = colander.Schema() + values = [(typ, typ) for typ in supported_keys] + schema.add(colander.SchemaNode(name='project_type', + typ=colander.String(), + validator=colander.OneOf(supported_keys), + widget=dfwidget.SelectWidget(values=values))) + form = forms.Form(schema=schema, request=self.request) + form.submit_label = "Continue" - return { + # if form validates, then user has chosen a project type, so + # we redirect to the appropriate "generate project" page + if form.validate(newstyle=True): + raise self.redirect(self.request.route_url( + 'generate_specific_project', + project_type=form.validated['project_type'])) + + return self.render_to_response('create', { 'index_title': "Generate Project", - 'handler': self.handler, - # 'choices': choices, - } + 'project_type': project_type, + 'form': form, + }) def generate_project(self, project_type, form): - options = form.validated - slug = options['slug'] - path = self.handler.generate_project(project_type, slug, options) + context = dict(form.validated) + output = self.project_handler.generate_project(project_type, + context=context) + return self.project_handler.zip_output(output) - zipped = '{}.zip'.format(path) - with zipfile.ZipFile(zipped, 'w', zipfile.ZIP_DEFLATED) as z: - self.zipdir(z, path, slug) - return zipped + def make_project_form(self, project_type): - def zipdir(self, zipf, path, slug): - for root, dirs, files in os.walk(path): - relative_root = os.path.join(slug, root[len(path)+1:]) - for fname in files: - zipf.write(os.path.join(root, fname), - arcname=os.path.join(relative_root, fname)) + # make form + schema = self.project_handler.make_project_schema(project_type) + form = forms.Form(schema=schema, request=self.request) + form.auto_disable = False + form.auto_disable_save = False + form.submit_label = "Generate Project" + form.cancel_url = self.request.route_url('generated_projects.create') + + # apply normal config + self.configure_form_common(form, project_type) + + # let supplemental views further configure form + for supp in self.iter_view_supplements(): + configure = getattr(supp, 'configure_form_{}'.format(project_type), None) + if configure: + configure(form) + + # if master view has more configure logic, do that too + configure = getattr(self, 'configure_form_{}'.format(project_type), None) + if configure: + configure(form) + + return form + + def configure_form_common(self, form, project_type): + generator = self.project_handler.get_project_generator(project_type, + require=True) + + # python-based projects + if isinstance(generator, PythonProjectGenerator): + self.configure_form_python(form) + + # poser-based projects + if isinstance(generator, PoserProjectGenerator): + self.configure_form_poser(form) + + def configure_form_python(self, f): + + f.set_grouping([ + ("Naming", [ + 'name', + 'pkg_name', + 'pypi_name', + ]), + ]) + + # name + f.set_label('name', "Project Name") + f.set_helptext('name', "Human-friendly name generally used to refer to this project.") + f.set_default('name', "Poser Plus") + + # pkg_name + f.set_label('pkg_name', "Package Name in Python") + f.set_helptext('pkg_name', "`For example, ~/src/${field_model_pkg_name.replace(/_/g, '-')}/${field_model_pkg_name}/__init__.py`", + dynamic=True) + f.set_default('pkg_name', "poser_plus") + + # pypi_name + f.set_label('pypi_name', "Package Name for PyPI") + f.set_helptext('pypi_name', "It's a good idea to use org name as namespace prefix here") + f.set_default('pypi_name', "Acme-Poser-Plus") + + def configure_form_poser(self, f): + + # extends_config + f.set_label('extends_config', "Extend Config") + f.set_helptext('extends_config', "Needed to customize default config values etc.") + f.set_default('extends_config', True) + + # has_cli + f.set_label('has_cli', "Use Separate CLI") + f.set_helptext('has_cli', "`Needed for e.g. '${field_model_pkg_name} install' command.`", + dynamic=True) + f.set_default('has_cli', True) + + # organization + f.set_helptext('organization', 'For use with branding etc.') + f.set_default('organization', "Acme Foods") + + # has_db + f.set_label('has_db', "Use Rattail DB") + f.set_helptext('has_db', "Note that a DB is required for the Web App") + f.set_default('has_db', True) + + # extends_db + f.set_label('extends_db', "Extend DB Schema") + f.set_helptext('extends_db', "For adding custom tables/columns to the core schema") + f.set_default('extends_db', True) + + # has_batch_schema + f.set_label('has_batch_schema', "Add Batch Schema") + f.set_helptext('has_batch_schema', 'Usually not needed - it\'s for "dynamic" (e.g. import/export) batches') + + # has_web + f.set_label('has_web', "Use Tailbone Web App") + f.set_default('has_web', True) + + # has_web_api + f.set_label('has_web_api', "Use Tailbone Web API") + f.set_helptext('has_web_api', "Needed for e.g. Vue.js SPA mobile apps") + + # has_datasync + f.set_label('has_datasync', "Use DataSync Service") + + # uses_fabric + f.set_label('uses_fabric', "Use Fabric") + f.set_default('uses_fabric', True) + + def configure_form_rattail(self, f): + + f.set_grouping([ + ("Naming", [ + 'name', + 'pkg_name', + 'pypi_name', + 'organization', + ]), + ("Core", [ + 'extends_config', + 'has_cli', + ]), + ("Database", [ + 'has_db', + 'extends_db', + 'has_batch_schema', + ]), + ("Web", [ + 'has_web', + 'has_web_api', + ]), + ("Integrations", [ + # 'integrates_catapult', + # 'integrates_corepos', + # 'integrates_locsms', + 'has_datasync', + ]), + ("Deployment", [ + 'uses_fabric', + ]), + ]) + + # # integrates_catapult + # f.set_label('integrates_catapult', "Integrate w/ Catapult") + # f.set_helptext('integrates_catapult', "Add schema, import/export logic etc. for ECRS Catapult") + + # # integrates_corepos + # f.set_label('integrates_corepos', "Integrate w/ CORE-POS") + # f.set_helptext('integrates_corepos', "Add schema, import/export logic etc. for CORE-POS") + + # # integrates_locsms + # f.set_label('integrates_locsms', "Integrate w/ LOC SMS") + # f.set_helptext('integrates_locsms', "Add schema, import/export logic etc. for LOC SMS") + + def configure_form_rattail_integration(self, f): + + f.set_grouping([ + ("Naming", [ + 'integration_name', + 'integration_url', + 'name', + 'pkg_name', + 'pypi_name', + ]), + ("Options", [ + 'extends_config', + 'extends_db', + ]), + ]) + + # integration_name + f.set_helptext('integration_name', "Name of the system to be integrated") + f.set_default('integration_name', "Foo") + + # integration_url + f.set_label('integration_url', "Integration URL") + f.set_helptext('integration_url', "Reference URL for the system to be integrated") + f.set_default('integration_url', "https://www.example.com/") + + def configure_form_tailbone_integration(self, f): + + f.set_grouping([ + ("Naming", [ + 'integration_name', + 'integration_url', + 'name', + 'pkg_name', + 'pypi_name', + ]), + ("Options", [ + 'has_static_files', + ]), + ]) + + # integration_name + f.set_helptext('integration_name', "Name of the system to be integrated") + f.set_default('integration_name', "Foo") + + # integration_url + f.set_label('integration_url', "Integration URL") + f.set_helptext('integration_url', "Reference URL for the system to be integrated") + f.set_default('integration_url', "https://www.example.com/") + + # has_static_files + f.set_helptext('has_static_files', "Register a subfolder for static files (images etc.)") + + def configure_form_byjove(self, f): + + f.set_grouping([ + ("Naming", [ + 'name', + 'slug', + ]), + ]) + + # name + f.set_default('name', "Okay Then Mobile") + + # slug + f.set_default('slug', "okay-then-mobile") + + def configure_form_fabric(self, f): + + f.set_grouping([ + ("Naming", [ + 'name', + 'pkg_name', + 'pypi_name', + 'organization', + ]), + ("Theo", [ + 'integrates_with', + ]), + ]) + + # naming defaults + f.set_default('name', "Acme Fabric") + f.set_default('pkg_name', "acmefab") + f.set_default('pypi_name', "Acme-Fabric") + + # organization + f.set_helptext('organization', 'For use with branding etc.') + f.set_default('organization', "Acme Foods") + + # integrates_with + f.set_helptext('integrates_with', "Which POS system should Theo integrate with, if any") + f.set_enum('integrates_with', OrderedDict([ + ('', "(nothing)"), + ('catapult', "ECRS Catapult"), + ('corepos', "CORE-POS"), + ('locsms', "LOC SMS") + ])) + f.set_default('integrates_with', '') @classmethod def defaults(cls, config): - config.add_tailbone_permission('common', 'common.generate_project', - "Generate new project source code") - config.add_route('generate_project', '/generate-project') - config.add_view(cls, route_name='generate_project', - permission='common.generate_project', - renderer='/generate_project.mako') + cls._defaults(config) + cls._generated_project_defaults(config) + + @classmethod + def _generated_project_defaults(cls, config): + url_prefix = cls.get_url_prefix() + permission_prefix = cls.get_permission_prefix() + + # generate project (accept custom params, truly create) + config.add_route('generate_specific_project', + '{}/new/{{project_type}}'.format(url_prefix)) + config.add_view(cls, attr='create', + route_name='generate_specific_project', + permission='{}.create'.format(permission_prefix)) def defaults(config, **kwargs): base = globals() - GenerateProjectView = kwargs.get('GenerateProjectView', base['GenerateProjectView']) - GenerateProjectView.defaults(config) + GeneratedProjectView = kwargs.get('GeneratedProjectView', base['GeneratedProjectView']) + GeneratedProjectView.defaults(config) def includeme(config): diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index b180a9a7..511f8164 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -28,6 +28,7 @@ import os import re import decimal import logging +from collections import OrderedDict import humanize import sqlalchemy as sa @@ -35,7 +36,7 @@ import sqlalchemy as sa from rattail import pod from rattail.db import model, Session as RattailSession from rattail.time import localtime, make_utc -from rattail.util import pretty_quantity, prettify, OrderedDict, simple_error +from rattail.util import pretty_quantity, prettify, simple_error from rattail.threads import Thread import colander diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index d3345b75..5ded5c5f 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -29,13 +29,14 @@ import json import re import datetime import logging +from collections import OrderedDict import rattail from rattail.db import model, Session as RattailSession from rattail.files import resource_path from rattail.time import localtime from rattail.threads import Thread -from rattail.util import simple_error, OrderedDict +from rattail.util import simple_error import colander from deform import widget as dfwidget diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index 5677f579..472ea199 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -28,12 +28,13 @@ import os import re import subprocess import sys +from collections import OrderedDict import json from rattail.db import model from rattail.settings import Setting -from rattail.util import import_module_path, OrderedDict +from rattail.util import import_module_path import colander diff --git a/tailbone/views/tables.py b/tailbone/views/tables.py index 75a61086..d4b9ee8b 100644 --- a/tailbone/views/tables.py +++ b/tailbone/views/tables.py @@ -28,6 +28,7 @@ import os import sys import warnings +import sqlalchemy as sa from sqlalchemy_utils import get_mapper from rattail.util import simple_error @@ -96,8 +97,8 @@ class TableView(MasterView): where schemaname = 'public' order by n_live_tup desc; """ - result = self.Session.execute(sql) - return [dict(table_name=row['relname'], row_count=row['n_live_tup']) + result = self.Session.execute(sa.text(sql)) + return [dict(table_name=row.relname, row_count=row.n_live_tup) for row in result] def configure_grid(self, g): diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index f6df80d3..eddd677c 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.py @@ -29,6 +29,7 @@ import os import re import logging import warnings +from collections import OrderedDict import sqlalchemy as sa @@ -36,7 +37,6 @@ from rattail.core import Object from rattail.db import model, Session as RattailSession from rattail.time import make_utc from rattail.threads import Thread -from rattail.util import OrderedDict from deform import widget as dfwidget from webhelpers2.html import tags, HTML From 62bdf8262718b2ea01ea4100570778b1ec560141 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 5 May 2023 10:39:29 -0500 Subject: [PATCH 021/636] Include project views by default, in "essential" views --- tailbone/views/essentials.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tailbone/views/essentials.py b/tailbone/views/essentials.py index a8ded812..08d2e0c4 100644 --- a/tailbone/views/essentials.py +++ b/tailbone/views/essentials.py @@ -24,8 +24,6 @@ Essential views for convenient includes """ -from __future__ import unicode_literals, absolute_import - def defaults(config, **kwargs): mod = lambda spec: kwargs.get(spec, spec) @@ -48,6 +46,14 @@ def defaults(config, **kwargs): config.include(mod('tailbone.views.users')) config.include(mod('tailbone.views.views')) + # include project views by default, but let caller avoid that by + # passing False + projects = kwargs.get('tailbone.views.projects', True) + if projects: + if projects is True: + projects = 'tailbone.views.projects' + config.include(projects) + def includeme(config): defaults(config) From 50d1bbbe4d2458f6d7b289cfc04212c30f42ed34 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 5 May 2023 13:30:17 -0500 Subject: [PATCH 022/636] Add "rattail-adjacent" logic for generating projects --- tailbone/views/projects.py | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/tailbone/views/projects.py b/tailbone/views/projects.py index 0cfcd349..8dc119f1 100644 --- a/tailbone/views/projects.py +++ b/tailbone/views/projects.py @@ -29,7 +29,9 @@ from collections import OrderedDict import colander from deform import widget as dfwidget -from rattail.projects import PythonProjectGenerator, PoserProjectGenerator +from rattail.projects import (PythonProjectGenerator, + PoserProjectGenerator, + RattailAdjacentProjectGenerator) from tailbone import forms from tailbone.views import MasterView @@ -132,6 +134,10 @@ class GeneratedProjectView(MasterView): if isinstance(generator, PythonProjectGenerator): self.configure_form_python(form) + # rattail-adjacent projects + if isinstance(generator, RattailAdjacentProjectGenerator): + self.configure_form_rattail_adjacent(form) + # poser-based projects if isinstance(generator, PoserProjectGenerator): self.configure_form_poser(form) @@ -162,7 +168,7 @@ class GeneratedProjectView(MasterView): f.set_helptext('pypi_name', "It's a good idea to use org name as namespace prefix here") f.set_default('pypi_name', "Acme-Poser-Plus") - def configure_form_poser(self, f): + def configure_form_rattail_adjacent(self, f): # extends_config f.set_label('extends_config', "Extend Config") @@ -175,6 +181,13 @@ class GeneratedProjectView(MasterView): dynamic=True) f.set_default('has_cli', True) + # extends_db + f.set_label('extends_db', "Extend DB Schema") + f.set_helptext('extends_db', "For adding custom tables/columns to the core schema") + f.set_default('extends_db', True) + + def configure_form_poser(self, f): + # organization f.set_helptext('organization', 'For use with branding etc.') f.set_default('organization', "Acme Foods") @@ -184,11 +197,6 @@ class GeneratedProjectView(MasterView): f.set_helptext('has_db', "Note that a DB is required for the Web App") f.set_default('has_db', True) - # extends_db - f.set_label('extends_db', "Extend DB Schema") - f.set_helptext('extends_db', "For adding custom tables/columns to the core schema") - f.set_default('extends_db', True) - # has_batch_schema f.set_label('has_batch_schema', "Add Batch Schema") f.set_helptext('has_batch_schema', 'Usually not needed - it\'s for "dynamic" (e.g. import/export) batches') @@ -266,9 +274,16 @@ class GeneratedProjectView(MasterView): ("Options", [ 'extends_config', 'extends_db', + 'has_cli', ]), ]) + # default settings + f.set_default('name', 'rattail-foo') + f.set_default('pkg_name', 'rattail_foo') + f.set_default('pypi_name', 'rattail-foo') + f.set_default('has_cli', False) + # integration_name f.set_helptext('integration_name', "Name of the system to be integrated") f.set_default('integration_name', "Foo") From 2f5e01c9e9e190261ab1acbd016c9cc315343b78 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 5 May 2023 19:10:54 -0500 Subject: [PATCH 023/636] Update changelog --- CHANGES.rst | 8 ++++++++ tailbone/_version.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 8cb0fd98..3fcf9b30 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.9.19 (2023-05-05) +------------------- + +* Massive overhaul of "generate project" feature. + +* Include project views by default, in "essential" views. + + 0.9.18 (2023-05-03) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index a1a9c6bb..31ec307b 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.18' +__version__ = '0.9.19' From 8fcef1fb4d586f76c2329dc06b2f6c4e64889706 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 8 May 2023 21:43:19 -0500 Subject: [PATCH 024/636] Add form config for generating 'shopfoo' projects --- tailbone/views/projects.py | 46 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tailbone/views/projects.py b/tailbone/views/projects.py index 8dc119f1..a412c388 100644 --- a/tailbone/views/projects.py +++ b/tailbone/views/projects.py @@ -293,6 +293,31 @@ class GeneratedProjectView(MasterView): f.set_helptext('integration_url', "Reference URL for the system to be integrated") f.set_default('integration_url', "https://www.example.com/") + def configure_form_rattail_shopfoo(self, f): + + # first do normal integration setup + self.configure_form_rattail_integration(f) + + f.set_grouping([ + ("Naming", [ + 'integration_name', + 'integration_url', + 'name', + 'pkg_name', + 'pypi_name', + ]), + ("Options", [ + 'has_cli', + ]), + ]) + + # default settings + f.set_default('integration_name', 'Shopfoo') + f.set_default('name', 'rattail-shopfoo') + f.set_default('pkg_name', 'rattail_shopfoo') + f.set_default('pypi_name', 'rattail-shopfoo') + f.set_default('has_cli', False) + def configure_form_tailbone_integration(self, f): f.set_grouping([ @@ -320,6 +345,27 @@ class GeneratedProjectView(MasterView): # has_static_files f.set_helptext('has_static_files', "Register a subfolder for static files (images etc.)") + def configure_form_tailbone_shopfoo(self, f): + + # first do normal integration setup + self.configure_form_tailbone_integration(f) + + f.set_grouping([ + ("Naming", [ + 'integration_name', + 'integration_url', + 'name', + 'pkg_name', + 'pypi_name', + ]), + ]) + + # default settings + f.set_default('integration_name', 'Shopfoo') + f.set_default('name', 'tailbone-shopfoo') + f.set_default('pkg_name', 'tailbone_shopfoo') + f.set_default('pypi_name', 'tailbone-shopfoo') + def configure_form_byjove(self, f): f.set_grouping([ From dcc78194662fa00b0d30785a7c701f9647e3707a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 9 May 2023 20:25:05 -0500 Subject: [PATCH 025/636] Misc. tweaks for "run import job" form --- tailbone/views/importing.py | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/tailbone/views/importing.py b/tailbone/views/importing.py index bfbd82e9..acfddbf8 100644 --- a/tailbone/views/importing.py +++ b/tailbone/views/importing.py @@ -295,15 +295,42 @@ class ImportingView(MasterView): f.set_widget('models', dfwidget.SelectWidget(values=[(k, k) for k in keys], multiple=True, size=len(keys))) - # f.set_default('models', keys) - f.set_default('create', True) - f.set_default('update', True) - f.set_default('delete', False) + allow_create = True + allow_update = True + allow_delete = True + if len(keys) == 1: + importers = handler.get_importers().values() + importer = list(importers)[0] + allow_create = importer.allow_create + allow_update = importer.allow_update + allow_delete = importer.allow_delete + + if allow_create: + f.set_default('create', True) + else: + f.remove('create') + + if allow_update: + f.set_default('update', True) + else: + f.remove('update') + + if allow_delete: + f.set_default('delete', False) + else: + f.remove('delete') + # f.set_default('runas', self.rattail_config.get('rattail', 'runas.default') or '') + f.set_default('versioning', True) + f.set_helptext('versioning', "If set, version history will be updated as appropriate") + f.set_default('dry_run', False) + f.set_helptext('dry_run', "If set, data will not actually be written") + f.set_default('warnings', False) + f.set_helptext('warnings', "If set, will send an email if any diffs") def do_runjob(self, handler_info, form): handler = handler_info['_handler'] From f942716bf90c1586407ab71bb4f90df7a82fd5da Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 9 May 2023 20:31:43 -0500 Subject: [PATCH 026/636] Update changelog --- CHANGES.rst | 8 ++++++++ tailbone/_version.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 3fcf9b30..c9de169c 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.9.20 (2023-05-09) +------------------- + +* Add form config for generating 'shopfoo' projects. + +* Misc. tweaks for "run import job" form. + + 0.9.19 (2023-05-05) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 31ec307b..08844f91 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.19' +__version__ = '0.9.20' From 82656f263d39f32df93c59587d5000a45f0cc16c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 10 May 2023 18:47:11 -0500 Subject: [PATCH 027/636] Move row delete check logic for receiving to batch handler --- tailbone/views/purchasing/receiving.py | 21 ++------------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 511f8164..4632723d 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -84,7 +84,6 @@ class ReceivingBatchView(PurchasingBatchView): rows_editable = False rows_editable_but_not_directly = True - rows_deletable = True default_uom_is_case = True @@ -379,24 +378,8 @@ class ReceivingBatchView(PurchasingBatchView): if not super(ReceivingBatchView, self).row_deletable(row): return False - batch = row.batch - - # can always delete rows from truck dump parent - if batch.is_truck_dump_parent(): - return True - - # can always delete rows from truck dump child - elif batch.is_truck_dump_child(): - return True - - else: # okay, normal batch - if batch.order_quantities_known: - return False - else: # allow delete if receiving rom scratch - return True - - # cannot delete row by default - return False + # otherwise let handler decide + return self.batch_handler.is_row_deletable(row) def get_instance_title(self, batch): title = super(ReceivingBatchView, self).get_instance_title(batch) From f49b4d1b8bdc3eb30a49901026511bd9e7dacbd4 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 10 May 2023 20:20:30 -0500 Subject: [PATCH 028/636] Update changelog --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index c9de169c..aae80e4b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.9.21 (2023-05-10) +------------------- + +* Move row delete check logic for receiving to batch handler. + + 0.9.20 (2023-05-09) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 08844f91..a5e0fde2 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.20' +__version__ = '0.9.21' From f5f973dc3a188ddb0b3f70449678739dc44429ea Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 12 May 2023 19:21:48 -0500 Subject: [PATCH 029/636] Tweak button wording in "find role by perm" form --- tailbone/templates/principal/find_by_perm.mako | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tailbone/templates/principal/find_by_perm.mako b/tailbone/templates/principal/find_by_perm.mako index 097597fc..9cc5aa05 100644 --- a/tailbone/templates/principal/find_by_perm.mako +++ b/tailbone/templates/principal/find_by_perm.mako @@ -43,8 +43,7 @@
+ text="Reset Form"> Date: Fri, 12 May 2023 21:27:15 -0500 Subject: [PATCH 030/636] Warn user if DB not up to date, in new table wizard also start adding 'dirty' page behavior, to warn user if navigating away that changes will be lost also improve steps in wizard, so page header is scrolled into view when prev/next buttons are clicked. unfortunately it still does not work right if user clicks the step number on left of screen.. --- tailbone/templates/tables/create.mako | 96 +++++++++++++++++------ tailbone/templates/tables/index.mako | 12 +++ tailbone/templates/tables/migrations.mako | 11 +++ tailbone/views/tables.py | 18 +++++ 4 files changed, 115 insertions(+), 22 deletions(-) create mode 100644 tailbone/templates/tables/index.mako create mode 100644 tailbone/templates/tables/migrations.mako diff --git a/tailbone/templates/tables/create.mako b/tailbone/templates/tables/create.mako index dfe6cc45..4fc2eb96 100644 --- a/tailbone/templates/tables/create.mako +++ b/tailbone/templates/tables/create.mako @@ -11,8 +11,25 @@ <%def name="render_this_page()"> + + ## scroll target used when navigating prev/next +
+ + % if not alembic_current_head: + +

+ DB is not up to date! There are + ${h.link_to("pending migrations", url('{}.migrations'.format(route_prefix)))}. +

+

+ (This will be a problem if you wish to auto-generate a migration for a new table.) +

+
+ % endif + - +
@@ -325,7 +349,7 @@
+ @click="showStep('enter-details')"> Back + @click="showStep('review-model')"> Skip
@@ -435,19 +459,19 @@
+ @click="showStep('write-model')"> Back Model class looks good! + @click="showStep('write-revision')"> Skip
@@ -486,7 +510,7 @@
+ @click="showStep('review-model')"> Back + @click="showStep('review-revision')"> Skip
@@ -526,13 +550,13 @@
+ @click="showStep('write-revision')"> Back + @click="showStep('upgrade-db')"> Revision script looks good!
@@ -553,7 +577,7 @@
+ @click="showStep('review-revision')"> Back + @click="showStep('upgrade-db')"> Back DB looks good! @@ -658,7 +682,7 @@
+ @click="showStep('review-db')"> Back + // nb. for warning user they may lose changes if leaving page + ThisPageData.dirty = false + ThisPageData.activeStep = null ThisPageData.alembicBranchOptions = ${json.dumps(branch_name_options)|n} @@ -713,6 +740,15 @@ ThisPageData.editingColumnVersioned = true ThisPageData.editingColumnRelationship = null + ThisPage.methods.showStep = function(step) { + this.activeStep = step + + // scroll so top of page is shown + this.$nextTick(() => { + this.$refs['showme'].scrollIntoView(true) + }) + } + ThisPage.methods.tableAddColumn = function() { this.editingColumn = null this.editingColumnName = null @@ -801,12 +837,14 @@ column.versioned = this.editingColumnVersioned column.relationship = this.editingColumnRelationship + this.dirty = true this.editingColumnShowDialog = false } ThisPage.methods.tableDeleteColumn = function(index) { if (confirm("Really delete this column?")) { this.tableColumns.splice(index, 1) + this.dirty = true } } @@ -929,6 +967,20 @@ }) } + // cf. https://stackoverflow.com/a/56551646 + ThisPage.methods.beforeWindowUnload = function(e) { + + // warn user if navigating away would lose changes + if (this.dirty) { + e.preventDefault() + e.returnValue = '' + } + } + + ThisPage.created = function() { + window.addEventListener('beforeunload', this.beforeWindowUnload) + } + diff --git a/tailbone/templates/tables/index.mako b/tailbone/templates/tables/index.mako new file mode 100644 index 00000000..b13f0785 --- /dev/null +++ b/tailbone/templates/tables/index.mako @@ -0,0 +1,12 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/index.mako" /> + +<%def name="context_menu_items()"> + ${parent.context_menu_items()} + % if master.has_perm('migrations'): +
  • ${h.link_to("View / Apply Migrations", url('{}.migrations'.format(route_prefix)))}
  • + % endif + + + +${parent.body()} diff --git a/tailbone/templates/tables/migrations.mako b/tailbone/templates/tables/migrations.mako new file mode 100644 index 00000000..af1734eb --- /dev/null +++ b/tailbone/templates/tables/migrations.mako @@ -0,0 +1,11 @@ +## -*- coding: utf-8; -*- +<%inherit file="/page.mako" /> + +<%def name="title()">Schema Migrations + +<%def name="render_this_page()"> +

    TODO: show current revisions and allow DB upgrades

    + + + +${parent.body()} diff --git a/tailbone/views/tables.py b/tailbone/views/tables.py index d4b9ee8b..962dbf50 100644 --- a/tailbone/views/tables.py +++ b/tailbone/views/tables.py @@ -194,6 +194,8 @@ class TableView(MasterView): app = self.get_rattail_app() model = self.model + kwargs['alembic_current_head'] = self.db_handler.check_alembic_current_head() + kwargs['branch_name_options'] = self.db_handler.get_alembic_branch_names() branch_name = app.get_table_prefix() @@ -331,6 +333,11 @@ class TableView(MasterView): return HTML.tag('span', title=text, c="{} ...".format(text[:max_length])) + def migrations(self): + # TODO: allow alembic upgrade on POST + # TODO: pass current revisions to page context + return self.render_to_response('migrations', {}) + @classmethod def defaults(cls, config): rattail_config = config.registry.settings.get('rattail_config') @@ -348,6 +355,17 @@ class TableView(MasterView): url_prefix = cls.get_url_prefix() permission_prefix = cls.get_permission_prefix() + # migrations + config.add_tailbone_permission(permission_prefix, + '{}.migrations'.format(permission_prefix), + "View / apply Alembic migrations") + config.add_route('{}.migrations'.format(route_prefix), + '{}/migrations'.format(url_prefix)) + config.add_view(cls, attr='migrations', + route_name='{}.migrations'.format(route_prefix), + renderer='json', + permission='{}.migrations'.format(permission_prefix)) + if cls.creatable: # write model class to file From a991dc068450a130ae7b704382a4e4a1ba6d12c7 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 13 May 2023 16:57:36 -0500 Subject: [PATCH 031/636] Update changelog --- CHANGES.rst | 8 ++++++++ tailbone/_version.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index aae80e4b..25ca1da5 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.9.22 (2023-05-13) +------------------- + +* Tweak button wording in "find role by perm" form. + +* Warn user if DB not up to date, in new table wizard. + + 0.9.21 (2023-05-10) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index a5e0fde2..b6cfd77e 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.21' +__version__ = '0.9.22' From 85947878c4bd6ade54fd60872252dd9279fc813b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 14 May 2023 20:28:48 -0500 Subject: [PATCH 032/636] Get rid of `newstyle` flag for `Form.validate()` method we always/only use "new style" now --- tailbone/api/batch/receiving.py | 2 +- tailbone/api/common.py | 2 +- tailbone/forms/core.py | 81 +++++++++++++------------- tailbone/views/auth.py | 4 +- tailbone/views/batch/core.py | 6 +- tailbone/views/batch/inventory.py | 2 +- tailbone/views/common.py | 2 +- tailbone/views/features.py | 4 +- tailbone/views/master.py | 12 ++-- tailbone/views/people.py | 6 +- tailbone/views/products.py | 4 +- tailbone/views/projects.py | 4 +- tailbone/views/purchasing/costing.py | 2 +- tailbone/views/purchasing/receiving.py | 6 +- tailbone/views/reports.py | 4 +- tailbone/views/settings.py | 2 +- tailbone/views/shifts/lib.py | 4 +- 17 files changed, 75 insertions(+), 72 deletions(-) diff --git a/tailbone/api/batch/receiving.py b/tailbone/api/batch/receiving.py index 53d5f98a..9a6864db 100644 --- a/tailbone/api/batch/receiving.py +++ b/tailbone/api/batch/receiving.py @@ -407,7 +407,7 @@ class ReceivingBatchRowViews(APIBatchRowView): form = forms.Form(schema=schema, request=self.request) # TODO: this seems hacky, but avoids "complex" date value parsing form.set_widget('expiration_date', dfwidget.TextInputWidget()) - if not form.validate(newstyle=True): + if not form.validate(): log.debug("form did not validate: %s", form.make_deform_form().error) return {'error': "Form did not validate"} diff --git a/tailbone/api/common.py b/tailbone/api/common.py index 6d8e9344..cd663d53 100644 --- a/tailbone/api/common.py +++ b/tailbone/api/common.py @@ -89,7 +89,7 @@ class CommonView(APIView): # identical; perhaps should merge somehow? schema = Feedback().bind(session=Session()) form = forms.Form(schema=schema, request=self.request) - if form.validate(newstyle=True): + if form.validate(): data = dict(form.validated) # figure out who the sending user is, if any diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 9f30512b..04cbb64a 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -26,6 +26,7 @@ Forms Core import json import logging +import warnings from collections import OrderedDict import sqlalchemy as sa @@ -1167,49 +1168,51 @@ class Form(object): return self.defaults[field_name] def validate(self, *args, **kwargs): - if kwargs.pop('newstyle', False): - # yay, new behavior! - if hasattr(self, 'validated'): - del self.validated - if self.request.method != 'POST': - return False + """ + Try to validate the form. - controls = get_form_data(self.request).items() + This should work whether data was submitted as classic POST + data, or as JSON body. - # unfortunately the normal form logic (i.e. peppercorn) is - # expecting all values to be strings, whereas if our data - # came from JSON body, may have given us some Pythonic - # objects. so here we must convert them *back* to strings - # TODO: this seems like a hack, i must be missing something - # TODO: also this uses same "JSON" check as get_form_data() - if self.request.is_xhr and not self.request.POST: - controls = [[key, val] for key, val in controls] - for i in range(len(controls)): - key, value = controls[i] - if value is None: - controls[i][1] = '' - elif value is True: - controls[i][1] = 'true' - elif value is False: - controls[i][1] = 'false' - elif not isinstance(value, str): - controls[i][1] = str(value) + :returns: ``True`` if form data is valid, otherwise ``False``. + """ + if 'newstyle' in kwargs: + warnings.warn("the `newstyle` kwarg is no longer used " + "for Form.validate()", + DeprecationWarning, stacklevel=2) - dform = self.make_deform_form() - try: - self.validated = dform.validate(controls) - return True - except deform.ValidationFailure: - return False + if hasattr(self, 'validated'): + del self.validated + if self.request.method != 'POST': + return False - else: # legacy behavior - raise_error = kwargs.pop('raise_error', True) - dform = self.make_deform_form() - try: - return dform.validate(*args, **kwargs) - except deform.ValidationFailure: - if raise_error: - raise + controls = get_form_data(self.request).items() + + # unfortunately the normal form logic (i.e. peppercorn) is + # expecting all values to be strings, whereas if our data + # came from JSON body, may have given us some Pythonic + # objects. so here we must convert them *back* to strings + # TODO: this seems like a hack, i must be missing something + # TODO: also this uses same "JSON" check as get_form_data() + if self.request.is_xhr and not self.request.POST: + controls = [[key, val] for key, val in controls] + for i in range(len(controls)): + key, value = controls[i] + if value is None: + controls[i][1] = '' + elif value is True: + controls[i][1] = 'true' + elif value is False: + controls[i][1] = 'false' + elif not isinstance(value, str): + controls[i][1] = str(value) + + dform = self.make_deform_form() + try: + self.validated = dform.validate(controls) + return True + except deform.ValidationFailure: + return False class FieldList(list): diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index fbae397b..f8d71d34 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -105,7 +105,7 @@ class AuthenticationView(View): form.auto_disable = False # TODO: deprecate / remove this form.show_reset = True form.show_cancel = False - if form.validate(newstyle=True): + if form.validate(): user = self.authenticate_user(form.validated['username'], form.validated['password']) if user: @@ -185,7 +185,7 @@ class AuthenticationView(View): schema = ChangePassword().bind(user=self.request.user, request=self.request) form = forms.Form(schema=schema, request=self.request) - if form.validate(newstyle=True): + if form.validate(): set_user_password(self.request.user, form.validated['new_password']) self.request.session.flash("Your password has been changed.") return self.redirect(self.request.get_referrer()) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 2ba7e6da..e2eeeda4 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -566,7 +566,7 @@ class BatchMasterView(MasterView): self.request.session.flash("Request ignored, since batch has already been executed") else: form = forms.Form(schema=ToggleComplete(), request=self.request) - if form.validate(newstyle=True): + if form.validate(): if form.validated['complete']: self.mark_batch_complete(batch) else: @@ -1273,7 +1273,7 @@ class BatchMasterView(MasterView): batch = self.get_instance() self.executing = True form = self.make_execute_form(batch) - if form.validate(newstyle=True): + if form.validate(): kwargs = dict(form.validated) # cache options to use as defaults next time @@ -1344,7 +1344,7 @@ class BatchMasterView(MasterView): indicator page. """ form = self.make_execute_form() - if form.validate(newstyle=True): + if form.validate(): kwargs = dict(form.validated) # cache options to use as defaults next time diff --git a/tailbone/views/batch/inventory.py b/tailbone/views/batch/inventory.py index b41a995e..92f0b2d4 100644 --- a/tailbone/views/batch/inventory.py +++ b/tailbone/views/batch/inventory.py @@ -234,7 +234,7 @@ class InventoryBatchView(BatchMasterView): schema = DesktopForm().bind(session=self.Session()) form = forms.Form(schema=schema, request=self.request) if self.request.method == 'POST': - if form.validate(newstyle=True): + if form.validate(): product = self.Session.get(model.Product, form.validated['product']) diff --git a/tailbone/views/common.py b/tailbone/views/common.py index 3882f357..e8d37904 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -161,7 +161,7 @@ class CommonView(View): model = self.model schema = Feedback().bind(session=Session()) form = forms.Form(schema=schema, request=self.request) - if form.validate(newstyle=True): + if form.validate(): data = dict(form.validated) if data['user']: data['user'] = Session.get(model.User, data['user']) diff --git a/tailbone/views/features.py b/tailbone/views/features.py index 39f683d3..d9417452 100644 --- a/tailbone/views/features.py +++ b/tailbone/views/features.py @@ -62,7 +62,7 @@ class GenerateFeatureView(View): result = rendered_result = None feature_type = 'new-report' if self.request.method == 'POST': - if app_form.validate(newstyle=True): + if app_form.validate(): feature_type = self.request.POST['feature_type'] feature = self.handler.get_feature(feature_type) @@ -70,7 +70,7 @@ class GenerateFeatureView(View): raise ValueError("Unknown feature type: {}".format(feature_type)) feature_form = feature_forms[feature.feature_key] - if feature_form.validate(newstyle=True): + if feature_form.validate(): context = dict(app_form.validated) context.update(feature_form.validated) result = self.handler.do_generate(feature, **context) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index ed0ed009..5e2f539c 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -723,7 +723,7 @@ class MasterView(View): form = forms.Form(schema=schema, request=self.request) form.save_label = "Upload" form.cancel_url = self.get_index_url() - if form.validate(newstyle=True): + if form.validate(): uploads = self.normalize_uploads(form) filepath = uploads['filename']['temp_path'] @@ -1408,7 +1408,7 @@ class MasterView(View): pass def validate_quick_row_form(self, form): - return form.validate(newstyle=True) + return form.validate() def make_default_row_grid_tools(self, obj): if self.rows_creatable: @@ -2301,7 +2301,7 @@ class MasterView(View): factory = self.get_form_factory() form = factory(schema=schema, request=self.request) - if not form.validate(newstyle=True): + if not form.validate(): return {'error': "Form did not validate"} # nb. self.Session may differ, so use tailbone.db.Session @@ -2334,7 +2334,7 @@ class MasterView(View): factory = self.get_form_factory() form = factory(schema=schema, request=self.request) - if not form.validate(newstyle=True): + if not form.validate(): return {'error': "Form did not validate"} # nb. self.Session may differ, so use tailbone.db.Session @@ -4057,7 +4057,7 @@ class MasterView(View): supp.configure_form(form) def validate_form(self, form): - if form.validate(newstyle=True): + if form.validate(): self.form_deserialized = form.validated return True return False @@ -4514,7 +4514,7 @@ class MasterView(View): self.configure_field_product_key(form) def validate_row_form(self, form): - if form.validate(newstyle=True): + if form.validate(): self.form_deserialized = form.validated return True return False diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 3761941a..c0d0c86f 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -1000,7 +1000,7 @@ class PersonView(MasterView): def profile_add_note(self): person = self.get_instance() form = self.make_note_form('create', person) - if form.validate(newstyle=True): + if form.validate(): note = self.create_note(person, form) self.Session.flush() return self.profile_add_note_success(note) @@ -1025,7 +1025,7 @@ class PersonView(MasterView): def profile_edit_note(self): person = self.get_instance() form = self.make_note_form('edit', person) - if form.validate(newstyle=True): + if form.validate(): note = self.update_note(person, form) self.Session.flush() return self.profile_edit_note_success(note) @@ -1047,7 +1047,7 @@ class PersonView(MasterView): def profile_delete_note(self): person = self.get_instance() form = self.make_note_form('delete', person) - if form.validate(newstyle=True): + if form.validate(): self.delete_note(person, form) self.Session.flush() return self.profile_delete_note_success(person) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index ebec578e..9700424b 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -1972,7 +1972,7 @@ class ProductView(MasterView): params_forms[key] = forms.Form(schema=schema, request=self.request) if self.request.method == 'POST': - if form.validate(newstyle=True): + if form.validate(): data = form.validated fully_validated = True @@ -1985,7 +1985,7 @@ class ProductView(MasterView): # collect batch-type-specific params pform = params_forms.get(batch_key) if pform: - if pform.validate(newstyle=True): + if pform.validate(): pdata = pform.validated for field in pform.schema: param_name = pform.schema[field.name].param_name diff --git a/tailbone/views/projects.py b/tailbone/views/projects.py index a412c388..99103101 100644 --- a/tailbone/views/projects.py +++ b/tailbone/views/projects.py @@ -65,7 +65,7 @@ class GeneratedProjectView(MasterView): project_type = self.request.matchdict.get('project_type') if project_type: form = self.make_project_form(project_type) - if form.validate(newstyle=True): + if form.validate(): zipped = self.generate_project(project_type, form) return self.file_response(zipped) @@ -83,7 +83,7 @@ class GeneratedProjectView(MasterView): # if form validates, then user has chosen a project type, so # we redirect to the appropriate "generate project" page - if form.validate(newstyle=True): + if form.validate(): raise self.redirect(self.request.route_url( 'generate_specific_project', project_type=form.validated['project_type'])) diff --git a/tailbone/views/purchasing/costing.py b/tailbone/views/purchasing/costing.py index d5c86908..294b29ef 100644 --- a/tailbone/views/purchasing/costing.py +++ b/tailbone/views/purchasing/costing.py @@ -225,7 +225,7 @@ class CostingBatchView(PurchasingBatchView): # if form validates, that means user has chosen a creation type, so we # just redirect to the appropriate "new batch of type X" page - if form.validate(newstyle=True): + if form.validate(): workflow_key = form.validated['workflow'] vendor_uuid = form.validated['vendor'] url = self.request.route_url('{}.create_workflow'.format(route_prefix), diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 4632723d..cdc69fe5 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -358,7 +358,7 @@ class ReceivingBatchView(PurchasingBatchView): # if form validates, that means user has chosen a creation type, so we # just redirect to the appropriate "new batch of type X" page - if form.validate(newstyle=True): + if form.validate(): workflow_key = form.validated['workflow'] vendor_uuid = form.validated['vendor'] url = self.request.route_url('{}.create_workflow'.format(route_prefix), @@ -1196,7 +1196,7 @@ class ReceivingBatchView(PurchasingBatchView): # TODO: what is this one about again? form.remove_field('quick_receive') - if form.validate(newstyle=True): + if form.validate(): # handler takes care of the row receiving logic for us kwargs = dict(form.validated) @@ -1382,7 +1382,7 @@ class ReceivingBatchView(PurchasingBatchView): # expiration_date form.set_type('expiration_date', 'date_jquery') - if form.validate(newstyle=True): + if form.validate(): # handler takes care of the row receiving logic for us kwargs = dict(form.validated) diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index 5ded5c5f..a1c737b6 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -373,7 +373,7 @@ class ReportOutputView(ExportMasterView): # if form validates, that means user has chosen a report type, so we # just redirect to the appropriate "new report" page - if form.validate(newstyle=True): + if form.validate(): raise self.redirect(self.request.route_url('generate_specific_report', type_key=form.validated['report_type'])) @@ -465,7 +465,7 @@ class ReportOutputView(ExportMasterView): form.set_default(param.name, value) # if form validates, start generating new report output; show progress page - if form.validate(newstyle=True): + if form.validate(): key = 'report_output.generate' progress = self.make_progress(key) kwargs = {'progress': progress} diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index 472ea199..47cca0c5 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -274,7 +274,7 @@ class AppSettingsView(View): form = self.make_form(settings) form.cancel_url = self.request.current_route_url() - if form.validate(newstyle=True): + if form.validate(): self.save_form(form) group = self.request.POST.get('settings-group') if group is not None: diff --git a/tailbone/views/shifts/lib.py b/tailbone/views/shifts/lib.py index 8cb75f33..d32a1309 100644 --- a/tailbone/views/shifts/lib.py +++ b/tailbone/views/shifts/lib.py @@ -164,7 +164,7 @@ class TimeSheetView(View): Process a "shift filter" form if one was in fact POST'ed. If it was then we store new context in session and redirect to display as normal. """ - if form.validate(newstyle=True): + if form.validate(): store = form.validated['store'] self.request.session['timesheet.{}.store'.format(self.key)] = store.uuid if store else None department = form.validated['department'] @@ -178,7 +178,7 @@ class TimeSheetView(View): Process an "employee shift filter" form if one was in fact POST'ed. If it was then we store new context in session and redirect to display as normal. """ - if form.validate(newstyle=True): + if form.validate(): employee = form.validated['employee'] self.request.session['timesheet.{}.employee'.format(self.key)] = employee.uuid if employee else None date = form.validated['date'] From c002d3d182d32712d188b9aa6443849a611f91f5 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 14 May 2023 20:10:05 -0500 Subject: [PATCH 033/636] Add basic support for managing, and accepting API tokens also various other changes in pursuit of that. so far tokens are only accepted by web API and not traditional web app --- tailbone/auth.py | 33 ++++++++ tailbone/forms/core.py | 22 +++++- tailbone/grids/core.py | 17 ++++ tailbone/templates/form.mako | 7 +- tailbone/templates/page.mako | 2 +- tailbone/templates/users/view.mako | 120 +++++++++++++++++++++++++++++ tailbone/views/master.py | 33 ++++---- tailbone/views/users.py | 105 +++++++++++++++++++++++++ tailbone/webapi.py | 5 +- 9 files changed, 318 insertions(+), 26 deletions(-) diff --git a/tailbone/auth.py b/tailbone/auth.py index 0c90003a..1f057404 100644 --- a/tailbone/auth.py +++ b/tailbone/auth.py @@ -25,6 +25,7 @@ Authentication & Authorization """ import logging +import re from rattail import enum from rattail.util import prettify, NOTSET @@ -32,6 +33,7 @@ from rattail.util import prettify, NOTSET from zope.interface import implementer from pyramid.interfaces import IAuthorizationPolicy from pyramid.security import remember, forget, Everyone, Authenticated +from pyramid.authentication import SessionAuthenticationPolicy from tailbone.db import Session @@ -87,6 +89,37 @@ def set_session_timeout(request, timeout): request.session['_timeout'] = timeout or None +class TailboneAuthenticationPolicy(SessionAuthenticationPolicy): + """ + Custom authentication policy for Tailbone. + + This is mostly Pyramid's built-in session-based policy, but adds + logic to accept Rattail User API Tokens in lieu of current user + being identified via the session. + + Note that the traditional Tailbone web app does *not* use this + policy, only the Tailbone web API uses it by default. + """ + + def unauthenticated_userid(self, request): + + # figure out userid from header token if present + credentials = request.headers.get('Authorization') + if credentials: + match = re.match(r'^Bearer (\S+)$', credentials) + if match: + token = match.group(1) + rattail_config = request.registry.settings.get('rattail_config') + app = rattail_config.get_app() + auth = app.get_auth_handler() + user = auth.authenticate_user_token(Session(), token) + if user: + return user.uuid + + # otherwise do normal session-based logic + return super(TailboneAuthenticationPolicy, self).unauthenticated_userid(request) + + @implementer(IAuthorizationPolicy) class TailboneAuthorizationPolicy(object): diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 04cbb64a..c4a7b0ea 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -338,7 +338,7 @@ class Form(object): assume_local_times=False, renderers=None, renderer_kwargs={}, hidden={}, widgets={}, defaults={}, validators={}, required={}, helptext={}, focus_spec=None, action_url=None, cancel_url=None, component='tailbone-form', - vuejs_field_converters={}, + vuejs_component_kwargs=None, vuejs_field_converters={}, # TODO: ugh this is getting out hand! can_edit_help=False, edit_help_url=None, route_prefix=None, ): @@ -379,6 +379,7 @@ class Form(object): self.action_url = action_url self.cancel_url = cancel_url self.component = component + self.vuejs_component_kwargs = vuejs_component_kwargs or {} self.vuejs_field_converters = vuejs_field_converters or {} self.can_edit_help = can_edit_help self.edit_help_url = edit_help_url @@ -913,6 +914,25 @@ class Form(object): return False return True + def set_vuejs_component_kwargs(self, **kwargs): + self.vuejs_component_kwargs.update(kwargs) + + def render_vuejs_component(self): + """ + Render the Vue.js component HTML for the form. + + Most typically this is something like: + + .. code-block:: html + + + + """ + kwargs = dict(self.vuejs_component_kwargs) + if self.can_edit_help: + kwargs.setdefault(':configure-fields-help', 'configureFieldsHelp') + return HTML.tag(self.component, **kwargs) + def render_buefy_field(self, fieldname, bfield_attrs={}): """ Render the given field in a Buefy-compatible way. Note that diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index f1f00904..230bd061 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -1613,6 +1613,23 @@ class GridAction(object): """ Represents an action available to a grid. This is used to construct the 'actions' column when rendering the grid. + + :param key: Key for the action (e.g. ``'edit'``), unique within + the grid. + + :param label: Label to be displayed for the action. If not set, + will be a capitalized version of ``key``. + + :param icon: Icon name for the action. + + :param click_handler: Optional JS click handler for the action. + This value will be rendered as-is within the final grid + template, hence the JS string must be callable code. Note + that ``props.row`` will be available in the calling context, + so a couple of examples: + + * ``deleteThisThing(props.row)`` + * ``$emit('do-something', props.row)`` """ def __init__(self, key, label=None, url='#', icon=None, target=None, diff --git a/tailbone/templates/form.mako b/tailbone/templates/form.mako index cb6ef9c1..5878e030 100644 --- a/tailbone/templates/form.mako +++ b/tailbone/templates/form.mako @@ -11,12 +11,7 @@ <%def name="render_buefy_form()">
    - <${form.component} - % if can_edit_help: - :configure-fields-help="configureFieldsHelp" - % endif - > - + ${form.render_vuejs_component()}
    diff --git a/tailbone/templates/page.mako b/tailbone/templates/page.mako index 94147a04..b5ac8773 100644 --- a/tailbone/templates/page.mako +++ b/tailbone/templates/page.mako @@ -32,7 +32,7 @@ let ThisPage = { template: '#this-page-template', - mixins: [FormPosterMixin], + mixins: [SimpleRequestMixin], props: { configureFieldsHelp: Boolean, }, diff --git a/tailbone/templates/users/view.mako b/tailbone/templates/users/view.mako index b34902a1..f65b6d1c 100644 --- a/tailbone/templates/users/view.mako +++ b/tailbone/templates/users/view.mako @@ -21,5 +21,125 @@ % endif +<%def name="render_this_page()"> + ${parent.render_this_page()} + + % if master.has_perm('manage_api_tokens'): + + + + + + % endif + + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + % if master.has_perm('manage_api_tokens'): + + % endif + + ${parent.body()} diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 5e2f539c..2d6bae16 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2282,9 +2282,15 @@ class MasterView(View): if info and info.markdown_text: return info.markdown_text + def can_edit_help(self): + if self.has_perm('edit_help'): + return True + if self.request.has_perm('common.edit_help'): + return True + return False + def edit_help(self): - if (not self.has_perm('edit_help') - and not self.request.has_perm('common.edit_help')): + if not self.can_edit_help(): raise self.forbidden() model = self.model @@ -2317,8 +2323,7 @@ class MasterView(View): return {'ok': True} def edit_field_help(self): - if (not self.has_perm('edit_help') - and not self.request.has_perm('common.edit_help')): + if not self.can_edit_help(): raise self.forbidden() model = self.model @@ -2371,8 +2376,7 @@ class MasterView(View): 'grid_index': self.grid_index, 'help_url': self.get_help_url(), 'help_markdown': self.get_help_markdown(), - 'can_edit_help': (self.has_perm('edit_help') - or self.request.has_perm('common.edit_help')), + 'can_edit_help': self.can_edit_help(), 'quickie': None, } @@ -2638,16 +2642,16 @@ class MasterView(View): elif is_primary: btn_kw['type'] = 'is-primary' + if icon_left: + btn_kw['icon_left'] = icon_left + elif is_external: + btn_kw['icon_left'] = 'external-link-alt' + elif url: + btn_kw['icon_left'] = 'eye' + if url: btn_kw['href'] = url - if icon_left: - btn_kw['icon_left'] = icon_left - elif is_external: - btn_kw['icon_left'] = 'external-link-alt' - else: - btn_kw['icon_left'] = 'eye' - if target: btn_kw['target'] = target elif is_external: @@ -4017,8 +4021,7 @@ class MasterView(View): 'action_url': self.request.current_route_url(_query=None), 'assume_local_times': self.has_local_times, 'route_prefix': route_prefix, - 'can_edit_help': (self.has_perm('edit_help') - or self.request.has_perm('common.edit_help')), + 'can_edit_help': self.can_edit_help(), } if defaults['can_edit_help']: diff --git a/tailbone/views/users.py b/tailbone/views/users.py index ff614460..833c6cf5 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -38,6 +38,7 @@ from webhelpers2.html import HTML, tags from tailbone import forms from tailbone.views import MasterView, View from tailbone.views.principal import PrincipalMasterView, PermissionsRenderer +from tailbone.util import raw_datetime class UserView(PrincipalMasterView): @@ -51,6 +52,10 @@ class UserView(PrincipalMasterView): touchable = True mergeable = True + labels = { + 'api_tokens': "API Tokens", + } + grid_columns = [ 'username', 'person', @@ -68,6 +73,7 @@ class UserView(PrincipalMasterView): 'active_sticky', 'set_password', 'prevent_password_change', + 'api_tokens', 'roles', 'permissions', ] @@ -218,6 +224,17 @@ class UserView(PrincipalMasterView): # if self.creating: # f.set_required('password') + # api_tokens + if self.creating or self.editing: + f.remove('api_tokens') + elif self.has_perm('manage_api_tokens'): + f.set_renderer('api_tokens', self.render_api_tokens) + f.set_vuejs_component_kwargs(**{':apiTokens': 'apiTokens', + '@api-new-token': 'apiNewToken', + '@api-token-delete': 'apiTokenDelete'}) + else: + f.remove('api_tokens') + # roles f.set_renderer('roles', self.render_roles) if self.creating or self.editing: @@ -260,6 +277,75 @@ class UserView(PrincipalMasterView): if self.viewing or self.deleting: f.remove('set_password') + def render_api_tokens(self, user, field): + route_prefix = self.get_route_prefix() + permission_prefix = self.get_permission_prefix() + + factory = self.get_grid_factory() + g = factory( + key='{}.api_tokens'.format(route_prefix), + data=[], + columns=['description', 'created'], + main_actions=[ + self.make_action('delete', icon='trash', + click_handler="$emit('api-token-delete', props.row)")]) + + button = self.make_buefy_button("New", is_primary=True, + icon_left='plus', + **{'@click': "$emit('api-new-token')"}) + + table = HTML.literal( + g.render_buefy_table_element(data_prop='apiTokens')) + + return HTML.tag('div', c=[button, table]) + + def add_api_token(self): + user = self.get_instance() + data = self.request.json_body + + token = self.auth_handler.add_api_token(user, data['description']) + self.Session.flush() + + return {'ok': True, + 'raw_token': token.token_string, + 'tokens': self.get_api_tokens(user)} + + def delete_api_token(self): + model = self.model + user = self.get_instance() + data = self.request.json_body + + token = self.Session.get(model.UserAPIToken, data['uuid']) + if not token: + return {'error': "API token not found"} + + if token.user is not user: + return {'error': "API token not found"} + + self.auth_handler.delete_api_token(token) + self.Session.flush() + + return {'ok': True, + 'tokens': self.get_api_tokens(user)} + + def template_kwargs_view(self, **kwargs): + kwargs = super(UserView, self).template_kwargs_view(**kwargs) + user = kwargs['instance'] + + kwargs['api_tokens_data'] = self.get_api_tokens(user) + + return kwargs + + def get_api_tokens(self, user): + tokens = [] + for token in reversed(user.api_tokens): + tokens.append({ + 'uuid': token.uuid, + 'description': token.description, + 'created': raw_datetime(self.rattail_config, token.created), + }) + return tokens + def get_possible_roles(self): model = self.model @@ -554,6 +640,25 @@ class UserView(PrincipalMasterView): config.add_tailbone_permission(permission_prefix, '{}.edit_roles'.format(permission_prefix), "Edit the Roles to which a {} belongs".format(model_title)) + # manage API tokens + config.add_tailbone_permission(permission_prefix, + '{}.manage_api_tokens'.format(permission_prefix), + "Manage API tokens for any {}".format(model_title)) + config.add_route('{}.add_api_token'.format(route_prefix), + '{}/add-api-token'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='add_api_token', + route_name='{}.add_api_token'.format(route_prefix), + permission='{}.manage_api_tokens'.format(permission_prefix), + renderer='json') + config.add_route('{}.delete_api_token'.format(route_prefix), + '{}/delete-api-token'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='delete_api_token', + route_name='{}.delete_api_token'.format(route_prefix), + permission='{}.manage_api_tokens'.format(permission_prefix), + renderer='json') + # edit preferences for any user config.add_tailbone_permission(permission_prefix, '{}.preferences'.format(permission_prefix), diff --git a/tailbone/webapi.py b/tailbone/webapi.py index a437f0c3..7a2c81b4 100644 --- a/tailbone/webapi.py +++ b/tailbone/webapi.py @@ -28,10 +28,9 @@ import simplejson from cornice.renderer import CorniceRenderer from pyramid.config import Configurator -from pyramid.authentication import SessionAuthenticationPolicy from tailbone import app -from tailbone.auth import TailboneAuthorizationPolicy +from tailbone.auth import TailboneAuthenticationPolicy, TailboneAuthorizationPolicy from tailbone.providers import get_all_providers @@ -51,8 +50,8 @@ def make_pyramid_config(settings): pyramid_config = Configurator(settings=settings, root_factory=app.Root) # configure user authorization / authentication + pyramid_config.set_authentication_policy(TailboneAuthenticationPolicy()) pyramid_config.set_authorization_policy(TailboneAuthorizationPolicy()) - pyramid_config.set_authentication_policy(SessionAuthenticationPolicy()) # always require CSRF token protection pyramid_config.set_default_csrf_options(require_csrf=True, From d90cab40a668be342526dc7f1f0941e10a88ec59 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 15 May 2023 08:49:01 -0500 Subject: [PATCH 034/636] Update changelog --- CHANGES.rst | 8 ++++++++ tailbone/_version.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 25ca1da5..8af59029 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.9.23 (2023-05-15) +------------------- + +* Get rid of ``newstyle`` flag for ``Form.validate()`` method. + +* Add basic support for managing, and accepting API tokens. + + 0.9.22 (2023-05-13) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index b6cfd77e..63846565 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.22' +__version__ = '0.9.23' From 5f6b3895564ab5fee83c3b214b13e2b69e3c6c7a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 16 May 2023 15:02:39 -0500 Subject: [PATCH 035/636] Replace `setup.py` contents with `setup.cfg` --- setup.cfg | 101 ++++++++++++++++++++++++++++++++++ setup.py | 158 +----------------------------------------------------- 2 files changed, 103 insertions(+), 156 deletions(-) diff --git a/setup.cfg b/setup.cfg index 7712ec72..420b9983 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,107 @@ +# -*- coding: utf-8; -*- + [nosetests] nocapture = 1 cover-package = tailbone cover-erase = 1 cover-html = 1 cover-html-dir = htmlcov + +[metadata] +name = Tailbone +version = attr: tailbone.__version__ +author = Lance Edgar +author_email = lance@edbob.org +url = http://rattailproject.org/ +license = GNU GPL v3 +description = Backoffice Web Application for Rattail +long_description = file: README.rst +classifiers = + Development Status :: 4 - Beta + Environment :: Web Environment + Framework :: Pyramid + Intended Audience :: Developers + License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+) + Natural Language :: English + Operating System :: OS Independent + Programming Language :: Python + Programming Language :: Python :: 3 + Topic :: Internet :: WWW/HTTP + Topic :: Office/Business + Topic :: Software Development :: Libraries :: Python Modules + + +[options] +install_requires = + + # TODO: apparently they jumped from 0.1 to 0.9 and that broke us... + # (0.1 was released on 2014-09-14 and then 0.9 came out on 2018-09-27) + # (i've cached 0.1 at pypi.rattailproject.org just in case it disappears) + # (still, probably a better idea is to refactor so we can use 0.9) + webhelpers2_grid==0.1 + + # TODO: remove once their bug is fixed? idk what this is about yet... + deform<2.0.15 + + # TODO: remove this cap and address warnings that follow + pyramid<2 + + asgiref + colander + ColanderAlchemy + cornice + humanize + Mako + markdown + openpyxl + paginate + paginate_sqlalchemy + passlib + Pillow + pyramid_beaker>=0.6 + pyramid_deform + pyramid_exclog + pyramid_mako + pyramid_retry + pyramid_tm + rattail[db,bouncer] + six + sa-filters + simplejson + transaction + waitress + WebHelpers2 + zope.sqlalchemy + +tests_require = Tailbone[tests] +test_suite = nose.collector +packages = find: +include_package_data = True +zip_safe = False + + +[options.packages.find] +exclude = + tests.* + tests + + +[options.extras_require] +docs = Sphinx; sphinx-rtd-theme +tests = coverage; fixture; mock; nose; pytest; pytest-cov + + +[options.entry_points] + +paste.app_factory = + main = tailbone.app:main + webapi = tailbone.webapi:main + +rattail.cleaners = + beaker = tailbone.cleanup:BeakerCleaner + +rattail.config.extensions = + tailbone = tailbone.config:ConfigExtension + +pyramid.scaffold = + rattail = tailbone.scaffolds:RattailTemplate diff --git a/setup.py b/setup.py index b295f062..5645ddff 100644 --- a/setup.py +++ b/setup.py @@ -24,160 +24,6 @@ Setup script for Tailbone """ -import os.path -from setuptools import setup, find_packages +from setuptools import setup - -here = os.path.abspath(os.path.dirname(__file__)) -exec(open(os.path.join(here, 'tailbone', '_version.py')).read()) -README = open(os.path.join(here, 'README.rst')).read() - - -requires = [ - # - # Version numbers within comments below have specific meanings. - # Basically the 'low' value is a "soft low," and 'high' a "soft high." - # In other words: - # - # If either a 'low' or 'high' value exists, the primary point to be - # made about the value is that it represents the most current (stable) - # version available for the package (assuming typical public access - # methods) whenever this project was started and/or documented. - # Therefore: - # - # If a 'low' version is present, you should know that attempts to use - # versions of the package significantly older than the 'low' version - # may not yield happy results. (A "hard" high limit may or may not be - # indicated by a true version requirement.) - # - # Similarly, if a 'high' version is present, and especially if this - # project has laid dormant for a while, you may need to refactor a bit - # when attempting to support a more recent version of the package. (A - # "hard" low limit should be indicated by a true version requirement - # when a 'high' version is present.) - # - # In any case, developers and other users are encouraged to play - # outside the lines with regard to these soft limits. If bugs are - # encountered then they should be filed as such. - # - # package # low high - - # TODO: apparently they jumped from 0.1 to 0.9 and that broke us... - # (0.1 was released on 2014-09-14 and then 0.9 came out on 2018-09-27) - # (i've cached 0.1 at pypi.rattailproject.org just in case it disappears) - # (still, probably a better idea is to refactor so we can use 0.9) - 'webhelpers2_grid==0.1', # 0.1 - - # TODO: remove once their bug is fixed? idk what this is about yet... - 'deform<2.0.15', # 2.0.14 - - # TODO: remove this cap and address warnings that follow - 'pyramid<2', # 1.3b2 1.10.8 - - 'asgiref', # 3.2.3 - 'colander', # 1.7.0 - 'ColanderAlchemy', # 0.3.3 - 'cornice', # 3.4.2 - 'humanize', # 0.5.1 - 'Mako', # 0.6.2 - 'markdown', # 3.3.3 - 'openpyxl', # 2.4.7 - 'paginate', # 0.5.6 - 'paginate_sqlalchemy', # 0.2.0 - 'passlib', # 1.7.1 - 'Pillow', # 5.3.0 - 'pyramid_beaker>=0.6', # 0.6.1 - 'pyramid_deform', # 0.2 - 'pyramid_exclog', # 0.6 - 'pyramid_mako', # 1.0.2 - 'pyramid_retry', # 2.1.1 - 'pyramid_tm', # 0.3 - 'rattail[db,bouncer]', # 0.5.0 - 'six', # 1.10.0 - 'sa-filters', # 1.2.0 - 'simplejson', # 3.18.3 - 'transaction', # 1.2.0 - 'waitress', # 0.8.1 - 'WebHelpers2', # 2.0 - 'zope.sqlalchemy', # 0.7 2.0 -] - - -extras = { - - 'docs': [ - # - # package # low high - - 'Sphinx', # 1.2 - 'sphinx-rtd-theme', # 0.2.4 - ], - - 'tests': [ - # - # package # low high - - 'coverage', # 3.6 - 'fixture', # 1.5 - 'mock', # 1.0.1 - 'nose', # 1.3.0 - 'pytest', # 4.6.11 - 'pytest-cov', # 2.12.1 - ], -} - - -setup( - name = "Tailbone", - version = __version__, - author = "Lance Edgar", - author_email = "lance@edbob.org", - url = "http://rattailproject.org/", - license = "GNU GPL v3", - description = "Backoffice Web Application for Rattail", - long_description = README, - - classifiers = [ - 'Development Status :: 4 - Beta', - 'Environment :: Web Environment', - 'Framework :: Pyramid', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)', - 'Natural Language :: English', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - 'Topic :: Internet :: WWW/HTTP', - 'Topic :: Office/Business', - 'Topic :: Software Development :: Libraries :: Python Modules', - ], - - install_requires = requires, - extras_require = extras, - tests_require = ['Tailbone[tests]'], - test_suite = 'nose.collector', - - packages = find_packages(exclude=['tests.*', 'tests']), - include_package_data = True, - zip_safe = False, - - entry_points = { - - 'paste.app_factory': [ - 'main = tailbone.app:main', - 'webapi = tailbone.webapi:main', - ], - - 'rattail.cleaners': [ - 'beaker = tailbone.cleanup:BeakerCleaner', - ], - - 'rattail.config.extensions': [ - 'tailbone = tailbone.config:ConfigExtension', - ], - - 'pyramid.scaffold': [ - 'rattail = tailbone.scaffolds:RattailTemplate', - ], - }, -) +setup() From 93bce5788813c8c07083e578e350916ecb983c25 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 16 May 2023 17:33:07 -0500 Subject: [PATCH 036/636] Prevent error in old product search logic when no POD image URL is configured --- tailbone/views/products.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 9700424b..8988538b 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -1876,6 +1876,7 @@ class ProductView(MasterView): ]) # TODO: deprecate / remove this? not sure if/where it is used + # (hm, still used by the old Instacart -> Configure page..) def search_v1(self): """ Locate a product(s) by UPC. @@ -1898,7 +1899,8 @@ class ProductView(MasterView): 'upc': str(product.upc), 'upc_pretty': product.upc.pretty(), 'full_description': product.full_description, - 'image_url': pod.get_image_url(self.rattail_config, product.upc), + 'image_url': pod.get_image_url(self.rattail_config, product.upc, + require=False), } uuid = self.request.GET.get('with_vendor_cost') if uuid: From 26a6a4d991ee851489ebb3ef7a72884197aa252d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 16 May 2023 17:33:55 -0500 Subject: [PATCH 037/636] Update changelog --- CHANGES.rst | 8 ++++++++ tailbone/_version.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 8af59029..e8d0ac4c 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.9.24 (2023-05-16) +------------------- + +* Replace ``setup.py`` contents with ``setup.cfg``. + +* Prevent error in old product search logic. + + 0.9.23 (2023-05-15) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 63846565..6e9597a5 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.23' +__version__ = '0.9.24' From c18367739ff8c8c24f58fe88eeb075abc9ae6a60 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 16 May 2023 23:10:54 -0500 Subject: [PATCH 038/636] Add initial swagger.json endpoint for API probably this needs more, but good enough to test with --- setup.cfg | 1 + tailbone/api/common.py | 20 ++++++++++++++++++++ tailbone/views/common.py | 5 +++++ 3 files changed, 26 insertions(+) diff --git a/setup.cfg b/setup.cfg index 420b9983..85501357 100644 --- a/setup.cfg +++ b/setup.cfg @@ -50,6 +50,7 @@ install_requires = colander ColanderAlchemy cornice + cornice-swagger humanize Mako markdown diff --git a/tailbone/api/common.py b/tailbone/api/common.py index cd663d53..30dfeab1 100644 --- a/tailbone/api/common.py +++ b/tailbone/api/common.py @@ -31,6 +31,8 @@ from rattail.db import model from rattail.mail import send_email from cornice import Service +from cornice.service import get_services +from cornice_swagger import CorniceSwagger import tailbone from tailbone import forms @@ -109,12 +111,22 @@ class CommonView(APIView): return {'error': "Form did not validate!"} + def swagger(self): + doc = CorniceSwagger(get_services()) + app = self.get_rattail_app() + spec = doc.generate(f"{app.get_node_title()} API docs", + app.get_version(), + base_path='/api') # TODO + return spec + @classmethod def defaults(cls, config): cls._common_defaults(config) @classmethod def _common_defaults(cls, config): + rattail_config = config.registry.settings.get('rattail_config') + app = rattail_config.get_app() # about about = Service(name='about', path='/about') @@ -127,6 +139,14 @@ class CommonView(APIView): permission='common.feedback') config.add_cornice_service(feedback) + # swagger + swagger = Service(name='swagger', + path='/swagger.json', + description=f"OpenAPI documentation for {app.get_title()}") + swagger.add_view('GET', 'swagger', klass=cls, + permission='common.api_swagger') + config.add_cornice_service(swagger) + def defaults(config, **kwargs): base = globals() diff --git a/tailbone/views/common.py b/tailbone/views/common.py index e8d37904..7d1cd402 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -275,6 +275,11 @@ class CommonView(View): config.add_tailbone_permission('common', 'common.edit_help', "Edit help info for *any* page") + # API swagger + if rattail_config.getbool('tailbone', 'expose_api_swagger'): + config.add_tailbone_permission('common', 'common.api_swagger', + "Explore the API with Swagger tools") + # home config.add_route('home', '/') config.add_view(cls, attr='home', route_name='home', renderer='/home.mako') From 8d880fc9dd71288231b7695a3329fa550c29b1ec Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 18 May 2023 13:48:22 -0500 Subject: [PATCH 039/636] Add workaround for "share grid link" on insecure sites --- tailbone/templates/grids/buefy.mako | 33 ++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako index 98de939d..48c3a081 100644 --- a/tailbone/templates/grids/buefy.mako +++ b/tailbone/templates/grids/buefy.mako @@ -315,6 +315,12 @@ + + ## dummy input field needed for sharing links on *insecure* sites + % if request.scheme == 'http': + + % endif +
    @@ -349,6 +355,11 @@ filters: ${json.dumps(filters_data if grid.filterable else None)|n}, filtersSequence: ${json.dumps(filters_sequence if grid.filterable else None)|n}, selectedFilter: null, + + ## dummy input value needed for sharing links on *insecure* sites + % if request.scheme == 'http': + shareLink: null, + % endif } let ${grid.component_studly} = { @@ -382,7 +393,27 @@ methods: { copyDirectLink() { - navigator.clipboard.writeText(this.directLink) + + if (navigator.clipboard) { + // this is the way forward, but requires HTTPS + navigator.clipboard.writeText(this.directLink) + + } else { + // use deprecated 'copy' command, but this just + // tells the browser to copy currently-selected + // text..which means we first must "add" some text + // to screen, and auto-select that, before copying + // to clipboard + this.shareLink = this.directLink + this.$nextTick(() => { + let input = this.$refs.shareLink.$el.firstChild + input.select() + document.execCommand('copy') + // re-hide the dummy input + this.shareLink = null + }) + } + this.$buefy.toast.open({ message: "Link was copied to clipboard", type: 'is-info', From af405cfd1003842d6d5d75fadf07d6df0aa2392c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 18 May 2023 13:51:59 -0500 Subject: [PATCH 040/636] Update changelog --- CHANGES.rst | 8 ++++++++ tailbone/_version.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index e8d0ac4c..e95389db 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.9.25 (2023-05-18) +------------------- + +* Add initial swagger.json endpoint for API. + +* Add workaround for "share grid link" on insecure sites. + + 0.9.24 (2023-05-16) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 6e9597a5..2e241d54 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.24' +__version__ = '0.9.25' From 05bb3849a2c7535a207f631f0f8d8ba3d072cbca Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 18 May 2023 19:56:55 -0500 Subject: [PATCH 041/636] Prevent bug in upgrade diff for empty new version apparently this is quite the rare case..but can happen --- tailbone/views/upgrades.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index eddd677c..f7c83eec 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.py @@ -400,6 +400,10 @@ class UpgradeView(MasterView): return projects def get_changelog_url(self, project, old_version, new_version): + # cannot generate URL if new version is unknown + if not new_version: + return + projects = self.get_changelog_projects() project_name = project From de13e48aa5763818e324b8e7fa3ee0bbd4d994ca Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 19 May 2023 17:16:19 -0500 Subject: [PATCH 042/636] Expose basic way to send test email most of the mechanics of sending email could already be tested by sending a "preview" email of any type, or e.g. via Feedback. but it seemed like the Configure Email Settings page should have a dedicated way to test sending --- .../templates/settings/email/configure.mako | 74 +++++++++++++++---- tailbone/views/email.py | 26 +++++++ 2 files changed, 84 insertions(+), 16 deletions(-) diff --git a/tailbone/templates/settings/email/configure.mako b/tailbone/templates/settings/email/configure.mako index 13bceb3e..f0e5d4d9 100644 --- a/tailbone/templates/settings/email/configure.mako +++ b/tailbone/templates/settings/email/configure.mako @@ -26,30 +26,72 @@
    - % if request.has_perm('errors.bogus'): -

    Testing

    -
    +

    Testing

    +
    - -

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

    + + + + + + {{ sendingTest ? "Working, please wait..." : "Send Test Email" }} + + + +
    +
    +
    +

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

    +
    +
    + :disabled="raisingBogusError" + % else: + disabled + title="your permissions do not allow this" + % endif + > + % if request.has_perm('errors.bogus'): {{ raisingBogusError ? "Working, please wait..." : "Raise Bogus Error" }} + % else: + Raise Bogus Error + % endif - - +
    - - % endif +
    + +
    <%def name="modify_this_page_vars()"> ${parent.modify_this_page_vars()} - % if request.has_perm('errors.bogus'): - - % endif + % endif + diff --git a/tailbone/views/email.py b/tailbone/views/email.py index 536bf6ed..428e8484 100644 --- a/tailbone/views/email.py +++ b/tailbone/views/email.py @@ -297,6 +297,22 @@ class EmailSettingView(MasterView): 'true' if data['hidden'] else 'false') return {'ok': True} + def send_test(self): + """ + AJAX view for sending a test email. + """ + data = self.request.json_body + + recip = data.get('recipient') + if not recip: + return {'error': "Must specify recipient"} + + app = self.get_rattail_app() + app.send_email('hello', to=[recip], cc=None, bcc=None, + default_subject="Hello world") + + return {'ok': True} + @classmethod def defaults(cls, config): cls._email_defaults(config) @@ -318,6 +334,16 @@ class EmailSettingView(MasterView): permission='{}.configure'.format(permission_prefix), renderer='json') + # send test + config.add_route('{}.send_test'.format(route_prefix), + '{}/send-test'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='send_test', + route_name='{}.send_test'.format(route_prefix), + permission='{}.configure'.format(permission_prefix), + renderer='json') + + # TODO: deprecate / remove this ProfilesView = EmailSettingView From ae38e09d1b8c8d755869d3b9628ef17448f79635 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 19 May 2023 17:43:31 -0500 Subject: [PATCH 043/636] Avoid error when filter params not valid --- tailbone/grids/filters.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py index 26ef4f59..59e20d78 100644 --- a/tailbone/grids/filters.py +++ b/tailbone/grids/filters.py @@ -271,7 +271,8 @@ class GridFilter(object): value = self.get_value(value) filtr = getattr(self, 'filter_{0}'.format(verb), None) if not filtr: - raise ValueError("Unknown filter verb: {0}".format(repr(verb))) + log.warning("unknown filter verb: %s", verb) + return data return filtr(data, value) def get_value(self, value=UNSPECIFIED): From dd3f91cf0c62dfdadb2cd8dd85445655286e5d1a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 19 May 2023 19:45:41 -0500 Subject: [PATCH 044/636] Tweak byjove project generator form --- tailbone/views/projects.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tailbone/views/projects.py b/tailbone/views/projects.py index 99103101..bcc4cb5d 100644 --- a/tailbone/views/projects.py +++ b/tailbone/views/projects.py @@ -370,16 +370,25 @@ class GeneratedProjectView(MasterView): f.set_grouping([ ("Naming", [ + 'system_name', 'name', 'slug', ]), ]) + # system_name + f.set_default('system_name', "Okay Then") + f.set_helptext('system_name', + "Name of overall system to which mobile app belongs.") + # name + f.set_label('name', "Mobile App Name") f.set_default('name', "Okay Then Mobile") + f.set_helptext('name', "Display name for the mobile app.") # slug f.set_default('slug', "okay-then-mobile") + f.set_helptext('slug', "Used for NPM-compatible project name etc.") def configure_form_fabric(self, f): From 29767dfcfba596ca811e5df8fb1cc52bc79002a3 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 19 May 2023 19:46:18 -0500 Subject: [PATCH 045/636] Define essential views for API --- tailbone/api/essentials.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 tailbone/api/essentials.py diff --git a/tailbone/api/essentials.py b/tailbone/api/essentials.py new file mode 100644 index 00000000..7b151578 --- /dev/null +++ b/tailbone/api/essentials.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2023 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Essential views for convenient includes +""" + + +def defaults(config, **kwargs): + mod = lambda spec: kwargs.get(spec, spec) + + config.include(mod('tailbone.api.auth')) + config.include(mod('tailbone.api.common')) + + +def includeme(config): + defaults(config) From b840ae75138386d177d48bc3b690b8d3dc7138ae Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 25 May 2023 12:21:04 -0500 Subject: [PATCH 046/636] Update changelog --- CHANGES.rst | 14 ++++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index e95389db..cb50228b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,20 @@ CHANGELOG ========= +0.9.26 (2023-05-25) +------------------- + +* Prevent bug in upgrade diff for empty new version. + +* Expose basic way to send test email. + +* Avoid error when filter params not valid. + +* Tweak byjove project generator form. + +* Define essential views for API. + + 0.9.25 (2023-05-18) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 2e241d54..f676a7c2 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.25' +__version__ = '0.9.26' From 0d9a502801801dd5c652929aa0f8794f4581fd40 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 25 May 2023 14:55:41 -0500 Subject: [PATCH 047/636] Fix test for config object --- tests/test_app.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_app.py b/tests/test_app.py index 6434aa0e..2523c424 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,6 +1,4 @@ -# -*- coding: utf-8 -*- - -from __future__ import unicode_literals, absolute_import +# -*- coding: utf-8; -*- import os from unittest import TestCase @@ -29,4 +27,6 @@ class TestRattailConfig(TestCase): self.assertRaises(ConfigurationError, app.make_rattail_config, {}) # get a config object if path provided result = app.make_rattail_config({'rattail.config': self.config_path}) - self.assertTrue(isinstance(result, RattailConfig)) + # nb. cannot test isinstance(RattailConfig) b/c now uses wrapper! + self.assertIsNotNone(result) + self.assertTrue(hasattr(result, 'get')) From b4816c6289fbd4256956f9109f12bf4a1d1f6db2 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 30 May 2023 13:25:20 -0500 Subject: [PATCH 048/636] Share some code for validating vendor field and add validation for new Ordering batch --- tailbone/views/batch/vendorcatalog.py | 7 ------- tailbone/views/master.py | 7 +++++++ tailbone/views/purchasing/batch.py | 7 +------ 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/tailbone/views/batch/vendorcatalog.py b/tailbone/views/batch/vendorcatalog.py index 1bd5eed7..ec8da979 100644 --- a/tailbone/views/batch/vendorcatalog.py +++ b/tailbone/views/batch/vendorcatalog.py @@ -260,13 +260,6 @@ class VendorCatalogView(FileBatchMasterView): else: f.remove('cache_products') - def valid_vendor_uuid(self, node, value): - model = self.model - if value: - vendor = self.Session.get(model.Vendor, value) - if not vendor: - raise colander.Invalid(node, "Vendor not found") - def render_parser_key(self, batch, field): key = getattr(batch, field) if not key: diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 2d6bae16..25543cb2 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -847,6 +847,13 @@ class MasterView(View): url = self.request.route_url('vendors.view', uuid=vendor.uuid) return tags.link_to(text, url) + def valid_vendor_uuid(self, node, value): + if value: + model = self.model + vendor = self.Session.get(model.Vendor, value) + if not vendor: + node.raise_invalid("Vendor not found") + def render_department(self, obj, field): department = getattr(obj, field) if not department: diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index fdbfe38c..16153f64 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -277,6 +277,7 @@ class PurchasingBatchView(BatchMasterView): vendors_url = self.request.route_url('vendors.autocomplete') f.set_widget('vendor_uuid', forms.widgets.JQueryAutocompleteWidget( field_display=vendor_display, service_url=vendors_url)) + f.set_validator('vendor_uuid', self.valid_vendor_uuid) elif self.editing: f.set_readonly('vendor') @@ -395,12 +396,6 @@ class PurchasingBatchView(BatchMasterView): 'vendor_contact', 'status_code') - def valid_vendor_uuid(self, node, value): - model = self.model - vendor = self.Session.get(model.Vendor, value) - if not vendor: - raise colander.Invalid(node, "Invalid vendor selection") - def render_store(self, batch, field): store = batch.store if not store: From fd2b290fd08120234aa576f2a7eec66c3732b5f6 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 1 Jun 2023 11:12:31 -0500 Subject: [PATCH 049/636] Save datasync config with new keys, per RattailConfiguration --- tailbone/views/datasync.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py index e6c31721..3a691218 100644 --- a/tailbone/views/datasync.py +++ b/tailbone/views/datasync.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,13 +24,10 @@ DataSync Views """ -from __future__ import unicode_literals, absolute_import - import json import subprocess import logging -import six import sqlalchemy as sa from rattail.db import model @@ -117,7 +114,7 @@ class DataSyncThreadView(MasterView): watcher_data = [] consumer_data = [] now = app.localtime() - for key, profile in six.iteritems(profiles): + for key, profile in profiles.items(): watcher = profile.watcher lastrun = self.datasync_handler.get_watcher_lastrun( @@ -258,7 +255,7 @@ class DataSyncThreadView(MasterView): watch.append(pkey) settings.extend([ - {'name': 'rattail.datasync.{}.watcher'.format(pkey), + {'name': 'rattail.datasync.{}.watcher.spec'.format(pkey), 'value': profile['watcher_spec']}, {'name': 'rattail.datasync.{}.watcher.db'.format(pkey), 'value': profile['watcher_dbkey']}, @@ -289,7 +286,7 @@ class DataSyncThreadView(MasterView): if consumer['enabled']: consumers.append(ckey) settings.extend([ - {'name': 'rattail.datasync.{}.consumer.{}'.format(pkey, ckey), + {'name': 'rattail.datasync.{}.consumer.spec.{}'.format(pkey, ckey), 'value': consumer['consumer_spec']}, {'name': 'rattail.datasync.{}.consumer.{}.db'.format(pkey, ckey), 'value': consumer['consumer_dbkey']}, @@ -304,7 +301,7 @@ class DataSyncThreadView(MasterView): ]) settings.extend([ - {'name': 'rattail.datasync.{}.consumers'.format(pkey), + {'name': 'rattail.datasync.{}.consumers.list'.format(pkey), 'value': ', '.join(consumers)}, ]) From 90cb25446bf323da97de6220be70f9972386b156 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 1 Jun 2023 11:37:26 -0500 Subject: [PATCH 050/636] Fix datasync consumer setting save logic --- tailbone/views/datasync.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py index 3a691218..ac0fec52 100644 --- a/tailbone/views/datasync.py +++ b/tailbone/views/datasync.py @@ -286,7 +286,7 @@ class DataSyncThreadView(MasterView): if consumer['enabled']: consumers.append(ckey) settings.extend([ - {'name': 'rattail.datasync.{}.consumer.spec.{}'.format(pkey, ckey), + {'name': f'rattail.datasync.{pkey}.consumer.{ckey}.spec', 'value': consumer['consumer_spec']}, {'name': 'rattail.datasync.{}.consumer.{}.db'.format(pkey, ckey), 'value': consumer['consumer_dbkey']}, From e1685231c23e260b14c75cd6364acf1d3967c31a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 1 Jun 2023 12:17:19 -0500 Subject: [PATCH 051/636] Update changelog --- CHANGES.rst | 8 ++++++++ tailbone/_version.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index cb50228b..e93be9f3 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.9.27 (2023-06-01) +------------------- + +* Share some code for validating vendor field. + +* Save datasync config with new keys, per RattailConfiguration. + + 0.9.26 (2023-05-25) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index f676a7c2..55910f0a 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.26' +__version__ = '0.9.27' From 93b03c95620bc4b2d41c404cbb55222ac519f5ee Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 2 Jun 2023 14:14:33 -0500 Subject: [PATCH 052/636] Expose mail handler and template paths in email config page --- .../templates/settings/email/configure.mako | 18 ++++++++++++ tailbone/views/email.py | 28 +++++++++++++------ 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/tailbone/templates/settings/email/configure.mako b/tailbone/templates/settings/email/configure.mako index f0e5d4d9..50a3d483 100644 --- a/tailbone/templates/settings/email/configure.mako +++ b/tailbone/templates/settings/email/configure.mako @@ -3,6 +3,24 @@ <%def name="form_content()"> +

    General

    +
    + + + + + + + + +
    +

    Sending

    diff --git a/tailbone/views/email.py b/tailbone/views/email.py index 428e8484..9c3d2268 100644 --- a/tailbone/views/email.py +++ b/tailbone/views/email.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,13 +24,9 @@ Email Views """ -from __future__ import unicode_literals, absolute_import - import re import warnings -import six - from rattail import mail from rattail.db import model from rattail.config import parse_list @@ -105,7 +101,7 @@ class EmailSettingView(MasterView): emails = self.email_handler.get_all_emails() else: emails = self.email_handler.get_available_emails() - for key, Email in six.iteritems(emails): + for key, Email in emails.items(): email = Email(self.rattail_config, key) data.append(self.normalize(email)) return data @@ -266,9 +262,9 @@ class EmailSettingView(MasterView): app.save_setting(session, 'rattail.mail.{}.to'.format(key), (data['to'] or '').replace('\n', ', ')) app.save_setting(session, 'rattail.mail.{}.cc'.format(key), (data['cc'] or '').replace('\n', ', ')) app.save_setting(session, 'rattail.mail.{}.bcc'.format(key), (data['bcc'] or '').replace('\n', ', ')) - app.save_setting(session, 'rattail.mail.{}.enabled'.format(key), six.text_type(data['enabled']).lower()) + app.save_setting(session, 'rattail.mail.{}.enabled'.format(key), str(data['enabled']).lower()) if self.has_perm('configure'): - app.save_setting(session, 'rattail.mail.{}.hidden'.format(key), six.text_type(data['hidden']).lower()) + app.save_setting(session, 'rattail.mail.{}.hidden'.format(key), str(data['hidden']).lower()) return data def template_kwargs_view(self, **kwargs): @@ -280,6 +276,12 @@ class EmailSettingView(MasterView): config = self.rattail_config return [ + # general + {'section': 'rattail.mail', + 'option': 'handler'}, + {'section': 'rattail.mail', + 'option': 'templates'}, + # sending {'section': 'rattail.mail', 'option': 'record_attempts', @@ -289,6 +291,16 @@ class EmailSettingView(MasterView): 'type': bool}, ] + def configure_get_context(self, *args, **kwargs): + context = super().configure_get_context(*args, **kwargs) + + # prettify list of template paths + templates = self.rattail_config.parse_list( + context['simple_settings']['rattail.mail.templates']) + context['simple_settings']['rattail.mail.templates'] = ', '.join(templates) + + return context + def toggle_hidden(self): app = self.get_rattail_app() data = self.request.json_body From 13ac33bb27eccd3fa17c32666fe6ab62f9874b43 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 2 Jun 2023 14:19:53 -0500 Subject: [PATCH 053/636] Update changelog --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index e93be9f3..53422091 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.9.28 (2023-06-02) +------------------- + +* Expose mail handler and template paths in email config page. + + 0.9.27 (2023-06-01) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 55910f0a..4ad67fa6 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.27' +__version__ = '0.9.28' From 4318f03bd628790d8f94ebb5e61ed42cbe1af392 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 5 Jun 2023 20:18:11 -0500 Subject: [PATCH 054/636] Add "typical" view config, for e.g. Theo and the like bring in all normal views for backoffice retail --- tailbone/views/typical.py | 57 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 tailbone/views/typical.py diff --git a/tailbone/views/typical.py b/tailbone/views/typical.py new file mode 100644 index 00000000..018794f5 --- /dev/null +++ b/tailbone/views/typical.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2023 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Typical views for convenient includes +""" + + +def defaults(config, **kwargs): + mod = lambda spec: kwargs.get(spec, spec) + + # main tables + config.include(mod('tailbone.views.brands')) + config.include(mod('tailbone.views.categories')) + config.include(mod('tailbone.views.customergroups')) + config.include(mod('tailbone.views.customers')) + config.include(mod('tailbone.views.custorders')) + config.include(mod('tailbone.views.departments')) + config.include(mod('tailbone.views.employees')) + config.include(mod('tailbone.views.families')) + config.include(mod('tailbone.views.members')) + config.include(mod('tailbone.views.products')) + config.include(mod('tailbone.views.purchases')) + config.include(mod('tailbone.views.reportcodes')) + config.include(mod('tailbone.views.stores')) + config.include(mod('tailbone.views.subdepartments')) + config.include(mod('tailbone.views.uoms')) + config.include(mod('tailbone.views.vendors')) + + # batches + config.include(mod('tailbone.views.batch.handheld')) + config.include(mod('tailbone.views.batch.importer')) + config.include(mod('tailbone.views.batch.inventory')) + config.include(mod('tailbone.views.purchasing')) + + +def includeme(config): + defaults(config) From 488126b92c42e2c59b9bed755caf5a7e6b59c4ad Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 5 Jun 2023 20:18:57 -0500 Subject: [PATCH 055/636] Add customer number filter for People grid --- tailbone/views/people.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index c0d0c86f..0a471f46 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -112,6 +112,7 @@ class PersonView(MasterView): def configure_grid(self, g): super(PersonView, self).configure_grid(g) + model = self.model g.joiners['email'] = lambda q: q.outerjoin(model.PersonEmailAddress, sa.and_( model.PersonEmailAddress.parent_uuid == model.Person.uuid, @@ -124,8 +125,17 @@ class PersonView(MasterView): g.set_filter('phone', model.PersonPhoneNumber.number, factory=grids.filters.AlchemyPhoneNumberFilter) - g.joiners['customer_id'] = lambda q: q.outerjoin(model.CustomerPerson).outerjoin(model.Customer) - g.filters['customer_id'] = g.make_filter('customer_id', model.Customer.id) + Customer_ID = orm.aliased(model.Customer) + CustomerPerson_ID = orm.aliased(model.CustomerPerson) + + Customer_Number = orm.aliased(model.Customer) + CustomerPerson_Number = orm.aliased(model.CustomerPerson) + + g.joiners['customer_id'] = lambda q: q.outerjoin(CustomerPerson_ID).outerjoin(Customer_ID) + g.filters['customer_id'] = g.make_filter('customer_id', Customer_ID.id) + + g.joiners['customer_number'] = lambda q: q.outerjoin(CustomerPerson_Number).outerjoin(Customer_Number) + g.filters['customer_number'] = g.make_filter('customer_number', Customer_Number.number) g.filters['first_name'].default_active = True g.filters['first_name'].default_verb = 'contains' From 6f02e1b18e0ce880ae88537e168f379002095d66 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 6 Jun 2023 09:39:02 -0500 Subject: [PATCH 056/636] Tweak logic for `MasterView.get_action_route_kwargs()` hopefully this improves default handling when model keys are composite, and if we can confirm the "secondary" (previous) logic no longer happens, then can remove that altogether..? --- docs/api/views/master.rst | 15 +++++ tailbone/views/master.py | 113 +++++++++++++++++++++++++++++++------- 2 files changed, 109 insertions(+), 19 deletions(-) diff --git a/docs/api/views/master.rst b/docs/api/views/master.rst index bf505b6c..44278e0a 100644 --- a/docs/api/views/master.rst +++ b/docs/api/views/master.rst @@ -88,6 +88,8 @@ Methods to Override The following is a list of methods which you can override when defining your subclass. + .. automethod:: MasterView.editable_instance + .. .. automethod:: MasterView.get_settings .. automethod:: MasterView.get_csv_fields @@ -95,3 +97,16 @@ subclass. .. automethod:: MasterView.get_csv_row .. automethod:: MasterView.get_help_url + + .. automethod:: MasterView.get_model_key + + +Support Methods +--------------- + +The following is a list of methods you should (probably) not need to +override, but may find useful: + + .. automethod:: MasterView.default_edit_url + + .. automethod:: MasterView.get_action_route_kwargs diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 25543cb2..394424a2 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2126,10 +2126,30 @@ class MasterView(View): @classmethod def get_model_key(cls, as_tuple=False): """ - Returns the primary key(s) for the model class. Note that this will - return a *string* value unless a tuple is requested. If the model has - a composite key then the string result would be a comma-delimited list - of names, e.g. ``foo_id,bar_id``. + Returns the primary model key(s) for the master view. + + Internally, model keys are a sequence of one or more keys. + Most typically it's just one, so e.g. ``('uuid',)``, but + composite keys are possible too, e.g. ``('parent_id', + 'child_id')``. + + Despite that, this method will return a *string* + representation of the keys, unless ``as_tuple=True`` in which + case it returns a tuple. For example:: + + # for model keys: ('uuid',) + + cls.get_model_key() # => 'uuid' + cls.get_model_key(as_tuple=True) # => ('uuid',) + + # for model keys: ('parent_id', 'child_id') + + cls.get_model_key() # => 'parent_id,child_id' + cls.get_model_key(as_tuple=True) # => ('parent_id', 'child_id') + + :param as_tuple: Whether to return a tuple instead of string. + + :returns: Either a string or tuple of model keys. """ if hasattr(cls, 'model_key'): keys = cls.model_key @@ -2850,10 +2870,23 @@ class MasterView(View): kwargs['click_handler'] = 'deleteObject' return self.make_action('delete', icon='trash', url=self.default_delete_url, **kwargs) - def default_edit_url(self, row, i=None): - if self.editable_instance(row): + def default_edit_url(self, obj, i=None): + """ + Return the default "edit" URL for the given object, if + applicable. This first checks :meth:`editable_instance()` for + the object, and will only return a URL if the object is deemed + editable. + + :param obj: A top-level record/object, of the type normally + handled by this master view. + + :param i: Optional row index within a grid. + + :returns: The "edit object" URL as string, or ``None``. + """ + if self.editable_instance(obj): return self.request.route_url('{}.edit'.format(self.get_route_prefix()), - **self.get_action_route_kwargs(row)) + **self.get_action_route_kwargs(obj)) def default_clone_url(self, row, i=None): return self.request.route_url('{}.clone'.format(self.get_route_prefix()), @@ -2875,24 +2908,61 @@ class MasterView(View): factory = grids.GridAction return factory(key, url=url, **kwargs) - def get_action_route_kwargs(self, row): + def get_action_route_kwargs(self, obj): """ - Hopefully generic kwarg generator for basic action routes. + Get a dict of route kwargs for the given object. + + This is called from various other "convenience" URL + generators, e.g. :meth:`default_edit_url()`. + + It inspects the given object, as well as the "model key" (as + returned by :meth:`get_model_key()`), and returns a dict of + appropriate route kwargs for the object. + + Most typically, the model key is just ``uuid`` and so this + would effectively return ``{'uuid': obj.uuid}``. + + But composite model keys are supported too, so if the model + key is ``(parent_id, child_id)`` this might instead return + ``{'parent_id': obj.parent_id, 'child_id': obj.child_id}``. + + Such kwargs would then be fed into ``route_url()`` as needed, + for example to get a "view product URL":: + + kw = self.get_action_route_kwargs(product) + url = self.request.route_url('products.view', **kw) + + :param obj: A top-level record/object, of the type normally + handled by this master view. + + :returns: A dict of route kwargs for the object. """ + keys = self.get_model_key(as_tuple=True) + if keys: + try: + return dict([(key, obj[key]) + for key in keys]) + except TypeError: + return dict([(key, getattr(obj, key)) + for key in keys]) + + # TODO: sanity check, is the above all we need..? + log.warning("yes we still do the code below sometimes") + try: - mapper = orm.object_mapper(row) + mapper = orm.object_mapper(obj) except orm.exc.UnmappedInstanceError: try: if isinstance(self.model_key, str): - return {self.model_key: row[self.model_key]} - return dict([(key, row[key]) + return {self.model_key: obj[self.model_key]} + return dict([(key, obj[key]) for key in self.model_key]) except TypeError: - return {self.model_key: getattr(row, self.model_key)} + return {self.model_key: getattr(obj, self.model_key)} else: - pkeys = get_primary_keys(row) + pkeys = get_primary_keys(obj) keys = list(pkeys) - values = [getattr(row, k) for k in keys] + values = [getattr(obj, k) for k in keys] return dict(zip(keys, values)) def get_data(self, session=None): @@ -4160,11 +4230,16 @@ class MasterView(View): Event hook, called just after a new instance is saved. """ - def editable_instance(self, instance): + def editable_instance(self, obj): """ - Returns boolean indicating whether or not the given instance can be - considered "editable". Returns ``True`` by default; override as - necessary. + Returns boolean indicating whether or not the given object + should be considered "editable". Returns ``True`` by default; + override as necessary. + + :param obj: A top-level record/object, of the type normally + handled by this master view. + + :returns: ``True`` if object is editable, else ``False``. """ return True From 9b59b44609623a2d9aa81f51533d12a62676d5c5 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 6 Jun 2023 09:40:14 -0500 Subject: [PATCH 057/636] Add "touch" support for Members --- tailbone/views/members.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/tailbone/views/members.py b/tailbone/views/members.py index a0157649..28265061 100644 --- a/tailbone/views/members.py +++ b/tailbone/views/members.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,9 +24,6 @@ Member Views """ -from __future__ import unicode_literals, absolute_import - -import six import sqlalchemy as sa from rattail.db import model @@ -43,6 +40,7 @@ class MemberView(MasterView): """ model_class = model.Member is_contact = True + touchable = True has_versions = True labels = { @@ -134,7 +132,7 @@ class MemberView(MasterView): f.replace('person', 'person_uuid') people = self.Session.query(model.Person)\ .order_by(model.Person.display_name) - values = [(p.uuid, six.text_type(p)) + values = [(p.uuid, str(p)) for p in people] require = False if not require: @@ -151,7 +149,7 @@ class MemberView(MasterView): f.replace('customer', 'customer_uuid') customers = self.Session.query(model.Customer)\ .order_by(model.Customer.name) - values = [(c.uuid, six.text_type(c)) + values = [(c.uuid, str(c)) for c in customers] require = False if not require: From 0d97ff29369fe01b9bc81e3223566f88b274937c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 6 Jun 2023 11:06:16 -0500 Subject: [PATCH 058/636] Add support for "configured customer/member key" also improve product key support, same patterns --- tailbone/templates/customers/configure.mako | 44 +++++++++ tailbone/templates/members/configure.mako | 57 ++++++++++++ .../templates/people/view_profile_buefy.mako | 22 ++--- tailbone/views/customers.py | 20 +++-- tailbone/views/master.py | 90 ++++++++++++++++--- tailbone/views/members.py | 26 ++++-- tailbone/views/people.py | 4 + 7 files changed, 224 insertions(+), 39 deletions(-) create mode 100644 tailbone/templates/members/configure.mako diff --git a/tailbone/templates/customers/configure.mako b/tailbone/templates/customers/configure.mako index f465fdf5..708d0b17 100644 --- a/tailbone/templates/customers/configure.mako +++ b/tailbone/templates/customers/configure.mako @@ -6,6 +6,26 @@

    General

    + + + + + + + + + + + + + + + + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + + + ${parent.body()} diff --git a/tailbone/templates/members/configure.mako b/tailbone/templates/members/configure.mako new file mode 100644 index 00000000..07d67970 --- /dev/null +++ b/tailbone/templates/members/configure.mako @@ -0,0 +1,57 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="form_content()"> + +

    General

    +
    + + + + + + + + + + + + + + + + + +
    + + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + + + + +${parent.body()} diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako index 6937f592..075735cc 100644 --- a/tailbone/templates/people/view_profile_buefy.mako +++ b/tailbone/templates/people/view_profile_buefy.mako @@ -540,19 +540,15 @@ - #{{ member.number }} {{ member.display }} + {{ member._key }} - {{ member.display }}
    - - {{ member.number }} - - - - {{ member.id }} + + {{ member._key }} @@ -630,19 +626,15 @@ - #{{ customer.number }} {{ customer.name }} + {{ customer._key }} - {{ customer.name }}
    - - {{ customer.number }} - - - - {{ customer.id }} + + {{ customer._key }} @@ -1011,8 +1003,8 @@ <%def name="render_profile_tabs()"> ${self.render_personal_tab()} - ${self.render_customer_tab()} ${self.render_member_tab()} + ${self.render_customer_tab()} ${self.render_employee_tab()} ${self.render_user_tab()} diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index 50b93d59..02071ab4 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -63,16 +63,14 @@ class CustomerView(MasterView): } grid_columns = [ - 'id', - 'number', + '_customer_key_', 'name', 'phone', 'email', ] form_fields = [ - 'id', - 'number', + '_customer_key_', 'name', 'default_phone', 'default_address', @@ -114,13 +112,16 @@ class CustomerView(MasterView): super(CustomerView, self).configure_grid(g) model = self.model - # number - g.set_link('number') + # customer key + field = self.get_customer_key_field() + g.filters[field].default_active = True + g.filters[field].default_verb = 'equal' + g.set_sort_defaults(field) + g.set_link(field) # name g.filters['name'].default_active = True g.filters['name'].default_verb = 'contains' - g.set_sort_defaults('name') # phone g.set_joiner('phone', lambda q: q.outerjoin(model.CustomerPhoneNumber, sa.and_( @@ -158,7 +159,6 @@ class CustomerView(MasterView): g.filters['active_in_pos'].default_active = True g.filters['active_in_pos'].default_verb = 'is_true' - g.set_link('id') g.set_link('name') g.set_link('person') g.set_link('email') @@ -485,6 +485,10 @@ class CustomerView(MasterView): return [ # General + {'section': 'rattail', + 'option': 'customers.key_field'}, + {'section': 'rattail', + 'option': 'customers.key_label'}, {'section': 'rattail', 'option': 'customers.choice_uses_dropdown', 'type': bool}, diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 394424a2..0993ac7d 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -163,6 +163,8 @@ class MasterView(View): labels = {'uuid': "UUID"} + customer_key_fields = {} + member_key_fields = {} product_key_fields = {} # ROW-RELATED ATTRS FOLLOW: @@ -463,6 +465,8 @@ class MasterView(View): grid.remove('local_only') grid.remove_filter('local_only') + self.configure_column_customer_key(grid) + self.configure_column_member_key(grid) self.configure_column_product_key(grid) for supp in self.iter_view_supplements(): @@ -561,6 +565,8 @@ class MasterView(View): # super(MasterView, self).configure_row_grid(grid) self.set_row_labels(grid) + self.configure_column_customer_key(grid) + self.configure_column_member_key(grid) self.configure_column_product_key(grid) def row_grid_extra_class(self, obj, i): @@ -2407,8 +2413,14 @@ class MasterView(View): 'quickie': None, } - key = self.rattail_config.product_key() - context['product_key_field'] = self.product_key_fields.get(key, key) + context['customer_key_field'] = self.get_customer_key_field() + context['customer_key_label'] = self.get_customer_key_label() + + context['member_key_field'] = self.get_member_key_field() + context['member_key_label'] = self.get_member_key_label() + + context['product_key_field'] = self.get_product_key_field() + context['product_key_label'] = self.get_product_key_label() if self.expose_quickie_search: context['quickie'] = self.get_quickie_context() @@ -4131,6 +4143,8 @@ class MasterView(View): """ self.configure_common_form(form) + self.configure_field_customer_key(form) + self.configure_field_member_key(form) self.configure_field_product_key(form) for supp in self.iter_view_supplements(): @@ -4596,6 +4610,8 @@ class MasterView(View): self.set_row_labels(form) + self.configure_field_customer_key(form) + self.configure_field_member_key(form) self.configure_field_product_key(form) def validate_row_form(self, form): @@ -4604,23 +4620,77 @@ class MasterView(View): return True return False + def get_customer_key_field(self): + app = self.get_rattail_app() + key = app.get_customer_key_field() + return self.customer_key_fields.get(key, key) + + def get_customer_key_label(self): + app = self.get_rattail_app() + field = self.get_customer_key_field() + return app.get_customer_key_label(field=field) + + def configure_column_customer_key(self, g): + if '_customer_key_' in g.columns: + field = self.get_customer_key_field() + g.replace('_customer_key_', field) + g.set_label(field, self.get_customer_key_label()) + g.set_link(field) + + def configure_field_customer_key(self, f): + if '_customer_key_' in f: + field = self.get_customer_key_field() + f.replace('_customer_key_', field) + f.set_label(field, self.get_customer_key_label()) + + def get_member_key_field(self): + app = self.get_rattail_app() + key = app.get_member_key_field() + return self.member_key_fields.get(key, key) + + def get_member_key_label(self): + app = self.get_rattail_app() + field = self.get_member_key_field() + return app.get_member_key_label(field=field) + + def configure_column_member_key(self, g): + if '_member_key_' in g.columns: + field = self.get_member_key_field() + g.replace('_member_key_', field) + g.set_label(field, self.get_member_key_label()) + g.set_link(field) + + def configure_field_member_key(self, f): + if '_member_key_' in f: + field = self.get_member_key_field() + f.replace('_member_key_', field) + f.set_label(field, self.get_member_key_label()) + + def get_product_key_field(self): + app = self.get_rattail_app() + key = app.get_product_key_field() + return self.product_key_fields.get(key, key) + + def get_product_key_label(self): + app = self.get_rattail_app() + field = self.get_product_key_field() + return app.get_product_key_label(field=field) + def configure_column_product_key(self, g): if '_product_key_' in g.columns: - key = self.rattail_config.product_key() - field = self.product_key_fields.get(key, key) + field = self.get_product_key_field() g.replace('_product_key_', field) - g.set_label(field, self.rattail_config.product_key_title(key)) + g.set_label(field, self.get_product_key_label()) g.set_link(field) - if key == 'upc': + if field == 'upc': g.set_renderer(field, self.render_upc) def configure_field_product_key(self, f): if '_product_key_' in f: - key = self.rattail_config.product_key() - field = self.product_key_fields.get(key, key) + field = self.get_product_key_field() f.replace('_product_key_', field) - f.set_label(field, self.rattail_config.product_key_title(key)) - if key == 'upc': + f.set_label(field, self.get_product_key_label()) + if field == 'upc': f.set_renderer(field, self.render_upc) def get_row_action_url(self, action, row, **kwargs): diff --git a/tailbone/views/members.py b/tailbone/views/members.py index 28265061..955a217f 100644 --- a/tailbone/views/members.py +++ b/tailbone/views/members.py @@ -42,14 +42,14 @@ class MemberView(MasterView): is_contact = True touchable = True has_versions = True + configurable = True labels = { 'id': "ID", } grid_columns = [ - 'number', - 'id', + '_member_key_', 'person', 'customer', 'email', @@ -61,8 +61,7 @@ class MemberView(MasterView): ] form_fields = [ - 'number', - 'id', + '_member_key_', 'person', 'customer', 'default_email', @@ -77,6 +76,13 @@ class MemberView(MasterView): def configure_grid(self, g): super(MemberView, self).configure_grid(g) + # member key + field = self.get_member_key_field() + g.filters[field].default_active = True + g.filters[field].default_verb = 'equal' + g.set_sort_defaults(field) + g.set_link(field) + g.set_joiner('person', lambda q: q.outerjoin(model.Person)) g.set_sorter('person', model.Person.display_name) g.set_filter('person', model.Person.display_name) @@ -105,8 +111,6 @@ class MemberView(MasterView): g.set_filter('email', model.MemberEmailAddress.address) g.set_label('email', "Email Address") - g.set_sort_defaults('number') - g.set_link('person') g.set_link('customer') @@ -186,6 +190,16 @@ class MemberView(MasterView): if member.phones: return member.phones[0].number + def configure_get_simple_settings(self): + return [ + + # General + {'section': 'rattail', + 'option': 'members.key_field'}, + {'section': 'rattail', + 'option': 'members.key_label'}, + ] + def defaults(config, **kwargs): base = globals() diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 0a471f46..dc75b8aa 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -545,12 +545,14 @@ class PersonView(MasterView): return context def get_context_customers(self, person): + key = self.get_customer_key_field() data = [] for cp in person._customers: customer = cp.customer data.append({ 'uuid': customer.uuid, 'ordinal': cp.ordinal, + '_key': getattr(customer, key), 'id': customer.id, 'number': customer.number, 'name': customer.name, @@ -582,8 +584,10 @@ class PersonView(MasterView): profile_url = self.request.route_url('people.view_profile', uuid=member.person_uuid) + key = self.get_member_key_field() return { 'uuid': member.uuid, + '_key': getattr(member, key), 'number': member.number, 'id': member.id, 'active': member.active, From c38dc8b84295f9d48047e0113f30749b66be9afc Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 6 Jun 2023 11:54:58 -0500 Subject: [PATCH 059/636] Use *actual* current URL for user feedback msg was using current URL as of page load, but #hash can change after that, e.g. on profile view --- tailbone/static/js/tailbone.feedback.js | 1 + tailbone/templates/base.mako | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tailbone/static/js/tailbone.feedback.js b/tailbone/static/js/tailbone.feedback.js index 6f687b80..648c9695 100644 --- a/tailbone/static/js/tailbone.feedback.js +++ b/tailbone/static/js/tailbone.feedback.js @@ -12,6 +12,7 @@ let FeedbackForm = { }, showFeedback() { + this.referrer = location.href this.showDialog = true this.$nextTick(function() { this.$refs.textarea.focus() diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index 91589990..723e106c 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -485,6 +485,7 @@ ${page_help.render_template()} + % if request.has_perm('common.feedback'): + % endif ${tailbone_autocomplete_template()} ${multi_file_upload.render_template()} @@ -882,8 +884,6 @@ <%def name="modify_whole_page_vars()"> - - - From 816e6523571ae6c54244b35ac0bda97c6a68c516 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 6 Jun 2023 13:13:19 -0500 Subject: [PATCH 061/636] Add basic support for membership types --- tailbone/menus.py | 19 +++-- .../templates/people/view_profile_buefy.mako | 10 +++ tailbone/views/members.py | 83 ++++++++++++++++++- tailbone/views/people.py | 14 +++- 4 files changed, 116 insertions(+), 10 deletions(-) diff --git a/tailbone/menus.py b/tailbone/menus.py index 9a0ba066..8aebf043 100644 --- a/tailbone/menus.py +++ b/tailbone/menus.py @@ -332,6 +332,12 @@ class MenuHandler(GenericHandler): 'route': 'members', 'perm': 'members.list', }, + { + 'title': "Membership Types", + 'route': 'membership_types', + 'perm': 'membership_types.list', + }, + {'type': 'sep'}, { 'title': "Customers", 'route': 'customers', @@ -342,22 +348,23 @@ class MenuHandler(GenericHandler): 'route': 'customergroups', 'perm': 'customergroups.list', }, + { + 'title': "Pending Customers", + 'route': 'pending_customers', + 'perm': 'pending_customers.list', + }, + {'type': 'sep'}, { 'title': "Employees", 'route': 'employees', 'perm': 'employees.list', }, + {'type': 'sep'}, { 'title': "All People", 'route': 'people', 'perm': 'people.list', }, - {'type': 'sep'}, - { - 'title': "Pending Customers", - 'route': 'pending_customers', - 'perm': 'pending_customers.list', - }, ], } diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako index 075735cc..f21c021e 100644 --- a/tailbone/templates/people/view_profile_buefy.mako +++ b/tailbone/templates/people/view_profile_buefy.mako @@ -551,6 +551,16 @@ {{ member._key }} + + + {{ member.membership_type_name }} + + + {{ member.membership_type_name }} + + + {{ member.active }} diff --git a/tailbone/views/members.py b/tailbone/views/members.py index 955a217f..9f96e667 100644 --- a/tailbone/views/members.py +++ b/tailbone/views/members.py @@ -27,13 +27,70 @@ Member Views import sqlalchemy as sa from rattail.db import model +from rattail.db.model import MembershipType, Member from deform import widget as dfwidget +from webhelpers2.html import tags from tailbone import grids from tailbone.views import MasterView +class MembershipTypeView(MasterView): + """ + Master view for Membership Types + """ + model_class = MembershipType + route_prefix = 'membership_types' + url_prefix = '/membership-types' + has_versions = True + + labels = { + 'id': "ID", + } + + grid_columns = [ + 'number', + 'name', + ] + + has_rows = True + model_row_class = Member + rows_title = "Members" + + row_grid_columns = [ + '_member_key_', + 'person', + 'active', + 'equity_current', + 'equity_total', + 'joined', + 'withdrew', + ] + + def configure_grid(self, g): + super().configure_grid(g) + + g.set_sort_defaults('number') + + g.set_link('number') + g.set_link('name') + + def get_row_data(self, memtype): + model = self.model + return self.Session.query(model.Member)\ + .filter(model.Member.membership_type == memtype) + + def get_parent(self, member): + return member.membership_type + + def configure_row_grid(self, g): + super().configure_row_grid(g) + + g.filters['active'].default_active = True + g.filters['active'].default_verb = 'is_true' + + class MemberView(MasterView): """ Master view for the Member class. @@ -51,9 +108,7 @@ class MemberView(MasterView): grid_columns = [ '_member_key_', 'person', - 'customer', - 'email', - 'phone', + 'membership_type', 'active', 'equity_current', 'joined', @@ -66,6 +121,7 @@ class MemberView(MasterView): 'customer', 'default_email', 'default_phone', + 'membership_type', 'active', 'equity_current', 'equity_payment_due', @@ -75,6 +131,7 @@ class MemberView(MasterView): def configure_grid(self, g): super(MemberView, self).configure_grid(g) + model = self.model # member key field = self.get_member_key_field() @@ -111,6 +168,12 @@ class MemberView(MasterView): g.set_filter('email', model.MemberEmailAddress.address) g.set_label('email', "Email Address") + # membership_type + g.set_joiner('membership_type', lambda q: q.outerjoin(model.MembershipType)) + g.set_sorter('membership_type', model.MembershipType.name) + g.set_filter('membership_type', model.MembershipType.name, + label="Membership Type Name") + g.set_link('person') g.set_link('customer') @@ -174,6 +237,9 @@ class MemberView(MasterView): if not self.creating and member.phones: f.set_default('default_phone', member.phones[0].number) + # membership_type + f.set_renderer('membership_type', self.render_membership_type) + if self.creating: f.remove_fields( 'equity_total', @@ -190,6 +256,14 @@ class MemberView(MasterView): if member.phones: return member.phones[0].number + def render_membership_type(self, member, field): + memtype = getattr(member, field) + if not memtype: + return + text = str(memtype) + url = self.request.route_url('membership_types.view', uuid=memtype.uuid) + return tags.link_to(text, url) + def configure_get_simple_settings(self): return [ @@ -204,6 +278,9 @@ class MemberView(MasterView): def defaults(config, **kwargs): base = globals() + MembershipTypeView = kwargs.get('MembershipTypeView', base['MembershipTypeView']) + MembershipTypeView.defaults(config) + MemberView = kwargs.get('MemberView', base['MemberView']) MemberView.defaults(config) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index dc75b8aa..89b857f1 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -585,7 +585,7 @@ class PersonView(MasterView): uuid=member.person_uuid) key = self.get_member_key_field() - return { + data = { 'uuid': member.uuid, '_key': getattr(member, key), 'number': member.number, @@ -602,6 +602,18 @@ class PersonView(MasterView): 'view_profile_url': profile_url, } + membership_type = member.membership_type + if membership_type: + data.update({ + 'membership_type_uuid': membership_type.uuid, + 'membership_type_number': membership_type.number, + 'membership_type_name': membership_type.name, + 'view_membership_type_url': self.request.route_url( + 'membership_types.view', uuid=membership_type.uuid), + }) + + return data + def get_context_employee(self, employee): """ Return a dict of context data for the given employee. From cfdb4923494e7e4d03d6db7e4dfe73220371d6e7 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 6 Jun 2023 16:37:58 -0500 Subject: [PATCH 062/636] Add support for version history in person profile view yay, finally --- .../templates/people/view_profile_buefy.mako | 261 +++++++++++++++++- tailbone/views/people.py | 199 +++++++++++++ 2 files changed, 448 insertions(+), 12 deletions(-) diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako index f21c021e..c1799c16 100644 --- a/tailbone/templates/people/view_profile_buefy.mako +++ b/tailbone/templates/people/view_profile_buefy.mako @@ -18,11 +18,59 @@ ${dynamic_content_title} +<%def name="render_instance_header_buttons()"> + % if request.has_perm('people_profile.view_versions'): + + View History + +
    + + {{ gettingRevisions ? "Working, please wait..." : "Refresh History" }} + + + View Profile + +
    + % endif + + <%def name="page_content()"> - + +<%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()"> ${self.page_content()} @@ -551,6 +599,16 @@ {{ member._key }}
    + + + {{ member.person_display_name }} + + + {{ member.person_display_name }} + + + @@ -562,7 +620,7 @@ - {{ member.active }} + {{ member.active ? "Yes" : "No" }} @@ -574,16 +632,6 @@ {{ member.withdrew }} - - - {{ member.person_display_name }} - - - {{ member.person_display_name }} - - -
    ${self.render_member_panel_buttons(member)} @@ -1019,14 +1067,112 @@ ${self.render_user_tab()} +<%def name="render_profile_info_extra_buttons()"> + <%def name="render_profile_info_template()"> @@ -1611,11 +1757,28 @@ phoneTypeOptions: ${json.dumps(phone_type_options)|n}, emailTypeOptions: ${json.dumps(email_type_options)|n}, maxLengths: ${json.dumps(max_lengths)|n}, + + % if request.has_perm('people_profile.view_versions'): + loadingRevisions: false, + showingRevisionDialog: false, + revision: {}, + revisionShowAllFields: false, + % endif } let ProfileInfo = { template: '#profile-info-template', mixins: [FormPosterMixin], + + % if request.has_perm('people_profile.view_versions'): + props: { + viewingHistory: Boolean, + gettingRevisions: Boolean, + revisions: Array, + revisionVersionMap: null, + }, + % endif + computed: {}, methods: { @@ -1641,6 +1804,29 @@ }, activeTabChangedExtra(value) {}, + + % if request.has_perm('people_profile.view_versions'): + + viewRevision(row) { + this.revision = this.revisionVersionMap[row.txnid] + this.showingRevisionDialog = true + }, + + viewPrevRevision() { + let txnid = this.revision.prev_txnid + this.revision = this.revisionVersionMap[txnid] + }, + + viewNextRevision() { + let txnid = this.revision.next_txnid + this.revision = this.revisionVersionMap[txnid] + }, + + toggleVersionFields() { + this.revisionShowAllFields = !this.revisionShowAllFields + }, + + % endif }, } @@ -1662,6 +1848,13 @@ ${parent.modify_this_page_vars()} + % endif + + ${parent.body()} diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 89b857f1..ce15e48a 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -30,6 +30,7 @@ from collections import OrderedDict import sqlalchemy as sa from sqlalchemy import orm +import sqlalchemy_continuum as continuum from rattail.db import model, api from rattail.db.util import maxlen @@ -42,6 +43,7 @@ from webhelpers2.html import HTML, tags from tailbone import forms, grids from tailbone.views import MasterView +from tailbone.util import raw_datetime log = logging.getLogger(__name__) @@ -429,6 +431,9 @@ class PersonView(MasterView): 'dynamic_content_title': self.get_context_content_title(person), } + if self.request.has_perm('people_profile.view_versions'): + context['revisions_grid'] = self.profile_revisions_grid(person) + template = 'view_profile_buefy' return self.render_to_response(template, context) @@ -1015,6 +1020,188 @@ class PersonView(MasterView): 'employee': self.get_context_employee(employee), } + def profile_revisions_grid(self, person): + route_prefix = self.get_route_prefix() + factory = self.get_grid_factory() + g = factory( + '{}.profile.revisions'.format(route_prefix), + [], # start with empty data! + request=self.request, + columns=[ + 'changed', + 'changed_by', + 'remote_addr', + 'comment', + ], + labels={ + 'remote_addr': "IP Address", + }, + linked_columns=[ + 'changed', + 'changed_by', + 'comment', + ], + main_actions=[ + self.make_action('view', icon='eye', url='#', + click_handler='viewRevision(props.row)'), + ], + ) + return g + + def profile_revisions_collect(self, person, versions=None): + model = self.model + versions = versions or [] + + # Person + cls = continuum.version_class(model.Person) + query = self.Session.query(cls)\ + .filter(cls.uuid == person.uuid) + versions.extend(query.all()) + + # User + cls = continuum.version_class(model.User) + query = self.Session.query(cls)\ + .filter(cls.person_uuid == person.uuid) + versions.extend(query.all()) + + # Member + cls = continuum.version_class(model.Member) + query = self.Session.query(cls)\ + .filter(cls.person_uuid == person.uuid) + versions.extend(query.all()) + + # Employee + cls = continuum.version_class(model.Employee) + query = self.Session.query(cls)\ + .filter(cls.person_uuid == person.uuid) + versions.extend(query.all()) + + # EmployeeHistory + cls = continuum.version_class(model.EmployeeHistory) + query = self.Session.query(cls)\ + .join(model.Employee, + model.Employee.uuid == cls.employee_uuid)\ + .filter(model.Employee.person_uuid == person.uuid) + versions.extend(query.all()) + + # PersonPhoneNumber + cls = continuum.version_class(model.PersonPhoneNumber) + query = self.Session.query(cls)\ + .filter(cls.parent_uuid == person.uuid) + versions.extend(query.all()) + + # PersonEmailAddress + cls = continuum.version_class(model.PersonEmailAddress) + query = self.Session.query(cls)\ + .filter(cls.parent_uuid == person.uuid) + versions.extend(query.all()) + + # PersonMailingAddress + cls = continuum.version_class(model.PersonMailingAddress) + query = self.Session.query(cls)\ + .filter(cls.parent_uuid == person.uuid) + versions.extend(query.all()) + + # CustomerPerson + cls = continuum.version_class(model.CustomerPerson) + query = self.Session.query(cls)\ + .filter(cls.person_uuid == person.uuid) + versions.extend(query.all()) + + # Customer + cls = continuum.version_class(model.Customer) + query = self.Session.query(cls)\ + .join(model.CustomerPerson, model.CustomerPerson.customer_uuid == cls.uuid)\ + .filter(model.CustomerPerson.person_uuid == person.uuid) + versions.extend(query.all()) + + # PersonNote + cls = continuum.version_class(model.PersonNote) + query = self.Session.query(cls)\ + .filter(cls.parent_uuid == person.uuid) + versions.extend(query.all()) + + return versions + + def profile_revisions_data(self): + """ + View which locates and organizes all relevant "transaction" + (version) history data for a given Person. Returns JSON, for + use with the Buefy table element on the full profile view. + """ + person = self.get_instance() + versions = self.profile_revisions_collect(person) + + # organize final table data + data = [] + all_txns = set([v.transaction for v in versions]) + for i, txn in enumerate( + sorted(all_txns, key=lambda txn: txn.issued_at, reverse=True), + 1): + data.append({ + 'txnid': txn.id, + 'changed': raw_datetime(self.rattail_config, txn.issued_at), + 'changed_by': str(txn.user or '') or None, + 'remote_addr': txn.remote_addr, + 'comment': txn.meta.get('comment'), + }) + # also stash the sequential index for this transaction, for use later + txn._sequential_index = i + + # also organize final transaction/versions (diff) map + vmap = {} + for version in versions: + + if version.previous and version.operation_type == continuum.Operation.DELETE: + diff_class = 'deleted' + elif version.previous: + diff_class = 'dirty' + else: + diff_class = 'new' + + # collect before/after field values for version + fields = self.fields_for_version(version) + values = {} + for field in fields: + before = '' + after = '' + if diff_class != 'new': + before = repr(getattr(version.previous, field)) + if diff_class != 'deleted': + after = repr(getattr(version, field)) + values[field] = {'before': before, 'after': after} + + if version.transaction_id not in vmap: + txn = version.transaction + prev_txnid = None + next_txnid = None + if txn._sequential_index < len(data): + prev_txnid = data[txn._sequential_index]['txnid'] + if txn._sequential_index > 1: + next_txnid = data[txn._sequential_index - 2]['txnid'] + vmap[txn.id] = { + 'index': txn._sequential_index, + 'txnid': txn.id, + 'prev_txnid': prev_txnid, + 'next_txnid': next_txnid, + 'changed': raw_datetime(self.rattail_config, txn.issued_at, + verbose=True), + 'changed_by': str(txn.user or '') or None, + 'remote_addr': txn.remote_addr, + 'comment': txn.meta.get('comment'), + 'versions': [], + } + + vmap[version.transaction_id]['versions'].append({ + 'key': id(version), + 'model_title': self.title_for_version(version), + 'diff_class': diff_class, + 'fields': fields, + 'values': values, + }) + + return {'data': data, 'vmap': vmap} + def make_note_form(self, mode, person): schema = NoteSchema().bind(session=self.Session(), person_uuid=person.uuid) @@ -1269,6 +1456,18 @@ class PersonView(MasterView): renderer='json', permission='employees.edit') + # profile - revisions data + config.add_tailbone_permission('people_profile', + 'people_profile.view_versions', + "View full version history for a profile") + config.add_route(f'{route_prefix}.view_profile_revisions', + f'{instance_url_prefix}/profile/revisions', + request_method='GET') + config.add_view(cls, attr='profile_revisions_data', + route_name=f'{route_prefix}.view_profile_revisions', + permission='people_profile.view_versions', + renderer='json') + # manage notes from profile view if cls.manage_notes_from_profile_view: From afd5c3a5fd3481a6b1452aa129da4adf4cf7aac2 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 6 Jun 2023 19:29:47 -0500 Subject: [PATCH 063/636] Update changelog --- CHANGES.rst | 22 ++++++++++++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 53422091..11705544 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,28 @@ CHANGELOG ========= +0.9.29 (2023-06-06) +------------------- + +* Add "typical" view config, for e.g. Theo and the like. + +* Add customer number filter for People grid. + +* Tweak logic for ``MasterView.get_action_route_kwargs()``. + +* Add "touch" support for Members. + +* Add support for "configured customer/member key". + +* Use *actual* current URL for user feedback msg. + +* Remove old/unused feedback templates. + +* Add basic support for membership types. + +* Add support for version history in person profile view. + + 0.9.28 (2023-06-02) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 4ad67fa6..d32eee0d 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.28' +__version__ = '0.9.29' From 3fde80f9918675476f1627f15b288e8bc33ca020 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 7 Jun 2023 16:27:10 -0500 Subject: [PATCH 064/636] Add basic support for exposing `Customer.shoppers` now there is a Shoppers field when viewing a Customer, unless configured otherwise also tweaked some logic for navigating Customer/Person relationships, to handle implications of Shoppers being (maybe) present --- tailbone/templates/customers/configure.mako | 22 ++- tailbone/templates/customers/view.mako | 9 +- tailbone/views/customers.py | 159 ++++++++++++++++---- tailbone/views/master.py | 2 +- tailbone/views/people.py | 55 +++++-- 5 files changed, 201 insertions(+), 46 deletions(-) diff --git a/tailbone/templates/customers/configure.mako b/tailbone/templates/customers/configure.mako index 708d0b17..9013bd5b 100644 --- a/tailbone/templates/customers/configure.mako +++ b/tailbone/templates/customers/configure.mako @@ -26,12 +26,30 @@ - + + + Show the Shoppers field + + + + + + Show the People field + + + + - Show customer chooser as dropdown (select) element + Use dropdown (select element) for Customer chooser diff --git a/tailbone/templates/customers/view.mako b/tailbone/templates/customers/view.mako index e35cc635..85ec0055 100644 --- a/tailbone/templates/customers/view.mako +++ b/tailbone/templates/customers/view.mako @@ -4,8 +4,8 @@ <%def name="object_helpers()"> ${parent.object_helpers()} - % if show_profiles_helper and instance.people: - ${view_profiles_helper(instance.people)} + % if show_profiles_helper and show_profiles_people: + ${view_profiles_helper(show_profiles_people)} % endif @@ -20,7 +20,12 @@ ${parent.modify_this_page_vars()} + + +<%def name="render_notes_tab()"> + + + + + + + + <%def name="render_user_tab()"> @@ -1271,6 +1419,7 @@ ${parent.render_this_page_template()} ${self.render_personal_tab_template()} ${self.render_employee_tab_template()} + ${self.render_notes_tab_template()} ${self.render_profile_info_template()} @@ -1833,6 +1982,136 @@ +<%def name="declare_notes_tab_vars()"> + + + +<%def name="make_notes_tab_component()"> + ${self.declare_notes_tab_vars()} + + + <%def name="declare_profile_info_vars()"> diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index d4bed60a..35e1d6b4 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -968,19 +968,16 @@ class ReceivingBatchView(PurchasingBatchView): g.filters['vendor_code'].default_verb = 'contains' # catalog_unit_cost - if (self.handler.has_purchase_order(batch) - or self.handler.has_invoice_file(batch)): - g.remove('catalog_unit_cost') - elif self.allow_edit_catalog_unit_cost(batch): + if self.allow_edit_catalog_unit_cost(batch): g.set_raw_renderer('catalog_unit_cost', self.render_catalog_unit_cost) g.set_click_handler('catalog_unit_cost', - 'catalogUnitCostClicked(props.row)') + 'this.catalogUnitCostClicked') # invoice_unit_cost if self.allow_edit_invoice_unit_cost(batch): g.set_raw_renderer('invoice_unit_cost', self.render_invoice_unit_cost) g.set_click_handler('invoice_unit_cost', - 'invoiceUnitCostClicked(props.row)') + 'this.invoiceUnitCostClicked') # nb. only show PO *or* invoice cost; prefer the latter unless # we have a PO and no invoice From 4ecea891b3347a878b710569e3a07c120a5a922a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 8 Aug 2023 18:42:50 -0500 Subject: [PATCH 109/636] Update changelog --- CHANGES.rst | 10 ++++++++++ tailbone/_version.py | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 08bff3b8..f43e669b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.9.41 (2023-08-08) +------------------- + +* Add common logic to validate employee reference field. + +* Fix HTML rendering for UOM choice options. + +* Fix custom cell click handlers in main buefy grid tables. + + 0.9.40 (2023-08-03) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 6d32d447..07ccc0e9 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.40' +__version__ = '0.9.41' From 90075b3b6539d554ccca6915fe6fcab14b7df7fe Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 9 Aug 2023 18:04:51 -0500 Subject: [PATCH 110/636] When bulk-deleting, skip objects which are not "deletable" whatever that means in context --- tailbone/views/master.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index eeae4dae..107870cd 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -1728,7 +1728,8 @@ class MasterView(View): def bulk_delete_objects(self, session, objects, progress=None): def delete(obj, i): - self.delete_instance(obj) + if self.deletable_instance(obj): + self.delete_instance(obj) if i % 1000 == 0: session.flush() From a007606863ab386578018c765a388f50a9bf8d0f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 17 Aug 2023 18:12:42 -0500 Subject: [PATCH 111/636] Declare "from PO" receiving workflow if applicable, in API --- tailbone/api/batch/receiving.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tailbone/api/batch/receiving.py b/tailbone/api/batch/receiving.py index 9a6864db..b02215d2 100644 --- a/tailbone/api/batch/receiving.py +++ b/tailbone/api/batch/receiving.py @@ -77,9 +77,15 @@ class ReceivingBatchViews(APIBatchView): def create_object(self, data): data = dict(data) + + # all about receiving mode here data['mode'] = self.enum.PURCHASE_BATCH_MODE_RECEIVING - batch = super(ReceivingBatchViews, self).create_object(data) - return batch + + # assume "receive from PO" if given a PO key + if data['purchase_key']: + data['receiving_workflow'] = 'from_po' + + return super().create_object(data) def auto_receive(self): """ From b2aea57da6933d84b79d049f10c07dff20d56579 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 18 Aug 2023 15:04:52 -0500 Subject: [PATCH 112/636] Auto-select text when editing costs for receiving --- tailbone/templates/receiving/view.mako | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako index b4de37f1..77560ac1 100644 --- a/tailbone/templates/receiving/view.mako +++ b/tailbone/templates/receiving/view.mako @@ -103,6 +103,7 @@ ref="input" v-show="editing" @keydown.native="inputKeyDown" + @focus="selectAll" @blur="inputBlur" style="width: 6rem;"> @@ -189,6 +190,12 @@ }, methods: { + selectAll() { + // nb. must traverse into the element + let trueInput = this.$refs.input.$el.firstChild + trueInput.select() + }, + startEdit() { this.inputValue = this.value this.editing = true From 8be7dac33b7020b3ae59db15ace1c74a9b9524cb Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 24 Aug 2023 22:00:11 -0500 Subject: [PATCH 113/636] Include shopper history from parent customer account perspective ..right? or should this be hidden? configurable etc.? --- tailbone/views/people.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 8dc96037..54d00ca7 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -1283,6 +1283,22 @@ class PersonView(MasterView): .filter(cls.account_holder_uuid == person.uuid) versions.extend(query.all()) + # CustomerShopper (from Customer perspective) + cls = continuum.version_class(model.CustomerShopper) + query = self.Session.query(cls)\ + .join(model.Customer, model.Customer.uuid == cls.customer_uuid)\ + .filter(model.Customer.account_holder_uuid == person.uuid) + versions.extend(query.all()) + + # CustomerShopperHistory (from Customer perspective) + cls = continuum.version_class(model.CustomerShopperHistory) + query = self.Session.query(cls)\ + .join(model.CustomerShopper, + model.CustomerShopper.uuid == cls.shopper_uuid)\ + .join(model.Customer)\ + .filter(model.Customer.account_holder_uuid == person.uuid) + versions.extend(query.all()) + # CustomerShopper (from Shopper perspective) cls = continuum.version_class(model.CustomerShopper) query = self.Session.query(cls)\ From bc8b5a8d324b3d30410ef8222e068714cdb7b84a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 25 Aug 2023 09:08:33 -0500 Subject: [PATCH 114/636] Link to product record, for New Product batch row also fix a typo --- tailbone/templates/products/configure.mako | 2 +- tailbone/views/batch/newproduct.py | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/tailbone/templates/products/configure.mako b/tailbone/templates/products/configure.mako index a8caeac7..10f3c0e5 100644 --- a/tailbone/templates/products/configure.mako +++ b/tailbone/templates/products/configure.mako @@ -50,7 +50,7 @@

    Handling

    - + Date: Fri, 25 Aug 2023 10:41:20 -0500 Subject: [PATCH 115/636] Fix profile history to show when a CustomerShopperHistory is deleted --- tailbone/views/people.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 54d00ca7..48391f63 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -1307,10 +1307,10 @@ class PersonView(MasterView): # CustomerShopperHistory (from Shopper perspective) cls = continuum.version_class(model.CustomerShopperHistory) + standin = continuum.version_class(model.CustomerShopper) query = self.Session.query(cls)\ - .join(model.CustomerShopper, - model.CustomerShopper.uuid == cls.shopper_uuid)\ - .filter(model.CustomerShopper.person_uuid == person.uuid) + .join(standin, standin.uuid == cls.shopper_uuid)\ + .filter(standin.person_uuid == person.uuid) versions.extend(query.all()) # PersonNote From 844c629a6a013ce57ff01f896fa9cce442cd6426 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 25 Aug 2023 13:59:58 -0500 Subject: [PATCH 116/636] Fix profile history to show when a CustomerShopperHistory is deleted --- tailbone/views/people.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 48391f63..d7f84849 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -1292,10 +1292,10 @@ class PersonView(MasterView): # CustomerShopperHistory (from Customer perspective) cls = continuum.version_class(model.CustomerShopperHistory) + standin = continuum.version_class(model.CustomerShopper) query = self.Session.query(cls)\ - .join(model.CustomerShopper, - model.CustomerShopper.uuid == cls.shopper_uuid)\ - .join(model.Customer)\ + .join(standin, standin.uuid == cls.shopper_uuid)\ + .join(model.Customer, model.Customer.uuid == standin.customer_uuid)\ .filter(model.Customer.account_holder_uuid == person.uuid) versions.extend(query.all()) From 12e477909305a1f2ed4b7e4ba2b421ab727c782e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 28 Aug 2023 20:43:31 -0500 Subject: [PATCH 117/636] Fairly massive overhaul of the Profile view; standardize tabs etc. much cleaner and more consistent interface now, between the main ProfileInfo component, and various *Tab components also cleaner interface between client-side JS and server view methods to my knowledge this is complete and breaks nothing..we'll see! --- tailbone/templates/members/configure.mako | 14 + tailbone/templates/page.mako | 7 +- .../templates/people/view_profile_buefy.mako | 1830 +++++++++-------- tailbone/views/members.py | 5 + tailbone/views/people.py | 397 ++-- 5 files changed, 1234 insertions(+), 1019 deletions(-) diff --git a/tailbone/templates/members/configure.mako b/tailbone/templates/members/configure.mako index c0e0355d..465bf611 100644 --- a/tailbone/templates/members/configure.mako +++ b/tailbone/templates/members/configure.mako @@ -36,6 +36,20 @@
    + +

    Relationships

    +
    + + + + Limit one (1) Member account per Person + + + +
    <%def name="modify_this_page_vars()"> diff --git a/tailbone/templates/page.mako b/tailbone/templates/page.mako index b5ac8773..bf799440 100644 --- a/tailbone/templates/page.mako +++ b/tailbone/templates/page.mako @@ -38,7 +38,12 @@ }, computed: {}, watch: {}, - methods: {}, + methods: { + + changeContentTitle(newTitle) { + this.$emit('change-content-title', newTitle) + }, + }, } let ThisPageData = { diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako index e1da8661..5574088e 100644 --- a/tailbone/templates/people/view_profile_buefy.mako +++ b/tailbone/templates/people/view_profile_buefy.mako @@ -119,17 +119,17 @@
    @@ -553,294 +555,336 @@ - + + :max-lengths="maxLengths"> +<%def name="render_member_tab_template()"> + + + <%def name="render_member_tab()"> - -
    - -
    -

    {{ person.display_name }} has {{ members.length }} member account{{ members.length == 1 ? '' : 's' }}

    -
    - -
    - - -
    - - - {{ member._key }} - {{ member.display }} -
    - -
    -
    -
    - - - {{ member._key }} - - - - - {{ member.person_display_name }} - - - {{ member.person_display_name }} - - - - - - {{ member.membership_type_name }} - - - {{ member.membership_type_name }} - - - - - {{ member.active ? "Yes" : "No" }} - - - - {{ member.joined }} - - - - {{ member.withdrew }} - - - - {{ member.equity_total_display }} - - -
    -
    - ${self.render_member_panel_buttons(member)} -
    -
    -
    - -
    - -
    -

    {{ person.display_name }} does not have a member account.

    -
    - + :icon="tabchecks.member ? 'check' : null"> + + -<%def name="render_member_panel_buttons(member)"> - % for button in member_xref_buttons: - ${button} - % endfor - % if request.has_perm('members.view'): - - View Member - - % endif +<%def name="render_customer_tab_template()"> + <%def name="render_customer_tab()"> - -
    - -
    -

    {{ person.display_name }} has {{ customers.length }} customer account{{ customers.length == 1 ? '' : 's' }}

    -
    - -
    - - -
    - - - {{ customer._key }} - {{ customer.name }} -
    - -
    -
    -
    - - - {{ customer._key }} - - - - {{ customer.name }} - - - % if expose_customer_shoppers: - - - - % endif - - % if expose_customer_people: - - - - % endif - - - {{ address.display }} - - -
    -
    - ${self.render_customer_panel_buttons(customer)} -
    -
    -
    -
    -
    - -
    -

    {{ person.display_name }} does not have a customer account.

    -
    - -
    + :icon="tabchecks.customer ? 'check' : null"> + + + -<%def name="render_customer_panel_buttons(customer)"> - - {{ link.label }} - - % if request.has_perm('customers.view'): - - View Customer - - % endif +<%def name="render_shopper_tab_template()"> + <%def name="render_shopper_tab()"> - -
    - -
    -

    {{ person.display_name }} is shopper for {{ shoppers.length }} customer account{{ shoppers.length == 1 ? '' : 's' }}

    -
    - -
    - - -
    - - - {{ shopper.customer_key }} - {{ shopper.customer_name }} -
    - -
    -
    -
    - - - {{ shopper.customer_key }} - - - - {{ shopper.customer_name }} - - - - - {{ shopper.account_holder_name }} - - - {{ shopper.account_holder_name }} - - - -
    -##
    -## ${self.render_shopper_panel_buttons(shopper)} -##
    -
    -
    -
    -
    - -
    -

    {{ person.display_name }} is not a shopper.

    -
    - -
    + :icon="tabchecks.shopper ? 'check' : null"> + + + <%def name="render_employee_tab_template()"> @@ -863,11 +907,11 @@ + @click="editEmployeeIdInit()"> Edit ID + :active.sync="editEmployeeIdShowDialog"> @@ -934,7 +978,7 @@ - + Edit @@ -964,7 +1008,7 @@ + @click="stopEmployeeInit()"> ${person} is no longer an Employee @@ -978,10 +1022,10 @@ @@ -990,8 +1034,8 @@ Cancel @@ -999,7 +1043,7 @@ + :active.sync="stopEmployeeShowDialog"> +
    @@ -1084,12 +1129,10 @@ - + :icon="tabchecks.employee ? 'check' : null"> + @@ -1101,7 +1144,7 @@ % if request.has_perm('people_profile.add_note'): Add Note @@ -1144,13 +1187,13 @@ % if request.has_perm('people_profile.edit_note'): - + Edit % endif % if request.has_perm('people_profile.delete_note'): - Delete @@ -1161,68 +1204,71 @@ - + % if request.has_any_perm('people_profile.add_note', 'people_profile.edit_note', 'people_profile.delete_note'): + - + + % endif +
    @@ -1231,70 +1277,79 @@ - - + :icon="tabchecks.notes ? 'check' : null"> + - +<%def name="render_user_tab_template()"> + + + <%def name="render_user_tab()"> - -
    - -

    {{ person.display_name }} has {{ users.length }} user account{{ users.length == 1 ? '' : 's' }}

    -
    -
    - - - -
    - {{ user.username }} -
    - -
    -
    - -
    -
    -
    - -
    - {{ user.username }} -
    -
    -
    -
    - -
    - % if request.has_perm('users.view'): - - View User - - % endif -
    - -
    -
    -
    -
    -
    - -
    -

    {{ person.display_name }} does not have a user account.

    -
    -
    + :icon="tabchecks.user ? 'check' : null"> + + + <%def name="render_profile_tabs()"> @@ -1302,7 +1357,7 @@ ${self.render_member_tab()} ${self.render_customer_tab()} % if expose_customer_shoppers: - ${self.render_shopper_tab()} + ${self.render_shopper_tab()} % endif ${self.render_employee_tab()} ${self.render_notes_tab()} @@ -1422,8 +1477,14 @@ <%def name="render_this_page_template()"> ${parent.render_this_page_template()} ${self.render_personal_tab_template()} + ${self.render_member_tab_template()} + ${self.render_customer_tab_template()} + % if expose_customer_shoppers: + ${self.render_shopper_tab_template()} + % endif ${self.render_employee_tab_template()} ${self.render_notes_tab_template()} + ${self.render_user_tab_template()} ${self.render_profile_info_template()} @@ -1431,127 +1492,95 @@ +<%def name="declare_member_tab_vars()"> + + + +<%def name="make_member_tab_component()"> + ${self.declare_member_tab_vars()} + + + +<%def name="declare_customer_tab_vars()"> + + + +<%def name="make_customer_tab_component()"> + ${self.declare_customer_tab_vars()} + + + +<%def name="declare_shopper_tab_vars()"> + + + +<%def name="make_shopper_tab_component()"> + ${self.declare_shopper_tab_vars()} + + + <%def name="declare_employee_tab_vars()"> +<%def name="declare_user_tab_vars()"> + + + +<%def name="make_user_tab_component()"> + ${self.declare_user_tab_vars()} + + + <%def name="declare_profile_info_vars()"> @@ -2232,54 +2340,48 @@ + % 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 176/636] 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 177/636] 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 178/636] 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 179/636] 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 180/636] 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 181/636] 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 182/636] 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 183/636] 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 184/636] 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 185/636] 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 186/636] 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 187/636] 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 188/636] 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 189/636] 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 190/636] 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 191/636] 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 192/636] 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 193/636] 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 194/636] 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 195/636] 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 196/636] 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 197/636] 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 198/636] 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 199/636] 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 200/636] 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 201/636] 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 202/636] 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 203/636] 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 204/636] 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 205/636] 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 206/636] 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 207/636] 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 208/636] 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 209/636] 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 210/636] 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 211/636] 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 212/636] 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 213/636] 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 214/636] 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 215/636] 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 216/636] 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 217/636] 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 218/636] 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 219/636] 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 220/636] 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 221/636] 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 222/636] 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 223/636] 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 224/636] 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 225/636] 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 226/636] 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 227/636] 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 228/636] 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 229/636] 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 230/636] 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 231/636] 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 232/636] 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 233/636] 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 234/636] 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 243/636] 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 244/636] 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 245/636] 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 246/636] 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 247/636] 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 248/636] 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 249/636] 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'})}