From 4318f03bd628790d8f94ebb5e61ed42cbe1af392 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 5 Jun 2023 20:18:11 -0500 Subject: [PATCH 001/582] 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 002/582] 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 003/582] 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 004/582] 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 005/582] 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 006/582] 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 008/582] 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 009/582] 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 010/582] 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 011/582] 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 056/582] 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 057/582] 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 058/582] 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 059/582] 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 060/582] 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 061/582] 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 062/582] 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 063/582] 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 064/582] 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 123/582] 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 124/582] 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 125/582] 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 126/582] 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 127/582] 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 128/582] 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 129/582] 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 130/582] 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 131/582] 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 132/582] 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 133/582] 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 134/582] 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 135/582] 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 136/582] 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 137/582] 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 138/582] 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 139/582] 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 140/582] 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 141/582] 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 142/582] 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 143/582] 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 144/582] 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 145/582] 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 146/582] 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 147/582] 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 148/582] 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 149/582] 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 150/582] 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 151/582] 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 152/582] 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 153/582] 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 154/582] 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 155/582] 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 156/582] 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 157/582] 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 158/582] 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 159/582] 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 160/582] 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 161/582] 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 162/582] 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 163/582] 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 164/582] 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 165/582] 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 166/582] 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 167/582] 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 168/582] 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 169/582] 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 170/582] 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 171/582] 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 172/582] 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 173/582] 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 174/582] 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 175/582] 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 176/582] 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 177/582] 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 178/582] 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 179/582] 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 180/582] 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 181/582] 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 190/582] 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 191/582] 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 192/582] 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 193/582] 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 194/582] 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 195/582] 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 196/582] 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'})}